1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
|
"""
`_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", "Some Show")
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"] == "Some Show"
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=<raw filename>` 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"
|