feat(movies): sync_movie populates library index from TMDB

Parallel to sync_show. Calls TMDBClient.get_movie_info,
combines the TmdbMovieInfo with the on-disk MovieRelease loaded
via DotAlfredMovieReleaseRepository.load_by_tmdb_id, and upserts
into DotAlfredMovieLibraryIndex.

Policy mirrors sync_show with two adaptations specific to movies:
* placeholder signature is name == metadata.path (auto-heal writes
  them equal — the schema requires name to be non-empty so we can't
  use name == "" as the spec originally suggested),
* when the per-movie sidecar is gone but the index entry remains,
  sync warns and returns the existing entry unchanged (no upsert
  possible without a release: index.upsert requires folder/imdb_id
  from the MovieRelease itself).

Raises MovieNotFoundInLibrary when neither index nor sidecar
carry tmdb_id.
This commit is contained in:
2026-05-26 00:51:43 +02:00
parent 8f31f880aa
commit 7ff2e6bc4e
3 changed files with 393 additions and 0 deletions
+49
View File
@@ -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)
+222
View File
@@ -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,
)