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:
@@ -34,14 +34,14 @@ if str(_PROJECT_ROOT) not in sys.path:
|
||||
|
||||
USE_COLOR = True
|
||||
|
||||
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"
|
||||
MAGENTA = "\033[35m"
|
||||
|
||||
|
||||
@@ -88,8 +88,7 @@ VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".mov", ".ts", ".m2ts"}
|
||||
|
||||
def find_videos(folder: Path) -> list[Path]:
|
||||
return sorted(
|
||||
p for p in folder.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
|
||||
p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS
|
||||
)
|
||||
|
||||
|
||||
@@ -107,9 +106,13 @@ def confidence_bar(conf: float, width: int = 20) -> str:
|
||||
|
||||
def track_summary(track, verbose: bool = False) -> None:
|
||||
lang = track.language.code if track.language else c("?", RED)
|
||||
fmt = track.format.id if track.format else c("?", RED)
|
||||
typ = track.subtitle_type.value
|
||||
src = "embedded" if track.is_embedded else (track.file_path.name if track.file_path else "?")
|
||||
fmt = track.format.id if track.format else c("?", RED)
|
||||
typ = track.subtitle_type.value
|
||||
src = (
|
||||
"embedded"
|
||||
if track.is_embedded
|
||||
else (track.file_path.name if track.file_path else "?")
|
||||
)
|
||||
|
||||
# Couleur du type
|
||||
type_colors = {
|
||||
@@ -125,11 +128,19 @@ def track_summary(track, verbose: bool = False) -> None:
|
||||
|
||||
print(f" {c(src, BOLD)}")
|
||||
print(f" lang={c(lang, CYAN)} type={typ_str} format={fmt}")
|
||||
conf_str = c("n/a (embedded)", DIM) if track.is_embedded else confidence_bar(track.confidence)
|
||||
conf_str = (
|
||||
c("n/a (embedded)", DIM)
|
||||
if track.is_embedded
|
||||
else confidence_bar(track.confidence)
|
||||
)
|
||||
print(f" confidence={conf_str}{clarif}")
|
||||
|
||||
if track.entry_count is not None:
|
||||
print(f" entries={track.entry_count} size={track.file_size_kb:.1f} KB" if track.file_size_kb else f" entries={track.entry_count}")
|
||||
print(
|
||||
f" entries={track.entry_count} size={track.file_size_kb:.1f} KB"
|
||||
if track.file_size_kb
|
||||
else f" entries={track.entry_count}"
|
||||
)
|
||||
|
||||
if verbose and track.raw_tokens:
|
||||
print(f" tokens={track.raw_tokens}")
|
||||
@@ -146,7 +157,8 @@ def track_summary(track, verbose: bool = False) -> None:
|
||||
# Étapes du pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def step_load_kb() -> "SubtitleKnowledgeBase":
|
||||
|
||||
def step_load_kb() -> SubtitleKnowledgeBase:
|
||||
from alfred.domain.subtitles.knowledge.base import SubtitleKnowledgeBase
|
||||
from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader
|
||||
|
||||
@@ -168,12 +180,12 @@ def step_load_kb() -> "SubtitleKnowledgeBase":
|
||||
|
||||
|
||||
def step_detect_pattern(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
kb: SubtitleKnowledgeBase,
|
||||
season_folder: Path,
|
||||
sample_video: Path,
|
||||
release_group: str | None,
|
||||
forced_pattern: str | None,
|
||||
) -> "SubtitlePattern":
|
||||
) -> SubtitlePattern:
|
||||
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
|
||||
|
||||
section("ÉTAPE 2 — Détection du pattern de release")
|
||||
@@ -192,7 +204,9 @@ def step_detect_pattern(
|
||||
known = kb.patterns_for_group(release_group)
|
||||
if known:
|
||||
kv("Release group", release_group)
|
||||
ok(f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}")
|
||||
ok(
|
||||
f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}"
|
||||
)
|
||||
pattern = known[0]
|
||||
kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD))
|
||||
return pattern
|
||||
@@ -237,12 +251,12 @@ def step_detect_pattern(
|
||||
|
||||
|
||||
def step_identify_tracks(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
kb: SubtitleKnowledgeBase,
|
||||
sample_video: Path,
|
||||
pattern: "SubtitlePattern",
|
||||
pattern: SubtitlePattern,
|
||||
release_group: str | None,
|
||||
verbose: bool,
|
||||
) -> "MediaSubtitleMetadata":
|
||||
) -> MediaSubtitleMetadata:
|
||||
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
|
||||
|
||||
section("ÉTAPE 3 — Identification des pistes")
|
||||
@@ -286,9 +300,9 @@ def step_identify_tracks(
|
||||
|
||||
|
||||
def step_apply_rules(
|
||||
metadata: "MediaSubtitleMetadata",
|
||||
metadata: MediaSubtitleMetadata,
|
||||
release_group: str | None,
|
||||
) -> tuple["SubtitleMatchingRules | None", list, list]:
|
||||
) -> tuple[SubtitleMatchingRules | None, list, list]:
|
||||
from alfred.domain.subtitles.aggregates import DEFAULT_RULES
|
||||
from alfred.domain.subtitles.services.matcher import SubtitleMatcher
|
||||
from alfred.domain.subtitles.services.utils import available_subtitles
|
||||
@@ -308,7 +322,9 @@ def step_apply_rules(
|
||||
kv("Formats préférés", str(rules.preferred_formats))
|
||||
kv("Types autorisés", str(rules.allowed_types))
|
||||
kv("Confiance min", str(rules.min_confidence))
|
||||
info(c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4)
|
||||
info(
|
||||
c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4
|
||||
)
|
||||
|
||||
matcher = SubtitleMatcher()
|
||||
matched, unresolved = matcher.match(metadata.external_tracks, rules)
|
||||
@@ -330,7 +346,9 @@ def step_show_results(
|
||||
section("RÉSULTAT FINAL")
|
||||
|
||||
if matched:
|
||||
label = "piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
|
||||
label = (
|
||||
"piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
|
||||
)
|
||||
ok(f"{len(matched)} {label}:")
|
||||
for track in matched:
|
||||
lang = track.language.code if track.language else "?"
|
||||
@@ -352,7 +370,11 @@ def step_show_results(
|
||||
warn(f"{len(unresolved)} piste(s) écartées ou à clarifier:")
|
||||
for track in unresolved:
|
||||
src = track.file_path.name if track.file_path else "?"
|
||||
reason = "langue inconnue" if track.language is None else "confiance insuffisante"
|
||||
reason = (
|
||||
"langue inconnue"
|
||||
if track.language is None
|
||||
else "confiance insuffisante"
|
||||
)
|
||||
line = f" {c(src, DIM)} ({reason})"
|
||||
if verbose and track.raw_tokens:
|
||||
line += c(f" tokens: {track.raw_tokens}", YELLOW)
|
||||
@@ -365,9 +387,10 @@ def step_show_results(
|
||||
# Scan multi-épisodes (résumé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def scan_season(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
pattern: "SubtitlePattern",
|
||||
kb: SubtitleKnowledgeBase,
|
||||
pattern: SubtitlePattern,
|
||||
season_folder: Path,
|
||||
release_group: str | None,
|
||||
verbose: bool,
|
||||
@@ -408,15 +431,20 @@ def scan_season(
|
||||
pass
|
||||
|
||||
status_icon = c("✓", GREEN, BOLD) if placed_names else c("✗", RED, BOLD)
|
||||
warn_icon = c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
|
||||
warn_icon = (
|
||||
c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
|
||||
)
|
||||
|
||||
print(f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '—', GREEN if placed_names else DIM)}{warn_icon}")
|
||||
print(
|
||||
f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '—', GREEN if placed_names else DIM)}{warn_icon}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scanner de sous-titres Alfred — pipeline de diagnostic",
|
||||
@@ -424,18 +452,35 @@ def parse_args() -> argparse.Namespace:
|
||||
epilog=textwrap.dedent(__doc__ or ""),
|
||||
)
|
||||
parser.add_argument("season_folder", help="Dossier de la saison (ou du film)")
|
||||
parser.add_argument("--release-group", "-g", metavar="GROUP",
|
||||
help="Groupe de release (ex: RARBG, KONSTRAST)")
|
||||
parser.add_argument("--pattern", "-p", metavar="PATTERN",
|
||||
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)")
|
||||
parser.add_argument("--video", "-v", metavar="FILE",
|
||||
help="Fichier vidéo de référence (défaut: premier trouvé)")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Affiche les tokens bruts par piste")
|
||||
parser.add_argument("--no-color", action="store_true",
|
||||
help="Désactive la colorisation ANSI")
|
||||
parser.add_argument("--season-scan", action="store_true",
|
||||
help="Après le diagnostic, scanner tous les épisodes de la saison")
|
||||
parser.add_argument(
|
||||
"--release-group",
|
||||
"-g",
|
||||
metavar="GROUP",
|
||||
help="Groupe de release (ex: RARBG, KONSTRAST)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pattern",
|
||||
"-p",
|
||||
metavar="PATTERN",
|
||||
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--video",
|
||||
"-v",
|
||||
metavar="FILE",
|
||||
help="Fichier vidéo de référence (défaut: premier trouvé)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", action="store_true", help="Affiche les tokens bruts par piste"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-color", action="store_true", help="Désactive la colorisation ANSI"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--season-scan",
|
||||
action="store_true",
|
||||
help="Après le diagnostic, scanner tous les épisodes de la saison",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -474,7 +519,9 @@ def main() -> None:
|
||||
if videos:
|
||||
break
|
||||
if not videos:
|
||||
print("Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr)
|
||||
print(
|
||||
"Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr
|
||||
)
|
||||
sys.exit(1)
|
||||
sample_video = videos[0]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user