diff --git a/database/video_database.py b/database/video_database.py index a5b4ffbe..bd5f7744 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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.""" diff --git a/database/video_schema.sql b/database/video_schema.sql index 3ac46925..c47b125e 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -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 diff --git a/tests/test_video_database.py b/tests/test_video_database.py index d99349ac..6c43eedf 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -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