aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py143
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py6
-rw-r--r--packages/meshbay-node/tests/test_enrich.py113
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py7
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