diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f72bae..450de2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,20 @@ callers). ## [Unreleased] +### Added + +- **`resolve_*_destination` use cases now consume `inspect_release`**. + `resolve_episode_destination` and `resolve_movie_destination` reuse + their existing `source_file` parameter as the inspection target; + `resolve_season_destination` and `resolve_series_destination` gain + a new **optional** `source_path` parameter (also threaded through + the tool wrappers and YAML specs). When the path exists, ffprobe + data fills tokens missing from the release name (e.g. quality) and + refreshes `tech_string`, so the destination folder / file names + end up more accurate. When the path is missing or absent (back-compat + callers), the use cases fall back to parse-only — same behavior as + before. + ### Fixed - **`enrich_from_probe` now refreshes `tech_string`** after filling diff --git a/alfred/agent/tools/filesystem.py b/alfred/agent/tools/filesystem.py index 1fd6f1a..5eb29a2 100644 --- a/alfred/agent/tools/filesystem.py +++ b/alfred/agent/tools/filesystem.py @@ -56,10 +56,11 @@ def resolve_season_destination( tmdb_title: str, tmdb_year: int, confirmed_folder: str | None = None, + source_path: str | None = None, ) -> dict[str, Any]: """Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_season_destination.yaml.""" return _resolve_season_destination( - release_name, tmdb_title, tmdb_year, confirmed_folder + release_name, tmdb_title, tmdb_year, confirmed_folder, source_path ).to_dict() @@ -99,10 +100,11 @@ def resolve_series_destination( tmdb_title: str, tmdb_year: int, confirmed_folder: str | None = None, + source_path: str | None = None, ) -> dict[str, Any]: """Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_series_destination.yaml.""" return _resolve_series_destination( - release_name, tmdb_title, tmdb_year, confirmed_folder + release_name, tmdb_title, tmdb_year, confirmed_folder, source_path ).to_dict() diff --git a/alfred/agent/tools/specs/resolve_season_destination.yaml b/alfred/agent/tools/specs/resolve_season_destination.yaml index 38b9806..f13c8f2 100644 --- a/alfred/agent/tools/specs/resolve_season_destination.yaml +++ b/alfred/agent/tools/specs/resolve_season_destination.yaml @@ -61,6 +61,17 @@ parameters: one. example: Oz.1997.1080p.WEBRip.x265-KONTRAST + source_path: + description: | + Absolute path to the release folder on disk. Optional. + why_needed: | + When provided, the tool runs ffprobe on the main video inside the + folder and uses the probe data to fill quality/codec tokens that + may be missing from the release name. The enriched tech tokens + end up in the destination folder name, so providing source_path + gives more accurate names for releases with sparse metadata. + example: /downloads/Oz.S03.1080p.WEBRip.x265-KONTRAST + returns: ok: description: Paths resolved unambiguously; ready to move. diff --git a/alfred/agent/tools/specs/resolve_series_destination.yaml b/alfred/agent/tools/specs/resolve_series_destination.yaml index e4224a6..a9db5ba 100644 --- a/alfred/agent/tools/specs/resolve_series_destination.yaml +++ b/alfred/agent/tools/specs/resolve_series_destination.yaml @@ -56,6 +56,16 @@ parameters: Forces the use case to use this exact folder name and skip detection. example: The.Wire.2002.1080p.BluRay.x265-GROUP + source_path: + description: | + Absolute path to the release folder on disk. Optional. + why_needed: | + When provided, the tool runs ffprobe on the main video inside the + folder and uses probe data to fill quality/codec tokens that may + be missing from the release name, producing a more accurate + destination folder name. + example: /downloads/The.Wire.S01-S05.1080p.BluRay.x265-GROUP + returns: ok: description: Path resolved; ready to move the pack. diff --git a/alfred/application/filesystem/resolve_destination.py b/alfred/application/filesystem/resolve_destination.py index de106b8..9e69a3d 100644 --- a/alfred/application/filesystem/resolve_destination.py +++ b/alfred/application/filesystem/resolve_destination.py @@ -24,8 +24,10 @@ from pathlib import Path from alfred.domain.release import parse_release from alfred.domain.release.ports import ReleaseKnowledge +from alfred.domain.release.value_objects import ParsedRelease from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge from alfred.infrastructure.persistence import get_memory +from alfred.infrastructure.probe import FfprobeMediaProber logger = logging.getLogger(__name__) @@ -33,6 +35,31 @@ logger = logging.getLogger(__name__) # Tests that need a custom KB can monkeypatch this attribute. _KB: ReleaseKnowledge = YamlReleaseKnowledge() +# Module-level prober — same singleton style as _KB. Tests that need a custom +# adapter can monkeypatch this attribute. +_PROBER = FfprobeMediaProber() + + +def _resolve_parsed(release_name: str, source_path: str | None) -> ParsedRelease: + """Pick the right entry point depending on whether we have a path. + + When ``source_path`` is provided and points to something that exists, + we run the full inspection pipeline so probe data can refresh + ``tech_string`` (which feeds every filename builder). Otherwise we + fall back to a parse-only path — same behavior as before. + """ + if source_path: + path = Path(source_path) + if path.exists(): + # Lazy import: ``alfred.application.release`` indirectly depends + # on this module (via :mod:`detect_media_type` / :mod:`enrich_from_probe`), + # so the import has to happen at call time, not at module load. + from alfred.application.release import inspect_release # noqa: PLC0415 + + return inspect_release(release_name, path, _KB, _PROBER).parsed + parsed, _ = parse_release(release_name, _KB) + return parsed + def _find_existing_tvshow_folders( tv_root: Path, tmdb_title_safe: str, tmdb_year: int @@ -237,12 +264,17 @@ def resolve_season_destination( tmdb_title: str, tmdb_year: int, confirmed_folder: str | None = None, + source_path: str | None = None, ) -> ResolvedSeasonDestination: """ Compute destination paths for a season pack. Returns series_folder + season_folder. No file paths — the whole source folder is moved as-is into season_folder. + + When ``source_path`` points to the release on disk, the parser is + augmented with ffprobe data so tech tokens missing from the release + name (quality / codec) end up in the folder names. """ tv_root = _get_tv_root() if not tv_root: @@ -252,7 +284,7 @@ def resolve_season_destination( message="TV show library path is not configured.", ) - parsed, _ = parse_release(release_name, _KB) + parsed = _resolve_parsed(release_name, source_path) tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title) computed_name = parsed.show_folder_name(tmdb_title_safe, tmdb_year) @@ -293,6 +325,8 @@ def resolve_episode_destination( Compute destination paths for a single episode file. Returns series_folder + season_folder + library_file (full path to .mkv). + ``source_file`` doubles as the inspection target — when it exists, + ffprobe enrichment refreshes tech tokens missing from the release name. """ tv_root = _get_tv_root() if not tv_root: @@ -302,7 +336,7 @@ def resolve_episode_destination( message="TV show library path is not configured.", ) - parsed, _ = parse_release(release_name, _KB) + parsed = _resolve_parsed(release_name, source_file) ext = Path(source_file).suffix tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title) tmdb_episode_title_safe = ( @@ -350,6 +384,8 @@ def resolve_movie_destination( Compute destination paths for a movie file. Returns movie_folder + library_file (full path to .mkv). + ``source_file`` doubles as the inspection target — when it exists, + ffprobe enrichment refreshes tech tokens missing from the release name. """ memory = get_memory() movies_root = memory.ltm.library_paths.get("movie") @@ -360,7 +396,7 @@ def resolve_movie_destination( message="Movie library path is not configured.", ) - parsed, _ = parse_release(release_name, _KB) + parsed = _resolve_parsed(release_name, source_file) ext = Path(source_file).suffix tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title) @@ -385,11 +421,15 @@ def resolve_series_destination( tmdb_title: str, tmdb_year: int, confirmed_folder: str | None = None, + source_path: str | None = None, ) -> ResolvedSeriesDestination: """ Compute destination path for a complete multi-season series pack. Returns only series_folder — the whole pack lands directly inside it. + + When ``source_path`` points to the release on disk, ffprobe + enrichment refreshes tech tokens missing from the release name. """ tv_root = _get_tv_root() if not tv_root: @@ -399,7 +439,7 @@ def resolve_series_destination( message="TV show library path is not configured.", ) - parsed, _ = parse_release(release_name, _KB) + parsed = _resolve_parsed(release_name, source_path) tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title) computed_name = parsed.show_folder_name(tmdb_title_safe, tmdb_year) diff --git a/tests/application/test_resolve_destination.py b/tests/application/test_resolve_destination.py index 1c67359..8cd57fa 100644 --- a/tests/application/test_resolve_destination.py +++ b/tests/application/test_resolve_destination.py @@ -322,6 +322,104 @@ class TestSeries: assert out.status == "needs_clarification" +# --------------------------------------------------------------------------- # +# Probe enrichment wiring # +# --------------------------------------------------------------------------- # + + +class _StubProber: + """Minimal MediaProber stub used to drive enrich_from_probe.""" + + def __init__(self, info): + self._info = info + + def list_subtitle_streams(self, video): # pragma: no cover - unused here + return [] + + def probe(self, video): + return self._info + + +def _stereo_movie_info(): + """A MediaInfo that fills quality+codec when the release name omits them.""" + from alfred.domain.shared.media import AudioTrack, MediaInfo, VideoTrack + + return MediaInfo( + video_tracks=(VideoTrack(index=0, codec="hevc", width=1920, height=1080),), + audio_tracks=( + AudioTrack( + index=1, + codec="aac", + channels=2, + channel_layout="stereo", + language="eng", + is_default=True, + ), + ), + subtitle_tracks=(), + ) + + +class TestProbeEnrichmentWiring: + """When source_path/source_file points to a real file, the resolver + should pick up ffprobe data via inspect_release and let the enriched + tech_string land in the destination name.""" + + def test_movie_picks_up_probe_quality( + self, cfg_memory, tmp_path, monkeypatch + ): + from alfred.application.filesystem import resolve_destination as rd + + monkeypatch.setattr(rd, "_PROBER", _StubProber(_stereo_movie_info())) + # Release name parses to "movie" but is missing the quality token; + # probe must supply 1080p and refresh tech_string. + bare_name = "Inception.2010.BluRay.x264-GROUP" + video = tmp_path / "movie.mkv" + video.write_bytes(b"") + + out = resolve_movie_destination(bare_name, str(video), "Inception", 2010) + + assert out.status == "ok" + # tech_string -> "1080p.BluRay.x264" -> "1080p" shows up in names. + assert "1080p" in out.movie_folder_name + assert "1080p" in out.filename + + def test_movie_skips_probe_when_path_missing(self, cfg_memory, monkeypatch): + # If the file doesn't exist, no probe runs (the stub would have + # injected 1080p — its absence proves the skip). + from alfred.application.filesystem import resolve_destination as rd + + monkeypatch.setattr(rd, "_PROBER", _StubProber(_stereo_movie_info())) + out = resolve_movie_destination( + "Inception.2010.BluRay.x264-GROUP", + "/nowhere/m.mkv", + "Inception", + 2010, + ) + assert out.status == "ok" + assert "1080p" not in out.movie_folder_name + + def test_season_picks_up_probe_via_source_path( + self, cfg_memory, tmp_path, monkeypatch + ): + from alfred.application.filesystem import resolve_destination as rd + + monkeypatch.setattr(rd, "_PROBER", _StubProber(_stereo_movie_info())) + # Season pack name missing quality token; probe must add it. + bare_name = "Oz.S03.BluRay.x265-KONTRAST" + release_dir = tmp_path / bare_name + release_dir.mkdir() + (release_dir / "episode.mkv").write_bytes(b"") + + out = resolve_season_destination( + bare_name, "Oz", 1997, source_path=str(release_dir) + ) + + assert out.status == "ok" + # Series folder name embeds tech_string -> "1080p" surfaced by probe. + assert "1080p" in out.series_folder_name + + # --------------------------------------------------------------------------- # # DTO to_dict() # # --------------------------------------------------------------------------- #