feat: split resolve_destination, persona-driven prompts, qBittorrent relocation

Destination resolution
- Replace the single ResolveDestinationUseCase with four dedicated
  functions, one per release type:
    resolve_season_destination    (pack season, folder move)
    resolve_episode_destination   (single episode, file move)
    resolve_movie_destination     (movie, file move)
    resolve_series_destination    (multi-season pack, folder move)
- Each returns a dedicated DTO carrying only the fields relevant to
  that release type — no more polymorphic ResolvedDestination with
  half the fields unused depending on the case.
- Looser series folder matching: exact computed-name match is reused
  silently; any deviation (different group, multiple candidates) now
  prompts the user with all options including the computed name.

Agent tools
- Four new tools wrapping the use cases above; old resolve_destination
  removed from the registry.
- New move_to_destination tool: create_folder + move, chained — used
  after a resolve_* call to perform the actual relocation.
- Low-level filesystem_operations module (create_folder, move via mv)
  for instant same-FS renames (ZFS).

Prompt & persona
- New PromptBuilder (alfred/agent/prompt.py) replacing prompts.py:
  identity + personality block, situational expressions, memory
  schema, episodic/STM/config context, tool catalogue.
- Per-user expression system: knowledge/users/common.yaml +
  {username}.yaml are merged at runtime; one phrase per situation
  (greeting/success/error/...) is sampled into the system prompt.

qBittorrent integration
- Credentials now come from settings (qbittorrent_url/username/password)
  instead of hardcoded defaults.
- New client methods: find_by_name, set_location, recheck — the trio
  needed to update a torrent's save path and re-verify after a move.
- Host→container path translation settings (qbittorrent_host_path /
  qbittorrent_container_path) for docker-mounted setups.

Subtitles
- Identifier: strip parenthesized qualifiers (simplified, brazil…) at
  tokenization; new _tokenize_suffix used for the episode_subfolder
  pattern so episode-stem tokens no longer pollute language detection.
- Placer: extract _build_dest_name so it can be reused by the new
  dry_run path in ManageSubtitlesUseCase.
- Knowledge: add yue, ell, ind, msa, rus, vie, heb, tam, tel, tha,
  hin, ukr; add 'fre' to fra; add 'simplified'/'traditional' to zho.

Misc
- LTM workspace: add 'trash' folder slot.
- Default LLM provider switched to deepseek.
- testing/debug_release.py: CLI to parse a release, hit TMDB, and
  dry-run the destination resolution end-to-end.
