feat(.alfred v2 — Phase 2): Pydantic sidecars, atomic repos, auto-heal index

Spec: specs/dot_alfred_v2.md (Phase 2).

New package alfred/infrastructure/persistence/dot_alfred/v2/:
  * sidecar_release.py / sidecar_root.py — Pydantic DTOs
    (extra="forbid", frozen=True) for per-item sidecars and the
    library-root index. schema_version enforced via model_validator.
  * serializer.py — read_yaml / atomic_write_yaml (.tmp + os.replace).
    SidecarSchemaError wraps YAML + Pydantic errors uniformly.
  * bridge.py — lossless domain <-> sidecar for SeriesRelease /
    MovieRelease; projection-only show_index_entry_from /
    movie_index_entry_from with multi-episode-file flattening.
  * repository.py — DotAlfredSeriesReleaseRepository /
    DotAlfredMovieReleaseRepository (log+skip on corruption),
    DotAlfredTVShowLibraryIndex / DotAlfredMovieLibraryIndex with
    silent auto-heal on missing/corrupt index reads. Writes never
    auto-heal (read paths handle that).

TMDB client extensions:
  * TmdbSeasonInfo / TmdbShowInfo DTOs + pure parse_tv_show_info.
  * TMDBClient.get_tv_show_info aggregates /tv/{id} +
    /tv/{id}/external_ids.

