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]
|
## [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
|
### Fixed
|
||||||
|
|
||||||
- **`enrich_from_probe` now refreshes `tech_string`** after filling
|
- **`enrich_from_probe` now refreshes `tech_string`** after filling
|
||||||
|
|||||||
@@ -56,10 +56,11 @@ def resolve_season_destination(
|
|||||||
tmdb_title: str,
|
tmdb_title: str,
|
||||||
tmdb_year: int,
|
tmdb_year: int,
|
||||||
confirmed_folder: str | None = None,
|
confirmed_folder: str | None = None,
|
||||||
|
source_path: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_season_destination.yaml."""
|
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_season_destination.yaml."""
|
||||||
return _resolve_season_destination(
|
return _resolve_season_destination(
|
||||||
release_name, tmdb_title, tmdb_year, confirmed_folder
|
release_name, tmdb_title, tmdb_year, confirmed_folder, source_path
|
||||||
).to_dict()
|
).to_dict()
|
||||||
|
|
||||||
|
|
||||||
@@ -99,10 +100,11 @@ def resolve_series_destination(
|
|||||||
tmdb_title: str,
|
tmdb_title: str,
|
||||||
tmdb_year: int,
|
tmdb_year: int,
|
||||||
confirmed_folder: str | None = None,
|
confirmed_folder: str | None = None,
|
||||||
|
source_path: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_series_destination.yaml."""
|
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_series_destination.yaml."""
|
||||||
return _resolve_series_destination(
|
return _resolve_series_destination(
|
||||||
release_name, tmdb_title, tmdb_year, confirmed_folder
|
release_name, tmdb_title, tmdb_year, confirmed_folder, source_path
|
||||||
).to_dict()
|
).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ parameters:
|
|||||||
one.
|
one.
|
||||||
example: Oz.1997.1080p.WEBRip.x265-KONTRAST
|
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:
|
returns:
|
||||||
ok:
|
ok:
|
||||||
description: Paths resolved unambiguously; ready to move.
|
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.
|
Forces the use case to use this exact folder name and skip detection.
|
||||||
example: The.Wire.2002.1080p.BluRay.x265-GROUP
|
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:
|
returns:
|
||||||
ok:
|
ok:
|
||||||
description: Path resolved; ready to move the pack.
|
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 import parse_release
|
||||||
from alfred.domain.release.ports import ReleaseKnowledge
|
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.knowledge.release_kb import YamlReleaseKnowledge
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
from alfred.infrastructure.probe import FfprobeMediaProber
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,6 +35,31 @@ logger = logging.getLogger(__name__)
|
|||||||
# Tests that need a custom KB can monkeypatch this attribute.
|
# Tests that need a custom KB can monkeypatch this attribute.
|
||||||
_KB: ReleaseKnowledge = YamlReleaseKnowledge()
|
_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(
|
def _find_existing_tvshow_folders(
|
||||||
tv_root: Path, tmdb_title_safe: str, tmdb_year: int
|
tv_root: Path, tmdb_title_safe: str, tmdb_year: int
|
||||||
@@ -237,12 +264,17 @@ def resolve_season_destination(
|
|||||||
tmdb_title: str,
|
tmdb_title: str,
|
||||||
tmdb_year: int,
|
tmdb_year: int,
|
||||||
confirmed_folder: str | None = None,
|
confirmed_folder: str | None = None,
|
||||||
|
source_path: str | None = None,
|
||||||
) -> ResolvedSeasonDestination:
|
) -> ResolvedSeasonDestination:
|
||||||
"""
|
"""
|
||||||
Compute destination paths for a season pack.
|
Compute destination paths for a season pack.
|
||||||
|
|
||||||
Returns series_folder + season_folder. No file paths — the whole
|
Returns series_folder + season_folder. No file paths — the whole
|
||||||
source folder is moved as-is into season_folder.
|
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()
|
tv_root = _get_tv_root()
|
||||||
if not tv_root:
|
if not tv_root:
|
||||||
@@ -252,7 +284,7 @@ def resolve_season_destination(
|
|||||||
message="TV show library path is not configured.",
|
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)
|
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
|
||||||
computed_name = parsed.show_folder_name(tmdb_title_safe, tmdb_year)
|
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.
|
Compute destination paths for a single episode file.
|
||||||
|
|
||||||
Returns series_folder + season_folder + library_file (full path to .mkv).
|
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()
|
tv_root = _get_tv_root()
|
||||||
if not tv_root:
|
if not tv_root:
|
||||||
@@ -302,7 +336,7 @@ def resolve_episode_destination(
|
|||||||
message="TV show library path is not configured.",
|
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
|
ext = Path(source_file).suffix
|
||||||
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
|
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
|
||||||
tmdb_episode_title_safe = (
|
tmdb_episode_title_safe = (
|
||||||
@@ -350,6 +384,8 @@ def resolve_movie_destination(
|
|||||||
Compute destination paths for a movie file.
|
Compute destination paths for a movie file.
|
||||||
|
|
||||||
Returns movie_folder + library_file (full path to .mkv).
|
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()
|
memory = get_memory()
|
||||||
movies_root = memory.ltm.library_paths.get("movie")
|
movies_root = memory.ltm.library_paths.get("movie")
|
||||||
@@ -360,7 +396,7 @@ def resolve_movie_destination(
|
|||||||
message="Movie library path is not configured.",
|
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
|
ext = Path(source_file).suffix
|
||||||
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
|
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
|
||||||
|
|
||||||
@@ -385,11 +421,15 @@ def resolve_series_destination(
|
|||||||
tmdb_title: str,
|
tmdb_title: str,
|
||||||
tmdb_year: int,
|
tmdb_year: int,
|
||||||
confirmed_folder: str | None = None,
|
confirmed_folder: str | None = None,
|
||||||
|
source_path: str | None = None,
|
||||||
) -> ResolvedSeriesDestination:
|
) -> ResolvedSeriesDestination:
|
||||||
"""
|
"""
|
||||||
Compute destination path for a complete multi-season series pack.
|
Compute destination path for a complete multi-season series pack.
|
||||||
|
|
||||||
Returns only series_folder — the whole pack lands directly inside it.
|
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()
|
tv_root = _get_tv_root()
|
||||||
if not tv_root:
|
if not tv_root:
|
||||||
@@ -399,7 +439,7 @@ def resolve_series_destination(
|
|||||||
message="TV show library path is not configured.",
|
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)
|
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
|
||||||
computed_name = parsed.show_folder_name(tmdb_title_safe, tmdb_year)
|
computed_name = parsed.show_folder_name(tmdb_title_safe, tmdb_year)
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,104 @@ class TestSeries:
|
|||||||
assert out.status == "needs_clarification"
|
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() #
|
# DTO to_dict() #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user