feat(rescan): Phase 4 Step 2 — add rescan_movie orchestrator
Mirror rescan_show for the movies library. Locates the main video via
find_video_file, runs inspect_release once (movies are one-folder-one-
main-file by convention), and writes a v2 MovieRelease sidecar via
DotAlfredMovieReleaseRepository.
Signature
rescan_movie(
movie_dir,
*,
tmdb_id: TmdbId,
imdb_id: ImdbId | None = None,
movie_repo: DotAlfredMovieReleaseRepository,
prober,
kb,
) -> MovieRelease
Behavior
* added_at = datetime.now(UTC) — the v2 sidecar records when the
release was last reconciled with disk, not filesystem mtime (which
drifts across moves and hard-links). Phase 3 made this field
required on MovieRelease.
* No TMDB call. Index auto-heals from the new sidecar on next read.
* MovieRescanFailed raised when no video is found inside movie_dir
(only explicit failure mode; all other adapter errors degrade
gracefully into empty / partial fields).
* file_path is recorded relative to movie_dir so the sidecar stays
portable across library moves.
Tests
tests/application/movies/test_rescan.py: 8 scenarios on the real v2
movie repo + real KB + stubbed prober. Covers track flattening,
sidecar round-trip, prober returning None, video in subfolder,
explicit no-video failure, imdb_id optional.
Full suite: 1233 passed / 10 skipped / 4 xfailed.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""Integration tests for ``rescan_movie``.
|
||||
|
||||
Real filesystem (``tmp_path``), real release KB, real v2 movie
|
||||
repository. Only the media prober is stubbed — ffprobe needs real
|
||||
bytes and a binary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.movies import MovieRescanFailed, rescan_movie
|
||||
from alfred.domain.shared.media import (
|
||||
AudioTrack,
|
||||
MediaInfo,
|
||||
SubtitleTrack,
|
||||
VideoTrack,
|
||||
)
|
||||
from alfred.domain.shared.value_objects import ImdbId, TmdbId
|
||||
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
|
||||
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
|
||||
SIDECAR_FILENAME,
|
||||
DotAlfredMovieReleaseRepository,
|
||||
)
|
||||
|
||||
_KB = YamlReleaseKnowledge()
|
||||
_TMDB_ID = TmdbId(27205)
|
||||
_IMDB_ID = ImdbId("tt1375666")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
class _StubProber:
|
||||
def __init__(self, info=_MISSING) -> None:
|
||||
self._info: MediaInfo | None = (
|
||||
_default_info() if info is _MISSING else info # type: ignore[assignment]
|
||||
)
|
||||
self.calls: list[Path] = []
|
||||
|
||||
def probe(self, video: Path) -> MediaInfo | None:
|
||||
self.calls.append(video)
|
||||
return self._info
|
||||
|
||||
def list_subtitle_streams(self, video: Path): # pragma: no cover
|
||||
return []
|
||||
|
||||
|
||||
def _default_info() -> MediaInfo:
|
||||
return MediaInfo(
|
||||
video_tracks=(VideoTrack(index=0, codec="hevc", width=1920, height=1080),),
|
||||
audio_tracks=(
|
||||
AudioTrack(
|
||||
index=0,
|
||||
codec="eac3",
|
||||
channels=6,
|
||||
channel_layout="5.1",
|
||||
language="eng",
|
||||
),
|
||||
),
|
||||
subtitle_tracks=(
|
||||
SubtitleTrack(
|
||||
index=0,
|
||||
codec="subrip",
|
||||
language="eng",
|
||||
is_default=False,
|
||||
is_forced=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_inception_folder(root: Path) -> Path:
|
||||
"""Build a fake Inception movie folder under ``root``."""
|
||||
movie_dir = root / "Inception (2010)"
|
||||
movie_dir.mkdir()
|
||||
(movie_dir / "Inception.2010.1080p.BluRay.x264-GROUP.mkv").write_bytes(b"")
|
||||
return movie_dir
|
||||
|
||||
|
||||
def _library_with_movie(tmp_path: Path) -> tuple[Path, Path]:
|
||||
library = tmp_path / "movies"
|
||||
library.mkdir()
|
||||
movie_dir = _make_inception_folder(library)
|
||||
return library, movie_dir
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Happy path #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
def test_builds_release_with_tracks(self, tmp_path):
|
||||
library, movie_dir = _library_with_movie(tmp_path)
|
||||
repo = DotAlfredMovieReleaseRepository(library)
|
||||
prober = _StubProber()
|
||||
|
||||
release = rescan_movie(
|
||||
movie_dir,
|
||||
tmdb_id=_TMDB_ID,
|
||||
imdb_id=_IMDB_ID,
|
||||
movie_repo=repo,
|
||||
prober=prober,
|
||||
kb=_KB,
|
||||
)
|
||||
|
||||
assert release.tmdb_id == _TMDB_ID
|
||||
assert release.imdb_id == _IMDB_ID
|
||||
assert release.folder == "Inception (2010)"
|
||||
assert str(release.file_path) == "Inception.2010.1080p.BluRay.x264-GROUP.mkv"
|
||||
assert isinstance(release.added_at, datetime)
|
||||
assert len(release.tracks.audio_tracks) == 1
|
||||
assert release.tracks.audio_tracks[0].language == "eng"
|
||||
assert len(release.tracks.subtitle_tracks) == 1
|
||||
|
||||
def test_file_path_relative_to_movie_dir(self, tmp_path):
|
||||
library, movie_dir = _library_with_movie(tmp_path)
|
||||
repo = DotAlfredMovieReleaseRepository(library)
|
||||
release = rescan_movie(
|
||||
movie_dir,
|
||||
tmdb_id=_TMDB_ID,
|
||||
movie_repo=repo,
|
||||
prober=_StubProber(),
|
||||
kb=_KB,
|
||||
)
|
||||
assert not Path(str(release.file_path)).is_absolute()
|
||||
|
||||
def test_persists_sidecar_on_disk_and_round_trips(self, tmp_path):
|
||||
library, movie_dir = _library_with_movie(tmp_path)
|
||||
repo = DotAlfredMovieReleaseRepository(library)
|
||||
rescan_movie(
|
||||
movie_dir,
|
||||
tmdb_id=_TMDB_ID,
|
||||
imdb_id=_IMDB_ID,
|
||||
movie_repo=repo,
|
||||
prober=_StubProber(),
|
||||
kb=_KB,
|
||||
)
|
||||
assert (movie_dir / SIDECAR_FILENAME).is_file()
|
||||
recovered = repo.find_by_tmdb_id(_TMDB_ID)
|
||||
assert recovered is not None
|
||||
assert recovered.tmdb_id == _TMDB_ID
|
||||
assert recovered.folder == "Inception (2010)"
|
||||
|
||||
def test_probe_called_exactly_once(self, tmp_path):
|
||||
library, movie_dir = _library_with_movie(tmp_path)
|
||||
repo = DotAlfredMovieReleaseRepository(library)
|
||||
prober = _StubProber()
|
||||
rescan_movie(
|
||||
movie_dir,
|
||||
tmdb_id=_TMDB_ID,
|
||||
movie_repo=repo,
|
||||
prober=prober,
|
||||
kb=_KB,
|
||||
)
|
||||
assert len(prober.calls) == 1
|
||||
|
||||
def test_imdb_id_optional(self, tmp_path):
|
||||
library, movie_dir = _library_with_movie(tmp_path)
|
||||
repo = DotAlfredMovieReleaseRepository(library)
|
||||
release = rescan_movie(
|
||||
movie_dir,
|
||||
tmdb_id=_TMDB_ID,
|
||||
movie_repo=repo,
|
||||
prober=_StubProber(),
|
||||
kb=_KB,
|
||||
)
|
||||
assert release.imdb_id is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Edge cases #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_no_video_raises(self, tmp_path):
|
||||
library = tmp_path / "movies"
|
||||
library.mkdir()
|
||||
movie_dir = library / "Empty (2010)"
|
||||
movie_dir.mkdir()
|
||||
(movie_dir / "notes.txt").write_text("x")
|
||||
repo = DotAlfredMovieReleaseRepository(library)
|
||||
with pytest.raises(MovieRescanFailed):
|
||||
rescan_movie(
|
||||
movie_dir,
|
||||
tmdb_id=_TMDB_ID,
|
||||
movie_repo=repo,
|
||||
prober=_StubProber(),
|
||||
kb=_KB,
|
||||
)
|
||||
|
||||
def test_prober_returning_none_still_produces_release(self, tmp_path):
|
||||
library, movie_dir = _library_with_movie(tmp_path)
|
||||
repo = DotAlfredMovieReleaseRepository(library)
|
||||
release = rescan_movie(
|
||||
movie_dir,
|
||||
tmdb_id=_TMDB_ID,
|
||||
movie_repo=repo,
|
||||
prober=_StubProber(info=None),
|
||||
kb=_KB,
|
||||
)
|
||||
assert release.tracks.audio_tracks == ()
|
||||
assert release.tracks.subtitle_tracks == ()
|
||||
|
||||
def test_video_in_subfolder_is_found(self, tmp_path):
|
||||
library = tmp_path / "movies"
|
||||
library.mkdir()
|
||||
movie_dir = library / "Inception (2010)"
|
||||
movie_dir.mkdir()
|
||||
sub = movie_dir / "extras"
|
||||
sub.mkdir()
|
||||
(sub / "Inception.2010.1080p.mkv").write_bytes(b"")
|
||||
repo = DotAlfredMovieReleaseRepository(library)
|
||||
release = rescan_movie(
|
||||
movie_dir,
|
||||
tmdb_id=_TMDB_ID,
|
||||
movie_repo=repo,
|
||||
prober=_StubProber(),
|
||||
kb=_KB,
|
||||
)
|
||||
# Relative path keeps the subfolder.
|
||||
assert str(release.file_path) == "extras/Inception.2010.1080p.mkv"
|
||||
Reference in New Issue
Block a user