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:
parent
246c650db4
commit
110f23f555
3 changed files with 299 additions and 1 deletions
|
|
@ -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 show→season→episode 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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue