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",
"description": "Tell the media server to rescan your video sections, then fire 'Video Library Scan Done'", "available": True,
"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}
]},
{"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",
"options": [{"value": "incremental", "label": "Incremental (recent only)"},
{"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",
"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
def _default_server_refresh() -> Dict[str, Any]:
"""Production wiring: nudge the media server to rescan its video sections."""
def _default_server_refresh(media_type: str = "all") -> Dict[str, Any]:
"""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
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]:
@ -83,11 +84,11 @@ def auto_video_scan_library(
log_type='info',
)
# Step 1 — best-effort server nudge. A server that can't be triggered
# (none configured, or an adapter without refresh support) is surfaced
# as a warning, NOT a hard failure: the read below still mirrors whatever
# the server currently reports, so the automation stays useful.
refresh = server_refresh() or {}
# Step 1 — best-effort server nudge, scoped to the same library we're about
# to read. A server that can't be triggered (none configured, or an adapter
# without refresh support) is surfaced as a warning, NOT a hard failure: the
# read below still mirrors whatever the server currently reports.
refresh = server_refresh(media_type) or {}
if not refresh.get('ok'):
deps.update_progress(
automation_id,
@ -192,21 +193,25 @@ def auto_video_scan_server(
sleep = sleep or time.sleep
emit = emit or deps.engine.emit
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:
debounce = int(config.get('debounce_seconds') or 120)
except (TypeError, ValueError):
debounce = 120
try:
deps.update_progress(automation_id, phase='Asking media server to rescan', progress=20,
log_line='Triggering server-side video scan', log_type='info')
refresh = server_refresh() or {}
deps.update_progress(automation_id, phase=f'Asking media server to rescan {lib_label}', progress=20,
log_line=f'Triggering server-side {lib_label} scan', log_type='info')
refresh = server_refresh(media_type) or {}
if not refresh.get('ok'):
deps.update_progress(automation_id,
log_line='Server scan trigger unavailable: ' + str(refresh.get('error') or 'unknown'),
log_type='warning')
deps.update_progress(automation_id, phase=f'Waiting for the server to index ({debounce}s)…', progress=55)
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',
log_line='Server scan done — updating the database', log_type='success')
return {'status': 'completed', '_manages_own_progress': True}
@ -219,16 +224,30 @@ def auto_video_update_database(
config: Dict[str, Any],
deps: AutomationDeps,
*,
run_video_scan: Optional[Callable[[str], Dict[str, Any]]] = None,
run_video_scan: Optional[Callable[..., Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""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
automation_id = config.get('_automation_id')
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:
deps.update_progress(automation_id, phase='Reading new media into SoulSync…', progress=40)
result = run_video_scan(mode) or {}
deps.update_progress(automation_id, phase=f'Reading new {lib_label} media into SoulSync…', progress=40)
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':
err = result.get('error') or 'Video database update failed'
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)
shows = int(result.get('shows', 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',
log_line=f'Video database updated: {movies} movies, {shows} shows, {episodes} episodes',
log_type='success')
log_line=summary, log_type='success')
return {'status': 'completed', '_manages_own_progress': True,
'movies': movies, 'shows': shows, 'episodes': episodes}
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)
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
downloaded files get indexed) the video twin of music's 'Scan Library'. Returns
{ok, sections} or {ok: False, error}."""
downloaded files get indexed) the video twin of music's 'Scan Library'.
``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()
if src is None:
return {"ok": False, "error": "No video server configured"}
if not hasattr(src, "refresh_sections"):
return {"ok": False, "error": "This server doesn't support a scan trigger"}
try:
return src.refresh_sections()
return src.refresh_sections(media_type)
except Exception as e: # noqa: BLE001 - surface any server error to the automation
logger.exception("video sources: refresh failed")
return {"ok": False, "error": str(e)}
@ -346,11 +360,14 @@ class PlexVideoSource:
except Exception:
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
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
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):
try:
s.update()
@ -526,15 +543,20 @@ class JellyfinVideoSource:
"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)
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
base = (self._c.base_url or "").rstrip("/")
if not base:
return {"ok": False, "sections": 0}
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
for v in views:
vid = v.get("Id")

View file

@ -34,7 +34,7 @@ class _RecordingDeps:
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):
@ -158,7 +158,7 @@ class TestServerUnavailable:
deps = _RecordingDeps()
result = auto_video_scan_library(
{'_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,
)
assert scanned.get('ran') is True
@ -169,7 +169,7 @@ class TestServerUnavailable:
deps = _RecordingDeps()
result = auto_video_scan_library(
{'_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'
@ -203,7 +203,7 @@ class TestHandlerNeverRaises:
deps = _RecordingDeps()
result = auto_video_scan_library(
{'_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['error'] == 'boom'
@ -243,14 +243,14 @@ class TestScanServerStage:
emit=lambda ev, data: events.append((ev, data)))
assert r['status'] == 'completed'
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):
deps = _RecordingDeps()
events = []
auto_video_scan_server(
{'_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))
assert events == ['video_library_scan_completed'] # fires even if refresh failed
assert 'warning' in deps.log_types()
@ -259,10 +259,25 @@ class TestScanServerStage:
deps = _RecordingDeps()
r = auto_video_scan_server(
{'_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)
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) ───────────────────
class TestUpdateDatabaseStage:
@ -285,5 +300,33 @@ class TestUpdateDatabaseStage:
def test_scan_error_propagates(self):
deps = _RecordingDeps()
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'
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
engine.resume_after_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