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/meshbay-node/src | |
| 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/meshbay-node/src')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich.py | 143 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/title_parse.py | 6 |
2 files changed, 70 insertions, 79 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, |