summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/tmdb.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 10:04:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 10:04:46 +0200
commit6af05abf410bbd038ce7fa6915a659defc509071 (patch)
tree09b1c941fa446b077ff51282fa18250998528263 /packages/meshbay-node/src/meshbay_node/tmdb.py
parentc4981454078a59f776d484f0f1828f2fc5eaad09 (diff)
downloadmeshbay-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.py159
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", {})