"""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=&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 import concurrent.futures 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 ' 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/foryou", methods=["GET"]) def video_discover_foryou(): """A single 'Recommended for you' wall blended from many owned titles — a title recommended by more of your library ranks higher (consensus).""" from . import get_video_db from core.video.enrichment.engine import get_video_enrichment_engine from core.video.discovery_recs import blend_recommendations 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(6, srv) # up to 6 movies + 6 shows seed_ids = [s["tmdb_id"] for s in seeds if s.get("tmdb_id")] rec_lists = [eng.recommendations(s["kind"], s["tmdb_id"]) for s in seeds if s.get("tmdb_id")] items = blend_recommendations(rec_lists, exclude_ids=seed_ids, limit=40) return jsonify({"items": items}) except Exception: logger.exception("discover foryou failed") return jsonify({"items": []}) @bp.route("/discover/gaps", methods=["GET"]) def video_discover_gaps(): """'What am I missing?' rails — franchises you've started but not finished, and more from the directors/creators you own the most. Powered by the gap engine.""" from . import get_video_db from core.video.enrichment.engine import get_video_enrichment_engine from core.video.discovery_gaps import collection_gaps, filmography_gaps 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: # Lazy collection-id backfill: movies matched before the collection column # exists have no franchise id. Fill a small batch each load (self-healing); # isolated so a backfill hiccup never breaks the gap rails. try: for mv in db.movies_missing_collection(srv, limit=20): coll = eng.movie_collection(mv["tmdb_id"]) if coll is not None: db.set_movie_collection(mv["id"], coll.get("id"), coll.get("name")) except Exception: logger.exception("collection-id backfill batch failed") owned = db.owned_movie_tmdb_ids(srv) ignored = db.ignored_keys() rails = [] # Complete your collections — top franchises you've started, missing entries. for coll in db.owned_movie_collections(srv, limit=8): missing = collection_gaps(owned, eng.collection(coll["collection_id"])) if missing: name = (coll.get("name") or "Collection").strip() rails.append({"title": "Complete the " + name, "kind": "collection", "items": missing[:30]}) # More from the people you own the most (directors / creators). for person in db.top_owned_people(min_titles=2, limit=6, server_source=srv): p = eng.person_detail(person["tmdb_id"]) if not p: continue missing = filmography_gaps(owned, p.get("credits") or [], kinds=("movie",), min_vote_count=50, limit=30) if ignored: missing = [m for m in missing if f"{m.get('kind')}:{m.get('tmdb_id')}" not in ignored] if len(missing) >= 3: rails.append({"title": "More from " + person["name"], "kind": "person", "items": missing}) return jsonify({"rails": rails}) except Exception: logger.exception("discover gaps 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/ignore", methods=["GET", "POST"]) def video_discover_ignore(): """The Discover 'Not interested' list. GET -> {items}. POST {action:'add'|'remove', kind, tmdb_id, title?, year?, poster?}.""" from . import get_video_db db = get_video_db() try: if request.method == "GET": return jsonify({"items": db.list_ignored()}) body = request.get_json(silent=True) or {} kind, tmdb_id = body.get("kind"), body.get("tmdb_id") if body.get("action") == "remove": db.remove_ignored(kind, tmdb_id) return jsonify({"success": True}) ok = db.add_ignored(kind, tmdb_id, body.get("title"), body.get("year"), body.get("poster")) return jsonify({"success": ok}) except Exception: logger.exception("discover ignore failed") return jsonify({"success": False, "items": []}) @bp.route("/discover/languages", methods=["GET", "POST"]) def video_discover_languages(): """Get/set the preferred original-languages for general rails (ISO-639-1 codes). POST {languages: ['en','ko']} (or 'en,ko'); GET returns the current list.""" from . import get_video_db db = get_video_db() try: if request.method == "POST": body = request.get_json(silent=True) or {} langs = body.get("languages") if isinstance(langs, list): val = ",".join(str(c).strip().lower() for c in langs if str(c).strip()) else: val = ",".join(c.strip().lower() for c in str(langs or "").split(",") if c.strip()) db.set_setting("discover_languages", val or "en") return jsonify({"success": True, "languages": [c for c in (val or "en").split(",") if c]}) raw = db.get_setting("discover_languages", "en") or "en" return jsonify({"languages": [c.strip() for c in raw.split(",") if c.strip()]}) except Exception: logger.exception("discover languages get/set failed") return jsonify({"languages": ["en"]}) @bp.route("/discover/providers-pref", methods=["GET", "POST"]) def video_discover_providers_pref(): """The user's subscribed streaming services (TMDB provider ids) — drives the 'On your streaming services' rail. POST {providers:[8,9]} (or '8,9'); GET returns them.""" from . import get_video_db db = get_video_db() try: if request.method == "POST": body = request.get_json(silent=True) or {} p = body.get("providers") if isinstance(p, list): val = ",".join(str(c).strip() for c in p if str(c).strip()) else: val = ",".join(c.strip() for c in str(p or "").split(",") if c.strip()) db.set_setting("discover_providers", val) return jsonify({"success": True, "providers": [c for c in val.split(",") if c]}) raw = db.get_setting("discover_providers", "") or "" return jsonify({"providers": [c.strip() for c in raw.split(",") if c.strip()]}) except Exception: logger.exception("discover providers-pref failed") return jsonify({"providers": []}) @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 providers = request.args.get("providers") or None if providers and "," in providers: providers = providers.replace(",", "|") # TMDB with_watch_providers OR-join sort = request.args.get("sort") or "popularity.desc" # Netflix-class discover extensions (all optional). Comma -> pipe = TMDB OR-join. keywords = (request.args.get("keywords") or "").replace(",", "|") or None companies = (request.args.get("companies") or "").replace(",", "|") or None networks = (request.args.get("networks") or "").replace(",", "|") or None cast = request.args.get("cast") or None crew = request.args.get("crew") or None min_runtime = request.args.get("min_runtime") or None max_runtime = request.args.get("max_runtime") or None certification = request.args.get("certification") or None release_window = request.args.get("release_window") or None try: vote_count_min = int(request.args["vote_count_min"]) if request.args.get("vote_count_min") else None except (TypeError, ValueError): vote_count_min = None lang = (request.args.get("lang") or "").strip().lower() or None # explicit (foreign rail / browse) # The Browse-all grid sends `lang=any` to opt OUT of the rail language preference # entirely (ad-hoc search shows every language); a real code (en/ko/…) filters to it. any_lang = lang in ("any", "all") if any_lang: lang = None hide_owned = (request.args.get("hide_owned") or "") in ("1", "true", "yes") # Preferred original-languages (multi) for GENERAL/curated rails — so the feeds # aren't flooded with foreign titles (e.g. Bollywood in Popular/Trending). A rail # with an explicit `lang` (a dedicated foreign rail) or `lang=any` (browse) bypasses # this. Default 'en'. prefer_langs = None if not lang and not any_lang: try: from . import get_video_db raw = get_video_db().get_setting("discover_languages", "en") or "en" prefer_langs = {c.strip().lower() for c in raw.split(",") if c.strip()} or None except Exception: prefer_langs = {"en"} def fetch(p): if key == "trending": return eng.trending() if key == "trending_today": return eng.trending(window="day") # the ranked 'Top 10 today' chart (mixed) if key == "trending_movies_today": return eng.trending(window="day", kind="movie") # split 'Top 10 Movies Today' if key == "trending_tv_today": return eng.trending(window="day", kind="show") # split 'Top 10 TV Shows Today' if key: return eng.discover_curated(key, page=p) return eng.discover_filter( kind, genre=genre, year=year, decade=decade, providers=providers, sort_by=sort, page=p, language=lang, keywords=keywords, companies=companies, networks=networks, cast=cast, crew=crew, min_runtime=min_runtime, max_runtime=max_runtime, certification=certification, vote_count_min=vote_count_min, release_window=release_window) try: items, seen = [], set() def consume(batch): """Filter a page into `items` (dedup + hide-owned + language). Returns False once TMDB hands back an empty page (it ran out — stop paging).""" if not batch: return False for it in batch: dk = (it.get("kind"), it.get("tmdb_id")) if dk in seen: continue seen.add(dk) if hide_owned and it.get("library_id") is not None: continue if prefer_langs: ol = (it.get("original_language") or "").lower() if ol and ol not in prefer_langs: continue # known foreign language not in your preference items.append(it) return True # When filtering (hide-owned or language), page DEEPER and drop items server-side # so a heavy library's rail still fills to ~target instead of showing 4 cards. We # fetch the extra pages in concurrent WAVES (TTLCache + the TMDB client are # thread-safe) so digging 20 pages deep costs ~4 page-latencies, not 20. need_fill = hide_owned or bool(prefer_langs) target = 20 if need_fill else 0 if key in ("trending", "trending_today", "trending_movies_today", "trending_tv_today"): consume(fetch(page)) # a single fixed chart; never paged elif not need_fill: for offset in range(pages): # no filtering: respect requested page count if not consume(fetch(page + offset)): break else: MAX_PAGES, WAVE = 20, 5 offset, ran_out = 0, False with concurrent.futures.ThreadPoolExecutor(max_workers=WAVE) as ex: while offset < MAX_PAGES and len(items) < target and not ran_out: batches = list(ex.map(fetch, [page + offset + i for i in range(WAVE)])) for batch in batches: if not consume(batch): ran_out = True # an empty page = TMDB has no more offset += WAVE return jsonify({"items": items, "page": page}) except Exception: logger.exception("discover list failed (key=%s)", key) return jsonify({"items": [], "page": page})