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:
2026-05-25 15:19:23 +02:00
parent de7030fa9c
commit c0f6d01048
12 changed files with 1316 additions and 2 deletions
View File
+184
View File
@@ -0,0 +1,184 @@
"""Tests for the releases domain builders."""
import pytest
from alfred.domain.releases.builders import (
SeasonReleaseBuilder,
SeriesReleaseBuilder,
)
from alfred.domain.releases.entities import EpisodeRelease, TrackProfile
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
from alfred.domain.shared.exceptions import ValidationError
from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
def _ep(start: int, end: int | None = None, *, file: str | None = None) -> EpisodeRelease:
end_n = EpisodeNumber(end if end is not None else start)
label = file or f"S01E{start:02d}.mkv"
return EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(start), end_n),
file_path=FilePath(label),
tracks=TrackProfile(),
)
# ---------------------------------------------------------------------------
# SeasonReleaseBuilder
# ---------------------------------------------------------------------------
class TestSeasonReleaseBuilder:
def test_build_empty(self):
b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK)
s = b.build()
assert s.season_number == SeasonNumber(1)
assert s.episodes == ()
assert s.mode == ReleaseMode.PACK
def test_int_normalized_to_vo(self):
b = SeasonReleaseBuilder(3, folder="X", mode=ReleaseMode.EPISODIC)
assert b.season_number == SeasonNumber(3)
def test_add_episode_returns_self(self):
b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK)
assert b.add_episode(_ep(1)) is b
def test_episodes_sorted_by_start(self):
b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK)
b.add_episode(_ep(3)).add_episode(_ep(1)).add_episode(_ep(2))
s = b.build()
starts = [ep.episodes.start.value for ep in s.episodes]
assert starts == [1, 2, 3]
def test_overlap_raises(self):
# E01-E03 + E02 -> E02 overlaps with the first range
b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK)
b.add_episode(_ep(1, 3)).add_episode(_ep(2))
with pytest.raises(ValidationError):
b.build()
def test_adjacent_ranges_dont_overlap(self):
# E01 + E02-E04 + E05 — all touch but no overlap
b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK)
b.add_episode(_ep(1)).add_episode(_ep(2, 4)).add_episode(_ep(5))
s = b.build()
assert s.episode_count() == 5
def test_set_folder(self):
b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK)
b.set_folder("Y")
assert b.build().folder == "Y"
def test_set_mode(self):
b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK)
b.set_mode(ReleaseMode.EPISODIC)
assert b.build().mode == ReleaseMode.EPISODIC
def test_from_existing_round_trip(self):
b = SeasonReleaseBuilder(1, folder="Show.S01", mode=ReleaseMode.PACK)
b.add_episode(_ep(1)).add_episode(_ep(2))
original = b.build()
b2 = SeasonReleaseBuilder.from_existing(original)
rebuilt = b2.build()
assert rebuilt == original
def test_from_existing_then_mutate(self):
b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK)
b.add_episode(_ep(1))
original = b.build()
b2 = SeasonReleaseBuilder.from_existing(original).add_episode(_ep(2))
rebuilt = b2.build()
assert len(rebuilt.episodes) == 2
# ---------------------------------------------------------------------------
# SeriesReleaseBuilder
# ---------------------------------------------------------------------------
class TestSeriesReleaseBuilder:
def test_build_empty(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(84958))
r = b.build()
assert r.tmdb_id == TmdbId(84958)
assert r.imdb_id is None
assert r.seasons == ()
def test_int_and_str_normalized(self):
b = SeriesReleaseBuilder(tmdb_id=84958, imdb_id="tt0804484")
r = b.build()
assert r.tmdb_id == TmdbId(84958)
assert r.imdb_id == ImdbId("tt0804484")
def test_season_builder_create_requires_folder_and_mode(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(1))
with pytest.raises(ValidationError):
b.season_builder(1)
with pytest.raises(ValidationError):
b.season_builder(1, folder="X")
with pytest.raises(ValidationError):
b.season_builder(1, mode=ReleaseMode.PACK)
def test_season_builder_reuses_existing(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(1))
sb1 = b.season_builder(1, folder="X", mode=ReleaseMode.PACK)
sb2 = b.season_builder(1) # no folder/mode — reuse
assert sb1 is sb2
def test_season_builder_can_override_folder_and_mode(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(1))
b.season_builder(1, folder="X", mode=ReleaseMode.PACK)
b.season_builder(1, folder="Y", mode=ReleaseMode.EPISODIC)
season = b.build().seasons[0]
assert season.folder == "Y"
assert season.mode == ReleaseMode.EPISODIC
def test_seasons_sorted_on_build(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(1))
b.season_builder(2, folder="S02", mode=ReleaseMode.PACK)
b.season_builder(1, folder="S01", mode=ReleaseMode.PACK)
r = b.build()
nums = [s.season_number.value for s in r.seasons]
assert nums == [1, 2]
def test_add_season_replaces(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(1))
b.season_builder(1, folder="OLD", mode=ReleaseMode.PACK)
# Build a SeasonRelease via its own builder and attach via add_season:
replacement = SeasonReleaseBuilder(
1, folder="NEW", mode=ReleaseMode.EPISODIC
).build()
b.add_season(replacement)
season = b.build().seasons[0]
assert season.folder == "NEW"
assert season.mode == ReleaseMode.EPISODIC
def test_set_imdb_id_string_normalized(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(1))
b.set_imdb_id("tt0804484")
assert b.build().imdb_id == ImdbId("tt0804484")
def test_set_imdb_id_none(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(1), imdb_id="tt0804484")
b.set_imdb_id(None)
assert b.build().imdb_id is None
def test_from_existing_round_trip(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(84958), imdb_id="tt0804484")
sb = b.season_builder(1, folder="S01", mode=ReleaseMode.PACK)
sb.add_episode(_ep(1)).add_episode(_ep(2))
original = b.build()
b2 = SeriesReleaseBuilder.from_existing(original)
rebuilt = b2.build()
assert rebuilt == original
def test_from_existing_then_mutate(self):
b = SeriesReleaseBuilder(tmdb_id=TmdbId(1))
sb = b.season_builder(1, folder="S01", mode=ReleaseMode.PACK)
sb.add_episode(_ep(1))
original = b.build()
b2 = SeriesReleaseBuilder.from_existing(original)
b2.season_builder(2, folder="S02", mode=ReleaseMode.PACK).add_episode(_ep(1))
rebuilt = b2.build()
assert len(rebuilt.seasons) == 2
+270
View File
@@ -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"),
)
+106
View File
@@ -0,0 +1,106 @@
"""Tests for the releases domain value objects: EpisodeRange, ReleaseMode."""
import pytest
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
from alfred.domain.shared.exceptions import ValidationError
from alfred.domain.tv_shows.value_objects import EpisodeNumber
# ---------------------------------------------------------------------------
# ReleaseMode
# ---------------------------------------------------------------------------
class TestReleaseMode:
def test_values(self):
assert ReleaseMode.PACK.value == "pack"
assert ReleaseMode.EPISODIC.value == "episodic"
def test_str_subclass(self):
# ReleaseMode is a (str, Enum) — string equality should work.
assert ReleaseMode.PACK == "pack"
assert ReleaseMode.EPISODIC == "episodic"
# ---------------------------------------------------------------------------
# EpisodeRange
# ---------------------------------------------------------------------------
class TestEpisodeRangeConstruction:
def test_single_episode(self):
r = EpisodeRange(EpisodeNumber(1), EpisodeNumber(1))
assert r.start == EpisodeNumber(1)
assert r.end == EpisodeNumber(1)
def test_multi_episode(self):
r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(4))
assert r.start == EpisodeNumber(2)
assert r.end == EpisodeNumber(4)
def test_end_before_start_raises(self):
with pytest.raises(ValidationError):
EpisodeRange(EpisodeNumber(5), EpisodeNumber(3))
def test_start_must_be_episode_number(self):
with pytest.raises(ValidationError):
EpisodeRange(1, EpisodeNumber(2)) # type: ignore[arg-type]
def test_end_must_be_episode_number(self):
with pytest.raises(ValidationError):
EpisodeRange(EpisodeNumber(1), 2) # type: ignore[arg-type]
class TestEpisodeRangeOperations:
def test_count_single(self):
assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)).count() == 1
def test_count_multi(self):
assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(3)).count() == 3
def test_count_large(self):
assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(10)).count() == 10
def test_numbers_single(self):
r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(2))
assert r.numbers() == (EpisodeNumber(2),)
def test_numbers_multi(self):
r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(4))
assert r.numbers() == (
EpisodeNumber(2),
EpisodeNumber(3),
EpisodeNumber(4),
)
def test_is_single_true(self):
assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)).is_single()
def test_is_single_false(self):
assert not EpisodeRange(EpisodeNumber(1), EpisodeNumber(2)).is_single()
def test_str_single(self):
assert str(EpisodeRange(EpisodeNumber(2), EpisodeNumber(2))) == "E02"
def test_str_multi(self):
assert str(EpisodeRange(EpisodeNumber(2), EpisodeNumber(4))) == "E02-E04"
def test_repr(self):
r = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3))
assert repr(r) == "EpisodeRange(1, 3)"
def test_equality(self):
a = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3))
b = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3))
c = EpisodeRange(EpisodeNumber(1), EpisodeNumber(2))
assert a == b
assert a != c
def test_hashable(self):
ranges = {
EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
EpisodeRange(EpisodeNumber(2), EpisodeNumber(3)),
}
assert len(ranges) == 2
+49 -2
View File
@@ -1,11 +1,11 @@
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
"""Tests for shared domain value objects: ImdbId, TmdbId, FilePath, FileSize."""
from pathlib import Path
import pytest
from alfred.domain.shared.exceptions import ValidationError
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId, TmdbId
# ---------------------------------------------------------------------------
# ImdbId
@@ -54,6 +54,53 @@ class TestImdbId:
assert len(ids) == 2
# ---------------------------------------------------------------------------
# TmdbId
# ---------------------------------------------------------------------------
class TestTmdbId:
def test_valid_int(self):
tid = TmdbId(84958)
assert tid.value == 84958
assert str(tid) == "84958"
assert int(tid) == 84958
def test_zero_raises(self):
with pytest.raises(ValidationError):
TmdbId(0)
def test_negative_raises(self):
with pytest.raises(ValidationError):
TmdbId(-1)
def test_string_raises(self):
with pytest.raises(ValidationError):
TmdbId("84958") # type: ignore[arg-type]
def test_float_raises(self):
with pytest.raises(ValidationError):
TmdbId(84958.0) # type: ignore[arg-type]
def test_bool_raises(self):
# bool is a subclass of int — make sure we reject it explicitly.
with pytest.raises(ValidationError):
TmdbId(True) # type: ignore[arg-type]
with pytest.raises(ValidationError):
TmdbId(False) # type: ignore[arg-type]
def test_repr(self):
assert repr(TmdbId(84958)) == "TmdbId(84958)"
def test_equality(self):
assert TmdbId(84958) == TmdbId(84958)
assert TmdbId(84958) != TmdbId(12345)
def test_hashable(self):
ids = {TmdbId(84958), TmdbId(12345), TmdbId(84958)}
assert len(ids) == 2
# ---------------------------------------------------------------------------
# FilePath
# ---------------------------------------------------------------------------