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:
2026-05-22 16:09:37 +02:00
parent 1427c8a54b
commit 6c12c18a27
7 changed files with 667 additions and 486 deletions
+58
View File
@@ -17,6 +17,64 @@ callers).
### Added ### 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 - **Design doc for `.alfred/` sidecar persistence
(`specs/dot_alfred.md`).** First entry in the new `specs/` directory. (`specs/dot_alfred.md`).** First entry in the new `specs/` directory.
Specifies a per-show `.alfred/` directory holding a `show.yaml` and Specifies a per-show `.alfred/` directory holding a `show.yaml` and
+5 -4
View File
@@ -1,20 +1,21 @@
"""TV Shows domain - Business logic for TV show management.""" """TV Shows domain - Business logic for TV show management."""
from .builders import SeasonBuilder, TVShowBuilder
from .entities import Episode, Season, TVShow from .entities import Episode, Season, TVShow
from .exceptions import InvalidEpisode, SeasonNotFound, TVShowNotFound from .exceptions import InvalidEpisode, SeasonNotFound, TVShowNotFound
from .value_objects import ( from .value_objects import (
CollectionStatus,
EpisodeNumber, EpisodeNumber,
SeasonMode,
SeasonNumber, SeasonNumber,
ShowStatus,
) )
__all__ = [ __all__ = [
"TVShow", "TVShow",
"Season", "Season",
"Episode", "Episode",
"ShowStatus", "TVShowBuilder",
"CollectionStatus", "SeasonBuilder",
"SeasonMode",
"SeasonNumber", "SeasonNumber",
"EpisodeNumber", "EpisodeNumber",
"TVShowNotFound", "TVShowNotFound",
+221
View File
@@ -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,
)
+76 -212
View File
@@ -5,9 +5,9 @@ This module implements the TVShow aggregate following DDD principles.
Aggregate ownership:: Aggregate ownership::
TVShow ← aggregate root (the repo returns this) TVShow ← aggregate root (the repo returns this)
└── seasons: dict[SeasonNumber, Season] └── seasons: tuple[Season, ...]
└── Season └── Season
└── episodes: dict[EpisodeNumber, Episode] └── episodes: tuple[Episode, ...]
└── Episode ← file metadata + audio/subtitle tracks └── Episode ← file metadata + audio/subtitle tracks
Rules: Rules:
@@ -17,10 +17,18 @@ Rules:
* ``Season`` is owned by TVShow. ``Episode`` is owned by Season. * ``Season`` is owned by TVShow. ``Episode`` is owned by Season.
* Children do not back-reference the root (no ``show_imdb_id`` on * Children do not back-reference the root (no ``show_imdb_id`` on
Season/Episode): they are only ever reached *through* TVShow. Season/Episode): they are only ever reached *through* TVShow.
* Mutation invariants are enforced through aggregate-root methods such as * The aggregate is **frozen all the way down**. Mutation happens exclusively
``TVShow.add_episode()`` — never reach into ``show.seasons[...].episodes`` through :class:`TVShowBuilder` (see ``builders.py``), which produces a new
to mutate without going through the root, otherwise invariants are not ``TVShow`` via ``build()``. There is no ``add_episode`` / ``add_season``
guaranteed. 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 from __future__ import annotations
@@ -36,10 +44,9 @@ from ..shared.value_objects import (
to_dot_folder_name, to_dot_folder_name,
) )
from .value_objects import ( from .value_objects import (
CollectionStatus,
EpisodeNumber, EpisodeNumber,
SeasonMode,
SeasonNumber, SeasonNumber,
ShowStatus,
) )
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
@@ -58,7 +65,8 @@ class Episode(MediaWithTracks):
scanned, or when no file is downloaded yet. scanned, or when no file is downloaded yet.
Frozen: rebuild via ``dataclasses.replace`` to project enrichment results 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 Equality is identity-based within the aggregate: two ``Episode`` instances
are equal iff they share the same ``(season_number, episode_number)``, are equal iff they share the same ``(season_number, episode_number)``,
@@ -125,51 +133,30 @@ class Episode(MediaWithTracks):
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
@dataclass @dataclass(frozen=True)
class Season: 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: The presence or absence of episodes also encodes the
:class:`SeasonMode` (see ``mode`` property): a season with no episodes
* ``expected_episodes`` — total episodes planned for the season is a PACK (single release covering the whole season), a season with
(``None`` if unknown). episodes is EPISODIC (currently airing, one release per episode).
* ``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.
""" """
season_number: SeasonNumber season_number: SeasonNumber
episodes: dict[EpisodeNumber, Episode] = field(default_factory=dict) episodes: tuple[Episode, ...] = ()
expected_episodes: int | None = None
aired_episodes: int | None = None
name: str | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(self.season_number, SeasonNumber): if not isinstance(self.season_number, SeasonNumber):
if isinstance(self.season_number, int): if isinstance(self.season_number, int):
self.season_number = SeasonNumber(self.season_number) object.__setattr__(
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})"
)
# ── Properties ───────────────────────────────────────────────────────── # ── Properties ─────────────────────────────────────────────────────────
@@ -178,65 +165,32 @@ class Season:
"""Number of episodes currently owned in this season.""" """Number of episodes currently owned in this season."""
return len(self.episodes) return len(self.episodes)
# ── Collection state ─────────────────────────────────────────────────── @property
def mode(self) -> SeasonMode:
"""
Storage mode of this season.
def _effective_aired(self) -> int | None: Derived from the structural shape: ``PACK`` when no episode is
"""``aired_episodes`` if set, else fall back to ``expected_episodes``.""" explicitly modeled (the season is a single release), ``EPISODIC``
return ( when episodes are present (one release per episode).
self.aired_episodes """
if self.aired_episodes is not None return SeasonMode.EPISODIC if self.episodes else SeasonMode.PACK
else self.expected_episodes
# ── 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 ───────────────────────────────────────────────────────────── # ── Naming ─────────────────────────────────────────────────────────────
def is_special(self) -> bool: def is_special(self) -> bool:
@@ -249,8 +203,6 @@ class Season:
return f"Season {self.season_number.value:02d}" return f"Season {self.season_number.value:02d}"
def __str__(self) -> str: def __str__(self) -> str:
if self.name:
return f"Season {self.season_number.value}: {self.name}"
return f"Season {self.season_number.value}" return f"Season {self.season_number.value}"
def __repr__(self) -> str: def __repr__(self) -> str:
@@ -264,62 +216,35 @@ class Season:
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
@dataclass @dataclass(frozen=True)
class TVShow: 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``. Owns its seasons as an ordered tuple keyed by ``SeasonNumber``. The
All mutations (adding episodes, creating seasons) MUST go through the tuple is sorted by season number at build time (guaranteed by
methods on this class — that is how invariants are preserved. :class:`TVShowBuilder`).
Two axes describe the show, kept deliberately orthogonal: Identity is carried by ``imdb_id`` and ``tmdb_id``. The ``title`` here
is the human-readable label used when generating folder names; the
* ``status`` (``ShowStatus``) — production state (TMDB-sourced). canonical title (and any other volatile metadata) lives on TMDB and is
* ``collection_status()`` — what the user owns vs what has aired today. re-fetched by the ``ShowTracker`` when needed.
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.
""" """
imdb_id: ImdbId imdb_id: ImdbId
title: str title: str
status: ShowStatus seasons: tuple[Season, ...] = ()
seasons: dict[SeasonNumber, Season] = field(default_factory=dict)
expected_seasons: int | None = None
tmdb_id: int | None = None tmdb_id: int | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(self.imdb_id, ImdbId): if not isinstance(self.imdb_id, ImdbId):
if isinstance(self.imdb_id, str): if isinstance(self.imdb_id, str):
self.imdb_id = ImdbId(self.imdb_id) object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id))
else: else:
raise ValueError( raise ValueError(
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}" f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
) )
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 ───────────────────────────────────────────────────────── # ── Properties ─────────────────────────────────────────────────────────
@property @property
@@ -330,83 +255,22 @@ class TVShow:
@property @property
def episode_count(self) -> int: def episode_count(self) -> int:
"""Total episodes owned across all seasons.""" """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 — O(n) traversal — fine for show-sized collections (typically ≤ 30
it preserves the invariant that an episode is always reachable through seasons). The aggregate guarantees uniqueness of season numbers
``show.seasons[s].episodes[e]``. within a show.
""" """
season = self.seasons.get(episode.season_number) return next(
if season is None: (s for s in self.seasons if s.season_number == number),
season = Season(season_number=episode.season_number) None,
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
# ── Naming ───────────────────────────────────────────────────────────── # ── Naming ─────────────────────────────────────────────────────────────
@@ -415,7 +279,7 @@ class TVShow:
return to_dot_folder_name(self.title) return to_dot_folder_name(self.title)
def __str__(self) -> str: 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: def __repr__(self) -> str:
return f"TVShow(imdb_id={self.imdb_id}, title='{self.title}')" return f"TVShow(imdb_id={self.imdb_id}, title='{self.title}')"
+17 -56
View File
@@ -8,49 +8,27 @@ from enum import Enum
from ..shared.exceptions import ValidationError 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 Derived from the structural shape of the ``Season``:
the user owns. Orthogonal to ``CollectionStatus``.
* ``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" PACK = "pack"
ENDED = "ended" EPISODIC = "episodic"
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)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -92,23 +70,6 @@ class SeasonNumber:
return self.value 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) @dataclass(frozen=True)
class EpisodeNumber: class EpisodeNumber:
""" """
+290 -211
View File
@@ -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]``. * The aggregate is **frozen all the way** — ``TVShow``, ``Season`` and
* ``Season`` owns ``episodes: dict[EpisodeNumber, Episode]`` and tracks ``Episode`` are all ``@dataclass(frozen=True)``. Children are stored as
``expected_episodes`` + ``aired_episodes``. ordered tuples sorted by number.
* ``Episode`` carries ``audio_tracks`` + ``subtitle_tracks`` and exposes * Construction goes exclusively through :class:`TVShowBuilder` (and its
language helpers following contract C+ (``str`` direct compare, ``Language`` helper :class:`SeasonBuilder`). No more ``add_episode`` / ``add_season``
cross-format). on entities.
* No back-references on Season/Episode — they are reached through the root. * ShowTracker-territory fields (production status, expected vs aired
* Sole sanctioned mutation entry point: ``TVShow.add_episode(ep)``. counts, collection completeness) are removed from the domain. The
aggregate carries only what the ``.alfred`` sidecar stores.
Coverage: Coverage:
* ``TestShowStatus`` — including the extended TMDB string mapping.
* ``TestSeasonNumber`` / ``TestEpisodeNumber`` — value-object validation. * ``TestSeasonNumber`` / ``TestEpisodeNumber`` — value-object validation.
* ``TestSeasonMode`` — enum sanity (computed in ``TestSeason``).
* ``TestEpisode`` — basic shape, file presence, audio/subtitle helpers. * ``TestEpisode`` — basic shape, file presence, audio/subtitle helpers.
* ``TestSeason`` — episode insertion, completeness vs aired, missing list. * ``TestSeason`` — frozen shape, episode lookup, mode derivation.
* ``TestTVShow`` — aggregate invariants, ``add_episode``, ``collection_status``, * ``TestTVShow`` — frozen aggregate root, season lookup, counts.
``missing_episodes``, ``is_complete_series``. * ``TestSeasonBuilder`` / ``TestTVShowBuilder`` — sole sanctioned mutation
surface; ordering, last-write-wins, ``from_existing`` round-trip.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses
import pytest import pytest
from alfred.domain.shared.exceptions import ValidationError from alfred.domain.shared.exceptions import ValidationError
from alfred.domain.shared.media import AudioTrack, SubtitleTrack from alfred.domain.shared.media import AudioTrack, SubtitleTrack
from alfred.domain.shared.value_objects import ImdbId, Language 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.entities import Episode, Season, TVShow
from alfred.domain.tv_shows.value_objects import ( from alfred.domain.tv_shows.value_objects import (
CollectionStatus,
EpisodeNumber, EpisodeNumber,
SeasonMode,
SeasonNumber, 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 # SeasonNumber
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -131,6 +100,17 @@ class TestEpisodeNumber:
assert int(e) == 12 assert int(e) == 12
# ---------------------------------------------------------------------------
# SeasonMode
# ---------------------------------------------------------------------------
class TestSeasonMode:
def test_values(self):
assert SeasonMode.PACK.value == "pack"
assert SeasonMode.EPISODIC.value == "episodic"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Episode entity # Episode entity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -151,6 +131,11 @@ class TestEpisode:
assert isinstance(e.season_number, SeasonNumber) assert isinstance(e.season_number, SeasonNumber)
assert isinstance(e.episode_number, EpisodeNumber) 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): def test_get_filename_format(self):
e = self._ep(season=1, episode=5, title="Gray Matter") e = self._ep(season=1, episode=5, title="Gray Matter")
filename = e.get_filename() filename = e.get_filename()
@@ -171,10 +156,10 @@ class TestEpisode:
def test_has_audio_in_with_str(self): def test_has_audio_in_with_str(self):
e = self._ep( e = self._ep(
audio_tracks=[ audio_tracks=(
AudioTrack(0, "eac3", 6, "5.1", "eng"), AudioTrack(0, "eac3", 6, "5.1", "eng"),
AudioTrack(1, "ac3", 6, "5.1", "fre"), AudioTrack(1, "ac3", 6, "5.1", "fre"),
] )
) )
assert e.has_audio_in("eng") is True assert e.has_audio_in("eng") is True
assert e.has_audio_in("ENG") is True # case-insensitive assert e.has_audio_in("ENG") is True # case-insensitive
@@ -187,49 +172,49 @@ class TestEpisode:
native_name="Français", native_name="Français",
aliases=("fr", "fra", "french"), 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 # 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(lang) is True
assert e.has_audio_in("fre") is False # direct compare misses assert e.has_audio_in("fre") is False # direct compare misses
def test_audio_languages_dedup_in_order(self): def test_audio_languages_dedup_in_order(self):
e = self._ep( e = self._ep(
audio_tracks=[ audio_tracks=(
AudioTrack(0, "ac3", 6, "5.1", "eng"), AudioTrack(0, "ac3", 6, "5.1", "eng"),
AudioTrack(1, "ac3", 6, "5.1", "fre"), AudioTrack(1, "ac3", 6, "5.1", "fre"),
AudioTrack(2, "aac", 2, "stereo", "eng"), # dupe AudioTrack(2, "aac", 2, "stereo", "eng"), # dupe
AudioTrack(3, "aac", 2, "stereo", None), # skipped AudioTrack(3, "aac", 2, "stereo", None), # skipped
] )
) )
assert e.audio_languages() == ["eng", "fre"] assert e.audio_languages() == ["eng", "fre"]
# ── Subtitle helpers ─────────────────────────────────────────────── # ── Subtitle helpers ───────────────────────────────────────────────
def test_has_subtitles_in(self): 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("fre") is True
assert e.has_subtitles_in("eng") is False assert e.has_subtitles_in("eng") is False
def test_has_forced_subs(self): def test_has_forced_subs(self):
e = self._ep( e = self._ep(
subtitle_tracks=[ subtitle_tracks=(
SubtitleTrack(0, "subrip", "eng", is_forced=False), SubtitleTrack(0, "subrip", "eng", is_forced=False),
SubtitleTrack(1, "subrip", "eng", is_forced=True), SubtitleTrack(1, "subrip", "eng", is_forced=True),
] )
) )
assert e.has_forced_subs() is True assert e.has_forced_subs() is True
def test_has_forced_subs_false_when_none(self): 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 assert e.has_forced_subs() is False
def test_subtitle_languages_dedup_in_order(self): def test_subtitle_languages_dedup_in_order(self):
e = self._ep( e = self._ep(
subtitle_tracks=[ subtitle_tracks=(
SubtitleTrack(0, "subrip", "eng"), SubtitleTrack(0, "subrip", "eng"),
SubtitleTrack(1, "subrip", "fre"), SubtitleTrack(1, "subrip", "fre"),
SubtitleTrack(2, "subrip", "eng"), SubtitleTrack(2, "subrip", "eng"),
] )
) )
assert e.subtitle_languages() == ["eng", "fre"] assert e.subtitle_languages() == ["eng", "fre"]
@@ -247,7 +232,12 @@ class TestSeason:
s = Season(season_number=1) s = Season(season_number=1)
assert isinstance(s.season_number, SeasonNumber) assert isinstance(s.season_number, SeasonNumber)
assert s.episode_count == 0 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): def test_get_folder_name_normal(self):
assert Season(season_number=2).get_folder_name() == "Season 02" assert Season(season_number=2).get_folder_name() == "Season 02"
@@ -257,82 +247,34 @@ class TestSeason:
assert s.get_folder_name() == "Specials" assert s.get_folder_name() == "Specials"
assert s.is_special() assert s.is_special()
def test_negative_aired_raises(self): # ── Mode derivation ────────────────────────────────────────────────
with pytest.raises(ValueError):
Season(season_number=1, aired_episodes=-1)
def test_aired_cannot_exceed_expected(self): def test_mode_pack_when_no_episodes(self):
with pytest.raises(ValueError):
Season(season_number=1, expected_episodes=5, aired_episodes=6)
def test_add_episode_rejects_mismatched_season(self):
s = Season(season_number=1) s = Season(season_number=1)
ep = Episode(season_number=2, episode_number=1, title="x") assert s.mode == SeasonMode.PACK
with pytest.raises(ValueError):
s.add_episode(ep)
def test_add_episode_replaces_same_number(self): def test_mode_episodic_when_episodes_present(self):
s = Season(season_number=1) s = Season(season_number=1, episodes=(self._ep(1),))
s.add_episode(self._ep(1)) assert s.mode == SeasonMode.EPISODIC
s.add_episode(Episode(season_number=1, episode_number=1, title="Replaced"))
assert s.episodes[EpisodeNumber(1)].title == "Replaced"
def test_str_uses_name_when_present(self): # ── Episode access ─────────────────────────────────────────────────
s = Season(season_number=1, name="Pilot Season")
assert "Pilot Season" in str(s)
# ── 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): def test_get_episode_returns_none_when_absent(self):
# Conservative: no aired count → cannot claim complete s = Season(season_number=1, episodes=(self._ep(1),))
s = Season(season_number=1) assert s.get_episode(EpisodeNumber(99)) is None
s.add_episode(self._ep(1))
assert s.is_complete() is False
def test_is_complete_when_owning_all_aired(self): def test_episode_count_reflects_tuple_size(self):
s = Season(season_number=1, aired_episodes=3) s = Season(
for i in (1, 2, 3): season_number=1,
s.add_episode(self._ep(i)) episodes=(self._ep(1), self._ep(2), self._ep(3)),
assert s.is_complete() is True )
assert s.episode_count == 3
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() == []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -342,40 +284,30 @@ class TestSeason:
class TestTVShow: class TestTVShow:
def _show(self, **kwargs) -> TVShow: def _show(self, **kwargs) -> TVShow:
defaults = dict( defaults = dict(imdb_id="tt0903747", title="Breaking Bad")
imdb_id="tt0903747",
title="Breaking Bad",
status="ended",
)
defaults.update(kwargs) defaults.update(kwargs)
return TVShow(**defaults) return TVShow(**defaults)
# ── Construction & coercion ──────────────────────────────────────── # ── Construction & coercion ────────────────────────────────────────
def test_basic_creation(self): def test_basic_creation(self):
show = self._show(expected_seasons=5) show = self._show()
assert show.title == "Breaking Bad" assert show.title == "Breaking Bad"
assert show.expected_seasons == 5 assert show.seasons == ()
assert show.seasons == {}
assert show.seasons_count == 0 assert show.seasons_count == 0
assert show.episode_count == 0
def test_coerces_string_imdb_id(self): def test_coerces_string_imdb_id(self):
assert isinstance(self._show().imdb_id, ImdbId) 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): def test_invalid_imdb_id_type_raises(self):
with pytest.raises(ValueError): 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): def test_get_folder_name_replaces_spaces(self):
assert self._show(title="Breaking Bad").get_folder_name() == "Breaking.Bad" assert self._show(title="Breaking Bad").get_folder_name() == "Breaking.Bad"
@@ -389,77 +321,224 @@ class TestTVShow:
assert "Breaking Bad" in str(show) assert "Breaking Bad" in str(show)
assert "tt0903747" in repr(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): def test_add_episode_creates_missing_season(self):
show = self._show() show = (
show.add_episode(Episode(season_number=1, episode_number=1, title="Pilot")) TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
assert SeasonNumber(1) in show.seasons .add_episode(self._ep(1, 1))
.build()
)
assert show.seasons_count == 1 assert show.seasons_count == 1
assert show.get_season(SeasonNumber(1)) is not None
assert show.episode_count == 1 assert show.episode_count == 1
def test_add_episode_reuses_existing_season(self): def test_add_episode_reuses_existing_season(self):
show = self._show() show = (
show.add_episode(Episode(season_number=1, episode_number=1, title="A")) TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
show.add_episode(Episode(season_number=1, episode_number=2, title="B")) .add_episode(self._ep(1, 1))
.add_episode(self._ep(1, 2))
.build()
)
assert show.seasons_count == 1 assert show.seasons_count == 1
assert show.episode_count == 2 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): def test_add_season_replaces_existing(self):
show = self._show() first = Season(season_number=1, episodes=(self._ep(1, 1),))
s1 = Season(season_number=1, aired_episodes=10) second = Season(
show.add_season(s1) season_number=1, episodes=(self._ep(1, 5), self._ep(1, 6))
s1bis = Season(season_number=1, aired_episodes=5) )
show.add_season(s1bis) show = (
assert show.seasons[SeasonNumber(1)] is s1bis 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): def test_season_builder_via_int(self):
assert self._show().collection_status() == CollectionStatus.EMPTY 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): def test_set_title_and_tmdb_id(self):
show = self._show() show = (
s = Season(season_number=1, aired_episodes=3) TVShowBuilder(imdb_id="tt0903747", title="Initial")
s.add_episode(Episode(season_number=1, episode_number=1, title="x")) .set_title("Updated")
show.add_season(s) .set_tmdb_id(1396)
assert show.collection_status() == CollectionStatus.PARTIAL .build()
)
assert show.title == "Updated"
assert show.tmdb_id == 1396
def test_collection_status_complete(self): def test_from_existing_round_trip(self):
show = self._show(expected_seasons=1) original = (
s = Season(season_number=1, aired_episodes=2) TVShowBuilder(
for n in (1, 2): imdb_id="tt0903747",
s.add_episode(Episode(season_number=1, episode_number=n, title=f"e{n}")) title="Breaking Bad",
show.add_season(s) tmdb_id=1396,
assert show.collection_status() == CollectionStatus.COMPLETE )
.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): def test_from_existing_then_add_extends(self):
# Seasons we own are complete, but expected_seasons says more exist. original = (
show = self._show(expected_seasons=2) TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
s = Season(season_number=1, aired_episodes=1) .add_episode(self._ep(1, 1))
s.add_episode(Episode(season_number=1, episode_number=1, title="x")) .build()
show.add_season(s) )
assert show.collection_status() == CollectionStatus.PARTIAL extended = (
TVShowBuilder.from_existing(original).add_episode(self._ep(1, 2)).build()
def test_is_complete_series_requires_ended_and_complete(self): )
show = self._show(status="ongoing", expected_seasons=1) assert extended.episode_count == 2
s = Season(season_number=1, aired_episodes=1) assert original.episode_count == 1 # original untouched
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)]
@@ -23,7 +23,6 @@ from alfred.domain.tv_shows.entities import Episode, TVShow
from alfred.domain.tv_shows.value_objects import ( from alfred.domain.tv_shows.value_objects import (
EpisodeNumber, EpisodeNumber,
SeasonNumber, SeasonNumber,
ShowStatus,
) )
from alfred.infrastructure.filesystem.filesystem_operations import ( from alfred.infrastructure.filesystem.filesystem_operations import (
create_folder, create_folder,
@@ -171,8 +170,6 @@ def _show() -> TVShow:
return TVShow( return TVShow(
imdb_id=ImdbId("tt0773262"), imdb_id=ImdbId("tt0773262"),
title="Dexter", title="Dexter",
expected_seasons=8,
status=ShowStatus.ENDED,
) )