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