From 8f31f880aa87be28aabc7733de378cfd5219914a Mon Sep 17 00:00:00 2001 From: Francwa Date: Tue, 26 May 2026 00:49:00 +0200 Subject: [PATCH] feat(tv_shows): sync_show populates library index from TMDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New orchestrator alfred.application.tv_shows.sync.sync_show calls TMDBClient.get_tv_show_info, combines the response with the on-disk release loaded via DotAlfredSeriesReleaseRepository.load_by_tmdb_id, and upserts the result into DotAlfredTVShowLibraryIndex. Policy: * placeholders (auto-healed entries, status=="unknown") always refresh regardless of TTL, * fresh entries within Settings.tmdb_cache_ttl_days are no-ops, * stale entries past TTL refresh, * force=True overrides both gates, * indexed shows whose per-show sidecar is gone still get a fresh TMDB pass — slot map clears until rescan repopulates it, * truly absent shows raise ShowNotFoundInLibrary from the new alfred.application.exceptions module. --- alfred/application/exceptions.py | 26 +++ alfred/application/tv_shows/sync.py | 146 +++++++++++++ tests/application/tv_shows/conftest.py | 70 ++++++ tests/application/tv_shows/test_sync.py | 271 ++++++++++++++++++++++++ 4 files changed, 513 insertions(+) create mode 100644 alfred/application/exceptions.py create mode 100644 alfred/application/tv_shows/sync.py create mode 100644 tests/application/tv_shows/conftest.py create mode 100644 tests/application/tv_shows/test_sync.py diff --git a/alfred/application/exceptions.py b/alfred/application/exceptions.py new file mode 100644 index 0000000..e307c55 --- /dev/null +++ b/alfred/application/exceptions.py @@ -0,0 +1,26 @@ +"""Application-layer exceptions shared across orchestrators. + +Kept in a dedicated module (rather than inside each orchestrator's +file) because the sync flows for TV shows and movies raise structurally +identical "not found in library" errors — pulling them out makes the +shared semantics explicit and avoids cross-imports between the +``tv_shows`` and ``movies`` packages. +""" + +from __future__ import annotations + + +class ShowNotFoundInLibrary(LookupError): + """Raised when no on-disk TV show carries the requested ``tmdb_id``. + + The sync orchestrator raises this when both the library index and + the per-show release repository return ``None`` for a lookup — + there is nothing on disk to refresh TMDB facts against. + """ + + +class MovieNotFoundInLibrary(LookupError): + """Raised when no on-disk movie carries the requested ``tmdb_id``. + + Symmetric to :class:`ShowNotFoundInLibrary` for the movies library. + """ diff --git a/alfred/application/tv_shows/sync.py b/alfred/application/tv_shows/sync.py new file mode 100644 index 0000000..a34aadf --- /dev/null +++ b/alfred/application/tv_shows/sync.py @@ -0,0 +1,146 @@ +"""``sync_show`` — refresh TMDB-cached fields on the TV library index. + +The orchestrator hits TMDB for one show, combines the response with +the on-disk release (if any), and upserts the result into the +library-root index. It is the only place that calls +:meth:`TMDBClient.get_tv_show_info`; the rescan flow stays +TMDB-free. + +TTL & placeholder policy +------------------------ + +``ttl_days`` (passed by the caller, sourced from +:attr:`Settings.tmdb_cache_ttl_days`) gates refreshes for entries +that already carry real TMDB facts. Placeholder entries — those +produced by the library index's auto-heal path, recognizable by +``status == "unknown"`` — always refresh regardless of TTL, because +auto-heal leaves the cache empty on purpose. ``force=True`` overrides +both gates. + +Missing on-disk release +----------------------- + +If the library index has an entry for ``tmdb_id`` but the per-show +sidecar is absent or corrupt, the sync still proceeds: ``release`` is +passed as ``None`` to :meth:`DotAlfredTVShowLibraryIndex.upsert`, +which produces an entry with TMDB facts but an empty episode-slot +map. Callers can then run :func:`rescan_show` to repopulate the slot +map. This is the "library knows the show, files temporarily missing" +state — a stale index pointing at a deleted folder. + +If both index and release sidecar are missing, the show genuinely +isn't in the library and we raise +:class:`ShowNotFoundInLibrary` — sync cannot invent a folder anchor. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path + +from alfred.application.exceptions import ShowNotFoundInLibrary +from alfred.domain.shared.value_objects import TmdbId +from alfred.infrastructure.api.tmdb.client import TMDBClient +from alfred.infrastructure.persistence.dot_alfred.v2.repository import ( + DotAlfredSeriesReleaseRepository, + DotAlfredTVShowLibraryIndex, +) +from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_root import ( + ShowIndexEntry, +) + +_LOG = logging.getLogger(__name__) + +# Placeholder marker written by the library index's auto-heal path. +# See ``DotAlfredTVShowLibraryIndex._build_from_releases``. +_PLACEHOLDER_STATUS = "unknown" + + +def sync_show( + library_root: Path, + *, + tmdb_id: TmdbId, + index: DotAlfredTVShowLibraryIndex, + release_repo: DotAlfredSeriesReleaseRepository, + tmdb_client: TMDBClient, + ttl_days: int, + now: Callable[[], datetime] = lambda: datetime.now(UTC), + force: bool = False, +) -> ShowIndexEntry: + """Refresh TMDB-cached fields for ``tmdb_id`` on the TV index. + + Args: + library_root: TV shows library root (informational — the + index and repos already carry their own root). + tmdb_id: show identifier. + index: library-root index to read and upsert into. + release_repo: per-show sidecar repository, used to resolve + the on-disk release + folder anchor. + tmdb_client: TMDB HTTP client. + ttl_days: max age (days) for an already-synced entry before + it is considered stale. Sourced from + :attr:`Settings.tmdb_cache_ttl_days` by the caller. + now: clock injection for deterministic tests. + force: bypass TTL gate; placeholders always refresh + regardless of this flag. + + Returns: + The fresh :class:`ShowIndexEntry` (the one already in the + index when fresh, the newly-upserted one otherwise). + + Raises: + ShowNotFoundInLibrary: when neither index nor release repo + knows the show. + TMDBAPIError: re-raised from the client. + """ + del library_root # not needed for the algorithm; documented for symmetry with rescan_show + current_time = now() + existing = index.find_by_tmdb_id(tmdb_id) + + if existing is not None and not force and not _needs_refresh( + existing, ttl_days=ttl_days, now=current_time + ): + return existing + + info = tmdb_client.get_tv_show_info(tmdb_id.value) + loaded = release_repo.load_by_tmdb_id(tmdb_id) + + if loaded is None and existing is None: + raise ShowNotFoundInLibrary( + f"no on-disk TV show carries tmdb_id={tmdb_id.value}" + ) + + if loaded is not None: + release, folder = loaded + else: + # Index entry exists but per-show sidecar is gone or corrupt. + # Use the anchor recorded in the index; the slot map will be + # empty until a rescan repopulates it. + release = None + folder = existing.metadata.path + _LOG.warning( + "sync_show: per-show sidecar missing for tmdb_id=%s; " + "upserting index entry with empty episode slots (anchor=%s)", + tmdb_id.value, + folder, + ) + + index.upsert(info, release, path=folder, fetched_at=current_time) + refreshed = index.find_by_tmdb_id(tmdb_id) + # ``upsert`` writes synchronously; the follow-up read returns the + # entry we just persisted. Defensive ``assert``: if it isn't there, + # the index implementation has regressed. + assert refreshed is not None, "upsert did not persist entry" + return refreshed + + +def _needs_refresh( + entry: ShowIndexEntry, *, ttl_days: int, now: datetime +) -> bool: + """True if ``entry`` is a placeholder or older than ``ttl_days``.""" + if entry.status == _PLACEHOLDER_STATUS: + return True + age = now - entry.metadata.fetched_at + return age.days >= ttl_days diff --git a/tests/application/tv_shows/conftest.py b/tests/application/tv_shows/conftest.py new file mode 100644 index 0000000..0fee6db --- /dev/null +++ b/tests/application/tv_shows/conftest.py @@ -0,0 +1,70 @@ +"""Shared fixtures for ``alfred.application.tv_shows`` tests. + +These mirror the v2 dot_alfred conftest (tv_library + foundation_release ++ now_utc) so the sync orchestrator tests can compose against the same +realistic aggregate without cross-package fixture inheritance. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from alfred.domain.releases.entities import ( + EpisodeRelease, + SeasonRelease, + SeriesRelease, + TrackProfile, +) +from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode +from alfred.domain.shared.media import AudioTrack +from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId +from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber + + +@pytest.fixture +def tv_library(tmp_path): + """Empty ``tv_shows/`` with a Foundation/ folder ready for sidecars.""" + root = tmp_path / "tv_shows" + root.mkdir() + (root / "Foundation").mkdir() + return root + + +@pytest.fixture +def foundation_release() -> SeriesRelease: + """Minimal Foundation S01 PACK — enough for slot-map assertions.""" + season = SeasonRelease( + season_number=SeasonNumber(1), + folder="Foundation.S01", + mode=ReleaseMode.PACK, + episodes=( + EpisodeRelease( + episodes=EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)), + file_path=FilePath("Foundation.S01/Foundation.S01E01.mkv"), + tracks=TrackProfile( + audio_tracks=( + AudioTrack( + index=0, + codec="eac3", + channels=6, + channel_layout="5.1", + language="eng", + ), + ), + ), + ), + ), + ) + return SeriesRelease( + tmdb_id=TmdbId(84958), + imdb_id=ImdbId("tt0804484"), + seasons=(season,), + ) + + +@pytest.fixture +def now_utc() -> datetime: + """Stable UTC reference for deterministic fetched_at fields.""" + return datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC) diff --git a/tests/application/tv_shows/test_sync.py b/tests/application/tv_shows/test_sync.py new file mode 100644 index 0000000..0bc3fee --- /dev/null +++ b/tests/application/tv_shows/test_sync.py @@ -0,0 +1,271 @@ +"""Tests for ``sync_show`` — TMDB sync orchestrator for TV shows. + +Cover the four interesting paths the spec calls out: + +* placeholder entry (auto-healed index) — always refreshes regardless of TTL; +* fresh entry within TTL — no-op, no TMDB call; +* stale entry past TTL — refreshes; +* ``force=True`` — refreshes regardless of TTL; +* missing on-disk sidecar but indexed — warns + upserts empty slots; +* not in index and no sidecar — raises :class:`ShowNotFoundInLibrary`. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta + +import pytest + +from alfred.application.exceptions import ShowNotFoundInLibrary +from alfred.application.tv_shows.sync import sync_show +from alfred.domain.shared.value_objects import TmdbId +from alfred.infrastructure.api.tmdb.dto import TmdbSeasonInfo, TmdbShowInfo +from alfred.infrastructure.persistence.dot_alfred.v2.repository import ( + DotAlfredSeriesReleaseRepository, + DotAlfredTVShowLibraryIndex, +) + + +class _StubTMDBClient: + """Minimal stand-in implementing only ``get_tv_show_info``. + + Records call count so tests can assert no-op behavior on fresh + entries. Raising stubs are passed inline when needed. + """ + + def __init__(self, info: TmdbShowInfo) -> None: + self._info = info + self.calls: list[int] = [] + + def get_tv_show_info(self, tmdb_id: int) -> TmdbShowInfo: + self.calls.append(tmdb_id) + return self._info + + +@pytest.fixture +def tmdb_info_fresh() -> TmdbShowInfo: + """TMDB payload returned by the stub — distinct from any placeholder.""" + 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), + ), + ) + + +@pytest.fixture +def now_clock(now_utc): + """A ``now()`` callable that returns the fixed ``now_utc``.""" + return lambda: now_utc + + +def _make_index_and_repos(tv_library): + repo = DotAlfredSeriesReleaseRepository(tv_library) + index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=repo) + return repo, index + + +# ════════════════════════════════════════════════════════════════════════════ +# Happy paths +# ════════════════════════════════════════════════════════════════════════════ + + +class TestSyncShowRefreshesPlaceholder: + def test_placeholder_entry_refreshes_regardless_of_ttl( + self, tv_library, foundation_release, tmdb_info_fresh, now_clock + ): + # Persist the per-show sidecar; the index auto-heal will then + # produce a placeholder entry (status="unknown") on first read. + repo, index = _make_index_and_repos(tv_library) + repo.save(foundation_release, show_folder="Foundation") + client = _StubTMDBClient(tmdb_info_fresh) + + # TTL=365 days — without the placeholder rule, this entry would + # be considered fresh (auto-heal stamps fetched_at=now). + result = sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=365, + now=now_clock, + ) + + assert client.calls == [84958] + assert result.name == "Foundation" + assert result.status == "Returning Series" + # Index entry was upserted with the on-disk slot map. + assert result.seasons[0].episodes # season 1 has files + + +class TestSyncShowTTLGate: + def test_fresh_entry_within_ttl_is_noop( + self, tv_library, foundation_release, tmdb_info_fresh, now_utc + ): + repo, index = _make_index_and_repos(tv_library) + repo.save(foundation_release, show_folder="Foundation") + # First sync — populates the entry with real status. + client = _StubTMDBClient(tmdb_info_fresh) + sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + ) + first_count = len(client.calls) + + # Second sync, ttl=14d, called 3 days later — should be no-op. + later = now_utc + timedelta(days=3) + result = sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: later, + ) + assert len(client.calls) == first_count # no extra call + assert result.status == "Returning Series" + + def test_stale_entry_past_ttl_refreshes( + self, tv_library, foundation_release, tmdb_info_fresh, now_utc + ): + repo, index = _make_index_and_repos(tv_library) + repo.save(foundation_release, show_folder="Foundation") + client = _StubTMDBClient(tmdb_info_fresh) + sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + ) + first_count = len(client.calls) + + # 20 days later — past the 14d TTL. + later = now_utc + timedelta(days=20) + sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: later, + ) + assert len(client.calls) == first_count + 1 + + +class TestSyncShowForce: + def test_force_bypasses_ttl( + self, tv_library, foundation_release, tmdb_info_fresh, now_utc + ): + repo, index = _make_index_and_repos(tv_library) + repo.save(foundation_release, show_folder="Foundation") + client = _StubTMDBClient(tmdb_info_fresh) + sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + ) + first_count = len(client.calls) + + # Same clock instant — would normally be a no-op. + sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + force=True, + ) + assert len(client.calls) == first_count + 1 + + +# ════════════════════════════════════════════════════════════════════════════ +# Degenerate paths +# ════════════════════════════════════════════════════════════════════════════ + + +class TestSyncShowMissingSidecar: + def test_indexed_show_without_sidecar_still_upserts_with_empty_slots( + self, tv_library, foundation_release, tmdb_info_fresh, now_utc, caplog + ): + # First, fully sync — establishes an index entry pointing at + # the Foundation folder. + repo, index = _make_index_and_repos(tv_library) + repo.save(foundation_release, show_folder="Foundation") + client = _StubTMDBClient(tmdb_info_fresh) + sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + ) + + # Remove the per-show sidecar; index entry persists. + (tv_library / "Foundation" / ".alfred").unlink() + + # Force a re-sync — load_by_tmdb_id will return None, but the + # index entry's path anchor lets us proceed with empty slots. + with caplog.at_level(logging.WARNING): + result = sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + force=True, + ) + assert result.metadata.path == "Foundation" + # Slot map is empty across all seasons. + for season in result.seasons: + assert season.episodes == {} + assert any("per-show sidecar missing" in r.message for r in caplog.records) + + +class TestSyncShowNotFound: + def test_no_index_entry_no_sidecar_raises( + self, tv_library, tmdb_info_fresh, now_clock + ): + repo, index = _make_index_and_repos(tv_library) + # No per-show sidecar saved → index auto-heals to an empty + # entry list. tmdb_id=84958 is not in the (empty) library. + client = _StubTMDBClient(tmdb_info_fresh) + with pytest.raises(ShowNotFoundInLibrary, match="84958"): + sync_show( + tv_library, + tmdb_id=TmdbId(84958), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=now_clock, + ) + # Note: TMDB *was* called (sync hits TMDB then checks for the + # release) — that's by design, so the caller learns whether + # tmdb_id is even valid before being told "not in library". + assert client.calls == [84958]