Files
alfred/tests/application/tv_shows/test_walker.py
T
francwa 97dc799a26 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.
2026-05-25 21:37:34 +02:00

243 lines
11 KiB
Python

"""Tests for the show-tree walker."""
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
_KB = YamlReleaseKnowledge()
_SCANNER = PathlibFilesystemScanner()
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"
show.mkdir()
(show / "Foundation.S01.1080p.WEB-DL.x265-GROUP").mkdir()
(show / "Foundation.S02.Complete.1080p.WEB-DL-GROUP").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
names = sorted(s.season_dir.name for s in tree.season_folders)
assert names == [
"Foundation.S01.1080p.WEB-DL.x265-GROUP",
"Foundation.S02.Complete.1080p.WEB-DL-GROUP",
]
def test_skips_folders_without_season_token(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
(show / "Sample").mkdir()
(show / "Soundtrack").mkdir()
(show / "Foundation.S01.WEB").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert [s.season_dir.name for s in tree.season_folders] == [
"Foundation.S01.WEB"
]
def test_season_token_is_case_insensitive(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
(show / "Foundation.s01.WEB").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert len(tree.season_folders) == 1
def test_season_token_rejects_in_word(self, tmp_path):
# "Season01" without separator must not match (we want a real
# Sxx token, not any letter S followed by digits).
show = tmp_path / "WeirdShow"
show.mkdir()
(show / "Pass100").mkdir()
(show / "ABS01XYZ").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert tree.season_folders == ()
def test_ignores_files_at_show_root(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
(show / "Foundation.S01.mkv").write_bytes(b"") # file, not a folder
(show / "Foundation.S01.WEB").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert [s.season_dir.name for s in tree.season_folders] == [
"Foundation.S01.WEB"
]
# ════════════════════════════════════════════════════════════════════════════
# 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()
_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"]
# ════════════════════════════════════════════════════════════════════════════
# 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_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()
_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"
show.mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert tree.show_root == show
assert tree.season_folders == ()
def test_missing_show_root_returns_empty_tree(self, tmp_path):
# Scanner returns [] for non-existent paths; walker must not raise.
tree = walk_show(tmp_path / "Nope", scanner=_SCANNER, kb=_KB)
assert tree.season_folders == ()