summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-26 16:04:00 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-26 16:04:00 +0200
commitc81eaa0bd07e360ec03406dfc63267c3013a0319 (patch)
treedbce9709954cd640a77356b3cd6b00b632b6023d /packages
parentb1543faad87fd865f8746711ab5f10ac4f331e8b (diff)
downloadmeshbay-c81eaa0bd07e360ec03406dfc63267c3013a0319.tar.gz
fix(video): fix two remaining bugs in Specials numbering and long-season episodes
1. A season spanning more than one folder (a per-book Bonus folder nested inside every numbered season) got the same synthetic episode numbers handed out again in each folder independently — three unrelated Specials all showing up as "S0E01". _synthetic_episode_number now ranks across the whole show for the target season, not just one file's own folder. 2. guessit reads a bare 3-digit leading episode number as a concatenated season+episode guess rather than a plain episode number — "100" parses as season=1, episode=0, not episode=100, with nothing in its output distinguishing that from a real 2-digit episode. A season-like ancestor already overrides guessit's season (previous commit); this applies the same fix to episode via a direct regex on the leading number, capped at 3 digits so a leading year (4 digits) is never misread the same way. Both confirmed against the real library this whole fix was found on: per-book Bonus features across two books no longer collide, and a real 100th-episode file now resolves to episode 100 instead of 0.
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py78
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py17
-rw-r--r--packages/meshbay-node/tests/test_enrich.py72
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py25
4 files changed, 159 insertions, 33 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
index 5535674..07f0be7 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
@@ -36,9 +36,14 @@ MAX_ANCESTOR_DEPTH = 4
# Bounds the "borrow a title from a sibling episode filename" scan (§3.4) so
# a folder with thousands of files costs a fixed, small amount of work.
MAX_SIBLINGS_CHECKED = 20
+# Bounds the whole-show scan _synthetic_episode_number uses to rank a
+# season's files across more than one folder (§3.4c) — a show's total file
+# count, not just one folder's, so this needs more headroom than
+# MAX_SIBLINGS_CHECKED.
+MAX_SEASON_FILES_CHECKED = 500
-def _season_and_show_from_ancestors(file_path: Path) -> tuple[int, str] | None:
+def _season_and_show_from_ancestors(file_path: Path) -> tuple[int, Path] | 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
@@ -72,7 +77,7 @@ def _season_and_show_from_ancestors(file_path: Path) -> tuple[int, str] | None:
folder = folder.parent
depth += 1
continue
- return season, folder.name
+ return season, folder
if season is None:
season = this_season
folder = folder.parent
@@ -112,27 +117,45 @@ def _title_from_siblings(file_path: Path) -> str | None:
return None
-def _synthetic_episode_number(file_path: Path) -> int:
+def _synthetic_episode_number(file_path: Path, show_root: Path, season: int) -> 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.
+ §3.4b/§3.4c: 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.
+
+ Ranked across the *whole show*, not just this file's own folder: season
+ 0 routinely spans more than one folder under the show's root — a
+ Bonus folder nested inside every numbered season
+ (`Show/Season N/Bonus/...`) is exactly the shape that produces this —
+ and ranking within just this file's own folder hands out the same
+ "episode 1" again in every other one, found live as three unrelated
+ Specials all showing up as "S0E01". Deterministic across re-scans as
+ long as the show'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. Bounded (MAX_SEASON_FILES_CHECKED) so a huge show
+ costs a fixed amount of work rather than scaling with its size.
"""
try:
- names = sorted(
- p.name for p in file_path.parent.iterdir()
- if p.is_file() and p.suffix.lower() in MEDIA_EXTENSIONS["video"])
+ candidates = sorted(
+ (p for p in show_root.rglob("*")
+ if p.is_file() and p.suffix.lower() in MEDIA_EXTENSIONS["video"]),
+ key=lambda p: (str(p.parent), p.name),
+ )
except OSError:
return 1
- try:
- return names.index(file_path.name) + 1
- except ValueError:
- return 1
+ rank = 0
+ for i, candidate in enumerate(candidates):
+ if i >= MAX_SEASON_FILES_CHECKED:
+ break
+ ancestor = _season_and_show_from_ancestors(candidate)
+ if ancestor is None or ancestor[0] != season:
+ continue
+ rank += 1
+ if candidate == file_path:
+ return rank
+ return 1
async def _make_thumbnail(file_path: Path, duration: float | None) -> bytes | None:
@@ -242,11 +265,22 @@ class Enricher:
# 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
+ season, show_folder = ancestor
+ # Not ep.episode: guessit reads a bare 3-digit leading
+ # number (routine once a season-like ancestor is already
+ # doing the real season/episode work) as a concatenated
+ # season+episode guess rather than a plain episode number
+ # — confirmed live, "100 Title.mkv" parsed as episode 0,
+ # not 100, with nothing in guessit's own output telling
+ # that apart from a real 2-digit episode. The direct regex
+ # reads the whole leading number as it is.
+ episode = title_parse.leading_episode_number(entry.name)
+ if episode is None:
+ episode = ep.episode
if episode is None:
- episode = await asyncio.to_thread(_synthetic_episode_number, file_path)
- fields["display_title"] = show_title
+ episode = await asyncio.to_thread(
+ _synthetic_episode_number, file_path, show_folder, season)
+ fields["display_title"] = show_folder.name
fields["season"] = season
fields["episode"] = episode
elif ep.episode is not None:
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 0baf34f..985bd7a 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
@@ -229,3 +229,20 @@ def parse_episode_filename(filename: str) -> ParsedName:
display_title=title or None, naive_title=nt,
season=season, episode=episode, confidence=confidence,
)
+
+
+# A bare leading episode number, no show name attached (§3.4c) — the same
+# shape as music's _TRACK_PREFIX_RE, capped at 3 digits for the same reason:
+# a leading year ("2010 - Episode.mkv") is 4 digits and must not match.
+# guessit's own `episode` is not a substitute here: given exactly 3 digits it
+# tries to read them as a concatenated SxxE/SEE season+episode pair instead
+# of a plain episode number — confirmed live, "100 Title.mkv" parses as
+# season=1, episode=0, not episode=100 — silently wrong in a way nothing
+# about its output distinguishes from a real 2-digit episode. This reads the
+# whole leading number as one value instead.
+_LEADING_NUMBER_RE = re.compile(r"^(\d{1,3})[\s._-]+(?=\S)")
+
+
+def leading_episode_number(filename: str) -> int | None:
+ m = _LEADING_NUMBER_RE.match(filename)
+ return int(m.group(1)) if m else None
diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py
index 8c0c6b2..4b957cd 100644
--- a/packages/meshbay-node/tests/test_enrich.py
+++ b/packages/meshbay-node/tests/test_enrich.py
@@ -20,12 +20,13 @@ _HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe")
# ── pure helpers, no ffmpeg needed ───────────────────────────────────────────
def test_season_and_show_from_ancestors_finds_season_folder(tmp_path):
- folder = tmp_path / "Some Show" / "Season 2"
+ show = tmp_path / "Some Show"
+ folder = show / "Season 2"
folder.mkdir(parents=True)
ep = folder / "01 - Episode Title.mkv"
ep.touch()
- assert _season_and_show_from_ancestors(ep) == (2, "Some Show")
+ assert _season_and_show_from_ancestors(ep) == (2, show)
def test_season_and_show_from_ancestors_none_when_no_season_folder(tmp_path):
@@ -43,24 +44,26 @@ def test_season_and_show_from_ancestors_walks_past_a_per_season_bonus_folder(tmp
# 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"
+ show = tmp_path / "Some Show"
+ folder = 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")
+ assert _season_and_show_from_ancestors(f) == (0, 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"
+ show = tmp_path / "Some Show"
+ folder = 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")
+ assert _season_and_show_from_ancestors(f) == (1, show)
def test_title_from_siblings_borrows_from_a_titled_sibling(tmp_path):
@@ -94,15 +97,36 @@ def test_title_from_siblings_ignores_a_titled_sibling_with_no_episode_number(tmp
def test_synthetic_episode_number_is_stable_alphabetical_rank(tmp_path):
- folder = tmp_path / "Specials"
- folder.mkdir()
+ show = tmp_path / "Some Show"
+ folder = show / "Specials"
+ folder.mkdir(parents=True)
(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
+ assert _synthetic_episode_number(folder / "Bonus Feature One.mkv", show, 0) == 1
+ assert _synthetic_episode_number(folder / "Bonus Feature Three.mkv", show, 0) == 2
+ assert _synthetic_episode_number(folder / "Bonus Feature Two.mkv", show, 0) == 3
+
+
+def test_synthetic_episode_number_does_not_collide_across_per_season_bonus_folders(tmp_path):
+ # The real bug this guards: season 0 spanning more than one folder
+ # (a Bonus folder nested inside each numbered season) — ranking within
+ # just one file's own folder hands out "episode 1" again in every
+ # other one, found live as three unrelated Specials all showing up as
+ # "S0E01". Ranked across the whole show instead, so each gets its own
+ # number.
+ show = tmp_path / "Some Show"
+ bonus1 = show / "Season 1" / "Bonus"
+ bonus1.mkdir(parents=True)
+ (bonus1 / "First Season's Bonus.mkv").touch()
+ bonus2 = show / "Season 2" / "Bonus"
+ bonus2.mkdir(parents=True)
+ (bonus2 / "Second Season's Bonus.mkv").touch()
+
+ n1 = _synthetic_episode_number(bonus1 / "First Season's Bonus.mkv", show, 0)
+ n2 = _synthetic_episode_number(bonus2 / "Second Season's Bonus.mkv", show, 0)
+ assert n1 != n2
# ── end-to-end against a real (tiny, synthetic) video file ──────────────────
@@ -323,3 +347,29 @@ async def test_enricher_groups_bare_numbered_episodes_under_the_show_folder(tmp_
assert fields["display_title"] == "Some Show"
assert fields["season"] == 1
assert fields["episode"] == 2
+
+
+@pytestmark_ffmpeg
+@pytest.mark.asyncio
+async def test_enricher_reads_a_three_digit_episode_number_correctly(tmp_path, media_cache):
+ """
+ guessit's own episode number is not trustworthy here: given a bare
+ leading "100", it reads that as a concatenated season+episode guess
+ (season=1, episode=0) rather than episode 100 — confirmed live, and
+ indistinguishable from a real 2-digit episode in its output. The
+ season-like ancestor already overrides guessit's season; this is the
+ same fix applied to episode.
+ """
+ show = tmp_path / "Some Show"
+ season_eps = show / "Season 6" / "Episodes"
+ season_eps.mkdir(parents=True)
+ clip = season_eps / "100 A Long Season's Own Title.mkv"
+ _make_clip(clip)
+ entry = IndexEntry(id="fileid5", 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)
+
+ assert fields["season"] == 6
+ assert fields["episode"] == 100
diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py
index 1881613..cf51646 100644
--- a/packages/meshbay-node/tests/test_title_parse.py
+++ b/packages/meshbay-node/tests/test_title_parse.py
@@ -6,6 +6,7 @@ is a manual acceptance step (§11), not something this repo's corpus holds.
from meshbay_node.indexer.title_parse import (
ParsedName,
+ leading_episode_number,
naive_title,
parse_episode_filename,
parse_movie_filename,
@@ -128,6 +129,30 @@ def test_non_season_folder_name_returns_none():
assert season_from_folder_name("Some Show Name") is None
+# ── §3.4c: a bare leading episode number, guessit's 3-digit blind spot ───────
+
+def test_leading_episode_number_reads_the_whole_number():
+ assert leading_episode_number("001 Episode's Own Title.mkv") == 1
+ assert leading_episode_number("099 Episode's Own Title.mkv") == 99
+
+
+def test_leading_episode_number_not_split_by_guessit_at_three_digits():
+ # The real bug this guards: guessit itself reads a bare "100" as season=1,
+ # episode=0 (a concatenated SxxE guess), not episode=100 — silently, with
+ # nothing in its output telling apart a real 2-digit episode from this.
+ assert leading_episode_number("100 Episode's Own Title.mkv") == 100
+
+
+def test_leading_episode_number_ignores_a_leading_year():
+ # Four digits, not the 1-3 an episode-number prefix can be — this is a
+ # normal movie filename shape, not an episode number.
+ assert leading_episode_number("2010 - Some Movie.mkv") is None
+
+
+def test_leading_episode_number_none_without_a_leading_number():
+ assert leading_episode_number("Some Show.S01E01.mkv") is None
+
+
def test_parsed_name_is_a_plain_dataclass():
# sanity: constructible with just the one required field, per the
# "None => caller must supply from elsewhere" contract.