Video wishlist: schema + DB layer (movies + episodes)

The curated 'get this' list. Atomic units are movies and episodes; adding a
whole show/season expands into episode rows (show/season are bulk ops). Upcoming
episodes stay out — the watchlist/calendar promote them once they air.

- video_wishlist table + two partial unique indexes (one per movie tmdb_id, one
  per (show tmdb_id, season, episode)) so the shapes don't collide and re-adds
  upsert. SCHEMA_VERSION 8 -> 9 (executescript creates it on existing DBs).
- DB: add_movie_to_wishlist, add_episodes_to_wishlist (bulk), remove_from_wishlist
  (movie/show/season/episode scope), query_wishlist (movies | shows grouped
  show->season->episode w/ wanted/done roll-ups, searched+paged), wishlist_counts,
  wishlist_state (hydration).

Tests: +7 (idempotent upserts, show-tree grouping, scoped removes, movie/episode
same-tmdb don't collide, hydration, search+paging). DB suite: 68 passed.
This commit is contained in:
BoulderBadgeDad 2026-06-16 16:03:25 -07:00
parent 246c650db4
commit 110f23f555
3 changed files with 299 additions and 1 deletions

View file

@ -29,7 +29,7 @@ logger = get_logger("video_database")
# Bump when video_schema.sql changes in a way worth recording. Stored in
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
SCHEMA_VERSION = 8
SCHEMA_VERSION = 9
_DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
@ -1473,6 +1473,198 @@ class VideoDatabase:
"page": page, "total_pages": total_pages, "total_count": total,
"has_prev": page > 1, "has_next": page < total_pages}}
# ── Wishlist (curated 'get this': movies + episodes) ──────────────────────
# Atomic units are movies and episodes. Adding a whole show/season expands
# into episode rows (the caller supplies the explicit episodes); show/season
# are just bulk add/remove operations over those rows.
def add_movie_to_wishlist(self, tmdb_id, title, *, year=None, poster_url=None,
library_id=None, server_source=None) -> bool:
"""Wish for a movie. Idempotent upsert on its tmdb id."""
if not tmdb_id or not title:
return False
conn = self._get_connection()
try:
conn.execute(
"""INSERT INTO video_wishlist (kind, tmdb_id, title, poster_url, year, library_id, server_source)
VALUES ('movie', ?, ?, ?, ?, ?, ?)
ON CONFLICT(tmdb_id) WHERE kind='movie' DO UPDATE SET
title=excluded.title,
poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url),
year=COALESCE(excluded.year, video_wishlist.year),
library_id=COALESCE(excluded.library_id, video_wishlist.library_id)""",
(int(tmdb_id), title, poster_url, year, library_id, server_source))
conn.commit()
return True
except Exception:
logger.exception("add_movie_to_wishlist failed (%s)", tmdb_id)
return False
finally:
conn.close()
def add_episodes_to_wishlist(self, show_tmdb_id, show_title, episodes, *,
poster_url=None, library_id=None, server_source=None) -> int:
"""Wish for one or more episodes of a show (the show's tmdb id keys them).
``episodes`` = [{season_number, episode_number, title?, air_date?}, ].
Idempotent per (show, season, episode). Returns the count written."""
if not show_tmdb_id or not show_title or not episodes:
return 0
conn = self._get_connection()
n = 0
try:
for e in episodes:
sn, en = e.get("season_number"), e.get("episode_number")
if sn is None or en is None:
continue
conn.execute(
"""INSERT INTO video_wishlist
(kind, tmdb_id, title, poster_url, season_number, episode_number,
episode_title, air_date, library_id, server_source)
VALUES ('episode', ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tmdb_id, season_number, episode_number) WHERE kind='episode' DO UPDATE SET
title=excluded.title,
poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url),
episode_title=COALESCE(excluded.episode_title, video_wishlist.episode_title),
air_date=COALESCE(excluded.air_date, video_wishlist.air_date),
library_id=COALESCE(excluded.library_id, video_wishlist.library_id)""",
(int(show_tmdb_id), show_title, poster_url, int(sn), int(en),
e.get("title"), e.get("air_date"), library_id, server_source))
n += 1
conn.commit()
return n
except Exception:
logger.exception("add_episodes_to_wishlist failed (%s)", show_tmdb_id)
conn.rollback()
return 0
finally:
conn.close()
def remove_from_wishlist(self, scope, *, tmdb_id, season_number=None, episode_number=None) -> int:
"""Remove at any granularity: 'movie' | 'show' (all its episodes) |
'season' | 'episode'. Returns the number of rows removed."""
if not tmdb_id:
return 0
if scope == "movie":
sql, args = "DELETE FROM video_wishlist WHERE kind='movie' AND tmdb_id=?", (int(tmdb_id),)
elif scope == "show":
sql, args = "DELETE FROM video_wishlist WHERE kind='episode' AND tmdb_id=?", (int(tmdb_id),)
elif scope == "season":
if season_number is None:
return 0
sql = "DELETE FROM video_wishlist WHERE kind='episode' AND tmdb_id=? AND season_number=?"
args = (int(tmdb_id), int(season_number))
elif scope == "episode":
if season_number is None or episode_number is None:
return 0
sql = ("DELETE FROM video_wishlist WHERE kind='episode' AND tmdb_id=? "
"AND season_number=? AND episode_number=?")
args = (int(tmdb_id), int(season_number), int(episode_number))
else:
return 0
conn = self._get_connection()
try:
cur = conn.execute(sql, args)
conn.commit()
return cur.rowcount
finally:
conn.close()
def wishlist_counts(self) -> dict:
"""{'movie': n, 'show': n, 'episode': n, 'total': movies+episodes}."""
conn = self._get_connection()
try:
movie = conn.execute("SELECT COUNT(*) c FROM video_wishlist WHERE kind='movie'").fetchone()["c"]
episode = conn.execute("SELECT COUNT(*) c FROM video_wishlist WHERE kind='episode'").fetchone()["c"]
shows = conn.execute("SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist WHERE kind='episode'").fetchone()["c"]
return {"movie": movie, "show": shows, "episode": episode, "total": movie + episode}
finally:
conn.close()
def query_wishlist(self, kind: str, *, search=None, page=1, limit=60) -> dict:
"""One paged slice of the wishlist. kind='movie' → movie cards; kind='show'
shows grouped showseasonepisode with wanted/done roll-ups. {items,
pagination} like the other paged queries."""
try:
page = max(1, int(page or 1))
limit = max(1, min(200, int(limit or 60)))
except (TypeError, ValueError):
page, limit = 1, 60
s = (search or "").strip()
conn = self._get_connection()
try:
if kind == "movie":
where, args = ["kind='movie'"], []
if s:
where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%")
wsql = " WHERE " + " AND ".join(where)
total = conn.execute("SELECT COUNT(*) c FROM video_wishlist" + wsql, args).fetchone()["c"]
rows = conn.execute(
"SELECT tmdb_id, title, poster_url, year, status, library_id, date_added "
"FROM video_wishlist" + wsql + " ORDER BY date_added DESC, id DESC LIMIT ? OFFSET ?",
args + [limit, (page - 1) * limit]).fetchall()
items = [{"kind": "movie", "tmdb_id": r["tmdb_id"], "title": r["title"],
"poster_url": r["poster_url"], "year": r["year"], "status": r["status"],
"library_id": r["library_id"]} for r in rows]
else: # shows (grouped from episode rows)
where, args = ["kind='episode'"], []
if s:
where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%")
wsql = " WHERE " + " AND ".join(where)
total = conn.execute(
"SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist" + wsql, args).fetchone()["c"]
show_rows = conn.execute(
"SELECT tmdb_id, MAX(title) AS title, MAX(poster_url) AS poster_url, "
"COUNT(*) AS wanted, "
"SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) AS done, "
"MAX(date_added) AS last_added "
"FROM video_wishlist" + wsql +
" GROUP BY tmdb_id ORDER BY last_added DESC LIMIT ? OFFSET ?",
args + [limit, (page - 1) * limit]).fetchall()
items = []
for sr in show_rows:
eps = conn.execute(
"SELECT season_number, episode_number, episode_title, air_date, status "
"FROM video_wishlist WHERE kind='episode' AND tmdb_id=? "
"ORDER BY season_number, episode_number", (sr["tmdb_id"],)).fetchall()
by_season: dict = {}
for e in eps:
by_season.setdefault(e["season_number"], []).append({
"episode_number": e["episode_number"], "title": e["episode_title"],
"air_date": e["air_date"], "status": e["status"]})
seasons = [{"season_number": sn, "episodes": by_season[sn]}
for sn in sorted(by_season)]
items.append({"kind": "show", "tmdb_id": sr["tmdb_id"], "title": sr["title"],
"poster_url": sr["poster_url"], "wanted": sr["wanted"],
"done": sr["done"] or 0, "seasons": seasons})
finally:
conn.close()
total_pages = max(1, (total + limit - 1) // limit)
page = min(page, total_pages)
return {"items": items, "pagination": {
"page": page, "total_pages": total_pages, "total_count": total,
"has_prev": page > 1, "has_next": page < total_pages}}
def wishlist_state(self, *, movie_ids=None, show_tmdb_id=None) -> dict:
"""Hydration: which of ``movie_ids`` are wishlisted, and which episode
keys ('S_E') of ``show_tmdb_id`` are. Returns {movies:set, episodes:set}."""
out = {"movies": set(), "episodes": set()}
conn = self._get_connection()
try:
ids = [int(x) for x in (movie_ids or []) if x]
for i in range(0, len(ids), 400):
chunk = ids[i:i + 400]
ph = ",".join("?" * len(chunk))
for r in conn.execute(
f"SELECT tmdb_id FROM video_wishlist WHERE kind='movie' AND tmdb_id IN ({ph})", chunk):
out["movies"].add(r["tmdb_id"])
if show_tmdb_id:
for r in conn.execute(
"SELECT season_number, episode_number FROM video_wishlist "
"WHERE kind='episode' AND tmdb_id=?", (int(show_tmdb_id),)):
out["episodes"].add("%s_%s" % (r["season_number"], r["episode_number"]))
return out
finally:
conn.close()
def movie_detail(self, movie_id: int) -> dict | None:
"""Full movie detail: the movie + owned/file info. Drives the (isolated)
video movie-detail page."""

View file

@ -353,6 +353,34 @@ CREATE TABLE IF NOT EXISTS video_watchlist (
);
CREATE INDEX IF NOT EXISTS idx_video_watchlist_kind ON video_watchlist(kind);
-- WISHLIST (curated 'get this') — atomic units are MOVIES and EPISODES. Adding a
-- whole show or a season just expands into episode rows. Upcoming (un-aired)
-- episodes do NOT live here; the watchlist/calendar promote them once they air,
-- so the wishlist only ever holds things you can actually acquire right now.
CREATE TABLE IF NOT EXISTS video_wishlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- 'movie' | 'episode'
tmdb_id INTEGER NOT NULL, -- movie's tmdb id | the SHOW's tmdb id (episode rows)
title TEXT NOT NULL, -- movie title | show title
poster_url TEXT, -- movie/show poster
year INTEGER, -- movie year (movie rows)
season_number INTEGER, -- episode rows
episode_number INTEGER, -- episode rows
episode_title TEXT, -- episode rows
air_date TEXT, -- episode rows (already aired by the time it's here)
status TEXT NOT NULL DEFAULT 'wanted', -- wanted|searching|downloading|downloaded|failed
library_id INTEGER, -- owned movies.id/shows.id when re-downloading
server_source TEXT, -- server context that added it (informational)
date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- one row per movie, one per (show, season, episode) — partial uniques so the two
-- shapes don't collide and re-adding is an idempotent upsert.
CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_movie
ON video_wishlist(tmdb_id) WHERE kind = 'movie';
CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_episode
ON video_wishlist(tmdb_id, season_number, episode_number) WHERE kind = 'episode';
CREATE INDEX IF NOT EXISTS idx_video_wishlist_show ON video_wishlist(tmdb_id) WHERE kind = 'episode';
-- ── Derived views: Watchlist / Wishlist / Calendar ──────────────────────────
-- WATCHLIST = things you follow for NEW content: monitored shows + channels.
CREATE VIEW IF NOT EXISTS v_watchlist AS

View file

@ -843,3 +843,81 @@ def test_query_watchlist_paginates_and_searches(db):
assert res2["pagination"]["total_count"] == 1
# page beyond range clamps to the last page
assert db.query_watchlist("person", page=99, limit=3)["pagination"]["page"] == 3
# ── wishlist (movies + episodes; show/season are bulk ops over episodes) ──────
def test_wishlist_movie_add_is_idempotent(db):
assert db.add_movie_to_wishlist(603, "The Matrix", year=1999, poster_url="/m.jpg") is True
db.add_movie_to_wishlist(603, "The Matrix", year=1999) # re-add → upsert, not dup
res = db.query_wishlist("movie")
assert len(res["items"]) == 1
m = res["items"][0]
assert m["tmdb_id"] == 603 and m["year"] == 1999 and m["poster_url"] == "/m.jpg" # poster kept
assert db.wishlist_counts()["movie"] == 1
def test_wishlist_episodes_group_into_show_tree(db):
n = db.add_episodes_to_wishlist(1396, "Breaking Bad", [
{"season_number": 1, "episode_number": 1, "title": "Pilot", "air_date": "2008-01-20"},
{"season_number": 1, "episode_number": 2, "title": "Cat's in the Bag"},
{"season_number": 2, "episode_number": 1, "title": "Seven Thirty-Seven"}],
poster_url="/bb.jpg")
assert n == 3
db.add_episodes_to_wishlist(1396, "Breaking Bad", [
{"season_number": 1, "episode_number": 1, "title": "Pilot"}]) # re-add → no dup
res = db.query_wishlist("show")
assert res["pagination"]["total_count"] == 1 # one show
show = res["items"][0]
assert show["tmdb_id"] == 1396 and show["wanted"] == 3 and show["done"] == 0
assert [s["season_number"] for s in show["seasons"]] == [1, 2]
assert [e["episode_number"] for e in show["seasons"][0]["episodes"]] == [1, 2]
counts = db.wishlist_counts()
assert counts == {"movie": 0, "show": 1, "episode": 3, "total": 3}
def test_wishlist_remove_scopes(db):
db.add_movie_to_wishlist(603, "The Matrix")
db.add_episodes_to_wishlist(1396, "Breaking Bad", [
{"season_number": 1, "episode_number": 1}, {"season_number": 1, "episode_number": 2},
{"season_number": 2, "episode_number": 1}])
# remove one episode
assert db.remove_from_wishlist("episode", tmdb_id=1396, season_number=1, episode_number=2) == 1
assert db.wishlist_counts()["episode"] == 2
# remove a whole season
assert db.remove_from_wishlist("season", tmdb_id=1396, season_number=1) == 1 # only S1E1 left in S1
assert db.wishlist_counts()["episode"] == 1
# remove the whole show (its remaining episodes)
assert db.remove_from_wishlist("show", tmdb_id=1396) == 1
assert db.wishlist_counts()["show"] == 0
# movie still there; remove it
assert db.remove_from_wishlist("movie", tmdb_id=603) == 1
assert db.wishlist_counts()["total"] == 0
def test_wishlist_movie_and_episode_same_tmdb_dont_collide(db):
# A movie and a show could share a tmdb id across namespaces — the partial
# uniques must keep them independent.
db.add_movie_to_wishlist(42, "Some Movie")
db.add_episodes_to_wishlist(42, "Some Show", [{"season_number": 1, "episode_number": 1}])
c = db.wishlist_counts()
assert c["movie"] == 1 and c["episode"] == 1
def test_wishlist_state_hydration(db):
db.add_movie_to_wishlist(603, "The Matrix")
db.add_episodes_to_wishlist(1396, "Breaking Bad", [
{"season_number": 1, "episode_number": 1}, {"season_number": 2, "episode_number": 3}])
st = db.wishlist_state(movie_ids=[603, 604], show_tmdb_id=1396)
assert st["movies"] == {603}
assert st["episodes"] == {"1_1", "2_3"}
def test_wishlist_query_search_and_paging(db):
for i in range(5):
db.add_movie_to_wishlist(100 + i, "Movie %d" % i)
db.add_movie_to_wishlist(900, "Zebra")
assert db.query_wishlist("movie", search="zeb")["pagination"]["total_count"] == 1
p1 = db.query_wishlist("movie", page=1, limit=2)
assert len(p1["items"]) == 2 and p1["pagination"]["total_pages"] == 3
assert db.query_wishlist("movie", page=99, limit=2)["pagination"]["page"] == 3 # clamps