diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich.py | 112 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_enrich.py | 99 |
2 files changed, 201 insertions, 10 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 diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py index 2205c7e..5497812 100644 --- a/packages/meshbay-node/tests/test_enrich.py +++ b/packages/meshbay-node/tests/test_enrich.py @@ -8,7 +8,10 @@ from pathlib import Path import pytest from meshbay_common.protocol import IndexEntry -from meshbay_node.indexer.enrich import Enricher, _season_from_ancestors, _title_from_siblings +from meshbay_node.indexer.enrich import ( + Enricher, _season_from_ancestors, _synthetic_episode_number, + _title_from_show_siblings, _title_from_siblings, +) from meshbay_node.media_cache import MediaCache _HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") @@ -53,6 +56,67 @@ def test_title_from_siblings_none_when_no_titled_sibling(tmp_path): assert _title_from_siblings(folder / "S01E02.mkv") is None +def test_title_from_siblings_ignores_a_titled_sibling_with_no_episode_number(tmp_path): + # A Specials-shaped folder: every file parses as a "movie" (its own + # one-off title, guessit finds no season/episode grammar at all) — none + # of them is a representative sibling for the show's actual name. + folder = tmp_path / "Some Show" / "Specials" + folder.mkdir(parents=True) + (folder / "Bonus Feature One.mkv").touch() + + 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() + (folder / "Bonus Feature One.mkv").touch() + (folder / "Bonus Feature Three.mkv").touch() + (folder / "Bonus Feature Two.mkv").touch() + + assert _synthetic_episode_number(folder / "Bonus Feature One.mkv") == 1 + assert _synthetic_episode_number(folder / "Bonus Feature Three.mkv") == 2 + assert _synthetic_episode_number(folder / "Bonus Feature Two.mkv") == 3 + + # ── end-to-end against a real (tiny, synthetic) video file ────────────────── pytestmark_ffmpeg = pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed") @@ -201,3 +265,36 @@ async def test_enricher_handles_episode_with_season_from_folder(tmp_path, media_ assert fields["display_title"] == "Some Show" assert fields["season"] == 3 assert fields["episode"] == 7 + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_handles_specials_folder_with_no_episode_grammar(tmp_path, media_cache): + # The real bug this guards: every file in a Specials folder is named + # after its own one-off joke, not the show — guessit finds no + # season/episode in any of them, so without the ancestor-folder check + # each one used to fall to the movie branch and get searched against + # TMDB as an unrelated standalone film (found live: a real show's + # Specials folder matched several of its bonus episodes to real, + # unrelated movies that happened to share their one-off titles). + show = tmp_path / "Some Show" + season1 = show / "Season 1" + season1.mkdir(parents=True) + (season1 / "Some Show.S01E01.mkv").touch() + specials = show / "Specials" + specials.mkdir() + (specials / "Bonus Feature One.mkv").touch() + clip = specials / "Bonus Feature Two.mkv" + _make_clip(clip) + entry = IndexEntry(id="fileid3", 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 "Bonus Feature Two" — that's this one Special's own title, and + # matching it against TMDB by itself is exactly the bug. The show's + # name, from its ordinary season folder next door. + assert fields["display_title"] == "Some Show" + assert fields["season"] == 0 + assert fields["episode"] is not None |