feat(release): inspect_release orchestrator + InspectedResult VO
New application-layer entry point that composes the four inspection
layers in one call:
1. parse_release(name, kb) -> (ParsedRelease, ParseReport)
2. detect_media_type(parsed, path, kb) -> patch parsed.media_type
3. find_main_video(path, kb) -> Path | None (top-level scan)
4. prober.probe(video) + enrich -> when video exists and
media_type not in
{unknown, other}
Returns a frozen InspectedResult(parsed, report, source_path,
main_video, media_info, probe_used). kb and prober are injected — no
module-level singletons in inspect.py.
analyze_release tool now delegates to inspect_release; its output
gains two fields, confidence (0-100) and road (easy/shitty/path_of_pain),
surfaced from ParseReport so the LLM can route by confidence. Spec
updated to document them.
12 new tests covering happy paths, probe gating (no video, media_type
'other', probe failure), mutation contract (detect refining
parsed.media_type, enrich filling None fields), resilience
(nonexistent path), and frozen contract. Suite: 1058 passing.
This commit is contained in:
@@ -15,8 +15,27 @@ callers).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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
|
- **`MediaProber` port now covers full media probing**: added
|
||||||
`probe(video) -> MediaInfo | None` alongside the existing
|
`probe(video) -> MediaInfo | None` alongside the existing
|
||||||
`list_subtitle_streams`. `FfprobeMediaProber` (in
|
`list_subtitle_streams`. `FfprobeMediaProber` (in
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ from alfred.application.filesystem import (
|
|||||||
MoveMediaUseCase,
|
MoveMediaUseCase,
|
||||||
SetFolderPathUseCase,
|
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 (
|
from alfred.application.filesystem.resolve_destination import (
|
||||||
resolve_episode_destination as _resolve_episode_destination,
|
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,
|
resolve_series_destination as _resolve_series_destination,
|
||||||
)
|
)
|
||||||
from alfred.infrastructure.filesystem import FileManager, create_folder, move
|
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.metadata import MetadataStore
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
from alfred.infrastructure.probe import FfprobeMediaProber
|
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]:
|
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."""
|
"""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.application.filesystem.resolve_destination import _KB # noqa: PLC0415
|
||||||
from alfred.domain.release.services import parse_release # noqa: PLC0415
|
from alfred.application.release import inspect_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
|
|
||||||
|
|
||||||
|
result = inspect_release(release_name, Path(source_path), _KB, _PROBER)
|
||||||
|
parsed = result.parsed
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"media_type": parsed.media_type,
|
"media_type": parsed.media_type,
|
||||||
@@ -229,7 +215,9 @@ def analyze_release(release_name: str, source_path: str) -> dict[str, Any]:
|
|||||||
"edition": parsed.edition,
|
"edition": parsed.edition,
|
||||||
"site_tag": parsed.site_tag,
|
"site_tag": parsed.site_tag,
|
||||||
"is_season_pack": parsed.is_season_pack,
|
"is_season_pack": parsed.is_season_pack,
|
||||||
"probe_used": probe_used,
|
"probe_used": result.probe_used,
|
||||||
|
"confidence": result.report.confidence,
|
||||||
|
"road": result.report.road,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,5 @@ returns:
|
|||||||
site_tag: Source-site tag if present.
|
site_tag: Source-site tag if present.
|
||||||
is_season_pack: True when the folder contains a full season.
|
is_season_pack: True when the folder contains a full season.
|
||||||
probe_used: True when ffprobe successfully enriched the result.
|
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)."
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
"""Release application layer — orchestrators sitting between domain
|
"""Release application layer — orchestrators sitting between domain
|
||||||
parsing and infrastructure I/O.
|
parsing and infrastructure I/O.
|
||||||
|
|
||||||
Today it exposes the pre-pipeline exclusion helpers
|
Public surface:
|
||||||
(:mod:`supported_media`). Phase C will add the ``inspect_release``
|
|
||||||
orchestrator here.
|
- :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
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user