feat: release parser, media type detection, ffprobe integration

Replace the old domain/media release parser with a full rewrite under
domain/release/:
- ParsedRelease with media_type ("movie" | "tv_show" | "tv_complete" |
  "documentary" | "concert" | "other" | "unknown"), site_tag, parse_path,
  languages, audio_codec, audio_channels, bit_depth, hdr_format, edition
- Well-formedness check + sanitize pipeline (_is_well_formed, _sanitize,
  _strip_site_tag) before token-level parsing
- Multi-token sequence matching for audio (DTS-HD.MA, TrueHD.Atmos…),
  HDR (DV.HDR10…) and editions (DIRECTORS.CUT…)
- Knowledge YAML: file_extensions, release_format, languages, audio,
  video, editions, sites/c411

New infrastructure:
- ffprobe.py — single-pass probe returning MediaInfo (video, audio
  tracks, subtitle tracks)
- find_video.py — locate first video file in a release folder

New application helpers:
- detect_media_type — filesystem-based type refinement
- enrich_from_probe — fill missing ParsedRelease fields from MediaInfo

New agent tools:
- analyze_release — parse + detect type + ffprobe in one call
- probe_media — standalone ffprobe for a specific file

New domain value object:
- MediaInfo + AudioTrack + SubtitleTrack (domain/shared/media_info.py)

