c22b2b78eb
Filesystem-side concerns (file paths, tracks, quality, mode, added_at) move to the releases/ domain added in Phase 1; the TMDB aggregates now carry only identity + TMDB catalog facts. Domain entities: - TVShow: tmdb_id: TmdbId required (primary key), imdb_id: ImdbId | None optional, status: str = "unknown" added. - Season: episode_count: int = 0 added (TMDB-cached); audio_tracks, subtitle_tracks, mode property removed. - Episode: slimmed to identity + title. file_path/file_size/tracks removed. No longer inherits MediaWithTracks. - Movie: tmdb_id required, imdb_id optional. file_path/file_size/quality/ added_at/audio_tracks/subtitle_tracks removed. get_filename() now returns "Title.Year" — quality moves to MovieRelease. Builders: - TVShowBuilder requires tmdb_id: TmdbId; imdb_id/status optional. - SeasonBuilder.set_episode_count(int) replaces set_audio_tracks / set_subtitle_tracks. No-coercion contract: TVShow(tmdb_id=1396) raises — callers pass TmdbId(1396). No ergonomic shim per the no-shims rule. Cascade fixes: - MediaOrganizer test fixtures updated to new Movie/TVShow shapes. - Movie.get_filename() re-added (without Quality) so MediaOrganizer keeps working until Phase 4 rewires it through MovieRelease. Quarantined (deleted in Phase 4 alongside v1 dot_alfred): - tests/application/library/test_rescan.py — module-level skip. - tests/infrastructure/persistence/dot_alfred/test_repository.py — module-level skip. - tests/infrastructure/persistence/dot_alfred/test_serializer.py — module-level skip. Suite: 1216 passed, 11 skipped (8 pre-existing + 3 Phase 3 quarantines), 4 xfailed. CHANGELOG updated under [Unreleased].
291 lines
9.6 KiB
Python
291 lines
9.6 KiB
Python
"""Integration tests for ``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.
|
|
"""
|
|
|
|
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.domain.shared.media import (
|
|
AudioTrack,
|
|
MediaInfo,
|
|
SubtitleTrack,
|
|
VideoTrack,
|
|
)
|
|
from alfred.domain.shared.value_objects import ImdbId
|
|
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 (
|
|
SIDECAR_FILENAME,
|
|
)
|
|
|
|
_KB = YamlReleaseKnowledge()
|
|
_SCANNER = PathlibFilesystemScanner()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
_MISSING = object()
|
|
|
|
|
|
class _StubProber:
|
|
"""Return a canned MediaInfo for every probe call, regardless of path."""
|
|
|
|
def __init__(self, info=_MISSING) -> None:
|
|
self._info: MediaInfo | None = (
|
|
_default_info() if info is _MISSING else info # type: ignore[assignment]
|
|
)
|
|
self.calls: list[Path] = []
|
|
|
|
def probe(self, video: Path) -> MediaInfo | None:
|
|
self.calls.append(video)
|
|
return self._info
|
|
|
|
def list_subtitle_streams(self, video: Path): # pragma: no cover - unused
|
|
return []
|
|
|
|
|
|
def _default_info() -> MediaInfo:
|
|
return MediaInfo(
|
|
video_tracks=(VideoTrack(index=0, codec="hevc", width=1920, height=1080),),
|
|
audio_tracks=(
|
|
AudioTrack(
|
|
index=0,
|
|
codec="eac3",
|
|
channels=6,
|
|
channel_layout="5.1",
|
|
language="eng",
|
|
),
|
|
),
|
|
subtitle_tracks=(
|
|
SubtitleTrack(
|
|
index=0,
|
|
codec="subrip",
|
|
language="eng",
|
|
is_default=False,
|
|
is_forced=False,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _make_foundation_library(
|
|
root: Path,
|
|
*,
|
|
episodic_episodes: tuple[int, ...] = (1, 2, 3),
|
|
include_pack_s2: bool = True,
|
|
) -> Path:
|
|
"""Build a fake Foundation show folder under ``root``.
|
|
|
|
Layout (matches the Foundation fixture naming):
|
|
|
|
root/Foundation/
|
|
Foundation.S01.1080p.x265-ELiTE/ ← EPISODIC
|
|
Foundation.S01E01.1080p.x265-ELiTE.mkv
|
|
Foundation.S01E02.1080p.x265-ELiTE.mkv
|
|
Foundation.S01E03.1080p.x265-ELiTE.mkv
|
|
Foundation.S02.1080p.x265-ELiTE/ ← PACK
|
|
Foundation.S02.1080p.x265-ELiTE.mkv
|
|
"""
|
|
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"
|
|
s2.mkdir()
|
|
(s2 / "Foundation.S02.1080p.x265-ELiTE.mkv").write_bytes(b"")
|
|
return show_root
|
|
|
|
|
|
def _library_with_show(tmp_path: Path) -> tuple[Path, Path]:
|
|
"""Return (library_root, show_root) prepared for the repository."""
|
|
library = tmp_path / "library"
|
|
library.mkdir()
|
|
show_root = _make_foundation_library(library)
|
|
return library, show_root
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Happy path #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestHappyPath:
|
|
def test_builds_show_with_episodic_and_pack_seasons(self, tmp_path):
|
|
library, show_root = _library_with_show(tmp_path)
|
|
repo = DotAlfredTVShowRepository(library)
|
|
prober = _StubProber()
|
|
|
|
show = rescan_show(
|
|
show_root,
|
|
imdb_id="tt0804484",
|
|
tmdb_id=84958,
|
|
repository=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
|
|
|
|
s1 = show.get_season(SeasonNumber(1))
|
|
s2 = show.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
|
|
|
|
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(
|
|
show_root,
|
|
imdb_id="tt0804484",
|
|
repository=repo,
|
|
scanner=_SCANNER,
|
|
prober=_StubProber(),
|
|
kb=_KB,
|
|
)
|
|
s1 = show.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_persists_sidecar_on_disk(self, tmp_path):
|
|
library, show_root = _library_with_show(tmp_path)
|
|
repo = DotAlfredTVShowRepository(library)
|
|
rescan_show(
|
|
show_root,
|
|
imdb_id="tt0804484",
|
|
repository=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"))
|
|
assert recovered is not None
|
|
assert recovered.seasons_count == 2
|
|
|
|
def test_probe_called_once_per_video(self, tmp_path):
|
|
library, show_root = _library_with_show(tmp_path)
|
|
repo = DotAlfredTVShowRepository(library)
|
|
prober = _StubProber()
|
|
rescan_show(
|
|
show_root,
|
|
imdb_id="tt0804484",
|
|
repository=repo,
|
|
scanner=_SCANNER,
|
|
prober=prober,
|
|
kb=_KB,
|
|
)
|
|
# 3 episodes + 1 pack video = 4 probes.
|
|
assert len(prober.calls) == 4
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Edge cases #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_empty_show_root_yields_empty_show(self, tmp_path):
|
|
library = tmp_path / "library"
|
|
library.mkdir()
|
|
show_root = library / "Empty"
|
|
show_root.mkdir()
|
|
repo = DotAlfredTVShowRepository(library)
|
|
show = rescan_show(
|
|
show_root,
|
|
imdb_id="tt0000001",
|
|
repository=repo,
|
|
scanner=_SCANNER,
|
|
prober=_StubProber(),
|
|
kb=_KB,
|
|
)
|
|
assert show.seasons_count == 0
|
|
assert (show_root / SIDECAR_FILENAME).is_file()
|
|
|
|
def test_season_folder_without_videos_is_skipped(self, tmp_path, caplog):
|
|
library = tmp_path / "library"
|
|
library.mkdir()
|
|
show_root = library / "Foundation"
|
|
show_root.mkdir()
|
|
(show_root / "Foundation.S01.WEB").mkdir() # empty season folder
|
|
repo = DotAlfredTVShowRepository(library)
|
|
with caplog.at_level("WARNING"):
|
|
show = rescan_show(
|
|
show_root,
|
|
imdb_id="tt0804484",
|
|
repository=repo,
|
|
scanner=_SCANNER,
|
|
prober=_StubProber(),
|
|
kb=_KB,
|
|
)
|
|
assert show.seasons_count == 0
|
|
assert any("no video file" 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
|
|
)
|
|
repo = DotAlfredTVShowRepository(library)
|
|
# Prober returns None — inspect_release skips enrichment, tracks empty.
|
|
show = rescan_show(
|
|
show_root,
|
|
imdb_id="tt0804484",
|
|
repository=repo,
|
|
scanner=_SCANNER,
|
|
prober=_StubProber(info=None),
|
|
kb=_KB,
|
|
)
|
|
s1 = show.get_season(SeasonNumber(1))
|
|
assert s1 is not None
|
|
assert s1.episode_count == 1
|
|
ep = s1.episodes[0]
|
|
assert ep.audio_tracks == ()
|
|
assert ep.subtitle_tracks == ()
|