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