aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/tmdb.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 14:33:38 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 14:33:38 +0200
commit317f09328ed8bf20148b707470c9b0fe82e59575 (patch)
treeba8daf5dcf050d44b7b0e766babbfda8fadac59f /packages/meshbay-node/src/meshbay_node/tmdb.py
parentb6e2dcea65124673da047f9b3c92bc5e25980d63 (diff)
parent0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a (diff)
downloadmeshbay-317f09328ed8bf20148b707470c9b0fe82e59575.tar.gz
Merge branch 'docs/mediacenter-videos-app': Videos group app
Poster grid / flat list browsing, TMDB metadata enrichment, thumbnail generation and caching, season-specific overviews, manual match correction, and the create-group wizard's app-selection step. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/tmdb.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/tmdb.py177
1 files changed, 177 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py
new file mode 100644
index 0000000..448a2b9
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/tmdb.py
@@ -0,0 +1,177 @@
+"""
+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 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", {})