de7030fa9c
Step 4 of specs/dot_alfred.md — rebuild a TVShow aggregate from disk
by reusing the existing release pipeline (inspect_release) on every
video file in a show folder, then persist via the .alfred repository.
- alfred/application/library/walker.py — pure structural walk
(season folders detected via \bS\d{1,2}\b regex, video files
filtered against kb.video_extensions, no recursion).
- alfred/application/library/rescan.py — orchestrator that ingests
each season folder, infers PACK vs EPISODIC from on-disk file
count + parser output, and assembles via TVShowBuilder. Episode
paths stored relative to show_root. Logs + skips corrupt input
(no season parsed, mixed season numbers, unparseable episodes).
- Season now inherits MediaWithTracks: PACK seasons carry
season-level audio_tracks / subtitle_tracks; EPISODIC seasons
leave them empty (tracks live per-episode). SeasonBuilder gains
set_audio_tracks / set_subtitle_tracks; bridge writes/reads them
in the PACK branch via shared _synth_* helpers.
Out of scope, tracked as tech debt: adjacent .srt capture, multi-
episode (episode_end), TMDB-driven PACK detection (the current
heuristic '1 file == PACK' is a placeholder until ShowTracker lands).
18 new tests (11 walker + 7 rescan integration) on tmp_path with
the Foundation layout. Full suite: 1149 passed.
129 lines
4.8 KiB
Python
129 lines
4.8 KiB
Python
"""Tests for the show-tree walker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from alfred.application.library.walker import walk_show
|
|
from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner
|
|
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
|
|
|
|
_KB = YamlReleaseKnowledge()
|
|
_SCANNER = PathlibFilesystemScanner()
|
|
|
|
|
|
def _episode(dir_path, name: str) -> None:
|
|
(dir_path / name).write_bytes(b"")
|
|
|
|
|
|
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"
|
|
]
|
|
|
|
|
|
class TestVideoFileCollection:
|
|
def test_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
|
|
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
|
|
(folder,) = tree.season_folders
|
|
suffixes = sorted(p.suffix for p in folder.video_files)
|
|
assert suffixes == [".mkv", ".mp4"]
|
|
|
|
def test_empty_season_folder_surfaces(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.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):
|
|
show = tmp_path / "Foundation"
|
|
show.mkdir()
|
|
season = show / "Foundation.S01.WEB"
|
|
season.mkdir()
|
|
nested = season / "Extras"
|
|
nested.mkdir()
|
|
_episode(nested, "Foundation.S01E01.mkv")
|
|
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
|
|
(folder,) = tree.season_folders
|
|
assert folder.video_files == ()
|
|
|
|
|
|
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 == ()
|