soulsync/api/video/watchlist.py
BoulderBadgeDad e5a4dda117 Video watchlist (shows + people): DB + endpoints + button + page (v1, no scan yet)
A curated follow-list for the video side, mirroring the music watchlist. v1 is
membership only — the monitoring/discovery engine is a later phase.

Backend:
- video_watchlist table (kind 'show'|'person', keyed on tmdb_id — the stable
  cross-context id both carry; library_id kept when owned). NOT the existing
  shows.monitored flag (that defaults to 1 / is library-only / has no people).
- VideoDatabase: add/remove/list/state/counts (upsert COALESCEs library_id +
  poster so a TMDB re-add can't wipe known data).
- /api/video/watchlist {GET, /add, /remove, /check, /counts}.
- query_library now selects s.tmdb_id so show cards can carry the key.

Frontend:
- video-watchlist-btn.js: shared eye button (the music ya-watchlist-btn mirror)
  — build/toggle/hydrate, one delegated capture-phase click handler, broadcasts
  soulsync:video-watchlist-changed so pages can react.
- Watchlist page (new subpage + video-watchlist.js): Shows / People tab switcher,
  poster grid to detail-page quality, reloads each visit, drops cards on unfollow.
- Wired the eye onto library TV-show cards (movies excluded — wishlist, not
  watch) + hydrate on render.

Tests: 6 new (DB upsert/COALESCE/state/counts + endpoint roundtrip/validation).
76 video tests green. Other card surfaces (cast, search, similar, filmography)
are the same VideoWatchlist.btn(...) one-liner — wired next.
2026-06-16 01:06:24 -07:00

104 lines
4.6 KiB
Python

"""Video watchlist API — the user's curated follow-list of shows + people.
Mirrors the music watchlist's add/remove/list/check shape. v1 just manages
membership (so cards can toggle + the Watchlist page can render); the
monitoring/discovery engine that turns a follow into new downloads 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.watchlist")
_KINDS = ("show", "person")
def register_routes(bp):
@bp.route("/watchlist", methods=["GET"])
def video_watchlist_list():
"""All watchlist entries grouped by kind (for the tabbed page)."""
from . import get_video_db
try:
db = get_video_db()
kind = request.args.get("kind")
if kind in _KINDS:
items = db.list_watchlist(kind)
return jsonify({"success": True, "kind": kind, "items": items})
rows = db.list_watchlist()
shows = [r for r in rows if r.get("kind") == "show"]
people = [r for r in rows if r.get("kind") == "person"]
return jsonify({"success": True, "shows": shows, "people": people,
"counts": {"show": len(shows), "person": len(people),
"total": len(rows)}})
except Exception:
logger.exception("Failed to list video watchlist")
return jsonify({"success": False, "error": "Failed to load watchlist"}), 500
@bp.route("/watchlist/counts", methods=["GET"])
def video_watchlist_counts():
from . import get_video_db
try:
return jsonify({"success": True, **get_video_db().watchlist_counts()})
except Exception:
logger.exception("Failed to count video watchlist")
return jsonify({"success": False, "error": "Failed"}), 500
@bp.route("/watchlist/add", methods=["POST"])
def video_watchlist_add():
"""Add a show/person. Body: {kind, tmdb_id, title, poster_url?, library_id?}."""
from . import get_video_db
body = request.get_json(silent=True) or {}
kind = body.get("kind")
tmdb_id = body.get("tmdb_id")
title = (body.get("title") or "").strip()
if kind not in _KINDS or not tmdb_id or not title:
return jsonify({"success": False, "error": "kind, tmdb_id and title are required"}), 400
try:
ok = get_video_db().add_to_watchlist(
kind, int(tmdb_id), title,
poster_url=body.get("poster_url") or None,
library_id=body.get("library_id") or None)
if not ok:
return jsonify({"success": False, "error": "Could not add to watchlist"}), 400
return jsonify({"success": True, "watched": True})
except Exception:
logger.exception("Failed to add to video watchlist")
return jsonify({"success": False, "error": "Failed"}), 500
@bp.route("/watchlist/remove", methods=["POST"])
def video_watchlist_remove():
"""Remove a show/person. Body: {kind, tmdb_id}."""
from . import get_video_db
body = request.get_json(silent=True) or {}
kind = body.get("kind")
tmdb_id = body.get("tmdb_id")
if kind not in _KINDS or not tmdb_id:
return jsonify({"success": False, "error": "kind and tmdb_id are required"}), 400
try:
removed = get_video_db().remove_from_watchlist(kind, int(tmdb_id))
return jsonify({"success": True, "watched": False, "removed": removed})
except Exception:
logger.exception("Failed to remove from video watchlist")
return jsonify({"success": False, "error": "Failed"}), 500
@bp.route("/watchlist/check", methods=["POST"])
def video_watchlist_check():
"""Hydrate cards. Body: {kind, tmdb_ids: [...]} → {results: {id: true}}.
Only watched ids appear in results (absent = not watched)."""
from . import get_video_db
body = request.get_json(silent=True) or {}
kind = body.get("kind")
ids = body.get("tmdb_ids") or []
if kind not in _KINDS:
return jsonify({"success": False, "error": "kind is required"}), 400
try:
state = get_video_db().watchlist_state(kind, ids)
# JSON object keys must be strings.
return jsonify({"success": True, "results": {str(k): True for k in state}})
except Exception:
logger.exception("Failed to check video watchlist")
return jsonify({"success": False, "error": "Failed"}), 500