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