feat: split resolve_destination, persona-driven prompts, qBittorrent relocation

Destination resolution
- Replace the single ResolveDestinationUseCase with four dedicated
  functions, one per release type:
    resolve_season_destination    (pack season, folder move)
    resolve_episode_destination   (single episode, file move)
    resolve_movie_destination     (movie, file move)
    resolve_series_destination    (multi-season pack, folder move)
- Each returns a dedicated DTO carrying only the fields relevant to
  that release type — no more polymorphic ResolvedDestination with
  half the fields unused depending on the case.
- Looser series folder matching: exact computed-name match is reused
  silently; any deviation (different group, multiple candidates) now
  prompts the user with all options including the computed name.

Agent tools
- Four new tools wrapping the use cases above; old resolve_destination
  removed from the registry.
- New move_to_destination tool: create_folder + move, chained — used
  after a resolve_* call to perform the actual relocation.
- Low-level filesystem_operations module (create_folder, move via mv)
  for instant same-FS renames (ZFS).

Prompt & persona
- New PromptBuilder (alfred/agent/prompt.py) replacing prompts.py:
  identity + personality block, situational expressions, memory
  schema, episodic/STM/config context, tool catalogue.
- Per-user expression system: knowledge/users/common.yaml +
  {username}.yaml are merged at runtime; one phrase per situation
  (greeting/success/error/...) is sampled into the system prompt.

qBittorrent integration
- Credentials now come from settings (qbittorrent_url/username/password)
  instead of hardcoded defaults.
- New client methods: find_by_name, set_location, recheck — the trio
  needed to update a torrent's save path and re-verify after a move.
- Host→container path translation settings (qbittorrent_host_path /
  qbittorrent_container_path) for docker-mounted setups.

Subtitles
- Identifier: strip parenthesized qualifiers (simplified, brazil…) at
  tokenization; new _tokenize_suffix used for the episode_subfolder
  pattern so episode-stem tokens no longer pollute language detection.
- Placer: extract _build_dest_name so it can be reused by the new
  dry_run path in ManageSubtitlesUseCase.
- Knowledge: add yue, ell, ind, msa, rus, vie, heb, tam, tel, tha,
  hin, ukr; add 'fre' to fra; add 'simplified'/'traditional' to zho.

Misc
- LTM workspace: add 'trash' folder slot.
- Default LLM provider switched to deepseek.
- testing/debug_release.py: CLI to parse a release, hit TMDB, and
  dry-run the destination resolution end-to-end.
