mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 13:33:49 -05:00
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:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user