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