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:
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user