diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 10:04:46 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 10:04:46 +0200 |
| commit | 6af05abf410bbd038ce7fa6915a659defc509071 (patch) | |
| tree | 09b1c941fa446b077ff51282fa18250998528263 /packages/meshbay-node/src/meshbay_node/tmdb.py | |
| parent | c4981454078a59f776d484f0f1828f2fc5eaad09 (diff) | |
| download | meshbay-6af05abf410bbd038ce7fa6915a659defc509071.tar.gz | |
feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)
Implements docs/mediacenter.md: a "Videos" group application built on the
existing files index rather than a separate catalogue. On the node side,
new indexer enrichment (technical probe, filename/season parsing, thumbnail
generation) runs per-file once an operator has chosen a video_root for the
group, plus a TMDB client for on-demand poster/metadata lookups (never
client-side, thumbnails delivered over the existing chunk path). On the hub
side, a new video-app.js renders a lazily-mounted poster grid or a
thumbnail-only flat list, with TMDB entirely optional per group.
Along the way: the global apps registry now drives Settings' default-tab
picker instead of a hardcoded list, and the video_root is configured from
group Settings (like uploads) rather than from Files, with the node
refusing to run any TMDB/thumbnail work until one is set.
Fixes several bugs found via live testing against a real library, notably
a race between two effects writing the same "image ready" state that could
leave a poster grid spinning forever on a same-tab revisit — see
mediacenter.md §5.4 for the full account of each one.
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/tmdb.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/tmdb.py | 159 |
1 files changed, 159 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..5e5ed9f --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/tmdb.py @@ -0,0 +1,159 @@ +""" +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", {}) |