feat(releases): Phase 1 — new filesystem release domain + TmdbId VO
First step of specs/dot_alfred_v2.md. Introduces a separate bounded context (alfred/domain/releases/) for the filesystem-side aggregates, disjoint from TMDB identity which stays in tv_shows/ and movies/. The link between the two worlds is TmdbId, used as the natural key in the persistence layer (no domain-level reference). New package alfred/domain/releases/: - value_objects: EpisodeRange (covers SxxE01E02E03 multi-episode files via start/end inclusive range, with count/numbers/is_single helpers), ReleaseMode enum (PACK = N video files direct in the season folder, EPISODIC = N sub-folders). - entities: TrackProfile, EpisodeRelease, SeasonRelease (with episode_count() summing each EpisodeRange.count()), SeriesRelease (tmdb_id primary anchor, optional imdb_id secondary), MovieRelease. All frozen dataclasses. - builders: SeasonReleaseBuilder + SeriesReleaseBuilder mirroring the v1 TVShowBuilder pattern. Builders sort episodes by range start on emit and reject overlapping ranges (two files claiming the same TMDB slot). from_existing() seeds a builder from an existing frozen aggregate for round-trip edits. - repositories: abstract ports (SeriesReleaseRepository, MovieReleaseRepository); concrete .alfred sidecar impls arrive in Phase 2. New shared VO alfred/domain/shared/value_objects.py::TmdbId — positive int, rejects bool/str/float, symmetric with the existing ImdbId VO. 73 unit tests cover VO validation, entity invariants, builder sort + overlap detection, and from_existing() round-trips. v1 code paths are untouched at this stage; the new domain coexists with the old TVShow aggregate until Phase 3 refactors it.
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"""Tests for the releases domain entities."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.releases.entities import (
|
||||
EpisodeRelease,
|
||||
MovieRelease,
|
||||
SeasonRelease,
|
||||
SeriesRelease,
|
||||
TrackProfile,
|
||||
)
|
||||
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
|
||||
from alfred.domain.shared.exceptions import ValidationError
|
||||
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
|
||||
from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId
|
||||
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ep(start: int, end: int | None = None, *, file: str = "x.mkv") -> EpisodeRelease:
|
||||
end_n = EpisodeNumber(end if end is not None else start)
|
||||
return EpisodeRelease(
|
||||
episodes=EpisodeRange(EpisodeNumber(start), end_n),
|
||||
file_path=FilePath(file),
|
||||
tracks=TrackProfile(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TrackProfile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrackProfile:
|
||||
def test_default_empty(self):
|
||||
p = TrackProfile()
|
||||
assert p.audio_tracks == ()
|
||||
assert p.subtitle_tracks == ()
|
||||
|
||||
def test_with_tracks(self):
|
||||
a = AudioTrack(0, "eac3", 6, "5.1", "eng")
|
||||
s = SubtitleTrack(0, "subrip", "eng", False, False)
|
||||
p = TrackProfile(audio_tracks=(a,), subtitle_tracks=(s,))
|
||||
assert p.audio_tracks == (a,)
|
||||
assert p.subtitle_tracks == (s,)
|
||||
|
||||
def test_frozen(self):
|
||||
p = TrackProfile()
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
p.audio_tracks = () # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EpisodeRelease
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEpisodeRelease:
|
||||
def test_construction(self):
|
||||
ep = _ep(1)
|
||||
assert ep.episodes.start == EpisodeNumber(1)
|
||||
assert ep.file_path == FilePath("x.mkv")
|
||||
|
||||
def test_frozen(self):
|
||||
ep = _ep(1)
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
ep.file_path = FilePath("y.mkv") # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SeasonRelease
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSeasonReleaseValidation:
|
||||
def test_construction_pack(self):
|
||||
s = SeasonRelease(
|
||||
season_number=SeasonNumber(1),
|
||||
folder="Show.S01",
|
||||
mode=ReleaseMode.PACK,
|
||||
episodes=(_ep(1), _ep(2)),
|
||||
)
|
||||
assert s.season_number == SeasonNumber(1)
|
||||
assert s.mode == ReleaseMode.PACK
|
||||
|
||||
def test_construction_episodic(self):
|
||||
s = SeasonRelease(
|
||||
season_number=SeasonNumber(2),
|
||||
folder="Show.S02",
|
||||
mode=ReleaseMode.EPISODIC,
|
||||
episodes=(),
|
||||
)
|
||||
assert s.mode == ReleaseMode.EPISODIC
|
||||
|
||||
def test_season_number_must_be_vo(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SeasonRelease(
|
||||
season_number=1, # type: ignore[arg-type]
|
||||
folder="X",
|
||||
mode=ReleaseMode.PACK,
|
||||
)
|
||||
|
||||
def test_mode_must_be_enum(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SeasonRelease(
|
||||
season_number=SeasonNumber(1),
|
||||
folder="X",
|
||||
mode="pack", # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def test_folder_must_be_non_empty(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SeasonRelease(
|
||||
season_number=SeasonNumber(1),
|
||||
folder="",
|
||||
mode=ReleaseMode.PACK,
|
||||
)
|
||||
|
||||
|
||||
class TestSeasonReleaseEpisodeCount:
|
||||
def test_zero_with_no_episodes(self):
|
||||
s = SeasonRelease(
|
||||
season_number=SeasonNumber(1),
|
||||
folder="X",
|
||||
mode=ReleaseMode.EPISODIC,
|
||||
episodes=(),
|
||||
)
|
||||
assert s.episode_count() == 0
|
||||
|
||||
def test_all_singles(self):
|
||||
s = SeasonRelease(
|
||||
season_number=SeasonNumber(1),
|
||||
folder="X",
|
||||
mode=ReleaseMode.PACK,
|
||||
episodes=(_ep(1), _ep(2), _ep(3)),
|
||||
)
|
||||
assert s.episode_count() == 3
|
||||
|
||||
def test_with_multi_episode_files(self):
|
||||
# E01 + E02-E04 + E05 -> 1 + 3 + 1 = 5
|
||||
s = SeasonRelease(
|
||||
season_number=SeasonNumber(1),
|
||||
folder="X",
|
||||
mode=ReleaseMode.PACK,
|
||||
episodes=(_ep(1), _ep(2, 4), _ep(5)),
|
||||
)
|
||||
assert s.episode_count() == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SeriesRelease
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _season(n: int, *eps_ranges: tuple[int, int]) -> SeasonRelease:
|
||||
eps = tuple(_ep(s, e) for s, e in eps_ranges)
|
||||
return SeasonRelease(
|
||||
season_number=SeasonNumber(n),
|
||||
folder=f"Show.S{n:02d}",
|
||||
mode=ReleaseMode.PACK,
|
||||
episodes=eps,
|
||||
)
|
||||
|
||||
|
||||
class TestSeriesReleaseValidation:
|
||||
def test_construction_minimal(self):
|
||||
r = SeriesRelease(
|
||||
tmdb_id=TmdbId(84958),
|
||||
imdb_id=None,
|
||||
seasons=(),
|
||||
)
|
||||
assert r.tmdb_id == TmdbId(84958)
|
||||
assert r.imdb_id is None
|
||||
assert r.seasons == ()
|
||||
|
||||
def test_construction_with_imdb(self):
|
||||
r = SeriesRelease(
|
||||
tmdb_id=TmdbId(84958),
|
||||
imdb_id=ImdbId("tt0804484"),
|
||||
)
|
||||
assert r.imdb_id == ImdbId("tt0804484")
|
||||
|
||||
def test_tmdb_id_must_be_vo(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SeriesRelease(tmdb_id=84958, imdb_id=None) # type: ignore[arg-type]
|
||||
|
||||
def test_imdb_id_wrong_type_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SeriesRelease(
|
||||
tmdb_id=TmdbId(84958),
|
||||
imdb_id="tt0804484", # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def test_duplicate_season_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SeriesRelease(
|
||||
tmdb_id=TmdbId(1),
|
||||
imdb_id=None,
|
||||
seasons=(_season(1, (1, 1)), _season(1, (2, 2))),
|
||||
)
|
||||
|
||||
|
||||
class TestSeriesReleaseQueries:
|
||||
def test_get_season_present(self):
|
||||
r = SeriesRelease(
|
||||
tmdb_id=TmdbId(1),
|
||||
imdb_id=None,
|
||||
seasons=(_season(1, (1, 1)), _season(2, (1, 1))),
|
||||
)
|
||||
s = r.get_season(SeasonNumber(2))
|
||||
assert s is not None
|
||||
assert s.season_number == SeasonNumber(2)
|
||||
|
||||
def test_get_season_absent(self):
|
||||
r = SeriesRelease(
|
||||
tmdb_id=TmdbId(1),
|
||||
imdb_id=None,
|
||||
seasons=(_season(1, (1, 1)),),
|
||||
)
|
||||
assert r.get_season(SeasonNumber(99)) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MovieRelease
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMovieRelease:
|
||||
def test_construction(self):
|
||||
m = MovieRelease(
|
||||
tmdb_id=TmdbId(27205),
|
||||
imdb_id=ImdbId("tt1375666"),
|
||||
folder="Inception.2010.1080p.BluRay.x264-GROUP",
|
||||
file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"),
|
||||
)
|
||||
assert m.tmdb_id == TmdbId(27205)
|
||||
assert m.imdb_id == ImdbId("tt1375666")
|
||||
|
||||
def test_optional_imdb(self):
|
||||
m = MovieRelease(
|
||||
tmdb_id=TmdbId(27205),
|
||||
imdb_id=None,
|
||||
folder="X",
|
||||
file_path=FilePath("x.mkv"),
|
||||
)
|
||||
assert m.imdb_id is None
|
||||
|
||||
def test_tmdb_id_must_be_vo(self):
|
||||
with pytest.raises(ValidationError):
|
||||
MovieRelease(
|
||||
tmdb_id=27205, # type: ignore[arg-type]
|
||||
imdb_id=None,
|
||||
folder="X",
|
||||
file_path=FilePath("x.mkv"),
|
||||
)
|
||||
|
||||
def test_folder_must_be_non_empty(self):
|
||||
with pytest.raises(ValidationError):
|
||||
MovieRelease(
|
||||
tmdb_id=TmdbId(27205),
|
||||
imdb_id=None,
|
||||
folder="",
|
||||
file_path=FilePath("x.mkv"),
|
||||
)
|
||||
Reference in New Issue
Block a user