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