feat: major architectural refactor
- Refactor memory system (episodic/STM/LTM with components) - Implement complete subtitle domain (scanner, matcher, placer) - Add YAML workflow infrastructure - Externalize knowledge base (patterns, release groups) - Add comprehensive testing suite - Create manual testing CLIs
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
scan_subtitles.py — CLI pour tester le pipeline de scan de sous-titres Alfred.
|
||||
|
||||
Usage:
|
||||
uv run testing/subtitles/scan_subtitles.py <season_folder> [options]
|
||||
|
||||
Options:
|
||||
--release-group RARBG Groupe de release (optionnel — active les known patterns)
|
||||
--pattern adjacent Forcer un pattern (adjacent|flat|episode_subfolder|embedded)
|
||||
--video FILE Fichier vidéo de référence (défaut: premier .mkv/.mp4 trouvé)
|
||||
--verbose Détails sur chaque token analysé
|
||||
--no-color Désactive la colorisation
|
||||
|
||||
Exemples:
|
||||
uv run scripts/scan_subtitles.py "/media/tv/The X-Files/Season 01"
|
||||
uv run scripts/scan_subtitles.py "/media/tv/The X-Files/Season 01" --release-group RARBG
|
||||
uv run scripts/scan_subtitles.py "/media/tv/The X-Files/Season 01" --pattern episode_subfolder --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
# Ajoute la racine du projet au path (testing/subtitles/ → ../../)
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colorisation simple (pas de dépendance externe)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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"
|
||||
MAGENTA = "\033[35m"
|
||||
|
||||
|
||||
def c(text: str, *codes: str) -> str:
|
||||
if not USE_COLOR:
|
||||
return text
|
||||
return "".join(codes) + text + RESET
|
||||
|
||||
|
||||
def section(title: str) -> None:
|
||||
width = 70
|
||||
print()
|
||||
print(c("─" * width, DIM))
|
||||
print(c(f" {title}", BOLD, CYAN))
|
||||
print(c("─" * width, 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, indent: int = 2) -> None:
|
||||
print(" " * indent + msg)
|
||||
|
||||
|
||||
def kv(key: str, value: str, indent: int = 4) -> None:
|
||||
print(" " * indent + c(f"{key}: ", BOLD) + value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def confidence_bar(conf: float, width: int = 20) -> str:
|
||||
filled = int(conf * width)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
if conf >= 0.8:
|
||||
color = GREEN
|
||||
elif conf >= 0.5:
|
||||
color = YELLOW
|
||||
else:
|
||||
color = RED
|
||||
return c(bar, color) + c(f" {conf:.0%}", BOLD)
|
||||
|
||||
|
||||
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 "?")
|
||||
|
||||
# Couleur du type
|
||||
type_colors = {
|
||||
"standard": GREEN,
|
||||
"sdh": YELLOW,
|
||||
"forced": BLUE,
|
||||
"unknown": RED,
|
||||
}
|
||||
typ_str = c(typ, type_colors.get(typ, RESET))
|
||||
|
||||
unresolved = not track.is_embedded and track.language is None
|
||||
clarif = c(" [langue inconnue]", RED, BOLD) if unresolved else ""
|
||||
|
||||
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)
|
||||
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}")
|
||||
|
||||
if verbose and track.raw_tokens:
|
||||
print(f" tokens={track.raw_tokens}")
|
||||
|
||||
if track.is_resolved() and track.language and track.format:
|
||||
try:
|
||||
dest = track.destination_name
|
||||
print(f" → {c(dest, GREEN, BOLD)}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Étapes du pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def step_load_kb() -> "SubtitleKnowledgeBase":
|
||||
from alfred.domain.subtitles.knowledge.base import SubtitleKnowledgeBase
|
||||
from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader
|
||||
|
||||
section("ÉTAPE 1 — Chargement de la base de connaissances")
|
||||
kb = SubtitleKnowledgeBase(KnowledgeLoader())
|
||||
|
||||
fmts = kb.formats()
|
||||
langs = kb.languages()
|
||||
patterns = kb.patterns()
|
||||
|
||||
ok(f"{len(fmts)} format(s) connu(s): {', '.join(fmts.keys())}")
|
||||
ok(f"{len(langs)} langue(s) connue(s): {', '.join(langs.keys())}")
|
||||
ok(f"{len(patterns)} pattern(s) connu(s): {', '.join(patterns.keys())}")
|
||||
|
||||
total_tokens = sum(len(l.tokens) for l in langs.values())
|
||||
info(c(f"→ {total_tokens} tokens de langue au total", DIM), indent=4)
|
||||
|
||||
return kb
|
||||
|
||||
|
||||
def step_detect_pattern(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
season_folder: Path,
|
||||
sample_video: Path,
|
||||
release_group: str | None,
|
||||
forced_pattern: str | None,
|
||||
) -> "SubtitlePattern":
|
||||
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
|
||||
|
||||
section("ÉTAPE 2 — Détection du pattern de release")
|
||||
|
||||
# Priorité: forced > known patterns from release_group > auto-detect
|
||||
if forced_pattern:
|
||||
pattern = kb.pattern(forced_pattern)
|
||||
if not pattern:
|
||||
err(f"Pattern inconnu: '{forced_pattern}'")
|
||||
print(f" Patterns disponibles: {', '.join(kb.patterns().keys())}")
|
||||
sys.exit(1)
|
||||
ok(f"Pattern forcé: {c(forced_pattern, CYAN, BOLD)}")
|
||||
return pattern
|
||||
|
||||
if release_group:
|
||||
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)}")
|
||||
pattern = known[0]
|
||||
kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD))
|
||||
return pattern
|
||||
else:
|
||||
warn(f"Groupe '{release_group}' inconnu — lancement de la détection auto")
|
||||
|
||||
# Auto-detect
|
||||
kv("Dossier analysé", str(season_folder))
|
||||
kv("Vidéo de référence", sample_video.name)
|
||||
|
||||
detector = PatternDetector(kb)
|
||||
result = detector.detect(season_folder, sample_video)
|
||||
|
||||
findings = result.get("raw_findings", {})
|
||||
info(c("Observations:", BOLD), indent=4)
|
||||
for key, val in findings.items():
|
||||
if val not in (False, None, 0):
|
||||
info(f" {key}: {c(str(val), CYAN)}", indent=4)
|
||||
|
||||
detected = result.get("detected")
|
||||
confidence = result.get("confidence", 0.0)
|
||||
description = result.get("description", "")
|
||||
|
||||
print()
|
||||
info(c(f'Description: "{description}"', DIM), indent=4)
|
||||
print(f" Confiance: {confidence_bar(confidence)}")
|
||||
|
||||
if detected:
|
||||
ok(f"Pattern détecté: {c(detected.id, CYAN, BOLD)}")
|
||||
kv("Stratégie de scan", detected.scan_strategy.value)
|
||||
kv("Détection de type", detected.type_detection.value)
|
||||
if detected.root_folder:
|
||||
kv("Dossier racine", detected.root_folder)
|
||||
return detected
|
||||
else:
|
||||
warn("Aucun pattern détecté avec confiance suffisante — fallback: adjacent")
|
||||
fallback = kb.pattern("adjacent")
|
||||
if not fallback:
|
||||
err("Pattern 'adjacent' introuvable dans la KB !")
|
||||
sys.exit(1)
|
||||
return fallback
|
||||
|
||||
|
||||
def step_identify_tracks(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
sample_video: Path,
|
||||
pattern: "SubtitlePattern",
|
||||
release_group: str | None,
|
||||
verbose: bool,
|
||||
) -> "MediaSubtitleMetadata":
|
||||
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
|
||||
|
||||
section("ÉTAPE 3 — Identification des pistes")
|
||||
|
||||
kv("Vidéo", sample_video.name)
|
||||
kv("Pattern", pattern.id)
|
||||
|
||||
identifier = SubtitleIdentifier(kb)
|
||||
metadata = identifier.identify(
|
||||
video_path=sample_video,
|
||||
pattern=pattern,
|
||||
media_id=None,
|
||||
media_type="tv_show",
|
||||
release_group=release_group,
|
||||
)
|
||||
|
||||
n_emb = len(metadata.embedded_tracks)
|
||||
n_ext = len(metadata.external_tracks)
|
||||
n_unresolved = len(metadata.unresolved_tracks)
|
||||
|
||||
print()
|
||||
ok(f"{n_ext} piste(s) externe(s) trouvée(s)")
|
||||
if n_emb:
|
||||
ok(f"{n_emb} piste(s) embarquée(s) (ffprobe)")
|
||||
if n_unresolved:
|
||||
warn(f"{n_unresolved} piste(s) externe(s) sans langue reconnue")
|
||||
|
||||
if metadata.external_tracks:
|
||||
print()
|
||||
info(c("Pistes externes:", BOLD))
|
||||
for track in metadata.external_tracks:
|
||||
track_summary(track, verbose)
|
||||
|
||||
if metadata.embedded_tracks:
|
||||
print()
|
||||
info(c("Pistes embarquées:", BOLD))
|
||||
for track in metadata.embedded_tracks:
|
||||
track_summary(track, verbose)
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def step_apply_rules(
|
||||
metadata: "MediaSubtitleMetadata",
|
||||
release_group: str | None,
|
||||
) -> 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
|
||||
from alfred.domain.subtitles.value_objects import ScanStrategy
|
||||
|
||||
section("ÉTAPE 4 — Application des règles")
|
||||
|
||||
# Cas embedded : pas de matcher, on liste directement les pistes disponibles
|
||||
if metadata.detected_pattern_id == ScanStrategy.EMBEDDED.value:
|
||||
info(c("Pattern embedded — le matcher est court-circuité", DIM), indent=4)
|
||||
tracks = available_subtitles(metadata.embedded_tracks)
|
||||
ok(f"{len(tracks)} piste(s) disponible(s)")
|
||||
return None, tracks, []
|
||||
|
||||
rules = DEFAULT_RULES()
|
||||
kv("Langues préférées", str(rules.preferred_languages))
|
||||
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)
|
||||
|
||||
matcher = SubtitleMatcher()
|
||||
matched, unresolved = matcher.match(metadata.external_tracks, rules)
|
||||
|
||||
print()
|
||||
ok(f"{len(matched)} piste(s) retenue(s)")
|
||||
if unresolved:
|
||||
warn(f"{len(unresolved)} piste(s) écartée(s) ou non résolue(s)")
|
||||
|
||||
return rules, matched, unresolved
|
||||
|
||||
|
||||
def step_show_results(
|
||||
matched: list,
|
||||
unresolved: list,
|
||||
is_embedded: bool,
|
||||
verbose: bool,
|
||||
) -> None:
|
||||
section("RÉSULTAT FINAL")
|
||||
|
||||
if matched:
|
||||
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 "?"
|
||||
typ = track.subtitle_type.value
|
||||
if is_embedded:
|
||||
print(f" {c(lang, CYAN)} {c(typ, GREEN)}")
|
||||
else:
|
||||
try:
|
||||
dest = track.destination_name
|
||||
src = track.file_path.name if track.file_path else "?"
|
||||
print(f" {c(src, DIM)} → {c(dest, GREEN, BOLD)}")
|
||||
except ValueError:
|
||||
warn(f" Piste incomplète (lang ou format manquant): {track}")
|
||||
else:
|
||||
warn("Aucune piste retenue.")
|
||||
|
||||
if unresolved:
|
||||
print()
|
||||
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"
|
||||
line = f" {c(src, DIM)} ({reason})"
|
||||
if verbose and track.raw_tokens:
|
||||
line += c(f" tokens: {track.raw_tokens}", YELLOW)
|
||||
print(line)
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scan multi-épisodes (résumé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_season(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
pattern: "SubtitlePattern",
|
||||
season_folder: Path,
|
||||
release_group: str | None,
|
||||
verbose: bool,
|
||||
) -> None:
|
||||
from alfred.domain.subtitles.aggregates import DEFAULT_RULES
|
||||
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
|
||||
from alfred.domain.subtitles.services.matcher import SubtitleMatcher
|
||||
|
||||
videos = find_videos(season_folder)
|
||||
|
||||
section(f"SCAN COMPLET DE LA SAISON ({len(videos)} épisode(s))")
|
||||
|
||||
if not videos:
|
||||
warn("Aucun fichier vidéo trouvé dans ce dossier.")
|
||||
return
|
||||
|
||||
identifier = SubtitleIdentifier(kb)
|
||||
matcher = SubtitleMatcher()
|
||||
rules = DEFAULT_RULES()
|
||||
|
||||
col_w = max(len(v.name) for v in videos) + 2
|
||||
|
||||
for video in videos:
|
||||
metadata = identifier.identify(
|
||||
video_path=video,
|
||||
pattern=pattern,
|
||||
media_id=None,
|
||||
media_type="tv_show",
|
||||
release_group=release_group,
|
||||
)
|
||||
matched, unresolved = matcher.match(metadata.external_tracks, rules)
|
||||
|
||||
placed_names = []
|
||||
for t in matched:
|
||||
try:
|
||||
placed_names.append(t.destination_name)
|
||||
except ValueError:
|
||||
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 ""
|
||||
|
||||
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",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
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")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global USE_COLOR
|
||||
|
||||
args = parse_args()
|
||||
|
||||
if args.no_color or not sys.stdout.isatty():
|
||||
USE_COLOR = False
|
||||
|
||||
season_folder = Path(args.season_folder).expanduser().resolve()
|
||||
if not season_folder.is_dir():
|
||||
print(f"Erreur: '{season_folder}' n'est pas un dossier.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print(c("━" * 70, BOLD))
|
||||
print(c(" Alfred — Subtitle Scanner", BOLD, MAGENTA))
|
||||
print(c("━" * 70, BOLD))
|
||||
kv("Dossier", str(season_folder), indent=2)
|
||||
|
||||
# Trouver la vidéo de référence
|
||||
if args.video:
|
||||
sample_video = Path(args.video).expanduser().resolve()
|
||||
if not sample_video.exists():
|
||||
print(f"Erreur: '{sample_video}' introuvable.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
videos = find_videos(season_folder)
|
||||
if not videos:
|
||||
# Chercher un niveau plus bas (structure release root)
|
||||
for sub in season_folder.iterdir():
|
||||
if sub.is_dir():
|
||||
videos = find_videos(sub)
|
||||
if videos:
|
||||
break
|
||||
if not videos:
|
||||
print("Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
sample_video = videos[0]
|
||||
|
||||
kv("Vidéo de référence", sample_video.name, indent=2)
|
||||
|
||||
# ---- Pipeline ----
|
||||
kb = step_load_kb()
|
||||
|
||||
pattern = step_detect_pattern(
|
||||
kb=kb,
|
||||
season_folder=season_folder,
|
||||
sample_video=sample_video,
|
||||
release_group=args.release_group,
|
||||
forced_pattern=args.pattern,
|
||||
)
|
||||
|
||||
metadata = step_identify_tracks(
|
||||
kb=kb,
|
||||
sample_video=sample_video,
|
||||
pattern=pattern,
|
||||
release_group=args.release_group,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
rules, matched, unresolved = step_apply_rules(
|
||||
metadata=metadata,
|
||||
release_group=args.release_group,
|
||||
)
|
||||
|
||||
step_show_results(
|
||||
matched=matched,
|
||||
unresolved=unresolved,
|
||||
is_embedded=rules is None,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
if args.season_scan:
|
||||
scan_season(
|
||||
kb=kb,
|
||||
pattern=pattern,
|
||||
season_folder=season_folder,
|
||||
release_group=args.release_group,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
print(c("━" * 70, BOLD))
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+479
@@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
run_workflow.py — Simulate an Alfred workflow step by step (dry-run or live).
|
||||
|
||||
Usage:
|
||||
uv run testing/workflows/run_workflow.py organize_media [options]
|
||||
|
||||
Options:
|
||||
--dry-run Print what each step would do without executing tools (default).
|
||||
--live Actually execute the tools (uses real filesystem + memory).
|
||||
--source PATH Source video file (download folder).
|
||||
--dest PATH Destination video file (library path).
|
||||
--download-folder P Original download folder (for create_seed_links).
|
||||
--imdb-id ID IMDb ID for identify_media step (tt1234567).
|
||||
--seed Answer "yes" to the seeding question.
|
||||
--no-color Disable ANSI colours.
|
||||
|
||||
Examples:
|
||||
uv run testing/workflows/run_workflow.py organize_media --dry-run \\
|
||||
--source "/downloads/Breaking.Bad.S01E01.mkv" \\
|
||||
--dest "/tv/Breaking Bad/Season 01/Breaking Bad.S01E01.mkv"
|
||||
|
||||
uv run testing/workflows/run_workflow.py organize_media --live \\
|
||||
--source "/downloads/BB/Breaking.Bad.S01E01.mkv" \\
|
||||
--dest "/tv/Breaking Bad/Season 01/Breaking Bad.S01E01.mkv" \\
|
||||
--download-folder "/downloads/BB" --seed
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Project root on sys.path
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colours
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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"
|
||||
MAGENTA = "\033[35m"
|
||||
|
||||
|
||||
def c(text: str, *codes: str) -> str:
|
||||
if not USE_COLOR:
|
||||
return text
|
||||
return "".join(codes) + str(text) + RESET
|
||||
|
||||
|
||||
def section(title: str) -> None:
|
||||
print()
|
||||
print(c("─" * 70, DIM))
|
||||
print(c(f" {title}", BOLD, CYAN))
|
||||
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 kv(key: str, val: str) -> None:
|
||||
print(f" {c(key + ':', BOLD)} {val}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry-run tool stubs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dry_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"folder_type": folder_type,
|
||||
"path": path,
|
||||
"entries": ["[dry-run — no real listing]"],
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
|
||||
def _dry_find_media_imdb_id(**kwargs) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"imdb_id": kwargs.get("imdb_id") or "tt0000000",
|
||||
"title": "Dry Run Show",
|
||||
"type": "tv_show",
|
||||
"year": 2024,
|
||||
}
|
||||
|
||||
|
||||
def _dry_resolve_destination(
|
||||
release_name: str,
|
||||
source_file: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
tmdb_episode_title: str | None = None,
|
||||
confirmed_folder: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from alfred.domain.media.release_parser import parse_release
|
||||
parsed = parse_release(release_name)
|
||||
ext = Path(source_file).suffix
|
||||
if parsed.is_movie:
|
||||
folder = parsed.movie_folder_name(tmdb_title, tmdb_year)
|
||||
fname = parsed.movie_filename(tmdb_title, tmdb_year, ext)
|
||||
return {
|
||||
"status": "ok",
|
||||
"library_file": f"/movies/{folder}/{fname}",
|
||||
"series_folder": f"/movies/{folder}",
|
||||
"series_folder_name": folder,
|
||||
"season_folder": None,
|
||||
"season_folder_name": None,
|
||||
"filename": fname,
|
||||
"is_new_series_folder": True,
|
||||
}
|
||||
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
|
||||
return {
|
||||
"status": "ok",
|
||||
"library_file": f"/tv/{show_folder}/{season_folder}/{fname}",
|
||||
"series_folder": f"/tv/{show_folder}",
|
||||
"season_folder": f"/tv/{show_folder}/{season_folder}",
|
||||
"series_folder_name": show_folder,
|
||||
"season_folder_name": season_folder,
|
||||
"filename": fname,
|
||||
"is_new_series_folder": confirmed_folder is None,
|
||||
}
|
||||
|
||||
|
||||
def _dry_move_media(source: str, destination: str) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"source": source,
|
||||
"destination": destination,
|
||||
"filename": Path(destination).name,
|
||||
"size": 0,
|
||||
}
|
||||
|
||||
|
||||
def _dry_manage_subtitles(source_video: str, destination_video: str) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"video_path": destination_video,
|
||||
"placed": [],
|
||||
"placed_count": 0,
|
||||
"skipped_count": 0,
|
||||
}
|
||||
|
||||
|
||||
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}",
|
||||
"linked_file": f"/torrents/{Path(original_download_folder).name}/{Path(library_file).name}",
|
||||
"copied_files": ["[dry-run — no real copy]"],
|
||||
"copied_count": 1,
|
||||
"skipped": [],
|
||||
}
|
||||
|
||||
|
||||
DRY_RUN_TOOLS: dict[str, Any] = {
|
||||
"list_folder": _dry_list_folder,
|
||||
"find_media_imdb_id": _dry_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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_live_tools() -> dict[str, Any]:
|
||||
from alfred.agent.tools.filesystem import (
|
||||
create_seed_links,
|
||||
list_folder,
|
||||
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 {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WorkflowRunner:
|
||||
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.step_results: list[dict] = []
|
||||
|
||||
def run(self) -> None:
|
||||
name = self.workflow.get("name", "?")
|
||||
desc = self.workflow.get("description", "").strip()
|
||||
mode = c("LIVE", RED, BOLD) if self.live else c("DRY-RUN", YELLOW, BOLD)
|
||||
|
||||
print()
|
||||
print(c("━" * 70, BOLD))
|
||||
print(c(f" Alfred — Workflow Simulator [{mode}]", BOLD, MAGENTA))
|
||||
print(c("━" * 70, BOLD))
|
||||
kv("Workflow", c(name, CYAN, BOLD))
|
||||
kv("Description", desc)
|
||||
kv("Tools allowed", ", ".join(self.workflow.get("tools", [])))
|
||||
|
||||
steps = self.workflow.get("steps", [])
|
||||
for step in steps:
|
||||
self._run_step(step)
|
||||
|
||||
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"]
|
||||
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')}")
|
||||
print()
|
||||
print(c("━" * 70, BOLD))
|
||||
print()
|
||||
|
||||
def _run_step(self, step: dict) -> None:
|
||||
step_id = step.get("id", "?")
|
||||
|
||||
# --- ask_user step ---
|
||||
if "ask_user" in step:
|
||||
section(f"STEP [{step_id}] — ask_user")
|
||||
q = step["ask_user"].get("question", "")
|
||||
answers = step["ask_user"].get("answers", {})
|
||||
info(c(f'Question: "{q}"', BOLD))
|
||||
info(f"Réponses possibles: {', '.join(str(k) for k in answers.keys())}")
|
||||
|
||||
answer = "yes" if self.args.seed else "no"
|
||||
# PyYAML parses bare yes/no as booleans — normalise keys to str
|
||||
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["ask_seeding_answer"] = answer
|
||||
self.context["next_after_ask"] = next_step
|
||||
|
||||
# If "no", skip create_seed_links
|
||||
if answer == "no":
|
||||
self.context["skip_create_seed_links"] = True
|
||||
return
|
||||
|
||||
# --- memory_write step ---
|
||||
if "memory_write" in step:
|
||||
section(f"STEP [{step_id}] — memory_write ({step['memory_write']})")
|
||||
if self.live:
|
||||
warn("memory_write: pas encore implémenté dans le simulator live")
|
||||
else:
|
||||
ok("(dry-run) Library entry would be written to LTM")
|
||||
self.step_results.append({"id": step_id, "result": {"status": "ok"}})
|
||||
return
|
||||
|
||||
# --- tool step ---
|
||||
tool_name = step.get("tool")
|
||||
if not tool_name:
|
||||
warn(f"Step '{step_id}' has no tool or ask_user — skipped")
|
||||
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"):
|
||||
section(f"STEP [{step_id}] — {tool_name}")
|
||||
warn("Skipped (user chose not to seed)")
|
||||
return
|
||||
|
||||
section(f"STEP [{step_id}] — {c(tool_name, CYAN, BOLD)}")
|
||||
|
||||
desc = step.get("description", "").strip()
|
||||
if desc:
|
||||
info(c(desc, DIM))
|
||||
|
||||
kwargs = self._build_kwargs(tool_name, step)
|
||||
for k, v in kwargs.items():
|
||||
kv(k, str(v))
|
||||
|
||||
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"}})
|
||||
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)}})
|
||||
return
|
||||
|
||||
self._print_result(result)
|
||||
self.context[step_id] = result
|
||||
self.step_results.append({"id": step_id, "result": result})
|
||||
|
||||
def _build_kwargs(self, tool_name: str, step: dict) -> dict[str, Any]:
|
||||
"""Build tool kwargs from step params + CLI args + previous context."""
|
||||
# Start from step-level params (static defaults from YAML)
|
||||
kwargs: dict[str, Any] = dict(step.get("params") or {})
|
||||
|
||||
a = self.args
|
||||
|
||||
if tool_name == "list_folder":
|
||||
kwargs.setdefault("folder_type", "download")
|
||||
|
||||
elif tool_name == "find_media_imdb_id":
|
||||
if a.imdb_id:
|
||||
kwargs["imdb_id"] = a.imdb_id
|
||||
|
||||
elif tool_name == "resolve_destination":
|
||||
if a.release:
|
||||
kwargs["release_name"] = a.release
|
||||
elif a.source:
|
||||
kwargs.setdefault("release_name", Path(a.source).parent.name)
|
||||
if a.source:
|
||||
kwargs["source_file"] = a.source
|
||||
if a.tmdb_title:
|
||||
kwargs["tmdb_title"] = a.tmdb_title
|
||||
if a.tmdb_year:
|
||||
kwargs["tmdb_year"] = a.tmdb_year
|
||||
if a.episode_title:
|
||||
kwargs["tmdb_episode_title"] = a.episode_title
|
||||
|
||||
elif tool_name == "move_media":
|
||||
# If resolve_destination ran, use its library_file as destination
|
||||
resolved = self.context.get("resolve_destination", {})
|
||||
if a.source:
|
||||
kwargs["source"] = a.source
|
||||
dest = a.dest or resolved.get("library_file")
|
||||
if dest:
|
||||
kwargs["destination"] = dest
|
||||
|
||||
elif tool_name == "manage_subtitles":
|
||||
resolved = self.context.get("resolve_destination", {})
|
||||
if a.source:
|
||||
kwargs["source_video"] = a.source
|
||||
dest = a.dest or resolved.get("library_file")
|
||||
if dest:
|
||||
kwargs["destination_video"] = dest
|
||||
|
||||
elif tool_name == "create_seed_links":
|
||||
resolved = self.context.get("resolve_destination", {})
|
||||
library_file = a.dest or resolved.get("library_file")
|
||||
if library_file:
|
||||
kwargs["library_file"] = library_file
|
||||
if a.download_folder:
|
||||
kwargs["original_download_folder"] = a.download_folder
|
||||
elif a.source:
|
||||
kwargs.setdefault("original_download_folder", str(Path(a.source).parent))
|
||||
|
||||
return kwargs
|
||||
|
||||
def _print_result(self, result: dict) -> None:
|
||||
status = result.get("status", "?")
|
||||
if status == "ok":
|
||||
ok(f"status={c('ok', GREEN)}")
|
||||
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')}")
|
||||
return
|
||||
|
||||
# Pretty-print notable fields
|
||||
skip = {"status", "error", "message"}
|
||||
for k, v in result.items():
|
||||
if k in skip:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
if v:
|
||||
info(c(f"{k}:", BOLD))
|
||||
for item in v[:10]:
|
||||
info(f" • {item}")
|
||||
if len(v) > 10:
|
||||
info(c(f" … and {len(v) - 10} more", DIM))
|
||||
else:
|
||||
info(f"{c(k + ':', BOLD)} (empty)")
|
||||
else:
|
||||
kv(k, str(v))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Alfred workflow simulator",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
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="PATH",
|
||||
help="Source video file (in download folder)")
|
||||
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()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global USE_COLOR
|
||||
args = parse_args()
|
||||
|
||||
if args.no_color or not sys.stdout.isatty():
|
||||
USE_COLOR = False
|
||||
|
||||
if args.live:
|
||||
args.dry_run = False
|
||||
|
||||
# Load workflow
|
||||
from alfred.agent.workflows.loader import WorkflowLoader
|
||||
loader = WorkflowLoader()
|
||||
workflow = loader.get(args.workflow)
|
||||
if not workflow:
|
||||
print(f"Erreur: workflow '{args.workflow}' introuvable.", file=sys.stderr)
|
||||
print(f"Disponibles: {', '.join(loader.names())}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load tools
|
||||
if args.live:
|
||||
try:
|
||||
tools = _load_live_tools()
|
||||
except Exception as e:
|
||||
print(f"Erreur chargement des tools live: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
tools = DRY_RUN_TOOLS
|
||||
|
||||
runner = WorkflowRunner(workflow, tools, live=args.live, args=args)
|
||||
runner.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user