diff --git a/.env.alfred b/.env.alfred index 8946047..80b4a13 100644 --- a/.env.alfred +++ b/.env.alfred @@ -46,9 +46,12 @@ TMDB_BASE_URL=https://api.themoviedb.org/3 # qBittorrent # → QBITTORRENT_PASSWORD goes in .env.secrets -QBITTORRENT_URL=http://qbittorrent:16140 -QBITTORRENT_USERNAME=admin +QBITTORRENT_URL=https://qb.lan.anustart.top +QBITTORRENT_USERNAME=letmein QBITTORRENT_PORT=16140 +# Path translation: host-side prefix → container-side prefix +QBITTORRENT_HOST_PATH=/mnt/testipool +QBITTORRENT_CONTAINER_PATH=/mnt/data # Meilisearch # → MEILI_MASTER_KEY goes in .env.secrets @@ -60,7 +63,7 @@ MEILI_HOST=http://meilisearch:7700 # --- LLM CONFIGURATION --- # Providers: local, openai, anthropic, deepseek, google, kimi # → API keys go in .env.secrets -DEFAULT_LLM_PROVIDER=local +DEFAULT_LLM_PROVIDER=deepseek # Local LLM (Ollama) #OLLAMA_BASE_URL=http://ollama:11434 diff --git a/alfred/agent/agent.py b/alfred/agent/agent.py index 5caba8a..b17dc60 100644 --- a/alfred/agent/agent.py +++ b/alfred/agent/agent.py @@ -190,7 +190,7 @@ class Agent: async def step_streaming( self, user_input: str, completion_id: str, created_ts: int, model: str - ) -> AsyncGenerator[dict[str, Any], None]: + ) -> AsyncGenerator[dict[str, Any]]: """ Execute agent step with streaming support for LibreChat. diff --git a/alfred/agent/expressions.py b/alfred/agent/expressions.py new file mode 100644 index 0000000..850fd69 --- /dev/null +++ b/alfred/agent/expressions.py @@ -0,0 +1,79 @@ +"""Expression loader — charge et merge les fichiers YAML d'expressions par user.""" + +import random +from pathlib import Path + +import yaml + +_USERS_DIR = Path(__file__).parent.parent / "knowledge" / "users" + + +def _load_yaml(path: Path) -> dict: + if not path.exists(): + return {} + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + + +def load_expressions(username: str | None) -> dict: + """ + Charge common.yaml et le merge avec {username}.yaml. + + Retourne un dict avec : + - nickname: str (surnom de l'user, ou username en fallback) + - expressions: dict[situation -> list[str]] + """ + common = _load_yaml(_USERS_DIR / "common.yaml") + user_data = _load_yaml(_USERS_DIR / f"{username}.yaml") if username else {} + + # Merge expressions : common + user (les phrases user s'ajoutent) + common_exprs: dict[str, list] = common.get("expressions", {}) + user_exprs: dict[str, list] = user_data.get("expressions", {}) + + merged: dict[str, list] = {} + all_situations = set(common_exprs) | set(user_exprs) + for situation in all_situations: + base = list(common_exprs.get(situation, [])) + extra = list(user_exprs.get(situation, [])) + merged[situation] = base + extra + + nickname = user_data.get("user", {}).get("nickname") or username or "mec" + + return { + "nickname": nickname, + "expressions": merged, + } + + +def pick(expressions: dict, situation: str, nickname: str | None = None) -> str: + """ + Pioche une expression aléatoire pour une situation donnée. + + Résout {user} avec le nickname si fourni. + Retourne une string vide si la situation n'existe pas. + """ + options = expressions.get("expressions", {}).get(situation, []) + if not options: + return "" + chosen = random.choice(options) + if nickname: + chosen = chosen.replace("{user}", nickname) + return chosen + + +def build_expressions_context(username: str | None) -> dict: + """ + Point d'entrée principal. + + Retourne : + - nickname: str + - samples: dict[situation -> une phrase résolue] — une seule par situation + """ + data = load_expressions(username) + nickname = data["nickname"] + samples = { + situation: pick(data, situation, nickname) for situation in data["expressions"] + } + return { + "nickname": nickname, + "samples": samples, + } diff --git a/alfred/agent/prompt.py b/alfred/agent/prompt.py new file mode 100644 index 0000000..4a1d272 --- /dev/null +++ b/alfred/agent/prompt.py @@ -0,0 +1,239 @@ +"""Prompt builder for the agent system.""" + +import json +from typing import Any + +from alfred.infrastructure.persistence import get_memory +from alfred.infrastructure.persistence.memory import MemoryRegistry + +from .expressions import build_expressions_context +from .registry import Tool + + +class PromptBuilder: + """Builds system prompts for the agent with memory context.""" + + def __init__(self, tools: dict[str, Tool]): + self.tools = tools + self._memory_registry = MemoryRegistry() + + def _format_identity(self, memory) -> str: + """Build Alfred's identity and personality section.""" + username = memory.stm.get_entity("username") + expr = build_expressions_context(username) + nickname = expr["nickname"] + samples = expr["samples"] + + # Format expressions as situational guidance for the LLM + expr_lines = [] + situation_labels = { + "greeting": "Salutation", + "success": "Succès", + "working": "En cours", + "error": "Erreur", + "unclear": "Demande floue", + "warning": "Avertissement", + "not_found": "Introuvable", + } + for situation, label in situation_labels.items(): + phrase = samples.get(situation, "") + if phrase: + expr_lines.append(f' {label}: "{phrase}"') + + expressions_block = "\n".join(expr_lines) + + return f"""Tu t'appelles Alfred. Tu es un assistant d'organisation de médiathèque — direct, opérationnel, légèrement impertinent. + +PERSONNALITÉ: +- Tu parles franglais : français avec des touches d'anglais quand ça colle mieux +- Tu es sarcastique et n'as pas peur de te moquer si l'user fait une connerie +- Tu envoies chier poliment (mais clairement) quand la demande est trop floue +- Tu ne fais pas de blabla inutile. Si "ok c'est fait" suffit, c'est tout ce que tu dis +- Tu peux jurer (putain, merde, con, ...) — c'est naturel, pas du remplissage +- Jamais de "Great question!" ou de politesse creuse + +USER COURANT: {nickname} + +EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta réponse) : +{expressions_block}""" + + def build_tools_spec(self) -> list[dict[str, Any]]: + """Build the tool specification for the LLM API.""" + tool_specs = [] + for tool in self.tools.values(): + spec = { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + }, + } + tool_specs.append(spec) + return tool_specs + + def _format_tools_description(self) -> str: + """Format tools with their descriptions and parameters.""" + if not self.tools: + return "" + return "\n".join( + f"- {tool.name}: {tool.description}\n" + f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}" + for tool in self.tools.values() + ) + + def _format_episodic_context(self, memory) -> str: + """Format episodic memory context for the prompt.""" + lines = [] + + if memory.episodic.last_search_results: + results = memory.episodic.last_search_results + result_list = results.get("results", []) + lines.append( + f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)" + ) + # Show first 5 results + for i, result in enumerate(result_list[:5]): + name = result.get("name", "Unknown") + lines.append(f" {i + 1}. {name}") + if len(result_list) > 5: + lines.append(f" ... and {len(result_list) - 5} more") + + if memory.episodic.pending_question: + question = memory.episodic.pending_question + lines.append(f"\nPENDING QUESTION: {question.get('question')}") + lines.append(f" Type: {question.get('type')}") + if question.get("options"): + lines.append(f" Options: {len(question.get('options'))}") + + if memory.episodic.active_downloads: + lines.append(f"\nACTIVE DOWNLOADS: {len(memory.episodic.active_downloads)}") + for dl in memory.episodic.active_downloads[:3]: + lines.append(f" - {dl.get('name')}: {dl.get('progress', 0)}%") + + if memory.episodic.recent_errors: + lines.append("\nRECENT ERRORS (up to 3):") + for error in memory.episodic.recent_errors[-3:]: + lines.append( + f" - Action '{error.get('action')}' failed: {error.get('error')}" + ) + + # Unread events + unread = [e for e in memory.episodic.background_events if not e.get("read")] + if unread: + lines.append(f"\nUNREAD EVENTS: {len(unread)}") + for event in unread[:3]: + lines.append(f" - {event.get('type')}: {event.get('data')}") + + return "\n".join(lines) + + def _format_stm_context(self, memory) -> str: + """Format short-term memory context for the prompt.""" + lines = [] + + if memory.stm.current_workflow: + workflow = memory.stm.current_workflow + lines.append( + f"CURRENT WORKFLOW: {workflow.get('type')} (stage: {workflow.get('stage')})" + ) + if workflow.get("target"): + lines.append(f" Target: {workflow.get('target')}") + + if memory.stm.current_topic: + lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}") + + if memory.stm.extracted_entities: + lines.append("EXTRACTED ENTITIES:") + for key, value in memory.stm.extracted_entities.items(): + lines.append(f" - {key}: {value}") + + if memory.stm.language: + lines.append(f"CONVERSATION LANGUAGE: {memory.stm.language}") + + return "\n".join(lines) + + def _format_memory_schema(self) -> str: + """Describe available memory components so the agent knows what to read/write and when.""" + schema = self._memory_registry.schema() + tier_labels = { + "ltm": "LONG-TERM (persisted)", + "stm": "SHORT-TERM (session)", + "episodic": "EPISODIC (volatile)", + } + lines = ["MEMORY COMPONENTS:"] + + for tier, components in schema.items(): + if not components: + continue + lines.append(f"\n [{tier_labels.get(tier, tier.upper())}]") + for c in components: + access = c.get("access", "read") + lines.append(f" {c['name']} ({access}): {c['description']}") + for field_name, field_desc in c.get("fields", {}).items(): + lines.append(f" · {field_name}: {field_desc}") + + return "\n".join(lines) + + def _format_config_context(self, memory) -> str: + """Format configuration context.""" + lines = ["CURRENT CONFIGURATION:"] + folders = { + **memory.ltm.workspace.as_dict(), + **memory.ltm.library_paths.to_dict(), + } + if folders: + for key, value in folders.items(): + lines.append(f" - {key}: {value}") + else: + lines.append(" (no configuration set)") + return "\n".join(lines) + + def build_system_prompt(self) -> str: + """Build the complete system prompt.""" + memory = get_memory() + + # Identity + personality + identity = self._format_identity(memory) + + # Language instruction + language_instruction = ( + "Si la langue de l'user est différente de la langue courante en STM, " + "appelle `set_language` en premier avant de répondre." + ) + + # Configuration + config_section = self._format_config_context(memory) + + # STM context + stm_context = self._format_stm_context(memory) + + # Episodic context + episodic_context = self._format_episodic_context(memory) + + # Memory schema + memory_schema = self._format_memory_schema() + + # Available tools + tools_desc = self._format_tools_description() + tools_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else "" + + rules = """ +RÈGLES: +- Utilise les outils pour accomplir les tâches, pas pour décorer +- Si des résultats de recherche sont dispo en mémoire épisodique, référence-les par index +- Confirme toujours avant une opération destructive (move, delete, overwrite) +- Réponses courtes — si c'est fait, dis-le en une ligne +- Si la demande est floue, demande un éclaircissement AVANT de lancer quoi que ce soit +""" + + sections = [ + identity, + language_instruction, + config_section, + stm_context, + episodic_context, + memory_schema, + tools_section, + rules, + ] + return "\n\n".join(s for s in sections if s and s.strip()) diff --git a/alfred/agent/registry.py b/alfred/agent/registry.py index ab75ffa..f3724c9 100644 --- a/alfred/agent/registry.py +++ b/alfred/agent/registry.py @@ -99,8 +99,12 @@ def make_tools(settings) -> dict[str, Tool]: fs_tools.list_folder, fs_tools.analyze_release, fs_tools.probe_media, - fs_tools.resolve_destination, + fs_tools.resolve_season_destination_tool, + fs_tools.resolve_episode_destination_tool, + fs_tools.resolve_movie_destination_tool, + fs_tools.resolve_series_destination_tool, fs_tools.move_media, + fs_tools.move_to_destination, fs_tools.manage_subtitles, fs_tools.create_seed_links, fs_tools.learn, diff --git a/alfred/agent/tools/filesystem.py b/alfred/agent/tools/filesystem.py index 7fe6398..628a0af 100644 --- a/alfred/agent/tools/filesystem.py +++ b/alfred/agent/tools/filesystem.py @@ -3,20 +3,25 @@ from pathlib import Path from typing import Any -import alfred as _alfred_pkg import yaml +import alfred as _alfred_pkg from alfred.application.filesystem import ( CreateSeedLinksUseCase, ListFolderUseCase, ManageSubtitlesUseCase, MoveMediaUseCase, - ResolveDestinationUseCase, SetFolderPathUseCase, ) 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 import FileManager +from alfred.application.filesystem.resolve_destination import ( + resolve_episode_destination, + resolve_movie_destination, + resolve_season_destination, + resolve_series_destination, +) +from alfred.infrastructure.filesystem import FileManager, create_folder, move from alfred.infrastructure.filesystem.ffprobe import probe from alfred.infrastructure.filesystem.find_video import find_video_file @@ -42,7 +47,57 @@ def move_media(source: str, destination: str) -> dict[str, Any]: return use_case.execute(source, destination).to_dict() -def resolve_destination( +def move_to_destination(source: str, destination: str) -> dict[str, Any]: + """ + Move a file or folder to a destination, creating parent directories if needed. + + Use this after resolve_*_destination to perform the actual move. + The destination parent is created automatically if it doesn't exist. + + Args: + source: Absolute path to the source file or folder. + destination: Absolute path to the destination. + + Returns: + Dict with status, source, destination — or error details. + """ + parent = str(Path(destination).parent) + result = create_folder(parent) + if result["status"] != "ok": + return result + return move(source, destination) + + +def resolve_season_destination_tool( + release_name: str, + tmdb_title: str, + tmdb_year: int, + confirmed_folder: str | None = None, +) -> dict[str, Any]: + """ + Compute destination paths for a season pack (folder move). + + Returns series_folder + season_folder. No file paths — the whole + source folder is moved as-is into season_folder. + + Args: + release_name: Raw release folder name (e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST"). + tmdb_title: Canonical show title from TMDB (e.g. "Oz"). + tmdb_year: Show start year from TMDB (e.g. 1997). + confirmed_folder: If needs_clarification was returned, pass the chosen folder name. + + Returns: + On success: dict with status, series_folder, season_folder, series_folder_name, + season_folder_name, is_new_series_folder. + On ambiguity: dict with status="needs_clarification", question, options. + On error: dict with status="error", error, message. + """ + return resolve_season_destination( + release_name, tmdb_title, tmdb_year, confirmed_folder + ).to_dict() + + +def resolve_episode_destination_tool( release_name: str, source_file: str, tmdb_title: str, @@ -51,44 +106,91 @@ def resolve_destination( confirmed_folder: str | None = None, ) -> dict[str, Any]: """ - Compute the destination path in the media library for a release. + Compute destination paths for a single episode file. - Call this before move_media to get the correct library path. Handles: - - Parsing the release name (quality, codec, group, season/episode) - - Looking up any existing series folder in the library - - Applying group-conflict rules (asks user if ambiguous) - - Building the full destination path with correct naming conventions + Returns series_folder + season_folder + library_file (full path to destination .mkv). Args: - release_name: Raw release folder or file name - (e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST"). + release_name: Raw release file name (e.g. "Oz.S03E01.1080p.WEBRip.x265-KONTRAST.mkv"). source_file: Absolute path to the source video file (used for extension). - tmdb_title: Canonical show/movie title from TMDB (e.g. "Oz"). - tmdb_year: Release/start year from TMDB (e.g. 1997). - tmdb_episode_title: Episode title from TMDB for single-episode releases - (e.g. "The Routine"). Omit for season packs and movies. - confirmed_folder: If a previous call returned needs_clarification, pass - the user-chosen folder name here to proceed. + tmdb_title: Canonical show title from TMDB (e.g. "Oz"). + tmdb_year: Show start year from TMDB (e.g. 1997). + tmdb_episode_title: Episode title from TMDB (e.g. "The Routine"). Optional. + confirmed_folder: If needs_clarification was returned, pass the chosen folder name. Returns: - On success: dict with status, library_file, series_folder, season_folder, - series_folder_name, season_folder_name, filename, - is_new_series_folder. + On success: dict with status, series_folder, season_folder, library_file, + series_folder_name, season_folder_name, filename, is_new_series_folder. On ambiguity: dict with status="needs_clarification", question, options. On error: dict with status="error", error, message. """ - use_case = ResolveDestinationUseCase() - return use_case.execute( - release_name=release_name, - source_file=source_file, - tmdb_title=tmdb_title, - tmdb_year=tmdb_year, - tmdb_episode_title=tmdb_episode_title, - confirmed_folder=confirmed_folder, + return resolve_episode_destination( + release_name, + source_file, + tmdb_title, + tmdb_year, + tmdb_episode_title, + confirmed_folder, ).to_dict() -def create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]: +def resolve_movie_destination_tool( + release_name: str, + source_file: str, + tmdb_title: str, + tmdb_year: int, +) -> dict[str, Any]: + """ + Compute destination paths for a movie file. + + Returns movie_folder + library_file (full path to destination .mkv). + + Args: + release_name: Raw release folder/file name (e.g. "Inception.2010.1080p.BluRay.x265-GROUP"). + source_file: Absolute path to the source video file (used for extension). + tmdb_title: Canonical movie title from TMDB (e.g. "Inception"). + tmdb_year: Movie release year from TMDB (e.g. 2010). + + Returns: + On success: dict with status, movie_folder, library_file, movie_folder_name, + filename, is_new_folder. + On error: dict with status="error", error, message. + """ + return resolve_movie_destination( + release_name, source_file, tmdb_title, tmdb_year + ).to_dict() + + +def resolve_series_destination_tool( + release_name: str, + tmdb_title: str, + tmdb_year: int, + confirmed_folder: str | None = None, +) -> dict[str, Any]: + """ + Compute destination path for a complete multi-season series pack (folder move). + + Returns only series_folder — the whole pack lands directly inside it. + + Args: + release_name: Raw release folder name. + tmdb_title: Canonical show title from TMDB. + tmdb_year: Show start year from TMDB. + confirmed_folder: If needs_clarification was returned, pass the chosen folder name. + + Returns: + On success: dict with status, series_folder, series_folder_name, is_new_series_folder. + On ambiguity: dict with status="needs_clarification", question, options. + On error: dict with status="error", error, message. + """ + return resolve_series_destination( + release_name, tmdb_title, tmdb_year, confirmed_folder + ).to_dict() + + +def create_seed_links( + library_file: str, original_download_folder: str +) -> dict[str, Any]: """ Prepare a torrent subfolder so qBittorrent can keep seeding after a move. @@ -159,10 +261,18 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An _VALID_CATEGORIES = {"languages", "types", "formats"} if pack not in _VALID_PACKS: - return {"status": "error", "error": "unknown_pack", "message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}"} + return { + "status": "error", + "error": "unknown_pack", + "message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}", + } if category not in _VALID_CATEGORIES: - return {"status": "error", "error": "unknown_category", "message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}"} + return { + "status": "error", + "error": "unknown_category", + "message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}", + } learned_path = _LEARNED_ROOT / "subtitles_learned.yaml" _LEARNED_ROOT.mkdir(parents=True, exist_ok=True) @@ -184,7 +294,9 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An tmp = learned_path.with_suffix(".yaml.tmp") try: with open(tmp, "w", encoding="utf-8") as f: - yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False) + yaml.safe_dump( + data, f, allow_unicode=True, default_flow_style=False, sort_keys=False + ) tmp.rename(learned_path) except Exception as e: tmp.unlink(missing_ok=True) @@ -252,26 +364,26 @@ def analyze_release(release_name: str, source_path: str) -> dict[str, Any]: return { "status": "ok", - "media_type": parsed.media_type, - "parse_path": parsed.parse_path, - "title": parsed.title, - "year": parsed.year, - "season": parsed.season, - "episode": parsed.episode, - "episode_end": parsed.episode_end, - "quality": parsed.quality, - "source": parsed.source, - "codec": parsed.codec, - "group": parsed.group, - "languages": parsed.languages, - "audio_codec": parsed.audio_codec, - "audio_channels": parsed.audio_channels, - "bit_depth": parsed.bit_depth, - "hdr_format": parsed.hdr_format, - "edition": parsed.edition, - "site_tag": parsed.site_tag, - "is_season_pack": parsed.is_season_pack, - "probe_used": probe_used, + "media_type": parsed.media_type, + "parse_path": parsed.parse_path, + "title": parsed.title, + "year": parsed.year, + "season": parsed.season, + "episode": parsed.episode, + "episode_end": parsed.episode_end, + "quality": parsed.quality, + "source": parsed.source, + "codec": parsed.codec, + "group": parsed.group, + "languages": parsed.languages, + "audio_codec": parsed.audio_codec, + "audio_channels": parsed.audio_channels, + "bit_depth": parsed.bit_depth, + "hdr_format": parsed.hdr_format, + "edition": parsed.edition, + "site_tag": parsed.site_tag, + "is_season_pack": parsed.is_season_pack, + "probe_used": probe_used, } @@ -293,45 +405,53 @@ def probe_media(source_path: str) -> dict[str, Any]: """ path = Path(source_path) if not path.exists(): - return {"status": "error", "error": "not_found", "message": f"{source_path} does not exist"} + return { + "status": "error", + "error": "not_found", + "message": f"{source_path} does not exist", + } media_info = probe(path) if media_info is None: - return {"status": "error", "error": "probe_failed", "message": "ffprobe failed to read the file"} + return { + "status": "error", + "error": "probe_failed", + "message": "ffprobe failed to read the file", + } return { "status": "ok", "video": { - "codec": media_info.video_codec, - "resolution": media_info.resolution, - "width": media_info.width, - "height": media_info.height, + "codec": media_info.video_codec, + "resolution": media_info.resolution, + "width": media_info.width, + "height": media_info.height, "duration_seconds": media_info.duration_seconds, - "bitrate_kbps": media_info.bitrate_kbps, + "bitrate_kbps": media_info.bitrate_kbps, }, "audio_tracks": [ { - "index": t.index, - "codec": t.codec, - "channels": t.channels, + "index": t.index, + "codec": t.codec, + "channels": t.channels, "channel_layout": t.channel_layout, - "language": t.language, - "is_default": t.is_default, + "language": t.language, + "is_default": t.is_default, } for t in media_info.audio_tracks ], "subtitle_tracks": [ { - "index": t.index, - "codec": t.codec, - "language": t.language, + "index": t.index, + "codec": t.codec, + "language": t.language, "is_default": t.is_default, - "is_forced": t.is_forced, + "is_forced": t.is_forced, } for t in media_info.subtitle_tracks ], "audio_languages": media_info.audio_languages, - "is_multi_audio": media_info.is_multi_audio, + "is_multi_audio": media_info.is_multi_audio, } diff --git a/alfred/application/filesystem/__init__.py b/alfred/application/filesystem/__init__.py index 7dc259b..c4b8a36 100644 --- a/alfred/application/filesystem/__init__.py +++ b/alfred/application/filesystem/__init__.py @@ -12,7 +12,16 @@ from .dto import ( from .list_folder import ListFolderUseCase from .manage_subtitles import ManageSubtitlesUseCase from .move_media import MoveMediaUseCase -from .resolve_destination import ResolveDestinationUseCase, ResolvedDestination +from .resolve_destination import ( + ResolvedEpisodeDestination, + ResolvedMovieDestination, + ResolvedSeasonDestination, + ResolvedSeriesDestination, + resolve_episode_destination, + resolve_movie_destination, + resolve_season_destination, + resolve_series_destination, +) from .set_folder_path import SetFolderPathUseCase __all__ = [ @@ -21,8 +30,14 @@ __all__ = [ "CreateSeedLinksUseCase", "MoveMediaUseCase", "ManageSubtitlesUseCase", - "ResolveDestinationUseCase", - "ResolvedDestination", + "ResolvedSeasonDestination", + "ResolvedEpisodeDestination", + "ResolvedMovieDestination", + "ResolvedSeriesDestination", + "resolve_season_destination", + "resolve_episode_destination", + "resolve_movie_destination", + "resolve_series_destination", "SetFolderPathResponse", "ListFolderResponse", "CreateSeedLinksResponse", diff --git a/alfred/application/filesystem/detect_media_type.py b/alfred/application/filesystem/detect_media_type.py index cd8b8ce..10c584a 100644 --- a/alfred/application/filesystem/detect_media_type.py +++ b/alfred/application/filesystem/detect_media_type.py @@ -20,10 +20,10 @@ from __future__ import annotations from pathlib import Path from alfred.domain.release.value_objects import ( - ParsedRelease, _METADATA_EXTENSIONS, _NON_VIDEO_EXTENSIONS, _VIDEO_EXTENSIONS, + ParsedRelease, ) diff --git a/alfred/application/filesystem/dto.py b/alfred/application/filesystem/dto.py index 743e336..3f238bc 100644 --- a/alfred/application/filesystem/dto.py +++ b/alfred/application/filesystem/dto.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass @dataclass @@ -88,7 +88,11 @@ class PlacedSubtitle: filename: str def to_dict(self) -> dict: - return {"source": self.source, "destination": self.destination, "filename": self.filename} + return { + "source": self.source, + "destination": self.destination, + "filename": self.filename, + } @dataclass @@ -98,7 +102,7 @@ class UnresolvedTrack: raw_tokens: list[str] file_path: str | None = None file_size_kb: float | None = None - reason: str = "" # "unknown_language" | "low_confidence" + reason: str = "" # "unknown_language" | "low_confidence" def to_dict(self) -> dict: return { @@ -113,8 +117,8 @@ class UnresolvedTrack: class AvailableSubtitle: """One subtitle track available on an embedded media item.""" - language: str # ISO 639-2 code - subtitle_type: str # "standard" | "sdh" | "forced" | "unknown" + language: str # ISO 639-2 code + subtitle_type: str # "standard" | "sdh" | "forced" | "unknown" def to_dict(self) -> dict: return {"language": self.language, "type": self.subtitle_type} @@ -124,12 +128,12 @@ class AvailableSubtitle: class ManageSubtitlesResponse: """Response from the manage_subtitles use case.""" - status: str # "ok" | "needs_clarification" | "error" + status: str # "ok" | "needs_clarification" | "error" video_path: str | None = None placed: list[PlacedSubtitle] | None = None skipped_count: int = 0 unresolved: list[UnresolvedTrack] | None = None - available: list[AvailableSubtitle] | None = None # embedded tracks summary + available: list[AvailableSubtitle] | None = None # embedded tracks summary error: str | None = None message: str | None = None diff --git a/alfred/application/filesystem/enrich_from_probe.py b/alfred/application/filesystem/enrich_from_probe.py index 2b97609..92a669a 100644 --- a/alfred/application/filesystem/enrich_from_probe.py +++ b/alfred/application/filesystem/enrich_from_probe.py @@ -10,21 +10,21 @@ _VIDEO_CODEC_MAP = { "hevc": "x265", "h264": "x264", "h265": "x265", - "av1": "AV1", - "vp9": "VP9", + "av1": "AV1", + "vp9": "VP9", "mpeg4": "XviD", } # Map ffprobe audio codec names to scene-style tokens _AUDIO_CODEC_MAP = { - "eac3": "EAC3", - "ac3": "AC3", - "dts": "DTS", - "truehd": "TrueHD", - "aac": "AAC", - "flac": "FLAC", - "opus": "OPUS", - "mp3": "MP3", + "eac3": "EAC3", + "ac3": "AC3", + "dts": "DTS", + "truehd": "TrueHD", + "aac": "AAC", + "flac": "FLAC", + "opus": "OPUS", + "mp3": "MP3", "pcm_s16l": "PCM", "pcm_s24l": "PCM", } @@ -50,7 +50,9 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None: parsed.quality = info.resolution if parsed.codec is None and info.video_codec: - parsed.codec = _VIDEO_CODEC_MAP.get(info.video_codec.lower(), info.video_codec.upper()) + parsed.codec = _VIDEO_CODEC_MAP.get( + info.video_codec.lower(), info.video_codec.upper() + ) if parsed.bit_depth is None and info.video_codec: # ffprobe exposes bit depth via pix_fmt — not in MediaInfo yet, skip for now @@ -62,10 +64,14 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None: if track: if parsed.audio_codec is None and track.codec: - parsed.audio_codec = _AUDIO_CODEC_MAP.get(track.codec.lower(), track.codec.upper()) + parsed.audio_codec = _AUDIO_CODEC_MAP.get( + track.codec.lower(), track.codec.upper() + ) if parsed.audio_channels is None and track.channels: - parsed.audio_channels = _CHANNEL_MAP.get(track.channels, f"{track.channels}ch") + parsed.audio_channels = _CHANNEL_MAP.get( + track.channels, f"{track.channels}ch" + ) # Languages — merge ffprobe languages with token-level ones # "und" = undetermined, not useful diff --git a/alfred/application/filesystem/manage_subtitles.py b/alfred/application/filesystem/manage_subtitles.py index 473f905..477c391 100644 --- a/alfred/application/filesystem/manage_subtitles.py +++ b/alfred/application/filesystem/manage_subtitles.py @@ -17,7 +17,12 @@ from alfred.infrastructure.persistence.context import get_memory from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore from alfred.infrastructure.subtitle.rule_repository import RuleSetRepository -from .dto import AvailableSubtitle, ManageSubtitlesResponse, PlacedSubtitle, UnresolvedTrack +from .dto import ( + AvailableSubtitle, + ManageSubtitlesResponse, + PlacedSubtitle, + UnresolvedTrack, +) logger = logging.getLogger(__name__) @@ -69,11 +74,12 @@ class ManageSubtitlesUseCase: season: int | None = None, episode: int | None = None, confirmed_pattern_id: str | None = None, + dry_run: bool = False, ) -> ManageSubtitlesResponse: source_path = Path(source_video) dest_path = Path(destination_video) - if not source_path.exists(): + if not source_path.exists() and not source_path.parent.exists(): return ManageSubtitlesResponse( status="error", error="source_not_found", @@ -108,7 +114,9 @@ class ManageSubtitlesUseCase: ) if metadata.total_count == 0: - logger.info(f"ManageSubtitles: no subtitle tracks found for {source_path.name}") + logger.info( + f"ManageSubtitles: no subtitle tracks found for {source_path.name}" + ) return ManageSubtitlesResponse( status="ok", video_path=destination_video, @@ -164,6 +172,32 @@ class ManageSubtitlesUseCase: skipped_count=metadata.total_count, ) + # --- Dry run: skip placement --- + if dry_run: + from alfred.domain.subtitles.services.placer import _build_dest_name + + placed_dtos = [] + for t in matched: + if not t.file_path: + continue + try: + filename = _build_dest_name(t, dest_path.stem) + except ValueError: + continue + placed_dtos.append( + PlacedSubtitle( + source=str(t.file_path), + destination=str(dest_path.parent / filename), + filename=filename, + ) + ) + return ManageSubtitlesResponse( + status="ok", + video_path=destination_video, + placed=placed_dtos, + skipped_count=0, + ) + # --- Place --- placer = SubtitlePlacer() place_result = placer.place(matched, dest_path) @@ -229,7 +263,9 @@ class ManageSubtitlesUseCase: return kb.pattern("adjacent") -def _to_unresolved_dto(track: SubtitleTrack, min_confidence: float = 0.7) -> UnresolvedTrack: +def _to_unresolved_dto( + track: SubtitleTrack, min_confidence: float = 0.7 +) -> UnresolvedTrack: reason = "unknown_language" if track.language is None else "low_confidence" return UnresolvedTrack( raw_tokens=track.raw_tokens, @@ -252,7 +288,7 @@ def _pair_placed_with_tracks( for p in placed: track = track_by_path.get(p.source) if track is None and tracks: - track = tracks[0] # positional fallback + track = tracks[0] # positional fallback if track: pairs.append((p, track)) return pairs diff --git a/alfred/application/filesystem/resolve_destination.py b/alfred/application/filesystem/resolve_destination.py index 0f7335f..9230419 100644 --- a/alfred/application/filesystem/resolve_destination.py +++ b/alfred/application/filesystem/resolve_destination.py @@ -1,58 +1,82 @@ """ -ResolveDestinationUseCase — compute the library destination path for a release. +Destination resolution — compute library paths for releases. -Steps: -1. Parse the release name -2. Look up TMDB for title + year (+ episode title if single episode) -3. Scan the library for an existing series folder -4. Apply group-conflict rules -5. Return the computed paths (or needs_clarification if ambiguous) +Four distinct use cases, one per release type: +- resolve_season_destination : season pack (folder move) +- resolve_episode_destination : single episode (file move) +- resolve_movie_destination : movie (file move) +- resolve_series_destination : complete series multi-season pack (folder move) + +Each returns a dedicated DTO with only the fields that make sense for that type. """ from __future__ import annotations import logging import re -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from alfred.domain.release import ParsedRelease, parse_release +from alfred.domain.release import parse_release from alfred.infrastructure.persistence import get_memory logger = logging.getLogger(__name__) -# Characters forbidden on Windows filesystems (served via NFS) _WIN_FORBIDDEN = re.compile(r'[?:*"<>|\\]') -def _sanitise(text: str) -> str: +def _sanitize(text: str) -> str: return _WIN_FORBIDDEN.sub("", text) +def _find_existing_tvshow_folders( + tv_root: Path, tmdb_title: str, tmdb_year: int +) -> list[str]: + """Return folder names in tv_root that match title + year prefix.""" + if not tv_root.exists(): + return [] + clean_title = _sanitize(tmdb_title).replace(" ", ".") + prefix = f"{clean_title}.{tmdb_year}".lower() + return sorted( + entry.name + for entry in tv_root.iterdir() + if entry.is_dir() and entry.name.lower().startswith(prefix) + ) + + +def _get_tv_root() -> Path | None: + memory = get_memory() + tv_root = memory.ltm.library_paths.get("tv_show") + return Path(tv_root) if tv_root else None + + # --------------------------------------------------------------------------- # DTOs # --------------------------------------------------------------------------- + @dataclass -class ResolvedDestination: - """All computed paths for a release, ready to hand to move_media.""" +class ResolvedSeasonDestination: + """Paths for a season pack — folder move, no individual file paths.""" - status: str # "ok" | "needs_clarification" | "error" + status: str # "ok" | "needs_clarification" | "error" - # Populated on "ok" - library_file: str | None = None # absolute path of the destination video file - series_folder: str | None = None # absolute path of the series root folder - season_folder: str | None = None # absolute path of the season subfolder - series_folder_name: str | None = None # just the folder name (for display) + # ok + series_folder: str | None = ( + None # /tv_shows/A.Knight.of.the.Seven.Kingdoms.2024.1080p.WEBRip.x265-KONTRAST + ) + season_folder: str | None = ( + None # .../A.Knight.of.the.Seven.Kingdoms.S01.1080p.WEBRip.x265-KONTRAST + ) + series_folder_name: str | None = None season_folder_name: str | None = None - filename: str | None = None - is_new_series_folder: bool = False # True if we're creating the folder + is_new_series_folder: bool = False - # Populated on "needs_clarification" + # needs_clarification question: str | None = None - options: list[str] | None = None # existing group folder names to pick from + options: list[str] | None = None - # Populated on "error" + # error error: str | None = None message: str | None = None @@ -67,189 +91,366 @@ class ResolvedDestination: } return { "status": self.status, - "library_file": self.library_file, "series_folder": self.series_folder, "season_folder": self.season_folder, "series_folder_name": self.series_folder_name, "season_folder_name": self.season_folder_name, + "is_new_series_folder": self.is_new_series_folder, + } + + +@dataclass +class ResolvedEpisodeDestination: + """Paths for a single episode — file move.""" + + status: str + + # ok + series_folder: str | None = None + season_folder: str | None = None + library_file: str | None = None # full path to destination .mkv + series_folder_name: str | None = None + season_folder_name: str | None = None + filename: str | None = None + is_new_series_folder: bool = False + + # needs_clarification + question: str | None = None + options: list[str] | None = None + + # error + error: str | None = None + message: str | None = None + + def to_dict(self) -> dict: + if self.status == "error": + return {"status": self.status, "error": self.error, "message": self.message} + if self.status == "needs_clarification": + return { + "status": self.status, + "question": self.question, + "options": self.options or [], + } + return { + "status": self.status, + "series_folder": self.series_folder, + "season_folder": self.season_folder, + "library_file": self.library_file, + "series_folder_name": self.series_folder_name, + "season_folder_name": self.season_folder_name, "filename": self.filename, "is_new_series_folder": self.is_new_series_folder, } +@dataclass +class ResolvedMovieDestination: + """Paths for a movie — file move.""" + + status: str + + # ok + movie_folder: str | None = None + library_file: str | None = None + movie_folder_name: str | None = None + filename: str | None = None + is_new_folder: bool = False + + # needs_clarification + question: str | None = None + options: list[str] | None = None + + # error + error: str | None = None + message: str | None = None + + def to_dict(self) -> dict: + if self.status == "error": + return {"status": self.status, "error": self.error, "message": self.message} + if self.status == "needs_clarification": + return { + "status": self.status, + "question": self.question, + "options": self.options or [], + } + return { + "status": self.status, + "movie_folder": self.movie_folder, + "library_file": self.library_file, + "movie_folder_name": self.movie_folder_name, + "filename": self.filename, + "is_new_folder": self.is_new_folder, + } + + +@dataclass +class ResolvedSeriesDestination: + """Paths for a complete multi-season series pack — folder move.""" + + status: str + + # ok + series_folder: str | None = None + series_folder_name: str | None = None + is_new_series_folder: bool = False + + # needs_clarification + question: str | None = None + options: list[str] | None = None + + # error + error: str | None = None + message: str | None = None + + def to_dict(self) -> dict: + if self.status == "error": + return {"status": self.status, "error": self.error, "message": self.message} + if self.status == "needs_clarification": + return { + "status": self.status, + "question": self.question, + "options": self.options or [], + } + return { + "status": self.status, + "series_folder": self.series_folder, + "series_folder_name": self.series_folder_name, + "is_new_series_folder": self.is_new_series_folder, + } + + # --------------------------------------------------------------------------- -# Use case +# Use cases # --------------------------------------------------------------------------- -class ResolveDestinationUseCase: + +def resolve_season_destination( + release_name: str, + tmdb_title: str, + tmdb_year: int, + confirmed_folder: str | None = None, +) -> ResolvedSeasonDestination: """ - Compute the full destination path for a media file being organised. + Compute destination paths for a season pack. - The caller provides: - - release_name: the raw release folder/file name - - source_file: path to the actual video file (to get extension) - - tmdb_title: canonical title from TMDB - - tmdb_year: release year from TMDB - - tmdb_episode_title: episode title from TMDB (None for movies / season packs) - - confirmed_folder: if the user already answered needs_clarification, pass - the chosen folder name here to skip the check - - Returns a ResolvedDestination. + Returns series_folder + season_folder. No file paths — the whole + source folder is moved as-is into season_folder. """ - - def execute( - self, - release_name: str, - source_file: str, - tmdb_title: str, - tmdb_year: int, - tmdb_episode_title: str | None = None, - confirmed_folder: str | None = None, - ) -> ResolvedDestination: - parsed = parse_release(release_name) - ext = Path(source_file).suffix # ".mkv" - - if parsed.media_type == "movie": - return self._resolve_movie(parsed, tmdb_title, tmdb_year, ext) - if parsed.media_type == "tv_show": - return self._resolve_tvshow( - parsed, tmdb_title, tmdb_year, tmdb_episode_title, ext, confirmed_folder - ) - return ResolvedDestination( + tv_root = _get_tv_root() + if not tv_root: + return ResolvedSeasonDestination( status="error", - error="unsupported_media_type", - message=( - f"Cannot organize '{release_name}': detected as '{parsed.media_type}'. " - "Only movies and TV shows are supported." - ), + error="library_not_set", + message="TV show library path is not configured.", ) - # ------------------------------------------------------------------ - # Movie - # ------------------------------------------------------------------ + parsed = parse_release(release_name) + computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year)) - def _resolve_movie( - self, parsed: ParsedRelease, tmdb_title: str, tmdb_year: int, ext: str - ) -> ResolvedDestination: - memory = get_memory() - movies_root = memory.ltm.library_paths.get("movie") - if not movies_root: - return ResolvedDestination( - status="error", - error="library_not_set", - message="Movie library path is not configured.", - ) + if confirmed_folder: + series_folder_name = confirmed_folder + is_new = not (tv_root / confirmed_folder).exists() + else: + existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year) - folder_name = _sanitise(parsed.movie_folder_name(tmdb_title, tmdb_year)) - filename = _sanitise(parsed.movie_filename(tmdb_title, tmdb_year, ext)) - - folder_path = Path(movies_root) / folder_name - file_path = folder_path / filename - - return ResolvedDestination( - status="ok", - library_file=str(file_path), - series_folder=str(folder_path), - series_folder_name=folder_name, - filename=filename, - is_new_series_folder=not folder_path.exists(), - ) - - # ------------------------------------------------------------------ - # TV show - # ------------------------------------------------------------------ - - def _resolve_tvshow( - self, - parsed: ParsedRelease, - tmdb_title: str, - tmdb_year: int, - tmdb_episode_title: str | None, - ext: str, - confirmed_folder: str | None, - ) -> ResolvedDestination: - memory = get_memory() - tv_root = memory.ltm.library_paths.get("tv_show") - if not tv_root: - return ResolvedDestination( - status="error", - error="library_not_set", - message="TV show library path is not configured.", - ) - - tv_root_path = Path(tv_root) - - # --- Find existing series folders for this title --- - existing = _find_existing_series_folders(tv_root_path, tmdb_title, tmdb_year) - - # --- Determine series folder name --- - if confirmed_folder: - series_folder_name = confirmed_folder - is_new = not (tv_root_path / confirmed_folder).exists() - elif len(existing) == 0: - # No existing folder — create with release group - series_folder_name = _sanitise(parsed.show_folder_name(tmdb_title, tmdb_year)) + if len(existing) == 0: + series_folder_name = computed_name is_new = True - elif len(existing) == 1: - # Exactly one match — use it regardless of group + elif len(existing) == 1 and existing[0] == computed_name: + # Exact match — use it series_folder_name = existing[0] is_new = False else: - # Multiple folders — ask user - return ResolvedDestination( + # One folder with a different name, or multiple — ask user + options = existing + ( + [computed_name] if computed_name not in existing else [] + ) + return ResolvedSeasonDestination( status="needs_clarification", question=( - f"Multiple folders found for '{tmdb_title}' in your library. " - f"Which one should I use for this release ({parsed.group})?" + f"Un dossier série existe déjà pour '{tmdb_title}' " + f"mais son nom diffère du nom calculé ({computed_name}). " + f"Lequel utiliser ?" ), - options=existing, + options=options, ) - # --- Build paths --- - season_folder_name = parsed.season_folder_name() - filename = _sanitise( - parsed.episode_filename(tmdb_episode_title, ext) - if not parsed.is_season_pack - else parsed.season_folder_name() + ext + season_folder_name = parsed.season_folder_name() + series_path = tv_root / series_folder_name + season_path = series_path / season_folder_name + + return ResolvedSeasonDestination( + status="ok", + series_folder=str(series_path), + season_folder=str(season_path), + series_folder_name=series_folder_name, + season_folder_name=season_folder_name, + is_new_series_folder=is_new, + ) + + +def resolve_episode_destination( + release_name: str, + source_file: str, + tmdb_title: str, + tmdb_year: int, + tmdb_episode_title: str | None = None, + confirmed_folder: str | None = None, +) -> ResolvedEpisodeDestination: + """ + Compute destination paths for a single episode file. + + Returns series_folder + season_folder + library_file (full path to .mkv). + """ + tv_root = _get_tv_root() + if not tv_root: + return ResolvedEpisodeDestination( + status="error", + error="library_not_set", + message="TV show library path is not configured.", ) - series_path = tv_root_path / series_folder_name - season_path = series_path / season_folder_name - file_path = season_path / filename + parsed = parse_release(release_name) + ext = Path(source_file).suffix + computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year)) - return ResolvedDestination( - status="ok", - library_file=str(file_path), - series_folder=str(series_path), - season_folder=str(season_path), - series_folder_name=series_folder_name, - season_folder_name=season_folder_name, - filename=filename, - is_new_series_folder=is_new, + if confirmed_folder: + series_folder_name = confirmed_folder + is_new = not (tv_root / confirmed_folder).exists() + else: + existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year) + + if len(existing) == 0: + series_folder_name = computed_name + is_new = True + elif len(existing) == 1 and existing[0] == computed_name: + series_folder_name = existing[0] + is_new = False + else: + options = existing + ( + [computed_name] if computed_name not in existing else [] + ) + return ResolvedEpisodeDestination( + status="needs_clarification", + question=( + f"Un dossier série existe déjà pour '{tmdb_title}' " + f"mais son nom diffère du nom calculé ({computed_name}). " + f"Lequel utiliser ?" + ), + options=options, + ) + + season_folder_name = parsed.season_folder_name() + filename = _sanitize(parsed.episode_filename(tmdb_episode_title, ext)) + + series_path = tv_root / series_folder_name + season_path = series_path / season_folder_name + file_path = season_path / filename + + return ResolvedEpisodeDestination( + status="ok", + series_folder=str(series_path), + season_folder=str(season_path), + library_file=str(file_path), + series_folder_name=series_folder_name, + season_folder_name=season_folder_name, + filename=filename, + is_new_series_folder=is_new, + ) + + +def resolve_movie_destination( + release_name: str, + source_file: str, + tmdb_title: str, + tmdb_year: int, +) -> ResolvedMovieDestination: + """ + Compute destination paths for a movie file. + + Returns movie_folder + library_file (full path to .mkv). + """ + memory = get_memory() + movies_root = memory.ltm.library_paths.get("movie") + if not movies_root: + return ResolvedMovieDestination( + status="error", + error="library_not_set", + message="Movie library path is not configured.", ) + parsed = parse_release(release_name) + ext = Path(source_file).suffix -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- + folder_name = _sanitize(parsed.movie_folder_name(tmdb_title, tmdb_year)) + filename = _sanitize(parsed.movie_filename(tmdb_title, tmdb_year, ext)) -def _find_existing_series_folders(tv_root: Path, tmdb_title: str, tmdb_year: int) -> list[str]: + folder_path = Path(movies_root) / folder_name + file_path = folder_path / filename + + return ResolvedMovieDestination( + status="ok", + movie_folder=str(folder_path), + library_file=str(file_path), + movie_folder_name=folder_name, + filename=filename, + is_new_folder=not folder_path.exists(), + ) + + +def resolve_series_destination( + release_name: str, + tmdb_title: str, + tmdb_year: int, + confirmed_folder: str | None = None, +) -> ResolvedSeriesDestination: """ - Return names of folders in tv_root that match the given title + year. + Compute destination path for a complete multi-season series pack. - Matching is loose: normalised title (dots, no special chars) + year must - appear at the start of the folder name. + Returns only series_folder — the whole pack lands directly inside it. """ - if not tv_root.exists(): - return [] + tv_root = _get_tv_root() + if not tv_root: + return ResolvedSeriesDestination( + status="error", + error="library_not_set", + message="TV show library path is not configured.", + ) - # Build a normalised prefix to match against: "Oz.1997" - clean_title = _sanitise(tmdb_title).replace(" ", ".") - prefix = f"{clean_title}.{tmdb_year}".lower() + parsed = parse_release(release_name) + computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year)) - matches = [] - for entry in tv_root.iterdir(): - if entry.is_dir() and entry.name.lower().startswith(prefix): - matches.append(entry.name) + if confirmed_folder: + series_folder_name = confirmed_folder + is_new = not (tv_root / confirmed_folder).exists() + else: + existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year) - return sorted(matches) + if len(existing) == 0: + series_folder_name = computed_name + is_new = True + elif len(existing) == 1 and existing[0] == computed_name: + series_folder_name = existing[0] + is_new = False + else: + options = existing + ( + [computed_name] if computed_name not in existing else [] + ) + return ResolvedSeriesDestination( + status="needs_clarification", + question=( + f"Un dossier série existe déjà pour '{tmdb_title}' " + f"mais son nom diffère du nom calculé ({computed_name}). " + f"Lequel utiliser ?" + ), + options=options, + ) + + series_path = tv_root / series_folder_name + + return ResolvedSeriesDestination( + status="ok", + series_folder=str(series_path), + series_folder_name=series_folder_name, + is_new_series_folder=is_new, + ) diff --git a/alfred/domain/movies/value_objects.py b/alfred/domain/movies/value_objects.py index 235b632..8a350d3 100644 --- a/alfred/domain/movies/value_objects.py +++ b/alfred/domain/movies/value_objects.py @@ -17,7 +17,7 @@ class Quality(Enum): UNKNOWN = "unknown" @classmethod - def from_string(cls, quality_str: str) -> "Quality": + def from_string(cls, quality_str: str) -> Quality: """ Parse quality from string. diff --git a/alfred/domain/release/knowledge.py b/alfred/domain/release/knowledge.py index 4f6dd5c..9c5ffd1 100644 --- a/alfred/domain/release/knowledge.py +++ b/alfred/domain/release/knowledge.py @@ -10,12 +10,15 @@ Lists are extended additively, scalars from higher layers win. from pathlib import Path -import alfred as _alfred_pkg import yaml +import alfred as _alfred_pkg + _BUILTIN_ROOT = Path(_alfred_pkg.__file__).parent / "knowledge" / "release" -_SITES_ROOT = _BUILTIN_ROOT / "sites" -_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release" +_SITES_ROOT = _BUILTIN_ROOT / "sites" +_LEARNED_ROOT = ( + Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release" +) def _merge(base: dict, overlay: dict) -> dict: diff --git a/alfred/domain/release/services.py b/alfred/domain/release/services.py index fde6aa4..ce83597 100644 --- a/alfred/domain/release/services.py +++ b/alfred/domain/release/services.py @@ -3,7 +3,6 @@ from __future__ import annotations from .value_objects import ( - ParsedRelease, _AUDIO, _CODECS, _EDITIONS, @@ -13,9 +12,8 @@ from .value_objects import ( _MEDIA_TYPE_TOKENS, _RESOLUTIONS, _SOURCES, - _VIDEO_EXTENSIONS, _VIDEO_META, - _NON_VIDEO_EXTENSIONS, + ParsedRelease, ) @@ -58,16 +56,18 @@ def parse_release(name: str) -> ParsedRelease: season, episode, episode_end = _extract_season_episode(tokens) quality, source, codec, group, tech_tokens = _extract_tech(tokens) - languages, lang_tokens = _extract_languages(tokens) + languages, lang_tokens = _extract_languages(tokens) audio_codec, audio_channels, audio_tokens = _extract_audio(tokens) - bit_depth, hdr_format, video_tokens = _extract_video_meta(tokens) - edition, edition_tokens = _extract_edition(tokens) + bit_depth, hdr_format, video_tokens = _extract_video_meta(tokens) + edition, edition_tokens = _extract_edition(tokens) title = _extract_title( tokens, tech_tokens | lang_tokens | audio_tokens | video_tokens | edition_tokens, ) year = _extract_year(tokens, title) - media_type = _infer_media_type(season, quality, source, codec, year, edition, tokens) + media_type = _infer_media_type( + season, quality, source, codec, year, edition, tokens + ) tech_parts = [p for p in [quality, source, codec] if p] tech_string = ".".join(tech_parts) @@ -118,7 +118,7 @@ def _infer_media_type( """ upper_tokens = {t.upper() for t in tokens} - doc_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("doc", [])} + doc_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("doc", [])} concert_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("concert", [])} integrale_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("integrale", [])} @@ -126,7 +126,10 @@ def _infer_media_type( return "documentary" if upper_tokens & concert_tokens: return "concert" - if (edition in {"COMPLETE", "INTEGRALE", "COLLECTION"} or upper_tokens & integrale_tokens) and season is None: + if ( + edition in {"COMPLETE", "INTEGRALE", "COLLECTION"} + or upper_tokens & integrale_tokens + ) and season is None: return "tv_complete" if season is not None: return "tv_show" @@ -172,14 +175,14 @@ def _strip_site_tag(name: str) -> tuple[str, str | None]: close = s.find("]") if close != -1: tag = s[1:close].strip() - remainder = s[close + 1:].strip() + remainder = s[close + 1 :].strip() if tag and remainder: return remainder, tag if s.endswith("]"): open_bracket = s.rfind("[") if open_bracket != -1: - tag = s[open_bracket + 1:-1].strip() + tag = s[open_bracket + 1 : -1].strip() remainder = s[:open_bracket].strip() if tag and remainder: return remainder, tag @@ -226,7 +229,9 @@ def _parse_season_episode(tok: str) -> tuple[int, int | None, int | None] | None return season, episode, episode_end -def _extract_season_episode(tokens: list[str]) -> tuple[int | None, int | None, int | None]: +def _extract_season_episode( + tokens: list[str], +) -> tuple[int | None, int | None, int | None]: for tok in tokens: parsed = _parse_season_episode(tok) if parsed is not None: @@ -333,6 +338,7 @@ def _extract_year(tokens: list[str], title: str) -> int | None: # Sequence matcher # --------------------------------------------------------------------------- + def _match_sequences( tokens: list[str], sequences: list[dict], @@ -349,8 +355,8 @@ def _match_sequences( seq_upper = [s.upper() for s in seq["tokens"]] n = len(seq_upper) for i in range(len(upper_tokens) - n + 1): - if upper_tokens[i:i + n] == seq_upper: - matched = set(tokens[i:i + n]) + if upper_tokens[i : i + n] == seq_upper: + matched = set(tokens[i : i + n]) return seq[key], matched return None, set() @@ -359,6 +365,7 @@ def _match_sequences( # Language extraction # --------------------------------------------------------------------------- + def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]: """Extract language tokens. Returns (languages, matched_token_set).""" languages = [] @@ -374,6 +381,7 @@ def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]: # Audio extraction # --------------------------------------------------------------------------- + def _extract_audio( tokens: list[str], ) -> tuple[str | None, str | None, set[str]]: @@ -391,7 +399,9 @@ def _extract_audio( known_channels = set(_AUDIO.get("channels", [])) # Try multi-token sequences first - matched_codec, matched_set = _match_sequences(tokens, _AUDIO.get("sequences", []), "codec") + matched_codec, matched_set = _match_sequences( + tokens, _AUDIO.get("sequences", []), "codec" + ) if matched_codec: audio_codec = matched_codec audio_tokens |= matched_set @@ -424,6 +434,7 @@ def _extract_audio( # Video metadata extraction (bit depth, HDR) # --------------------------------------------------------------------------- + def _extract_video_meta( tokens: list[str], ) -> tuple[str | None, str | None, set[str]]: @@ -440,7 +451,9 @@ def _extract_video_meta( known_depth = {d.lower() for d in _VIDEO_META.get("bit_depth", [])} # Try HDR sequences first - matched_hdr, matched_set = _match_sequences(tokens, _VIDEO_META.get("sequences", []), "hdr") + matched_hdr, matched_set = _match_sequences( + tokens, _VIDEO_META.get("sequences", []), "hdr" + ) if matched_hdr: hdr_format = matched_hdr video_tokens |= matched_set @@ -462,6 +475,7 @@ def _extract_video_meta( # Edition extraction # --------------------------------------------------------------------------- + def _extract_edition(tokens: list[str]) -> tuple[str | None, set[str]]: """ Extract release edition (UNRATED, EXTENDED, DIRECTORS.CUT, …). diff --git a/alfred/domain/release/value_objects.py b/alfred/domain/release/value_objects.py index a56fc8e..83af56c 100644 --- a/alfred/domain/release/value_objects.py +++ b/alfred/domain/release/value_objects.py @@ -74,27 +74,31 @@ _strip_episode_from_normalised = _strip_episode_from_normalized class ParsedRelease: """Structured representation of a parsed release name.""" - raw: str # original release name (untouched) - normalised: str # dots instead of spaces - title: str # show/movie title (dots, no year/season/tech) - year: int | None # movie year or show start year (from TMDB) - season: int | None # season number (None for movies) - episode: int | None # first episode number (None if season-pack) - episode_end: int | None # last episode for multi-ep (None otherwise) - quality: str | None # 1080p, 2160p, … - source: str | None # WEBRip, BluRay, … - codec: str | None # x265, HEVC, … - group: str # release group, "UNKNOWN" if missing - tech_string: str # quality.source.codec joined with dots - media_type: str = "unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown" - site_tag: str | None = None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc" - parse_path: str = "direct" # "direct" | "sanitized" | "ai" - languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], … - audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", … - audio_channels: str | None = None # "5.1", "7.1", "2.0", … - bit_depth: str | None = None # "10bit", "8bit", … - hdr_format: str | None = None # "DV", "HDR10", "DV.HDR10", … - edition: str | None = None # "UNRATED", "EXTENDED", "DIRECTORS.CUT", … + raw: str # original release name (untouched) + normalised: str # dots instead of spaces + title: str # show/movie title (dots, no year/season/tech) + year: int | None # movie year or show start year (from TMDB) + season: int | None # season number (None for movies) + episode: int | None # first episode number (None if season-pack) + episode_end: int | None # last episode for multi-ep (None otherwise) + quality: str | None # 1080p, 2160p, … + source: str | None # WEBRip, BluRay, … + codec: str | None # x265, HEVC, … + group: str # release group, "UNKNOWN" if missing + tech_string: str # quality.source.codec joined with dots + media_type: str = ( + "unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown" + ) + site_tag: str | None = ( + None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc" + ) + parse_path: str = "direct" # "direct" | "sanitized" | "ai" + languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], … + audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", … + audio_channels: str | None = None # "5.1", "7.1", "2.0", … + bit_depth: str | None = None # "10bit", "8bit", … + hdr_format: str | None = None # "DV", "HDR10", "DV.HDR10", … + edition: str | None = None # "UNRATED", "EXTENDED", "DIRECTORS.CUT", … def __post_init__(self): if self.languages is None: diff --git a/alfred/domain/shared/media_info.py b/alfred/domain/shared/media_info.py index f69bf47..328ad97 100644 --- a/alfred/domain/shared/media_info.py +++ b/alfred/domain/shared/media_info.py @@ -10,10 +10,10 @@ class AudioTrack: """A single audio track as reported by ffprobe.""" index: int - codec: str | None # aac, ac3, eac3, dts, truehd, flac, … - channels: int | None # 2, 6 (5.1), 8 (7.1), … + codec: str | None # aac, ac3, eac3, dts, truehd, flac, … + channels: int | None # 2, 6 (5.1), 8 (7.1), … channel_layout: str | None # stereo, 5.1, 7.1, … - language: str | None # ISO 639-2: fre, eng, und, … + language: str | None # ISO 639-2: fre, eng, und, … is_default: bool = False @@ -22,8 +22,8 @@ class SubtitleTrack: """A single subtitle track as reported by ffprobe.""" index: int - codec: str | None # subrip, ass, hdmv_pgs_subtitle, … - language: str | None # ISO 639-2: fre, eng, und, … + codec: str | None # subrip, ass, hdmv_pgs_subtitle, … + language: str | None # ISO 639-2: fre, eng, und, … is_default: bool = False is_forced: bool = False @@ -39,7 +39,7 @@ class MediaInfo: # Video width: int | None = None height: int | None = None - video_codec: str | None = None # h264, hevc, av1, … + video_codec: str | None = None # h264, hevc, av1, … duration_seconds: float | None = None bitrate_kbps: int | None = None @@ -63,20 +63,32 @@ class MediaInfo: return None case (w, h) if w is not None: match True: - case _ if w >= 3840: return "2160p" - case _ if w >= 1920: return "1080p" - case _ if w >= 1280: return "720p" - case _ if w >= 720: return "576p" - case _ if w >= 640: return "480p" - case _: return f"{h}p" if h else f"{w}w" + case _ if w >= 3840: + return "2160p" + case _ if w >= 1920: + return "1080p" + case _ if w >= 1280: + return "720p" + case _ if w >= 720: + return "576p" + case _ if w >= 640: + return "480p" + case _: + return f"{h}p" if h else f"{w}w" case (None, h): match True: - case _ if h >= 2160: return "2160p" - case _ if h >= 1080: return "1080p" - case _ if h >= 720: return "720p" - case _ if h >= 576: return "576p" - case _ if h >= 480: return "480p" - case _: return f"{h}p" + case _ if h >= 2160: + return "2160p" + case _ if h >= 1080: + return "1080p" + case _ if h >= 720: + return "720p" + case _ if h >= 576: + return "576p" + case _ if h >= 480: + return "480p" + case _: + return f"{h}p" @property def audio_languages(self) -> list[str]: diff --git a/alfred/domain/subtitles/aggregates.py b/alfred/domain/subtitles/aggregates.py index 26541ab..81e664e 100644 --- a/alfred/domain/subtitles/aggregates.py +++ b/alfred/domain/subtitles/aggregates.py @@ -26,7 +26,7 @@ class SubtitleRuleSet: """ scope: RuleScope - parent: "SubtitleRuleSet | None" = None + parent: SubtitleRuleSet | None = None pinned_to: ImdbId | None = None # Deltas — None = inherit @@ -47,7 +47,9 @@ class SubtitleRuleSet: preferred_formats=self._formats or base.preferred_formats, allowed_types=self._types or base.allowed_types, format_priority=self._format_priority or base.format_priority, - min_confidence=self._min_confidence if self._min_confidence is not None else base.min_confidence, + min_confidence=self._min_confidence + if self._min_confidence is not None + else base.min_confidence, ) def override( @@ -83,8 +85,11 @@ class SubtitleRuleSet: delta["format_priority"] = self._format_priority if self._min_confidence is not None: delta["min_confidence"] = self._min_confidence - return {"scope": {"level": self.scope.level, "identifier": self.scope.identifier}, "override": delta} + return { + "scope": {"level": self.scope.level, "identifier": self.scope.identifier}, + "override": delta, + } @classmethod - def global_default(cls) -> "SubtitleRuleSet": + def global_default(cls) -> SubtitleRuleSet: return cls(scope=RuleScope(level="global")) diff --git a/alfred/domain/subtitles/entities.py b/alfred/domain/subtitles/entities.py index 96e5a1d..969e639 100644 --- a/alfred/domain/subtitles/entities.py +++ b/alfred/domain/subtitles/entities.py @@ -4,7 +4,11 @@ from dataclasses import dataclass, field from pathlib import Path from ..shared.value_objects import ImdbId -from .value_objects import SubtitleFormat, SubtitleLanguage, SubtitleMatchingRules, SubtitleType +from .value_objects import ( + SubtitleFormat, + SubtitleLanguage, + SubtitleType, +) @dataclass @@ -23,13 +27,15 @@ class SubtitleTrack: # Source is_embedded: bool = False - file_path: Path | None = None # None if embedded + file_path: Path | None = None # None if embedded file_size_kb: float | None = None - entry_count: int | None = None # number of subtitle cues in the file + entry_count: int | None = None # number of subtitle cues in the file # Matching state - confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded - raw_tokens: list[str] = field(default_factory=list) # tokens extracted from filename + confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded + raw_tokens: list[str] = field( + default_factory=list + ) # tokens extracted from filename def is_resolved(self) -> bool: return self.language is not None @@ -43,7 +49,9 @@ class SubtitleTrack: {lang}.forced.{ext} """ if not self.language or not self.format: - raise ValueError("Cannot compute destination_name: language or format missing") + raise ValueError( + "Cannot compute destination_name: language or format missing" + ) ext = self.format.extensions[0].lstrip(".") parts = [self.language.code] if self.subtitle_type == SubtitleType.SDH: @@ -55,7 +63,11 @@ class SubtitleTrack: def __repr__(self) -> str: lang = self.language.code if self.language else "?" fmt = self.format.id if self.format else "?" - src = "embedded" if self.is_embedded else str(self.file_path.name if self.file_path else "?") + src = ( + "embedded" + if self.is_embedded + else str(self.file_path.name if self.file_path else "?") + ) return f"SubtitleTrack({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})" @@ -67,7 +79,7 @@ class MediaSubtitleMetadata: """ media_id: ImdbId | None - media_type: str # "movie" | "tv_show" + media_type: str # "movie" | "tv_show" embedded_tracks: list[SubtitleTrack] = field(default_factory=list) external_tracks: list[SubtitleTrack] = field(default_factory=list) release_group: str | None = None diff --git a/alfred/domain/subtitles/knowledge/base.py b/alfred/domain/subtitles/knowledge/base.py index c00e29b..412164a 100644 --- a/alfred/domain/subtitles/knowledge/base.py +++ b/alfred/domain/subtitles/knowledge/base.py @@ -1,7 +1,6 @@ """SubtitleKnowledgeBase — parsed, typed view of the loaded knowledge.""" import logging -from functools import cached_property from ..value_objects import ( ScanStrategy, diff --git a/alfred/domain/subtitles/knowledge/loader.py b/alfred/domain/subtitles/knowledge/loader.py index 96802f6..6528154 100644 --- a/alfred/domain/subtitles/knowledge/loader.py +++ b/alfred/domain/subtitles/knowledge/loader.py @@ -84,7 +84,9 @@ class KnowledgeLoader: data = _load_yaml(path) pid = data.get("id", path.stem) if pid in self._cache["patterns"]: - self._cache["patterns"][pid] = _merge(self._cache["patterns"][pid], data) + self._cache["patterns"][pid] = _merge( + self._cache["patterns"][pid], data + ) else: self._cache["patterns"][pid] = data logger.info(f"KnowledgeLoader: learned new pattern '{pid}'") @@ -100,7 +102,9 @@ class KnowledgeLoader: data = _load_yaml(path) name = data.get("name", path.stem) if name in self._cache["release_groups"]: - self._cache["release_groups"][name] = _merge(self._cache["release_groups"][name], data) + self._cache["release_groups"][name] = _merge( + self._cache["release_groups"][name], data + ) else: self._cache["release_groups"][name] = data logger.info(f"KnowledgeLoader: learned new release group '{name}'") diff --git a/alfred/domain/subtitles/scanner.py b/alfred/domain/subtitles/scanner.py index ba81520..2208737 100644 --- a/alfred/domain/subtitles/scanner.py +++ b/alfred/domain/subtitles/scanner.py @@ -26,7 +26,7 @@ Output naming convention (matches SubtitlePreferences docstring): """ import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path logger = logging.getLogger(__name__) @@ -89,10 +89,10 @@ class SubtitleCandidate: """A subtitle file that passed the filter, ready to be placed.""" source_path: Path - language: str # ISO 639-1 code, e.g. "fr" + language: str # ISO 639-1 code, e.g. "fr" is_sdh: bool is_forced: bool - extension: str # e.g. ".srt" + extension: str # e.g. ".srt" @property def destination_name(self) -> str: @@ -120,6 +120,7 @@ def _classify(path: Path) -> tuple[str | None, bool, bool]: stem = path.stem.lower() # Split on dots, spaces, underscores, hyphens import re + tokens = re.split(r"[\.\s_\-]+", stem) language: str | None = None @@ -147,7 +148,9 @@ class SubtitleScanner: # Each candidate has .source_path and .destination_name """ - def __init__(self, languages: list[str], min_size_kb: int, keep_sdh: bool, keep_forced: bool): + def __init__( + self, languages: list[str], min_size_kb: int, keep_sdh: bool, keep_forced: bool + ): self.languages = [l.lower() for l in languages] self.min_size_kb = min_size_kb self.keep_sdh = keep_sdh @@ -180,7 +183,9 @@ class SubtitleScanner: if candidate is not None: candidates.append(candidate) - logger.info(f"SubtitleScanner: {len(candidates)} candidate(s) found for {video_path.name}") + logger.info( + f"SubtitleScanner: {len(candidates)} candidate(s) found for {video_path.name}" + ) return candidates def _evaluate(self, path: Path) -> SubtitleCandidate | None: @@ -188,7 +193,9 @@ class SubtitleScanner: # Size filter size_kb = path.stat().st_size / 1024 if size_kb < self.min_size_kb: - logger.debug(f"SubtitleScanner: skip {path.name} (too small: {size_kb:.1f} KB)") + logger.debug( + f"SubtitleScanner: skip {path.name} (too small: {size_kb:.1f} KB)" + ) return None language, is_sdh, is_forced = _classify(path) @@ -199,7 +206,9 @@ class SubtitleScanner: return None if language not in self.languages: - logger.debug(f"SubtitleScanner: skip {path.name} (language '{language}' not in prefs)") + logger.debug( + f"SubtitleScanner: skip {path.name} (language '{language}' not in prefs)" + ) return None # SDH filter diff --git a/alfred/domain/subtitles/services/identifier.py b/alfred/domain/subtitles/services/identifier.py index 74d9935..8f32b99 100644 --- a/alfred/domain/subtitles/services/identifier.py +++ b/alfred/domain/subtitles/services/identifier.py @@ -1,9 +1,9 @@ """SubtitleIdentifier — finds and classifies all subtitle tracks for a video file.""" +import json import logging import re import subprocess -import json from pathlib import Path from ...shared.value_objects import ImdbId @@ -15,10 +15,28 @@ logger = logging.getLogger(__name__) def _tokenize(name: str) -> list[str]: - """Split a filename stem into lowercase tokens.""" + """Split a filename stem into lowercase tokens, stripping parentheses.""" + # Strip parenthesized qualifiers like (simplified), (canada), (brazil) + name = re.sub(r"\([^)]*\)", "", name) return [t.lower() for t in re.split(r"[\.\s_\-]+", name) if t] +def _tokenize_suffix(stem: str, episode_stem: str) -> list[str]: + """ + For episode_subfolder pattern: the filename is {episode_stem}.{lang_tokens}. + Return only the tokens that come after the episode stem portion. + Falls back to full tokenization if the stem doesn't start with episode_stem. + """ + stem_lower = stem.lower() + prefix = episode_stem.lower() + if stem_lower.startswith(prefix): + suffix = stem[len(prefix) :] + tokens = _tokenize(suffix) + if tokens: + return tokens + return _tokenize(stem) + + def _count_entries(path: Path) -> int: """Return the entry count of an SRT file by finding the last cue number.""" try: @@ -79,17 +97,29 @@ class SubtitleIdentifier: try: result = subprocess.run( [ - "ffprobe", "-v", "quiet", - "-print_format", "json", + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", "-show_streams", - "-select_streams", "s", + "-select_streams", + "s", str(video_path), ], - capture_output=True, text=True, timeout=30, + capture_output=True, + text=True, + timeout=30, ) data = json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e: - logger.debug(f"SubtitleIdentifier: ffprobe failed for {video_path.name}: {e}") + except ( + subprocess.TimeoutExpired, + json.JSONDecodeError, + FileNotFoundError, + ) as e: + logger.debug( + f"SubtitleIdentifier: ffprobe failed for {video_path.name}: {e}" + ) return [] tracks = [] @@ -108,39 +138,50 @@ class SubtitleIdentifier: else: stype = SubtitleType.STANDARD - tracks.append(SubtitleTrack( - language=lang, - format=None, - subtitle_type=stype, - is_embedded=True, - raw_tokens=[lang_code] if lang_code else [], - )) + tracks.append( + SubtitleTrack( + language=lang, + format=None, + subtitle_type=stype, + is_embedded=True, + raw_tokens=[lang_code] if lang_code else [], + ) + ) - logger.debug(f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}") + logger.debug( + f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}" + ) return tracks # ------------------------------------------------------------------ # External tracks — filesystem scan per pattern strategy # ------------------------------------------------------------------ - def _scan_external(self, video_path: Path, pattern: SubtitlePattern) -> list[SubtitleTrack]: + def _scan_external( + self, video_path: Path, pattern: SubtitlePattern + ) -> list[SubtitleTrack]: strategy = pattern.scan_strategy + episode_stem: str | None = None if strategy == ScanStrategy.ADJACENT: candidates = self._find_adjacent(video_path) elif strategy == ScanStrategy.FLAT: candidates = self._find_flat(video_path, pattern.root_folder or "Subs") elif strategy == ScanStrategy.EPISODE_SUBFOLDER: - candidates = self._find_episode_subfolder(video_path, pattern.root_folder or "Subs") + candidates, episode_stem = self._find_episode_subfolder( + video_path, pattern.root_folder or "Subs" + ) else: return [] - return self._classify_files(candidates, pattern) + return self._classify_files(candidates, pattern, episode_stem=episode_stem) def _find_adjacent(self, video_path: Path) -> list[Path]: return [ - p for p in sorted(video_path.parent.iterdir()) - if p.is_file() and p.suffix.lower() in self.kb.known_extensions() + p + for p in sorted(video_path.parent.iterdir()) + if p.is_file() + and p.suffix.lower() in self.kb.known_extensions() and p.stem != video_path.stem ] @@ -152,17 +193,22 @@ class SubtitleIdentifier: if not subs_dir.is_dir(): return [] return [ - p for p in sorted(subs_dir.iterdir()) + p + for p in sorted(subs_dir.iterdir()) if p.is_file() and p.suffix.lower() in self.kb.known_extensions() ] - def _find_episode_subfolder(self, video_path: Path, root_folder: str) -> list[Path]: + def _find_episode_subfolder( + self, video_path: Path, root_folder: str + ) -> tuple[list[Path], str]: """ Look for Subs/{episode_stem}/*.srt Checks two locations: 1. Adjacent to the video: video_path.parent / root_folder / video_path.stem 2. Release root (one level up): video_path.parent.parent / root_folder / video_path.stem + + Returns (files, episode_stem) so the classifier can strip the prefix. """ episode_stem = video_path.stem candidates_dirs = [ @@ -172,22 +218,30 @@ class SubtitleIdentifier: for subs_dir in candidates_dirs: if subs_dir.is_dir(): files = [ - p for p in sorted(subs_dir.iterdir()) + p + for p in sorted(subs_dir.iterdir()) if p.is_file() and p.suffix.lower() in self.kb.known_extensions() ] if files: - logger.debug(f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}") - return files - return [] + logger.debug( + f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}" + ) + return files, episode_stem + return [], episode_stem # ------------------------------------------------------------------ # Classification # ------------------------------------------------------------------ - def _classify_files(self, paths: list[Path], pattern: SubtitlePattern) -> list[SubtitleTrack]: + def _classify_files( + self, + paths: list[Path], + pattern: SubtitlePattern, + episode_stem: str | None = None, + ) -> list[SubtitleTrack]: tracks = [] for path in paths: - track = self._classify_single(path) + track = self._classify_single(path, episode_stem=episode_stem) tracks.append(track) # Post-process: if multiple tracks share same language but type is ambiguous, @@ -197,9 +251,15 @@ class SubtitleIdentifier: return tracks - def _classify_single(self, path: Path) -> SubtitleTrack: + def _classify_single( + self, path: Path, episode_stem: str | None = None + ) -> SubtitleTrack: fmt = self.kb.format_for_extension(path.suffix) - tokens = _tokenize(path.stem) + tokens = ( + _tokenize_suffix(path.stem, episode_stem) + if episode_stem + else _tokenize(path.stem) + ) language = None subtitle_type = SubtitleType.UNKNOWN @@ -250,7 +310,6 @@ class SubtitleIdentifier: Only applied when type_detection = size_and_count. """ - from itertools import groupby # Group by language code lang_groups: dict[str, list[SubtitleTrack]] = {} diff --git a/alfred/domain/subtitles/services/matcher.py b/alfred/domain/subtitles/services/matcher.py index ecb2d0f..49d2203 100644 --- a/alfred/domain/subtitles/services/matcher.py +++ b/alfred/domain/subtitles/services/matcher.py @@ -3,7 +3,7 @@ import logging from ..entities import SubtitleTrack -from ..value_objects import SubtitleMatchingRules, SubtitleType +from ..value_objects import SubtitleMatchingRules logger = logging.getLogger(__name__) @@ -50,7 +50,9 @@ class SubtitleMatcher: ) return matched, unresolved - def _passes_filters(self, track: SubtitleTrack, rules: SubtitleMatchingRules) -> bool: + def _passes_filters( + self, track: SubtitleTrack, rules: SubtitleMatchingRules + ) -> bool: # Language filter if rules.preferred_languages: if not track.language: diff --git a/alfred/domain/subtitles/services/pattern_detector.py b/alfred/domain/subtitles/services/pattern_detector.py index f430428..6b94ebd 100644 --- a/alfred/domain/subtitles/services/pattern_detector.py +++ b/alfred/domain/subtitles/services/pattern_detector.py @@ -49,13 +49,19 @@ class PatternDetector: try: result = subprocess.run( [ - "ffprobe", "-v", "quiet", - "-print_format", "json", + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", "-show_streams", - "-select_streams", "s", + "-select_streams", + "s", str(video_path), ], - capture_output=True, text=True, timeout=30, + capture_output=True, + text=True, + timeout=30, ) data = json.loads(result.stdout) return len(data.get("streams", [])) > 0 @@ -67,7 +73,7 @@ class PatternDetector: known_exts = self.kb.known_extensions() findings: dict = { "has_subs_folder": False, - "subs_strategy": None, # "flat" | "episode_subfolder" + "subs_strategy": None, # "flat" | "episode_subfolder" "subs_root": None, "adjacent_subs": False, "has_embedded": self._has_embedded_subtitles(sample_video), @@ -87,15 +93,22 @@ class PatternDetector: # Is it flat or episode_subfolder? children = list(subs_candidate.iterdir()) - sub_files = [c for c in children if c.is_file() and c.suffix.lower() in known_exts] + sub_files = [ + c + for c in children + if c.is_file() and c.suffix.lower() in known_exts + ] sub_dirs = [c for c in children if c.is_dir()] if sub_dirs and not sub_files: findings["subs_strategy"] = "episode_subfolder" # Count files in a sample subfolder sample_sub = sub_dirs[0] - sample_files = [f for f in sample_sub.iterdir() - if f.is_file() and f.suffix.lower() in known_exts] + sample_files = [ + f + for f in sample_sub.iterdir() + if f.is_file() and f.suffix.lower() in known_exts + ] findings["files_per_episode"] = len(sample_files) # Check naming conventions for f in sample_files: @@ -103,22 +116,27 @@ class PatternDetector: parts = stem.split("_") if parts[0].isdigit(): findings["has_numeric_prefix"] = True - if any(self.kb.is_known_lang_token(t.lower()) - for t in stem.replace("_", ".").split(".")): + if any( + self.kb.is_known_lang_token(t.lower()) + for t in stem.replace("_", ".").split(".") + ): findings["has_lang_tokens"] = True else: findings["subs_strategy"] = "flat" findings["files_per_episode"] = len(sub_files) for f in sub_files: - if any(self.kb.is_known_lang_token(t.lower()) - for t in f.stem.replace("_", ".").split(".")): + if any( + self.kb.is_known_lang_token(t.lower()) + for t in f.stem.replace("_", ".").split(".") + ): findings["has_lang_tokens"] = True break # Check adjacent subs (next to the video) if not findings["has_subs_folder"]: adjacent = [ - p for p in sample_video.parent.iterdir() + p + for p in sample_video.parent.iterdir() if p.is_file() and p.suffix.lower() in known_exts ] if adjacent: @@ -157,7 +175,9 @@ class PatternDetector: total += 1 if findings.get("has_embedded"): score += 1.0 - if not findings.get("has_subs_folder") and not findings.get("adjacent_subs"): + if not findings.get("has_subs_folder") and not findings.get( + "adjacent_subs" + ): score += 0.5 total += 0.5 diff --git a/alfred/domain/subtitles/services/placer.py b/alfred/domain/subtitles/services/placer.py index da82648..436ab4c 100644 --- a/alfred/domain/subtitles/services/placer.py +++ b/alfred/domain/subtitles/services/placer.py @@ -10,6 +10,28 @@ from ..entities import SubtitleTrack logger = logging.getLogger(__name__) +def _build_dest_name(track: SubtitleTrack, video_stem: str) -> str: + """ + Build the destination filename for a subtitle track. + + Format: {video_stem}.{lang}.{ext} + {video_stem}.{lang}.sdh.{ext} + {video_stem}.{lang}.forced.{ext} + """ + from ..value_objects import SubtitleType + + if not track.language or not track.format: + raise ValueError("Cannot compute destination name: language or format missing") + + ext = track.format.extensions[0].lstrip(".") + parts = [video_stem, track.language.code] + if track.subtitle_type == SubtitleType.SDH: + parts.append("sdh") + elif track.subtitle_type == SubtitleType.FORCED: + parts.append("forced") + return ".".join(parts) + "." + ext + + @dataclass class PlacedTrack: source: Path @@ -20,7 +42,7 @@ class PlacedTrack: @dataclass class PlaceResult: placed: list[PlacedTrack] - skipped: list[tuple[SubtitleTrack, str]] # (track, reason) + skipped: list[tuple[SubtitleTrack, str]] # (track, reason) @property def placed_count(self) -> int: @@ -62,7 +84,7 @@ class SubtitlePlacer: continue try: - dest_name = track.destination_name + dest_name = _build_dest_name(track, destination_video.stem) except ValueError as e: skipped.append((track, str(e))) continue @@ -76,11 +98,13 @@ class SubtitlePlacer: try: os.link(track.file_path, dest_path) - placed.append(PlacedTrack( - source=track.file_path, - destination=dest_path, - filename=dest_name, - )) + placed.append( + PlacedTrack( + source=track.file_path, + destination=dest_path, + filename=dest_name, + ) + ) logger.info(f"SubtitlePlacer: placed {dest_name}") except OSError as e: logger.warning(f"SubtitlePlacer: failed to place {dest_name}: {e}") diff --git a/alfred/domain/subtitles/value_objects.py b/alfred/domain/subtitles/value_objects.py index f03ab7a..1d9f466 100644 --- a/alfred/domain/subtitles/value_objects.py +++ b/alfred/domain/subtitles/value_objects.py @@ -2,17 +2,15 @@ from dataclasses import dataclass, field from enum import Enum -from pathlib import Path -from typing import Any class ScanStrategy(Enum): """How to locate subtitle files for a given release.""" - ADJACENT = "adjacent" # .srt next to the video - FLAT = "flat" # Subs/*.srt + ADJACENT = "adjacent" # .srt next to the video + FLAT = "flat" # Subs/*.srt EPISODE_SUBFOLDER = "episode_subfolder" # Subs/{episode_name}/*.srt - EMBEDDED = "embedded" # tracks inside the video container + EMBEDDED = "embedded" # tracks inside the video container class TypeDetectionMethod(Enum): @@ -46,7 +44,7 @@ class SubtitleFormat: class SubtitleLanguage: """A known subtitle language with its recognition tokens.""" - code: str # ISO 639-1 + code: str # ISO 639-1 tokens: list[str] # lowercase def matches_token(self, token: str) -> bool: @@ -66,7 +64,7 @@ class SubtitlePattern: id: str description: str scan_strategy: ScanStrategy - root_folder: str | None # e.g. "Subs", None for adjacent/embedded + root_folder: str | None # e.g. "Subs", None for adjacent/embedded type_detection: TypeDetectionMethod version: str = "1.0" @@ -78,10 +76,10 @@ class SubtitleMatchingRules: Only stores actual values — None means "inherited, not overridden at this level". """ - preferred_languages: list[str] = field(default_factory=list) # ISO 639-1 codes - preferred_formats: list[str] = field(default_factory=list) # format ids - allowed_types: list[str] = field(default_factory=list) # SubtitleType ids - format_priority: list[str] = field(default_factory=list) # ordered format ids + preferred_languages: list[str] = field(default_factory=list) # ISO 639-1 codes + preferred_formats: list[str] = field(default_factory=list) # format ids + allowed_types: list[str] = field(default_factory=list) # SubtitleType ids + format_priority: list[str] = field(default_factory=list) # ordered format ids min_confidence: float = 0.7 @@ -89,5 +87,5 @@ class SubtitleMatchingRules: class RuleScope: """At which level a rule set applies.""" - level: str # "global" | "release_group" | "movie" | "show" | "season" | "episode" - identifier: str | None = None # imdb_id, group name, "S01", "S01E03"… + level: str # "global" | "release_group" | "movie" | "show" | "season" | "episode" + identifier: str | None = None # imdb_id, group name, "S01", "S01E03"… diff --git a/alfred/domain/tv_shows/entities.py b/alfred/domain/tv_shows/entities.py index 5d03568..84c182b 100644 --- a/alfred/domain/tv_shows/entities.py +++ b/alfred/domain/tv_shows/entities.py @@ -1,7 +1,7 @@ """TV Show domain entities.""" import re -from dataclasses import dataclass, field +from dataclasses import dataclass from ..shared.value_objects import FilePath, FileSize, ImdbId from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus diff --git a/alfred/domain/tv_shows/value_objects.py b/alfred/domain/tv_shows/value_objects.py index 9900271..7a931a3 100644 --- a/alfred/domain/tv_shows/value_objects.py +++ b/alfred/domain/tv_shows/value_objects.py @@ -14,7 +14,7 @@ class ShowStatus(Enum): UNKNOWN = "unknown" @classmethod - def from_string(cls, status_str: str) -> "ShowStatus": + def from_string(cls, status_str: str) -> ShowStatus: """ Parse status from string. diff --git a/alfred/infrastructure/api/qbittorrent/client.py b/alfred/infrastructure/api/qbittorrent/client.py index f536e84..fafc16b 100644 --- a/alfred/infrastructure/api/qbittorrent/client.py +++ b/alfred/infrastructure/api/qbittorrent/client.py @@ -48,9 +48,9 @@ class QBittorrentClient: """ cfg = config or settings - self.host = host or "http://192.168.178.47:30024" - self.username = username or "admin" - self.password = password or "adminadmin" + self.host = host or cfg.qbittorrent_url + self.username = username or cfg.qbittorrent_username + self.password = password or cfg.qbittorrent_password self.timeout = timeout or cfg.request_timeout self.session = requests.Session() @@ -336,6 +336,90 @@ class QBittorrentClient: logger.error(f"Failed to resume torrent: {e}") raise + def find_by_name(self, name: str) -> TorrentInfo | None: + """ + Find a torrent by release folder name. + + Matching strategy (in order): + 1. Exact name match (torrent.name == name) + 2. Case-insensitive name match + 3. save_path ends with the name (folder moved but name intact) + + Args: + name: Release folder name (e.g. "Foundation.2021.S01.1080p...") + + Returns: + TorrentInfo if found, None otherwise + """ + torrents = self.get_torrents() + + # 1. Exact + for t in torrents: + if t.name == name: + return t + + # 2. Case-insensitive + name_lower = name.lower() + for t in torrents: + if t.name.lower() == name_lower: + return t + + # 3. save_path ends with the folder name + from pathlib import Path + + for t in torrents: + if t.save_path and Path(t.save_path).name.lower() == name_lower: + return t + + return None + + def set_location(self, torrent_hash: str, location: str) -> bool: + """ + Change the save path of a torrent. + + Args: + torrent_hash: Hash of the torrent + location: New save path (must exist on the server) + + Returns: + True if location changed successfully + """ + if not self._authenticated: + self.login() + + data = {"hashes": torrent_hash, "location": location} + + try: + self._make_request("POST", "/api/v2/torrents/setLocation", data=data) + logger.info(f"Set location for {torrent_hash} → {location}") + return True + except QBittorrentAPIError as e: + logger.error(f"Failed to set location for {torrent_hash}: {e}") + raise + + def recheck(self, torrent_hash: str) -> bool: + """ + Force recheck (hash verification) of a torrent. + + Args: + torrent_hash: Hash of the torrent + + Returns: + True if recheck triggered successfully + """ + if not self._authenticated: + self.login() + + data = {"hashes": torrent_hash} + + try: + self._make_request("POST", "/api/v2/torrents/recheck", data=data) + logger.info(f"Recheck triggered for {torrent_hash}") + return True + except QBittorrentAPIError as e: + logger.error(f"Failed to recheck {torrent_hash}: {e}") + raise + def get_torrent_properties(self, torrent_hash: str) -> dict[str, Any]: """ Get detailed properties of a torrent. diff --git a/alfred/infrastructure/filesystem/__init__.py b/alfred/infrastructure/filesystem/__init__.py index b7ed33e..2e9b2bf 100644 --- a/alfred/infrastructure/filesystem/__init__.py +++ b/alfred/infrastructure/filesystem/__init__.py @@ -2,6 +2,7 @@ from .exceptions import FilesystemError, PathTraversalError from .file_manager import FileManager +from .filesystem_operations import create_folder, move from .organizer import MediaOrganizer __all__ = [ @@ -9,4 +10,6 @@ __all__ = [ "MediaOrganizer", "FilesystemError", "PathTraversalError", + "create_folder", + "move", ] diff --git a/alfred/infrastructure/filesystem/ffprobe.py b/alfred/infrastructure/filesystem/ffprobe.py index da5e757..ff7469c 100644 --- a/alfred/infrastructure/filesystem/ffprobe.py +++ b/alfred/infrastructure/filesystem/ffprobe.py @@ -13,8 +13,10 @@ logger = logging.getLogger(__name__) _FFPROBE_CMD = [ "ffprobe", - "-v", "quiet", - "-print_format", "json", + "-v", + "quiet", + "-print_format", + "json", "-show_streams", "-show_format", ] @@ -77,22 +79,26 @@ def _parse(data: dict) -> MediaInfo: info.height = stream.get("height") elif codec_type == "audio": - info.audio_tracks.append(AudioTrack( - index=stream.get("index", len(info.audio_tracks)), - codec=stream.get("codec_name"), - channels=stream.get("channels"), - channel_layout=stream.get("channel_layout"), - language=stream.get("tags", {}).get("language"), - is_default=stream.get("disposition", {}).get("default", 0) == 1, - )) + info.audio_tracks.append( + AudioTrack( + index=stream.get("index", len(info.audio_tracks)), + codec=stream.get("codec_name"), + channels=stream.get("channels"), + channel_layout=stream.get("channel_layout"), + language=stream.get("tags", {}).get("language"), + is_default=stream.get("disposition", {}).get("default", 0) == 1, + ) + ) elif codec_type == "subtitle": - info.subtitle_tracks.append(SubtitleTrack( - index=stream.get("index", len(info.subtitle_tracks)), - codec=stream.get("codec_name"), - language=stream.get("tags", {}).get("language"), - is_default=stream.get("disposition", {}).get("default", 0) == 1, - is_forced=stream.get("disposition", {}).get("forced", 0) == 1, - )) + info.subtitle_tracks.append( + SubtitleTrack( + index=stream.get("index", len(info.subtitle_tracks)), + codec=stream.get("codec_name"), + language=stream.get("tags", {}).get("language"), + is_default=stream.get("disposition", {}).get("default", 0) == 1, + is_forced=stream.get("disposition", {}).get("forced", 0) == 1, + ) + ) return info diff --git a/alfred/infrastructure/filesystem/file_manager.py b/alfred/infrastructure/filesystem/file_manager.py index f1ee173..a30acd1 100644 --- a/alfred/infrastructure/filesystem/file_manager.py +++ b/alfred/infrastructure/filesystem/file_manager.py @@ -89,13 +89,18 @@ class FileManager: folder_path = memory.ltm.library_paths.get(folder_type) if not folder_path: - return _err("folder_not_set", f"{folder_type.capitalize()} folder not configured.") + return _err( + "folder_not_set", + f"{folder_type.capitalize()} folder not configured.", + ) root = Path(folder_path) target = root / safe_path if not self._is_safe_path(root, target): - return _err("forbidden", "Access denied: path outside allowed directory") + return _err( + "forbidden", "Access denied: path outside allowed directory" + ) if not target.exists(): return _err("not_found", f"Path does not exist: {safe_path}") @@ -153,10 +158,15 @@ class FileManager: return _err("source_not_file", f"Source is not a file: {source}") if not dest_path.parent.exists(): - return _err("destination_dir_not_found", f"Destination directory does not exist: {dest_path.parent}") + return _err( + "destination_dir_not_found", + f"Destination directory does not exist: {dest_path.parent}", + ) if dest_path.exists(): - return _err("destination_exists", f"Destination already exists: {destination}") + return _err( + "destination_exists", f"Destination already exists: {destination}" + ) os.link(source_path, dest_path) @@ -197,7 +207,9 @@ class FileManager: source_path.unlink() - logger.info(f"File moved: {source_path.name} -> {link_result['destination']}") + logger.info( + f"File moved: {source_path.name} -> {link_result['destination']}" + ) return { "status": "ok", "source": str(source_path), @@ -237,11 +249,19 @@ class FileManager: torrent_root = Path(torrent_folder).resolve() if not lib_path.exists(): - return _err("library_file_not_found", f"Library file not found: {library_file}") + return _err( + "library_file_not_found", f"Library file not found: {library_file}" + ) if not src_folder.exists(): - return _err("source_folder_not_found", f"Download folder not found: {original_download_folder}") + return _err( + "source_folder_not_found", + f"Download folder not found: {original_download_folder}", + ) if not torrent_root.exists(): - return _err("torrent_folder_not_found", f"Torrent folder not found: {torrent_folder}") + return _err( + "torrent_folder_not_found", + f"Torrent folder not found: {torrent_folder}", + ) dest_folder = torrent_root / src_folder.name dest_folder.mkdir(parents=True, exist_ok=True) @@ -266,6 +286,7 @@ class FileManager: skipped.append(str(rel)) continue import shutil + shutil.copy2(item, dest_item) copied.append(str(rel)) logger.debug(f"Copied for seeding: {rel}") diff --git a/alfred/infrastructure/filesystem/filesystem_operations.py b/alfred/infrastructure/filesystem/filesystem_operations.py new file mode 100644 index 0000000..77e1e32 --- /dev/null +++ b/alfred/infrastructure/filesystem/filesystem_operations.py @@ -0,0 +1,72 @@ +"""Low-level filesystem operations — one responsibility per function.""" + +import logging +import subprocess +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _err(error: str, message: str) -> dict[str, Any]: + return {"status": "error", "error": error, "message": message} + + +def create_folder(path: str) -> dict[str, Any]: + """ + Create a directory and all missing parents. + + Args: + path: Absolute path to the directory to create. + + Returns: + Dict with status and path, or error details. + """ + try: + p = Path(path) + p.mkdir(parents=True, exist_ok=True) + logger.info(f"Folder ready: {p}") + return {"status": "ok", "path": str(p)} + except OSError as e: + logger.error(f"create_folder failed: {e}") + return _err("mkdir_failed", str(e)) + + +def move(source: str, destination: str) -> dict[str, Any]: + """ + Move a file or folder to a destination path. + + Uses the system mv command — instant on the same filesystem (ZFS rename). + + Args: + source: Absolute path to the source file or folder. + destination: Absolute path to the destination. + + Returns: + Dict with status, source, destination — or error details. + """ + src = Path(source) + dst = Path(destination) + + if not src.exists(): + return _err("source_not_found", f"Source does not exist: {source}") + + if dst.exists(): + return _err("destination_exists", f"Destination already exists: {destination}") + + try: + result = subprocess.run( + ["mv", str(src), str(dst)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + logger.error(f"mv failed: {result.stderr}") + return _err("move_failed", result.stderr.strip()) + + logger.info(f"Moved: {src} -> {dst}") + return {"status": "ok", "source": str(src), "destination": str(dst)} + + except OSError as e: + logger.error(f"move failed: {e}") + return _err("move_failed", str(e)) diff --git a/alfred/infrastructure/persistence/memory/base.py b/alfred/infrastructure/persistence/memory/base.py index 145638a..a80165e 100644 --- a/alfred/infrastructure/persistence/memory/base.py +++ b/alfred/infrastructure/persistence/memory/base.py @@ -67,12 +67,20 @@ class Memory: "current_topic": self.stm.entities.topic, "extracted_entities": self.stm.entities.data, "last_search": { - "query": self.episodic.search_results.last.get("query") if self.episodic.search_results.last else None, - "result_count": len(self.episodic.search_results.last.get("results", [])) if self.episodic.search_results.last else 0, + "query": self.episodic.search_results.last.get("query") + if self.episodic.search_results.last + else None, + "result_count": len( + self.episodic.search_results.last.get("results", []) + ) + if self.episodic.search_results.last + else 0, }, "active_downloads_count": len(self.episodic.downloads.active), "pending_question": self.episodic.pending_question is not None, - "unread_events": len([e for e in self.episodic.events.items if not e.get("read")]), + "unread_events": len( + [e for e in self.episodic.events.items if not e.get("read")] + ), } def get_full_state(self) -> dict: diff --git a/alfred/infrastructure/persistence/memory/episodic/components/downloads.py b/alfred/infrastructure/persistence/memory/episodic/components/downloads.py index 60ce9dd..1d8a305 100644 --- a/alfred/infrastructure/persistence/memory/episodic/components/downloads.py +++ b/alfred/infrastructure/persistence/memory/episodic/components/downloads.py @@ -16,7 +16,9 @@ class Downloads: self.active.append(download) logger.info(f"Downloads: Added '{download.get('name')}'") - def update_progress(self, task_id: str, progress: int, status: str = "downloading") -> None: + def update_progress( + self, task_id: str, progress: int, status: str = "downloading" + ) -> None: for dl in self.active: if dl.get("task_id") == task_id: dl["progress"] = progress @@ -28,7 +30,13 @@ class Downloads: for i, dl in enumerate(self.active): if dl.get("task_id") == task_id: completed = self.active.pop(i) - completed.update({"status": "completed", "file_path": file_path, "completed_at": datetime.now().isoformat()}) + completed.update( + { + "status": "completed", + "file_path": file_path, + "completed_at": datetime.now().isoformat(), + } + ) logger.info(f"Downloads: Completed '{completed.get('name')}'") return completed return None diff --git a/alfred/infrastructure/persistence/memory/episodic/components/errors.py b/alfred/infrastructure/persistence/memory/episodic/components/errors.py index 7709781..60abd2c 100644 --- a/alfred/infrastructure/persistence/memory/episodic/components/errors.py +++ b/alfred/infrastructure/persistence/memory/episodic/components/errors.py @@ -15,13 +15,15 @@ class Errors: max_errors: int = MAX_ERRORS def add(self, action: str, error: str, context: dict | None = None) -> None: - self.items.append({ - "timestamp": datetime.now().isoformat(), - "action": action, - "error": error, - "context": context or {}, - }) - self.items = self.items[-self.max_errors:] + self.items.append( + { + "timestamp": datetime.now().isoformat(), + "action": action, + "error": error, + "context": context or {}, + } + ) + self.items = self.items[-self.max_errors :] logger.warning(f"Errors: '{action}': {error}") def clear(self) -> None: diff --git a/alfred/infrastructure/persistence/memory/episodic/components/events.py b/alfred/infrastructure/persistence/memory/episodic/components/events.py index 097c82e..8c898d5 100644 --- a/alfred/infrastructure/persistence/memory/episodic/components/events.py +++ b/alfred/infrastructure/persistence/memory/episodic/components/events.py @@ -15,8 +15,15 @@ class Events: max_events: int = MAX_EVENTS def add(self, event_type: str, data: dict) -> None: - self.items.append({"type": event_type, "timestamp": datetime.now().isoformat(), "data": data, "read": False}) - self.items = self.items[-self.max_events:] + self.items.append( + { + "type": event_type, + "timestamp": datetime.now().isoformat(), + "data": data, + "read": False, + } + ) + self.items = self.items[-self.max_events :] logger.info(f"Events: '{event_type}'") def get_unread(self) -> list[dict]: diff --git a/alfred/infrastructure/persistence/memory/episodic/components/search_results.py b/alfred/infrastructure/persistence/memory/episodic/components/search_results.py index f541e98..1c799ad 100644 --- a/alfred/infrastructure/persistence/memory/episodic/components/search_results.py +++ b/alfred/infrastructure/persistence/memory/episodic/components/search_results.py @@ -11,7 +11,9 @@ logger = logging.getLogger(__name__) class SearchResults: last: dict | None = None - def store(self, query: str, results: list[dict], search_type: str = "torrent") -> None: + def store( + self, query: str, results: list[dict], search_type: str = "torrent" + ) -> None: self.last = { "query": query, "type": search_type, diff --git a/alfred/infrastructure/persistence/memory/episodic/episodic.py b/alfred/infrastructure/persistence/memory/episodic/episodic.py index 208799b..15b4175 100644 --- a/alfred/infrastructure/persistence/memory/episodic/episodic.py +++ b/alfred/infrastructure/persistence/memory/episodic/episodic.py @@ -46,7 +46,9 @@ class EpisodicMemory: pending_question: dict | None = None # Convenience methods forwarded to components - def store_search_results(self, query: str, results: list[dict], search_type: str = "torrent") -> None: + def store_search_results( + self, query: str, results: list[dict], search_type: str = "torrent" + ) -> None: self.search_results.store(query, results, search_type) def get_result_by_index(self, index: int) -> dict | None: @@ -61,13 +63,18 @@ class EpisodicMemory: def add_active_download(self, download: dict) -> None: self.downloads.add(download) - def update_download_progress(self, task_id: str, progress: int, status: str = "downloading") -> None: + def update_download_progress( + self, task_id: str, progress: int, status: str = "downloading" + ) -> None: self.downloads.update_progress(task_id, progress, status) def complete_download(self, task_id: str, file_path: str) -> dict | None: completed = self.downloads.complete(task_id, file_path) if completed: - self.events.add("download_complete", {"name": completed.get("name"), "file_path": file_path}) + self.events.add( + "download_complete", + {"name": completed.get("name"), "file_path": file_path}, + ) return completed def get_active_downloads(self) -> list[dict]: @@ -79,7 +86,13 @@ class EpisodicMemory: def get_recent_errors(self) -> list[dict]: return self.errors.items - def set_pending_question(self, question: str, options: list[dict], context: dict, question_type: str = "choice") -> None: + def set_pending_question( + self, + question: str, + options: list[dict], + context: dict, + question_type: str = "choice", + ) -> None: self.pending_question = { "type": question_type, "question": question, diff --git a/alfred/infrastructure/persistence/memory/ltm/components/following.py b/alfred/infrastructure/persistence/memory/ltm/components/following.py index 741bee7..7ba26c6 100644 --- a/alfred/infrastructure/persistence/memory/ltm/components/following.py +++ b/alfred/infrastructure/persistence/memory/ltm/components/following.py @@ -39,5 +39,5 @@ class Following: } @classmethod - def from_dict(cls, data: list) -> "Following": + def from_dict(cls, data: list) -> Following: return cls(shows=data) diff --git a/alfred/infrastructure/persistence/memory/ltm/components/library.py b/alfred/infrastructure/persistence/memory/ltm/components/library.py index 6595dbf..075ca51 100644 --- a/alfred/infrastructure/persistence/memory/ltm/components/library.py +++ b/alfred/infrastructure/persistence/memory/ltm/components/library.py @@ -57,7 +57,7 @@ class Library: return {"movies": self.movies, "tv_shows": self.tv_shows} @classmethod - def from_dict(cls, data: dict) -> "Library": + def from_dict(cls, data: dict) -> Library: return cls( movies=data.get("movies", []), tv_shows=data.get("tv_shows", []), diff --git a/alfred/infrastructure/persistence/memory/ltm/components/library_paths.py b/alfred/infrastructure/persistence/memory/ltm/components/library_paths.py index beb6617..a39bdc7 100644 --- a/alfred/infrastructure/persistence/memory/ltm/components/library_paths.py +++ b/alfred/infrastructure/persistence/memory/ltm/components/library_paths.py @@ -50,7 +50,7 @@ class LibraryPaths: } @classmethod - def from_dict(cls, data: dict) -> "LibraryPaths": + def from_dict(cls, data: dict) -> LibraryPaths: # Migrate from old flat format (tvshow_folder, movie_folder) folders = dict(data) if not folders: diff --git a/alfred/infrastructure/persistence/memory/ltm/components/media_preferences.py b/alfred/infrastructure/persistence/memory/ltm/components/media_preferences.py index 0e80602..412fcb1 100644 --- a/alfred/infrastructure/persistence/memory/ltm/components/media_preferences.py +++ b/alfred/infrastructure/persistence/memory/ltm/components/media_preferences.py @@ -40,7 +40,7 @@ class MediaPreferences: } @classmethod - def from_dict(cls, data: dict) -> "MediaPreferences": + def from_dict(cls, data: dict) -> MediaPreferences: return cls( # migration: old key was preferred_quality / preferred_languages quality=data.get("quality") or data.get("preferred_quality", "1080p"), diff --git a/alfred/infrastructure/persistence/memory/ltm/components/subtitle_preferences.py b/alfred/infrastructure/persistence/memory/ltm/components/subtitle_preferences.py index a60db05..f890320 100644 --- a/alfred/infrastructure/persistence/memory/ltm/components/subtitle_preferences.py +++ b/alfred/infrastructure/persistence/memory/ltm/components/subtitle_preferences.py @@ -62,7 +62,7 @@ class SubtitlePreferences: } @classmethod - def from_dict(cls, data: dict) -> "SubtitlePreferences": + def from_dict(cls, data: dict) -> SubtitlePreferences: # Migration: old fields (min_size_kb, keep_sdh, keep_forced, link_subs_folder) are silently dropped prefs = cls( languages=data.get("languages", ["fr", "en"]), diff --git a/alfred/infrastructure/persistence/memory/ltm/components/workspace.py b/alfred/infrastructure/persistence/memory/ltm/components/workspace.py index 85c9613..a747d80 100644 --- a/alfred/infrastructure/persistence/memory/ltm/components/workspace.py +++ b/alfred/infrastructure/persistence/memory/ltm/components/workspace.py @@ -20,16 +20,22 @@ class WorkspacePaths: download: str | None = None torrent: str | None = None + trash: str | None = None def as_dict(self) -> dict[str, str]: """Return configured paths, skipping unset values.""" - return {k: v for k, v in { - "download": self.download, - "torrent": self.torrent, - }.items() if v is not None} + return { + k: v + for k, v in { + "download": self.download, + "torrent": self.torrent, + "trash": self.trash, + }.items() + if v is not None + } def to_dict(self) -> dict: - return {"download": self.download, "torrent": self.torrent} + return {"download": self.download, "torrent": self.torrent, "trash": self.trash} @classmethod def describe(cls) -> dict: @@ -45,13 +51,15 @@ class WorkspacePaths: "fields": { "download": "Root folder where qBittorrent drops completed downloads.", "torrent": "Folder where .torrent files are stored.", + "trash": "Trash folder — files moved here instead of deleted, for manual review.", }, } @classmethod - def from_dict(cls, data: dict) -> "WorkspacePaths": + def from_dict(cls, data: dict) -> WorkspacePaths: # Migrate from old flat format (download_folder, torrent_folder) return cls( download=data.get("download") or data.get("download_folder"), torrent=data.get("torrent") or data.get("torrent_folder"), + trash=data.get("trash"), ) diff --git a/alfred/infrastructure/persistence/memory/ltm/ltm.py b/alfred/infrastructure/persistence/memory/ltm/ltm.py index d48f826..8550eeb 100644 --- a/alfred/infrastructure/persistence/memory/ltm/ltm.py +++ b/alfred/infrastructure/persistence/memory/ltm/ltm.py @@ -31,7 +31,9 @@ class LongTermMemory: workspace: WorkspacePaths = field(default_factory=WorkspacePaths) library_paths: LibraryPaths = field(default_factory=LibraryPaths) media_preferences: MediaPreferences = field(default_factory=MediaPreferences) - subtitle_preferences: SubtitlePreferences = field(default_factory=SubtitlePreferences) + subtitle_preferences: SubtitlePreferences = field( + default_factory=SubtitlePreferences + ) library: Library = field(default_factory=Library) following: Following = field(default_factory=Following) @@ -46,7 +48,7 @@ class LongTermMemory: } @classmethod - def from_dict(cls, data: dict) -> "LongTermMemory": + def from_dict(cls, data: dict) -> LongTermMemory: # Migration: old flat format had paths at the top level workspace_data = data.get("workspace") or data library_paths_data = data.get("library_paths") or data.get("paths") or data diff --git a/alfred/infrastructure/persistence/memory/registry.py b/alfred/infrastructure/persistence/memory/registry.py index f394c30..8487b84 100644 --- a/alfred/infrastructure/persistence/memory/registry.py +++ b/alfred/infrastructure/persistence/memory/registry.py @@ -38,7 +38,9 @@ def _load_components(package_name: str) -> list[dict]: try: descriptions.append(cls.describe()) except Exception as e: - logger.warning(f"MemoryRegistry: describe() failed on {cls.__name__}: {e}") + logger.warning( + f"MemoryRegistry: describe() failed on {cls.__name__}: {e}" + ) except Exception as e: logger.warning(f"MemoryRegistry: Could not load package {package_name}: {e}") diff --git a/alfred/infrastructure/persistence/memory/stm/components/conversation.py b/alfred/infrastructure/persistence/memory/stm/components/conversation.py index 199d68d..ae10b12 100644 --- a/alfred/infrastructure/persistence/memory/stm/components/conversation.py +++ b/alfred/infrastructure/persistence/memory/stm/components/conversation.py @@ -17,9 +17,11 @@ class Conversation: def add(self, role: str, content: str) -> None: """Append a message, capping at max_history.""" - self.messages.append({"role": role, "content": content, "timestamp": datetime.now().isoformat()}) + self.messages.append( + {"role": role, "content": content, "timestamp": datetime.now().isoformat()} + ) if len(self.messages) > self.max_history: - self.messages = self.messages[-self.max_history:] + self.messages = self.messages[-self.max_history :] logger.debug(f"Conversation: Added {role} message") def recent(self, n: int = 10) -> list[dict]: diff --git a/alfred/infrastructure/subtitle/metadata_store.py b/alfred/infrastructure/subtitle/metadata_store.py index 31e6e3a..77c217e 100644 --- a/alfred/infrastructure/subtitle/metadata_store.py +++ b/alfred/infrastructure/subtitle/metadata_store.py @@ -1,7 +1,7 @@ """SubtitleMetadataStore — reads/writes .alfred/metadata.yaml colocated with media.""" import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -50,7 +50,13 @@ class SubtitleMetadataStore: tmp = self._metadata_path.with_suffix(".yaml.tmp") try: with open(tmp, "w", encoding="utf-8") as f: - yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False) + yaml.safe_dump( + data, + f, + allow_unicode=True, + default_flow_style=False, + sort_keys=False, + ) tmp.rename(self._metadata_path) except Exception as e: logger.error(f"MetadataStore: could not write {self._metadata_path}: {e}") @@ -68,7 +74,9 @@ class SubtitleMetadataStore: return data.get("detected_pattern") return None - def mark_pattern_confirmed(self, pattern_id: str, media_info: dict | None = None) -> None: + def mark_pattern_confirmed( + self, pattern_id: str, media_info: dict | None = None + ) -> None: """Persist detected_pattern + pattern_confirmed=true.""" data = self.load() data["detected_pattern"] = pattern_id @@ -78,7 +86,9 @@ class SubtitleMetadataStore: data.setdefault("imdb_id", media_info.get("imdb_id")) data.setdefault("title", media_info.get("title")) self.save(data) - logger.info(f"MetadataStore: confirmed pattern '{pattern_id}' for {self._root.name}") + logger.info( + f"MetadataStore: confirmed pattern '{pattern_id}' for {self._root.name}" + ) # ------------------------------------------------------------------ # Subtitle history @@ -101,21 +111,25 @@ class SubtitleMetadataStore: tracks_data: list[dict[str, Any]] = [] for placed, track in placed_pairs: # Infer type from destination filename parts (e.g. en.sdh.srt → sdh) - parts = placed.filename.rsplit(".", 2) # ["en", "sdh", "srt"] or ["en", "srt"] + parts = placed.filename.rsplit( + ".", 2 + ) # ["en", "sdh", "srt"] or ["en", "srt"] inferred_type = parts[1] if len(parts) == 3 else "standard" - tracks_data.append({ - "language": track.language.code if track.language else "unknown", - "type": inferred_type, - "format": placed.destination.suffix.lstrip("."), - "is_embedded": track.is_embedded, - "source_file": placed.source.name, - "placed_as": placed.filename, - "confidence": round(track.confidence, 3), - }) + tracks_data.append( + { + "language": track.language.code if track.language else "unknown", + "type": inferred_type, + "format": placed.destination.suffix.lstrip("."), + "is_embedded": track.is_embedded, + "source_file": placed.source.name, + "placed_as": placed.filename, + "confidence": round(track.confidence, 3), + } + ) entry: dict[str, Any] = { - "placed_at": datetime.now(timezone.utc).isoformat(), + "placed_at": datetime.now(UTC).isoformat(), "release_group": release_group, "tracks": tracks_data, } diff --git a/alfred/infrastructure/subtitle/rule_repository.py b/alfred/infrastructure/subtitle/rule_repository.py index 7e4925a..43e7d53 100644 --- a/alfred/infrastructure/subtitle/rule_repository.py +++ b/alfred/infrastructure/subtitle/rule_repository.py @@ -10,7 +10,9 @@ from alfred.domain.subtitles.aggregates import SubtitleRuleSet from alfred.domain.subtitles.value_objects import RuleScope if TYPE_CHECKING: - from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import SubtitlePreferences + from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import ( + SubtitlePreferences, + ) logger = logging.getLogger(__name__) @@ -46,7 +48,7 @@ class RuleSetRepository: def load( self, release_group: str | None = None, - subtitle_preferences: "SubtitlePreferences | None" = None, + subtitle_preferences: SubtitlePreferences | None = None, ) -> SubtitleRuleSet: """ Build and return the resolved RuleSet chain. @@ -75,7 +77,9 @@ class RuleSetRepository: ) rg_ruleset.override(**_filter_override(rg_data)) current = rg_ruleset - logger.debug(f"RuleSetRepository: loaded release_group override for '{release_group}'") + logger.debug( + f"RuleSetRepository: loaded release_group override for '{release_group}'" + ) # Local (show/movie) level local_data = _load_yaml(self._alfred_dir / "rules.yaml").get("override", {}) @@ -101,7 +105,13 @@ class RuleSetRepository: tmp = path.with_suffix(".yaml.tmp") try: with open(tmp, "w", encoding="utf-8") as f: - yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False) + yaml.safe_dump( + data, + f, + allow_unicode=True, + default_flow_style=False, + sort_keys=False, + ) tmp.rename(path) logger.info(f"RuleSetRepository: saved local rules to {path}") except Exception as e: diff --git a/alfred/knowledge/subtitles.yaml b/alfred/knowledge/subtitles.yaml index f45f587..276684b 100644 --- a/alfred/knowledge/subtitles.yaml +++ b/alfred/knowledge/subtitles.yaml @@ -30,7 +30,7 @@ types: languages: fra: - tokens: ["fr", "fra", "french", "francais", "vf", "vff", "vostfr"] + tokens: ["fr", "fra", "fre", "french", "francais", "vf", "vff", "vostfr"] eng: tokens: ["en", "eng", "english"] spa: @@ -80,10 +80,34 @@ languages: jpn: tokens: ["ja", "jpn", "japanese"] zho: - tokens: ["zh", "zho", "chi", "chinese"] + tokens: ["zh", "zho", "chi", "chinese", "simplified", "traditional"] + yue: + tokens: ["yue", "cantonese"] kor: tokens: ["ko", "kor", "korean"] ara: tokens: ["ar", "ara", "arabic"] tur: tokens: ["tr", "tur", "turkish"] + ell: + tokens: ["el", "ell", "gre", "greek"] + ind: + tokens: ["id", "ind", "indonesian"] + msa: + tokens: ["ms", "msa", "may", "malay", "malayalam"] + rus: + tokens: ["ru", "rus", "russian"] + vie: + tokens: ["vi", "vie", "vietnamese"] + heb: + tokens: ["he", "heb", "hebrew"] + tam: + tokens: ["ta", "tam", "tamil"] + tel: + tokens: ["te", "tel", "telugu"] + tha: + tokens: ["th", "tha", "thai"] + hin: + tokens: ["hi", "hin", "hindi"] + ukr: + tokens: ["uk", "ukr", "ukrainian"] diff --git a/alfred/knowledge/users/common.yaml b/alfred/knowledge/users/common.yaml new file mode 100644 index 0000000..96d94d3 --- /dev/null +++ b/alfred/knowledge/users/common.yaml @@ -0,0 +1,53 @@ +# Alfred — expressions communes +# Chaque situation contient une liste de phrases. +# {user} = surnom de l'utilisateur courant. +# Le code injecte UNE phrase aléatoire par situation dans le prompt. + +expressions: + greeting: + - "Yo {user}, quoi de neuf ?" + - "Salut {user}, on fait quoi ?" + - "Présent {user}, j'écoute." + - "Alfred à l'appareil, {user}." + + success: + - "Nickel {user}, c'est dans la boîte." + - "Ça roule ma poule, c'est réglé." + - "Putain ça marche, bien joué (moi, pas toi)." + - "C'est tombé en marche {user}." + - "Done. Propre." + - "GG, c'est plié." + + working: + - "J'y suis, deux secondes." + - "On s'en occupe." + - "Ça tourne, patience." + - "En cours {user}, je te reviens." + - "Je check ça." + + error: + - "Merde, quelque chose s'est cassé la gueule." + - "Oups. Ça a foiré, je te dis pourquoi." + - "Sorry mec, j'ai merdé — voilà ce qui s'est passé." + - "Putain. Erreur. Détails :" + - "Ça a planté {user}, on regarde ensemble." + + unclear: + - "Computer says no. C'est quoi exactement que tu veux ?" + - "Je pige pas {user}, sois plus précis." + - "T'es sûr de toi là ? Parce que j'ai rien compris." + - "Hm. Reformule, j'arrive pas à suivre." + - "C'est flou {user}. Donne-moi plus de contexte." + - "Je vais pas deviner hein. C'est quoi le truc ?" + + warning: + - "Attention {user}, c'est irréversible." + - "Yo, double-check avant que je lance ça." + - "Sûr ? Parce qu'après y'a pas de ctrl+z." + - "Je te préviens, ça va faire des dégâts si c'est pas ça." + + not_found: + - "Introuvable {user}. Ça existe même ?" + - "Néant. J'ai rien trouvé." + - "Zero result. T'as bien écrit ?" + - "Nada {user}, ça me revient rien." diff --git a/alfred/knowledge/users/francwa.yaml b/alfred/knowledge/users/francwa.yaml new file mode 100644 index 0000000..f2529a8 --- /dev/null +++ b/alfred/knowledge/users/francwa.yaml @@ -0,0 +1,32 @@ +# Alfred — profil utilisateur : francwa +# Mergé par-dessus common.yaml. +# Les expressions ici s'ajoutent (pas remplacent) celles du common. + +user: + username: francwa + nickname: poulet + tone: franglais # casual, direct, sarcasme autorisé + +expressions: + greeting: + - "Yo poulet, on fait quoi aujourd'hui ?" + - "LFG poulet, j'écoute." + - "Présent poulet." + + success: + - "C'est clean poulet, bien joué." + - "Gg poulet, c'est dans la boîte." + - "Nickel, c'est réglé poulet." + + working: + - "J'y suis poulet." + - "On s'en occupe, deux secondes." + + error: + - "Putain poulet, ça a foiré — voilà ce qui s'est passé." + - "Merde. Je t'explique ce qui a merdé." + + unclear: + - "Computer says no poulet. C'est quoi exactement ?" + - "Reformule poulet, j'ai rien compris là." + - "C'est flou poulet, donne-moi plus de contexte." diff --git a/alfred/settings.py b/alfred/settings.py index d326698..7351a7f 100644 --- a/alfred/settings.py +++ b/alfred/settings.py @@ -20,7 +20,11 @@ class ConfigurationError(Exception): class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=[BASE_DIR / ".env.alfred", BASE_DIR / ".env.secrets", BASE_DIR / ".env.make"], + env_file=[ + BASE_DIR / ".env.alfred", + BASE_DIR / ".env.secrets", + BASE_DIR / ".env.make", + ], env_file_encoding="utf-8", extra="ignore", case_sensitive=False, @@ -41,7 +45,16 @@ class Settings(BaseSettings): ollama_base_url: str = "http://ollama:11434" ollama_model: str = "llama3.3:latest" deepseek_base_url: str = "https://api.deepseek.com" - deepseek_model: str = "deepseek-chat" + deepseek_model: str = "deepseek-chat" # TODO: update => https://api-docs.deepseek.com/quick_start/pricing + + # --- QBITTORRENT --- + qbittorrent_url: str = "http://localhost:8080" + qbittorrent_username: str = "admin" + qbittorrent_password: str = "adminadmin" + # Path translation: local path prefix → qBittorrent container path prefix + # e.g. QBITTORRENT_HOST_PATH=/mnt/testipool QBITTORRENT_CONTAINER_PATH=/mnt/data + qbittorrent_host_path: str | None = None + qbittorrent_container_path: str | None = None # --- API KEYS --- tmdb_api_key: str | None = None @@ -57,21 +70,27 @@ class Settings(BaseSettings): @classmethod def validate_temperature(cls, v: float) -> float: if not 0.0 <= v <= 2.0: - raise ConfigurationError(f"Temperature must be between 0.0 and 2.0, got {v}") + raise ConfigurationError( + f"Temperature must be between 0.0 and 2.0, got {v}" + ) return v @field_validator("max_tool_iterations") @classmethod def validate_max_iterations(cls, v: int) -> int: if not 1 <= v <= 20: - raise ConfigurationError(f"max_tool_iterations must be between 1 and 20, got {v}") + raise ConfigurationError( + f"max_tool_iterations must be between 1 and 20, got {v}" + ) return v @field_validator("request_timeout") @classmethod def validate_timeout(cls, v: int) -> int: if not 1 <= v <= 300: - raise ConfigurationError(f"request_timeout must be between 1 and 300 seconds, got {v}") + raise ConfigurationError( + f"request_timeout must be between 1 and 300 seconds, got {v}" + ) return v # --- HELPERS --- diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index a3a5d4f..cac8c8d 100644 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -4,12 +4,12 @@ import re import secrets import sys +import tomllib from pathlib import Path -import tomllib - BASE_DIR = Path(__file__).resolve().parent.parent + def load_secrets_spec(toml_data: dict) -> dict[str, tuple[int, str]]: """Load secrets spec from pyproject.toml [tool.alfred.secrets].""" raw = toml_data.get("tool", {}).get("alfred", {}).get("secrets", {}) @@ -58,11 +58,15 @@ def copy_example_if_missing(src: Path, dst: Path, label: str) -> None: def generate_secrets_file(path: Path, secrets_spec: dict[str, tuple[int, str]]) -> None: """Generate .env.secrets with missing secrets, never overwrite existing ones.""" existing = load_env_file(path) - lines = list(path.read_text().splitlines()) if path.exists() else [ - "# Auto-generated secrets — DO NOT COMMIT", - "# Run 'make bootstrap' to generate missing secrets", - "", - ] + lines = ( + list(path.read_text().splitlines()) + if path.exists() + else [ + "# Auto-generated secrets — DO NOT COMMIT", + "# Run 'make bootstrap' to generate missing secrets", + "", + ] + ) added = [] for key, (size, fmt) in secrets_spec.items(): @@ -108,7 +112,9 @@ def build_uris(env_alfred: Path, env_secrets: Path) -> None: added = [] for key, value in computed.items(): if key in existing: - content = re.sub(rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE) + content = re.sub( + rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE + ) else: content = content.rstrip("\n") + f"\n{key}={value}\n" added.append(key) diff --git a/scripts/config_loader.py b/scripts/config_loader.py index 7cbc6a2..d69c5f2 100644 --- a/scripts/config_loader.py +++ b/scripts/config_loader.py @@ -1,11 +1,10 @@ """Shared configuration loader — reads build config from pyproject.toml.""" import re +import tomllib from pathlib import Path from typing import NamedTuple -import tomllib - class BuildConfig(NamedTuple): """Build configuration extracted from pyproject.toml.""" diff --git a/testing/debug_release.py b/testing/debug_release.py new file mode 100644 index 0000000..fa28591 --- /dev/null +++ b/testing/debug_release.py @@ -0,0 +1,418 @@ +"""CLI de debug pour analyser une release et dry-run le déplacement.""" + +import json +import sys +from pathlib import Path + +# Permet de lancer le script depuis n'importe où sans install du package +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def _init_memory(): + from alfred.infrastructure.persistence import init_memory + from alfred.settings import settings + + init_memory(settings.data_storage_dir) + + +def _resolve_via_tmdb(release_name: str) -> tuple[str, int] | None: + """Parse le release name, interroge TMDB, retourne (tmdb_title, tmdb_year).""" + from alfred.application.movies import SearchMovieUseCase + from alfred.domain.release.services import parse_release + from alfred.infrastructure.api.tmdb import tmdb_client + + parsed = parse_release(release_name) + raw_title = parsed.title.replace(".", " ").strip() + + print(f" titre extrait : {raw_title}") + print(" interrogation TMDB...") + + use_case = SearchMovieUseCase(tmdb_client) + result = use_case.execute(raw_title).to_dict() + + if result.get("status") != "ok": + print(f" TMDB error: {result.get('message')}") + return None + + title = result["title"] + release_date = result.get("release_date", "") + year = int(release_date[:4]) if release_date and len(release_date) >= 4 else None + + if not year: + print(f" TMDB: pas d'année trouvée pour '{title}'") + return None + + print(f" TMDB: {title} ({year})") + return title, year + + +def _extract_release_name(release_name: str) -> tuple[str, str]: + """ + Retourne (release_name, source_path). + + Si c'est un path absolu existant → extrait le basename et utilise le path comme source. + Sinon → cherche le dossier dans workspace.download configuré en LTM. + """ + p = Path(release_name) + if p.is_absolute() and p.exists(): + return p.name, str(p) + + from alfred.infrastructure.persistence import get_memory + + memory = get_memory() + download_root = memory.ltm.workspace.download + if download_root: + candidate = Path(download_root) / release_name + if candidate.exists(): + return release_name, str(candidate) + + return release_name, "" + + +def analyze(release_name: str, source_path: str | None = None) -> None: + from alfred.domain.release.services import parse_release + + release_name, resolved_path = _extract_release_name(release_name) + if source_path is None and resolved_path: + source_path = resolved_path + + print(f"\n=== PARSE: {release_name} ===") + r = parse_release(release_name) + for k, v in vars(r).items(): + if v is not None and v != [] and v != "": + print(f" {k}: {v}") + + if source_path: + path = Path(source_path) + print(f"\n=== PROBE: {path} ===") + if not path.exists(): + print(" (chemin inexistant, probe skipped)") + else: + from alfred.infrastructure.filesystem.ffprobe import probe + from alfred.infrastructure.filesystem.find_video import find_video_file + + video = find_video_file(path) if path.is_dir() else path + if video: + print(f" video file: {video.name}") + info = probe(video) + if info: + print(f" codec: {info.video_codec}") + print(f" resolution: {info.resolution}") + print( + f" audio_tracks: {[(t.codec, t.language) for t in info.audio_tracks]}" + ) + print( + f" subtitle_tracks: {[(t.codec, t.language) for t in info.subtitle_tracks]}" + ) + else: + print(" probe failed (ffprobe dispo ?)") + else: + print(" aucun fichier vidéo trouvé") + + +def dry_run(release_name: str) -> None: + _init_memory() + release_name, _ = _extract_release_name(release_name) + + print(f"\n=== DRY-RUN: {release_name} ===") + tmdb = _resolve_via_tmdb(release_name) + if not tmdb: + sys.exit(1) + + tmdb_title, tmdb_year = tmdb + + from alfred.application.filesystem.resolve_destination import ( + resolve_season_destination, + ) + + result = resolve_season_destination(release_name, tmdb_title, tmdb_year) + d = result.to_dict() + print() + print(json.dumps(d, indent=2, ensure_ascii=False)) + + if d["status"] == "ok": + print("\n=== MOVE PREVIEW ===") + print(" src : ") + print(f" dst : {d['season_folder']}") + + +def _translate_path(path: str) -> str: + """Translate a host-side path to the qBittorrent container path.""" + from alfred.settings import settings + + host_prefix = settings.qbittorrent_host_path + container_prefix = settings.qbittorrent_container_path + if host_prefix and container_prefix and path.startswith(host_prefix): + return container_prefix + path[len(host_prefix) :] + return path + + +def _qbittorrent_update(torrent_name: str, new_location: str | None) -> None: + """ + Find the torrent in qBittorrent by name, update its save_path, and force recheck. + + Args: + torrent_name: Exact torrent name (release folder basename) + new_location: New save path on the host (parent of the torrent folder). + None if the torrent was sent to trash — skip location change. + """ + try: + from alfred.infrastructure.api.qbittorrent.client import QBittorrentClient + + client = QBittorrentClient() + client.login() + + torrent = client.find_by_name(torrent_name) + if torrent is None: + print(f" ⚠ qBittorrent: torrent '{torrent_name}' not found — skipping") + return + + print(f" qBittorrent: found '{torrent.name}' (hash={torrent.hash[:8]}…)") + + if new_location: + container_location = _translate_path(new_location) + client.set_location(torrent.hash, container_location) + print(f" ✓ qBittorrent: location → {container_location}") + + client.recheck(torrent.hash) + print(" ✓ qBittorrent: recheck triggered") + + except Exception as e: + # Non-fatal — the files are already in place + print(f" ⚠ qBittorrent update failed (non-fatal): {e}") + + +def do_move(release_name: str, source_folder: str | None = None) -> None: + _init_memory() + release_name, resolved_path = _extract_release_name(release_name) + if source_folder is None: + source_folder = resolved_path + if not source_folder: + print( + " Erreur: source introuvable. Configure workspace.download ou passe le path complet." + ) + sys.exit(1) + + print(f"\n=== MOVE: {release_name} ===") + tmdb = _resolve_via_tmdb(release_name) + if not tmdb: + sys.exit(1) + + tmdb_title, tmdb_year = tmdb + + from alfred.application.filesystem.resolve_destination import ( + resolve_season_destination, + ) + + result = resolve_season_destination(release_name, tmdb_title, tmdb_year) + d = result.to_dict() + + if d["status"] == "needs_clarification": + print(f"\n {d['question']}") + for i, opt in enumerate(d["options"]): + print(f" {i + 1}. {opt}") + choice = input(" Choix (numéro) : ").strip() + try: + chosen = d["options"][int(choice) - 1] + except (ValueError, IndexError): + print(" Choix invalide.") + sys.exit(1) + result = resolve_season_destination( + release_name, tmdb_title, tmdb_year, confirmed_folder=chosen + ) + d = result.to_dict() + + if d["status"] != "ok": + print(json.dumps(d, indent=2, ensure_ascii=False)) + sys.exit(1) + + src_path = Path(source_folder) + season_folder = d["season_folder"] + mkv_files = sorted(src_path.glob("*.mkv")) or sorted(src_path.glob("*.mp4")) + + from alfred.infrastructure.persistence import get_memory + + memory = get_memory() + torrent_root = memory.ltm.workspace.torrent + trash_root = memory.ltm.workspace.trash + torrent_dst = str(Path(torrent_root) / src_path.name) if torrent_root else None + trash_dst = str(Path(trash_root) / src_path.name) if trash_root else None + + rebuild = input(" Recréer le torrent ? [y/N] : ").strip().lower() == "y" + + # --- PHASE 1: PLAN --- + print("\n=== PLAN ===") + print(f" destination : {season_folder}") + + from alfred.application.filesystem.manage_subtitles import ManageSubtitlesUseCase + from alfred.domain.release.services import parse_release + + parsed = parse_release(release_name) + + # Dict: video_path → sub_result (pre-scanned, files not yet moved) + plan: list[tuple[Path, str, object]] = [] # (src_file, dst_path, sub_result) + has_errors = False + + for f in mkv_files: + dst = str(Path(season_folder) / f.name) + ghost_src = str(src_path / f.name) + sub_result = ManageSubtitlesUseCase().execute( + source_video=ghost_src, + destination_video=dst, + media_type="tv_show", + release_group=parsed.group, + season=parsed.season, + dry_run=True, + ) + + print(f"\n {f.name}") + print(f" → {dst}") + + if sub_result.status == "ok": + if sub_result.placed: + for p in sub_result.placed: + print(f" sub: {p.filename}") + elif sub_result.available: + for a in sub_result.available: + print(f" sub (embedded): {a.language} {a.subtitle_type}") + else: + print(" subs: aucun") + elif sub_result.status == "needs_clarification": + print(" ✗ subs non résolus:") + for u in sub_result.unresolved: + print(f" {u.raw_tokens} ({u.reason})") + has_errors = True + elif sub_result.status == "error": + print(f" ✗ erreur subs: {sub_result.message}") + has_errors = True + + plan.append((f, dst, sub_result)) + + if rebuild and torrent_dst: + print(f"\n source → torrents : {torrent_dst}") + print(f" hard-links : {len(mkv_files)} fichier(s)") + elif trash_dst: + print(f"\n source → trash : {trash_dst}") + else: + print("\n source : laissée en place") + + if has_errors: + print("\n ✗ Plan invalide — subs non résolus, abandon.") + sys.exit(1) + + # --- CONFIRMATION --- + print() + confirm = input(" Confirmer ? [y/N] : ").strip().lower() + if confirm != "y": + print(" Annulé.") + sys.exit(0) + + # --- PHASE 2: EXECUTE --- + import os + + from alfred.infrastructure.filesystem.filesystem_operations import ( + create_folder, + move, + ) + + print("\n=== EXECUTE ===") + + # 1. Créer le season_folder + r = create_folder(season_folder) + if r["status"] != "ok": + print(f" ✗ create_folder: {r}") + sys.exit(1) + print(f" ✓ dossier : {season_folder}") + + # 2. Déplacer chaque fichier vidéo + placer les subs (re-run après move) + for f, dst, _pre_scan in plan: + r = move(str(f), dst) + if r["status"] != "ok": + print(f" ✗ {f.name}: {r['message']}") + sys.exit(1) + print(f" ✓ {f.name}") + + # Re-run manage_subtitles maintenant que dst existe (pour le hard-link) + ghost_src = str(src_path / f.name) + sub_result = ManageSubtitlesUseCase().execute( + source_video=ghost_src, + destination_video=dst, + media_type="tv_show", + release_group=parsed.group, + season=parsed.season, + ) + if sub_result.status == "ok" and sub_result.placed: + for p in sub_result.placed: + print(f" ✓ sub: {p.filename}") + + # 3. Dossier source → torrents ou trash + if rebuild and torrent_dst: + r = move(source_folder, torrent_dst) + if r["status"] != "ok": + print(f" ✗ source → torrents: {r['message']}") + sys.exit(1) + print(" ✓ source → torrents") + + # 4. Hard-link depuis season_folder → torrent_dst + torrent_dst_path = Path(torrent_dst) + for f, dst, _ in plan: + lib_file = Path(season_folder) / f.name + link_dst = torrent_dst_path / f.name + try: + os.link(lib_file, link_dst) + print(f" ✓ hard-link: {f.name}") + except OSError as e: + print(f" ✗ hard-link {f.name}: {e}") + sys.exit(1) + + elif trash_dst: + r = move(source_folder, trash_dst) + if r["status"] != "ok": + print(f" ✗ source → trash: {r['message']}") + sys.exit(1) + print(" ✓ source → trash") + + # 5. qBittorrent: update location + recheck + qb_location = torrent_root if (rebuild and torrent_dst) else None + _qbittorrent_update(src_path.name, qb_location) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Debug release parsing + dry-run/move") + sub = parser.add_subparsers(dest="cmd") + + p_analyze = sub.add_parser( + "analyze", help="Parser une release (+ probe si path fourni)" + ) + p_analyze.add_argument("release_name") + p_analyze.add_argument("--path", help="Chemin vers le dossier/fichier source") + + p_dry = sub.add_parser( + "dryrun", help="Résout via TMDB et affiche les chemins sans rien bouger" + ) + p_dry.add_argument("release_name") + + p_move = sub.add_parser( + "move", help="Résout via TMDB et déplace le dossier (confirmation requise)" + ) + p_move.add_argument("release_name") + p_move.add_argument( + "source_folder", + nargs="?", + default=None, + help="Chemin absolu du dossier source (optionnel si workspace.download est configuré)", + ) + + args = parser.parse_args() + + if args.cmd == "analyze": + analyze(args.release_name, args.path) + elif args.cmd == "dryrun": + dry_run(args.release_name) + elif args.cmd == "move": + do_move(args.release_name, args.source_folder) + else: + parser.print_help() + sys.exit(1) diff --git a/testing/parse_release.py b/testing/parse_release.py index 1e13f2f..c40bef5 100644 --- a/testing/parse_release.py +++ b/testing/parse_release.py @@ -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) diff --git a/testing/probe_video.py b/testing/probe_video.py index 8decbe7..078a6bd 100644 --- a/testing/probe_video.py +++ b/testing/probe_video.py @@ -19,14 +19,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 @@ -54,6 +54,7 @@ def hr() -> None: # Formatting helpers # --------------------------------------------------------------------------- + def fmt_duration(seconds: float) -> str: h = int(seconds // 3600) m = int((seconds % 3600) // 60) @@ -80,6 +81,7 @@ def flag(val: bool) -> str: # Main # --------------------------------------------------------------------------- + def main() -> None: global USE_COLOR @@ -111,14 +113,14 @@ def main() -> None: # --- Video --- section("Video") - kv("codec", info.video_codec or c("—", DIM)) - kv("resolution", info.resolution or c("—", DIM)) + kv("codec", info.video_codec or c("—", DIM)) + kv("resolution", info.resolution or c("—", DIM)) if info.width and info.height: kv("dimensions", f"{info.width} × {info.height}") if info.duration_seconds is not None: - kv("duration", fmt_duration(info.duration_seconds)) + kv("duration", fmt_duration(info.duration_seconds)) if info.bitrate_kbps is not None: - kv("bitrate", f"{info.bitrate_kbps} kbps") + kv("bitrate", f"{info.bitrate_kbps} kbps") # --- Audio --- section(f"Audio {c(str(len(info.audio_tracks)) + ' track(s)', DIM)}") @@ -128,7 +130,7 @@ def main() -> None: lang = track.language or "und" default_marker = f" {c('default', GREEN, DIM)}" if track.is_default else "" print(f" {c(f'[{track.index}]', BOLD)} {c(lang, YELLOW)}{default_marker}") - kv("codec", track.codec or c("—", DIM), indent=8) + kv("codec", track.codec or c("—", DIM), indent=8) kv("channels", fmt_channels(track.channels, track.channel_layout), indent=8) # --- Subtitles --- @@ -151,7 +153,9 @@ def main() -> None: hr() multi = c("yes", GREEN) if info.is_multi_audio else c("no", DIM) langs = ", ".join(info.audio_languages) if info.audio_languages else c("—", DIM) - print(f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}") + print( + f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}" + ) hr() print() diff --git a/testing/recognize_folders_in_downloads.py b/testing/recognize_folders_in_downloads.py index 3ec0f31..498716d 100644 --- a/testing/recognize_folders_in_downloads.py +++ b/testing/recognize_folders_in_downloads.py @@ -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() diff --git a/testing/subtitles/scan_subtitles.py b/testing/subtitles/scan_subtitles.py index 0f0387f..7adb304 100644 --- a/testing/subtitles/scan_subtitles.py +++ b/testing/subtitles/scan_subtitles.py @@ -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] diff --git a/testing/workflows/run_workflow.py b/testing/workflows/run_workflow.py index ab46fbe..f238226 100755 --- a/testing/workflows/run_workflow.py +++ b/testing/workflows/run_workflow.py @@ -43,14 +43,14 @@ if str(_PROJECT_ROOT) not in sys.path: 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" +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" @@ -67,10 +67,22 @@ def section(title: str) -> None: 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 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}") @@ -79,6 +91,7 @@ def kv(key: str, val: str) -> None: # Dry-run tool stubs # --------------------------------------------------------------------------- + def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]: """Call the real list_folder (read-only, safe in dry-run).""" # TODO: remove hardcoded fallback once download path is configured in LTM @@ -86,20 +99,29 @@ def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]: try: from alfred.infrastructure.persistence import get_memory, init_memory + try: get_memory() except Exception: init_memory() from alfred.agent.tools.filesystem import list_folder + result = list_folder(folder_type=folder_type, path=path) if result.get("status") == "error" and folder_type == "download": raise RuntimeError(result.get("message", "not configured")) return result except Exception as e: if folder_type == "download": - warn(f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}") + warn( + f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}" + ) import os - resolved = os.path.join(_HARDCODED_DOWNLOAD_ROOT, path) if path != "." else _HARDCODED_DOWNLOAD_ROOT + + resolved = ( + os.path.join(_HARDCODED_DOWNLOAD_ROOT, path) + if path != "." + else _HARDCODED_DOWNLOAD_ROOT + ) try: entries = sorted(os.listdir(resolved)) except OSError as oe: @@ -125,11 +147,13 @@ def _real_find_media_imdb_id(media_title: str, **kwargs) -> dict[str, Any]: """Call the real TMDB API even in dry-run (read-only, no filesystem side effects).""" try: from alfred.infrastructure.persistence import get_memory, init_memory + try: get_memory() except Exception: init_memory() from alfred.agent.tools.api import find_media_imdb_id + return find_media_imdb_id(media_title=media_title) except Exception as e: warn(f"find_media_imdb_id: TMDB unavailable ({e}), falling back to stub") @@ -151,6 +175,7 @@ def _dry_resolve_destination( confirmed_folder: str | None = None, ) -> dict[str, Any]: from alfred.domain.release import parse_release + parsed = parse_release(release_name) ext = Path(source_file).suffix if parsed.is_movie: @@ -168,7 +193,11 @@ def _dry_resolve_destination( } 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 + 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}", @@ -201,7 +230,9 @@ def _dry_manage_subtitles(source_video: str, destination_video: str) -> dict[str } -def _dry_create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]: +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}", @@ -213,12 +244,12 @@ def _dry_create_seed_links(library_file: str, original_download_folder: str) -> DRY_RUN_TOOLS: dict[str, Any] = { - "list_folder": _real_list_folder, + "list_folder": _real_list_folder, "find_media_imdb_id": _real_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, + "move_media": _dry_move_media, + "manage_subtitles": _dry_manage_subtitles, + "create_seed_links": _dry_create_seed_links, } @@ -226,6 +257,7 @@ DRY_RUN_TOOLS: dict[str, Any] = { # Live tools # --------------------------------------------------------------------------- + def _load_live_tools() -> dict[str, Any]: from alfred.agent.tools.filesystem import ( create_seed_links, @@ -233,19 +265,25 @@ def _load_live_tools() -> dict[str, Any]: 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 { + "status": "error", + "error": "not_available", + "message": "api tools not loaded", + } return { - "list_folder": list_folder, + "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, + "move_media": move_media, + "manage_subtitles": manage_subtitles, + "create_seed_links": create_seed_links, } @@ -253,13 +291,20 @@ def _load_live_tools() -> dict[str, Any]: # Workflow runner # --------------------------------------------------------------------------- + class WorkflowRunner: - def __init__(self, workflow: dict, tools: dict[str, Any], live: bool, args: argparse.Namespace): + 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.context: dict[str, Any] = {} # step results accumulate here self.step_results: list[dict] = [] def run(self) -> None: @@ -281,11 +326,15 @@ class WorkflowRunner: 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"] + 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')}") + err( + f" {r['id']}: {r['result'].get('error')} — {r['result'].get('message')}" + ) print() print(c("━" * 70, BOLD)) print() @@ -306,7 +355,7 @@ class WorkflowRunner: 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["seeding"] = answer == "yes" self.context["ask_seeding_answer"] = answer self.context["next_after_ask"] = next_step @@ -332,7 +381,9 @@ class WorkflowRunner: 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"): + 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 @@ -349,14 +400,18 @@ class WorkflowRunner: 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"}}) + 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)}}) + self.step_results.append( + {"id": step_id, "result": {"status": "error", "error": str(e)}} + ) return self._print_result(result, tool_name=tool_name) @@ -364,14 +419,20 @@ class WorkflowRunner: self.step_results.append({"id": step_id, "result": result}) # After list_downloads: confirm the requested media folder exists in downloads - if tool_name == "list_folder" and result.get("status") == "ok" and self.args.source: + if ( + tool_name == "list_folder" + and result.get("status") == "ok" + and self.args.source + ): folder_path = result.get("path", "") entries = result.get("entries", []) if self.args.source in entries: media_folder = str(Path(folder_path) / self.args.source) self.context["media_folder"] = media_folder print() - print(f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}") + print( + f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}" + ) else: warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}") @@ -446,13 +507,17 @@ class WorkflowRunner: 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')}") + err( + f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}" + ) return # Highlight resolved folder path for list_folder if tool_name == "list_folder" and result.get("path"): print() - print(f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}") + print( + f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}" + ) # Pretty-print notable fields skip = {"status", "error", "message"} @@ -476,6 +541,7 @@ class WorkflowRunner: # CLI # --------------------------------------------------------------------------- + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Alfred workflow simulator", @@ -483,28 +549,58 @@ def parse_args() -> argparse.Namespace: 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="FOLDER_NAME", - help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)") - 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( + "--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="FOLDER_NAME", + help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)", + ) + 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() @@ -521,6 +617,7 @@ def main() -> None: # Load workflow from alfred.agent.workflows.loader import WorkflowLoader + loader = WorkflowLoader() workflow = loader.get(args.workflow) if not workflow: diff --git a/tests/agent/test_registry.py b/tests/agent/test_registry.py index 305c2a2..d474004 100644 --- a/tests/agent/test_registry.py +++ b/tests/agent/test_registry.py @@ -2,22 +2,20 @@ Tests for alfred.agent.registry — tool registration and JSON schema generation. """ -import pytest - from alfred.agent.registry import Tool, _create_tool_from_function, make_tools from alfred.settings import settings - # --------------------------------------------------------------------------- # _create_tool_from_function # --------------------------------------------------------------------------- -class TestCreateToolFromFunction: +class TestCreateToolFromFunction: def test_name_from_function(self): def my_tool(x: str) -> dict: """Does something.""" return {} + tool = _create_tool_from_function(my_tool) assert tool.name == "my_tool" @@ -28,12 +26,14 @@ class TestCreateToolFromFunction: More details here. """ return {} + tool = _create_tool_from_function(my_tool) assert tool.description == "First line description." def test_description_fallback_to_name(self): def no_doc(x: str) -> dict: return {} + tool = _create_tool_from_function(no_doc) assert tool.description == "no_doc" @@ -41,6 +41,7 @@ class TestCreateToolFromFunction: def tool(a: str, b: int) -> dict: """Tool.""" return {} + t = _create_tool_from_function(tool) assert "a" in t.parameters["required"] assert "b" in t.parameters["required"] @@ -49,6 +50,7 @@ class TestCreateToolFromFunction: def tool(a: str, b: str = "default") -> dict: """Tool.""" return {} + t = _create_tool_from_function(tool) assert "a" in t.parameters["required"] assert "b" not in t.parameters["required"] @@ -57,6 +59,7 @@ class TestCreateToolFromFunction: def tool(a: str, b: str | None = None) -> dict: """Tool.""" return {} + t = _create_tool_from_function(tool) assert "b" not in t.parameters["required"] @@ -64,6 +67,7 @@ class TestCreateToolFromFunction: def tool(x: str) -> dict: """T.""" return {} + t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "string" @@ -71,6 +75,7 @@ class TestCreateToolFromFunction: def tool(x: int) -> dict: """T.""" return {} + t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "integer" @@ -78,6 +83,7 @@ class TestCreateToolFromFunction: def tool(x: float) -> dict: """T.""" return {} + t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "number" @@ -85,6 +91,7 @@ class TestCreateToolFromFunction: def tool(x: bool) -> dict: """T.""" return {} + t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "boolean" @@ -92,6 +99,7 @@ class TestCreateToolFromFunction: def tool(x: list) -> dict: """T.""" return {} + t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "string" @@ -99,6 +107,7 @@ class TestCreateToolFromFunction: def tool(x) -> dict: """T.""" return {} + t = _create_tool_from_function(tool) assert t.parameters["properties"]["x"]["type"] == "string" @@ -107,6 +116,7 @@ class TestCreateToolFromFunction: def tool(self, x: str) -> dict: """T.""" return {} + t = _create_tool_from_function(MyClass().tool) assert "self" not in t.parameters["properties"] @@ -114,6 +124,7 @@ class TestCreateToolFromFunction: def tool(a: str, b: int = 0) -> dict: """T.""" return {} + t = _create_tool_from_function(tool) assert t.parameters["type"] == "object" assert "properties" in t.parameters @@ -123,6 +134,7 @@ class TestCreateToolFromFunction: def tool(x: str) -> dict: """T.""" return {"x": x} + t = _create_tool_from_function(tool) assert t.func("hello") == {"x": "hello"} @@ -131,8 +143,8 @@ class TestCreateToolFromFunction: # make_tools # --------------------------------------------------------------------------- -class TestMakeTools: +class TestMakeTools: def test_returns_dict(self): tools = make_tools(settings) assert isinstance(tools, dict) diff --git a/tests/application/conftest.py b/tests/application/conftest.py index fd85bdb..dbd24eb 100644 --- a/tests/application/conftest.py +++ b/tests/application/conftest.py @@ -2,7 +2,6 @@ import shutil import tempfile -from pathlib import Path import pytest @@ -25,7 +24,6 @@ def memory_configured(app_temp, tmp_path): Fresh Memory with library_paths and workspace configured using the real API. Replaces the broken memory_with_config from root conftest for these tests. """ - import tempfile, os storage = tempfile.mkdtemp() mem = Memory(storage_dir=storage) set_memory(mem) diff --git a/tests/application/test_create_seed_links.py b/tests/application/test_create_seed_links.py index b34ceda..d1d8186 100644 --- a/tests/application/test_create_seed_links.py +++ b/tests/application/test_create_seed_links.py @@ -2,10 +2,6 @@ Tests for alfred.application.filesystem.create_seed_links.CreateSeedLinksUseCase """ -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - import pytest from alfred.application.filesystem.create_seed_links import CreateSeedLinksUseCase @@ -32,7 +28,12 @@ def seed_env(tmp_path_factory): """ d = tmp_path_factory.mktemp("seed_env") - lib_dir = d / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST" + lib_dir = ( + d + / "tv" + / "Oz.1997.1080p.WEBRip.x265-KONTRAST" + / "Oz.S01.1080p.WEBRip.x265-KONTRAST" + ) lib_dir.mkdir(parents=True) lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4" lib_video.write_bytes(b"video") @@ -43,7 +44,9 @@ def seed_env(tmp_path_factory): (dl / "[TGx]info.txt").write_text("tgx") subs = dl / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST" subs.mkdir(parents=True) - (subs / "2_eng,English [CC][SDH].srt").write_text("1\n00:00:01 --> 00:00:02\nHello\n") + (subs / "2_eng,English [CC][SDH].srt").write_text( + "1\n00:00:01 --> 00:00:02\nHello\n" + ) torrents = d / "torrents" torrents.mkdir() @@ -55,10 +58,13 @@ def seed_env(tmp_path_factory): # Happy path # --------------------------------------------------------------------------- -class TestCreateSeedLinksHappyPath: - def test_ok_when_torrent_folder_configured(self, use_case, seed_env, memory_configured): +class TestCreateSeedLinksHappyPath: + def test_ok_when_torrent_folder_configured( + self, use_case, seed_env, memory_configured + ): from alfred.infrastructure.persistence import get_memory + mem = get_memory() lib_video, dl, torrents = seed_env mem.ltm.workspace.torrent = str(torrents) @@ -73,6 +79,7 @@ class TestCreateSeedLinksHappyPath: def test_to_dict_ok(self, use_case, seed_env, memory_configured): from alfred.infrastructure.persistence import get_memory + mem = get_memory() lib_video, dl, torrents = seed_env mem.ltm.workspace.torrent = str(torrents) @@ -89,8 +96,8 @@ class TestCreateSeedLinksHappyPath: # Error: torrent folder not configured # --------------------------------------------------------------------------- -class TestCreateSeedLinksErrors: +class TestCreateSeedLinksErrors: def test_error_when_torrent_not_configured(self, use_case, seed_env, memory): lib_video, dl, _ = seed_env result = use_case.execute(str(lib_video), str(dl)) @@ -109,6 +116,7 @@ class TestCreateSeedLinksErrors: def test_error_delegates_to_file_manager(self, memory_configured): """FileManager errors are propagated correctly.""" from alfred.infrastructure.persistence import get_memory + mem = get_memory() # torrent already configured by memory_configured fixture # library_file does not exist → should propagate error from FileManager diff --git a/tests/application/test_list_folder_move_media.py b/tests/application/test_list_folder_move_media.py index aee4092..d0383dd 100644 --- a/tests/application/test_list_folder_move_media.py +++ b/tests/application/test_list_folder_move_media.py @@ -1,31 +1,31 @@ """Tests for ListFolderUseCase and MoveMediaUseCase.""" -import pytest from unittest.mock import MagicMock from alfred.application.filesystem.list_folder import ListFolderUseCase from alfred.application.filesystem.move_media import MoveMediaUseCase - # --------------------------------------------------------------------------- # ListFolderUseCase # --------------------------------------------------------------------------- -class TestListFolderUseCase: +class TestListFolderUseCase: def _use_case(self, fm_result): fm = MagicMock() fm.list_folder.return_value = fm_result return ListFolderUseCase(fm) def test_success_returns_response(self): - uc = self._use_case({ - "status": "ok", - "folder_type": "download", - "path": ".", - "entries": ["movie.mkv", "show/"], - "count": 2, - }) + uc = self._use_case( + { + "status": "ok", + "folder_type": "download", + "path": ".", + "entries": ["movie.mkv", "show/"], + "count": 2, + } + ) resp = uc.execute("download") assert resp.status == "ok" assert resp.folder_type == "download" @@ -34,11 +34,13 @@ class TestListFolderUseCase: assert resp.count == 2 def test_error_propagates(self): - uc = self._use_case({ - "status": "error", - "error": "folder_not_set", - "message": "Download folder not configured.", - }) + uc = self._use_case( + { + "status": "error", + "error": "folder_not_set", + "message": "Download folder not configured.", + } + ) resp = uc.execute("download") assert resp.status == "error" assert resp.error == "folder_not_set" @@ -60,30 +62,37 @@ class TestListFolderUseCase: def test_default_path_is_dot(self): fm = MagicMock() fm.list_folder.return_value = { - "status": "ok", "folder_type": "download", - "path": ".", "entries": [], "count": 0, + "status": "ok", + "folder_type": "download", + "path": ".", + "entries": [], + "count": 0, } uc = ListFolderUseCase(fm) uc.execute("download") fm.list_folder.assert_called_once_with("download", ".") def test_success_response_has_no_error(self): - uc = self._use_case({ - "status": "ok", - "folder_type": "movie", - "path": ".", - "entries": [], - "count": 0, - }) + uc = self._use_case( + { + "status": "ok", + "folder_type": "movie", + "path": ".", + "entries": [], + "count": 0, + } + ) resp = uc.execute("movie") assert resp.error is None def test_error_response_has_no_entries(self): - uc = self._use_case({ - "status": "error", - "error": "not_found", - "message": "Path does not exist", - }) + uc = self._use_case( + { + "status": "error", + "error": "not_found", + "message": "Path does not exist", + } + ) resp = uc.execute("download", "some/path") assert resp.entries is None assert resp.count is None @@ -93,8 +102,8 @@ class TestListFolderUseCase: # MoveMediaUseCase # --------------------------------------------------------------------------- -class TestMoveMediaUseCase: +class TestMoveMediaUseCase: def _use_case(self, fm_result): fm = MagicMock() fm.move_file.return_value = fm_result @@ -103,13 +112,15 @@ class TestMoveMediaUseCase: def test_success_returns_response(self, tmp_path): src = str(tmp_path / "src.mkv") dst = str(tmp_path / "dst.mkv") - uc = self._use_case({ - "status": "ok", - "source": src, - "destination": dst, - "filename": "dst.mkv", - "size": 1024, - }) + uc = self._use_case( + { + "status": "ok", + "source": src, + "destination": dst, + "filename": "dst.mkv", + "size": 1024, + } + ) resp = uc.execute(src, dst) assert resp.status == "ok" assert resp.source == src @@ -118,11 +129,13 @@ class TestMoveMediaUseCase: assert resp.size == 1024 def test_error_propagates(self, tmp_path): - uc = self._use_case({ - "status": "error", - "error": "source_not_found", - "message": "Source does not exist: /ghost.mkv", - }) + uc = self._use_case( + { + "status": "error", + "error": "source_not_found", + "message": "Source does not exist: /ghost.mkv", + } + ) resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv")) assert resp.status == "error" assert resp.error == "source_not_found" @@ -132,19 +145,24 @@ class TestMoveMediaUseCase: dst = "/movies/Movie.2024/movie.mkv" fm = MagicMock() fm.move_file.return_value = { - "status": "ok", "source": src, "destination": dst, - "filename": "movie.mkv", "size": 1, + "status": "ok", + "source": src, + "destination": dst, + "filename": "movie.mkv", + "size": 1, } uc = MoveMediaUseCase(fm) uc.execute(src, dst) fm.move_file.assert_called_once_with(src, dst) def test_error_response_has_no_paths(self): - uc = self._use_case({ - "status": "error", - "error": "destination_exists", - "message": "File already exists", - }) + uc = self._use_case( + { + "status": "error", + "error": "destination_exists", + "message": "File already exists", + } + ) resp = uc.execute("/src.mkv", "/dst.mkv") assert resp.source is None assert resp.destination is None @@ -153,13 +171,15 @@ class TestMoveMediaUseCase: def test_to_dict_success(self, tmp_path): src = "/downloads/movie.mkv" dst = "/movies/movie.mkv" - uc = self._use_case({ - "status": "ok", - "source": src, - "destination": dst, - "filename": "movie.mkv", - "size": 2048, - }) + uc = self._use_case( + { + "status": "ok", + "source": src, + "destination": dst, + "filename": "movie.mkv", + "size": 2048, + } + ) resp = uc.execute(src, dst) d = resp.to_dict() assert d["status"] == "ok" @@ -167,11 +187,13 @@ class TestMoveMediaUseCase: assert d["size"] == 2048 def test_to_dict_error(self): - uc = self._use_case({ - "status": "error", - "error": "link_failed", - "message": "Cross-device link not permitted", - }) + uc = self._use_case( + { + "status": "error", + "error": "link_failed", + "message": "Cross-device link not permitted", + } + ) resp = uc.execute("/src.mkv", "/dst.mkv") d = resp.to_dict() assert d["status"] == "error" diff --git a/tests/application/test_resolve_destination.py b/tests/application/test_resolve_destination.py index a4af274..17b46b4 100644 --- a/tests/application/test_resolve_destination.py +++ b/tests/application/test_resolve_destination.py @@ -7,18 +7,16 @@ No network calls — TMDB data is passed in directly. from pathlib import Path -import pytest - from alfred.application.filesystem.resolve_destination import ( ResolveDestinationUseCase, _find_existing_series_folders, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _use_case(): return ResolveDestinationUseCase() @@ -27,8 +25,8 @@ def _use_case(): # Movies # --------------------------------------------------------------------------- -class TestResolveMovie: +class TestResolveMovie: def test_basic_movie(self, memory_configured): result = _use_case().execute( release_name="Another.Round.2020.1080p.BluRay.x264-YTS", @@ -101,8 +99,8 @@ class TestResolveMovie: # TV shows — no existing folder # --------------------------------------------------------------------------- -class TestResolveTVShowNewFolder: +class TestResolveTVShowNewFolder: def test_oz_s01_creates_new_folder(self, memory_configured): result = _use_case().execute( release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST", @@ -164,11 +162,10 @@ class TestResolveTVShowNewFolder: # TV shows — existing folder matching # --------------------------------------------------------------------------- -class TestResolveTVShowExistingFolder: +class TestResolveTVShowExistingFolder: def _make_series_folder(self, tv_root, name): """Create a series folder in the tv library.""" - import os path = tv_root / name path.mkdir(parents=True, exist_ok=True) return path @@ -176,6 +173,7 @@ class TestResolveTVShowExistingFolder: def test_uses_existing_single_folder(self, memory_configured, app_temp): """When exactly one folder matches title+year, use it regardless of group.""" from alfred.infrastructure.persistence import get_memory + mem = get_memory() tv_root = Path(mem.ltm.library_paths.get("tv_show")) @@ -195,11 +193,16 @@ class TestResolveTVShowExistingFolder: def test_needs_clarification_on_multiple_folders(self, memory_configured, app_temp): """When multiple folders match, return needs_clarification with options.""" from alfred.infrastructure.persistence import get_memory + mem = get_memory() tv_root = Path(mem.ltm.library_paths.get("tv_show")) - (tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True) - (tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir(parents=True, exist_ok=True) + (tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-RARBG").mkdir( + parents=True, exist_ok=True + ) + (tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir( + parents=True, exist_ok=True + ) result = _use_case().execute( release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST", @@ -216,6 +219,7 @@ class TestResolveTVShowExistingFolder: def test_confirmed_folder_bypasses_detection(self, memory_configured, app_temp): """confirmed_folder skips the folder search.""" from alfred.infrastructure.persistence import get_memory + mem = get_memory() tv_root = Path(mem.ltm.library_paths.get("tv_show")) chosen = "Slow.Horses.2022.1080p.WEBRip.x265-RARBG" @@ -233,10 +237,13 @@ class TestResolveTVShowExistingFolder: def test_to_dict_needs_clarification(self, memory_configured, app_temp): from alfred.infrastructure.persistence import get_memory + mem = get_memory() tv_root = Path(mem.ltm.library_paths.get("tv_show")) (tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True) - (tv_root / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir(parents=True, exist_ok=True) + (tv_root / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir( + parents=True, exist_ok=True + ) result = _use_case().execute( release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST", @@ -266,8 +273,8 @@ class TestResolveTVShowExistingFolder: # _find_existing_series_folders # --------------------------------------------------------------------------- -class TestFindExistingSeriesFolders: +class TestFindExistingSeriesFolders: def test_empty_library(self, tmp_path): assert _find_existing_series_folders(tmp_path, "Oz", 1997) == [] @@ -284,7 +291,7 @@ class TestFindExistingSeriesFolders: (tmp_path / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir() result = _find_existing_series_folders(tmp_path, "Oz", 1997) assert len(result) == 2 - assert sorted(result) == result # sorted + assert sorted(result) == result # sorted def test_no_match_different_year(self, tmp_path): (tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir() diff --git a/tests/domain/test_release_parser.py b/tests/domain/test_release_parser.py index bf737ed..2dc51fa 100644 --- a/tests/domain/test_release_parser.py +++ b/tests/domain/test_release_parser.py @@ -5,23 +5,30 @@ Real-data cases sourced from /mnt/testipool/downloads/. Covers: parsing, normalisation, naming methods, edge cases. """ -import pytest - -from alfred.domain.release import ParsedRelease, parse_release +from alfred.domain.release import parse_release from alfred.domain.release.services import _normalise -from alfred.domain.release.value_objects import _sanitise_for_fs, _strip_episode_from_normalised - +from alfred.domain.release.value_objects import ( + _sanitise_for_fs, + _strip_episode_from_normalised, +) # --------------------------------------------------------------------------- # _normalise # --------------------------------------------------------------------------- + class TestNormalise: def test_dots_unchanged(self): - assert _normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST" + assert ( + _normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST") + == "Oz.S01.1080p.WEBRip.x265-KONTRAST" + ) def test_spaces_become_dots(self): - assert _normalise("Oz S01 1080p WEBRip x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST" + assert ( + _normalise("Oz S01 1080p WEBRip x265-KONTRAST") + == "Oz.S01.1080p.WEBRip.x265-KONTRAST" + ) def test_double_dots_collapsed(self): assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p" @@ -31,7 +38,9 @@ class TestNormalise: def test_mixed_spaces_and_dots(self): # "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb" - result = _normalise("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb") + result = _normalise( + "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb" + ) assert " " not in result assert ".." not in result @@ -40,6 +49,7 @@ class TestNormalise: # _sanitise_for_fs # --------------------------------------------------------------------------- + class TestSanitiseForFs: def test_clean_string_unchanged(self): assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST" @@ -65,28 +75,38 @@ class TestSanitiseForFs: # _strip_episode_from_normalised # --------------------------------------------------------------------------- + class TestStripEpisode: def test_strips_single_episode(self): - assert _strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST") \ - == "Oz.S01.1080p.WEBRip.x265-KONTRAST" + assert ( + _strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST") + == "Oz.S01.1080p.WEBRip.x265-KONTRAST" + ) def test_strips_multi_episode(self): - assert _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") \ - == "Archer.S14.1080p.HULU.WEB-DL-NTb" + assert ( + _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") + == "Archer.S14.1080p.HULU.WEB-DL-NTb" + ) def test_season_pack_unchanged(self): - assert _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") \ - == "Oz.S01.1080p.WEBRip.x265-KONTRAST" + assert ( + _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") + == "Oz.S01.1080p.WEBRip.x265-KONTRAST" + ) def test_case_insensitive(self): - assert _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") \ - == "oz.s01.1080p-KONTRAST" + assert ( + _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") + == "oz.s01.1080p-KONTRAST" + ) # --------------------------------------------------------------------------- # parse_release — Season packs (dots) # --------------------------------------------------------------------------- + class TestSeasonPackDots: """Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc.""" @@ -135,13 +155,17 @@ class TestSeasonPackDots: assert p.group == "RARBG" def test_gilmore_girls_s01_s07_repack(self): - p = parse_release("Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH") + p = parse_release( + "Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH" + ) # Season range — we parse the first season number found assert p.season == 1 assert p.group == "MONOLITH" def test_plot_against_america_4k(self): - p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY") + p = parse_release( + "The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY" + ) assert p.title == "The.Plot.Against.America" assert p.season == 1 assert p.quality == "2160p" @@ -165,6 +189,7 @@ class TestSeasonPackDots: # parse_release — Single episodes (dots) # --------------------------------------------------------------------------- + class TestSingleEpisodeDots: """Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc.""" @@ -211,19 +236,23 @@ class TestSingleEpisodeDots: # parse_release — Multi-episode # --------------------------------------------------------------------------- + class TestMultiEpisode: def test_archer_triple_episode(self): # "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb" - p = parse_release("Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb") + p = parse_release( + "Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb" + ) assert p.season == 14 assert p.episode == 9 - assert p.episode_end == 10 # only first E-pair captured by regex group 2+3 + assert p.episode_end == 10 # only first E-pair captured by regex group 2+3 # --------------------------------------------------------------------------- # parse_release — Movies # --------------------------------------------------------------------------- + class TestMovies: def test_another_round_yts(self): # "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised @@ -276,6 +305,7 @@ class TestMovies: # parse_release — Space-separated (no dots) # --------------------------------------------------------------------------- + class TestSpaceSeparated: def test_oz_spaces(self): p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST") @@ -285,7 +315,9 @@ class TestSpaceSeparated: assert p.group == "KONTRAST" def test_archer_spaces(self): - p = parse_release("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb") + p = parse_release( + "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb" + ) assert p.season == 14 assert p.episode == 9 assert p.group == "NTb" @@ -295,6 +327,7 @@ class TestSpaceSeparated: # parse_release — tech_string # --------------------------------------------------------------------------- + class TestTechString: def test_full_tech(self): p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST") @@ -312,7 +345,9 @@ class TestTechString: assert "Unknown" in folder def test_4k_hdr(self): - p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY") + p = parse_release( + "The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY" + ) assert p.quality == "2160p" @@ -320,8 +355,8 @@ class TestTechString: # ParsedRelease — naming methods # --------------------------------------------------------------------------- -class TestNamingMethods: +class TestNamingMethods: def test_show_folder_name(self): p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST") assert p.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST" @@ -370,7 +405,10 @@ class TestNamingMethods: def test_movie_folder_name(self): p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS") - assert p.movie_folder_name("Another Round", 2020) == "Another.Round.2020.1080p.BluRay.x264-YTS" + assert ( + p.movie_folder_name("Another Round", 2020) + == "Another.Round.2020.1080p.BluRay.x264-YTS" + ) def test_movie_filename(self): p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS") @@ -379,13 +417,16 @@ class TestNamingMethods: def test_movie_folder_same_as_show_folder(self): p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG") - assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name("Revolver", 2005) + assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name( + "Revolver", 2005 + ) # --------------------------------------------------------------------------- # ParsedRelease — is_movie / is_season_pack # --------------------------------------------------------------------------- + class TestMediaTypeFlags: def test_season_pack_is_not_movie(self): p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST") @@ -412,11 +453,13 @@ class TestMediaTypeFlags: # Tricky real-world releases # --------------------------------------------------------------------------- -class TestRealWorldEdgeCases: +class TestRealWorldEdgeCases: def test_angel_integrale_multi(self): # "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod" - p = parse_release("Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod") + p = parse_release( + "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod" + ) assert p.year == 1999 assert p.quality == "1080p" assert p.source == "WEBRip" diff --git a/tests/domain/test_shared_value_objects.py b/tests/domain/test_shared_value_objects.py index 6ae1435..9ba42f0 100644 --- a/tests/domain/test_shared_value_objects.py +++ b/tests/domain/test_shared_value_objects.py @@ -1,18 +1,18 @@ """Tests for shared domain value objects: ImdbId, FilePath, FileSize.""" -import pytest from pathlib import Path +import pytest + from alfred.domain.shared.exceptions import ValidationError from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId - # --------------------------------------------------------------------------- # ImdbId # --------------------------------------------------------------------------- -class TestImdbId: +class TestImdbId: def test_valid_7_digits(self): id_ = ImdbId("tt1375666") assert str(id_) == "tt1375666" @@ -31,7 +31,7 @@ class TestImdbId: def test_too_few_digits_raises(self): with pytest.raises(ValidationError): - ImdbId("tt12345") # only 5 digits + ImdbId("tt12345") # only 5 digits def test_too_many_digits_raises(self): with pytest.raises(ValidationError): @@ -58,8 +58,8 @@ class TestImdbId: # FilePath # --------------------------------------------------------------------------- -class TestFilePath: +class TestFilePath: def test_from_string(self, tmp_path): p = FilePath(str(tmp_path)) assert isinstance(p.value, Path) @@ -98,8 +98,8 @@ class TestFilePath: # FileSize # --------------------------------------------------------------------------- -class TestFileSize: +class TestFileSize: def test_bytes(self): s = FileSize(500) assert s.bytes == 500 @@ -128,7 +128,7 @@ class TestFileSize: assert "MB" in result def test_human_readable_gb(self): - result = FileSize(2 * 1024 ** 3).to_human_readable() + result = FileSize(2 * 1024**3).to_human_readable() assert "GB" in result def test_str_is_human_readable(self): diff --git a/tests/domain/test_subtitle_scanner.py b/tests/domain/test_subtitle_scanner.py index 3220b77..a1b141a 100644 --- a/tests/domain/test_subtitle_scanner.py +++ b/tests/domain/test_subtitle_scanner.py @@ -1,6 +1,5 @@ """Tests for SubtitleScanner and _classify helper.""" -import pytest from pathlib import Path from alfred.domain.subtitles.scanner import ( @@ -9,13 +8,12 @@ from alfred.domain.subtitles.scanner import ( _classify, ) - # --------------------------------------------------------------------------- # _classify — unit tests for the filename parser # --------------------------------------------------------------------------- -class TestClassify: +class TestClassify: def test_iso_lang_code(self, tmp_path): p = tmp_path / "fr.srt" p.write_text("") @@ -86,8 +84,8 @@ class TestClassify: # SubtitleCandidate.destination_name # --------------------------------------------------------------------------- -class TestSubtitleCandidateDestinationName: +class TestSubtitleCandidateDestinationName: def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None): return SubtitleCandidate( source_path=path or Path("/fake/fr.srt"), @@ -117,8 +115,8 @@ class TestSubtitleCandidateDestinationName: # SubtitleScanner — integration with real filesystem # --------------------------------------------------------------------------- -class TestSubtitleScanner: +class TestSubtitleScanner: def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True): return SubtitleScanner( languages=languages or ["fr", "en"], diff --git a/tests/domain/test_tv_shows.py b/tests/domain/test_tv_shows.py index a91b7f9..eac1f3c 100644 --- a/tests/domain/test_tv_shows.py +++ b/tests/domain/test_tv_shows.py @@ -6,13 +6,12 @@ from alfred.domain.shared.exceptions import ValidationError from alfred.domain.tv_shows.entities import Episode, Season, TVShow from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus - # --------------------------------------------------------------------------- # ShowStatus # --------------------------------------------------------------------------- -class TestShowStatus: +class TestShowStatus: def test_from_string_ongoing(self): assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING @@ -32,8 +31,8 @@ class TestShowStatus: # SeasonNumber # --------------------------------------------------------------------------- -class TestSeasonNumber: +class TestSeasonNumber: def test_valid_season(self): s = SeasonNumber(1) assert s.value == 1 @@ -67,8 +66,8 @@ class TestSeasonNumber: # EpisodeNumber # --------------------------------------------------------------------------- -class TestEpisodeNumber: +class TestEpisodeNumber: def test_valid_episode(self): e = EpisodeNumber(1) assert e.value == 1 @@ -95,10 +94,14 @@ class TestEpisodeNumber: # TVShow entity # --------------------------------------------------------------------------- -class TestTVShow: - def _make(self, imdb_id="tt0903747", title="Breaking Bad", seasons=5, status="ended"): - return TVShow(imdb_id=imdb_id, title=title, seasons_count=seasons, status=status) +class TestTVShow: + def _make( + self, imdb_id="tt0903747", title="Breaking Bad", seasons=5, status="ended" + ): + return TVShow( + imdb_id=imdb_id, title=title, seasons_count=seasons, status=status + ) def test_basic_creation(self): show = self._make() @@ -108,6 +111,7 @@ class TestTVShow: def test_coerces_string_imdb_id(self): show = self._make() from alfred.domain.shared.value_objects import ImdbId + assert isinstance(show.imdb_id, ImdbId) def test_coerces_string_status(self): @@ -151,8 +155,8 @@ class TestTVShow: # Season entity # --------------------------------------------------------------------------- -class TestSeason: +class TestSeason: def test_basic_creation(self): s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7) assert s.episode_count == 7 @@ -171,7 +175,12 @@ class TestSeason: Season(show_imdb_id="tt0903747", season_number=1, episode_count=-1) def test_str(self): - s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7, name="Pilot Season") + s = Season( + show_imdb_id="tt0903747", + season_number=1, + episode_count=7, + name="Pilot Season", + ) assert "Pilot Season" in str(s) @@ -179,8 +188,8 @@ class TestSeason: # Episode entity # --------------------------------------------------------------------------- -class TestEpisode: +class TestEpisode: def test_basic_creation(self): e = Episode( show_imdb_id="tt0903747", diff --git a/tests/infrastructure/conftest.py b/tests/infrastructure/conftest.py index aa4f551..8d0fc1b 100644 --- a/tests/infrastructure/conftest.py +++ b/tests/infrastructure/conftest.py @@ -2,7 +2,6 @@ import shutil import tempfile -from pathlib import Path import pytest diff --git a/tests/infrastructure/test_file_manager.py b/tests/infrastructure/test_file_manager.py index 3951350..30bbc9b 100644 --- a/tests/infrastructure/test_file_manager.py +++ b/tests/infrastructure/test_file_manager.py @@ -5,13 +5,12 @@ Uses real temp filesystem. No mocks on os.link — we test the actual behavior. """ import os -import stat from pathlib import Path import pytest -from alfred.infrastructure.filesystem.file_manager import FileManager from alfred.infrastructure.filesystem.exceptions import PathTraversalError +from alfred.infrastructure.filesystem.file_manager import FileManager @pytest.fixture @@ -23,8 +22,8 @@ def fm(): # copy_file (hard-link) # --------------------------------------------------------------------------- -class TestCopyFile: +class TestCopyFile: def test_creates_hard_link(self, fm, tmp_path): src = tmp_path / "source.mkv" src.write_bytes(b"video data") @@ -80,8 +79,8 @@ class TestCopyFile: # move_file # --------------------------------------------------------------------------- -class TestMoveFile: +class TestMoveFile: def test_moves_file(self, fm, tmp_path): src = tmp_path / "episode.mkv" src.write_bytes(b"video") @@ -132,8 +131,8 @@ class TestMoveFile: # create_seed_links # --------------------------------------------------------------------------- -class TestCreateSeedLinks: +class TestCreateSeedLinks: def _setup(self, tmp_path): """Create realistic download + library + torrent structure.""" download = tmp_path / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST" @@ -146,7 +145,12 @@ class TestCreateSeedLinks: subs.mkdir(parents=True) (subs / "2_eng.srt").write_text("subtitle content") - library = tmp_path / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST" + library = ( + tmp_path + / "tv" + / "Oz.1997.1080p.WEBRip.x265-KONTRAST" + / "Oz.S01.1080p.WEBRip.x265-KONTRAST" + ) library.mkdir(parents=True) lib_video = library / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4" # Hard-link the video to simulate post-move state @@ -188,7 +192,13 @@ class TestCreateSeedLinks: lib_video, download, torrents = self._setup(tmp_path) fm.create_seed_links(str(lib_video), str(download), str(torrents)) - srt = torrents / "Oz.S01.1080p.WEBRip.x265-KONTRAST" / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST" / "2_eng.srt" + srt = ( + torrents + / "Oz.S01.1080p.WEBRip.x265-KONTRAST" + / "Subs" + / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST" + / "2_eng.srt" + ) assert srt.exists() def test_returns_copied_and_skipped(self, fm, tmp_path): @@ -270,8 +280,8 @@ class TestCreateSeedLinks: # list_folder # --------------------------------------------------------------------------- -class TestListFolder: +class TestListFolder: def test_lists_entries(self, fm, memory_configured, infra_temp): result = fm.list_folder("download") assert result["status"] == "ok" @@ -300,8 +310,8 @@ class TestListFolder: # _sanitize_path # --------------------------------------------------------------------------- -class TestSanitizePath: +class TestSanitizePath: def test_normal_path(self, fm): assert fm._sanitize_path("some/path") == "some/path" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 3d5f536..9be5619 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -16,7 +16,6 @@ from bootstrap import ( load_env_file, ) - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -171,11 +170,11 @@ class TestBuildUris: build_uris(alfred_file, secrets_file) uri = load_env_file(secrets_file)["MONGO_URI"] - assert "alfred" in uri # user - assert "cafebabe" in uri # password from secrets - assert "mongodb" in uri # host - assert "27017" in uri # port - assert "mydb" in uri # dbname + assert "alfred" in uri # user + assert "cafebabe" in uri # password from secrets + assert "mongodb" in uri # host + assert "27017" in uri # port + assert "mydb" in uri # dbname assert "authSource=admin" in uri def test_postgres_uri_contains_all_components(self, alfred_file, secrets_file): @@ -183,7 +182,7 @@ class TestBuildUris: uri = load_env_file(secrets_file)["POSTGRES_URI"] assert "alfred" in uri - assert "f00dface" in uri # password from secrets + assert "f00dface" in uri # password from secrets assert "vectordb" in uri assert "5432" in uri assert uri.startswith("postgresql://") @@ -191,7 +190,9 @@ class TestBuildUris: def test_uri_is_updated_when_host_changes(self, tmp_path, secrets_file): """If MONGO_HOST changes in .env.alfred, the URI must reflect it.""" alfred = tmp_path / ".env.alfred" - alfred.write_text(ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost")) + alfred.write_text( + ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost") + ) build_uris(alfred, secrets_file) uri = load_env_file(secrets_file)["MONGO_URI"] @@ -217,7 +218,9 @@ class TestBuildUris: uri_v1 = load_env_file(secrets_file)["MONGO_URI"] alfred_v2 = tmp_path / "alfred_v2" - alfred_v2.write_text(ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb")) + alfred_v2.write_text( + ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb") + ) build_uris(alfred_v2, secrets_file) uri_v2 = load_env_file(secrets_file)["MONGO_URI"] @@ -265,12 +268,15 @@ class TestCopyExampleIfMissing: class TestExtractPythonVersion: - @pytest.mark.parametrize("spec,expected_full,expected_short", [ - ("==3.14.3", "3.14.3", "3.14"), - ("^3.12.0", "3.12.0", "3.12"), - ("~3.11.1", "3.11.1", "3.11"), - ("3.10.5", "3.10.5", "3.10"), - ]) + @pytest.mark.parametrize( + "spec,expected_full,expected_short", + [ + ("==3.14.3", "3.14.3", "3.14"), + ("^3.12.0", "3.12.0", "3.12"), + ("~3.11.1", "3.11.1", "3.11"), + ("3.10.5", "3.10.5", "3.10"), + ], + ) def test_parses_version_specifiers(self, spec, expected_full, expected_short): full, short = extract_python_version(spec) assert full == expected_full diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 0d72d9f..eb25b4c 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -1,6 +1,7 @@ """Tests for PromptBuilder.""" from alfred.agent.prompts import PromptBuilder + from alfred.agent.registry import make_tools from alfred.settings import settings diff --git a/tests/test_prompts_critical.py b/tests/test_prompts_critical.py index 763c2db..7dbd6c5 100644 --- a/tests/test_prompts_critical.py +++ b/tests/test_prompts_critical.py @@ -1,6 +1,7 @@ """Critical tests for prompt builder - Tests that would have caught bugs.""" from alfred.agent.prompts import PromptBuilder + from alfred.agent.registry import make_tools from alfred.settings import settings diff --git a/tests/test_prompts_edge_cases.py b/tests/test_prompts_edge_cases.py index ebf94ca..17dc419 100644 --- a/tests/test_prompts_edge_cases.py +++ b/tests/test_prompts_edge_cases.py @@ -1,6 +1,7 @@ """Edge case tests for PromptBuilder.""" from alfred.agent.prompts import PromptBuilder + from alfred.agent.registry import make_tools from alfred.settings import settings diff --git a/tests/test_registry_critical.py b/tests/test_registry_critical.py index 4f71844..4bd5b21 100644 --- a/tests/test_registry_critical.py +++ b/tests/test_registry_critical.py @@ -3,8 +3,8 @@ import inspect import pytest - from alfred.agent.prompts import PromptBuilder + from alfred.agent.registry import Tool, _create_tool_from_function, make_tools from alfred.settings import settings diff --git a/tests/test_tools_language.py b/tests/test_tools_language.py index 9b2b4fa..02c6639 100644 --- a/tests/test_tools_language.py +++ b/tests/test_tools_language.py @@ -1,12 +1,9 @@ """Tests for language tools.""" -import pytest - from alfred.agent.tools.language import set_language class TestSetLanguage: - def test_success_returns_ok(self, memory): result = set_language("fr") assert result["status"] == "ok" @@ -20,6 +17,7 @@ class TestSetLanguage: set_language("es") # Verify it's stored in STM from alfred.infrastructure.persistence import get_memory + mem = get_memory() assert mem.stm.language == "es" diff --git a/tests/workflows/test_workflow_loader.py b/tests/workflows/test_workflow_loader.py index 9b23752..f718837 100644 --- a/tests/workflows/test_workflow_loader.py +++ b/tests/workflows/test_workflow_loader.py @@ -4,15 +4,14 @@ Tests for alfred.agent.workflows.loader.WorkflowLoader import pytest import yaml -from pathlib import Path from alfred.agent.workflows.loader import WorkflowLoader - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def workflows_dir(tmp_path): """A temp directory pre-populated with one valid workflow YAML.""" @@ -32,6 +31,7 @@ def workflows_dir(tmp_path): def loader_from_dir(workflows_dir, monkeypatch): """WorkflowLoader pointed at our temp dir.""" import alfred.agent.workflows.loader as loader_module + monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", workflows_dir) return WorkflowLoader() @@ -40,8 +40,8 @@ def loader_from_dir(workflows_dir, monkeypatch): # Real loader (loads actual YAML files from the repo) # --------------------------------------------------------------------------- -class TestRealWorkflows: +class TestRealWorkflows: def test_organize_media_loaded(self): loader = WorkflowLoader() assert "organize_media" in loader.names() @@ -96,8 +96,8 @@ class TestRealWorkflows: # WorkflowLoader mechanics (via monkeypatched dir) # --------------------------------------------------------------------------- -class TestLoaderMechanics: +class TestLoaderMechanics: def test_get_returns_workflow(self, loader_from_dir): wf = loader_from_dir.get("test_workflow") assert wf is not None @@ -119,6 +119,7 @@ class TestLoaderMechanics: def test_uses_yaml_name_field(self, tmp_path, monkeypatch): """name from YAML content takes priority over filename stem.""" import alfred.agent.workflows.loader as loader_module + monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) wf = {"name": "my_custom_name", "steps": []} @@ -130,6 +131,7 @@ class TestLoaderMechanics: def test_falls_back_to_stem_when_no_name(self, tmp_path, monkeypatch): import alfred.agent.workflows.loader as loader_module + monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) (tmp_path / "my_workflow.yaml").write_text(yaml.dump({"steps": []})) @@ -138,6 +140,7 @@ class TestLoaderMechanics: def test_skips_malformed_yaml(self, tmp_path, monkeypatch): import alfred.agent.workflows.loader as loader_module + monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) (tmp_path / "valid.yaml").write_text(yaml.dump({"name": "valid", "steps": []})) @@ -150,10 +153,15 @@ class TestLoaderMechanics: def test_deterministic_load_order(self, tmp_path, monkeypatch): """Files loaded in sorted order — later file wins on name collision.""" import alfred.agent.workflows.loader as loader_module + monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) - (tmp_path / "a_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 1})) - (tmp_path / "b_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 2})) + (tmp_path / "a_workflow.yaml").write_text( + yaml.dump({"name": "duplicate", "version": 1}) + ) + (tmp_path / "b_workflow.yaml").write_text( + yaml.dump({"name": "duplicate", "version": 2}) + ) loader = WorkflowLoader() # b_workflow loaded last → version 2 wins @@ -161,6 +169,7 @@ class TestLoaderMechanics: def test_empty_directory(self, tmp_path, monkeypatch): import alfred.agent.workflows.loader as loader_module + monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) loader = WorkflowLoader()