diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-26 15:22:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-26 15:29:05 +0200 |
| commit | 4c45792c861c47d44ebdbbab4130e37fba5d6795 (patch) | |
| tree | 2bfeaa52840057964aa69f7e2cbd9906701df849 /packages/meshbay-node/src/meshbay_node/indexer | |
| parent | db7f81fd08742847f3ebea061c75530b7b31b934 (diff) | |
| download | meshbay-4c45792c861c47d44ebdbbab4130e37fba5d6795.tar.gz | |
fix(video): a Specials/Bonus folder's episodes were matched to TMDB as unrelated standalone movies
Found live: a "Specials" folder full of one-off-named bonus episodes had
every file appear as its own poster, matched against TMDB by its own
title, because guessit finds no season/episode grammar at all in a
filename with no SxxExx of its own — so the classification (episode vs
standalone movie), based solely on that, fell to the movie branch. Real,
unrelated films happened to share several of those one-off titles and
matched confidently, one per Special, cluttering the Videos view with
dozens of wrong posters instead of grouping under the show.
An ancestor folder saying this is part of a show — a numbered season, or
Specials/Bonus/Extras -> season 0 — is now trusted over the filename
having no SxxExx of its own. The show's name can't come from this file's
own guessit title (that's the bug) or from siblings in the same Specials
folder (every one of them has the same gap) — it's borrowed from the
show's ordinary season folders next door, which do carry it in the usual
ShowName.SxxExx shape (_title_from_show_siblings). A synthetic, stable
episode number (alphabetical rank among the folder's video files) stands
in for the real one nothing in a Specials folder provides.
Generic, not specific to a folder literally named "Specials": the same
fallback fires for any season-like ancestor folder whose files lack
per-file episode grammar, numbered seasons included (covered by a
dedicated test).
Also hardens _title_from_siblings to require the sibling's own episode
number too, not just a title — otherwise it would borrow one Special's
one-off title as if it were representative, on a folder like this one.
Existing already-indexed entries will need a rescan (restart the node) to
be re-enriched under this logic — nothing re-derives them on its own.
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich.py | 112 |
1 files changed, 103 insertions, 9 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py index ae3f3dc..fc4b059 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py @@ -55,6 +55,13 @@ def _title_from_siblings(file_path: Path) -> str | None: §3.4: an episode filename with no show name in it borrows the title from a representative sibling in the same folder, never from the folder name alone (an acronym-named show folder is a real, observed case). + + Requires the sibling to carry its own episode number too, not just a + title — a folder where every file is a one-off-named Special (§3.4b) + has plenty of `display_title`s (guessit reads *a* title off nearly + anything) but none of them name the show; requiring a real episode + number alongside is what tells apart a genuinely representative sibling + from another Special just like this one. """ try: names = sorted(p.name for p in file_path.parent.iterdir() if p.is_file()) @@ -70,11 +77,76 @@ def _title_from_siblings(file_path: Path) -> str | None: if checked > MAX_SIBLINGS_CHECKED: break parsed = title_parse.parse_episode_filename(name) - if parsed.display_title: + if parsed.display_title and parsed.episode is not None: return parsed.display_title 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 + all — each is just named after its own one-off title. The frontend + (video-app.js's buildSeasons) sorts within a season by this number but + only needs it to provide a stable order, not to mean anything beyond + that — alphabetical rank among the folder's video files is enough, and + deterministic across re-scans as long as the folder's contents don't + change. 1-based so it reads as "episode 1", not "episode 0", in a UI + that already uses season 0 for "Specials" itself. + """ + try: + names = sorted( + p.name for p in file_path.parent.iterdir() + if p.is_file() and p.suffix.lower() in MEDIA_EXTENSIONS["video"]) + except OSError: + return 1 + try: + return names.index(file_path.name) + 1 + except ValueError: + return 1 + + async def _make_thumbnail(file_path: Path, duration: float | None) -> bytes | None: """One ffmpeg frame grab at ~10% of duration (or 5s if unknown), scaled down.""" seek = max(0.0, (duration or 50.0) * 0.1) @@ -165,15 +237,37 @@ class Enricher: entry.id, fields.get("duration"), fields.get("width"), fields.get("height")) ep = title_parse.parse_episode_filename(entry.name) - if ep.episode is not None: - title = ep.display_title or await asyncio.to_thread( - _title_from_siblings, file_path) - season = ep.season - if season is None: - season = await asyncio.to_thread(_season_from_ancestors, file_path) + season = ep.season + if season is None: + season = await asyncio.to_thread(_season_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) + episode = await asyncio.to_thread(_synthetic_episode_number, file_path) fields["display_title"] = title or title_parse.naive_title(entry.name) - fields["season"] = season - fields["episode"] = ep.episode + fields["season"] = season if season is not None else 0 + fields["episode"] = episode else: mv = title_parse.parse_movie_filename(entry.name) fields["display_title"] = mv.display_title or mv.naive_title |