diff --git a/CHANGELOG.md b/CHANGELOG.md index a357e05..5ea41cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,19 @@ callers). ### 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 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 diff --git a/alfred/domain/movies/entities.py b/alfred/domain/movies/entities.py index bdac712..a22f26e 100644 --- a/alfred/domain/movies/entities.py +++ b/alfred/domain/movies/entities.py @@ -8,19 +8,22 @@ from ..shared.value_objects import FilePath, FileSize, ImdbId from .value_objects import MovieTitle, Quality, ReleaseYear -@dataclass(eq=False) +@dataclass(frozen=True, eq=False) class Movie(MediaWithTracks): """ Movie aggregate root for the movies domain. 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. Track helpers follow the same "C+" contract as ``Episode``: pass a ``Language`` for cross-format matching, or a ``str`` for case-insensitive 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 share the same ``imdb_id``, regardless of file/track contents. This is the DDD aggregate invariant — the aggregate is identified by its root id. @@ -34,15 +37,15 @@ class Movie(MediaWithTracks): file_size: FileSize | None = None tmdb_id: int | None = None added_at: datetime = field(default_factory=datetime.now) - audio_tracks: list[AudioTrack] = field(default_factory=list) - subtitle_tracks: list[SubtitleTrack] = field(default_factory=list) + audio_tracks: tuple[AudioTrack, ...] = field(default_factory=tuple) + subtitle_tracks: tuple[SubtitleTrack, ...] = field(default_factory=tuple) def __post_init__(self): """Validate movie entity.""" # Ensure ImdbId is actually an ImdbId instance if not isinstance(self.imdb_id, ImdbId): if isinstance(self.imdb_id, str): - self.imdb_id = ImdbId(self.imdb_id) + object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id)) else: raise ValueError( 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 if not isinstance(self.title, MovieTitle): if isinstance(self.title, str): - self.title = MovieTitle(self.title) + object.__setattr__(self, "title", MovieTitle(self.title)) else: raise ValueError( f"title must be MovieTitle or str, got {type(self.title)}" diff --git a/alfred/domain/shared/media.py b/alfred/domain/shared/media.py index f075fb1..cd142c2 100644 --- a/alfred/domain/shared/media.py +++ b/alfred/domain/shared/media.py @@ -218,8 +218,8 @@ class MediaWithTracks: Hosts must expose two attributes: - * ``audio_tracks: list[AudioTrack]`` - * ``subtitle_tracks: list[SubtitleTrack]`` + * ``audio_tracks: tuple[AudioTrack, ...]`` + * ``subtitle_tracks: tuple[SubtitleTrack, ...]`` The helpers follow the "C+" matching contract: pass a :class:`Language` 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, …). # Declared here only for type-checkers and to make the contract explicit. - audio_tracks: list[AudioTrack] - subtitle_tracks: list[SubtitleTrack] + audio_tracks: tuple[AudioTrack, ...] + subtitle_tracks: tuple[SubtitleTrack, ...] # ── Audio helpers ────────────────────────────────────────────────────── diff --git a/alfred/domain/tv_shows/entities.py b/alfred/domain/tv_shows/entities.py index 0ad87ec..d232947 100644 --- a/alfred/domain/tv_shows/entities.py +++ b/alfred/domain/tv_shows/entities.py @@ -47,16 +47,19 @@ from .value_objects import ( # ════════════════════════════════════════════════════════════════════════════ -@dataclass(eq=False) +@dataclass(frozen=True, eq=False) class Episode(MediaWithTracks): """ A single episode of a TV show — leaf of the TVShow aggregate. 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 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 are equal iff they share the same ``(season_number, episode_number)``, regardless of title/file/track contents. The root TVShow guarantees @@ -68,17 +71,21 @@ class Episode(MediaWithTracks): title: str file_path: FilePath | None = None file_size: FileSize | None = None - audio_tracks: list[AudioTrack] = field(default_factory=list) - subtitle_tracks: list[SubtitleTrack] = field(default_factory=list) + audio_tracks: tuple[AudioTrack, ...] = field(default_factory=tuple) + subtitle_tracks: tuple[SubtitleTrack, ...] = field(default_factory=tuple) def __post_init__(self) -> None: # Coerce numbers if raw ints were passed if not isinstance(self.season_number, SeasonNumber): 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 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: if not isinstance(other, Episode):