Video watchlist: actively-airing library shows are watched by default

Owning a still-running show means you want its new episodes, so it's on the
watchlist without a click. Implemented as a computed default + explicit-override
so it stays correct:

- video_watchlist gains a 'state' column: 'follow' (explicit) | 'mute' (a
  tombstone — user un-followed an airing show that's on by default, so the
  default must not silently re-add it).
- Effective watchlist (list/state/counts) = explicit follows  ∪  library shows
  whose status isn't ended/canceled, minus mutes. Computed at READ time, so it
  always tracks the library + a show's status — no scanner hook, no re-seeding.
- remove() now writes a mute tombstone (idempotent) instead of deleting; add()
  sets state='follow' and clears any mute. Scoped to the active video server.

The existing library-card eye now paints 'watched' on airing shows by default;
clicking mutes, clicking again re-follows.

4 tests updated/added incl. the airing-default + mute + re-follow flow. 80 video
tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-16 08:47:48 -07:00
parent e5a4dda117
commit ffaa36105b
4 changed files with 132 additions and 43 deletions

View file

@ -17,18 +17,31 @@ logger = get_logger("video_api.watchlist")
_KINDS = ("show", "person")
def _server():
"""Active video server_source (scopes the airing-show default). None on error."""
try:
from core.video.sources import resolve_video_server
return resolve_video_server()
except Exception:
return None
def register_routes(bp):
@bp.route("/watchlist", methods=["GET"])
def video_watchlist_list():
"""All watchlist entries grouped by kind (for the tabbed page)."""
"""All watchlist entries grouped by kind (for the tabbed page).
Shows include actively-airing library shows by default, scoped to the
active video server so Plex/Jellyfin libraries don't commingle."""
from . import get_video_db
try:
db = get_video_db()
server = _server()
kind = request.args.get("kind")
if kind in _KINDS:
items = db.list_watchlist(kind)
items = db.list_watchlist(kind, server_source=server)
return jsonify({"success": True, "kind": kind, "items": items})
rows = db.list_watchlist()
rows = db.list_watchlist(server_source=server)
shows = [r for r in rows if r.get("kind") == "show"]
people = [r for r in rows if r.get("kind") == "person"]
return jsonify({"success": True, "shows": shows, "people": people,
@ -42,7 +55,7 @@ def register_routes(bp):
def video_watchlist_counts():
from . import get_video_db
try:
return jsonify({"success": True, **get_video_db().watchlist_counts()})
return jsonify({"success": True, **get_video_db().watchlist_counts(server_source=_server())})
except Exception:
logger.exception("Failed to count video watchlist")
return jsonify({"success": False, "error": "Failed"}), 500
@ -96,7 +109,7 @@ def register_routes(bp):
if kind not in _KINDS:
return jsonify({"success": False, "error": "kind is required"}), 400
try:
state = get_video_db().watchlist_state(kind, ids)
state = get_video_db().watchlist_state(kind, ids, server_source=_server())
# JSON object keys must be strings.
return jsonify({"success": True, "results": {str(k): True for k in state}})
except Exception:

View file

@ -98,6 +98,7 @@ _COLUMN_MIGRATIONS = [
("movies", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
("shows", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
("shows", "airs_time", "TEXT"), # TVDB show air time, e.g. "21:00" (network local)
("video_watchlist", "state", "TEXT NOT NULL DEFAULT 'follow'"), # follow | mute (tombstone)
]
@ -1212,19 +1213,27 @@ class VideoDatabase:
# Mirrors the music watchlist_artists model: an explicit follow-list that may
# include shows/people not in the library yet. Keyed on (kind, tmdb_id). The
# monitoring/discovery engine is a later phase — these just manage membership.
# An actively-airing library show (status present and not finished) is on the
# watchlist BY DEFAULT — owning a still-running show means you want its new
# episodes. The `state` rows store only explicit user decisions; this default
# is computed at read time so it always tracks the library + a show's status.
_ACTIVE_SHOW_SQL = ("status IS NOT NULL AND TRIM(status) <> '' "
"AND LOWER(status) NOT IN ('ended', 'canceled', 'cancelled', 'completed')")
def add_to_watchlist(self, kind: str, tmdb_id: int, title: str,
poster_url: str | None = None, library_id: int | None = None) -> bool:
"""Add a show or person. Idempotent upsert on (kind, tmdb_id) — re-adding
refreshes title/poster/library_id without duplicating. Returns True on success."""
"""Explicitly follow a show/person (state='follow'). Idempotent upsert on
(kind, tmdb_id) re-adding refreshes title/poster/library_id and clears
any 'mute' tombstone. Returns True on success."""
if kind not in ("show", "person") or not tmdb_id or not title:
return False
conn = self._get_connection()
try:
conn.execute(
"""INSERT INTO video_watchlist (kind, tmdb_id, title, poster_url, library_id)
VALUES (?, ?, ?, ?, ?)
"""INSERT INTO video_watchlist (kind, tmdb_id, title, poster_url, library_id, state)
VALUES (?, ?, ?, ?, ?, 'follow')
ON CONFLICT(kind, tmdb_id) DO UPDATE SET
title=excluded.title,
state='follow', title=excluded.title,
poster_url=COALESCE(excluded.poster_url, video_watchlist.poster_url),
library_id=COALESCE(excluded.library_id, video_watchlist.library_id)""",
(kind, int(tmdb_id), title, poster_url, library_id))
@ -1237,36 +1246,71 @@ class VideoDatabase:
conn.close()
def remove_from_watchlist(self, kind: str, tmdb_id: int) -> bool:
"""Drop a show/person from the watchlist. Returns True if a row went."""
"""Un-follow. Stored as a 'mute' tombstone (not a delete) so an
actively-airing library show watched by default is not silently
re-added. Returns True."""
if kind not in ("show", "person") or not tmdb_id:
return False
conn = self._get_connection()
try:
cur = conn.execute("DELETE FROM video_watchlist WHERE kind=? AND tmdb_id=?",
(kind, int(tmdb_id)))
conn.execute(
"""INSERT INTO video_watchlist (kind, tmdb_id, title, state)
VALUES (?, ?, '', 'mute')
ON CONFLICT(kind, tmdb_id) DO UPDATE SET state='mute'""",
(kind, int(tmdb_id)))
conn.commit()
return cur.rowcount > 0
return True
finally:
conn.close()
def list_watchlist(self, kind: str | None = None) -> list[dict]:
"""Watchlist entries newest-first; optionally filtered to one kind."""
def _effective_shows(self, conn, server_source) -> list[dict]:
"""Explicit show follows actively-airing library shows (not muted)."""
out, seen = [], set()
for r in conn.execute(
"SELECT tmdb_id, title, poster_url, library_id, date_added FROM video_watchlist "
"WHERE kind='show' AND state='follow' ORDER BY date_added DESC, id DESC"):
d = dict(r); d["kind"] = "show"; out.append(d); seen.add(r["tmdb_id"])
muted = {r["tmdb_id"] for r in conn.execute(
"SELECT tmdb_id FROM video_watchlist WHERE kind='show' AND state='mute'")}
sql = ("SELECT tmdb_id, title, id AS library_id FROM shows "
"WHERE tmdb_id IS NOT NULL AND " + self._ACTIVE_SHOW_SQL)
args: list = []
if server_source:
sql += " AND server_source = ?"; args.append(server_source)
sql += " ORDER BY COALESCE(sort_title, title) COLLATE NOCASE"
for r in conn.execute(sql, args):
tid = r["tmdb_id"]
if tid in seen or tid in muted:
continue
seen.add(tid)
out.append({"kind": "show", "tmdb_id": tid, "title": r["title"],
"poster_url": "/api/video/poster/show/%d" % r["library_id"],
"library_id": r["library_id"], "date_added": None, "auto": True})
return out
def list_watchlist(self, kind: str | None = None, server_source=None) -> list[dict]:
"""Effective watchlist. Shows include the airing-library default; people
are explicit follows only."""
conn = self._get_connection()
try:
sql = ("SELECT kind, tmdb_id, title, poster_url, library_id, date_added "
"FROM video_watchlist")
args: list = []
if kind in ("show", "person"):
sql += " WHERE kind=?"
args.append(kind)
sql += " ORDER BY date_added DESC, id DESC"
return [dict(r) for r in conn.execute(sql, args).fetchall()]
people = []
if kind in (None, "person"):
for r in conn.execute(
"SELECT tmdb_id, title, poster_url, library_id, date_added FROM video_watchlist "
"WHERE kind='person' AND state='follow' ORDER BY date_added DESC, id DESC"):
d = dict(r); d["kind"] = "person"; people.append(d)
shows = self._effective_shows(conn, server_source) if kind in (None, "show") else []
if kind == "person":
return people
if kind == "show":
return shows
return shows + people
finally:
conn.close()
def watchlist_state(self, kind: str, tmdb_ids) -> dict:
"""{tmdb_id: True} for the given ids that are on the watchlist — used to
hydrate card buttons. Ids not on the list are simply absent."""
def watchlist_state(self, kind: str, tmdb_ids, server_source=None) -> dict:
"""{tmdb_id: True} for ids that are watched — explicit follow OR (for
shows) an actively-airing library show that isn't muted. Hydrates buttons."""
out: dict = {}
ids = [int(x) for x in (tmdb_ids or []) if x]
if kind not in ("show", "person") or not ids:
@ -1276,26 +1320,30 @@ class VideoDatabase:
for i in range(0, len(ids), 400): # stay under SQLite's variable cap
chunk = ids[i:i + 400]
ph = ",".join("?" * len(chunk))
rows = conn.execute(
f"SELECT tmdb_id FROM video_watchlist WHERE kind=? AND tmdb_id IN ({ph})",
[kind] + chunk).fetchall()
for r in rows:
for r in conn.execute(
f"SELECT tmdb_id FROM video_watchlist WHERE kind=? AND state='follow' "
f"AND tmdb_id IN ({ph})", [kind] + chunk):
out[r["tmdb_id"]] = True
if kind == "show":
muted = {r["tmdb_id"] for r in conn.execute(
f"SELECT tmdb_id FROM video_watchlist WHERE kind='show' AND state='mute' "
f"AND tmdb_id IN ({ph})", chunk)}
ssql = f"SELECT tmdb_id FROM shows WHERE tmdb_id IN ({ph}) AND " + self._ACTIVE_SHOW_SQL
sargs = list(chunk)
if server_source:
ssql += " AND server_source = ?"; sargs.append(server_source)
for r in conn.execute(ssql, sargs):
if r["tmdb_id"] not in muted:
out[r["tmdb_id"]] = True
return out
finally:
conn.close()
def watchlist_counts(self) -> dict:
"""{'show': n, 'person': n, 'total': n} for badges/headers."""
conn = self._get_connection()
try:
rows = conn.execute(
"SELECT kind, COUNT(*) AS n FROM video_watchlist GROUP BY kind").fetchall()
counts = {r["kind"]: r["n"] for r in rows}
return {"show": counts.get("show", 0), "person": counts.get("person", 0),
"total": sum(counts.values())}
finally:
conn.close()
def watchlist_counts(self, server_source=None) -> dict:
"""{'show': n, 'person': n, 'total': n} over the EFFECTIVE watchlist."""
shows = self.list_watchlist("show", server_source=server_source)
people = self.list_watchlist("person")
return {"show": len(shows), "person": len(people), "total": len(shows) + len(people)}
def movie_detail(self, movie_id: int) -> dict | None:
"""Full movie detail: the movie + owned/file info. Drives the (isolated)

View file

@ -343,6 +343,11 @@ CREATE TABLE IF NOT EXISTS video_watchlist (
title TEXT NOT NULL, -- show title / person name
poster_url TEXT, -- poster (show) / photo (person)
library_id INTEGER, -- shows.id when owned (else NULL)
-- 'follow' = explicit user follow. 'mute' = a TOMBSTONE: the user
-- un-followed something that is on the watchlist by default (an actively
-- airing library show), so the default must not re-add it. Library shows
-- that are still airing are watched by default WITHOUT a row here.
state TEXT NOT NULL DEFAULT 'follow',
date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(kind, tmdb_id)
);

View file

@ -780,7 +780,30 @@ def test_watchlist_add_list_remove(db):
assert {r["kind"] for r in rows} == {"show", "person"}
assert db.list_watchlist("person")[0]["title"] == "Brad Pitt"
assert db.remove_from_watchlist("show", 1399) is True
assert db.remove_from_watchlist("show", 1399) is False # already gone
assert db.remove_from_watchlist("show", 1399) is True # idempotent (mute tombstone)
assert all(r["tmdb_id"] != 1399 for r in db.list_watchlist("show")) # gone from the list
def test_watchlist_airing_library_shows_are_watched_by_default(db):
# Two library shows: one still airing, one ended. No explicit watchlist rows.
with db.connect() as conn:
conn.execute("INSERT INTO shows(id,title,tmdb_id,status) VALUES (1,'Severance',95396,'continuing')")
conn.execute("INSERT INTO shows(id,title,tmdb_id,status) VALUES (2,'The Wire',1438,'ended')")
conn.commit()
# The airing show is on the watchlist by default; the ended one is not.
shows = db.list_watchlist("show")
assert [s["tmdb_id"] for s in shows] == [95396]
assert shows[0].get("auto") is True and shows[0]["library_id"] == 1
assert db.watchlist_state("show", [95396, 1438]) == {95396: True} # ended one absent
# Un-following the airing show (mute tombstone) drops it from the default…
db.remove_from_watchlist("show", 95396)
assert db.list_watchlist("show") == []
assert db.watchlist_state("show", [95396]) == {}
# …and explicitly re-following brings it back (clears the mute).
db.add_to_watchlist("show", 95396, "Severance")
assert db.watchlist_state("show", [95396]) == {95396: True}
def test_watchlist_reAdd_is_upsert_and_coalesces_library_id(db):