diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bd0d8..75d48f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,33 @@ callers). ### Added +- **`.alfred` v2 — Phase 1: new `releases/` domain.** First step of + `specs/dot_alfred_v2.md` on branch `refactor/dot-alfred-v2`. The + new `alfred/domain/releases/` package introduces a filesystem-only + bounded context separated from TMDB identity (the existing + `tv_shows` / `movies` domains). It hosts: + - **`EpisodeRange` VO** — covers single-episode files + (`EpisodeRange(E02, E02)`) and multi-episode files + (`EpisodeRange(E02, E04)` for `SxxE02E03E04.mkv`), with + `count()` / `numbers()` / `is_single()` helpers. + - **`ReleaseMode` enum** — `PACK` (N video files directly in the + season folder) vs `EPISODIC` (N sub-folders, one episode each); + classified by the walker, never re-derived. + - **Aggregates** — `TrackProfile`, `EpisodeRelease`, + `SeasonRelease` (with `episode_count()` summing each file's + range), `SeriesRelease`, `MovieRelease`. All frozen + dataclasses; mutation via `SeasonReleaseBuilder` / + `SeriesReleaseBuilder` (mirror the v1 `TVShowBuilder` pattern, + including `from_existing()` round-trip). + - **Abstract ports** — `SeriesReleaseRepository`, + `MovieReleaseRepository` (concrete `DotAlfred*` arrive in + Phase 2). + - **`TmdbId` VO** added to `alfred/domain/shared/value_objects.py` + (positive int, rejects bool/str/float — symmetry with `ImdbId`). +- 73 unit tests covering VO validation, entity invariants, builder + sort + overlap detection, and `from_existing()` round-trips. v1 + code paths untouched at this stage; new domain coexists. + - **`rescan_show` orchestrator (`alfred/application/library/rescan.py`).** Step 4 of the `specs/dot_alfred.md` plan. Walks an Alfred-managed show folder, diff --git a/alfred/domain/releases/__init__.py b/alfred/domain/releases/__init__.py new file mode 100644 index 0000000..aced145 --- /dev/null +++ b/alfred/domain/releases/__init__.py @@ -0,0 +1,38 @@ +"""Filesystem release aggregates — what the user owns on disk. + +This bounded context is intentionally separated from +``alfred.domain.tv_shows`` / ``alfred.domain.movies`` (TMDB identity). +A :class:`SeriesRelease` describes the physical files on disk for one +show; a :class:`TVShow` describes the work as catalogued by TMDB. The +two are linked by :class:`~alfred.domain.shared.value_objects.TmdbId` +in the persistence layer, never by direct reference. + +Not to be confused with ``alfred.domain.release`` (singular) which +parses release **names** (strings → tokens). The two packages may be +merged later; for now they coexist as separate concerns. +""" + +from .builders import SeasonReleaseBuilder, SeriesReleaseBuilder +from .entities import ( + EpisodeRelease, + MovieRelease, + SeasonRelease, + SeriesRelease, + TrackProfile, +) +from .repositories import MovieReleaseRepository, SeriesReleaseRepository +from .value_objects import EpisodeRange, ReleaseMode + +__all__ = [ + "EpisodeRange", + "EpisodeRelease", + "MovieRelease", + "MovieReleaseRepository", + "ReleaseMode", + "SeasonRelease", + "SeasonReleaseBuilder", + "SeriesRelease", + "SeriesReleaseBuilder", + "SeriesReleaseRepository", + "TrackProfile", +] diff --git a/alfred/domain/releases/builders.py b/alfred/domain/releases/builders.py new file mode 100644 index 0000000..2bdff85 --- /dev/null +++ b/alfred/domain/releases/builders.py @@ -0,0 +1,234 @@ +"""Builders for the filesystem release aggregates. + +The aggregates are frozen — :class:`SeriesRelease`, :class:`SeasonRelease`, +and :class:`EpisodeRelease` are ``@dataclass(frozen=True)`` and offer no +mutation methods. All construction goes through these builders, which +assemble the aggregate piece by piece and emit a frozen instance via +``build()``. + +Typical usage during a filesystem walk:: + + builder = SeriesReleaseBuilder(tmdb_id=TmdbId(84958), imdb_id=ImdbId("tt0804484")) + sb = builder.season_builder(SeasonNumber(1), folder="Show.S01", mode=ReleaseMode.PACK) + sb.add_episode(EpisodeRelease( + episodes=EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)), + file_path=FilePath("Show.S01/Show.S01E01.mkv"), + tracks=TrackProfile(), + )) + release = builder.build() + +Builders are **single-use scratchpads**: they hold mutable state during +construction, then produce an immutable aggregate. + +Invariants enforced at ``build()`` time: + +* Seasons are emitted sorted by ``season_number``. +* Episodes within each season are emitted sorted by their + ``EpisodeRange.start`` (so a season with ``E01-E03`` + ``E04`` is + emitted in that order). +* No two ``EpisodeRelease`` within a season may overlap (same TMDB + episode covered by two distinct files) — raises ``ValidationError``. +""" + +from __future__ import annotations + +from ..shared.exceptions import ValidationError +from ..shared.value_objects import ImdbId, TmdbId +from ..tv_shows.value_objects import SeasonNumber +from .entities import ( + EpisodeRelease, + SeasonRelease, + SeriesRelease, +) +from .value_objects import ReleaseMode + +# ════════════════════════════════════════════════════════════════════════════ +# SeasonReleaseBuilder +# ════════════════════════════════════════════════════════════════════════════ + + +class SeasonReleaseBuilder: + """ + Mutable scratchpad for a :class:`SeasonRelease`. + + Episodes are appended in arbitrary order; ``build()`` sorts them by + their range start before emitting the frozen aggregate and verifies + there are no overlapping ranges. + """ + + def __init__( + self, + season_number: SeasonNumber | int, + *, + folder: str, + mode: ReleaseMode, + ) -> None: + if isinstance(season_number, int): + season_number = SeasonNumber(season_number) + self._season_number: SeasonNumber = season_number + self._folder: str = folder + self._mode: ReleaseMode = mode + self._episodes: list[EpisodeRelease] = [] + + @classmethod + def from_existing(cls, season: SeasonRelease) -> SeasonReleaseBuilder: + """Seed a builder from an existing frozen :class:`SeasonRelease`.""" + builder = cls( + season.season_number, + folder=season.folder, + mode=season.mode, + ) + builder._episodes = list(season.episodes) + return builder + + @property + def season_number(self) -> SeasonNumber: + return self._season_number + + @property + def mode(self) -> ReleaseMode: + return self._mode + + def set_folder(self, folder: str) -> SeasonReleaseBuilder: + self._folder = folder + return self + + def set_mode(self, mode: ReleaseMode) -> SeasonReleaseBuilder: + self._mode = mode + return self + + def add_episode(self, episode: EpisodeRelease) -> SeasonReleaseBuilder: + """Append a physical-file :class:`EpisodeRelease` to this season.""" + self._episodes.append(episode) + return self + + def build(self) -> SeasonRelease: + """Emit a frozen :class:`SeasonRelease` with episodes sorted. + + Raises :class:`ValidationError` if any two episode ranges overlap + (same TMDB slot claimed by two distinct files). + """ + ordered = tuple( + sorted(self._episodes, key=lambda ep: ep.episodes.start.value) + ) + # Overlap check — ranges are inclusive on both ends, sorted by start. + for prev, curr in zip(ordered, ordered[1:], strict=False): + if curr.episodes.start.value <= prev.episodes.end.value: + raise ValidationError( + f"SeasonRelease season {self._season_number}: overlapping " + f"episode ranges {prev.episodes} and {curr.episodes}" + ) + return SeasonRelease( + season_number=self._season_number, + folder=self._folder, + mode=self._mode, + episodes=ordered, + ) + + +# ════════════════════════════════════════════════════════════════════════════ +# SeriesReleaseBuilder +# ════════════════════════════════════════════════════════════════════════════ + + +class SeriesReleaseBuilder: + """ + Mutable scratchpad for the :class:`SeriesRelease` aggregate root. + + Seasons are tracked via internal :class:`SeasonReleaseBuilder` + instances keyed by :class:`SeasonNumber`. + """ + + def __init__( + self, + *, + tmdb_id: TmdbId | int, + imdb_id: ImdbId | str | None = None, + ) -> None: + if isinstance(tmdb_id, int): + tmdb_id = TmdbId(tmdb_id) + if isinstance(imdb_id, str): + imdb_id = ImdbId(imdb_id) + self._tmdb_id: TmdbId = tmdb_id + self._imdb_id: ImdbId | None = imdb_id + self._season_builders: dict[SeasonNumber, SeasonReleaseBuilder] = {} + + @classmethod + def from_existing(cls, release: SeriesRelease) -> SeriesReleaseBuilder: + """Seed a builder from an existing frozen :class:`SeriesRelease`.""" + builder = cls( + tmdb_id=release.tmdb_id, + imdb_id=release.imdb_id, + ) + for season in release.seasons: + builder._season_builders[season.season_number] = ( + SeasonReleaseBuilder.from_existing(season) + ) + return builder + + # ── Top-level mutators ───────────────────────────────────────────────── + + def set_imdb_id(self, imdb_id: ImdbId | str | None) -> SeriesReleaseBuilder: + if isinstance(imdb_id, str): + imdb_id = ImdbId(imdb_id) + self._imdb_id = imdb_id + return self + + # ── Content ──────────────────────────────────────────────────────────── + + def season_builder( + self, + season_number: SeasonNumber | int, + *, + folder: str | None = None, + mode: ReleaseMode | None = None, + ) -> SeasonReleaseBuilder: + """ + Return (creating if needed) the :class:`SeasonReleaseBuilder` for a + season. + + ``folder`` and ``mode`` are required when the builder does not yet + exist for this season; subsequent calls may pass them to override. + """ + if isinstance(season_number, int): + season_number = SeasonNumber(season_number) + sb = self._season_builders.get(season_number) + if sb is None: + if folder is None or mode is None: + raise ValidationError( + f"season_builder({season_number}): folder and mode " + f"are required to create a new season builder" + ) + sb = SeasonReleaseBuilder(season_number, folder=folder, mode=mode) + self._season_builders[season_number] = sb + else: + if folder is not None: + sb.set_folder(folder) + if mode is not None: + sb.set_mode(mode) + return sb + + def add_season(self, season: SeasonRelease) -> SeriesReleaseBuilder: + """ + Attach (or replace) a fully-built :class:`SeasonRelease`. + + Replaces any existing season with the same number. + """ + self._season_builders[season.season_number] = ( + SeasonReleaseBuilder.from_existing(season) + ) + return self + + # ── Emit ─────────────────────────────────────────────────────────────── + + def build(self) -> SeriesRelease: + """Emit a frozen :class:`SeriesRelease` with seasons sorted by number.""" + ordered_seasons = tuple( + self._season_builders[n].build() + for n in sorted(self._season_builders, key=lambda x: x.value) + ) + return SeriesRelease( + tmdb_id=self._tmdb_id, + imdb_id=self._imdb_id, + seasons=ordered_seasons, + ) diff --git a/alfred/domain/releases/entities.py b/alfred/domain/releases/entities.py new file mode 100644 index 0000000..0584117 --- /dev/null +++ b/alfred/domain/releases/entities.py @@ -0,0 +1,204 @@ +"""Filesystem release aggregates. + +The release domain models what the user owns on disk — one +:class:`SeriesRelease` per show, one :class:`MovieRelease` per movie. +TMDB identity (title, status, episode_count, …) lives in the +``tv_shows`` / ``movies`` domains and is linked via the +:class:`~alfred.domain.shared.value_objects.TmdbId` natural key. + +All entities are frozen. Mutation goes through the builders in +:mod:`alfred.domain.releases.builders`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..shared.exceptions import ValidationError +from ..shared.media import AudioTrack, SubtitleTrack +from ..shared.value_objects import FilePath, ImdbId, TmdbId +from ..tv_shows.value_objects import SeasonNumber +from .value_objects import EpisodeRange, ReleaseMode + +__all__ = [ + "EpisodeRelease", + "MovieRelease", + "SeasonRelease", + "SeriesRelease", + "TrackProfile", +] + + +@dataclass(frozen=True) +class TrackProfile: + """ + Audio + subtitle tracks of one physical file. + + Tracks live per-file (not per-season): every ``EpisodeRelease`` and + ``MovieRelease`` carries its own ``TrackProfile``. Season-level + aggregation is computed by the caller when needed. + """ + + audio_tracks: tuple[AudioTrack, ...] = () + subtitle_tracks: tuple[SubtitleTrack, ...] = () + + +@dataclass(frozen=True) +class EpisodeRelease: + """ + One physical episode file (or multi-episode file) on disk. + + :attr:`episodes` is an :class:`EpisodeRange` — a single ``.mkv`` + that covers ``S01E02E03`` carries ``EpisodeRange(start=E02, end=E03)`` + and is recorded once. The library index lists it under each covered + slot (``E02``, ``E03``) for symmetric lookups. + + :attr:`file_path` is **relative to the show root** (e.g. + ``"Show.S01/Show.S01E02.mkv"`` for PACK, + ``"Show.S01/Show.S01E02-RG/Show.S01E02-RG.mkv"`` for EPISODIC). + The caller (repository) prepends the absolute show root when + needed. + """ + + episodes: EpisodeRange + file_path: FilePath + tracks: TrackProfile = TrackProfile() + + +@dataclass(frozen=True) +class SeasonRelease: + """ + All physical files on disk for one season of a show. + + The :attr:`mode` flag records the filesystem layout: + + * :attr:`ReleaseMode.PACK` — the season folder contains N video + files directly. ``episodes`` lists each ``.mkv`` in the folder. + * :attr:`ReleaseMode.EPISODIC` — the season folder contains N + sub-folders, each with one episode. ``episodes`` lists each + ``(subfolder, file)`` pair. + + :attr:`folder` is the season folder name, relative to the show root. + + Invariant: every ``EpisodeRelease.episodes`` range stays within + sane bounds (validated at construction). Cross-episode duplicate + detection (two files claiming the same TMDB slot) is the + builder's job, not the entity's. + """ + + season_number: SeasonNumber + folder: str + mode: ReleaseMode + episodes: tuple[EpisodeRelease, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.season_number, SeasonNumber): + raise ValidationError( + f"SeasonRelease.season_number must be SeasonNumber, " + f"got {type(self.season_number)}" + ) + if not isinstance(self.mode, ReleaseMode): + raise ValidationError( + f"SeasonRelease.mode must be ReleaseMode, got {type(self.mode)}" + ) + if not isinstance(self.folder, str) or not self.folder: + raise ValidationError( + f"SeasonRelease.folder must be a non-empty string, " + f"got {self.folder!r}" + ) + + def episode_count(self) -> int: + """ + Total number of TMDB episode slots covered by all physical files. + + Sums each :meth:`EpisodeRange.count` — a season with two files + ``E01`` + ``E02-E03`` returns ``3`` (one slot from the first + file, two from the second). + + Compared by the caller against the library index's TMDB + ``episode_count`` to detect incomplete seasons. + """ + return sum(ep.episodes.count() for ep in self.episodes) + + +@dataclass(frozen=True) +class SeriesRelease: + """ + All physical seasons on disk for one show. + + Anchored to TMDB by :attr:`tmdb_id` (primary key). :attr:`imdb_id` + is optional and stored as a secondary anchor — useful for the + occasional show without TMDB coverage, and for cross-checking + when both ids are known. + + Seasons are exposed sorted by ``season_number`` (the builder + enforces this on emit). No duplicate ``season_number`` is + permitted across :attr:`seasons`. + """ + + tmdb_id: TmdbId + imdb_id: ImdbId | None + seasons: tuple[SeasonRelease, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.tmdb_id, TmdbId): + raise ValidationError( + f"SeriesRelease.tmdb_id must be TmdbId, got {type(self.tmdb_id)}" + ) + if self.imdb_id is not None and not isinstance(self.imdb_id, ImdbId): + raise ValidationError( + f"SeriesRelease.imdb_id must be ImdbId or None, " + f"got {type(self.imdb_id)}" + ) + seen: set[int] = set() + for s in self.seasons: + if s.season_number.value in seen: + raise ValidationError( + f"SeriesRelease has duplicate season " + f"{s.season_number}" + ) + seen.add(s.season_number.value) + + def get_season(self, season_number: SeasonNumber) -> SeasonRelease | None: + """Return the :class:`SeasonRelease` for ``season_number`` or ``None``.""" + for s in self.seasons: + if s.season_number == season_number: + return s + return None + + +@dataclass(frozen=True) +class MovieRelease: + """ + A single physical movie file on disk. + + Anchored to TMDB by :attr:`tmdb_id`; :attr:`imdb_id` optional + secondary anchor. + + :attr:`folder` is the movie folder name relative to the + ``movies/`` library root. :attr:`file_path` is the video file + name relative to the folder (movies are one folder, one file in + Alfred's layout — no sub-folders). + """ + + tmdb_id: TmdbId + imdb_id: ImdbId | None + folder: str + file_path: FilePath + tracks: TrackProfile = TrackProfile() + + def __post_init__(self) -> None: + if not isinstance(self.tmdb_id, TmdbId): + raise ValidationError( + f"MovieRelease.tmdb_id must be TmdbId, got {type(self.tmdb_id)}" + ) + if self.imdb_id is not None and not isinstance(self.imdb_id, ImdbId): + raise ValidationError( + f"MovieRelease.imdb_id must be ImdbId or None, " + f"got {type(self.imdb_id)}" + ) + if not isinstance(self.folder, str) or not self.folder: + raise ValidationError( + f"MovieRelease.folder must be a non-empty string, " + f"got {self.folder!r}" + ) diff --git a/alfred/domain/releases/repositories.py b/alfred/domain/releases/repositories.py new file mode 100644 index 0000000..983e79a --- /dev/null +++ b/alfred/domain/releases/repositories.py @@ -0,0 +1,79 @@ +"""Repository ports for the filesystem release domain. + +One repository per aggregate root: + +* :class:`SeriesReleaseRepository` — persists :class:`SeriesRelease` + (one per TV show). +* :class:`MovieReleaseRepository` — persists :class:`MovieRelease` + (one per movie). + +Implementations live in the infrastructure layer. The +``DotAlfred*ReleaseRepository`` concrete classes write the per-show / +per-movie ``.alfred`` sidecar (the release-only sidecar — the +``.alfred.index`` library file is handled by separate +``LibraryIndex`` repositories defined alongside the TMDB-side +aggregates). +""" + +from abc import ABC, abstractmethod + +from ..shared.value_objects import TmdbId +from .entities import MovieRelease, SeriesRelease + + +class SeriesReleaseRepository(ABC): + """ + Abstract repository for :class:`SeriesRelease` aggregates. + + Persistence is per-show: each call to :meth:`save` writes the full + aggregate (all seasons + all episode files + tracks) atomically. + """ + + @abstractmethod + def save(self, release: SeriesRelease) -> None: + """Persist the full SeriesRelease aggregate.""" + + @abstractmethod + def find_by_tmdb_id(self, tmdb_id: TmdbId) -> SeriesRelease | None: + """Load the SeriesRelease for ``tmdb_id``, or None if absent.""" + + @abstractmethod + def find_all(self) -> list[SeriesRelease]: + """Load all SeriesRelease aggregates known to the store.""" + + @abstractmethod + def delete(self, tmdb_id: TmdbId) -> bool: + """Remove the aggregate. Returns True if it existed and was deleted.""" + + @abstractmethod + def exists(self, tmdb_id: TmdbId) -> bool: + """True if the aggregate exists in the store.""" + + +class MovieReleaseRepository(ABC): + """ + Abstract repository for :class:`MovieRelease` aggregates. + + Mirrors :class:`SeriesReleaseRepository`; the movie aggregate is a + single file so persistence is naturally atomic per movie. + """ + + @abstractmethod + def save(self, release: MovieRelease) -> None: + """Persist the MovieRelease aggregate.""" + + @abstractmethod + def find_by_tmdb_id(self, tmdb_id: TmdbId) -> MovieRelease | None: + """Load the MovieRelease for ``tmdb_id``, or None if absent.""" + + @abstractmethod + def find_all(self) -> list[MovieRelease]: + """Load all MovieRelease aggregates known to the store.""" + + @abstractmethod + def delete(self, tmdb_id: TmdbId) -> bool: + """Remove the aggregate. Returns True if it existed and was deleted.""" + + @abstractmethod + def exists(self, tmdb_id: TmdbId) -> bool: + """True if the aggregate exists in the store.""" diff --git a/alfred/domain/releases/value_objects.py b/alfred/domain/releases/value_objects.py new file mode 100644 index 0000000..54c86ff --- /dev/null +++ b/alfred/domain/releases/value_objects.py @@ -0,0 +1,89 @@ +"""Value objects for the filesystem release domain.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from ..shared.exceptions import ValidationError +from ..tv_shows.value_objects import EpisodeNumber + + +class ReleaseMode(str, Enum): + """ + Filesystem layout of a season release. + + Determined structurally by the walker — not a guess: + + * ``PACK`` — the season folder contains N video files directly. + A single release group posted the whole season as N files in + one folder. + * ``EPISODIC`` — the season folder contains N sub-folders, each + holding one episode (and its adjacent subs / nfo / etc.). + Episodes were acquired one-by-one, possibly from different + release groups. + + The mode is stored explicitly on :class:`SeasonRelease`; the + walker never has to re-derive it after the first scan. + """ + + PACK = "pack" + EPISODIC = "episodic" + + +@dataclass(frozen=True) +class EpisodeRange: + """ + Episode coverage of one physical release file. + + A single-episode file (``Show.S01E02.mkv``) is represented as + ``EpisodeRange(start=E02, end=E02)``. + + A multi-episode file (``Show.S01E02E03E04.mkv``) is represented as + ``EpisodeRange(start=E02, end=E04)``. Ranges are inclusive on both + ends and must satisfy ``end >= start``. + + The VO carries no opinion about file paths or tracks — those live on + :class:`EpisodeRelease`. ``EpisodeRange`` is purely about which TMDB + episode slots a given physical file covers. + """ + + start: EpisodeNumber + end: EpisodeNumber + + def __post_init__(self) -> None: + if not isinstance(self.start, EpisodeNumber): + raise ValidationError( + f"EpisodeRange.start must be EpisodeNumber, got {type(self.start)}" + ) + if not isinstance(self.end, EpisodeNumber): + raise ValidationError( + f"EpisodeRange.end must be EpisodeNumber, got {type(self.end)}" + ) + if self.end.value < self.start.value: + raise ValidationError( + f"EpisodeRange end ({self.end}) must be >= start ({self.start})" + ) + + def count(self) -> int: + """Number of TMDB episodes covered by this range (inclusive).""" + return self.end.value - self.start.value + 1 + + def numbers(self) -> tuple[EpisodeNumber, ...]: + """All :class:`EpisodeNumber` values covered, in ascending order.""" + return tuple( + EpisodeNumber(n) + for n in range(self.start.value, self.end.value + 1) + ) + + def is_single(self) -> bool: + """True if the range covers exactly one episode (``start == end``).""" + return self.start == self.end + + def __str__(self) -> str: + if self.is_single(): + return f"E{self.start.value:02d}" + return f"E{self.start.value:02d}-E{self.end.value:02d}" + + def __repr__(self) -> str: + return f"EpisodeRange({self.start.value}, {self.end.value})" diff --git a/alfred/domain/shared/value_objects.py b/alfred/domain/shared/value_objects.py index b468a2e..306851c 100644 --- a/alfred/domain/shared/value_objects.py +++ b/alfred/domain/shared/value_objects.py @@ -42,6 +42,42 @@ class ImdbId: return f"ImdbId('{self.value}')" +@dataclass(frozen=True) +class TmdbId: + """ + Value object representing a TMDB ID. + + TMDB ids are positive integers. The same id is used across the TMDB API + for a given work (movie or TV show); the type qualifier (``movie`` / + ``tv``) lives at the call site, not in the VO. + + Stored as ``int`` (not zero-padded string) — TMDB exposes ids as + integers in their API responses. + """ + + value: int + + def __post_init__(self) -> None: + # bool is a subclass of int in Python — reject explicitly so that + # ``TmdbId(True)`` does not silently become ``TmdbId(1)``. + if isinstance(self.value, bool) or not isinstance(self.value, int): + raise ValidationError( + f"TMDB ID must be an integer, got {type(self.value)}" + ) + + if self.value <= 0: + raise ValidationError(f"TMDB ID must be positive, got {self.value}") + + def __str__(self) -> str: + return str(self.value) + + def __repr__(self) -> str: + return f"TmdbId({self.value})" + + def __int__(self) -> int: + return self.value + + @dataclass(frozen=True) class FilePath: """ diff --git a/tests/domain/releases/__init__.py b/tests/domain/releases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domain/releases/test_builders.py b/tests/domain/releases/test_builders.py new file mode 100644 index 0000000..045baab --- /dev/null +++ b/tests/domain/releases/test_builders.py @@ -0,0 +1,184 @@ +"""Tests for the releases domain builders.""" + +import pytest + +from alfred.domain.releases.builders import ( + SeasonReleaseBuilder, + SeriesReleaseBuilder, +) +from alfred.domain.releases.entities import EpisodeRelease, TrackProfile +from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode +from alfred.domain.shared.exceptions import ValidationError +from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId +from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber + + +def _ep(start: int, end: int | None = None, *, file: str | None = None) -> EpisodeRelease: + end_n = EpisodeNumber(end if end is not None else start) + label = file or f"S01E{start:02d}.mkv" + return EpisodeRelease( + episodes=EpisodeRange(EpisodeNumber(start), end_n), + file_path=FilePath(label), + tracks=TrackProfile(), + ) + + +# --------------------------------------------------------------------------- +# SeasonReleaseBuilder +# --------------------------------------------------------------------------- + + +class TestSeasonReleaseBuilder: + def test_build_empty(self): + b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK) + s = b.build() + assert s.season_number == SeasonNumber(1) + assert s.episodes == () + assert s.mode == ReleaseMode.PACK + + def test_int_normalized_to_vo(self): + b = SeasonReleaseBuilder(3, folder="X", mode=ReleaseMode.EPISODIC) + assert b.season_number == SeasonNumber(3) + + def test_add_episode_returns_self(self): + b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK) + assert b.add_episode(_ep(1)) is b + + def test_episodes_sorted_by_start(self): + b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK) + b.add_episode(_ep(3)).add_episode(_ep(1)).add_episode(_ep(2)) + s = b.build() + starts = [ep.episodes.start.value for ep in s.episodes] + assert starts == [1, 2, 3] + + def test_overlap_raises(self): + # E01-E03 + E02 -> E02 overlaps with the first range + b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK) + b.add_episode(_ep(1, 3)).add_episode(_ep(2)) + with pytest.raises(ValidationError): + b.build() + + def test_adjacent_ranges_dont_overlap(self): + # E01 + E02-E04 + E05 — all touch but no overlap + b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK) + b.add_episode(_ep(1)).add_episode(_ep(2, 4)).add_episode(_ep(5)) + s = b.build() + assert s.episode_count() == 5 + + def test_set_folder(self): + b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK) + b.set_folder("Y") + assert b.build().folder == "Y" + + def test_set_mode(self): + b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK) + b.set_mode(ReleaseMode.EPISODIC) + assert b.build().mode == ReleaseMode.EPISODIC + + def test_from_existing_round_trip(self): + b = SeasonReleaseBuilder(1, folder="Show.S01", mode=ReleaseMode.PACK) + b.add_episode(_ep(1)).add_episode(_ep(2)) + original = b.build() + b2 = SeasonReleaseBuilder.from_existing(original) + rebuilt = b2.build() + assert rebuilt == original + + def test_from_existing_then_mutate(self): + b = SeasonReleaseBuilder(1, folder="X", mode=ReleaseMode.PACK) + b.add_episode(_ep(1)) + original = b.build() + b2 = SeasonReleaseBuilder.from_existing(original).add_episode(_ep(2)) + rebuilt = b2.build() + assert len(rebuilt.episodes) == 2 + + +# --------------------------------------------------------------------------- +# SeriesReleaseBuilder +# --------------------------------------------------------------------------- + + +class TestSeriesReleaseBuilder: + def test_build_empty(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(84958)) + r = b.build() + assert r.tmdb_id == TmdbId(84958) + assert r.imdb_id is None + assert r.seasons == () + + def test_int_and_str_normalized(self): + b = SeriesReleaseBuilder(tmdb_id=84958, imdb_id="tt0804484") + r = b.build() + assert r.tmdb_id == TmdbId(84958) + assert r.imdb_id == ImdbId("tt0804484") + + def test_season_builder_create_requires_folder_and_mode(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(1)) + with pytest.raises(ValidationError): + b.season_builder(1) + with pytest.raises(ValidationError): + b.season_builder(1, folder="X") + with pytest.raises(ValidationError): + b.season_builder(1, mode=ReleaseMode.PACK) + + def test_season_builder_reuses_existing(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(1)) + sb1 = b.season_builder(1, folder="X", mode=ReleaseMode.PACK) + sb2 = b.season_builder(1) # no folder/mode — reuse + assert sb1 is sb2 + + def test_season_builder_can_override_folder_and_mode(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(1)) + b.season_builder(1, folder="X", mode=ReleaseMode.PACK) + b.season_builder(1, folder="Y", mode=ReleaseMode.EPISODIC) + season = b.build().seasons[0] + assert season.folder == "Y" + assert season.mode == ReleaseMode.EPISODIC + + def test_seasons_sorted_on_build(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(1)) + b.season_builder(2, folder="S02", mode=ReleaseMode.PACK) + b.season_builder(1, folder="S01", mode=ReleaseMode.PACK) + r = b.build() + nums = [s.season_number.value for s in r.seasons] + assert nums == [1, 2] + + def test_add_season_replaces(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(1)) + b.season_builder(1, folder="OLD", mode=ReleaseMode.PACK) + # Build a SeasonRelease via its own builder and attach via add_season: + replacement = SeasonReleaseBuilder( + 1, folder="NEW", mode=ReleaseMode.EPISODIC + ).build() + b.add_season(replacement) + season = b.build().seasons[0] + assert season.folder == "NEW" + assert season.mode == ReleaseMode.EPISODIC + + def test_set_imdb_id_string_normalized(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(1)) + b.set_imdb_id("tt0804484") + assert b.build().imdb_id == ImdbId("tt0804484") + + def test_set_imdb_id_none(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(1), imdb_id="tt0804484") + b.set_imdb_id(None) + assert b.build().imdb_id is None + + def test_from_existing_round_trip(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(84958), imdb_id="tt0804484") + sb = b.season_builder(1, folder="S01", mode=ReleaseMode.PACK) + sb.add_episode(_ep(1)).add_episode(_ep(2)) + original = b.build() + b2 = SeriesReleaseBuilder.from_existing(original) + rebuilt = b2.build() + assert rebuilt == original + + def test_from_existing_then_mutate(self): + b = SeriesReleaseBuilder(tmdb_id=TmdbId(1)) + sb = b.season_builder(1, folder="S01", mode=ReleaseMode.PACK) + sb.add_episode(_ep(1)) + original = b.build() + b2 = SeriesReleaseBuilder.from_existing(original) + b2.season_builder(2, folder="S02", mode=ReleaseMode.PACK).add_episode(_ep(1)) + rebuilt = b2.build() + assert len(rebuilt.seasons) == 2 diff --git a/tests/domain/releases/test_entities.py b/tests/domain/releases/test_entities.py new file mode 100644 index 0000000..f9dcb65 --- /dev/null +++ b/tests/domain/releases/test_entities.py @@ -0,0 +1,270 @@ +"""Tests for the releases domain entities.""" + +import dataclasses + +import pytest + +from alfred.domain.releases.entities import ( + EpisodeRelease, + MovieRelease, + SeasonRelease, + SeriesRelease, + TrackProfile, +) +from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode +from alfred.domain.shared.exceptions import ValidationError +from alfred.domain.shared.media import AudioTrack, SubtitleTrack +from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId +from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ep(start: int, end: int | None = None, *, file: str = "x.mkv") -> EpisodeRelease: + end_n = EpisodeNumber(end if end is not None else start) + return EpisodeRelease( + episodes=EpisodeRange(EpisodeNumber(start), end_n), + file_path=FilePath(file), + tracks=TrackProfile(), + ) + + +# --------------------------------------------------------------------------- +# TrackProfile +# --------------------------------------------------------------------------- + + +class TestTrackProfile: + def test_default_empty(self): + p = TrackProfile() + assert p.audio_tracks == () + assert p.subtitle_tracks == () + + def test_with_tracks(self): + a = AudioTrack(0, "eac3", 6, "5.1", "eng") + s = SubtitleTrack(0, "subrip", "eng", False, False) + p = TrackProfile(audio_tracks=(a,), subtitle_tracks=(s,)) + assert p.audio_tracks == (a,) + assert p.subtitle_tracks == (s,) + + def test_frozen(self): + p = TrackProfile() + with pytest.raises(dataclasses.FrozenInstanceError): + p.audio_tracks = () # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# EpisodeRelease +# --------------------------------------------------------------------------- + + +class TestEpisodeRelease: + def test_construction(self): + ep = _ep(1) + assert ep.episodes.start == EpisodeNumber(1) + assert ep.file_path == FilePath("x.mkv") + + def test_frozen(self): + ep = _ep(1) + with pytest.raises(dataclasses.FrozenInstanceError): + ep.file_path = FilePath("y.mkv") # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# SeasonRelease +# --------------------------------------------------------------------------- + + +class TestSeasonReleaseValidation: + def test_construction_pack(self): + s = SeasonRelease( + season_number=SeasonNumber(1), + folder="Show.S01", + mode=ReleaseMode.PACK, + episodes=(_ep(1), _ep(2)), + ) + assert s.season_number == SeasonNumber(1) + assert s.mode == ReleaseMode.PACK + + def test_construction_episodic(self): + s = SeasonRelease( + season_number=SeasonNumber(2), + folder="Show.S02", + mode=ReleaseMode.EPISODIC, + episodes=(), + ) + assert s.mode == ReleaseMode.EPISODIC + + def test_season_number_must_be_vo(self): + with pytest.raises(ValidationError): + SeasonRelease( + season_number=1, # type: ignore[arg-type] + folder="X", + mode=ReleaseMode.PACK, + ) + + def test_mode_must_be_enum(self): + with pytest.raises(ValidationError): + SeasonRelease( + season_number=SeasonNumber(1), + folder="X", + mode="pack", # type: ignore[arg-type] + ) + + def test_folder_must_be_non_empty(self): + with pytest.raises(ValidationError): + SeasonRelease( + season_number=SeasonNumber(1), + folder="", + mode=ReleaseMode.PACK, + ) + + +class TestSeasonReleaseEpisodeCount: + def test_zero_with_no_episodes(self): + s = SeasonRelease( + season_number=SeasonNumber(1), + folder="X", + mode=ReleaseMode.EPISODIC, + episodes=(), + ) + assert s.episode_count() == 0 + + def test_all_singles(self): + s = SeasonRelease( + season_number=SeasonNumber(1), + folder="X", + mode=ReleaseMode.PACK, + episodes=(_ep(1), _ep(2), _ep(3)), + ) + assert s.episode_count() == 3 + + def test_with_multi_episode_files(self): + # E01 + E02-E04 + E05 -> 1 + 3 + 1 = 5 + s = SeasonRelease( + season_number=SeasonNumber(1), + folder="X", + mode=ReleaseMode.PACK, + episodes=(_ep(1), _ep(2, 4), _ep(5)), + ) + assert s.episode_count() == 5 + + +# --------------------------------------------------------------------------- +# SeriesRelease +# --------------------------------------------------------------------------- + + +def _season(n: int, *eps_ranges: tuple[int, int]) -> SeasonRelease: + eps = tuple(_ep(s, e) for s, e in eps_ranges) + return SeasonRelease( + season_number=SeasonNumber(n), + folder=f"Show.S{n:02d}", + mode=ReleaseMode.PACK, + episodes=eps, + ) + + +class TestSeriesReleaseValidation: + def test_construction_minimal(self): + r = SeriesRelease( + tmdb_id=TmdbId(84958), + imdb_id=None, + seasons=(), + ) + assert r.tmdb_id == TmdbId(84958) + assert r.imdb_id is None + assert r.seasons == () + + def test_construction_with_imdb(self): + r = SeriesRelease( + tmdb_id=TmdbId(84958), + imdb_id=ImdbId("tt0804484"), + ) + assert r.imdb_id == ImdbId("tt0804484") + + def test_tmdb_id_must_be_vo(self): + with pytest.raises(ValidationError): + SeriesRelease(tmdb_id=84958, imdb_id=None) # type: ignore[arg-type] + + def test_imdb_id_wrong_type_raises(self): + with pytest.raises(ValidationError): + SeriesRelease( + tmdb_id=TmdbId(84958), + imdb_id="tt0804484", # type: ignore[arg-type] + ) + + def test_duplicate_season_raises(self): + with pytest.raises(ValidationError): + SeriesRelease( + tmdb_id=TmdbId(1), + imdb_id=None, + seasons=(_season(1, (1, 1)), _season(1, (2, 2))), + ) + + +class TestSeriesReleaseQueries: + def test_get_season_present(self): + r = SeriesRelease( + tmdb_id=TmdbId(1), + imdb_id=None, + seasons=(_season(1, (1, 1)), _season(2, (1, 1))), + ) + s = r.get_season(SeasonNumber(2)) + assert s is not None + assert s.season_number == SeasonNumber(2) + + def test_get_season_absent(self): + r = SeriesRelease( + tmdb_id=TmdbId(1), + imdb_id=None, + seasons=(_season(1, (1, 1)),), + ) + assert r.get_season(SeasonNumber(99)) is None + + +# --------------------------------------------------------------------------- +# MovieRelease +# --------------------------------------------------------------------------- + + +class TestMovieRelease: + def test_construction(self): + m = MovieRelease( + tmdb_id=TmdbId(27205), + imdb_id=ImdbId("tt1375666"), + folder="Inception.2010.1080p.BluRay.x264-GROUP", + file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"), + ) + assert m.tmdb_id == TmdbId(27205) + assert m.imdb_id == ImdbId("tt1375666") + + def test_optional_imdb(self): + m = MovieRelease( + tmdb_id=TmdbId(27205), + imdb_id=None, + folder="X", + file_path=FilePath("x.mkv"), + ) + assert m.imdb_id is None + + def test_tmdb_id_must_be_vo(self): + with pytest.raises(ValidationError): + MovieRelease( + tmdb_id=27205, # type: ignore[arg-type] + imdb_id=None, + folder="X", + file_path=FilePath("x.mkv"), + ) + + def test_folder_must_be_non_empty(self): + with pytest.raises(ValidationError): + MovieRelease( + tmdb_id=TmdbId(27205), + imdb_id=None, + folder="", + file_path=FilePath("x.mkv"), + ) diff --git a/tests/domain/releases/test_value_objects.py b/tests/domain/releases/test_value_objects.py new file mode 100644 index 0000000..6bca00e --- /dev/null +++ b/tests/domain/releases/test_value_objects.py @@ -0,0 +1,106 @@ +"""Tests for the releases domain value objects: EpisodeRange, ReleaseMode.""" + +import pytest + +from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode +from alfred.domain.shared.exceptions import ValidationError +from alfred.domain.tv_shows.value_objects import EpisodeNumber + + +# --------------------------------------------------------------------------- +# ReleaseMode +# --------------------------------------------------------------------------- + + +class TestReleaseMode: + def test_values(self): + assert ReleaseMode.PACK.value == "pack" + assert ReleaseMode.EPISODIC.value == "episodic" + + def test_str_subclass(self): + # ReleaseMode is a (str, Enum) — string equality should work. + assert ReleaseMode.PACK == "pack" + assert ReleaseMode.EPISODIC == "episodic" + + +# --------------------------------------------------------------------------- +# EpisodeRange +# --------------------------------------------------------------------------- + + +class TestEpisodeRangeConstruction: + def test_single_episode(self): + r = EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)) + assert r.start == EpisodeNumber(1) + assert r.end == EpisodeNumber(1) + + def test_multi_episode(self): + r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(4)) + assert r.start == EpisodeNumber(2) + assert r.end == EpisodeNumber(4) + + def test_end_before_start_raises(self): + with pytest.raises(ValidationError): + EpisodeRange(EpisodeNumber(5), EpisodeNumber(3)) + + def test_start_must_be_episode_number(self): + with pytest.raises(ValidationError): + EpisodeRange(1, EpisodeNumber(2)) # type: ignore[arg-type] + + def test_end_must_be_episode_number(self): + with pytest.raises(ValidationError): + EpisodeRange(EpisodeNumber(1), 2) # type: ignore[arg-type] + + +class TestEpisodeRangeOperations: + def test_count_single(self): + assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)).count() == 1 + + def test_count_multi(self): + assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(3)).count() == 3 + + def test_count_large(self): + assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(10)).count() == 10 + + def test_numbers_single(self): + r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(2)) + assert r.numbers() == (EpisodeNumber(2),) + + def test_numbers_multi(self): + r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(4)) + assert r.numbers() == ( + EpisodeNumber(2), + EpisodeNumber(3), + EpisodeNumber(4), + ) + + def test_is_single_true(self): + assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)).is_single() + + def test_is_single_false(self): + assert not EpisodeRange(EpisodeNumber(1), EpisodeNumber(2)).is_single() + + def test_str_single(self): + assert str(EpisodeRange(EpisodeNumber(2), EpisodeNumber(2))) == "E02" + + def test_str_multi(self): + assert str(EpisodeRange(EpisodeNumber(2), EpisodeNumber(4))) == "E02-E04" + + def test_repr(self): + r = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3)) + assert repr(r) == "EpisodeRange(1, 3)" + + def test_equality(self): + a = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3)) + b = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3)) + c = EpisodeRange(EpisodeNumber(1), EpisodeNumber(2)) + assert a == b + assert a != c + + def test_hashable(self): + ranges = { + EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)), + EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)), + EpisodeRange(EpisodeNumber(2), EpisodeNumber(3)), + } + assert len(ranges) == 2 diff --git a/tests/domain/test_shared_value_objects.py b/tests/domain/test_shared_value_objects.py index 63335a2..0dbf1aa 100644 --- a/tests/domain/test_shared_value_objects.py +++ b/tests/domain/test_shared_value_objects.py @@ -1,11 +1,11 @@ -"""Tests for shared domain value objects: ImdbId, FilePath, FileSize.""" +"""Tests for shared domain value objects: ImdbId, TmdbId, FilePath, FileSize.""" from pathlib import Path import pytest from alfred.domain.shared.exceptions import ValidationError -from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId +from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId, TmdbId # --------------------------------------------------------------------------- # ImdbId @@ -54,6 +54,53 @@ class TestImdbId: assert len(ids) == 2 +# --------------------------------------------------------------------------- +# TmdbId +# --------------------------------------------------------------------------- + + +class TestTmdbId: + def test_valid_int(self): + tid = TmdbId(84958) + assert tid.value == 84958 + assert str(tid) == "84958" + assert int(tid) == 84958 + + def test_zero_raises(self): + with pytest.raises(ValidationError): + TmdbId(0) + + def test_negative_raises(self): + with pytest.raises(ValidationError): + TmdbId(-1) + + def test_string_raises(self): + with pytest.raises(ValidationError): + TmdbId("84958") # type: ignore[arg-type] + + def test_float_raises(self): + with pytest.raises(ValidationError): + TmdbId(84958.0) # type: ignore[arg-type] + + def test_bool_raises(self): + # bool is a subclass of int — make sure we reject it explicitly. + with pytest.raises(ValidationError): + TmdbId(True) # type: ignore[arg-type] + with pytest.raises(ValidationError): + TmdbId(False) # type: ignore[arg-type] + + def test_repr(self): + assert repr(TmdbId(84958)) == "TmdbId(84958)" + + def test_equality(self): + assert TmdbId(84958) == TmdbId(84958) + assert TmdbId(84958) != TmdbId(12345) + + def test_hashable(self): + ids = {TmdbId(84958), TmdbId(12345), TmdbId(84958)} + assert len(ids) == 2 + + # --------------------------------------------------------------------------- # FilePath # ---------------------------------------------------------------------------