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