diff --git a/backend/server.py b/backend/server.py index a198694..edf9d4d 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1537,9 +1537,45 @@ async def scan_import(payload: dict, user: dict = Depends(require_admin)): # ============ LIBRARY CLEANUP (admin) ============ -@api.get("/library/cleanup/scan") -async def library_cleanup_scan(user: dict = Depends(require_admin)): - return await library_cleanup.scan(db, VIDEOS_DIR, HLS_DIR) +_cleanup_scan_status = {"running": False, "findings": None, "error": "", "scan_id": 0} + + +async def _run_cleanup_scan(scan_id: int): + try: + findings = await library_cleanup.scan(db, VIDEOS_DIR, HLS_DIR) + if _cleanup_scan_status["scan_id"] == scan_id: + _cleanup_scan_status["findings"] = findings + except Exception as e: + if _cleanup_scan_status["scan_id"] == scan_id: + _cleanup_scan_status["error"] = str(e) + finally: + if _cleanup_scan_status["scan_id"] == scan_id: + _cleanup_scan_status["running"] = False + + +@api.post("/library/cleanup/scan/start") +async def library_cleanup_scan_start(user: dict = Depends(require_admin)): + if _cleanup_scan_status["running"]: + raise HTTPException(status_code=409, detail="Scan already running") + _cleanup_scan_status["scan_id"] += 1 + _cleanup_scan_status.update(running=True, findings=None, error="") + asyncio.create_task(_run_cleanup_scan(_cleanup_scan_status["scan_id"])) + return {"ok": True} + + +@api.get("/library/cleanup/scan/status") +async def library_cleanup_scan_status(user: dict = Depends(require_admin)): + return _cleanup_scan_status + + +@api.post("/library/cleanup/scan/stop") +async def library_cleanup_scan_stop(user: dict = Depends(require_admin)): + # The already-dispatched file checks finish naturally in the background (there's no clean + # way to interrupt a ThreadPoolExecutor mid-flight), but bumping scan_id means whenever they + # do finish, _run_cleanup_scan sees a stale id and discards the result instead of surfacing it. + _cleanup_scan_status["scan_id"] += 1 + _cleanup_scan_status["running"] = False + return {"ok": True} @api.post("/library/cleanup/fix") diff --git a/frontend/src/components/AlbumArt.jsx b/frontend/src/components/AlbumArt.jsx new file mode 100644 index 0000000..92bb9d7 --- /dev/null +++ b/frontend/src/components/AlbumArt.jsx @@ -0,0 +1,20 @@ +// Shows real album art when a URL is available; otherwise a branded placeholder instead of an +// empty box. Once musicbrainz enrichment (or a manual edit) sets album_art_url, callers just +// pass the new url and this swaps over automatically — no separate "loaded" state to manage. +export default function AlbumArt({ url, size = "lg", className = "" }) { + if (url) { + return ; + } + const small = size === "sm"; + return ( +
+ {small ? ( + S. + ) : ( + + Streamhoard + + )} +
+ ); +} diff --git a/frontend/src/components/MusicPlayerBar.jsx b/frontend/src/components/MusicPlayerBar.jsx index 4ee0dc6..7c3d58e 100644 --- a/frontend/src/components/MusicPlayerBar.jsx +++ b/frontend/src/components/MusicPlayerBar.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; -import { Play, Pause, SkipBack, SkipForward, Music2, Speaker } from "lucide-react"; +import { Play, Pause, SkipBack, SkipForward, Speaker } from "lucide-react"; import { useMusicPlayer } from "../lib/musicPlayer"; +import AlbumArt from "./AlbumArt"; const fmt = (s) => { if (!s || !isFinite(s)) return "0:00"; @@ -33,8 +34,8 @@ export default function MusicPlayerBar() { return (
-
- {current.album_art_url ? : } +
+

{current.title}

diff --git a/frontend/src/pages/AlbumDetail.jsx b/frontend/src/pages/AlbumDetail.jsx index 1d749cb..10db63c 100644 --- a/frontend/src/pages/AlbumDetail.jsx +++ b/frontend/src/pages/AlbumDetail.jsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom"; import api from "../lib/api"; import { Play, Pause, ArrowLeft, Music2 } from "lucide-react"; import { useMusicPlayer } from "../lib/musicPlayer"; +import AlbumArt from "../components/AlbumArt"; export default function AlbumDetail() { const { artist, album } = useParams(); @@ -40,8 +41,8 @@ export default function AlbumDetail() {
-
- {artUrl ? : } +
+
Album diff --git a/frontend/src/pages/LibraryCleanup.jsx b/frontend/src/pages/LibraryCleanup.jsx index 8e901fb..808fc42 100644 --- a/frontend/src/pages/LibraryCleanup.jsx +++ b/frontend/src/pages/LibraryCleanup.jsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import api from "../lib/api"; import { toast } from "sonner"; -import { RefreshCcw, Trash2, Wrench, ShieldCheck } from "lucide-react"; +import { RefreshCcw, Trash2, Wrench, ShieldCheck, Square } from "lucide-react"; const CATEGORIES = [ { key: "missing_files", label: "Missing Files", desc: "Database record exists but the file is gone from disk.", action: "Remove record" }, @@ -15,21 +15,58 @@ const CATEGORIES = [ export default function LibraryCleanup() { const [findings, setFindings] = useState(null); - const [loading, setLoading] = useState(false); + const [running, setRunning] = useState(false); const [fixing, setFixing] = useState({}); + const pollRef = useRef(null); + + const checkStatus = async () => { + const { data } = await api.get("/library/cleanup/scan/status"); + setRunning(data.running); + if (data.error) toast.error(`Scan failed: ${data.error}`); + if (data.findings) setFindings(data.findings); + return data.running; + }; + + const startPolling = () => { + if (pollRef.current) return; + pollRef.current = setInterval(async () => { + const stillRunning = await checkStatus(); + if (!stillRunning) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, 2000); + }; + + useEffect(() => { + checkStatus().then((isRunning) => { if (isRunning) startPolling(); }); + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, []); const scan = async () => { - setLoading(true); + if (!window.confirm( + "This checks every movie, episode, and track's file against the NAS, which can take a while on a large library (roughly a minute or more) since each check is a network round trip. It runs in the background and won't block the rest of the app — you can navigate away or stop it anytime. Continue?" + )) return; try { - const { data } = await api.get("/library/cleanup/scan"); - setFindings(data); + await api.post("/library/cleanup/scan/start"); + setRunning(true); + setFindings(null); + startPolling(); } catch (err) { - toast.error(err.response?.data?.detail || "Cleanup scan failed"); - } finally { - setLoading(false); + toast.error(err.response?.data?.detail || "Could not start scan"); + } + }; + + const stopScan = async () => { + try { + await api.post("/library/cleanup/scan/stop"); + setRunning(false); + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + toast.success("Scan stopped"); + } catch (err) { + toast.error(err.response?.data?.detail || "Could not stop scan"); } }; - useEffect(() => { scan(); }, []); const fix = async (category, items) => { if (items.length === 0) return; @@ -57,10 +94,17 @@ export default function LibraryCleanup() {

- - {findings && ( + {running ? ( + + ) : ( + + )} + {running && Scanning… this can take a minute} + {!running && findings && ( {totalIssues === 0 ? ( <> No issues found @@ -106,12 +150,17 @@ export default function LibraryCleanup() {
); })} - {findings && totalIssues === 0 && ( + {!running && findings && totalIssues === 0 && (
Library is clean — no missing files, orphaned records, or stuck jobs.
)} + {!running && !findings && ( +
+ Click "Scan Library" above to check for issues. +
+ )}
diff --git a/frontend/src/pages/Music.jsx b/frontend/src/pages/Music.jsx index 6f9f6e1..a97a978 100644 --- a/frontend/src/pages/Music.jsx +++ b/frontend/src/pages/Music.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import api from "../lib/api"; import { Music2 } from "lucide-react"; +import AlbumArt from "../components/AlbumArt"; export default function Music() { const [tracks, setTracks] = useState([]); @@ -45,11 +46,7 @@ export default function Music() { onClick={() => nav(`/music/album/${encodeURIComponent(a.artist)}/${encodeURIComponent(a.album)}`)} className="group relative aspect-square overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]" data-testid={`album-card-${a.artist}-${a.album}`}> - {a.album_art_url ? ( - {a.album} - ) : ( -
- )} +

{a.album}

{a.artist} · {a.count} tracks