feat(releases): Phase 1 — new filesystem release domain + TmdbId VO

First step of specs/dot_alfred_v2.md. Introduces a separate bounded
context (alfred/domain/releases/) for the filesystem-side aggregates,
disjoint from TMDB identity which stays in tv_shows/ and movies/.
The link between the two worlds is TmdbId, used as the natural key
in the persistence layer (no domain-level reference).

New package alfred/domain/releases/:
- value_objects: EpisodeRange (covers SxxE01E02E03 multi-episode
  files via start/end inclusive range, with count/numbers/is_single
  helpers), ReleaseMode enum (PACK = N video files direct in the
  season folder, EPISODIC = N sub-folders).
- entities: TrackProfile, EpisodeRelease, SeasonRelease (with
  episode_count() summing each EpisodeRange.count()), SeriesRelease
  (tmdb_id primary anchor, optional imdb_id secondary), MovieRelease.
  All frozen dataclasses.
- builders: SeasonReleaseBuilder + SeriesReleaseBuilder mirroring
  the v1 TVShowBuilder pattern. Builders sort episodes by range
  start on emit and reject overlapping ranges (two files claiming
  the same TMDB slot). from_existing() seeds a builder from an
  existing frozen aggregate for round-trip edits.
- repositories: abstract ports (SeriesReleaseRepository,
  MovieReleaseRepository); concrete .alfred sidecar impls arrive
  in Phase 2.

New shared VO alfred/domain/shared/value_objects.py::TmdbId — positive
int, rejects bool/str/float, symmetric with the existing ImdbId VO.

73 unit tests cover VO validation, entity invariants, builder sort
+ overlap detection, and from_existing() round-trips.

v1 code paths are untouched at this stage; the new domain coexists
with the old TVShow aggregate until Phase 3 refactors it.
This commit is contained in:
2026-05-25 15:19:23 +02:00
parent de7030fa9c
commit c0f6d01048
12 changed files with 1316 additions and 2 deletions
+27
View File
@@ -17,6 +17,33 @@ callers).
### Added ### 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 - **`rescan_show` orchestrator
(`alfred/application/library/rescan.py`).** Step 4 of the (`alfred/application/library/rescan.py`).** Step 4 of the
`specs/dot_alfred.md` plan. Walks an Alfred-managed show folder, `specs/dot_alfred.md` plan. Walks an Alfred-managed show folder,
+38
View File
@@ -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",
]
+234
View File
@@ -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,
)
+204
View File
@@ -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}"
)
+79
View File
@@ -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."""
+89
View File
@@ -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})"
+36
View File
@@ -42,6 +42,42 @@ class ImdbId:
return f"ImdbId('{self.value}')" 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) @dataclass(frozen=True)
class FilePath: class FilePath:
""" """
View File
+184
View File
@@ -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
+270
View File
@@ -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"),
)
+106
View File
@@ -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
+49 -2
View File
@@ -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 from pathlib import Path
import pytest import pytest
from alfred.domain.shared.exceptions import ValidationError 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 # ImdbId
@@ -54,6 +54,53 @@ class TestImdbId:
assert len(ids) == 2 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 # FilePath
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------