Watchlist hygiene: auto-prune ended/canceled followed shows
You can eye-add a show to the watchlist before we know its status, so ended/canceled shows leak in (auto-airing LIBRARY shows already exclude ended ones; explicit follows don't). Fix it as cleanup-on-process, per Boulder: the daily 'Wishlist Today's Airings' automation now runs a watchlist-tidy pass first — scans every explicit show follow, resolves its status (local for owned, TMDB for tmdb-only follows), and removes any that have ended/been canceled/completed. Only prunes on a DEFINITIVE terminal status; unknown/lookup-error → left alone. Toggle prune_ended (default on); returns shows_pruned. DB: followed_shows(). Pure prune_ended_show_follows() with injected seams; seam tests.
This commit is contained in:
parent
7fac921afc
commit
66bce2b83a
5 changed files with 199 additions and 13 deletions
|
|
@ -261,7 +261,10 @@ ACTIONS: list[dict] = [
|
|||
{"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_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},
|
||||
"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": [
|
||||
{"key": "prune_ended", "type": "checkbox", "label": "Also remove ended/canceled shows from the watchlist", "default": True}
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,67 @@ def _default_season_meta(tmdb_id: Any, season_number: Any):
|
|||
return get_video_enrichment_engine().tmdb_season(tmdb_id, season_number)
|
||||
|
||||
|
||||
# ── watchlist hygiene: drop shows that have ended/been canceled ───────────────
|
||||
_TERMINAL = ('ended', 'canceled', 'cancelled', 'completed')
|
||||
|
||||
|
||||
def _is_terminal_status(status) -> bool:
|
||||
"""A show that won't air again — pointless to keep on the (watch-for-new) list."""
|
||||
return str(status or '').strip().lower() in _TERMINAL
|
||||
|
||||
|
||||
def _default_fetch_follows() -> List[Dict[str, Any]]:
|
||||
from api.video import get_video_db
|
||||
return get_video_db().followed_shows()
|
||||
|
||||
|
||||
def _default_show_status(tmdb_id: Any) -> Optional[str]:
|
||||
"""TMDB status for a follow with no local status (a tmdb-only follow). Cached by
|
||||
the engine; returns None on any hiccup so we never prune on uncertainty."""
|
||||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||||
d = get_video_enrichment_engine().tmdb_full_detail('show', tmdb_id) or {}
|
||||
return d.get('status')
|
||||
|
||||
|
||||
def _default_remove_show(tmdb_id: Any) -> None:
|
||||
from api.video import get_video_db
|
||||
get_video_db().remove_from_watchlist('show', tmdb_id)
|
||||
|
||||
|
||||
def prune_ended_show_follows(deps, automation_id=None, *, fetch_follows=None,
|
||||
show_status=None, remove_show=None) -> int:
|
||||
"""Remove explicitly-followed shows that are no longer airing.
|
||||
|
||||
Auto-airing LIBRARY shows are already excluded by status, so this targets explicit
|
||||
eye-button follows (which persist regardless). For follows with no local status (a
|
||||
tmdb-only follow), look the status up on TMDB. Only prunes on a DEFINITIVE terminal
|
||||
status — unknown status is left alone. Pure: all I/O injected. Returns count removed."""
|
||||
fetch_follows = fetch_follows or _default_fetch_follows
|
||||
show_status = show_status or _default_show_status
|
||||
remove_show = remove_show or _default_remove_show
|
||||
removed = 0
|
||||
for f in (fetch_follows() or []):
|
||||
tid = f.get('tmdb_id')
|
||||
if not tid:
|
||||
continue
|
||||
status = f.get('status')
|
||||
if not status: # tmdb-only follow — ask TMDB
|
||||
try:
|
||||
status = show_status(tid)
|
||||
except Exception: # noqa: BLE001 - never prune on a lookup failure
|
||||
status = None
|
||||
if status and _is_terminal_status(status):
|
||||
try:
|
||||
remove_show(tid)
|
||||
removed += 1
|
||||
deps.update_progress(
|
||||
automation_id, log_line="Removed ended show '%s' from the watchlist"
|
||||
% (f.get('title') or tid), log_type='info')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def _season_lookup(season_meta, tmdb_id, season_number, cache):
|
||||
"""(season_poster_url, {episode_number: tmdb_episode}) for a show+season, fetched
|
||||
once and cached. A TMDB hiccup degrades to empty (DB values fill in)."""
|
||||
|
|
@ -74,17 +135,32 @@ def auto_video_add_airing_episodes(
|
|||
add_episodes: Optional[Callable[[Any, Any, List[Dict[str, Any]]], int]] = None,
|
||||
today_fn: Optional[Callable[[], str]] = None,
|
||||
season_meta: Optional[Callable[[Any, Any], Any]] = None,
|
||||
prune_follows: Optional[Callable[[], List[Dict[str, Any]]]] = None,
|
||||
show_status: Optional[Callable[[Any], Any]] = None,
|
||||
remove_show: Optional[Callable[[Any], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add today's airing (unowned, followed-show) episodes to the video wishlist.
|
||||
"""Add today's airing (unowned, followed-show) episodes to the video wishlist, and
|
||||
first tidy the watchlist by dropping shows that have ended / been canceled.
|
||||
|
||||
Returns ``{'status': 'completed', 'episodes_added': int, 'shows': int, ...}``."""
|
||||
Returns ``{'status': 'completed', 'episodes_added': int, 'shows': int,
|
||||
'shows_pruned': int, ...}``."""
|
||||
fetch_airing = fetch_airing or _default_fetch_airing
|
||||
add_episodes = add_episodes or _default_add_episodes
|
||||
season_meta = season_meta or _default_season_meta
|
||||
today_fn = today_fn or (lambda: date.today().isoformat())
|
||||
automation_id = config.get('_automation_id')
|
||||
prune_ended = config.get('prune_ended', True)
|
||||
try:
|
||||
today = today_fn()
|
||||
# Watchlist hygiene first: a followed show that has since ended/been canceled
|
||||
# won't air again, so drop it (ended LIBRARY shows are already auto-excluded;
|
||||
# this catches explicit eye-button follows).
|
||||
pruned = 0
|
||||
if prune_ended:
|
||||
deps.update_progress(automation_id, phase='Tidying the watchlist…', progress=10,
|
||||
log_line='Removing shows that have ended or been canceled', log_type='info')
|
||||
pruned = prune_ended_show_follows(deps, automation_id, fetch_follows=prune_follows,
|
||||
show_status=show_status, remove_show=remove_show)
|
||||
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 []
|
||||
|
|
@ -126,13 +202,15 @@ def auto_video_add_airing_episodes(
|
|||
_show_poster_url(grp['library_id'])) or 0)
|
||||
shows = len(by_show)
|
||||
|
||||
done = ('Added %d airing episode(s) across %d show(s) to the wishlist'
|
||||
% (added, shows)) if added else 'No new airing episodes to wishlist today'
|
||||
if pruned:
|
||||
done += ' · pruned %d ended show(s)' % pruned
|
||||
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')
|
||||
log_line=done, log_type='success')
|
||||
return {'status': 'completed', 'episodes_added': added, 'shows': shows,
|
||||
'_manages_own_progress': True}
|
||||
'shows_pruned': pruned, '_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}
|
||||
|
|
|
|||
|
|
@ -2055,6 +2055,19 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
def followed_shows(self) -> list[dict]:
|
||||
"""Explicitly-followed shows (state='follow') with their library status when
|
||||
owned (NULL for tmdb-only follows). Used by the watchlist-prune pass to drop
|
||||
shows that have since ended/been canceled."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT w.tmdb_id, w.title, w.library_id, s.status "
|
||||
"FROM video_watchlist w LEFT JOIN shows s ON s.id = w.library_id "
|
||||
"WHERE w.kind='show' AND w.state='follow'")]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def test_adds_unowned_airings_grouped_by_show():
|
|||
return len(eps)
|
||||
|
||||
res = auto_video_add_airing_episodes(
|
||||
{"_automation_id": "a1"}, _Deps(),
|
||||
{"_automation_id": "a1", "prune_ended": False}, _Deps(),
|
||||
fetch_airing=lambda today: rows, add_episodes=add, today_fn=lambda: "2026-06-21",
|
||||
season_meta=lambda *a: None)
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ def test_uses_tmdb_season_metadata_like_a_manual_add():
|
|||
captured["eps"] = eps
|
||||
return len(eps)
|
||||
|
||||
auto_video_add_airing_episodes({"_automation_id": "a"}, _Deps(),
|
||||
auto_video_add_airing_episodes({"_automation_id": "a", "prune_ended": False}, _Deps(),
|
||||
fetch_airing=lambda t: rows, add_episodes=add,
|
||||
today_fn=lambda: "2026-06-21", season_meta=season_meta)
|
||||
ep = captured["eps"][0]
|
||||
|
|
@ -85,7 +85,7 @@ def test_falls_back_to_db_values_when_tmdb_unavailable():
|
|||
captured["eps"] = eps
|
||||
return len(eps)
|
||||
|
||||
auto_video_add_airing_episodes({"_automation_id": "a"}, _Deps(),
|
||||
auto_video_add_airing_episodes({"_automation_id": "a", "prune_ended": False}, _Deps(),
|
||||
fetch_airing=lambda t: rows, add_episodes=add,
|
||||
today_fn=lambda: "2026-06-21", season_meta=lambda *a: None)
|
||||
ep = captured["eps"][0]
|
||||
|
|
@ -100,14 +100,14 @@ def test_queries_the_calendar_for_today():
|
|||
seen["today"] = today
|
||||
return []
|
||||
|
||||
auto_video_add_airing_episodes({"_automation_id": "a"}, _Deps(),
|
||||
auto_video_add_airing_episodes({"_automation_id": "a", "prune_ended": False}, _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(),
|
||||
res = auto_video_add_airing_episodes({"_automation_id": "a", "prune_ended": False}, _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
|
||||
|
|
@ -118,7 +118,64 @@ def test_error_is_caught_and_reported():
|
|||
raise RuntimeError("calendar down")
|
||||
|
||||
deps = _Deps()
|
||||
res = auto_video_add_airing_episodes({"_automation_id": "a"}, deps,
|
||||
res = auto_video_add_airing_episodes({"_automation_id": "a", "prune_ended": False}, 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)
|
||||
|
||||
|
||||
# ── watchlist hygiene: prune ended/canceled follows ─────────────────────────
|
||||
from core.automation.handlers.video_auto_wishlist_airing import prune_ended_show_follows # noqa: E402
|
||||
|
||||
|
||||
def test_prune_removes_ended_and_canceled_follows():
|
||||
follows = [
|
||||
{"tmdb_id": 1, "title": "Returning Show", "status": "Returning Series"},
|
||||
{"tmdb_id": 2, "title": "Done Show", "status": "Ended"},
|
||||
{"tmdb_id": 3, "title": "Axed Show", "status": "Canceled"},
|
||||
{"tmdb_id": 4, "title": "Live Show", "status": "In Production"},
|
||||
]
|
||||
removed = []
|
||||
n = prune_ended_show_follows(_Deps(), "a", fetch_follows=lambda: follows,
|
||||
show_status=lambda t: None, remove_show=removed.append)
|
||||
assert n == 2 and removed == [2, 3] # only Ended + Canceled
|
||||
|
||||
|
||||
def test_prune_looks_up_status_for_tmdb_only_follows():
|
||||
# no local status → fetch from TMDB; ended → prune
|
||||
follows = [{"tmdb_id": 9, "title": "TMDB-only", "status": None}]
|
||||
removed = []
|
||||
prune_ended_show_follows(_Deps(), "a", fetch_follows=lambda: follows,
|
||||
show_status=lambda t: "Ended", remove_show=removed.append)
|
||||
assert removed == [9]
|
||||
|
||||
|
||||
def test_prune_never_removes_on_unknown_status_or_lookup_error():
|
||||
def _boom(t):
|
||||
raise RuntimeError("tmdb down")
|
||||
follows = [{"tmdb_id": 1, "title": "Unknown", "status": None}, # lookup returns None
|
||||
{"tmdb_id": 2, "title": "Errored", "status": None}] # lookup raises
|
||||
removed = []
|
||||
n = prune_ended_show_follows(
|
||||
_Deps(), "a", fetch_follows=lambda: follows,
|
||||
show_status=lambda t: (_boom(t) if t == 2 else None), remove_show=removed.append)
|
||||
assert n == 0 and removed == [] # uncertainty → keep
|
||||
|
||||
|
||||
def test_airing_handler_runs_the_prune_pass():
|
||||
removed = []
|
||||
res = auto_video_add_airing_episodes(
|
||||
{"_automation_id": "a"}, _Deps(),
|
||||
fetch_airing=lambda t: [], add_episodes=lambda *a, **k: 0, today_fn=lambda: "2026-06-21",
|
||||
prune_follows=lambda: [{"tmdb_id": 7, "title": "Old", "status": "Ended"}],
|
||||
show_status=lambda t: None, remove_show=removed.append)
|
||||
assert res["status"] == "completed" and res["shows_pruned"] == 1 and removed == [7]
|
||||
|
||||
|
||||
def test_airing_handler_can_disable_the_prune():
|
||||
called = {"n": 0}
|
||||
auto_video_add_airing_episodes(
|
||||
{"_automation_id": "a", "prune_ended": False}, _Deps(),
|
||||
fetch_airing=lambda t: [], add_episodes=lambda *a, **k: 0, today_fn=lambda: "2026-06-21",
|
||||
prune_follows=lambda: called.update(n=called["n"] + 1) or [])
|
||||
assert called["n"] == 0 # prune skipped entirely
|
||||
|
|
|
|||
35
tests/test_video_followed_shows.py
Normal file
35
tests/test_video_followed_shows.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""followed_shows(): explicit show follows + their library status — feeds the
|
||||
watchlist-prune pass that drops ended/canceled shows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from database.video_database import VideoDatabase
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
|
||||
|
||||
|
||||
def test_lists_explicit_follows_with_library_status(db):
|
||||
# a tmdb-only follow (no library row → status None)
|
||||
db.add_to_watchlist("show", 100, "TMDB Only")
|
||||
# a follow backed by a library show carrying a status
|
||||
conn = db._get_connection()
|
||||
conn.execute("INSERT INTO shows (id, server_source, title, tmdb_id, status) "
|
||||
"VALUES (5, 'plex', 'Owned Show', 200, 'Ended')")
|
||||
conn.commit(); conn.close()
|
||||
db.add_to_watchlist("show", 200, "Owned Show", library_id=5)
|
||||
|
||||
rows = {r["tmdb_id"]: r for r in db.followed_shows()}
|
||||
assert rows[100]["status"] is None # tmdb-only → no local status
|
||||
assert rows[200]["status"] == "Ended" # owned → carries the status
|
||||
|
||||
|
||||
def test_excludes_muted_and_people(db):
|
||||
db.add_to_watchlist("show", 1, "A")
|
||||
db.remove_from_watchlist("show", 1) # mute (tombstone)
|
||||
db.add_to_watchlist("person", 2, "Someone")
|
||||
assert db.followed_shows() == [] # neither muted shows nor people
|
||||
Loading…
Reference in a new issue