Add Library Cleanup admin page — missing files, orphaned records, stuck jobs, duplicates

New read-only scan (GET /api/library/cleanup/scan) across movies, episodes,
tracks, subtitles, HLS output, transcode queue, and watchlist/progress rows:
- Missing files: DB record exists but the file is gone from disk
- Orphaned episodes: show_id no longer exists
- Orphaned HLS output: transcoded folders left behind by deleted content
- Stuck transcode jobs: "running" 2+ hours with no completion (backend-restart artifact)
- Duplicate imports: same file imported into the library more than once
- Orphaned subtitles / dangling watchlist+progress rows

Nothing is deleted until an admin clicks Fix (per-item or Fix All per
category) via POST /api/library/cleanup/fix.
This commit is contained in:
Myron Blair
2026-07-26 21:13:16 -05:00
parent 3c47894628
commit c174ac60b1
5 changed files with 301 additions and 1 deletions
+2
View File
@@ -28,6 +28,7 @@ import Users from "./pages/Users";
import Music from "./pages/Music";
import AlbumDetail from "./pages/AlbumDetail";
import LibraryScan from "./pages/LibraryScan";
import LibraryCleanup from "./pages/LibraryCleanup";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
@@ -101,6 +102,7 @@ function App() {
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/scan" element={<AdminGate><LibraryScan /></AdminGate>} />
<Route path="/admin/cleanup" element={<AdminGate><LibraryCleanup /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
+4 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw, FolderSearch } from "lucide-react";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw, FolderSearch, Wrench } from "lucide-react";
import { useNavigate, Link } from "react-router-dom";
export default function Admin() {
@@ -88,6 +88,9 @@ export default function Admin() {
<Link to="/admin/scan" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-scan-link">
<FolderSearch size={14} strokeWidth={1.5} /> Library Scan
</Link>
<Link to="/admin/cleanup" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-cleanup-link">
<Wrench size={14} strokeWidth={1.5} /> Library Cleanup
</Link>
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link>
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, Trash2, Wrench, ShieldCheck } 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" },
{ key: "orphaned_episodes", label: "Orphaned Episodes", desc: "Episode references a show that no longer exists.", action: "Delete episode" },
{ key: "orphaned_hls", label: "Orphaned HLS Output", desc: "Transcoded HLS folders left behind by deleted movies/episodes.", action: "Delete folder" },
{ key: "stuck_jobs", label: "Stuck Transcode Jobs", desc: "Jobs stuck \"running\" for 2+ hours, usually from a backend restart mid-job.", action: "Mark failed" },
{ key: "duplicate_paths", label: "Duplicate Imports", desc: "The same file imported into the library more than once.", action: "Delete duplicate" },
{ key: "orphaned_subtitles", label: "Orphaned Subtitles", desc: "Subtitle references a movie/episode that no longer exists.", action: "Delete subtitle" },
{ key: "dangling_refs", label: "Dangling Watchlist/Progress", desc: "Watchlist or progress rows pointing at a deleted movie.", action: "Delete row" },
];
export default function LibraryCleanup() {
const [findings, setFindings] = useState(null);
const [loading, setLoading] = useState(false);
const [fixing, setFixing] = useState({});
const scan = async () => {
setLoading(true);
try {
const { data } = await api.get("/library/cleanup/scan");
setFindings(data);
} catch (err) {
toast.error(err.response?.data?.detail || "Cleanup scan failed");
} finally {
setLoading(false);
}
};
useEffect(() => { scan(); }, []);
const fix = async (category, items) => {
if (items.length === 0) return;
setFixing((p) => ({ ...p, [category]: true }));
try {
const { data } = await api.post("/library/cleanup/fix", { category, items });
toast.success(`Fixed ${data.fixed} item(s)`);
scan();
} catch (err) {
toast.error(err.response?.data?.detail || "Fix failed");
} finally {
setFixing((p) => ({ ...p, [category]: false }));
}
};
const totalIssues = findings ? Object.values(findings).reduce((n, arr) => n + arr.length, 0) : 0;
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="library-cleanup-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Maintenance</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Library Cleanup</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
Checks for missing files, orphaned records, stuck jobs, and duplicate imports across movies, shows, and music. Nothing is deleted until you click Fix.
</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 && (
<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></>
) : (
<span className="text-[#fcd34d]">{totalIssues} issue(s) found</span>
)}
</span>
)}
</div>
<div className="mt-10 space-y-8">
{CATEGORIES.map((cat) => {
const items = findings?.[cat.key] || [];
if (findings && items.length === 0) return null;
return (
<div key={cat.key} className="border border-[#222]" data-testid={`cleanup-section-${cat.key}`}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[#222] bg-[#0F0F0F]">
<div>
<h2 className="text-white text-lg font-display">{cat.label} <span className="text-[#8A8A8A] text-sm">({items.length})</span></h2>
<p className="text-xs text-[#8A8A8A] mt-1">{cat.desc}</p>
</div>
<button onClick={() => fix(cat.key, items)} disabled={fixing[cat.key] || items.length === 0}
className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-4 py-2 text-xs uppercase tracking-[0.2em] shrink-0"
data-testid={`cleanup-fix-all-${cat.key}`}>
<Wrench size={13} strokeWidth={1.5} /> {cat.action} All
</button>
</div>
<div>
{items.map((it) => (
<div key={`${it.content_type}-${it.id}`} className="flex items-center justify-between gap-4 px-5 py-3 border-b border-[#222] last:border-b-0" data-testid={`cleanup-item-${it.id}`}>
<div className="min-w-0">
<p className="text-white text-sm truncate">{it.label}</p>
<p className="text-xs text-[#666] truncate">{it.detail}</p>
</div>
<button onClick={() => fix(cat.key, [it])} disabled={fixing[cat.key]}
className="flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-3 py-2 shrink-0 disabled:opacity-50"
data-testid={`cleanup-fix-${it.id}`}>
<Trash2 size={12} strokeWidth={1.5} /> {cat.action}
</button>
</div>
))}
</div>
</div>
);
})}
{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>
)}
</div>
</div>
</div>
);
}