diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 556594a4..7e34e60f 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -218,7 +218,12 @@ ACTIONS: list[dict] = [ "options": [{"value": "full", "label": "Full (add + refresh)"}, {"value": "incremental", "label": "Incremental (recent only)"}, {"value": "deep", "label": "Deep (also remove missing)"}], - "default": "full"} + "default": "full"}, + {"key": "media_type", "type": "select", "label": "Library", + "options": [{"value": "all", "label": "Movies + TV"}, + {"value": "movie", "label": "Movies only"}, + {"value": "show", "label": "TV only"}], + "default": "all"} ]}, # Post-download chain actions (two stages, like music's scan_library + # start_database_update). Stage 1 nudges the server; stage 2 reads it in. diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index ac686c73..133cd447 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -178,6 +178,18 @@ def register_all(deps: AutomationDeps) -> None: 'video_scan_library', lambda config: auto_video_scan_library(config, deps), ) + # Per-library deep scans (the video twin of music's 'Auto-Deep Scan Library', + # split because Movies and TV are independent libraries). Distinct action types + # so the system seeder — which keys on action_type — treats them as two separate + # automations; both reuse the one handler, scoped via media_type in action_config. + engine.register_action_handler( + 'video_deep_scan_movies', + lambda config: auto_video_scan_library({**config, 'media_type': 'movie', 'mode': config.get('mode') or 'deep'}, deps), + ) + engine.register_action_handler( + 'video_deep_scan_tv', + lambda config: auto_video_scan_library({**config, 'media_type': 'show', 'mode': config.get('mode') or 'deep'}, deps), + ) # Post-download chain: scan the server, then (on the scan-done event) update the DB. engine.register_action_handler( 'video_scan_server', diff --git a/core/automation/handlers/video_scan_library.py b/core/automation/handlers/video_scan_library.py index ea7f7703..c1be85e8 100644 --- a/core/automation/handlers/video_scan_library.py +++ b/core/automation/handlers/video_scan_library.py @@ -37,12 +37,13 @@ def _default_server_refresh() -> Dict[str, Any]: return refresh_video_server_sections() -def _default_run_video_scan(mode: str) -> Dict[str, Any]: - """Production wiring: read the server into video.db (blocking).""" +def _default_run_video_scan(mode: str, media_type: str = "all") -> Dict[str, Any]: + """Production wiring: read the server into video.db (blocking). ``media_type`` + scopes it to one library ('movie' / 'show'); 'all' does both.""" from api.video import get_video_db from core.video.scanner import get_video_scanner from core.video.sources import get_active_video_source - return get_video_scanner(get_video_db()).scan_sync(get_active_video_source, mode) + return get_video_scanner(get_video_db()).scan_sync(get_active_video_source, mode, media_type) def auto_video_scan_library( @@ -50,12 +51,17 @@ def auto_video_scan_library( deps: AutomationDeps, *, server_refresh: Optional[Callable[[], Dict[str, Any]]] = None, - run_video_scan: Optional[Callable[[str], Dict[str, Any]]] = None, + run_video_scan: Optional[Callable[..., Dict[str, Any]]] = None, ) -> Dict[str, Any]: """Trigger a server-side video rescan, then mirror the result into video.db. + ``config['media_type']`` scopes the scan to one library — 'movie' or 'show' + (TV); 'all' (default) does both. Movies and TV are independent libraries, so + the Movie scan never touches TV and vice-versa. + Returns one of: - ``{'status': 'completed', '_manages_own_progress': True, 'movies': .., 'shows': .., 'episodes': ..}`` + - ``{'status': 'skipped', 'reason': '...'}`` (another scan was already running) - ``{'status': 'error', 'error': '...', '_manages_own_progress': True}`` """ server_refresh = server_refresh or _default_server_refresh @@ -65,13 +71,15 @@ def auto_video_scan_library( # 'full' is the safe default — upsert everything, never prune. 'deep' prunes # what the server no longer has; only use it when the config asks explicitly. mode = config.get('mode') or 'full' + media_type = config.get('media_type') or 'all' + lib_label = {'movie': 'Movie', 'show': 'TV'}.get(media_type, 'video') try: deps.update_progress( automation_id, - phase='Asking media server to rescan video sections...', + phase=f'Asking media server to rescan {lib_label} sections...', progress=10, - log_line='Triggering server-side video scan', + log_line=f'Triggering server-side {lib_label} scan', log_type='info', ) @@ -100,11 +108,21 @@ def auto_video_scan_library( # scan handler blocking until the scan resolves). deps.update_progress( automation_id, - phase='Reading library into SoulSync...', + phase=f'Reading {lib_label} library into SoulSync...', progress=45, ) - result = run_video_scan(mode) or {} + result = run_video_scan(mode, media_type) or {} state = result.get('state') + # Singleton scanner already busy with another scan (e.g. the Movie + TV + # deep scans firing close together) — skip cleanly, don't error. + if state == 'in_progress': + deps.update_progress( + automation_id, status='finished', phase='Skipped', + log_line='Another video scan is already running — skipping this run', + log_type='info', + ) + return {'status': 'skipped', 'reason': 'a video scan is already running', + '_manages_own_progress': True} if state == 'error': err = result.get('error') or 'Video library scan failed' deps.update_progress( @@ -119,12 +137,19 @@ def auto_video_scan_library( movies = int(result.get('movies', 0) or 0) shows = int(result.get('shows', 0) or 0) episodes = int(result.get('episodes', 0) or 0) + # Summary names only the scanned library so a TV scan doesn't read "0 movies". + if media_type == 'movie': + summary = f'Movie library scanned: {movies} movies' + elif media_type == 'show': + summary = f'TV library scanned: {shows} shows, {episodes} episodes' + else: + summary = f'Video library scanned: {movies} movies, {shows} shows, {episodes} episodes' deps.update_progress( automation_id, status='finished', progress=100, phase='Complete', - log_line=f'Video library scanned: {movies} movies, {shows} shows, {episodes} episodes', + log_line=summary, log_type='success', ) return { diff --git a/core/automation_engine.py b/core/automation_engine.py index 01c3f473..37725527 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -182,6 +182,29 @@ SYSTEM_AUTOMATIONS = [ 'action_type': 'video_add_airing_episodes', 'owned_by': 'video', }, + # Video twin of music's 'Auto-Deep Scan Library', split into TWO because Movies + # and TV are independent libraries — a TV scan never pulls in new movies and + # vice-versa. Weekly deep scan (re-read + prune removed). Initial delays are + # staggered so they don't collide on startup; the singleton scanner also skips a + # run that overlaps the other (handler returns 'skipped'). + { + 'name': 'Auto-Deep Scan Movie Library', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 7, 'unit': 'days'}, + 'action_type': 'video_deep_scan_movies', + 'action_config': {'mode': 'deep', 'media_type': 'movie'}, + 'initial_delay': 1500, # 25 min after startup + 'owned_by': 'video', + }, + { + 'name': 'Auto-Deep Scan TV Library', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 7, 'unit': 'days'}, + 'action_type': 'video_deep_scan_tv', + 'action_config': {'mode': 'deep', 'media_type': 'show'}, + 'initial_delay': 2400, # 40 min after startup (staggered past the movie scan) + 'owned_by': 'video', + }, ] diff --git a/core/video/scanner.py b/core/video/scanner.py index b0be8db6..18466805 100644 --- a/core/video/scanner.py +++ b/core/video/scanner.py @@ -29,6 +29,9 @@ from utils.logging_config import get_logger logger = get_logger("video_scanner") VALID_MODES = ("incremental", "full", "deep") +# Which library to scan. Movies and TV are independent libraries, so a TV scan +# must never touch movies and vice-versa. 'all' (default) does both. +VALID_MEDIA_TYPES = ("all", "movie", "show") # Incremental stops after this many consecutive already-known items (recent # first), mirroring music's "25 consecutive complete albums" early-stop. @@ -73,31 +76,51 @@ class VideoLibraryScanner: def _norm_mode(mode) -> str: return mode if mode in VALID_MODES else "full" - def request_scan(self, source_factory, mode: str = "full") -> dict: + @staticmethod + def _norm_media_type(media_type) -> str: + """'movie'|'show'|'all', accepting the friendly aliases the UI/config use.""" + m = str(media_type or "all").lower() + if m in ("movie", "movies", "film", "films"): + return "movie" + if m in ("show", "shows", "tv", "series", "episode", "episodes"): + return "show" + return "all" + + def request_scan(self, source_factory, mode: str = "full", media_type: str = "all") -> dict: """Kick off a background scan. ``source_factory()`` returns a media - source (or None if no video-capable server is connected).""" + source (or None if no video-capable server is connected). ``media_type`` + limits it to one library ('movie' / 'show'); 'all' does both.""" mode = self._norm_mode(mode) + media_type = self._norm_media_type(media_type) 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(), "percent": None, - "movies": 0, "shows": 0, "episodes": 0} + "media_type": media_type, "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) + target=self._run, args=(source_factory, mode, media_type), daemon=True) self._thread.start() - return {"status": "started", "mode": mode} + return {"status": "started", "mode": mode, "media_type": media_type} + + def scan_sync(self, source_factory, mode: str = "full", media_type: str = "all") -> dict: + """Run a scan inline (used by tests / callers that want to block). - def scan_sync(self, source_factory, mode: str = "full") -> dict: - """Run a scan inline (used by tests / callers that want to block).""" + ``media_type`` limits the scan to one library: 'movie' or 'show' (TV); + 'all' (default) does both. Because the scanner is a process singleton, a + scan that starts while another is running returns ``state='in_progress'`` + without stomping the live one — the caller should skip.""" mode = self._norm_mode(mode) + media_type = self._norm_media_type(media_type) with self._lock: + if self._status.get("state") == "scanning": + return {"state": "in_progress", "phase": "a video scan is already running"} self._cancel = False self._status = {"state": "scanning", "phase": "starting", "mode": mode, - "started_at": time.time(), "percent": None, - "movies": 0, "shows": 0, "episodes": 0} - self._run(source_factory, mode) + "media_type": media_type, "started_at": time.time(), + "percent": None, "movies": 0, "shows": 0, "episodes": 0} + self._run(source_factory, mode, media_type) return self.get_status() def _finish_cancelled(self, movies, shows, episodes) -> None: @@ -125,7 +148,7 @@ class VideoLibraryScanner: except Exception: logger.debug("video scan: resuming enrichment workers failed", exc_info=True) - def _run(self, source_factory, mode: str = "full") -> None: + def _run(self, source_factory, mode: str = "full", media_type: str = "all") -> None: # Enrichment steps aside for the scan (all modes, both entry points), and # the finally guarantees it resumes on success, cancel, or error. paused = self._pause_for_scan() @@ -138,6 +161,10 @@ class VideoLibraryScanner: server = source.server_name incremental = mode == "incremental" do_prune = mode == "deep" + # Movies and TV are independent libraries — scan only the requested + # kind(s) so a TV scan never pulls in (or prunes) movies, and vice-versa. + do_movies = media_type in ("all", "movie") + do_shows = media_type in ("all", "show") # FULL = a clean reset (clobber enrichment-owned fields). Incremental/deep # PRESERVE them, so a routine re-scan never wipes the TMDB-backfilled # `status` the airing watchlist relies on. (Only an explicit full resets.) @@ -153,7 +180,8 @@ class VideoLibraryScanner: total = 0 try: c = source.counts(incremental=incremental) or {} - total = int(c.get("movies", 0) or 0) + int(c.get("shows", 0) or 0) + total = (int(c.get("movies", 0) or 0) if do_movies else 0) + \ + (int(c.get("shows", 0) or 0) if do_shows else 0) except Exception: logger.debug("video scan: counts() unavailable; progress will be indeterminate") processed = 0 @@ -161,78 +189,82 @@ class VideoLibraryScanner: def pct(): return round(processed / total * 100) if total else None - known_movies = self.db.server_ids("movies", server) if incremental else set() - known_shows = self.db.server_ids("shows", server) if incremental else set() + known_movies = self.db.server_ids("movies", server) if (incremental and do_movies) else set() + known_shows = self.db.server_ids("shows", server) if (incremental and do_shows) else set() - # ── Movies ── - self._set(phase="scanning movies", total=total, percent=pct()) + # ── Movies ── (skipped entirely on a TV-only scan) seen_movies: set[str] = set() movies = 0 - consec = 0 - for item in source.iter_movies(incremental=incremental): - if self._cancel: - return self._finish_cancelled(movies, 0, 0) - sid = str(item["server_id"]) - # Incremental early-stop: skip already-known items and bail after - # a run of consecutive known ones (server lists recent first). - if incremental and sid in known_movies: - consec += 1 - if consec >= INCREMENTAL_STOP_AFTER: - break - continue + removed_m = 0 + if do_movies: + self._set(phase="scanning movies", total=total, percent=pct()) consec = 0 - try: - self.db.upsert_movie(server, item, preserve_enrichment=preserve) - except Exception: - logger.exception("video scan: skipping movie %s", sid) - continue - seen_movies.add(sid) - movies += 1 - 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. The prune - # runs AFTER the bar fills, and a big cleanup (many orphaned rows + - # cascades) takes a few seconds — surface a phase so the UI shows - # "cleaning up", not a stuck 100%. - if do_prune and seen_movies: - self._set(phase="cleaning up removed movies", percent=pct()) - removed_m = (self.db.prune_missing("movies", server, seen_movies) - if do_prune and seen_movies else 0) - - # ── Shows ── - self._set(phase="scanning shows") + for item in source.iter_movies(incremental=incremental): + if self._cancel: + return self._finish_cancelled(movies, 0, 0) + sid = str(item["server_id"]) + # Incremental early-stop: skip already-known items and bail after + # a run of consecutive known ones (server lists recent first). + if incremental and sid in known_movies: + consec += 1 + if consec >= INCREMENTAL_STOP_AFTER: + break + continue + consec = 0 + try: + self.db.upsert_movie(server, item, preserve_enrichment=preserve) + except Exception: + logger.exception("video scan: skipping movie %s", sid) + continue + seen_movies.add(sid) + movies += 1 + 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. The prune + # runs AFTER the bar fills, and a big cleanup (many orphaned rows + + # cascades) takes a few seconds — surface a phase so the UI shows + # "cleaning up", not a stuck 100%. + if do_prune and seen_movies: + self._set(phase="cleaning up removed movies", percent=pct()) + removed_m = (self.db.prune_missing("movies", server, seen_movies) + if do_prune and seen_movies else 0) + + # ── Shows ── (skipped entirely on a movie-only scan) seen_shows: set[str] = set() shows = 0 episodes = 0 - consec = 0 - for show in source.iter_shows(incremental=incremental): - if self._cancel: - return self._finish_cancelled(movies, shows, episodes) - sid = str(show["server_id"]) - if incremental and sid in known_shows: - consec += 1 - if consec >= INCREMENTAL_STOP_AFTER: - break - continue + removed_s = 0 + if do_shows: + self._set(phase="scanning shows") consec = 0 - try: - self.db.upsert_show_tree(server, show, preserve_enrichment=preserve) - except Exception: - logger.exception("video scan: skipping show %s", sid) - continue - seen_shows.add(sid) - shows += 1 - episodes += sum(len(s.get("episodes", [])) for s in show.get("seasons", [])) - processed += 1 - self._set(shows=shows, episodes=episodes, percent=pct()) - # Final prune (the one that delays "done" on a deep scan) — show it. - if do_prune and seen_shows: - self._set(phase="cleaning up removed shows", percent=100) - removed_s = (self.db.prune_missing("shows", server, seen_shows) - if do_prune and seen_shows else 0) + for show in source.iter_shows(incremental=incremental): + if self._cancel: + return self._finish_cancelled(movies, shows, episodes) + sid = str(show["server_id"]) + if incremental and sid in known_shows: + consec += 1 + if consec >= INCREMENTAL_STOP_AFTER: + break + continue + consec = 0 + try: + self.db.upsert_show_tree(server, show, preserve_enrichment=preserve) + except Exception: + logger.exception("video scan: skipping show %s", sid) + continue + seen_shows.add(sid) + shows += 1 + episodes += sum(len(s.get("episodes", [])) for s in show.get("seasons", [])) + processed += 1 + self._set(shows=shows, episodes=episodes, percent=pct()) + # Final prune (the one that delays "done" on a deep scan) — show it. + if do_prune and seen_shows: + self._set(phase="cleaning up removed shows", percent=100) + 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, percent=100, diff --git a/tests/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index 2cb2f9d2..1cbf4253 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -55,6 +55,8 @@ EXPECTED_ACTION_NAMES = frozenset({ 'search_and_download', # Video side (isolated app, shared engine). 'video_scan_library', + 'video_deep_scan_movies', + 'video_deep_scan_tv', 'video_scan_server', 'video_update_database', 'video_add_airing_episodes', diff --git a/tests/automation/test_handler_video_scan_library.py b/tests/automation/test_handler_video_scan_library.py index c39065b0..f8d1555c 100644 --- a/tests/automation/test_handler_video_scan_library.py +++ b/tests/automation/test_handler_video_scan_library.py @@ -38,7 +38,7 @@ def _refresh_ok(sections: int = 2): def _scan_done(movies: int = 3, shows: int = 1, episodes: int = 9): - return lambda mode: {'state': 'done', 'movies': movies, 'shows': shows, 'episodes': episodes} + return lambda mode, media_type=None: {'state': 'done', 'movies': movies, 'shows': shows, 'episodes': episodes} class TestHappyPath: @@ -67,7 +67,7 @@ class TestHappyPath: def test_passes_configured_mode_through_to_scan(self): seen = {} - def _scan(mode): + def _scan(mode, media_type=None): seen['mode'] = mode return {'state': 'done'} @@ -81,7 +81,7 @@ class TestHappyPath: def test_defaults_mode_to_full(self): seen = {} - def _scan(mode): + def _scan(mode, media_type=None): seen['mode'] = mode return {'state': 'done'} @@ -93,13 +93,65 @@ class TestHappyPath: assert seen['mode'] == 'full' +class TestMediaTypeScope: + """The Movie and TV deep scans run the same handler scoped via media_type.""" + + def test_passes_media_type_through_to_scan(self): + seen = {} + + def _scan(mode, media_type=None): + seen['media_type'] = media_type + return {'state': 'done'} + + auto_video_scan_library( + {'_automation_id': 'a', 'mode': 'deep', 'media_type': 'show'}, _RecordingDeps(), + server_refresh=_refresh_ok(), run_video_scan=_scan) + assert seen['media_type'] == 'show' + + def test_defaults_media_type_to_all(self): + seen = {} + + def _scan(mode, media_type=None): + seen['media_type'] = media_type + return {'state': 'done'} + + auto_video_scan_library( + {'_automation_id': 'a'}, _RecordingDeps(), + server_refresh=_refresh_ok(), run_video_scan=_scan) + assert seen['media_type'] == 'all' + + def test_movie_scan_summary_names_only_movies(self): + deps = _RecordingDeps() + auto_video_scan_library( + {'_automation_id': 'a', 'media_type': 'movie'}, deps, + server_refresh=_refresh_ok(), run_video_scan=_scan_done(7, 0, 0)) + summary = deps.calls[-1].get('log_line', '') + assert 'Movie library scanned: 7 movies' == summary # no "0 shows" + + def test_tv_scan_summary_names_only_tv(self): + deps = _RecordingDeps() + auto_video_scan_library( + {'_automation_id': 'a', 'media_type': 'show'}, deps, + server_refresh=_refresh_ok(), run_video_scan=_scan_done(0, 4, 22)) + summary = deps.calls[-1].get('log_line', '') + assert summary == 'TV library scanned: 4 shows, 22 episodes' + + def test_busy_scanner_skips_cleanly(self): + # The singleton scanner reports another run in progress → skip, don't error. + res = auto_video_scan_library( + {'_automation_id': 'a', 'media_type': 'movie'}, _RecordingDeps(), + server_refresh=_refresh_ok(), + run_video_scan=lambda mode, media_type=None: {'state': 'in_progress'}) + assert res['status'] == 'skipped' + + class TestServerUnavailable: def test_warns_but_still_reads_library(self): """A server that can't be triggered is a warning, not a failure — the read still mirrors whatever the server currently reports.""" scanned = {} - def _scan(mode): + def _scan(mode, media_type=None): scanned['ran'] = True return {'state': 'done', 'movies': 5} @@ -128,7 +180,7 @@ class TestScanFailure: result = auto_video_scan_library( {'_automation_id': 'a'}, deps, server_refresh=_refresh_ok(), - run_video_scan=lambda mode: {'state': 'error', 'error': 'no connected server'}, + run_video_scan=lambda mode, media_type=None: {'state': 'error', 'error': 'no connected server'}, ) assert result['status'] == 'error' assert result['error'] == 'no connected server' @@ -139,7 +191,7 @@ class TestScanFailure: deps = _RecordingDeps() result = auto_video_scan_library( {'_automation_id': 'a'}, deps, - server_refresh=_refresh_ok(), run_video_scan=lambda mode: None, + server_refresh=_refresh_ok(), run_video_scan=lambda mode, media_type=None: None, ) # No state -> treated as a (zero-count) completion, never raises. assert result['status'] == 'completed' @@ -162,7 +214,7 @@ class TestHandlerNeverRaises: result = auto_video_scan_library( {'_automation_id': 'a'}, deps, server_refresh=_refresh_ok(), - run_video_scan=lambda mode: (_ for _ in ()).throw(ValueError('kaboom')), + run_video_scan=lambda mode, media_type=None: (_ for _ in ()).throw(ValueError('kaboom')), ) assert result['status'] == 'error' assert result['error'] == 'kaboom' @@ -223,7 +275,7 @@ class TestUpdateDatabaseStage: def test_defaults_to_incremental_mode(self): seen = {} - def _scan(mode): + def _scan(mode, media_type=None): seen['mode'] = mode return {'state': 'done'} diff --git a/tests/test_video_scanner.py b/tests/test_video_scanner.py index 3afb08c1..1b7680a5 100644 --- a/tests/test_video_scanner.py +++ b/tests/test_video_scanner.py @@ -266,3 +266,47 @@ def test_core_video_imports_nothing_from_music(): s = line.strip() if s.startswith("import ") or s.startswith("from "): assert "music" not in s.lower(), f"{py.name}: music import leaked: {s!r}" + + +# ── media_type scope: Movies and TV are independent libraries ─────────────── + +_MOVIES = [{"server_id": "m1", "title": "A", "file": {"relative_path": "a.mkv", "size_bytes": 5}}] +_SHOWS = [{"server_id": "s1", "title": "Show", "seasons": [ + {"season_number": 1, "episodes": [ + {"episode_number": 1, "title": "E1", "file": {"relative_path": "e1.mkv"}}]}]}] + + +def test_movie_media_type_scans_only_movies(db): + src = FakeSource(_MOVIES, _SHOWS) + st = VideoLibraryScanner(db).scan_sync(lambda: src, "full", "movie") + assert st["state"] == "done" + assert (st["movies"], st["shows"], st["episodes"]) == (1, 0, 0) + assert [k for k, _ in src.incremental_calls] == ["movies"] # shows iterator never touched + lib = db.dashboard_stats()["library"] + assert (lib["movies"], lib["shows"]) == (1, 0) + + +def test_tv_media_type_scans_only_shows(db): + # 'tv' is a friendly alias normalised to 'show' + src = FakeSource(_MOVIES, _SHOWS) + st = VideoLibraryScanner(db).scan_sync(lambda: src, "full", "tv") + assert (st["movies"], st["shows"], st["episodes"]) == (0, 1, 1) + assert [k for k, _ in src.incremental_calls] == ["shows"] # movies iterator never touched + lib = db.dashboard_stats()["library"] + assert (lib["movies"], lib["shows"]) == (0, 1) + + +def test_all_media_type_scans_both(db): + src = FakeSource(_MOVIES, _SHOWS) + st = VideoLibraryScanner(db).scan_sync(lambda: src, "full", "all") + assert (st["movies"], st["shows"], st["episodes"]) == (1, 1, 1) + assert {k for k, _ in src.incremental_calls} == {"movies", "shows"} + + +def test_concurrent_scan_reports_in_progress_without_running(db): + scanner = VideoLibraryScanner(db) + scanner._status = {"state": "scanning"} # a scan is already running + src = FakeSource(_MOVIES, _SHOWS) + st = scanner.scan_sync(lambda: src, "full", "movie") + assert st["state"] == "in_progress" + assert src.incremental_calls == [] # nothing scanned — didn't stomp the live run diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 472d5d8b..06f5bddd 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1773,6 +1773,9 @@ const _autoIcons = { clean_completed_downloads: '\u2705', full_cleanup: '\uD83E\uDDF9', playlist_pipeline: '\uD83D\uDE80', + // Video side + video_scan_library: '\uD83C\uDFAC', video_scan_server: '\uD83D\uDD04', video_update_database: '\uD83D\uDDC4\uFE0F', + video_add_airing_episodes: '\uD83D\uDCFA', video_deep_scan_movies: '\uD83C\uDFAC', video_deep_scan_tv: '\uD83D\uDCFA', }; // --- Inspiration Templates --- @@ -3362,7 +3365,11 @@ function _autoFormatAction(type) { refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History', clean_completed_downloads: 'Clean Completed Downloads', full_cleanup: 'Full Cleanup', - playlist_pipeline: 'Playlist Pipeline' + playlist_pipeline: 'Playlist Pipeline', + // Video side + video_scan_library: 'Scan Video Library', video_scan_server: 'Scan Video Server', + video_update_database: 'Update Video Database', video_add_airing_episodes: 'Wishlist Airing Episodes', + video_deep_scan_movies: 'Deep Scan Movie Library', video_deep_scan_tv: 'Deep Scan TV Library', }; return labels[type] || type || 'Unknown'; }