video automations: 'Scan Video Library' — the first video twin (shared engine)

The video side gets its OWN automations at music-side parity, kept separate so
nothing on the music side breaks. First twin: Scan Video Library — tells the media
server to rescan the user's SELECTED video sections (movies/TV, never music), then
reads the result into video.db so freshly-downloaded media shows as owned.

Architecture (scope tags + video twins on the shared engine):
- Handler core/automation/handlers/video_scan_library.py — pure function with
  injected I/O (server_refresh / run_video_scan); production lazily binds
  refresh_video_server_sections() + the video scanner. Owns its own progress.
  Lives on the SHARED automation side so it may import core.video (isolation only
  forbids core/video & api/video from importing music, not the reverse).
- blocks.py gains a 'scope' tag ('both' generic / 'video' video-only / absent=music)
  + blocks_for_scope(). The music /api/automations/blocks now filters out video
  blocks; new isolated /api/video/automations/blocks serves the video palette.
- automation_engine seeds 'Scan Video Library' (owned_by='video', schedule 6h) so
  it appears ONLY on the video Automations page; ensure_system_automations now
  honours owned_by + action_config. Music page excludes owned_by='video' rows.

kettui: seam-level tests for every handler path (happy/no-server/scan-error/never-
raises/mode), scope filtering (music excludes video, video gets generics, music
parity preserved), seeding (owned_by + mode), registration drift guard. 39 new
tests; full automation suite (288) + isolation guards green.
This commit is contained in:
BoulderBadgeDad 2026-06-19 19:38:18 -07:00
parent 13b30a5997
commit 40149d09f7
12 changed files with 532 additions and 24 deletions

View file

@ -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

28
api/video/automations.py Normal file
View file

@ -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"))

View file

@ -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)],
}

View file

@ -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

View file

@ -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}

View file

@ -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)

View file

@ -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'

View file

@ -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']) == {}

View file

@ -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).

View file

@ -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'

View file

@ -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():

View file

@ -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 = '';