feat(filesystem): wire inspect_release into resolve_destination

The four resolve_*_destination use cases now route through a private
_resolve_parsed helper that picks the right entry point:

  - source path provided AND it exists -> inspect_release(name, path)
    runs the full pipeline (parse + media-type refinement + probe
    + enrich), so missing tech tokens (quality, codec, ...) get
    filled by ffprobe and the refreshed tech_string lands in the
    destination folder / file names.

  - source path missing or absent       -> parse_release(name) only,
    same behavior as before. Back-compat: tests using fake /dl/*.mkv
    paths still pass unchanged.

resolve_episode_destination / resolve_movie_destination reuse their
existing source_file parameter as the inspection target. The two
folder-move use cases (season / series) gain a new OPTIONAL
source_path parameter — threaded through the agent tool wrappers
and documented in the YAML specs.

The lazy import inside _resolve_parsed avoids a circular import:
inspect_release imports detect_media_type / enrich_from_probe from
the same application.filesystem package whose __init__ re-exports
resolve_destination.

Three new tests in TestProbeEnrichmentWiring with a stub MediaProber
prove the wiring: movie picks up probe quality, season picks it up
via source_path, and a missing path correctly skips probe (back-compat
guard).
This commit is contained in:
2026-05-20 09:26:30 +02:00
parent e79ca462b8
commit 0fb59a4581
6 changed files with 181 additions and 6 deletions
+14
View File
@@ -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
+4 -2
View File
@@ -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()
@@ -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.
@@ -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.
@@ -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)
@@ -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() #
# --------------------------------------------------------------------------- #