diff --git a/alfred/application/movies/sync.py b/alfred/application/movies/sync.py new file mode 100644 index 0000000..994d053 --- /dev/null +++ b/alfred/application/movies/sync.py @@ -0,0 +1,122 @@ +"""``sync_movie`` — refresh TMDB-cached fields on the movies library index. + +Parallel to :func:`alfred.application.tv_shows.sync.sync_show`. See +that module for the TTL / placeholder / force-flag policy — the +movies flow is structurally identical, differing only in the DTO +shape (movies have no seasons) and the placeholder marker (movies +use ``name == ""`` rather than ``status == "unknown"``). +""" + +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 MovieNotFoundInLibrary +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 ( + DotAlfredMovieLibraryIndex, + DotAlfredMovieReleaseRepository, +) +from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_root import ( + MovieIndexEntry, +) + +_LOG = logging.getLogger(__name__) + +# Placeholder signature written by the movie library index's +# auto-heal path: ``MovieIndexEntry.name == metadata.path`` (the +# heal copies the folder name into ``name`` because it has no TMDB +# title to write). The sidecar schema requires ``name`` to be +# non-empty, so we cannot use ``name == ""`` as the marker — the +# folder-name equality is the next best signature. +# See ``DotAlfredMovieLibraryIndex._build_from_releases``. + + +def sync_movie( + library_root: Path, + *, + tmdb_id: TmdbId, + index: DotAlfredMovieLibraryIndex, + release_repo: DotAlfredMovieReleaseRepository, + tmdb_client: TMDBClient, + ttl_days: int, + now: Callable[[], datetime] = lambda: datetime.now(UTC), + force: bool = False, +) -> MovieIndexEntry: + """Refresh TMDB-cached fields for ``tmdb_id`` on the movie index. + + Args: + library_root: movies library root (informational). + tmdb_id: movie identifier. + index: library-root index to read and upsert into. + release_repo: per-movie sidecar repository. + 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`. + now: clock injection for deterministic tests. + force: bypass TTL gate; placeholders always refresh. + + Returns: + The fresh :class:`MovieIndexEntry`. + + Raises: + MovieNotFoundInLibrary: when no on-disk movie carries + ``tmdb_id`` (no per-movie sidecar and no index entry). + TMDBAPIError: re-raised from the client. + """ + del library_root # documented for symmetry with sync_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_movie_info(tmdb_id.value) + release = release_repo.load_by_tmdb_id(tmdb_id) + + if release is None and existing is None: + raise MovieNotFoundInLibrary( + f"no on-disk movie carries tmdb_id={tmdb_id.value}" + ) + + if release is None: + # Index entry exists but per-movie sidecar is gone or corrupt. + # We cannot upsert because index.upsert(release=...) is the + # only path that knows the imdb_id and folder anchor for + # movies (it's all carried on the release). Warn and skip — + # let the caller rescan to repopulate the per-movie sidecar. + _LOG.warning( + "sync_movie: per-movie sidecar missing for tmdb_id=%s; " + "skipping index upsert (anchor=%s) — rescan to repair", + tmdb_id.value, + existing.metadata.path, + ) + return existing + + index.upsert( + release, + name=info.title, + release_year=info.release_year, + path=release.folder, + fetched_at=current_time, + ) + refreshed = index.find_by_tmdb_id(tmdb_id) + assert refreshed is not None, "upsert did not persist entry" + return refreshed + + +def _needs_refresh( + entry: MovieIndexEntry, *, ttl_days: int, now: datetime +) -> bool: + """True if ``entry`` is a placeholder or older than ``ttl_days``.""" + if entry.name == entry.metadata.path: + return True + age = now - entry.metadata.fetched_at + return age.days >= ttl_days diff --git a/tests/application/movies/conftest.py b/tests/application/movies/conftest.py new file mode 100644 index 0000000..253399a --- /dev/null +++ b/tests/application/movies/conftest.py @@ -0,0 +1,49 @@ +"""Shared fixtures for ``alfred.application.movies`` tests.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from alfred.domain.releases.entities import MovieRelease, TrackProfile +from alfred.domain.shared.media import AudioTrack +from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId + + +@pytest.fixture +def movie_library(tmp_path): + """Empty ``movies/`` with an Inception folder ready for sidecars.""" + root = tmp_path / "movies" + root.mkdir() + (root / "Inception.2010.1080p.BluRay.x264-GROUP").mkdir() + return root + + +@pytest.fixture +def inception_release() -> MovieRelease: + """Minimal Inception MovieRelease — enough for index assertions.""" + 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"), + added_at=datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC), + tracks=TrackProfile( + audio_tracks=( + AudioTrack( + index=0, + codec="dts", + channels=8, + channel_layout="7.1", + language="eng", + ), + ), + ), + ) + + +@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/movies/test_sync.py b/tests/application/movies/test_sync.py new file mode 100644 index 0000000..eb86a56 --- /dev/null +++ b/tests/application/movies/test_sync.py @@ -0,0 +1,222 @@ +"""Tests for ``sync_movie`` — TMDB sync orchestrator for movies.""" + +from __future__ import annotations + +import logging +from datetime import timedelta + +import pytest + +from alfred.application.exceptions import MovieNotFoundInLibrary +from alfred.application.movies.sync import sync_movie +from alfred.domain.shared.value_objects import TmdbId +from alfred.infrastructure.api.tmdb.dto import TmdbMovieInfo +from alfred.infrastructure.persistence.dot_alfred.v2.repository import ( + DotAlfredMovieLibraryIndex, + DotAlfredMovieReleaseRepository, +) + + +class _StubTMDBClient: + """Minimal stand-in implementing only ``get_movie_info``.""" + + def __init__(self, info: TmdbMovieInfo) -> None: + self._info = info + self.calls: list[int] = [] + + def get_movie_info(self, tmdb_id: int) -> TmdbMovieInfo: + self.calls.append(tmdb_id) + return self._info + + +@pytest.fixture +def tmdb_info_fresh() -> TmdbMovieInfo: + """TMDB payload returned by the stub — distinct from any placeholder.""" + return TmdbMovieInfo( + tmdb_id=27205, + imdb_id="tt1375666", + title="Inception", + release_year=2010, + ) + + +def _make_index_and_repos(movie_library): + repo = DotAlfredMovieReleaseRepository(movie_library) + index = DotAlfredMovieLibraryIndex(movie_library, release_repo=repo) + return repo, index + + +# ════════════════════════════════════════════════════════════════════════════ +# Happy paths +# ════════════════════════════════════════════════════════════════════════════ + + +class TestSyncMovieRefreshesPlaceholder: + def test_placeholder_entry_refreshes_regardless_of_ttl( + self, movie_library, inception_release, tmdb_info_fresh, now_utc + ): + repo, index = _make_index_and_repos(movie_library) + repo.save(inception_release) + client = _StubTMDBClient(tmdb_info_fresh) + + result = sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=365, + now=lambda: now_utc, + ) + assert client.calls == [27205] + assert result.name == "Inception" + assert result.release_year == 2010 + + +class TestSyncMovieTTLGate: + def test_fresh_entry_within_ttl_is_noop( + self, movie_library, inception_release, tmdb_info_fresh, now_utc + ): + repo, index = _make_index_and_repos(movie_library) + repo.save(inception_release) + client = _StubTMDBClient(tmdb_info_fresh) + sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + ) + first = len(client.calls) + later = now_utc + timedelta(days=3) + result = sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: later, + ) + assert len(client.calls) == first + assert result.name == "Inception" + + def test_stale_entry_past_ttl_refreshes( + self, movie_library, inception_release, tmdb_info_fresh, now_utc + ): + repo, index = _make_index_and_repos(movie_library) + repo.save(inception_release) + client = _StubTMDBClient(tmdb_info_fresh) + sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + ) + first = len(client.calls) + later = now_utc + timedelta(days=20) + sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: later, + ) + assert len(client.calls) == first + 1 + + +class TestSyncMovieForce: + def test_force_bypasses_ttl( + self, movie_library, inception_release, tmdb_info_fresh, now_utc + ): + repo, index = _make_index_and_repos(movie_library) + repo.save(inception_release) + client = _StubTMDBClient(tmdb_info_fresh) + sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + ) + first = len(client.calls) + sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + force=True, + ) + assert len(client.calls) == first + 1 + + +# ════════════════════════════════════════════════════════════════════════════ +# Degenerate paths +# ════════════════════════════════════════════════════════════════════════════ + + +class TestSyncMovieMissingSidecar: + def test_indexed_movie_without_sidecar_logs_and_returns_existing( + self, movie_library, inception_release, tmdb_info_fresh, now_utc, caplog + ): + repo, index = _make_index_and_repos(movie_library) + repo.save(inception_release) + client = _StubTMDBClient(tmdb_info_fresh) + sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + ) + + # Wipe the per-movie sidecar. + (movie_library / inception_release.folder / ".alfred").unlink() + + with caplog.at_level(logging.WARNING): + result = sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + force=True, + ) + # Existing index entry returned unchanged — sync can't upsert + # without a release to source folder/imdb_id from. + assert result.name == "Inception" + assert any("per-movie sidecar missing" in r.message for r in caplog.records) + + +class TestSyncMovieNotFound: + def test_no_index_entry_no_sidecar_raises( + self, movie_library, tmdb_info_fresh, now_utc + ): + repo, index = _make_index_and_repos(movie_library) + client = _StubTMDBClient(tmdb_info_fresh) + with pytest.raises(MovieNotFoundInLibrary, match="27205"): + sync_movie( + movie_library, + tmdb_id=TmdbId(27205), + index=index, + release_repo=repo, + tmdb_client=client, + ttl_days=14, + now=lambda: now_utc, + )