diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 161e4652..536003ac 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -260,6 +260,8 @@ ACTIONS: list[dict] = [ "description": "Full reconcile of the TV library: re-read every show from the server and drop ones it no longer has (a read, NOT a Plex disk-scan; never touches movies)", "available": True}, {"type": "video_deep_scan_movies", "label": "Deep Scan Movie Library", "icon": "search", "scope": "video", "description": "Full reconcile of the Movie library: re-read every movie from the server and drop ones it no longer has (a read, NOT a Plex disk-scan; never touches TV)", "available": True}, + {"type": "video_refresh_airing_schedules", "label": "Refresh Airing TV Schedules", "icon": "calendar", "scope": "video", + "description": "Re-pull the latest TMDB episode schedules (air dates, stills) for the still-airing shows on your watchlist, so the calendar the airing automation reads is current — newly-announced or rescheduled episodes get picked up instead of being missed. Skips ended/canceled shows. Pair with a daily Schedule a couple hours before the airing run.", "available": True}, {"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. Also tidies the watchlist by dropping shows that have ended/been canceled.", "available": True, "config_fields": [ diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 178c6011..e78893fc 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -37,6 +37,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_refresh_airing_schedules import auto_video_refresh_airing_schedules from core.automation.handlers.video_scan_watchlist_people import auto_video_scan_watchlist_people from core.automation.handlers.video_scan_watchlist_channels import auto_video_scan_watchlist_channels from core.automation.handlers.video_process_youtube_wishlist import auto_video_process_youtube_wishlist @@ -232,6 +233,12 @@ def register_all(deps: AutomationDeps) -> None: 'video_add_airing_episodes', lambda config: auto_video_add_airing_episodes(config, deps), ) + # Keep the calendar honest: re-pull TMDB episode schedules for still-airing watchlist + # shows (the airing automation above reads the LOCAL calendar, so it needs this fresh). + engine.register_action_handler( + 'video_refresh_airing_schedules', + lambda config: auto_video_refresh_airing_schedules(config, deps), + ) # ── Watchlist → Wishlist pipeline ───────────────────────────────────────── # Stage 1 — SCANS that fill the wishlist from what you follow. # People: wishlist every un-owned movie followed actors/directors made (catalog + upcoming). diff --git a/core/automation/handlers/video_refresh_airing_schedules.py b/core/automation/handlers/video_refresh_airing_schedules.py new file mode 100644 index 00000000..d2dbd34a --- /dev/null +++ b/core/automation/handlers/video_refresh_airing_schedules.py @@ -0,0 +1,88 @@ +"""Automation handler: ``video_refresh_airing_schedules`` action. + +Keeps the TV calendar honest. The "Wishlist Episodes Airing Today" automation reads the +LOCAL ``episodes`` table — which is only refreshed from TMDB on initial enrichment, a manual +re-match, or lazily when you open a show's page. So a newly-announced or rescheduled episode +can sit unknown indefinitely, and the airing automation misses it. + +This runs daily (a couple hours before the airing run) and re-pulls each still-airing +watchlist show's season/episode schedule from TMDB (air dates, stills, overviews) so the +calendar is current when the airing automation reads it. + +Scope: the EFFECTIVE watchlist's continuing LIBRARY shows (follows ∪ airing library shows, +skipping ended/canceled — they'll never air again, and skipping tmdb-only follows, which have +no episodes table to refresh). Like the other video handlers it owns its progress reporting +(``_manages_own_progress``); the show fetch + per-show refresh are injected seams, so the +handler is a pure function tests drive with fakes. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +from core.automation.deps import AutomationDeps + + +def _default_fetch_shows() -> List[Dict[str, Any]]: + """Production wiring: the still-airing watchlist shows that live in the library.""" + from api.video import get_video_db + from core.video.sources import resolve_video_server + return get_video_db().watchlist_continuing_shows(resolve_video_server()) + + +def _default_refresh_show(library_id: Any) -> Dict[str, Any]: + """Re-pull a library show's TMDB season/episode schedule (the lazy on-view backfill, + invoked deliberately). Re-matches + cascades episodes, so air dates/stills refresh.""" + from core.video.enrichment.engine import get_video_enrichment_engine + return get_video_enrichment_engine().refresh_show_art(library_id) or {} + + +def auto_video_refresh_airing_schedules( + config: Dict[str, Any], + deps: AutomationDeps, + *, + fetch_shows: Optional[Callable[[], List[Dict[str, Any]]]] = None, + refresh_show: Optional[Callable[[Any], Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Refresh the TMDB episode schedule for every still-airing watchlist library show, so + the airing automation's calendar read is current. + + Returns ``{'status': 'completed', 'refreshed': int, 'failed': int, 'shows': int, ...}``.""" + fetch_shows = fetch_shows or _default_fetch_shows + refresh_show = refresh_show or _default_refresh_show + automation_id = config.get('_automation_id') + try: + deps.update_progress(automation_id, phase='Finding shows to refresh…', progress=8, + log_line='Reading your watchlist for still-airing shows', log_type='info') + shows = fetch_shows() or [] + total = len(shows) + if not total: + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line='No airing shows on your watchlist to refresh', log_type='success') + return {'status': 'completed', 'refreshed': 0, 'failed': 0, 'shows': 0, + '_manages_own_progress': True} + + refreshed = failed = 0 + for i, s in enumerate(shows): + title = s.get('title') or ('show %s' % s.get('library_id')) + deps.update_progress( + automation_id, phase='Refreshing TV schedules…', progress=10 + int(i / total * 85), + log_line="Pulling the latest episodes for '%s' (%d/%d)" % (title, i + 1, total), + log_type='info') + try: + ok = bool((refresh_show(s.get('library_id')) or {}).get('ok')) + except Exception: # noqa: BLE001 - one show failing must not stop the rest + ok = False + if ok: + refreshed += 1 + else: + failed += 1 + + done = 'Refreshed %d show schedule(s)' % refreshed + (' · %d failed' % failed if failed else '') + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line=done, log_type='success') + return {'status': 'completed', 'refreshed': refreshed, 'failed': failed, 'shows': total, + '_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 ff4a35c5..3f3fa1e4 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -175,6 +175,15 @@ SYSTEM_AUTOMATIONS = [ # are queued overnight. A fixed wall-clock 'daily_time' (not a rolling 24h interval # that drifts with restarts) — the seeder now arms timed system triggers, and # _fix_airing_automation_schedule migrates the old 24h-interval row. + # Runs before the airing automation so the calendar it reads is current — re-pulls + # TMDB episode schedules for still-airing watchlist shows (the airing read is LOCAL). + { + 'name': 'Refresh Airing TV Schedules', + 'trigger_type': 'daily_time', + 'trigger_config': {'time': '23:00'}, + 'action_type': 'video_refresh_airing_schedules', + 'owned_by': 'video', + }, { 'name': 'Auto-Wishlist Episodes Airing Today', 'trigger_type': 'daily_time', diff --git a/database/video_database.py b/database/video_database.py index 23d55726..c3fd61be 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -2308,6 +2308,30 @@ class VideoDatabase: finally: conn.close() + def watchlist_continuing_shows(self, server_source=None) -> list[dict]: + """Effective-watchlist shows that are IN the library (so they have an episodes table + to refresh) and still airing — the set whose TMDB episode schedules the 'Refresh + Airing TV Schedules' automation re-pulls so the calendar stays current. Skips + tmdb-only follows (no episodes to refresh) and ended/canceled shows (no new episodes + coming); unknown status is kept (never skip on uncertainty).""" + terminal = ("ended", "canceled", "cancelled", "completed") + conn = self._get_connection() + try: + rows = self._effective_shows(conn, server_source) + finally: + conn.close() + out, seen = [], set() + for r in rows: + lib = r.get("library_id") + if lib is None or lib in seen: + continue + if str(r.get("status") or "").strip().lower() in terminal: + continue + seen.add(lib) + out.append({"library_id": lib, "tmdb_id": r.get("tmdb_id"), + "title": r.get("title"), "status": r.get("status")}) + return out + def remove_from_watchlist(self, kind: str, tmdb_id: int) -> bool: """Un-follow. Stored as a 'mute' tombstone (not a delete) so an actively-airing library show — watched by default — is not silently diff --git a/tests/test_video_refresh_airing_schedules.py b/tests/test_video_refresh_airing_schedules.py new file mode 100644 index 00000000..f673fdc9 --- /dev/null +++ b/tests/test_video_refresh_airing_schedules.py @@ -0,0 +1,96 @@ +"""Refresh-airing-schedules automation: re-pull TMDB episode schedules for still-airing +watchlist shows so the airing automation's LOCAL calendar read is current. Pure handler with +the show fetch + per-show refresh injected, plus the DB scoping query + the wiring contract.""" + +from __future__ import annotations + +import pytest + +from core.automation.handlers.video_refresh_airing_schedules import ( + auto_video_refresh_airing_schedules, +) + + +class _Deps: + def __init__(self): + self.progress = [] + + def update_progress(self, automation_id, **kw): + self.progress.append(kw) + + +def _logs(deps): + return " ".join(p.get("log_line") or "" for p in deps.progress) + + +# ── handler ─────────────────────────────────────────────────────────────────── +def test_refreshes_each_show_and_tallies(): + shows = [{"library_id": 1, "title": "A"}, {"library_id": 2, "title": "B"}, {"library_id": 3, "title": "C"}] + seen = [] + + def refresh(lib): + seen.append(lib) + return {"ok": lib != 2} # show 2 fails to match + + deps = _Deps() + res = auto_video_refresh_airing_schedules({"_automation_id": "a"}, deps, + fetch_shows=lambda: shows, refresh_show=refresh) + assert res["status"] == "completed" and res["shows"] == 3 + assert res["refreshed"] == 2 and res["failed"] == 1 + assert seen == [1, 2, 3] # every show attempted + assert "Refreshed 2 show schedule(s)" in _logs(deps) and "1 failed" in _logs(deps) + + +def test_empty_watchlist_is_a_clean_noop(): + deps = _Deps() + res = auto_video_refresh_airing_schedules({"_automation_id": "a"}, deps, fetch_shows=lambda: []) + assert res["status"] == "completed" and res["shows"] == 0 and res["refreshed"] == 0 + assert not any(p.get("status") == "error" for p in deps.progress) + + +def test_one_show_raising_does_not_stop_the_rest(): + def refresh(lib): + if lib == 1: + raise RuntimeError("tmdb timeout") + return {"ok": True} + + res = auto_video_refresh_airing_schedules( + {"_automation_id": "a"}, _Deps(), + fetch_shows=lambda: [{"library_id": 1, "title": "A"}, {"library_id": 2, "title": "B"}], + refresh_show=refresh) + assert res["status"] == "completed" and res["refreshed"] == 1 and res["failed"] == 1 + + +def test_top_level_error_is_caught(): + def boom(): + raise RuntimeError("db down") + + res = auto_video_refresh_airing_schedules({"_automation_id": "x"}, _Deps(), fetch_shows=boom) + assert res["status"] == "error" and "db down" in res["error"] + + +# ── DB scoping ──────────────────────────────────────────────────────────────── +from database.video_database import VideoDatabase # noqa: E402 + + +def test_watchlist_continuing_shows_skips_ended_tmdbonly_and_dupes(tmp_path, monkeypatch): + db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) + rows = [ + {"library_id": 1, "tmdb_id": 10, "title": "Airing", "status": "Returning Series"}, + {"library_id": 2, "tmdb_id": 20, "title": "Done", "status": "Ended"}, # terminal → skip + {"library_id": None, "tmdb_id": 30, "title": "Tmdb-only", "status": None}, # no episodes → skip + {"library_id": 1, "tmdb_id": 10, "title": "Dup", "status": "Returning Series"}, # dup lib → once + {"library_id": 4, "tmdb_id": 40, "title": "Unknown", "status": None}, # unknown → keep + ] + monkeypatch.setattr(db, "_effective_shows", lambda conn, ss: rows) + out = db.watchlist_continuing_shows("plex") + assert [s["library_id"] for s in out] == [1, 4] + + +# ── wiring contract ─────────────────────────────────────────────────────────── +def test_seeded_before_the_airing_automation(): + import core.automation_engine as ae + order = [a.get("action_type") for a in ae.SYSTEM_AUTOMATIONS] + assert "video_refresh_airing_schedules" in order + # must run BEFORE the airing read so the calendar is fresh when it runs + assert order.index("video_refresh_airing_schedules") < order.index("video_add_airing_episodes") diff --git a/webui/static/video/video-automations.js b/webui/static/video/video-automations.js index 9059c206..91628c22 100644 --- a/webui/static/video/video-automations.js +++ b/webui/static/video/video-automations.js @@ -32,7 +32,7 @@ var _SYS_ORDER = [ // Stage 1 — scans that FILL the wishlist 'video_scan_watchlist_people', 'video_scan_watchlist_channels', - 'video_scan_watchlist_playlists', 'video_add_airing_episodes', + 'video_scan_watchlist_playlists', 'video_refresh_airing_schedules', 'video_add_airing_episodes', // Stage 2 — processors that DRAIN it (download) 'video_process_movie_wishlist', 'video_process_episode_wishlist', 'video_process_youtube_wishlist', // Library scan / sync