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:
@@ -1,9 +1,12 @@
|
|||||||
"""Movie use cases."""
|
"""Movie use cases."""
|
||||||
|
|
||||||
from .dto import SearchMovieResponse
|
from .dto import SearchMovieResponse
|
||||||
|
from .rescan import MovieRescanFailed, rescan_movie
|
||||||
from .search_movie import SearchMovieUseCase
|
from .search_movie import SearchMovieUseCase
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SearchMovieUseCase",
|
"MovieRescanFailed",
|
||||||
"SearchMovieResponse",
|
"SearchMovieResponse",
|
||||||
|
"SearchMovieUseCase",
|
||||||
|
"rescan_movie",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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