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
@@ -303,6 +303,63 @@ class TestDetailsEndpoints:
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:
def test_true_when_complete(self, client):
assert client.is_configured() is True