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:
@@ -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
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user