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
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Integration tests for the library-root index repositories.
|
||||
|
||||
Cover upsert / delete / find_by_* and the auto-heal behavior on
|
||||
missing / corrupt index files. Auto-heal must produce a valid
|
||||
sidecar with TMDB-cached fields left as documented placeholders
|
||||
(``status="unknown"``, ``seasons=()``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from alfred.domain.shared.value_objects import ImdbId, TmdbId
|
||||
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
|
||||
DotAlfredMovieLibraryIndex,
|
||||
DotAlfredMovieReleaseRepository,
|
||||
DotAlfredSeriesReleaseRepository,
|
||||
DotAlfredTVShowLibraryIndex,
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# TV — upsert / find / delete
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestTVShowLibraryIndexUpsert:
|
||||
def test_upsert_creates_index_file(
|
||||
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
|
||||
):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
assert (tv_library / ".alfred.index").is_file()
|
||||
|
||||
def test_upsert_then_find_by_tmdb_id_returns_entry(
|
||||
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
|
||||
):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
entry = index.find_by_tmdb_id(TmdbId(84958))
|
||||
assert entry is not None
|
||||
assert entry.name == "Foundation"
|
||||
assert entry.status == "Returning Series"
|
||||
assert entry.metadata.path == "Foundation"
|
||||
assert entry.metadata.fetched_at == now_utc
|
||||
|
||||
def test_upsert_flattens_multi_episode_file_across_slots(
|
||||
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
|
||||
):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
entry = index.find_by_tmdb_id(TmdbId(84958))
|
||||
s02 = next(s for s in entry.seasons if s.number == 2)
|
||||
# E02 and E03 must point to the SAME multi-episode file.
|
||||
assert s02.episodes["E02"] == s02.episodes["E03"]
|
||||
assert "E02-E03" in s02.episodes["E02"]
|
||||
|
||||
def test_upsert_twice_replaces_entry_does_not_duplicate(
|
||||
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
|
||||
):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
all_entries = index.find_all()
|
||||
assert len(all_entries) == 1
|
||||
|
||||
def test_find_by_imdb_id_returns_entry(
|
||||
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
|
||||
):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
entry = index.find_by_imdb_id(ImdbId("tt0804484"))
|
||||
assert entry is not None
|
||||
assert entry.tmdb_id == 84958
|
||||
|
||||
def test_find_by_path_returns_entry(
|
||||
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
|
||||
):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
entry = index.find_by_path("Foundation")
|
||||
assert entry is not None
|
||||
|
||||
def test_delete_removes_entry(
|
||||
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
|
||||
):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
assert index.delete(TmdbId(84958)) is True
|
||||
assert index.find_by_tmdb_id(TmdbId(84958)) is None
|
||||
|
||||
def test_delete_unknown_id_returns_false(self, tv_library):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
assert index.delete(TmdbId(999)) is False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# TV — auto-heal
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestTVShowLibraryIndexAutoHeal:
|
||||
def test_missing_index_is_silently_healed_from_per_show_sidecars(
|
||||
self, tv_library, foundation_release, caplog
|
||||
):
|
||||
# Write a per-show sidecar but no index.
|
||||
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
release_repo.save(foundation_release, show_folder="Foundation")
|
||||
assert not (tv_library / ".alfred.index").exists()
|
||||
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
|
||||
with caplog.at_level(logging.INFO):
|
||||
entry = index.find_by_tmdb_id(TmdbId(84958))
|
||||
|
||||
assert entry is not None
|
||||
assert entry.tmdb_id == 84958
|
||||
# Healed entries carry placeholders (no TMDB sync yet).
|
||||
assert entry.status == "unknown"
|
||||
assert entry.seasons == ()
|
||||
assert (tv_library / ".alfred.index").is_file()
|
||||
assert any("healing" in r.message for r in caplog.records)
|
||||
|
||||
def test_corrupt_index_is_healed(
|
||||
self, tv_library, foundation_release, caplog
|
||||
):
|
||||
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
release_repo.save(foundation_release, show_folder="Foundation")
|
||||
# Plant a corrupt index.
|
||||
(tv_library / ".alfred.index").write_text("not: [valid yaml")
|
||||
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
entries = index.find_all()
|
||||
|
||||
assert len(entries) == 1
|
||||
assert any("corrupt" in r.message for r in caplog.records)
|
||||
|
||||
def test_schema_version_mismatch_in_index_is_healed(
|
||||
self, tv_library, foundation_release
|
||||
):
|
||||
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
release_repo.save(foundation_release, show_folder="Foundation")
|
||||
(tv_library / ".alfred.index").write_text(
|
||||
"schema_version: 999\nshows: []\n"
|
||||
)
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
|
||||
entries = index.find_all()
|
||||
# After heal, only Foundation (the only valid per-show sidecar) appears.
|
||||
assert len(entries) == 1
|
||||
assert entries[0].tmdb_id == 84958
|
||||
|
||||
def test_heal_is_idempotent(
|
||||
self, tv_library, foundation_release
|
||||
):
|
||||
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
release_repo.save(foundation_release, show_folder="Foundation")
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
|
||||
first = index.heal()
|
||||
second = index.heal()
|
||||
# Compare model state minus the ``fetched_at`` (timestamps differ).
|
||||
assert len(first.shows) == len(second.shows) == 1
|
||||
assert first.shows[0].tmdb_id == second.shows[0].tmdb_id
|
||||
|
||||
def test_heal_with_empty_library_writes_empty_index(self, tv_library):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.heal()
|
||||
assert (tv_library / ".alfred.index").is_file()
|
||||
assert index.find_all() == ()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# TV — atomicity
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestTVShowLibraryIndexAtomicity:
|
||||
def test_upsert_leaves_no_tmp_file(
|
||||
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
|
||||
):
|
||||
index = DotAlfredTVShowLibraryIndex(tv_library)
|
||||
index.upsert(
|
||||
foundation_tmdb_info,
|
||||
foundation_release,
|
||||
path="Foundation",
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
tmps = list(tv_library.glob("*.tmp"))
|
||||
assert tmps == []
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Movies
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMovieLibraryIndex:
|
||||
def test_upsert_and_find(
|
||||
self, movie_library, inception_release, now_utc
|
||||
):
|
||||
index = DotAlfredMovieLibraryIndex(movie_library)
|
||||
index.upsert(
|
||||
inception_release,
|
||||
name="Inception",
|
||||
release_year=2010,
|
||||
path=inception_release.folder,
|
||||
fetched_at=now_utc,
|
||||
)
|
||||
entry = index.find_by_tmdb_id(TmdbId(27205))
|
||||
assert entry is not None
|
||||
assert entry.name == "Inception"
|
||||
assert entry.release_year == 2010
|
||||
|
||||
def test_missing_index_heals_from_movie_sidecars(
|
||||
self, movie_library, inception_release, caplog
|
||||
):
|
||||
release_repo = DotAlfredMovieReleaseRepository(movie_library)
|
||||
release_repo.save(inception_release)
|
||||
|
||||
index = DotAlfredMovieLibraryIndex(movie_library, release_repo=release_repo)
|
||||
with caplog.at_level(logging.INFO):
|
||||
entry = index.find_by_tmdb_id(TmdbId(27205))
|
||||
assert entry is not None
|
||||
assert entry.tmdb_id == 27205
|
||||
# Placeholder until TMDB sync.
|
||||
assert entry.release_year is None
|
||||
assert any("healing" in r.message for r in caplog.records)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Integration tests for the per-item release repositories.
|
||||
|
||||
Cover the atomic-write contract, the log+skip-on-corruption behavior,
|
||||
the strict schema-version check, and the movie-anchor warning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.shared.value_objects import TmdbId
|
||||
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
|
||||
DotAlfredMovieReleaseRepository,
|
||||
DotAlfredSeriesReleaseRepository,
|
||||
ShowFolderUnknown,
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Series — save / read / delete
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSeriesReleaseRepositorySave:
|
||||
def test_save_writes_alfred_in_show_folder(
|
||||
self, tv_library, foundation_release
|
||||
):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
repo.save(foundation_release, show_folder="Foundation")
|
||||
assert (tv_library / "Foundation" / ".alfred").is_file()
|
||||
|
||||
def test_save_unknown_folder_raises(self, tv_library, foundation_release):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
with pytest.raises(ShowFolderUnknown):
|
||||
repo.save(foundation_release, show_folder="Nope")
|
||||
|
||||
def test_save_then_find_by_tmdb_id_returns_equal(
|
||||
self, tv_library, foundation_release
|
||||
):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
repo.save(foundation_release, show_folder="Foundation")
|
||||
restored = repo.find_by_tmdb_id(TmdbId(84958))
|
||||
assert restored == foundation_release
|
||||
|
||||
def test_save_is_atomic_no_tmp_left_behind(
|
||||
self, tv_library, foundation_release
|
||||
):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
repo.save(foundation_release, show_folder="Foundation")
|
||||
tmps = list((tv_library / "Foundation").glob("*.tmp"))
|
||||
assert tmps == []
|
||||
|
||||
|
||||
class TestSeriesReleaseRepositoryReads:
|
||||
def test_find_all_skips_folders_without_sidecar(
|
||||
self, tv_library, foundation_release
|
||||
):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
repo.save(foundation_release, show_folder="Foundation")
|
||||
# Fallout/ exists in the fixture but has no .alfred — must be skipped.
|
||||
results = repo.find_all()
|
||||
assert len(results) == 1
|
||||
assert results[0].tmdb_id == TmdbId(84958)
|
||||
|
||||
def test_find_all_logs_and_skips_corrupt_sidecar(
|
||||
self, tv_library, foundation_release, caplog
|
||||
):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
repo.save(foundation_release, show_folder="Foundation")
|
||||
# Corrupt Fallout's sidecar.
|
||||
(tv_library / "Fallout" / ".alfred").write_text("not: [valid")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = repo.find_all()
|
||||
assert len(results) == 1
|
||||
assert any("Fallout" in r.message for r in caplog.records)
|
||||
|
||||
def test_unknown_schema_version_is_skipped(
|
||||
self, tv_library, foundation_release, caplog
|
||||
):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
repo.save(foundation_release, show_folder="Foundation")
|
||||
# Hand-roll a future-version sidecar.
|
||||
(tv_library / "Fallout" / ".alfred").write_text(
|
||||
"schema_version: 999\ntmdb_id: 12345\nreleases: []\n"
|
||||
)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = repo.find_all()
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
class TestSeriesReleaseRepositoryDelete:
|
||||
def test_delete_removes_sidecar(self, tv_library, foundation_release):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
repo.save(foundation_release, show_folder="Foundation")
|
||||
assert repo.delete(TmdbId(84958)) is True
|
||||
assert not (tv_library / "Foundation" / ".alfred").exists()
|
||||
assert (tv_library / "Foundation").is_dir() # folder preserved
|
||||
|
||||
def test_delete_unknown_id_returns_false(self, tv_library):
|
||||
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||
assert repo.delete(TmdbId(999)) is False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Movies
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMovieReleaseRepository:
|
||||
def test_save_writes_alfred_in_movie_folder(
|
||||
self, movie_library, inception_release
|
||||
):
|
||||
repo = DotAlfredMovieReleaseRepository(movie_library)
|
||||
repo.save(inception_release)
|
||||
sidecar = movie_library / inception_release.folder / ".alfred"
|
||||
assert sidecar.is_file()
|
||||
|
||||
def test_save_round_trip(self, movie_library, inception_release):
|
||||
repo = DotAlfredMovieReleaseRepository(movie_library)
|
||||
repo.save(inception_release)
|
||||
restored = repo.find_by_tmdb_id(TmdbId(27205))
|
||||
assert restored == inception_release
|
||||
|
||||
def test_anchor_mismatch_logs_warning(
|
||||
self, movie_library, inception_release, caplog
|
||||
):
|
||||
repo = DotAlfredMovieReleaseRepository(movie_library)
|
||||
repo.save(inception_release)
|
||||
# Rename folder so the sidecar.folder anchor no longer matches.
|
||||
original = movie_library / inception_release.folder
|
||||
renamed = movie_library / "Renamed.Manually"
|
||||
original.rename(renamed)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
list(repo.find_all())
|
||||
assert any("anchor mismatch" in r.message for r in caplog.records)
|
||||
@@ -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