Files
pinkudex/components/settings/VideoLibrarySettings.tsx
T
2026-05-26 22:46:00 +02:00

385 lines
15 KiB
TypeScript

"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<string[]>(settings.videoExtraPaths ?? []);
const [newExtra, setNewExtra] = useState("");
const [saving, setSaving] = useState(false);
const [rescanning, setRescanning] = useState(false);
const [result, setResult] = useState<ScanResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [, start] = useTransition();
const [picking, setPicking] = useState<string | null>(null);
async function pickFolder(start: string): Promise<string | null> {
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 (
<div className="py-2 space-y-5">
{/* Main folder ---------------------------------------------------- */}
<div>
<div className="text-sm font-medium mb-1">Main Library Folder</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Recursive scan. Expected to follow the same letter-bucket layout as the cover library, e.g.{" "}
<span className="font-mono">D:\JAV\A-E\A\AOZ-200Z.mp4</span>.
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={draftMain}
onChange={(e) => 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)]"
/>
</div>
<button
type="button"
onClick={browseMain}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "main" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
</div>
</div>
{/* Additional folders --------------------------------------------- */}
<div>
<div className="text-sm font-medium mb-1">Additional Folders</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Flat folders where videos sit directly inside (e.g. <span className="font-mono">E:\JAV\IBW-203.mp4</span>). Subfolders are still walked. One absolute path per row.
</div>
<div className="space-y-1.5">
{draftExtras.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)]">
No additional folders.
</div>
) : (
draftExtras.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={p}
onChange={(e) => 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)]"
/>
</div>
<button
type="button"
onClick={() => browseExtra(i)}
disabled={picking !== null}
title="Browse for folder"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === `extra-${i}` ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => removeExtra(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
<div className="flex items-center gap-2 mt-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={newExtra}
onChange={(e) => 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)]"
/>
</div>
<button
type="button"
onClick={browseNewExtra}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "new" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
<button
type="button"
onClick={addExtra}
disabled={!newExtra.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
<Plus className="w-4 h-4" /> Add
</button>
</div>
</div>
{/* Action row ----------------------------------------------------- */}
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)]">
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save &amp; Rescan
</button>
<button
type="button"
onClick={() => rescan()}
disabled={rescanning || saving || !hasAnyConfigured}
title={hasAnyConfigured ? "Incremental — only re-walk folders whose mtime changed" : "Configure a folder first"}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{rescanning ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Re-scan Only
</button>
<button
type="button"
onClick={() => rescan({ force: true })}
disabled={rescanning || saving || !hasAnyConfigured}
title={hasAnyConfigured ? "Force full rescan — bypass dir-mtime cache (use after content edits without rename)" : "Configure a folder first"}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-400/30 text-amber-200 hover:bg-amber-400/10 disabled:opacity-40"
>
{rescanning ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Force Full
</button>
</div>
{result && (
<div className="text-xs text-[var(--color-mint)] bg-[var(--color-mint)]/5 border border-[var(--color-mint)]/25 rounded-lg px-3 py-2 space-y-0.5">
<div>
Scanned in {result.elapsedMs}ms. <strong>{result.count}</strong> file{result.count === 1 ? "" : "s"} matched, <strong>{result.codes}</strong> unique code{result.codes === 1 ? "" : "s"}.
</div>
{result.rootsScanned.length > 0 && (
<div className="text-[10px] font-mono text-[var(--color-fg-muted)]">
roots: {result.rootsScanned.join(" · ")}
</div>
)}
</div>
)}
{error && (
<div className="text-xs text-red-300 bg-red-500/5 border border-red-500/25 rounded-lg px-3 py-2">
{error}
</div>
)}
{/* Playback transcode mode --------------------------------------- */}
<div className="pt-3 border-t border-[var(--color-glass-border)] space-y-3">
<div className="space-y-1">
<div className="text-sm font-medium flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Playback Transcoding (NVENC)
</div>
<div className="text-xs text-[var(--color-fg-muted)] max-w-2xl">
Live HLS re-encode with NVENC, dropping B-frames to bypass Chromium&apos;s
H.264 sink reorder bug. Requires NVIDIA GPU + ffmpeg with
<span className="font-mono"> h264_nvenc</span>. Auto modes detect
whether transcoding is needed per file.
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{([
{
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 (
<button
key={opt.value}
type="button"
onClick={() => {
setLocalTranscodeMode(opt.value);
startTranscode(() => {
void setTranscodeMode(opt.value);
});
}}
className={cn(
"text-left rounded-lg border px-3 py-2 transition-colors",
active
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/10 shadow-[var(--shadow-glow-cyan)]"
: "border-[var(--color-glass-border)] bg-[var(--color-glass)]/30 hover:border-[var(--color-glass-border-strong)]",
)}
>
<div className={cn(
"text-sm font-medium",
active ? "text-[var(--color-cyan)]" : "text-[var(--color-fg)]",
)}>{opt.title}</div>
<div className="text-[11px] text-[var(--color-fg-muted)] mt-0.5 leading-snug">{opt.desc}</div>
</button>
);
})}
</div>
</div>
{/* Part suffix patterns ------------------------------------------ */}
<PartSuffixPatterns />
</div>
);
}