From 3ef0990ffcd3e1d926745c58092cc0f21963825a Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 21 Jun 2026 23:31:10 -0700 Subject: [PATCH] Video automations: Clean Search History twin (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The music side's 'Clean Search History' automation now has a video counterpart so it appears on the video Automations page too. Distinct action_type video_clean_search_history (the system seeder keys on action_type, so reusing the music key would collide), registered to the SAME shared handler so behaviour is identical, scope='video' block (registry — users can build their own), and an owned_by='video' system automation on the same 1h cadence. The music action/row is untouched. Seam tests: video-scoped only (not on music), music action still music-scoped, exactly one video-owned system row at the 1h cadence, and it reuses the music handler. Registration contract (EXPECTED_ACTION_NAMES) updated. --- core/automation/blocks.py | 7 ++ core/automation/handlers/registration.py | 6 ++ core/automation_engine.py | 11 +++ tests/automation/test_handler_registration.py | 1 + .../test_video_maintenance_twins.py | 69 +++++++++++++++++++ 5 files changed, 94 insertions(+) create mode 100644 tests/automation/test_video_maintenance_twins.py diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 1656ee29..7547bd68 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -265,6 +265,13 @@ ACTIONS: list[dict] = [ "config_fields": [ {"key": "prune_ended", "type": "checkbox", "label": "Also remove ended/canceled shows from the watchlist", "default": True} ]}, + # Video twins of the music maintenance actions. Distinct action_type (the + # system seeder keys on action_type, so a shared key would collide with the + # music row) but the SAME shared handler — the cleanup operates on the common + # download/search state, so behaviour stays identical. scope='video' keeps + # them on the video builder only; the music blocks above are untouched. + {"type": "video_clean_search_history", "label": "Clean Search History", "icon": "trash-2", "scope": "video", + "description": "Remove old searches from Soulseek", "available": True}, ] diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 6d333c98..8698daa4 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -154,6 +154,12 @@ def register_all(deps: AutomationDeps) -> None: 'clean_search_history', lambda config: auto_clean_search_history(config, deps), ) + # Video twin — same handler, distinct action_type so the system seeder + # (keyed on action_type) creates a separate video-owned row. + engine.register_action_handler( + 'video_clean_search_history', + lambda config: auto_clean_search_history(config, deps), + ) engine.register_action_handler( 'clean_completed_downloads', lambda config: auto_clean_completed_downloads(config, deps), diff --git a/core/automation_engine.py b/core/automation_engine.py index 19633c7a..35a3a551 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -182,6 +182,17 @@ SYSTEM_AUTOMATIONS = [ 'action_type': 'video_add_airing_episodes', 'owned_by': 'video', }, + # Video twins of the music maintenance jobs — same schedule + shared handler, + # distinct action_type + owned_by='video' so they seed as separate rows and + # show on the video Automations page (music's copies are untouched). + { + 'name': 'Clean Search History', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 1, 'unit': 'hours'}, + 'action_type': 'video_clean_search_history', + 'initial_delay': 600, + 'owned_by': 'video', + }, # Video twin of music's 'Auto-Deep Scan Library', split into TWO because Movies # and TV are independent libraries — a TV scan never pulls in new movies and # vice-versa. Fixed weekly deep scan (re-read + prune removed) at 02:00 server- diff --git a/tests/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index 273a73be..bb996ffd 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -60,6 +60,7 @@ EXPECTED_ACTION_NAMES = frozenset({ 'video_scan_server', 'video_update_database', 'video_add_airing_episodes', + 'video_clean_search_history', }) # Action names that MUST register a guard (duplicate-run prevention). diff --git a/tests/automation/test_video_maintenance_twins.py b/tests/automation/test_video_maintenance_twins.py new file mode 100644 index 00000000..80e9de89 --- /dev/null +++ b/tests/automation/test_video_maintenance_twins.py @@ -0,0 +1,69 @@ +"""Video twins of the music maintenance automations. + +The music side has "Clean Search History" / "Clean Completed Downloads" / "Full +Cleanup" / "Auto-Backup Database". The video side gets its own copies so they +appear on the video Automations page too — distinct ``video_*`` action_type +(the system seeder keys on action_type, so a shared key would collide), the SAME +shared handler where the behaviour is identical, and ``owned_by='video'`` so the +music page is never disrupted. + +These pin the registry + scope + seed contract, one twin per phase. +""" + +from __future__ import annotations + +from core.automation.blocks import blocks_for_scope +from core.automation_engine import SYSTEM_AUTOMATIONS + + +def _action_types(scope): + return {a["type"] for a in blocks_for_scope(scope)["actions"]} + + +def _system_by_action(action_type): + return [s for s in SYSTEM_AUTOMATIONS if s.get("action_type") == action_type] + + +# ── Phase 2: Clean Search History ─────────────────────────────────────────── + +def test_video_clean_search_history_is_video_scoped_only(): + # Appears on the video builder, NOT the music builder (music block untouched). + assert "video_clean_search_history" in _action_types("video") + assert "video_clean_search_history" not in _action_types("music") + + +def test_music_clean_search_history_is_untouched(): + # The original music action still exists and is still music-scoped — no disruption. + assert "clean_search_history" in _action_types("music") + assert "clean_search_history" not in _action_types("video") + + +def test_video_clean_search_history_seeds_one_video_owned_system_row(): + rows = _system_by_action("video_clean_search_history") + assert len(rows) == 1 + row = rows[0] + assert row["owned_by"] == "video" + assert row["trigger_type"] == "schedule" + # mirrors the music cadence (every 1 hour) + assert row["trigger_config"] == {"interval": 1, "unit": "hours"} + + +def test_video_clean_search_history_reuses_the_music_handler(): + # Registered to the SAME handler function as the music action (identical behaviour). + from core.automation.handlers import register_all + + class _Eng: + def __init__(self): + self.handlers = {} + def register_action_handler(self, t, fn, guard_fn=None): + self.handlers[t] = fn + def register_progress_callbacks(self, *a, **k): + pass + + import core.automation.handlers.registration as reg + eng = _Eng() + # Build minimal deps via the test helper the registration suite uses. + from tests.automation.test_handler_registration import _build_deps + register_all(_build_deps(eng)) + assert "video_clean_search_history" in eng.handlers + assert "clean_search_history" in eng.handlers