diff --git a/CHANGELOG.md b/CHANGELOG.md index db99a90..3f30449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,27 @@ callers). ## [Unreleased] +### Added + +- **`inspect_release` orchestrator + `InspectedResult` VO** + (`alfred/application/release/inspect.py`). Single composition of the + four inspection layers: `parse_release` → `detect_media_type` (patches + `parsed.media_type`) → `find_main_video` (top-level scan) → + `prober.probe` + `enrich_from_probe` when a video exists and the + refined media type isn't in `{"unknown", "other"}`. Returns a frozen + `InspectedResult(parsed, report, source_path, main_video, media_info, + probe_used)` that downstream callers consume directly instead of + rebuilding the same chain. `kb` and `prober` are injected — no + module-level singletons. Never raises. + ### Changed +- **`analyze_release` tool now delegates to `inspect_release`** — same + output shape, plus two new fields: `confidence` (0–100) and `road` + (`"easy"` / `"shitty"` / `"path_of_pain"`) surfaced from the parser's + `ParseReport`. The tool spec (`specs/analyze_release.yaml`) documents + both fields so the LLM can route releases by confidence. + - **`MediaProber` port now covers full media probing**: added `probe(video) -> MediaInfo | None` alongside the existing `list_subtitle_streams`. `FfprobeMediaProber` (in diff --git a/alfred/agent/tools/filesystem.py b/alfred/agent/tools/filesystem.py index fc50a6a..1fd6f1a 100644 --- a/alfred/agent/tools/filesystem.py +++ b/alfred/agent/tools/filesystem.py @@ -13,8 +13,6 @@ from alfred.application.filesystem import ( MoveMediaUseCase, SetFolderPathUseCase, ) -from alfred.application.filesystem.detect_media_type import detect_media_type -from alfred.application.filesystem.enrich_from_probe import enrich_from_probe from alfred.application.filesystem.resolve_destination import ( resolve_episode_destination as _resolve_episode_destination, ) @@ -28,7 +26,6 @@ from alfred.application.filesystem.resolve_destination import ( resolve_series_destination as _resolve_series_destination, ) from alfred.infrastructure.filesystem import FileManager, create_folder, move -from alfred.infrastructure.filesystem.find_video import find_video_file from alfred.infrastructure.metadata import MetadataStore from alfred.infrastructure.persistence import get_memory from alfred.infrastructure.probe import FfprobeMediaProber @@ -193,21 +190,10 @@ def set_path_for_folder(folder_name: str, path_value: str) -> dict[str, Any]: def analyze_release(release_name: str, source_path: str) -> dict[str, Any]: """Thin tool wrapper — semantics live in alfred/agent/tools/specs/analyze_release.yaml.""" from alfred.application.filesystem.resolve_destination import _KB # noqa: PLC0415 - from alfred.domain.release.services import parse_release # noqa: PLC0415 - - path = Path(source_path) - parsed, _ = parse_release(release_name, _KB) - parsed.media_type = detect_media_type(parsed, path, _KB) - - probe_used = False - if parsed.media_type not in ("unknown", "other"): - video_file = find_video_file(path, _KB) - if video_file: - media_info = _PROBER.probe(video_file) - if media_info: - enrich_from_probe(parsed, media_info) - probe_used = True + from alfred.application.release import inspect_release # noqa: PLC0415 + result = inspect_release(release_name, Path(source_path), _KB, _PROBER) + parsed = result.parsed return { "status": "ok", "media_type": parsed.media_type, @@ -229,7 +215,9 @@ def analyze_release(release_name: str, source_path: str) -> dict[str, Any]: "edition": parsed.edition, "site_tag": parsed.site_tag, "is_season_pack": parsed.is_season_pack, - "probe_used": probe_used, + "probe_used": result.probe_used, + "confidence": result.report.confidence, + "road": result.report.road, } diff --git a/alfred/agent/tools/specs/analyze_release.yaml b/alfred/agent/tools/specs/analyze_release.yaml index c701fc6..17d4ba2 100644 --- a/alfred/agent/tools/specs/analyze_release.yaml +++ b/alfred/agent/tools/specs/analyze_release.yaml @@ -80,3 +80,5 @@ returns: site_tag: Source-site tag if present. is_season_pack: True when the folder contains a full season. probe_used: True when ffprobe successfully enriched the result. + confidence: Parser confidence score, 0–100 (higher = more reliable). + road: "Parser road: 'easy' (group schema matched), 'shitty' (heuristic but acceptable), or 'path_of_pain' (low confidence — ask the user before auto-routing)." diff --git a/alfred/application/release/__init__.py b/alfred/application/release/__init__.py index c00e603..1d75168 100644 --- a/alfred/application/release/__init__.py +++ b/alfred/application/release/__init__.py @@ -1,11 +1,20 @@ """Release application layer — orchestrators sitting between domain parsing and infrastructure I/O. -Today it exposes the pre-pipeline exclusion helpers -(:mod:`supported_media`). Phase C will add the ``inspect_release`` -orchestrator here. +Public surface: + +- :func:`is_supported_video` / :func:`find_main_video` — pre-pipeline + filesystem helpers (extension-only filtering, top-level video pick). +- :func:`inspect_release` / :class:`InspectedResult` — full inspection + pipeline combining parse + filesystem refinement + probe enrichment. """ +from .inspect import InspectedResult, inspect_release from .supported_media import find_main_video, is_supported_video -__all__ = ["find_main_video", "is_supported_video"] +__all__ = [ + "InspectedResult", + "find_main_video", + "inspect_release", + "is_supported_video", +] diff --git a/alfred/application/release/inspect.py b/alfred/application/release/inspect.py new file mode 100644 index 0000000..103a48b --- /dev/null +++ b/alfred/application/release/inspect.py @@ -0,0 +1,140 @@ +"""Release inspection orchestrator — the canonical "look at this thing" +entry point. + +``inspect_release`` is the single composition of the four layers we +care about for a freshly-arrived release: + +1. **Parse the name** — :func:`alfred.domain.release.services.parse_release` + gives a ``ParsedRelease`` plus a ``ParseReport`` (confidence + road). +2. **Pick the main video** — :func:`find_main_video` runs a top-level + scan over the source path. If nothing qualifies the result still + completes; downstream callers decide what to do with a videoless + release. +3. **Refine the media type** — :func:`detect_media_type` uses the + on-disk extension mix to override any token-level guess (e.g. a + bare ``.iso`` folder becomes ``"other"``). The refined value is + patched onto ``parsed`` in place — same convention as + ``analyze_release`` had before. +4. **Probe the video** — the injected :class:`MediaProber` fills in + missing technical fields via :func:`enrich_from_probe`. Skipped + when there is no main video or when ``media_type`` ended up in + ``{"unknown", "other"}`` (the probe would tell us nothing useful). + +The return type is :class:`InspectedResult`, a frozen VO that bundles +everything downstream callers need (``analyze_release`` tool, +``resolve_destination``, future workflow stages) without forcing them +to redo the same four calls. + +Design notes: + +- **Application layer.** This module touches both domain + (``parse_release``) and infrastructure (``MediaProber`` port). That + is exactly application's job — orchestrate. +- **Knowledge base is injected.** ``inspect_release`` takes ``kb`` and + ``prober`` as parameters; no module-level singletons here. Callers + (the tool wrapper, tests) decide what to plug in. +- **Mutation is contained.** We still mutate ``parsed.media_type`` and + let ``enrich_from_probe`` fill its ``None`` fields, because + ``ParsedRelease`` is intentionally a mutable dataclass. The outer + ``InspectedResult`` is frozen so the *bundle* is immutable from the + caller's perspective. +- **Never raises.** Filesystem / probe errors surface as ``None`` + fields on the result, never as exceptions — same contract as the + underlying adapters. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from alfred.application.filesystem.detect_media_type import detect_media_type +from alfred.application.filesystem.enrich_from_probe import enrich_from_probe +from alfred.application.release.supported_media import find_main_video +from alfred.domain.release.ports import ReleaseKnowledge +from alfred.domain.release.services import parse_release +from alfred.domain.release.value_objects import ParsedRelease, ParseReport +from alfred.domain.shared.media import MediaInfo +from alfred.domain.shared.ports import MediaProber + + +@dataclass(frozen=True) +class InspectedResult: + """The full picture of a release: parsed name + filesystem reality. + + Bundles everything the downstream pipeline needs after a single + inspection pass: + + - ``parsed`` — :class:`ParsedRelease`, with ``media_type`` already + refined by :func:`detect_media_type` and ``None`` tech fields + filled in by :func:`enrich_from_probe` when a probe ran. + - ``report`` — :class:`ParseReport` from the parser (confidence + + road, untouched by inspection). + - ``source_path`` — the path the inspector was pointed at (file or + folder), as supplied by the caller. + - ``main_video`` — the canonical video file inside ``source_path``, + or ``None`` if no eligible file was found. + - ``media_info`` — the :class:`MediaInfo` snapshot when a probe + succeeded; ``None`` when no video was probed (no main video, or + ``media_type`` in ``{"unknown", "other"}``) or when ffprobe + failed. + - ``probe_used`` — ``True`` iff ``media_info`` is non-``None`` and + ``enrich_from_probe`` actually ran. Explicit flag so callers + don't have to re-derive the condition. + """ + + parsed: ParsedRelease + report: ParseReport + source_path: Path + main_video: Path | None + media_info: MediaInfo | None + probe_used: bool + + +# Media types for which a probe carries no useful information. +_NON_PROBABLE_MEDIA_TYPES = frozenset({"unknown", "other"}) + + +def inspect_release( + release_name: str, + source_path: Path, + kb: ReleaseKnowledge, + prober: MediaProber, +) -> InspectedResult: + """Run the full inspection pipeline on ``release_name`` / + ``source_path``. + + See module docstring for the four-step flow. ``kb`` and ``prober`` + are injected so the caller controls the knowledge base layering + and the probe adapter (real ffprobe in production, stubs in tests). + + Never raises. A missing or unreadable ``source_path`` simply + results in ``main_video=None`` and ``media_info=None``. + """ + parsed, report = parse_release(release_name, kb) + + # Step 2: refine media_type from the on-disk extension mix. + # detect_media_type tolerates non-existent paths (returns parsed.media_type + # untouched), so no need to guard here. + parsed.media_type = detect_media_type(parsed, source_path, kb) + + # Step 3: pick the canonical main video (top-level scan only). + main_video = find_main_video(source_path, kb) + + # Step 4: probe + enrich, when it makes sense. + media_info: MediaInfo | None = None + probe_used = False + if main_video is not None and parsed.media_type not in _NON_PROBABLE_MEDIA_TYPES: + media_info = prober.probe(main_video) + if media_info is not None: + enrich_from_probe(parsed, media_info) + probe_used = True + + return InspectedResult( + parsed=parsed, + report=report, + source_path=source_path, + main_video=main_video, + media_info=media_info, + probe_used=probe_used, + ) diff --git a/tests/application/test_inspect.py b/tests/application/test_inspect.py new file mode 100644 index 0000000..f095a1f --- /dev/null +++ b/tests/application/test_inspect.py @@ -0,0 +1,265 @@ +"""Tests for the ``inspect_release`` orchestrator (Phase C). + +Covers the four composition steps as a black box: a real +``YamlReleaseKnowledge``, real on-disk filesystem under ``tmp_path``, +and a stubbed ``MediaProber`` so we don't depend on a system ``ffprobe``. +""" + +from __future__ import annotations + +from pathlib import Path + +from alfred.application.release import InspectedResult, inspect_release +from alfred.domain.shared.media import AudioTrack, MediaInfo, VideoTrack +from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge + +_KB = YamlReleaseKnowledge() + +_MOVIE_NAME = "Inception.2010.1080p.BluRay.x264-GROUP" +_TV_NAME = "Dexter.S01E01.1080p.WEB-DL.x264-GROUP" + + +# --------------------------------------------------------------------------- # +# Test doubles # +# --------------------------------------------------------------------------- # + + +class _StubProber: + """Minimal MediaProber stub. Records the path it was asked to probe.""" + + def __init__(self, info: MediaInfo | None) -> None: + self._info = info + self.calls: list[Path] = [] + + def list_subtitle_streams(self, video: Path): # pragma: no cover - unused here + return [] + + def probe(self, video: Path) -> MediaInfo | None: + self.calls.append(video) + return self._info + + +class _RaisingProber: + """A prober that would explode if called — used to assert no probe.""" + + def list_subtitle_streams(self, video: Path): # pragma: no cover + raise AssertionError("list_subtitle_streams must not be called") + + def probe(self, video: Path): # pragma: no cover + raise AssertionError("probe must not be called") + + +def _media_info_1080p_h264() -> MediaInfo: + return MediaInfo( + video_tracks=(VideoTrack(index=0, codec="h264", width=1920, height=1080),), + audio_tracks=( + AudioTrack( + index=1, + codec="ac3", + channels=6, + channel_layout="5.1", + language="eng", + is_default=True, + ), + ), + subtitle_tracks=(), + duration_seconds=7200.0, + bitrate_kbps=8000, + ) + + +# --------------------------------------------------------------------------- # +# Happy paths # +# --------------------------------------------------------------------------- # + + +class TestInspectMovieFolder: + def test_returns_inspected_result_with_all_fields(self, tmp_path: Path) -> None: + folder = tmp_path / _MOVIE_NAME + folder.mkdir() + video = folder / "movie.mkv" + video.write_bytes(b"") + prober = _StubProber(_media_info_1080p_h264()) + + result = inspect_release(_MOVIE_NAME, folder, _KB, prober) + + assert isinstance(result, InspectedResult) + assert result.source_path == folder + assert result.main_video == video + assert result.media_info is not None + assert result.probe_used is True + assert prober.calls == [video] + + def test_parsed_carries_token_level_fields(self, tmp_path: Path) -> None: + folder = tmp_path / _MOVIE_NAME + folder.mkdir() + (folder / "movie.mkv").write_bytes(b"") + prober = _StubProber(_media_info_1080p_h264()) + + result = inspect_release(_MOVIE_NAME, folder, _KB, prober) + + assert result.parsed.title.lower().startswith("inception") + assert result.parsed.year == 2010 + assert result.parsed.group == "GROUP" + assert result.parsed.media_type == "movie" + + def test_report_has_confidence_and_road(self, tmp_path: Path) -> None: + folder = tmp_path / _MOVIE_NAME + folder.mkdir() + (folder / "movie.mkv").write_bytes(b"") + prober = _StubProber(None) + + result = inspect_release(_MOVIE_NAME, folder, _KB, prober) + + assert 0 <= result.report.confidence <= 100 + assert result.report.road in ("easy", "shitty", "path_of_pain") + + +class TestInspectSingleFile: + def test_file_is_its_own_main_video(self, tmp_path: Path) -> None: + f = tmp_path / f"{_MOVIE_NAME}.mkv" + f.write_bytes(b"") + prober = _StubProber(_media_info_1080p_h264()) + + result = inspect_release(_MOVIE_NAME, f, _KB, prober) + + assert result.main_video == f + assert result.probe_used is True + + +# --------------------------------------------------------------------------- # +# Probe-gating logic # +# --------------------------------------------------------------------------- # + + +class TestProbeGating: + def test_no_video_means_no_probe(self, tmp_path: Path) -> None: + folder = tmp_path / _MOVIE_NAME + folder.mkdir() + # Only a non-video file present. + (folder / "readme.txt").write_text("hi") + prober = _RaisingProber() + + result = inspect_release(_MOVIE_NAME, folder, _KB, prober) + + assert result.main_video is None + assert result.media_info is None + assert result.probe_used is False + + def test_media_type_other_means_no_probe(self, tmp_path: Path) -> None: + # An ISO-only folder gets detect_media_type → "other". + folder = tmp_path / _MOVIE_NAME + folder.mkdir() + (folder / "disc.iso").write_bytes(b"") + prober = _RaisingProber() + + result = inspect_release(_MOVIE_NAME, folder, _KB, prober) + + assert result.parsed.media_type == "other" + assert result.media_info is None + assert result.probe_used is False + + def test_probe_failure_keeps_probe_used_false(self, tmp_path: Path) -> None: + folder = tmp_path / _MOVIE_NAME + folder.mkdir() + (folder / "movie.mkv").write_bytes(b"") + prober = _StubProber(None) # ffprobe simulated as failing + + result = inspect_release(_MOVIE_NAME, folder, _KB, prober) + + assert result.main_video is not None + assert result.media_info is None + assert result.probe_used is False + + +# --------------------------------------------------------------------------- # +# Mutation contract # +# --------------------------------------------------------------------------- # + + +class TestMutationContract: + def test_detect_media_type_refines_parsed(self, tmp_path: Path) -> None: + # Release name parses to "movie", but folder mixes video + non_video + # (e.g. an ISO sitting next to an mkv) → detect_media_type returns + # "unknown", which is in _NON_PROBABLE_MEDIA_TYPES → no probe. + folder = tmp_path / _MOVIE_NAME + folder.mkdir() + (folder / "movie.mkv").write_bytes(b"") + (folder / "extras.iso").write_bytes(b"") + prober = _RaisingProber() + + result = inspect_release(_MOVIE_NAME, folder, _KB, prober) + + assert result.parsed.media_type == "unknown" + assert result.probe_used is False + + def test_enrich_runs_when_probe_succeeds(self, tmp_path: Path) -> None: + # Build a release name with no codec; probe should fill it in. + name = "Inception.2010.1080p.BluRay-GROUP" + folder = tmp_path / name + folder.mkdir() + (folder / "movie.mkv").write_bytes(b"") + prober = _StubProber(_media_info_1080p_h264()) + + result = inspect_release(name, folder, _KB, prober) + + assert result.probe_used is True + # enrich_from_probe should have filled the missing codec field. + assert result.parsed.codec is not None + + +# --------------------------------------------------------------------------- # +# Resilience # +# --------------------------------------------------------------------------- # + + +class TestResilience: + def test_nonexistent_path_does_not_raise(self, tmp_path: Path) -> None: + ghost = tmp_path / "does-not-exist" + prober = _RaisingProber() + + result = inspect_release(_MOVIE_NAME, ghost, _KB, prober) + + assert result.main_video is None + assert result.media_info is None + assert result.probe_used is False + + def test_tv_release_inspection(self, tmp_path: Path) -> None: + folder = tmp_path / _TV_NAME + folder.mkdir() + video = folder / "episode.mkv" + video.write_bytes(b"") + prober = _StubProber(_media_info_1080p_h264()) + + result = inspect_release(_TV_NAME, folder, _KB, prober) + + assert result.parsed.media_type == "tv_show" + assert result.parsed.season == 1 + assert result.parsed.episode == 1 + assert result.main_video == video + assert result.probe_used is True + + +# --------------------------------------------------------------------------- # +# Frozen contract # +# --------------------------------------------------------------------------- # + + +class TestFrozen: + def test_inspected_result_is_frozen(self, tmp_path: Path) -> None: + folder = tmp_path / _MOVIE_NAME + folder.mkdir() + (folder / "movie.mkv").write_bytes(b"") + prober = _StubProber(None) + + result = inspect_release(_MOVIE_NAME, folder, _KB, prober) + + # frozen=True → assigning a field raises FrozenInstanceError. + import dataclasses + + try: + result.probe_used = True # type: ignore[misc] + except dataclasses.FrozenInstanceError: + pass + else: # pragma: no cover + raise AssertionError("InspectedResult should be frozen")