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:
parent
378ae39bef
commit
bbf7abad1b
3 changed files with 96 additions and 19 deletions
|
|
@ -284,6 +284,20 @@ def register_routes(bp):
|
||||||
if providers and "," in providers:
|
if providers and "," in providers:
|
||||||
providers = providers.replace(",", "|") # TMDB with_watch_providers OR-join
|
providers = providers.replace(",", "|") # TMDB with_watch_providers OR-join
|
||||||
sort = request.args.get("sort") or "popularity.desc"
|
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)
|
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
|
# 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.
|
# 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):
|
def fetch(p):
|
||||||
if key == "trending":
|
if key == "trending":
|
||||||
return eng.trending()
|
return eng.trending()
|
||||||
|
if key == "trending_today":
|
||||||
|
return eng.trending(window="day") # the ranked 'Top 10 today' chart
|
||||||
if key:
|
if key:
|
||||||
return eng.discover_curated(key, page=p)
|
return eng.discover_curated(key, page=p)
|
||||||
return eng.discover_filter(kind, genre=genre, year=year, decade=decade,
|
return eng.discover_filter(
|
||||||
providers=providers, sort_by=sort, page=p, language=lang)
|
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:
|
try:
|
||||||
items, seen = [], set()
|
items, seen = [], set()
|
||||||
|
|
|
||||||
|
|
@ -514,18 +514,32 @@ class TMDBClient:
|
||||||
return self._disc_map((r.json() or {}).get("results"), kind)
|
return self._disc_map((r.json() or {}).get("results"), kind)
|
||||||
|
|
||||||
def discover(self, kind, *, genre=None, year=None, decade=None, providers=None,
|
def discover(self, kind, *, genre=None, year=None, decade=None, providers=None,
|
||||||
sort_by="popularity.desc", page=1, region="US", language=None):
|
sort_by="popularity.desc", page=1, region="US", language=None,
|
||||||
"""Browse /discover/{movie,tv} filtered by genre / year / decade / streaming
|
keywords=None, companies=None, networks=None, cast=None, crew=None,
|
||||||
provider (``providers`` is a TMDB provider id; needs ``region``). ``language``
|
min_runtime=None, max_runtime=None, certification=None,
|
||||||
restricts to an original-language (ISO-639-1, e.g. 'en') so general rails aren't
|
cert_country="US", vote_count_min=None, release_window=None):
|
||||||
flooded with foreign-language titles; pass a non-English code for a foreign rail."""
|
"""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:
|
if not self.api_key:
|
||||||
return []
|
return []
|
||||||
import requests
|
import requests
|
||||||
is_movie = kind == "movie"
|
is_movie = kind == "movie"
|
||||||
path = "/discover/movie" if is_movie else "/discover/tv"
|
path = "/discover/movie" if is_movie else "/discover/tv"
|
||||||
params = {"api_key": self.api_key, "sort_by": sort_by, "page": page,
|
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:
|
if language:
|
||||||
params["with_original_language"] = language
|
params["with_original_language"] = language
|
||||||
if genre:
|
if genre:
|
||||||
|
|
@ -534,6 +548,23 @@ class TMDBClient:
|
||||||
params["with_watch_providers"] = providers
|
params["with_watch_providers"] = providers
|
||||||
params["watch_region"] = region or "US"
|
params["watch_region"] = region or "US"
|
||||||
params["with_watch_monetization_types"] = "flatrate" # streaming, not rent/buy
|
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:
|
if year:
|
||||||
params["primary_release_year" if is_movie else "first_air_date_year"] = year
|
params["primary_release_year" if is_movie else "first_air_date_year"] = year
|
||||||
if decade:
|
if decade:
|
||||||
|
|
@ -546,6 +577,17 @@ class TMDBClient:
|
||||||
params["first_air_date.gte"], params["first_air_date.lte"] = gte, lte
|
params["first_air_date.gte"], params["first_air_date.lte"] = gte, lte
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
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 = requests.get(self.BASE + path, params=params, timeout=15)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return self._disc_map((r.json() or {}).get("results"), kind)
|
return self._disc_map((r.json() or {}).get("results"), kind)
|
||||||
|
|
|
||||||
|
|
@ -310,16 +310,20 @@ class VideoEnrichmentEngine:
|
||||||
return []
|
return []
|
||||||
return self._stamp_owned(results)
|
return self._stamp_owned(results)
|
||||||
|
|
||||||
def trending(self) -> list:
|
def trending(self, window="week") -> list:
|
||||||
"""Trending titles for the idle search page, annotated owned/not."""
|
"""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")
|
w = self.workers.get("tmdb")
|
||||||
if not w or not w.enabled:
|
if not w or not w.enabled:
|
||||||
return []
|
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:
|
if cached is None:
|
||||||
try:
|
try:
|
||||||
cached = w.client.trending() or []
|
cached = w.client.trending(window=window) or []
|
||||||
self._cache_put(("trending",), cached, ttl=3600)
|
self._cache_put(ck, cached, ttl=3600)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("video trending failed")
|
logger.exception("video trending failed")
|
||||||
return []
|
return []
|
||||||
|
|
@ -372,21 +376,32 @@ class VideoEnrichmentEngine:
|
||||||
return self._stamp_owned(items)
|
return self._stamp_owned(items)
|
||||||
|
|
||||||
def discover_filter(self, kind, *, genre=None, year=None, decade=None, providers=None,
|
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 /
|
"""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")
|
w = self.workers.get("tmdb")
|
||||||
if not w or not w.enabled:
|
if not w or not w.enabled:
|
||||||
return []
|
return []
|
||||||
if kind not in ("movie", "show"):
|
if kind not in ("movie", "show"):
|
||||||
kind = "movie"
|
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)
|
items = self._cache_get(ck)
|
||||||
if items is None:
|
if items is None:
|
||||||
try:
|
try:
|
||||||
items = w.client.discover(kind, genre=genre, year=year, decade=decade,
|
items = w.client.discover(
|
||||||
providers=providers, sort_by=sort_by, page=page,
|
kind, genre=genre, year=year, decade=decade, providers=providers,
|
||||||
region=self._region(), language=language) or []
|
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)
|
self._cache_put(ck, items, ttl=3600)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("discover filter failed (%s g=%s y=%s d=%s p=%s)",
|
logger.exception("discover filter failed (%s g=%s y=%s d=%s p=%s)",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue