Video: two system deep-scan automations (Movie + TV), independently scoped
Video twin of music's 'Auto-Deep Scan Library', split in two because Movies and TV
are separate libraries — scanning the TV library must not pull in new movies and
vice-versa.
- scanner: add a media_type param ('all'|'movie'|'show', friendly aliases) that
gates the movies vs shows passes (and their pruning), plus an in_progress busy
guard so the singleton scanner can't be stomped by an overlapping run.
- video_scan_library handler: thread media_type through, skip cleanly when the
scanner is busy, and name only the scanned library in the summary.
- two system automations (owned_by=video, weekly deep scan, staggered start delays):
'Auto-Deep Scan Movie Library' + 'Auto-Deep Scan TV Library'. Distinct action
types (video_deep_scan_movies / _tv) because the seeder keys on action_type; both
reuse the one handler, scoped via action_config.
- builder block gains a Library selector (Movies+TV / Movies / TV) so custom scans
can scope too; card label/icon maps cover the video action types.
Seam tests for scanner scope + busy guard, handler scope + skip, registration set.
This commit is contained in:
parent
36f944be25
commit
b6320d1a30
9 changed files with 297 additions and 95 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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") -> dict:
|
||||
"""Run a scan inline (used by tests / callers that want to block)."""
|
||||
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).
|
||||
|
||||
``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)
|
||||
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 ──
|
||||
self._set(phase="scanning shows")
|
||||
# ── 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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue