diff options
| -rw-r--r-- | docs/mediacenter.md | 16 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-app.js | 16 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/title_parse.py | 20 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 44 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_enrich.py | 41 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_media_meta_request.py | 61 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_title_parse.py | 22 |
8 files changed, 215 insertions, 18 deletions
diff --git a/docs/mediacenter.md b/docs/mediacenter.md index 5c27939..45f57d9 100644 --- a/docs/mediacenter.md +++ b/docs/mediacenter.md @@ -770,6 +770,22 @@ TMDB id, and an unmatched poster gets a badge. | ~~V12~~ | Movies not merged in the poster grid | `mergedMovies` groups by resolved `tmdb_id`, mirroring `mergedShows`; the detail modal lists the versions (resolution · duration · size), each a Play button. New `video.versions` key ×10. Edge: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own | | ~~V13~~ | Per-card "re-match this one file" button | `OP_TMDB_REMATCH` / `MNP.TMDB_REMATCH` (MNP 0.13, additive) → `media_cache.drop_tmdb_match` (forgets the match *and* the override marker). Signed like `tmdb_override`. Button next to "Fix match" in the detail modal; `transport.rematchTmdbMatch`; `video.rematch_one` key ×10 | +### 10.2 A movie mis-shelved as a series (2026-08-29) + +`Some.Film.2017.MULTI.108.grp.mkv` — a movie whose `1080p` tag was truncated to `108` — +made guessit invent `S01E08`, so `enrich.py`'s flat-library branch filed a standalone film +as a nonexistent series, and "Fix match" (TV-only for a "show") could only offer other +series. **Fixed** (`fix/movie-misclassified-as-show`): that branch now requires +`ep.season` *and* `ep.episode`, plus either an explicit marker +(`title_parse.has_episode_marker` — `SxxExx` / `1x08` / `Episode N` / `Season N`) or the +absence of a `(2019)`-style year; otherwise it falls through to the movie branch. A real +flat-dumped episode always carries a marker, so nothing regresses. + +**Still open:** there is no operator control over the movie/show classification itself — +"Fix match" only changes the TMDB id, not the kind. A genuinely ambiguous name +(`Show.2019.308.mkv`) that guessit slots wrongly has no in-UI remedy short of renaming the +file. A "this is a movie / a show" toggle would close that. + ## 11. Acceptance before shipping 1. Re-run the §3 validation (real TMDB calls, same corpus, same script 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/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py index 07f0be7..8b2eca5 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py @@ -283,9 +283,18 @@ class Enricher: fields["display_title"] = show_folder.name fields["season"] = season fields["episode"] = episode - elif ep.episode is not None: + elif (ep.season is not None and ep.episode is not None + and (title_parse.has_episode_marker(entry.name) + or title_parse.year_in(entry.name) is None)): # No season-like ancestor at all (a flat library) but the - # filename itself carries season+episode (§3.4). + # filename itself carries season+episode (§3.4) — *and* it + # is a real marker, not guessit reading a bare number as + # SxxExx. A movie whose "1080p" tag was truncated to "108", + # or "1280" left in the name, otherwise parses to S01E08 / + # S12E80 and gets shelved as a nonexistent series + # (found live 2026-08-29). A genuine flat-dumped episode + # has an explicit SxxExx/1x08/"Episode N" marker; a movie + # has a "(2019)"-style year and no such marker. title = ep.display_title or await asyncio.to_thread( _title_from_siblings, file_path) fields["display_title"] = title or title_parse.naive_title(entry.name) diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py index 018746b..def947f 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py @@ -288,6 +288,26 @@ def strip_track_prefix(text: str) -> str: return re.sub(r"^[\s-]+", "", text[m.end():]).strip() if m else text +# An *explicit* season/episode marker: SxxExx, 1x08, "Episode 8", "Ep 8", +# "Season 1"/"Saison 1". guessit will also invent a season+episode from a +# bare 3-4 digit run ("1080p" truncated to "108" -> S01E08; "1280" -> +# S12E80), which is how a plain movie ends up shelved as a series +# (§10.1/V14). The indexer uses this to tell a real flat-library episode +# from that hallucination. +_EPISODE_MARKER_RE = re.compile( + r"s\d{1,2}[\s._-]*e\d{1,3}" + r"|\b\d{1,2}x\d{1,3}\b" + r"|\bepisode[\s._-]*\d{1,3}\b" + r"|\bep[\s._-]*\d{1,3}\b" + r"|\b(?:season|saison)[\s._-]*\d{1,2}\b", + re.IGNORECASE, +) + + +def has_episode_marker(filename: str) -> bool: + return bool(_EPISODE_MARKER_RE.search(filename)) + + def parse_episode_filename(filename: str) -> ParsedName: """ Parse an episode filename. `display_title` may come back None (e.g. 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_enrich.py b/packages/meshbay-node/tests/test_enrich.py index 4b957cd..a35c713 100644 --- a/packages/meshbay-node/tests/test_enrich.py +++ b/packages/meshbay-node/tests/test_enrich.py @@ -373,3 +373,44 @@ async def test_enricher_reads_a_three_digit_episode_number_correctly(tmp_path, m assert fields["season"] == 6 assert fields["episode"] == 100 + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_a_movie_with_a_mangled_quality_tag_is_not_shelved_as_a_series( + tmp_path, media_cache): + """ + Found live 2026-08-29: a standalone film whose "1080p" tag was + truncated to "108" in the filename makes guessit invent S01E08, so + enrich (flat library, no season ancestor) filed it as a nonexistent + series. A real flat-dumped episode carries an explicit SxxExx / 1x08 / + "Episode N" marker; a movie has a "(2017)"-style year and none. + """ + clip = tmp_path / "Some.Film.2017.MULTI.108.grp.mkv" + _make_clip(clip) + entry = IndexEntry(id="fid-trunc", name=clip.name, path=clip.name, + size=clip.stat().st_size, type="video", added_at=0) + + enricher = Enricher(media_cache) + _, fields = await _run(enricher, entry, clip) + + assert fields.get("season") is None and fields.get("episode") is None, ( + "a movie with a mangled quality tag must not become a series") + assert fields["display_title"] + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_a_flat_episode_with_a_real_marker_stays_a_show_even_with_a_year( + tmp_path, media_cache): + """The guard must not misfire: a genuine flat-dumped episode that also + carries a year has an explicit SxxExx marker and stays a show.""" + clip = tmp_path / "Some.Show.2022.S01E02.1080p.WEB.mkv" + _make_clip(clip) + entry = IndexEntry(id="fid-marker", name=clip.name, path=clip.name, + size=clip.stat().st_size, type="video", added_at=0) + + enricher = Enricher(media_cache) + _, fields = await _run(enricher, entry, clip) + + assert fields["season"] == 1 and fields["episode"] == 2 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" diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py index ac4d434..045635f 100644 --- a/packages/meshbay-node/tests/test_title_parse.py +++ b/packages/meshbay-node/tests/test_title_parse.py @@ -7,6 +7,7 @@ is a manual acceptance step (§11), not something this repo's corpus holds. from meshbay_node.indexer.title_parse import ( ParsedName, clean_query, + has_episode_marker, leading_episode_number, naive_title, parse_episode_filename, @@ -113,6 +114,27 @@ def test_clean_query_despaces_a_folder_name_without_eating_the_last_word(): assert naive_title("Some.Show.Name") != "Some Show Name" # the trap it avoids +# ── §10.1/V14: telling a real episode marker from a mangled number ────────── + +def test_has_episode_marker_accepts_real_markers(): + for name in ["Some.Show.S01E08.mkv", "some.show.s1.e8.mkv", + "Some Show 1x08.mkv", "Some Show 01x08.mkv", + "Some Show Episode 8.mkv", "Some Show ep08.mkv", + "Some Show ep.8.mkv", "Some Show Season 1.mkv", + "Une Serie Saison 3.mkv"]: + assert has_episode_marker(name), name + + +def test_has_episode_marker_rejects_bare_numbers_and_ordinary_words(): + # "1080p" truncated to "108", "1280" left in, a year, plain words that + # merely contain "ep" — none of these are episode markers. + for name in ["Some.Film.2017.MULTI.108.grp.mkv", + "Some.Flick.2013.1280.x264-grp.mkv", + "Some Movie 2017.mkv", "The Dark Knight.mkv", + "Sleep 8.mkv", "Deep 8 mm.mkv", "Ocean's 11.mkv"]: + assert not has_episode_marker(name), name + + # ── bug 2026-08-29: guessit peels "Volume N" off the title ───────────────── def test_movie_volume_number_is_folded_back_into_the_title(): |