From 97dc799a26c02296ed843ffbece4f57a5ab13319 Mon Sep 17 00:00:00 2001 From: Francwa Date: Mon, 25 May 2026 21:37:34 +0200 Subject: [PATCH] fix(tv_shows): correct PACK vs EPISODIC classification model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- alfred/application/tv_shows/rescan.py | 63 ++++----- alfred/application/tv_shows/walker.py | 160 +++++++++++++++++---- tests/application/tv_shows/test_rescan.py | 151 +++++++++++++++----- tests/application/tv_shows/test_walker.py | 162 ++++++++++++++++++---- 4 files changed, 416 insertions(+), 120 deletions(-) diff --git a/alfred/application/tv_shows/rescan.py b/alfred/application/tv_shows/rescan.py index aa45807..230db39 100644 --- a/alfred/application/tv_shows/rescan.py +++ b/alfred/application/tv_shows/rescan.py @@ -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), ) diff --git a/alfred/application/tv_shows/walker.py b/alfred/application/tv_shows/walker.py index 0c1924e..2e7f821 100644 --- a/alfred/application/tv_shows/walker.py +++ b/alfred/application/tv_shows/walker.py @@ -1,11 +1,13 @@ """Show tree walker — minimal filesystem traversal of a TV show folder. -The walker is intentionally dumb: it lists season folders and the -video files inside them, nothing more. It does not parse release -names, run ffprobe, or classify subtitle files. All of that -intelligence lives in the existing release pipeline -(``inspect_release`` + downstream services); the walker just hands -the orchestrator the paths to feed into that pipeline. +The walker is intentionally dumb: it lists season folders, classifies +each one as PACK or EPISODIC by **inspecting its filesystem +structure**, and hands the orchestrator a flat list of video files +per season. It does not parse release names, run ffprobe, or +classify subtitle files. All of that intelligence lives in the +existing release pipeline (``inspect_release`` + downstream +services); the walker just hands the orchestrator the paths to feed +into that pipeline. Folder convention ----------------- @@ -13,32 +15,47 @@ Folder convention Inside an Alfred-managed library, a show root looks like:: Foundation/ - Foundation.S01.1080p.WEB-DL.x265-GROUP/ ← season folder - Foundation.S01E01.1080p.WEB-DL.x265.mkv ← episode (EPISODIC) + Foundation.S01.1080p.WEB-DL.x265-GROUP/ ← PACK season + Foundation.S01E01.1080p.WEB-DL.x265.mkv ← flat video Foundation.S01E02.1080p.WEB-DL.x265.mkv - Foundation.S02.Complete.1080p.WEB-DL-GROUP/ ← season folder - Foundation.S02.Complete.1080p.WEB-DL.mkv ← PACK (single video) + ... + Foundation.S02/ ← EPISODIC season + Foundation.S02E01.1080p.WEB-DL.x265-GROUP/ ← episode subfolder + Foundation.S02E01.1080p.WEB-DL.x265-GROUP.mkv + Foundation.S02E02.1080p.WEB-DL.x265-OTHER/ + Foundation.S02E02.1080p.WEB-DL.x265-OTHER.mkv The walker recognizes a season folder by a ``Sxx`` token anywhere in its name (case-insensitive). It does **not** care about Plex-style names (``Season 01``, ``Specials``) — the Alfred library uses release-style folder names only. -PACK vs EPISODIC detection happens **above** the walker, by the -orchestrator looking at how many video files came back and whether -they carry ``Exx`` tokens. The walker itself reports every video -file it sees, untouched. +PACK vs EPISODIC is a **structural distinction**, not a naming one: + +* **PACK** — season folder contains N flat video files. No + subfolders. +* **EPISODIC** — season folder contains N subfolders, each holding + exactly one video. + +A season folder that mixes the two layouts (some flat videos AND +some subfolders) is malformed: the walker reports +``mode=None`` and an empty ``video_files`` tuple so the +orchestrator can warn and skip it. """ from __future__ import annotations +import logging import re from dataclasses import dataclass from pathlib import Path from alfred.domain.release.ports import ReleaseKnowledge +from alfred.domain.releases.value_objects import ReleaseMode from alfred.domain.shared.ports import FilesystemScanner +_LOG = logging.getLogger(__name__) + # Matches any ``Sxx`` token (1-2 digits) bounded by non-alphanumerics. # Examples that match: ``Foundation.S01.1080p`` , ``S2.Pack`` , ``BBC.s10.bluray``. # Examples that don't: ``Sample`` , ``Soundtrack`` , ``2024.S0E1`` (no S+digits boundary). @@ -47,9 +64,21 @@ _SEASON_TOKEN_RE = re.compile(r"(? SeasonFolder: + """Inspect one season folder and decide PACK / EPISODIC / unknown. + + Looks only at direct children. For EPISODIC, descends one extra + level into each subfolder to collect its single video. Mixed + layouts (flat videos + subfolders) are reported as ``mode=None`` + so the orchestrator can skip them with a warning. + """ + flat_videos: list[Path] = [] + subdirs: list[Path] = [] + for child in scanner.scan_dir(season_dir): + if child.is_file and child.suffix.lower() in video_exts: + flat_videos.append(child.path) + elif child.is_dir: + subdirs.append(child.path) + # Anything else (non-video files like .nfo, .srt at the season + # root) is ignored — it doesn't affect classification. + + has_flat = bool(flat_videos) + has_subdirs = bool(subdirs) + + if has_flat and has_subdirs: + _LOG.warning( + "walker: season folder %s mixes flat videos and subfolders — " + "malformed layout, skipping", + season_dir, + ) + return SeasonFolder(season_dir=season_dir, mode=None, video_files=()) + + if has_flat: + return SeasonFolder( + season_dir=season_dir, + mode=ReleaseMode.PACK, + video_files=tuple(sorted(flat_videos)), + ) + + if has_subdirs: + episode_videos: list[Path] = [] + for sub in sorted(subdirs): + videos_in_sub = [ + child.path + for child in scanner.scan_dir(sub) + if child.is_file and child.suffix.lower() in video_exts + ] + if len(videos_in_sub) == 0: + _LOG.warning( + "walker: episode subfolder %s contains no video — skipping", + sub, + ) + continue + if len(videos_in_sub) > 1: + _LOG.warning( + "walker: episode subfolder %s contains %d videos — " + "malformed, skipping season %s", + sub, + len(videos_in_sub), + season_dir, + ) + return SeasonFolder( + season_dir=season_dir, mode=None, video_files=() + ) + episode_videos.append(videos_in_sub[0]) + return SeasonFolder( + season_dir=season_dir, + mode=ReleaseMode.EPISODIC, + video_files=tuple(episode_videos), + ) + + # No flat videos, no subdirs → empty season folder. + return SeasonFolder(season_dir=season_dir, mode=None, video_files=()) diff --git a/tests/application/tv_shows/test_rescan.py b/tests/application/tv_shows/test_rescan.py index f4971e5..d74c141 100644 --- a/tests/application/tv_shows/test_rescan.py +++ b/tests/application/tv_shows/test_rescan.py @@ -85,31 +85,41 @@ def _default_info() -> MediaInfo: def _make_foundation_library( root: Path, *, - episodic_episodes: tuple[int, ...] = (1, 2, 3), - include_pack_s2: bool = True, + pack_episodes: tuple[int, ...] = (1, 2, 3), + episodic_episodes: tuple[int, ...] = (1, 2), ) -> Path: """Build a fake Foundation show folder under ``root``. Layout:: root/Foundation/ - Foundation.S01.1080p.x265-ELiTE/ ← EPISODIC - Foundation.S01E01.1080p.x265-ELiTE.mkv + Foundation.S01.1080p.x265-ELiTE/ ← PACK + Foundation.S01E01.1080p.x265-ELiTE.mkv (flat) Foundation.S01E02.1080p.x265-ELiTE.mkv Foundation.S01E03.1080p.x265-ELiTE.mkv - Foundation.S02.1080p.x265-ELiTE/ ← PACK - Foundation.S02.1080p.x265-ELiTE.mkv + Foundation.S02/ ← EPISODIC + Foundation.S02E01.1080p.x265-ELiTE/ (subfolder) + Foundation.S02E01.1080p.x265-ELiTE.mkv + Foundation.S02E02.1080p.x265-ELiTE/ + Foundation.S02E02.1080p.x265-ELiTE.mkv + + ``pack_episodes`` and ``episodic_episodes`` can each be set to + empty tuples to omit the corresponding season. """ show_root = root / "Foundation" show_root.mkdir() - s1 = show_root / "Foundation.S01.1080p.x265-ELiTE" - s1.mkdir() - for n in episodic_episodes: - (s1 / f"Foundation.S01E{n:02d}.1080p.x265-ELiTE.mkv").write_bytes(b"") - if include_pack_s2: - s2 = show_root / "Foundation.S02.1080p.x265-ELiTE" + if pack_episodes: + s1 = show_root / "Foundation.S01.1080p.x265-ELiTE" + s1.mkdir() + for n in pack_episodes: + (s1 / f"Foundation.S01E{n:02d}.1080p.x265-ELiTE.mkv").write_bytes(b"") + if episodic_episodes: + s2 = show_root / "Foundation.S02" s2.mkdir() - (s2 / "Foundation.S02.1080p.x265-ELiTE.mkv").write_bytes(b"") + for n in episodic_episodes: + sub = s2 / f"Foundation.S02E{n:02d}.1080p.x265-ELiTE" + sub.mkdir() + (sub / f"Foundation.S02E{n:02d}.1080p.x265-ELiTE.mkv").write_bytes(b"") return show_root @@ -127,7 +137,7 @@ def _library_with_show(tmp_path: Path) -> tuple[Path, Path]: class TestHappyPath: - def test_builds_release_with_episodic_and_pack_seasons(self, tmp_path): + def test_builds_release_with_pack_and_episodic_seasons(self, tmp_path): library, show_root = _library_with_show(tmp_path) repo = DotAlfredSeriesReleaseRepository(library) prober = _StubProber() @@ -150,9 +160,9 @@ class TestHappyPath: s2 = release.get_season(SeasonNumber(2)) assert s1 is not None and s2 is not None - # S01 EPISODIC: three single-episode releases, each carrying - # its own TrackProfile. - assert s1.mode is ReleaseMode.EPISODIC + # S01 PACK: three single-episode releases, each carrying its + # own TrackProfile. + assert s1.mode is ReleaseMode.PACK assert s1.episode_count() == 3 for ep in s1.episodes: assert ep.episodes.is_single() @@ -160,12 +170,12 @@ class TestHappyPath: assert ep.tracks.audio_tracks[0].language == "eng" assert len(ep.tracks.subtitle_tracks) == 1 - # S02 PACK: empty episodes tuple. Phase 5 (TMDB sync) will - # populate the slot map once episode_count is known. - assert s2.mode is ReleaseMode.PACK - assert s2.episodes == () + # S02 EPISODIC: two single-episode releases, each in its own + # subfolder. + assert s2.mode is ReleaseMode.EPISODIC + assert s2.episode_count() == 2 - def test_episode_paths_are_relative_to_show_root(self, tmp_path): + def test_pack_episode_paths_relative_and_flat(self, tmp_path): library, show_root = _library_with_show(tmp_path) repo = DotAlfredSeriesReleaseRepository(library) release = rescan_show( @@ -181,11 +191,33 @@ class TestHappyPath: assert s1 is not None for ep in s1.episodes: path_str = str(ep.file_path) - # Must NOT be absolute and must start with the season folder. assert not Path(path_str).is_absolute() + # PACK files sit directly inside the season folder. assert path_str.startswith("Foundation.S01.1080p.x265-ELiTE/") + assert path_str.count("/") == 1 - def test_season_folder_name_recorded(self, tmp_path): + def test_episodic_episode_paths_descend_into_subfolders(self, tmp_path): + library, show_root = _library_with_show(tmp_path) + repo = DotAlfredSeriesReleaseRepository(library) + release = rescan_show( + show_root, + tmdb_id=_TMDB_ID, + imdb_id=_IMDB_ID, + series_repo=repo, + scanner=_SCANNER, + prober=_StubProber(), + kb=_KB, + ) + s2 = release.get_season(SeasonNumber(2)) + assert s2 is not None + for ep in s2.episodes: + path_str = str(ep.file_path) + assert not Path(path_str).is_absolute() + # EPISODIC: season/episode_subdir/video.mkv → 2 separators. + assert path_str.startswith("Foundation.S02/") + assert path_str.count("/") == 2 + + def test_season_folder_names_recorded(self, tmp_path): library, show_root = _library_with_show(tmp_path) repo = DotAlfredSeriesReleaseRepository(library) release = rescan_show( @@ -201,7 +233,7 @@ class TestHappyPath: s2 = release.get_season(SeasonNumber(2)) assert s1 is not None and s2 is not None assert s1.folder == "Foundation.S01.1080p.x265-ELiTE" - assert s2.folder == "Foundation.S02.1080p.x265-ELiTE" + assert s2.folder == "Foundation.S02" def test_persists_sidecar_on_disk(self, tmp_path): library, show_root = _library_with_show(tmp_path) @@ -216,7 +248,6 @@ class TestHappyPath: kb=_KB, ) assert (show_root / SIDECAR_FILENAME).is_file() - # Round-trip via the repo. recovered = repo.find_by_tmdb_id(_TMDB_ID) assert recovered is not None assert len(recovered.seasons) == 2 @@ -234,8 +265,8 @@ class TestHappyPath: prober=prober, kb=_KB, ) - # 3 episodes + 1 pack video = 4 probes. - assert len(prober.calls) == 4 + # 3 PACK files + 2 EPISODIC files = 5 probes. + assert len(prober.calls) == 5 def test_imdb_id_optional(self, tmp_path): library, show_root = _library_with_show(tmp_path) @@ -274,12 +305,35 @@ class TestEdgeCases: assert release.seasons == () assert (show_root / SIDECAR_FILENAME).is_file() - def test_season_folder_without_videos_is_skipped(self, tmp_path, caplog): + def test_empty_season_folder_is_skipped(self, tmp_path): library = tmp_path / "library" library.mkdir() show_root = library / "Foundation" show_root.mkdir() - (show_root / "Foundation.S01.WEB").mkdir() # empty season folder + (show_root / "Foundation.S01.WEB").mkdir() # empty + repo = DotAlfredSeriesReleaseRepository(library) + release = rescan_show( + show_root, + tmdb_id=_TMDB_ID, + series_repo=repo, + scanner=_SCANNER, + prober=_StubProber(), + kb=_KB, + ) + assert release.seasons == () + + def test_malformed_mixed_season_is_skipped(self, tmp_path, caplog): + library = tmp_path / "library" + library.mkdir() + show_root = library / "Foundation" + show_root.mkdir() + season = show_root / "Foundation.S01.Mixed" + season.mkdir() + # Flat video + subfolder → malformed. + (season / "Foundation.S01E01.mkv").write_bytes(b"") + sub = season / "Foundation.S01E02-RG" + sub.mkdir() + (sub / "Foundation.S01E02-RG.mkv").write_bytes(b"") repo = DotAlfredSeriesReleaseRepository(library) with caplog.at_level("WARNING"): release = rescan_show( @@ -291,16 +345,47 @@ class TestEdgeCases: kb=_KB, ) assert release.seasons == () - assert any("no video file" in r.message for r in caplog.records) + assert any( + "mixes flat videos and subfolders" in r.message + for r in caplog.records + ) + + def test_single_unnumbered_pack_file_is_skipped(self, tmp_path, caplog): + # A season folder with a single video whose name has no Exx + # marker: the season number is parsed but the episode is not, + # so the file is skipped and (no other parseable files →) + # the whole season is skipped. + library = tmp_path / "library" + library.mkdir() + show_root = library / "Foundation" + show_root.mkdir() + season = show_root / "Foundation.S01.Complete" + season.mkdir() + (season / "Foundation.S01.Complete.1080p.mkv").write_bytes(b"") + repo = DotAlfredSeriesReleaseRepository(library) + with caplog.at_level("WARNING"): + release = rescan_show( + show_root, + tmdb_id=_TMDB_ID, + series_repo=repo, + scanner=_SCANNER, + prober=_StubProber(), + kb=_KB, + ) + assert release.seasons == () + assert any( + "no parseable episodes" in r.message or + "no episode number parsed" in r.message + for r in caplog.records + ) def test_prober_returning_none_still_produces_episodes(self, tmp_path): library = tmp_path / "library" library.mkdir() show_root = _make_foundation_library( - library, episodic_episodes=(1,), include_pack_s2=False + library, pack_episodes=(1,), episodic_episodes=() ) repo = DotAlfredSeriesReleaseRepository(library) - # Prober returns None — inspect_release skips enrichment, tracks empty. release = rescan_show( show_root, tmdb_id=_TMDB_ID, diff --git a/tests/application/tv_shows/test_walker.py b/tests/application/tv_shows/test_walker.py index dbd9c43..ed71672 100644 --- a/tests/application/tv_shows/test_walker.py +++ b/tests/application/tv_shows/test_walker.py @@ -3,6 +3,7 @@ from __future__ import annotations from alfred.application.tv_shows.walker import walk_show +from alfred.domain.releases.value_objects import ReleaseMode from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge @@ -10,10 +11,15 @@ _KB = YamlReleaseKnowledge() _SCANNER = PathlibFilesystemScanner() -def _episode(dir_path, name: str) -> None: +def _file(dir_path, name: str) -> None: (dir_path / name).write_bytes(b"") +# ════════════════════════════════════════════════════════════════════════════ +# Season-folder detection (by Sxx token in folder name) +# ════════════════════════════════════════════════════════════════════════════ + + class TestSeasonFolderDetection: def test_keeps_folders_with_season_token(self, tmp_path): show = tmp_path / "Foundation" @@ -66,54 +72,162 @@ class TestSeasonFolderDetection: ] -class TestVideoFileCollection: - def test_keeps_only_video_extensions(self, tmp_path): +# ════════════════════════════════════════════════════════════════════════════ +# PACK classification (flat numbered videos inside season folder) +# ════════════════════════════════════════════════════════════════════════════ + + +class TestPackClassification: + def test_flat_videos_yield_pack(self, tmp_path): + show = tmp_path / "Foundation" + show.mkdir() + season = show / "Foundation.S01.1080p.WEB-DL.x265-GROUP" + season.mkdir() + _file(season, "Foundation.S01E01.1080p.WEB-DL.x265-GROUP.mkv") + _file(season, "Foundation.S01E02.1080p.WEB-DL.x265-GROUP.mkv") + tree = walk_show(show, scanner=_SCANNER, kb=_KB) + (folder,) = tree.season_folders + assert folder.mode is ReleaseMode.PACK + assert [p.name for p in folder.video_files] == [ + "Foundation.S01E01.1080p.WEB-DL.x265-GROUP.mkv", + "Foundation.S01E02.1080p.WEB-DL.x265-GROUP.mkv", + ] + + def test_pack_keeps_only_video_extensions(self, tmp_path): show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S01.WEB" season.mkdir() - _episode(season, "Foundation.S01E01.mkv") - _episode(season, "Foundation.S01E02.mp4") - _episode(season, "Foundation.S01E01.eng.srt") # not a video - _episode(season, "info.nfo") # not a video + _file(season, "Foundation.S01E01.mkv") + _file(season, "Foundation.S01E02.mp4") + _file(season, "Foundation.S01E01.eng.srt") # not a video + _file(season, "info.nfo") # not a video tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders + assert folder.mode is ReleaseMode.PACK suffixes = sorted(p.suffix for p in folder.video_files) assert suffixes == [".mkv", ".mp4"] - def test_empty_season_folder_surfaces(self, tmp_path): + +# ════════════════════════════════════════════════════════════════════════════ +# EPISODIC classification (subfolders, one video each) +# ════════════════════════════════════════════════════════════════════════════ + + +class TestEpisodicClassification: + def test_subfolders_yield_episodic(self, tmp_path): + show = tmp_path / "Foundation" + show.mkdir() + season = show / "Foundation.S02" + season.mkdir() + for n in (1, 2): + sub = season / f"Foundation.S02E{n:02d}.1080p.x265-GROUP" + sub.mkdir() + _file(sub, f"Foundation.S02E{n:02d}.1080p.x265-GROUP.mkv") + tree = walk_show(show, scanner=_SCANNER, kb=_KB) + (folder,) = tree.season_folders + assert folder.mode is ReleaseMode.EPISODIC + assert [p.name for p in folder.video_files] == [ + "Foundation.S02E01.1080p.x265-GROUP.mkv", + "Foundation.S02E02.1080p.x265-GROUP.mkv", + ] + + def test_episodic_paths_descend_into_subfolders(self, tmp_path): + show = tmp_path / "Foundation" + show.mkdir() + season = show / "Foundation.S02" + season.mkdir() + sub = season / "Foundation.S02E01-RG" + sub.mkdir() + _file(sub, "Foundation.S02E01-RG.mkv") + tree = walk_show(show, scanner=_SCANNER, kb=_KB) + (folder,) = tree.season_folders + assert folder.video_files == (sub / "Foundation.S02E01-RG.mkv",) + + def test_episodic_skips_empty_subfolder(self, tmp_path, caplog): + show = tmp_path / "Foundation" + show.mkdir() + season = show / "Foundation.S02" + season.mkdir() + (season / "Foundation.S02E01-RG").mkdir() # empty subfolder + good_sub = season / "Foundation.S02E02-RG" + good_sub.mkdir() + _file(good_sub, "Foundation.S02E02-RG.mkv") + with caplog.at_level("WARNING"): + tree = walk_show(show, scanner=_SCANNER, kb=_KB) + (folder,) = tree.season_folders + assert folder.mode is ReleaseMode.EPISODIC + assert len(folder.video_files) == 1 + assert any("no video" in r.message for r in caplog.records) + + def test_episodic_with_two_videos_in_subfolder_skips_season( + self, tmp_path, caplog + ): + show = tmp_path / "Foundation" + show.mkdir() + season = show / "Foundation.S02" + season.mkdir() + sub = season / "Foundation.S02E01-RG" + sub.mkdir() + _file(sub, "Foundation.S02E01-RG.mkv") + _file(sub, "Foundation.S02E01-RG-extras.mkv") # second video! + with caplog.at_level("WARNING"): + tree = walk_show(show, scanner=_SCANNER, kb=_KB) + (folder,) = tree.season_folders + assert folder.mode is None + assert folder.video_files == () + assert any("malformed" in r.message for r in caplog.records) + + +# ════════════════════════════════════════════════════════════════════════════ +# Malformed and empty seasons +# ════════════════════════════════════════════════════════════════════════════ + + +class TestMalformedAndEmpty: + def test_mixed_flat_and_subfolders_is_malformed(self, tmp_path, caplog): + show = tmp_path / "Foundation" + show.mkdir() + season = show / "Foundation.S01.Mixed" + season.mkdir() + _file(season, "Foundation.S01E01.mkv") # flat video + sub = season / "Foundation.S01E02-RG" # AND a subfolder + sub.mkdir() + _file(sub, "Foundation.S01E02-RG.mkv") + with caplog.at_level("WARNING"): + tree = walk_show(show, scanner=_SCANNER, kb=_KB) + (folder,) = tree.season_folders + assert folder.mode is None + assert folder.video_files == () + assert any("mixes flat videos and subfolders" in r.message for r in caplog.records) + + def test_empty_season_folder_yields_unknown_mode(self, tmp_path): show = tmp_path / "Foundation" show.mkdir() (show / "Foundation.S01.WEB").mkdir() tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders + assert folder.mode is None assert folder.video_files == () - def test_single_pack_video_is_kept(self, tmp_path): - show = tmp_path / "Foundation" - show.mkdir() - season = show / "Foundation.S01.Complete.WEB" - season.mkdir() - _episode(season, "Foundation.S01.Complete.WEB.mkv") - tree = walk_show(show, scanner=_SCANNER, kb=_KB) - (folder,) = tree.season_folders - assert [p.name for p in folder.video_files] == [ - "Foundation.S01.Complete.WEB.mkv" - ] - - def test_does_not_recurse_into_sub_subfolders(self, tmp_path): + def test_season_with_only_non_video_files_is_unknown(self, tmp_path): + # No videos, no subfolders → unknown. The .nfo doesn't count. show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S01.WEB" season.mkdir() - nested = season / "Extras" - nested.mkdir() - _episode(nested, "Foundation.S01E01.mkv") + _file(season, "info.nfo") tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders + assert folder.mode is None assert folder.video_files == () +# ════════════════════════════════════════════════════════════════════════════ +# Edge cases on the show root itself +# ════════════════════════════════════════════════════════════════════════════ + + class TestEdgeCases: def test_empty_show_root(self, tmp_path): show = tmp_path / "Empty"