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