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:
parent
9a8550661b
commit
9aee723d1a
7 changed files with 227 additions and 1 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
88
core/automation/handlers/video_refresh_airing_schedules.py
Normal file
88
core/automation/handlers/video_refresh_airing_schedules.py
Normal 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}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
96
tests/test_video_refresh_airing_schedules.py
Normal file
96
tests/test_video_refresh_airing_schedules.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue