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:
parent
110f23f555
commit
6d3a59c8dc
4 changed files with 191 additions and 5 deletions
|
|
@ -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
124
api/video/wishlist.py
Normal 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 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue