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.
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""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)
|