summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-29 18:30:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 18:30:30 +0200
commit8de80b7e19738b716d9973c0b4ba3b42085f7359 (patch)
tree87305a6977fabafa2f733044c4c0d776295626f3 /packages
parent78eef92bfa3513a81ac64d4377f8300c533aaa6c (diff)
parent32318a64b12c83429c156a36c228769492e41d4e (diff)
downloadmeshbay-8de80b7e19738b716d9973c0b4ba3b42085f7359.tar.gz
Merge branch 'fix/media-meta-unenriched-guard'
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js16
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py44
-rw-r--r--packages/meshbay-node/tests/test_media_meta_request.py61
3 files changed, 105 insertions, 16 deletions
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 6d13e27..50fbcf6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -213,7 +213,13 @@ function bumpThumbGeneration() {
for (const fn of _thumbRetryListeners) fn();
}
-function useMediaMeta(transportRef, fileId, active) {
+// `enrichSig` — a value that changes when the entry's index-time
+// enrichment lands (display_title fills in). The node answers confidence 0
+// for a not-yet-enriched video (it can't tell it apart from a movie and
+// would otherwise storm TMDB with its raw filename), so the client must
+// refetch once the enriched fields arrive on an index delta — the fileId
+// (a content hash) never changes, so nothing else would trigger it.
+function useMediaMeta(transportRef, fileId, active, enrichSig) {
const [meta, setMeta] = useState(null);
const [refetchToken, setRefetchToken] = useState(0);
@@ -238,7 +244,7 @@ function useMediaMeta(transportRef, fileId, active) {
} catch { if (!cancelled) setMeta({ confidence: 0 }); }
})();
return () => { cancelled = true; };
- }, [fileId, active, refetchToken]);
+ }, [fileId, active, refetchToken, enrichSig]);
return meta;
}
@@ -280,7 +286,7 @@ function useSeasonMeta(transportRef, tmdbId, season, active) {
function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) {
const tRef = repEntry._tRef || transportRef;
const gRef = repEntry._gRef || gekRef;
- const meta = useMediaMeta(tRef, repEntry.id, true);
+ const meta = useMediaMeta(tRef, repEntry.id, true, repEntry.display_title || '');
const confident = Boolean(meta && meta.confidence && meta.tmdb_id);
const metaReady = meta !== null;
@@ -708,7 +714,9 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
const openDetail = (title, repEntry, show, files) => setDetail({ title, repEntry, show, files });
const detailTRef = detail && detail.repEntry._tRef ? detail.repEntry._tRef : transportRef;
const detailGRef = detail && detail.repEntry._gRef ? detail.repEntry._gRef : gekRef;
- const detailMeta = useMediaMeta(detailTRef, detail ? detail.repEntry.id : null, !!detail);
+ const detailMeta = useMediaMeta(
+ detailTRef, detail ? detail.repEntry.id : null, !!detail,
+ detail ? (detail.repEntry.display_title || '') : '');
return html`
<div class="video-grid">
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index af6bf08..1bd7203 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -2915,6 +2915,17 @@ class WebRTCPeerSession:
"file_id": file_id, "confidence": 0})
return
+ # A video the indexer has seen but not yet *enriched* has no
+ # display_title (enrich.py always sets one) and season/episode still
+ # None — so the movie/show split reads "movie" and would hand 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>` (found live 2026-08-29, an
+ # 8-minute scan). While un-enriched we never *search*: we serve a
+ # cached match if there is one (§ below), else confidence 0 and the
+ # client refetches once the index delta carries the enriched fields.
+ enriched = bool(entry.display_title)
+
is_show = entry.season is not None and entry.episode is not None
media_type = "tv" if is_show else "movie"
@@ -2923,21 +2934,32 @@ class WebRTCPeerSession:
tmdb_id = None
if cached is not None:
cached_tmdb_id, cached_media_type = cached
- # Trustworthy only if it still agrees with what this file
- # resolves to *now*. season/episode come from index-time
- # enrichment (enrich.py), which can reclassify a file between
- # movie and show on a later scan without this cache knowing —
- # it is keyed by the file's content hash alone, which a
- # reclassification never changes. Found live: an enrichment fix
- # to a Specials-folder bug reclassified hundreds of files from
- # "movie" to "tv", and every one kept answering with its
- # stale movie-era match forever, because this was trusted
- # before ever comparing media_type against the current one.
- if cached_media_type == media_type:
+ # Serve the cached match when its kind still agrees with the
+ # entry's current classification — OR when the entry is not
+ # enriched yet: its season/episode aren't populated, so the
+ # movie/show split above is not meaningful, and the cached kind
+ # (set when this file WAS enriched) is the reliable one. This is
+ # what keeps a restart from re-querying TMDB for everything
+ # already resolved: the storm was an un-enriched show episode
+ # looking like a "movie" and treating its own valid "tv" match
+ # as stale.
+ #
+ # Once enriched, the strict `cached_media_type == media_type`
+ # check still stands: an enrichment fix that reclassifies a
+ # folder movie->tv must drop the stale movie-era match and
+ # re-resolve (found live — a Specials-folder fix left hundreds
+ # of files answering with their wrong-kind match forever).
+ if cached_media_type == media_type or not enriched:
+ media_type = cached_media_type
+ is_show = media_type == "tv"
tmdb_id = cached_tmdb_id
meta = await media_cache.get_tmdb_meta(tmdb_id, media_type)
if meta is None:
+ if not enriched:
+ 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,
diff --git a/packages/meshbay-node/tests/test_media_meta_request.py b/packages/meshbay-node/tests/test_media_meta_request.py
index a752825..0a3df12 100644
--- a/packages/meshbay-node/tests/test_media_meta_request.py
+++ b/packages/meshbay-node/tests/test_media_meta_request.py
@@ -9,7 +9,7 @@ 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 IndexEntry
+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
@@ -165,3 +165,62 @@ async def test_unknown_file_id_is_refused(media_cache):
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"