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:
parent
e5a4dda117
commit
ffaa36105b
4 changed files with 132 additions and 43 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue