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