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,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"
|
||||
Reference in New Issue
Block a user