Initial commit
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
"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 & 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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user