summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer/enrich.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py78
1 files changed, 56 insertions, 22 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: