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.
This commit is contained in:
BoulderBadgeDad 2026-06-16 16:07:28 -07:00
parent 110f23f555
commit 6d3a59c8dc
4 changed files with 191 additions and 5 deletions

View file

@ -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

124
api/video/wishlist.py Normal file
View file

@ -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 showseasonepisode 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

View file

@ -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()

View file

@ -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