Video scan family: make every action movie/TV-aware + deep scans are real actions

The deep-scan action types weren't selectable builder actions, and Scan Video Server
/ Update Video Database had no movie-vs-TV dimension — inconsistent with the rest.

- video_deep_scan_tv / video_deep_scan_movies are now proper builder blocks
  (Deep Scan TV/Movie Library), not just system-automation action types.
- video_scan_server + video_update_database gain a media_type ('all'|'movie'|'show')
  config + selector, threaded through. The post-download chain carries the scope on
  the scan-done event, so a TV-only rescan updates only TV (stage 2 inherits it).
- refresh_video_server_sections / Plex+Jellyfin refresh_sections scope the server
  nudge to the chosen library; auto_video_scan_library now nudges only its library.
- shared normalize_media_type() in sources; update_database skips cleanly when the
  singleton scanner is busy. Defaults stay 'all' so existing chains are unchanged.

Seam tests for refresh scoping, scan-server scope+event, update-db scope/inherit/skip.
This commit is contained in:
BoulderBadgeDad 2026-06-21 13:04:39 -07:00
parent cf47032660
commit 14a32f6006
5 changed files with 167 additions and 36 deletions

View file

@ -230,6 +230,11 @@ ACTIONS: list[dict] = [
{"type": "video_scan_server", "label": "Scan Video Server", "icon": "refresh", "scope": "video", {"type": "video_scan_server", "label": "Scan Video Server", "icon": "refresh", "scope": "video",
"description": "Tell the media server to rescan your video sections, then fire 'Video Library Scan Done'", "available": True, "description": "Tell the media server to rescan your video sections, then fire 'Video Library Scan Done'", "available": True,
"config_fields": [ "config_fields": [
{"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"},
{"key": "debounce_seconds", "type": "number", "label": "Wait for indexing (sec)", "default": 120, "min": 10} {"key": "debounce_seconds", "type": "number", "label": "Wait for indexing (sec)", "default": 120, "min": 10}
]}, ]},
{"type": "video_update_database", "label": "Update Video Database", "icon": "database", "scope": "video", {"type": "video_update_database", "label": "Update Video Database", "icon": "database", "scope": "video",
@ -238,8 +243,20 @@ ACTIONS: list[dict] = [
{"key": "mode", "type": "select", "label": "Mode", {"key": "mode", "type": "select", "label": "Mode",
"options": [{"value": "incremental", "label": "Incremental (recent only)"}, "options": [{"value": "incremental", "label": "Incremental (recent only)"},
{"value": "full", "label": "Full (add + refresh)"}], {"value": "full", "label": "Full (add + refresh)"}],
"default": "incremental"} "default": "incremental"},
{"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"}
]}, ]},
# Per-library deep-scan presets (the system 'Auto-Deep Scan TV/Movie Library' run
# these). Scope + deep mode are baked in by the registration wrapper, so no config
# fields — drag one in and it just deep-scans that library.
{"type": "video_deep_scan_tv", "label": "Deep Scan TV Library", "icon": "search", "scope": "video",
"description": "Deep-rescan the TV library: re-read every show and prune ones the server no longer has (never touches movies)", "available": True},
{"type": "video_deep_scan_movies", "label": "Deep Scan Movie Library", "icon": "search", "scope": "video",
"description": "Deep-rescan the Movie library: re-read every movie and prune ones the server no longer has (never touches TV)", "available": True},
{"type": "video_add_airing_episodes", "label": "Wishlist Today's Airings", "icon": "calendar", "scope": "video", {"type": "video_add_airing_episodes", "label": "Wishlist Today's Airings", "icon": "calendar", "scope": "video",
"description": "Sonarr-style: add every episode airing today (for shows you follow) to the wishlist, skipping ones you already own", "available": True}, "description": "Sonarr-style: add every episode airing today (for shows you follow) to the wishlist, skipping ones you already own", "available": True},
] ]

