"use client"; import { useEffect, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Folder, Loader2, RefreshCw, Save, Plus, Trash2, FolderOpen, Cpu } from "lucide-react"; import { setVideoLibraryPath, setVideoExtraPaths, setTranscodeMode, } from "@/app/actions/settings"; import { useSettings } from "./SettingsProvider"; import { PartSuffixPatterns } from "./PartSuffixPatterns"; import { dispatchVideoStatusRefresh } from "@/components/video/videoStatusEvents"; import { cn } from "@/lib/utils"; interface ScanResult { count: number; codes: number; rootsScanned: string[]; elapsedMs: number; } export function VideoLibrarySettings() { const { settings } = useSettings(); const [, startTranscode] = useTransition(); const [transcodeMode, setLocalTranscodeMode] = useState(settings.transcodeMode); // Stay in sync if the server-side value changes (e.g. user edits in // another tab and we router.refresh). useEffect(() => { setLocalTranscodeMode(settings.transcodeMode); }, [settings.transcodeMode]); const router = useRouter(); const [draftMain, setDraftMain] = useState(settings.videoLibraryPath); const [draftExtras, setDraftExtras] = useState(settings.videoExtraPaths ?? []); const [newExtra, setNewExtra] = useState(""); const [saving, setSaving] = useState(false); const [rescanning, setRescanning] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [, start] = useTransition(); const [picking, setPicking] = useState(null); async function pickFolder(start: string): Promise { try { const r = await fetch("/api/pick-folder", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ start }), }); const j = await r.json(); if (!r.ok) throw new Error(j.error ?? `picker failed (${r.status})`); return typeof j.path === "string" && j.path ? j.path : null; } catch (e) { setError((e as Error).message); return null; } } async function browseMain() { setPicking("main"); try { const p = await pickFolder(draftMain); if (p) setDraftMain(p); } finally { setPicking(null); } } async function browseExtra(idx: number) { setPicking(`extra-${idx}`); try { const p = await pickFolder(draftExtras[idx] ?? ""); if (p) updateExtra(idx, p); } finally { setPicking(null); } } async function browseNewExtra() { setPicking("new"); try { const p = await pickFolder(newExtra); if (p) { if (!draftExtras.includes(p)) { setDraftExtras((cur) => [...cur, p]); setNewExtra(""); } else { setNewExtra(p); } } } finally { setPicking(null); } } const mainDirty = draftMain.trim() !== (settings.videoLibraryPath ?? ""); const extrasDirty = draftExtras.length !== (settings.videoExtraPaths ?? []).length || draftExtras.some((v, i) => v !== (settings.videoExtraPaths ?? [])[i]); const dirty = mainDirty || extrasDirty; const hasAnyConfigured = !!settings.videoLibraryPath || (settings.videoExtraPaths ?? []).length > 0; function addExtra() { const v = newExtra.trim(); if (!v) return; if (draftExtras.includes(v)) return; setDraftExtras((cur) => [...cur, v]); setNewExtra(""); } function removeExtra(idx: number) { setDraftExtras((cur) => cur.filter((_, i) => i !== idx)); } function updateExtra(idx: number, value: string) { setDraftExtras((cur) => cur.map((v, i) => i === idx ? value : v)); } async function save() { setSaving(true); setError(null); try { if (mainDirty) await setVideoLibraryPath(draftMain); if (extrasDirty) await setVideoExtraPaths(draftExtras); const r = await fetch("/api/video-rescan", { method: "POST" }); const j = await r.json(); if (!r.ok) throw new Error(j.error ?? `rescan failed (${r.status})`); setResult(j); dispatchVideoStatusRefresh(); start(() => router.refresh()); } catch (e) { setError((e as Error).message); } finally { setSaving(false); } } async function rescan(opts: { force?: boolean } = {}) { setRescanning(true); setError(null); try { const url = opts.force ? "/api/video-rescan?force=1" : "/api/video-rescan"; const r = await fetch(url, { method: "POST" }); const j = await r.json(); if (!r.ok) throw new Error(j.error ?? `rescan failed (${r.status})`); setResult(j); dispatchVideoStatusRefresh(); start(() => router.refresh()); } catch (e) { setError((e as Error).message); } finally { setRescanning(false); } } return (
{/* Main folder ---------------------------------------------------- */}
Main Library Folder
Recursive scan. Expected to follow the same letter-bucket layout as the cover library, e.g.{" "} D:\JAV\A-E\A\AOZ-200Z.mp4.
setDraftMain(e.target.value)} placeholder="D:\JAV" className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]" />
{/* Additional folders --------------------------------------------- */}
Additional Folders
Flat folders where videos sit directly inside (e.g. E:\JAV\IBW-203.mp4). Subfolders are still walked. One absolute path per row.
{draftExtras.length === 0 ? (
No additional folders.
) : ( draftExtras.map((p, i) => (
updateExtra(i, e.target.value)} className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]" />
)) )}
setNewExtra(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addExtra(); } }} placeholder="E:\JAV" className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]" />
{/* Action row ----------------------------------------------------- */}
{result && (
Scanned in {result.elapsedMs}ms. {result.count} file{result.count === 1 ? "" : "s"} matched, {result.codes} unique code{result.codes === 1 ? "" : "s"}.
{result.rootsScanned.length > 0 && (
roots: {result.rootsScanned.join(" · ")}
)}
)} {error && (
{error}
)} {/* Playback transcode mode --------------------------------------- */}
Playback Transcoding (NVENC)
Live HLS re-encode with NVENC, dropping B-frames to bypass Chromium's H.264 sink reorder bug. Requires NVIDIA GPU + ffmpeg with h264_nvenc. Auto modes detect whether transcoding is needed per file.
{([ { value: "off", title: "Off", desc: "Always serve the original file. Stuttery H.264 files will stutter.", }, { value: "always", title: "Always Transcode", desc: "Every file goes through NVENC HLS. Bullet-proof, slight quality loss.", }, { value: "auto-predicate", title: "Auto · Predicate", desc: "Probe codec on first play. Transcode only H.264 files with B-frames.", }, { value: "auto-runtime", title: "Auto · Runtime", desc: "Measure dropped frames in a brief pre-roll, decide and remember per file.", }, ] as const).map((opt) => { const active = transcodeMode === opt.value; return ( ); })}
{/* Part suffix patterns ------------------------------------------ */}
); }