mirror of
https://github.com/myronblair/kino-app
synced 2026-07-31 21:12:43 -05:00
Add filesystem Library Scan (movies/tv/music) and a full Music library
- New admin Library Scan page walks the NAS mounts directly for movies, TV, and music not already in Radarr/Sonarr/local storage, and imports them with storage_type=scan (fully integrated: streamable, transcodable, listed in Admin) — Radarr/Sonarr import flows are untouched. - New Music section (nav tab, album-grid browse, per-album track list, persistent bottom player with queue/seek) backed by a Track model and range-request audio streaming endpoint. - MusicBrainz + Cover Art Archive integration (free, no API key) to fill in album art for scanned tracks, paced to their 1 req/sec rate limit. - Mounted the NAS music share into CT104 (new mp2 LXC mountpoint, CIFS read-only) alongside the existing video mount. - Optional Bluetooth/output-device picker on the player bar via the Audio Output Devices API (setSinkId) for already-paired speakers — Chrome/Edge only, since browsers can't pair new BT devices directly.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { RefreshCcw, Download, Check, Sparkles } 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 load = async (k = kind) => {
|
||||
setLoading(true);
|
||||
setSelected(new Set());
|
||||
try {
|
||||
const { data } = await api.get(`/scan/${k}`);
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Scan failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { 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 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={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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user