View file

@ -31,10 +31,11 @@ from typing import Any, Callable, Dict, Optional
from core.automation.deps import AutomationDeps from core.automation.deps import AutomationDeps
def _default_server_refresh() -> Dict[str, Any]: def _default_server_refresh(media_type: str = "all") -> Dict[str, Any]:
"""Production wiring: nudge the media server to rescan its video sections.""" """Production wiring: nudge the media server to rescan its video sections.
``media_type`` scopes it to the Movie or TV section; 'all' nudges both."""
from core.video.sources import refresh_video_server_sections from core.video.sources import refresh_video_server_sections
return refresh_video_server_sections() return refresh_video_server_sections(media_type)
def _default_run_video_scan(mode: str, media_type: str = "all") -> Dict[str, Any]: def _default_run_video_scan(mode: str, media_type: str = "all") -> Dict[str, Any]:
@ -83,11 +84,11 @@ def auto_video_scan_library(
log_type='info', log_type='info',
) )
# Step 1 — best-effort server nudge. A server that can't be triggered # Step 1 — best-effort server nudge, scoped to the same library we're about
# (none configured, or an adapter without refresh support) is surfaced # to read. A server that can't be triggered (none configured, or an adapter
# as a warning, NOT a hard failure: the read below still mirrors whatever # without refresh support) is surfaced as a warning, NOT a hard failure: the
# the server currently reports, so the automation stays useful. # read below still mirrors whatever the server currently reports.
refresh = server_refresh() or {} refresh = server_refresh(media_type) or {}
if not refresh.get('ok'): if not refresh.get('ok'):
deps.update_progress( deps.update_progress(
automation_id, automation_id,
@ -192,21 +193,25 @@ def auto_video_scan_server(
sleep = sleep or time.sleep sleep = sleep or time.sleep
emit = emit or deps.engine.emit emit = emit or deps.engine.emit
automation_id = config.get('_automation_id') automation_id = config.get('_automation_id')
media_type = config.get('media_type') or 'all'
lib_label = {'movie': 'Movie', 'show': 'TV'}.get(media_type, 'video')
try: try:
debounce = int(config.get('debounce_seconds') or 120) debounce = int(config.get('debounce_seconds') or 120)
except (TypeError, ValueError): except (TypeError, ValueError):
debounce = 120 debounce = 120
try: try:
deps.update_progress(automation_id, phase='Asking media server to rescan', progress=20, deps.update_progress(automation_id, phase=f'Asking media server to rescan {lib_label}', progress=20,
log_line='Triggering server-side video scan', log_type='info') log_line=f'Triggering server-side {lib_label} scan', log_type='info')
refresh = server_refresh() or {} refresh = server_refresh(media_type) or {}
if not refresh.get('ok'): if not refresh.get('ok'):
deps.update_progress(automation_id, deps.update_progress(automation_id,
log_line='Server scan trigger unavailable: ' + str(refresh.get('error') or 'unknown'), log_line='Server scan trigger unavailable: ' + str(refresh.get('error') or 'unknown'),
log_type='warning') log_type='warning')
deps.update_progress(automation_id, phase=f'Waiting for the server to index ({debounce}s)…', progress=55) deps.update_progress(automation_id, phase=f'Waiting for the server to index ({debounce}s)…', progress=55)
sleep(debounce) sleep(debounce)
emit('video_library_scan_completed', {'server': refresh.get('server') or ''}) # Carry the scope so stage 2 (Update DB) reads only the library we rescanned.
emit('video_library_scan_completed',
{'server': refresh.get('server') or '', 'media_type': media_type})
deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', deps.update_progress(automation_id, status='finished', progress=100, phase='Complete',
log_line='Server scan done — updating the database', log_type='success') log_line='Server scan done — updating the database', log_type='success')
return {'status': 'completed', '_manages_own_progress': True} return {'status': 'completed', '_manages_own_progress': True}
@ -219,16 +224,30 @@ def auto_video_update_database(
config: Dict[str, Any], config: Dict[str, Any],
deps: AutomationDeps, deps: AutomationDeps,
*, *,
run_video_scan: Optional[Callable[[str], Dict[str, Any]]] = None, run_video_scan: Optional[Callable[..., Dict[str, Any]]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Stage 2: read the (now-rescanned) server into video.db — INCREMENTAL by default """Stage 2: read the (now-rescanned) server into video.db — INCREMENTAL by default
(newest-first, stop after N consecutive known). Video twin of start_database_update.""" (newest-first, stop after N consecutive known). Video twin of start_database_update.
``media_type`` scopes it to the Movie or TV library ('all' does both). When fired by
the post-download chain it inherits the scope of the scan that ran (carried on the
event), so a TV-only rescan updates only TV."""
run_video_scan = run_video_scan or _default_run_video_scan run_video_scan = run_video_scan or _default_run_video_scan
automation_id = config.get('_automation_id') automation_id = config.get('_automation_id')
mode = config.get('mode') or 'incremental' mode = config.get('mode') or 'incremental'
media_type = (config.get('media_type')
or (config.get('_event_data') or {}).get('media_type')
or 'all')
lib_label = {'movie': 'Movie', 'show': 'TV'}.get(media_type, 'video')
try: try:
deps.update_progress(automation_id, phase='Reading new media into SoulSync…', progress=40) deps.update_progress(automation_id, phase=f'Reading new {lib_label} media into SoulSync…', progress=40)
result = run_video_scan(mode) or {} result = run_video_scan(mode, media_type) or {}
if result.get('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 result.get('state') == 'error': if result.get('state') == 'error':
err = result.get('error') or 'Video database update failed' err = result.get('error') or 'Video database update failed'
deps.update_progress(automation_id, status='error', phase='Error', log_line=err, log_type='error') deps.update_progress(automation_id, status='error', phase='Error', log_line=err, log_type='error')
@ -236,9 +255,14 @@ def auto_video_update_database(
movies = int(result.get('movies', 0) or 0) movies = int(result.get('movies', 0) or 0)
shows = int(result.get('shows', 0) or 0) shows = int(result.get('shows', 0) or 0)
episodes = int(result.get('episodes', 0) or 0) episodes = int(result.get('episodes', 0) or 0)
if media_type == 'movie':
summary = f'Video database updated: {movies} movies'
elif media_type == 'show':
summary = f'Video database updated: {shows} shows, {episodes} episodes'
else:
summary = f'Video database updated: {movies} movies, {shows} shows, {episodes} episodes'
deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', deps.update_progress(automation_id, status='finished', progress=100, phase='Complete',
log_line=f'Video database updated: {movies} movies, {shows} shows, {episodes} episodes', log_line=summary, log_type='success')
log_type='success')
return {'status': 'completed', '_manages_own_progress': True, return {'status': 'completed', '_manages_own_progress': True,
'movies': movies, 'shows': shows, 'episodes': episodes} 'movies': movies, 'shows': shows, 'episodes': episodes}
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001

View file

@ -266,17 +266,31 @@ def get_active_video_source():
return _build_source(sel.get("movies") or None, sel.get("tv") or None) return _build_source(sel.get("movies") or None, sel.get("tv") or None)
def refresh_video_server_sections(): def normalize_media_type(media_type) -> str:
"""'movie'|'show'|'all' — accepts the friendly aliases (movies/tv/series/…) the
UI and automation configs use. Movies and TV are independent libraries, so the
scan family is scoped by this everywhere."""
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 refresh_video_server_sections(media_type="all"):
"""Tell the active media server to rescan its selected VIDEO sections (so newly """Tell the active media server to rescan its selected VIDEO sections (so newly
downloaded files get indexed) the video twin of music's 'Scan Library'. Returns downloaded files get indexed) the video twin of music's 'Scan Library'.
{ok, sections} or {ok: False, error}.""" ``media_type`` scopes it to one library ('movie' / 'show'); 'all' (default) nudges
both. Returns {ok, sections} or {ok: False, error}."""
media_type = normalize_media_type(media_type)
src = get_active_video_source() src = get_active_video_source()
if src is None: if src is None:
return {"ok": False, "error": "No video server configured"} return {"ok": False, "error": "No video server configured"}
if not hasattr(src, "refresh_sections"): if not hasattr(src, "refresh_sections"):
return {"ok": False, "error": "This server doesn't support a scan trigger"} return {"ok": False, "error": "This server doesn't support a scan trigger"}
try: try:
return src.refresh_sections() return src.refresh_sections(media_type)
except Exception as e: # noqa: BLE001 - surface any server error to the automation except Exception as e: # noqa: BLE001 - surface any server error to the automation
logger.exception("video sources: refresh failed") logger.exception("video sources: refresh failed")
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
@ -346,11 +360,14 @@ class PlexVideoSource:
except Exception: except Exception:
logger.exception("Plex: skipping show %s", getattr(sh, "title", "?")) logger.exception("Plex: skipping show %s", getattr(sh, "title", "?"))
def refresh_sections(self) -> dict: def refresh_sections(self, media_type="all") -> dict:
"""Tell Plex to rescan the selected video sections so freshly-downloaded files """Tell Plex to rescan the selected video sections so freshly-downloaded files
get indexed. (plexapi ``section.update()`` triggers the library scan.)""" get indexed. (plexapi ``section.update()`` triggers the library scan.)
``media_type`` scopes it to the Movie or TV section; 'all' does both."""
n = 0 n = 0
for kind, name in (("movie", self._movies_lib), ("show", self._tv_lib)): for kind, name in (("movie", self._movies_lib), ("show", self._tv_lib)):
if media_type != "all" and media_type != kind:
continue
for s in self._scan_sections(kind, name): for s in self._scan_sections(kind, name):
try: try:
s.update() s.update()
@ -526,15 +543,20 @@ class JellyfinVideoSource:
"tv": [{"title": v.get("Name")} for v in self._views("tvshows")], "tv": [{"title": v.get("Name")} for v in self._views("tvshows")],
} }
def refresh_sections(self) -> dict: def refresh_sections(self, media_type="all") -> dict:
"""Ask Jellyfin to rescan the selected video libraries (POST /Items/{id}/Refresh) """Ask Jellyfin to rescan the selected video libraries (POST /Items/{id}/Refresh)
so freshly-downloaded files get indexed. _make_request is GET-only, so POST direct.""" so freshly-downloaded files get indexed. _make_request is GET-only, so POST direct.
``media_type`` scopes it to the Movie or TV view; 'all' does both."""
import requests import requests
base = (self._c.base_url or "").rstrip("/") base = (self._c.base_url or "").rstrip("/")
if not base: if not base:
return {"ok": False, "sections": 0} return {"ok": False, "sections": 0}
headers = {"X-Emby-Token": self._c.api_key or ""} headers = {"X-Emby-Token": self._c.api_key or ""}
views = list(self._scan_views("movies", self._movies_lib)) + list(self._scan_views("tvshows", self._tv_lib)) views = []
if media_type in ("all", "movie"):
views += list(self._scan_views("movies", self._movies_lib))
if media_type in ("all", "show"):
views += list(self._scan_views("tvshows", self._tv_lib))
n = 0 n = 0
for v in views: for v in views:
vid = v.get("Id") vid = v.get("Id")

View file

@ -34,7 +34,7 @@ class _RecordingDeps:
def _refresh_ok(sections: int = 2): def _refresh_ok(sections: int = 2):
return lambda: {'ok': True, 'sections': sections} return lambda media_type=None: {'ok': True, 'sections': sections}
def _scan_done(movies: int = 3, shows: int = 1, episodes: int = 9): def _scan_done(movies: int = 3, shows: int = 1, episodes: int = 9):
@ -158,7 +158,7 @@ class TestServerUnavailable:
deps = _RecordingDeps() deps = _RecordingDeps()
result = auto_video_scan_library( result = auto_video_scan_library(
{'_automation_id': 'a'}, deps, {'_automation_id': 'a'}, deps,
server_refresh=lambda: {'ok': False, 'error': 'No video server configured'}, server_refresh=lambda media_type=None: {'ok': False, 'error': 'No video server configured'},
run_video_scan=_scan, run_video_scan=_scan,
) )
assert scanned.get('ran') is True assert scanned.get('ran') is True
@ -169,7 +169,7 @@ class TestServerUnavailable:
deps = _RecordingDeps() deps = _RecordingDeps()
result = auto_video_scan_library( result = auto_video_scan_library(
{'_automation_id': 'a'}, deps, {'_automation_id': 'a'}, deps,
server_refresh=lambda: None, run_video_scan=_scan_done(), server_refresh=lambda media_type=None: None, run_video_scan=_scan_done(),
) )
assert result['status'] == 'completed' assert result['status'] == 'completed'
@ -203,7 +203,7 @@ class TestHandlerNeverRaises:
deps = _RecordingDeps() deps = _RecordingDeps()
result = auto_video_scan_library( result = auto_video_scan_library(
{'_automation_id': 'a'}, deps, {'_automation_id': 'a'}, deps,
server_refresh=lambda: (_ for _ in ()).throw(RuntimeError('boom')), server_refresh=lambda media_type=None: (_ for _ in ()).throw(RuntimeError('boom')),
) )
assert result['status'] == 'error' assert result['status'] == 'error'
assert result['error'] == 'boom' assert result['error'] == 'boom'
@ -243,14 +243,14 @@ class TestScanServerStage:
emit=lambda ev, data: events.append((ev, data))) emit=lambda ev, data: events.append((ev, data)))
assert r['status'] == 'completed' assert r['status'] == 'completed'
assert slept == [90] # waited the debounce assert slept == [90] # waited the debounce
assert events == [('video_library_scan_completed', {'server': ''})] assert events == [('video_library_scan_completed', {'server': '', 'media_type': 'all'})]
def test_default_debounce_and_server_unavailable_still_emits(self): def test_default_debounce_and_server_unavailable_still_emits(self):
deps = _RecordingDeps() deps = _RecordingDeps()
events = [] events = []
auto_video_scan_server( auto_video_scan_server(
{'_automation_id': 'a'}, deps, {'_automation_id': 'a'}, deps,
server_refresh=lambda: {'ok': False, 'error': 'no server'}, server_refresh=lambda media_type=None: {'ok': False, 'error': 'no server'},
sleep=lambda s: None, emit=lambda ev, data: events.append(ev)) sleep=lambda s: None, emit=lambda ev, data: events.append(ev))
assert events == ['video_library_scan_completed'] # fires even if refresh failed assert events == ['video_library_scan_completed'] # fires even if refresh failed
assert 'warning' in deps.log_types() assert 'warning' in deps.log_types()
@ -259,10 +259,25 @@ class TestScanServerStage:
deps = _RecordingDeps() deps = _RecordingDeps()
r = auto_video_scan_server( r = auto_video_scan_server(
{'_automation_id': 'a'}, deps, {'_automation_id': 'a'}, deps,
server_refresh=lambda: (_ for _ in ()).throw(RuntimeError('boom')), server_refresh=lambda media_type=None: (_ for _ in ()).throw(RuntimeError('boom')),
sleep=lambda s: None, emit=lambda *a: None) sleep=lambda s: None, emit=lambda *a: None)
assert r['status'] == 'error' and r['error'] == 'boom' assert r['status'] == 'error' and r['error'] == 'boom'
def test_scopes_refresh_and_carries_media_type_on_the_event(self):
seen = {}
events = []
def _refresh(media_type=None):
seen['mt'] = media_type
return {'ok': True}
auto_video_scan_server(
{'_automation_id': 'a', 'media_type': 'show'}, _RecordingDeps(),
server_refresh=_refresh, sleep=lambda s: None,
emit=lambda ev, data: events.append((ev, data)))
assert seen['mt'] == 'show' # only TV sections nudged
assert events[0][1]['media_type'] == 'show' # stage 2 inherits the scope
# ── post-download chain: video_update_database (stage 2) ─────────────────── # ── post-download chain: video_update_database (stage 2) ───────────────────
class TestUpdateDatabaseStage: class TestUpdateDatabaseStage:
@ -285,5 +300,33 @@ class TestUpdateDatabaseStage:
def test_scan_error_propagates(self): def test_scan_error_propagates(self):
deps = _RecordingDeps() deps = _RecordingDeps()
r = auto_video_update_database({'_automation_id': 'a'}, deps, r = auto_video_update_database({'_automation_id': 'a'}, deps,
run_video_scan=lambda m: {'state': 'error', 'error': 'no server'}) run_video_scan=lambda m, media_type=None: {'state': 'error', 'error': 'no server'})
assert r['status'] == 'error' and r['error'] == 'no server' assert r['status'] == 'error' and r['error'] == 'no server'
def test_inherits_media_type_from_the_scan_event(self):
# The post-download chain carries the scope: a TV-only rescan updates only TV.
seen = {}
def _scan(mode, media_type=None):
seen['media_type'] = media_type
return {'state': 'done', 'shows': 3, 'episodes': 12}
deps = _RecordingDeps()
auto_video_update_database(
{'_automation_id': 'a', '_event_data': {'media_type': 'show'}}, deps, run_video_scan=_scan)
assert seen['media_type'] == 'show'
assert deps.calls[-1]['log_line'] == 'Video database updated: 3 shows, 12 episodes'
def test_explicit_media_type_beats_event(self):
seen = {}
auto_video_update_database(
{'_automation_id': 'a', 'media_type': 'movie', '_event_data': {'media_type': 'show'}},
_RecordingDeps(),
run_video_scan=lambda mode, media_type=None: seen.setdefault('mt', media_type) or {'state': 'done'})
assert seen['mt'] == 'movie'
def test_busy_scanner_skips_cleanly(self):
r = auto_video_update_database(
{'_automation_id': 'a'}, _RecordingDeps(),
run_video_scan=lambda mode, media_type=None: {'state': 'in_progress'})
assert r['status'] == 'skipped'

View file

@ -101,3 +101,28 @@ def test_scan_never_resumes_a_manually_paused_youtube_enricher(engine, monkeypat
assert "youtube" not in engine._scan_paused # we didn't touch it assert "youtube" not in engine._scan_paused # we didn't touch it
engine.resume_after_scan() engine.resume_after_scan()
assert fake._paused is True # still paused after the scan assert fake._paused is True # still paused after the scan
# ── refresh_sections is scoped by media_type too (server nudge) ─────────────
def test_refresh_sections_scopes_by_media_type():
class SecU:
def __init__(self, type_, title):
self.type, self.title, self.updated = type_, title, False
def update(self):
self.updated = True
secs = [SecU("movie", "Movies"), SecU("show", "TV Shows")]
src = PlexVideoSource(_Server(secs), movies_lib="Movies", tv_lib="TV Shows")
src.refresh_sections("movie")
assert secs[0].updated and not secs[1].updated # only the Movie section nudged
secs[0].updated = False
src.refresh_sections("show")
assert secs[1].updated and not secs[0].updated # only the TV section nudged
secs[1].updated = False
res = src.refresh_sections("all")
assert secs[0].updated and secs[1].updated and res["sections"] == 2 # both