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