This commit is contained in:
2026-05-14 05:01:59 +02:00
parent 1723b9fa53
commit e45465d52d
81 changed files with 2904 additions and 896 deletions
+6 -3
View File
@@ -46,9 +46,12 @@ TMDB_BASE_URL=https://api.themoviedb.org/3
# qBittorrent # qBittorrent
# → QBITTORRENT_PASSWORD goes in .env.secrets # → QBITTORRENT_PASSWORD goes in .env.secrets
QBITTORRENT_URL=http://qbittorrent:16140 QBITTORRENT_URL=https://qb.lan.anustart.top
QBITTORRENT_USERNAME=admin QBITTORRENT_USERNAME=letmein
QBITTORRENT_PORT=16140 QBITTORRENT_PORT=16140
# Path translation: host-side prefix → container-side prefix
QBITTORRENT_HOST_PATH=/mnt/testipool
QBITTORRENT_CONTAINER_PATH=/mnt/data
# Meilisearch # Meilisearch
# → MEILI_MASTER_KEY goes in .env.secrets # → MEILI_MASTER_KEY goes in .env.secrets
@@ -60,7 +63,7 @@ MEILI_HOST=http://meilisearch:7700
# --- LLM CONFIGURATION --- # --- LLM CONFIGURATION ---
# Providers: local, openai, anthropic, deepseek, google, kimi # Providers: local, openai, anthropic, deepseek, google, kimi
# → API keys go in .env.secrets # → API keys go in .env.secrets
DEFAULT_LLM_PROVIDER=local DEFAULT_LLM_PROVIDER=deepseek
# Local LLM (Ollama) # Local LLM (Ollama)
#OLLAMA_BASE_URL=http://ollama:11434 #OLLAMA_BASE_URL=http://ollama:11434
+1 -1
View File
@@ -190,7 +190,7 @@ class Agent:
async def step_streaming( async def step_streaming(
self, user_input: str, completion_id: str, created_ts: int, model: str 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. Execute agent step with streaming support for LibreChat.
+79
View File
@@ -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,
}
+239
View File
@@ -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())
+5 -1
View File
@@ -99,8 +99,12 @@ def make_tools(settings) -> dict[str, Tool]:
fs_tools.list_folder, fs_tools.list_folder,
fs_tools.analyze_release, fs_tools.analyze_release,
fs_tools.probe_media, 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_media,
fs_tools.move_to_destination,
fs_tools.manage_subtitles, fs_tools.manage_subtitles,
fs_tools.create_seed_links, fs_tools.create_seed_links,
fs_tools.learn, fs_tools.learn,
+155 -35
View File
@@ -3,20 +3,25 @@
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import alfred as _alfred_pkg
import yaml import yaml
import alfred as _alfred_pkg
from alfred.application.filesystem import ( from alfred.application.filesystem import (
CreateSeedLinksUseCase, CreateSeedLinksUseCase,
ListFolderUseCase, ListFolderUseCase,
ManageSubtitlesUseCase, ManageSubtitlesUseCase,
MoveMediaUseCase, MoveMediaUseCase,
ResolveDestinationUseCase,
SetFolderPathUseCase, SetFolderPathUseCase,
) )
from alfred.application.filesystem.detect_media_type import detect_media_type from alfred.application.filesystem.detect_media_type import detect_media_type
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe 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.ffprobe import probe
from alfred.infrastructure.filesystem.find_video import find_video_file 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() 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, release_name: str,
source_file: str, source_file: str,
tmdb_title: str, tmdb_title: str,
@@ -51,44 +106,91 @@ def resolve_destination(
confirmed_folder: str | None = None, confirmed_folder: str | None = None,
) -> dict[str, Any]: ) -> 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: Returns series_folder + season_folder + library_file (full path to destination .mkv).
- 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
Args: Args:
release_name: Raw release folder or file name release_name: Raw release file name (e.g. "Oz.S03E01.1080p.WEBRip.x265-KONTRAST.mkv").
(e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
source_file: Absolute path to the source video file (used for extension). source_file: Absolute path to the source video file (used for extension).
tmdb_title: Canonical show/movie title from TMDB (e.g. "Oz"). tmdb_title: Canonical show title from TMDB (e.g. "Oz").
tmdb_year: Release/start year from TMDB (e.g. 1997). tmdb_year: Show start year from TMDB (e.g. 1997).
tmdb_episode_title: Episode title from TMDB for single-episode releases tmdb_episode_title: Episode title from TMDB (e.g. "The Routine"). Optional.
(e.g. "The Routine"). Omit for season packs and movies. confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
confirmed_folder: If a previous call returned needs_clarification, pass
the user-chosen folder name here to proceed.
Returns: Returns:
On success: dict with status, library_file, series_folder, season_folder, On success: dict with status, series_folder, season_folder, library_file,
series_folder_name, season_folder_name, filename, series_folder_name, season_folder_name, filename, is_new_series_folder.
is_new_series_folder.
On ambiguity: dict with status="needs_clarification", question, options. On ambiguity: dict with status="needs_clarification", question, options.
On error: dict with status="error", error, message. On error: dict with status="error", error, message.
""" """
use_case = ResolveDestinationUseCase() return resolve_episode_destination(
return use_case.execute( release_name,
release_name=release_name, source_file,
source_file=source_file, tmdb_title,
tmdb_title=tmdb_title, tmdb_year,
tmdb_year=tmdb_year, tmdb_episode_title,
tmdb_episode_title=tmdb_episode_title, confirmed_folder,
confirmed_folder=confirmed_folder,
).to_dict() ).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. 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"} _VALID_CATEGORIES = {"languages", "types", "formats"}
if pack not in _VALID_PACKS: 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: 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_path = _LEARNED_ROOT / "subtitles_learned.yaml"
_LEARNED_ROOT.mkdir(parents=True, exist_ok=True) _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") tmp = learned_path.with_suffix(".yaml.tmp")
try: try:
with open(tmp, "w", encoding="utf-8") as f: 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) tmp.rename(learned_path)
except Exception as e: except Exception as e:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
@@ -293,11 +405,19 @@ def probe_media(source_path: str) -> dict[str, Any]:
""" """
path = Path(source_path) path = Path(source_path)
if not path.exists(): 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) media_info = probe(path)
if media_info is None: 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 { return {
"status": "ok", "status": "ok",
+18 -3
View File
@@ -12,7 +12,16 @@ from .dto import (
from .list_folder import ListFolderUseCase from .list_folder import ListFolderUseCase
from .manage_subtitles import ManageSubtitlesUseCase from .manage_subtitles import ManageSubtitlesUseCase
from .move_media import MoveMediaUseCase 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 from .set_folder_path import SetFolderPathUseCase
__all__ = [ __all__ = [
@@ -21,8 +30,14 @@ __all__ = [
"CreateSeedLinksUseCase", "CreateSeedLinksUseCase",
"MoveMediaUseCase", "MoveMediaUseCase",
"ManageSubtitlesUseCase", "ManageSubtitlesUseCase",
"ResolveDestinationUseCase", "ResolvedSeasonDestination",
"ResolvedDestination", "ResolvedEpisodeDestination",
"ResolvedMovieDestination",
"ResolvedSeriesDestination",
"resolve_season_destination",
"resolve_episode_destination",
"resolve_movie_destination",
"resolve_series_destination",
"SetFolderPathResponse", "SetFolderPathResponse",
"ListFolderResponse", "ListFolderResponse",
"CreateSeedLinksResponse", "CreateSeedLinksResponse",
@@ -20,10 +20,10 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from alfred.domain.release.value_objects import ( from alfred.domain.release.value_objects import (
ParsedRelease,
_METADATA_EXTENSIONS, _METADATA_EXTENSIONS,
_NON_VIDEO_EXTENSIONS, _NON_VIDEO_EXTENSIONS,
_VIDEO_EXTENSIONS, _VIDEO_EXTENSIONS,
ParsedRelease,
) )
+6 -2
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
@dataclass @dataclass
@@ -88,7 +88,11 @@ class PlacedSubtitle:
filename: str filename: str
def to_dict(self) -> dict: 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 @dataclass
@@ -50,7 +50,9 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
parsed.quality = info.resolution parsed.quality = info.resolution
if parsed.codec is None and info.video_codec: 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: if parsed.bit_depth is None and info.video_codec:
# ffprobe exposes bit depth via pix_fmt — not in MediaInfo yet, skip for now # 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 track:
if parsed.audio_codec is None and track.codec: 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: 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 # Languages — merge ffprobe languages with token-level ones
# "und" = undetermined, not useful # "und" = undetermined, not useful
@@ -17,7 +17,12 @@ from alfred.infrastructure.persistence.context import get_memory
from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore
from alfred.infrastructure.subtitle.rule_repository import RuleSetRepository 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__) logger = logging.getLogger(__name__)
@@ -69,11 +74,12 @@ class ManageSubtitlesUseCase:
season: int | None = None, season: int | None = None,
episode: int | None = None, episode: int | None = None,
confirmed_pattern_id: str | None = None, confirmed_pattern_id: str | None = None,
dry_run: bool = False,
) -> ManageSubtitlesResponse: ) -> ManageSubtitlesResponse:
source_path = Path(source_video) source_path = Path(source_video)
dest_path = Path(destination_video) dest_path = Path(destination_video)
if not source_path.exists(): if not source_path.exists() and not source_path.parent.exists():
return ManageSubtitlesResponse( return ManageSubtitlesResponse(
status="error", status="error",
error="source_not_found", error="source_not_found",
@@ -108,7 +114,9 @@ class ManageSubtitlesUseCase:
) )
if metadata.total_count == 0: 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( return ManageSubtitlesResponse(
status="ok", status="ok",
video_path=destination_video, video_path=destination_video,
@@ -164,6 +172,32 @@ class ManageSubtitlesUseCase:
skipped_count=metadata.total_count, 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 --- # --- Place ---
placer = SubtitlePlacer() placer = SubtitlePlacer()
place_result = placer.place(matched, dest_path) place_result = placer.place(matched, dest_path)
@@ -229,7 +263,9 @@ class ManageSubtitlesUseCase:
return kb.pattern("adjacent") 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" reason = "unknown_language" if track.language is None else "low_confidence"
return UnresolvedTrack( return UnresolvedTrack(
raw_tokens=track.raw_tokens, raw_tokens=track.raw_tokens,
@@ -1,58 +1,82 @@
""" """
ResolveDestinationUseCase — compute the library destination path for a release. Destination resolution — compute library paths for releases.
Steps: Four distinct use cases, one per release type:
1. Parse the release name - resolve_season_destination : season pack (folder move)
2. Look up TMDB for title + year (+ episode title if single episode) - resolve_episode_destination : single episode (file move)
3. Scan the library for an existing series folder - resolve_movie_destination : movie (file move)
4. Apply group-conflict rules - resolve_series_destination : complete series multi-season pack (folder move)
5. Return the computed paths (or needs_clarification if ambiguous)
Each returns a dedicated DTO with only the fields that make sense for that type.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass
from pathlib import Path 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 from alfred.infrastructure.persistence import get_memory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Characters forbidden on Windows filesystems (served via NFS)
_WIN_FORBIDDEN = re.compile(r'[?:*"<>|\\]') _WIN_FORBIDDEN = re.compile(r'[?:*"<>|\\]')
def _sanitise(text: str) -> str: def _sanitize(text: str) -> str:
return _WIN_FORBIDDEN.sub("", text) 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 # DTOs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass @dataclass
class ResolvedDestination: class ResolvedSeasonDestination:
"""All computed paths for a release, ready to hand to move_media.""" """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" # ok
library_file: str | None = None # absolute path of the destination video file series_folder: str | None = (
series_folder: str | None = None # absolute path of the series root folder None # /tv_shows/A.Knight.of.the.Seven.Kingdoms.2024.1080p.WEBRip.x265-KONTRAST
season_folder: str | None = None # absolute path of the season subfolder )
series_folder_name: str | None = None # just the folder name (for display) 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 season_folder_name: str | None = None
filename: str | None = None is_new_series_folder: bool = False
is_new_series_folder: bool = False # True if we're creating the folder
# Populated on "needs_clarification" # needs_clarification
question: str | None = None 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 error: str | None = None
message: str | None = None message: str | None = None
@@ -67,161 +91,267 @@ class ResolvedDestination:
} }
return { return {
"status": self.status, "status": self.status,
"library_file": self.library_file,
"series_folder": self.series_folder, "series_folder": self.series_folder,
"season_folder": self.season_folder, "season_folder": self.season_folder,
"series_folder_name": self.series_folder_name, "series_folder_name": self.series_folder_name,
"season_folder_name": self.season_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, "filename": self.filename,
"is_new_series_folder": self.is_new_series_folder, "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: Returns series_folder + season_folder. No file paths — the whole
- release_name: the raw release folder/file name source folder is moved as-is into season_folder.
- 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.
""" """
tv_root = _get_tv_root()
if not tv_root:
return ResolvedSeasonDestination(
status="error",
error="library_not_set",
message="TV show library path is not configured.",
)
def execute( parsed = parse_release(release_name)
self, computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
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:
# Exact match — use it
series_folder_name = existing[0]
is_new = False
else:
# 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"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()
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, release_name: str,
source_file: str, source_file: str,
tmdb_title: str, tmdb_title: str,
tmdb_year: int, tmdb_year: int,
tmdb_episode_title: str | None = None, tmdb_episode_title: str | None = None,
confirmed_folder: str | None = None, confirmed_folder: str | None = None,
) -> ResolvedDestination: ) -> ResolvedEpisodeDestination:
parsed = parse_release(release_name) """
ext = Path(source_file).suffix # ".mkv" Compute destination paths for a single episode file.
if parsed.media_type == "movie": Returns series_folder + season_folder + library_file (full path to .mkv).
return self._resolve_movie(parsed, tmdb_title, tmdb_year, ext) """
if parsed.media_type == "tv_show": tv_root = _get_tv_root()
return self._resolve_tvshow(
parsed, tmdb_title, tmdb_year, tmdb_episode_title, ext, confirmed_folder
)
return ResolvedDestination(
status="error",
error="unsupported_media_type",
message=(
f"Cannot organize '{release_name}': detected as '{parsed.media_type}'. "
"Only movies and TV shows are supported."
),
)
# ------------------------------------------------------------------
# Movie
# ------------------------------------------------------------------
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.",
)
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: if not tv_root:
return ResolvedDestination( return ResolvedEpisodeDestination(
status="error", status="error",
error="library_not_set", error="library_not_set",
message="TV show library path is not configured.", message="TV show library path is not configured.",
) )
tv_root_path = Path(tv_root) parsed = parse_release(release_name)
ext = Path(source_file).suffix
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
# --- 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: if confirmed_folder:
series_folder_name = confirmed_folder series_folder_name = confirmed_folder
is_new = not (tv_root_path / confirmed_folder).exists() is_new = not (tv_root / confirmed_folder).exists()
elif len(existing) == 0: else:
# No existing folder — create with release group existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
series_folder_name = _sanitise(parsed.show_folder_name(tmdb_title, tmdb_year))
if len(existing) == 0:
series_folder_name = computed_name
is_new = True is_new = True
elif len(existing) == 1: elif len(existing) == 1 and existing[0] == computed_name:
# Exactly one match — use it regardless of group
series_folder_name = existing[0] series_folder_name = existing[0]
is_new = False is_new = False
else: else:
# Multiple folders — ask user options = existing + (
return ResolvedDestination( [computed_name] if computed_name not in existing else []
)
return ResolvedEpisodeDestination(
status="needs_clarification", status="needs_clarification",
question=( question=(
f"Multiple folders found for '{tmdb_title}' in your library. " f"Un dossier série existe déjà pour '{tmdb_title}' "
f"Which one should I use for this release ({parsed.group})?" 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() season_folder_name = parsed.season_folder_name()
filename = _sanitise( filename = _sanitize(parsed.episode_filename(tmdb_episode_title, ext))
parsed.episode_filename(tmdb_episode_title, ext)
if not parsed.is_season_pack
else parsed.season_folder_name() + ext
)
series_path = tv_root_path / series_folder_name series_path = tv_root / series_folder_name
season_path = series_path / season_folder_name season_path = series_path / season_folder_name
file_path = season_path / filename file_path = season_path / filename
return ResolvedDestination( return ResolvedEpisodeDestination(
status="ok", status="ok",
library_file=str(file_path),
series_folder=str(series_path), series_folder=str(series_path),
season_folder=str(season_path), season_folder=str(season_path),
library_file=str(file_path),
series_folder_name=series_folder_name, series_folder_name=series_folder_name,
season_folder_name=season_folder_name, season_folder_name=season_folder_name,
filename=filename, filename=filename,
@@ -229,27 +359,98 @@ class ResolveDestinationUseCase:
) )
# --------------------------------------------------------------------------- def resolve_movie_destination(
# Helpers release_name: str,
# --------------------------------------------------------------------------- source_file: str,
tmdb_title: str,
def _find_existing_series_folders(tv_root: Path, tmdb_title: str, tmdb_year: int) -> list[str]: tmdb_year: int,
) -> ResolvedMovieDestination:
""" """
Return names of folders in tv_root that match the given title + year. Compute destination paths for a movie file.
Matching is loose: normalised title (dots, no special chars) + year must Returns movie_folder + library_file (full path to .mkv).
appear at the start of the folder name.
""" """
if not tv_root.exists(): memory = get_memory()
return [] 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.",
)
# Build a normalised prefix to match against: "Oz.1997" parsed = parse_release(release_name)
clean_title = _sanitise(tmdb_title).replace(" ", ".") ext = Path(source_file).suffix
prefix = f"{clean_title}.{tmdb_year}".lower()
matches = [] folder_name = _sanitize(parsed.movie_folder_name(tmdb_title, tmdb_year))
for entry in tv_root.iterdir(): filename = _sanitize(parsed.movie_filename(tmdb_title, tmdb_year, ext))
if entry.is_dir() and entry.name.lower().startswith(prefix):
matches.append(entry.name)
return sorted(matches) 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:
"""
Compute destination path for a complete multi-season series pack.
Returns only series_folder — the whole pack lands directly inside it.
"""
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.",
)
parsed = parse_release(release_name)
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
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 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,
)
+1 -1
View File
@@ -17,7 +17,7 @@ class Quality(Enum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
@classmethod @classmethod
def from_string(cls, quality_str: str) -> "Quality": def from_string(cls, quality_str: str) -> Quality:
""" """
Parse quality from string. Parse quality from string.
+5 -2
View File
@@ -10,12 +10,15 @@ Lists are extended additively, scalars from higher layers win.
from pathlib import Path from pathlib import Path
import alfred as _alfred_pkg
import yaml import yaml
import alfred as _alfred_pkg
_BUILTIN_ROOT = Path(_alfred_pkg.__file__).parent / "knowledge" / "release" _BUILTIN_ROOT = Path(_alfred_pkg.__file__).parent / "knowledge" / "release"
_SITES_ROOT = _BUILTIN_ROOT / "sites" _SITES_ROOT = _BUILTIN_ROOT / "sites"
_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release" _LEARNED_ROOT = (
Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release"
)
def _merge(base: dict, overlay: dict) -> dict: def _merge(base: dict, overlay: dict) -> dict:
+26 -12
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from .value_objects import ( from .value_objects import (
ParsedRelease,
_AUDIO, _AUDIO,
_CODECS, _CODECS,
_EDITIONS, _EDITIONS,
@@ -13,9 +12,8 @@ from .value_objects import (
_MEDIA_TYPE_TOKENS, _MEDIA_TYPE_TOKENS,
_RESOLUTIONS, _RESOLUTIONS,
_SOURCES, _SOURCES,
_VIDEO_EXTENSIONS,
_VIDEO_META, _VIDEO_META,
_NON_VIDEO_EXTENSIONS, ParsedRelease,
) )
@@ -67,7 +65,9 @@ def parse_release(name: str) -> ParsedRelease:
tech_tokens | lang_tokens | audio_tokens | video_tokens | edition_tokens, tech_tokens | lang_tokens | audio_tokens | video_tokens | edition_tokens,
) )
year = _extract_year(tokens, title) 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_parts = [p for p in [quality, source, codec] if p]
tech_string = ".".join(tech_parts) tech_string = ".".join(tech_parts)
@@ -126,7 +126,10 @@ def _infer_media_type(
return "documentary" return "documentary"
if upper_tokens & concert_tokens: if upper_tokens & concert_tokens:
return "concert" 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" return "tv_complete"
if season is not None: if season is not None:
return "tv_show" return "tv_show"
@@ -172,14 +175,14 @@ def _strip_site_tag(name: str) -> tuple[str, str | None]:
close = s.find("]") close = s.find("]")
if close != -1: if close != -1:
tag = s[1:close].strip() tag = s[1:close].strip()
remainder = s[close + 1:].strip() remainder = s[close + 1 :].strip()
if tag and remainder: if tag and remainder:
return remainder, tag return remainder, tag
if s.endswith("]"): if s.endswith("]"):
open_bracket = s.rfind("[") open_bracket = s.rfind("[")
if open_bracket != -1: if open_bracket != -1:
tag = s[open_bracket + 1:-1].strip() tag = s[open_bracket + 1 : -1].strip()
remainder = s[:open_bracket].strip() remainder = s[:open_bracket].strip()
if tag and remainder: if tag and remainder:
return remainder, tag 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 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: for tok in tokens:
parsed = _parse_season_episode(tok) parsed = _parse_season_episode(tok)
if parsed is not None: if parsed is not None:
@@ -333,6 +338,7 @@ def _extract_year(tokens: list[str], title: str) -> int | None:
# Sequence matcher # Sequence matcher
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _match_sequences( def _match_sequences(
tokens: list[str], tokens: list[str],
sequences: list[dict], sequences: list[dict],
@@ -349,8 +355,8 @@ def _match_sequences(
seq_upper = [s.upper() for s in seq["tokens"]] seq_upper = [s.upper() for s in seq["tokens"]]
n = len(seq_upper) n = len(seq_upper)
for i in range(len(upper_tokens) - n + 1): for i in range(len(upper_tokens) - n + 1):
if upper_tokens[i:i + n] == seq_upper: if upper_tokens[i : i + n] == seq_upper:
matched = set(tokens[i:i + n]) matched = set(tokens[i : i + n])
return seq[key], matched return seq[key], matched
return None, set() return None, set()
@@ -359,6 +365,7 @@ def _match_sequences(
# Language extraction # Language extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]: def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]:
"""Extract language tokens. Returns (languages, matched_token_set).""" """Extract language tokens. Returns (languages, matched_token_set)."""
languages = [] languages = []
@@ -374,6 +381,7 @@ def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]:
# Audio extraction # Audio extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_audio( def _extract_audio(
tokens: list[str], tokens: list[str],
) -> tuple[str | None, str | None, set[str]]: ) -> tuple[str | None, str | None, set[str]]:
@@ -391,7 +399,9 @@ def _extract_audio(
known_channels = set(_AUDIO.get("channels", [])) known_channels = set(_AUDIO.get("channels", []))
# Try multi-token sequences first # 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: if matched_codec:
audio_codec = matched_codec audio_codec = matched_codec
audio_tokens |= matched_set audio_tokens |= matched_set
@@ -424,6 +434,7 @@ def _extract_audio(
# Video metadata extraction (bit depth, HDR) # Video metadata extraction (bit depth, HDR)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_video_meta( def _extract_video_meta(
tokens: list[str], tokens: list[str],
) -> tuple[str | None, str | None, set[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", [])} known_depth = {d.lower() for d in _VIDEO_META.get("bit_depth", [])}
# Try HDR sequences first # 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: if matched_hdr:
hdr_format = matched_hdr hdr_format = matched_hdr
video_tokens |= matched_set video_tokens |= matched_set
@@ -462,6 +475,7 @@ def _extract_video_meta(
# Edition extraction # Edition extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_edition(tokens: list[str]) -> tuple[str | None, set[str]]: def _extract_edition(tokens: list[str]) -> tuple[str | None, set[str]]:
""" """
Extract release edition (UNRATED, EXTENDED, DIRECTORS.CUT, …). Extract release edition (UNRATED, EXTENDED, DIRECTORS.CUT, …).
+6 -2
View File
@@ -86,8 +86,12 @@ class ParsedRelease:
codec: str | None # x265, HEVC, … codec: str | None # x265, HEVC, …
group: str # release group, "UNKNOWN" if missing group: str # release group, "UNKNOWN" if missing
tech_string: str # quality.source.codec joined with dots tech_string: str # quality.source.codec joined with dots
media_type: str = "unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown" media_type: str = (
site_tag: str | None = None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc" "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" parse_path: str = "direct" # "direct" | "sanitized" | "ai"
languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], … languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], …
audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", … audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", …
+24 -12
View File
@@ -63,20 +63,32 @@ class MediaInfo:
return None return None
case (w, h) if w is not None: case (w, h) if w is not None:
match True: match True:
case _ if w >= 3840: return "2160p" case _ if w >= 3840:
case _ if w >= 1920: return "1080p" return "2160p"
case _ if w >= 1280: return "720p" case _ if w >= 1920:
case _ if w >= 720: return "576p" return "1080p"
case _ if w >= 640: return "480p" case _ if w >= 1280:
case _: return f"{h}p" if h else f"{w}w" 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): case (None, h):
match True: match True:
case _ if h >= 2160: return "2160p" case _ if h >= 2160:
case _ if h >= 1080: return "1080p" return "2160p"
case _ if h >= 720: return "720p" case _ if h >= 1080:
case _ if h >= 576: return "576p" return "1080p"
case _ if h >= 480: return "480p" case _ if h >= 720:
case _: return f"{h}p" return "720p"
case _ if h >= 576:
return "576p"
case _ if h >= 480:
return "480p"
case _:
return f"{h}p"
@property @property
def audio_languages(self) -> list[str]: def audio_languages(self) -> list[str]:
+9 -4
View File
@@ -26,7 +26,7 @@ class SubtitleRuleSet:
""" """
scope: RuleScope scope: RuleScope
parent: "SubtitleRuleSet | None" = None parent: SubtitleRuleSet | None = None
pinned_to: ImdbId | None = None pinned_to: ImdbId | None = None
# Deltas — None = inherit # Deltas — None = inherit
@@ -47,7 +47,9 @@ class SubtitleRuleSet:
preferred_formats=self._formats or base.preferred_formats, preferred_formats=self._formats or base.preferred_formats,
allowed_types=self._types or base.allowed_types, allowed_types=self._types or base.allowed_types,
format_priority=self._format_priority or base.format_priority, 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( def override(
@@ -83,8 +85,11 @@ class SubtitleRuleSet:
delta["format_priority"] = self._format_priority delta["format_priority"] = self._format_priority
if self._min_confidence is not None: if self._min_confidence is not None:
delta["min_confidence"] = self._min_confidence 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 @classmethod
def global_default(cls) -> "SubtitleRuleSet": def global_default(cls) -> SubtitleRuleSet:
return cls(scope=RuleScope(level="global")) return cls(scope=RuleScope(level="global"))
+16 -4
View File
@@ -4,7 +4,11 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from ..shared.value_objects import ImdbId from ..shared.value_objects import ImdbId
from .value_objects import SubtitleFormat, SubtitleLanguage, SubtitleMatchingRules, SubtitleType from .value_objects import (
SubtitleFormat,
SubtitleLanguage,
SubtitleType,
)
@dataclass @dataclass
@@ -29,7 +33,9 @@ class SubtitleTrack:
# Matching state # Matching state
confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded
raw_tokens: list[str] = field(default_factory=list) # tokens extracted from filename raw_tokens: list[str] = field(
default_factory=list
) # tokens extracted from filename
def is_resolved(self) -> bool: def is_resolved(self) -> bool:
return self.language is not None return self.language is not None
@@ -43,7 +49,9 @@ class SubtitleTrack:
{lang}.forced.{ext} {lang}.forced.{ext}
""" """
if not self.language or not self.format: 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(".") ext = self.format.extensions[0].lstrip(".")
parts = [self.language.code] parts = [self.language.code]
if self.subtitle_type == SubtitleType.SDH: if self.subtitle_type == SubtitleType.SDH:
@@ -55,7 +63,11 @@ class SubtitleTrack:
def __repr__(self) -> str: def __repr__(self) -> str:
lang = self.language.code if self.language else "?" lang = self.language.code if self.language else "?"
fmt = self.format.id if self.format 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})" return f"SubtitleTrack({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})"
@@ -1,7 +1,6 @@
"""SubtitleKnowledgeBase — parsed, typed view of the loaded knowledge.""" """SubtitleKnowledgeBase — parsed, typed view of the loaded knowledge."""
import logging import logging
from functools import cached_property
from ..value_objects import ( from ..value_objects import (
ScanStrategy, ScanStrategy,
+6 -2
View File
@@ -84,7 +84,9 @@ class KnowledgeLoader:
data = _load_yaml(path) data = _load_yaml(path)
pid = data.get("id", path.stem) pid = data.get("id", path.stem)
if pid in self._cache["patterns"]: 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: else:
self._cache["patterns"][pid] = data self._cache["patterns"][pid] = data
logger.info(f"KnowledgeLoader: learned new pattern '{pid}'") logger.info(f"KnowledgeLoader: learned new pattern '{pid}'")
@@ -100,7 +102,9 @@ class KnowledgeLoader:
data = _load_yaml(path) data = _load_yaml(path)
name = data.get("name", path.stem) name = data.get("name", path.stem)
if name in self._cache["release_groups"]: 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: else:
self._cache["release_groups"][name] = data self._cache["release_groups"][name] = data
logger.info(f"KnowledgeLoader: learned new release group '{name}'") logger.info(f"KnowledgeLoader: learned new release group '{name}'")
+14 -5
View File
@@ -26,7 +26,7 @@ Output naming convention (matches SubtitlePreferences docstring):
""" """
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -120,6 +120,7 @@ def _classify(path: Path) -> tuple[str | None, bool, bool]:
stem = path.stem.lower() stem = path.stem.lower()
# Split on dots, spaces, underscores, hyphens # Split on dots, spaces, underscores, hyphens
import re import re
tokens = re.split(r"[\.\s_\-]+", stem) tokens = re.split(r"[\.\s_\-]+", stem)
language: str | None = None language: str | None = None
@@ -147,7 +148,9 @@ class SubtitleScanner:
# Each candidate has .source_path and .destination_name # 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.languages = [l.lower() for l in languages]
self.min_size_kb = min_size_kb self.min_size_kb = min_size_kb
self.keep_sdh = keep_sdh self.keep_sdh = keep_sdh
@@ -180,7 +183,9 @@ class SubtitleScanner:
if candidate is not None: if candidate is not None:
candidates.append(candidate) 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 return candidates
def _evaluate(self, path: Path) -> SubtitleCandidate | None: def _evaluate(self, path: Path) -> SubtitleCandidate | None:
@@ -188,7 +193,9 @@ class SubtitleScanner:
# Size filter # Size filter
size_kb = path.stat().st_size / 1024 size_kb = path.stat().st_size / 1024
if size_kb < self.min_size_kb: 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 return None
language, is_sdh, is_forced = _classify(path) language, is_sdh, is_forced = _classify(path)
@@ -199,7 +206,9 @@ class SubtitleScanner:
return None return None
if language not in self.languages: 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 return None
# SDH filter # SDH filter
+86 -27
View File
@@ -1,9 +1,9 @@
"""SubtitleIdentifier — finds and classifies all subtitle tracks for a video file.""" """SubtitleIdentifier — finds and classifies all subtitle tracks for a video file."""
import json
import logging import logging
import re import re
import subprocess import subprocess
import json
from pathlib import Path from pathlib import Path
from ...shared.value_objects import ImdbId from ...shared.value_objects import ImdbId
@@ -15,10 +15,28 @@ logger = logging.getLogger(__name__)
def _tokenize(name: str) -> list[str]: 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] 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: def _count_entries(path: Path) -> int:
"""Return the entry count of an SRT file by finding the last cue number.""" """Return the entry count of an SRT file by finding the last cue number."""
try: try:
@@ -79,17 +97,29 @@ class SubtitleIdentifier:
try: try:
result = subprocess.run( result = subprocess.run(
[ [
"ffprobe", "-v", "quiet", "ffprobe",
"-print_format", "json", "-v",
"quiet",
"-print_format",
"json",
"-show_streams", "-show_streams",
"-select_streams", "s", "-select_streams",
"s",
str(video_path), str(video_path),
], ],
capture_output=True, text=True, timeout=30, capture_output=True,
text=True,
timeout=30,
) )
data = json.loads(result.stdout) data = json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e: except (
logger.debug(f"SubtitleIdentifier: ffprobe failed for {video_path.name}: {e}") subprocess.TimeoutExpired,
json.JSONDecodeError,
FileNotFoundError,
) as e:
logger.debug(
f"SubtitleIdentifier: ffprobe failed for {video_path.name}: {e}"
)
return [] return []
tracks = [] tracks = []
@@ -108,39 +138,50 @@ class SubtitleIdentifier:
else: else:
stype = SubtitleType.STANDARD stype = SubtitleType.STANDARD
tracks.append(SubtitleTrack( tracks.append(
SubtitleTrack(
language=lang, language=lang,
format=None, format=None,
subtitle_type=stype, subtitle_type=stype,
is_embedded=True, is_embedded=True,
raw_tokens=[lang_code] if lang_code else [], 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 return tracks
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# External tracks — filesystem scan per pattern strategy # 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 strategy = pattern.scan_strategy
episode_stem: str | None = None
if strategy == ScanStrategy.ADJACENT: if strategy == ScanStrategy.ADJACENT:
candidates = self._find_adjacent(video_path) candidates = self._find_adjacent(video_path)
elif strategy == ScanStrategy.FLAT: elif strategy == ScanStrategy.FLAT:
candidates = self._find_flat(video_path, pattern.root_folder or "Subs") candidates = self._find_flat(video_path, pattern.root_folder or "Subs")
elif strategy == ScanStrategy.EPISODE_SUBFOLDER: 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: else:
return [] 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]: def _find_adjacent(self, video_path: Path) -> list[Path]:
return [ return [
p for p in sorted(video_path.parent.iterdir()) p
if p.is_file() and p.suffix.lower() in self.kb.known_extensions() 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 and p.stem != video_path.stem
] ]
@@ -152,17 +193,22 @@ class SubtitleIdentifier:
if not subs_dir.is_dir(): if not subs_dir.is_dir():
return [] return []
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() 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 Look for Subs/{episode_stem}/*.srt
Checks two locations: Checks two locations:
1. Adjacent to the video: video_path.parent / root_folder / video_path.stem 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 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 episode_stem = video_path.stem
candidates_dirs = [ candidates_dirs = [
@@ -172,22 +218,30 @@ class SubtitleIdentifier:
for subs_dir in candidates_dirs: for subs_dir in candidates_dirs:
if subs_dir.is_dir(): if subs_dir.is_dir():
files = [ 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 p.is_file() and p.suffix.lower() in self.kb.known_extensions()
] ]
if files: if files:
logger.debug(f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}") logger.debug(
return files f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}"
return [] )
return files, episode_stem
return [], episode_stem
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Classification # 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 = [] tracks = []
for path in paths: for path in paths:
track = self._classify_single(path) track = self._classify_single(path, episode_stem=episode_stem)
tracks.append(track) tracks.append(track)
# Post-process: if multiple tracks share same language but type is ambiguous, # Post-process: if multiple tracks share same language but type is ambiguous,
@@ -197,9 +251,15 @@ class SubtitleIdentifier:
return tracks 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) 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 language = None
subtitle_type = SubtitleType.UNKNOWN subtitle_type = SubtitleType.UNKNOWN
@@ -250,7 +310,6 @@ class SubtitleIdentifier:
Only applied when type_detection = size_and_count. Only applied when type_detection = size_and_count.
""" """
from itertools import groupby
# Group by language code # Group by language code
lang_groups: dict[str, list[SubtitleTrack]] = {} lang_groups: dict[str, list[SubtitleTrack]] = {}
+4 -2
View File
@@ -3,7 +3,7 @@
import logging import logging
from ..entities import SubtitleTrack from ..entities import SubtitleTrack
from ..value_objects import SubtitleMatchingRules, SubtitleType from ..value_objects import SubtitleMatchingRules
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,7 +50,9 @@ class SubtitleMatcher:
) )
return matched, unresolved return matched, unresolved
def _passes_filters(self, track: SubtitleTrack, rules: SubtitleMatchingRules) -> bool: def _passes_filters(
self, track: SubtitleTrack, rules: SubtitleMatchingRules
) -> bool:
# Language filter # Language filter
if rules.preferred_languages: if rules.preferred_languages:
if not track.language: if not track.language:
@@ -49,13 +49,19 @@ class PatternDetector:
try: try:
result = subprocess.run( result = subprocess.run(
[ [
"ffprobe", "-v", "quiet", "ffprobe",
"-print_format", "json", "-v",
"quiet",
"-print_format",
"json",
"-show_streams", "-show_streams",
"-select_streams", "s", "-select_streams",
"s",
str(video_path), str(video_path),
], ],
capture_output=True, text=True, timeout=30, capture_output=True,
text=True,
timeout=30,
) )
data = json.loads(result.stdout) data = json.loads(result.stdout)
return len(data.get("streams", [])) > 0 return len(data.get("streams", [])) > 0
@@ -87,15 +93,22 @@ class PatternDetector:
# Is it flat or episode_subfolder? # Is it flat or episode_subfolder?
children = list(subs_candidate.iterdir()) 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()] sub_dirs = [c for c in children if c.is_dir()]
if sub_dirs and not sub_files: if sub_dirs and not sub_files:
findings["subs_strategy"] = "episode_subfolder" findings["subs_strategy"] = "episode_subfolder"
# Count files in a sample subfolder # Count files in a sample subfolder
sample_sub = sub_dirs[0] sample_sub = sub_dirs[0]
sample_files = [f for f in sample_sub.iterdir() sample_files = [
if f.is_file() and f.suffix.lower() in known_exts] 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) findings["files_per_episode"] = len(sample_files)
# Check naming conventions # Check naming conventions
for f in sample_files: for f in sample_files:
@@ -103,22 +116,27 @@ class PatternDetector:
parts = stem.split("_") parts = stem.split("_")
if parts[0].isdigit(): if parts[0].isdigit():
findings["has_numeric_prefix"] = True findings["has_numeric_prefix"] = True
if any(self.kb.is_known_lang_token(t.lower()) if any(
for t in stem.replace("_", ".").split(".")): self.kb.is_known_lang_token(t.lower())
for t in stem.replace("_", ".").split(".")
):
findings["has_lang_tokens"] = True findings["has_lang_tokens"] = True
else: else:
findings["subs_strategy"] = "flat" findings["subs_strategy"] = "flat"
findings["files_per_episode"] = len(sub_files) findings["files_per_episode"] = len(sub_files)
for f in sub_files: for f in sub_files:
if any(self.kb.is_known_lang_token(t.lower()) if any(
for t in f.stem.replace("_", ".").split(".")): self.kb.is_known_lang_token(t.lower())
for t in f.stem.replace("_", ".").split(".")
):
findings["has_lang_tokens"] = True findings["has_lang_tokens"] = True
break break
# Check adjacent subs (next to the video) # Check adjacent subs (next to the video)
if not findings["has_subs_folder"]: if not findings["has_subs_folder"]:
adjacent = [ 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 p.is_file() and p.suffix.lower() in known_exts
] ]
if adjacent: if adjacent:
@@ -157,7 +175,9 @@ class PatternDetector:
total += 1 total += 1
if findings.get("has_embedded"): if findings.get("has_embedded"):
score += 1.0 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 score += 0.5
total += 0.5 total += 0.5
+27 -3
View File
@@ -10,6 +10,28 @@ from ..entities import SubtitleTrack
logger = logging.getLogger(__name__) 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 @dataclass
class PlacedTrack: class PlacedTrack:
source: Path source: Path
@@ -62,7 +84,7 @@ class SubtitlePlacer:
continue continue
try: try:
dest_name = track.destination_name dest_name = _build_dest_name(track, destination_video.stem)
except ValueError as e: except ValueError as e:
skipped.append((track, str(e))) skipped.append((track, str(e)))
continue continue
@@ -76,11 +98,13 @@ class SubtitlePlacer:
try: try:
os.link(track.file_path, dest_path) os.link(track.file_path, dest_path)
placed.append(PlacedTrack( placed.append(
PlacedTrack(
source=track.file_path, source=track.file_path,
destination=dest_path, destination=dest_path,
filename=dest_name, filename=dest_name,
)) )
)
logger.info(f"SubtitlePlacer: placed {dest_name}") logger.info(f"SubtitlePlacer: placed {dest_name}")
except OSError as e: except OSError as e:
logger.warning(f"SubtitlePlacer: failed to place {dest_name}: {e}") logger.warning(f"SubtitlePlacer: failed to place {dest_name}: {e}")
-2
View File
@@ -2,8 +2,6 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path
from typing import Any
class ScanStrategy(Enum): class ScanStrategy(Enum):
+1 -1
View File
@@ -1,7 +1,7 @@
"""TV Show domain entities.""" """TV Show domain entities."""
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass
from ..shared.value_objects import FilePath, FileSize, ImdbId from ..shared.value_objects import FilePath, FileSize, ImdbId
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
+1 -1
View File
@@ -14,7 +14,7 @@ class ShowStatus(Enum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
@classmethod @classmethod
def from_string(cls, status_str: str) -> "ShowStatus": def from_string(cls, status_str: str) -> ShowStatus:
""" """
Parse status from string. Parse status from string.
@@ -48,9 +48,9 @@ class QBittorrentClient:
""" """
cfg = config or settings cfg = config or settings
self.host = host or "http://192.168.178.47:30024" self.host = host or cfg.qbittorrent_url
self.username = username or "admin" self.username = username or cfg.qbittorrent_username
self.password = password or "adminadmin" self.password = password or cfg.qbittorrent_password
self.timeout = timeout or cfg.request_timeout self.timeout = timeout or cfg.request_timeout
self.session = requests.Session() self.session = requests.Session()
@@ -336,6 +336,90 @@ class QBittorrentClient:
logger.error(f"Failed to resume torrent: {e}") logger.error(f"Failed to resume torrent: {e}")
raise 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]: def get_torrent_properties(self, torrent_hash: str) -> dict[str, Any]:
""" """
Get detailed properties of a torrent. Get detailed properties of a torrent.
@@ -2,6 +2,7 @@
from .exceptions import FilesystemError, PathTraversalError from .exceptions import FilesystemError, PathTraversalError
from .file_manager import FileManager from .file_manager import FileManager
from .filesystem_operations import create_folder, move
from .organizer import MediaOrganizer from .organizer import MediaOrganizer
__all__ = [ __all__ = [
@@ -9,4 +10,6 @@ __all__ = [
"MediaOrganizer", "MediaOrganizer",
"FilesystemError", "FilesystemError",
"PathTraversalError", "PathTraversalError",
"create_folder",
"move",
] ]
+12 -6
View File
@@ -13,8 +13,10 @@ logger = logging.getLogger(__name__)
_FFPROBE_CMD = [ _FFPROBE_CMD = [
"ffprobe", "ffprobe",
"-v", "quiet", "-v",
"-print_format", "json", "quiet",
"-print_format",
"json",
"-show_streams", "-show_streams",
"-show_format", "-show_format",
] ]
@@ -77,22 +79,26 @@ def _parse(data: dict) -> MediaInfo:
info.height = stream.get("height") info.height = stream.get("height")
elif codec_type == "audio": elif codec_type == "audio":
info.audio_tracks.append(AudioTrack( info.audio_tracks.append(
AudioTrack(
index=stream.get("index", len(info.audio_tracks)), index=stream.get("index", len(info.audio_tracks)),
codec=stream.get("codec_name"), codec=stream.get("codec_name"),
channels=stream.get("channels"), channels=stream.get("channels"),
channel_layout=stream.get("channel_layout"), channel_layout=stream.get("channel_layout"),
language=stream.get("tags", {}).get("language"), language=stream.get("tags", {}).get("language"),
is_default=stream.get("disposition", {}).get("default", 0) == 1, is_default=stream.get("disposition", {}).get("default", 0) == 1,
)) )
)
elif codec_type == "subtitle": elif codec_type == "subtitle":
info.subtitle_tracks.append(SubtitleTrack( info.subtitle_tracks.append(
SubtitleTrack(
index=stream.get("index", len(info.subtitle_tracks)), index=stream.get("index", len(info.subtitle_tracks)),
codec=stream.get("codec_name"), codec=stream.get("codec_name"),
language=stream.get("tags", {}).get("language"), language=stream.get("tags", {}).get("language"),
is_default=stream.get("disposition", {}).get("default", 0) == 1, is_default=stream.get("disposition", {}).get("default", 0) == 1,
is_forced=stream.get("disposition", {}).get("forced", 0) == 1, is_forced=stream.get("disposition", {}).get("forced", 0) == 1,
)) )
)
return info return info
@@ -89,13 +89,18 @@ class FileManager:
folder_path = memory.ltm.library_paths.get(folder_type) folder_path = memory.ltm.library_paths.get(folder_type)
if not folder_path: 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) root = Path(folder_path)
target = root / safe_path target = root / safe_path
if not self._is_safe_path(root, target): 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(): if not target.exists():
return _err("not_found", f"Path does not exist: {safe_path}") 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}") return _err("source_not_file", f"Source is not a file: {source}")
if not dest_path.parent.exists(): 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(): 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) os.link(source_path, dest_path)
@@ -197,7 +207,9 @@ class FileManager:
source_path.unlink() 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 { return {
"status": "ok", "status": "ok",
"source": str(source_path), "source": str(source_path),
@@ -237,11 +249,19 @@ class FileManager:
torrent_root = Path(torrent_folder).resolve() torrent_root = Path(torrent_folder).resolve()
if not lib_path.exists(): 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(): 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(): 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 = torrent_root / src_folder.name
dest_folder.mkdir(parents=True, exist_ok=True) dest_folder.mkdir(parents=True, exist_ok=True)
@@ -266,6 +286,7 @@ class FileManager:
skipped.append(str(rel)) skipped.append(str(rel))
continue continue
import shutil import shutil
shutil.copy2(item, dest_item) shutil.copy2(item, dest_item)
copied.append(str(rel)) copied.append(str(rel))
logger.debug(f"Copied for seeding: {rel}") logger.debug(f"Copied for seeding: {rel}")
@@ -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))
@@ -67,12 +67,20 @@ class Memory:
"current_topic": self.stm.entities.topic, "current_topic": self.stm.entities.topic,
"extracted_entities": self.stm.entities.data, "extracted_entities": self.stm.entities.data,
"last_search": { "last_search": {
"query": self.episodic.search_results.last.get("query") if self.episodic.search_results.last else None, "query": self.episodic.search_results.last.get("query")
"result_count": len(self.episodic.search_results.last.get("results", [])) if self.episodic.search_results.last else 0, 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), "active_downloads_count": len(self.episodic.downloads.active),
"pending_question": self.episodic.pending_question is not None, "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: def get_full_state(self) -> dict:
@@ -16,7 +16,9 @@ class Downloads:
self.active.append(download) self.active.append(download)
logger.info(f"Downloads: Added '{download.get('name')}'") 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: for dl in self.active:
if dl.get("task_id") == task_id: if dl.get("task_id") == task_id:
dl["progress"] = progress dl["progress"] = progress
@@ -28,7 +30,13 @@ class Downloads:
for i, dl in enumerate(self.active): for i, dl in enumerate(self.active):
if dl.get("task_id") == task_id: if dl.get("task_id") == task_id:
completed = self.active.pop(i) 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')}'") logger.info(f"Downloads: Completed '{completed.get('name')}'")
return completed return completed
return None return None
@@ -15,13 +15,15 @@ class Errors:
max_errors: int = MAX_ERRORS max_errors: int = MAX_ERRORS
def add(self, action: str, error: str, context: dict | None = None) -> None: def add(self, action: str, error: str, context: dict | None = None) -> None:
self.items.append({ self.items.append(
{
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"action": action, "action": action,
"error": error, "error": error,
"context": context or {}, "context": context or {},
}) }
self.items = self.items[-self.max_errors:] )
self.items = self.items[-self.max_errors :]
logger.warning(f"Errors: '{action}': {error}") logger.warning(f"Errors: '{action}': {error}")
def clear(self) -> None: def clear(self) -> None:
@@ -15,8 +15,15 @@ class Events:
max_events: int = MAX_EVENTS max_events: int = MAX_EVENTS
def add(self, event_type: str, data: dict) -> None: 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.append(
self.items = self.items[-self.max_events:] {
"type": event_type,
"timestamp": datetime.now().isoformat(),
"data": data,
"read": False,
}
)
self.items = self.items[-self.max_events :]
logger.info(f"Events: '{event_type}'") logger.info(f"Events: '{event_type}'")
def get_unread(self) -> list[dict]: def get_unread(self) -> list[dict]:
@@ -11,7 +11,9 @@ logger = logging.getLogger(__name__)
class SearchResults: class SearchResults:
last: dict | None = None 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 = { self.last = {
"query": query, "query": query,
"type": search_type, "type": search_type,
@@ -46,7 +46,9 @@ class EpisodicMemory:
pending_question: dict | None = None pending_question: dict | None = None
# Convenience methods forwarded to components # 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) self.search_results.store(query, results, search_type)
def get_result_by_index(self, index: int) -> dict | None: def get_result_by_index(self, index: int) -> dict | None:
@@ -61,13 +63,18 @@ class EpisodicMemory:
def add_active_download(self, download: dict) -> None: def add_active_download(self, download: dict) -> None:
self.downloads.add(download) 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) self.downloads.update_progress(task_id, progress, status)
def complete_download(self, task_id: str, file_path: str) -> dict | None: def complete_download(self, task_id: str, file_path: str) -> dict | None:
completed = self.downloads.complete(task_id, file_path) completed = self.downloads.complete(task_id, file_path)
if completed: 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 return completed
def get_active_downloads(self) -> list[dict]: def get_active_downloads(self) -> list[dict]:
@@ -79,7 +86,13 @@ class EpisodicMemory:
def get_recent_errors(self) -> list[dict]: def get_recent_errors(self) -> list[dict]:
return self.errors.items 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 = { self.pending_question = {
"type": question_type, "type": question_type,
"question": question, "question": question,
@@ -39,5 +39,5 @@ class Following:
} }
@classmethod @classmethod
def from_dict(cls, data: list) -> "Following": def from_dict(cls, data: list) -> Following:
return cls(shows=data) return cls(shows=data)
@@ -57,7 +57,7 @@ class Library:
return {"movies": self.movies, "tv_shows": self.tv_shows} return {"movies": self.movies, "tv_shows": self.tv_shows}
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Library": def from_dict(cls, data: dict) -> Library:
return cls( return cls(
movies=data.get("movies", []), movies=data.get("movies", []),
tv_shows=data.get("tv_shows", []), tv_shows=data.get("tv_shows", []),
@@ -50,7 +50,7 @@ class LibraryPaths:
} }
@classmethod @classmethod
def from_dict(cls, data: dict) -> "LibraryPaths": def from_dict(cls, data: dict) -> LibraryPaths:
# Migrate from old flat format (tvshow_folder, movie_folder) # Migrate from old flat format (tvshow_folder, movie_folder)
folders = dict(data) folders = dict(data)
if not folders: if not folders:
@@ -40,7 +40,7 @@ class MediaPreferences:
} }
@classmethod @classmethod
def from_dict(cls, data: dict) -> "MediaPreferences": def from_dict(cls, data: dict) -> MediaPreferences:
return cls( return cls(
# migration: old key was preferred_quality / preferred_languages # migration: old key was preferred_quality / preferred_languages
quality=data.get("quality") or data.get("preferred_quality", "1080p"), quality=data.get("quality") or data.get("preferred_quality", "1080p"),
@@ -62,7 +62,7 @@ class SubtitlePreferences:
} }
@classmethod @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 # Migration: old fields (min_size_kb, keep_sdh, keep_forced, link_subs_folder) are silently dropped
prefs = cls( prefs = cls(
languages=data.get("languages", ["fr", "en"]), languages=data.get("languages", ["fr", "en"]),
@@ -20,16 +20,22 @@ class WorkspacePaths:
download: str | None = None download: str | None = None
torrent: str | None = None torrent: str | None = None
trash: str | None = None
def as_dict(self) -> dict[str, str]: def as_dict(self) -> dict[str, str]:
"""Return configured paths, skipping unset values.""" """Return configured paths, skipping unset values."""
return {k: v for k, v in { return {
k: v
for k, v in {
"download": self.download, "download": self.download,
"torrent": self.torrent, "torrent": self.torrent,
}.items() if v is not None} "trash": self.trash,
}.items()
if v is not None
}
def to_dict(self) -> dict: def to_dict(self) -> dict:
return {"download": self.download, "torrent": self.torrent} return {"download": self.download, "torrent": self.torrent, "trash": self.trash}
@classmethod @classmethod
def describe(cls) -> dict: def describe(cls) -> dict:
@@ -45,13 +51,15 @@ class WorkspacePaths:
"fields": { "fields": {
"download": "Root folder where qBittorrent drops completed downloads.", "download": "Root folder where qBittorrent drops completed downloads.",
"torrent": "Folder where .torrent files are stored.", "torrent": "Folder where .torrent files are stored.",
"trash": "Trash folder — files moved here instead of deleted, for manual review.",
}, },
} }
@classmethod @classmethod
def from_dict(cls, data: dict) -> "WorkspacePaths": def from_dict(cls, data: dict) -> WorkspacePaths:
# Migrate from old flat format (download_folder, torrent_folder) # Migrate from old flat format (download_folder, torrent_folder)
return cls( return cls(
download=data.get("download") or data.get("download_folder"), download=data.get("download") or data.get("download_folder"),
torrent=data.get("torrent") or data.get("torrent_folder"), torrent=data.get("torrent") or data.get("torrent_folder"),
trash=data.get("trash"),
) )
@@ -31,7 +31,9 @@ class LongTermMemory:
workspace: WorkspacePaths = field(default_factory=WorkspacePaths) workspace: WorkspacePaths = field(default_factory=WorkspacePaths)
library_paths: LibraryPaths = field(default_factory=LibraryPaths) library_paths: LibraryPaths = field(default_factory=LibraryPaths)
media_preferences: MediaPreferences = field(default_factory=MediaPreferences) 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) library: Library = field(default_factory=Library)
following: Following = field(default_factory=Following) following: Following = field(default_factory=Following)
@@ -46,7 +48,7 @@ class LongTermMemory:
} }
@classmethod @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 # Migration: old flat format had paths at the top level
workspace_data = data.get("workspace") or data workspace_data = data.get("workspace") or data
library_paths_data = data.get("library_paths") or data.get("paths") or data library_paths_data = data.get("library_paths") or data.get("paths") or data
@@ -38,7 +38,9 @@ def _load_components(package_name: str) -> list[dict]:
try: try:
descriptions.append(cls.describe()) descriptions.append(cls.describe())
except Exception as e: 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: except Exception as e:
logger.warning(f"MemoryRegistry: Could not load package {package_name}: {e}") logger.warning(f"MemoryRegistry: Could not load package {package_name}: {e}")
@@ -17,9 +17,11 @@ class Conversation:
def add(self, role: str, content: str) -> None: def add(self, role: str, content: str) -> None:
"""Append a message, capping at max_history.""" """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: 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") logger.debug(f"Conversation: Added {role} message")
def recent(self, n: int = 10) -> list[dict]: def recent(self, n: int = 10) -> list[dict]:
@@ -1,7 +1,7 @@
"""SubtitleMetadataStore — reads/writes .alfred/metadata.yaml colocated with media.""" """SubtitleMetadataStore — reads/writes .alfred/metadata.yaml colocated with media."""
import logging import logging
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -50,7 +50,13 @@ class SubtitleMetadataStore:
tmp = self._metadata_path.with_suffix(".yaml.tmp") tmp = self._metadata_path.with_suffix(".yaml.tmp")
try: try:
with open(tmp, "w", encoding="utf-8") as f: 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) tmp.rename(self._metadata_path)
except Exception as e: except Exception as e:
logger.error(f"MetadataStore: could not write {self._metadata_path}: {e}") logger.error(f"MetadataStore: could not write {self._metadata_path}: {e}")
@@ -68,7 +74,9 @@ class SubtitleMetadataStore:
return data.get("detected_pattern") return data.get("detected_pattern")
return None 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.""" """Persist detected_pattern + pattern_confirmed=true."""
data = self.load() data = self.load()
data["detected_pattern"] = pattern_id data["detected_pattern"] = pattern_id
@@ -78,7 +86,9 @@ class SubtitleMetadataStore:
data.setdefault("imdb_id", media_info.get("imdb_id")) data.setdefault("imdb_id", media_info.get("imdb_id"))
data.setdefault("title", media_info.get("title")) data.setdefault("title", media_info.get("title"))
self.save(data) 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 # Subtitle history
@@ -101,10 +111,13 @@ class SubtitleMetadataStore:
tracks_data: list[dict[str, Any]] = [] tracks_data: list[dict[str, Any]] = []
for placed, track in placed_pairs: for placed, track in placed_pairs:
# Infer type from destination filename parts (e.g. en.sdh.srt → sdh) # 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" inferred_type = parts[1] if len(parts) == 3 else "standard"
tracks_data.append({ tracks_data.append(
{
"language": track.language.code if track.language else "unknown", "language": track.language.code if track.language else "unknown",
"type": inferred_type, "type": inferred_type,
"format": placed.destination.suffix.lstrip("."), "format": placed.destination.suffix.lstrip("."),
@@ -112,10 +125,11 @@ class SubtitleMetadataStore:
"source_file": placed.source.name, "source_file": placed.source.name,
"placed_as": placed.filename, "placed_as": placed.filename,
"confidence": round(track.confidence, 3), "confidence": round(track.confidence, 3),
}) }
)
entry: dict[str, Any] = { entry: dict[str, Any] = {
"placed_at": datetime.now(timezone.utc).isoformat(), "placed_at": datetime.now(UTC).isoformat(),
"release_group": release_group, "release_group": release_group,
"tracks": tracks_data, "tracks": tracks_data,
} }
@@ -10,7 +10,9 @@ from alfred.domain.subtitles.aggregates import SubtitleRuleSet
from alfred.domain.subtitles.value_objects import RuleScope from alfred.domain.subtitles.value_objects import RuleScope
if TYPE_CHECKING: 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__) logger = logging.getLogger(__name__)
@@ -46,7 +48,7 @@ class RuleSetRepository:
def load( def load(
self, self,
release_group: str | None = None, release_group: str | None = None,
subtitle_preferences: "SubtitlePreferences | None" = None, subtitle_preferences: SubtitlePreferences | None = None,
) -> SubtitleRuleSet: ) -> SubtitleRuleSet:
""" """
Build and return the resolved RuleSet chain. Build and return the resolved RuleSet chain.
@@ -75,7 +77,9 @@ class RuleSetRepository:
) )
rg_ruleset.override(**_filter_override(rg_data)) rg_ruleset.override(**_filter_override(rg_data))
current = rg_ruleset 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 (show/movie) level
local_data = _load_yaml(self._alfred_dir / "rules.yaml").get("override", {}) local_data = _load_yaml(self._alfred_dir / "rules.yaml").get("override", {})
@@ -101,7 +105,13 @@ class RuleSetRepository:
tmp = path.with_suffix(".yaml.tmp") tmp = path.with_suffix(".yaml.tmp")
try: try:
with open(tmp, "w", encoding="utf-8") as f: 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) tmp.rename(path)
logger.info(f"RuleSetRepository: saved local rules to {path}") logger.info(f"RuleSetRepository: saved local rules to {path}")
except Exception as e: except Exception as e:
+26 -2
View File
@@ -30,7 +30,7 @@ types:
languages: languages:
fra: fra:
tokens: ["fr", "fra", "french", "francais", "vf", "vff", "vostfr"] tokens: ["fr", "fra", "fre", "french", "francais", "vf", "vff", "vostfr"]
eng: eng:
tokens: ["en", "eng", "english"] tokens: ["en", "eng", "english"]
spa: spa:
@@ -80,10 +80,34 @@ languages:
jpn: jpn:
tokens: ["ja", "jpn", "japanese"] tokens: ["ja", "jpn", "japanese"]
zho: zho:
tokens: ["zh", "zho", "chi", "chinese"] tokens: ["zh", "zho", "chi", "chinese", "simplified", "traditional"]
yue:
tokens: ["yue", "cantonese"]
kor: kor:
tokens: ["ko", "kor", "korean"] tokens: ["ko", "kor", "korean"]
ara: ara:
tokens: ["ar", "ara", "arabic"] tokens: ["ar", "ara", "arabic"]
tur: tur:
tokens: ["tr", "tur", "turkish"] 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"]
+53
View File
@@ -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."
+32
View File
@@ -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."
+24 -5
View File
@@ -20,7 +20,11 @@ class ConfigurationError(Exception):
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( 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", env_file_encoding="utf-8",
extra="ignore", extra="ignore",
case_sensitive=False, case_sensitive=False,
@@ -41,7 +45,16 @@ class Settings(BaseSettings):
ollama_base_url: str = "http://ollama:11434" ollama_base_url: str = "http://ollama:11434"
ollama_model: str = "llama3.3:latest" ollama_model: str = "llama3.3:latest"
deepseek_base_url: str = "https://api.deepseek.com" 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 --- # --- API KEYS ---
tmdb_api_key: str | None = None tmdb_api_key: str | None = None
@@ -57,21 +70,27 @@ class Settings(BaseSettings):
@classmethod @classmethod
def validate_temperature(cls, v: float) -> float: def validate_temperature(cls, v: float) -> float:
if not 0.0 <= v <= 2.0: 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 return v
@field_validator("max_tool_iterations") @field_validator("max_tool_iterations")
@classmethod @classmethod
def validate_max_iterations(cls, v: int) -> int: def validate_max_iterations(cls, v: int) -> int:
if not 1 <= v <= 20: 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 return v
@field_validator("request_timeout") @field_validator("request_timeout")
@classmethod @classmethod
def validate_timeout(cls, v: int) -> int: def validate_timeout(cls, v: int) -> int:
if not 1 <= v <= 300: 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 return v
# --- HELPERS --- # --- HELPERS ---
+10 -4
View File
@@ -4,12 +4,12 @@
import re import re
import secrets import secrets
import sys import sys
import tomllib
from pathlib import Path from pathlib import Path
import tomllib
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
def load_secrets_spec(toml_data: dict) -> dict[str, tuple[int, str]]: def load_secrets_spec(toml_data: dict) -> dict[str, tuple[int, str]]:
"""Load secrets spec from pyproject.toml [tool.alfred.secrets].""" """Load secrets spec from pyproject.toml [tool.alfred.secrets]."""
raw = toml_data.get("tool", {}).get("alfred", {}).get("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: def generate_secrets_file(path: Path, secrets_spec: dict[str, tuple[int, str]]) -> None:
"""Generate .env.secrets with missing secrets, never overwrite existing ones.""" """Generate .env.secrets with missing secrets, never overwrite existing ones."""
existing = load_env_file(path) existing = load_env_file(path)
lines = list(path.read_text().splitlines()) if path.exists() else [ lines = (
list(path.read_text().splitlines())
if path.exists()
else [
"# Auto-generated secrets — DO NOT COMMIT", "# Auto-generated secrets — DO NOT COMMIT",
"# Run 'make bootstrap' to generate missing secrets", "# Run 'make bootstrap' to generate missing secrets",
"", "",
] ]
)
added = [] added = []
for key, (size, fmt) in secrets_spec.items(): for key, (size, fmt) in secrets_spec.items():
@@ -108,7 +112,9 @@ def build_uris(env_alfred: Path, env_secrets: Path) -> None:
added = [] added = []
for key, value in computed.items(): for key, value in computed.items():
if key in existing: 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: else:
content = content.rstrip("\n") + f"\n{key}={value}\n" content = content.rstrip("\n") + f"\n{key}={value}\n"
added.append(key) added.append(key)
+1 -2
View File
@@ -1,11 +1,10 @@
"""Shared configuration loader — reads build config from pyproject.toml.""" """Shared configuration loader — reads build config from pyproject.toml."""
import re import re
import tomllib
from pathlib import Path from pathlib import Path
from typing import NamedTuple from typing import NamedTuple
import tomllib
class BuildConfig(NamedTuple): class BuildConfig(NamedTuple):
"""Build configuration extracted from pyproject.toml.""" """Build configuration extracted from pyproject.toml."""
+418
View File
@@ -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 : <source_folder>")
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)
+60 -19
View File
@@ -51,6 +51,7 @@ def hr() -> None:
# TMDB lookup # TMDB lookup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]: def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
""" """
Call TMDBClient.search_media() and return (canonical_title, year). Call TMDBClient.search_media() and return (canonical_title, year).
@@ -58,6 +59,7 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
""" """
try: try:
from alfred.infrastructure.api.tmdb import TMDBClient from alfred.infrastructure.api.tmdb import TMDBClient
client = TMDBClient() client = TMDBClient()
result = client.search_media(title) result = client.search_media(title)
year: int | None = None year: int | None = None
@@ -66,7 +68,12 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
year = int(result.release_date[:4]) year = int(result.release_date[:4])
except (ValueError, IndexError): except (ValueError, IndexError):
pass 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 return result.title, year
except Exception as e: except Exception as e:
print(c(f" TMDB lookup failed: {e}", YELLOW)) print(c(f" TMDB lookup failed: {e}", YELLOW))
@@ -77,8 +84,14 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
# Display # 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 from alfred.domain.release import parse_release
p = parse_release(release_name) p = parse_release(release_name)
@@ -102,7 +115,10 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
kv("year", str(p.year) if p.year else c("None", DIM)) 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("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", 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(
"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("quality", p.quality or c("None", DIM))
kv("source", p.source or c("None", DIM)) kv("source", p.source or c("None", DIM))
kv("codec", p.codec or c("None", DIM)) kv("codec", p.codec or c("None", DIM))
@@ -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: if tmdb_title or tmdb_year or tmdb_episode_title:
hr() hr()
print(c(" TMDB data used:", DIM)) print(c(" TMDB data used:", DIM))
if tmdb_title: kv(" tmdb_title", tmdb_title) if tmdb_title:
if tmdb_year: kv(" tmdb_year", str(tmdb_year)) kv(" tmdb_title", tmdb_title)
if tmdb_episode_title: kv(" tmdb_episode_title", tmdb_episode_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(c("" * 64, BOLD))
print() print()
@@ -145,10 +164,16 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
# Interactive mode # Interactive mode
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _interactive() -> None: def _interactive() -> None:
print(c("\n Alfred — Release Parser REPL", BOLD, CYAN)) print(c("\n Alfred — Release Parser REPL", BOLD, CYAN))
print(c(" Type a release name, or 'q' to quit.", DIM)) 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: while True:
try: try:
@@ -186,6 +211,7 @@ def _interactive() -> None:
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main() -> None: def main() -> None:
global USE_COLOR global USE_COLOR
@@ -194,16 +220,29 @@ def main() -> None:
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
parser.add_argument("release", nargs="?", help="Release name to parse") parser.add_argument("release", nargs="?", help="Release name to parse")
parser.add_argument("-i", "--interactive", action="store_true", parser.add_argument(
help="Interactive REPL mode") "-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(
parser.add_argument("--tmdb-year", metavar="YEAR", type=int, "--tmdb-title", metavar="TITLE", help="Override TMDB title for name generation"
help="Override TMDB year for name generation") )
parser.add_argument("--episode-title", metavar="TITLE", parser.add_argument(
help="TMDB episode title for episode_filename()") "--tmdb-year",
parser.add_argument("--ext", default=".mkv", metavar="EXT", metavar="YEAR",
help="File extension for filename generation (default: .mkv)") 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") parser.add_argument("--no-color", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@@ -219,7 +258,9 @@ def main() -> None:
sys.exit(1) sys.exit(1)
try: 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: except Exception as e:
print(c(f"Error: {e}", RED), file=sys.stderr) print(c(f"Error: {e}", RED), file=sys.stderr)
sys.exit(1) sys.exit(1)
+5 -1
View File
@@ -54,6 +54,7 @@ def hr() -> None:
# Formatting helpers # Formatting helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def fmt_duration(seconds: float) -> str: def fmt_duration(seconds: float) -> str:
h = int(seconds // 3600) h = int(seconds // 3600)
m = int((seconds % 3600) // 60) m = int((seconds % 3600) // 60)
@@ -80,6 +81,7 @@ def flag(val: bool) -> str:
# Main # Main
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main() -> None: def main() -> None:
global USE_COLOR global USE_COLOR
@@ -151,7 +153,9 @@ def main() -> None:
hr() hr()
multi = c("yes", GREEN) if info.is_multi_audio else c("no", DIM) 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) 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() hr()
print() print()
+23 -13
View File
@@ -50,6 +50,7 @@ def hr() -> None:
# Parsing quality check # Parsing quality check
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _assess(p) -> list[str]: def _assess(p) -> list[str]:
"""Return a list of warning strings for fields that look wrong.""" """Return a list of warning strings for fields that look wrong."""
if p.media_type in ("other", "unknown"): if p.media_type in ("other", "unknown"):
@@ -70,16 +71,24 @@ def _assess(p) -> list[str]:
# Main # Main
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main() -> None: def main() -> None:
global USE_COLOR global USE_COLOR
parser = argparse.ArgumentParser(description="Recognize release folders in downloads") parser = argparse.ArgumentParser(
parser.add_argument("--path", default="/mnt/testipool/downloads", description="Recognize release folders in downloads"
help="Downloads directory (default: /mnt/testipool/downloads)") )
parser.add_argument("--failures-only", action="store_true", parser.add_argument(
help="Show only entries with warnings") "--path",
parser.add_argument("--successes-only", action="store_true", default="/mnt/testipool/downloads",
help="Show only fully parsed entries") 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") parser.add_argument("--no-color", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@@ -91,11 +100,11 @@ def main() -> None:
print(c(f"Error: {downloads} does not exist", RED), file=sys.stderr) print(c(f"Error: {downloads} does not exist", RED), file=sys.stderr)
sys.exit(1) 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.detect_media_type import detect_media_type
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe 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.ffprobe import probe
from alfred.infrastructure.filesystem.find_video import find_video_file
entries = sorted(downloads.iterdir(), key=lambda p: p.name.lower()) entries = sorted(downloads.iterdir(), key=lambda p: p.name.lower())
total = len(entries) total = len(entries)
@@ -178,8 +187,7 @@ def main() -> None:
kv("hdr/depth", " ".join(hdr_parts)) kv("hdr/depth", " ".join(hdr_parts))
if p.edition: if p.edition:
kv("edition", p.edition, color=YELLOW) kv("edition", p.edition, color=YELLOW)
kv("group", p.group, kv("group", p.group, color=YELLOW if p.group == "UNKNOWN" else GREEN)
color=YELLOW if p.group == "UNKNOWN" else GREEN)
if p.site_tag: if p.site_tag:
kv("site tag", p.site_tag, color=YELLOW) kv("site tag", p.site_tag, color=YELLOW)
@@ -191,10 +199,12 @@ def main() -> None:
print() print()
hr() hr()
skipped = total - ok_count - warn_count skipped = total - ok_count - warn_count
print(f" {c('Total:', BOLD)} {total} " print(
f" {c('Total:', BOLD)} {total} "
f"{c(str(ok_count) + ' ok', GREEN, BOLD)} " f"{c(str(ok_count) + ' ok', GREEN, BOLD)} "
f"{c(str(warn_count) + ' warnings', YELLOW, BOLD)}" f"{c(str(warn_count) + ' warnings', YELLOW, BOLD)}"
+ (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else "")) + (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else "")
)
hr() hr()
print() print()
+81 -34
View File
@@ -88,8 +88,7 @@ VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".mov", ".ts", ".m2ts"}
def find_videos(folder: Path) -> list[Path]: def find_videos(folder: Path) -> list[Path]:
return sorted( return sorted(
p for p in folder.iterdir() p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
) )
@@ -109,7 +108,11 @@ def track_summary(track, verbose: bool = False) -> None:
lang = track.language.code if track.language else c("?", RED) lang = track.language.code if track.language else c("?", RED)
fmt = track.format.id if track.format else c("?", RED) fmt = track.format.id if track.format else c("?", RED)
typ = track.subtitle_type.value typ = track.subtitle_type.value
src = "embedded" if track.is_embedded else (track.file_path.name if track.file_path else "?") src = (
"embedded"
if track.is_embedded
else (track.file_path.name if track.file_path else "?")
)
# Couleur du type # Couleur du type
type_colors = { type_colors = {
@@ -125,11 +128,19 @@ def track_summary(track, verbose: bool = False) -> None:
print(f" {c(src, BOLD)}") print(f" {c(src, BOLD)}")
print(f" lang={c(lang, CYAN)} type={typ_str} format={fmt}") 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}") print(f" confidence={conf_str}{clarif}")
if track.entry_count is not None: 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: if verbose and track.raw_tokens:
print(f" tokens={track.raw_tokens}") print(f" tokens={track.raw_tokens}")
@@ -146,7 +157,8 @@ def track_summary(track, verbose: bool = False) -> None:
# Étapes du pipeline # É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.base import SubtitleKnowledgeBase
from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader
@@ -168,12 +180,12 @@ def step_load_kb() -> "SubtitleKnowledgeBase":
def step_detect_pattern( def step_detect_pattern(
kb: "SubtitleKnowledgeBase", kb: SubtitleKnowledgeBase,
season_folder: Path, season_folder: Path,
sample_video: Path, sample_video: Path,
release_group: str | None, release_group: str | None,
forced_pattern: str | None, forced_pattern: str | None,
) -> "SubtitlePattern": ) -> SubtitlePattern:
from alfred.domain.subtitles.services.pattern_detector import PatternDetector from alfred.domain.subtitles.services.pattern_detector import PatternDetector
section("ÉTAPE 2 — Détection du pattern de release") section("ÉTAPE 2 — Détection du pattern de release")
@@ -192,7 +204,9 @@ def step_detect_pattern(
known = kb.patterns_for_group(release_group) known = kb.patterns_for_group(release_group)
if known: if known:
kv("Release group", release_group) 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] pattern = known[0]
kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD)) kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD))
return pattern return pattern
@@ -237,12 +251,12 @@ def step_detect_pattern(
def step_identify_tracks( def step_identify_tracks(
kb: "SubtitleKnowledgeBase", kb: SubtitleKnowledgeBase,
sample_video: Path, sample_video: Path,
pattern: "SubtitlePattern", pattern: SubtitlePattern,
release_group: str | None, release_group: str | None,
verbose: bool, verbose: bool,
) -> "MediaSubtitleMetadata": ) -> MediaSubtitleMetadata:
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
section("ÉTAPE 3 — Identification des pistes") section("ÉTAPE 3 — Identification des pistes")
@@ -286,9 +300,9 @@ def step_identify_tracks(
def step_apply_rules( def step_apply_rules(
metadata: "MediaSubtitleMetadata", metadata: MediaSubtitleMetadata,
release_group: str | None, 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.aggregates import DEFAULT_RULES
from alfred.domain.subtitles.services.matcher import SubtitleMatcher from alfred.domain.subtitles.services.matcher import SubtitleMatcher
from alfred.domain.subtitles.services.utils import available_subtitles 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("Formats préférés", str(rules.preferred_formats))
kv("Types autorisés", str(rules.allowed_types)) kv("Types autorisés", str(rules.allowed_types))
kv("Confiance min", str(rules.min_confidence)) 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() matcher = SubtitleMatcher()
matched, unresolved = matcher.match(metadata.external_tracks, rules) matched, unresolved = matcher.match(metadata.external_tracks, rules)
@@ -330,7 +346,9 @@ def step_show_results(
section("RÉSULTAT FINAL") section("RÉSULTAT FINAL")
if matched: 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}:") ok(f"{len(matched)} {label}:")
for track in matched: for track in matched:
lang = track.language.code if track.language else "?" 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:") warn(f"{len(unresolved)} piste(s) écartées ou à clarifier:")
for track in unresolved: for track in unresolved:
src = track.file_path.name if track.file_path else "?" 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})" line = f" {c(src, DIM)} ({reason})"
if verbose and track.raw_tokens: if verbose and track.raw_tokens:
line += c(f" tokens: {track.raw_tokens}", YELLOW) line += c(f" tokens: {track.raw_tokens}", YELLOW)
@@ -365,9 +387,10 @@ def step_show_results(
# Scan multi-épisodes (résumé) # Scan multi-épisodes (résumé)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def scan_season( def scan_season(
kb: "SubtitleKnowledgeBase", kb: SubtitleKnowledgeBase,
pattern: "SubtitlePattern", pattern: SubtitlePattern,
season_folder: Path, season_folder: Path,
release_group: str | None, release_group: str | None,
verbose: bool, verbose: bool,
@@ -408,15 +431,20 @@ def scan_season(
pass pass
status_icon = c("", GREEN, BOLD) if placed_names else c("", RED, BOLD) 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 # Main
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Scanner de sous-titres Alfred — pipeline de diagnostic", description="Scanner de sous-titres Alfred — pipeline de diagnostic",
@@ -424,18 +452,35 @@ def parse_args() -> argparse.Namespace:
epilog=textwrap.dedent(__doc__ or ""), epilog=textwrap.dedent(__doc__ or ""),
) )
parser.add_argument("season_folder", help="Dossier de la saison (ou du film)") parser.add_argument("season_folder", help="Dossier de la saison (ou du film)")
parser.add_argument("--release-group", "-g", metavar="GROUP", parser.add_argument(
help="Groupe de release (ex: RARBG, KONSTRAST)") "--release-group",
parser.add_argument("--pattern", "-p", metavar="PATTERN", "-g",
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)") metavar="GROUP",
parser.add_argument("--video", "-v", metavar="FILE", help="Groupe de release (ex: RARBG, KONSTRAST)",
help="Fichier vidéo de référence (défaut: premier trouvé)") )
parser.add_argument("--verbose", action="store_true", parser.add_argument(
help="Affiche les tokens bruts par piste") "--pattern",
parser.add_argument("--no-color", action="store_true", "-p",
help="Désactive la colorisation ANSI") metavar="PATTERN",
parser.add_argument("--season-scan", action="store_true", help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)",
help="Après le diagnostic, scanner tous les épisodes de la saison") )
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() return parser.parse_args()
@@ -474,7 +519,9 @@ def main() -> None:
if videos: if videos:
break break
if not videos: 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) sys.exit(1)
sample_video = videos[0] sample_video = videos[0]
+139 -42
View File
@@ -67,10 +67,22 @@ def section(title: str) -> None:
print(c("" * 70, DIM)) print(c("" * 70, DIM))
def ok(msg: str) -> None: print(c("", GREEN, BOLD) + msg) def ok(msg: str) -> None:
def warn(msg: str) -> None: print(c(" ", YELLOW, BOLD) + msg) print(c(" ", GREEN, BOLD) + msg)
def err(msg: str) -> None: print(c("", RED, BOLD) + msg)
def info(msg: str) -> None: print(f" {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: def kv(key: str, val: str) -> None:
print(f" {c(key + ':', BOLD)} {val}") print(f" {c(key + ':', BOLD)} {val}")
@@ -79,6 +91,7 @@ def kv(key: str, val: str) -> None:
# Dry-run tool stubs # Dry-run tool stubs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]: def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
"""Call the real list_folder (read-only, safe in dry-run).""" """Call the real list_folder (read-only, safe in dry-run)."""
# TODO: remove hardcoded fallback once download path is configured in LTM # 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: try:
from alfred.infrastructure.persistence import get_memory, init_memory from alfred.infrastructure.persistence import get_memory, init_memory
try: try:
get_memory() get_memory()
except Exception: except Exception:
init_memory() init_memory()
from alfred.agent.tools.filesystem import list_folder from alfred.agent.tools.filesystem import list_folder
result = list_folder(folder_type=folder_type, path=path) result = list_folder(folder_type=folder_type, path=path)
if result.get("status") == "error" and folder_type == "download": if result.get("status") == "error" and folder_type == "download":
raise RuntimeError(result.get("message", "not configured")) raise RuntimeError(result.get("message", "not configured"))
return result return result
except Exception as e: except Exception as e:
if folder_type == "download": 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 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: try:
entries = sorted(os.listdir(resolved)) entries = sorted(os.listdir(resolved))
except OSError as oe: 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).""" """Call the real TMDB API even in dry-run (read-only, no filesystem side effects)."""
try: try:
from alfred.infrastructure.persistence import get_memory, init_memory from alfred.infrastructure.persistence import get_memory, init_memory
try: try:
get_memory() get_memory()
except Exception: except Exception:
init_memory() init_memory()
from alfred.agent.tools.api import find_media_imdb_id from alfred.agent.tools.api import find_media_imdb_id
return find_media_imdb_id(media_title=media_title) return find_media_imdb_id(media_title=media_title)
except Exception as e: except Exception as e:
warn(f"find_media_imdb_id: TMDB unavailable ({e}), falling back to stub") 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, confirmed_folder: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from alfred.domain.release import parse_release from alfred.domain.release import parse_release
parsed = parse_release(release_name) parsed = parse_release(release_name)
ext = Path(source_file).suffix ext = Path(source_file).suffix
if parsed.is_movie: if parsed.is_movie:
@@ -168,7 +193,11 @@ def _dry_resolve_destination(
} }
season_folder = parsed.season_folder_name() season_folder = parsed.season_folder_name()
show_folder = confirmed_folder or parsed.show_folder_name(tmdb_title, tmdb_year) 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 { return {
"status": "ok", "status": "ok",
"library_file": f"/tv/{show_folder}/{season_folder}/{fname}", "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 { return {
"status": "ok", "status": "ok",
"torrent_subfolder": f"/torrents/{Path(original_download_folder).name}", "torrent_subfolder": f"/torrents/{Path(original_download_folder).name}",
@@ -226,6 +257,7 @@ DRY_RUN_TOOLS: dict[str, Any] = {
# Live tools # Live tools
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _load_live_tools() -> dict[str, Any]: def _load_live_tools() -> dict[str, Any]:
from alfred.agent.tools.filesystem import ( from alfred.agent.tools.filesystem import (
create_seed_links, create_seed_links,
@@ -233,12 +265,18 @@ def _load_live_tools() -> dict[str, Any]:
manage_subtitles, manage_subtitles,
move_media, move_media,
) )
# find_media_imdb_id lives in the api tools # find_media_imdb_id lives in the api tools
try: try:
from alfred.agent.tools.api import find_media_imdb_id from alfred.agent.tools.api import find_media_imdb_id
except ImportError: except ImportError:
def find_media_imdb_id(**kwargs): # type: ignore[misc] 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 { return {
"list_folder": list_folder, "list_folder": list_folder,
@@ -253,8 +291,15 @@ def _load_live_tools() -> dict[str, Any]:
# Workflow runner # Workflow runner
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class WorkflowRunner: 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.workflow = workflow
self.tools = tools self.tools = tools
self.live = live self.live = live
@@ -281,11 +326,15 @@ class WorkflowRunner:
section("SIMULATION TERMINÉE") section("SIMULATION TERMINÉE")
ok(f"{len(self.step_results)} step(s) exécuté(s)") 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: if errors:
warn(f"{len(errors)} step(s) en erreur") warn(f"{len(errors)} step(s) en erreur")
for r in errors: 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()
print(c("" * 70, BOLD)) print(c("" * 70, BOLD))
print() print()
@@ -306,7 +355,7 @@ class WorkflowRunner:
answers_str = {str(k): v for k, v in answers.items()} answers_str = {str(k): v for k, v in answers.items()}
next_step = answers_str.get(answer, {}).get("next_step", "update_library") 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)}") 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["ask_seeding_answer"] = answer
self.context["next_after_ask"] = next_step self.context["next_after_ask"] = next_step
@@ -332,7 +381,9 @@ class WorkflowRunner:
return return
# Skip create_seed_links if user said no to seeding # 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}") section(f"STEP [{step_id}] — {tool_name}")
warn("Skipped (user chose not to seed)") warn("Skipped (user chose not to seed)")
return return
@@ -349,14 +400,18 @@ class WorkflowRunner:
if tool_name not in self.tools: if tool_name not in self.tools:
err(f"Tool '{tool_name}' not found in tool registry") 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 return
try: try:
result = self.tools[tool_name](**kwargs) result = self.tools[tool_name](**kwargs)
except Exception as e: except Exception as e:
err(f"Tool raised an exception: {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 return
self._print_result(result, tool_name=tool_name) self._print_result(result, tool_name=tool_name)
@@ -364,14 +419,20 @@ class WorkflowRunner:
self.step_results.append({"id": step_id, "result": result}) self.step_results.append({"id": step_id, "result": result})
# After list_downloads: confirm the requested media folder exists in downloads # 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", "") folder_path = result.get("path", "")
entries = result.get("entries", []) entries = result.get("entries", [])
if self.args.source in entries: if self.args.source in entries:
media_folder = str(Path(folder_path) / self.args.source) media_folder = str(Path(folder_path) / self.args.source)
self.context["media_folder"] = media_folder self.context["media_folder"] = media_folder
print() 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: else:
warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}") warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}")
@@ -446,13 +507,17 @@ class WorkflowRunner:
elif status == "needs_clarification": elif status == "needs_clarification":
warn(f"status={c('needs_clarification', YELLOW)}") warn(f"status={c('needs_clarification', YELLOW)}")
else: 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 return
# Highlight resolved folder path for list_folder # Highlight resolved folder path for list_folder
if tool_name == "list_folder" and result.get("path"): if tool_name == "list_folder" and result.get("path"):
print() 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 # Pretty-print notable fields
skip = {"status", "error", "message"} skip = {"status", "error", "message"}
@@ -476,6 +541,7 @@ class WorkflowRunner:
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Alfred workflow simulator", description="Alfred workflow simulator",
@@ -483,28 +549,58 @@ def parse_args() -> argparse.Namespace:
epilog=textwrap.dedent(__doc__ or ""), epilog=textwrap.dedent(__doc__ or ""),
) )
parser.add_argument("workflow", help="Workflow name (e.g. organize_media)") parser.add_argument("workflow", help="Workflow name (e.g. organize_media)")
parser.add_argument("--dry-run", dest="dry_run", action="store_true", default=True, parser.add_argument(
help="Simulate steps without executing tools (default)") "--dry-run",
parser.add_argument("--live", action="store_true", dest="dry_run",
help="Actually execute tools against the real filesystem") action="store_true",
parser.add_argument("--source", metavar="FOLDER_NAME", default=True,
help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)") help="Simulate steps without executing tools (default)",
parser.add_argument("--dest", metavar="PATH", )
help="Destination video file (in library, overrides resolve_destination)") parser.add_argument(
parser.add_argument("--download-folder", metavar="PATH", "--live",
help="Original download folder (for create_seed_links)") action="store_true",
parser.add_argument("--imdb-id", metavar="ID", help="Actually execute tools against the real filesystem",
help="IMDb ID for identify_media (tt1234567)") )
parser.add_argument("--release", metavar="NAME", parser.add_argument(
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)") "--source",
parser.add_argument("--tmdb-title", metavar="TITLE", metavar="FOLDER_NAME",
help="Canonical title from TMDB (e.g. 'Oz')") help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)",
parser.add_argument("--tmdb-year", metavar="YEAR", type=int, )
help="Start/release year from TMDB (e.g. 1997)") parser.add_argument(
parser.add_argument("--episode-title", metavar="TITLE", "--dest",
help="Episode title from TMDB for single-episode releases") metavar="PATH",
parser.add_argument("--seed", action="store_true", help="Destination video file (in library, overrides resolve_destination)",
help='Answer "yes" to the seeding question') )
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") parser.add_argument("--no-color", action="store_true")
return parser.parse_args() return parser.parse_args()
@@ -521,6 +617,7 @@ def main() -> None:
# Load workflow # Load workflow
from alfred.agent.workflows.loader import WorkflowLoader from alfred.agent.workflows.loader import WorkflowLoader
loader = WorkflowLoader() loader = WorkflowLoader()
workflow = loader.get(args.workflow) workflow = loader.get(args.workflow)
if not workflow: if not workflow:
+17 -5
View File
@@ -2,22 +2,20 @@
Tests for alfred.agent.registry — tool registration and JSON schema generation. 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.agent.registry import Tool, _create_tool_from_function, make_tools
from alfred.settings import settings from alfred.settings import settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _create_tool_from_function # _create_tool_from_function
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCreateToolFromFunction:
class TestCreateToolFromFunction:
def test_name_from_function(self): def test_name_from_function(self):
def my_tool(x: str) -> dict: def my_tool(x: str) -> dict:
"""Does something.""" """Does something."""
return {} return {}
tool = _create_tool_from_function(my_tool) tool = _create_tool_from_function(my_tool)
assert tool.name == "my_tool" assert tool.name == "my_tool"
@@ -28,12 +26,14 @@ class TestCreateToolFromFunction:
More details here. More details here.
""" """
return {} return {}
tool = _create_tool_from_function(my_tool) tool = _create_tool_from_function(my_tool)
assert tool.description == "First line description." assert tool.description == "First line description."
def test_description_fallback_to_name(self): def test_description_fallback_to_name(self):
def no_doc(x: str) -> dict: def no_doc(x: str) -> dict:
return {} return {}
tool = _create_tool_from_function(no_doc) tool = _create_tool_from_function(no_doc)
assert tool.description == "no_doc" assert tool.description == "no_doc"
@@ -41,6 +41,7 @@ class TestCreateToolFromFunction:
def tool(a: str, b: int) -> dict: def tool(a: str, b: int) -> dict:
"""Tool.""" """Tool."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert "a" in t.parameters["required"] assert "a" in t.parameters["required"]
assert "b" in t.parameters["required"] assert "b" in t.parameters["required"]
@@ -49,6 +50,7 @@ class TestCreateToolFromFunction:
def tool(a: str, b: str = "default") -> dict: def tool(a: str, b: str = "default") -> dict:
"""Tool.""" """Tool."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert "a" in t.parameters["required"] assert "a" in t.parameters["required"]
assert "b" not 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: def tool(a: str, b: str | None = None) -> dict:
"""Tool.""" """Tool."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert "b" not in t.parameters["required"] assert "b" not in t.parameters["required"]
@@ -64,6 +67,7 @@ class TestCreateToolFromFunction:
def tool(x: str) -> dict: def tool(x: str) -> dict:
"""T.""" """T."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "string" assert t.parameters["properties"]["x"]["type"] == "string"
@@ -71,6 +75,7 @@ class TestCreateToolFromFunction:
def tool(x: int) -> dict: def tool(x: int) -> dict:
"""T.""" """T."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "integer" assert t.parameters["properties"]["x"]["type"] == "integer"
@@ -78,6 +83,7 @@ class TestCreateToolFromFunction:
def tool(x: float) -> dict: def tool(x: float) -> dict:
"""T.""" """T."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "number" assert t.parameters["properties"]["x"]["type"] == "number"
@@ -85,6 +91,7 @@ class TestCreateToolFromFunction:
def tool(x: bool) -> dict: def tool(x: bool) -> dict:
"""T.""" """T."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "boolean" assert t.parameters["properties"]["x"]["type"] == "boolean"
@@ -92,6 +99,7 @@ class TestCreateToolFromFunction:
def tool(x: list) -> dict: def tool(x: list) -> dict:
"""T.""" """T."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "string" assert t.parameters["properties"]["x"]["type"] == "string"
@@ -99,6 +107,7 @@ class TestCreateToolFromFunction:
def tool(x) -> dict: def tool(x) -> dict:
"""T.""" """T."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "string" assert t.parameters["properties"]["x"]["type"] == "string"
@@ -107,6 +116,7 @@ class TestCreateToolFromFunction:
def tool(self, x: str) -> dict: def tool(self, x: str) -> dict:
"""T.""" """T."""
return {} return {}
t = _create_tool_from_function(MyClass().tool) t = _create_tool_from_function(MyClass().tool)
assert "self" not in t.parameters["properties"] assert "self" not in t.parameters["properties"]
@@ -114,6 +124,7 @@ class TestCreateToolFromFunction:
def tool(a: str, b: int = 0) -> dict: def tool(a: str, b: int = 0) -> dict:
"""T.""" """T."""
return {} return {}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert t.parameters["type"] == "object" assert t.parameters["type"] == "object"
assert "properties" in t.parameters assert "properties" in t.parameters
@@ -123,6 +134,7 @@ class TestCreateToolFromFunction:
def tool(x: str) -> dict: def tool(x: str) -> dict:
"""T.""" """T."""
return {"x": x} return {"x": x}
t = _create_tool_from_function(tool) t = _create_tool_from_function(tool)
assert t.func("hello") == {"x": "hello"} assert t.func("hello") == {"x": "hello"}
@@ -131,8 +143,8 @@ class TestCreateToolFromFunction:
# make_tools # make_tools
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMakeTools:
class TestMakeTools:
def test_returns_dict(self): def test_returns_dict(self):
tools = make_tools(settings) tools = make_tools(settings)
assert isinstance(tools, dict) assert isinstance(tools, dict)
-2
View File
@@ -2,7 +2,6 @@
import shutil import shutil
import tempfile import tempfile
from pathlib import Path
import pytest 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. Fresh Memory with library_paths and workspace configured using the real API.
Replaces the broken memory_with_config from root conftest for these tests. Replaces the broken memory_with_config from root conftest for these tests.
""" """
import tempfile, os
storage = tempfile.mkdtemp() storage = tempfile.mkdtemp()
mem = Memory(storage_dir=storage) mem = Memory(storage_dir=storage)
set_memory(mem) set_memory(mem)
+17 -9
View File
@@ -2,10 +2,6 @@
Tests for alfred.application.filesystem.create_seed_links.CreateSeedLinksUseCase Tests for alfred.application.filesystem.create_seed_links.CreateSeedLinksUseCase
""" """
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest import pytest
from alfred.application.filesystem.create_seed_links import CreateSeedLinksUseCase 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") 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_dir.mkdir(parents=True)
lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4" lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
lib_video.write_bytes(b"video") lib_video.write_bytes(b"video")
@@ -43,7 +44,9 @@ def seed_env(tmp_path_factory):
(dl / "[TGx]info.txt").write_text("tgx") (dl / "[TGx]info.txt").write_text("tgx")
subs = dl / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST" subs = dl / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
subs.mkdir(parents=True) 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 = d / "torrents"
torrents.mkdir() torrents.mkdir()
@@ -55,10 +58,13 @@ def seed_env(tmp_path_factory):
# Happy path # 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 from alfred.infrastructure.persistence import get_memory
mem = get_memory() mem = get_memory()
lib_video, dl, torrents = seed_env lib_video, dl, torrents = seed_env
mem.ltm.workspace.torrent = str(torrents) mem.ltm.workspace.torrent = str(torrents)
@@ -73,6 +79,7 @@ class TestCreateSeedLinksHappyPath:
def test_to_dict_ok(self, use_case, seed_env, memory_configured): def test_to_dict_ok(self, use_case, seed_env, memory_configured):
from alfred.infrastructure.persistence import get_memory from alfred.infrastructure.persistence import get_memory
mem = get_memory() mem = get_memory()
lib_video, dl, torrents = seed_env lib_video, dl, torrents = seed_env
mem.ltm.workspace.torrent = str(torrents) mem.ltm.workspace.torrent = str(torrents)
@@ -89,8 +96,8 @@ class TestCreateSeedLinksHappyPath:
# Error: torrent folder not configured # Error: torrent folder not configured
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCreateSeedLinksErrors:
class TestCreateSeedLinksErrors:
def test_error_when_torrent_not_configured(self, use_case, seed_env, memory): def test_error_when_torrent_not_configured(self, use_case, seed_env, memory):
lib_video, dl, _ = seed_env lib_video, dl, _ = seed_env
result = use_case.execute(str(lib_video), str(dl)) 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): def test_error_delegates_to_file_manager(self, memory_configured):
"""FileManager errors are propagated correctly.""" """FileManager errors are propagated correctly."""
from alfred.infrastructure.persistence import get_memory from alfred.infrastructure.persistence import get_memory
mem = get_memory() mem = get_memory()
# torrent already configured by memory_configured fixture # torrent already configured by memory_configured fixture
# library_file does not exist → should propagate error from FileManager # library_file does not exist → should propagate error from FileManager
@@ -1,31 +1,31 @@
"""Tests for ListFolderUseCase and MoveMediaUseCase.""" """Tests for ListFolderUseCase and MoveMediaUseCase."""
import pytest
from unittest.mock import MagicMock from unittest.mock import MagicMock
from alfred.application.filesystem.list_folder import ListFolderUseCase from alfred.application.filesystem.list_folder import ListFolderUseCase
from alfred.application.filesystem.move_media import MoveMediaUseCase from alfred.application.filesystem.move_media import MoveMediaUseCase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ListFolderUseCase # ListFolderUseCase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestListFolderUseCase:
class TestListFolderUseCase:
def _use_case(self, fm_result): def _use_case(self, fm_result):
fm = MagicMock() fm = MagicMock()
fm.list_folder.return_value = fm_result fm.list_folder.return_value = fm_result
return ListFolderUseCase(fm) return ListFolderUseCase(fm)
def test_success_returns_response(self): def test_success_returns_response(self):
uc = self._use_case({ uc = self._use_case(
{
"status": "ok", "status": "ok",
"folder_type": "download", "folder_type": "download",
"path": ".", "path": ".",
"entries": ["movie.mkv", "show/"], "entries": ["movie.mkv", "show/"],
"count": 2, "count": 2,
}) }
)
resp = uc.execute("download") resp = uc.execute("download")
assert resp.status == "ok" assert resp.status == "ok"
assert resp.folder_type == "download" assert resp.folder_type == "download"
@@ -34,11 +34,13 @@ class TestListFolderUseCase:
assert resp.count == 2 assert resp.count == 2
def test_error_propagates(self): def test_error_propagates(self):
uc = self._use_case({ uc = self._use_case(
{
"status": "error", "status": "error",
"error": "folder_not_set", "error": "folder_not_set",
"message": "Download folder not configured.", "message": "Download folder not configured.",
}) }
)
resp = uc.execute("download") resp = uc.execute("download")
assert resp.status == "error" assert resp.status == "error"
assert resp.error == "folder_not_set" assert resp.error == "folder_not_set"
@@ -60,30 +62,37 @@ class TestListFolderUseCase:
def test_default_path_is_dot(self): def test_default_path_is_dot(self):
fm = MagicMock() fm = MagicMock()
fm.list_folder.return_value = { fm.list_folder.return_value = {
"status": "ok", "folder_type": "download", "status": "ok",
"path": ".", "entries": [], "count": 0, "folder_type": "download",
"path": ".",
"entries": [],
"count": 0,
} }
uc = ListFolderUseCase(fm) uc = ListFolderUseCase(fm)
uc.execute("download") uc.execute("download")
fm.list_folder.assert_called_once_with("download", ".") fm.list_folder.assert_called_once_with("download", ".")
def test_success_response_has_no_error(self): def test_success_response_has_no_error(self):
uc = self._use_case({ uc = self._use_case(
{
"status": "ok", "status": "ok",
"folder_type": "movie", "folder_type": "movie",
"path": ".", "path": ".",
"entries": [], "entries": [],
"count": 0, "count": 0,
}) }
)
resp = uc.execute("movie") resp = uc.execute("movie")
assert resp.error is None assert resp.error is None
def test_error_response_has_no_entries(self): def test_error_response_has_no_entries(self):
uc = self._use_case({ uc = self._use_case(
{
"status": "error", "status": "error",
"error": "not_found", "error": "not_found",
"message": "Path does not exist", "message": "Path does not exist",
}) }
)
resp = uc.execute("download", "some/path") resp = uc.execute("download", "some/path")
assert resp.entries is None assert resp.entries is None
assert resp.count is None assert resp.count is None
@@ -93,8 +102,8 @@ class TestListFolderUseCase:
# MoveMediaUseCase # MoveMediaUseCase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMoveMediaUseCase:
class TestMoveMediaUseCase:
def _use_case(self, fm_result): def _use_case(self, fm_result):
fm = MagicMock() fm = MagicMock()
fm.move_file.return_value = fm_result fm.move_file.return_value = fm_result
@@ -103,13 +112,15 @@ class TestMoveMediaUseCase:
def test_success_returns_response(self, tmp_path): def test_success_returns_response(self, tmp_path):
src = str(tmp_path / "src.mkv") src = str(tmp_path / "src.mkv")
dst = str(tmp_path / "dst.mkv") dst = str(tmp_path / "dst.mkv")
uc = self._use_case({ uc = self._use_case(
{
"status": "ok", "status": "ok",
"source": src, "source": src,
"destination": dst, "destination": dst,
"filename": "dst.mkv", "filename": "dst.mkv",
"size": 1024, "size": 1024,
}) }
)
resp = uc.execute(src, dst) resp = uc.execute(src, dst)
assert resp.status == "ok" assert resp.status == "ok"
assert resp.source == src assert resp.source == src
@@ -118,11 +129,13 @@ class TestMoveMediaUseCase:
assert resp.size == 1024 assert resp.size == 1024
def test_error_propagates(self, tmp_path): def test_error_propagates(self, tmp_path):
uc = self._use_case({ uc = self._use_case(
{
"status": "error", "status": "error",
"error": "source_not_found", "error": "source_not_found",
"message": "Source does not exist: /ghost.mkv", "message": "Source does not exist: /ghost.mkv",
}) }
)
resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv")) resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv"))
assert resp.status == "error" assert resp.status == "error"
assert resp.error == "source_not_found" assert resp.error == "source_not_found"
@@ -132,19 +145,24 @@ class TestMoveMediaUseCase:
dst = "/movies/Movie.2024/movie.mkv" dst = "/movies/Movie.2024/movie.mkv"
fm = MagicMock() fm = MagicMock()
fm.move_file.return_value = { fm.move_file.return_value = {
"status": "ok", "source": src, "destination": dst, "status": "ok",
"filename": "movie.mkv", "size": 1, "source": src,
"destination": dst,
"filename": "movie.mkv",
"size": 1,
} }
uc = MoveMediaUseCase(fm) uc = MoveMediaUseCase(fm)
uc.execute(src, dst) uc.execute(src, dst)
fm.move_file.assert_called_once_with(src, dst) fm.move_file.assert_called_once_with(src, dst)
def test_error_response_has_no_paths(self): def test_error_response_has_no_paths(self):
uc = self._use_case({ uc = self._use_case(
{
"status": "error", "status": "error",
"error": "destination_exists", "error": "destination_exists",
"message": "File already exists", "message": "File already exists",
}) }
)
resp = uc.execute("/src.mkv", "/dst.mkv") resp = uc.execute("/src.mkv", "/dst.mkv")
assert resp.source is None assert resp.source is None
assert resp.destination is None assert resp.destination is None
@@ -153,13 +171,15 @@ class TestMoveMediaUseCase:
def test_to_dict_success(self, tmp_path): def test_to_dict_success(self, tmp_path):
src = "/downloads/movie.mkv" src = "/downloads/movie.mkv"
dst = "/movies/movie.mkv" dst = "/movies/movie.mkv"
uc = self._use_case({ uc = self._use_case(
{
"status": "ok", "status": "ok",
"source": src, "source": src,
"destination": dst, "destination": dst,
"filename": "movie.mkv", "filename": "movie.mkv",
"size": 2048, "size": 2048,
}) }
)
resp = uc.execute(src, dst) resp = uc.execute(src, dst)
d = resp.to_dict() d = resp.to_dict()
assert d["status"] == "ok" assert d["status"] == "ok"
@@ -167,11 +187,13 @@ class TestMoveMediaUseCase:
assert d["size"] == 2048 assert d["size"] == 2048
def test_to_dict_error(self): def test_to_dict_error(self):
uc = self._use_case({ uc = self._use_case(
{
"status": "error", "status": "error",
"error": "link_failed", "error": "link_failed",
"message": "Cross-device link not permitted", "message": "Cross-device link not permitted",
}) }
)
resp = uc.execute("/src.mkv", "/dst.mkv") resp = uc.execute("/src.mkv", "/dst.mkv")
d = resp.to_dict() d = resp.to_dict()
assert d["status"] == "error" assert d["status"] == "error"
+18 -11
View File
@@ -7,18 +7,16 @@ No network calls — TMDB data is passed in directly.
from pathlib import Path from pathlib import Path
import pytest
from alfred.application.filesystem.resolve_destination import ( from alfred.application.filesystem.resolve_destination import (
ResolveDestinationUseCase, ResolveDestinationUseCase,
_find_existing_series_folders, _find_existing_series_folders,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _use_case(): def _use_case():
return ResolveDestinationUseCase() return ResolveDestinationUseCase()
@@ -27,8 +25,8 @@ def _use_case():
# Movies # Movies
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestResolveMovie:
class TestResolveMovie:
def test_basic_movie(self, memory_configured): def test_basic_movie(self, memory_configured):
result = _use_case().execute( result = _use_case().execute(
release_name="Another.Round.2020.1080p.BluRay.x264-YTS", release_name="Another.Round.2020.1080p.BluRay.x264-YTS",
@@ -101,8 +99,8 @@ class TestResolveMovie:
# TV shows — no existing folder # TV shows — no existing folder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestResolveTVShowNewFolder:
class TestResolveTVShowNewFolder:
def test_oz_s01_creates_new_folder(self, memory_configured): def test_oz_s01_creates_new_folder(self, memory_configured):
result = _use_case().execute( result = _use_case().execute(
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST", release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
@@ -164,11 +162,10 @@ class TestResolveTVShowNewFolder:
# TV shows — existing folder matching # TV shows — existing folder matching
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestResolveTVShowExistingFolder:
class TestResolveTVShowExistingFolder:
def _make_series_folder(self, tv_root, name): def _make_series_folder(self, tv_root, name):
"""Create a series folder in the tv library.""" """Create a series folder in the tv library."""
import os
path = tv_root / name path = tv_root / name
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return path return path
@@ -176,6 +173,7 @@ class TestResolveTVShowExistingFolder:
def test_uses_existing_single_folder(self, memory_configured, app_temp): def test_uses_existing_single_folder(self, memory_configured, app_temp):
"""When exactly one folder matches title+year, use it regardless of group.""" """When exactly one folder matches title+year, use it regardless of group."""
from alfred.infrastructure.persistence import get_memory from alfred.infrastructure.persistence import get_memory
mem = get_memory() mem = get_memory()
tv_root = Path(mem.ltm.library_paths.get("tv_show")) 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): def test_needs_clarification_on_multiple_folders(self, memory_configured, app_temp):
"""When multiple folders match, return needs_clarification with options.""" """When multiple folders match, return needs_clarification with options."""
from alfred.infrastructure.persistence import get_memory from alfred.infrastructure.persistence import get_memory
mem = get_memory() mem = get_memory()
tv_root = Path(mem.ltm.library_paths.get("tv_show")) 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-RARBG").mkdir(
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir(parents=True, exist_ok=True) parents=True, exist_ok=True
)
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir(
parents=True, exist_ok=True
)
result = _use_case().execute( result = _use_case().execute(
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST", 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): def test_confirmed_folder_bypasses_detection(self, memory_configured, app_temp):
"""confirmed_folder skips the folder search.""" """confirmed_folder skips the folder search."""
from alfred.infrastructure.persistence import get_memory from alfred.infrastructure.persistence import get_memory
mem = get_memory() mem = get_memory()
tv_root = Path(mem.ltm.library_paths.get("tv_show")) tv_root = Path(mem.ltm.library_paths.get("tv_show"))
chosen = "Slow.Horses.2022.1080p.WEBRip.x265-RARBG" 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): def test_to_dict_needs_clarification(self, memory_configured, app_temp):
from alfred.infrastructure.persistence import get_memory from alfred.infrastructure.persistence import get_memory
mem = get_memory() mem = get_memory()
tv_root = Path(mem.ltm.library_paths.get("tv_show")) 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-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( result = _use_case().execute(
release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST", release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST",
@@ -266,8 +273,8 @@ class TestResolveTVShowExistingFolder:
# _find_existing_series_folders # _find_existing_series_folders
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestFindExistingSeriesFolders:
class TestFindExistingSeriesFolders:
def test_empty_library(self, tmp_path): def test_empty_library(self, tmp_path):
assert _find_existing_series_folders(tmp_path, "Oz", 1997) == [] assert _find_existing_series_folders(tmp_path, "Oz", 1997) == []
+65 -22
View File
@@ -5,23 +5,30 @@ Real-data cases sourced from /mnt/testipool/downloads/.
Covers: parsing, normalisation, naming methods, edge cases. Covers: parsing, normalisation, naming methods, edge cases.
""" """
import pytest from alfred.domain.release import parse_release
from alfred.domain.release import ParsedRelease, parse_release
from alfred.domain.release.services import _normalise 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 # _normalise
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNormalise: class TestNormalise:
def test_dots_unchanged(self): 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): 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): def test_double_dots_collapsed(self):
assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p" assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p"
@@ -31,7 +38,9 @@ class TestNormalise:
def test_mixed_spaces_and_dots(self): def test_mixed_spaces_and_dots(self):
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb" # "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
assert ".." not in result assert ".." not in result
@@ -40,6 +49,7 @@ class TestNormalise:
# _sanitise_for_fs # _sanitise_for_fs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSanitiseForFs: class TestSanitiseForFs:
def test_clean_string_unchanged(self): def test_clean_string_unchanged(self):
assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST" assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST"
@@ -65,28 +75,38 @@ class TestSanitiseForFs:
# _strip_episode_from_normalised # _strip_episode_from_normalised
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestStripEpisode: class TestStripEpisode:
def test_strips_single_episode(self): def test_strips_single_episode(self):
assert _strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST") \ assert (
_strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
== "Oz.S01.1080p.WEBRip.x265-KONTRAST" == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
)
def test_strips_multi_episode(self): def test_strips_multi_episode(self):
assert _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") \ assert (
_strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb")
== "Archer.S14.1080p.HULU.WEB-DL-NTb" == "Archer.S14.1080p.HULU.WEB-DL-NTb"
)
def test_season_pack_unchanged(self): def test_season_pack_unchanged(self):
assert _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") \ assert (
_strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST")
== "Oz.S01.1080p.WEBRip.x265-KONTRAST" == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
)
def test_case_insensitive(self): def test_case_insensitive(self):
assert _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") \ assert (
_strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST")
== "oz.s01.1080p-KONTRAST" == "oz.s01.1080p-KONTRAST"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# parse_release — Season packs (dots) # parse_release — Season packs (dots)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSeasonPackDots: class TestSeasonPackDots:
"""Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc.""" """Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc."""
@@ -135,13 +155,17 @@ class TestSeasonPackDots:
assert p.group == "RARBG" assert p.group == "RARBG"
def test_gilmore_girls_s01_s07_repack(self): 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 # Season range — we parse the first season number found
assert p.season == 1 assert p.season == 1
assert p.group == "MONOLITH" assert p.group == "MONOLITH"
def test_plot_against_america_4k(self): 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.title == "The.Plot.Against.America"
assert p.season == 1 assert p.season == 1
assert p.quality == "2160p" assert p.quality == "2160p"
@@ -165,6 +189,7 @@ class TestSeasonPackDots:
# parse_release — Single episodes (dots) # parse_release — Single episodes (dots)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSingleEpisodeDots: class TestSingleEpisodeDots:
"""Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc.""" """Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc."""
@@ -211,10 +236,13 @@ class TestSingleEpisodeDots:
# parse_release — Multi-episode # parse_release — Multi-episode
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMultiEpisode: class TestMultiEpisode:
def test_archer_triple_episode(self): def test_archer_triple_episode(self):
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb" # "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.season == 14
assert p.episode == 9 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
@@ -224,6 +252,7 @@ class TestMultiEpisode:
# parse_release — Movies # parse_release — Movies
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMovies: class TestMovies:
def test_another_round_yts(self): def test_another_round_yts(self):
# "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised # "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised
@@ -276,6 +305,7 @@ class TestMovies:
# parse_release — Space-separated (no dots) # parse_release — Space-separated (no dots)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSpaceSeparated: class TestSpaceSeparated:
def test_oz_spaces(self): def test_oz_spaces(self):
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST") p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
@@ -285,7 +315,9 @@ class TestSpaceSeparated:
assert p.group == "KONTRAST" assert p.group == "KONTRAST"
def test_archer_spaces(self): 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.season == 14
assert p.episode == 9 assert p.episode == 9
assert p.group == "NTb" assert p.group == "NTb"
@@ -295,6 +327,7 @@ class TestSpaceSeparated:
# parse_release — tech_string # parse_release — tech_string
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTechString: class TestTechString:
def test_full_tech(self): def test_full_tech(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST") p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
@@ -312,7 +345,9 @@ class TestTechString:
assert "Unknown" in folder assert "Unknown" in folder
def test_4k_hdr(self): 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" assert p.quality == "2160p"
@@ -320,8 +355,8 @@ class TestTechString:
# ParsedRelease — naming methods # ParsedRelease — naming methods
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNamingMethods:
class TestNamingMethods:
def test_show_folder_name(self): def test_show_folder_name(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST") p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
assert p.show_folder_name("Oz", 1997) == "Oz.1997.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): def test_movie_folder_name(self):
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS") 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): def test_movie_filename(self):
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS") 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): def test_movie_folder_same_as_show_folder(self):
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG") 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 # ParsedRelease — is_movie / is_season_pack
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMediaTypeFlags: class TestMediaTypeFlags:
def test_season_pack_is_not_movie(self): def test_season_pack_is_not_movie(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST") p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
@@ -412,11 +453,13 @@ class TestMediaTypeFlags:
# Tricky real-world releases # Tricky real-world releases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRealWorldEdgeCases:
class TestRealWorldEdgeCases:
def test_angel_integrale_multi(self): def test_angel_integrale_multi(self):
# "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod" # "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.year == 1999
assert p.quality == "1080p" assert p.quality == "1080p"
assert p.source == "WEBRip" assert p.source == "WEBRip"
+6 -6
View File
@@ -1,18 +1,18 @@
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize.""" """Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
import pytest
from pathlib import Path from pathlib import Path
import pytest
from alfred.domain.shared.exceptions import ValidationError from alfred.domain.shared.exceptions import ValidationError
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ImdbId # ImdbId
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestImdbId:
class TestImdbId:
def test_valid_7_digits(self): def test_valid_7_digits(self):
id_ = ImdbId("tt1375666") id_ = ImdbId("tt1375666")
assert str(id_) == "tt1375666" assert str(id_) == "tt1375666"
@@ -58,8 +58,8 @@ class TestImdbId:
# FilePath # FilePath
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestFilePath:
class TestFilePath:
def test_from_string(self, tmp_path): def test_from_string(self, tmp_path):
p = FilePath(str(tmp_path)) p = FilePath(str(tmp_path))
assert isinstance(p.value, Path) assert isinstance(p.value, Path)
@@ -98,8 +98,8 @@ class TestFilePath:
# FileSize # FileSize
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestFileSize:
class TestFileSize:
def test_bytes(self): def test_bytes(self):
s = FileSize(500) s = FileSize(500)
assert s.bytes == 500 assert s.bytes == 500
@@ -128,7 +128,7 @@ class TestFileSize:
assert "MB" in result assert "MB" in result
def test_human_readable_gb(self): 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 assert "GB" in result
def test_str_is_human_readable(self): def test_str_is_human_readable(self):
+3 -5
View File
@@ -1,6 +1,5 @@
"""Tests for SubtitleScanner and _classify helper.""" """Tests for SubtitleScanner and _classify helper."""
import pytest
from pathlib import Path from pathlib import Path
from alfred.domain.subtitles.scanner import ( from alfred.domain.subtitles.scanner import (
@@ -9,13 +8,12 @@ from alfred.domain.subtitles.scanner import (
_classify, _classify,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _classify — unit tests for the filename parser # _classify — unit tests for the filename parser
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestClassify:
class TestClassify:
def test_iso_lang_code(self, tmp_path): def test_iso_lang_code(self, tmp_path):
p = tmp_path / "fr.srt" p = tmp_path / "fr.srt"
p.write_text("") p.write_text("")
@@ -86,8 +84,8 @@ class TestClassify:
# SubtitleCandidate.destination_name # SubtitleCandidate.destination_name
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSubtitleCandidateDestinationName:
class TestSubtitleCandidateDestinationName:
def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None): def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None):
return SubtitleCandidate( return SubtitleCandidate(
source_path=path or Path("/fake/fr.srt"), source_path=path or Path("/fake/fr.srt"),
@@ -117,8 +115,8 @@ class TestSubtitleCandidateDestinationName:
# SubtitleScanner — integration with real filesystem # SubtitleScanner — integration with real filesystem
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSubtitleScanner:
class TestSubtitleScanner:
def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True): def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True):
return SubtitleScanner( return SubtitleScanner(
languages=languages or ["fr", "en"], languages=languages or ["fr", "en"],
+19 -10
View File
@@ -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.entities import Episode, Season, TVShow
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ShowStatus # ShowStatus
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestShowStatus:
class TestShowStatus:
def test_from_string_ongoing(self): def test_from_string_ongoing(self):
assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING
@@ -32,8 +31,8 @@ class TestShowStatus:
# SeasonNumber # SeasonNumber
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSeasonNumber:
class TestSeasonNumber:
def test_valid_season(self): def test_valid_season(self):
s = SeasonNumber(1) s = SeasonNumber(1)
assert s.value == 1 assert s.value == 1
@@ -67,8 +66,8 @@ class TestSeasonNumber:
# EpisodeNumber # EpisodeNumber
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestEpisodeNumber:
class TestEpisodeNumber:
def test_valid_episode(self): def test_valid_episode(self):
e = EpisodeNumber(1) e = EpisodeNumber(1)
assert e.value == 1 assert e.value == 1
@@ -95,10 +94,14 @@ class TestEpisodeNumber:
# TVShow entity # TVShow entity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTVShow:
def _make(self, imdb_id="tt0903747", title="Breaking Bad", seasons=5, status="ended"): class TestTVShow:
return TVShow(imdb_id=imdb_id, title=title, seasons_count=seasons, status=status) 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): def test_basic_creation(self):
show = self._make() show = self._make()
@@ -108,6 +111,7 @@ class TestTVShow:
def test_coerces_string_imdb_id(self): def test_coerces_string_imdb_id(self):
show = self._make() show = self._make()
from alfred.domain.shared.value_objects import ImdbId from alfred.domain.shared.value_objects import ImdbId
assert isinstance(show.imdb_id, ImdbId) assert isinstance(show.imdb_id, ImdbId)
def test_coerces_string_status(self): def test_coerces_string_status(self):
@@ -151,8 +155,8 @@ class TestTVShow:
# Season entity # Season entity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSeason:
class TestSeason:
def test_basic_creation(self): def test_basic_creation(self):
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7) s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7)
assert s.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) Season(show_imdb_id="tt0903747", season_number=1, episode_count=-1)
def test_str(self): 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) assert "Pilot Season" in str(s)
@@ -179,8 +188,8 @@ class TestSeason:
# Episode entity # Episode entity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestEpisode:
class TestEpisode:
def test_basic_creation(self): def test_basic_creation(self):
e = Episode( e = Episode(
show_imdb_id="tt0903747", show_imdb_id="tt0903747",
-1
View File
@@ -2,7 +2,6 @@
import shutil import shutil
import tempfile import tempfile
from pathlib import Path
import pytest import pytest
+19 -9
View File
@@ -5,13 +5,12 @@ Uses real temp filesystem. No mocks on os.link — we test the actual behavior.
""" """
import os import os
import stat
from pathlib import Path from pathlib import Path
import pytest import pytest
from alfred.infrastructure.filesystem.file_manager import FileManager
from alfred.infrastructure.filesystem.exceptions import PathTraversalError from alfred.infrastructure.filesystem.exceptions import PathTraversalError
from alfred.infrastructure.filesystem.file_manager import FileManager
@pytest.fixture @pytest.fixture
@@ -23,8 +22,8 @@ def fm():
# copy_file (hard-link) # copy_file (hard-link)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCopyFile:
class TestCopyFile:
def test_creates_hard_link(self, fm, tmp_path): def test_creates_hard_link(self, fm, tmp_path):
src = tmp_path / "source.mkv" src = tmp_path / "source.mkv"
src.write_bytes(b"video data") src.write_bytes(b"video data")
@@ -80,8 +79,8 @@ class TestCopyFile:
# move_file # move_file
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMoveFile:
class TestMoveFile:
def test_moves_file(self, fm, tmp_path): def test_moves_file(self, fm, tmp_path):
src = tmp_path / "episode.mkv" src = tmp_path / "episode.mkv"
src.write_bytes(b"video") src.write_bytes(b"video")
@@ -132,8 +131,8 @@ class TestMoveFile:
# create_seed_links # create_seed_links
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCreateSeedLinks:
class TestCreateSeedLinks:
def _setup(self, tmp_path): def _setup(self, tmp_path):
"""Create realistic download + library + torrent structure.""" """Create realistic download + library + torrent structure."""
download = tmp_path / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST" download = tmp_path / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
@@ -146,7 +145,12 @@ class TestCreateSeedLinks:
subs.mkdir(parents=True) subs.mkdir(parents=True)
(subs / "2_eng.srt").write_text("subtitle content") (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) library.mkdir(parents=True)
lib_video = library / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4" lib_video = library / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
# Hard-link the video to simulate post-move state # Hard-link the video to simulate post-move state
@@ -188,7 +192,13 @@ class TestCreateSeedLinks:
lib_video, download, torrents = self._setup(tmp_path) lib_video, download, torrents = self._setup(tmp_path)
fm.create_seed_links(str(lib_video), str(download), str(torrents)) 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() assert srt.exists()
def test_returns_copied_and_skipped(self, fm, tmp_path): def test_returns_copied_and_skipped(self, fm, tmp_path):
@@ -270,8 +280,8 @@ class TestCreateSeedLinks:
# list_folder # list_folder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestListFolder:
class TestListFolder:
def test_lists_entries(self, fm, memory_configured, infra_temp): def test_lists_entries(self, fm, memory_configured, infra_temp):
result = fm.list_folder("download") result = fm.list_folder("download")
assert result["status"] == "ok" assert result["status"] == "ok"
@@ -300,8 +310,8 @@ class TestListFolder:
# _sanitize_path # _sanitize_path
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSanitizePath:
class TestSanitizePath:
def test_normal_path(self, fm): def test_normal_path(self, fm):
assert fm._sanitize_path("some/path") == "some/path" assert fm._sanitize_path("some/path") == "some/path"
+11 -5
View File
@@ -16,7 +16,6 @@ from bootstrap import (
load_env_file, load_env_file,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fixtures # Fixtures
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -191,7 +190,9 @@ class TestBuildUris:
def test_uri_is_updated_when_host_changes(self, tmp_path, secrets_file): 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.""" """If MONGO_HOST changes in .env.alfred, the URI must reflect it."""
alfred = tmp_path / ".env.alfred" 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) build_uris(alfred, secrets_file)
uri = load_env_file(secrets_file)["MONGO_URI"] uri = load_env_file(secrets_file)["MONGO_URI"]
@@ -217,7 +218,9 @@ class TestBuildUris:
uri_v1 = load_env_file(secrets_file)["MONGO_URI"] uri_v1 = load_env_file(secrets_file)["MONGO_URI"]
alfred_v2 = tmp_path / "alfred_v2" 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) build_uris(alfred_v2, secrets_file)
uri_v2 = load_env_file(secrets_file)["MONGO_URI"] uri_v2 = load_env_file(secrets_file)["MONGO_URI"]
@@ -265,12 +268,15 @@ class TestCopyExampleIfMissing:
class TestExtractPythonVersion: class TestExtractPythonVersion:
@pytest.mark.parametrize("spec,expected_full,expected_short", [ @pytest.mark.parametrize(
"spec,expected_full,expected_short",
[
("==3.14.3", "3.14.3", "3.14"), ("==3.14.3", "3.14.3", "3.14"),
("^3.12.0", "3.12.0", "3.12"), ("^3.12.0", "3.12.0", "3.12"),
("~3.11.1", "3.11.1", "3.11"), ("~3.11.1", "3.11.1", "3.11"),
("3.10.5", "3.10.5", "3.10"), ("3.10.5", "3.10.5", "3.10"),
]) ],
)
def test_parses_version_specifiers(self, spec, expected_full, expected_short): def test_parses_version_specifiers(self, spec, expected_full, expected_short):
full, short = extract_python_version(spec) full, short = extract_python_version(spec)
assert full == expected_full assert full == expected_full
+1
View File
@@ -1,6 +1,7 @@
"""Tests for PromptBuilder.""" """Tests for PromptBuilder."""
from alfred.agent.prompts import PromptBuilder from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import make_tools from alfred.agent.registry import make_tools
from alfred.settings import settings from alfred.settings import settings
+1
View File
@@ -1,6 +1,7 @@
"""Critical tests for prompt builder - Tests that would have caught bugs.""" """Critical tests for prompt builder - Tests that would have caught bugs."""
from alfred.agent.prompts import PromptBuilder from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import make_tools from alfred.agent.registry import make_tools
from alfred.settings import settings from alfred.settings import settings
+1
View File
@@ -1,6 +1,7 @@
"""Edge case tests for PromptBuilder.""" """Edge case tests for PromptBuilder."""
from alfred.agent.prompts import PromptBuilder from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import make_tools from alfred.agent.registry import make_tools
from alfred.settings import settings from alfred.settings import settings
+1 -1
View File
@@ -3,8 +3,8 @@
import inspect import inspect
import pytest import pytest
from alfred.agent.prompts import PromptBuilder from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools from alfred.agent.registry import Tool, _create_tool_from_function, make_tools
from alfred.settings import settings from alfred.settings import settings
+1 -3
View File
@@ -1,12 +1,9 @@
"""Tests for language tools.""" """Tests for language tools."""
import pytest
from alfred.agent.tools.language import set_language from alfred.agent.tools.language import set_language
class TestSetLanguage: class TestSetLanguage:
def test_success_returns_ok(self, memory): def test_success_returns_ok(self, memory):
result = set_language("fr") result = set_language("fr")
assert result["status"] == "ok" assert result["status"] == "ok"
@@ -20,6 +17,7 @@ class TestSetLanguage:
set_language("es") set_language("es")
# Verify it's stored in STM # Verify it's stored in STM
from alfred.infrastructure.persistence import get_memory from alfred.infrastructure.persistence import get_memory
mem = get_memory() mem = get_memory()
assert mem.stm.language == "es" assert mem.stm.language == "es"
+15 -6
View File
@@ -4,15 +4,14 @@ Tests for alfred.agent.workflows.loader.WorkflowLoader
import pytest import pytest
import yaml import yaml
from pathlib import Path
from alfred.agent.workflows.loader import WorkflowLoader from alfred.agent.workflows.loader import WorkflowLoader
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fixtures # Fixtures
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture @pytest.fixture
def workflows_dir(tmp_path): def workflows_dir(tmp_path):
"""A temp directory pre-populated with one valid workflow YAML.""" """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): def loader_from_dir(workflows_dir, monkeypatch):
"""WorkflowLoader pointed at our temp dir.""" """WorkflowLoader pointed at our temp dir."""
import alfred.agent.workflows.loader as loader_module import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", workflows_dir) monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", workflows_dir)
return WorkflowLoader() return WorkflowLoader()
@@ -40,8 +40,8 @@ def loader_from_dir(workflows_dir, monkeypatch):
# Real loader (loads actual YAML files from the repo) # Real loader (loads actual YAML files from the repo)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRealWorkflows:
class TestRealWorkflows:
def test_organize_media_loaded(self): def test_organize_media_loaded(self):
loader = WorkflowLoader() loader = WorkflowLoader()
assert "organize_media" in loader.names() assert "organize_media" in loader.names()
@@ -96,8 +96,8 @@ class TestRealWorkflows:
# WorkflowLoader mechanics (via monkeypatched dir) # WorkflowLoader mechanics (via monkeypatched dir)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLoaderMechanics:
class TestLoaderMechanics:
def test_get_returns_workflow(self, loader_from_dir): def test_get_returns_workflow(self, loader_from_dir):
wf = loader_from_dir.get("test_workflow") wf = loader_from_dir.get("test_workflow")
assert wf is not None assert wf is not None
@@ -119,6 +119,7 @@ class TestLoaderMechanics:
def test_uses_yaml_name_field(self, tmp_path, monkeypatch): def test_uses_yaml_name_field(self, tmp_path, monkeypatch):
"""name from YAML content takes priority over filename stem.""" """name from YAML content takes priority over filename stem."""
import alfred.agent.workflows.loader as loader_module import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
wf = {"name": "my_custom_name", "steps": []} 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): def test_falls_back_to_stem_when_no_name(self, tmp_path, monkeypatch):
import alfred.agent.workflows.loader as loader_module import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
(tmp_path / "my_workflow.yaml").write_text(yaml.dump({"steps": []})) (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): def test_skips_malformed_yaml(self, tmp_path, monkeypatch):
import alfred.agent.workflows.loader as loader_module import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
(tmp_path / "valid.yaml").write_text(yaml.dump({"name": "valid", "steps": []})) (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): def test_deterministic_load_order(self, tmp_path, monkeypatch):
"""Files loaded in sorted order — later file wins on name collision.""" """Files loaded in sorted order — later file wins on name collision."""
import alfred.agent.workflows.loader as loader_module import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
(tmp_path / "a_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 1})) (tmp_path / "a_workflow.yaml").write_text(
(tmp_path / "b_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 2})) yaml.dump({"name": "duplicate", "version": 1})
)
(tmp_path / "b_workflow.yaml").write_text(
yaml.dump({"name": "duplicate", "version": 2})
)
loader = WorkflowLoader() loader = WorkflowLoader()
# b_workflow loaded last → version 2 wins # b_workflow loaded last → version 2 wins
@@ -161,6 +169,7 @@ class TestLoaderMechanics:
def test_empty_directory(self, tmp_path, monkeypatch): def test_empty_directory(self, tmp_path, monkeypatch):
import alfred.agent.workflows.loader as loader_module import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path) monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
loader = WorkflowLoader() loader = WorkflowLoader()