diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-26 15:51:10 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-26 15:51:10 +0200 |
| commit | b1543faad87fd865f8746711ab5f10ac4f331e8b (patch) | |
| tree | 37bca053cc875973c27dcdbbfed9bfb65f50c2f4 /packages | |
| parent | 84aa73e89cfe486c62317f761dcf87d7845eb4f7 (diff) | |
| download | meshbay-b1543faad87fd865f8746711ab5f10ac4f331e8b.tar.gz | |
fix(video): group every episode under the show's own folder, not a per-file guessit title
The mechanism itself was wrong, not just the TMDB matching: a bare episode
numbering convention with no show name in the filename at all
(`001 Episode's Own Title.ext`, no SxxExx, no show prefix — entirely
ordinary on its own) makes guessit invent a "title" from whatever text
follows the number. That text is the individual episode's own name, and
differs for every episode in the folder — trusting it, as the code did,
groups nothing together at all: every episode became its own single-
episode "show", searched against TMDB by that one-off title alone.
Whenever a season-like ancestor folder exists (a numbered season, or
Specials/Bonus/Extras -> season 0), its own root folder now names the show
unconditionally — never a per-file guessit title, which cannot tell a
show's real name from an individual episode's one-off name when the
filename carries no reliable ShowName/SxxExx structure. The walk continues
past *every* consecutive season-like ancestor, not just the first: a
per-season Bonus folder (Show/Season N/Bonus/file.ext) is nested two
levels inside the show, both "Bonus" and "Season N" season-like on their
own, and stopping at the first would hand back "Season N" as the show's
name instead of "Show".
Also adds "livre" ("book") to the season-word vocabulary (§3.4) — some
shows number their seasons that way (Roman numerals) rather than
"season"/"saison".
Supersedes _title_from_show_siblings from the previous commit (removed):
that fallback assumed a per-file title could still be trusted often
enough to be worth borrowing from a sibling season folder — this fix
means it never needs trusting in the first place once a season-like
ancestor exists.
Verified directly against the real library this was found on, not just
synthetic tests: every sample file (a numbered episode, a Book/Bonus
feature, a Book-root "making of") now resolves to the show's real name.
Diffstat (limited to 'packages')
4 files changed, 146 insertions, 123 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py index fc4b059..5535674 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py @@ -38,15 +38,45 @@ MAX_ANCESTOR_DEPTH = 4 MAX_SIBLINGS_CHECKED = 20 -def _season_from_ancestors(file_path: Path) -> int | None: +def _season_and_show_from_ancestors(file_path: Path) -> tuple[int, str] | None: + """ + §3.4/§3.4c: walk up ancestor folders for a season-like one (a numbered + season, or Specials/Bonus/Extras -> season 0) — season from the first + (innermost) match, but the show's own name from *above every + consecutive season-like ancestor*, not just the first one. A + per-season Bonus folder (`Show/Season N/Bonus/file.ext`) is nested two + levels inside the show, both of them season-like on their own + ("Bonus" and "Season N") — stopping at the first would hand back + "Season N" as the show's name instead of "Show". + + Trusted over any per-file guessit title once found: a bare episode + numbering convention with no show name embedded at all + (`001 Episode's Own Title.mkv`, no SxxExx, no show prefix) is + completely ordinary and makes guessit invent a title from whatever + text follows the number — that text is the individual episode's own + name, never the show's, and every episode in the folder produces a + different one. None of that ambiguity exists for the folder structure + itself: the show's own root folder names it once, not per file. + + None if no ancestor looks like a season folder at all — a flat + library has nothing to borrow a show name from this way, and + _title_from_siblings' per-file logic is what applies instead. + """ folder = file_path.parent - for _ in range(MAX_ANCESTOR_DEPTH): - if folder is None or folder == folder.parent: - break - season = title_parse.season_from_folder_name(folder.name) - if season is not None: - return season + season: int | None = None + depth = 0 + while folder is not None and folder.parent != folder and depth < MAX_ANCESTOR_DEPTH: + this_season = title_parse.season_from_folder_name(folder.name) + if this_season is None: + if season is None: + folder = folder.parent + depth += 1 + continue + return season, folder.name + if season is None: + season = this_season folder = folder.parent + depth += 1 return None @@ -82,48 +112,6 @@ def _title_from_siblings(file_path: Path) -> str | None: return None -def _title_from_show_siblings(file_path: Path) -> str | None: - """ - §3.4b: a Specials/Bonus folder's files are routinely named after their - own one-off joke or theme rather than the show at all — every one of - them parses as a standalone "movie" (guessit has no season/episode - grammar to find), and by coincidence a good few of those one-off - titles collide with real, unrelated films: found live, a real show's - Specials folder matched several bonus episodes to real, unrelated - movies sharing those one-off titles, instead of anything to do with - the show. _title_from_siblings can't help — every sibling *here* has the same - problem. What the ordinary season folders next to this one hold does - not: `Show.S01E01.mkv`-shaped filenames name the show properly, so - borrowing from the first one found there is the same trick as - _title_from_siblings, aimed one level higher — at the show's other - seasons rather than this file's own folder. - """ - season_folder = file_path.parent - show_folder = season_folder.parent - try: - sibling_folders = sorted( - (d for d in show_folder.iterdir() if d.is_dir() and d != season_folder), - key=lambda d: d.name) - except OSError: - return None - for folder in sibling_folders: - try: - names = sorted(p.name for p in folder.iterdir() if p.is_file()) - except OSError: - continue - checked = 0 - for name in names: - if Path(name).suffix.lower() not in MEDIA_EXTENSIONS["video"]: - continue - checked += 1 - if checked > MAX_SIBLINGS_CHECKED: - break - parsed = title_parse.parse_episode_filename(name) - if parsed.display_title and parsed.episode is not None: - return parsed.display_title - return None - - def _synthetic_episode_number(file_path: Path) -> int: """ §3.4b: a Specials/Bonus folder's files often carry no episode number at @@ -237,37 +225,38 @@ class Enricher: entry.id, fields.get("duration"), fields.get("width"), fields.get("height")) ep = title_parse.parse_episode_filename(entry.name) - season = ep.season - if season is None: - season = await asyncio.to_thread(_season_from_ancestors, file_path) + ancestor = await asyncio.to_thread(_season_and_show_from_ancestors, file_path) - # §3.4b: an ancestor folder saying this is part of a show (a - # numbered season, or Specials/Bonus/Extras -> season 0) is - # trusted over the filename having no SxxExx of its own — - # otherwise a Specials folder's one-off-named files (no episode - # grammar for guessit to find at all) fall to the movie branch - # below and get searched against TMDB as unrelated standalone - # films, one per Special. Found live: a real show's Specials - # folder matched several bonus episodes to real, unrelated - # movies sharing those one-off titles. - if ep.episode is not None or season is not None: - if ep.episode is not None: - title = ep.display_title or await asyncio.to_thread( - _title_from_siblings, file_path) - episode = ep.episode - else: - # This file's own name carries no episode grammar at - # all — every sibling in *this* folder has the same gap - # (that is what makes it a Specials-shaped folder), so - # borrowing from them (_title_from_siblings) would just - # hand back another Special's own one-off title. The - # show's ordinary season folders, next to this one, - # don't have that gap. - title = await asyncio.to_thread(_title_from_show_siblings, file_path) + if ancestor is not None: + # A season-like ancestor folder exists — the show's own + # root folder names it, trusted over any per-file guessit + # title. This is what actually groups every episode under + # one show: a bare numbering convention with no show name + # in the filename at all (`001 Episode's Own Title.mkv`, + # ordinary enough on its own) makes guessit invent a title + # from whatever text follows the number, which is that + # episode's own name, never the show's — and differs for + # every episode, so nothing would ever group together. + # Found live: a real show's episodes and its Specials + # folder alike, both named this way, showed up as + # individual "movies" each searched against — and matched + # to — an unrelated real film sharing that one-off title, + # instead of anything grouped under the show at all. + season, show_title = ancestor + episode = ep.episode + if episode is None: episode = await asyncio.to_thread(_synthetic_episode_number, file_path) - fields["display_title"] = title or title_parse.naive_title(entry.name) - fields["season"] = season if season is not None else 0 + fields["display_title"] = show_title + fields["season"] = season fields["episode"] = episode + elif ep.episode is not None: + # No season-like ancestor at all (a flat library) but the + # filename itself carries season+episode (§3.4). + 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) + fields["season"] = ep.season + fields["episode"] = ep.episode else: mv = title_parse.parse_movie_filename(entry.name) fields["display_title"] = mv.display_title or mv.naive_title 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 5203247..0baf34f 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py @@ -36,8 +36,10 @@ _EDITION_RE = re.compile("|".join(_EDITION_PHRASES), re.IGNORECASE) # A season-like ancestor folder: the English/French words plus a number or # Roman numeral. Vocabulary is a plain tuple so a deployment can extend it -# per locale without touching the regex-building logic. -SEASON_WORDS = ("season", "saison") +# per locale without touching the regex-building logic. "livre" ("book") is +# real, observed vocabulary too — some shows name their seasons that way +# (Roman numerals: "Livre I".."Livre VI") rather than "saison". +SEASON_WORDS = ("season", "saison", "livre") _SEASON_RE = re.compile( r"(?:" + "|".join(SEASON_WORDS) + r")\s*([0-9]+|[ivxlc]+)\b", re.IGNORECASE, diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py index 5497812..8c0c6b2 100644 --- a/packages/meshbay-node/tests/test_enrich.py +++ b/packages/meshbay-node/tests/test_enrich.py @@ -9,8 +9,8 @@ import pytest from meshbay_common.protocol import IndexEntry from meshbay_node.indexer.enrich import ( - Enricher, _season_from_ancestors, _synthetic_episode_number, - _title_from_show_siblings, _title_from_siblings, + Enricher, _season_and_show_from_ancestors, _synthetic_episode_number, + _title_from_siblings, ) from meshbay_node.media_cache import MediaCache @@ -19,22 +19,48 @@ _HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") # ── pure helpers, no ffmpeg needed ─────────────────────────────────────────── -def test_season_from_ancestors_finds_season_folder(tmp_path): +def test_season_and_show_from_ancestors_finds_season_folder(tmp_path): folder = tmp_path / "Some Show" / "Season 2" folder.mkdir(parents=True) ep = folder / "01 - Episode Title.mkv" ep.touch() - assert _season_from_ancestors(ep) == 2 + assert _season_and_show_from_ancestors(ep) == (2, "Some Show") -def test_season_from_ancestors_none_when_no_season_folder(tmp_path): +def test_season_and_show_from_ancestors_none_when_no_season_folder(tmp_path): folder = tmp_path / "Movies" folder.mkdir() f = folder / "Some Movie 2015.mkv" f.touch() - assert _season_from_ancestors(f) is None + assert _season_and_show_from_ancestors(f) is None + + +def test_season_and_show_from_ancestors_walks_past_a_per_season_bonus_folder(tmp_path): + # The real shape this guards: a Specials/Bonus folder nested *inside* + # each numbered season folder (Show/Season N/Bonus/file.ext) rather + # than once at the show's top level — both "Bonus" and "Season N" are + # season-like on their own, and stopping at the first (innermost) + # would hand back "Season N" as the show's name instead of "Show". + folder = tmp_path / "Some Show" / "Season 1" / "Bonus" + folder.mkdir(parents=True) + f = folder / "One-Off Bonus Title.mkv" + f.touch() + + assert _season_and_show_from_ancestors(f) == (0, "Some Show") + + +def test_season_and_show_from_ancestors_book_word_season_folder(tmp_path): + # Same shape as the real bug this whole fix guards, with the "book" + # season vocabulary (§3.4) instead of "season"/"saison": a plain + # numbered-episode folder nested under it. + folder = tmp_path / "Some Show" / "Livre I" / "Episodes" + folder.mkdir(parents=True) + f = folder / "001 Episode's Own Title.mkv" + f.touch() + + assert _season_and_show_from_ancestors(f) == (1, "Some Show") def test_title_from_siblings_borrows_from_a_titled_sibling(tmp_path): @@ -67,44 +93,6 @@ def test_title_from_siblings_ignores_a_titled_sibling_with_no_episode_number(tmp assert _title_from_siblings(folder / "Bonus Feature Two.mkv") is None -def test_title_from_show_siblings_borrows_from_a_sibling_season_folder(tmp_path): - show = tmp_path / "Some Show" - specials = show / "Specials" - specials.mkdir(parents=True) - (specials / "Bonus Feature One.mkv").touch() - season1 = show / "Season 1" - season1.mkdir() - (season1 / "Some Show.S01E01.mkv").touch() - - assert _title_from_show_siblings(specials / "Bonus Feature One.mkv") == "Some Show" - - -def test_title_from_show_siblings_none_when_no_show_folder_has_one(tmp_path): - show = tmp_path / "Some Show" - specials = show / "Specials" - specials.mkdir(parents=True) - (specials / "Bonus Feature One.mkv").touch() - (specials / "Bonus Feature Two.mkv").touch() - - assert _title_from_show_siblings(specials / "Bonus Feature One.mkv") is None - - -def test_title_from_show_siblings_not_specific_to_the_word_specials(tmp_path): - # Genericity check: the same gap (a season-like folder whose files carry - # no episode grammar of their own) can show up under a plain numbered - # season folder too, not only one named "Specials" — nothing in - # _title_from_show_siblings should assume otherwise. - show = tmp_path / "Some Show" - season2 = show / "Season 2" - season2.mkdir(parents=True) - (season2 / "One-Off Episode Title.mkv").touch() - season1 = show / "Season 1" - season1.mkdir() - (season1 / "Some Show.S01E01.mkv").touch() - - assert _title_from_show_siblings(season2 / "One-Off Episode Title.mkv") == "Some Show" - - def test_synthetic_episode_number_is_stable_alphabetical_rank(tmp_path): folder = tmp_path / "Specials" folder.mkdir() @@ -298,3 +286,40 @@ async def test_enricher_handles_specials_folder_with_no_episode_grammar(tmp_path assert fields["display_title"] == "Some Show" assert fields["season"] == 0 assert fields["episode"] is not None + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_groups_bare_numbered_episodes_under_the_show_folder(tmp_path, media_cache): + """ + The real-world shape this whole fix is for: every episode (and every + Bonus feature) named "<number> <its own one-off title>", with the + show's name appearing nowhere in any filename at all — only in the + show's own root folder. guessit still finds an episode number here + (unlike the plain-Specials case above), and invents a "title" from + whatever text follows it — a different one for every file. Trusting + that per-file title, as the code used to, groups nothing together at + all: every episode becomes its own single-episode "show", searched + against TMDB by its own one-off title, and mismatched to whichever + unrelated real film or show happens to share it. + """ + show = tmp_path / "Some Show" + season1_eps = show / "Livre I" / "Episodes" + season1_eps.mkdir(parents=True) + (season1_eps / "001 First Episode's Own Title.mkv").touch() + clip = season1_eps / "002 Second Episode's Own Title.mkv" + _make_clip(clip) + season2_eps = show / "Livre II" / "Episodes" + season2_eps.mkdir(parents=True) + (season2_eps / "001 A Season 2 Episode's Own Title.mkv").touch() + entry = IndexEntry(id="fileid4", name=clip.name, path=str(clip.relative_to(tmp_path)), + size=clip.stat().st_size, type="video", added_at=0) + + enricher = Enricher(media_cache) + _, fields = await _run(enricher, entry, clip) + + # Not "Second Episode's Own Title" — every episode has a different + # one, and none of them is the show. + assert fields["display_title"] == "Some Show" + assert fields["season"] == 1 + assert fields["episode"] == 2 diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py index 1a277f2..1881613 100644 --- a/packages/meshbay-node/tests/test_title_parse.py +++ b/packages/meshbay-node/tests/test_title_parse.py @@ -111,6 +111,13 @@ def test_season_folder_roman_numeral(): assert season_from_folder_name("Saison IV") == 4 +def test_season_folder_book_word_roman_numeral(): + # Some shows number their seasons by "book" (Roman numerals) rather + # than "season"/"saison" — real, observed vocabulary (§3.4). + assert season_from_folder_name("Livre I") == 1 + assert season_from_folder_name("Livre VI") == 6 + + def test_specials_folder_maps_to_season_zero(): assert season_from_folder_name("Specials") == 0 assert season_from_folder_name("Bonus") == 0 |