feat(transcoding): add FFmpeg HLS transcoding support
- Introduced `TranscodeManager` for managing on-demand transcoding of local video files. - Added configuration options for transcoding in `Config` and `LocalFilesConfig`. - Implemented new API routes for managing transcoding settings, stats, and cache. - Updated `LocalFilesProvider` to support transcoding capabilities. - Created frontend components for managing transcode settings and displaying stats. - Added database migration for transcode settings. - Enhanced existing routes and DTOs to accommodate new transcoding features.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
useTranscodeSettings,
|
||||
useUpdateTranscodeSettings,
|
||||
useTranscodeStats,
|
||||
useClearTranscodeCache,
|
||||
} from "@/hooks/use-transcode";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function TranscodeSettingsDialog({ open, onOpenChange }: Props) {
|
||||
const { data: settings } = useTranscodeSettings();
|
||||
const { data: stats } = useTranscodeStats();
|
||||
const updateSettings = useUpdateTranscodeSettings();
|
||||
const clearCache = useClearTranscodeCache();
|
||||
|
||||
const [ttl, setTtl] = useState<number>(24);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) setTtl(settings.cleanup_ttl_hours);
|
||||
}, [settings]);
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings.mutate(
|
||||
{ cleanup_ttl_hours: ttl },
|
||||
{
|
||||
onSuccess: () => toast.success("Settings saved"),
|
||||
onError: () => toast.error("Failed to save settings"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (!confirmClear) {
|
||||
setConfirmClear(true);
|
||||
return;
|
||||
}
|
||||
clearCache.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success("Transcode cache cleared");
|
||||
setConfirmClear(false);
|
||||
},
|
||||
onError: () => toast.error("Failed to clear cache"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-zinc-100">Transcode Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* TTL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ttl" className="text-zinc-300">
|
||||
Cache cleanup TTL (hours)
|
||||
</Label>
|
||||
<Input
|
||||
id="ttl"
|
||||
type="number"
|
||||
min={1}
|
||||
value={ttl}
|
||||
onChange={(e) => setTtl(Number(e.target.value))}
|
||||
className="w-32 bg-zinc-800 border-zinc-700 text-zinc-100"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Transcoded segments older than this are removed automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-800/50 p-4 space-y-1">
|
||||
<p className="text-xs font-medium text-zinc-400">Cache</p>
|
||||
<p className="text-sm text-zinc-200">
|
||||
{stats ? fmtBytes(stats.cache_size_bytes) : "—"}{" "}
|
||||
<span className="text-zinc-500">
|
||||
({stats ? stats.item_count : "—"} items)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Clear cache */}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
disabled={clearCache.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{confirmClear ? "Confirm — clear cache?" : "Clear transcode cache"}
|
||||
</Button>
|
||||
{confirmClear && (
|
||||
<p
|
||||
className="text-center text-xs text-zinc-500 cursor-pointer hover:text-zinc-300"
|
||||
onClick={() => setConfirmClear(false)}
|
||||
>
|
||||
Cancel
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={updateSettings.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Upload, RefreshCw, Antenna } from "lucide-react";
|
||||
import { Plus, Upload, RefreshCw, Antenna, Settings2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
useChannels,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ChannelImportData,
|
||||
} from "./components/import-channel-dialog";
|
||||
import { IptvExportDialog } from "./components/iptv-export-dialog";
|
||||
import { TranscodeSettingsDialog } from "./components/transcode-settings-dialog";
|
||||
import type {
|
||||
ChannelResponse,
|
||||
ProgrammingBlock,
|
||||
@@ -112,6 +113,7 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
const [iptvOpen, setIptvOpen] = useState(false);
|
||||
const [transcodeOpen, setTranscodeOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importPending, setImportPending] = useState(false);
|
||||
@@ -231,6 +233,16 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{config?.providers?.some((p) => p.capabilities.transcode) && (
|
||||
<Button
|
||||
onClick={() => setTranscodeOpen(true)}
|
||||
title="Transcode settings"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
Transcode
|
||||
</Button>
|
||||
)}
|
||||
{capabilities?.rescan && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
@@ -329,6 +341,11 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* Dialogs / sheets */}
|
||||
<TranscodeSettingsDialog
|
||||
open={transcodeOpen}
|
||||
onOpenChange={setTranscodeOpen}
|
||||
/>
|
||||
|
||||
{token && (
|
||||
<IptvExportDialog
|
||||
open={iptvOpen}
|
||||
|
||||
43
k-tv-frontend/hooks/use-transcode.ts
Normal file
43
k-tv-frontend/hooks/use-transcode.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuthContext } from "@/context/auth-context";
|
||||
import type { TranscodeSettings } from "@/lib/types";
|
||||
|
||||
export function useTranscodeSettings() {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["transcode-settings"],
|
||||
queryFn: () => api.transcode.getSettings(token!),
|
||||
enabled: !!token,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTranscodeSettings() {
|
||||
const { token } = useAuthContext();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: TranscodeSettings) =>
|
||||
api.transcode.updateSettings(data, token!),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["transcode-settings"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranscodeStats() {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["transcode-stats"],
|
||||
queryFn: () => api.transcode.getStats(token!),
|
||||
enabled: !!token,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearTranscodeCache() {
|
||||
const { token } = useAuthContext();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.transcode.clearCache(token!),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["transcode-stats"] }),
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
SeriesResponse,
|
||||
LibraryItemResponse,
|
||||
MediaFilter,
|
||||
TranscodeSettings,
|
||||
TranscodeStats,
|
||||
} from "@/lib/types";
|
||||
|
||||
const API_BASE =
|
||||
@@ -155,6 +157,24 @@ export const api = {
|
||||
request<{ items_found: number }>("/files/rescan", { method: "POST", token }),
|
||||
},
|
||||
|
||||
transcode: {
|
||||
getSettings: (token: string) =>
|
||||
request<TranscodeSettings>("/files/transcode-settings", { token }),
|
||||
|
||||
updateSettings: (data: TranscodeSettings, token: string) =>
|
||||
request<TranscodeSettings>("/files/transcode-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
getStats: (token: string) =>
|
||||
request<TranscodeStats>("/files/transcode-stats", { token }),
|
||||
|
||||
clearCache: (token: string) =>
|
||||
request<void>("/files/transcode-cache", { method: "DELETE", token }),
|
||||
},
|
||||
|
||||
schedule: {
|
||||
generate: (channelId: string, token: string) =>
|
||||
request<ScheduleResponse>(`/channels/${channelId}/schedule`, {
|
||||
|
||||
@@ -93,6 +93,16 @@ export interface ProviderCapabilities {
|
||||
search: boolean;
|
||||
streaming_protocol: StreamingProtocol;
|
||||
rescan: boolean;
|
||||
transcode: boolean;
|
||||
}
|
||||
|
||||
export interface TranscodeSettings {
|
||||
cleanup_ttl_hours: number;
|
||||
}
|
||||
|
||||
export interface TranscodeStats {
|
||||
cache_size_bytes: number;
|
||||
item_count: number;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
|
||||
Reference in New Issue
Block a user