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