""" TMDB (themoviedb.org) client for the Videos group app. Called only by the node, never by a client (docs/mediacenter.md §2): the node holds the one credential and makes the one request per unique title, shared by every member. Token resolution order (§5.5): 1. an operator-supplied token (roster.py group_settings, group_id="") 2. the MESHBAY_TMDB_DEFAULT_TOKEN environment variable 3. none — TMDB lookups are inert (callers get an empty result, never an exception, so a node with no token configured just serves thumbnails) The real secret (whichever token resolves) never appears in source control: there is no literal fallback value in this file. See mediacenter.md's implementation notes on why a shipped default is a deployment concern, not a code concern. Results also come back in whatever language the operator configured (roster.py's `tmdb_language`, e.g. "fr-FR") — one language for the whole node, same reasoning as the token: one shared cache, not a per-viewer request. Omitted entirely when unset, which lets TMDB fall back to its own default (English) rather than this client guessing one. Whether TMDB is used *at all* is a **per-group** decision (roster.py's `tmdb_enabled(group_id)`, moved off the node-wide sentinel 2026-08-24) — this client has no group in scope, so that check happens once, in webrtc_server.py, before any of this client's methods are ever called for a given request. This client only resolves the shared credential/language. """ import difflib import logging import os import re import httpx from meshbay_node.roster import Roster log = logging.getLogger(__name__) _BASE_URL = "https://api.themoviedb.org/3/" _IMAGE_BASE = "https://image.tmdb.org/t/p/w500" _TIMEOUT = 10.0 _DEFAULT_TOKEN_ENV = "MESHBAY_TMDB_DEFAULT_TOKEN" def _normalize(s: str) -> str: s = s.lower() s = re.sub(r"[^a-z0-9àâäéèêëïîôöùûüçñ ]+", " ", s) return re.sub(r"\s+", " ", s).strip() def _release_year_of(item: dict) -> int | None: d = str(item.get("release_date") or item.get("first_air_date") or "") return int(d[:4]) if d[:4].isdigit() else None def _ratio_against(qn: str, item: dict, keys: tuple[str, ...]) -> float: best = 0.0 for k in keys: val = item.get(k) if val: best = max(best, difflib.SequenceMatcher(None, qn, _normalize(str(val))).ratio()) return best def _best_match( query_title: str, results: list[dict], keys: tuple[str, ...], year: int | None = None, ) -> tuple[dict | None, float]: """ Trusts TMDB's own ranking (§3.3's last row — a locally-recomputed re-rank picked a coincidentally-closer-looking wrong show once): the top result is what's returned. The similarity ratio rides along purely as a confidence signal for the caller's fallback decision. One narrow exception (§10.1/V9): when the top result is *not* a confident textual hit (ratio < 0.6) and a `year` was requested, a different result of that **exact** release year is preferred. TMDB already year-filtered the search, so an entry landing on the requested year is a hard corroboration, not the fuzzy string re-rank §3.3 warns against — and this never overrides a confident top hit. """ if not results: return None, 0.0 qn = _normalize(query_title) top = results[0] top_ratio = _ratio_against(qn, top, keys) if year is not None and top_ratio < 0.6: for item in results: if _release_year_of(item) == year: return item, _ratio_against(qn, item, keys) return top, top_ratio class TmdbClient: """One instance per node, holding the resolved token and an httpx client.""" def __init__(self, roster: Roster | None = None, transport: httpx.AsyncBaseTransport | None = None): self._roster = roster # `transport` is a test-only seam (httpx.MockTransport) — production # callers never pass it, and httpx.AsyncClient defaults to real # network I/O when it's None. self._client = httpx.AsyncClient(timeout=_TIMEOUT, transport=transport) async def close(self) -> None: await self._client.aclose() async def _resolve(self) -> tuple[bool, str | None, str | None]: """ Returns (has_token, token, language). token/language are None when unset. Whether TMDB is used *at all* is a per-group decision made by the caller (roster.tmdb_enabled(group_id), checked in webrtc_server.py before any of this client's methods are called) — this client only knows the node-wide credential/language, and has no group to check against. """ if self._roster is not None: custom_token, language = await self._roster.tmdb_config() else: custom_token, language = None, None token = custom_token or os.environ.get(_DEFAULT_TOKEN_ENV) or None return bool(token), token, language async def _get(self, path: str, params: dict) -> dict | None: has_token, token, language = await self._resolve() if not has_token: return None if language and "language" not in params: params = {**params, "language": language} try: resp = await self._client.get( _BASE_URL + path, params=params, headers={"Authorization": f"Bearer {token}", "accept": "application/json"}, ) resp.raise_for_status() return resp.json() except httpx.HTTPError as e: log.warning("TMDB request failed (%s): %s", path, e) return None @staticmethod def poster_url(path: str | None) -> str | None: return f"{_IMAGE_BASE}{path}" if path else None async def fetch_image(self, url: str) -> bytes | None: """Fetches a poster/backdrop image. Unauthenticated — image.tmdb.org needs no token.""" try: resp = await self._client.get(url, timeout=_TIMEOUT) resp.raise_for_status() return resp.content except httpx.HTTPError as e: log.warning("TMDB image fetch failed (%s): %s", url, e) return None async def search_movie(self, title: str, year: int | None = None) -> tuple[dict | None, float]: params = {"query": title, "include_adult": "false"} if year: params["year"] = year data = await self._get("search/movie", params) results = (data or {}).get("results", []) return _best_match(title, results, ("title", "original_title"), year=year) async def search_tv(self, title: str, year: int | None = None) -> tuple[dict | None, float]: params = {"query": title} if year: params["first_air_date_year"] = year data = await self._get("search/tv", params) results = (data or {}).get("results", []) return _best_match(title, results, ("name", "original_name"), year=year) async def search_movie_results(self, title: str) -> list[dict]: """ The raw candidate list (capped), for an operator correcting a wrong automatic match (§ webrtc_server.py's tmdb_search_req) — unlike `search_movie`, this doesn't collapse to TMDB's own top result: a human picks from several, so several is the point. """ data = await self._get("search/movie", {"query": title, "include_adult": "false"}) return (data or {}).get("results", [])[:8] async def search_tv_results(self, title: str) -> list[dict]: data = await self._get("search/tv", {"query": title}) return (data or {}).get("results", [])[:8] async def tv_season(self, tmdb_id: str | int, season: int, language: str | None = None) -> dict | None: """`language`, when given, overrides the configured one — same English-fallback use as `movie_details`/`tv_details`.""" params = {"language": language} if language else {} return await self._get(f"tv/{tmdb_id}/season/{season}", params) async def movie_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None: """ Full details, not the search result: search/movie doesn't return `runtime` or genre names (only `genre_ids`) at all. `language`, when given, overrides the configured one — used for the English fallback fetch (§ below): TMDB itself doesn't fall back server-side for an untranslated field, it just returns "" for it, the same gap the TMDB website itself papers over client-side. """ params = {"language": language} if language else {} return await self._get(f"movie/{tmdb_id}", params) async def tv_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None: params = {"language": language} if language else {} return await self._get(f"tv/{tmdb_id}", params) async def movie_credits(self, tmdb_id: str | int) -> dict | None: return await self._get(f"movie/{tmdb_id}/credits", {}) async def tv_credits(self, tmdb_id: str | int) -> dict | None: return await self._get(f"tv/{tmdb_id}/credits", {})