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:
@@ -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
|
||||
|
||||
@@ -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() #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user