refactor(rescan): Phase 4 Step 1 — rescan_show on v2 release repo

Rewrite rescan_show to build a SeriesRelease (Phase 1 v2 aggregate)
and persist it via DotAlfredSeriesReleaseRepository. The orchestrator
keeps reusing inspect_release as the single source of parse/probe
truth — only the assembly target changes (SeriesRelease/SeasonRelease/
EpisodeRelease instead of TVShow/Season/Episode).

New signature

    rescan_show(
        show_root,
        *,
        tmdb_id: TmdbId,
        imdb_id: ImdbId | None = None,
        series_repo: DotAlfredSeriesReleaseRepository,
        scanner,
        prober,
        kb,
    ) -> SeriesRelease

Identity is TMDB-anchored (tmdb_id required, no coercion); imdb_id is
optional. No TMDB call from rescan — the library index auto-heals
from the new sidecar on its next read.

PACK vs EPISODIC

* Single-video + season-parsed + no-episode → SeasonRelease(
  mode=PACK, folder=<season folder>, episodes=()). The slot map stays
  empty until the Phase 5 TMDB sync supplies episode_count. We do
  not fabricate an EpisodeRange we cannot prove on disk.
* Otherwise → EPISODIC: every file with (season, episode) becomes an
  EpisodeRelease with EpisodeRange(start, end) = (E, E). Multi-episode
  files (S01E01E02) still record only the first slot — Parser does
  not yet expose episode_end (existing tech debt, unchanged).

Package move

The orchestrator moves from alfred/application/library/ to
alfred/application/tv_shows/ for symmetry with alfred/application/
movies/ (Step 2). walker.py + its tests move with it. The empty
library/ package is deleted.

Tests

tests/application/tv_shows/test_rescan.py rewritten end-to-end on
the real v2 repository, real KB, real scanner, stubbed prober.
9 happy-path + edge-case scenarios cover EPISODIC track flattening,
PACK empty-episodes semantics, sidecar round-trip, imdb_id optional,
empty show root, season folder with no videos, prober returning None.
test_walker.py moved verbatim (import path updated).

