Files
alfred/testing/parse_release.py
T
francwa e45465d52d 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.
2026-05-14 05:01:59 +02:00

271 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""
parse_release.py — Test ParsedRelease interactively or via CLI args.
Usage:
uv run testing/parse_release.py "Oz.S03.1080p.WEBRip.x265-KONTRAST"
uv run testing/parse_release.py "Oz.S03.1080p.WEBRip.x265-KONTRAST" --tmdb
uv run testing/parse_release.py "Inception.2010.1080p.BluRay.x265-GROUP" --tmdb-title "Inception" --tmdb-year 2010
uv run testing/parse_release.py --interactive
"""
import argparse
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
USE_COLOR = True
def c(text: str, *codes: str) -> str:
if not USE_COLOR:
return str(text)
return "".join(codes) + str(text) + RESET
def kv(key: str, val: str, color: str = CYAN) -> None:
print(f" {c(key + ':', BOLD)} {c(val, color)}")
def hr() -> None:
print(c("" * 64, DIM))
# ---------------------------------------------------------------------------
# TMDB lookup
# ---------------------------------------------------------------------------
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
"""
Call TMDBClient.search_media() and return (canonical_title, year).
Returns (None, None) on failure.
"""
try:
from alfred.infrastructure.api.tmdb import TMDBClient
client = TMDBClient()
result = client.search_media(title)
year: int | None = None
if result.release_date:
try:
year = int(result.release_date[:4])
except (ValueError, IndexError):
pass
print(
c(
f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}",
DIM,
)
)
return result.title, year
except Exception as e:
print(c(f" TMDB lookup failed: {e}", YELLOW))
return None, None
# ---------------------------------------------------------------------------
# Display
# ---------------------------------------------------------------------------
def _show(
release_name: str,
tmdb_title: str | None,
tmdb_year: int | None,
tmdb_episode_title: str | None,
ext: str,
) -> None:
from alfred.domain.release import parse_release
p = parse_release(release_name)
# Auto-fetch TMDB if requested and not already provided
if not (tmdb_title and tmdb_year):
fetched_title, fetched_year = _fetch_tmdb(p.title.replace(".", " "))
tmdb_title = tmdb_title or fetched_title
tmdb_year = tmdb_year or fetched_year
print()
print(c("" * 64, BOLD))
print(c(f" ParsedRelease — {p.raw}", BOLD, CYAN))
print(c("" * 64, BOLD))
# Core fields
hr()
kv("raw", p.raw)
kv("normalised", p.normalised)
kv("title", p.title)
kv("year", str(p.year) if p.year else c("None", DIM))
kv("season", str(p.season) if p.season is not None else c("None", DIM))
kv("episode", str(p.episode) if p.episode is not None else c("None", DIM))
kv(
"episode_end",
str(p.episode_end) if p.episode_end is not None else c("None", DIM),
)
kv("quality", p.quality or c("None", DIM))
kv("source", p.source or c("None", DIM))
kv("codec", p.codec or c("None", DIM))
kv("group", p.group, YELLOW if p.group == "UNKNOWN" else GREEN)
kv("tech_string", p.tech_string or c("(empty)", DIM))
# Derived booleans
hr()
kv("is_movie", c(str(p.is_movie), GREEN if p.is_movie else DIM))
kv("is_season_pack", c(str(p.is_season_pack), GREEN if p.is_season_pack else DIM))
# Generated names
hr()
title_for_names = tmdb_title or p.title.replace(".", " ")
year_for_names = tmdb_year or p.year or 0
if p.is_movie:
kv("movie_folder_name", p.movie_folder_name(title_for_names, year_for_names))
kv("movie_filename", p.movie_filename(title_for_names, year_for_names, ext))
else:
kv("show_folder_name", p.show_folder_name(title_for_names, year_for_names))
kv("season_folder_name", p.season_folder_name())
if not p.is_season_pack:
kv("episode_filename", p.episode_filename(tmdb_episode_title, ext))
else:
kv("episode_filename", c("(season pack — no episode filename)", DIM))
if tmdb_title or tmdb_year or tmdb_episode_title:
hr()
print(c(" TMDB data used:", DIM))
if tmdb_title:
kv(" tmdb_title", tmdb_title)
if tmdb_year:
kv(" tmdb_year", str(tmdb_year))
if tmdb_episode_title:
kv(" tmdb_episode_title", tmdb_episode_title)
print(c("" * 64, BOLD))
print()
# ---------------------------------------------------------------------------
# Interactive mode
# ---------------------------------------------------------------------------
def _interactive() -> None:
print(c("\n Alfred — Release Parser REPL", BOLD, CYAN))
print(c(" Type a release name, or 'q' to quit.", DIM))
print(
c(
" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n",
DIM,
)
)
while True:
try:
raw = input(c(" release> ", BOLD)).strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not raw or raw.lower() in ("q", "quit", "exit"):
break
# Parse inline overrides: "Oz.S03E01... ::title=Oz ::year=1997 ::tmdb"
parts = raw.split("::")
release = parts[0].strip()
overrides: dict[str, str] = {}
for part in parts[1:]:
part = part.strip()
if "=" in part:
k, _, v = part.partition("=")
overrides[k.strip()] = v.strip()
else:
overrides[part] = "1" # flag-style: ::tmdb
tmdb_title = overrides.get("title")
tmdb_year = int(overrides["year"]) if "year" in overrides else None
tmdb_episode_title = overrides.get("ep")
ext = overrides.get("ext", ".mkv")
try:
_show(release, tmdb_title, tmdb_year, tmdb_episode_title, ext)
except Exception as e:
print(c(f" Error: {e}", RED))
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
parser = argparse.ArgumentParser(
description="Test ParsedRelease from domain/release/release_parser.py",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("release", nargs="?", help="Release name to parse")
parser.add_argument(
"-i", "--interactive", action="store_true", help="Interactive REPL mode"
)
parser.add_argument(
"--tmdb-title", metavar="TITLE", help="Override TMDB title for name generation"
)
parser.add_argument(
"--tmdb-year",
metavar="YEAR",
type=int,
help="Override TMDB year for name generation",
)
parser.add_argument(
"--episode-title",
metavar="TITLE",
help="TMDB episode title for episode_filename()",
)
parser.add_argument(
"--ext",
default=".mkv",
metavar="EXT",
help="File extension for filename generation (default: .mkv)",
)
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args()
if args.no_color or not sys.stdout.isatty():
USE_COLOR = False
if args.interactive:
_interactive()
return
if not args.release:
parser.print_help()
sys.exit(1)
try:
_show(
args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext
)
except Exception as e:
print(c(f"Error: {e}", RED), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()