diff --git a/alfred/application/movies/__init__.py b/alfred/application/movies/__init__.py index 0e4c9e6..1bb5436 100644 --- a/alfred/application/movies/__init__.py +++ b/alfred/application/movies/__init__.py @@ -1,9 +1,12 @@ """Movie use cases.""" from .dto import SearchMovieResponse +from .rescan import MovieRescanFailed, rescan_movie from .search_movie import SearchMovieUseCase __all__ = [ - "SearchMovieUseCase", + "MovieRescanFailed", "SearchMovieResponse", + "SearchMovieUseCase", + "rescan_movie", ] diff --git a/alfred/application/movies/rescan.py b/alfred/application/movies/rescan.py new file mode 100644 index 0000000..448ac92 --- /dev/null +++ b/alfred/application/movies/rescan.py @@ -0,0 +1,122 @@ +"""``rescan_movie`` — rebuild a MovieRelease from disk and persist it. + +The orchestrator locates the main video inside a movie folder, runs +``inspect_release`` on it (same single-source-of-truth as the TV +rescan flow), and assembles the result into a frozen +:class:`MovieRelease` written to the per-movie v2 ``.alfred`` sidecar. + +Folder convention +----------------- + +Movies are **one folder, one main file** in Alfred's library layout: + + movies/ + Inception (2010)/ + Inception.2010.1080p.BluRay.x264-GROUP.mkv + optional.srt + optional.nfo + +``find_video_file`` is responsible for picking the main video +(recursive walk, deterministic ordering). Adjacent subtitles / nfos +are ignored by this orchestrator — only embedded subtitle tracks are +captured (same scope as TV rescan). + +TMDB +---- + +``rescan_movie`` does **not** call TMDB. Identity (``tmdb_id``, +optional ``imdb_id``) is supplied by the caller; the library index +auto-heals from the new sidecar on its next read. A subsequent TMDB +sync (Phase 5) layers identity facts (``name``, ``release_year``) on +top of the on-disk truth. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from pathlib import Path + +from alfred.application.release.inspect import inspect_release +from alfred.domain.release.ports import ReleaseKnowledge +from alfred.domain.releases.entities import MovieRelease, TrackProfile +from alfred.domain.shared.ports import MediaProber +from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId +from alfred.infrastructure.filesystem.find_video import find_video_file +from alfred.infrastructure.persistence.dot_alfred.v2.repository import ( + DotAlfredMovieReleaseRepository, +) + +_LOG = logging.getLogger(__name__) + + +class MovieRescanFailed(RuntimeError): + """Raised when ``rescan_movie`` cannot produce a release. + + The orchestrator surfaces a single explicit failure mode — no main + video found inside ``movie_dir``. All other adapter-level errors + (probe failure, parser low-confidence) degrade gracefully into a + sidecar with empty / partial fields, since the file is on disk + regardless. + """ + + +def rescan_movie( + movie_dir: Path, + *, + tmdb_id: TmdbId, + imdb_id: ImdbId | None = None, + movie_repo: DotAlfredMovieReleaseRepository, + prober: MediaProber, + kb: ReleaseKnowledge, +) -> MovieRelease: + """Rebuild and persist the :class:`MovieRelease` for ``movie_dir``. + + ``movie_dir.name`` is used as both the sidecar location (relative + to the movie library root) and the ``folder`` field on the + aggregate. + + Args: + movie_dir: absolute path to the movie folder under the movie + library root. + tmdb_id: TMDB primary key (required, no coercion). + imdb_id: optional secondary anchor. + movie_repo: v2 per-movie ``.alfred`` repository. + prober: ffprobe adapter (or stub). + kb: release knowledge base (video extensions, codecs, …). + + Returns: + The rebuilt :class:`MovieRelease` (also written to disk). + + Raises: + MovieRescanFailed: if no video file can be located inside + ``movie_dir``. ``added_at`` is fresh + (``datetime.now(UTC)``) on every rescan — the v2 sidecar + records when the release was last reconciled with disk, + not when the file appeared on the filesystem. + """ + main_video = find_video_file(movie_dir, kb) + if main_video is None: + raise MovieRescanFailed( + f"no video file found in {movie_dir}" + ) + + result = inspect_release(main_video.name, main_video, kb, prober) + media_info = result.media_info + audio_tracks = media_info.audio_tracks if media_info else () + subtitle_tracks = media_info.subtitle_tracks if media_info else () + + rel_path = main_video.relative_to(movie_dir) + release = MovieRelease( + tmdb_id=tmdb_id, + imdb_id=imdb_id, + folder=movie_dir.name, + file_path=FilePath(str(rel_path)), + added_at=datetime.now(UTC), + tracks=TrackProfile( + audio_tracks=audio_tracks, + subtitle_tracks=subtitle_tracks, + ), + ) + movie_repo.save(release) + return release diff --git a/tests/application/movies/__init__.py b/tests/application/movies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/application/movies/test_rescan.py b/tests/application/movies/test_rescan.py new file mode 100644 index 0000000..f25bcff --- /dev/null +++ b/tests/application/movies/test_rescan.py @@ -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"