From 43340c0f499365a891d8422e5c564a5f2cfa23d9 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 26 Jul 2026 23:05:53 -0500 Subject: [PATCH] Remove redundant nav entries; parallelize cleanup file checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Navbar: drop the Users link (already reachable from Admin's quick-links) - Admin quick-links: drop Settings (already reachable from the navbar for every user) - library_cleanup.py: parallelize the missing-file stat checks across a small thread pool instead of one file at a time — sequential NAS round trips were taking minutes for a library of any real size, even after moving them off the event loop. --- backend/library_cleanup.py | 39 +++++++++++++++++++++--------- frontend/src/components/Navbar.jsx | 1 - frontend/src/pages/Admin.jsx | 3 --- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/backend/library_cleanup.py b/backend/library_cleanup.py index 1e1546e..fd8cb44 100644 --- a/backend/library_cleanup.py +++ b/backend/library_cleanup.py @@ -3,12 +3,18 @@ Read-only by design: scan() only reports findings; server.py's /library/cleanup/ performs the actual deletion/reset the admin picked, one category at a time. """ import shutil +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from pathlib import Path from typing import Dict, List import asyncio +# Movie/episode/track files often live on an NFS/CIFS-mounted NAS, where every is_file() stat +# is a real network round trip. Checking hundreds of them one at a time (even off the event +# loop) can take minutes; a bounded worker pool checks them concurrently instead. +_STAT_WORKERS = 24 + def _file_path(storage_type: str, storage_path: str, videos_dir: Path) -> Path: if storage_type in ("radarr", "sonarr", "scan"): @@ -19,29 +25,40 @@ def _file_path(storage_type: str, storage_path: str, videos_dir: Path) -> Path: def _scan_filesystem(movies: List[Dict], episodes: List[Dict], tracks: List[Dict], videos_dir: Path, hls_dir: Path, live_ids: set) -> Dict[str, List[Dict]]: """Every filesystem stat call in the whole scan lives here, run off the event loop via - asyncio.to_thread — movie/episode/track paths are often on an NFS/CIFS-mounted NAS, and - doing hundreds of blocking stats directly in an async function would freeze the entire - server (every other request) for as long as the scan takes.""" + asyncio.to_thread, with the individual stat calls themselves parallelized across a small + worker pool — sequential one-at-a-time NAS round trips were taking minutes for a library + of any real size.""" out: Dict[str, List[Dict]] = {"missing_files": [], "orphaned_hls": []} + candidates = [] # (finding_dict, path_to_check) 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(): - out["missing_files"].append({"id": m["id"], "content_type": "movie", "label": m.get("title", "Untitled"), "detail": m["storage_path"]}) - + candidates.append(( + {"id": m["id"], "content_type": "movie", "label": m.get("title", "Untitled"), "detail": m["storage_path"]}, + _file_path(m["storage_type"], m["storage_path"], videos_dir), + )) for e in episodes: if e.get("_orphaned"): continue # already reported as orphaned_episodes; don't also report a missing file if e.get("storage_type") not in ("local", "sonarr", "scan") or not e.get("storage_path"): continue label = f"S{e.get('season_number')}E{e.get('episode_number')} {e.get('title') or ''}".strip() - if not _file_path(e["storage_type"], e["storage_path"], videos_dir).is_file(): - out["missing_files"].append({"id": e["id"], "content_type": "episode", "label": label, "detail": e["storage_path"]}) - + candidates.append(( + {"id": e["id"], "content_type": "episode", "label": label, "detail": e["storage_path"]}, + _file_path(e["storage_type"], e["storage_path"], videos_dir), + )) for t in tracks: - if not Path(t["storage_path"]).is_file(): - out["missing_files"].append({"id": t["id"], "content_type": "track", "label": f"{t.get('artist','')} — {t.get('title','Untitled')}", "detail": t["storage_path"]}) + candidates.append(( + {"id": t["id"], "content_type": "track", "label": f"{t.get('artist','')} — {t.get('title','Untitled')}", "detail": t["storage_path"]}, + Path(t["storage_path"]), + )) + + with ThreadPoolExecutor(max_workers=_STAT_WORKERS) as pool: + exists = pool.map(lambda pair: pair[1].is_file(), candidates) + for (finding, _path), is_present in zip(candidates, exists): + if not is_present: + out["missing_files"].append(finding) if hls_dir.is_dir(): for d in hls_dir.iterdir(): diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 5e20cc6..910e150 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -35,7 +35,6 @@ export const Navbar = () => { Music Wishlist {user.is_admin && Admin} - {user.is_admin && Users} )} diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index ad1e1af..737a839 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -94,9 +94,6 @@ export default function Admin() { Queue - - Settings - Users