diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-26 14:28:13 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-26 14:28:13 +0200 |
| commit | eccd465f8954bfd49c3f7d8f02446bc2aa5c4338 (patch) | |
| tree | 0033f9232c56ca79c4838795c1cf5bdab13bc55f | |
| parent | fc761e7df40eac828d4e9858fab56958078c928b (diff) | |
| download | meshbay-eccd465f8954bfd49c3f7d8f02446bc2aa5c4338.tar.gz | |
TMDB fiches are fetched lazily, on browse, and the fetch used to run whatever
the moment it was first triggered — routinely before the operator had opened
settings and picked a language, so it queried in TMDB's English default. Then
the fiche was cached by tmdb_id alone, with no note of language and a 30-day
TTL, so switching to the intended language afterwards changed nothing: the
English fiche was served until it expired. The operator's only recourse was to
find and wipe the cache by hand (found live 2026-09-26: a whole library indexed
in English although "Français" had been chosen).
Two rules now, both there to make the first fetch the right language rather
than English-then-corrected, and to stop the doubled requests that eventually
get a node rate-limited:
- No language configured, no query. media_meta_req/season_meta_req answer
confidence 0 and make no TMDB call while tmdb_language is unset; the fetch
waits for the operator's choice, so the first (and only) query is in it.
English is now a first-class choice (en-US), not the default of skipping the
setting.
- Changing the language wipes the metadata cache (ops.set_tmdb_config), so the
new language takes effect on an already-browsed library. The file->tmdb
matches are language-independent and kept. The client refetches on the
tmdb_config_ack that carries the new language, so the grid updates without a
page reload.
docs/MESHBAY_DESIGN.md §9.7 states both rules; tests cover the gate and the
cache wipe, and two existing handler harnesses now declare a language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9 files changed, 300 insertions, 1 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 4daa094..4541d64 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2615,6 +2615,28 @@ its own bounded pool after a file is first seen: the file appears in the index immediately with size and hash, and an index delta fills in the technical and parsed fields once ready. **No scan is blocked waiting for enrichment.** +**TMDB metadata is queried in one node-wide language, and not until one is chosen.** +A cached fiche is keyed by TMDB id alone — it records no language, because there is +only ever one. Two rules keep it honest, both there to make the very first fetch the +right language rather than English-then-corrected: + +- **No language configured, no query.** While `tmdb_language` is unset the node + answers `media_meta_req`/`season_meta_req` with confidence 0 and makes no TMDB call + — it does *not* fall back to TMDB's English default. Browsing the library is what + triggers the lazy fetch, and it routinely happens before the operator has opened the + settings, so querying on an unset language would fetch the whole library in English + and then throw it all away the moment a language was picked — double the requests + against TMDB's rate limit, for a result nobody wanted. The fetch waits for the + choice, so the first (and only) query is in the chosen language. English is a + first-class choice like any other (`en-US`), not the accidental default of skipping + the setting. +- **Changing the language wipes the cache.** A fiche fetched under the old language + would otherwise be served for its whole 30-day TTL. The file→TMDB matches are + language-independent and survive the wipe; only the localized fiches are dropped and + refetched, lazily, on the next request. The client refetches on its own when the + `tmdb_config_ack` carrying the new language arrives, so the grid switches language + (or fills in for the first time) without a page reload. + ### 9.8 Music An album browser and a player over the audio files in the group's configured diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 124033e..4c9f90c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -976,6 +976,22 @@ function VideoApp({ useEffect(() => { setMode(loadViewMode()); }, [groupId]); useEffect(() => { setFilter(''); setTypeFilter('all'); }, [groupId]); + // When the operator changes the node's TMDB language, the node drops its + // metadata cache and refetches in the new language, and — until a language + // is set — answers nothing at all rather than querying in English (§9.7). + // Tell every mounted tile to redo its media_meta_req and drop the + // show-level meta already merged here, so the grid switches language (or + // fills in for the first time, right after the operator picks one) without + // a page reload. Skip the initial mount: the language is already right then, + // and bumping would restorm TMDB on every open of the Videos tab. + const tmdbLanguage = tmdbConfig ? (tmdbConfig.language || '') : ''; + const firstLangRef = useRef(true); + useEffect(() => { + if (firstLangRef.current) { firstLangRef.current = false; return; } + setMetaByGroup({}); + bumpMediaMetaGeneration(); + }, [tmdbLanguage]); + const setModeAndSave = (m) => { setMode(m); saveViewMode(m); }; const videoEntries = availableEntries || entries; diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py index 9c692ca..17262bc 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -290,6 +290,33 @@ class MediaCache: await self._db.commit() return cur.rowcount + async def clear_tmdb_metadata(self) -> int: + """ + Drop every cached TMDB fiche — show/movie details (`tmdb_meta`) and + per-season metadata (`season_meta`) — so the next `media_meta_req` + refetches each from TMDB. The file->tmdb *matches* (`file_tmdb`) are + language-independent and deliberately kept: the match is the same + title whatever language its blurb is in. + + Called when the node's TMDB *language* changes (`ops.set_tmdb_config`). + A fiche is cached under `tmdb_id` alone, on purpose — there is only + ever one node-wide language, so a per-language key would be dead + weight — which is exactly why the language it was fetched in is not + recorded, and a fiche cached under the old language would otherwise be + served unchanged for its whole 30-day TTL after the operator switched. + Wiping them on the switch is what makes the new language actually take + effect on a library that has already been browsed. Returns the number + of rows removed. + """ + if not self._db: + return 0 + cur = await self._db.execute("DELETE FROM tmdb_meta") + removed = cur.rowcount + cur = await self._db.execute("DELETE FROM season_meta") + removed += cur.rowcount + await self._db.commit() + return removed + # ── tmdb id -> metadata json ───────────────────────────────────────────── async def get_tmdb_meta(self, tmdb_id: str, media_type: str) -> dict | None: diff --git a/packages/meshbay-node/src/meshbay_node/ops/apps.py b/packages/meshbay-node/src/meshbay_node/ops/apps.py index 0e4f6dd..fbd984d 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/apps.py +++ b/packages/meshbay-node/src/meshbay_node/ops/apps.py @@ -53,6 +53,13 @@ async def set_tmdb_config(state: dict, token: str | None = None, `language`. """ roster = _roster(state) + # Whether the *language* actually changes decides whether the cached + # fiches must go (below) — read the old value before overwriting it. + # "" (default/English) and None (unset) are the same language here. + language_changed = False + if language is not None: + _, old_language = await roster.tmdb_config() + language_changed = (language or None) != (old_language or None) await roster.set_tmdb_config(token, language, set_by=state.get("node_user_id", "")) # `token=None` means "leave whatever was there" (§ set_tmdb_config's own # docstring) — so the customized flag only changes when a value (a real @@ -61,6 +68,19 @@ async def set_tmdb_config(state: dict, token: str | None = None, state["tmdb_token_customized"] = bool(token) if language is not None: state["tmdb_language"] = language + # A cached TMDB fiche is stored under its tmdb_id alone and carries no note + # of the language it was fetched in (there is only one node-wide language), + # so changing the language leaves every fiche stale for its 30-day TTL. + # Drop the metadata cache here so the next media_meta_req refetches in the + # new language — this is what makes the setting take on a library that was + # already browsed, instead of the operator having to find a cache to clear. + # The matches (file_tmdb) are language-independent and kept. + if language_changed: + media_cache = state.get("media_cache") + if media_cache is not None: + removed = await media_cache.clear_tmdb_metadata() + log.info("TMDB language changed to %s: cleared %d cached fiche(s)", + language or "(default)", removed) log.info("TMDB config: custom_token=%s language=%s", bool(token), language or state.get("tmdb_language", "")) return { diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py index 9222ad8..834bac4 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py @@ -169,6 +169,15 @@ class VideoMetaMixin: await media_cache.put_thumb(thumb_hash, synthetic_id, content) return thumb_hash + def _tmdb_language(self) -> str: + """ + The node-wide TMDB query language, or "" when the operator has not + chosen one yet. Read from the live daemon state (kept current by + tmdb_config_ack, same source the handshake ack reads), not the DB, so + it is a cheap in-memory lookup on the hot metadata path. + """ + return (self._ctx.get("daemon_state") or {}).get("tmdb_language") or "" + async def _do_media_meta_request(self, msg: dict) -> None: """ docs/MESHBAY_DESIGN.md §9.7: TMDB metadata for one file, resolved from @@ -250,6 +259,19 @@ class VideoMetaMixin: self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, "file_id": file_id, "confidence": 0}) return + if not self._tmdb_language(): + # No query language chosen yet: hold off entirely rather than + # search now. TMDB would answer in its English default, which + # is both the wrong language and a wasted call — the whole + # library fetched now would be thrown away and refetched the + # moment a language is set, doubling the request count against + # TMDB's rate limit. Waiting until the operator has chosen one + # is what makes the first (and only) fetch the chosen language + # (docs/MESHBAY_DESIGN.md §9.7). The client refetches on the + # tmdb_config_ack that carries the new language. + self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, + "file_id": file_id, "confidence": 0}) + return result, ratio = await self._tmdb_search(tmdb_client, entry, is_show) if result is None or ratio < 0.6: self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, @@ -315,6 +337,15 @@ class VideoMetaMixin: details = await media_cache.get_season_meta(tmdb_id, season) if details is None: + if not self._tmdb_language(): + # Same gate as media_meta_req above: no query language yet + # means no TMDB call (docs/MESHBAY_DESIGN.md §9.7). A show is + # only matched once a language is set, so this is normally + # unreachable, but a client holding a tmdb_id from an earlier + # session must not reopen an English fetch either. + self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION, + "tmdb_id": tmdb_id, "season": season, "confidence": 0}) + return fetched = await tmdb_client.tv_season(tmdb_id, season) if fetched is None: self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION, diff --git a/packages/meshbay-node/tests/test_media_meta_request.py b/packages/meshbay-node/tests/test_media_meta_request.py index 9a1c5aa..0392845 100644 --- a/packages/meshbay-node/tests/test_media_meta_request.py +++ b/packages/meshbay-node/tests/test_media_meta_request.py @@ -64,6 +64,9 @@ def _session(index, media_cache, tmdb_client): "media_cache": media_cache, "tmdb_client": tmdb_client, "tmdb_enabled": True, + # A configured node: the language gate (§9.7) holds every TMDB fetch + # until a language is chosen, so these routing tests must set one. + "daemon_state": {"tmdb_language": "en-US"}, } session._group_id = None session.sent = [] diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py index 7e81e63..bec7df9 100644 --- a/packages/meshbay-node/tests/test_season_and_search_requests.py +++ b/packages/meshbay-node/tests/test_season_and_search_requests.py @@ -19,7 +19,10 @@ pytestmark = pytest.mark.asyncio def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession: session = WebRTCPeerSession.__new__(WebRTCPeerSession) - session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client} + # daemon_state carries a configured language: the gate (§9.7) holds every + # TMDB fetch until one is set, so these fetch/search tests must set one. + session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client, + "daemon_state": {"tmdb_language": "en-US"}} session._group_id = None # Set because production always has one: `_dispatch_message` refuses every # message until the handshake settles `_user_id`, so a session reaching any diff --git a/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py b/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py new file mode 100644 index 0000000..cf1ecdc --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py @@ -0,0 +1,80 @@ +""" +Changing the node's TMDB language wipes the cached fiches (`ops.set_tmdb_config`). + +The cache is keyed by TMDB id alone and records no language, so a fiche fetched +under the old language would be served for its whole 30-day TTL. An operator who +sets the language *after* browsing the library once — the ordinary order, since +browsing is what triggers the lazy fetch — would keep seeing the old language +otherwise (found live: a whole library indexed in English before "Français" was +chosen, 2026-09-26). Only the language change clears; a no-op re-set or a +token-only change must leave the cache alone, or every unrelated settings save +would throw the library's metadata away. +""" + +import pytest +from meshbay_node import ops +from meshbay_node.media_cache import MediaCache +from meshbay_node.roster import Roster + +pytestmark = pytest.mark.asyncio + + +async def _state(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + cache = MediaCache(db_path=tmp_path / "media_cache.db") + await cache.open() + state = {"roster": roster, "media_cache": cache, "node_user_id": "operator"} + return state, roster, cache + + +async def _seed(cache): + await cache.set_tmdb_meta("1668", "tv", {"name": "Friends"}) + await cache.set_season_meta("1668", 1, {"overview": "Season one"}) + + +async def test_changing_language_clears_the_metadata_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="") # start at default/English + await _seed(cache) + + await ops.set_tmdb_config(state, language="fr-FR") + + assert await cache.get_tmdb_meta("1668", "tv") is None + assert await cache.get_season_meta("1668", 1) is None + finally: + await roster.close() + await cache.close() + + +async def test_re_setting_the_same_language_keeps_the_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="fr-FR") + await _seed(cache) + + await ops.set_tmdb_config(state, language="fr-FR") + + assert await cache.get_tmdb_meta("1668", "tv") == {"name": "Friends"} + assert await cache.get_season_meta("1668", 1) == {"overview": "Season one"} + finally: + await roster.close() + await cache.close() + + +async def test_token_only_change_keeps_the_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="fr-FR") + await _seed(cache) + + # language=None means "leave the language" — not a language change, + # so the fiches stay. + await ops.set_tmdb_config(state, token="a-custom-token") + + assert await cache.get_tmdb_meta("1668", "tv") == {"name": "Friends"} + assert await cache.get_season_meta("1668", 1) == {"overview": "Season one"} + finally: + await roster.close() + await cache.close() diff --git a/packages/meshbay-node/tests/test_tmdb_language_gate.py b/packages/meshbay-node/tests/test_tmdb_language_gate.py new file mode 100644 index 0000000..cd43fef --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_language_gate.py @@ -0,0 +1,97 @@ +""" +The node does not query TMDB until a language is configured (§9.7). + +Browsing the Videos tab is what triggers the lazy `media_meta_req`, and it +routinely happens before the operator has opened the settings and chosen a +language. Querying then would fetch the whole library in TMDB's English default +and throw it away the moment a language was picked — double the requests against +TMDB's rate limit, for a result nobody asked for (found live 2026-09-26: a whole +library indexed in English before "Français" was chosen). So an unset language +answers confidence 0 and makes no call; a set language fetches once, in it. +""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.protocol import IndexEntry +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +class CountingTmdbClient: + def __init__(self): + self.calls = 0 + + async def search_movie(self, title, year=None): + self.calls += 1 + return {"id": 42, "title": title, "release_date": "2001-01-01"}, 1.0 + + async def search_tv(self, title, year=None): + self.calls += 1 + return {"id": 43, "name": title, "first_air_date": "2001-01-01"}, 1.0 + + async def movie_details(self, tmdb_id, language=None): + self.calls += 1 + return {"title": "A Film", "genres": [{"name": "Drama"}], + "poster_path": "/p.jpg", "overview": "x"} + + async def movie_credits(self, tmdb_id): + return {"cast": [], "crew": []} + + async def fetch_image(self, url): + return b"img" + + @staticmethod + def poster_url(path): + return f"https://image.tmdb.org/t/p/w500{path}" + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +def _session(media_cache, tmdb_client, language): + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + entry = IndexEntry(id="f1", name="Some.Film.2001.mkv", path="movies", size=1, + type="video", added_at=0, display_title="Some Film") + index.add_entry(entry) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "index": index, + "media_cache": media_cache, + "tmdb_client": tmdb_client, + "tmdb_enabled": True, + "daemon_state": {"tmdb_language": language}, + } + session._group_id = None + session.sent = [] + session._send = session.sent.append + return session, entry + + +async def test_no_language_makes_no_tmdb_call(media_cache): + client = CountingTmdbClient() + session, entry = _session(media_cache, client, language="") + + await session._do_media_meta_request({"file_id": entry.id}) + + assert client.calls == 0 + assert session.sent[-1]["confidence"] == 0 + # Nothing cached, so a later request in a real language still starts clean. + assert await media_cache.get_file_tmdb(entry.id) is None + + +async def test_a_configured_language_fetches(media_cache): + client = CountingTmdbClient() + session, entry = _session(media_cache, client, language="fr-FR") + + await session._do_media_meta_request({"file_id": entry.id}) + + assert client.calls > 0 + assert session.sent[-1].get("tmdb_id") == "42" |