Files
kino-app/backend/library_cleanup.py
T
Myron Blair 5fc37e46e4 Fix 8 code-review findings across transcode queue, requests, cleanup, music
- Episode transcode (single + bulk) now accepts storage_type=scan, matching
  stream_episode and the movie-transcode endpoint — Library-Scan-imported
  TV episodes can finally be transcoded, not just streamed.
- retry_job now forwards content_type, fixing retry for failed/cancelled
  episode jobs (was silently treating them as movies and failing again).
- cancel_job now updates the correct collection (movies vs episodes) based
  on content_type instead of always writing to db.movies.
- approve_request atomically claims the request (pending -> approving)
  before contacting Radarr/Sonarr, so a double-click or two concurrent
  admins can't both add the same content; releases the claim back to
  pending on any failure so a fixable error can be retried.
- Music enrichment's update_many now re-checks album_art_url is still
  empty, so it no longer clobbers art an admin manually set via PATCH.
- Library Cleanup's dangling-refs scan now also checks episode_progress
  for rows referencing a deleted episode (previously only watchlist/
  progress against movie_id were checked).
- Settings now surfaces a failed bulk-TMDB-enrich run as an error instead
  of a false "Enrichment done" success summary.
- Music player's isPlaying now syncs from the audio element's native
  play/pause events instead of only being set inside togglePlay/track-
  change, so it can't drift from actual playback state (buffering stalls,
  OS media-key pauses, etc).
2026-07-26 22:24:09 -05:00

172 lines
9.1 KiB
Python

"""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")})
# --- Dangling episode_progress rows (reference a deleted episode) ---
ep_rows = await db.episode_progress.find({}, {"_id": 0}).to_list(20000)
for r in ep_rows:
if r.get("episode_id") and r["episode_id"] not in episode_ids:
findings["dangling_refs"].append({"id": f"{r['profile_id']}:{r['episode_id']}", "content_type": "episode_progress", "label": r["episode_id"], "detail": f"references deleted episode {r['episode_id']}", "profile_id": r.get("profile_id"), "episode_id": r.get("episode_id")})
return findings
async def _delete_content(db, content_type: str, item_id: str) -> None:
"""Deletes a movie/episode/track and every row that references it, so a Fix never
leaves orphans behind for the next scan to catch on a second pass."""
if content_type == "movie":
await db.movies.delete_one({"id": item_id})
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 content_type == "episode":
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 content_type == "track":
await db.tracks.delete_one({"id": item_id})
else:
raise ValueError(f"Unknown content_type: {content_type}")
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":
await _delete_content(db, content_type, item_id)
elif category == "orphaned_episodes":
await _delete_content(db, "episode", 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":
await _delete_content(db, content_type, 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":
if content_type == "episode_progress":
await db.episode_progress.delete_one({"profile_id": item.get("profile_id"), "episode_id": item.get("episode_id")})
else:
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}")