diff --git a/api/video/__init__.py b/api/video/__init__.py index 76346c10..4b042dd4 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -51,6 +51,7 @@ def create_video_blueprint() -> Blueprint: from .wishlist import register_routes as reg_wishlist from .youtube import register_routes as reg_youtube from .downloads import register_routes as reg_downloads + from .automations import register_routes as reg_automations reg_dashboard(bp) reg_scan(bp) reg_library(bp) @@ -65,5 +66,6 @@ def create_video_blueprint() -> Blueprint: reg_wishlist(bp) reg_youtube(bp) reg_downloads(bp) + reg_automations(bp) return bp diff --git a/api/video/automations.py b/api/video/automations.py new file mode 100644 index 00000000..a06d6ae5 --- /dev/null +++ b/api/video/automations.py @@ -0,0 +1,28 @@ +"""Video automation builder support (isolated /api/video routes). + +The automation ENGINE is app-wide (shared with the music side), but the +builder block palette is scoped: the video builder must only ever offer +video + generic blocks, never music-only ones. This endpoint returns that +scoped slice via ``core.automation.blocks.blocks_for_scope('video')``. + +ISOLATION: imports only the shared automation block definitions (which +themselves import nothing music-specific) — no music API / music DB. +Reading/running/toggling video automations goes through the shared +``/api/automations`` endpoints (owned_by='video' rows); only the scoped +block palette lives here. +""" + +from __future__ import annotations + +from flask import jsonify + +from utils.logging_config import get_logger + +logger = get_logger("video_api.automations") + + +def register_routes(bp): + @bp.route("/automations/blocks", methods=["GET"]) + def video_automation_blocks(): + from core.automation.blocks import blocks_for_scope + return jsonify(blocks_for_scope("video")) diff --git a/core/automation/blocks.py b/core/automation/blocks.py index ecd88cd4..de106ddb 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -10,30 +10,37 @@ Three top-level lists: - `ACTIONS` — DO blocks: process_wishlist, scan_library, etc. - `NOTIFICATIONS` — THEN blocks: discord/pushbullet/telegram/webhook, plus fire_signal and run_script then-actions. + +Each block carries an optional ``scope`` tag so the SAME definitions can +feed both the music and the (isolated) video automation builders: + - ``"both"`` — generic; shown on both sides (schedule, notifications, …). + - ``"video"`` — video-only; shown only on the video builder. + - absent — treated as music-only (the default); never shown on video. +Use :func:`blocks_for_scope` to get the filtered lists for one side. """ from __future__ import annotations TRIGGERS: list[dict] = [ - {"type": "schedule", "label": "Schedule", "icon": "clock", "description": "Run on a timer interval", "available": True, + {"type": "schedule", "label": "Schedule", "icon": "clock", "scope": "both", "description": "Run on a timer interval", "available": True, "config_fields": [ {"key": "interval", "type": "number", "label": "Every", "default": 6, "min": 1}, {"key": "unit", "type": "select", "label": "Unit", "options": [{"value": "minutes", "label": "Minutes"}, {"value": "hours", "label": "Hours"}, {"value": "days", "label": "Days"}], "default": "hours"} ]}, - {"type": "daily_time", "label": "Daily Time", "icon": "clock", "description": "Run every day at a specific time", "available": True, + {"type": "daily_time", "label": "Daily Time", "icon": "clock", "scope": "both", "description": "Run every day at a specific time", "available": True, "config_fields": [ {"key": "time", "type": "time", "label": "At", "default": "03:00"} ]}, - {"type": "weekly_time", "label": "Weekly Schedule", "icon": "calendar", "description": "Run on specific days of the week at a set time", "available": True, + {"type": "weekly_time", "label": "Weekly Schedule", "icon": "calendar", "scope": "both", "description": "Run on specific days of the week at a set time", "available": True, "config_fields": [ {"key": "time", "type": "time", "label": "At", "default": "03:00"}, {"key": "days", "type": "multi_select", "label": "Days", "options": [{"value": "mon", "label": "Mon"}, {"value": "tue", "label": "Tue"}, {"value": "wed", "label": "Wed"}, {"value": "thu", "label": "Thu"}, {"value": "fri", "label": "Fri"}, {"value": "sat", "label": "Sat"}, {"value": "sun", "label": "Sun"}]} ]}, - {"type": "app_started", "label": "App Started", "icon": "power", "description": "When SoulSync starts up", "available": True}, + {"type": "app_started", "label": "App Started", "icon": "power", "scope": "both", "description": "When SoulSync starts up", "available": True}, {"type": "track_downloaded", "label": "Track Downloaded", "icon": "download", "description": "When a track finishes downloading", "available": True, "has_conditions": True, "condition_fields": ["artist", "title", "album", "quality"], @@ -106,14 +113,14 @@ TRIGGERS: list[dict] = [ "description": "When duplicate cleaner finishes", "available": True, "variables": ["files_scanned", "duplicates_found", "space_freed"]}, # Signal trigger - {"type": "signal_received", "label": "Signal Received", "icon": "zap", + {"type": "signal_received", "label": "Signal Received", "icon": "zap", "scope": "both", "description": "When another automation fires a named signal", "available": True, "config_fields": [ {"key": "signal_name", "type": "signal_input", "label": "Signal Name"} ], "variables": ["signal_name"]}, # Webhook trigger - {"type": "webhook_received", "label": "Webhook Received", "icon": "globe", + {"type": "webhook_received", "label": "Webhook Received", "icon": "globe", "scope": "both", "description": "When an external API request is received (POST /api/v1/request)", "available": True, "variables": ["query", "request_id", "source"]}, ] @@ -155,7 +162,7 @@ ACTIONS: list[dict] = [ {"key": "refresh_first", "type": "checkbox", "label": "Refresh playlists before sync (regenerate snapshots)", "default": False}, {"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False}, ]}, - {"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True}, + {"type": "notify_only", "label": "Notify Only", "icon": "bell", "scope": "both", "description": "No action — just send notification", "available": True}, # Phase 3 actions {"type": "start_database_update", "label": "Update Database", "icon": "database", "description": "Trigger library database refresh", "available": True, @@ -184,7 +191,7 @@ ACTIONS: list[dict] = [ "description": "Clear quarantine, download queue, import folder, and search history in one sweep", "available": True}, {"type": "deep_scan_library", "label": "Deep Scan Library", "icon": "search", "description": "Full library comparison without losing enrichment data", "available": True}, - {"type": "run_script", "label": "Run Script", "icon": "terminal", + {"type": "run_script", "label": "Run Script", "icon": "terminal", "scope": "both", "description": "Execute a script from the scripts folder", "available": True}, {"type": "search_and_download", "label": "Search & Download", "icon": "download", "description": "Search for a track and download the best match", "available": True, @@ -192,28 +199,62 @@ ACTIONS: list[dict] = [ {"key": "query", "type": "text", "label": "Search Query", "placeholder": "Artist - Track (leave empty to use trigger's query)"} ]}, + + # ── Video side (isolated app, shared engine) ────────────────────────── + # Tagged scope='video' so they appear ONLY on the video automation + # builder, never the music one. Their handlers bridge into core.video. + {"type": "video_scan_library", "label": "Scan Video Library", "icon": "refresh", "scope": "video", + "description": "Tell the media server to rescan your selected movie/TV sections, then read what it found into SoulSync", "available": True, + "config_fields": [ + {"key": "mode", "type": "select", "label": "Mode", + "options": [{"value": "full", "label": "Full (add + refresh)"}, + {"value": "incremental", "label": "Incremental (recent only)"}, + {"value": "deep", "label": "Deep (also remove missing)"}], + "default": "full"} + ]}, ] NOTIFICATIONS: list[dict] = [ - {"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True, + {"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "scope": "both", "description": "Send a Discord notification", "available": True, "variables": ["time", "name", "run_count", "status"]}, - {"type": "pushbullet", "label": "Pushbullet", "icon": "push", "description": "Push notification to phone/desktop", "available": True, + {"type": "pushbullet", "label": "Pushbullet", "icon": "push", "scope": "both", "description": "Push notification to phone/desktop", "available": True, "variables": ["time", "name", "run_count", "status"]}, - {"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True, + {"type": "telegram", "label": "Telegram", "icon": "message", "scope": "both", "description": "Send a Telegram message", "available": True, "variables": ["time", "name", "run_count", "status"]}, - {"type": "webhook", "label": "Webhook (POST)", "icon": "globe", "description": "Send a POST request to any URL", "available": True, + {"type": "webhook", "label": "Webhook (POST)", "icon": "globe", "scope": "both", "description": "Send a POST request to any URL", "available": True, "variables": ["time", "name", "run_count", "status"]}, # Signal fire action - {"type": "fire_signal", "label": "Fire Signal", "icon": "zap", + {"type": "fire_signal", "label": "Fire Signal", "icon": "zap", "scope": "both", "description": "Fire a signal that other automations can listen for", "available": True, "config_fields": [ {"key": "signal_name", "type": "signal_input", "label": "Signal Name"} ]}, # Run script then-action - {"type": "run_script", "label": "Run Script", "icon": "terminal", + {"type": "run_script", "label": "Run Script", "icon": "terminal", "scope": "both", "description": "Execute a script after the action completes", "available": True, "config_fields": [ {"key": "script_name", "type": "script_select", "label": "Script"} ]}, ] + + +def _in_scope(block: dict, scope: str) -> bool: + """A block belongs to ``scope`` if it's generic (``"both"``) or tagged for + that side. Untagged blocks default to ``"music"`` (the original behaviour), + so the video builder never picks up music-only blocks by accident.""" + s = block.get("scope", "music") + return s == "both" or s == scope + + +def blocks_for_scope(scope: str = "music") -> dict: + """Return the trigger/action/notification lists filtered to one side. + + ``scope="music"`` reproduces the pre-scope behaviour (everything except + video-only blocks); ``scope="video"`` returns generic + video-only blocks. + """ + return { + "triggers": [b for b in TRIGGERS if _in_scope(b, scope)], + "actions": [b for b in ACTIONS if _in_scope(b, scope)], + "notifications": [b for b in NOTIFICATIONS if _in_scope(b, scope)], + } diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 3afbe4df..65c641f9 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -35,6 +35,7 @@ from core.automation.handlers.download_cleanup import ( ) from core.automation.handlers.run_script import auto_run_script from core.automation.handlers.search_and_download import auto_search_and_download +from core.automation.handlers.video_scan_library import auto_video_scan_library from core.automation.handlers.progress_callbacks import ( progress_init, progress_finish, @@ -167,6 +168,14 @@ def register_all(deps: AutomationDeps) -> None: lambda config: auto_search_and_download(config, deps), ) + # Video side (isolated app, shared engine). The video twins are tagged + # owned_by='video' on their automation rows so they never surface on the + # music automations page; the handlers bridge into core.video. + engine.register_action_handler( + 'video_scan_library', + lambda config: auto_video_scan_library(config, deps), + ) + # Progress + history callbacks: the engine invokes these around # each handler run. Lift the closures from # `web_server._register_automation_handlers` into thin lambdas diff --git a/core/automation/handlers/video_scan_library.py b/core/automation/handlers/video_scan_library.py new file mode 100644 index 00000000..fe9ccde9 --- /dev/null +++ b/core/automation/handlers/video_scan_library.py @@ -0,0 +1,145 @@ +"""Automation handler: ``video_scan_library`` action. + +The VIDEO twin of music's ``scan_library``. It does two things, in order: + + 1. Tells the active media server (Plex/Jellyfin) to rescan ONLY the + user's selected VIDEO sections (the movie + TV libraries chosen in + Settings) — never the music library. + 2. Reads the server's current state into ``video.db`` so freshly-added + media shows up as owned on the video side. + +Both run through injected seams (``server_refresh`` / ``run_video_scan``) +so the handler stays a pure function: production lazily binds the real +``core.video`` functions; tests pass fakes and never spin up Flask, a DB, +or a media-server client. + +ISOLATION NOTE: this handler lives on the SHARED automation side, so it +is allowed to import ``core.video`` — the isolation contract only forbids +``core/video`` and ``api/video`` from importing the MUSIC side, not the +other way round. Video core stays import-clean; the bridge lives here. + +Like ``scan_library``, the handler owns its own progress reporting +(``_manages_own_progress: True``) so the engine doesn't stomp the live +phase string with a generic 'completed' label. +""" + +from __future__ import annotations + +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.""" + from core.video.sources import refresh_video_server_sections + return refresh_video_server_sections() + + +def _default_run_video_scan(mode: str) -> Dict[str, Any]: + """Production wiring: read the server into video.db (blocking).""" + 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) + + +def auto_video_scan_library( + config: Dict[str, Any], + deps: AutomationDeps, + *, + server_refresh: Optional[Callable[[], Dict[str, Any]]] = None, + run_video_scan: Optional[Callable[[str], Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Trigger a server-side video rescan, then mirror the result into video.db. + + Returns one of: + - ``{'status': 'completed', '_manages_own_progress': True, 'movies': .., 'shows': .., 'episodes': ..}`` + - ``{'status': 'error', 'error': '...', '_manages_own_progress': True}`` + """ + server_refresh = server_refresh or _default_server_refresh + run_video_scan = run_video_scan or _default_run_video_scan + + automation_id = config.get('_automation_id') + # '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' + + try: + deps.update_progress( + automation_id, + phase='Asking media server to rescan video sections...', + progress=10, + log_line='Triggering server-side video scan', + 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 {} + 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', + ) + else: + sections = refresh.get('sections') + detail = str(sections) + ' video section(s)' if sections else 'selected video section(s)' + deps.update_progress( + automation_id, + progress=30, + log_line='Server rescanning ' + detail, + log_type='success', + ) + + # Step 2 — read the server into video.db (blocking; mirrors music's + # scan handler blocking until the scan resolves). + deps.update_progress( + automation_id, + phase='Reading library into SoulSync...', + progress=45, + ) + result = run_video_scan(mode) or {} + state = result.get('state') + if state == 'error': + err = result.get('error') or 'Video library scan failed' + deps.update_progress( + automation_id, + status='error', + phase='Error', + log_line=err, + log_type='error', + ) + return {'status': 'error', 'error': err, '_manages_own_progress': True} + + movies = int(result.get('movies', 0) or 0) + shows = int(result.get('shows', 0) or 0) + episodes = int(result.get('episodes', 0) or 0) + deps.update_progress( + automation_id, + status='finished', + progress=100, + phase='Complete', + log_line=f'Video library scanned: {movies} movies, {shows} shows, {episodes} episodes', + log_type='success', + ) + return { + 'status': 'completed', + '_manages_own_progress': True, + 'movies': movies, + 'shows': shows, + 'episodes': episodes, + } + + except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine + deps.update_progress( + automation_id, + status='error', + phase='Error', + log_line=str(e), + log_type='error', + ) + return {'status': 'error', 'error': str(e), '_manages_own_progress': True} diff --git a/core/automation_engine.py b/core/automation_engine.py index 1b85e221..a752207d 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -145,6 +145,20 @@ SYSTEM_AUTOMATIONS = [ 'action_type': 'full_cleanup', 'initial_delay': 900, # 15 min after startup }, + # ── Video side (isolated app, shared engine) ────────────────────────── + # owned_by='video' keeps these OFF the music automations page (it filters + # them out) and ON the video Automations page (it shows only these). + # Schedule-based for now; a video-download-complete event trigger can + # replace the schedule once that event is wired into the engine. + { + 'name': 'Scan Video Library', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 6, 'unit': 'hours'}, + 'action_type': 'video_scan_library', + 'action_config': {'mode': 'full'}, + 'owned_by': 'video', + 'initial_delay': 300, # 5 min after startup + }, ] @@ -237,8 +251,11 @@ class AutomationEngine: trigger_type=spec['trigger_type'], trigger_config=json.dumps(spec['trigger_config']), action_type=spec['action_type'], - action_config='{}', + action_config=json.dumps(spec.get('action_config', {})), profile_id=1, + # owned_by tags the side that owns this automation (e.g. + # 'video'), so the music page can exclude another side's rows. + owned_by=spec.get('owned_by'), ) if aid: self.db.update_automation(aid, is_system=1) diff --git a/tests/automation/test_automation_blocks.py b/tests/automation/test_automation_blocks.py index 151725bb..3cd5122b 100644 --- a/tests/automation/test_automation_blocks.py +++ b/tests/automation/test_automation_blocks.py @@ -81,3 +81,53 @@ def test_event_triggers_with_conditions_have_condition_fields(): assert 'condition_fields' in t, f"{t['type']} marked has_conditions but no condition_fields" assert isinstance(t['condition_fields'], list) assert len(t['condition_fields']) > 0 + + +# ── scope filtering (music vs the isolated video builder) ──────────────── + +def test_video_only_block_hidden_from_music_builder(): + """The video action must never appear on the music builder.""" + music = blocks.blocks_for_scope('music') + assert 'video_scan_library' not in {a['type'] for a in music['actions']} + + +def test_video_builder_gets_video_block_plus_generics(): + video = blocks.blocks_for_scope('video') + action_types = {a['type'] for a in video['actions']} + # its own action… + assert 'video_scan_library' in action_types + # …plus the generic (scope='both') ones it shares with music… + assert 'notify_only' in action_types + assert 'run_script' in action_types + # …but NOT music-only actions. + assert 'process_wishlist' not in action_types + assert 'scan_library' not in action_types + + +def test_generic_blocks_appear_on_both_sides(): + """Every scope='both' block shows on music AND video.""" + music = blocks.blocks_for_scope('music') + video = blocks.blocks_for_scope('video') + for key in ('triggers', 'actions', 'notifications'): + both = {b['type'] for b in getattr(blocks, key.upper()) if b.get('scope') == 'both'} + assert both, f"expected at least one scope='both' {key}" + assert both <= {b['type'] for b in music[key]}, f"music missing a 'both' {key}" + assert both <= {b['type'] for b in video[key]}, f"video missing a 'both' {key}" + + +def test_music_scope_matches_legacy_full_lists_minus_video(): + """scope='music' must reproduce the pre-scope behaviour: everything that + isn't explicitly video-only. Guards against accidentally hiding a music + block when new scope tags are added.""" + music = blocks.blocks_for_scope('music') + for key in ('triggers', 'actions', 'notifications'): + expected = {b['type'] for b in getattr(blocks, key.upper()) if b.get('scope') != 'video'} + assert {b['type'] for b in music[key]} == expected + + +def test_video_scan_library_block_shape(): + action = next(a for a in blocks.ACTIONS if a['type'] == 'video_scan_library') + assert action['scope'] == 'video' + mode = next(f for f in action['config_fields'] if f['key'] == 'mode') + assert {o['value'] for o in mode['options']} == {'full', 'incremental', 'deep'} + assert mode['default'] == 'full' diff --git a/tests/automation/test_engine_schedule_integration.py b/tests/automation/test_engine_schedule_integration.py index 11fde011..22583767 100644 --- a/tests/automation/test_engine_schedule_integration.py +++ b/tests/automation/test_engine_schedule_integration.py @@ -403,3 +403,37 @@ def test_end_to_end_monthly_schedule_produces_valid_db_string(engine_with_db): assert parsed.day == 15 assert parsed.hour == 9 assert parsed.minute == 0 + + +# --------------------------------------------------------------------------- +# System-automation seeding — owned_by / action_config (video side). +# --------------------------------------------------------------------------- + + +def test_ensure_system_automations_seeds_video_with_owned_by_and_mode(): + """The video twin ('Scan Video Library') must seed with owned_by='video' + so it stays off the music page, and carry its action_config (mode). Music + system automations keep owned_by=None. Regression guard for the seeding + seam in ensure_system_automations().""" + db = MagicMock() + db.get_system_automation_by_action.return_value = None # nothing seeded yet + created = {} + + def _create(**kw): + created[kw['action_type']] = kw + return 'id-' + kw['action_type'] + + db.create_automation.side_effect = _create + + engine = AutomationEngine(db) + engine.ensure_system_automations() + + # Video twin seeded, tagged for the video side, with its mode config. + assert 'video_scan_library' in created, 'video twin not seeded' + video = created['video_scan_library'] + assert video['owned_by'] == 'video' + assert json.loads(video['action_config']) == {'mode': 'full'} + + # Music automations stay owned_by=None (shown on the music page). + assert created['scan_library']['owned_by'] is None + assert json.loads(created['scan_library']['action_config']) == {} diff --git a/tests/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index 7b23c67a..23441a05 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -53,6 +53,8 @@ EXPECTED_ACTION_NAMES = frozenset({ 'full_cleanup', 'run_script', 'search_and_download', + # Video side (isolated app, shared engine). + 'video_scan_library', }) # Action names that MUST register a guard (duplicate-run prevention). diff --git a/tests/automation/test_handler_video_scan_library.py b/tests/automation/test_handler_video_scan_library.py new file mode 100644 index 00000000..8411a3d9 --- /dev/null +++ b/tests/automation/test_handler_video_scan_library.py @@ -0,0 +1,175 @@ +"""Seam-level tests for the ``video_scan_library`` automation handler. + +The handler is the VIDEO twin of ``scan_library``: it nudges the media +server to rescan the user's selected video sections, then reads the result +into video.db. Both side effects are injected (``server_refresh`` / +``run_video_scan``) so these tests exercise the real handler logic with +fakes — no Flask, no DB, no media server. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from core.automation.handlers.video_scan_library import auto_video_scan_library + + +class _RecordingDeps: + """Captures every update_progress call so tests can assert on the + streamed phase/log/status sequence.""" + + def __init__(self) -> None: + self.calls: List[Dict[str, Any]] = [] + + def update_progress(self, automation_id: Any = None, **kw: Any) -> None: + kw['_id'] = automation_id + self.calls.append(kw) + + # convenience accessors + def statuses(self) -> List[str]: + return [c['status'] for c in self.calls if 'status' in c] + + def log_types(self) -> List[str]: + return [c['log_type'] for c in self.calls if 'log_type' in c] + + +def _refresh_ok(sections: int = 2): + return lambda: {'ok': True, 'sections': sections} + + +def _scan_done(movies: int = 3, shows: int = 1, episodes: int = 9): + return lambda mode: {'state': 'done', 'movies': movies, 'shows': shows, 'episodes': episodes} + + +class TestHappyPath: + def test_returns_completed_with_counts(self): + deps = _RecordingDeps() + result = auto_video_scan_library( + {'_automation_id': 'a', 'mode': 'full'}, deps, + server_refresh=_refresh_ok(), run_video_scan=_scan_done(3, 1, 9), + ) + assert result == { + 'status': 'completed', '_manages_own_progress': True, + 'movies': 3, 'shows': 1, 'episodes': 9, + } + + def test_finishes_at_100_percent(self): + deps = _RecordingDeps() + auto_video_scan_library( + {'_automation_id': 'a'}, deps, + server_refresh=_refresh_ok(), run_video_scan=_scan_done(), + ) + # The final progress call drives the card to completion. + final = deps.calls[-1] + assert final.get('status') == 'finished' + assert final.get('progress') == 100 + + def test_passes_configured_mode_through_to_scan(self): + seen = {} + + def _scan(mode): + seen['mode'] = mode + return {'state': 'done'} + + deps = _RecordingDeps() + auto_video_scan_library( + {'_automation_id': 'a', 'mode': 'deep'}, deps, + server_refresh=_refresh_ok(), run_video_scan=_scan, + ) + assert seen['mode'] == 'deep' + + def test_defaults_mode_to_full(self): + seen = {} + + def _scan(mode): + seen['mode'] = mode + return {'state': 'done'} + + deps = _RecordingDeps() + auto_video_scan_library( + {'_automation_id': 'a'}, deps, + server_refresh=_refresh_ok(), run_video_scan=_scan, + ) + assert seen['mode'] == 'full' + + +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): + scanned['ran'] = True + return {'state': 'done', 'movies': 5} + + deps = _RecordingDeps() + result = auto_video_scan_library( + {'_automation_id': 'a'}, deps, + server_refresh=lambda: {'ok': False, 'error': 'No video server configured'}, + run_video_scan=_scan, + ) + assert scanned.get('ran') is True + assert result['status'] == 'completed' + assert 'warning' in deps.log_types() + + def test_none_refresh_result_is_tolerated(self): + deps = _RecordingDeps() + result = auto_video_scan_library( + {'_automation_id': 'a'}, deps, + server_refresh=lambda: None, run_video_scan=_scan_done(), + ) + assert result['status'] == 'completed' + + +class TestScanFailure: + def test_scan_error_state_returns_error(self): + deps = _RecordingDeps() + result = auto_video_scan_library( + {'_automation_id': 'a'}, deps, + server_refresh=_refresh_ok(), + run_video_scan=lambda mode: {'state': 'error', 'error': 'no connected server'}, + ) + assert result['status'] == 'error' + assert result['error'] == 'no connected server' + assert result['_manages_own_progress'] is True + assert 'error' in deps.statuses() + + def test_none_scan_result_does_not_crash(self): + deps = _RecordingDeps() + result = auto_video_scan_library( + {'_automation_id': 'a'}, deps, + server_refresh=_refresh_ok(), run_video_scan=lambda mode: None, + ) + # No state -> treated as a (zero-count) completion, never raises. + assert result['status'] == 'completed' + assert result['movies'] == 0 + + +class TestHandlerNeverRaises: + def test_swallows_refresh_exception(self): + deps = _RecordingDeps() + result = auto_video_scan_library( + {'_automation_id': 'a'}, deps, + server_refresh=lambda: (_ for _ in ()).throw(RuntimeError('boom')), + ) + assert result['status'] == 'error' + assert result['error'] == 'boom' + assert result['_manages_own_progress'] is True + + def test_swallows_scan_exception(self): + deps = _RecordingDeps() + result = auto_video_scan_library( + {'_automation_id': 'a'}, deps, + server_refresh=_refresh_ok(), + run_video_scan=lambda mode: (_ for _ in ()).throw(ValueError('kaboom')), + ) + assert result['status'] == 'error' + assert result['error'] == 'kaboom' + + def test_missing_automation_id_is_fine(self): + deps = _RecordingDeps() + result = auto_video_scan_library( + {}, deps, server_refresh=_refresh_ok(), run_video_scan=_scan_done(), + ) + assert result['status'] == 'completed' diff --git a/web_server.py b/web_server.py index d18b9d8f..c9faf469 100644 --- a/web_server.py +++ b/web_server.py @@ -3789,13 +3789,14 @@ def list_available_scripts(): @app.route('/api/automations/blocks', methods=['GET']) def get_automation_blocks(): - """Return available block types for the automation builder sidebar.""" - return jsonify({ - 'triggers': _auto_blocks.TRIGGERS, - 'actions': _auto_blocks.ACTIONS, - 'notifications': _auto_blocks.NOTIFICATIONS, - 'known_signals': _collect_known_signals(), - }) + """Return available block types for the automation builder sidebar. + + Music builder only — video-only blocks (scope='video') are filtered out + so the music builder never offers a video action. The video side fetches + its own scope via /api/video/automations/blocks.""" + scoped = _auto_blocks.blocks_for_scope('music') + scoped['known_signals'] = _collect_known_signals() + return jsonify(scoped) @app.route('/api/mirrored-playlists/list', methods=['GET']) def get_mirrored_playlists_list(): diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 395ba528..d829a632 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -2428,8 +2428,12 @@ async function loadAutomations() { if (!list || !empty) return; try { const res = await fetch('/api/automations'); - const automations = await res.json(); - if (automations.error) throw new Error(automations.error); + const raw = await res.json(); + if (raw.error) throw new Error(raw.error); + // The automation engine is app-wide, so /api/automations also returns + // video-owned rows. Those belong only on the video Automations page — + // exclude them here so the music page shows music automations only. + const automations = Array.isArray(raw) ? raw.filter(a => a.owned_by !== 'video') : raw; if (!automations.length) { list.innerHTML = ''; empty.style.display = ''; if (statsBar) statsBar.innerHTML = '';