diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 0d4f1afb..556594a4 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -235,6 +235,8 @@ ACTIONS: list[dict] = [ {"value": "full", "label": "Full (add + refresh)"}], "default": "incremental"} ]}, + {"type": "video_add_airing_episodes", "label": "Wishlist Today's Airings", "icon": "calendar", "scope": "video", + "description": "Sonarr-style: add every episode airing today (for shows you follow) to the wishlist, skipping ones you already own", "available": True}, ] diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 9aed539f..ac686c73 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_auto_wishlist_airing import auto_video_add_airing_episodes from core.automation.handlers.video_scan_library import ( auto_video_scan_library, auto_video_scan_server, auto_video_update_database, ) @@ -186,6 +187,11 @@ def register_all(deps: AutomationDeps) -> None: 'video_update_database', lambda config: auto_video_update_database(config, deps), ) + # Sonarr-style: wishlist every episode airing today (for followed shows). + engine.register_action_handler( + 'video_add_airing_episodes', + lambda config: auto_video_add_airing_episodes(config, deps), + ) # Progress + history callbacks: the engine invokes these around # each handler run. Lift the closures from diff --git a/core/automation/handlers/video_auto_wishlist_airing.py b/core/automation/handlers/video_auto_wishlist_airing.py new file mode 100644 index 00000000..bff0d434 --- /dev/null +++ b/core/automation/handlers/video_auto_wishlist_airing.py @@ -0,0 +1,89 @@ +"""Automation handler: ``video_add_airing_episodes`` action. + +Sonarr-style "monitor airings": add every episode airing TODAY — for the TV shows you +follow on the video watchlist — to the video WISHLIST, skipping ones you already own. +Runs on a daily schedule so the day's airings queue up to be grabbed automatically. + +Like the other video handlers it lives on the SHARED automation side (so it may import +``core.video`` / ``api.video`` — the isolation contract only forbids the reverse) and +owns its own progress reporting (``_manages_own_progress``). The calendar read + wishlist +write are injected seams, so the handler is a pure function: tests pass fakes and never +touch a DB or a media server; production lazily binds the real calls. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Callable, Dict, List, Optional + +from core.automation.deps import AutomationDeps + + +def _default_fetch_airing(today: str) -> List[Dict[str, Any]]: + """Production wiring: the calendar's episodes airing on ``today`` for followed shows.""" + from api.video import get_video_db + from core.video.sources import resolve_video_server + return get_video_db().calendar_upcoming( + today, today, server_source=resolve_video_server(), watchlist_only=True) + + +def _default_add_episodes(show_tmdb_id: Any, show_title: Any, episodes: List[Dict[str, Any]]) -> int: + from api.video import get_video_db + from core.video.sources import resolve_video_server + return get_video_db().add_episodes_to_wishlist( + show_tmdb_id, show_title, episodes, server_source=resolve_video_server()) + + +def auto_video_add_airing_episodes( + config: Dict[str, Any], + deps: AutomationDeps, + *, + fetch_airing: Optional[Callable[[str], List[Dict[str, Any]]]] = None, + add_episodes: Optional[Callable[[Any, Any, List[Dict[str, Any]]], int]] = None, + today_fn: Optional[Callable[[], str]] = None, +) -> Dict[str, Any]: + """Add today's airing (unowned, followed-show) episodes to the video wishlist. + + Returns ``{'status': 'completed', 'episodes_added': int, 'shows': int, ...}``.""" + fetch_airing = fetch_airing or _default_fetch_airing + add_episodes = add_episodes or _default_add_episodes + today_fn = today_fn or (lambda: date.today().isoformat()) + automation_id = config.get('_automation_id') + try: + today = today_fn() + deps.update_progress(automation_id, phase="Checking today's airings…", progress=25, + log_line='Reading the calendar for episodes airing today', log_type='info') + rows = fetch_airing(today) or [] + + # Group what to wish for by show: airing today, NOT already owned, with a + # real season/episode. add_episodes_to_wishlist is idempotent, so re-runs + # never duplicate. + by_show: Dict[tuple, List[Dict[str, Any]]] = {} + for r in rows: + if r.get('has_file'): + continue + tid = r.get('show_tmdb_id') + if not tid or r.get('season_number') is None or r.get('episode_number') is None: + continue + by_show.setdefault((tid, r.get('show_title')), []).append({ + 'season_number': r.get('season_number'), + 'episode_number': r.get('episode_number'), + 'title': r.get('title'), + 'air_date': r.get('air_date'), + }) + + added = 0 + for (tid, title), eps in by_show.items(): + added += int(add_episodes(tid, title, eps) or 0) + shows = len(by_show) + + deps.update_progress( + automation_id, status='finished', progress=100, phase='Complete', + log_line=('Added %d airing episode(s) across %d show(s) to the wishlist' + % (added, shows)) if added else 'No new airing episodes to wishlist today', + log_type='success') + return {'status': 'completed', 'episodes_added': added, 'shows': shows, + '_manages_own_progress': True} + except Exception as e: # noqa: BLE001 + 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 a599c8d0..f0b90f4a 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -170,6 +170,15 @@ SYSTEM_AUTOMATIONS = [ 'action_config': {'mode': 'incremental'}, 'owned_by': 'video', }, + # Sonarr-style: each day, wishlist every episode airing today for the shows you + # follow (skipping ones already owned) so they queue up to be grabbed. + { + 'name': 'Auto-Wishlist Episodes Airing Today', + 'trigger_type': 'daily_time', + 'trigger_config': {'time': '01:00'}, + 'action_type': 'video_add_airing_episodes', + 'owned_by': 'video', + }, ] diff --git a/tests/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index ef18334c..2cb2f9d2 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -57,6 +57,7 @@ EXPECTED_ACTION_NAMES = frozenset({ 'video_scan_library', 'video_scan_server', 'video_update_database', + 'video_add_airing_episodes', }) # Action names that MUST register a guard (duplicate-run prevention). diff --git a/tests/test_video_auto_wishlist_airing.py b/tests/test_video_auto_wishlist_airing.py new file mode 100644 index 00000000..e32ed54c --- /dev/null +++ b/tests/test_video_auto_wishlist_airing.py @@ -0,0 +1,74 @@ +"""Sonarr-style 'wishlist today's airings' automation handler — pure logic with the +calendar read + wishlist write injected, so it runs without a DB or media server.""" + +from __future__ import annotations + +from core.automation.handlers.video_auto_wishlist_airing import auto_video_add_airing_episodes + + +class _Deps: + def __init__(self): + self.progress = [] + + def update_progress(self, automation_id, **kw): + self.progress.append(kw) + + +def _row(tid, title, s, e, owned=False): + return {"show_tmdb_id": tid, "show_title": title, "season_number": s, + "episode_number": e, "title": "Ep", "air_date": "2026-06-21", "has_file": owned} + + +def test_adds_unowned_airings_grouped_by_show(): + rows = [ + _row(1, "Widows Bay", 1, 1), + _row(1, "Widows Bay", 1, 2), + _row(2, "Another Show", 3, 5), + _row(1, "Widows Bay", 1, 3, owned=True), # already owned → skipped + {"show_title": "No id", "season_number": 1, "episode_number": 1}, # no tmdb id → skipped + ] + added = [] + + def add(tid, title, eps): + added.append((tid, title, len(eps))) + return len(eps) + + res = auto_video_add_airing_episodes( + {"_automation_id": "a1"}, _Deps(), + fetch_airing=lambda today: rows, add_episodes=add, today_fn=lambda: "2026-06-21") + + assert res["status"] == "completed" + assert res["episodes_added"] == 3 # 2 of Widows Bay + 1 of Another Show + assert res["shows"] == 2 + assert (1, "Widows Bay", 2) in added and (2, "Another Show", 1) in added + + +def test_queries_the_calendar_for_today(): + seen = {} + + def fetch(today): + seen["today"] = today + return [] + + auto_video_add_airing_episodes({"_automation_id": "a"}, _Deps(), + fetch_airing=fetch, add_episodes=lambda *a: 0, + today_fn=lambda: "2026-06-21") + assert seen["today"] == "2026-06-21" # start == end == today + + +def test_nothing_airing_is_a_clean_noop(): + res = auto_video_add_airing_episodes({"_automation_id": "a"}, _Deps(), + fetch_airing=lambda t: [], add_episodes=lambda *a: 0, + today_fn=lambda: "2026-06-21") + assert res["status"] == "completed" and res["episodes_added"] == 0 + + +def test_error_is_caught_and_reported(): + def boom(today): + raise RuntimeError("calendar down") + + deps = _Deps() + res = auto_video_add_airing_episodes({"_automation_id": "a"}, deps, + fetch_airing=boom, add_episodes=lambda *a: 0) + assert res["status"] == "error" and "calendar down" in res["error"] + assert any(p.get("status") == "error" for p in deps.progress) diff --git a/tests/test_video_download_tracking.py b/tests/test_video_download_tracking.py index 864e1f33..8f09a804 100644 --- a/tests/test_video_download_tracking.py +++ b/tests/test_video_download_tracking.py @@ -91,6 +91,33 @@ def test_modal_resumes_active_download_on_reopen(): assert '.vdl-active' in _CSS +# --- per-episode live tracking (TV parity with the movie inline tracker) --- + +def test_episode_rows_get_live_download_status(): + # each episode ROW shows its own Searching → Downloading % → Downloaded status + assert 'function epTrack(' in _VIEW and 'function epStatusRender(' in _VIEW + assert "/api/video/downloads/status?id=" in _VIEW + assert 'data-vdl-ep-status' in _VIEW + assert 'vdl-ep--dl-active' in _VIEW and 'vdl-ep--dl-done' in _VIEW and 'vdl-ep--dl-fail' in _VIEW + assert '.vdl-ep-dl' in _CSS and '.vdl-ep-dl-bar' in _CSS + + +def test_season_grab_lights_rows_and_resumes_on_reopen(): + # season grab gives immediate per-row feedback (not headless), and reopening the + # modal resumes tracking in-flight episodes + assert 'function epSearching(' in _VIEW + assert 'eps.forEach(function (en) { epSearching(' in _VIEW # rows light up at once + assert 'function resumeEpisodeTracking(' in _VIEW + assert "/api/video/downloads/active" in _VIEW + assert 'resumeEpisodeTracking(container, st)' in _VIEW # called when rows build + + +def test_episode_grab_uses_the_shared_single_grab_path(): + # the season batch grabs via the same payload as a manual grab (can't diverge) + assert 'function _pickAndGrab(' in _VIEW + assert 'sendGrab(buildGrabPayload(panel, best))' in _VIEW + + # --- navigation plumbing -------------------------------------------------- def test_video_side_handles_navigate_event(): diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index db525571..cf19c007 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -448,6 +448,8 @@ if (res && res.ok) { toast('Sent to Downloads', 'success'); beginTracking(card, res.id); // selected card → live tracker + Track button + var _ep = panel.closest && panel.closest('.vdl-ep'); + if (_ep) epTrack(_ep, res.id); // also light the collapsed episode row document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { btn.disabled = false; btn.classList.remove('vdl-res-grab--busy'); @@ -488,6 +490,8 @@ if (statusEl) { statusEl.textContent = 'Sent'; statusEl.className = 'vdl-src-status vdl-src-status--done'; } toast('Sent to Downloads', 'success'); beginTracking(card, res.id); // chosen card → live tracker + Track button + var _ep = panel.closest && panel.closest('.vdl-ep'); + if (_ep) epTrack(_ep, res.id); // also light the collapsed episode row document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { if (gbtn) { gbtn.disabled = false; gbtn.classList.remove('vdl-res-grab--busy'); } @@ -712,6 +716,7 @@ if (meta) meta.textContent = eps.length + ' eps' + (missing ? ' · ' + missing + ' missing' : ''); card.setAttribute('data-loaded', '1'); syncSeason(container, sn); + resumeEpisodeTracking(container, st); // light up any in-flight episode rows } function fetchSeason(container, card, sn, st) { @@ -838,12 +843,83 @@ panel.innerHTML = '