""" 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. """ 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 _best_match(query_title: str, results: list[dict], keys: tuple[str, ...]) -> 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): only the top result is considered. The similarity ratio is returned purely as a confidence signal for the caller's fallback decision, never used to pick a different candidate. """ if not results: return None, 0.0 top = results[0] qn = _normalize(query_title) best_ratio = 0.0 for k in keys: val = top.get(k) if val: best_ratio = max(best_ratio, difflib.SequenceMatcher(None, qn, _normalize(str(val))).ratio()) return top, best_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 (enabled, token, language). token/language are None when unset.""" if self._roster is not None: enabled, custom_token, language = await self._roster.tmdb_config() else: enabled, custom_token, language = True, None, None token = custom_token or os.environ.get(_DEFAULT_TOKEN_ENV) or None return enabled and bool(token), token, language async def _get(self, path: str, params: dict) -> dict | None: enabled, token, language = await self._resolve() if not enabled: 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")) async def search_tv(self, title: str) -> tuple[dict | None, float]: data = await self._get("search/tv", {"query": title}) results = (data or {}).get("results", []) return _best_match(title, results, ("name", "original_name")) async def tv_season(self, tmdb_id: str | int, season: int) -> dict | None: return await self._get(f"tv/{tmdb_id}/season/{season}", {}) 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", {})