video discover: wire Netflix-class TMDB filters (keywords/studio/network/cast/runtime/cert/release-window + daily trending)

Foundation for the best-in-class discover build. All additive — existing callers unaffected:
- clients.discover() gains keywords (mood/theme), companies (studio), networks (TV), cast/crew
  (people), min/max_runtime, certification (+country), vote_count_min override, and release_window
  ('last_30/90/365', computed date ranges) — each mapped to the right TMDB /discover param + gated
  to movie/tv where TMDB requires.
- engine.discover_filter() threads them through + into the cache key.
- engine.trending(window) adds the real-time 'day' chart (cache keyed by window) for a Top 10 row.
- /discover/list parses the new query params and a key=trending_today shortcut.

py_compile + ruff clean. Frontend rails (Top 10, mood, studio, new&upcoming, quick-watches) next.
This commit is contained in:
BoulderBadgeDad 2026-06-23 23:45:41 -07:00
parent 378ae39bef
commit bbf7abad1b
3 changed files with 96 additions and 19 deletions

View file

@ -284,6 +284,20 @@ def register_routes(bp):
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.
@ -307,10 +321,16 @@ def register_routes(bp):
def fetch(p):
if key == "trending":
return eng.trending()
if key == "trending_today":
return eng.trending(window="day") # the ranked 'Top 10 today' chart
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)
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()

View file

@ -514,18 +514,32 @@ class TMDBClient:
return self._disc_map((r.json() or {}).get("results"), kind)
def discover(self, kind, *, genre=None, year=None, decade=None, providers=None,
sort_by="popularity.desc", page=1, region="US", language=None):
"""Browse /discover/{movie,tv} filtered by genre / year / decade / streaming
provider (``providers`` is a TMDB provider id; needs ``region``). ``language``
restricts to an original-language (ISO-639-1, e.g. 'en') so general rails aren't
flooded with foreign-language titles; pass a non-English code for a foreign rail."""
sort_by="popularity.desc", page=1, region="US", language=None,
keywords=None, companies=None, networks=None, cast=None, crew=None,
min_runtime=None, max_runtime=None, certification=None,
cert_country="US", vote_count_min=None, release_window=None):
"""Browse /discover/{movie,tv}. The original filters (genre / year / decade /
streaming ``providers`` + ``region`` / original ``language``) plus the
Netflix-class extensions, all optional + additive:
- ``keywords`` TMDB keyword id(s), pipe-joined for OR ('818|9715'). Powers
mood/theme rails (feel-good, heist, time-travel ).
- ``companies`` TMDB company id(s) studio rails (Pixar, A24, Ghibli ).
- ``networks`` TMDB network id(s), TV only network rails (HBO, AMC ).
- ``cast`` / ``crew`` person id(s), movies "starring …" / "directed by …".
- ``min_runtime`` / ``max_runtime`` minutes quick-watches / epics.
- ``certification`` (+ ``cert_country``) e.g. 'PG-13', movies family-friendly.
- ``vote_count_min`` override the default popularity floor (40).
- ``release_window`` 'last_30' | 'last_90' | 'last_365' date-windowed "new"
rails (computed relative to today)."""
if not self.api_key:
return []
import requests
is_movie = kind == "movie"
path = "/discover/movie" if is_movie else "/discover/tv"
params = {"api_key": self.api_key, "sort_by": sort_by, "page": page,
"include_adult": "false", "vote_count.gte": 40}
"include_adult": "false",
"vote_count.gte": vote_count_min if vote_count_min is not None else 40}
if language:
params["with_original_language"] = language
if genre:
@ -534,6 +548,23 @@ class TMDBClient:
params["with_watch_providers"] = providers
params["watch_region"] = region or "US"
params["with_watch_monetization_types"] = "flatrate" # streaming, not rent/buy
if keywords:
params["with_keywords"] = keywords
if companies:
params["with_companies"] = companies
if networks and not is_movie:
params["with_networks"] = networks
if cast and is_movie:
params["with_cast"] = cast
if crew and is_movie:
params["with_crew"] = crew
if min_runtime:
params["with_runtime.gte"] = min_runtime
if max_runtime:
params["with_runtime.lte"] = max_runtime
if certification and is_movie:
params["certification.lte"] = certification
params["certification_country"] = cert_country or "US"
if year:
params["primary_release_year" if is_movie else "first_air_date_year"] = year
if decade:
@ -546,6 +577,17 @@ class TMDBClient:
params["first_air_date.gte"], params["first_air_date.lte"] = gte, lte
except (TypeError, ValueError):
pass
if release_window:
days = {"last_30": 30, "last_90": 90, "last_365": 365}.get(release_window)
if days:
import datetime as _dt
today = _dt.date.today()
start = (today - _dt.timedelta(days=days)).isoformat()
end = today.isoformat()
if is_movie:
params["primary_release_date.gte"], params["primary_release_date.lte"] = start, end
else:
params["first_air_date.gte"], params["first_air_date.lte"] = start, end
r = requests.get(self.BASE + path, params=params, timeout=15)
r.raise_for_status()
return self._disc_map((r.json() or {}).get("results"), kind)

View file

@ -310,16 +310,20 @@ class VideoEnrichmentEngine:
return []
return self._stamp_owned(results)
def trending(self) -> list:
"""Trending titles for the idle search page, annotated owned/not."""
def trending(self, window="week") -> list:
"""Trending titles, annotated owned/not. ``window='day'`` is the real-time
daily chart (the iconic 'Top 10 today' row); 'week' is the steadier hero/idle
feed. Order is preserved, so the caller can render rank numbers."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return []
cached = self._cache_get(("trending",))
window = "day" if window == "day" else "week"
ck = ("trending", window)
cached = self._cache_get(ck)
if cached is None:
try:
cached = w.client.trending() or []
self._cache_put(("trending",), cached, ttl=3600)
cached = w.client.trending(window=window) or []
self._cache_put(ck, cached, ttl=3600)
except Exception:
logger.exception("video trending failed")
return []
@ -372,21 +376,32 @@ class VideoEnrichmentEngine:
return self._stamp_owned(items)
def discover_filter(self, kind, *, genre=None, year=None, decade=None, providers=None,
sort_by="popularity.desc", page=1, language=None) -> list:
sort_by="popularity.desc", page=1, language=None,
keywords=None, companies=None, networks=None, cast=None, crew=None,
min_runtime=None, max_runtime=None, certification=None,
vote_count_min=None, release_window=None) -> list:
"""Browse /discover filtered by genre / year / decade / streaming provider /
original language, cached + owned-annotated."""
original language plus the Netflix-class extensions (keywords/mood,
companies/studio, networks, cast/crew, runtime, certification, release window).
Cached + owned-annotated; all extension params are optional/additive."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return []
if kind not in ("movie", "show"):
kind = "movie"
ck = ("disc-flt", kind, genre, year, decade, providers, sort_by, page, language)
ck = ("disc-flt", kind, genre, year, decade, providers, sort_by, page, language,
keywords, companies, networks, cast, crew, min_runtime, max_runtime,
certification, vote_count_min, release_window)
items = self._cache_get(ck)
if items is None:
try:
items = w.client.discover(kind, genre=genre, year=year, decade=decade,
providers=providers, sort_by=sort_by, page=page,
region=self._region(), language=language) or []
items = w.client.discover(
kind, genre=genre, year=year, decade=decade, providers=providers,
sort_by=sort_by, page=page, region=self._region(), language=language,
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) or []
self._cache_put(ck, items, ttl=3600)
except Exception:
logger.exception("discover filter failed (%s g=%s y=%s d=%s p=%s)",