mirror of
https://github.com/myronblair/kino-app
synced 2026-07-31 13:02:29 -05:00
3c47894628
Switching tabs quickly (e.g. Movies -> Music) had no guard against an in-flight response from the previous tab resolving late and overwriting the new tab's results — showing movie/TV rips under the Music tab. Now tracks a request sequence number and ignores stale responses, and clears results immediately on tab switch so nothing renders under the wrong label.
157 lines
7.4 KiB
React
157 lines
7.4 KiB
React
import { useEffect, useRef, useState } from "react";
|
|
import api from "../lib/api";
|
|
import { toast } from "sonner";
|
|
import { RefreshCcw, Download, Check, Sparkles, CheckSquare, Square } from "lucide-react";
|
|
|
|
const KINDS = [
|
|
{ key: "movies", label: "Movies" },
|
|
{ key: "tv", label: "TV Shows" },
|
|
{ key: "music", label: "Music" },
|
|
];
|
|
|
|
export default function LibraryScan() {
|
|
const [kind, setKind] = useState("movies");
|
|
const [results, setResults] = useState([]);
|
|
const [selected, setSelected] = useState(new Set());
|
|
const [loading, setLoading] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [enrich, setEnrich] = useState(null);
|
|
const requestSeq = useRef(0);
|
|
|
|
const load = async (k = kind) => {
|
|
const seq = ++requestSeq.current;
|
|
setLoading(true);
|
|
setSelected(new Set());
|
|
try {
|
|
const { data } = await api.get(`/scan/${k}`);
|
|
if (seq !== requestSeq.current) return; // a newer tab switch/rescan superseded this response
|
|
setResults(data);
|
|
} catch (err) {
|
|
if (seq !== requestSeq.current) return;
|
|
toast.error(err.response?.data?.detail || "Scan failed");
|
|
} finally {
|
|
if (seq === requestSeq.current) setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { setResults([]); load(kind); }, [kind]);
|
|
|
|
useEffect(() => {
|
|
if (!enrich?.running) return;
|
|
const t = setInterval(async () => {
|
|
const { data } = await api.get("/music/enrich/status");
|
|
setEnrich(data);
|
|
if (!data.running) clearInterval(t);
|
|
}, 2000);
|
|
return () => clearInterval(t);
|
|
}, [enrich?.running]);
|
|
|
|
const toggle = (path) => {
|
|
const s = new Set(selected);
|
|
s.has(path) ? s.delete(path) : s.add(path);
|
|
setSelected(s);
|
|
};
|
|
|
|
const importable = results.filter((r) => !r.already_imported);
|
|
const allSelected = importable.length > 0 && importable.every((r) => selected.has(r.storage_path));
|
|
|
|
const toggleSelectAll = () => {
|
|
setSelected(allSelected ? new Set() : new Set(importable.map((r) => r.storage_path)));
|
|
};
|
|
|
|
const importSelected = async () => {
|
|
if (selected.size === 0) return;
|
|
setImporting(true);
|
|
try {
|
|
const items = results.filter((r) => selected.has(r.storage_path));
|
|
const { data } = await api.post("/scan/import", { kind, items });
|
|
toast.success(`Imported ${data.imported} item(s)`);
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.detail || "Import failed");
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
const startEnrich = async () => {
|
|
try {
|
|
await api.post("/music/enrich");
|
|
const { data } = await api.get("/music/enrich/status");
|
|
setEnrich(data);
|
|
toast.success("Fetching metadata & covers from MusicBrainz…");
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.detail || "Could not start enrichment");
|
|
}
|
|
};
|
|
|
|
const label = (r) => {
|
|
if (kind === "movies") return `${r.title}${r.year ? ` (${r.year})` : ""}`;
|
|
if (kind === "tv") return `${r.show_title} · S${r.season_number}E${r.episode_number ?? "?"} · ${r.title}`;
|
|
return `${r.artist} — ${r.album} — ${r.title}`;
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="library-scan-page">
|
|
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
|
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Filesystem</span>
|
|
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Library Scan</h1>
|
|
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
|
Scans the NAS mounts directly for movies, TV, and music that aren't already in the library — separate from Radarr/Sonarr, which are untouched.
|
|
</p>
|
|
|
|
<div className="mt-8 flex gap-3 flex-wrap">
|
|
{KINDS.map((k) => (
|
|
<button key={k.key} onClick={() => setKind(k.key)}
|
|
className={`text-sm px-4 py-2 border transition-colors ${kind === k.key ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`}
|
|
data-testid={`scan-tab-${k.key}`}>
|
|
{k.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-6 flex gap-3 flex-wrap items-center">
|
|
<button onClick={() => load()} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="scan-refresh">
|
|
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Rescan
|
|
</button>
|
|
<button onClick={toggleSelectAll} disabled={importable.length === 0} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="scan-select-all">
|
|
{allSelected ? <CheckSquare size={14} strokeWidth={1.5} /> : <Square size={14} strokeWidth={1.5} />} {allSelected ? "Deselect All" : "Select All"}
|
|
</button>
|
|
<button onClick={importSelected} disabled={selected.size === 0 || importing} className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="scan-import-btn">
|
|
<Download size={14} strokeWidth={1.5} /> Import {selected.size > 0 ? `(${selected.size})` : ""}
|
|
</button>
|
|
{kind === "music" && (
|
|
<button onClick={startEnrich} disabled={enrich?.running} className="flex items-center gap-2 border border-white/10 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="scan-enrich-btn">
|
|
<Sparkles size={14} strokeWidth={1.5} /> {enrich?.running ? `Fetching covers… ${enrich.done}/${enrich.total}` : "Fetch metadata & covers"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-[#8A8A8A] mt-6 text-sm">
|
|
{loading ? "Scanning…" : `${importable.length} new of ${results.length} found on disk.`}
|
|
</p>
|
|
|
|
<div className="mt-6 border border-[#222]">
|
|
{results.map((r) => (
|
|
<button key={r.storage_path} onClick={() => !r.already_imported && toggle(r.storage_path)}
|
|
disabled={r.already_imported}
|
|
className={`w-full flex items-center gap-4 px-5 py-3 border-b border-[#222] last:border-b-0 text-left transition-colors ${
|
|
r.already_imported ? "opacity-40 cursor-not-allowed" : selected.has(r.storage_path) ? "bg-[#D9381E]/10" : "hover:bg-[#0F0F0F]"
|
|
}`}
|
|
data-testid={`scan-row-${r.storage_path}`}>
|
|
<div className={`w-5 h-5 shrink-0 border flex items-center justify-center ${selected.has(r.storage_path) ? "bg-[#D9381E] border-[#D9381E]" : "border-[#222]"}`}>
|
|
{selected.has(r.storage_path) && <Check size={12} strokeWidth={2} color="white" />}
|
|
</div>
|
|
<span className="text-white text-sm truncate flex-1">{label(r)}</span>
|
|
{r.already_imported && <span className="text-[10px] uppercase tracking-[0.2em] text-[#86efac] shrink-0">Imported</span>}
|
|
</button>
|
|
))}
|
|
{results.length === 0 && !loading && (
|
|
<div className="px-5 py-8 text-center text-[#8A8A8A] text-sm">Nothing found on disk for this category.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|