diff --git a/backend/library_cleanup.py b/backend/library_cleanup.py new file mode 100644 index 0000000..461a46d --- /dev/null +++ b/backend/library_cleanup.py @@ -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}") diff --git a/backend/server.py b/backend/server.py index 6b0b723..03fbefe 100644 --- a/backend/server.py +++ b/backend/server.py @@ -36,6 +36,7 @@ import radarr as radarr_client import sonarr as sonarr_client import services as services_client import library_scan +import library_cleanup import musicbrainz as musicbrainz_client import trakt as trakt_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} +# ============ 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: []}""" + 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 ============ @api.get("/tracks", response_model=List[Track]) async def list_tracks(user: dict = Depends(get_current_user)): diff --git a/frontend/src/App.js b/frontend/src/App.js index 29f3a11..9151c6c 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 60c5eba..ad1e1af 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -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() { Library Scan + + Library Cleanup + Queue diff --git a/frontend/src/pages/LibraryCleanup.jsx b/frontend/src/pages/LibraryCleanup.jsx new file mode 100644 index 0000000..8e901fb --- /dev/null +++ b/frontend/src/pages/LibraryCleanup.jsx @@ -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 ( +
+
+ Maintenance +

Library Cleanup

+

+ Checks for missing files, orphaned records, stuck jobs, and duplicate imports across movies, shows, and music. Nothing is deleted until you click Fix. +

+ +
+ + {findings && ( + + {totalIssues === 0 ? ( + <> No issues found + ) : ( + {totalIssues} issue(s) found + )} + + )} +
+ +
+ {CATEGORIES.map((cat) => { + const items = findings?.[cat.key] || []; + if (findings && items.length === 0) return null; + return ( +
+
+
+

{cat.label} ({items.length})

+

{cat.desc}

+
+ +
+
+ {items.map((it) => ( +
+
+

{it.label}

+

{it.detail}

+
+ +
+ ))} +
+
+ ); + })} + {findings && totalIssues === 0 && ( +
+ + Library is clean — no missing files, orphaned records, or stuck jobs. +
+ )} +
+
+
+ ); +}