fix(tv_shows): correct PACK vs EPISODIC classification model

The Phase 4 walker + rescan logic classified seasons by parser
output (does the filename carry Exx?), but PACK vs EPISODIC is a
structural distinction:

* PACK = season folder with N flat SxxEyy videos directly inside
* EPISODIC = season folder with N subfolders, each holding one video

Changes:
* walker.py: descends two levels under show_root and classifies
  each season folder by FS structure. SeasonFolder now carries
  mode: ReleaseMode | None. Mixed layouts (flat + subfolders) and
  EPISODIC subfolders with >1 video log a warning and report
  mode=None.
* rescan.py: trusts walker.mode; drops the bogus 'single un-
  numbered video → PACK with empty episodes' branch. A season
  with no parseable episodes is now skipped with a warning.
* Tests rewritten against the real model: PACK with flat numbered
  files, EPISODIC with one-video-per-subfolder, malformed mixed
  layout skipped, single-un-numbered-file skipped.

Suite: 1237 → 1245 passing.
This commit is contained in:
2026-05-25 21:37:34 +02:00
parent fe9857aaed
commit 97dc799a26
4 changed files with 416 additions and 120 deletions
+25 -38
View File
@@ -15,27 +15,18 @@ a single source of truth for parsing / probing rules. The orchestrator
just translates per-file :class:`InspectedResult` into release
aggregate construction.
PACK vs EPISODIC detection
---------------------------
PACK vs EPISODIC
----------------
Detection lives in this layer (until the TMDB-driven season-tracker
arrives). The current rule:
Classification is done by the walker, by inspecting the season
folder's filesystem structure (flat videos → PACK, subfolders →
EPISODIC). See :mod:`alfred.application.tv_shows.walker`. The
orchestrator trusts ``season_folder.mode`` and never re-derives.
* A season folder containing exactly one video whose parser yields
``season is not None`` and ``episode is None`` → **PACK** with an
empty ``episodes`` tuple. We record the season's mode + folder, but
we cannot fill the episode slot map without TMDB's
``episode_count`` — that's Phase 5's job. The file is still on
disk; the next TMDB sync repairs the slot map.
* Otherwise → **EPISODIC**: every file with a valid ``(season,
episode)`` becomes an :class:`EpisodeRelease`. Multi-episode files
(``S01E01E02``) are recorded once with a wide ``EpisodeRange``;
Phase 4 only handles the single-episode case (the parser does not
yet expose ``episode_end`` on ``ParsedRelease``).
Files that fall outside both rules (no season parsed, mixed season
numbers in a folder, etc.) are logged and skipped — the walker doesn't
raise on corrupt input, and neither does the orchestrator.
Files whose parser yields ``season is None`` or ``episode is None``
are logged and skipped — a real PACK or EPISODIC file always carries
both. Mixed-season folders (two different ``Sxx`` numbers in the
same directory) are skipped with a warning.
TMDB
----
@@ -124,6 +115,11 @@ def _ingest_season(
kb: ReleaseKnowledge,
prober: MediaProber,
) -> SeasonRelease | None:
if season_folder.mode is None:
# Walker already logged the reason (empty / malformed mix /
# multi-video subfolder). Just skip.
return None
if not season_folder.video_files:
_LOG.warning(
"rescan_show: season folder %s contains no video file — skipping",
@@ -131,8 +127,7 @@ def _ingest_season(
)
return None
# Inspect every video first; we need the whole batch to decide
# PACK vs EPISODIC before assembling the SeasonRelease.
# Inspect every video to extract season + episode numbers.
inspected = []
for video_path in season_folder.video_files:
result = inspect_release(video_path.name, video_path, kb, prober)
@@ -157,22 +152,6 @@ def _ingest_season(
season_number = SeasonNumber(season_numbers.pop())
folder_name = season_folder.season_dir.name
# Single video, no episode → PACK with empty episodes. We can't
# synthesize an EpisodeRange without TMDB's episode_count; the
# Phase 5 sync repairs the slot map. Track info from the PACK
# file is intentionally not persisted here — re-derivable on the
# next rescan after the sync fills the range.
if len(inspected) == 1 and inspected[0][1].parsed.episode is None:
return SeasonRelease(
season_number=season_number,
folder=folder_name,
mode=ReleaseMode.PACK,
episodes=(),
)
# EPISODIC: every file with a parseable episode number becomes an
# EpisodeRelease. Files without an episode number are logged and
# dropped (a mixed PACK/EPISODIC folder is malformed).
episodes: list[EpisodeRelease] = []
for video_path, result in inspected:
if result.parsed.episode is None:
@@ -189,10 +168,18 @@ def _ingest_season(
media_info=result.media_info,
)
)
if not episodes:
_LOG.warning(
"rescan_show: no parseable episodes in %s — skipping",
season_folder.season_dir,
)
return None
return SeasonRelease(
season_number=season_number,
folder=folder_name,
mode=ReleaseMode.EPISODIC,
mode=season_folder.mode,
episodes=tuple(episodes),
)