Full suite: 1214 passed / 10 skipped / 4 xfailed. The three v1
dot_alfred quarantines from Phase 3 stay in place until Step 3.
This commit is contained in:
2026-05-25 21:07:25 +02:00
parent c22b2b78eb
commit 7da0f887e7
8 changed files with 335 additions and 283 deletions
@@ -1,47 +1,36 @@
"""Integration tests for ``rescan_show``.
"""Integration tests for the v2 ``rescan_show``.
Uses the real filesystem (``tmp_path``), the real release knowledge
base, and the real ``.alfred`` repository. Only the media prober is
stubbed ffprobe needs real bytes and a binary.
base, the real v2 ``.alfred`` series repository, and the real scanner.
Only the media prober is stubbed ffprobe needs real bytes and a
binary.
"""
from __future__ import annotations
import pytest
# Phase 3 (refactor/dot-alfred-v2): the v1 rescan_show + v1
# DotAlfredTVShowRepository stack is intentionally left broken
# while the TVShow/Movie aggregates are slimmed to TMDB-only.
# Phase 4 rewrites rescan on top of the v2 release repositories +
# library index, then deletes this quarantine block alongside the
# v1 code.
pytest.skip(
"v1 rescan + v1 dot_alfred — replaced in Phase 4",
allow_module_level=True,
)
from pathlib import Path
from alfred.application.library import rescan_show
from alfred.application.tv_shows import rescan_show
from alfred.domain.releases.value_objects import ReleaseMode
from alfred.domain.shared.media import (
AudioTrack,
MediaInfo,
SubtitleTrack,
VideoTrack,
)
from alfred.domain.shared.value_objects import ImdbId
from alfred.domain.shared.value_objects import ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import SeasonNumber
from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
from alfred.infrastructure.persistence.dot_alfred import (
DotAlfredTVShowRepository,
)
from alfred.infrastructure.persistence.dot_alfred.repository import (
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
SIDECAR_FILENAME,
DotAlfredSeriesReleaseRepository,
)
_KB = YamlReleaseKnowledge()
_SCANNER = PathlibFilesystemScanner()
_TMDB_ID = TmdbId(84958)
_IMDB_ID = ImdbId("tt0804484")
# --------------------------------------------------------------------------- #
@@ -101,7 +90,7 @@ def _make_foundation_library(
) -> Path:
"""Build a fake Foundation show folder under ``root``.
Layout (matches the Foundation fixture naming):
Layout::
root/Foundation/
Foundation.S01.1080p.x265-ELiTE/ EPISODIC
@@ -138,84 +127,109 @@ def _library_with_show(tmp_path: Path) -> tuple[Path, Path]:
class TestHappyPath:
def test_builds_show_with_episodic_and_pack_seasons(self, tmp_path):
def test_builds_release_with_episodic_and_pack_seasons(self, tmp_path):
library, show_root = _library_with_show(tmp_path)
repo = DotAlfredTVShowRepository(library)
repo = DotAlfredSeriesReleaseRepository(library)
prober = _StubProber()
show = rescan_show(
release = rescan_show(
show_root,
imdb_id="tt0804484",
tmdb_id=84958,
repository=repo,
tmdb_id=_TMDB_ID,
imdb_id=_IMDB_ID,
series_repo=repo,
scanner=_SCANNER,
prober=prober,
kb=_KB,
)
assert show.imdb_id == ImdbId("tt0804484")
assert show.tmdb_id == 84958
assert show.title == "Foundation"
assert show.seasons_count == 2
assert release.tmdb_id == _TMDB_ID
assert release.imdb_id == _IMDB_ID
assert len(release.seasons) == 2
s1 = show.get_season(SeasonNumber(1))
s2 = show.get_season(SeasonNumber(2))
s1 = release.get_season(SeasonNumber(1))
s2 = release.get_season(SeasonNumber(2))
assert s1 is not None and s2 is not None
# S01 EPISODIC: three episodes, season-level tracks empty.
assert s1.episode_count == 3
assert s1.audio_tracks == ()
assert s1.subtitle_tracks == ()
# S02 PACK: no episodes, season-level tracks populated.
assert s2.episode_count == 0
assert len(s2.audio_tracks) == 1
assert s2.audio_tracks[0].language == "eng"
assert len(s2.subtitle_tracks) == 1
# S01 EPISODIC: three single-episode releases, each carrying
# its own TrackProfile.
assert s1.mode is ReleaseMode.EPISODIC
assert s1.episode_count() == 3
for ep in s1.episodes:
assert ep.episodes.is_single()
assert len(ep.tracks.audio_tracks) == 1
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 == ()
def test_episode_paths_are_relative_to_show_root(self, tmp_path):
library, show_root = _library_with_show(tmp_path)
repo = DotAlfredTVShowRepository(library)
show = rescan_show(
repo = DotAlfredSeriesReleaseRepository(library)
release = rescan_show(
show_root,
imdb_id="tt0804484",
repository=repo,
tmdb_id=_TMDB_ID,
imdb_id=_IMDB_ID,
series_repo=repo,
scanner=_SCANNER,
prober=_StubProber(),
kb=_KB,
)
s1 = show.get_season(SeasonNumber(1))
s1 = release.get_season(SeasonNumber(1))
assert s1 is not None
for ep in s1.episodes:
assert ep.file_path is not None
path_str = str(ep.file_path)
# Must NOT be absolute and must start with the season folder.
assert not Path(path_str).is_absolute()
assert path_str.startswith("Foundation.S01.1080p.x265-ELiTE/")
def test_season_folder_name_recorded(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,
)
s1 = release.get_season(SeasonNumber(1))
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"
def test_persists_sidecar_on_disk(self, tmp_path):
library, show_root = _library_with_show(tmp_path)
repo = DotAlfredTVShowRepository(library)
repo = DotAlfredSeriesReleaseRepository(library)
rescan_show(
show_root,
imdb_id="tt0804484",
repository=repo,
tmdb_id=_TMDB_ID,
imdb_id=_IMDB_ID,
series_repo=repo,
scanner=_SCANNER,
prober=_StubProber(),
kb=_KB,
)
assert (show_root / SIDECAR_FILENAME).is_file()
# Round-trip via the repo.
recovered = repo.find_by_imdb_id(ImdbId("tt0804484"))
recovered = repo.find_by_tmdb_id(_TMDB_ID)
assert recovered is not None
assert recovered.seasons_count == 2
assert len(recovered.seasons) == 2
def test_probe_called_once_per_video(self, tmp_path):
library, show_root = _library_with_show(tmp_path)
repo = DotAlfredTVShowRepository(library)
repo = DotAlfredSeriesReleaseRepository(library)
prober = _StubProber()
rescan_show(
show_root,
imdb_id="tt0804484",
repository=repo,
tmdb_id=_TMDB_ID,
imdb_id=_IMDB_ID,
series_repo=repo,
scanner=_SCANNER,
prober=prober,
kb=_KB,
@@ -223,6 +237,19 @@ class TestHappyPath:
# 3 episodes + 1 pack video = 4 probes.
assert len(prober.calls) == 4
def test_imdb_id_optional(self, tmp_path):
library, show_root = _library_with_show(tmp_path)
repo = DotAlfredSeriesReleaseRepository(library)
release = rescan_show(
show_root,
tmdb_id=_TMDB_ID,
series_repo=repo,
scanner=_SCANNER,
prober=_StubProber(),
kb=_KB,
)
assert release.imdb_id is None
# --------------------------------------------------------------------------- #
# Edge cases #
@@ -230,21 +257,21 @@ class TestHappyPath:
class TestEdgeCases:
def test_empty_show_root_yields_empty_show(self, tmp_path):
def test_empty_show_root_yields_empty_release(self, tmp_path):
library = tmp_path / "library"
library.mkdir()
show_root = library / "Empty"
show_root.mkdir()
repo = DotAlfredTVShowRepository(library)
show = rescan_show(
repo = DotAlfredSeriesReleaseRepository(library)
release = rescan_show(
show_root,
imdb_id="tt0000001",
repository=repo,
tmdb_id=_TMDB_ID,
series_repo=repo,
scanner=_SCANNER,
prober=_StubProber(),
kb=_KB,
)
assert show.seasons_count == 0
assert release.seasons == ()
assert (show_root / SIDECAR_FILENAME).is_file()
def test_season_folder_without_videos_is_skipped(self, tmp_path, caplog):
@@ -253,17 +280,17 @@ class TestEdgeCases:
show_root = library / "Foundation"
show_root.mkdir()
(show_root / "Foundation.S01.WEB").mkdir() # empty season folder
repo = DotAlfredTVShowRepository(library)
repo = DotAlfredSeriesReleaseRepository(library)
with caplog.at_level("WARNING"):
show = rescan_show(
release = rescan_show(
show_root,
imdb_id="tt0804484",
repository=repo,
tmdb_id=_TMDB_ID,
series_repo=repo,
scanner=_SCANNER,
prober=_StubProber(),
kb=_KB,
)
assert show.seasons_count == 0
assert release.seasons == ()
assert any("no video file" in r.message for r in caplog.records)
def test_prober_returning_none_still_produces_episodes(self, tmp_path):
@@ -272,19 +299,19 @@ class TestEdgeCases:
show_root = _make_foundation_library(
library, episodic_episodes=(1,), include_pack_s2=False
)
repo = DotAlfredTVShowRepository(library)
repo = DotAlfredSeriesReleaseRepository(library)
# Prober returns None — inspect_release skips enrichment, tracks empty.
show = rescan_show(
release = rescan_show(
show_root,
imdb_id="tt0804484",
repository=repo,
tmdb_id=_TMDB_ID,
series_repo=repo,
scanner=_SCANNER,
prober=_StubProber(info=None),
kb=_KB,
)
s1 = show.get_season(SeasonNumber(1))
s1 = release.get_season(SeasonNumber(1))
assert s1 is not None
assert s1.episode_count == 1
assert s1.episode_count() == 1
ep = s1.episodes[0]
assert ep.audio_tracks == ()
assert ep.subtitle_tracks == ()
assert ep.tracks.audio_tracks == ()
assert ep.tracks.subtitle_tracks == ()
@@ -2,7 +2,7 @@
from __future__ import annotations
from alfred.application.library.walker import walk_show
from alfred.application.tv_shows.walker import walk_show
from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge