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 = () => {
MusicWishlist
{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