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
+152
View File
@@ -0,0 +1,152 @@
"""Library integrity scan — missing files, orphaned records/disk usage, stuck jobs, duplicates.
Read-only by design: scan() only reports findings; server.py's /library/cleanup/fix endpoint
performs the actual deletion/reset the admin picked, one category at a time.
"""
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List
def _file_path(storage_type: str, storage_path: str, videos_dir: Path) -> Path:
if storage_type in ("radarr", "sonarr", "scan"):
return Path(storage_path)
return videos_dir / storage_path
async def scan(db, videos_dir: Path, hls_dir: Path) -> Dict[str, List[Dict]]:
findings: Dict[str, List[Dict]] = {
"missing_files": [], "orphaned_episodes": [], "orphaned_hls": [],
"stuck_jobs": [], "duplicate_paths": [], "orphaned_subtitles": [], "dangling_refs": [],
}
# --- Missing files ---
movies = await db.movies.find({}, {"_id": 0, "id": 1, "title": 1, "storage_type": 1, "storage_path": 1}).to_list(10000)
for m in movies:
if m.get("storage_type") not in ("local", "radarr", "scan") or not m.get("storage_path"):
continue
if not _file_path(m["storage_type"], m["storage_path"], videos_dir).is_file():
findings["missing_files"].append({"id": m["id"], "content_type": "movie", "label": m.get("title", "Untitled"), "detail": m["storage_path"]})
episodes = await db.episodes.find({}, {"_id": 0, "id": 1, "title": 1, "show_id": 1, "season_number": 1, "episode_number": 1, "storage_type": 1, "storage_path": 1}).to_list(20000)
show_ids = set(await db.shows.distinct("id"))
for e in episodes:
label = f"S{e.get('season_number')}E{e.get('episode_number')} {e.get('title') or ''}".strip()
if e.get("show_id") not in show_ids:
findings["orphaned_episodes"].append({"id": e["id"], "content_type": "episode", "label": label, "detail": f"show_id {e.get('show_id')} not found"})
continue
if e.get("storage_type") not in ("local", "sonarr", "scan") or not e.get("storage_path"):
continue
if not _file_path(e["storage_type"], e["storage_path"], videos_dir).is_file():
findings["missing_files"].append({"id": e["id"], "content_type": "episode", "label": label, "detail": e["storage_path"]})
tracks = await db.tracks.find({}, {"_id": 0, "id": 1, "title": 1, "artist": 1, "storage_path": 1}).to_list(20000)
for t in tracks:
if not Path(t["storage_path"]).is_file():
findings["missing_files"].append({"id": t["id"], "content_type": "track", "label": f"{t.get('artist','')}{t.get('title','Untitled')}", "detail": t["storage_path"]})
# --- Orphaned HLS output dirs (no matching movie or episode id) ---
live_ids = {m["id"] for m in movies} | {e["id"] for e in episodes}
if hls_dir.is_dir():
for d in hls_dir.iterdir():
if d.is_dir() and d.name not in live_ids:
size = sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
findings["orphaned_hls"].append({"id": d.name, "content_type": "hls_dir", "label": d.name, "detail": f"{round(size / 1_000_000)} MB reclaimable"})
# --- Stuck transcode jobs (running with no progress for 2+ hours — likely dead from a restart) ---
now = datetime.now(timezone.utc)
running = await db.transcode_queue.find({"status": "running"}, {"_id": 0}).to_list(500)
for j in running:
started = j.get("started_at")
if not started:
continue
try:
age_hours = (now - datetime.fromisoformat(started)).total_seconds() / 3600
except ValueError:
continue
if age_hours > 2:
findings["stuck_jobs"].append({"id": j["id"], "content_type": "job", "label": j.get("movie_title") or j["movie_id"], "detail": f"running {round(age_hours, 1)}h with no completion"})
# --- Duplicate storage_path within the same collection (same file imported twice) ---
for coll_name, coll, label_fn in (
("movies", db.movies, lambda d: d.get("title", "Untitled")),
("episodes", db.episodes, lambda d: d.get("title") or d.get("id")),
("tracks", db.tracks, lambda d: d.get("title", "Untitled")),
):
docs = await coll.find({"storage_path": {"$ne": None}}, {"_id": 0}).sort("created_at", 1).to_list(20000)
seen = {}
for d in docs:
key = d.get("storage_path")
if not key:
continue
if key in seen:
findings["duplicate_paths"].append({"id": d["id"], "content_type": coll_name[:-1], "label": label_fn(d), "detail": f"duplicate of {seen[key]}"})
else:
seen[key] = d["id"]
# --- Orphaned subtitles (reference a movie/episode that no longer exists) ---
movie_ids = {m["id"] for m in movies}
episode_ids = {e["id"] for e in episodes}
subs = await db.subtitles.find({}, {"_id": 0}).to_list(5000)
for s in subs:
mid, eid = s.get("movie_id") or "", s.get("episode_id") or ""
if mid and mid not in movie_ids:
findings["orphaned_subtitles"].append({"id": s["id"], "content_type": "subtitle", "label": s.get("label", "Subtitle"), "detail": f"movie_id {mid} not found"})
elif eid and eid not in episode_ids:
findings["orphaned_subtitles"].append({"id": s["id"], "content_type": "subtitle", "label": s.get("label", "Subtitle"), "detail": f"episode_id {eid} not found"})
# --- Dangling watchlist/progress rows (reference a deleted movie) ---
for coll_name, coll in (("watchlist", db.watchlist), ("progress", db.progress)):
rows = await coll.find({}, {"_id": 0}).to_list(20000)
for r in rows:
if r.get("movie_id") and r["movie_id"] not in movie_ids:
findings["dangling_refs"].append({"id": r["id"] if "id" in r else f"{r['profile_id']}:{r['movie_id']}", "content_type": coll_name, "label": r["movie_id"], "detail": f"references deleted movie {r['movie_id']}", "profile_id": r.get("profile_id"), "movie_id": r.get("movie_id")})
return findings
async def fix_one(db, videos_dir: Path, hls_dir: Path, category: str, item: Dict) -> None:
content_type = item.get("content_type")
item_id = item.get("id")
if category == "missing_files":
coll = {"movie": db.movies, "episode": db.episodes, "track": db.tracks}[content_type]
await coll.delete_one({"id": item_id})
if content_type == "movie":
await db.watchlist.delete_many({"movie_id": item_id})
await db.progress.delete_many({"movie_id": item_id})
await db.subtitles.delete_many({"movie_id": item_id})
elif category == "orphaned_episodes":
await db.episodes.delete_one({"id": item_id})
await db.episode_progress.delete_many({"episode_id": item_id})
await db.subtitles.delete_many({"episode_id": item_id})
elif category == "orphaned_hls":
shutil.rmtree(hls_dir / item_id, ignore_errors=True)
elif category == "stuck_jobs":
await db.transcode_queue.update_one({"id": item_id}, {"$set": {
"status": "failed", "error": "Reset by library cleanup (stuck with no progress)",
"finished_at": datetime.now(timezone.utc).isoformat(),
}})
elif category == "duplicate_paths":
coll = {"movie": db.movies, "episode": db.episodes, "track": db.tracks}[content_type]
await coll.delete_one({"id": item_id})
elif category == "orphaned_subtitles":
sub = await db.subtitles.find_one({"id": item_id}, {"_id": 0})
if sub and sub.get("storage_path"):
try:
(Path(str(videos_dir.parent / "subtitles")) / sub["storage_path"]).unlink(missing_ok=True)
except OSError:
pass
await db.subtitles.delete_one({"id": item_id})
elif category == "dangling_refs":
coll = {"watchlist": db.watchlist, "progress": db.progress}[content_type]
await coll.delete_one({"profile_id": item.get("profile_id"), "movie_id": item.get("movie_id")})
else:
raise ValueError(f"Unknown cleanup category: {category}")
+24
View File
@@ -36,6 +36,7 @@ import radarr as radarr_client
import sonarr as sonarr_client import sonarr as sonarr_client
import services as services_client import services as services_client
import library_scan import library_scan
import library_cleanup
import musicbrainz as musicbrainz_client import musicbrainz as musicbrainz_client
import trakt as trakt_client import trakt as trakt_client
import opensubtitles as opensubs_client import opensubtitles as opensubs_client
@@ -1510,6 +1511,29 @@ async def scan_import(payload: dict, user: dict = Depends(require_admin)):
return {"ok": True, "imported": created} return {"ok": True, "imported": created}
# ============ 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)
@api.post("/library/cleanup/fix")
async def library_cleanup_fix(payload: dict, user: dict = Depends(require_admin)):
"""Body: {category: str, items: [<finding dicts as returned by GET /library/cleanup/scan>]}"""
category = payload.get("category")
items = payload.get("items") or []
if not items:
raise HTTPException(status_code=400, detail="No items provided")
fixed = 0
for item in items:
try:
await library_cleanup.fix_one(db, VIDEOS_DIR, HLS_DIR, category, item)
fixed += 1
except Exception as e:
logger.warning(f"Library cleanup fix failed for {category}/{item.get('id')}: {e}")
return {"ok": True, "fixed": fixed}
# ============ MUSIC ============ # ============ MUSIC ============
@api.get("/tracks", response_model=List[Track]) @api.get("/tracks", response_model=List[Track])
async def list_tracks(user: dict = Depends(get_current_user)): async def list_tracks(user: dict = Depends(get_current_user)):
+2
View File
@@ -28,6 +28,7 @@ import Users from "./pages/Users";
import Music from "./pages/Music"; import Music from "./pages/Music";
import AlbumDetail from "./pages/AlbumDetail"; import AlbumDetail from "./pages/AlbumDetail";
import LibraryScan from "./pages/LibraryScan"; import LibraryScan from "./pages/LibraryScan";
import LibraryCleanup from "./pages/LibraryCleanup";
const ProfileGate = ({ children }) => { const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
@@ -101,6 +102,7 @@ function App() {
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} /> <Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} /> <Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/scan" element={<AdminGate><LibraryScan /></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/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} /> <Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+4 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import api from "../lib/api"; import api from "../lib/api";
import { toast } from "sonner"; 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"; import { useNavigate, Link } from "react-router-dom";
export default function Admin() { 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"> <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 <FolderSearch size={14} strokeWidth={1.5} /> Library Scan
</Link> </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"> <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 <SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link> </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>
);
}