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:
parent
13b30a5997
commit
40149d09f7
12 changed files with 532 additions and 24 deletions
|
|
@ -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
28
api/video/automations.py
Normal 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"))
|
||||
|
|
@ -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)],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
145
core/automation/handlers/video_scan_library.py
Normal file
145
core/automation/handlers/video_scan_library.py
Normal 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}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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']) == {}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
175
tests/automation/test_handler_video_scan_library.py
Normal file
175
tests/automation/test_handler_video_scan_library.py
Normal 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'
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
Loading…
Reference in a new issue