Domain change:
  * SubtitleTrack gains is_sdh: bool = False, populated from
    ffprobe's hearing_impaired disposition. Required for v2 sidecar
    parity (spec replaces v1's type: "sdh" with explicit flag).
    Default keeps every existing caller unchanged.

Tests: 37 new v2 integration tests on tmp_path (round-trips, atomic
writes, schema mismatch handling, anchor warnings, auto-heal paths)
plus 16 TMDB DTO tests. Full suite: 1240 -> 1277 passed.

Implementation notes filed in .claude/specs/dot_alfred_v2_notes.md
(strict=True trade-off, upsert signature deviation from spec, etc.).

Phases 3-5 (TVShow/Movie refactor to TMDB-only, rescan_show rewrite,
v1 deletion + wiring) are next.
This commit is contained in:
2026-05-25 16:01:39 +02:00
parent c0f6d01048
commit e65c1df229
18 changed files with 2565 additions and 3 deletions
+53
View File
@@ -17,6 +17,59 @@ callers).
### Added ### Added
- **`.alfred` v2 — Phase 2: new persistence package + TMDB client
extensions.** Second phase of `specs/dot_alfred_v2.md` on branch
`refactor/dot-alfred-v2`. The new
`alfred/infrastructure/persistence/dot_alfred/v2/` package ships
the full v2 sidecar stack while leaving v1 (and the existing
`TVShow` aggregate) untouched — Phase 3 is the cutover.
- **Pydantic DTOs** — `SeriesReleaseSidecar` /
`MovieReleaseSidecar` (per-item), `TVShowLibraryIndexSidecar` /
`MovieLibraryIndexSidecar` (library-root index). All built on a
common `_Strict` base (`extra="forbid"`, `frozen=True`) with a
`@model_validator` enforcing `schema_version == 1`.
- **Track entries** — `AudioTrackEntry` / `SubtitleEntry` (sidecar
cache shape, slimmed from the domain track types). `SubtitleEntry`
carries `is_forced` + `is_sdh` as explicit booleans (v1's
`type: "sdh"` overload is gone).
- **Serializer** — `read_yaml` / `atomic_write_yaml` helpers
centralize YAML I/O and atomic writes (`.tmp + os.replace`).
`SidecarSchemaError` wraps both YAML parse errors and Pydantic
validation errors for uniform catch-and-skip semantics.
- **Bridge** — lossless `domain ↔ sidecar` conversion for
`SeriesRelease` / `MovieRelease` (round-trippable, including
multi-episode ranges and `is_sdh` subtitles); one-way projection
for library-index entries (`show_index_entry_from`,
`movie_index_entry_from`) that flattens multi-episode files into
per-TMDB-slot maps in `seasons[*].episodes`.
- **Repositories** —
`DotAlfredSeriesReleaseRepository` /
`DotAlfredMovieReleaseRepository` walk `library_root/*/` with
log+skip on corruption; **`DotAlfredTVShowLibraryIndex`** /
**`DotAlfredMovieLibraryIndex`** auto-heal silently on missing or
corrupt index files by rebuilding from the per-item sidecars
(healed entries keep TMDB-cached fields as placeholders until the
next sync repopulates them). Writes are atomic and never auto-heal
(read paths handle that).
- **TMDB client extensions** — `TmdbSeasonInfo` / `TmdbShowInfo`
DTOs + `TMDBClient.get_tv_show_info(tmdb_id)` aggregating
`/tv/{id}` + `/tv/{id}/external_ids`. The parsing logic is a pure
function (`parse_tv_show_info`) testable without HTTP, with an
injectable reference date for deterministic `aired` flag tests.
- **`is_sdh` flag on `SubtitleTrack`.** Added to
`alfred/domain/shared/media.py::SubtitleTrack` to mirror ffprobe's
`hearing_impaired` disposition. Wired through the ffprobe layer
(`ffprobe_prober.py`) and the v2 sidecar bridge so SDH information
round-trips end-to-end. Defaults to `False` — backwards-compatible
for every existing caller.
- **37 v2 integration tests** on `tmp_path` covering round-trips
(domain ↔ sidecar ↔ YAML ↔ domain), atomic writes (no `.tmp`
leftovers), per-item log+skip on corruption / schema mismatch,
movie anchor-mismatch warning, full upsert / find / delete on both
library indexes, and the auto-heal path on missing / corrupt /
schema-mismatched index files. **16 TMDB DTO tests** for the new
`parse_tv_show_info` pure function.
- **`.alfred` v2 — Phase 1: new `releases/` domain.** First step of - **`.alfred` v2 — Phase 1: new `releases/` domain.** First step of
`specs/dot_alfred_v2.md` on branch `refactor/dot-alfred-v2`. The `specs/dot_alfred_v2.md` on branch `refactor/dot-alfred-v2`. The
new `alfred/domain/releases/` package introduces a filesystem-only new `alfred/domain/releases/` package introduces a filesystem-only
+11 -1
View File
@@ -44,13 +44,23 @@ class AudioTrack:
@dataclass(frozen=True) @dataclass(frozen=True)
class SubtitleTrack: class SubtitleTrack:
"""A single embedded subtitle track as reported by ffprobe.""" """A single embedded subtitle track as reported by ffprobe.
ffprobe reports ``forced`` / ``default`` / ``hearing_impaired`` as
independent disposition flags — we mirror that shape directly.
``is_sdh`` flags hearing-impaired tracks (called "SDH" in the
Anglo-Saxon distribution world: subtitles for the deaf and hard of
hearing, with non-speech audio cues). v2 ``.alfred`` sidecars
persist this flag explicitly; v1's ``type: "sdh"`` string overload
is gone.
"""
index: int index: int
codec: str | None # subrip, ass, hdmv_pgs_subtitle, … codec: str | None # subrip, ass, hdmv_pgs_subtitle, …
language: str | None # ISO 639-2: fre, eng, und, … language: str | None # ISO 639-2: fre, eng, und, …
is_default: bool = False is_default: bool = False
is_forced: bool = False is_forced: bool = False
is_sdh: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
+24 -1
View File
@@ -8,7 +8,7 @@ from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.settings import Settings, settings from alfred.settings import Settings, settings
from .dto import MediaResult from .dto import MediaResult, TmdbShowInfo, parse_tv_show_info
from .exceptions import ( from .exceptions import (
TMDBAPIError, TMDBAPIError,
TMDBConfigurationError, TMDBConfigurationError,
@@ -279,6 +279,29 @@ class TMDBClient:
""" """
return self._make_request(f"/tv/{tv_id}") return self._make_request(f"/tv/{tv_id}")
def get_tv_show_info(self, tmdb_id: int) -> TmdbShowInfo:
"""
Aggregate ``/tv/{id}`` + ``/tv/{id}/external_ids`` into a
:class:`TmdbShowInfo` — the shape consumed by the v2 library-root
index cache.
Args:
tmdb_id: TMDB TV show ID.
Returns:
:class:`TmdbShowInfo` with ``imdb_id`` (when available),
``status``, and one :class:`TmdbSeasonInfo` per season.
Raises:
TMDBAPIError: if either HTTP call fails.
TMDBNotFoundError: if the show id is unknown.
ValueError: if the TMDB payload is missing required fields
(``id``, ``name``, ``status``).
"""
details = self.get_tv_details(tmdb_id)
external = self.get_external_ids("tv", tmdb_id)
return parse_tv_show_info(details, external)
def is_configured(self) -> bool: def is_configured(self) -> bool:
""" """
Check if TMDB client is properly configured. Check if TMDB client is properly configured.
+131 -1
View File
@@ -1,6 +1,10 @@
"""TMDB Data Transfer Objects.""" """TMDB Data Transfer Objects."""
from dataclasses import dataclass from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from typing import Any
@dataclass @dataclass
@@ -26,3 +30,129 @@ class ExternalIds:
facebook_id: str | None = None facebook_id: str | None = None
instagram_id: str | None = None instagram_id: str | None = None
twitter_id: str | None = None twitter_id: str | None = None
# ────────────────────────────────────────────────────────────────────────────
# TV show details — used by the v2 library-root index cache
# ────────────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class TmdbSeasonInfo:
"""One season block extracted from the ``/tv/{id}`` payload.
``aired`` is derived from ``air_date`` vs the current date (``today``),
not stored directly by TMDB. A season with no ``air_date`` is treated
as not yet aired.
"""
number: int
episode_count: int
aired: bool
@dataclass(frozen=True)
class TmdbShowInfo:
"""TMDB-cached identity for one TV show.
Populated by :meth:`TMDBClient.get_tv_show_info`. Carries only the
fields the v2 library index needs to cache; richer details remain
on-demand via the raw client methods.
"""
tmdb_id: int
imdb_id: str | None
name: str
status: str
seasons: tuple[TmdbSeasonInfo, ...] = field(default_factory=tuple)
def parse_tv_show_info(
details: dict[str, Any],
external_ids: dict[str, Any],
*,
today: date | None = None,
) -> TmdbShowInfo:
"""Build a :class:`TmdbShowInfo` from raw TMDB payloads.
Pure function — no HTTP, no I/O. The HTTP layer
(:meth:`TMDBClient.get_tv_show_info`) calls this with the JSON it
received from TMDB.
Args:
details: payload from ``/tv/{tmdb_id}`` (``name``, ``status``,
``seasons`` …).
external_ids: payload from ``/tv/{tmdb_id}/external_ids``
(``imdb_id`` mostly).
today: optional reference date for the ``aired`` flag — defaults
to :meth:`date.today`. Injectable for deterministic tests.
Raises:
ValueError: if a required field (``id``, ``name``, ``status``)
is missing from ``details``.
"""
ref = today or date.today()
tmdb_id = details.get("id")
if not isinstance(tmdb_id, int):
raise ValueError(
f"TMDB show payload missing/invalid 'id': {tmdb_id!r}"
)
name = details.get("name")
if not isinstance(name, str) or not name:
raise ValueError(
f"TMDB show payload missing/invalid 'name': {name!r}"
)
status = details.get("status")
if not isinstance(status, str) or not status:
raise ValueError(
f"TMDB show payload missing/invalid 'status': {status!r}"
)
imdb_id_raw = external_ids.get("imdb_id")
imdb_id = imdb_id_raw if isinstance(imdb_id_raw, str) and imdb_id_raw else None
seasons_raw = details.get("seasons", []) or []
seasons = tuple(_parse_season(s, ref) for s in seasons_raw)
return TmdbShowInfo(
tmdb_id=tmdb_id,
imdb_id=imdb_id,
name=name,
status=status,
seasons=seasons,
)
def _parse_season(raw: dict[str, Any], today: date) -> TmdbSeasonInfo:
number = raw.get("season_number")
if not isinstance(number, int):
raise ValueError(
f"TMDB season missing/invalid 'season_number': {number!r}"
)
episode_count = raw.get("episode_count")
if not isinstance(episode_count, int):
raise ValueError(
f"TMDB season missing/invalid 'episode_count': {episode_count!r}"
)
air_date_raw = raw.get("air_date")
aired = _is_aired(air_date_raw, today)
return TmdbSeasonInfo(
number=number,
episode_count=episode_count,
aired=aired,
)
def _is_aired(air_date_raw: Any, today: date) -> bool:
"""True if ``air_date_raw`` (YYYY-MM-DD string) is on or before ``today``.
An empty / missing / unparseable air_date counts as not yet aired —
consistent with TMDB's behavior for announced-but-unscheduled seasons.
"""
if not isinstance(air_date_raw, str) or not air_date_raw:
return False
try:
return date.fromisoformat(air_date_raw) <= today
except ValueError:
return False
@@ -0,0 +1,59 @@
"""`.alfred` v2 — strict Pydantic sidecars + library-root index.
v2 separates two kinds of files:
* **Per-item sidecars** (``<show>/.alfred``, ``<movie>/.alfred``) —
pure release data: what's physically on disk.
* **Library-root index** (``tv_shows/.alfred.index``,
``movies/.alfred.index``) — TMDB-cached identity + grep-friendly
folder/path projection.
All DTOs are Pydantic v2 models with strict validation
(``extra="forbid"`` + explicit ``schema_version`` check). The
serializer handles YAML I/O and atomic writes; the bridge translates
between DTOs and domain aggregates.
See ``.claude/specs/dot_alfred_v2.md`` for the full schema.
"""
from .serializer import (
SCHEMA_VERSION,
SidecarSchemaError,
atomic_write_yaml,
read_yaml,
)
from .sidecar_release import (
AudioTrackEntry,
EpisodeReleaseEntry,
MovieReleaseSidecar,
SeasonReleaseEntry,
SeriesReleaseSidecar,
SubtitleEntry,
)
from .sidecar_root import (
MovieIndexEntry,
MovieLibraryIndexSidecar,
SeasonIndexEntry,
ShowIndexEntry,
ShowIndexMetadata,
TVShowLibraryIndexSidecar,
)
__all__ = [
"SCHEMA_VERSION",
"AudioTrackEntry",
"EpisodeReleaseEntry",
"MovieIndexEntry",
"MovieLibraryIndexSidecar",
"MovieReleaseSidecar",
"SeasonIndexEntry",
"SeasonReleaseEntry",
"SeriesReleaseSidecar",
"ShowIndexEntry",
"ShowIndexMetadata",
"SidecarSchemaError",
"SubtitleEntry",
"TVShowLibraryIndexSidecar",
"atomic_write_yaml",
"read_yaml",
]
@@ -0,0 +1,318 @@
"""Translate between v2 sidecar DTOs and domain aggregates.
Four conversion families, each lossless in both directions where it
makes sense:
* :class:`SeriesRelease` ↔ :class:`SeriesReleaseSidecar` — per-show
release file (round-trippable).
* :class:`MovieRelease` ↔ :class:`MovieReleaseSidecar` — per-movie
release file (round-trippable).
* :class:`TmdbShowInfo` + :class:`SeriesRelease` → :class:`ShowIndexEntry`
— projection only (the index entry combines TMDB cache + flattened
release episodes map; the reverse direction is not needed because the
per-show sidecar is the source of truth for releases and TMDB calls
populate identity).
* (movie identity, release_year) + :class:`MovieRelease` →
:class:`MovieIndexEntry` — same shape.
The bridge owns:
* Pydantic DTO ↔ domain VO unwrapping (``TmdbId`` ↔ ``int``,
``ImdbId`` ↔ ``str | None``, ``EpisodeNumber`` ↔ ``int`` …).
* The track-shape diff between :class:`AudioTrack` /
:class:`SubtitleTrack` (domain, ffprobe-shaped with ``index`` and
``is_default``) and :class:`AudioTrackEntry` / :class:`SubtitleEntry`
(sidecar cache, slimmed). ``index`` and ``is_default`` are not
persisted; ``is_sdh`` is persisted on subtitles.
* The episode-slot flattening — one physical file with
``EpisodeRange(start=2, end=3)`` becomes two entries in the
``ShowIndexEntry.seasons[*].episodes`` map (``"E02"`` and ``"E03"``,
both pointing at the same path).
"""
from __future__ import annotations
from datetime import datetime
from ....api.tmdb.dto import TmdbShowInfo
from .....domain.releases.entities import (
EpisodeRelease,
MovieRelease,
SeasonRelease,
SeriesRelease,
TrackProfile,
)
from .....domain.releases.value_objects import EpisodeRange, ReleaseMode
from .....domain.shared.media import AudioTrack, SubtitleTrack
from .....domain.shared.value_objects import FilePath, ImdbId, TmdbId
from .....domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
from .sidecar_release import (
SCHEMA_VERSION,
AudioTrackEntry,
EpisodeReleaseEntry,
MovieReleaseSidecar,
SeasonReleaseEntry,
SeriesReleaseSidecar,
SubtitleEntry,
)
from .sidecar_root import (
MovieIndexEntry,
SeasonIndexEntry,
ShowIndexEntry,
ShowIndexMetadata,
)
__all__ = [
"movie_index_entry_from",
"movie_release_from_sidecar",
"movie_release_to_sidecar",
"series_release_from_sidecar",
"series_release_to_sidecar",
"show_index_entry_from",
]
# ════════════════════════════════════════════════════════════════════════════
# Series release — domain ↔ sidecar (round-trippable)
# ════════════════════════════════════════════════════════════════════════════
def series_release_to_sidecar(release: SeriesRelease) -> SeriesReleaseSidecar:
"""Project a :class:`SeriesRelease` to its per-show sidecar DTO."""
return SeriesReleaseSidecar(
schema_version=SCHEMA_VERSION,
tmdb_id=release.tmdb_id.value,
imdb_id=str(release.imdb_id) if release.imdb_id is not None else None,
releases=tuple(_season_to_entry(s) for s in release.seasons),
)
def series_release_from_sidecar(sidecar: SeriesReleaseSidecar) -> SeriesRelease:
"""Rebuild a :class:`SeriesRelease` from its per-show sidecar DTO."""
return SeriesRelease(
tmdb_id=TmdbId(sidecar.tmdb_id),
imdb_id=ImdbId(sidecar.imdb_id) if sidecar.imdb_id else None,
seasons=tuple(_season_from_entry(s) for s in sidecar.releases),
)
def _season_to_entry(season: SeasonRelease) -> SeasonReleaseEntry:
return SeasonReleaseEntry(
season=season.season_number.value,
mode=season.mode,
folder=season.folder,
episodes=tuple(_episode_to_entry(ep) for ep in season.episodes),
)
def _season_from_entry(entry: SeasonReleaseEntry) -> SeasonRelease:
return SeasonRelease(
season_number=SeasonNumber(entry.season),
folder=entry.folder,
mode=ReleaseMode(entry.mode),
episodes=tuple(_episode_from_entry(ep) for ep in entry.episodes),
)
def _episode_to_entry(episode: EpisodeRelease) -> EpisodeReleaseEntry:
return EpisodeReleaseEntry(
start=episode.episodes.start.value,
end=episode.episodes.end.value,
file=str(episode.file_path),
audio=tuple(_audio_to_entry(t) for t in episode.tracks.audio_tracks),
subtitles=tuple(_sub_to_entry(t) for t in episode.tracks.subtitle_tracks),
)
def _episode_from_entry(entry: EpisodeReleaseEntry) -> EpisodeRelease:
return EpisodeRelease(
episodes=EpisodeRange(
start=EpisodeNumber(entry.start),
end=EpisodeNumber(entry.end),
),
file_path=FilePath(entry.file),
tracks=TrackProfile(
audio_tracks=tuple(_audio_from_entry(a, i) for i, a in enumerate(entry.audio)),
subtitle_tracks=tuple(
_sub_from_entry(s, i) for i, s in enumerate(entry.subtitles)
),
),
)
# ════════════════════════════════════════════════════════════════════════════
# Movie release — domain ↔ sidecar (round-trippable)
# ════════════════════════════════════════════════════════════════════════════
def movie_release_to_sidecar(release: MovieRelease) -> MovieReleaseSidecar:
"""Project a :class:`MovieRelease` to its per-movie sidecar DTO."""
return MovieReleaseSidecar(
schema_version=SCHEMA_VERSION,
tmdb_id=release.tmdb_id.value,
imdb_id=str(release.imdb_id) if release.imdb_id is not None else None,
folder=release.folder,
file=str(release.file_path),
audio=tuple(_audio_to_entry(t) for t in release.tracks.audio_tracks),
subtitles=tuple(_sub_to_entry(t) for t in release.tracks.subtitle_tracks),
)
def movie_release_from_sidecar(sidecar: MovieReleaseSidecar) -> MovieRelease:
"""Rebuild a :class:`MovieRelease` from its per-movie sidecar DTO."""
return MovieRelease(
tmdb_id=TmdbId(sidecar.tmdb_id),
imdb_id=ImdbId(sidecar.imdb_id) if sidecar.imdb_id else None,
folder=sidecar.folder,
file_path=FilePath(sidecar.file),
tracks=TrackProfile(
audio_tracks=tuple(
_audio_from_entry(a, i) for i, a in enumerate(sidecar.audio)
),
subtitle_tracks=tuple(
_sub_from_entry(s, i) for i, s in enumerate(sidecar.subtitles)
),
),
)
# ════════════════════════════════════════════════════════════════════════════
# Library-root index — projection only
# ════════════════════════════════════════════════════════════════════════════
def show_index_entry_from(
info: TmdbShowInfo,
release: SeriesRelease | None,
*,
path: str,
fetched_at: datetime,
) -> ShowIndexEntry:
"""Combine a TMDB cache snapshot + (optional) on-disk release into one
library-index entry.
The TMDB DTO supplies identity and per-season ``episode_count`` +
``aired`` flags. The release (if present) supplies the
``episodes:`` slot map per season — flattened so a multi-episode
file appears under every TMDB slot it covers.
A season exists in the index entry only if TMDB knows about it.
A release season unknown to TMDB (e.g. a manually-added "Season 0"
specials folder) is silently ignored at the index level — it's
still recorded faithfully in the per-show release sidecar, which
is the source of truth for what's on disk.
"""
release_by_season = (
{s.season_number.value: s for s in release.seasons} if release else {}
)
seasons = tuple(
SeasonIndexEntry(
number=s.number,
episode_count=s.episode_count,
aired=s.aired,
episodes=_flatten_season_episodes(release_by_season.get(s.number)),
)
for s in info.seasons
)
return ShowIndexEntry(
tmdb_id=info.tmdb_id,
imdb_id=info.imdb_id,
name=info.name,
status=info.status,
metadata=ShowIndexMetadata(path=path, fetched_at=fetched_at),
seasons=seasons,
)
def movie_index_entry_from(
release: MovieRelease,
*,
name: str,
release_year: int | None,
path: str,
fetched_at: datetime,
) -> MovieIndexEntry:
"""Project a movie release + identity facts into one library-index entry.
Movies don't have a ``TmdbMovieInfo`` DTO yet (no per-movie TMDB
cache surface defined in Phase 2), so identity facts are passed
explicitly by the caller. The release supplies ``tmdb_id`` /
``imdb_id``; ``name`` and ``release_year`` come from the caller's
TMDB lookup (or a future ``TmdbMovieInfo`` DTO when one ships).
"""
return MovieIndexEntry(
tmdb_id=release.tmdb_id.value,
imdb_id=str(release.imdb_id) if release.imdb_id is not None else None,
name=name,
release_year=release_year,
metadata=ShowIndexMetadata(path=path, fetched_at=fetched_at),
)
def _flatten_season_episodes(season: SeasonRelease | None) -> dict[str, str]:
"""Build the per-TMDB-slot ``{"E01": path, "E02": path …}`` map.
A multi-episode file (``EpisodeRange(start=2, end=3)``) appears
twice — once under ``"E02"`` and once under ``"E03"`` — with the
same path. This duplication is intentional (see the spec's
*Trade-offs* section): symmetric reads, grep-friendly lookups.
"""
if season is None:
return {}
out: dict[str, str] = {}
for ep in season.episodes:
path = str(ep.file_path)
for n in ep.episodes.numbers():
out[f"E{n.value:02d}"] = path
return out
# ════════════════════════════════════════════════════════════════════════════
# Track conversions — sidecar entry ↔ domain track
# ════════════════════════════════════════════════════════════════════════════
#
# The domain tracks (``AudioTrack`` / ``SubtitleTrack``) carry an
# ``index`` (ffprobe stream index) and ``is_default`` flag that are
# **not** persisted to the sidecar — the sidecar is a cache, not a
# full ffprobe dump. On read-back we synthesize ``index`` from the
# list position and default ``is_default`` to False; callers that
# need the original ffprobe view should re-probe.
def _audio_to_entry(track: AudioTrack) -> AudioTrackEntry:
return AudioTrackEntry(
codec=track.codec,
channels=track.channels,
channel_layout=track.channel_layout,
language=track.language,
)
def _audio_from_entry(entry: AudioTrackEntry, index: int) -> AudioTrack:
return AudioTrack(
index=index,
codec=entry.codec,
channels=entry.channels,
channel_layout=entry.channel_layout,
language=entry.language,
)
def _sub_to_entry(track: SubtitleTrack) -> SubtitleEntry:
return SubtitleEntry(
codec=track.codec,
language=track.language,
is_forced=track.is_forced,
is_sdh=track.is_sdh,
)
def _sub_from_entry(entry: SubtitleEntry, index: int) -> SubtitleTrack:
return SubtitleTrack(
index=index,
codec=entry.codec,
language=entry.language,
is_default=False,
is_forced=entry.is_forced,
is_sdh=entry.is_sdh,
)
@@ -0,0 +1,658 @@
"""Filesystem-backed implementations of the v2 ``.alfred`` repositories.
Four concrete classes — two per media type — back the abstract ports
defined in the domain (``SeriesReleaseRepository`` etc., wired in
Phase 5):
* :class:`DotAlfredSeriesReleaseRepository` — per-show ``.alfred``.
* :class:`DotAlfredMovieReleaseRepository` — per-movie ``.alfred``.
* :class:`DotAlfredTVShowLibraryIndex` — ``tv_shows/.alfred.index``
(with auto-heal on missing/corrupt reads).
* :class:`DotAlfredMovieLibraryIndex` — ``movies/.alfred.index``
(same auto-heal behavior).
Validation is delegated to the Pydantic DTOs (strict, ``extra=forbid``).
The repository layer only handles:
* YAML I/O (via :mod:`.serializer`).
* Atomic writes (``.tmp + os.replace``).
* Per-item log+skip on corruption (a single bad per-show sidecar must
not break the rest of the library walk).
* Auto-heal on the library index: when the index file is absent or
fails schema validation on load, the index is silently rebuilt by
walking ``library_root/*/`` and reading each per-item sidecar (the
source of truth for ``tmdb_id`` / ``imdb_id``). TMDB-cached fields
(``name``, ``status``, ``episode_count``, ``aired``) are left empty
until the next sync repopulates them.
* Anchor-mismatch warning: a per-show sidecar that lives at folder
``X`` but whose index entry says ``path: Y`` triggers a warning log
(and the heal path on the next mismatch is the caller's call).
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from pathlib import Path
from pydantic import ValidationError
from ....api.tmdb.dto import TmdbShowInfo
from .....domain.releases.entities import MovieRelease, SeriesRelease
from .....domain.shared.value_objects import ImdbId, TmdbId
from .bridge import (
movie_index_entry_from,
movie_release_from_sidecar,
movie_release_to_sidecar,
series_release_from_sidecar,
series_release_to_sidecar,
show_index_entry_from,
)
from .serializer import (
SCHEMA_VERSION,
SidecarSchemaError,
atomic_write_yaml,
read_yaml,
)
from .sidecar_release import MovieReleaseSidecar, SeriesReleaseSidecar
from .sidecar_root import (
MovieIndexEntry,
MovieLibraryIndexSidecar,
ShowIndexEntry,
TVShowLibraryIndexSidecar,
)
logger = logging.getLogger(__name__)
SIDECAR_FILENAME = ".alfred"
INDEX_FILENAME = ".alfred.index"
__all__ = [
"DotAlfredMovieLibraryIndex",
"DotAlfredMovieReleaseRepository",
"DotAlfredSeriesReleaseRepository",
"DotAlfredTVShowLibraryIndex",
"ShowFolderUnknown",
]
class ShowFolderUnknown(LookupError):
"""Raised by release repos when ``save()`` is called with a path
that doesn't exist on disk.
Repositories never invent folders — the upstream ``MediaOrganizer``
is responsible for placing files first. ``save()`` only writes the
sidecar next to them.
"""
# ════════════════════════════════════════════════════════════════════════════
# Series release repository — per-show ``.alfred``
# ════════════════════════════════════════════════════════════════════════════
class DotAlfredSeriesReleaseRepository:
"""Per-show ``.alfred`` reader/writer.
Args:
library_root: ``tv_shows/`` directory, containing one folder
per show.
The repository walks ``library_root/*/`` on each read; there is no
in-memory cache. Reads that fail (missing file, corrupt YAML,
schema mismatch) are logged and skipped — one bad sidecar does
not break the rest of the library walk.
"""
def __init__(self, library_root: Path) -> None:
self._library_root = Path(library_root)
# ── Reads ───────────────────────────────────────────────────────────────
def find_by_tmdb_id(self, tmdb_id: TmdbId) -> SeriesRelease | None:
"""Return the release whose sidecar carries ``tmdb_id``, or ``None``."""
for _folder, release in self._iter_library():
if release.tmdb_id == tmdb_id:
return release
return None
def find_all(self) -> list[SeriesRelease]:
"""Return every readable release under ``library_root/``.
Order matches sorted folder name (deterministic across runs).
Corrupt sidecars are skipped with a warning log.
"""
return [release for _folder, release in self._iter_library()]
# ── Writes ──────────────────────────────────────────────────────────────
def save(self, release: SeriesRelease, *, show_folder: str) -> None:
"""Atomically write ``release`` to ``<show_folder>/.alfred``.
Args:
release: the aggregate to persist.
show_folder: folder name **relative to** ``library_root``
(e.g. ``"Foundation"``). The caller is responsible for
knowing the folder — repositories never invent one.
Raises:
ShowFolderUnknown: if ``library_root/show_folder`` does
not exist on disk.
"""
show_dir = self._library_root / show_folder
if not show_dir.is_dir():
raise ShowFolderUnknown(
f"show folder does not exist on disk: {show_dir}"
)
sidecar = series_release_to_sidecar(release)
atomic_write_yaml(show_dir / SIDECAR_FILENAME, _dump_model(sidecar))
def delete(self, tmdb_id: TmdbId) -> bool:
"""Delete the sidecar of the show with ``tmdb_id``.
Returns ``True`` if a sidecar was found and removed, ``False``
otherwise. The folder itself is left untouched.
"""
for folder, release in self._iter_library():
if release.tmdb_id == tmdb_id:
(self._library_root / folder / SIDECAR_FILENAME).unlink()
return True
return False
# ── Internals ───────────────────────────────────────────────────────────
def _iter_library(self):
"""Yield ``(folder_name, SeriesRelease)`` for every readable sidecar."""
if not self._library_root.is_dir():
return
for entry in sorted(self._library_root.iterdir()):
if not entry.is_dir():
continue
sidecar_path = entry / SIDECAR_FILENAME
if not sidecar_path.is_file():
continue
release = _load_series_release(sidecar_path, expected_folder=entry.name)
if release is not None:
yield entry.name, release
# ════════════════════════════════════════════════════════════════════════════
# Movie release repository — per-movie ``.alfred``
# ════════════════════════════════════════════════════════════════════════════
class DotAlfredMovieReleaseRepository:
"""Per-movie ``.alfred`` reader/writer.
Mirrors :class:`DotAlfredSeriesReleaseRepository` for the movies
library — same walk-and-skip strategy, same atomic-write contract.
"""
def __init__(self, library_root: Path) -> None:
self._library_root = Path(library_root)
def find_by_tmdb_id(self, tmdb_id: TmdbId) -> MovieRelease | None:
for _folder, release in self._iter_library():
if release.tmdb_id == tmdb_id:
return release
return None
def find_all(self) -> list[MovieRelease]:
return [release for _folder, release in self._iter_library()]
def save(self, release: MovieRelease) -> None:
"""Atomically write ``release`` to ``<release.folder>/.alfred``.
Unlike :class:`DotAlfredSeriesReleaseRepository.save`, the
folder is carried by the aggregate itself (movies are
one-folder-one-file by convention), so no separate parameter
is needed.
Raises:
ShowFolderUnknown: if ``library_root/release.folder`` does
not exist on disk.
"""
movie_dir = self._library_root / release.folder
if not movie_dir.is_dir():
raise ShowFolderUnknown(
f"movie folder does not exist on disk: {movie_dir}"
)
sidecar = movie_release_to_sidecar(release)
atomic_write_yaml(movie_dir / SIDECAR_FILENAME, _dump_model(sidecar))
def delete(self, tmdb_id: TmdbId) -> bool:
for folder, release in self._iter_library():
if release.tmdb_id == tmdb_id:
(self._library_root / folder / SIDECAR_FILENAME).unlink()
return True
return False
def _iter_library(self):
if not self._library_root.is_dir():
return
for entry in sorted(self._library_root.iterdir()):
if not entry.is_dir():
continue
sidecar_path = entry / SIDECAR_FILENAME
if not sidecar_path.is_file():
continue
release = _load_movie_release(sidecar_path, expected_folder=entry.name)
if release is not None:
yield entry.name, release
# ════════════════════════════════════════════════════════════════════════════
# TV library-root index — ``tv_shows/.alfred.index``
# ════════════════════════════════════════════════════════════════════════════
class DotAlfredTVShowLibraryIndex:
"""Library-root index for TV shows.
Persists :class:`TVShowLibraryIndexSidecar` to
``library_root/.alfred.index``. On every read, if the index file
is absent or fails schema validation, the index is silently
rebuilt from the per-show sidecars (auto-heal). TMDB-cached
fields are left empty on healed entries — the next sync repopulates
them.
Writes are not auto-healed (a caller who attempts to write to a
corrupt-then-fixed index gets a clean read first via any of the
``find_*`` methods; the next ``upsert`` then writes a valid file).
"""
def __init__(
self,
library_root: Path,
*,
release_repo: DotAlfredSeriesReleaseRepository | None = None,
) -> None:
self._library_root = Path(library_root)
# The release repo is injected to enable auto-heal (it knows
# how to walk the per-show sidecars). Defaults to one over the
# same library_root so the index is self-sufficient.
self._release_repo = release_repo or DotAlfredSeriesReleaseRepository(
self._library_root
)
# ── Reads (auto-heal on missing/corrupt) ────────────────────────────────
def find_by_tmdb_id(self, tmdb_id: TmdbId) -> ShowIndexEntry | None:
for entry in self._load_or_heal().shows:
if entry.tmdb_id == tmdb_id.value:
return entry
return None
def find_by_imdb_id(self, imdb_id: ImdbId) -> ShowIndexEntry | None:
needle = str(imdb_id)
for entry in self._load_or_heal().shows:
if entry.imdb_id == needle:
return entry
return None
def find_by_path(self, path: str) -> ShowIndexEntry | None:
for entry in self._load_or_heal().shows:
if entry.metadata.path == path:
return entry
return None
def find_all(self) -> tuple[ShowIndexEntry, ...]:
return self._load_or_heal().shows
# ── Writes ──────────────────────────────────────────────────────────────
def upsert(
self,
info: TmdbShowInfo,
release: SeriesRelease | None,
*,
path: str,
fetched_at: datetime,
) -> None:
"""Insert or replace the index entry for ``info.tmdb_id``.
Args:
info: TMDB cache snapshot (identity + per-season episode
counts + aired flags).
release: optional on-disk release for the same show. When
present, its files are flattened into the per-season
``episodes:`` slot map (multi-episode files appear
under each covered slot). When absent, the slot map is
empty (the show is "known to TMDB but nothing on disk
yet" — a legitimate state).
path: folder name relative to ``library_root``.
fetched_at: timestamp of the TMDB sync that produced
``info``. Used by the TTL policy to decide future
refreshes.
The write is atomic. If the index doesn't exist yet, it's
created. Concurrent readers see either the old version or the
new one, never a torn file.
"""
new_entry = show_index_entry_from(
info, release, path=path, fetched_at=fetched_at
)
current = self._load_or_heal()
shows = tuple(
new_entry if e.tmdb_id == new_entry.tmdb_id else e
for e in current.shows
)
if not any(e.tmdb_id == new_entry.tmdb_id for e in shows):
shows = (*shows, new_entry)
self._write(TVShowLibraryIndexSidecar(
schema_version=SCHEMA_VERSION,
shows=shows,
))
def delete(self, tmdb_id: TmdbId) -> bool:
"""Remove the entry for ``tmdb_id`` if present."""
current = self._load_or_heal()
kept = tuple(e for e in current.shows if e.tmdb_id != tmdb_id.value)
if len(kept) == len(current.shows):
return False
self._write(TVShowLibraryIndexSidecar(
schema_version=SCHEMA_VERSION,
shows=kept,
))
return True
def heal(self) -> TVShowLibraryIndexSidecar:
"""Rebuild the index by walking the per-show sidecars.
Public entry point so callers can force a heal after detecting
drift (e.g. ``index.find_by_path`` returned an entry whose
``path`` no longer exists on disk). TMDB-cached fields on
healed entries are left empty (``name=""`` etc.) — the next
sync repopulates them.
Returns the fresh in-memory index after writing it atomically.
"""
sidecar = self._build_from_releases()
self._write(sidecar)
return sidecar
# ── Internals ───────────────────────────────────────────────────────────
@property
def _index_path(self) -> Path:
return self._library_root / INDEX_FILENAME
def _load_or_heal(self) -> TVShowLibraryIndexSidecar:
"""Return the parsed index, healing silently on missing/corrupt."""
path = self._index_path
if not path.is_file():
logger.info(
"library index missing at %s — healing from per-show sidecars",
path,
)
return self.heal()
try:
raw = read_yaml(path)
return TVShowLibraryIndexSidecar.model_validate(raw)
except (SidecarSchemaError, ValidationError) as exc:
logger.warning(
"library index at %s is corrupt (%s) — healing", path, exc
)
return self.heal()
def _build_from_releases(self) -> TVShowLibraryIndexSidecar:
"""Walk the per-show sidecars and synthesize an index.
TMDB-cached fields (``name``, ``status``, seasons) are left
empty placeholders because we cannot fabricate them without a
TMDB round-trip — the next sync fills them in. The ``path``
is recovered from the folder name, and ``fetched_at`` is set
to "now" so the TTL policy treats the entry as fresh-but-empty
until a sync runs.
"""
now = datetime.now(UTC)
shows: list[ShowIndexEntry] = []
for folder, release in self._release_repo._iter_library():
shows.append(
ShowIndexEntry(
tmdb_id=release.tmdb_id.value,
imdb_id=str(release.imdb_id) if release.imdb_id else None,
name=folder, # placeholder until TMDB sync supplies the real name
status="unknown", # placeholder until TMDB sync supplies status
metadata={
"path": folder,
"fetched_at": now,
},
seasons=(),
)
)
return TVShowLibraryIndexSidecar(
schema_version=SCHEMA_VERSION,
shows=tuple(shows),
)
def _write(self, sidecar: TVShowLibraryIndexSidecar) -> None:
self._library_root.mkdir(parents=True, exist_ok=True)
atomic_write_yaml(self._index_path, _dump_model(sidecar))
# ════════════════════════════════════════════════════════════════════════════
# Movie library-root index — ``movies/.alfred.index``
# ════════════════════════════════════════════════════════════════════════════
class DotAlfredMovieLibraryIndex:
"""Library-root index for movies — same shape as the TV index.
Auto-heals on missing/corrupt loads by walking the per-movie
sidecars. Identity fields (``name``, ``release_year``) are left
empty placeholders on healed entries; the next sync fills them in.
"""
def __init__(
self,
library_root: Path,
*,
release_repo: DotAlfredMovieReleaseRepository | None = None,
) -> None:
self._library_root = Path(library_root)
self._release_repo = release_repo or DotAlfredMovieReleaseRepository(
self._library_root
)
# ── Reads (auto-heal on missing/corrupt) ────────────────────────────────
def find_by_tmdb_id(self, tmdb_id: TmdbId) -> MovieIndexEntry | None:
for entry in self._load_or_heal().movies:
if entry.tmdb_id == tmdb_id.value:
return entry
return None
def find_by_imdb_id(self, imdb_id: ImdbId) -> MovieIndexEntry | None:
needle = str(imdb_id)
for entry in self._load_or_heal().movies:
if entry.imdb_id == needle:
return entry
return None
def find_by_path(self, path: str) -> MovieIndexEntry | None:
for entry in self._load_or_heal().movies:
if entry.metadata.path == path:
return entry
return None
def find_all(self) -> tuple[MovieIndexEntry, ...]:
return self._load_or_heal().movies
# ── Writes ──────────────────────────────────────────────────────────────
def upsert(
self,
release: MovieRelease,
*,
name: str,
release_year: int | None,
path: str,
fetched_at: datetime,
) -> None:
"""Insert or replace the index entry for ``release.tmdb_id``.
Args:
release: the on-disk movie release (carries ``tmdb_id`` /
``imdb_id``).
name: TMDB title (no per-movie TMDB DTO yet — see the
bridge module's note).
release_year: TMDB ``release_date`` year, or ``None``.
path: folder name relative to ``library_root``.
fetched_at: TMDB sync timestamp.
"""
new_entry = movie_index_entry_from(
release,
name=name,
release_year=release_year,
path=path,
fetched_at=fetched_at,
)
current = self._load_or_heal()
movies = tuple(
new_entry if e.tmdb_id == new_entry.tmdb_id else e
for e in current.movies
)
if not any(e.tmdb_id == new_entry.tmdb_id for e in movies):
movies = (*movies, new_entry)
self._write(MovieLibraryIndexSidecar(
schema_version=SCHEMA_VERSION,
movies=movies,
))
def delete(self, tmdb_id: TmdbId) -> bool:
current = self._load_or_heal()
kept = tuple(e for e in current.movies if e.tmdb_id != tmdb_id.value)
if len(kept) == len(current.movies):
return False
self._write(MovieLibraryIndexSidecar(
schema_version=SCHEMA_VERSION,
movies=kept,
))
return True
def heal(self) -> MovieLibraryIndexSidecar:
sidecar = self._build_from_releases()
self._write(sidecar)
return sidecar
# ── Internals ───────────────────────────────────────────────────────────
@property
def _index_path(self) -> Path:
return self._library_root / INDEX_FILENAME
def _load_or_heal(self) -> MovieLibraryIndexSidecar:
path = self._index_path
if not path.is_file():
logger.info(
"library index missing at %s — healing from per-movie sidecars",
path,
)
return self.heal()
try:
raw = read_yaml(path)
return MovieLibraryIndexSidecar.model_validate(raw)
except (SidecarSchemaError, ValidationError) as exc:
logger.warning(
"library index at %s is corrupt (%s) — healing", path, exc
)
return self.heal()
def _build_from_releases(self) -> MovieLibraryIndexSidecar:
now = datetime.now(UTC)
movies: list[MovieIndexEntry] = []
for folder, release in self._release_repo._iter_library():
movies.append(
MovieIndexEntry(
tmdb_id=release.tmdb_id.value,
imdb_id=str(release.imdb_id) if release.imdb_id else None,
name=folder, # placeholder until TMDB sync supplies the real name
release_year=None,
metadata={
"path": folder,
"fetched_at": now,
},
)
)
return MovieLibraryIndexSidecar(
schema_version=SCHEMA_VERSION,
movies=tuple(movies),
)
def _write(self, sidecar: MovieLibraryIndexSidecar) -> None:
self._library_root.mkdir(parents=True, exist_ok=True)
atomic_write_yaml(self._index_path, _dump_model(sidecar))
# ════════════════════════════════════════════════════════════════════════════
# Shared helpers
# ════════════════════════════════════════════════════════════════════════════
def _dump_model(model) -> dict:
"""Serialize a Pydantic model to a YAML-friendly dict.
``mode="json"`` is what coerces ``datetime`` → ISO 8601 string and
``ReleaseMode`` enum → its underlying string value, which is what
we want in the on-disk YAML. Defaults are preserved (not
excluded) so a re-load yields an identical model.
"""
return model.model_dump(mode="json")
def _load_series_release(
sidecar_path: Path, *, expected_folder: str
) -> SeriesRelease | None:
"""Load + validate one per-show sidecar; return ``None`` on failure.
Logs a warning on corruption (caller still gets to walk the rest
of the library). Currently the ``expected_folder`` argument is
informational — anchor mismatches are surfaced by the library
index, not by the release sidecar itself (the sidecar has no
record of its own folder; only the per-season block does, and
those are validated against disk by the walker).
"""
try:
raw = read_yaml(sidecar_path)
sidecar = SeriesReleaseSidecar.model_validate(raw)
except (SidecarSchemaError, ValidationError) as exc:
logger.warning(
"skipping %s (in %s) — invalid sidecar: %s",
sidecar_path,
expected_folder,
exc,
)
return None
return series_release_from_sidecar(sidecar)
def _load_movie_release(
sidecar_path: Path, *, expected_folder: str
) -> MovieRelease | None:
"""Load + validate one per-movie sidecar; return ``None`` on failure.
Warns on anchor mismatch (``sidecar.folder != expected_folder``)
— a movie sidecar carries its own folder name, so we can detect
drift directly here, unlike the series case.
"""
try:
raw = read_yaml(sidecar_path)
sidecar = MovieReleaseSidecar.model_validate(raw)
except (SidecarSchemaError, ValidationError) as exc:
logger.warning(
"skipping %s (in %s) — invalid sidecar: %s",
sidecar_path,
expected_folder,
exc,
)
return None
if sidecar.folder != expected_folder:
logger.warning(
"anchor mismatch at %s: sidecar.folder=%r != actual folder=%r",
sidecar_path,
sidecar.folder,
expected_folder,
)
return movie_release_from_sidecar(sidecar)
@@ -0,0 +1,76 @@
"""YAML I/O helpers for ``.alfred`` v2 sidecars.
This module is intentionally thin: validation lives entirely in the
Pydantic DTOs (``sidecar_release.py`` / ``sidecar_root.py``). Here we
only do two things:
* ``read_yaml`` — load text → dict, with friendly error translation.
* ``atomic_write_yaml`` — render dict → ``.tmp`` file, then
``os.replace`` to the final path. Atomic on POSIX and NTFS, so no
half-written file ever becomes visible to a concurrent reader.
The bridge module is responsible for converting between domain
aggregates and these DTOs; the repository module composes everything
(walks the library, calls the bridge, persists via these helpers).
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
from .sidecar_release import SCHEMA_VERSION
__all__ = [
"SCHEMA_VERSION",
"SidecarSchemaError",
"atomic_write_yaml",
"read_yaml",
]
class SidecarSchemaError(ValueError):
"""Raised when a sidecar file fails to load or validate.
Wraps both YAML parse errors and Pydantic validation errors so
callers can catch a single exception type and log + skip.
"""
def read_yaml(path: Path) -> dict[str, Any]:
"""Load a YAML file and return its top-level mapping.
Raises :class:`SidecarSchemaError` if the file is unreadable,
contains invalid YAML, or the top-level value is not a mapping
(a list or scalar at the root is always a bug for our sidecars).
"""
try:
text = path.read_text()
except OSError as exc:
raise SidecarSchemaError(f"cannot read {path}: {exc}") from exc
try:
data = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise SidecarSchemaError(f"invalid YAML in {path}: {exc}") from exc
if not isinstance(data, dict):
raise SidecarSchemaError(
f"{path}: top-level must be a mapping, got {type(data).__name__}"
)
return data
def atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
"""Atomically write ``data`` as YAML to ``path``.
Uses the same ``write-tmp + os.replace`` pattern as v1: a reader
racing with a writer either sees the previous version or the new
one, never a torn file.
"""
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(yaml.safe_dump(data, sort_keys=False))
os.replace(tmp, path)
@@ -0,0 +1,167 @@
"""Pydantic DTOs mirroring the per-item ``.alfred`` v2 schema.
These models are the **in-memory representation** of one per-show or
per-movie sidecar file. They are intentionally strict
(``extra="forbid"``) so a stray key in the YAML raises at load time —
silent drift is a v1 lesson we don't want to repeat.
Per-show ``releases[]`` is keyed by season; each season carries a
``mode`` flag (``pack`` or ``episodic``) and a list of physical files
described by an :class:`EpisodeReleaseEntry`. A single physical file
can cover one or several TMDB episodes via the ``start`` / ``end``
range (multi-episode files like ``SxxE01E02E03.mkv``).
The DTO layer carries no domain knowledge — it only validates shapes
and types. The bridge module translates between these DTOs and the
:mod:`alfred.domain.releases` aggregates.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, model_validator
from .....domain.releases.value_objects import ReleaseMode
# Reused by the root-index module; declared here once.
SCHEMA_VERSION = 1
class _Strict(BaseModel):
"""Base for every v2 DTO — strict on **unknown keys**, lenient on
sequence coercion (YAML always loads sequences as ``list`` even
when the DTO declares ``tuple``)."""
model_config = ConfigDict(extra="forbid", frozen=True)
# ────────────────────────────────────────────────────────────────────────────
# Track entries (audio + subtitle), per physical file
# ────────────────────────────────────────────────────────────────────────────
class AudioTrackEntry(_Strict):
"""One audio stream as cached in the sidecar.
Mirrors :class:`alfred.domain.shared.media.AudioTrack` minus
``index`` / ``is_default`` (the sidecar is a cache, not a
full-fidelity ffprobe dump).
"""
codec: str | None = None
channels: int | None = None
channel_layout: str | None = None
language: str | None = None
class SubtitleEntry(_Strict):
"""One embedded subtitle track as cached in the sidecar.
``is_sdh`` and ``is_forced`` are explicit flags (v1's ``type:
"sdh"`` is gone — see ``specs/dot_alfred_v2.md``).
"""
codec: str | None = None
language: str | None = None
is_forced: bool = False
is_sdh: bool = False
# ────────────────────────────────────────────────────────────────────────────
# Per-season release entries
# ────────────────────────────────────────────────────────────────────────────
class EpisodeReleaseEntry(_Strict):
"""One physical episode file (single or multi-episode).
``start`` / ``end`` are inclusive TMDB episode numbers. A
single-episode file has ``start == end``; a multi-episode file
(``SxxE02E03E04``) has ``end > start``.
``file`` is **relative to the show root** (e.g.
``"Show.S01/Show.S01E02.mkv"`` in PACK,
``"Show.S01/Show.S01E02-RG/Show.S01E02-RG.mkv"`` in EPISODIC).
"""
start: int = Field(ge=1)
end: int = Field(ge=1)
file: str = Field(min_length=1)
audio: tuple[AudioTrackEntry, ...] = ()
subtitles: tuple[SubtitleEntry, ...] = ()
@model_validator(mode="after")
def _end_ge_start(self) -> EpisodeReleaseEntry:
if self.end < self.start:
raise ValueError(
f"episode entry end ({self.end}) must be >= start ({self.start})"
)
return self
class SeasonReleaseEntry(_Strict):
"""One season block in the per-show sidecar's ``releases[]``.
PACK and EPISODIC share the same shape (a list of physical files);
the mode is recorded explicitly so the walker doesn't have to
re-derive it from filesystem layout on every load.
"""
season: int = Field(ge=0)
mode: ReleaseMode
folder: str = Field(min_length=1)
episodes: tuple[EpisodeReleaseEntry, ...] = ()
# ────────────────────────────────────────────────────────────────────────────
# Per-show / per-movie root DTOs
# ────────────────────────────────────────────────────────────────────────────
class SeriesReleaseSidecar(_Strict):
"""Root DTO — one per-show ``.alfred`` file maps to one of these.
``tmdb_id`` is the primary anchor (and the link back to the
library-root ``.alfred.index``). ``imdb_id`` is optional and acts
as a secondary anchor — useful for cross-checking when both are
known.
"""
schema_version: int
tmdb_id: int = Field(gt=0)
imdb_id: str | None = None
releases: tuple[SeasonReleaseEntry, ...] = ()
@model_validator(mode="after")
def _check_schema_version(self) -> SeriesReleaseSidecar:
if self.schema_version != SCHEMA_VERSION:
raise ValueError(
f"unsupported schema_version: {self.schema_version} "
f"(expected {SCHEMA_VERSION})"
)
return self
class MovieReleaseSidecar(_Strict):
"""Root DTO — one per-movie ``.alfred`` file maps to one of these.
Movies have a single ``release:`` block (no list, no modes). The
folder + file layout matches Alfred's "one folder, one file" movie
convention.
"""
schema_version: int
tmdb_id: int = Field(gt=0)
imdb_id: str | None = None
folder: str = Field(min_length=1)
file: str = Field(min_length=1)
audio: tuple[AudioTrackEntry, ...] = ()
subtitles: tuple[SubtitleEntry, ...] = ()
@model_validator(mode="after")
def _check_schema_version(self) -> MovieReleaseSidecar:
if self.schema_version != SCHEMA_VERSION:
raise ValueError(
f"unsupported schema_version: {self.schema_version} "
f"(expected {SCHEMA_VERSION})"
)
return self
@@ -0,0 +1,148 @@
"""Pydantic DTOs mirroring the library-root ``.alfred.index`` v2 schema.
Two DTOs, one per media type — TV shows and movies have intentionally
divergent schemas (see ``specs/dot_alfred_v2.md``). Both carry a
``schema_version`` checked at load time.
The library-root index is the grep-friendly + TMDB-cache file that
sits at the root of ``tv_shows/`` and ``movies/``. It is rebuilt
from the per-item sidecars on auto-heal (see
:class:`DotAlfredTVShowLibraryIndex`), so a corruption is recoverable
without TMDB round-trips (TMDB-cached fields are simply left empty
until the next sync).
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator
from .sidecar_release import SCHEMA_VERSION
class _Strict(BaseModel):
"""Base for every v2 DTO — strict on **unknown keys**, lenient on
sequence coercion (YAML always loads sequences as ``list`` even
when the DTO declares ``tuple``)."""
model_config = ConfigDict(extra="forbid", frozen=True)
# ────────────────────────────────────────────────────────────────────────────
# Per-item metadata block (shared by show + movie index entries)
# ────────────────────────────────────────────────────────────────────────────
class ShowIndexMetadata(_Strict):
"""Per-entry bookkeeping for one item in a library index.
``path`` is the folder name **relative to the library root** (so
moving the whole library root doesn't invalidate the index).
``fetched_at`` is the UTC timestamp of the last TMDB sync; the
TTL policy compares against it to decide whether to re-fetch.
"""
path: str = Field(min_length=1)
fetched_at: datetime
# ────────────────────────────────────────────────────────────────────────────
# TV — per-season + per-show index entries
# ────────────────────────────────────────────────────────────────────────────
class SeasonIndexEntry(_Strict):
"""One season block inside a :class:`ShowIndexEntry`.
``episode_count`` and ``aired`` come from TMDB (cached). The
``episodes`` map is a **flattened projection** of the per-show
release sidecar: one slot per TMDB episode, even for
multi-episode files (the same path string appears under each
covered ``"E0x"`` key). Keys are zero-padded TMDB episode tokens
(``"E01"``, ``"E02"`` …) — matches what the user greps for.
"""
number: int = Field(ge=0)
episode_count: int = Field(ge=0)
aired: bool = False
episodes: dict[str, str] = Field(default_factory=dict)
class ShowIndexEntry(_Strict):
"""One TV show entry in the library-root index.
``tmdb_id`` is the primary key (matches the per-show sidecar's
anchor). ``imdb_id`` is the optional secondary anchor.
``status`` is the raw TMDB status string (e.g. ``"Returning
Series"`` / ``"Ended"``) — kept verbatim so callers don't have
to guess at our taxonomy.
"""
tmdb_id: int = Field(gt=0)
imdb_id: str | None = None
name: str = Field(min_length=1)
status: str = Field(min_length=1)
metadata: ShowIndexMetadata
seasons: tuple[SeasonIndexEntry, ...] = ()
# ────────────────────────────────────────────────────────────────────────────
# Movies — per-item index entry
# ────────────────────────────────────────────────────────────────────────────
class MovieIndexEntry(_Strict):
"""One movie entry in the library-root index.
Movies have no seasons and no ``status`` — they're released
once. ``release_year`` is optional because TMDB occasionally
lacks it (very old or future-dated titles).
"""
tmdb_id: int = Field(gt=0)
imdb_id: str | None = None
name: str = Field(min_length=1)
release_year: int | None = None
metadata: ShowIndexMetadata
# ────────────────────────────────────────────────────────────────────────────
# Library-root root DTOs (one per media type)
# ────────────────────────────────────────────────────────────────────────────
class TVShowLibraryIndexSidecar(_Strict):
"""Root DTO — one ``tv_shows/.alfred.index`` maps to one of these.
The TV and movie indexes are intentionally **not** unified — see
the *Rejected alternatives* section of ``specs/dot_alfred_v2.md``.
"""
schema_version: int
shows: tuple[ShowIndexEntry, ...] = ()
@model_validator(mode="after")
def _check_schema_version(self) -> TVShowLibraryIndexSidecar:
if self.schema_version != SCHEMA_VERSION:
raise ValueError(
f"unsupported schema_version: {self.schema_version} "
f"(expected {SCHEMA_VERSION})"
)
return self
class MovieLibraryIndexSidecar(_Strict):
"""Root DTO — one ``movies/.alfred.index`` maps to one of these."""
schema_version: int
movies: tuple[MovieIndexEntry, ...] = ()
@model_validator(mode="after")
def _check_schema_version(self) -> MovieLibraryIndexSidecar:
if self.schema_version != SCHEMA_VERSION:
raise ValueError(
f"unsupported schema_version: {self.schema_version} "
f"(expected {SCHEMA_VERSION})"
)
return self
@@ -162,6 +162,7 @@ def _parse_media_info(data: dict) -> MediaInfo:
language=stream.get("tags", {}).get("language"), language=stream.get("tags", {}).get("language"),
is_default=stream.get("disposition", {}).get("default", 0) == 1, is_default=stream.get("disposition", {}).get("default", 0) == 1,
is_forced=stream.get("disposition", {}).get("forced", 0) == 1, is_forced=stream.get("disposition", {}).get("forced", 0) == 1,
is_sdh=stream.get("disposition", {}).get("hearing_impaired", 0) == 1,
) )
) )
@@ -303,6 +303,63 @@ class TestDetailsEndpoints:
assert result["number_of_seasons"] == 5 assert result["number_of_seasons"] == 5
class TestGetTvShowInfo:
"""``get_tv_show_info`` aggregates ``/tv/{id}`` + external_ids."""
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_happy_path(self, mock_get, client):
details = {
"id": 84958,
"name": "Foundation",
"status": "Returning Series",
"seasons": [
{
"season_number": 1,
"episode_count": 10,
"air_date": "2021-09-24",
},
{
"season_number": 2,
"episode_count": 10,
"air_date": "2023-07-14",
},
],
}
external = {"imdb_id": "tt0804484"}
mock_get.side_effect = [
_ok_response(details),
_ok_response(external),
]
info = client.get_tv_show_info(84958)
assert info.tmdb_id == 84958
assert info.imdb_id == "tt0804484"
assert info.name == "Foundation"
assert info.status == "Returning Series"
assert len(info.seasons) == 2
assert info.seasons[0].number == 1
assert info.seasons[0].episode_count == 10
assert info.seasons[0].aired is True
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_missing_imdb_id_becomes_none(self, mock_get, client):
mock_get.side_effect = [
_ok_response(
{
"id": 1,
"name": "X",
"status": "Ended",
"seasons": [],
}
),
_ok_response({}), # external_ids without imdb_id
]
info = client.get_tv_show_info(1)
assert info.imdb_id is None
assert info.seasons == ()
class TestIsConfigured: class TestIsConfigured:
def test_true_when_complete(self, client): def test_true_when_complete(self, client):
assert client.is_configured() is True assert client.is_configured() is True
+168
View File
@@ -0,0 +1,168 @@
"""Tests for the pure parsing helpers in ``alfred.infrastructure.api.tmdb.dto``.
These tests exercise :func:`parse_tv_show_info` without any HTTP — the
function takes the raw dicts that the client would otherwise pass after
deserializing the TMDB JSON response. The reference date is injected so
the ``aired`` derivation is deterministic.
"""
from __future__ import annotations
from datetime import date
import pytest
from alfred.infrastructure.api.tmdb.dto import (
TmdbSeasonInfo,
TmdbShowInfo,
parse_tv_show_info,
)
REF_DATE = date(2026, 5, 25)
def _details(**overrides):
base = {
"id": 84958,
"name": "Foundation",
"status": "Returning Series",
"seasons": [],
}
base.update(overrides)
return base
class TestParseTvShowInfoHappyPath:
def test_minimal(self):
info = parse_tv_show_info(
_details(),
{"imdb_id": "tt0804484"},
today=REF_DATE,
)
assert info == TmdbShowInfo(
tmdb_id=84958,
imdb_id="tt0804484",
name="Foundation",
status="Returning Series",
seasons=(),
)
def test_with_seasons(self):
info = parse_tv_show_info(
_details(
seasons=[
{"season_number": 1, "episode_count": 10, "air_date": "2021-09-24"},
{"season_number": 2, "episode_count": 10, "air_date": "2023-07-14"},
{"season_number": 3, "episode_count": 10, "air_date": "2027-01-01"},
],
),
{"imdb_id": "tt0804484"},
today=REF_DATE,
)
assert info.seasons == (
TmdbSeasonInfo(number=1, episode_count=10, aired=True),
TmdbSeasonInfo(number=2, episode_count=10, aired=True),
TmdbSeasonInfo(number=3, episode_count=10, aired=False),
)
class TestParseTvShowInfoImdb:
def test_missing_imdb_id_becomes_none(self):
info = parse_tv_show_info(_details(), {}, today=REF_DATE)
assert info.imdb_id is None
def test_null_imdb_id_becomes_none(self):
info = parse_tv_show_info(_details(), {"imdb_id": None}, today=REF_DATE)
assert info.imdb_id is None
def test_empty_string_imdb_id_becomes_none(self):
info = parse_tv_show_info(_details(), {"imdb_id": ""}, today=REF_DATE)
assert info.imdb_id is None
class TestParseTvShowInfoAired:
def test_air_date_today_counts_as_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1, "air_date": "2026-05-25"}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is True
def test_air_date_tomorrow_not_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1, "air_date": "2026-05-26"}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is False
def test_no_air_date_not_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is False
def test_empty_air_date_not_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1, "air_date": ""}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is False
def test_malformed_air_date_not_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1, "air_date": "soon"}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is False
class TestParseTvShowInfoErrors:
def test_missing_id_raises(self):
with pytest.raises(ValueError, match="'id'"):
parse_tv_show_info({"name": "X", "status": "Ended"}, {}, today=REF_DATE)
def test_missing_name_raises(self):
with pytest.raises(ValueError, match="'name'"):
parse_tv_show_info({"id": 1, "status": "Ended"}, {}, today=REF_DATE)
def test_empty_name_raises(self):
with pytest.raises(ValueError, match="'name'"):
parse_tv_show_info(
{"id": 1, "name": "", "status": "Ended"}, {}, today=REF_DATE
)
def test_missing_status_raises(self):
with pytest.raises(ValueError, match="'status'"):
parse_tv_show_info({"id": 1, "name": "X"}, {}, today=REF_DATE)
def test_season_missing_number_raises(self):
with pytest.raises(ValueError, match="season_number"):
parse_tv_show_info(
_details(seasons=[{"episode_count": 5}]),
{},
today=REF_DATE,
)
def test_season_missing_episode_count_raises(self):
with pytest.raises(ValueError, match="episode_count"):
parse_tv_show_info(
_details(seasons=[{"season_number": 1}]),
{},
today=REF_DATE,
)
@@ -0,0 +1,200 @@
"""Shared fixtures for v2 ``.alfred`` integration tests.
The fixtures here build realistic ``SeriesRelease`` / ``MovieRelease``
aggregates — populated tracks, multi-episode files, both PACK and
EPISODIC modes — so every test starts from a known-rich state.
The point is to make round-trip tests genuinely lossless-checking
(if a field is unused in the fixture, the round-trip can't prove
much about it).
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from alfred.domain.releases.entities import (
EpisodeRelease,
MovieRelease,
SeasonRelease,
SeriesRelease,
TrackProfile,
)
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
from alfred.infrastructure.api.tmdb.dto import TmdbSeasonInfo, TmdbShowInfo
def _audio(lang: str = "eng", *, index: int = 0) -> AudioTrack:
# ``index`` defaults to 0 to match what the bridge reconstructs on
# read (sidecars don't persist ffprobe stream indices — see the
# bridge module's track-conversion notes). Pass explicit indices
# only when a fixture has multiple tracks of the same kind.
return AudioTrack(
index=index,
codec="eac3",
channels=6,
channel_layout="5.1",
language=lang,
)
def _sub(
lang: str = "eng",
*,
index: int = 0,
forced: bool = False,
sdh: bool = False,
) -> SubtitleTrack:
return SubtitleTrack(
index=index,
codec="subrip",
language=lang,
is_default=False,
is_forced=forced,
is_sdh=sdh,
)
@pytest.fixture
def foundation_release() -> SeriesRelease:
"""Foundation S01 (PACK, 3 files) + S02 (EPISODIC, one multi-episode file)."""
s01 = SeasonRelease(
season_number=SeasonNumber(1),
folder="Foundation.S01.1080p.WEBRip.x265-RARBG",
mode=ReleaseMode.PACK,
episodes=(
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
file_path=FilePath(
"Foundation.S01.1080p.WEBRip.x265-RARBG/"
"Foundation.S01E01.1080p.WEBRip.x265-RARBG.mkv"
),
tracks=TrackProfile(
audio_tracks=(_audio("eng"),),
subtitle_tracks=(
_sub("eng", index=0),
_sub("eng", index=1, sdh=True),
),
),
),
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(2), EpisodeNumber(2)),
file_path=FilePath(
"Foundation.S01.1080p.WEBRip.x265-RARBG/"
"Foundation.S01E02.1080p.WEBRip.x265-RARBG.mkv"
),
tracks=TrackProfile(audio_tracks=(_audio("eng"),)),
),
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(3), EpisodeNumber(3)),
file_path=FilePath(
"Foundation.S01.1080p.WEBRip.x265-RARBG/"
"Foundation.S01E03.1080p.WEBRip.x265-RARBG.mkv"
),
tracks=TrackProfile(audio_tracks=(_audio("eng"),)),
),
),
)
s02 = SeasonRelease(
season_number=SeasonNumber(2),
folder="Foundation.S02",
mode=ReleaseMode.EPISODIC,
episodes=(
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
file_path=FilePath(
"Foundation.S02/Foundation.S02E01.1080p.x265-ELiTE/"
"Foundation.S02E01.1080p.x265-ELiTE.mkv"
),
tracks=TrackProfile(audio_tracks=(_audio("eng"),)),
),
# Multi-episode file (E02 + E03 in one .mkv).
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(2), EpisodeNumber(3)),
file_path=FilePath(
"Foundation.S02/Foundation.S02E02-E03.2160p.WEB.x265-CtrlHD/"
"Foundation.S02E02-E03.2160p.WEB.x265-CtrlHD.mkv"
),
tracks=TrackProfile(
audio_tracks=(_audio("eng"),),
subtitle_tracks=(_sub("eng", forced=True),),
),
),
),
)
return SeriesRelease(
tmdb_id=TmdbId(84958),
imdb_id=ImdbId("tt0804484"),
seasons=(s01, s02),
)
@pytest.fixture
def inception_release() -> MovieRelease:
"""Inception (2010) — single-file movie with rich tracks."""
return MovieRelease(
tmdb_id=TmdbId(27205),
imdb_id=ImdbId("tt1375666"),
folder="Inception.2010.1080p.BluRay.x264-GROUP",
file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"),
tracks=TrackProfile(
audio_tracks=(
AudioTrack(
index=0,
codec="dts",
channels=8,
channel_layout="7.1",
language="eng",
),
),
subtitle_tracks=(
_sub("eng", index=0),
_sub("fre", index=1, forced=True),
),
),
)
@pytest.fixture
def foundation_tmdb_info() -> TmdbShowInfo:
"""Foundation TMDB cache snapshot — 3 seasons, S03 not yet aired."""
return TmdbShowInfo(
tmdb_id=84958,
imdb_id="tt0804484",
name="Foundation",
status="Returning Series",
seasons=(
TmdbSeasonInfo(number=1, episode_count=10, aired=True),
TmdbSeasonInfo(number=2, episode_count=10, aired=True),
TmdbSeasonInfo(number=3, episode_count=10, aired=False),
),
)
@pytest.fixture
def now_utc() -> datetime:
"""Stable UTC reference for deterministic fetched_at fields."""
return datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC)
@pytest.fixture
def tv_library(tmp_path):
"""Empty ``tv_shows/`` directory pre-populated with show folders."""
root = tmp_path / "tv_shows"
root.mkdir()
(root / "Foundation").mkdir()
(root / "Fallout").mkdir()
return root
@pytest.fixture
def movie_library(tmp_path):
"""Empty ``movies/`` directory pre-populated with one movie folder."""
root = tmp_path / "movies"
root.mkdir()
(root / "Inception.2010.1080p.BluRay.x264-GROUP").mkdir()
return root
@@ -0,0 +1,266 @@
"""Integration tests for the library-root index repositories.
Cover upsert / delete / find_by_* and the auto-heal behavior on
missing / corrupt index files. Auto-heal must produce a valid
sidecar with TMDB-cached fields left as documented placeholders
(``status="unknown"``, ``seasons=()``).
"""
from __future__ import annotations
import logging
from alfred.domain.shared.value_objects import ImdbId, TmdbId
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
DotAlfredMovieLibraryIndex,
DotAlfredMovieReleaseRepository,
DotAlfredSeriesReleaseRepository,
DotAlfredTVShowLibraryIndex,
)
# ════════════════════════════════════════════════════════════════════════════
# TV — upsert / find / delete
# ════════════════════════════════════════════════════════════════════════════
class TestTVShowLibraryIndexUpsert:
def test_upsert_creates_index_file(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
assert (tv_library / ".alfred.index").is_file()
def test_upsert_then_find_by_tmdb_id_returns_entry(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
entry = index.find_by_tmdb_id(TmdbId(84958))
assert entry is not None
assert entry.name == "Foundation"
assert entry.status == "Returning Series"
assert entry.metadata.path == "Foundation"
assert entry.metadata.fetched_at == now_utc
def test_upsert_flattens_multi_episode_file_across_slots(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
entry = index.find_by_tmdb_id(TmdbId(84958))
s02 = next(s for s in entry.seasons if s.number == 2)
# E02 and E03 must point to the SAME multi-episode file.
assert s02.episodes["E02"] == s02.episodes["E03"]
assert "E02-E03" in s02.episodes["E02"]
def test_upsert_twice_replaces_entry_does_not_duplicate(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
all_entries = index.find_all()
assert len(all_entries) == 1
def test_find_by_imdb_id_returns_entry(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
entry = index.find_by_imdb_id(ImdbId("tt0804484"))
assert entry is not None
assert entry.tmdb_id == 84958
def test_find_by_path_returns_entry(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
entry = index.find_by_path("Foundation")
assert entry is not None
def test_delete_removes_entry(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
assert index.delete(TmdbId(84958)) is True
assert index.find_by_tmdb_id(TmdbId(84958)) is None
def test_delete_unknown_id_returns_false(self, tv_library):
index = DotAlfredTVShowLibraryIndex(tv_library)
assert index.delete(TmdbId(999)) is False
# ════════════════════════════════════════════════════════════════════════════
# TV — auto-heal
# ════════════════════════════════════════════════════════════════════════════
class TestTVShowLibraryIndexAutoHeal:
def test_missing_index_is_silently_healed_from_per_show_sidecars(
self, tv_library, foundation_release, caplog
):
# Write a per-show sidecar but no index.
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
assert not (tv_library / ".alfred.index").exists()
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
with caplog.at_level(logging.INFO):
entry = index.find_by_tmdb_id(TmdbId(84958))
assert entry is not None
assert entry.tmdb_id == 84958
# Healed entries carry placeholders (no TMDB sync yet).
assert entry.status == "unknown"
assert entry.seasons == ()
assert (tv_library / ".alfred.index").is_file()
assert any("healing" in r.message for r in caplog.records)
def test_corrupt_index_is_healed(
self, tv_library, foundation_release, caplog
):
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
# Plant a corrupt index.
(tv_library / ".alfred.index").write_text("not: [valid yaml")
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
with caplog.at_level(logging.WARNING):
entries = index.find_all()
assert len(entries) == 1
assert any("corrupt" in r.message for r in caplog.records)
def test_schema_version_mismatch_in_index_is_healed(
self, tv_library, foundation_release
):
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
(tv_library / ".alfred.index").write_text(
"schema_version: 999\nshows: []\n"
)
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
entries = index.find_all()
# After heal, only Foundation (the only valid per-show sidecar) appears.
assert len(entries) == 1
assert entries[0].tmdb_id == 84958
def test_heal_is_idempotent(
self, tv_library, foundation_release
):
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
first = index.heal()
second = index.heal()
# Compare model state minus the ``fetched_at`` (timestamps differ).
assert len(first.shows) == len(second.shows) == 1
assert first.shows[0].tmdb_id == second.shows[0].tmdb_id
def test_heal_with_empty_library_writes_empty_index(self, tv_library):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.heal()
assert (tv_library / ".alfred.index").is_file()
assert index.find_all() == ()
# ════════════════════════════════════════════════════════════════════════════
# TV — atomicity
# ════════════════════════════════════════════════════════════════════════════
class TestTVShowLibraryIndexAtomicity:
def test_upsert_leaves_no_tmp_file(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
tmps = list(tv_library.glob("*.tmp"))
assert tmps == []
# ════════════════════════════════════════════════════════════════════════════
# Movies
# ════════════════════════════════════════════════════════════════════════════
class TestMovieLibraryIndex:
def test_upsert_and_find(
self, movie_library, inception_release, now_utc
):
index = DotAlfredMovieLibraryIndex(movie_library)
index.upsert(
inception_release,
name="Inception",
release_year=2010,
path=inception_release.folder,
fetched_at=now_utc,
)
entry = index.find_by_tmdb_id(TmdbId(27205))
assert entry is not None
assert entry.name == "Inception"
assert entry.release_year == 2010
def test_missing_index_heals_from_movie_sidecars(
self, movie_library, inception_release, caplog
):
release_repo = DotAlfredMovieReleaseRepository(movie_library)
release_repo.save(inception_release)
index = DotAlfredMovieLibraryIndex(movie_library, release_repo=release_repo)
with caplog.at_level(logging.INFO):
entry = index.find_by_tmdb_id(TmdbId(27205))
assert entry is not None
assert entry.tmdb_id == 27205
# Placeholder until TMDB sync.
assert entry.release_year is None
assert any("healing" in r.message for r in caplog.records)
@@ -0,0 +1,137 @@
"""Integration tests for the per-item release repositories.
Cover the atomic-write contract, the log+skip-on-corruption behavior,
the strict schema-version check, and the movie-anchor warning.
"""
from __future__ import annotations
import logging
import pytest
from alfred.domain.shared.value_objects import TmdbId
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
DotAlfredMovieReleaseRepository,
DotAlfredSeriesReleaseRepository,
ShowFolderUnknown,
)
# ════════════════════════════════════════════════════════════════════════════
# Series — save / read / delete
# ════════════════════════════════════════════════════════════════════════════
class TestSeriesReleaseRepositorySave:
def test_save_writes_alfred_in_show_folder(
self, tv_library, foundation_release
):
repo = DotAlfredSeriesReleaseRepository(tv_library)
repo.save(foundation_release, show_folder="Foundation")
assert (tv_library / "Foundation" / ".alfred").is_file()
def test_save_unknown_folder_raises(self, tv_library, foundation_release):
repo = DotAlfredSeriesReleaseRepository(tv_library)
with pytest.raises(ShowFolderUnknown):
repo.save(foundation_release, show_folder="Nope")
def test_save_then_find_by_tmdb_id_returns_equal(
self, tv_library, foundation_release
):
repo = DotAlfredSeriesReleaseRepository(tv_library)
repo.save(foundation_release, show_folder="Foundation")
restored = repo.find_by_tmdb_id(TmdbId(84958))
assert restored == foundation_release
def test_save_is_atomic_no_tmp_left_behind(
self, tv_library, foundation_release
):
repo = DotAlfredSeriesReleaseRepository(tv_library)
repo.save(foundation_release, show_folder="Foundation")
tmps = list((tv_library / "Foundation").glob("*.tmp"))
assert tmps == []
class TestSeriesReleaseRepositoryReads:
def test_find_all_skips_folders_without_sidecar(
self, tv_library, foundation_release
):
repo = DotAlfredSeriesReleaseRepository(tv_library)
repo.save(foundation_release, show_folder="Foundation")
# Fallout/ exists in the fixture but has no .alfred — must be skipped.
results = repo.find_all()
assert len(results) == 1
assert results[0].tmdb_id == TmdbId(84958)
def test_find_all_logs_and_skips_corrupt_sidecar(
self, tv_library, foundation_release, caplog
):
repo = DotAlfredSeriesReleaseRepository(tv_library)
repo.save(foundation_release, show_folder="Foundation")
# Corrupt Fallout's sidecar.
(tv_library / "Fallout" / ".alfred").write_text("not: [valid")
with caplog.at_level(logging.WARNING):
results = repo.find_all()
assert len(results) == 1
assert any("Fallout" in r.message for r in caplog.records)
def test_unknown_schema_version_is_skipped(
self, tv_library, foundation_release, caplog
):
repo = DotAlfredSeriesReleaseRepository(tv_library)
repo.save(foundation_release, show_folder="Foundation")
# Hand-roll a future-version sidecar.
(tv_library / "Fallout" / ".alfred").write_text(
"schema_version: 999\ntmdb_id: 12345\nreleases: []\n"
)
with caplog.at_level(logging.WARNING):
results = repo.find_all()
assert len(results) == 1
class TestSeriesReleaseRepositoryDelete:
def test_delete_removes_sidecar(self, tv_library, foundation_release):
repo = DotAlfredSeriesReleaseRepository(tv_library)
repo.save(foundation_release, show_folder="Foundation")
assert repo.delete(TmdbId(84958)) is True
assert not (tv_library / "Foundation" / ".alfred").exists()
assert (tv_library / "Foundation").is_dir() # folder preserved
def test_delete_unknown_id_returns_false(self, tv_library):
repo = DotAlfredSeriesReleaseRepository(tv_library)
assert repo.delete(TmdbId(999)) is False
# ════════════════════════════════════════════════════════════════════════════
# Movies
# ════════════════════════════════════════════════════════════════════════════
class TestMovieReleaseRepository:
def test_save_writes_alfred_in_movie_folder(
self, movie_library, inception_release
):
repo = DotAlfredMovieReleaseRepository(movie_library)
repo.save(inception_release)
sidecar = movie_library / inception_release.folder / ".alfred"
assert sidecar.is_file()
def test_save_round_trip(self, movie_library, inception_release):
repo = DotAlfredMovieReleaseRepository(movie_library)
repo.save(inception_release)
restored = repo.find_by_tmdb_id(TmdbId(27205))
assert restored == inception_release
def test_anchor_mismatch_logs_warning(
self, movie_library, inception_release, caplog
):
repo = DotAlfredMovieReleaseRepository(movie_library)
repo.save(inception_release)
# Rename folder so the sidecar.folder anchor no longer matches.
original = movie_library / inception_release.folder
renamed = movie_library / "Renamed.Manually"
original.rename(renamed)
with caplog.at_level(logging.WARNING):
list(repo.find_all())
assert any("anchor mismatch" in r.message for r in caplog.records)
@@ -0,0 +1,91 @@
"""Round-trip tests — domain → sidecar → YAML → sidecar → domain.
These tests are the contract guarantee that the v2 sidecar is a
lossless cache for everything the spec claims it stores. Any field
introduced in the future must come with a round-trip test that
covers it; otherwise we can silently drop it on read.
"""
from __future__ import annotations
import yaml
from alfred.infrastructure.persistence.dot_alfred.v2.bridge import (
movie_release_from_sidecar,
movie_release_to_sidecar,
series_release_from_sidecar,
series_release_to_sidecar,
)
from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_release import (
MovieReleaseSidecar,
SeriesReleaseSidecar,
)
class TestSeriesReleaseRoundTrip:
def test_domain_to_sidecar_preserves_top_level(self, foundation_release):
sidecar = series_release_to_sidecar(foundation_release)
assert sidecar.schema_version == 1
assert sidecar.tmdb_id == 84958
assert sidecar.imdb_id == "tt0804484"
assert len(sidecar.releases) == 2
def test_full_loop_domain_to_domain_is_equal(self, foundation_release):
sidecar = series_release_to_sidecar(foundation_release)
restored = series_release_from_sidecar(sidecar)
assert restored == foundation_release
def test_full_loop_through_yaml_is_equal(self, foundation_release):
sidecar = series_release_to_sidecar(foundation_release)
text = yaml.safe_dump(sidecar.model_dump(mode="json"))
reloaded = SeriesReleaseSidecar.model_validate(yaml.safe_load(text))
restored = series_release_from_sidecar(reloaded)
assert restored == foundation_release
def test_multi_episode_file_round_trips(self, foundation_release):
sidecar = series_release_to_sidecar(foundation_release)
s02 = sidecar.releases[1]
multi = s02.episodes[1]
assert multi.start == 2 and multi.end == 3
restored = series_release_from_sidecar(sidecar)
restored_multi = restored.seasons[1].episodes[1]
assert restored_multi.episodes.start.value == 2
assert restored_multi.episodes.end.value == 3
def test_sdh_flag_round_trips(self, foundation_release):
sidecar = series_release_to_sidecar(foundation_release)
restored = series_release_from_sidecar(sidecar)
sdh_track = restored.seasons[0].episodes[0].tracks.subtitle_tracks[1]
assert sdh_track.is_sdh is True
def test_no_imdb_id_round_trips_as_none(self, foundation_release):
# Replace the imdb_id with None and verify it survives the loop.
from dataclasses import replace
no_imdb = replace(foundation_release, imdb_id=None)
sidecar = series_release_to_sidecar(no_imdb)
assert sidecar.imdb_id is None
restored = series_release_from_sidecar(sidecar)
assert restored.imdb_id is None
class TestMovieReleaseRoundTrip:
def test_domain_to_sidecar_preserves_top_level(self, inception_release):
sidecar = movie_release_to_sidecar(inception_release)
assert sidecar.schema_version == 1
assert sidecar.tmdb_id == 27205
assert sidecar.imdb_id == "tt1375666"
assert sidecar.folder == "Inception.2010.1080p.BluRay.x264-GROUP"
def test_full_loop_through_yaml_is_equal(self, inception_release):
sidecar = movie_release_to_sidecar(inception_release)
text = yaml.safe_dump(sidecar.model_dump(mode="json"))
reloaded = MovieReleaseSidecar.model_validate(yaml.safe_load(text))
restored = movie_release_from_sidecar(reloaded)
assert restored == inception_release
def test_forced_subtitle_flag_round_trips(self, inception_release):
sidecar = movie_release_to_sidecar(inception_release)
restored = movie_release_from_sidecar(sidecar)
forced = restored.tracks.subtitle_tracks[1]
assert forced.is_forced is True
assert forced.language == "fre"