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),
)
+135 -25
View File
@@ -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"(?<![A-Za-z0-9])s(\d{1,2})(?![A-Za-z0-9])", re.I
@dataclass(frozen=True)
class SeasonFolder:
"""One season folder discovered inside a show root."""
"""One season folder discovered inside a show root.
``mode`` is set by the walker from the FS structure:
* :attr:`ReleaseMode.PACK` — ``video_files`` lists the season
folder's flat videos.
* :attr:`ReleaseMode.EPISODIC` — ``video_files`` lists each
episode subfolder's single video.
* ``None`` — the folder is empty, malformed (mixed layout), or
otherwise unclassifiable. ``video_files`` is empty. The
orchestrator decides whether to warn/skip.
"""
season_dir: Path
mode: ReleaseMode | None
video_files: tuple[Path, ...]
@@ -73,26 +102,107 @@ def walk_show(
* lists direct children of ``show_root``,
* keeps the directories whose name contains a ``Sxx`` token,
* lists each season directory's video files (one level deep —
no recursion into sub-sub-folders),
* classifies each season folder as PACK / EPISODIC / unknown by
inspecting its direct children (videos vs subfolders),
* for EPISODIC, descends one extra level into each episode
subfolder to collect its single video,
* sorts season folders by name and video files by name within
each folder.
Empty / unreadable directories surface as a ``SeasonFolder`` with
an empty ``video_files`` tuple. The orchestrator decides how to
react (skip, warn, fail) — the walker never raises.
The walker never raises — empty / unreadable / malformed
directories surface as a ``SeasonFolder`` with ``mode=None`` and
an empty ``video_files`` tuple.
"""
video_exts = {ext.lower() for ext in kb.video_extensions}
season_folders: list[SeasonFolder] = []
for entry in scanner.scan_dir(show_root):
if not entry.is_dir or not _SEASON_TOKEN_RE.search(entry.name):
continue
videos = tuple(
child.path
for child in scanner.scan_dir(entry.path)
if child.is_file and child.suffix.lower() in video_exts
season_folders.append(
_classify_season(entry.path, scanner=scanner, video_exts=video_exts)
)
season_folders.append(SeasonFolder(season_dir=entry.path, video_files=videos))
return ShowTree(
show_root=show_root, season_folders=tuple(season_folders)
)
# --------------------------------------------------------------------------- #
# Season-folder classification #
# --------------------------------------------------------------------------- #
def _classify_season(
season_dir: Path,
*,
scanner: FilesystemScanner,
video_exts: set[str],
) -> 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=())
+118 -33
View File
@@ -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,
+138 -24
View File
@@ -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"