summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/mediacenter.md16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js16
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py20
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py44
-rw-r--r--packages/meshbay-node/tests/test_enrich.py41
-rw-r--r--packages/meshbay-node/tests/test_media_meta_request.py61
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py22
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():