diff --git a/api/video/discover.py b/api/video/discover.py index 2da8edd8..36334860 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -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() diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index e076136c..a049716b 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -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) diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 3047f852..626e5b62 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -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)",