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:
@@ -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
|
||||
Reference in New Issue
Block a user