video: 'Refresh Airing TV Schedules' automation — keep the calendar current

the airing automation reads the LOCAL episodes table, which only refreshes on initial
enrichment / manual re-match / lazy on-view — so a newly-announced or rescheduled
episode could be missed indefinitely. (the deep TV scan is a SERVER scan, unrelated.)

new daily automation re-pulls TMDB episode schedules (air dates/stills) for still-airing
watchlist shows so the calendar is fresh when the airing run reads it:
- db.watchlist_continuing_shows(): effective-watchlist library shows that are still airing
  (skips tmdb-only follows — no episodes — and ended/canceled shows; keeps unknown status).
- handler mirrors the standard: injected fetch_shows + refresh_show seams, live per-show
  progress, _manages_own_progress; reuses engine.refresh_show_art (match + episode cascade).
- seeded daily at 23:00 (2h before the 01:00 airing run), registered, UI block, sorted in
  the page right before the airing automation.
seam-level tested + a regression that it's seeded before the airing run.
This commit is contained in:
BoulderBadgeDad 2026-06-26 11:47:15 -07:00
parent 9a8550661b
commit 9aee723d1a
7 changed files with 227 additions and 1 deletions

View file

@ -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": [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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