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:
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user