e65c1df229
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.
201 lines
6.5 KiB
Python
201 lines
6.5 KiB
Python
"""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
|