mirror of
https://github.com/myronblair/kino-app
synced 2026-07-29 14:02:50 -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) ============
|
# ============ LIBRARY CLEANUP (admin) ============
|
||||||
@api.get("/library/cleanup/scan")
|
_cleanup_scan_status = {"running": False, "findings": None, "error": "", "scan_id": 0}
|
||||||
async def library_cleanup_scan(user: dict = Depends(require_admin)):
|
|
||||||
return await library_cleanup.scan(db, VIDEOS_DIR, HLS_DIR)
|
|
||||||
|
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")
|
@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 { 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 { useMusicPlayer } from "../lib/musicPlayer";
|
||||||
|
import AlbumArt from "./AlbumArt";
|
||||||
|
|
||||||
const fmt = (s) => {
|
const fmt = (s) => {
|
||||||
if (!s || !isFinite(s)) return "0:00";
|
if (!s || !isFinite(s)) return "0:00";
|
||||||
@@ -33,8 +34,8 @@ export default function MusicPlayerBar() {
|
|||||||
return (
|
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="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="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">
|
<div className="w-10 h-10 shrink-0 bg-[#0F0F0F] 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]" />}
|
<AlbumArt url={current.album_art_url} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 w-40 md:w-56">
|
<div className="min-w-0 w-40 md:w-56">
|
||||||
<p className="text-white text-sm truncate">{current.title}</p>
|
<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 api from "../lib/api";
|
||||||
import { Play, Pause, ArrowLeft, Music2 } from "lucide-react";
|
import { Play, Pause, ArrowLeft, Music2 } from "lucide-react";
|
||||||
import { useMusicPlayer } from "../lib/musicPlayer";
|
import { useMusicPlayer } from "../lib/musicPlayer";
|
||||||
|
import AlbumArt from "../components/AlbumArt";
|
||||||
|
|
||||||
export default function AlbumDetail() {
|
export default function AlbumDetail() {
|
||||||
const { artist, album } = useParams();
|
const { artist, album } = useParams();
|
||||||
@@ -40,8 +41,8 @@ export default function AlbumDetail() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 mb-10">
|
<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">
|
<div className="w-32 h-32 shrink-0 bg-[#0F0F0F] overflow-hidden">
|
||||||
{artUrl ? <img src={artUrl} alt="" className="w-full h-full object-cover" /> : <Music2 size={36} strokeWidth={1.5} className="text-[#8A8A8A]" />}
|
<AlbumArt url={artUrl} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Album</span>
|
<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 api from "../lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { RefreshCcw, Trash2, Wrench, ShieldCheck } from "lucide-react";
|
import { RefreshCcw, Trash2, Wrench, ShieldCheck, Square } from "lucide-react";
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ key: "missing_files", label: "Missing Files", desc: "Database record exists but the file is gone from disk.", action: "Remove record" },
|
{ 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() {
|
export default function LibraryCleanup() {
|
||||||
const [findings, setFindings] = useState(null);
|
const [findings, setFindings] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [fixing, setFixing] = useState({});
|
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 () => {
|
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 {
|
try {
|
||||||
const { data } = await api.get("/library/cleanup/scan");
|
await api.post("/library/cleanup/scan/start");
|
||||||
setFindings(data);
|
setRunning(true);
|
||||||
|
setFindings(null);
|
||||||
|
startPolling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.response?.data?.detail || "Cleanup scan failed");
|
toast.error(err.response?.data?.detail || "Could not start scan");
|
||||||
} finally {
|
}
|
||||||
setLoading(false);
|
};
|
||||||
|
|
||||||
|
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) => {
|
const fix = async (category, items) => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
@@ -57,10 +94,17 @@ export default function LibraryCleanup() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 flex items-center gap-4">
|
<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">
|
{running ? (
|
||||||
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> {loading ? "Scanning…" : "Rescan"}
|
<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">
|
||||||
</button>
|
<Square size={12} strokeWidth={1.5} fill="currentColor" /> Stop Scan
|
||||||
{findings && (
|
</button>
|
||||||
|
) : (
|
||||||
|
<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">
|
<span className="flex items-center gap-2 text-sm" data-testid="cleanup-summary">
|
||||||
{totalIssues === 0 ? (
|
{totalIssues === 0 ? (
|
||||||
<><ShieldCheck size={16} className="text-[#86efac]" /> <span className="text-[#86efac]">No issues found</span></>
|
<><ShieldCheck size={16} className="text-[#86efac]" /> <span className="text-[#86efac]">No issues found</span></>
|
||||||
@@ -106,12 +150,17 @@ export default function LibraryCleanup() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{findings && totalIssues === 0 && (
|
{!running && findings && totalIssues === 0 && (
|
||||||
<div className="text-center py-16 text-[#8A8A8A]">
|
<div className="text-center py-16 text-[#8A8A8A]">
|
||||||
<ShieldCheck size={40} strokeWidth={1.5} className="mx-auto mb-4 text-[#86efac]" />
|
<ShieldCheck size={40} strokeWidth={1.5} className="mx-auto mb-4 text-[#86efac]" />
|
||||||
Library is clean — no missing files, orphaned records, or stuck jobs.
|
Library is clean — no missing files, orphaned records, or stuck jobs.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!running && !findings && (
|
||||||
|
<div className="text-center py-16 text-[#8A8A8A]">
|
||||||
|
Click "Scan Library" above to check for issues.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
import { Music2 } from "lucide-react";
|
import { Music2 } from "lucide-react";
|
||||||
|
import AlbumArt from "../components/AlbumArt";
|
||||||
|
|
||||||
export default function Music() {
|
export default function Music() {
|
||||||
const [tracks, setTracks] = useState([]);
|
const [tracks, setTracks] = useState([]);
|
||||||
@@ -45,11 +46,7 @@ export default function Music() {
|
|||||||
onClick={() => nav(`/music/album/${encodeURIComponent(a.artist)}/${encodeURIComponent(a.album)}`)}
|
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]"
|
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}`}>
|
data-testid={`album-card-${a.artist}-${a.album}`}>
|
||||||
{a.album_art_url ? (
|
<AlbumArt url={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>
|
|
||||||
)}
|
|
||||||
<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">
|
<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-white text-sm truncate">{a.album}</p>
|
||||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] truncate">{a.artist} · {a.count} tracks</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