More-like rails (real personalization beyond genre): - db.random_owned_titles() seeds from a few random owned titles (with tmdb_id); client.recommendations() + engine.recommendations() (cached + owned-annotated) fetch TMDB recs. New /discover/morelike interleaves movie/show seeds (max 3 rails) and the page prepends them, pre-filled, above the rail stack with a gradient 'for you' title. Hero trailer (cheap big visual win): - client.video_trailer() (light /videos call, Trailer over Teaser) + engine .trailer() (day-cached) + /discover/trailer. Hero gets a 'Trailer' button that opens an in-app YouTube lightbox (autoplay, Esc/backdrop close, pauses the slideshow) — nothing leaves SoulSync. Tests: +11 (recs parse/annotate/cache, trailer pick+fallback+cache, random owned seeds owned-with-tmdb only, /discover/trailer + /discover/morelike endpoints). Enrichment + API suites: 114 passed.
161 lines
6.9 KiB
Python
161 lines
6.9 KiB
Python
"""Video discover API — browse TMDB (movies/TV the user doesn't own yet).
|
||
|
||
GET /api/video/discover/hero → trending titles w/ backdrops (slideshow)
|
||
GET /api/video/discover/genres → {movie:[{id,name}], show:[{id,name}]}
|
||
GET /api/video/discover/list?... → one shelf/grid of items, e.g.
|
||
?key=trending → trending movies + shows
|
||
?key=<curated>&page= → a canned list (popular_movies, top_shows…)
|
||
?kind=movie|show&genre=&year=&decade=&sort=&page= → a filtered browse
|
||
|
||
Items are annotated with ``library_id`` when already owned (so the card links to
|
||
the owned detail, not the TMDB preview). Reads only the enrichment engine +
|
||
video.db; isolated from the music API.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from flask import jsonify, request
|
||
|
||
from utils.logging_config import get_logger
|
||
|
||
logger = get_logger("video_api.discover")
|
||
|
||
|
||
def register_routes(bp):
|
||
@bp.route("/discover/hero", methods=["GET"])
|
||
def video_discover_hero():
|
||
"""A few trending titles that have a backdrop — drives the hero slideshow."""
|
||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||
try:
|
||
items = [x for x in (get_video_enrichment_engine().trending() or [])
|
||
if x.get("backdrop")][:6]
|
||
except Exception:
|
||
logger.exception("discover hero failed")
|
||
items = []
|
||
return jsonify({"items": items})
|
||
|
||
@bp.route("/discover/taste", methods=["GET"])
|
||
def video_discover_taste():
|
||
"""The user's most-owned genres (movies + shows) → personalized rails."""
|
||
from . import get_video_db
|
||
try:
|
||
from core.video.sources import resolve_video_server
|
||
srv = resolve_video_server()
|
||
except Exception:
|
||
srv = None
|
||
db = get_video_db()
|
||
try:
|
||
return jsonify({"movie": db.top_owned_genres("movie", srv, 6),
|
||
"show": db.top_owned_genres("show", srv, 6)})
|
||
except Exception:
|
||
logger.exception("discover taste failed")
|
||
return jsonify({"movie": [], "show": []})
|
||
|
||
@bp.route("/discover/morelike", methods=["GET"])
|
||
def video_discover_morelike():
|
||
"""'More like <owned title>' rails — TMDB recommendations seeded from a few
|
||
random titles you already own (interleaved movie/show, max 3 rails)."""
|
||
from . import get_video_db
|
||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||
try:
|
||
from core.video.sources import resolve_video_server
|
||
srv = resolve_video_server()
|
||
except Exception:
|
||
srv = None
|
||
db = get_video_db()
|
||
eng = get_video_enrichment_engine()
|
||
try:
|
||
seeds = db.random_owned_titles(2, srv)
|
||
movies = [s for s in seeds if s["kind"] == "movie"]
|
||
shows = [s for s in seeds if s["kind"] == "show"]
|
||
ordered = []
|
||
while (movies or shows) and len(ordered) < 3:
|
||
if movies:
|
||
ordered.append(movies.pop(0))
|
||
if shows and len(ordered) < 3:
|
||
ordered.append(shows.pop(0))
|
||
rails = []
|
||
for s in ordered:
|
||
items = [it for it in eng.recommendations(s["kind"], s["tmdb_id"])
|
||
if it.get("tmdb_id") != s["tmdb_id"]]
|
||
if len(items) >= 4:
|
||
rails.append({"title": "More like " + s["title"], "items": items[:30]})
|
||
return jsonify({"rails": rails})
|
||
except Exception:
|
||
logger.exception("discover morelike failed")
|
||
return jsonify({"rails": []})
|
||
|
||
@bp.route("/discover/trailer", methods=["GET"])
|
||
def video_discover_trailer():
|
||
"""Best YouTube trailer {key,name} for a tmdb title (hero 'Trailer' button)."""
|
||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||
kind = request.args.get("kind", "movie")
|
||
try:
|
||
tmdb_id = int(request.args.get("tmdb_id"))
|
||
except (TypeError, ValueError):
|
||
return jsonify({"trailer": None})
|
||
try:
|
||
tr = get_video_enrichment_engine().trailer(kind, tmdb_id)
|
||
except Exception:
|
||
logger.exception("discover trailer failed")
|
||
tr = None
|
||
return jsonify({"trailer": tr or None})
|
||
|
||
@bp.route("/discover/genres", methods=["GET"])
|
||
def video_discover_genres():
|
||
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""
|
||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||
eng = get_video_enrichment_engine()
|
||
try:
|
||
return jsonify({"movie": eng.genre_list("movie"), "show": eng.genre_list("show")})
|
||
except Exception:
|
||
logger.exception("discover genres failed")
|
||
return jsonify({"movie": [], "show": []})
|
||
|
||
@bp.route("/discover/list", methods=["GET"])
|
||
def video_discover_list():
|
||
"""One shelf (rail) or one page of a filtered browse — see module docstring.
|
||
|
||
``pages`` (1–3, default 1) fetches that many consecutive TMDB pages and
|
||
concatenates them (deduped) in one response — so a rail can show ~40 items
|
||
and still look full after 'Hide owned' drops the ones you have."""
|
||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||
eng = get_video_enrichment_engine()
|
||
try:
|
||
page = max(1, int(request.args.get("page", 1) or 1))
|
||
except (TypeError, ValueError):
|
||
page = 1
|
||
try:
|
||
pages = min(3, max(1, int(request.args.get("pages", 1) or 1)))
|
||
except (TypeError, ValueError):
|
||
pages = 1
|
||
key = (request.args.get("key") or "").strip()
|
||
kind = request.args.get("kind", "movie")
|
||
genre = request.args.get("genre") or None
|
||
year = request.args.get("year") or None
|
||
decade = request.args.get("decade") or None
|
||
sort = request.args.get("sort") or "popularity.desc"
|
||
|
||
def fetch(p):
|
||
if key == "trending":
|
||
return eng.trending()
|
||
if key:
|
||
return eng.discover_curated(key, page=p)
|
||
return eng.discover_filter(kind, genre=genre, year=year, decade=decade,
|
||
sort_by=sort, page=p)
|
||
|
||
try:
|
||
items, seen = [], set()
|
||
for p in range(page, page + pages):
|
||
for it in (fetch(p) or []):
|
||
dk = (it.get("kind"), it.get("tmdb_id"))
|
||
if dk in seen:
|
||
continue
|
||
seen.add(dk)
|
||
items.append(it)
|
||
if key == "trending":
|
||
break # trending is a fixed list — extra pages just repeat it
|
||
return jsonify({"items": items, "page": page})
|
||
except Exception:
|
||
logger.exception("discover list failed (key=%s)", key)
|
||
return jsonify({"items": [], "page": page})
|