mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 13:33:49 -05:00
Add confirm+stop to Library Cleanup scan; branded album art placeholder
- Library Cleanup scan is now a real background job (POST .../start, GET .../status, POST .../stop) instead of one long synchronous request, so a Stop button can actually abandon it instead of just waiting. Frontend confirms before starting (explains it can take a minute+) and polls status while running. - New shared AlbumArt component: shows real art when available, otherwise a branded "Streamhoard" placeholder instead of a bare icon in an empty box. Used in the Music grid, album detail header, and the player bar — swaps to real art automatically once enrichment/a manual edit sets it.
This commit is contained in:
+39
-3
@@ -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")
|
||||
|
||||
@@ -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 <img src={url} alt="" className={`w-full h-full object-cover ${className}`} />;
|
||||
}
|
||||
const small = size === "sm";
|
||||
return (
|
||||
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br from-[#161616] to-[#0a0a0a] ${className}`}>
|
||||
{small ? (
|
||||
<span className="font-display font-black text-[#3a3a3a] text-lg select-none">S<span className="text-[#D9381E]">.</span></span>
|
||||
) : (
|
||||
<span className="font-display font-black tracking-tighter text-[#3a3a3a] text-sm sm:text-base select-none text-center px-2">
|
||||
Stream<span className="text-[#D9381E]">hoard</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0a]/95 backdrop-blur border-t border-[#222] px-4 md:px-8 py-3" data-testid="music-player-bar">
|
||||
<div className="max-w-[1500px] mx-auto flex items-center gap-4">
|
||||
<div className="w-10 h-10 shrink-0 bg-[#0F0F0F] flex items-center justify-center overflow-hidden">
|
||||
{current.album_art_url ? <img src={current.album_art_url} alt="" className="w-full h-full object-cover" /> : <Music2 size={18} className="text-[#8A8A8A]" />}
|
||||
<div className="w-10 h-10 shrink-0 bg-[#0F0F0F] overflow-hidden">
|
||||
<AlbumArt url={current.album_art_url} size="sm" />
|
||||
</div>
|
||||
<div className="min-w-0 w-40 md:w-56">
|
||||
<p className="text-white text-sm truncate">{current.title}</p>
|
||||
|
||||
@@ -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() {
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-6 mb-10">
|
||||
<div className="w-32 h-32 shrink-0 bg-[#0F0F0F] flex items-center justify-center overflow-hidden">
|
||||
{artUrl ? <img src={artUrl} alt="" className="w-full h-full object-cover" /> : <Music2 size={36} strokeWidth={1.5} className="text-[#8A8A8A]" />}
|
||||
<div className="w-32 h-32 shrink-0 bg-[#0F0F0F] overflow-hidden">
|
||||
<AlbumArt url={artUrl} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Album</span>
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
<button onClick={scan} 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="cleanup-scan">
|
||||
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> {loading ? "Scanning…" : "Rescan"}
|
||||
{running ? (
|
||||
<button onClick={stopScan} className="flex items-center gap-2 border border-[#D9381E] text-[#D9381E] hover:bg-[#D9381E]/10 px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="cleanup-stop">
|
||||
<Square size={12} strokeWidth={1.5} fill="currentColor" /> Stop Scan
|
||||
</button>
|
||||
{findings && (
|
||||
) : (
|
||||
<button onClick={scan} 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" data-testid="cleanup-scan">
|
||||
<RefreshCcw size={14} strokeWidth={1.5} /> {findings ? "Rescan" : "Scan Library"}
|
||||
</button>
|
||||
)}
|
||||
{running && <span className="text-xs text-[#fcd34d] animate-pulse" data-testid="cleanup-running">Scanning… this can take a minute</span>}
|
||||
{!running && findings && (
|
||||
<span className="flex items-center gap-2 text-sm" data-testid="cleanup-summary">
|
||||
{totalIssues === 0 ? (
|
||||
<><ShieldCheck size={16} className="text-[#86efac]" /> <span className="text-[#86efac]">No issues found</span></>
|
||||
@@ -106,12 +150,17 @@ export default function LibraryCleanup() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{findings && totalIssues === 0 && (
|
||||
{!running && findings && totalIssues === 0 && (
|
||||
<div className="text-center py-16 text-[#8A8A8A]">
|
||||
<ShieldCheck size={40} strokeWidth={1.5} className="mx-auto mb-4 text-[#86efac]" />
|
||||
Library is clean — no missing files, orphaned records, or stuck jobs.
|
||||
</div>
|
||||
)}
|
||||
{!running && !findings && (
|
||||
<div className="text-center py-16 text-[#8A8A8A]">
|
||||
Click "Scan Library" above to check for issues.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 ? (
|
||||
<img src={a.album_art_url} alt={a.album} loading="lazy" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center"><Music2 size={32} strokeWidth={1.5} className="text-[#8A8A8A]" /></div>
|
||||
)}
|
||||
<AlbumArt url={a.album_art_url} />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-white text-sm truncate">{a.album}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] truncate">{a.artist} · {a.count} tracks</p>
|
||||
|
||||
Reference in New Issue
Block a user