From ffaa36105bd6d302e1e105fa3c06ec0f37284053 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 08:47:48 -0700 Subject: [PATCH] Video watchlist: actively-airing library shows are watched by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/video/watchlist.py | 23 +++++-- database/video_database.py | 122 ++++++++++++++++++++++++----------- database/video_schema.sql | 5 ++ tests/test_video_database.py | 25 ++++++- 4 files changed, 132 insertions(+), 43 deletions(-) diff --git a/api/video/watchlist.py b/api/video/watchlist.py index f889a9ca..8090cfbb 100644 --- a/api/video/watchlist.py +++ b/api/video/watchlist.py @@ -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: diff --git a/database/video_database.py b/database/video_database.py index 1797b0c3..eff54bc9 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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) diff --git a/database/video_schema.sql b/database/video_schema.sql index 067e484f..3ac46925 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -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) ); diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 7a5b3e59..63b1550c 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -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):