From 6d3a59c8dc0d5ab8c2541affe334502c11943d4d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 16:07:28 -0700 Subject: [PATCH] Video wishlist: API + dashboard count - api/video/wishlist.py: GET /wishlist (paged movie|show tab, or counts-only), /wishlist/counts, POST /wishlist/add (movie OR show+episodes), /wishlist/remove (scope movie|show|season|episode), /wishlist/check (hydration). Registered in the blueprint. - Dashboard 'wishlist' stat now reflects the real curated count (was a 0 stub). Tests: +6 API (add movie/episodes, body validation, scoped removes, hydration, routes registered). API suite 30 + DB suite 68 passing. --- api/video/__init__.py | 2 + api/video/wishlist.py | 124 +++++++++++++++++++++++++++++++++++++ database/video_database.py | 8 +-- tests/test_video_api.py | 62 +++++++++++++++++++ 4 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 api/video/wishlist.py diff --git a/api/video/__init__.py b/api/video/__init__.py index 8463725e..db7f8bc8 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -48,6 +48,7 @@ def create_video_blueprint() -> Blueprint: from .discover import register_routes as reg_discover from .calendar import register_routes as reg_calendar from .watchlist import register_routes as reg_watchlist + from .wishlist import register_routes as reg_wishlist reg_dashboard(bp) reg_scan(bp) reg_library(bp) @@ -59,5 +60,6 @@ def create_video_blueprint() -> Blueprint: reg_discover(bp) reg_calendar(bp) reg_watchlist(bp) + reg_wishlist(bp) return bp diff --git a/api/video/wishlist.py b/api/video/wishlist.py new file mode 100644 index 00000000..5a0f39fa --- /dev/null +++ b/api/video/wishlist.py @@ -0,0 +1,124 @@ +"""Video wishlist API — the curated 'get this' list (movies + episodes). + +Atomic units are movies and episodes; adding a whole show or a season just hands +us the explicit episodes to expand into rows. v1 manages membership + the tabbed +Movies/TV page; the search/download engine that fulfils a wish is a later phase. +Reads/writes only video_library.db via the shared VideoDatabase. +""" + +from __future__ import annotations + +from flask import jsonify, request + +from utils.logging_config import get_logger + +logger = get_logger("video_api.wishlist") + +_KINDS = ("movie", "show") +_SCOPES = ("movie", "show", "season", "episode") + + +def _server(): + """Active video server_source (stored on rows, informational). 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("/wishlist", methods=["GET"]) + def video_wishlist_list(): + """Paged slice for a tab (kind='movie'|'show'), or counts-only with no kind. + Shows are grouped show→season→episode with wanted/done roll-ups.""" + from . import get_video_db + try: + db = get_video_db() + counts = db.wishlist_counts() + kind = request.args.get("kind") + if kind in _KINDS: + res = db.query_wishlist( + kind, search=request.args.get("search", ""), + page=request.args.get("page", 1), limit=request.args.get("limit", 60)) + return jsonify({"success": True, "kind": kind, "counts": counts, **res}) + return jsonify({"success": True, "counts": counts}) + except Exception: + logger.exception("Failed to list video wishlist") + return jsonify({"success": False, "error": "Failed to load wishlist"}), 500 + + @bp.route("/wishlist/counts", methods=["GET"]) + def video_wishlist_counts(): + from . import get_video_db + try: + return jsonify({"success": True, **get_video_db().wishlist_counts()}) + except Exception: + logger.exception("Failed to count video wishlist") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/wishlist/add", methods=["POST"]) + def video_wishlist_add(): + """Add a movie or a set of a show's episodes. Body is one of: + {"movie": {tmdb_id, title, year?, poster_url?, library_id?}} + {"show": {tmdb_id, title, poster_url?, library_id?}, + "episodes": [{season_number, episode_number, title?, air_date?}, …]}""" + from . import get_video_db + body = request.get_json(silent=True) or {} + srv = _server() + db = get_video_db() + try: + movie = body.get("movie") + if movie and movie.get("tmdb_id") and (movie.get("title") or "").strip(): + ok = db.add_movie_to_wishlist( + int(movie["tmdb_id"]), movie["title"].strip(), year=movie.get("year"), + poster_url=movie.get("poster_url") or None, + library_id=movie.get("library_id") or None, server_source=srv) + return jsonify({"success": ok, "added": 1 if ok else 0, "counts": db.wishlist_counts()}) + + show = body.get("show") + episodes = body.get("episodes") or [] + if show and show.get("tmdb_id") and (show.get("title") or "").strip() and episodes: + n = db.add_episodes_to_wishlist( + int(show["tmdb_id"]), show["title"].strip(), episodes, + poster_url=show.get("poster_url") or None, + library_id=show.get("library_id") or None, server_source=srv) + return jsonify({"success": n > 0, "added": n, "counts": db.wishlist_counts()}) + + return jsonify({"success": False, "error": "movie or show+episodes required"}), 400 + except Exception: + logger.exception("Failed to add to video wishlist") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/wishlist/remove", methods=["POST"]) + def video_wishlist_remove(): + """Remove at any granularity. Body: {scope, tmdb_id, season_number?, episode_number?} + where scope ∈ movie|show|season|episode.""" + from . import get_video_db + body = request.get_json(silent=True) or {} + scope, tmdb_id = body.get("scope"), body.get("tmdb_id") + if scope not in _SCOPES or not tmdb_id: + return jsonify({"success": False, "error": "scope and tmdb_id are required"}), 400 + try: + db = get_video_db() + removed = db.remove_from_wishlist( + scope, tmdb_id=int(tmdb_id), + season_number=body.get("season_number"), episode_number=body.get("episode_number")) + return jsonify({"success": True, "removed": removed, "counts": db.wishlist_counts()}) + except Exception: + logger.exception("Failed to remove from video wishlist") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/wishlist/check", methods=["POST"]) + def video_wishlist_check(): + """Hydrate cards/modal. Body: {movie_ids: [...], show_tmdb_id?} → + {movies: [ids already wished], episodes: ['S_E' already wished]}.""" + from . import get_video_db + body = request.get_json(silent=True) or {} + try: + st = get_video_db().wishlist_state( + movie_ids=body.get("movie_ids") or [], show_tmdb_id=body.get("show_tmdb_id")) + return jsonify({"success": True, "movies": sorted(st["movies"]), + "episodes": sorted(st["episodes"])}) + except Exception: + logger.exception("Failed to check video wishlist") + return jsonify({"success": False, "error": "Failed"}), 500 diff --git a/database/video_database.py b/database/video_database.py index bd5f7744..3cf6bfb2 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -821,11 +821,9 @@ class VideoDatabase: # Curated watchlist (explicit follows + actively-airing library # shows), NOT the old monitored-based v_watchlist view. "watchlist": self.watchlist_counts(server_source=server_source)["total"], - # Wishlist is intentionally 0 for now: the old v_wishlist view - # auto-listed EVERY missing movie/episode (monitored defaults to - # 1), which isn't the intended curated wishlist. Repoint this at - # the curated source once "add to wishlist" population lands. - "wishlist": 0, + # Curated wishlist (movies + wanted episodes), NOT the old + # v_wishlist view that auto-listed every missing item. + "wishlist": self.wishlist_counts()["total"], } finally: conn.close() diff --git a/tests/test_video_api.py b/tests/test_video_api.py index c32e865f..80925516 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -425,3 +425,65 @@ def test_watchlist_endpoint_paginates_and_searches(tmp_path): assert d["counts"]["person"] == 5 s = client.get("/api/video/watchlist?kind=person&search=P3").get_json() assert len(s["items"]) == 1 and s["items"][0]["title"] == "P3" + + +# ── wishlist endpoints ──────────────────────────────────────────────────────── + +def test_wishlist_add_movie_then_list(tmp_path): + client, _ = _make_client(tmp_path) + r = client.post("/api/video/wishlist/add", json={"movie": {"tmdb_id": 603, "title": "The Matrix", "year": 1999}}) + assert r.get_json() == {"success": True, "added": 1, "counts": {"movie": 1, "show": 0, "episode": 0, "total": 1}} + lst = client.get("/api/video/wishlist?kind=movie").get_json() + assert lst["success"] and lst["items"][0]["tmdb_id"] == 603 and lst["counts"]["movie"] == 1 + + +def test_wishlist_add_episodes_groups_into_show(tmp_path): + client, _ = _make_client(tmp_path) + r = client.post("/api/video/wishlist/add", json={ + "show": {"tmdb_id": 1396, "title": "Breaking Bad", "poster_url": "/bb.jpg"}, + "episodes": [{"season_number": 1, "episode_number": 1, "title": "Pilot"}, + {"season_number": 1, "episode_number": 2}]}) + assert r.get_json()["added"] == 2 + show = client.get("/api/video/wishlist?kind=show").get_json()["items"][0] + assert show["tmdb_id"] == 1396 and show["wanted"] == 2 + assert show["seasons"][0]["season_number"] == 1 + + +def test_wishlist_add_requires_valid_body(tmp_path): + client, _ = _make_client(tmp_path) + assert client.post("/api/video/wishlist/add", json={}).status_code == 400 + # show with no episodes is rejected (episodes are the atomic unit) + assert client.post("/api/video/wishlist/add", json={"show": {"tmdb_id": 1, "title": "S"}}).status_code == 400 + + +def test_wishlist_remove_scopes_via_api(tmp_path): + client, _ = _make_client(tmp_path) + client.post("/api/video/wishlist/add", json={ + "show": {"tmdb_id": 1396, "title": "Breaking Bad"}, + "episodes": [{"season_number": 1, "episode_number": 1}, {"season_number": 1, "episode_number": 2}]}) + r = client.post("/api/video/wishlist/remove", + json={"scope": "episode", "tmdb_id": 1396, "season_number": 1, "episode_number": 2}) + assert r.get_json()["removed"] == 1 and r.get_json()["counts"]["episode"] == 1 + assert client.post("/api/video/wishlist/remove", json={"scope": "show", "tmdb_id": 1396}).get_json()["removed"] == 1 + assert client.post("/api/video/wishlist/remove", json={"scope": "bogus", "tmdb_id": 1}).status_code == 400 + + +def test_wishlist_check_hydration(tmp_path): + client, _ = _make_client(tmp_path) + client.post("/api/video/wishlist/add", json={"movie": {"tmdb_id": 603, "title": "The Matrix"}}) + client.post("/api/video/wishlist/add", json={ + "show": {"tmdb_id": 1396, "title": "Breaking Bad"}, + "episodes": [{"season_number": 2, "episode_number": 3}]}) + res = client.post("/api/video/wishlist/check", json={"movie_ids": [603, 700], "show_tmdb_id": 1396}).get_json() + assert res["movies"] == [603] and res["episodes"] == ["2_3"] + + +def test_wishlist_routes_registered(): + from flask import Flask + from api.video import create_video_blueprint + app = Flask(__name__) + app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") + rules = {r.rule for r in app.url_map.iter_rules()} + for r in ("/api/video/wishlist", "/api/video/wishlist/add", "/api/video/wishlist/remove", + "/api/video/wishlist/check", "/api/video/wishlist/counts"): + assert r in rules