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:
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
Loading…
Reference in a new issue