Testing CLIs:
- recognize_folders_in_downloads.py — full pipeline with colored output
- probe_video.py — display MediaInfo for a video file
This commit is contained in:
2026-05-12 16:14:20 +02:00
parent 249c5de76a
commit 1723b9fa53
32 changed files with 2323 additions and 562 deletions
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
parse_release.py — Test ParsedRelease interactively or via CLI args.
Usage:
uv run testing/parse_release.py "Oz.S03.1080p.WEBRip.x265-KONTRAST"
uv run testing/parse_release.py "Oz.S03.1080p.WEBRip.x265-KONTRAST" --tmdb
uv run testing/parse_release.py "Inception.2010.1080p.BluRay.x265-GROUP" --tmdb-title "Inception" --tmdb-year 2010
uv run testing/parse_release.py --interactive
"""
import argparse
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
USE_COLOR = True
def c(text: str, *codes: str) -> str:
if not USE_COLOR:
return str(text)
return "".join(codes) + str(text) + RESET
def kv(key: str, val: str, color: str = CYAN) -> None:
print(f" {c(key + ':', BOLD)} {c(val, color)}")
def hr() -> None:
print(c("" * 64, DIM))
# ---------------------------------------------------------------------------
# TMDB lookup
# ---------------------------------------------------------------------------
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
"""
Call TMDBClient.search_media() and return (canonical_title, year).
Returns (None, None) on failure.
"""
try:
from alfred.infrastructure.api.tmdb import TMDBClient
client = TMDBClient()
result = client.search_media(title)
year: int | None = None
if result.release_date:
try:
year = int(result.release_date[:4])
except (ValueError, IndexError):
pass
print(c(f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}", DIM))
return result.title, year
except Exception as e:
print(c(f" TMDB lookup failed: {e}", YELLOW))
return None, None
# ---------------------------------------------------------------------------
# Display
# ---------------------------------------------------------------------------
def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
tmdb_episode_title: str | None, ext: str) -> None:
from alfred.domain.release import parse_release
p = parse_release(release_name)
# Auto-fetch TMDB if requested and not already provided
if not (tmdb_title and tmdb_year):
fetched_title, fetched_year = _fetch_tmdb(p.title.replace(".", " "))
tmdb_title = tmdb_title or fetched_title
tmdb_year = tmdb_year or fetched_year
print()
print(c("" * 64, BOLD))
print(c(f" ParsedRelease — {p.raw}", BOLD, CYAN))
print(c("" * 64, BOLD))
# Core fields
hr()
kv("raw", p.raw)
kv("normalised", p.normalised)
kv("title", p.title)
kv("year", str(p.year) if p.year else c("None", DIM))
kv("season", str(p.season) if p.season is not None else c("None", DIM))
kv("episode", str(p.episode) if p.episode is not None else c("None", DIM))
kv("episode_end", str(p.episode_end) if p.episode_end is not None else c("None", DIM))
kv("quality", p.quality or c("None", DIM))
kv("source", p.source or c("None", DIM))
kv("codec", p.codec or c("None", DIM))
kv("group", p.group, YELLOW if p.group == "UNKNOWN" else GREEN)
kv("tech_string", p.tech_string or c("(empty)", DIM))
# Derived booleans
hr()
kv("is_movie", c(str(p.is_movie), GREEN if p.is_movie else DIM))
kv("is_season_pack", c(str(p.is_season_pack), GREEN if p.is_season_pack else DIM))
# Generated names
hr()
title_for_names = tmdb_title or p.title.replace(".", " ")
year_for_names = tmdb_year or p.year or 0
if p.is_movie:
kv("movie_folder_name", p.movie_folder_name(title_for_names, year_for_names))
kv("movie_filename", p.movie_filename(title_for_names, year_for_names, ext))
else:
kv("show_folder_name", p.show_folder_name(title_for_names, year_for_names))
kv("season_folder_name", p.season_folder_name())
if not p.is_season_pack:
kv("episode_filename", p.episode_filename(tmdb_episode_title, ext))
else:
kv("episode_filename", c("(season pack — no episode filename)", DIM))
if tmdb_title or tmdb_year or tmdb_episode_title:
hr()
print(c(" TMDB data used:", DIM))
if tmdb_title: kv(" tmdb_title", tmdb_title)
if tmdb_year: kv(" tmdb_year", str(tmdb_year))
if tmdb_episode_title: kv(" tmdb_episode_title", tmdb_episode_title)
print(c("" * 64, BOLD))
print()
# ---------------------------------------------------------------------------
# Interactive mode
# ---------------------------------------------------------------------------
def _interactive() -> None:
print(c("\n Alfred — Release Parser REPL", BOLD, CYAN))
print(c(" Type a release name, or 'q' to quit.", DIM))
print(c(" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n", DIM))
while True:
try:
raw = input(c(" release> ", BOLD)).strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not raw or raw.lower() in ("q", "quit", "exit"):
break
# Parse inline overrides: "Oz.S03E01... ::title=Oz ::year=1997 ::tmdb"
parts = raw.split("::")
release = parts[0].strip()
overrides: dict[str, str] = {}
for part in parts[1:]:
part = part.strip()
if "=" in part:
k, _, v = part.partition("=")
overrides[k.strip()] = v.strip()
else:
overrides[part] = "1" # flag-style: ::tmdb
tmdb_title = overrides.get("title")
tmdb_year = int(overrides["year"]) if "year" in overrides else None
tmdb_episode_title = overrides.get("ep")
ext = overrides.get("ext", ".mkv")
try:
_show(release, tmdb_title, tmdb_year, tmdb_episode_title, ext)
except Exception as e:
print(c(f" Error: {e}", RED))
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
parser = argparse.ArgumentParser(
description="Test ParsedRelease from domain/release/release_parser.py",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("release", nargs="?", help="Release name to parse")
parser.add_argument("-i", "--interactive", action="store_true",
help="Interactive REPL mode")
parser.add_argument("--tmdb-title", metavar="TITLE",
help="Override TMDB title for name generation")
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
help="Override TMDB year for name generation")
parser.add_argument("--episode-title", metavar="TITLE",
help="TMDB episode title for episode_filename()")
parser.add_argument("--ext", default=".mkv", metavar="EXT",
help="File extension for filename generation (default: .mkv)")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args()
if args.no_color or not sys.stdout.isatty():
USE_COLOR = False
if args.interactive:
_interactive()
return
if not args.release:
parser.print_help()
sys.exit(1)
try:
_show(args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext)
except Exception as e:
print(c(f"Error: {e}", RED), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
probe_video.py — Display MediaInfo extracted by ffprobe for a video file.
Usage:
uv run testing/probe_video.py /path/to/video.mkv
uv run testing/probe_video.py /path/to/video.mkv --no-color
"""
import argparse
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
USE_COLOR = True
def c(text: str, *codes: str) -> str:
if not USE_COLOR:
return str(text)
return "".join(codes) + str(text) + RESET
def kv(key: str, val: str, indent: int = 4, color: str = CYAN) -> None:
print(f"{' ' * indent}{c(key + ':', BOLD)} {c(val, color)}")
def section(title: str) -> None:
print()
print(f" {c('' + title, BOLD, BLUE)}")
def hr() -> None:
print(c("" * 70, DIM))
# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------
def fmt_duration(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
if h:
return f"{h}h {m:02d}m {s:02d}s"
return f"{m}m {s:02d}s"
def fmt_channels(channels: int | None, layout: str | None) -> str:
parts = []
if channels is not None:
parts.append(str(channels) + "ch")
if layout:
parts.append(f"({layout})")
return " ".join(parts) if parts else ""
def flag(val: bool) -> str:
return c("yes", GREEN) if val else c("no", DIM)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
parser = argparse.ArgumentParser(description="Probe a video file with ffprobe")
parser.add_argument("file", help="Path to the video file")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args()
if args.no_color or not sys.stdout.isatty():
USE_COLOR = False
path = Path(args.file)
if not path.exists():
print(c(f"Error: {path} does not exist", RED), file=sys.stderr)
sys.exit(1)
from alfred.infrastructure.filesystem.ffprobe import probe
info = probe(path)
if info is None:
print(c("Error: ffprobe failed to probe the file", RED), file=sys.stderr)
sys.exit(1)
print()
print(c("" * 70, BOLD))
print(c(f" {path.name}", BOLD, CYAN))
print(c(f" {path}", DIM))
print(c("" * 70, BOLD))
# --- Video ---
section("Video")
kv("codec", info.video_codec or c("", DIM))
kv("resolution", info.resolution or c("", DIM))
if info.width and info.height:
kv("dimensions", f"{info.width} × {info.height}")
if info.duration_seconds is not None:
kv("duration", fmt_duration(info.duration_seconds))
if info.bitrate_kbps is not None:
kv("bitrate", f"{info.bitrate_kbps} kbps")
# --- Audio ---
section(f"Audio {c(str(len(info.audio_tracks)) + ' track(s)', DIM)}")
if not info.audio_tracks:
print(f" {c('no audio tracks found', DIM)}")
for track in info.audio_tracks:
lang = track.language or "und"
default_marker = f" {c('default', GREEN, DIM)}" if track.is_default else ""
print(f" {c(f'[{track.index}]', BOLD)} {c(lang, YELLOW)}{default_marker}")
kv("codec", track.codec or c("", DIM), indent=8)
kv("channels", fmt_channels(track.channels, track.channel_layout), indent=8)
# --- Subtitles ---
section(f"Subtitles {c(str(len(info.subtitle_tracks)) + ' track(s)', DIM)}")
if not info.subtitle_tracks:
print(f" {c('no embedded subtitle tracks', DIM)}")
for track in info.subtitle_tracks:
lang = track.language or "und"
markers = []
if track.is_default:
markers.append(c("default", GREEN, DIM))
if track.is_forced:
markers.append(c("forced", YELLOW, DIM))
marker_str = (" " + " ".join(markers)) if markers else ""
print(f" {c(f'[{track.index}]', BOLD)} {c(lang, YELLOW)}{marker_str}")
kv("codec", track.codec or c("", DIM), indent=8)
# --- Summary ---
print()
hr()
multi = c("yes", GREEN) if info.is_multi_audio else c("no", DIM)
langs = ", ".join(info.audio_languages) if info.audio_languages else c("", DIM)
print(f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}")
hr()
print()
if __name__ == "__main__":
main()
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
recognize_folders_in_downloads.py — Parse every folder/file in the downloads directory.
Usage:
uv run testing/recognize_folders_in_downloads.py
uv run testing/recognize_folders_in_downloads.py --path /mnt/testipool/downloads
uv run testing/recognize_folders_in_downloads.py --failures-only
uv run testing/recognize_folders_in_downloads.py --successes-only
"""
import argparse
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
USE_COLOR = True
def c(text: str, *codes: str) -> str:
if not USE_COLOR:
return str(text)
return "".join(codes) + str(text) + RESET
def kv(key: str, val: str, indent: int = 4, color: str = CYAN) -> None:
print(f"{' ' * indent}{c(key + ':', BOLD)} {c(val, color)}")
def hr() -> None:
print(c("" * 70, DIM))
# ---------------------------------------------------------------------------
# Parsing quality check
# ---------------------------------------------------------------------------
def _assess(p) -> list[str]:
"""Return a list of warning strings for fields that look wrong."""
if p.media_type in ("other", "unknown"):
return []
warnings = []
if p.group == "UNKNOWN":
warnings.append("group not found")
if not p.quality:
warnings.append("resolution not found")
if not p.codec:
warnings.append("codec not found")
if not p.title or p.title == p.normalised:
warnings.append("title extraction likely wrong")
return warnings
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
parser = argparse.ArgumentParser(description="Recognize release folders in downloads")
parser.add_argument("--path", default="/mnt/testipool/downloads",
help="Downloads directory (default: /mnt/testipool/downloads)")
parser.add_argument("--failures-only", action="store_true",
help="Show only entries with warnings")
parser.add_argument("--successes-only", action="store_true",
help="Show only fully parsed entries")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args()
if args.no_color or not sys.stdout.isatty():
USE_COLOR = False
downloads = Path(args.path)
if not downloads.exists():
print(c(f"Error: {downloads} does not exist", RED), file=sys.stderr)
sys.exit(1)
from alfred.domain.release.services import parse_release
from alfred.application.filesystem.detect_media_type import detect_media_type
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
from alfred.infrastructure.filesystem.find_video import find_video_file
from alfred.infrastructure.filesystem.ffprobe import probe
entries = sorted(downloads.iterdir(), key=lambda p: p.name.lower())
total = len(entries)
ok_count = 0
warn_count = 0
print()
print(c("" * 70, BOLD))
print(c(f" Downloads — {downloads}", BOLD, CYAN))
print(c(f" {total} entries", DIM))
print(c("" * 70, BOLD))
for entry in entries:
name = entry.name
try:
p = parse_release(name)
p.media_type = detect_media_type(p, entry)
if p.media_type not in ("unknown", "other"):
video_file = find_video_file(entry)
if video_file:
media_info = probe(video_file)
if media_info:
enrich_from_probe(p, media_info)
warnings = _assess(p)
except Exception as e:
warnings = [f"parse error: {e}"]
p = None
has_warnings = bool(warnings)
if args.failures_only and not has_warnings:
continue
if args.successes_only and has_warnings:
continue
print()
path_label = ""
if p:
path_label = {
"direct": c("direct", GREEN, DIM),
"sanitized": c("sanitized", YELLOW),
"ai": c("ai", RED),
}.get(p.parse_path, p.parse_path)
if has_warnings:
warn_count += 1
print(f" {c('', YELLOW, BOLD)} {c(name, YELLOW)} {path_label}")
else:
ok_count += 1
print(f" {c('', GREEN, BOLD)} {c(name, BOLD)} {path_label}")
if p:
kind = {
"movie": "movie",
"tv_show": "season pack" if p.is_season_pack else "episode",
"tv_complete": c("tv complete", CYAN),
"documentary": c("documentary", CYAN),
"concert": c("concert", CYAN),
"other": c("other", RED),
"unknown": c("unknown", YELLOW),
}.get(p.media_type, p.media_type)
kv("type", kind)
kv("title", p.title)
if p.season is not None:
ep = f"E{p.episode:02d}" if p.episode is not None else ""
kv("season/ep", f"S{p.season:02d} / {ep}")
if p.year:
kv("year", str(p.year))
if p.languages:
kv("langs", " ".join(p.languages))
kv("quality", p.quality or c("", DIM))
kv("source", p.source or c("", DIM))
kv("codec", p.codec or c("", DIM))
if p.audio_codec:
ch = f" {p.audio_channels}" if p.audio_channels else ""
kv("audio", f"{p.audio_codec}{ch}")
if p.bit_depth or p.hdr_format:
hdr_parts = [x for x in [p.bit_depth, p.hdr_format] if x]
kv("hdr/depth", " ".join(hdr_parts))
if p.edition:
kv("edition", p.edition, color=YELLOW)
kv("group", p.group,
color=YELLOW if p.group == "UNKNOWN" else GREEN)
if p.site_tag:
kv("site tag", p.site_tag, color=YELLOW)
if warnings:
for w in warnings:
print(f" {c('' + w, YELLOW)}")
# Summary
print()
hr()
skipped = total - ok_count - warn_count
print(f" {c('Total:', BOLD)} {total} "
f"{c(str(ok_count) + ' ok', GREEN, BOLD)} "
f"{c(str(warn_count) + ' warnings', YELLOW, BOLD)}"
+ (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else ""))
hr()
print()
if __name__ == "__main__":
main()
+99 -32
View File
@@ -79,24 +79,67 @@ def kv(key: str, val: str) -> None:
# Dry-run tool stubs
# ---------------------------------------------------------------------------
def _dry_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
return {
"status": "ok",
"folder_type": folder_type,
"path": path,
"entries": ["[dry-run — no real listing]"],
"count": 1,
}
def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
"""Call the real list_folder (read-only, safe in dry-run)."""
# TODO: remove hardcoded fallback once download path is configured in LTM
_HARDCODED_DOWNLOAD_ROOT = "/mnt/testipool/downloads"
try:
from alfred.infrastructure.persistence import get_memory, init_memory
try:
get_memory()
except Exception:
init_memory()
from alfred.agent.tools.filesystem import list_folder
result = list_folder(folder_type=folder_type, path=path)
if result.get("status") == "error" and folder_type == "download":
raise RuntimeError(result.get("message", "not configured"))
return result
except Exception as e:
if folder_type == "download":
warn(f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}")
import os
resolved = os.path.join(_HARDCODED_DOWNLOAD_ROOT, path) if path != "." else _HARDCODED_DOWNLOAD_ROOT
try:
entries = sorted(os.listdir(resolved))
except OSError as oe:
return {"status": "error", "error": "os_error", "message": str(oe)}
return {
"status": "ok",
"folder_type": folder_type,
"path": resolved,
"entries": entries,
"count": len(entries),
}
warn(f"list_folder: filesystem unavailable ({e}), falling back to stub")
return {
"status": "ok",
"folder_type": folder_type,
"path": path,
"entries": ["[stub — filesystem unavailable]"],
"count": 1,
}
def _dry_find_media_imdb_id(**kwargs) -> dict[str, Any]:
return {
"status": "ok",
"imdb_id": kwargs.get("imdb_id") or "tt0000000",
"title": "Dry Run Show",
"type": "tv_show",
"year": 2024,
}
def _real_find_media_imdb_id(media_title: str, **kwargs) -> dict[str, Any]:
"""Call the real TMDB API even in dry-run (read-only, no filesystem side effects)."""
try:
from alfred.infrastructure.persistence import get_memory, init_memory
try:
get_memory()
except Exception:
init_memory()
from alfred.agent.tools.api import find_media_imdb_id
return find_media_imdb_id(media_title=media_title)
except Exception as e:
warn(f"find_media_imdb_id: TMDB unavailable ({e}), falling back to stub")
return {
"status": "ok",
"imdb_id": "tt0000000",
"title": media_title,
"media_type": "tv_show",
"year": 2024,
}
def _dry_resolve_destination(
@@ -107,7 +150,7 @@ def _dry_resolve_destination(
tmdb_episode_title: str | None = None,
confirmed_folder: str | None = None,
) -> dict[str, Any]:
from alfred.domain.media.release_parser import parse_release
from alfred.domain.release import parse_release
parsed = parse_release(release_name)
ext = Path(source_file).suffix
if parsed.is_movie:
@@ -170,8 +213,8 @@ def _dry_create_seed_links(library_file: str, original_download_folder: str) ->
DRY_RUN_TOOLS: dict[str, Any] = {
"list_folder": _dry_list_folder,
"find_media_imdb_id": _dry_find_media_imdb_id,
"list_folder": _real_list_folder,
"find_media_imdb_id": _real_find_media_imdb_id,
"resolve_destination": _dry_resolve_destination,
"move_media": _dry_move_media,
"manage_subtitles": _dry_manage_subtitles,
@@ -316,10 +359,22 @@ class WorkflowRunner:
self.step_results.append({"id": step_id, "result": {"status": "error", "error": str(e)}})
return
self._print_result(result)
self._print_result(result, tool_name=tool_name)
self.context[step_id] = result
self.step_results.append({"id": step_id, "result": result})
# After list_downloads: confirm the requested media folder exists in downloads
if tool_name == "list_folder" and result.get("status") == "ok" and self.args.source:
folder_path = result.get("path", "")
entries = result.get("entries", [])
if self.args.source in entries:
media_folder = str(Path(folder_path) / self.args.source)
self.context["media_folder"] = media_folder
print()
print(f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}")
else:
warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}")
def _build_kwargs(self, tool_name: str, step: dict) -> dict[str, Any]:
"""Build tool kwargs from step params + CLI args + previous context."""
# Start from step-level params (static defaults from YAML)
@@ -335,12 +390,13 @@ class WorkflowRunner:
kwargs["imdb_id"] = a.imdb_id
elif tool_name == "resolve_destination":
media_folder = self.context.get("media_folder")
if a.release:
kwargs["release_name"] = a.release
elif a.source:
kwargs.setdefault("release_name", Path(a.source).parent.name)
if a.source:
kwargs["source_file"] = a.source
kwargs.setdefault("release_name", a.source)
if media_folder:
kwargs["source_file"] = media_folder
if a.tmdb_title:
kwargs["tmdb_title"] = a.tmdb_title
if a.tmdb_year:
@@ -351,16 +407,18 @@ class WorkflowRunner:
elif tool_name == "move_media":
# If resolve_destination ran, use its library_file as destination
resolved = self.context.get("resolve_destination", {})
if a.source:
kwargs["source"] = a.source
media_folder = self.context.get("media_folder")
if media_folder:
kwargs["source"] = media_folder
dest = a.dest or resolved.get("library_file")
if dest:
kwargs["destination"] = dest
elif tool_name == "manage_subtitles":
resolved = self.context.get("resolve_destination", {})
if a.source:
kwargs["source_video"] = a.source
media_folder = self.context.get("media_folder")
if media_folder:
kwargs["source_video"] = media_folder
dest = a.dest or resolved.get("library_file")
if dest:
kwargs["destination_video"] = dest
@@ -372,12 +430,16 @@ class WorkflowRunner:
kwargs["library_file"] = library_file
if a.download_folder:
kwargs["original_download_folder"] = a.download_folder
elif a.source:
kwargs.setdefault("original_download_folder", str(Path(a.source).parent))
else:
# Use the resolved folder path from list_downloads context
list_result = self.context.get("list_downloads", {})
folder_path = list_result.get("path")
if folder_path:
kwargs.setdefault("original_download_folder", folder_path)
return kwargs
def _print_result(self, result: dict) -> None:
def _print_result(self, result: dict, tool_name: str = "") -> None:
status = result.get("status", "?")
if status == "ok":
ok(f"status={c('ok', GREEN)}")
@@ -387,6 +449,11 @@ class WorkflowRunner:
err(f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}")
return
# Highlight resolved folder path for list_folder
if tool_name == "list_folder" and result.get("path"):
print()
print(f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}")
# Pretty-print notable fields
skip = {"status", "error", "message"}
for k, v in result.items():
@@ -420,8 +487,8 @@ def parse_args() -> argparse.Namespace:
help="Simulate steps without executing tools (default)")
parser.add_argument("--live", action="store_true",
help="Actually execute tools against the real filesystem")
parser.add_argument("--source", metavar="PATH",
help="Source video file (in download folder)")
parser.add_argument("--source", metavar="FOLDER_NAME",
help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)")
parser.add_argument("--dest", metavar="PATH",
help="Destination video file (in library, overrides resolve_destination)")
parser.add_argument("--download-folder", metavar="PATH",