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:
@@ -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