aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/setup.py5
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py27
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/apps.py20
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py64
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py31
-rw-r--r--packages/meshbay-node/tests/test_media_meta_request.py3
-rw-r--r--packages/meshbay-node/tests/test_platform.py63
-rw-r--r--packages/meshbay-node/tests/test_season_and_search_requests.py5
-rw-r--r--packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py80
-rw-r--r--packages/meshbay-node/tests/test_tmdb_language_gate.py97
10 files changed, 320 insertions, 75 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/setup.py b/packages/meshbay-node/src/meshbay_node/cli/setup.py
index b2a8e83..48a55ef 100644
--- a/packages/meshbay-node/src/meshbay_node/cli/setup.py
+++ b/packages/meshbay-node/src/meshbay_node/cli/setup.py
@@ -33,11 +33,6 @@ def init(args) -> None:
cfg_dir = cfg_path.parent
cfg_dir.mkdir(parents=True, exist_ok=True)
- from meshbay_node.platform import install_node_env
- env_written = install_node_env(cfg_dir)
- if env_written:
- print(f"Wrote {env_written} (packaged defaults).")
-
hub_url = args.hub_url
username = args.username
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/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py
index 7e840ad..7db251b 100644
--- a/packages/meshbay-node/src/meshbay_node/platform.py
+++ b/packages/meshbay-node/src/meshbay_node/platform.py
@@ -80,9 +80,10 @@ def state_dir() -> Path:
def packaged_default_env() -> Path | None:
"""
The `default.env` shipped with the package: build-time defaults, currently
- the shared read-only TMDB token. `init` copies it to config_dir()/node.env
- and nothing reads it in place, so an operator's edits to their own copy
- survive an upgrade.
+ the shared read-only TMDB token. The daemon reads it in place, beneath
+ <config>/node.env, so it reaches every node however that node was set up --
+ `meshbay-node init` and the desktop client's onboarding alike -- and an
+ operator's own node.env still wins.
Frozen (PyInstaller/Windows): beside the executable, where
build-node-runtime.ps1 puts it -- the same placement it uses for ffmpeg.
@@ -102,39 +103,7 @@ def packaged_default_env() -> Path | None:
return None
-def install_node_env(target_dir: Path) -> Path | None:
- """
- Copy the packaged default.env to <target_dir>/node.env, once, at init.
-
- Never overwrites: an existing node.env holds the operator's own values, and
- silently replacing a configured token with the packaged one would be worse
- than doing nothing. Returns the path when written, None when there was
- nothing to copy or a file was already there.
- """
- src = packaged_default_env()
- if src is None:
- return None
- dest = target_dir / "node.env"
- if dest.exists():
- return None
- dest.write_bytes(src.read_bytes())
- chmod_private(dest)
- return dest
-
-
-def load_node_env(source_dir: Path) -> int:
- """
- Read <source_dir>/node.env into os.environ, returning how many names were
- set.
-
- systemd does this on Linux through `EnvironmentFile=`, but the Windows
- autostart is a Startup-folder .vbs with no equivalent, so the daemon reads
- the file itself and both platforms behave the same. An existing environment
- variable always wins -- an operator exporting a value, or systemd having
- already loaded the same file, overrides the packaged default rather than
- being overridden by it.
- """
- path = source_dir / "node.env"
+def _load_env_file(path: Path) -> int:
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
@@ -154,6 +123,29 @@ def load_node_env(source_dir: Path) -> int:
return count
+def load_node_env(source_dir: Path) -> int:
+ """
+ Read <source_dir>/node.env, then the packaged default.env, into
+ os.environ, returning how many names were set.
+
+ systemd does the first through `EnvironmentFile=`, but the Windows
+ autostart is a Startup-folder .vbs with no equivalent, so the daemon reads
+ the file itself and both platforms behave the same. An existing environment
+ variable always wins, and node.env wins over the packaged default -- an
+ operator exporting a value, or systemd having already loaded the same file,
+ overrides the packaged default rather than being overridden by it.
+
+ The packaged default used to reach a node only as a copy made by
+ `meshbay-node init`; a node onboarded by the desktop client never ran it and
+ ran with no TMDB token at all.
+ """
+ count = _load_env_file(source_dir / "node.env")
+ packaged = packaged_default_env()
+ if packaged is not None:
+ count += _load_env_file(packaged)
+ return count
+
+
# ── File permissions ─────────────────────────────────────────────────────────
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_platform.py b/packages/meshbay-node/tests/test_platform.py
index 5c80c5b..bebe8d9 100644
--- a/packages/meshbay-node/tests/test_platform.py
+++ b/packages/meshbay-node/tests/test_platform.py
@@ -427,39 +427,8 @@ def test_source_checkout_has_no_packaged_default(monkeypatch, tmp_path):
assert plat.packaged_default_env() is None
-def test_install_node_env_copies_once(monkeypatch, tmp_path):
- src = tmp_path / "default.env"
- src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJfirst\n")
- monkeypatch.setattr(plat, "packaged_default_env", lambda: src)
- cfg = tmp_path / "config"
- cfg.mkdir()
-
- written = plat.install_node_env(cfg)
- assert written == cfg / "node.env"
- assert "eyJfirst" in written.read_text()
-
-
-def test_install_node_env_never_overwrites_operator_values(monkeypatch, tmp_path):
- """An existing node.env holds the operator's own token; clobbering it would
- silently downgrade a configured node to the shared default."""
- src = tmp_path / "default.env"
- src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n")
- monkeypatch.setattr(plat, "packaged_default_env", lambda: src)
- cfg = tmp_path / "config"
- cfg.mkdir()
- (cfg / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJoperator\n")
-
- assert plat.install_node_env(cfg) is None
- assert "eyJoperator" in (cfg / "node.env").read_text()
-
-
-def test_install_node_env_is_a_noop_without_a_package(monkeypatch, tmp_path):
- monkeypatch.setattr(plat, "packaged_default_env", lambda: None)
- assert plat.install_node_env(tmp_path) is None
- assert not (tmp_path / "node.env").exists()
-
-
def test_load_node_env_sets_names(monkeypatch, tmp_path):
+ monkeypatch.setattr(plat, "packaged_default_env", lambda: None)
(tmp_path / "node.env").write_text(
"# a comment\n"
"\n"
@@ -482,5 +451,33 @@ def test_load_node_env_does_not_override_the_environment(monkeypatch, tmp_path):
assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJfromenv"
-def test_load_node_env_tolerates_a_missing_file(tmp_path):
+def test_load_node_env_tolerates_a_missing_file(monkeypatch, tmp_path):
+ monkeypatch.setattr(plat, "packaged_default_env", lambda: None)
assert plat.load_node_env(tmp_path) == 0
+
+
+def test_load_node_env_reads_the_packaged_default_without_a_node_env(monkeypatch, tmp_path):
+ """A node onboarded by the desktop client has no node.env: nothing ran
+ `init` to copy one. The packaged token must reach it anyway."""
+ src = tmp_path / "default.env"
+ src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n")
+ monkeypatch.setattr(plat, "packaged_default_env", lambda: src)
+ monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False)
+ cfg = tmp_path / "config"
+ cfg.mkdir()
+
+ assert plat.load_node_env(cfg) == 1
+ assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJpackaged"
+
+
+def test_load_node_env_prefers_the_operator_node_env(monkeypatch, tmp_path):
+ src = tmp_path / "default.env"
+ src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n")
+ monkeypatch.setattr(plat, "packaged_default_env", lambda: src)
+ monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False)
+ cfg = tmp_path / "config"
+ cfg.mkdir()
+ (cfg / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJoperator\n")
+
+ plat.load_node_env(cfg)
+ assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJoperator"
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"