This commit is contained in:
2026-05-14 05:01:59 +02:00
parent 1723b9fa53
commit e45465d52d
81 changed files with 2904 additions and 896 deletions
+418
View File
@@ -0,0 +1,418 @@
"""CLI de debug pour analyser une release et dry-run le déplacement."""
import json
import sys
from pathlib import Path
# Permet de lancer le script depuis n'importe où sans install du package
sys.path.insert(0, str(Path(__file__).parent.parent))
def _init_memory():
from alfred.infrastructure.persistence import init_memory
from alfred.settings import settings
init_memory(settings.data_storage_dir)
def _resolve_via_tmdb(release_name: str) -> tuple[str, int] | None:
"""Parse le release name, interroge TMDB, retourne (tmdb_title, tmdb_year)."""
from alfred.application.movies import SearchMovieUseCase
from alfred.domain.release.services import parse_release
from alfred.infrastructure.api.tmdb import tmdb_client
parsed = parse_release(release_name)
raw_title = parsed.title.replace(".", " ").strip()
print(f" titre extrait : {raw_title}")
print(" interrogation TMDB...")
use_case = SearchMovieUseCase(tmdb_client)
result = use_case.execute(raw_title).to_dict()
if result.get("status") != "ok":
print(f" TMDB error: {result.get('message')}")
return None
title = result["title"]
release_date = result.get("release_date", "")
year = int(release_date[:4]) if release_date and len(release_date) >= 4 else None
if not year:
print(f" TMDB: pas d'année trouvée pour '{title}'")
return None
print(f" TMDB: {title} ({year})")
return title, year
def _extract_release_name(release_name: str) -> tuple[str, str]:
"""
Retourne (release_name, source_path).
Si c'est un path absolu existant → extrait le basename et utilise le path comme source.
Sinon → cherche le dossier dans workspace.download configuré en LTM.
"""
p = Path(release_name)
if p.is_absolute() and p.exists():
return p.name, str(p)
from alfred.infrastructure.persistence import get_memory
memory = get_memory()
download_root = memory.ltm.workspace.download
if download_root:
candidate = Path(download_root) / release_name
if candidate.exists():
return release_name, str(candidate)
return release_name, ""
def analyze(release_name: str, source_path: str | None = None) -> None:
from alfred.domain.release.services import parse_release
release_name, resolved_path = _extract_release_name(release_name)
if source_path is None and resolved_path:
source_path = resolved_path
print(f"\n=== PARSE: {release_name} ===")
r = parse_release(release_name)
for k, v in vars(r).items():
if v is not None and v != [] and v != "":
print(f" {k}: {v}")
if source_path:
path = Path(source_path)
print(f"\n=== PROBE: {path} ===")
if not path.exists():
print(" (chemin inexistant, probe skipped)")
else:
from alfred.infrastructure.filesystem.ffprobe import probe
from alfred.infrastructure.filesystem.find_video import find_video_file
video = find_video_file(path) if path.is_dir() else path
if video:
print(f" video file: {video.name}")
info = probe(video)
if info:
print(f" codec: {info.video_codec}")
print(f" resolution: {info.resolution}")
print(
f" audio_tracks: {[(t.codec, t.language) for t in info.audio_tracks]}"
)
print(
f" subtitle_tracks: {[(t.codec, t.language) for t in info.subtitle_tracks]}"
)
else:
print(" probe failed (ffprobe dispo ?)")
else:
print(" aucun fichier vidéo trouvé")
def dry_run(release_name: str) -> None:
_init_memory()
release_name, _ = _extract_release_name(release_name)
print(f"\n=== DRY-RUN: {release_name} ===")
tmdb = _resolve_via_tmdb(release_name)
if not tmdb:
sys.exit(1)
tmdb_title, tmdb_year = tmdb
from alfred.application.filesystem.resolve_destination import (
resolve_season_destination,
)
result = resolve_season_destination(release_name, tmdb_title, tmdb_year)
d = result.to_dict()
print()
print(json.dumps(d, indent=2, ensure_ascii=False))
if d["status"] == "ok":
print("\n=== MOVE PREVIEW ===")
print(" src : <source_folder>")
print(f" dst : {d['season_folder']}")
def _translate_path(path: str) -> str:
"""Translate a host-side path to the qBittorrent container path."""
from alfred.settings import settings
host_prefix = settings.qbittorrent_host_path
container_prefix = settings.qbittorrent_container_path
if host_prefix and container_prefix and path.startswith(host_prefix):
return container_prefix + path[len(host_prefix) :]
return path
def _qbittorrent_update(torrent_name: str, new_location: str | None) -> None:
"""
Find the torrent in qBittorrent by name, update its save_path, and force recheck.
Args:
torrent_name: Exact torrent name (release folder basename)
new_location: New save path on the host (parent of the torrent folder).
None if the torrent was sent to trash — skip location change.
"""
try:
from alfred.infrastructure.api.qbittorrent.client import QBittorrentClient
client = QBittorrentClient()
client.login()
torrent = client.find_by_name(torrent_name)
if torrent is None:
print(f" ⚠ qBittorrent: torrent '{torrent_name}' not found — skipping")
return
print(f" qBittorrent: found '{torrent.name}' (hash={torrent.hash[:8]}…)")
if new_location:
container_location = _translate_path(new_location)
client.set_location(torrent.hash, container_location)
print(f" ✓ qBittorrent: location → {container_location}")
client.recheck(torrent.hash)
print(" ✓ qBittorrent: recheck triggered")
except Exception as e:
# Non-fatal — the files are already in place
print(f" ⚠ qBittorrent update failed (non-fatal): {e}")
def do_move(release_name: str, source_folder: str | None = None) -> None:
_init_memory()
release_name, resolved_path = _extract_release_name(release_name)
if source_folder is None:
source_folder = resolved_path
if not source_folder:
print(
" Erreur: source introuvable. Configure workspace.download ou passe le path complet."
)
sys.exit(1)
print(f"\n=== MOVE: {release_name} ===")
tmdb = _resolve_via_tmdb(release_name)
if not tmdb:
sys.exit(1)
tmdb_title, tmdb_year = tmdb
from alfred.application.filesystem.resolve_destination import (
resolve_season_destination,
)
result = resolve_season_destination(release_name, tmdb_title, tmdb_year)
d = result.to_dict()
if d["status"] == "needs_clarification":
print(f"\n {d['question']}")
for i, opt in enumerate(d["options"]):
print(f" {i + 1}. {opt}")
choice = input(" Choix (numéro) : ").strip()
try:
chosen = d["options"][int(choice) - 1]
except (ValueError, IndexError):
print(" Choix invalide.")
sys.exit(1)
result = resolve_season_destination(
release_name, tmdb_title, tmdb_year, confirmed_folder=chosen
)
d = result.to_dict()
if d["status"] != "ok":
print(json.dumps(d, indent=2, ensure_ascii=False))
sys.exit(1)
src_path = Path(source_folder)
season_folder = d["season_folder"]
mkv_files = sorted(src_path.glob("*.mkv")) or sorted(src_path.glob("*.mp4"))
from alfred.infrastructure.persistence import get_memory
memory = get_memory()
torrent_root = memory.ltm.workspace.torrent
trash_root = memory.ltm.workspace.trash
torrent_dst = str(Path(torrent_root) / src_path.name) if torrent_root else None
trash_dst = str(Path(trash_root) / src_path.name) if trash_root else None
rebuild = input(" Recréer le torrent ? [y/N] : ").strip().lower() == "y"
# --- PHASE 1: PLAN ---
print("\n=== PLAN ===")
print(f" destination : {season_folder}")
from alfred.application.filesystem.manage_subtitles import ManageSubtitlesUseCase
from alfred.domain.release.services import parse_release
parsed = parse_release(release_name)
# Dict: video_path → sub_result (pre-scanned, files not yet moved)
plan: list[tuple[Path, str, object]] = [] # (src_file, dst_path, sub_result)
has_errors = False
for f in mkv_files:
dst = str(Path(season_folder) / f.name)
ghost_src = str(src_path / f.name)
sub_result = ManageSubtitlesUseCase().execute(
source_video=ghost_src,
destination_video=dst,
media_type="tv_show",
release_group=parsed.group,
season=parsed.season,
dry_run=True,
)
print(f"\n {f.name}")
print(f"{dst}")
if sub_result.status == "ok":
if sub_result.placed:
for p in sub_result.placed:
print(f" sub: {p.filename}")
elif sub_result.available:
for a in sub_result.available:
print(f" sub (embedded): {a.language} {a.subtitle_type}")
else:
print(" subs: aucun")
elif sub_result.status == "needs_clarification":
print(" ✗ subs non résolus:")
for u in sub_result.unresolved:
print(f" {u.raw_tokens} ({u.reason})")
has_errors = True
elif sub_result.status == "error":
print(f" ✗ erreur subs: {sub_result.message}")
has_errors = True
plan.append((f, dst, sub_result))
if rebuild and torrent_dst:
print(f"\n source → torrents : {torrent_dst}")
print(f" hard-links : {len(mkv_files)} fichier(s)")
elif trash_dst:
print(f"\n source → trash : {trash_dst}")
else:
print("\n source : laissée en place")
if has_errors:
print("\n ✗ Plan invalide — subs non résolus, abandon.")
sys.exit(1)
# --- CONFIRMATION ---
print()
confirm = input(" Confirmer ? [y/N] : ").strip().lower()
if confirm != "y":
print(" Annulé.")
sys.exit(0)
# --- PHASE 2: EXECUTE ---
import os
from alfred.infrastructure.filesystem.filesystem_operations import (
create_folder,
move,
)
print("\n=== EXECUTE ===")
# 1. Créer le season_folder
r = create_folder(season_folder)
if r["status"] != "ok":
print(f" ✗ create_folder: {r}")
sys.exit(1)
print(f" ✓ dossier : {season_folder}")
# 2. Déplacer chaque fichier vidéo + placer les subs (re-run après move)
for f, dst, _pre_scan in plan:
r = move(str(f), dst)
if r["status"] != "ok":
print(f"{f.name}: {r['message']}")
sys.exit(1)
print(f"{f.name}")
# Re-run manage_subtitles maintenant que dst existe (pour le hard-link)
ghost_src = str(src_path / f.name)
sub_result = ManageSubtitlesUseCase().execute(
source_video=ghost_src,
destination_video=dst,
media_type="tv_show",
release_group=parsed.group,
season=parsed.season,
)
if sub_result.status == "ok" and sub_result.placed:
for p in sub_result.placed:
print(f" ✓ sub: {p.filename}")
# 3. Dossier source → torrents ou trash
if rebuild and torrent_dst:
r = move(source_folder, torrent_dst)
if r["status"] != "ok":
print(f" ✗ source → torrents: {r['message']}")
sys.exit(1)
print(" ✓ source → torrents")
# 4. Hard-link depuis season_folder → torrent_dst
torrent_dst_path = Path(torrent_dst)
for f, dst, _ in plan:
lib_file = Path(season_folder) / f.name
link_dst = torrent_dst_path / f.name
try:
os.link(lib_file, link_dst)
print(f" ✓ hard-link: {f.name}")
except OSError as e:
print(f" ✗ hard-link {f.name}: {e}")
sys.exit(1)
elif trash_dst:
r = move(source_folder, trash_dst)
if r["status"] != "ok":
print(f" ✗ source → trash: {r['message']}")
sys.exit(1)
print(" ✓ source → trash")
# 5. qBittorrent: update location + recheck
qb_location = torrent_root if (rebuild and torrent_dst) else None
_qbittorrent_update(src_path.name, qb_location)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Debug release parsing + dry-run/move")
sub = parser.add_subparsers(dest="cmd")
p_analyze = sub.add_parser(
"analyze", help="Parser une release (+ probe si path fourni)"
)
p_analyze.add_argument("release_name")
p_analyze.add_argument("--path", help="Chemin vers le dossier/fichier source")
p_dry = sub.add_parser(
"dryrun", help="Résout via TMDB et affiche les chemins sans rien bouger"
)
p_dry.add_argument("release_name")
p_move = sub.add_parser(
"move", help="Résout via TMDB et déplace le dossier (confirmation requise)"
)
p_move.add_argument("release_name")
p_move.add_argument(
"source_folder",
nargs="?",
default=None,
help="Chemin absolu du dossier source (optionnel si workspace.download est configuré)",
)
args = parser.parse_args()
if args.cmd == "analyze":
analyze(args.release_name, args.path)
elif args.cmd == "dryrun":
dry_run(args.release_name)
elif args.cmd == "move":
do_move(args.release_name, args.source_folder)
else:
parser.print_help()
sys.exit(1)
+89 -48
View File
@@ -21,14 +21,14 @@ if str(_PROJECT_ROOT) not in sys.path:
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
USE_COLOR = True
@@ -51,6 +51,7 @@ def hr() -> None:
# TMDB lookup
# ---------------------------------------------------------------------------
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
"""
Call TMDBClient.search_media() and return (canonical_title, year).
@@ -58,6 +59,7 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
"""
try:
from alfred.infrastructure.api.tmdb import TMDBClient
client = TMDBClient()
result = client.search_media(title)
year: int | None = None
@@ -66,7 +68,12 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
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))
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))
@@ -77,8 +84,14 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
# Display
# ---------------------------------------------------------------------------
def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
tmdb_episode_title: str | None, ext: str) -> None:
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)
@@ -87,7 +100,7 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
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
tmdb_year = tmdb_year or fetched_year
print()
print(c("" * 64, BOLD))
@@ -96,34 +109,37 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
# 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))
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_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
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))
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("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))
@@ -133,9 +149,12 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
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)
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()
@@ -145,10 +164,16 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
# 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))
print(
c(
" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n",
DIM,
)
)
while True:
try:
@@ -161,8 +186,8 @@ def _interactive() -> None:
break
# Parse inline overrides: "Oz.S03E01... ::title=Oz ::year=1997 ::tmdb"
parts = raw.split("::")
release = parts[0].strip()
parts = raw.split("::")
release = parts[0].strip()
overrides: dict[str, str] = {}
for part in parts[1:]:
part = part.strip()
@@ -170,12 +195,12 @@ def _interactive() -> None:
k, _, v = part.partition("=")
overrides[k.strip()] = v.strip()
else:
overrides[part] = "1" # flag-style: ::tmdb
overrides[part] = "1" # flag-style: ::tmdb
tmdb_title = overrides.get("title")
tmdb_year = int(overrides["year"]) if "year" in overrides else None
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")
ext = overrides.get("ext", ".mkv")
try:
_show(release, tmdb_title, tmdb_year, tmdb_episode_title, ext)
except Exception as e:
@@ -186,6 +211,7 @@ def _interactive() -> None:
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
@@ -194,16 +220,29 @@ def main() -> None:
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(
"-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()
@@ -219,7 +258,9 @@ def main() -> None:
sys.exit(1)
try:
_show(args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext)
_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)
+17 -13
View File
@@ -19,14 +19,14 @@ if str(_PROJECT_ROOT) not in sys.path:
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
USE_COLOR = True
@@ -54,6 +54,7 @@ def hr() -> None:
# Formatting helpers
# ---------------------------------------------------------------------------
def fmt_duration(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
@@ -80,6 +81,7 @@ def flag(val: bool) -> str:
# Main
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
@@ -111,14 +113,14 @@ def main() -> None:
# --- Video ---
section("Video")
kv("codec", info.video_codec or c("", DIM))
kv("resolution", info.resolution or c("", DIM))
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))
kv("duration", fmt_duration(info.duration_seconds))
if info.bitrate_kbps is not None:
kv("bitrate", f"{info.bitrate_kbps} kbps")
kv("bitrate", f"{info.bitrate_kbps} kbps")
# --- Audio ---
section(f"Audio {c(str(len(info.audio_tracks)) + ' track(s)', DIM)}")
@@ -128,7 +130,7 @@ def main() -> None:
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("codec", track.codec or c("", DIM), indent=8)
kv("channels", fmt_channels(track.channels, track.channel_layout), indent=8)
# --- Subtitles ---
@@ -151,7 +153,9 @@ def main() -> None:
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)}")
print(
f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}"
)
hr()
print()
+48 -38
View File
@@ -21,13 +21,13 @@ if str(_PROJECT_ROOT) not in sys.path:
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
RED = "\033[31m"
CYAN = "\033[36m"
USE_COLOR = True
@@ -50,6 +50,7 @@ def hr() -> None:
# 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"):
@@ -70,16 +71,24 @@ def _assess(p) -> list[str]:
# 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 = 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()
@@ -91,11 +100,11 @@ def main() -> None:
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.domain.release.services import parse_release
from alfred.infrastructure.filesystem.ffprobe import probe
from alfred.infrastructure.filesystem.find_video import find_video_file
entries = sorted(downloads.iterdir(), key=lambda p: p.name.lower())
total = len(entries)
@@ -136,9 +145,9 @@ def main() -> None:
path_label = ""
if p:
path_label = {
"direct": c("direct", GREEN, DIM),
"direct": c("direct", GREEN, DIM),
"sanitized": c("sanitized", YELLOW),
"ai": c("ai", RED),
"ai": c("ai", RED),
}.get(p.parse_path, p.parse_path)
if has_warnings:
@@ -150,36 +159,35 @@ def main() -> None:
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),
"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)
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))
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))
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}")
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)
kv("group", p.group, color=YELLOW if p.group == "UNKNOWN" else GREEN)
if p.site_tag:
kv("site tag", p.site_tag, color=YELLOW)
@@ -191,10 +199,12 @@ def main() -> None:
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 ""))
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()
+90 -43
View File
@@ -34,14 +34,14 @@ if str(_PROJECT_ROOT) not in sys.path:
USE_COLOR = True
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
@@ -88,8 +88,7 @@ VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".mov", ".ts", ".m2ts"}
def find_videos(folder: Path) -> list[Path]:
return sorted(
p for p in folder.iterdir()
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS
)
@@ -107,9 +106,13 @@ def confidence_bar(conf: float, width: int = 20) -> str:
def track_summary(track, verbose: bool = False) -> None:
lang = track.language.code if track.language else c("?", RED)
fmt = track.format.id if track.format else c("?", RED)
typ = track.subtitle_type.value
src = "embedded" if track.is_embedded else (track.file_path.name if track.file_path else "?")
fmt = track.format.id if track.format else c("?", RED)
typ = track.subtitle_type.value
src = (
"embedded"
if track.is_embedded
else (track.file_path.name if track.file_path else "?")
)
# Couleur du type
type_colors = {
@@ -125,11 +128,19 @@ def track_summary(track, verbose: bool = False) -> None:
print(f" {c(src, BOLD)}")
print(f" lang={c(lang, CYAN)} type={typ_str} format={fmt}")
conf_str = c("n/a (embedded)", DIM) if track.is_embedded else confidence_bar(track.confidence)
conf_str = (
c("n/a (embedded)", DIM)
if track.is_embedded
else confidence_bar(track.confidence)
)
print(f" confidence={conf_str}{clarif}")
if track.entry_count is not None:
print(f" entries={track.entry_count} size={track.file_size_kb:.1f} KB" if track.file_size_kb else f" entries={track.entry_count}")
print(
f" entries={track.entry_count} size={track.file_size_kb:.1f} KB"
if track.file_size_kb
else f" entries={track.entry_count}"
)
if verbose and track.raw_tokens:
print(f" tokens={track.raw_tokens}")
@@ -146,7 +157,8 @@ def track_summary(track, verbose: bool = False) -> None:
# Étapes du pipeline
# ---------------------------------------------------------------------------
def step_load_kb() -> "SubtitleKnowledgeBase":
def step_load_kb() -> SubtitleKnowledgeBase:
from alfred.domain.subtitles.knowledge.base import SubtitleKnowledgeBase
from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader
@@ -168,12 +180,12 @@ def step_load_kb() -> "SubtitleKnowledgeBase":
def step_detect_pattern(
kb: "SubtitleKnowledgeBase",
kb: SubtitleKnowledgeBase,
season_folder: Path,
sample_video: Path,
release_group: str | None,
forced_pattern: str | None,
) -> "SubtitlePattern":
) -> SubtitlePattern:
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
section("ÉTAPE 2 — Détection du pattern de release")
@@ -192,7 +204,9 @@ def step_detect_pattern(
known = kb.patterns_for_group(release_group)
if known:
kv("Release group", release_group)
ok(f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}")
ok(
f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}"
)
pattern = known[0]
kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD))
return pattern
@@ -237,12 +251,12 @@ def step_detect_pattern(
def step_identify_tracks(
kb: "SubtitleKnowledgeBase",
kb: SubtitleKnowledgeBase,
sample_video: Path,
pattern: "SubtitlePattern",
pattern: SubtitlePattern,
release_group: str | None,
verbose: bool,
) -> "MediaSubtitleMetadata":
) -> MediaSubtitleMetadata:
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
section("ÉTAPE 3 — Identification des pistes")
@@ -286,9 +300,9 @@ def step_identify_tracks(
def step_apply_rules(
metadata: "MediaSubtitleMetadata",
metadata: MediaSubtitleMetadata,
release_group: str | None,
) -> tuple["SubtitleMatchingRules | None", list, list]:
) -> tuple[SubtitleMatchingRules | None, list, list]:
from alfred.domain.subtitles.aggregates import DEFAULT_RULES
from alfred.domain.subtitles.services.matcher import SubtitleMatcher
from alfred.domain.subtitles.services.utils import available_subtitles
@@ -308,7 +322,9 @@ def step_apply_rules(
kv("Formats préférés", str(rules.preferred_formats))
kv("Types autorisés", str(rules.allowed_types))
kv("Confiance min", str(rules.min_confidence))
info(c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4)
info(
c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4
)
matcher = SubtitleMatcher()
matched, unresolved = matcher.match(metadata.external_tracks, rules)
@@ -330,7 +346,9 @@ def step_show_results(
section("RÉSULTAT FINAL")
if matched:
label = "piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
label = (
"piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
)
ok(f"{len(matched)} {label}:")
for track in matched:
lang = track.language.code if track.language else "?"
@@ -352,7 +370,11 @@ def step_show_results(
warn(f"{len(unresolved)} piste(s) écartées ou à clarifier:")
for track in unresolved:
src = track.file_path.name if track.file_path else "?"
reason = "langue inconnue" if track.language is None else "confiance insuffisante"
reason = (
"langue inconnue"
if track.language is None
else "confiance insuffisante"
)
line = f" {c(src, DIM)} ({reason})"
if verbose and track.raw_tokens:
line += c(f" tokens: {track.raw_tokens}", YELLOW)
@@ -365,9 +387,10 @@ def step_show_results(
# Scan multi-épisodes (résumé)
# ---------------------------------------------------------------------------
def scan_season(
kb: "SubtitleKnowledgeBase",
pattern: "SubtitlePattern",
kb: SubtitleKnowledgeBase,
pattern: SubtitlePattern,
season_folder: Path,
release_group: str | None,
verbose: bool,
@@ -408,15 +431,20 @@ def scan_season(
pass
status_icon = c("", GREEN, BOLD) if placed_names else c("", RED, BOLD)
warn_icon = c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
warn_icon = (
c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
)
print(f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '', GREEN if placed_names else DIM)}{warn_icon}")
print(
f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '', GREEN if placed_names else DIM)}{warn_icon}"
)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Scanner de sous-titres Alfred — pipeline de diagnostic",
@@ -424,18 +452,35 @@ def parse_args() -> argparse.Namespace:
epilog=textwrap.dedent(__doc__ or ""),
)
parser.add_argument("season_folder", help="Dossier de la saison (ou du film)")
parser.add_argument("--release-group", "-g", metavar="GROUP",
help="Groupe de release (ex: RARBG, KONSTRAST)")
parser.add_argument("--pattern", "-p", metavar="PATTERN",
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)")
parser.add_argument("--video", "-v", metavar="FILE",
help="Fichier vidéo de référence (défaut: premier trouvé)")
parser.add_argument("--verbose", action="store_true",
help="Affiche les tokens bruts par piste")
parser.add_argument("--no-color", action="store_true",
help="Désactive la colorisation ANSI")
parser.add_argument("--season-scan", action="store_true",
help="Après le diagnostic, scanner tous les épisodes de la saison")
parser.add_argument(
"--release-group",
"-g",
metavar="GROUP",
help="Groupe de release (ex: RARBG, KONSTRAST)",
)
parser.add_argument(
"--pattern",
"-p",
metavar="PATTERN",
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)",
)
parser.add_argument(
"--video",
"-v",
metavar="FILE",
help="Fichier vidéo de référence (défaut: premier trouvé)",
)
parser.add_argument(
"--verbose", action="store_true", help="Affiche les tokens bruts par piste"
)
parser.add_argument(
"--no-color", action="store_true", help="Désactive la colorisation ANSI"
)
parser.add_argument(
"--season-scan",
action="store_true",
help="Après le diagnostic, scanner tous les épisodes de la saison",
)
return parser.parse_args()
@@ -474,7 +519,9 @@ def main() -> None:
if videos:
break
if not videos:
print("Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr)
print(
"Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr
)
sys.exit(1)
sample_video = videos[0]
+156 -59
View File
@@ -43,14 +43,14 @@ if str(_PROJECT_ROOT) not in sys.path:
USE_COLOR = True
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
@@ -67,10 +67,22 @@ def section(title: str) -> None:
print(c("" * 70, DIM))
def ok(msg: str) -> None: print(c("", GREEN, BOLD) + msg)
def warn(msg: str) -> None: print(c(" ", YELLOW, BOLD) + msg)
def err(msg: str) -> None: print(c("", RED, BOLD) + msg)
def info(msg: str) -> None: print(f" {msg}")
def ok(msg: str) -> None:
print(c(" ", GREEN, BOLD) + msg)
def warn(msg: str) -> None:
print(c("", YELLOW, BOLD) + msg)
def err(msg: str) -> None:
print(c("", RED, BOLD) + msg)
def info(msg: str) -> None:
print(f" {msg}")
def kv(key: str, val: str) -> None:
print(f" {c(key + ':', BOLD)} {val}")
@@ -79,6 +91,7 @@ def kv(key: str, val: str) -> None:
# Dry-run tool stubs
# ---------------------------------------------------------------------------
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
@@ -86,20 +99,29 @@ def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
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}")
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
resolved = (
os.path.join(_HARDCODED_DOWNLOAD_ROOT, path)
if path != "."
else _HARDCODED_DOWNLOAD_ROOT
)
try:
entries = sorted(os.listdir(resolved))
except OSError as oe:
@@ -125,11 +147,13 @@ 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")
@@ -151,6 +175,7 @@ def _dry_resolve_destination(
confirmed_folder: str | None = None,
) -> dict[str, Any]:
from alfred.domain.release import parse_release
parsed = parse_release(release_name)
ext = Path(source_file).suffix
if parsed.is_movie:
@@ -168,7 +193,11 @@ def _dry_resolve_destination(
}
season_folder = parsed.season_folder_name()
show_folder = confirmed_folder or parsed.show_folder_name(tmdb_title, tmdb_year)
fname = parsed.episode_filename(tmdb_episode_title, ext) if not parsed.is_season_pack else season_folder + ext
fname = (
parsed.episode_filename(tmdb_episode_title, ext)
if not parsed.is_season_pack
else season_folder + ext
)
return {
"status": "ok",
"library_file": f"/tv/{show_folder}/{season_folder}/{fname}",
@@ -201,7 +230,9 @@ def _dry_manage_subtitles(source_video: str, destination_video: str) -> dict[str
}
def _dry_create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]:
def _dry_create_seed_links(
library_file: str, original_download_folder: str
) -> dict[str, Any]:
return {
"status": "ok",
"torrent_subfolder": f"/torrents/{Path(original_download_folder).name}",
@@ -213,12 +244,12 @@ def _dry_create_seed_links(library_file: str, original_download_folder: str) ->
DRY_RUN_TOOLS: dict[str, Any] = {
"list_folder": _real_list_folder,
"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,
"create_seed_links": _dry_create_seed_links,
"move_media": _dry_move_media,
"manage_subtitles": _dry_manage_subtitles,
"create_seed_links": _dry_create_seed_links,
}
@@ -226,6 +257,7 @@ DRY_RUN_TOOLS: dict[str, Any] = {
# Live tools
# ---------------------------------------------------------------------------
def _load_live_tools() -> dict[str, Any]:
from alfred.agent.tools.filesystem import (
create_seed_links,
@@ -233,19 +265,25 @@ def _load_live_tools() -> dict[str, Any]:
manage_subtitles,
move_media,
)
# find_media_imdb_id lives in the api tools
try:
from alfred.agent.tools.api import find_media_imdb_id
except ImportError:
def find_media_imdb_id(**kwargs): # type: ignore[misc]
return {"status": "error", "error": "not_available", "message": "api tools not loaded"}
return {
"status": "error",
"error": "not_available",
"message": "api tools not loaded",
}
return {
"list_folder": list_folder,
"list_folder": list_folder,
"find_media_imdb_id": find_media_imdb_id,
"move_media": move_media,
"manage_subtitles": manage_subtitles,
"create_seed_links": create_seed_links,
"move_media": move_media,
"manage_subtitles": manage_subtitles,
"create_seed_links": create_seed_links,
}
@@ -253,13 +291,20 @@ def _load_live_tools() -> dict[str, Any]:
# Workflow runner
# ---------------------------------------------------------------------------
class WorkflowRunner:
def __init__(self, workflow: dict, tools: dict[str, Any], live: bool, args: argparse.Namespace):
def __init__(
self,
workflow: dict,
tools: dict[str, Any],
live: bool,
args: argparse.Namespace,
):
self.workflow = workflow
self.tools = tools
self.live = live
self.args = args
self.context: dict[str, Any] = {} # step results accumulate here
self.context: dict[str, Any] = {} # step results accumulate here
self.step_results: list[dict] = []
def run(self) -> None:
@@ -281,11 +326,15 @@ class WorkflowRunner:
section("SIMULATION TERMINÉE")
ok(f"{len(self.step_results)} step(s) exécuté(s)")
errors = [r for r in self.step_results if r.get("result", {}).get("status") == "error"]
errors = [
r for r in self.step_results if r.get("result", {}).get("status") == "error"
]
if errors:
warn(f"{len(errors)} step(s) en erreur")
for r in errors:
err(f" {r['id']}: {r['result'].get('error')}{r['result'].get('message')}")
err(
f" {r['id']}: {r['result'].get('error')}{r['result'].get('message')}"
)
print()
print(c("" * 70, BOLD))
print()
@@ -306,7 +355,7 @@ class WorkflowRunner:
answers_str = {str(k): v for k, v in answers.items()}
next_step = answers_str.get(answer, {}).get("next_step", "update_library")
ok(f"Réponse simulée: {c(answer, CYAN)} → next: {c(next_step, CYAN)}")
self.context["seeding"] = (answer == "yes")
self.context["seeding"] = answer == "yes"
self.context["ask_seeding_answer"] = answer
self.context["next_after_ask"] = next_step
@@ -332,7 +381,9 @@ class WorkflowRunner:
return
# Skip create_seed_links if user said no to seeding
if tool_name == "create_seed_links" and self.context.get("skip_create_seed_links"):
if tool_name == "create_seed_links" and self.context.get(
"skip_create_seed_links"
):
section(f"STEP [{step_id}] — {tool_name}")
warn("Skipped (user chose not to seed)")
return
@@ -349,14 +400,18 @@ class WorkflowRunner:
if tool_name not in self.tools:
err(f"Tool '{tool_name}' not found in tool registry")
self.step_results.append({"id": step_id, "result": {"status": "error", "error": "unknown_tool"}})
self.step_results.append(
{"id": step_id, "result": {"status": "error", "error": "unknown_tool"}}
)
return
try:
result = self.tools[tool_name](**kwargs)
except Exception as e:
err(f"Tool raised an exception: {e}")
self.step_results.append({"id": step_id, "result": {"status": "error", "error": str(e)}})
self.step_results.append(
{"id": step_id, "result": {"status": "error", "error": str(e)}}
)
return
self._print_result(result, tool_name=tool_name)
@@ -364,14 +419,20 @@ class WorkflowRunner:
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:
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)}")
print(
f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}"
)
else:
warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}")
@@ -446,13 +507,17 @@ class WorkflowRunner:
elif status == "needs_clarification":
warn(f"status={c('needs_clarification', YELLOW)}")
else:
err(f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}")
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)}")
print(
f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}"
)
# Pretty-print notable fields
skip = {"status", "error", "message"}
@@ -476,6 +541,7 @@ class WorkflowRunner:
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Alfred workflow simulator",
@@ -483,28 +549,58 @@ def parse_args() -> argparse.Namespace:
epilog=textwrap.dedent(__doc__ or ""),
)
parser.add_argument("workflow", help="Workflow name (e.g. organize_media)")
parser.add_argument("--dry-run", dest="dry_run", action="store_true", default=True,
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="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",
help="Original download folder (for create_seed_links)")
parser.add_argument("--imdb-id", metavar="ID",
help="IMDb ID for identify_media (tt1234567)")
parser.add_argument("--release", metavar="NAME",
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)")
parser.add_argument("--tmdb-title", metavar="TITLE",
help="Canonical title from TMDB (e.g. 'Oz')")
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
help="Start/release year from TMDB (e.g. 1997)")
parser.add_argument("--episode-title", metavar="TITLE",
help="Episode title from TMDB for single-episode releases")
parser.add_argument("--seed", action="store_true",
help='Answer "yes" to the seeding question')
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
default=True,
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="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",
help="Original download folder (for create_seed_links)",
)
parser.add_argument(
"--imdb-id", metavar="ID", help="IMDb ID for identify_media (tt1234567)"
)
parser.add_argument(
"--release",
metavar="NAME",
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)",
)
parser.add_argument(
"--tmdb-title", metavar="TITLE", help="Canonical title from TMDB (e.g. 'Oz')"
)
parser.add_argument(
"--tmdb-year",
metavar="YEAR",
type=int,
help="Start/release year from TMDB (e.g. 1997)",
)
parser.add_argument(
"--episode-title",
metavar="TITLE",
help="Episode title from TMDB for single-episode releases",
)
parser.add_argument(
"--seed", action="store_true", help='Answer "yes" to the seeding question'
)
parser.add_argument("--no-color", action="store_true")
return parser.parse_args()
@@ -521,6 +617,7 @@ def main() -> None:
# Load workflow
from alfred.agent.workflows.loader import WorkflowLoader
loader = WorkflowLoader()
workflow = loader.get(args.workflow)
if not workflow: