feat(persistence): add DotAlfredTVShowRepository (filesystem-backed)

Step 3 of specs/dot_alfred.md. Concrete TVShowRepository
implementation reading and writing per-show .alfred YAML files under
a configurable library_root. Writes are atomic (.alfred.tmp +
os.replace), reads tolerate corrupted/wrong-schema sidecars (log +
skip), and the repo never invents a folder name — save(show)
requires the target folder to exist beforehand (raises
ShowFolderUnknown otherwise), matching the spec's
MediaOrganizer-then-sidecar split.

Cold folders without a sidecar are skipped by find_all and yield
None from find_by_imdb_id — the upcoming rescan_show tool (step 4)
will own the opt-in rebuild path.

A small bridge module translates between the rich domain TVShow
(AudioTrack/SubtitleTrack with full ffprobe minutiae) and the
compact sidecar shape (language-only audio, embedded-only subs with
type derived from is_forced). The bridge is intentionally lossy on
probe details the sidecar does not store, per the spec's
factual-only philosophy.

20 integration tests on tmp_path: round-trip save/find,
cold-folder/unknown-id returns, find_all skipping
(corrupted/schema-violating sidecars), delete/exists, atomic write
(no .alfred.tmp leftover), overwrite, and folder-name fallbacks
(get_folder_name guess + full-scan rescue when renamed).
This commit is contained in:
2026-05-22 17:16:41 +02:00
parent b0e275bd11
commit c7c11180d9
5 changed files with 721 additions and 0 deletions
@@ -0,0 +1,308 @@
"""Tests for the filesystem-backed ``.alfred`` repository."""
from __future__ import annotations
import pytest
import yaml
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
from alfred.domain.shared.value_objects import FilePath, ImdbId
from alfred.domain.tv_shows.builders import TVShowBuilder
from alfred.domain.tv_shows.entities import Episode
from alfred.infrastructure.persistence.dot_alfred import (
DotAlfredTVShowRepository,
ShowFolderUnknown,
)
from alfred.infrastructure.persistence.dot_alfred.repository import (
SIDECAR_FILENAME,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_breaking_bad_episodic():
"""Breaking Bad with one EPISODIC season carrying two episodes."""
return (
TVShowBuilder(
imdb_id="tt0903747",
title="Breaking Bad",
tmdb_id=1396,
)
.add_episode(
Episode(
season_number=5,
episode_number=1,
title="Live Free or Die",
file_path=FilePath("Breaking.Bad.S05E01.mkv"),
audio_tracks=(
AudioTrack(
index=0,
codec=None,
channels=None,
channel_layout=None,
language="eng",
),
),
subtitle_tracks=(
SubtitleTrack(
index=0,
codec=None,
language="eng",
is_default=False,
is_forced=False,
),
),
)
)
.add_episode(
Episode(
season_number=5,
episode_number=2,
title="Madrigal",
file_path=FilePath("Breaking.Bad.S05E02.mkv"),
audio_tracks=(
AudioTrack(
index=0,
codec=None,
channels=None,
channel_layout=None,
language="eng",
),
),
)
)
.build()
)
def _make_foundation_pack():
"""Foundation as a PACK season (no episodes in the aggregate)."""
return TVShowBuilder(
imdb_id="tt0804484",
title="Foundation",
tmdb_id=84958,
).build()
# ---------------------------------------------------------------------------
# save + find_by_imdb_id round-trip
# ---------------------------------------------------------------------------
class TestSaveAndRead:
def test_save_then_find_by_imdb_id(self, tmp_path):
# Folder must exist before save (the repo never invents one).
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
show = _make_breaking_bad_episodic()
repo.save(show)
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
assert recovered is not None
assert recovered.imdb_id == show.imdb_id
assert recovered.tmdb_id == show.tmdb_id
assert recovered.seasons_count == 1
assert recovered.episode_count == 2
def test_find_by_imdb_id_uses_folder_name_as_title(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
assert recovered is not None
assert recovered.title == "Breaking.Bad"
def test_find_returns_none_for_unknown(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_by_imdb_id(ImdbId("tt9999999")) is None
def test_find_returns_none_for_cold_folder(self, tmp_path):
# Folder exists but has no .alfred — cold scan returns None.
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_by_imdb_id(ImdbId("tt0903747")) is None
def test_pack_season_round_trip(self, tmp_path):
(tmp_path / "Foundation").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_foundation_pack())
recovered = repo.find_by_imdb_id(ImdbId("tt0804484"))
assert recovered is not None
assert recovered.seasons_count == 0
# ---------------------------------------------------------------------------
# find_all
# ---------------------------------------------------------------------------
class TestFindAll:
def test_returns_every_sidecar(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
(tmp_path / "Foundation").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
repo.save(_make_foundation_pack())
all_shows = repo.find_all()
ids = {str(s.imdb_id) for s in all_shows}
assert ids == {"tt0903747", "tt0804484"}
def test_skips_folders_without_sidecar(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
(tmp_path / "ColdFolder").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
assert len(repo.find_all()) == 1
def test_skips_corrupted_sidecar(self, tmp_path, caplog):
cold = tmp_path / "Garbage"
cold.mkdir()
(cold / SIDECAR_FILENAME).write_text("not: valid: yaml: :{[")
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_all() == []
def test_skips_schema_violation(self, tmp_path):
bad = tmp_path / "WrongSchema"
bad.mkdir()
(bad / SIDECAR_FILENAME).write_text(
yaml.safe_dump({"schema_version": 99, "imdb_id": "tt0", "seasons": []})
)
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_all() == []
def test_empty_library(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_all() == []
# ---------------------------------------------------------------------------
# delete + exists
# ---------------------------------------------------------------------------
class TestDeleteAndExists:
def test_exists_after_save(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
assert repo.exists(ImdbId("tt0903747")) is True
def test_exists_false_before_save(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.exists(ImdbId("tt0903747")) is False
def test_delete_removes_sidecar(self, tmp_path):
show_dir = tmp_path / "Breaking.Bad"
show_dir.mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
assert (show_dir / SIDECAR_FILENAME).is_file()
assert repo.delete(ImdbId("tt0903747")) is True
assert not (show_dir / SIDECAR_FILENAME).exists()
# The show folder itself stays — the repo only owns the sidecar.
assert show_dir.is_dir()
def test_delete_returns_false_when_unknown(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.delete(ImdbId("tt9999999")) is False
# ---------------------------------------------------------------------------
# save edge cases
# ---------------------------------------------------------------------------
class TestSaveEdgeCases:
def test_save_raises_when_folder_missing(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
with pytest.raises(ShowFolderUnknown):
repo.save(_make_breaking_bad_episodic())
def test_save_atomic_no_tmp_file_left_behind(self, tmp_path):
show_dir = tmp_path / "Breaking.Bad"
show_dir.mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
# Only the final .alfred should remain.
leftovers = [p.name for p in show_dir.iterdir()]
assert leftovers == [SIDECAR_FILENAME]
def test_save_overwrites_existing(self, tmp_path):
show_dir = tmp_path / "Breaking.Bad"
show_dir.mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
# Save a second time with a different aggregate — sidecar must
# carry the new content.
updated = TVShowBuilder(
imdb_id="tt0903747",
title="Breaking Bad",
tmdb_id=1396,
).build()
repo.save(updated)
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
assert recovered is not None
assert recovered.seasons_count == 0 # the second save dropped the season
def test_save_finds_folder_via_get_folder_name(self, tmp_path):
# show.get_folder_name() returns "Breaking.Bad" for title "Breaking Bad"
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
# No prior save → cache empty → falls back to get_folder_name().
repo.save(_make_breaking_bad_episodic())
assert (tmp_path / "Breaking.Bad" / SIDECAR_FILENAME).is_file()
def test_save_falls_back_to_scan_when_folder_renamed(self, tmp_path):
# Create a custom folder name (not the show's default).
custom = tmp_path / "Breaking.Bad.1080p"
custom.mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
# Pre-populate the index by reading the sidecar — first we need
# to put one there. Write a minimal one by hand.
(custom / SIDECAR_FILENAME).write_text(
yaml.safe_dump(
{
"schema_version": 1,
"imdb_id": "tt0903747",
"tmdb_id": 1396,
"seasons": [],
}
)
)
# find_all primes the folder index.
repo.find_all()
# Now save through the repo — it must reuse the custom folder.
repo.save(_make_breaking_bad_episodic())
assert (custom / SIDECAR_FILENAME).is_file()
# Default folder was never created.
assert not (tmp_path / "Breaking.Bad").exists()
# ---------------------------------------------------------------------------
# Sidecar content sanity
# ---------------------------------------------------------------------------
class TestSidecarContent:
def test_written_yaml_matches_schema(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
text = (tmp_path / "Breaking.Bad" / SIDECAR_FILENAME).read_text()
data = yaml.safe_load(text)
assert data["schema_version"] == 1
assert data["imdb_id"] == "tt0903747"
assert data["tmdb_id"] == 1396
# One EPISODIC season with two episodes.
assert len(data["seasons"]) == 1
season = data["seasons"][0]
assert season["number"] == 5
assert len(season["episodes"]) == 2
# Episode paths come from FilePath.
assert season["episodes"][0]["path"] == "Breaking.Bad.S05E01.mkv"