refactor(domain): identity-based equality + dedup track helpers
Two related DDD fixes for Movie and Episode entities: - Identity equality: @dataclass(eq=False) with custom __eq__/__hash__. Movie is identified by imdb_id, Episode by (season, episode) within the TVShow aggregate. Auto-generated field-by-field equality was incorrectly making two Movie instances with the same imdb_id but different audio_tracks compare unequal — breaks dedup/caching. - MediaWithTracks mixin: the 5 audio/subtitle helpers (has_audio_in / audio_languages / has_subtitles_in / has_forced_subs / subtitle_languages) were duplicated verbatim between Movie and Episode. Extracted to shared/media/tracks_mixin.py; both entities now inherit. Bonus: dropped the object.__setattr__ coercion dance in Movie.__post_init__ — the class isn't frozen so plain assignment is the right call.
This commit is contained in:
@@ -3,13 +3,13 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ..shared.media import AudioTrack, SubtitleTrack, track_lang_matches
|
from ..shared.media import AudioTrack, MediaWithTracks, SubtitleTrack
|
||||||
from ..shared.value_objects import FilePath, FileSize, ImdbId, Language
|
from ..shared.value_objects import FilePath, FileSize, ImdbId
|
||||||
from .value_objects import MovieTitle, Quality, ReleaseYear
|
from .value_objects import MovieTitle, Quality, ReleaseYear
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Movie:
|
class Movie(MediaWithTracks):
|
||||||
"""
|
"""
|
||||||
Movie aggregate root for the movies domain.
|
Movie aggregate root for the movies domain.
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ class Movie:
|
|||||||
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.
|
||||||
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
imdb_id: ImdbId
|
imdb_id: ImdbId
|
||||||
@@ -38,7 +42,7 @@ class Movie:
|
|||||||
# 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):
|
||||||
object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id))
|
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)}"
|
||||||
@@ -47,12 +51,20 @@ class Movie:
|
|||||||
# 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):
|
||||||
object.__setattr__(self, "title", MovieTitle(self.title))
|
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)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, Movie):
|
||||||
|
return NotImplemented
|
||||||
|
return self.imdb_id == other.imdb_id
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash(self.imdb_id)
|
||||||
|
|
||||||
def has_file(self) -> bool:
|
def has_file(self) -> bool:
|
||||||
"""Check if the movie has an associated file."""
|
"""Check if the movie has an associated file."""
|
||||||
return self.file_path is not None and self.file_path.exists()
|
return self.file_path is not None and self.file_path.exists()
|
||||||
@@ -61,41 +73,8 @@ class Movie:
|
|||||||
"""Check if the movie is downloaded (has a file)."""
|
"""Check if the movie is downloaded (has a file)."""
|
||||||
return self.has_file()
|
return self.has_file()
|
||||||
|
|
||||||
# ── Audio helpers ──────────────────────────────────────────────────────
|
# Track helpers (has_audio_in / audio_languages / has_subtitles_in /
|
||||||
|
# has_forced_subs / subtitle_languages) come from MediaWithTracks.
|
||||||
def has_audio_in(self, lang: str | Language) -> bool:
|
|
||||||
"""True if at least one audio track is in the given language."""
|
|
||||||
return any(track_lang_matches(t.language, lang) for t in self.audio_tracks)
|
|
||||||
|
|
||||||
def audio_languages(self) -> list[str]:
|
|
||||||
"""Unique audio languages across all tracks, in track order."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result: list[str] = []
|
|
||||||
for t in self.audio_tracks:
|
|
||||||
if t.language and t.language not in seen:
|
|
||||||
seen.add(t.language)
|
|
||||||
result.append(t.language)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ── Subtitle helpers ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def has_subtitles_in(self, lang: str | Language) -> bool:
|
|
||||||
"""True if at least one subtitle track is in the given language."""
|
|
||||||
return any(track_lang_matches(t.language, lang) for t in self.subtitle_tracks)
|
|
||||||
|
|
||||||
def has_forced_subs(self) -> bool:
|
|
||||||
"""True if at least one subtitle track is flagged as forced."""
|
|
||||||
return any(t.is_forced for t in self.subtitle_tracks)
|
|
||||||
|
|
||||||
def subtitle_languages(self) -> list[str]:
|
|
||||||
"""Unique subtitle languages across all tracks, in track order."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result: list[str] = []
|
|
||||||
for t in self.subtitle_tracks:
|
|
||||||
if t.language and t.language not in seen:
|
|
||||||
seen.add(t.language)
|
|
||||||
result.append(t.language)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_folder_name(self) -> str:
|
def get_folder_name(self) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ from .audio import AudioTrack
|
|||||||
from .info import MediaInfo
|
from .info import MediaInfo
|
||||||
from .matching import track_lang_matches
|
from .matching import track_lang_matches
|
||||||
from .subtitle import SubtitleTrack
|
from .subtitle import SubtitleTrack
|
||||||
|
from .tracks_mixin import MediaWithTracks
|
||||||
from .video import VideoTrack
|
from .video import VideoTrack
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AudioTrack",
|
"AudioTrack",
|
||||||
"MediaInfo",
|
"MediaInfo",
|
||||||
|
"MediaWithTracks",
|
||||||
"SubtitleTrack",
|
"SubtitleTrack",
|
||||||
"VideoTrack",
|
"VideoTrack",
|
||||||
"track_lang_matches",
|
"track_lang_matches",
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Mixin shared by entities that carry audio + subtitle tracks.
|
||||||
|
|
||||||
|
Both ``Movie`` and ``Episode`` carry a ``list[AudioTrack]`` plus a
|
||||||
|
``list[SubtitleTrack]`` and answer the same 5 queries about them (language
|
||||||
|
presence, unique languages, forced flag). Keep that behavior in one place so a
|
||||||
|
fix in one is a fix in both.
|
||||||
|
|
||||||
|
The mixin is plain Python (no dataclass machinery) so it composes cleanly with
|
||||||
|
``@dataclass`` entities — it only reads ``self.audio_tracks`` and
|
||||||
|
``self.subtitle_tracks`` which the host class provides as fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ..value_objects import Language
|
||||||
|
from .matching import track_lang_matches
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .audio import AudioTrack
|
||||||
|
from .subtitle import SubtitleTrack
|
||||||
|
|
||||||
|
|
||||||
|
class MediaWithTracks:
|
||||||
|
"""
|
||||||
|
Mixin providing audio/subtitle helpers for entities with track collections.
|
||||||
|
|
||||||
|
Hosts must expose two attributes:
|
||||||
|
|
||||||
|
* ``audio_tracks: list[AudioTrack]``
|
||||||
|
* ``subtitle_tracks: list[SubtitleTrack]``
|
||||||
|
|
||||||
|
The helpers follow the "C+" matching contract: pass a :class:`Language`
|
||||||
|
for cross-format matching, or a ``str`` for case-insensitive comparison.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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 helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def has_audio_in(self, lang: str | Language) -> bool:
|
||||||
|
"""True if at least one audio track is in the given language."""
|
||||||
|
return any(track_lang_matches(t.language, lang) for t in self.audio_tracks)
|
||||||
|
|
||||||
|
def audio_languages(self) -> list[str]:
|
||||||
|
"""Unique audio languages across all tracks, in track order."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for t in self.audio_tracks:
|
||||||
|
if t.language and t.language not in seen:
|
||||||
|
seen.add(t.language)
|
||||||
|
result.append(t.language)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Subtitle helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def has_subtitles_in(self, lang: str | Language) -> bool:
|
||||||
|
"""True if at least one subtitle track is in the given language."""
|
||||||
|
return any(track_lang_matches(t.language, lang) for t in self.subtitle_tracks)
|
||||||
|
|
||||||
|
def has_forced_subs(self) -> bool:
|
||||||
|
"""True if at least one subtitle track is flagged as forced."""
|
||||||
|
return any(t.is_forced for t in self.subtitle_tracks)
|
||||||
|
|
||||||
|
def subtitle_languages(self) -> list[str]:
|
||||||
|
"""Unique subtitle languages across all tracks, in track order."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for t in self.subtitle_tracks:
|
||||||
|
if t.language and t.language not in seen:
|
||||||
|
seen.add(t.language)
|
||||||
|
result.append(t.language)
|
||||||
|
return result
|
||||||
@@ -28,12 +28,11 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from ..shared.media import AudioTrack, SubtitleTrack, track_lang_matches
|
from ..shared.media import AudioTrack, MediaWithTracks, SubtitleTrack
|
||||||
from ..shared.value_objects import (
|
from ..shared.value_objects import (
|
||||||
FilePath,
|
FilePath,
|
||||||
FileSize,
|
FileSize,
|
||||||
ImdbId,
|
ImdbId,
|
||||||
Language,
|
|
||||||
to_dot_folder_name,
|
to_dot_folder_name,
|
||||||
)
|
)
|
||||||
from .value_objects import (
|
from .value_objects import (
|
||||||
@@ -48,8 +47,8 @@ from .value_objects import (
|
|||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Episode:
|
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.
|
||||||
|
|
||||||
@@ -57,6 +56,11 @@ class Episode:
|
|||||||
(audio + subtitle). Track lists are populated by the ffprobe + subtitle
|
(audio + subtitle). Track lists 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
cross-show uniqueness.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
season_number: SeasonNumber
|
season_number: SeasonNumber
|
||||||
@@ -76,6 +80,17 @@ class Episode:
|
|||||||
if isinstance(self.episode_number, int):
|
if isinstance(self.episode_number, int):
|
||||||
self.episode_number = EpisodeNumber(self.episode_number)
|
self.episode_number = EpisodeNumber(self.episode_number)
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, Episode):
|
||||||
|
return NotImplemented
|
||||||
|
return (
|
||||||
|
self.season_number == other.season_number
|
||||||
|
and self.episode_number == other.episode_number
|
||||||
|
)
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash((self.season_number, self.episode_number))
|
||||||
|
|
||||||
# ── File presence ──────────────────────────────────────────────────────
|
# ── File presence ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def has_file(self) -> bool:
|
def has_file(self) -> bool:
|
||||||
@@ -86,41 +101,8 @@ class Episode:
|
|||||||
"""Alias of ``has_file()`` — reads better in collection-status contexts."""
|
"""Alias of ``has_file()`` — reads better in collection-status contexts."""
|
||||||
return self.has_file()
|
return self.has_file()
|
||||||
|
|
||||||
# ── Audio helpers ──────────────────────────────────────────────────────
|
# Track helpers (has_audio_in / audio_languages / has_subtitles_in /
|
||||||
|
# has_forced_subs / subtitle_languages) come from MediaWithTracks.
|
||||||
def has_audio_in(self, lang: str | Language) -> bool:
|
|
||||||
"""True if at least one audio track is in the given language."""
|
|
||||||
return any(track_lang_matches(t.language, lang) for t in self.audio_tracks)
|
|
||||||
|
|
||||||
def audio_languages(self) -> list[str]:
|
|
||||||
"""Unique audio languages across all tracks, in track order."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result: list[str] = []
|
|
||||||
for t in self.audio_tracks:
|
|
||||||
if t.language and t.language not in seen:
|
|
||||||
seen.add(t.language)
|
|
||||||
result.append(t.language)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ── Subtitle helpers ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def has_subtitles_in(self, lang: str | Language) -> bool:
|
|
||||||
"""True if at least one subtitle track is in the given language."""
|
|
||||||
return any(track_lang_matches(t.language, lang) for t in self.subtitle_tracks)
|
|
||||||
|
|
||||||
def has_forced_subs(self) -> bool:
|
|
||||||
"""True if at least one subtitle track is flagged as forced."""
|
|
||||||
return any(t.is_forced for t in self.subtitle_tracks)
|
|
||||||
|
|
||||||
def subtitle_languages(self) -> list[str]:
|
|
||||||
"""Unique subtitle languages across all tracks, in track order."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result: list[str] = []
|
|
||||||
for t in self.subtitle_tracks:
|
|
||||||
if t.language and t.language not in seen:
|
|
||||||
seen.add(t.language)
|
|
||||||
result.append(t.language)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ── Naming ─────────────────────────────────────────────────────────────
|
# ── Naming ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user