diff --git a/api/video/scan.py b/api/video/scan.py index 0e17918d..99e83984 100644 --- a/api/video/scan.py +++ b/api/video/scan.py @@ -32,3 +32,9 @@ def register_routes(bp): from . import get_video_db from core.video.scanner import get_video_scanner return jsonify(get_video_scanner(get_video_db()).get_status()) + + @bp.route("/scan/stop", methods=["POST"]) + def video_scan_stop(): + from . import get_video_db + from core.video.scanner import get_video_scanner + return jsonify(get_video_scanner(get_video_db()).cancel()) diff --git a/core/video/scanner.py b/core/video/scanner.py index 14592369..e74d5d75 100644 --- a/core/video/scanner.py +++ b/core/video/scanner.py @@ -39,6 +39,7 @@ class VideoLibraryScanner: self._lock = threading.Lock() self._status = {"state": "idle"} self._thread = None + self._cancel = False def get_status(self) -> dict: with self._lock: @@ -48,6 +49,15 @@ class VideoLibraryScanner: with self._lock: self._status.update(kw) + def cancel(self) -> dict: + """Request the running scan to stop after the current item.""" + with self._lock: + if self._status.get("state") == "scanning": + self._cancel = True + self._status["phase"] = "cancelling" + return {"status": "cancelling"} + return {"status": "idle"} + @staticmethod def _norm_mode(mode) -> str: return mode if mode in VALID_MODES else "full" @@ -59,8 +69,9 @@ class VideoLibraryScanner: with self._lock: if self._status.get("state") == "scanning": return {"status": "in_progress"} + self._cancel = False self._status = {"state": "scanning", "phase": "starting", "mode": mode, - "started_at": time.time(), + "started_at": time.time(), "percent": None, "movies": 0, "shows": 0, "episodes": 0} self._thread = threading.Thread( target=self._run, args=(source_factory, mode), daemon=True) @@ -71,12 +82,18 @@ class VideoLibraryScanner: """Run a scan inline (used by tests / callers that want to block).""" mode = self._norm_mode(mode) with self._lock: + self._cancel = False self._status = {"state": "scanning", "phase": "starting", "mode": mode, - "started_at": time.time(), + "started_at": time.time(), "percent": None, "movies": 0, "shows": 0, "episodes": 0} self._run(source_factory, mode) return self.get_status() + def _finish_cancelled(self, movies, shows, episodes) -> None: + self._set(state="cancelled", phase="cancelled", finished_at=time.time(), + movies=movies, shows=shows, episodes=episodes) + logger.info("Video scan cancelled at %d movies, %d shows", movies, shows) + def _run(self, source_factory, mode: str = "full") -> None: try: source = source_factory() @@ -88,17 +105,33 @@ class VideoLibraryScanner: incremental = mode == "incremental" do_prune = mode == "deep" + # Totals up front so the progress bar shows a REAL percentage + # (movies + shows are the unit; episodes ride along under each show). + total = 0 + try: + c = source.counts(incremental=incremental) or {} + total = int(c.get("movies", 0) or 0) + int(c.get("shows", 0) or 0) + except Exception: + logger.debug("video scan: counts() unavailable; progress will be indeterminate") + processed = 0 + + def pct(): + return round(processed / total * 100) if total else None + # ── Movies ── - self._set(phase="scanning movies") + self._set(phase="scanning movies", total=total, percent=pct()) seen_movies: set[str] = set() movies = 0 for item in source.iter_movies(incremental=incremental): + if self._cancel: + return self._finish_cancelled(movies, 0, 0) self.db.upsert_movie(server, item) seen_movies.add(str(item["server_id"])) movies += 1 - if movies % 25 == 0: - self._set(movies=movies) - self._set(movies=movies) + processed += 1 + if movies % 10 == 0: + self._set(movies=movies, percent=pct()) + self._set(movies=movies, percent=pct()) # Prune ONLY on a deep scan, and only when we actually saw items — # so a transient empty response can never wipe the library. removed_m = (self.db.prune_missing("movies", server, seen_movies) @@ -110,16 +143,19 @@ class VideoLibraryScanner: shows = 0 episodes = 0 for show in source.iter_shows(incremental=incremental): + if self._cancel: + return self._finish_cancelled(movies, shows, episodes) self.db.upsert_show_tree(server, show) seen_shows.add(str(show["server_id"])) shows += 1 episodes += sum(len(s.get("episodes", [])) for s in show.get("seasons", [])) - self._set(shows=shows, episodes=episodes) + processed += 1 + self._set(shows=shows, episodes=episodes, percent=pct()) removed_s = (self.db.prune_missing("shows", server, seen_shows) if do_prune and seen_shows else 0) self._set(state="done", phase="complete", finished_at=time.time(), - movies=movies, shows=shows, episodes=episodes, + movies=movies, shows=shows, episodes=episodes, percent=100, removed=removed_m + removed_s) logger.info("Video scan (%s) complete: %d movies, %d shows, %d episodes (%d pruned)", mode, movies, shows, episodes, removed_m + removed_s) diff --git a/core/video/sources.py b/core/video/sources.py index 2d7e4dbc..6154665e 100644 --- a/core/video/sources.py +++ b/core/video/sources.py @@ -112,6 +112,14 @@ class PlexVideoSource: "tv": [{"title": s.title} for s in self._sections("show")], } + def counts(self, incremental=False) -> dict: + """Cheap item totals (no full fetch) for the progress bar.""" + m = sum(int(getattr(s, "totalSize", 0) or 0) for s in self._sections("movie", self._movies_lib)) + sh = sum(int(getattr(s, "totalSize", 0) or 0) for s in self._sections("show", self._tv_lib)) + if incremental: + m, sh = min(m, 100), min(sh, 50) + return {"movies": m, "shows": sh} + def iter_movies(self, incremental=False): for section in self._sections("movie", self._movies_lib): items = section.search(sort="addedAt:desc", maxresults=100) if incremental else section.all() @@ -235,6 +243,18 @@ class JellyfinVideoSource: "tv": [{"title": v.get("Name")} for v in self._views("tvshows")], } + def counts(self, incremental=False) -> dict: + def total(view, itype): + resp = self._req(f"/Users/{self.uid}/Items", { + "ParentId": view["Id"], "IncludeItemTypes": itype, + "Recursive": "true", "Limit": "0"}) or {} + return int(resp.get("TotalRecordCount", 0) or 0) + m = sum(total(v, "Movie") for v in self._views("movies", self._movies_lib)) + sh = sum(total(v, "Series") for v in self._views("tvshows", self._tv_lib)) + if incremental: + m, sh = min(m, 100), min(sh, 50) + return {"movies": m, "shows": sh} + def _paged(self, path, params, page_size=500): """Yield items across pages so large libraries aren't capped/truncated.""" start = 0 diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 8b878fba..27fec752 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -31,6 +31,7 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/dashboard" in rules assert "/api/video/scan/request" in rules assert "/api/video/scan/status" in rules + assert "/api/video/scan/stop" in rules assert "/api/video/library" in rules assert "/api/video/libraries" in rules assert any(r.startswith("/api/video/poster/") for r in rules) diff --git a/tests/test_video_scanner.py b/tests/test_video_scanner.py index 3d4637b5..dec3be75 100644 --- a/tests/test_video_scanner.py +++ b/tests/test_video_scanner.py @@ -87,6 +87,42 @@ def test_scan_sync_no_source_reports_error(db): assert st["state"] == "error" and "error" in st +def test_scan_reports_percent_from_counts(db): + class S: + server_name = "plex" + def counts(self, incremental=False): + return {"movies": 4, "shows": 0} + def iter_movies(self, incremental=False): + return iter([{"server_id": "m%d" % i, "title": str(i)} for i in range(4)]) + def iter_shows(self, incremental=False): + return iter([]) + st = VideoLibraryScanner(db).scan_sync(lambda: S()) + assert st["state"] == "done" + assert st["percent"] == 100 + + +def test_scan_cancel_stops_midway(db): + scanner = VideoLibraryScanner(db) + + def gen(): + yield {"server_id": "m1", "title": "A"} + scanner.cancel() # request stop after the first item + yield {"server_id": "m2", "title": "B"} + + class S: + server_name = "plex" + def counts(self, incremental=False): + return {"movies": 2, "shows": 0} + def iter_movies(self, incremental=False): + return gen() + def iter_shows(self, incremental=False): + return iter([]) + + st = scanner.scan_sync(lambda: S()) + assert st["state"] == "cancelled" + assert db.dashboard_stats()["library"]["movies"] == 1 # only the first was saved + + def test_core_video_imports_nothing_from_music(): base = Path(__file__).resolve().parent.parent / "core" / "video" for py in base.glob("*.py"): diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 3ffcc180..d2c7e38e 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -168,6 +168,10 @@ def test_video_tools_page_has_three_scan_modes(): assert "data-video-scan-run" in block and "data-video-scan-select" in block assert "data-video-scan-phase" in block # reuses music progress markup assert "tool-card" in block and "tool-card-controls" in block # reuses music classes + # Matches the music Database Updater card: help button, last-scan line, stats grid. + assert "tool-help-button" in block + assert "data-video-scan-last" in block + assert "tool-card-stats" in block and 'data-video-scan-stat="movies"' in block assert "onclick" not in block diff --git a/webui/index.html b/webui/index.html index cf0621b5..e7c86260 100644 --- a/webui/index.html +++ b/webui/index.html @@ -762,8 +762,27 @@
Last Scan: Never
+Pull movies & shows from your media server. Incremental = recently-added only; Full Refresh = re-read everything; Deep Scan = also remove items no longer on the server.