refactor(domain): freeze Movie and Episode, switch track collections to tuple

Movie and Episode become @dataclass(frozen=True, eq=False), with
audio_tracks/subtitle_tracks held as tuple[...] instead of list[...].
Identity-based equality is preserved via the existing __eq__/__hash__.
__post_init__ coercion (imdb_id, title, season_number, episode_number)
uses object.__setattr__ to stay compatible with frozen.

The MediaWithTracks mixin contract is updated to tuple accordingly.

Callers projecting enrichment results (probe output, file metadata) now
rebuild via dataclasses.replace(...) — same pattern recently adopted for
ParsedRelease.

Season and TVShow stay mutable for now: freezing the aggregate root
would cascade a full reconstruction on every add_episode, deferred.
This commit is contained in:
2026-05-21 13:40:22 +02:00
parent 3dc73a5214
commit 02e478a157
4 changed files with 39 additions and 16 deletions
+13
View File
@@ -77,6 +77,19 @@ callers).
### Changed ### Changed
- **`Movie` and `Episode` are now frozen dataclasses.** Both entities
hold their track collections as `tuple[AudioTrack, ...]` and
`tuple[SubtitleTrack, ...]` instead of mutable lists, and are
`@dataclass(frozen=True, eq=False)` (identity-based equality
preserved via `__eq__`/`__hash__`). `__post_init__` coercion uses
`object.__setattr__` for the `imdb_id` / `title` /
`season_number` / `episode_number` normalizations. To project
enrichment results (probe output, file metadata) callers now rebuild
via `dataclasses.replace(...)`. Pattern aligned with the recent
`ParsedRelease` freeze. `MediaWithTracks` mixin contract updated to
`tuple` accordingly. `Season` and `TVShow` remain mutable for now —
freezing the aggregate root would cascade a full reconstruction on
every `add_episode`, deferred.
- **`SubtitleCandidate` renamed to `SubtitleScanResult`.** The old name - **`SubtitleCandidate` renamed to `SubtitleScanResult`.** The old name
conflated "this might become a placed subtitle" with "this is what a conflated "this might become a placed subtitle" with "this is what a
scan pass produced". The class is the output of a scan/identify pass scan pass produced". The class is the output of a scan/identify pass
+9 -6
View File
@@ -8,19 +8,22 @@ from ..shared.value_objects import FilePath, FileSize, ImdbId
from .value_objects import MovieTitle, Quality, ReleaseYear from .value_objects import MovieTitle, Quality, ReleaseYear
@dataclass(eq=False) @dataclass(frozen=True, eq=False)
class Movie(MediaWithTracks): class Movie(MediaWithTracks):
""" """
Movie aggregate root for the movies domain. Movie aggregate root for the movies domain.
Carries file metadata (path, size) and the tracks discovered by the Carries file metadata (path, size) and the tracks discovered by the
ffprobe + subtitle scan pipeline. The track lists may be empty when the ffprobe + subtitle scan pipeline. The track tuples may be empty when the
movie is known but not yet scanned, or when no file is downloaded. movie is known but not yet scanned, or when no file is downloaded.
Track helpers follow the same "C+" contract as ``Episode``: pass a Track helpers follow the same "C+" contract as ``Episode``: pass a
``Language`` for cross-format matching, or a ``str`` for case-insensitive ``Language`` for cross-format matching, or a ``str`` for case-insensitive
direct comparison. direct comparison.
Frozen: rebuild via ``dataclasses.replace`` to project enrichment results
(audio/subtitle tracks, file metadata) onto a new instance.
Equality is identity-based: two ``Movie`` instances are equal iff they Equality is identity-based: two ``Movie`` instances are equal iff they
share the same ``imdb_id``, regardless of file/track contents. This is share the same ``imdb_id``, regardless of file/track contents. This is
the DDD aggregate invariant — the aggregate is identified by its root id. the DDD aggregate invariant — the aggregate is identified by its root id.
@@ -34,15 +37,15 @@ class Movie(MediaWithTracks):
file_size: FileSize | None = None file_size: FileSize | None = None
tmdb_id: int | None = None tmdb_id: int | None = None
added_at: datetime = field(default_factory=datetime.now) added_at: datetime = field(default_factory=datetime.now)
audio_tracks: list[AudioTrack] = field(default_factory=list) audio_tracks: tuple[AudioTrack, ...] = field(default_factory=tuple)
subtitle_tracks: list[SubtitleTrack] = field(default_factory=list) subtitle_tracks: tuple[SubtitleTrack, ...] = field(default_factory=tuple)
def __post_init__(self): def __post_init__(self):
"""Validate movie entity.""" """Validate movie entity."""
# Ensure ImdbId is actually an ImdbId instance # Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.imdb_id, ImdbId): if not isinstance(self.imdb_id, ImdbId):
if isinstance(self.imdb_id, str): if isinstance(self.imdb_id, str):
self.imdb_id = ImdbId(self.imdb_id) object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id))
else: else:
raise ValueError( raise ValueError(
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}" f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
@@ -51,7 +54,7 @@ class Movie(MediaWithTracks):
# Ensure MovieTitle is actually a MovieTitle instance # Ensure MovieTitle is actually a MovieTitle instance
if not isinstance(self.title, MovieTitle): if not isinstance(self.title, MovieTitle):
if isinstance(self.title, str): if isinstance(self.title, str):
self.title = MovieTitle(self.title) object.__setattr__(self, "title", MovieTitle(self.title))
else: else:
raise ValueError( raise ValueError(
f"title must be MovieTitle or str, got {type(self.title)}" f"title must be MovieTitle or str, got {type(self.title)}"
+4 -4
View File
@@ -218,8 +218,8 @@ class MediaWithTracks:
Hosts must expose two attributes: Hosts must expose two attributes:
* ``audio_tracks: list[AudioTrack]`` * ``audio_tracks: tuple[AudioTrack, ...]``
* ``subtitle_tracks: list[SubtitleTrack]`` * ``subtitle_tracks: tuple[SubtitleTrack, ...]``
The helpers follow the "C+" matching contract: pass a :class:`Language` The helpers follow the "C+" matching contract: pass a :class:`Language`
for cross-format matching, or a ``str`` for case-insensitive comparison. for cross-format matching, or a ``str`` for case-insensitive comparison.
@@ -227,8 +227,8 @@ class MediaWithTracks:
# These attributes are provided by the host entity (Movie, Episode, …). # These attributes are provided by the host entity (Movie, Episode, …).
# Declared here only for type-checkers and to make the contract explicit. # Declared here only for type-checkers and to make the contract explicit.
audio_tracks: list[AudioTrack] audio_tracks: tuple[AudioTrack, ...]
subtitle_tracks: list[SubtitleTrack] subtitle_tracks: tuple[SubtitleTrack, ...]
# ── Audio helpers ────────────────────────────────────────────────────── # ── Audio helpers ──────────────────────────────────────────────────────
+13 -6
View File
@@ -47,16 +47,19 @@ from .value_objects import (
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
@dataclass(eq=False) @dataclass(frozen=True, eq=False)
class Episode(MediaWithTracks): class Episode(MediaWithTracks):
""" """
A single episode of a TV show — leaf of the TVShow aggregate. A single episode of a TV show — leaf of the TVShow aggregate.
Carries the file metadata (path, size) and the discovered tracks Carries the file metadata (path, size) and the discovered tracks
(audio + subtitle). Track lists are populated by the ffprobe + subtitle (audio + subtitle). Track tuples are populated by the ffprobe + subtitle
scan pipeline; they may be empty when the episode is known but not yet scan pipeline; they may be empty when the episode is known but not yet
scanned, or when no file is downloaded yet. scanned, or when no file is downloaded yet.
Frozen: rebuild via ``dataclasses.replace`` to project enrichment results
onto a new instance.
Equality is identity-based within the aggregate: two ``Episode`` instances Equality is identity-based within the aggregate: two ``Episode`` instances
are equal iff they share the same ``(season_number, episode_number)``, are equal iff they share the same ``(season_number, episode_number)``,
regardless of title/file/track contents. The root TVShow guarantees regardless of title/file/track contents. The root TVShow guarantees
@@ -68,17 +71,21 @@ class Episode(MediaWithTracks):
title: str title: str
file_path: FilePath | None = None file_path: FilePath | None = None
file_size: FileSize | None = None file_size: FileSize | None = None
audio_tracks: list[AudioTrack] = field(default_factory=list) audio_tracks: tuple[AudioTrack, ...] = field(default_factory=tuple)
subtitle_tracks: list[SubtitleTrack] = field(default_factory=list) subtitle_tracks: tuple[SubtitleTrack, ...] = field(default_factory=tuple)
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Coerce numbers if raw ints were passed # Coerce numbers if raw ints were passed
if not isinstance(self.season_number, SeasonNumber): if not isinstance(self.season_number, SeasonNumber):
if isinstance(self.season_number, int): if isinstance(self.season_number, int):
self.season_number = SeasonNumber(self.season_number) object.__setattr__(
self, "season_number", SeasonNumber(self.season_number)
)
if not isinstance(self.episode_number, EpisodeNumber): if not isinstance(self.episode_number, EpisodeNumber):
if isinstance(self.episode_number, int): if isinstance(self.episode_number, int):
self.episode_number = EpisodeNumber(self.episode_number) object.__setattr__(
self, "episode_number", EpisodeNumber(self.episode_number)
)
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
if not isinstance(other, Episode): if not isinstance(other, Episode):