aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-26 15:22:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-26 15:29:05 +0200
commit4c45792c861c47d44ebdbbab4130e37fba5d6795 (patch)
tree2bfeaa52840057964aa69f7e2cbd9906701df849 /packages/meshbay-node
parentdb7f81fd08742847f3ebea061c75530b7b31b934 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py112
-rw-r--r--packages/meshbay-node/tests/test_enrich.py99
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