""" `_do_media_meta_request`, keyed by `file_id` (2026-08-25 fix) — same regression as test_music_meta_request.py, one app over: `IndexEntry.path` is the *folder* a file is in, not the file itself, so a lookup by path alone (the pre-fix `GroupIndex.get_entry_by_path`) silently resolved to whichever entry the index happened to return first for that folder — a real risk here too, since a season folder routinely holds many episodes. """ import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common.protocol import MNP, 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 FakeTmdbClient: """Returns a distinct, deterministic match per entry — real enough to prove the server searched using the *right* entry's own fields.""" def __init__(self): self.searched = [] async def search_movie(self, title): self.searched.append(("movie", title)) return {"id": 1000 + len(self.searched), "title": title, "release_date": "2001-01-01"}, 1.0 async def search_tv(self, title): self.searched.append(("tv", title)) return {"id": 2000 + len(self.searched), "name": title, "first_air_date": "2001-01-01"}, 1.0 async def fetch_image(self, url): return f"image-bytes-for-{url}".encode() @staticmethod def poster_url(path): return f"https://image.tmdb.org/t/p/w500{path}" def _entry(path: str, name: str, file_id: str, display_title: str) -> IndexEntry: return IndexEntry( id=file_id, name=name, path=path, size=1, type="video", added_at=0, display_title=display_title, ) @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(index, media_cache, tmdb_client): session = WebRTCPeerSession.__new__(WebRTCPeerSession) session._ctx = { "index": index, "media_cache": media_cache, "tmdb_client": tmdb_client, "tmdb_enabled": True, } session._group_id = None session.sent = [] session._send = session.sent.append # _tmdb_search/_tmdb_build_meta are the real methods (not part of this # regression) — stub the search ladder to a single direct call by # display_title so this test is about routing, not TMDB matching. async def _search(tmdb_client_, entry, is_show): return await (tmdb_client_.search_tv(entry.display_title) if is_show else tmdb_client_.search_movie(entry.display_title)) session._tmdb_search = lambda *a: _search(*a) async def _build_meta(tmdb_client_, tmdb_id, media_type, result): return { "title": result.get("title") or result.get("name"), "original_title": result.get("title") or result.get("name"), "release_date": result.get("release_date"), "first_air_date": result.get("first_air_date"), "confidence": 1.0, } session._tmdb_build_meta = lambda *a: _build_meta(*a) return session async def test_two_episodes_in_the_same_season_folder_each_get_their_own_metadata(media_cache): """The Videos-side analogue of the Music bug: two episodes share a season folder, and each must resolve against its own entry — not whichever one the index happens to return first for that folder.""" index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) ep1 = _entry("shows/Show/Season 1", "s01e01.mkv", "id-1", "War of the Worlds") ep2 = _entry("shows/Show/Season 1", "s01e02.mkv", "id-2", "A Different Show") index.add_entry(ep1) index.add_entry(ep2) client = FakeTmdbClient() session = _session(index, media_cache, client) await session._do_media_meta_request({"file_id": "id-1"}) await session._do_media_meta_request({"file_id": "id-2"}) resp_1, resp_2 = session.sent assert resp_1["file_id"] == "id-1" assert resp_1["title"] == "War of the Worlds" assert resp_2["file_id"] == "id-2" assert resp_2["title"] == "A Different Show" assert resp_1["tmdb_id"] != resp_2["tmdb_id"], ( "two different shows sharing a season folder must not resolve to the same match") async def test_a_reclassified_file_ignores_its_stale_cached_match(media_cache): """ A file's classification (movie vs show) comes from its IndexEntry's season/episode — set by index-time enrichment, which can change its mind on a later scan (a filename-parsing fix reclassifying a whole folder from "movie" to "tv", say) without media_cache's file->tmdb mapping knowing anything happened: that cache is keyed by the file's content hash alone, unchanged by any such reclassification. Found live: exactly this scenario left every affected file answering with its stale, wrong-kind-of-match forever, since the cache was trusted before ever comparing media_type against what the entry resolves to now. """ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) entry = IndexEntry(id="id-1", name="ep.mkv", path="shows/Show/Specials", size=1, type="video", added_at=0, display_title="Some Show", season=0, episode=1) index.add_entry(entry) # Pre-populate the cache exactly as it would be left over from before # entry.season/episode existed — a movie search matched to some # unrelated title, cached by this file's content hash. await media_cache.set_file_tmdb("id-1", "stale-movie-id", "movie") await media_cache.set_tmdb_meta("stale-movie-id", "movie", { "title": "An Unrelated Movie", "original_title": "An Unrelated Movie", "release_date": "1999-01-01", "confidence": 1.0, }) client = FakeTmdbClient() session = _session(index, media_cache, client) await session._do_media_meta_request({"file_id": "id-1"}) resp = session.sent[0] assert resp["title"] == "Some Show" assert ("tv", "Some Show") in client.searched assert resp["tmdb_id"] != "stale-movie-id" async def test_missing_file_id_is_refused(media_cache): index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) session = _session(index, media_cache, FakeTmdbClient()) await session._do_media_meta_request({}) assert session.sent == [{"type": "error", "detail": "Missing file_id"}] async def test_unknown_file_id_is_refused(media_cache): index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) session = _session(index, media_cache, FakeTmdbClient()) await session._do_media_meta_request({"file_id": "nope"}) assert session.sent == [{"type": "error", "detail": "File not found"}] async def test_a_not_yet_enriched_entry_with_no_cache_is_answered_without_a_search(media_cache): """ A video the indexer has seen but not enriched yet has no display_title (enrich.py always sets one) and season/episode still None — which the movie/show split reads as "movie" and hands its raw filename to TMDB's movie search. During a slow initial scan with a browser on the Videos tab that is a storm of `search/movie?query=` and bogus cached matches (found live 2026-08-29). With nothing cached it must answer confidence 0 and let the client refetch once the enriched fields arrive — never guess a match from the raw filename. """ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) entry = IndexEntry(id="id-raw", name="Show.S01E01.1080p.WEB.mkv", path="shows/Show", size=1, type="video", added_at=0) index.add_entry(entry) client = FakeTmdbClient() session = _session(index, media_cache, client) await session._do_media_meta_request({"file_id": "id-raw"}) assert len(session.sent) == 1 resp = session.sent[0] assert resp["type"] == MNP.MEDIA_META_RESP assert resp["file_id"] == "id-raw" assert resp["confidence"] == 0 assert "tmdb_id" not in resp assert client.searched == [], "no TMDB search for a not-yet-enriched video" async def test_a_not_yet_enriched_entry_is_served_from_cache_without_a_search(media_cache): """ The point the operator raised: a restart must not re-query TMDB for a file already resolved. An un-enriched entry (season/episode not yet populated) whose content hash already has a cached match is served straight from that cache, honouring the cached *kind* rather than the provisional "movie" the split would pick — so a show episode keeps its real "tv" match instead of triggering a fresh movie search on its raw filename. """ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) entry = IndexEntry(id="id-known", name="Show.S02E05.1080p.WEB.mkv", path="shows/Show", size=1, type="video", added_at=0) index.add_entry(entry) await media_cache.set_file_tmdb("id-known", "1396", "tv") await media_cache.set_tmdb_meta("1396", "tv", { "title": "The Cached Show", "original_title": "The Cached Show", "first_air_date": "2008-01-20", "confidence": 1.0, }) client = FakeTmdbClient() session = _session(index, media_cache, client) await session._do_media_meta_request({"file_id": "id-known"}) resp = session.sent[0] assert resp["tmdb_id"] == "1396" assert resp["title"] == "The Cached Show" assert client.searched == [], "a cached match must not be re-searched on restart"