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:
Myron Blair
2026-07-26 23:15:07 -05:00
parent 43340c0f49
commit cf8c7890be
6 changed files with 132 additions and 28 deletions
+20
View File
@@ -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>
);
}
+4 -3
View File
@@ -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 -2
View File
@@ -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>
+64 -15
View File
@@ -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"}
</button>
{findings && (
{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>
) : (
<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 -5
View File
@@ -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>