refactor(tv_shows): freeze aggregate, builder-only construction, drop ShowTracker fields
The TVShow aggregate is now fully immutable. TVShow, Season and Episode are @dataclass(frozen=True), children stored as ordered tuples sorted by number. All construction goes through TVShowBuilder / SeasonBuilder (new module), which expose from_existing() to seed from a current frozen aggregate and apply modifications. ShowTracker-territory fields are stripped from the domain: ShowStatus, CollectionStatus, expected_seasons/episodes, aired_episodes, collection_status(), is_complete_series(), missing_episodes(), is_ongoing(), is_ended(), Season.name, the aired<=expected validation, and the TMDB status string mapping. These will reappear in a dedicated ShowTracker layer (to be designed) combining the .alfred sidecar with live TMDB data. New SeasonMode enum (PACK / EPISODIC) computed at read time from the season's structural shape — never stored, the YAML sidecar encodes the mode via presence/absence of the episodes: block. Test suite for the domain entirely rewritten to cover frozen invariants, builder ordering, last-write-wins, from_existing round-trip, and SeasonMode derivation. Full suite still green (1078 passed).
This commit is contained in:
@@ -17,6 +17,64 @@ callers).
|
||||
|
||||
### Added
|
||||
|
||||
- **`TVShowBuilder` / `SeasonBuilder` — sole construction surface for the
|
||||
TVShow aggregate** (`alfred/domain/tv_shows/builders.py`). The aggregate
|
||||
is now fully frozen; building goes through a mutable scratchpad that
|
||||
emits an immutable `TVShow` via `build()`. Both builders offer a
|
||||
`from_existing()` classmethod to seed from a current frozen aggregate
|
||||
and apply modifications. Episodes are emitted sorted by number within a
|
||||
season, seasons sorted by number within the show.
|
||||
- **`SeasonMode` enum** (`PACK` / `EPISODIC`) in
|
||||
`alfred/domain/tv_shows/value_objects.py`. Computed at read time from
|
||||
the season's structural shape (`Season.mode` property): a season with
|
||||
no explicit episodes is `PACK` (a single release covering the whole
|
||||
season), a season with episodes is `EPISODIC` (currently airing, one
|
||||
release per episode). Never stored — the YAML sidecar encodes the
|
||||
mode via the presence/absence of the `episodes:` block.
|
||||
|
||||
### Changed
|
||||
|
||||
- **TVShow aggregate is now frozen all the way down.** `TVShow`,
|
||||
`Season` and `Episode` are all `@dataclass(frozen=True)`. Children
|
||||
are stored as ordered tuples (`tuple[Season, ...]`,
|
||||
`tuple[Episode, ...]`) sorted by their respective numbers, replacing
|
||||
the previous mutable dicts. Lookup helpers `TVShow.get_season(n)` and
|
||||
`Season.get_episode(n)` traverse the tuple lazily via `next()`. The
|
||||
former `add_episode` / `add_season` mutation methods are gone — all
|
||||
construction goes through `TVShowBuilder` / `SeasonBuilder`.
|
||||
|
||||
### Removed
|
||||
|
||||
- **ShowTracker-territory fields stripped from the TVShow aggregate.**
|
||||
The aggregate now models only what the `.alfred` sidecar stores
|
||||
(filesystem-observable facts + immutable identity). Dropped from the
|
||||
domain:
|
||||
- `TVShow.status` (`ShowStatus`) and the `ShowStatus` enum entirely,
|
||||
along with its TMDB string mapping (`from_string`).
|
||||
- `TVShow.expected_seasons`, `Season.expected_episodes`,
|
||||
`Season.aired_episodes`, `Season.name`.
|
||||
- `TVShow.collection_status()`, `is_complete_series()`,
|
||||
`missing_episodes()`, `is_ongoing()`, `is_ended()` and the
|
||||
`CollectionStatus` enum.
|
||||
- `Season.is_complete()`, `is_fully_aired()`, `missing_episodes()`
|
||||
and the `aired ≤ expected` validation.
|
||||
- `TVShow.add_episode()` / `TVShow.add_season()` /
|
||||
`Season.add_episode()` — replaced by the builder API.
|
||||
These concerns will reappear in a dedicated `ShowTracker` layer (to
|
||||
be designed) that combines the `.alfred` sidecar with live TMDB data
|
||||
to answer questions like "is this show complete?" or "are new
|
||||
episodes out?". Keeping volatile/derived state out of the aggregate
|
||||
matches the factuel-only philosophy locked in `specs/dot_alfred.md`.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Test suite rewritten for the new aggregate shape.**
|
||||
`tests/domain/test_tv_shows.py` now covers frozen invariants, builder
|
||||
ordering, last-write-wins on duplicates, `from_existing` round-trip,
|
||||
and `SeasonMode` derivation. `tests/infrastructure/test_filesystem_extras.py`
|
||||
helper simplified (no more `ShowStatus.ENDED` / `expected_seasons` on
|
||||
test shows). 1078 tests still green.
|
||||
|
||||
- **Design doc for `.alfred/` sidecar persistence
|
||||
(`specs/dot_alfred.md`).** First entry in the new `specs/` directory.
|
||||
Specifies a per-show `.alfred/` directory holding a `show.yaml` and
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"""TV Shows domain - Business logic for TV show management."""
|
||||
|
||||
from .builders import SeasonBuilder, TVShowBuilder
|
||||
from .entities import Episode, Season, TVShow
|
||||
from .exceptions import InvalidEpisode, SeasonNotFound, TVShowNotFound
|
||||
from .value_objects import (
|
||||
CollectionStatus,
|
||||
EpisodeNumber,
|
||||
SeasonMode,
|
||||
SeasonNumber,
|
||||
ShowStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TVShow",
|
||||
"Season",
|
||||
"Episode",
|
||||
"ShowStatus",
|
||||
"CollectionStatus",
|
||||
"TVShowBuilder",
|
||||
"SeasonBuilder",
|
||||
"SeasonMode",
|
||||
"SeasonNumber",
|
||||
"EpisodeNumber",
|
||||
"TVShowNotFound",
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Builders for the TVShow aggregate.
|
||||
|
||||
The aggregate is fully frozen — :class:`TVShow` and :class:`Season` 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 = TVShowBuilder(
|
||||
imdb_id=ImdbId("tt0903747"),
|
||||
title="Breaking Bad",
|
||||
tmdb_id=1396,
|
||||
)
|
||||
builder.add_episode(Episode(
|
||||
season_number=SeasonNumber(1),
|
||||
episode_number=EpisodeNumber(1),
|
||||
title="Pilot",
|
||||
))
|
||||
builder.add_episode(Episode(
|
||||
season_number=SeasonNumber(1),
|
||||
episode_number=EpisodeNumber(2),
|
||||
title="Cat's in the Bag...",
|
||||
))
|
||||
show = builder.build() # frozen TVShow ready to circulate
|
||||
|
||||
To modify an existing frozen aggregate, use :meth:`TVShowBuilder.from_existing`
|
||||
to seed a builder from a current ``TVShow`` and apply changes::
|
||||
|
||||
builder = TVShowBuilder.from_existing(show)
|
||||
builder.add_episode(new_ep)
|
||||
updated = builder.build()
|
||||
|
||||
Builders are **single-use scratchpads**: they hold mutable state during
|
||||
construction, then produce an immutable aggregate. Reusing a builder after
|
||||
``build()`` is allowed but its state continues to evolve independently of
|
||||
the emitted TVShow.
|
||||
|
||||
Invariants enforced at ``build()`` time:
|
||||
|
||||
* Seasons are emitted sorted by ``season_number``.
|
||||
* Episodes within each season are emitted sorted by ``episode_number``.
|
||||
* No duplicate season numbers (last-write-wins on the season itself).
|
||||
* No duplicate episode numbers within a season (last-write-wins on the
|
||||
episode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..shared.value_objects import ImdbId
|
||||
from .entities import Episode, Season, TVShow
|
||||
from .value_objects import EpisodeNumber, SeasonNumber
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# SeasonBuilder
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class SeasonBuilder:
|
||||
"""
|
||||
Mutable scratchpad for a :class:`Season`.
|
||||
|
||||
Episodes are stored in an internal dict keyed by :class:`EpisodeNumber`,
|
||||
allowing last-write-wins replacement on a duplicate insert. ``build()``
|
||||
emits a frozen ``Season`` with episodes sorted by number.
|
||||
"""
|
||||
|
||||
def __init__(self, season_number: SeasonNumber | int) -> None:
|
||||
if isinstance(season_number, int):
|
||||
season_number = SeasonNumber(season_number)
|
||||
self._season_number: SeasonNumber = season_number
|
||||
self._episodes: dict[EpisodeNumber, Episode] = {}
|
||||
|
||||
@classmethod
|
||||
def from_existing(cls, season: Season) -> SeasonBuilder:
|
||||
"""Seed a builder from an existing frozen :class:`Season`."""
|
||||
builder = cls(season.season_number)
|
||||
for ep in season.episodes:
|
||||
builder._episodes[ep.episode_number] = ep
|
||||
return builder
|
||||
|
||||
@property
|
||||
def season_number(self) -> SeasonNumber:
|
||||
return self._season_number
|
||||
|
||||
def add_episode(self, episode: Episode) -> SeasonBuilder:
|
||||
"""
|
||||
Add or replace an episode in this season.
|
||||
|
||||
Raises ``ValueError`` if the episode's season number does not match
|
||||
this builder's season number — episodes carry their season number
|
||||
for invariant checks during aggregation.
|
||||
"""
|
||||
if episode.season_number != self._season_number:
|
||||
raise ValueError(
|
||||
f"Episode season ({episode.season_number}) does not match "
|
||||
f"season ({self._season_number})"
|
||||
)
|
||||
self._episodes[episode.episode_number] = episode
|
||||
return self
|
||||
|
||||
def build(self) -> Season:
|
||||
"""Emit a frozen :class:`Season` with episodes sorted by number."""
|
||||
ordered = tuple(
|
||||
self._episodes[n] for n in sorted(self._episodes, key=lambda x: x.value)
|
||||
)
|
||||
return Season(season_number=self._season_number, episodes=ordered)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# TVShowBuilder
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TVShowBuilder:
|
||||
"""
|
||||
Mutable scratchpad for the :class:`TVShow` aggregate root.
|
||||
|
||||
Seasons are tracked via internal :class:`SeasonBuilder` instances keyed
|
||||
by :class:`SeasonNumber`. Adding an episode auto-creates the season
|
||||
builder if absent — matching the previous ``TVShow.add_episode``
|
||||
convenience without compromising immutability of the emitted aggregate.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
imdb_id: ImdbId | str,
|
||||
title: str,
|
||||
tmdb_id: int | None = None,
|
||||
) -> None:
|
||||
if isinstance(imdb_id, str):
|
||||
imdb_id = ImdbId(imdb_id)
|
||||
self._imdb_id: ImdbId = imdb_id
|
||||
self._title: str = title
|
||||
self._tmdb_id: int | None = tmdb_id
|
||||
self._season_builders: dict[SeasonNumber, SeasonBuilder] = {}
|
||||
|
||||
@classmethod
|
||||
def from_existing(cls, show: TVShow) -> TVShowBuilder:
|
||||
"""Seed a builder from an existing frozen :class:`TVShow`."""
|
||||
builder = cls(
|
||||
imdb_id=show.imdb_id,
|
||||
title=show.title,
|
||||
tmdb_id=show.tmdb_id,
|
||||
)
|
||||
for season in show.seasons:
|
||||
builder._season_builders[season.season_number] = SeasonBuilder.from_existing(
|
||||
season
|
||||
)
|
||||
return builder
|
||||
|
||||
# ── Top-level mutators ─────────────────────────────────────────────────
|
||||
|
||||
def set_title(self, title: str) -> TVShowBuilder:
|
||||
self._title = title
|
||||
return self
|
||||
|
||||
def set_tmdb_id(self, tmdb_id: int | None) -> TVShowBuilder:
|
||||
self._tmdb_id = tmdb_id
|
||||
return self
|
||||
|
||||
# ── Content ────────────────────────────────────────────────────────────
|
||||
|
||||
def add_episode(self, episode: Episode) -> TVShowBuilder:
|
||||
"""
|
||||
Add or replace an episode in the appropriate season.
|
||||
|
||||
Auto-creates a :class:`SeasonBuilder` for the season if needed.
|
||||
Last-write-wins on duplicate ``(season, episode)`` numbers.
|
||||
"""
|
||||
sb = self._season_builders.get(episode.season_number)
|
||||
if sb is None:
|
||||
sb = SeasonBuilder(episode.season_number)
|
||||
self._season_builders[episode.season_number] = sb
|
||||
sb.add_episode(episode)
|
||||
return self
|
||||
|
||||
def add_season(self, season: Season) -> TVShowBuilder:
|
||||
"""
|
||||
Attach (or replace) a fully-built :class:`Season`.
|
||||
|
||||
The season is re-wrapped in a :class:`SeasonBuilder` internally so
|
||||
subsequent ``add_episode`` calls remain coherent. Replaces any
|
||||
existing season with the same number.
|
||||
"""
|
||||
self._season_builders[season.season_number] = SeasonBuilder.from_existing(
|
||||
season
|
||||
)
|
||||
return self
|
||||
|
||||
def season_builder(
|
||||
self, season_number: SeasonNumber | int
|
||||
) -> SeasonBuilder:
|
||||
"""
|
||||
Return (creating if needed) the :class:`SeasonBuilder` for a given
|
||||
season number. Useful when assembling a season with many tweaks.
|
||||
"""
|
||||
if isinstance(season_number, int):
|
||||
season_number = SeasonNumber(season_number)
|
||||
sb = self._season_builders.get(season_number)
|
||||
if sb is None:
|
||||
sb = SeasonBuilder(season_number)
|
||||
self._season_builders[season_number] = sb
|
||||
return sb
|
||||
|
||||
# ── Emit ───────────────────────────────────────────────────────────────
|
||||
|
||||
def build(self) -> TVShow:
|
||||
"""Emit a frozen :class:`TVShow` 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 TVShow(
|
||||
imdb_id=self._imdb_id,
|
||||
title=self._title,
|
||||
seasons=ordered_seasons,
|
||||
tmdb_id=self._tmdb_id,
|
||||
)
|
||||
@@ -5,9 +5,9 @@ This module implements the TVShow aggregate following DDD principles.
|
||||
Aggregate ownership::
|
||||
|
||||
TVShow ← aggregate root (the repo returns this)
|
||||
└── seasons: dict[SeasonNumber, Season]
|
||||
└── seasons: tuple[Season, ...]
|
||||
└── Season
|
||||
└── episodes: dict[EpisodeNumber, Episode]
|
||||
└── episodes: tuple[Episode, ...]
|
||||
└── Episode ← file metadata + audio/subtitle tracks
|
||||
|
||||
Rules:
|
||||
@@ -17,10 +17,18 @@ Rules:
|
||||
* ``Season`` is owned by TVShow. ``Episode`` is owned by Season.
|
||||
* Children do not back-reference the root (no ``show_imdb_id`` on
|
||||
Season/Episode): they are only ever reached *through* TVShow.
|
||||
* Mutation invariants are enforced through aggregate-root methods such as
|
||||
``TVShow.add_episode()`` — never reach into ``show.seasons[...].episodes``
|
||||
to mutate without going through the root, otherwise invariants are not
|
||||
guaranteed.
|
||||
* The aggregate is **frozen all the way down**. Mutation happens exclusively
|
||||
through :class:`TVShowBuilder` (see ``builders.py``), which produces a new
|
||||
``TVShow`` via ``build()``. There is no ``add_episode`` / ``add_season``
|
||||
on entities anymore.
|
||||
|
||||
Scope (post-2026-05-22 refactor):
|
||||
|
||||
* The entities model only what the ``.alfred`` sidecar stores — facts
|
||||
observable on disk plus identity (``imdb_id`` / ``tmdb_id``).
|
||||
* Volatile / TMDB-derived information (production status, expected vs aired
|
||||
counts, collection completeness) lives in a separate ``ShowTracker``
|
||||
layer to be designed; the aggregate carries none of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,10 +44,9 @@ from ..shared.value_objects import (
|
||||
to_dot_folder_name,
|
||||
)
|
||||
from .value_objects import (
|
||||
CollectionStatus,
|
||||
EpisodeNumber,
|
||||
SeasonMode,
|
||||
SeasonNumber,
|
||||
ShowStatus,
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -58,7 +65,8 @@ class Episode(MediaWithTracks):
|
||||
scanned, or when no file is downloaded yet.
|
||||
|
||||
Frozen: rebuild via ``dataclasses.replace`` to project enrichment results
|
||||
onto a new instance.
|
||||
onto a new instance, or use :class:`TVShowBuilder` to replace inside the
|
||||
aggregate.
|
||||
|
||||
Equality is identity-based within the aggregate: two ``Episode`` instances
|
||||
are equal iff they share the same ``(season_number, episode_number)``,
|
||||
@@ -125,50 +133,29 @@ class Episode(MediaWithTracks):
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class Season:
|
||||
"""
|
||||
A season of a TV show — owned by ``TVShow``.
|
||||
A season of a TV show — owned by ``TVShow``, frozen value.
|
||||
|
||||
Owns its episodes via the ``episodes`` dict keyed by ``EpisodeNumber``.
|
||||
Owns its episodes as an ordered tuple keyed by ``EpisodeNumber``.
|
||||
The tuple is sorted by episode number at build time (guaranteed by
|
||||
:class:`SeasonBuilder`).
|
||||
|
||||
Two TMDB-sourced counts shape the collection logic:
|
||||
|
||||
* ``expected_episodes`` — total episodes planned for the season
|
||||
(``None`` if unknown).
|
||||
* ``aired_episodes`` — episodes **already aired** as of the latest TMDB
|
||||
refresh. ``None`` falls back to ``expected_episodes`` (best-effort).
|
||||
|
||||
The split matters: ``is_complete()`` checks owned against aired, so a season
|
||||
in the middle of broadcasting can be "complete" today and become "partial"
|
||||
later when new episodes air — that is correct behavior.
|
||||
The presence or absence of episodes also encodes the
|
||||
:class:`SeasonMode` (see ``mode`` property): a season with no episodes
|
||||
is a PACK (single release covering the whole season), a season with
|
||||
episodes is EPISODIC (currently airing, one release per episode).
|
||||
"""
|
||||
|
||||
season_number: SeasonNumber
|
||||
episodes: dict[EpisodeNumber, Episode] = field(default_factory=dict)
|
||||
expected_episodes: int | None = None
|
||||
aired_episodes: int | None = None
|
||||
name: str | None = None
|
||||
episodes: tuple[Episode, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.season_number, SeasonNumber):
|
||||
if isinstance(self.season_number, int):
|
||||
self.season_number = SeasonNumber(self.season_number)
|
||||
|
||||
if self.expected_episodes is not None and self.expected_episodes < 0:
|
||||
raise ValueError(
|
||||
f"expected_episodes must be >= 0, got {self.expected_episodes}"
|
||||
)
|
||||
if self.aired_episodes is not None and self.aired_episodes < 0:
|
||||
raise ValueError(f"aired_episodes must be >= 0, got {self.aired_episodes}")
|
||||
if (
|
||||
self.expected_episodes is not None
|
||||
and self.aired_episodes is not None
|
||||
and self.aired_episodes > self.expected_episodes
|
||||
):
|
||||
raise ValueError(
|
||||
f"aired_episodes ({self.aired_episodes}) cannot exceed "
|
||||
f"expected_episodes ({self.expected_episodes})"
|
||||
object.__setattr__(
|
||||
self, "season_number", SeasonNumber(self.season_number)
|
||||
)
|
||||
|
||||
# ── Properties ─────────────────────────────────────────────────────────
|
||||
@@ -178,65 +165,32 @@ class Season:
|
||||
"""Number of episodes currently owned in this season."""
|
||||
return len(self.episodes)
|
||||
|
||||
# ── Collection state ───────────────────────────────────────────────────
|
||||
@property
|
||||
def mode(self) -> SeasonMode:
|
||||
"""
|
||||
Storage mode of this season.
|
||||
|
||||
def _effective_aired(self) -> int | None:
|
||||
"""``aired_episodes`` if set, else fall back to ``expected_episodes``."""
|
||||
return (
|
||||
self.aired_episodes
|
||||
if self.aired_episodes is not None
|
||||
else self.expected_episodes
|
||||
Derived from the structural shape: ``PACK`` when no episode is
|
||||
explicitly modeled (the season is a single release), ``EPISODIC``
|
||||
when episodes are present (one release per episode).
|
||||
"""
|
||||
return SeasonMode.EPISODIC if self.episodes else SeasonMode.PACK
|
||||
|
||||
# ── Episode access ─────────────────────────────────────────────────────
|
||||
|
||||
def get_episode(self, number: EpisodeNumber) -> Episode | None:
|
||||
"""
|
||||
Return the episode with the given number, or ``None`` if absent.
|
||||
|
||||
O(n) traversal — fine for season-sized collections (typically ≤ 25
|
||||
episodes). The aggregate guarantees uniqueness of episode numbers
|
||||
within a season.
|
||||
"""
|
||||
return next(
|
||||
(ep for ep in self.episodes if ep.episode_number == number),
|
||||
None,
|
||||
)
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""
|
||||
True if every aired episode is owned.
|
||||
|
||||
Returns False (conservative) when the aired count is unknown — without
|
||||
knowing how many episodes have aired we cannot claim completeness.
|
||||
"""
|
||||
aired = self._effective_aired()
|
||||
if aired is None:
|
||||
return False
|
||||
if aired == 0:
|
||||
# No episode has aired yet → trivially "complete"
|
||||
return True
|
||||
return len(self.episodes) >= aired
|
||||
|
||||
def is_fully_aired(self) -> bool:
|
||||
"""True if all planned episodes have already aired."""
|
||||
if self.expected_episodes is None or self.aired_episodes is None:
|
||||
return False
|
||||
return self.aired_episodes >= self.expected_episodes
|
||||
|
||||
def missing_episodes(self) -> list[EpisodeNumber]:
|
||||
"""
|
||||
List of episode numbers that have aired but are not owned.
|
||||
|
||||
Episodes beyond ``aired_episodes`` are **not** considered missing
|
||||
(they have not aired yet). When the aired count is unknown, returns
|
||||
an empty list — we cannot reason about gaps without a target.
|
||||
"""
|
||||
aired = self._effective_aired()
|
||||
if aired is None or aired <= 0:
|
||||
return []
|
||||
present = {ep.value for ep in self.episodes}
|
||||
return [EpisodeNumber(n) for n in range(1, aired + 1) if n not in present]
|
||||
|
||||
# ── Mutation (called through the aggregate root) ───────────────────────
|
||||
|
||||
def add_episode(self, episode: Episode) -> None:
|
||||
"""
|
||||
Insert an episode into this season. Replaces any episode with the same
|
||||
number — callers wishing to detect conflicts should check beforehand.
|
||||
"""
|
||||
if episode.season_number != self.season_number:
|
||||
raise ValueError(
|
||||
f"Episode season ({episode.season_number}) does not match season "
|
||||
f"({self.season_number})"
|
||||
)
|
||||
self.episodes[episode.episode_number] = episode
|
||||
|
||||
# ── Naming ─────────────────────────────────────────────────────────────
|
||||
|
||||
def is_special(self) -> bool:
|
||||
@@ -249,8 +203,6 @@ class Season:
|
||||
return f"Season {self.season_number.value:02d}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.name:
|
||||
return f"Season {self.season_number.value}: {self.name}"
|
||||
return f"Season {self.season_number.value}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -264,62 +216,35 @@ class Season:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class TVShow:
|
||||
"""
|
||||
Aggregate root for the TV shows domain.
|
||||
Aggregate root for the TV shows domain — frozen value.
|
||||
|
||||
Owns its seasons via the ``seasons`` dict keyed by ``SeasonNumber``.
|
||||
All mutations (adding episodes, creating seasons) MUST go through the
|
||||
methods on this class — that is how invariants are preserved.
|
||||
Owns its seasons as an ordered tuple keyed by ``SeasonNumber``. The
|
||||
tuple is sorted by season number at build time (guaranteed by
|
||||
:class:`TVShowBuilder`).
|
||||
|
||||
Two axes describe the show, kept deliberately orthogonal:
|
||||
|
||||
* ``status`` (``ShowStatus``) — production state (TMDB-sourced).
|
||||
* ``collection_status()`` — what the user owns vs what has aired today.
|
||||
|
||||
A third axis (upcoming/scheduled) will be added later as a separate flag
|
||||
when scheduling support is introduced; for now we make no claim about
|
||||
future episodes.
|
||||
Identity is carried by ``imdb_id`` and ``tmdb_id``. The ``title`` here
|
||||
is the human-readable label used when generating folder names; the
|
||||
canonical title (and any other volatile metadata) lives on TMDB and is
|
||||
re-fetched by the ``ShowTracker`` when needed.
|
||||
"""
|
||||
|
||||
imdb_id: ImdbId
|
||||
title: str
|
||||
status: ShowStatus
|
||||
seasons: dict[SeasonNumber, Season] = field(default_factory=dict)
|
||||
expected_seasons: int | None = None
|
||||
seasons: tuple[Season, ...] = ()
|
||||
tmdb_id: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.imdb_id, ImdbId):
|
||||
if isinstance(self.imdb_id, str):
|
||||
self.imdb_id = ImdbId(self.imdb_id)
|
||||
object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id))
|
||||
else:
|
||||
raise ValueError(
|
||||
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
|
||||
)
|
||||
|
||||
if not isinstance(self.status, ShowStatus):
|
||||
if isinstance(self.status, str):
|
||||
self.status = ShowStatus.from_string(self.status)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"status must be ShowStatus or str, got {type(self.status)}"
|
||||
)
|
||||
|
||||
if self.expected_seasons is not None and self.expected_seasons < 0:
|
||||
raise ValueError(
|
||||
f"expected_seasons must be >= 0, got {self.expected_seasons}"
|
||||
)
|
||||
|
||||
# ── Production-state queries ───────────────────────────────────────────
|
||||
|
||||
def is_ongoing(self) -> bool:
|
||||
return self.status == ShowStatus.ONGOING
|
||||
|
||||
def is_ended(self) -> bool:
|
||||
return self.status == ShowStatus.ENDED
|
||||
|
||||
# ── Properties ─────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
@@ -330,83 +255,22 @@ class TVShow:
|
||||
@property
|
||||
def episode_count(self) -> int:
|
||||
"""Total episodes owned across all seasons."""
|
||||
return sum(s.episode_count for s in self.seasons.values())
|
||||
return sum(s.episode_count for s in self.seasons)
|
||||
|
||||
# ── Mutation — the sole entry point for adding content ─────────────────
|
||||
# ── Season access ──────────────────────────────────────────────────────
|
||||
|
||||
def add_episode(self, episode: Episode) -> None:
|
||||
def get_season(self, number: SeasonNumber) -> Season | None:
|
||||
"""
|
||||
Add an episode to the appropriate season, creating the season if needed.
|
||||
Return the season with the given number, or ``None`` if absent.
|
||||
|
||||
This is the **only** sanctioned way to add content to the aggregate —
|
||||
it preserves the invariant that an episode is always reachable through
|
||||
``show.seasons[s].episodes[e]``.
|
||||
O(n) traversal — fine for show-sized collections (typically ≤ 30
|
||||
seasons). The aggregate guarantees uniqueness of season numbers
|
||||
within a show.
|
||||
"""
|
||||
season = self.seasons.get(episode.season_number)
|
||||
if season is None:
|
||||
season = Season(season_number=episode.season_number)
|
||||
self.seasons[episode.season_number] = season
|
||||
season.add_episode(episode)
|
||||
|
||||
def add_season(self, season: Season) -> None:
|
||||
"""
|
||||
Attach a (possibly already populated) Season to the show.
|
||||
|
||||
Replaces any existing season with the same number.
|
||||
"""
|
||||
self.seasons[season.season_number] = season
|
||||
|
||||
# ── Collection state ───────────────────────────────────────────────────
|
||||
|
||||
def collection_status(self) -> CollectionStatus:
|
||||
"""
|
||||
High-level state of the user's collection for this show.
|
||||
|
||||
* ``EMPTY`` — no episode owned
|
||||
* ``COMPLETE`` — every season is complete relative to its aired count
|
||||
* ``PARTIAL`` — at least one aired episode is missing
|
||||
|
||||
Seasons with an unknown aired count are treated conservatively: if no
|
||||
season has any episode, the show is EMPTY; otherwise the unknown
|
||||
seasons cannot prove completeness, so the show is PARTIAL.
|
||||
"""
|
||||
if self.episode_count == 0:
|
||||
return CollectionStatus.EMPTY
|
||||
|
||||
# Check completeness across all seasons we know about
|
||||
for season in self.seasons.values():
|
||||
if not season.is_complete():
|
||||
return CollectionStatus.PARTIAL
|
||||
|
||||
# We also need to consider whether seasons themselves are missing.
|
||||
# If expected_seasons is known and we have fewer seasons than expected,
|
||||
# the missing seasons may have aired episodes → cannot claim COMPLETE.
|
||||
if (
|
||||
self.expected_seasons is not None
|
||||
and len(self.seasons) < self.expected_seasons
|
||||
):
|
||||
return CollectionStatus.PARTIAL
|
||||
|
||||
return CollectionStatus.COMPLETE
|
||||
|
||||
def is_complete_series(self) -> bool:
|
||||
"""
|
||||
True if the show is finished (ENDED) **and** the collection is complete.
|
||||
|
||||
This is the strongest "I own the entire series, no more to come" claim
|
||||
we can make today, before scheduling/upcoming-episode awareness lands.
|
||||
"""
|
||||
return self.is_ended() and self.collection_status() == CollectionStatus.COMPLETE
|
||||
|
||||
def missing_episodes(self) -> list[tuple[SeasonNumber, EpisodeNumber]]:
|
||||
"""All aired-but-not-owned ``(season, episode)`` pairs across the show."""
|
||||
result: list[tuple[SeasonNumber, EpisodeNumber]] = []
|
||||
for season_number, season in sorted(
|
||||
self.seasons.items(), key=lambda kv: kv[0].value
|
||||
):
|
||||
for ep_number in season.missing_episodes():
|
||||
result.append((season_number, ep_number))
|
||||
return result
|
||||
return next(
|
||||
(s for s in self.seasons if s.season_number == number),
|
||||
None,
|
||||
)
|
||||
|
||||
# ── Naming ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -415,7 +279,7 @@ class TVShow:
|
||||
return to_dot_folder_name(self.title)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} ({self.status.value}, {self.seasons_count} seasons)"
|
||||
return f"{self.title} ({self.seasons_count} seasons)"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TVShow(imdb_id={self.imdb_id}, title='{self.title}')"
|
||||
|
||||
@@ -8,49 +8,27 @@ from enum import Enum
|
||||
from ..shared.exceptions import ValidationError
|
||||
|
||||
|
||||
class ShowStatus(Enum):
|
||||
class SeasonMode(Enum):
|
||||
"""
|
||||
Production status of a TV show (real-world, source of truth = TMDB).
|
||||
Storage mode of a season on disk.
|
||||
|
||||
Describes the **production** state of the show, independently of what
|
||||
the user owns. Orthogonal to ``CollectionStatus``.
|
||||
Derived from the structural shape of the ``Season``:
|
||||
|
||||
* ``PACK`` — the season was downloaded as a complete pack. Technical
|
||||
fields (group, source, codec, quality, audio, subtitles) live on the
|
||||
``Season`` itself, episodes carry no technical metadata.
|
||||
* ``EPISODIC`` — the season is being assembled episode by episode (show
|
||||
currently airing). Technical fields live on each ``Episode``; the
|
||||
season-level fields are unset (the release group of individual episodes
|
||||
may vary).
|
||||
|
||||
Computed at read time from ``len(season.episodes) > 0`` — never stored
|
||||
explicitly. A season that becomes complete is rewritten by the ingestion
|
||||
pipeline into PACK form (episodes flattened away).
|
||||
"""
|
||||
|
||||
ONGOING = "ongoing"
|
||||
ENDED = "ended"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, status_str: str) -> ShowStatus:
|
||||
"""
|
||||
Parse a production status string into a ShowStatus.
|
||||
|
||||
Accepts our internal vocabulary ("ongoing", "ended") as well as the
|
||||
statuses returned by TMDB ("Returning Series", "In Production",
|
||||
"Pilot", "Ended", "Canceled"). The mapping is intentionally binary:
|
||||
|
||||
* ONGOING — any state where new episodes may still ship
|
||||
* ENDED — production has stopped (naturally or cancelled)
|
||||
* UNKNOWN — anything else / unrecognized
|
||||
|
||||
Comparison is case-insensitive and whitespace-trimmed.
|
||||
"""
|
||||
if not status_str:
|
||||
return cls.UNKNOWN
|
||||
key = status_str.strip().lower()
|
||||
status_map = {
|
||||
# Internal
|
||||
"ongoing": cls.ONGOING,
|
||||
"ended": cls.ENDED,
|
||||
# TMDB
|
||||
"returning series": cls.ONGOING,
|
||||
"in production": cls.ONGOING,
|
||||
"pilot": cls.ONGOING,
|
||||
"planned": cls.ONGOING,
|
||||
"canceled": cls.ENDED,
|
||||
"cancelled": cls.ENDED,
|
||||
}
|
||||
return status_map.get(key, cls.UNKNOWN)
|
||||
PACK = "pack"
|
||||
EPISODIC = "episodic"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -92,23 +70,6 @@ class SeasonNumber:
|
||||
return self.value
|
||||
|
||||
|
||||
class CollectionStatus(Enum):
|
||||
"""
|
||||
State of the user's **collection** for a TV show (orthogonal to ShowStatus).
|
||||
|
||||
Compares possessed episodes against episodes **already aired** — never
|
||||
against announced/upcoming ones. A returning show with all aired episodes
|
||||
owned is ``COMPLETE``, not ``PARTIAL``, even if more seasons are upcoming.
|
||||
|
||||
Future scheduling info (upcoming seasons, next airing date) will live on
|
||||
the TVShow aggregate as separate flags, not in this enum.
|
||||
"""
|
||||
|
||||
EMPTY = "empty" # 0 episode owned
|
||||
PARTIAL = "partial" # some aired episodes are missing
|
||||
COMPLETE = "complete" # all aired-to-date episodes are owned
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EpisodeNumber:
|
||||
"""
|
||||
|
||||
+290
-211
@@ -1,76 +1,45 @@
|
||||
"""Tests for the TV Show domain — entities, value objects, aggregate behavior.
|
||||
"""Tests for the TV Show domain — entities, value objects, builders.
|
||||
|
||||
Rewritten for the post-refactor aggregate:
|
||||
Post-2026-05-22 refactor:
|
||||
|
||||
* ``TVShow`` is the root, owning ``seasons: dict[SeasonNumber, Season]``.
|
||||
* ``Season`` owns ``episodes: dict[EpisodeNumber, Episode]`` and tracks
|
||||
``expected_episodes`` + ``aired_episodes``.
|
||||
* ``Episode`` carries ``audio_tracks`` + ``subtitle_tracks`` and exposes
|
||||
language helpers following contract C+ (``str`` direct compare, ``Language``
|
||||
cross-format).
|
||||
* No back-references on Season/Episode — they are reached through the root.
|
||||
* Sole sanctioned mutation entry point: ``TVShow.add_episode(ep)``.
|
||||
* The aggregate is **frozen all the way** — ``TVShow``, ``Season`` and
|
||||
``Episode`` are all ``@dataclass(frozen=True)``. Children are stored as
|
||||
ordered tuples sorted by number.
|
||||
* Construction goes exclusively through :class:`TVShowBuilder` (and its
|
||||
helper :class:`SeasonBuilder`). No more ``add_episode`` / ``add_season``
|
||||
on entities.
|
||||
* ShowTracker-territory fields (production status, expected vs aired
|
||||
counts, collection completeness) are removed from the domain. The
|
||||
aggregate carries only what the ``.alfred`` sidecar stores.
|
||||
|
||||
Coverage:
|
||||
|
||||
* ``TestShowStatus`` — including the extended TMDB string mapping.
|
||||
* ``TestSeasonNumber`` / ``TestEpisodeNumber`` — value-object validation.
|
||||
* ``TestSeasonMode`` — enum sanity (computed in ``TestSeason``).
|
||||
* ``TestEpisode`` — basic shape, file presence, audio/subtitle helpers.
|
||||
* ``TestSeason`` — episode insertion, completeness vs aired, missing list.
|
||||
* ``TestTVShow`` — aggregate invariants, ``add_episode``, ``collection_status``,
|
||||
``missing_episodes``, ``is_complete_series``.
|
||||
* ``TestSeason`` — frozen shape, episode lookup, mode derivation.
|
||||
* ``TestTVShow`` — frozen aggregate root, season lookup, counts.
|
||||
* ``TestSeasonBuilder`` / ``TestTVShowBuilder`` — sole sanctioned mutation
|
||||
surface; ordering, last-write-wins, ``from_existing`` round-trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.shared.exceptions import ValidationError
|
||||
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
|
||||
from alfred.domain.shared.value_objects import ImdbId, Language
|
||||
from alfred.domain.tv_shows.builders import SeasonBuilder, TVShowBuilder
|
||||
from alfred.domain.tv_shows.entities import Episode, Season, TVShow
|
||||
from alfred.domain.tv_shows.value_objects import (
|
||||
CollectionStatus,
|
||||
EpisodeNumber,
|
||||
SeasonMode,
|
||||
SeasonNumber,
|
||||
ShowStatus,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ShowStatus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShowStatus:
|
||||
def test_from_string_ongoing(self):
|
||||
assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING
|
||||
|
||||
def test_from_string_ended(self):
|
||||
assert ShowStatus.from_string("ended") == ShowStatus.ENDED
|
||||
|
||||
def test_from_string_case_insensitive(self):
|
||||
assert ShowStatus.from_string("ONGOING") == ShowStatus.ONGOING
|
||||
assert ShowStatus.from_string(" Ended ") == ShowStatus.ENDED
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("Returning Series", ShowStatus.ONGOING),
|
||||
("In Production", ShowStatus.ONGOING),
|
||||
("Pilot", ShowStatus.ONGOING),
|
||||
("Planned", ShowStatus.ONGOING),
|
||||
("Canceled", ShowStatus.ENDED),
|
||||
("Cancelled", ShowStatus.ENDED),
|
||||
],
|
||||
)
|
||||
def test_from_string_tmdb_mappings(self, raw, expected):
|
||||
assert ShowStatus.from_string(raw) == expected
|
||||
|
||||
def test_from_string_empty_or_unknown(self):
|
||||
assert ShowStatus.from_string("") == ShowStatus.UNKNOWN
|
||||
assert ShowStatus.from_string("borked") == ShowStatus.UNKNOWN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SeasonNumber
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -131,6 +100,17 @@ class TestEpisodeNumber:
|
||||
assert int(e) == 12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SeasonMode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSeasonMode:
|
||||
def test_values(self):
|
||||
assert SeasonMode.PACK.value == "pack"
|
||||
assert SeasonMode.EPISODIC.value == "episodic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Episode entity
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -151,6 +131,11 @@ class TestEpisode:
|
||||
assert isinstance(e.season_number, SeasonNumber)
|
||||
assert isinstance(e.episode_number, EpisodeNumber)
|
||||
|
||||
def test_is_frozen(self):
|
||||
e = self._ep()
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
e.title = "Other" # type: ignore[misc]
|
||||
|
||||
def test_get_filename_format(self):
|
||||
e = self._ep(season=1, episode=5, title="Gray Matter")
|
||||
filename = e.get_filename()
|
||||
@@ -171,10 +156,10 @@ class TestEpisode:
|
||||
|
||||
def test_has_audio_in_with_str(self):
|
||||
e = self._ep(
|
||||
audio_tracks=[
|
||||
audio_tracks=(
|
||||
AudioTrack(0, "eac3", 6, "5.1", "eng"),
|
||||
AudioTrack(1, "ac3", 6, "5.1", "fre"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert e.has_audio_in("eng") is True
|
||||
assert e.has_audio_in("ENG") is True # case-insensitive
|
||||
@@ -187,49 +172,49 @@ class TestEpisode:
|
||||
native_name="Français",
|
||||
aliases=("fr", "fra", "french"),
|
||||
)
|
||||
e = self._ep(audio_tracks=[AudioTrack(0, "ac3", 6, "5.1", "fr")])
|
||||
e = self._ep(audio_tracks=(AudioTrack(0, "ac3", 6, "5.1", "fr"),))
|
||||
# str query "fre" wouldn't match "fr" directly — but Language does cross-format
|
||||
assert e.has_audio_in(lang) is True
|
||||
assert e.has_audio_in("fre") is False # direct compare misses
|
||||
|
||||
def test_audio_languages_dedup_in_order(self):
|
||||
e = self._ep(
|
||||
audio_tracks=[
|
||||
audio_tracks=(
|
||||
AudioTrack(0, "ac3", 6, "5.1", "eng"),
|
||||
AudioTrack(1, "ac3", 6, "5.1", "fre"),
|
||||
AudioTrack(2, "aac", 2, "stereo", "eng"), # dupe
|
||||
AudioTrack(3, "aac", 2, "stereo", None), # skipped
|
||||
]
|
||||
)
|
||||
)
|
||||
assert e.audio_languages() == ["eng", "fre"]
|
||||
|
||||
# ── Subtitle helpers ───────────────────────────────────────────────
|
||||
|
||||
def test_has_subtitles_in(self):
|
||||
e = self._ep(subtitle_tracks=[SubtitleTrack(0, "subrip", "fre")])
|
||||
e = self._ep(subtitle_tracks=(SubtitleTrack(0, "subrip", "fre"),))
|
||||
assert e.has_subtitles_in("fre") is True
|
||||
assert e.has_subtitles_in("eng") is False
|
||||
|
||||
def test_has_forced_subs(self):
|
||||
e = self._ep(
|
||||
subtitle_tracks=[
|
||||
subtitle_tracks=(
|
||||
SubtitleTrack(0, "subrip", "eng", is_forced=False),
|
||||
SubtitleTrack(1, "subrip", "eng", is_forced=True),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert e.has_forced_subs() is True
|
||||
|
||||
def test_has_forced_subs_false_when_none(self):
|
||||
e = self._ep(subtitle_tracks=[SubtitleTrack(0, "subrip", "eng")])
|
||||
e = self._ep(subtitle_tracks=(SubtitleTrack(0, "subrip", "eng"),))
|
||||
assert e.has_forced_subs() is False
|
||||
|
||||
def test_subtitle_languages_dedup_in_order(self):
|
||||
e = self._ep(
|
||||
subtitle_tracks=[
|
||||
subtitle_tracks=(
|
||||
SubtitleTrack(0, "subrip", "eng"),
|
||||
SubtitleTrack(1, "subrip", "fre"),
|
||||
SubtitleTrack(2, "subrip", "eng"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert e.subtitle_languages() == ["eng", "fre"]
|
||||
|
||||
@@ -247,7 +232,12 @@ class TestSeason:
|
||||
s = Season(season_number=1)
|
||||
assert isinstance(s.season_number, SeasonNumber)
|
||||
assert s.episode_count == 0
|
||||
assert s.episodes == {}
|
||||
assert s.episodes == ()
|
||||
|
||||
def test_is_frozen(self):
|
||||
s = Season(season_number=1)
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
s.episodes = (self._ep(1),) # type: ignore[misc]
|
||||
|
||||
def test_get_folder_name_normal(self):
|
||||
assert Season(season_number=2).get_folder_name() == "Season 02"
|
||||
@@ -257,82 +247,34 @@ class TestSeason:
|
||||
assert s.get_folder_name() == "Specials"
|
||||
assert s.is_special()
|
||||
|
||||
def test_negative_aired_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
Season(season_number=1, aired_episodes=-1)
|
||||
# ── Mode derivation ────────────────────────────────────────────────
|
||||
|
||||
def test_aired_cannot_exceed_expected(self):
|
||||
with pytest.raises(ValueError):
|
||||
Season(season_number=1, expected_episodes=5, aired_episodes=6)
|
||||
|
||||
def test_add_episode_rejects_mismatched_season(self):
|
||||
def test_mode_pack_when_no_episodes(self):
|
||||
s = Season(season_number=1)
|
||||
ep = Episode(season_number=2, episode_number=1, title="x")
|
||||
with pytest.raises(ValueError):
|
||||
s.add_episode(ep)
|
||||
assert s.mode == SeasonMode.PACK
|
||||
|
||||
def test_add_episode_replaces_same_number(self):
|
||||
s = Season(season_number=1)
|
||||
s.add_episode(self._ep(1))
|
||||
s.add_episode(Episode(season_number=1, episode_number=1, title="Replaced"))
|
||||
assert s.episodes[EpisodeNumber(1)].title == "Replaced"
|
||||
def test_mode_episodic_when_episodes_present(self):
|
||||
s = Season(season_number=1, episodes=(self._ep(1),))
|
||||
assert s.mode == SeasonMode.EPISODIC
|
||||
|
||||
def test_str_uses_name_when_present(self):
|
||||
s = Season(season_number=1, name="Pilot Season")
|
||||
assert "Pilot Season" in str(s)
|
||||
# ── Episode access ─────────────────────────────────────────────────
|
||||
|
||||
# ── Completeness vs aired ──────────────────────────────────────────
|
||||
def test_get_episode_returns_match(self):
|
||||
ep1 = self._ep(1)
|
||||
ep2 = self._ep(2)
|
||||
s = Season(season_number=1, episodes=(ep1, ep2))
|
||||
assert s.get_episode(EpisodeNumber(2)) is ep2
|
||||
|
||||
def test_is_complete_unknown_aired_is_false(self):
|
||||
# Conservative: no aired count → cannot claim complete
|
||||
s = Season(season_number=1)
|
||||
s.add_episode(self._ep(1))
|
||||
assert s.is_complete() is False
|
||||
def test_get_episode_returns_none_when_absent(self):
|
||||
s = Season(season_number=1, episodes=(self._ep(1),))
|
||||
assert s.get_episode(EpisodeNumber(99)) is None
|
||||
|
||||
def test_is_complete_when_owning_all_aired(self):
|
||||
s = Season(season_number=1, aired_episodes=3)
|
||||
for i in (1, 2, 3):
|
||||
s.add_episode(self._ep(i))
|
||||
assert s.is_complete() is True
|
||||
|
||||
def test_is_complete_zero_aired_is_trivially_true(self):
|
||||
s = Season(season_number=1, aired_episodes=0)
|
||||
assert s.is_complete() is True
|
||||
|
||||
def test_partial_when_missing_aired_episodes(self):
|
||||
s = Season(season_number=1, aired_episodes=3)
|
||||
s.add_episode(self._ep(1))
|
||||
assert s.is_complete() is False
|
||||
|
||||
def test_is_fully_aired(self):
|
||||
s = Season(season_number=1, expected_episodes=10, aired_episodes=10)
|
||||
assert s.is_fully_aired() is True
|
||||
|
||||
def test_is_fully_aired_false_when_in_flight(self):
|
||||
s = Season(season_number=1, expected_episodes=10, aired_episodes=4)
|
||||
assert s.is_fully_aired() is False
|
||||
|
||||
def test_is_fully_aired_false_with_unknowns(self):
|
||||
assert Season(season_number=1).is_fully_aired() is False
|
||||
|
||||
def test_missing_episodes_when_partial(self):
|
||||
s = Season(season_number=1, aired_episodes=5)
|
||||
s.add_episode(self._ep(1))
|
||||
s.add_episode(self._ep(3))
|
||||
missing = [n.value for n in s.missing_episodes()]
|
||||
assert missing == [2, 4, 5]
|
||||
|
||||
def test_missing_episodes_empty_when_complete(self):
|
||||
s = Season(season_number=1, aired_episodes=2)
|
||||
s.add_episode(self._ep(1))
|
||||
s.add_episode(self._ep(2))
|
||||
assert s.missing_episodes() == []
|
||||
|
||||
def test_missing_episodes_empty_when_unknown_aired(self):
|
||||
# Without an aired count we cannot reason about gaps
|
||||
s = Season(season_number=1)
|
||||
s.add_episode(self._ep(2))
|
||||
assert s.missing_episodes() == []
|
||||
def test_episode_count_reflects_tuple_size(self):
|
||||
s = Season(
|
||||
season_number=1,
|
||||
episodes=(self._ep(1), self._ep(2), self._ep(3)),
|
||||
)
|
||||
assert s.episode_count == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -342,40 +284,30 @@ class TestSeason:
|
||||
|
||||
class TestTVShow:
|
||||
def _show(self, **kwargs) -> TVShow:
|
||||
defaults = dict(
|
||||
imdb_id="tt0903747",
|
||||
title="Breaking Bad",
|
||||
status="ended",
|
||||
)
|
||||
defaults = dict(imdb_id="tt0903747", title="Breaking Bad")
|
||||
defaults.update(kwargs)
|
||||
return TVShow(**defaults)
|
||||
|
||||
# ── Construction & coercion ────────────────────────────────────────
|
||||
|
||||
def test_basic_creation(self):
|
||||
show = self._show(expected_seasons=5)
|
||||
show = self._show()
|
||||
assert show.title == "Breaking Bad"
|
||||
assert show.expected_seasons == 5
|
||||
assert show.seasons == {}
|
||||
assert show.seasons == ()
|
||||
assert show.seasons_count == 0
|
||||
assert show.episode_count == 0
|
||||
|
||||
def test_coerces_string_imdb_id(self):
|
||||
assert isinstance(self._show().imdb_id, ImdbId)
|
||||
|
||||
def test_coerces_string_status(self):
|
||||
assert self._show(status="ongoing").status == ShowStatus.ONGOING
|
||||
|
||||
def test_is_ongoing_and_is_ended(self):
|
||||
assert self._show(status="ongoing").is_ongoing()
|
||||
assert self._show(status="ended").is_ended()
|
||||
|
||||
def test_negative_expected_seasons_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
self._show(expected_seasons=-1)
|
||||
|
||||
def test_invalid_imdb_id_type_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TVShow(imdb_id=12345, title="X", status="ended") # type: ignore
|
||||
TVShow(imdb_id=12345, title="X") # type: ignore
|
||||
|
||||
def test_is_frozen(self):
|
||||
show = self._show()
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
show.title = "Other" # type: ignore[misc]
|
||||
|
||||
def test_get_folder_name_replaces_spaces(self):
|
||||
assert self._show(title="Breaking Bad").get_folder_name() == "Breaking.Bad"
|
||||
@@ -389,77 +321,224 @@ class TestTVShow:
|
||||
assert "Breaking Bad" in str(show)
|
||||
assert "tt0903747" in repr(show)
|
||||
|
||||
# ── add_episode — the only sanctioned mutation ─────────────────────
|
||||
# ── Season access ──────────────────────────────────────────────────
|
||||
|
||||
def test_get_season_returns_match(self):
|
||||
s1 = Season(season_number=1)
|
||||
s2 = Season(season_number=2)
|
||||
show = self._show(seasons=(s1, s2))
|
||||
assert show.get_season(SeasonNumber(2)) is s2
|
||||
|
||||
def test_get_season_returns_none_when_absent(self):
|
||||
show = self._show(seasons=(Season(season_number=1),))
|
||||
assert show.get_season(SeasonNumber(99)) is None
|
||||
|
||||
def test_episode_count_aggregates_across_seasons(self):
|
||||
ep11 = Episode(season_number=1, episode_number=1, title="x")
|
||||
ep12 = Episode(season_number=1, episode_number=2, title="y")
|
||||
ep21 = Episode(season_number=2, episode_number=1, title="z")
|
||||
show = self._show(
|
||||
seasons=(
|
||||
Season(season_number=1, episodes=(ep11, ep12)),
|
||||
Season(season_number=2, episodes=(ep21,)),
|
||||
)
|
||||
)
|
||||
assert show.episode_count == 3
|
||||
assert show.seasons_count == 2
|
||||
|
||||
def test_tmdb_id_defaults_to_none(self):
|
||||
assert self._show().tmdb_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SeasonBuilder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSeasonBuilder:
|
||||
def _ep(self, episode: int) -> Episode:
|
||||
return Episode(season_number=1, episode_number=episode, title=f"Ep {episode}")
|
||||
|
||||
def test_build_empty(self):
|
||||
s = SeasonBuilder(SeasonNumber(1)).build()
|
||||
assert isinstance(s, Season)
|
||||
assert s.episodes == ()
|
||||
assert s.mode == SeasonMode.PACK
|
||||
|
||||
def test_build_emits_sorted_episodes(self):
|
||||
s = (
|
||||
SeasonBuilder(SeasonNumber(1))
|
||||
.add_episode(self._ep(3))
|
||||
.add_episode(self._ep(1))
|
||||
.add_episode(self._ep(2))
|
||||
.build()
|
||||
)
|
||||
assert [ep.episode_number.value for ep in s.episodes] == [1, 2, 3]
|
||||
|
||||
def test_add_episode_last_write_wins(self):
|
||||
first = Episode(season_number=1, episode_number=1, title="First")
|
||||
second = Episode(season_number=1, episode_number=1, title="Replacement")
|
||||
s = (
|
||||
SeasonBuilder(SeasonNumber(1))
|
||||
.add_episode(first)
|
||||
.add_episode(second)
|
||||
.build()
|
||||
)
|
||||
assert s.episodes == (second,)
|
||||
assert s.episodes[0].title == "Replacement"
|
||||
|
||||
def test_add_episode_rejects_mismatched_season(self):
|
||||
builder = SeasonBuilder(SeasonNumber(1))
|
||||
with pytest.raises(ValueError):
|
||||
builder.add_episode(
|
||||
Episode(season_number=2, episode_number=1, title="bad")
|
||||
)
|
||||
|
||||
def test_int_season_number_coerced(self):
|
||||
s = SeasonBuilder(1).build()
|
||||
assert s.season_number == SeasonNumber(1)
|
||||
|
||||
def test_from_existing_round_trip(self):
|
||||
original = Season(
|
||||
season_number=1,
|
||||
episodes=(self._ep(1), self._ep(2)),
|
||||
)
|
||||
rebuilt = SeasonBuilder.from_existing(original).build()
|
||||
assert rebuilt == original
|
||||
|
||||
def test_from_existing_then_add_replaces(self):
|
||||
original = Season(season_number=1, episodes=(self._ep(1), self._ep(2)))
|
||||
replacement = Episode(season_number=1, episode_number=2, title="New")
|
||||
rebuilt = (
|
||||
SeasonBuilder.from_existing(original).add_episode(replacement).build()
|
||||
)
|
||||
assert rebuilt.get_episode(EpisodeNumber(2)) is replacement
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TVShowBuilder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTVShowBuilder:
|
||||
def _ep(self, season: int, episode: int) -> Episode:
|
||||
return Episode(
|
||||
season_number=season,
|
||||
episode_number=episode,
|
||||
title=f"S{season:02d}E{episode:02d}",
|
||||
)
|
||||
|
||||
def test_build_minimal(self):
|
||||
show = TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad").build()
|
||||
assert isinstance(show, TVShow)
|
||||
assert show.title == "Breaking Bad"
|
||||
assert show.seasons == ()
|
||||
assert show.tmdb_id is None
|
||||
|
||||
def test_coerces_string_imdb_id(self):
|
||||
show = TVShowBuilder(imdb_id="tt0903747", title="x").build()
|
||||
assert isinstance(show.imdb_id, ImdbId)
|
||||
|
||||
def test_add_episode_creates_missing_season(self):
|
||||
show = self._show()
|
||||
show.add_episode(Episode(season_number=1, episode_number=1, title="Pilot"))
|
||||
assert SeasonNumber(1) in show.seasons
|
||||
show = (
|
||||
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
||||
.add_episode(self._ep(1, 1))
|
||||
.build()
|
||||
)
|
||||
assert show.seasons_count == 1
|
||||
assert show.get_season(SeasonNumber(1)) is not None
|
||||
assert show.episode_count == 1
|
||||
|
||||
def test_add_episode_reuses_existing_season(self):
|
||||
show = self._show()
|
||||
show.add_episode(Episode(season_number=1, episode_number=1, title="A"))
|
||||
show.add_episode(Episode(season_number=1, episode_number=2, title="B"))
|
||||
show = (
|
||||
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
||||
.add_episode(self._ep(1, 1))
|
||||
.add_episode(self._ep(1, 2))
|
||||
.build()
|
||||
)
|
||||
assert show.seasons_count == 1
|
||||
assert show.episode_count == 2
|
||||
|
||||
def test_seasons_emitted_sorted(self):
|
||||
show = (
|
||||
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
||||
.add_episode(self._ep(3, 1))
|
||||
.add_episode(self._ep(1, 1))
|
||||
.add_episode(self._ep(2, 1))
|
||||
.build()
|
||||
)
|
||||
assert [s.season_number.value for s in show.seasons] == [1, 2, 3]
|
||||
|
||||
def test_episodes_within_season_emitted_sorted(self):
|
||||
show = (
|
||||
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
||||
.add_episode(self._ep(1, 3))
|
||||
.add_episode(self._ep(1, 1))
|
||||
.add_episode(self._ep(1, 2))
|
||||
.build()
|
||||
)
|
||||
season = show.get_season(SeasonNumber(1))
|
||||
assert season is not None
|
||||
assert [ep.episode_number.value for ep in season.episodes] == [1, 2, 3]
|
||||
|
||||
def test_add_season_replaces_existing(self):
|
||||
show = self._show()
|
||||
s1 = Season(season_number=1, aired_episodes=10)
|
||||
show.add_season(s1)
|
||||
s1bis = Season(season_number=1, aired_episodes=5)
|
||||
show.add_season(s1bis)
|
||||
assert show.seasons[SeasonNumber(1)] is s1bis
|
||||
first = Season(season_number=1, episodes=(self._ep(1, 1),))
|
||||
second = Season(
|
||||
season_number=1, episodes=(self._ep(1, 5), self._ep(1, 6))
|
||||
)
|
||||
show = (
|
||||
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
||||
.add_season(first)
|
||||
.add_season(second)
|
||||
.build()
|
||||
)
|
||||
season = show.get_season(SeasonNumber(1))
|
||||
assert season is not None
|
||||
assert [ep.episode_number.value for ep in season.episodes] == [5, 6]
|
||||
|
||||
# ── Collection status ──────────────────────────────────────────────
|
||||
def test_season_builder_returns_same_instance(self):
|
||||
builder = TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
||||
sb1 = builder.season_builder(1)
|
||||
sb2 = builder.season_builder(1)
|
||||
assert sb1 is sb2
|
||||
|
||||
def test_collection_status_empty(self):
|
||||
assert self._show().collection_status() == CollectionStatus.EMPTY
|
||||
def test_season_builder_via_int(self):
|
||||
builder = TVShowBuilder(imdb_id="tt0903747", title="x")
|
||||
sb = builder.season_builder(5)
|
||||
assert sb.season_number == SeasonNumber(5)
|
||||
|
||||
def test_collection_status_partial_missing_episode(self):
|
||||
show = self._show()
|
||||
s = Season(season_number=1, aired_episodes=3)
|
||||
s.add_episode(Episode(season_number=1, episode_number=1, title="x"))
|
||||
show.add_season(s)
|
||||
assert show.collection_status() == CollectionStatus.PARTIAL
|
||||
def test_set_title_and_tmdb_id(self):
|
||||
show = (
|
||||
TVShowBuilder(imdb_id="tt0903747", title="Initial")
|
||||
.set_title("Updated")
|
||||
.set_tmdb_id(1396)
|
||||
.build()
|
||||
)
|
||||
assert show.title == "Updated"
|
||||
assert show.tmdb_id == 1396
|
||||
|
||||
def test_collection_status_complete(self):
|
||||
show = self._show(expected_seasons=1)
|
||||
s = Season(season_number=1, aired_episodes=2)
|
||||
for n in (1, 2):
|
||||
s.add_episode(Episode(season_number=1, episode_number=n, title=f"e{n}"))
|
||||
show.add_season(s)
|
||||
assert show.collection_status() == CollectionStatus.COMPLETE
|
||||
def test_from_existing_round_trip(self):
|
||||
original = (
|
||||
TVShowBuilder(
|
||||
imdb_id="tt0903747",
|
||||
title="Breaking Bad",
|
||||
tmdb_id=1396,
|
||||
)
|
||||
.add_episode(self._ep(1, 1))
|
||||
.add_episode(self._ep(2, 1))
|
||||
.build()
|
||||
)
|
||||
rebuilt = TVShowBuilder.from_existing(original).build()
|
||||
assert rebuilt == original
|
||||
|
||||
def test_collection_status_partial_when_seasons_missing(self):
|
||||
# Seasons we own are complete, but expected_seasons says more exist.
|
||||
show = self._show(expected_seasons=2)
|
||||
s = Season(season_number=1, aired_episodes=1)
|
||||
s.add_episode(Episode(season_number=1, episode_number=1, title="x"))
|
||||
show.add_season(s)
|
||||
assert show.collection_status() == CollectionStatus.PARTIAL
|
||||
|
||||
def test_is_complete_series_requires_ended_and_complete(self):
|
||||
show = self._show(status="ongoing", expected_seasons=1)
|
||||
s = Season(season_number=1, aired_episodes=1)
|
||||
s.add_episode(Episode(season_number=1, episode_number=1, title="x"))
|
||||
show.add_season(s)
|
||||
# Ongoing → never "complete series" even if collection is COMPLETE
|
||||
assert show.is_complete_series() is False
|
||||
|
||||
show.status = ShowStatus.ENDED
|
||||
assert show.is_complete_series() is True
|
||||
|
||||
# ── missing_episodes traversal ─────────────────────────────────────
|
||||
|
||||
def test_missing_episodes_walks_seasons_in_order(self):
|
||||
show = self._show()
|
||||
s2 = Season(season_number=2, aired_episodes=2)
|
||||
s1 = Season(season_number=1, aired_episodes=3)
|
||||
s1.add_episode(Episode(season_number=1, episode_number=2, title="x"))
|
||||
show.add_season(s2)
|
||||
show.add_season(s1)
|
||||
missing = [(s.value, e.value) for s, e in show.missing_episodes()]
|
||||
assert missing == [(1, 1), (1, 3), (2, 1), (2, 2)]
|
||||
def test_from_existing_then_add_extends(self):
|
||||
original = (
|
||||
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
||||
.add_episode(self._ep(1, 1))
|
||||
.build()
|
||||
)
|
||||
extended = (
|
||||
TVShowBuilder.from_existing(original).add_episode(self._ep(1, 2)).build()
|
||||
)
|
||||
assert extended.episode_count == 2
|
||||
assert original.episode_count == 1 # original untouched
|
||||
|
||||
@@ -23,7 +23,6 @@ from alfred.domain.tv_shows.entities import Episode, TVShow
|
||||
from alfred.domain.tv_shows.value_objects import (
|
||||
EpisodeNumber,
|
||||
SeasonNumber,
|
||||
ShowStatus,
|
||||
)
|
||||
from alfred.infrastructure.filesystem.filesystem_operations import (
|
||||
create_folder,
|
||||
@@ -171,8 +170,6 @@ def _show() -> TVShow:
|
||||
return TVShow(
|
||||
imdb_id=ImdbId("tt0773262"),
|
||||
title="Dexter",
|
||||
expected_seasons=8,
|
||||
status=ShowStatus.ENDED,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user