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,
+190 -70
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)
@@ -252,26 +364,26 @@ def analyze_release(release_name: str, source_path: str) -> dict[str, Any]:
return {
"status": "ok",
"media_type": parsed.media_type,
"parse_path": parsed.parse_path,
"title": parsed.title,
"year": parsed.year,
"season": parsed.season,
"episode": parsed.episode,
"episode_end": parsed.episode_end,
"quality": parsed.quality,
"source": parsed.source,
"codec": parsed.codec,
"group": parsed.group,
"languages": parsed.languages,
"audio_codec": parsed.audio_codec,
"audio_channels": parsed.audio_channels,
"bit_depth": parsed.bit_depth,
"hdr_format": parsed.hdr_format,
"edition": parsed.edition,
"site_tag": parsed.site_tag,
"is_season_pack": parsed.is_season_pack,
"probe_used": probe_used,
"media_type": parsed.media_type,
"parse_path": parsed.parse_path,
"title": parsed.title,
"year": parsed.year,
"season": parsed.season,
"episode": parsed.episode,
"episode_end": parsed.episode_end,
"quality": parsed.quality,
"source": parsed.source,
"codec": parsed.codec,
"group": parsed.group,
"languages": parsed.languages,
"audio_codec": parsed.audio_codec,
"audio_channels": parsed.audio_channels,
"bit_depth": parsed.bit_depth,
"hdr_format": parsed.hdr_format,
"edition": parsed.edition,
"site_tag": parsed.site_tag,
"is_season_pack": parsed.is_season_pack,
"probe_used": probe_used,
}
@@ -293,45 +405,53 @@ def probe_media(source_path: str) -> dict[str, Any]:
"""
path = Path(source_path)
if not path.exists():
return {"status": "error", "error": "not_found", "message": f"{source_path} does not exist"}
return {
"status": "error",
"error": "not_found",
"message": f"{source_path} does not exist",
}
media_info = probe(path)
if media_info is None:
return {"status": "error", "error": "probe_failed", "message": "ffprobe failed to read the file"}
return {
"status": "error",
"error": "probe_failed",
"message": "ffprobe failed to read the file",
}
return {
"status": "ok",
"video": {
"codec": media_info.video_codec,
"resolution": media_info.resolution,
"width": media_info.width,
"height": media_info.height,
"codec": media_info.video_codec,
"resolution": media_info.resolution,
"width": media_info.width,
"height": media_info.height,
"duration_seconds": media_info.duration_seconds,
"bitrate_kbps": media_info.bitrate_kbps,
"bitrate_kbps": media_info.bitrate_kbps,
},
"audio_tracks": [
{
"index": t.index,
"codec": t.codec,
"channels": t.channels,
"index": t.index,
"codec": t.codec,
"channels": t.channels,
"channel_layout": t.channel_layout,
"language": t.language,
"is_default": t.is_default,
"language": t.language,
"is_default": t.is_default,
}
for t in media_info.audio_tracks
],
"subtitle_tracks": [
{
"index": t.index,
"codec": t.codec,
"language": t.language,
"index": t.index,
"codec": t.codec,
"language": t.language,
"is_default": t.is_default,
"is_forced": t.is_forced,
"is_forced": t.is_forced,
}
for t in media_info.subtitle_tracks
],
"audio_languages": media_info.audio_languages,
"is_multi_audio": media_info.is_multi_audio,
"is_multi_audio": media_info.is_multi_audio,
}
+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,
)
+11 -7
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
@@ -98,7 +102,7 @@ class UnresolvedTrack:
raw_tokens: list[str]
file_path: str | None = None
file_size_kb: float | None = None
reason: str = "" # "unknown_language" | "low_confidence"
reason: str = "" # "unknown_language" | "low_confidence"
def to_dict(self) -> dict:
return {
@@ -113,8 +117,8 @@ class UnresolvedTrack:
class AvailableSubtitle:
"""One subtitle track available on an embedded media item."""
language: str # ISO 639-2 code
subtitle_type: str # "standard" | "sdh" | "forced" | "unknown"
language: str # ISO 639-2 code
subtitle_type: str # "standard" | "sdh" | "forced" | "unknown"
def to_dict(self) -> dict:
return {"language": self.language, "type": self.subtitle_type}
@@ -124,12 +128,12 @@ class AvailableSubtitle:
class ManageSubtitlesResponse:
"""Response from the manage_subtitles use case."""
status: str # "ok" | "needs_clarification" | "error"
status: str # "ok" | "needs_clarification" | "error"
video_path: str | None = None
placed: list[PlacedSubtitle] | None = None
skipped_count: int = 0
unresolved: list[UnresolvedTrack] | None = None
available: list[AvailableSubtitle] | None = None # embedded tracks summary
available: list[AvailableSubtitle] | None = None # embedded tracks summary
error: str | None = None
message: str | None = None
@@ -10,21 +10,21 @@ _VIDEO_CODEC_MAP = {
"hevc": "x265",
"h264": "x264",
"h265": "x265",
"av1": "AV1",
"vp9": "VP9",
"av1": "AV1",
"vp9": "VP9",
"mpeg4": "XviD",
}
# Map ffprobe audio codec names to scene-style tokens
_AUDIO_CODEC_MAP = {
"eac3": "EAC3",
"ac3": "AC3",
"dts": "DTS",
"truehd": "TrueHD",
"aac": "AAC",
"flac": "FLAC",
"opus": "OPUS",
"mp3": "MP3",
"eac3": "EAC3",
"ac3": "AC3",
"dts": "DTS",
"truehd": "TrueHD",
"aac": "AAC",
"flac": "FLAC",
"opus": "OPUS",
"mp3": "MP3",
"pcm_s16l": "PCM",
"pcm_s24l": "PCM",
}
@@ -50,7 +50,9 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
parsed.quality = info.resolution
if parsed.codec is None and info.video_codec:
parsed.codec = _VIDEO_CODEC_MAP.get(info.video_codec.lower(), info.video_codec.upper())
parsed.codec = _VIDEO_CODEC_MAP.get(
info.video_codec.lower(), info.video_codec.upper()
)
if parsed.bit_depth is None and info.video_codec:
# ffprobe exposes bit depth via pix_fmt — not in MediaInfo yet, skip for now
@@ -62,10 +64,14 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
if track:
if parsed.audio_codec is None and track.codec:
parsed.audio_codec = _AUDIO_CODEC_MAP.get(track.codec.lower(), track.codec.upper())
parsed.audio_codec = _AUDIO_CODEC_MAP.get(
track.codec.lower(), track.codec.upper()
)
if parsed.audio_channels is None and track.channels:
parsed.audio_channels = _CHANNEL_MAP.get(track.channels, f"{track.channels}ch")
parsed.audio_channels = _CHANNEL_MAP.get(
track.channels, f"{track.channels}ch"
)
# Languages — merge ffprobe languages with token-level ones
# "und" = undetermined, not useful
@@ -17,7 +17,12 @@ from alfred.infrastructure.persistence.context import get_memory
from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore
from alfred.infrastructure.subtitle.rule_repository import RuleSetRepository
from .dto import AvailableSubtitle, ManageSubtitlesResponse, PlacedSubtitle, UnresolvedTrack
from .dto import (
AvailableSubtitle,
ManageSubtitlesResponse,
PlacedSubtitle,
UnresolvedTrack,
)
logger = logging.getLogger(__name__)
@@ -69,11 +74,12 @@ class ManageSubtitlesUseCase:
season: int | None = None,
episode: int | None = None,
confirmed_pattern_id: str | None = None,
dry_run: bool = False,
) -> ManageSubtitlesResponse:
source_path = Path(source_video)
dest_path = Path(destination_video)
if not source_path.exists():
if not source_path.exists() and not source_path.parent.exists():
return ManageSubtitlesResponse(
status="error",
error="source_not_found",
@@ -108,7 +114,9 @@ class ManageSubtitlesUseCase:
)
if metadata.total_count == 0:
logger.info(f"ManageSubtitles: no subtitle tracks found for {source_path.name}")
logger.info(
f"ManageSubtitles: no subtitle tracks found for {source_path.name}"
)
return ManageSubtitlesResponse(
status="ok",
video_path=destination_video,
@@ -164,6 +172,32 @@ class ManageSubtitlesUseCase:
skipped_count=metadata.total_count,
)
# --- Dry run: skip placement ---
if dry_run:
from alfred.domain.subtitles.services.placer import _build_dest_name
placed_dtos = []
for t in matched:
if not t.file_path:
continue
try:
filename = _build_dest_name(t, dest_path.stem)
except ValueError:
continue
placed_dtos.append(
PlacedSubtitle(
source=str(t.file_path),
destination=str(dest_path.parent / filename),
filename=filename,
)
)
return ManageSubtitlesResponse(
status="ok",
video_path=destination_video,
placed=placed_dtos,
skipped_count=0,
)
# --- Place ---
placer = SubtitlePlacer()
place_result = placer.place(matched, dest_path)
@@ -229,7 +263,9 @@ class ManageSubtitlesUseCase:
return kb.pattern("adjacent")
def _to_unresolved_dto(track: SubtitleTrack, min_confidence: float = 0.7) -> UnresolvedTrack:
def _to_unresolved_dto(
track: SubtitleTrack, min_confidence: float = 0.7
) -> UnresolvedTrack:
reason = "unknown_language" if track.language is None else "low_confidence"
return UnresolvedTrack(
raw_tokens=track.raw_tokens,
@@ -252,7 +288,7 @@ def _pair_placed_with_tracks(
for p in placed:
track = track_by_path.get(p.source)
if track is None and tracks:
track = tracks[0] # positional fallback
track = tracks[0] # positional fallback
if track:
pairs.append((p, track))
return pairs
@@ -1,58 +1,82 @@
"""
ResolveDestinationUseCase — compute the library destination path for a release.
Destination resolution — compute library paths for releases.
Steps:
1. Parse the release name
2. Look up TMDB for title + year (+ episode title if single episode)
3. Scan the library for an existing series folder
4. Apply group-conflict rules
5. Return the computed paths (or needs_clarification if ambiguous)
Four distinct use cases, one per release type:
- resolve_season_destination : season pack (folder move)
- resolve_episode_destination : single episode (file move)
- resolve_movie_destination : movie (file move)
- resolve_series_destination : complete series multi-season pack (folder move)
Each returns a dedicated DTO with only the fields that make sense for that type.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from alfred.domain.release import ParsedRelease, parse_release
from alfred.domain.release import parse_release
from alfred.infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
# Characters forbidden on Windows filesystems (served via NFS)
_WIN_FORBIDDEN = re.compile(r'[?:*"<>|\\]')
def _sanitise(text: str) -> str:
def _sanitize(text: str) -> str:
return _WIN_FORBIDDEN.sub("", text)
def _find_existing_tvshow_folders(
tv_root: Path, tmdb_title: str, tmdb_year: int
) -> list[str]:
"""Return folder names in tv_root that match title + year prefix."""
if not tv_root.exists():
return []
clean_title = _sanitize(tmdb_title).replace(" ", ".")
prefix = f"{clean_title}.{tmdb_year}".lower()
return sorted(
entry.name
for entry in tv_root.iterdir()
if entry.is_dir() and entry.name.lower().startswith(prefix)
)
def _get_tv_root() -> Path | None:
memory = get_memory()
tv_root = memory.ltm.library_paths.get("tv_show")
return Path(tv_root) if tv_root else None
# ---------------------------------------------------------------------------
# DTOs
# ---------------------------------------------------------------------------
@dataclass
class ResolvedDestination:
"""All computed paths for a release, ready to hand to move_media."""
class ResolvedSeasonDestination:
"""Paths for a season pack — folder move, no individual file paths."""
status: str # "ok" | "needs_clarification" | "error"
status: str # "ok" | "needs_clarification" | "error"
# Populated on "ok"
library_file: str | None = None # absolute path of the destination video file
series_folder: str | None = None # absolute path of the series root folder
season_folder: str | None = None # absolute path of the season subfolder
series_folder_name: str | None = None # just the folder name (for display)
# ok
series_folder: str | None = (
None # /tv_shows/A.Knight.of.the.Seven.Kingdoms.2024.1080p.WEBRip.x265-KONTRAST
)
season_folder: str | None = (
None # .../A.Knight.of.the.Seven.Kingdoms.S01.1080p.WEBRip.x265-KONTRAST
)
series_folder_name: str | None = None
season_folder_name: str | None = None
filename: str | None = None
is_new_series_folder: bool = False # True if we're creating the folder
is_new_series_folder: bool = False
# Populated on "needs_clarification"
# needs_clarification
question: str | None = None
options: list[str] | None = None # existing group folder names to pick from
options: list[str] | None = None
# Populated on "error"
# error
error: str | None = None
message: str | None = None
@@ -67,189 +91,366 @@ class ResolvedDestination:
}
return {
"status": self.status,
"library_file": self.library_file,
"series_folder": self.series_folder,
"season_folder": self.season_folder,
"series_folder_name": self.series_folder_name,
"season_folder_name": self.season_folder_name,
"is_new_series_folder": self.is_new_series_folder,
}
@dataclass
class ResolvedEpisodeDestination:
"""Paths for a single episode — file move."""
status: str
# ok
series_folder: str | None = None
season_folder: str | None = None
library_file: str | None = None # full path to destination .mkv
series_folder_name: str | None = None
season_folder_name: str | None = None
filename: str | None = None
is_new_series_folder: bool = False
# needs_clarification
question: str | None = None
options: list[str] | None = None
# error
error: str | None = None
message: str | None = None
def to_dict(self) -> dict:
if self.status == "error":
return {"status": self.status, "error": self.error, "message": self.message}
if self.status == "needs_clarification":
return {
"status": self.status,
"question": self.question,
"options": self.options or [],
}
return {
"status": self.status,
"series_folder": self.series_folder,
"season_folder": self.season_folder,
"library_file": self.library_file,
"series_folder_name": self.series_folder_name,
"season_folder_name": self.season_folder_name,
"filename": self.filename,
"is_new_series_folder": self.is_new_series_folder,
}
@dataclass
class ResolvedMovieDestination:
"""Paths for a movie — file move."""
status: str
# ok
movie_folder: str | None = None
library_file: str | None = None
movie_folder_name: str | None = None
filename: str | None = None
is_new_folder: bool = False
# needs_clarification
question: str | None = None
options: list[str] | None = None
# error
error: str | None = None
message: str | None = None
def to_dict(self) -> dict:
if self.status == "error":
return {"status": self.status, "error": self.error, "message": self.message}
if self.status == "needs_clarification":
return {
"status": self.status,
"question": self.question,
"options": self.options or [],
}
return {
"status": self.status,
"movie_folder": self.movie_folder,
"library_file": self.library_file,
"movie_folder_name": self.movie_folder_name,
"filename": self.filename,
"is_new_folder": self.is_new_folder,
}
@dataclass
class ResolvedSeriesDestination:
"""Paths for a complete multi-season series pack — folder move."""
status: str
# ok
series_folder: str | None = None
series_folder_name: str | None = None
is_new_series_folder: bool = False
# needs_clarification
question: str | None = None
options: list[str] | None = None
# error
error: str | None = None
message: str | None = None
def to_dict(self) -> dict:
if self.status == "error":
return {"status": self.status, "error": self.error, "message": self.message}
if self.status == "needs_clarification":
return {
"status": self.status,
"question": self.question,
"options": self.options or [],
}
return {
"status": self.status,
"series_folder": self.series_folder,
"series_folder_name": self.series_folder_name,
"is_new_series_folder": self.is_new_series_folder,
}
# ---------------------------------------------------------------------------
# Use case
# Use cases
# ---------------------------------------------------------------------------
class ResolveDestinationUseCase:
def resolve_season_destination(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
) -> ResolvedSeasonDestination:
"""
Compute the full destination path for a media file being organised.
Compute destination paths for a season pack.
The caller provides:
- release_name: the raw release folder/file name
- source_file: path to the actual video file (to get extension)
- tmdb_title: canonical title from TMDB
- tmdb_year: release year from TMDB
- tmdb_episode_title: episode title from TMDB (None for movies / season packs)
- confirmed_folder: if the user already answered needs_clarification, pass
the chosen folder name here to skip the check
Returns a ResolvedDestination.
Returns series_folder + season_folder. No file paths — the whole
source folder is moved as-is into season_folder.
"""
def execute(
self,
release_name: str,
source_file: str,
tmdb_title: str,
tmdb_year: int,
tmdb_episode_title: str | None = None,
confirmed_folder: str | None = None,
) -> ResolvedDestination:
parsed = parse_release(release_name)
ext = Path(source_file).suffix # ".mkv"
if parsed.media_type == "movie":
return self._resolve_movie(parsed, tmdb_title, tmdb_year, ext)
if parsed.media_type == "tv_show":
return self._resolve_tvshow(
parsed, tmdb_title, tmdb_year, tmdb_episode_title, ext, confirmed_folder
)
return ResolvedDestination(
tv_root = _get_tv_root()
if not tv_root:
return ResolvedSeasonDestination(
status="error",
error="unsupported_media_type",
message=(
f"Cannot organize '{release_name}': detected as '{parsed.media_type}'. "
"Only movies and TV shows are supported."
),
error="library_not_set",
message="TV show library path is not configured.",
)
# ------------------------------------------------------------------
# Movie
# ------------------------------------------------------------------
parsed = parse_release(release_name)
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
def _resolve_movie(
self, parsed: ParsedRelease, tmdb_title: str, tmdb_year: int, ext: str
) -> ResolvedDestination:
memory = get_memory()
movies_root = memory.ltm.library_paths.get("movie")
if not movies_root:
return ResolvedDestination(
status="error",
error="library_not_set",
message="Movie library path is not configured.",
)
if confirmed_folder:
series_folder_name = confirmed_folder
is_new = not (tv_root / confirmed_folder).exists()
else:
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
folder_name = _sanitise(parsed.movie_folder_name(tmdb_title, tmdb_year))
filename = _sanitise(parsed.movie_filename(tmdb_title, tmdb_year, ext))
folder_path = Path(movies_root) / folder_name
file_path = folder_path / filename
return ResolvedDestination(
status="ok",
library_file=str(file_path),
series_folder=str(folder_path),
series_folder_name=folder_name,
filename=filename,
is_new_series_folder=not folder_path.exists(),
)
# ------------------------------------------------------------------
# TV show
# ------------------------------------------------------------------
def _resolve_tvshow(
self,
parsed: ParsedRelease,
tmdb_title: str,
tmdb_year: int,
tmdb_episode_title: str | None,
ext: str,
confirmed_folder: str | None,
) -> ResolvedDestination:
memory = get_memory()
tv_root = memory.ltm.library_paths.get("tv_show")
if not tv_root:
return ResolvedDestination(
status="error",
error="library_not_set",
message="TV show library path is not configured.",
)
tv_root_path = Path(tv_root)
# --- Find existing series folders for this title ---
existing = _find_existing_series_folders(tv_root_path, tmdb_title, tmdb_year)
# --- Determine series folder name ---
if confirmed_folder:
series_folder_name = confirmed_folder
is_new = not (tv_root_path / confirmed_folder).exists()
elif len(existing) == 0:
# No existing folder — create with release group
series_folder_name = _sanitise(parsed.show_folder_name(tmdb_title, tmdb_year))
if len(existing) == 0:
series_folder_name = computed_name
is_new = True
elif len(existing) == 1:
# Exactly one match — use it regardless of group
elif len(existing) == 1 and existing[0] == computed_name:
# Exact match — use it
series_folder_name = existing[0]
is_new = False
else:
# Multiple folders — ask user
return ResolvedDestination(
# One folder with a different name, or multiple — ask user
options = existing + (
[computed_name] if computed_name not in existing else []
)
return ResolvedSeasonDestination(
status="needs_clarification",
question=(
f"Multiple folders found for '{tmdb_title}' in your library. "
f"Which one should I use for this release ({parsed.group})?"
f"Un dossier série existe déjà pour '{tmdb_title}' "
f"mais son nom diffère du nom calculé ({computed_name}). "
f"Lequel utiliser ?"
),
options=existing,
options=options,
)
# --- Build paths ---
season_folder_name = parsed.season_folder_name()
filename = _sanitise(
parsed.episode_filename(tmdb_episode_title, ext)
if not parsed.is_season_pack
else parsed.season_folder_name() + ext
season_folder_name = parsed.season_folder_name()
series_path = tv_root / series_folder_name
season_path = series_path / season_folder_name
return ResolvedSeasonDestination(
status="ok",
series_folder=str(series_path),
season_folder=str(season_path),
series_folder_name=series_folder_name,
season_folder_name=season_folder_name,
is_new_series_folder=is_new,
)
def resolve_episode_destination(
release_name: str,
source_file: str,
tmdb_title: str,
tmdb_year: int,
tmdb_episode_title: str | None = None,
confirmed_folder: str | None = None,
) -> ResolvedEpisodeDestination:
"""
Compute destination paths for a single episode file.
Returns series_folder + season_folder + library_file (full path to .mkv).
"""
tv_root = _get_tv_root()
if not tv_root:
return ResolvedEpisodeDestination(
status="error",
error="library_not_set",
message="TV show library path is not configured.",
)
series_path = tv_root_path / series_folder_name
season_path = series_path / season_folder_name
file_path = season_path / filename
parsed = parse_release(release_name)
ext = Path(source_file).suffix
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
return ResolvedDestination(
status="ok",
library_file=str(file_path),
series_folder=str(series_path),
season_folder=str(season_path),
series_folder_name=series_folder_name,
season_folder_name=season_folder_name,
filename=filename,
is_new_series_folder=is_new,
if confirmed_folder:
series_folder_name = confirmed_folder
is_new = not (tv_root / confirmed_folder).exists()
else:
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
if len(existing) == 0:
series_folder_name = computed_name
is_new = True
elif len(existing) == 1 and existing[0] == computed_name:
series_folder_name = existing[0]
is_new = False
else:
options = existing + (
[computed_name] if computed_name not in existing else []
)
return ResolvedEpisodeDestination(
status="needs_clarification",
question=(
f"Un dossier série existe déjà pour '{tmdb_title}' "
f"mais son nom diffère du nom calculé ({computed_name}). "
f"Lequel utiliser ?"
),
options=options,
)
season_folder_name = parsed.season_folder_name()
filename = _sanitize(parsed.episode_filename(tmdb_episode_title, ext))
series_path = tv_root / series_folder_name
season_path = series_path / season_folder_name
file_path = season_path / filename
return ResolvedEpisodeDestination(
status="ok",
series_folder=str(series_path),
season_folder=str(season_path),
library_file=str(file_path),
series_folder_name=series_folder_name,
season_folder_name=season_folder_name,
filename=filename,
is_new_series_folder=is_new,
)
def resolve_movie_destination(
release_name: str,
source_file: str,
tmdb_title: str,
tmdb_year: int,
) -> ResolvedMovieDestination:
"""
Compute destination paths for a movie file.
Returns movie_folder + library_file (full path to .mkv).
"""
memory = get_memory()
movies_root = memory.ltm.library_paths.get("movie")
if not movies_root:
return ResolvedMovieDestination(
status="error",
error="library_not_set",
message="Movie library path is not configured.",
)
parsed = parse_release(release_name)
ext = Path(source_file).suffix
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
folder_name = _sanitize(parsed.movie_folder_name(tmdb_title, tmdb_year))
filename = _sanitize(parsed.movie_filename(tmdb_title, tmdb_year, ext))
def _find_existing_series_folders(tv_root: Path, tmdb_title: str, tmdb_year: int) -> list[str]:
folder_path = Path(movies_root) / folder_name
file_path = folder_path / filename
return ResolvedMovieDestination(
status="ok",
movie_folder=str(folder_path),
library_file=str(file_path),
movie_folder_name=folder_name,
filename=filename,
is_new_folder=not folder_path.exists(),
)
def resolve_series_destination(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
) -> ResolvedSeriesDestination:
"""
Return names of folders in tv_root that match the given title + year.
Compute destination path for a complete multi-season series pack.
Matching is loose: normalised title (dots, no special chars) + year must
appear at the start of the folder name.
Returns only series_folder — the whole pack lands directly inside it.
"""
if not tv_root.exists():
return []
tv_root = _get_tv_root()
if not tv_root:
return ResolvedSeriesDestination(
status="error",
error="library_not_set",
message="TV show library path is not configured.",
)
# Build a normalised prefix to match against: "Oz.1997"
clean_title = _sanitise(tmdb_title).replace(" ", ".")
prefix = f"{clean_title}.{tmdb_year}".lower()
parsed = parse_release(release_name)
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
matches = []
for entry in tv_root.iterdir():
if entry.is_dir() and entry.name.lower().startswith(prefix):
matches.append(entry.name)
if confirmed_folder:
series_folder_name = confirmed_folder
is_new = not (tv_root / confirmed_folder).exists()
else:
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
return sorted(matches)
if len(existing) == 0:
series_folder_name = computed_name
is_new = True
elif len(existing) == 1 and existing[0] == computed_name:
series_folder_name = existing[0]
is_new = False
else:
options = existing + (
[computed_name] if computed_name not in existing else []
)
return ResolvedSeriesDestination(
status="needs_clarification",
question=(
f"Un dossier série existe déjà pour '{tmdb_title}' "
f"mais son nom diffère du nom calculé ({computed_name}). "
f"Lequel utiliser ?"
),
options=options,
)
series_path = tv_root / series_folder_name
return ResolvedSeriesDestination(
status="ok",
series_folder=str(series_path),
series_folder_name=series_folder_name,
is_new_series_folder=is_new,
)
+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.
+6 -3
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"
_SITES_ROOT = _BUILTIN_ROOT / "sites"
_LEARNED_ROOT = (
Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release"
)
def _merge(base: dict, overlay: dict) -> dict:
+30 -16
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,
)
@@ -58,16 +56,18 @@ def parse_release(name: str) -> ParsedRelease:
season, episode, episode_end = _extract_season_episode(tokens)
quality, source, codec, group, tech_tokens = _extract_tech(tokens)
languages, lang_tokens = _extract_languages(tokens)
languages, lang_tokens = _extract_languages(tokens)
audio_codec, audio_channels, audio_tokens = _extract_audio(tokens)
bit_depth, hdr_format, video_tokens = _extract_video_meta(tokens)
edition, edition_tokens = _extract_edition(tokens)
bit_depth, hdr_format, video_tokens = _extract_video_meta(tokens)
edition, edition_tokens = _extract_edition(tokens)
title = _extract_title(
tokens,
tech_tokens | lang_tokens | audio_tokens | video_tokens | edition_tokens,
)
year = _extract_year(tokens, title)
media_type = _infer_media_type(season, quality, source, codec, year, edition, tokens)
media_type = _infer_media_type(
season, quality, source, codec, year, edition, tokens
)
tech_parts = [p for p in [quality, source, codec] if p]
tech_string = ".".join(tech_parts)
@@ -118,7 +118,7 @@ def _infer_media_type(
"""
upper_tokens = {t.upper() for t in tokens}
doc_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("doc", [])}
doc_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("doc", [])}
concert_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("concert", [])}
integrale_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("integrale", [])}
@@ -126,7 +126,10 @@ def _infer_media_type(
return "documentary"
if upper_tokens & concert_tokens:
return "concert"
if (edition in {"COMPLETE", "INTEGRALE", "COLLECTION"} or upper_tokens & integrale_tokens) and season is None:
if (
edition in {"COMPLETE", "INTEGRALE", "COLLECTION"}
or upper_tokens & integrale_tokens
) and season is None:
return "tv_complete"
if season is not None:
return "tv_show"
@@ -172,14 +175,14 @@ def _strip_site_tag(name: str) -> tuple[str, str | None]:
close = s.find("]")
if close != -1:
tag = s[1:close].strip()
remainder = s[close + 1:].strip()
remainder = s[close + 1 :].strip()
if tag and remainder:
return remainder, tag
if s.endswith("]"):
open_bracket = s.rfind("[")
if open_bracket != -1:
tag = s[open_bracket + 1:-1].strip()
tag = s[open_bracket + 1 : -1].strip()
remainder = s[:open_bracket].strip()
if tag and remainder:
return remainder, tag
@@ -226,7 +229,9 @@ def _parse_season_episode(tok: str) -> tuple[int, int | None, int | None] | None
return season, episode, episode_end
def _extract_season_episode(tokens: list[str]) -> tuple[int | None, int | None, int | None]:
def _extract_season_episode(
tokens: list[str],
) -> tuple[int | None, int | None, int | None]:
for tok in tokens:
parsed = _parse_season_episode(tok)
if parsed is not None:
@@ -333,6 +338,7 @@ def _extract_year(tokens: list[str], title: str) -> int | None:
# Sequence matcher
# ---------------------------------------------------------------------------
def _match_sequences(
tokens: list[str],
sequences: list[dict],
@@ -349,8 +355,8 @@ def _match_sequences(
seq_upper = [s.upper() for s in seq["tokens"]]
n = len(seq_upper)
for i in range(len(upper_tokens) - n + 1):
if upper_tokens[i:i + n] == seq_upper:
matched = set(tokens[i:i + n])
if upper_tokens[i : i + n] == seq_upper:
matched = set(tokens[i : i + n])
return seq[key], matched
return None, set()
@@ -359,6 +365,7 @@ def _match_sequences(
# Language extraction
# ---------------------------------------------------------------------------
def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]:
"""Extract language tokens. Returns (languages, matched_token_set)."""
languages = []
@@ -374,6 +381,7 @@ def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]:
# Audio extraction
# ---------------------------------------------------------------------------
def _extract_audio(
tokens: list[str],
) -> tuple[str | None, str | None, set[str]]:
@@ -391,7 +399,9 @@ def _extract_audio(
known_channels = set(_AUDIO.get("channels", []))
# Try multi-token sequences first
matched_codec, matched_set = _match_sequences(tokens, _AUDIO.get("sequences", []), "codec")
matched_codec, matched_set = _match_sequences(
tokens, _AUDIO.get("sequences", []), "codec"
)
if matched_codec:
audio_codec = matched_codec
audio_tokens |= matched_set
@@ -424,6 +434,7 @@ def _extract_audio(
# Video metadata extraction (bit depth, HDR)
# ---------------------------------------------------------------------------
def _extract_video_meta(
tokens: list[str],
) -> tuple[str | None, str | None, set[str]]:
@@ -440,7 +451,9 @@ def _extract_video_meta(
known_depth = {d.lower() for d in _VIDEO_META.get("bit_depth", [])}
# Try HDR sequences first
matched_hdr, matched_set = _match_sequences(tokens, _VIDEO_META.get("sequences", []), "hdr")
matched_hdr, matched_set = _match_sequences(
tokens, _VIDEO_META.get("sequences", []), "hdr"
)
if matched_hdr:
hdr_format = matched_hdr
video_tokens |= matched_set
@@ -462,6 +475,7 @@ def _extract_video_meta(
# Edition extraction
# ---------------------------------------------------------------------------
def _extract_edition(tokens: list[str]) -> tuple[str | None, set[str]]:
"""
Extract release edition (UNRATED, EXTENDED, DIRECTORS.CUT, …).
+25 -21
View File
@@ -74,27 +74,31 @@ _strip_episode_from_normalised = _strip_episode_from_normalized
class ParsedRelease:
"""Structured representation of a parsed release name."""
raw: str # original release name (untouched)
normalised: str # dots instead of spaces
title: str # show/movie title (dots, no year/season/tech)
year: int | None # movie year or show start year (from TMDB)
season: int | None # season number (None for movies)
episode: int | None # first episode number (None if season-pack)
episode_end: int | None # last episode for multi-ep (None otherwise)
quality: str | None # 1080p, 2160p, …
source: str | None # WEBRip, BluRay, …
codec: str | None # x265, HEVC, …
group: str # release group, "UNKNOWN" if missing
tech_string: str # quality.source.codec joined with dots
media_type: str = "unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown"
site_tag: str | None = None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
parse_path: str = "direct" # "direct" | "sanitized" | "ai"
languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], …
audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", …
audio_channels: str | None = None # "5.1", "7.1", "2.0", …
bit_depth: str | None = None # "10bit", "8bit", …
hdr_format: str | None = None # "DV", "HDR10", "DV.HDR10", …
edition: str | None = None # "UNRATED", "EXTENDED", "DIRECTORS.CUT", …
raw: str # original release name (untouched)
normalised: str # dots instead of spaces
title: str # show/movie title (dots, no year/season/tech)
year: int | None # movie year or show start year (from TMDB)
season: int | None # season number (None for movies)
episode: int | None # first episode number (None if season-pack)
episode_end: int | None # last episode for multi-ep (None otherwise)
quality: str | None # 1080p, 2160p, …
source: str | None # WEBRip, BluRay, …
codec: str | None # x265, HEVC, …
group: str # release group, "UNKNOWN" if missing
tech_string: str # quality.source.codec joined with dots
media_type: str = (
"unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown"
)
site_tag: str | None = (
None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
)
parse_path: str = "direct" # "direct" | "sanitized" | "ai"
languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], …
audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", …
audio_channels: str | None = None # "5.1", "7.1", "2.0", …
bit_depth: str | None = None # "10bit", "8bit", …
hdr_format: str | None = None # "DV", "HDR10", "DV.HDR10", …
edition: str | None = None # "UNRATED", "EXTENDED", "DIRECTORS.CUT", …
def __post_init__(self):
if self.languages is None:
+30 -18
View File
@@ -10,10 +10,10 @@ class AudioTrack:
"""A single audio track as reported by ffprobe."""
index: int
codec: str | None # aac, ac3, eac3, dts, truehd, flac, …
channels: int | None # 2, 6 (5.1), 8 (7.1), …
codec: str | None # aac, ac3, eac3, dts, truehd, flac, …
channels: int | None # 2, 6 (5.1), 8 (7.1), …
channel_layout: str | None # stereo, 5.1, 7.1, …
language: str | None # ISO 639-2: fre, eng, und, …
language: str | None # ISO 639-2: fre, eng, und, …
is_default: bool = False
@@ -22,8 +22,8 @@ class SubtitleTrack:
"""A single subtitle track as reported by ffprobe."""
index: int
codec: str | None # subrip, ass, hdmv_pgs_subtitle, …
language: str | None # ISO 639-2: fre, eng, und, …
codec: str | None # subrip, ass, hdmv_pgs_subtitle, …
language: str | None # ISO 639-2: fre, eng, und, …
is_default: bool = False
is_forced: bool = False
@@ -39,7 +39,7 @@ class MediaInfo:
# Video
width: int | None = None
height: int | None = None
video_codec: str | None = None # h264, hevc, av1, …
video_codec: str | None = None # h264, hevc, av1, …
duration_seconds: float | None = None
bitrate_kbps: int | None = None
@@ -63,20 +63,32 @@ class MediaInfo:
return None
case (w, h) if w is not None:
match True:
case _ if w >= 3840: return "2160p"
case _ if w >= 1920: return "1080p"
case _ if w >= 1280: return "720p"
case _ if w >= 720: return "576p"
case _ if w >= 640: return "480p"
case _: return f"{h}p" if h else f"{w}w"
case _ if w >= 3840:
return "2160p"
case _ if w >= 1920:
return "1080p"
case _ if w >= 1280:
return "720p"
case _ if w >= 720:
return "576p"
case _ if w >= 640:
return "480p"
case _:
return f"{h}p" if h else f"{w}w"
case (None, h):
match True:
case _ if h >= 2160: return "2160p"
case _ if h >= 1080: return "1080p"
case _ if h >= 720: return "720p"
case _ if h >= 576: return "576p"
case _ if h >= 480: return "480p"
case _: return f"{h}p"
case _ if h >= 2160:
return "2160p"
case _ if h >= 1080:
return "1080p"
case _ if h >= 720:
return "720p"
case _ if h >= 576:
return "576p"
case _ if h >= 480:
return "480p"
case _:
return f"{h}p"
@property
def audio_languages(self) -> list[str]:
+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"))
+20 -8
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
@@ -23,13 +27,15 @@ class SubtitleTrack:
# Source
is_embedded: bool = False
file_path: Path | None = None # None if embedded
file_path: Path | None = None # None if embedded
file_size_kb: float | None = None
entry_count: int | None = None # number of subtitle cues in the file
entry_count: int | None = None # number of subtitle cues in the file
# Matching state
confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded
raw_tokens: list[str] = field(default_factory=list) # tokens extracted from filename
confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded
raw_tokens: list[str] = field(
default_factory=list
) # tokens extracted from filename
def is_resolved(self) -> bool:
return self.language is not None
@@ -43,7 +49,9 @@ class SubtitleTrack:
{lang}.forced.{ext}
"""
if not self.language or not self.format:
raise ValueError("Cannot compute destination_name: language or format missing")
raise ValueError(
"Cannot compute destination_name: language or format missing"
)
ext = self.format.extensions[0].lstrip(".")
parts = [self.language.code]
if self.subtitle_type == SubtitleType.SDH:
@@ -55,7 +63,11 @@ class SubtitleTrack:
def __repr__(self) -> str:
lang = self.language.code if self.language else "?"
fmt = self.format.id if self.format else "?"
src = "embedded" if self.is_embedded else str(self.file_path.name if self.file_path else "?")
src = (
"embedded"
if self.is_embedded
else str(self.file_path.name if self.file_path else "?")
)
return f"SubtitleTrack({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})"
@@ -67,7 +79,7 @@ class MediaSubtitleMetadata:
"""
media_id: ImdbId | None
media_type: str # "movie" | "tv_show"
media_type: str # "movie" | "tv_show"
embedded_tracks: list[SubtitleTrack] = field(default_factory=list)
external_tracks: list[SubtitleTrack] = field(default_factory=list)
release_group: str | None = None
@@ -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}'")
+16 -7
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__)
@@ -89,10 +89,10 @@ class SubtitleCandidate:
"""A subtitle file that passed the filter, ready to be placed."""
source_path: Path
language: str # ISO 639-1 code, e.g. "fr"
language: str # ISO 639-1 code, e.g. "fr"
is_sdh: bool
is_forced: bool
extension: str # e.g. ".srt"
extension: str # e.g. ".srt"
@property
def destination_name(self) -> str:
@@ -120,6 +120,7 @@ def _classify(path: Path) -> tuple[str | None, bool, bool]:
stem = path.stem.lower()
# Split on dots, spaces, underscores, hyphens
import re
tokens = re.split(r"[\.\s_\-]+", stem)
language: str | None = None
@@ -147,7 +148,9 @@ class SubtitleScanner:
# Each candidate has .source_path and .destination_name
"""
def __init__(self, languages: list[str], min_size_kb: int, keep_sdh: bool, keep_forced: bool):
def __init__(
self, languages: list[str], min_size_kb: int, keep_sdh: bool, keep_forced: bool
):
self.languages = [l.lower() for l in languages]
self.min_size_kb = min_size_kb
self.keep_sdh = keep_sdh
@@ -180,7 +183,9 @@ class SubtitleScanner:
if candidate is not None:
candidates.append(candidate)
logger.info(f"SubtitleScanner: {len(candidates)} candidate(s) found for {video_path.name}")
logger.info(
f"SubtitleScanner: {len(candidates)} candidate(s) found for {video_path.name}"
)
return candidates
def _evaluate(self, path: Path) -> SubtitleCandidate | None:
@@ -188,7 +193,9 @@ class SubtitleScanner:
# Size filter
size_kb = path.stat().st_size / 1024
if size_kb < self.min_size_kb:
logger.debug(f"SubtitleScanner: skip {path.name} (too small: {size_kb:.1f} KB)")
logger.debug(
f"SubtitleScanner: skip {path.name} (too small: {size_kb:.1f} KB)"
)
return None
language, is_sdh, is_forced = _classify(path)
@@ -199,7 +206,9 @@ class SubtitleScanner:
return None
if language not in self.languages:
logger.debug(f"SubtitleScanner: skip {path.name} (language '{language}' not in prefs)")
logger.debug(
f"SubtitleScanner: skip {path.name} (language '{language}' not in prefs)"
)
return None
# SDH filter
+91 -32
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(
language=lang,
format=None,
subtitle_type=stype,
is_embedded=True,
raw_tokens=[lang_code] if lang_code else [],
))
tracks.append(
SubtitleTrack(
language=lang,
format=None,
subtitle_type=stype,
is_embedded=True,
raw_tokens=[lang_code] if lang_code else [],
)
)
logger.debug(f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}")
logger.debug(
f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}"
)
return tracks
# ------------------------------------------------------------------
# External tracks — filesystem scan per pattern strategy
# ------------------------------------------------------------------
def _scan_external(self, video_path: Path, pattern: SubtitlePattern) -> list[SubtitleTrack]:
def _scan_external(
self, video_path: Path, pattern: SubtitlePattern
) -> list[SubtitleTrack]:
strategy = pattern.scan_strategy
episode_stem: str | None = None
if strategy == ScanStrategy.ADJACENT:
candidates = self._find_adjacent(video_path)
elif strategy == ScanStrategy.FLAT:
candidates = self._find_flat(video_path, pattern.root_folder or "Subs")
elif strategy == ScanStrategy.EPISODE_SUBFOLDER:
candidates = self._find_episode_subfolder(video_path, pattern.root_folder or "Subs")
candidates, episode_stem = self._find_episode_subfolder(
video_path, pattern.root_folder or "Subs"
)
else:
return []
return self._classify_files(candidates, pattern)
return self._classify_files(candidates, pattern, episode_stem=episode_stem)
def _find_adjacent(self, video_path: Path) -> list[Path]:
return [
p for p in sorted(video_path.parent.iterdir())
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
p
for p in sorted(video_path.parent.iterdir())
if p.is_file()
and p.suffix.lower() in self.kb.known_extensions()
and p.stem != video_path.stem
]
@@ -152,17 +193,22 @@ class SubtitleIdentifier:
if not subs_dir.is_dir():
return []
return [
p for p in sorted(subs_dir.iterdir())
p
for p in sorted(subs_dir.iterdir())
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
]
def _find_episode_subfolder(self, video_path: Path, root_folder: str) -> list[Path]:
def _find_episode_subfolder(
self, video_path: Path, root_folder: str
) -> tuple[list[Path], str]:
"""
Look for Subs/{episode_stem}/*.srt
Checks two locations:
1. Adjacent to the video: video_path.parent / root_folder / video_path.stem
2. Release root (one level up): video_path.parent.parent / root_folder / video_path.stem
Returns (files, episode_stem) so the classifier can strip the prefix.
"""
episode_stem = video_path.stem
candidates_dirs = [
@@ -172,22 +218,30 @@ class SubtitleIdentifier:
for subs_dir in candidates_dirs:
if subs_dir.is_dir():
files = [
p for p in sorted(subs_dir.iterdir())
p
for p in sorted(subs_dir.iterdir())
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
]
if files:
logger.debug(f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}")
return files
return []
logger.debug(
f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}"
)
return files, episode_stem
return [], episode_stem
# ------------------------------------------------------------------
# Classification
# ------------------------------------------------------------------
def _classify_files(self, paths: list[Path], pattern: SubtitlePattern) -> list[SubtitleTrack]:
def _classify_files(
self,
paths: list[Path],
pattern: SubtitlePattern,
episode_stem: str | None = None,
) -> list[SubtitleTrack]:
tracks = []
for path in paths:
track = self._classify_single(path)
track = self._classify_single(path, episode_stem=episode_stem)
tracks.append(track)
# Post-process: if multiple tracks share same language but type is ambiguous,
@@ -197,9 +251,15 @@ class SubtitleIdentifier:
return tracks
def _classify_single(self, path: Path) -> SubtitleTrack:
def _classify_single(
self, path: Path, episode_stem: str | None = None
) -> SubtitleTrack:
fmt = self.kb.format_for_extension(path.suffix)
tokens = _tokenize(path.stem)
tokens = (
_tokenize_suffix(path.stem, episode_stem)
if episode_stem
else _tokenize(path.stem)
)
language = None
subtitle_type = SubtitleType.UNKNOWN
@@ -250,7 +310,6 @@ class SubtitleIdentifier:
Only applied when type_detection = size_and_count.
"""
from itertools import groupby
# Group by language code
lang_groups: dict[str, list[SubtitleTrack]] = {}
+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
@@ -67,7 +73,7 @@ class PatternDetector:
known_exts = self.kb.known_extensions()
findings: dict = {
"has_subs_folder": False,
"subs_strategy": None, # "flat" | "episode_subfolder"
"subs_strategy": None, # "flat" | "episode_subfolder"
"subs_root": None,
"adjacent_subs": False,
"has_embedded": self._has_embedded_subtitles(sample_video),
@@ -87,15 +93,22 @@ class PatternDetector:
# Is it flat or episode_subfolder?
children = list(subs_candidate.iterdir())
sub_files = [c for c in children if c.is_file() and c.suffix.lower() in known_exts]
sub_files = [
c
for c in children
if c.is_file() and c.suffix.lower() in known_exts
]
sub_dirs = [c for c in children if c.is_dir()]
if sub_dirs and not sub_files:
findings["subs_strategy"] = "episode_subfolder"
# Count files in a sample subfolder
sample_sub = sub_dirs[0]
sample_files = [f for f in sample_sub.iterdir()
if f.is_file() and f.suffix.lower() in known_exts]
sample_files = [
f
for f in sample_sub.iterdir()
if f.is_file() and f.suffix.lower() in known_exts
]
findings["files_per_episode"] = len(sample_files)
# Check naming conventions
for f in sample_files:
@@ -103,22 +116,27 @@ class PatternDetector:
parts = stem.split("_")
if parts[0].isdigit():
findings["has_numeric_prefix"] = True
if any(self.kb.is_known_lang_token(t.lower())
for t in stem.replace("_", ".").split(".")):
if any(
self.kb.is_known_lang_token(t.lower())
for t in stem.replace("_", ".").split(".")
):
findings["has_lang_tokens"] = True
else:
findings["subs_strategy"] = "flat"
findings["files_per_episode"] = len(sub_files)
for f in sub_files:
if any(self.kb.is_known_lang_token(t.lower())
for t in f.stem.replace("_", ".").split(".")):
if any(
self.kb.is_known_lang_token(t.lower())
for t in f.stem.replace("_", ".").split(".")
):
findings["has_lang_tokens"] = True
break
# Check adjacent subs (next to the video)
if not findings["has_subs_folder"]:
adjacent = [
p for p in sample_video.parent.iterdir()
p
for p in sample_video.parent.iterdir()
if p.is_file() and p.suffix.lower() in known_exts
]
if adjacent:
@@ -157,7 +175,9 @@ class PatternDetector:
total += 1
if findings.get("has_embedded"):
score += 1.0
if not findings.get("has_subs_folder") and not findings.get("adjacent_subs"):
if not findings.get("has_subs_folder") and not findings.get(
"adjacent_subs"
):
score += 0.5
total += 0.5
+31 -7
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
@@ -20,7 +42,7 @@ class PlacedTrack:
@dataclass
class PlaceResult:
placed: list[PlacedTrack]
skipped: list[tuple[SubtitleTrack, str]] # (track, reason)
skipped: list[tuple[SubtitleTrack, str]] # (track, reason)
@property
def placed_count(self) -> int:
@@ -62,7 +84,7 @@ class SubtitlePlacer:
continue
try:
dest_name = track.destination_name
dest_name = _build_dest_name(track, destination_video.stem)
except ValueError as e:
skipped.append((track, str(e)))
continue
@@ -76,11 +98,13 @@ class SubtitlePlacer:
try:
os.link(track.file_path, dest_path)
placed.append(PlacedTrack(
source=track.file_path,
destination=dest_path,
filename=dest_name,
))
placed.append(
PlacedTrack(
source=track.file_path,
destination=dest_path,
filename=dest_name,
)
)
logger.info(f"SubtitlePlacer: placed {dest_name}")
except OSError as e:
logger.warning(f"SubtitlePlacer: failed to place {dest_name}: {e}")
+11 -13
View File
@@ -2,17 +2,15 @@
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
class ScanStrategy(Enum):
"""How to locate subtitle files for a given release."""
ADJACENT = "adjacent" # .srt next to the video
FLAT = "flat" # Subs/*.srt
ADJACENT = "adjacent" # .srt next to the video
FLAT = "flat" # Subs/*.srt
EPISODE_SUBFOLDER = "episode_subfolder" # Subs/{episode_name}/*.srt
EMBEDDED = "embedded" # tracks inside the video container
EMBEDDED = "embedded" # tracks inside the video container
class TypeDetectionMethod(Enum):
@@ -46,7 +44,7 @@ class SubtitleFormat:
class SubtitleLanguage:
"""A known subtitle language with its recognition tokens."""
code: str # ISO 639-1
code: str # ISO 639-1
tokens: list[str] # lowercase
def matches_token(self, token: str) -> bool:
@@ -66,7 +64,7 @@ class SubtitlePattern:
id: str
description: str
scan_strategy: ScanStrategy
root_folder: str | None # e.g. "Subs", None for adjacent/embedded
root_folder: str | None # e.g. "Subs", None for adjacent/embedded
type_detection: TypeDetectionMethod
version: str = "1.0"
@@ -78,10 +76,10 @@ class SubtitleMatchingRules:
Only stores actual values — None means "inherited, not overridden at this level".
"""
preferred_languages: list[str] = field(default_factory=list) # ISO 639-1 codes
preferred_formats: list[str] = field(default_factory=list) # format ids
allowed_types: list[str] = field(default_factory=list) # SubtitleType ids
format_priority: list[str] = field(default_factory=list) # ordered format ids
preferred_languages: list[str] = field(default_factory=list) # ISO 639-1 codes
preferred_formats: list[str] = field(default_factory=list) # format ids
allowed_types: list[str] = field(default_factory=list) # SubtitleType ids
format_priority: list[str] = field(default_factory=list) # ordered format ids
min_confidence: float = 0.7
@@ -89,5 +87,5 @@ class SubtitleMatchingRules:
class RuleScope:
"""At which level a rule set applies."""
level: str # "global" | "release_group" | "movie" | "show" | "season" | "episode"
identifier: str | None = None # imdb_id, group name, "S01", "S01E03"…
level: str # "global" | "release_group" | "movie" | "show" | "season" | "episode"
identifier: str | None = None # imdb_id, group name, "S01", "S01E03"…
+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",
]
+23 -17
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(
index=stream.get("index", len(info.audio_tracks)),
codec=stream.get("codec_name"),
channels=stream.get("channels"),
channel_layout=stream.get("channel_layout"),
language=stream.get("tags", {}).get("language"),
is_default=stream.get("disposition", {}).get("default", 0) == 1,
))
info.audio_tracks.append(
AudioTrack(
index=stream.get("index", len(info.audio_tracks)),
codec=stream.get("codec_name"),
channels=stream.get("channels"),
channel_layout=stream.get("channel_layout"),
language=stream.get("tags", {}).get("language"),
is_default=stream.get("disposition", {}).get("default", 0) == 1,
)
)
elif codec_type == "subtitle":
info.subtitle_tracks.append(SubtitleTrack(
index=stream.get("index", len(info.subtitle_tracks)),
codec=stream.get("codec_name"),
language=stream.get("tags", {}).get("language"),
is_default=stream.get("disposition", {}).get("default", 0) == 1,
is_forced=stream.get("disposition", {}).get("forced", 0) == 1,
))
info.subtitle_tracks.append(
SubtitleTrack(
index=stream.get("index", len(info.subtitle_tracks)),
codec=stream.get("codec_name"),
language=stream.get("tags", {}).get("language"),
is_default=stream.get("disposition", {}).get("default", 0) == 1,
is_forced=stream.get("disposition", {}).get("forced", 0) == 1,
)
)
return info
@@ -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({
"timestamp": datetime.now().isoformat(),
"action": action,
"error": error,
"context": context or {},
})
self.items = self.items[-self.max_errors:]
self.items.append(
{
"timestamp": datetime.now().isoformat(),
"action": action,
"error": error,
"context": context or {},
}
)
self.items = self.items[-self.max_errors :]
logger.warning(f"Errors: '{action}': {error}")
def clear(self) -> None:
@@ -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 {
"download": self.download,
"torrent": self.torrent,
}.items() if v is not None}
return {
k: v
for k, v in {
"download": self.download,
"torrent": self.torrent,
"trash": self.trash,
}.items()
if v is not None
}
def to_dict(self) -> dict:
return {"download": self.download, "torrent": self.torrent}
return {"download": self.download, "torrent": self.torrent, "trash": self.trash}
@classmethod
def describe(cls) -> dict:
@@ -45,13 +51,15 @@ class WorkspacePaths:
"fields": {
"download": "Root folder where qBittorrent drops completed downloads.",
"torrent": "Folder where .torrent files are stored.",
"trash": "Trash folder — files moved here instead of deleted, for manual review.",
},
}
@classmethod
def from_dict(cls, data: dict) -> "WorkspacePaths":
def from_dict(cls, data: dict) -> WorkspacePaths:
# Migrate from old flat format (download_folder, torrent_folder)
return cls(
download=data.get("download") or data.get("download_folder"),
torrent=data.get("torrent") or data.get("torrent_folder"),
trash=data.get("trash"),
)
@@ -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,21 +111,25 @@ class SubtitleMetadataStore:
tracks_data: list[dict[str, Any]] = []
for placed, track in placed_pairs:
# Infer type from destination filename parts (e.g. en.sdh.srt → sdh)
parts = placed.filename.rsplit(".", 2) # ["en", "sdh", "srt"] or ["en", "srt"]
parts = placed.filename.rsplit(
".", 2
) # ["en", "sdh", "srt"] or ["en", "srt"]
inferred_type = parts[1] if len(parts) == 3 else "standard"
tracks_data.append({
"language": track.language.code if track.language else "unknown",
"type": inferred_type,
"format": placed.destination.suffix.lstrip("."),
"is_embedded": track.is_embedded,
"source_file": placed.source.name,
"placed_as": placed.filename,
"confidence": round(track.confidence, 3),
})
tracks_data.append(
{
"language": track.language.code if track.language else "unknown",
"type": inferred_type,
"format": placed.destination.suffix.lstrip("."),
"is_embedded": track.is_embedded,
"source_file": placed.source.name,
"placed_as": placed.filename,
"confidence": round(track.confidence, 3),
}
)
entry: dict[str, Any] = {
"placed_at": datetime.now(timezone.utc).isoformat(),
"placed_at": datetime.now(UTC).isoformat(),
"release_group": release_group,
"tracks": tracks_data,
}
@@ -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 ---
+14 -8
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 [
"# Auto-generated secrets — DO NOT COMMIT",
"# Run 'make bootstrap' to generate missing secrets",
"",
]
lines = (
list(path.read_text().splitlines())
if path.exists()
else [
"# Auto-generated secrets — DO NOT COMMIT",
"# Run 'make bootstrap' to generate missing secrets",
"",
]
)
added = []
for key, (size, fmt) in secrets_spec.items():
@@ -108,7 +112,9 @@ def build_uris(env_alfred: Path, env_secrets: Path) -> None:
added = []
for key, value in computed.items():
if key in existing:
content = re.sub(rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE)
content = re.sub(
rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE
)
else:
content = content.rstrip("\n") + f"\n{key}={value}\n"
added.append(key)
+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)
+89 -48
View File
@@ -21,14 +21,14 @@ if str(_PROJECT_ROOT) not in sys.path:
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
USE_COLOR = True
@@ -51,6 +51,7 @@ def hr() -> None:
# TMDB lookup
# ---------------------------------------------------------------------------
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
"""
Call TMDBClient.search_media() and return (canonical_title, year).
@@ -58,6 +59,7 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
"""
try:
from alfred.infrastructure.api.tmdb import TMDBClient
client = TMDBClient()
result = client.search_media(title)
year: int | None = None
@@ -66,7 +68,12 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
year = int(result.release_date[:4])
except (ValueError, IndexError):
pass
print(c(f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}", DIM))
print(
c(
f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}",
DIM,
)
)
return result.title, year
except Exception as e:
print(c(f" TMDB lookup failed: {e}", YELLOW))
@@ -77,8 +84,14 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
# Display
# ---------------------------------------------------------------------------
def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
tmdb_episode_title: str | None, ext: str) -> None:
def _show(
release_name: str,
tmdb_title: str | None,
tmdb_year: int | None,
tmdb_episode_title: str | None,
ext: str,
) -> None:
from alfred.domain.release import parse_release
p = parse_release(release_name)
@@ -87,7 +100,7 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
if not (tmdb_title and tmdb_year):
fetched_title, fetched_year = _fetch_tmdb(p.title.replace(".", " "))
tmdb_title = tmdb_title or fetched_title
tmdb_year = tmdb_year or fetched_year
tmdb_year = tmdb_year or fetched_year
print()
print(c("" * 64, BOLD))
@@ -96,34 +109,37 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
# Core fields
hr()
kv("raw", p.raw)
kv("normalised", p.normalised)
kv("title", p.title)
kv("year", str(p.year) if p.year else c("None", DIM))
kv("season", str(p.season) if p.season is not None else c("None", DIM))
kv("episode", str(p.episode) if p.episode is not None else c("None", DIM))
kv("episode_end", str(p.episode_end) if p.episode_end is not None else c("None", DIM))
kv("quality", p.quality or c("None", DIM))
kv("source", p.source or c("None", DIM))
kv("codec", p.codec or c("None", DIM))
kv("group", p.group, YELLOW if p.group == "UNKNOWN" else GREEN)
kv("tech_string", p.tech_string or c("(empty)", DIM))
kv("raw", p.raw)
kv("normalised", p.normalised)
kv("title", p.title)
kv("year", str(p.year) if p.year else c("None", DIM))
kv("season", str(p.season) if p.season is not None else c("None", DIM))
kv("episode", str(p.episode) if p.episode is not None else c("None", DIM))
kv(
"episode_end",
str(p.episode_end) if p.episode_end is not None else c("None", DIM),
)
kv("quality", p.quality or c("None", DIM))
kv("source", p.source or c("None", DIM))
kv("codec", p.codec or c("None", DIM))
kv("group", p.group, YELLOW if p.group == "UNKNOWN" else GREEN)
kv("tech_string", p.tech_string or c("(empty)", DIM))
# Derived booleans
hr()
kv("is_movie", c(str(p.is_movie), GREEN if p.is_movie else DIM))
kv("is_movie", c(str(p.is_movie), GREEN if p.is_movie else DIM))
kv("is_season_pack", c(str(p.is_season_pack), GREEN if p.is_season_pack else DIM))
# Generated names
hr()
title_for_names = tmdb_title or p.title.replace(".", " ")
year_for_names = tmdb_year or p.year or 0
year_for_names = tmdb_year or p.year or 0
if p.is_movie:
kv("movie_folder_name", p.movie_folder_name(title_for_names, year_for_names))
kv("movie_filename", p.movie_filename(title_for_names, year_for_names, ext))
kv("movie_filename", p.movie_filename(title_for_names, year_for_names, ext))
else:
kv("show_folder_name", p.show_folder_name(title_for_names, year_for_names))
kv("show_folder_name", p.show_folder_name(title_for_names, year_for_names))
kv("season_folder_name", p.season_folder_name())
if not p.is_season_pack:
kv("episode_filename", p.episode_filename(tmdb_episode_title, ext))
@@ -133,9 +149,12 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
if tmdb_title or tmdb_year or tmdb_episode_title:
hr()
print(c(" TMDB data used:", DIM))
if tmdb_title: kv(" tmdb_title", tmdb_title)
if tmdb_year: kv(" tmdb_year", str(tmdb_year))
if tmdb_episode_title: kv(" tmdb_episode_title", tmdb_episode_title)
if tmdb_title:
kv(" tmdb_title", tmdb_title)
if tmdb_year:
kv(" tmdb_year", str(tmdb_year))
if tmdb_episode_title:
kv(" tmdb_episode_title", tmdb_episode_title)
print(c("" * 64, BOLD))
print()
@@ -145,10 +164,16 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
# Interactive mode
# ---------------------------------------------------------------------------
def _interactive() -> None:
print(c("\n Alfred — Release Parser REPL", BOLD, CYAN))
print(c(" Type a release name, or 'q' to quit.", DIM))
print(c(" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n", DIM))
print(
c(
" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n",
DIM,
)
)
while True:
try:
@@ -161,8 +186,8 @@ def _interactive() -> None:
break
# Parse inline overrides: "Oz.S03E01... ::title=Oz ::year=1997 ::tmdb"
parts = raw.split("::")
release = parts[0].strip()
parts = raw.split("::")
release = parts[0].strip()
overrides: dict[str, str] = {}
for part in parts[1:]:
part = part.strip()
@@ -170,12 +195,12 @@ def _interactive() -> None:
k, _, v = part.partition("=")
overrides[k.strip()] = v.strip()
else:
overrides[part] = "1" # flag-style: ::tmdb
overrides[part] = "1" # flag-style: ::tmdb
tmdb_title = overrides.get("title")
tmdb_year = int(overrides["year"]) if "year" in overrides else None
tmdb_title = overrides.get("title")
tmdb_year = int(overrides["year"]) if "year" in overrides else None
tmdb_episode_title = overrides.get("ep")
ext = overrides.get("ext", ".mkv")
ext = overrides.get("ext", ".mkv")
try:
_show(release, tmdb_title, tmdb_year, tmdb_episode_title, ext)
except Exception as e:
@@ -186,6 +211,7 @@ def _interactive() -> None:
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
@@ -194,16 +220,29 @@ def main() -> None:
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("release", nargs="?", help="Release name to parse")
parser.add_argument("-i", "--interactive", action="store_true",
help="Interactive REPL mode")
parser.add_argument("--tmdb-title", metavar="TITLE",
help="Override TMDB title for name generation")
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
help="Override TMDB year for name generation")
parser.add_argument("--episode-title", metavar="TITLE",
help="TMDB episode title for episode_filename()")
parser.add_argument("--ext", default=".mkv", metavar="EXT",
help="File extension for filename generation (default: .mkv)")
parser.add_argument(
"-i", "--interactive", action="store_true", help="Interactive REPL mode"
)
parser.add_argument(
"--tmdb-title", metavar="TITLE", help="Override TMDB title for name generation"
)
parser.add_argument(
"--tmdb-year",
metavar="YEAR",
type=int,
help="Override TMDB year for name generation",
)
parser.add_argument(
"--episode-title",
metavar="TITLE",
help="TMDB episode title for episode_filename()",
)
parser.add_argument(
"--ext",
default=".mkv",
metavar="EXT",
help="File extension for filename generation (default: .mkv)",
)
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args()
@@ -219,7 +258,9 @@ def main() -> None:
sys.exit(1)
try:
_show(args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext)
_show(
args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext
)
except Exception as e:
print(c(f"Error: {e}", RED), file=sys.stderr)
sys.exit(1)
+17 -13
View File
@@ -19,14 +19,14 @@ if str(_PROJECT_ROOT) not in sys.path:
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
USE_COLOR = True
@@ -54,6 +54,7 @@ def hr() -> None:
# Formatting helpers
# ---------------------------------------------------------------------------
def fmt_duration(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
@@ -80,6 +81,7 @@ def flag(val: bool) -> str:
# Main
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
@@ -111,14 +113,14 @@ def main() -> None:
# --- Video ---
section("Video")
kv("codec", info.video_codec or c("", DIM))
kv("resolution", info.resolution or c("", DIM))
kv("codec", info.video_codec or c("", DIM))
kv("resolution", info.resolution or c("", DIM))
if info.width and info.height:
kv("dimensions", f"{info.width} × {info.height}")
if info.duration_seconds is not None:
kv("duration", fmt_duration(info.duration_seconds))
kv("duration", fmt_duration(info.duration_seconds))
if info.bitrate_kbps is not None:
kv("bitrate", f"{info.bitrate_kbps} kbps")
kv("bitrate", f"{info.bitrate_kbps} kbps")
# --- Audio ---
section(f"Audio {c(str(len(info.audio_tracks)) + ' track(s)', DIM)}")
@@ -128,7 +130,7 @@ def main() -> None:
lang = track.language or "und"
default_marker = f" {c('default', GREEN, DIM)}" if track.is_default else ""
print(f" {c(f'[{track.index}]', BOLD)} {c(lang, YELLOW)}{default_marker}")
kv("codec", track.codec or c("", DIM), indent=8)
kv("codec", track.codec or c("", DIM), indent=8)
kv("channels", fmt_channels(track.channels, track.channel_layout), indent=8)
# --- Subtitles ---
@@ -151,7 +153,9 @@ def main() -> None:
hr()
multi = c("yes", GREEN) if info.is_multi_audio else c("no", DIM)
langs = ", ".join(info.audio_languages) if info.audio_languages else c("", DIM)
print(f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}")
print(
f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}"
)
hr()
print()
+48 -38
View File
@@ -21,13 +21,13 @@ if str(_PROJECT_ROOT) not in sys.path:
# Colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
RED = "\033[31m"
CYAN = "\033[36m"
USE_COLOR = True
@@ -50,6 +50,7 @@ def hr() -> None:
# Parsing quality check
# ---------------------------------------------------------------------------
def _assess(p) -> list[str]:
"""Return a list of warning strings for fields that look wrong."""
if p.media_type in ("other", "unknown"):
@@ -70,16 +71,24 @@ def _assess(p) -> list[str]:
# Main
# ---------------------------------------------------------------------------
def main() -> None:
global USE_COLOR
parser = argparse.ArgumentParser(description="Recognize release folders in downloads")
parser.add_argument("--path", default="/mnt/testipool/downloads",
help="Downloads directory (default: /mnt/testipool/downloads)")
parser.add_argument("--failures-only", action="store_true",
help="Show only entries with warnings")
parser.add_argument("--successes-only", action="store_true",
help="Show only fully parsed entries")
parser = argparse.ArgumentParser(
description="Recognize release folders in downloads"
)
parser.add_argument(
"--path",
default="/mnt/testipool/downloads",
help="Downloads directory (default: /mnt/testipool/downloads)",
)
parser.add_argument(
"--failures-only", action="store_true", help="Show only entries with warnings"
)
parser.add_argument(
"--successes-only", action="store_true", help="Show only fully parsed entries"
)
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args()
@@ -91,11 +100,11 @@ def main() -> None:
print(c(f"Error: {downloads} does not exist", RED), file=sys.stderr)
sys.exit(1)
from alfred.domain.release.services import parse_release
from alfred.application.filesystem.detect_media_type import detect_media_type
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
from alfred.infrastructure.filesystem.find_video import find_video_file
from alfred.domain.release.services import parse_release
from alfred.infrastructure.filesystem.ffprobe import probe
from alfred.infrastructure.filesystem.find_video import find_video_file
entries = sorted(downloads.iterdir(), key=lambda p: p.name.lower())
total = len(entries)
@@ -136,9 +145,9 @@ def main() -> None:
path_label = ""
if p:
path_label = {
"direct": c("direct", GREEN, DIM),
"direct": c("direct", GREEN, DIM),
"sanitized": c("sanitized", YELLOW),
"ai": c("ai", RED),
"ai": c("ai", RED),
}.get(p.parse_path, p.parse_path)
if has_warnings:
@@ -150,36 +159,35 @@ def main() -> None:
if p:
kind = {
"movie": "movie",
"tv_show": "season pack" if p.is_season_pack else "episode",
"tv_complete": c("tv complete", CYAN),
"documentary": c("documentary", CYAN),
"concert": c("concert", CYAN),
"other": c("other", RED),
"unknown": c("unknown", YELLOW),
"movie": "movie",
"tv_show": "season pack" if p.is_season_pack else "episode",
"tv_complete": c("tv complete", CYAN),
"documentary": c("documentary", CYAN),
"concert": c("concert", CYAN),
"other": c("other", RED),
"unknown": c("unknown", YELLOW),
}.get(p.media_type, p.media_type)
kv("type", kind)
kv("title", p.title)
kv("type", kind)
kv("title", p.title)
if p.season is not None:
ep = f"E{p.episode:02d}" if p.episode is not None else ""
kv("season/ep", f"S{p.season:02d} / {ep}")
if p.year:
kv("year", str(p.year))
kv("year", str(p.year))
if p.languages:
kv("langs", " ".join(p.languages))
kv("quality", p.quality or c("", DIM))
kv("source", p.source or c("", DIM))
kv("codec", p.codec or c("", DIM))
kv("langs", " ".join(p.languages))
kv("quality", p.quality or c("", DIM))
kv("source", p.source or c("", DIM))
kv("codec", p.codec or c("", DIM))
if p.audio_codec:
ch = f" {p.audio_channels}" if p.audio_channels else ""
kv("audio", f"{p.audio_codec}{ch}")
kv("audio", f"{p.audio_codec}{ch}")
if p.bit_depth or p.hdr_format:
hdr_parts = [x for x in [p.bit_depth, p.hdr_format] if x]
kv("hdr/depth", " ".join(hdr_parts))
if p.edition:
kv("edition", p.edition, color=YELLOW)
kv("group", p.group,
color=YELLOW if p.group == "UNKNOWN" else GREEN)
kv("group", p.group, color=YELLOW if p.group == "UNKNOWN" else GREEN)
if p.site_tag:
kv("site tag", p.site_tag, color=YELLOW)
@@ -191,10 +199,12 @@ def main() -> None:
print()
hr()
skipped = total - ok_count - warn_count
print(f" {c('Total:', BOLD)} {total} "
f"{c(str(ok_count) + ' ok', GREEN, BOLD)} "
f"{c(str(warn_count) + ' warnings', YELLOW, BOLD)}"
+ (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else ""))
print(
f" {c('Total:', BOLD)} {total} "
f"{c(str(ok_count) + ' ok', GREEN, BOLD)} "
f"{c(str(warn_count) + ' warnings', YELLOW, BOLD)}"
+ (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else "")
)
hr()
print()
+90 -43
View File
@@ -34,14 +34,14 @@ if str(_PROJECT_ROOT) not in sys.path:
USE_COLOR = True
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
@@ -88,8 +88,7 @@ VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".mov", ".ts", ".m2ts"}
def find_videos(folder: Path) -> list[Path]:
return sorted(
p for p in folder.iterdir()
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS
)
@@ -107,9 +106,13 @@ def confidence_bar(conf: float, width: int = 20) -> str:
def track_summary(track, verbose: bool = False) -> None:
lang = track.language.code if track.language else c("?", RED)
fmt = track.format.id if track.format else c("?", RED)
typ = track.subtitle_type.value
src = "embedded" if track.is_embedded else (track.file_path.name if track.file_path else "?")
fmt = track.format.id if track.format else c("?", RED)
typ = track.subtitle_type.value
src = (
"embedded"
if track.is_embedded
else (track.file_path.name if track.file_path else "?")
)
# Couleur du type
type_colors = {
@@ -125,11 +128,19 @@ def track_summary(track, verbose: bool = False) -> None:
print(f" {c(src, BOLD)}")
print(f" lang={c(lang, CYAN)} type={typ_str} format={fmt}")
conf_str = c("n/a (embedded)", DIM) if track.is_embedded else confidence_bar(track.confidence)
conf_str = (
c("n/a (embedded)", DIM)
if track.is_embedded
else confidence_bar(track.confidence)
)
print(f" confidence={conf_str}{clarif}")
if track.entry_count is not None:
print(f" entries={track.entry_count} size={track.file_size_kb:.1f} KB" if track.file_size_kb else f" entries={track.entry_count}")
print(
f" entries={track.entry_count} size={track.file_size_kb:.1f} KB"
if track.file_size_kb
else f" entries={track.entry_count}"
)
if verbose and track.raw_tokens:
print(f" tokens={track.raw_tokens}")
@@ -146,7 +157,8 @@ def track_summary(track, verbose: bool = False) -> None:
# Étapes du pipeline
# ---------------------------------------------------------------------------
def step_load_kb() -> "SubtitleKnowledgeBase":
def step_load_kb() -> SubtitleKnowledgeBase:
from alfred.domain.subtitles.knowledge.base import SubtitleKnowledgeBase
from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader
@@ -168,12 +180,12 @@ def step_load_kb() -> "SubtitleKnowledgeBase":
def step_detect_pattern(
kb: "SubtitleKnowledgeBase",
kb: SubtitleKnowledgeBase,
season_folder: Path,
sample_video: Path,
release_group: str | None,
forced_pattern: str | None,
) -> "SubtitlePattern":
) -> SubtitlePattern:
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
section("ÉTAPE 2 — Détection du pattern de release")
@@ -192,7 +204,9 @@ def step_detect_pattern(
known = kb.patterns_for_group(release_group)
if known:
kv("Release group", release_group)
ok(f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}")
ok(
f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}"
)
pattern = known[0]
kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD))
return pattern
@@ -237,12 +251,12 @@ def step_detect_pattern(
def step_identify_tracks(
kb: "SubtitleKnowledgeBase",
kb: SubtitleKnowledgeBase,
sample_video: Path,
pattern: "SubtitlePattern",
pattern: SubtitlePattern,
release_group: str | None,
verbose: bool,
) -> "MediaSubtitleMetadata":
) -> MediaSubtitleMetadata:
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
section("ÉTAPE 3 — Identification des pistes")
@@ -286,9 +300,9 @@ def step_identify_tracks(
def step_apply_rules(
metadata: "MediaSubtitleMetadata",
metadata: MediaSubtitleMetadata,
release_group: str | None,
) -> tuple["SubtitleMatchingRules | None", list, list]:
) -> tuple[SubtitleMatchingRules | None, list, list]:
from alfred.domain.subtitles.aggregates import DEFAULT_RULES
from alfred.domain.subtitles.services.matcher import SubtitleMatcher
from alfred.domain.subtitles.services.utils import available_subtitles
@@ -308,7 +322,9 @@ def step_apply_rules(
kv("Formats préférés", str(rules.preferred_formats))
kv("Types autorisés", str(rules.allowed_types))
kv("Confiance min", str(rules.min_confidence))
info(c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4)
info(
c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4
)
matcher = SubtitleMatcher()
matched, unresolved = matcher.match(metadata.external_tracks, rules)
@@ -330,7 +346,9 @@ def step_show_results(
section("RÉSULTAT FINAL")
if matched:
label = "piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
label = (
"piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
)
ok(f"{len(matched)} {label}:")
for track in matched:
lang = track.language.code if track.language else "?"
@@ -352,7 +370,11 @@ def step_show_results(
warn(f"{len(unresolved)} piste(s) écartées ou à clarifier:")
for track in unresolved:
src = track.file_path.name if track.file_path else "?"
reason = "langue inconnue" if track.language is None else "confiance insuffisante"
reason = (
"langue inconnue"
if track.language is None
else "confiance insuffisante"
)
line = f" {c(src, DIM)} ({reason})"
if verbose and track.raw_tokens:
line += c(f" tokens: {track.raw_tokens}", YELLOW)
@@ -365,9 +387,10 @@ def step_show_results(
# Scan multi-épisodes (résumé)
# ---------------------------------------------------------------------------
def scan_season(
kb: "SubtitleKnowledgeBase",
pattern: "SubtitlePattern",
kb: SubtitleKnowledgeBase,
pattern: SubtitlePattern,
season_folder: Path,
release_group: str | None,
verbose: bool,
@@ -408,15 +431,20 @@ def scan_season(
pass
status_icon = c("", GREEN, BOLD) if placed_names else c("", RED, BOLD)
warn_icon = c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
warn_icon = (
c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
)
print(f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '', GREEN if placed_names else DIM)}{warn_icon}")
print(
f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '', GREEN if placed_names else DIM)}{warn_icon}"
)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Scanner de sous-titres Alfred — pipeline de diagnostic",
@@ -424,18 +452,35 @@ def parse_args() -> argparse.Namespace:
epilog=textwrap.dedent(__doc__ or ""),
)
parser.add_argument("season_folder", help="Dossier de la saison (ou du film)")
parser.add_argument("--release-group", "-g", metavar="GROUP",
help="Groupe de release (ex: RARBG, KONSTRAST)")
parser.add_argument("--pattern", "-p", metavar="PATTERN",
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)")
parser.add_argument("--video", "-v", metavar="FILE",
help="Fichier vidéo de référence (défaut: premier trouvé)")
parser.add_argument("--verbose", action="store_true",
help="Affiche les tokens bruts par piste")
parser.add_argument("--no-color", action="store_true",
help="Désactive la colorisation ANSI")
parser.add_argument("--season-scan", action="store_true",
help="Après le diagnostic, scanner tous les épisodes de la saison")
parser.add_argument(
"--release-group",
"-g",
metavar="GROUP",
help="Groupe de release (ex: RARBG, KONSTRAST)",
)
parser.add_argument(
"--pattern",
"-p",
metavar="PATTERN",
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)",
)
parser.add_argument(
"--video",
"-v",
metavar="FILE",
help="Fichier vidéo de référence (défaut: premier trouvé)",
)
parser.add_argument(
"--verbose", action="store_true", help="Affiche les tokens bruts par piste"
)
parser.add_argument(
"--no-color", action="store_true", help="Désactive la colorisation ANSI"
)
parser.add_argument(
"--season-scan",
action="store_true",
help="Après le diagnostic, scanner tous les épisodes de la saison",
)
return parser.parse_args()
@@ -474,7 +519,9 @@ def main() -> None:
if videos:
break
if not videos:
print("Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr)
print(
"Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr
)
sys.exit(1)
sample_video = videos[0]
+156 -59
View File
@@ -43,14 +43,14 @@ if str(_PROJECT_ROOT) not in sys.path:
USE_COLOR = True
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
@@ -67,10 +67,22 @@ def section(title: str) -> None:
print(c("" * 70, DIM))
def ok(msg: str) -> None: print(c("", GREEN, BOLD) + msg)
def warn(msg: str) -> None: print(c(" ", YELLOW, BOLD) + msg)
def err(msg: str) -> None: print(c("", RED, BOLD) + msg)
def info(msg: str) -> None: print(f" {msg}")
def ok(msg: str) -> None:
print(c(" ", GREEN, BOLD) + msg)
def warn(msg: str) -> None:
print(c("", YELLOW, BOLD) + msg)
def err(msg: str) -> None:
print(c("", RED, BOLD) + msg)
def info(msg: str) -> None:
print(f" {msg}")
def kv(key: str, val: str) -> None:
print(f" {c(key + ':', BOLD)} {val}")
@@ -79,6 +91,7 @@ def kv(key: str, val: str) -> None:
# Dry-run tool stubs
# ---------------------------------------------------------------------------
def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
"""Call the real list_folder (read-only, safe in dry-run)."""
# TODO: remove hardcoded fallback once download path is configured in LTM
@@ -86,20 +99,29 @@ def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
try:
from alfred.infrastructure.persistence import get_memory, init_memory
try:
get_memory()
except Exception:
init_memory()
from alfred.agent.tools.filesystem import list_folder
result = list_folder(folder_type=folder_type, path=path)
if result.get("status") == "error" and folder_type == "download":
raise RuntimeError(result.get("message", "not configured"))
return result
except Exception as e:
if folder_type == "download":
warn(f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}")
warn(
f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}"
)
import os
resolved = os.path.join(_HARDCODED_DOWNLOAD_ROOT, path) if path != "." else _HARDCODED_DOWNLOAD_ROOT
resolved = (
os.path.join(_HARDCODED_DOWNLOAD_ROOT, path)
if path != "."
else _HARDCODED_DOWNLOAD_ROOT
)
try:
entries = sorted(os.listdir(resolved))
except OSError as oe:
@@ -125,11 +147,13 @@ def _real_find_media_imdb_id(media_title: str, **kwargs) -> dict[str, Any]:
"""Call the real TMDB API even in dry-run (read-only, no filesystem side effects)."""
try:
from alfred.infrastructure.persistence import get_memory, init_memory
try:
get_memory()
except Exception:
init_memory()
from alfred.agent.tools.api import find_media_imdb_id
return find_media_imdb_id(media_title=media_title)
except Exception as e:
warn(f"find_media_imdb_id: TMDB unavailable ({e}), falling back to stub")
@@ -151,6 +175,7 @@ def _dry_resolve_destination(
confirmed_folder: str | None = None,
) -> dict[str, Any]:
from alfred.domain.release import parse_release
parsed = parse_release(release_name)
ext = Path(source_file).suffix
if parsed.is_movie:
@@ -168,7 +193,11 @@ def _dry_resolve_destination(
}
season_folder = parsed.season_folder_name()
show_folder = confirmed_folder or parsed.show_folder_name(tmdb_title, tmdb_year)
fname = parsed.episode_filename(tmdb_episode_title, ext) if not parsed.is_season_pack else season_folder + ext
fname = (
parsed.episode_filename(tmdb_episode_title, ext)
if not parsed.is_season_pack
else season_folder + ext
)
return {
"status": "ok",
"library_file": f"/tv/{show_folder}/{season_folder}/{fname}",
@@ -201,7 +230,9 @@ def _dry_manage_subtitles(source_video: str, destination_video: str) -> dict[str
}
def _dry_create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]:
def _dry_create_seed_links(
library_file: str, original_download_folder: str
) -> dict[str, Any]:
return {
"status": "ok",
"torrent_subfolder": f"/torrents/{Path(original_download_folder).name}",
@@ -213,12 +244,12 @@ def _dry_create_seed_links(library_file: str, original_download_folder: str) ->
DRY_RUN_TOOLS: dict[str, Any] = {
"list_folder": _real_list_folder,
"list_folder": _real_list_folder,
"find_media_imdb_id": _real_find_media_imdb_id,
"resolve_destination": _dry_resolve_destination,
"move_media": _dry_move_media,
"manage_subtitles": _dry_manage_subtitles,
"create_seed_links": _dry_create_seed_links,
"move_media": _dry_move_media,
"manage_subtitles": _dry_manage_subtitles,
"create_seed_links": _dry_create_seed_links,
}
@@ -226,6 +257,7 @@ DRY_RUN_TOOLS: dict[str, Any] = {
# Live tools
# ---------------------------------------------------------------------------
def _load_live_tools() -> dict[str, Any]:
from alfred.agent.tools.filesystem import (
create_seed_links,
@@ -233,19 +265,25 @@ def _load_live_tools() -> dict[str, Any]:
manage_subtitles,
move_media,
)
# find_media_imdb_id lives in the api tools
try:
from alfred.agent.tools.api import find_media_imdb_id
except ImportError:
def find_media_imdb_id(**kwargs): # type: ignore[misc]
return {"status": "error", "error": "not_available", "message": "api tools not loaded"}
return {
"status": "error",
"error": "not_available",
"message": "api tools not loaded",
}
return {
"list_folder": list_folder,
"list_folder": list_folder,
"find_media_imdb_id": find_media_imdb_id,
"move_media": move_media,
"manage_subtitles": manage_subtitles,
"create_seed_links": create_seed_links,
"move_media": move_media,
"manage_subtitles": manage_subtitles,
"create_seed_links": create_seed_links,
}
@@ -253,13 +291,20 @@ def _load_live_tools() -> dict[str, Any]:
# Workflow runner
# ---------------------------------------------------------------------------
class WorkflowRunner:
def __init__(self, workflow: dict, tools: dict[str, Any], live: bool, args: argparse.Namespace):
def __init__(
self,
workflow: dict,
tools: dict[str, Any],
live: bool,
args: argparse.Namespace,
):
self.workflow = workflow
self.tools = tools
self.live = live
self.args = args
self.context: dict[str, Any] = {} # step results accumulate here
self.context: dict[str, Any] = {} # step results accumulate here
self.step_results: list[dict] = []
def run(self) -> None:
@@ -281,11 +326,15 @@ class WorkflowRunner:
section("SIMULATION TERMINÉE")
ok(f"{len(self.step_results)} step(s) exécuté(s)")
errors = [r for r in self.step_results if r.get("result", {}).get("status") == "error"]
errors = [
r for r in self.step_results if r.get("result", {}).get("status") == "error"
]
if errors:
warn(f"{len(errors)} step(s) en erreur")
for r in errors:
err(f" {r['id']}: {r['result'].get('error')}{r['result'].get('message')}")
err(
f" {r['id']}: {r['result'].get('error')}{r['result'].get('message')}"
)
print()
print(c("" * 70, BOLD))
print()
@@ -306,7 +355,7 @@ class WorkflowRunner:
answers_str = {str(k): v for k, v in answers.items()}
next_step = answers_str.get(answer, {}).get("next_step", "update_library")
ok(f"Réponse simulée: {c(answer, CYAN)} → next: {c(next_step, CYAN)}")
self.context["seeding"] = (answer == "yes")
self.context["seeding"] = answer == "yes"
self.context["ask_seeding_answer"] = answer
self.context["next_after_ask"] = next_step
@@ -332,7 +381,9 @@ class WorkflowRunner:
return
# Skip create_seed_links if user said no to seeding
if tool_name == "create_seed_links" and self.context.get("skip_create_seed_links"):
if tool_name == "create_seed_links" and self.context.get(
"skip_create_seed_links"
):
section(f"STEP [{step_id}] — {tool_name}")
warn("Skipped (user chose not to seed)")
return
@@ -349,14 +400,18 @@ class WorkflowRunner:
if tool_name not in self.tools:
err(f"Tool '{tool_name}' not found in tool registry")
self.step_results.append({"id": step_id, "result": {"status": "error", "error": "unknown_tool"}})
self.step_results.append(
{"id": step_id, "result": {"status": "error", "error": "unknown_tool"}}
)
return
try:
result = self.tools[tool_name](**kwargs)
except Exception as e:
err(f"Tool raised an exception: {e}")
self.step_results.append({"id": step_id, "result": {"status": "error", "error": str(e)}})
self.step_results.append(
{"id": step_id, "result": {"status": "error", "error": str(e)}}
)
return
self._print_result(result, tool_name=tool_name)
@@ -364,14 +419,20 @@ class WorkflowRunner:
self.step_results.append({"id": step_id, "result": result})
# After list_downloads: confirm the requested media folder exists in downloads
if tool_name == "list_folder" and result.get("status") == "ok" and self.args.source:
if (
tool_name == "list_folder"
and result.get("status") == "ok"
and self.args.source
):
folder_path = result.get("path", "")
entries = result.get("entries", [])
if self.args.source in entries:
media_folder = str(Path(folder_path) / self.args.source)
self.context["media_folder"] = media_folder
print()
print(f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}")
print(
f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}"
)
else:
warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}")
@@ -446,13 +507,17 @@ class WorkflowRunner:
elif status == "needs_clarification":
warn(f"status={c('needs_clarification', YELLOW)}")
else:
err(f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}")
err(
f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}"
)
return
# Highlight resolved folder path for list_folder
if tool_name == "list_folder" and result.get("path"):
print()
print(f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}")
print(
f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}"
)
# Pretty-print notable fields
skip = {"status", "error", "message"}
@@ -476,6 +541,7 @@ class WorkflowRunner:
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Alfred workflow simulator",
@@ -483,28 +549,58 @@ def parse_args() -> argparse.Namespace:
epilog=textwrap.dedent(__doc__ or ""),
)
parser.add_argument("workflow", help="Workflow name (e.g. organize_media)")
parser.add_argument("--dry-run", dest="dry_run", action="store_true", default=True,
help="Simulate steps without executing tools (default)")
parser.add_argument("--live", action="store_true",
help="Actually execute tools against the real filesystem")
parser.add_argument("--source", metavar="FOLDER_NAME",
help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)")
parser.add_argument("--dest", metavar="PATH",
help="Destination video file (in library, overrides resolve_destination)")
parser.add_argument("--download-folder", metavar="PATH",
help="Original download folder (for create_seed_links)")
parser.add_argument("--imdb-id", metavar="ID",
help="IMDb ID for identify_media (tt1234567)")
parser.add_argument("--release", metavar="NAME",
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)")
parser.add_argument("--tmdb-title", metavar="TITLE",
help="Canonical title from TMDB (e.g. 'Oz')")
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
help="Start/release year from TMDB (e.g. 1997)")
parser.add_argument("--episode-title", metavar="TITLE",
help="Episode title from TMDB for single-episode releases")
parser.add_argument("--seed", action="store_true",
help='Answer "yes" to the seeding question')
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
default=True,
help="Simulate steps without executing tools (default)",
)
parser.add_argument(
"--live",
action="store_true",
help="Actually execute tools against the real filesystem",
)
parser.add_argument(
"--source",
metavar="FOLDER_NAME",
help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)",
)
parser.add_argument(
"--dest",
metavar="PATH",
help="Destination video file (in library, overrides resolve_destination)",
)
parser.add_argument(
"--download-folder",
metavar="PATH",
help="Original download folder (for create_seed_links)",
)
parser.add_argument(
"--imdb-id", metavar="ID", help="IMDb ID for identify_media (tt1234567)"
)
parser.add_argument(
"--release",
metavar="NAME",
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)",
)
parser.add_argument(
"--tmdb-title", metavar="TITLE", help="Canonical title from TMDB (e.g. 'Oz')"
)
parser.add_argument(
"--tmdb-year",
metavar="YEAR",
type=int,
help="Start/release year from TMDB (e.g. 1997)",
)
parser.add_argument(
"--episode-title",
metavar="TITLE",
help="Episode title from TMDB for single-episode releases",
)
parser.add_argument(
"--seed", action="store_true", help='Answer "yes" to the seeding question'
)
parser.add_argument("--no-color", action="store_true")
return parser.parse_args()
@@ -521,6 +617,7 @@ def main() -> None:
# Load workflow
from alfred.agent.workflows.loader import WorkflowLoader
loader = WorkflowLoader()
workflow = loader.get(args.workflow)
if not workflow:
+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({
"status": "ok",
"folder_type": "download",
"path": ".",
"entries": ["movie.mkv", "show/"],
"count": 2,
})
uc = self._use_case(
{
"status": "ok",
"folder_type": "download",
"path": ".",
"entries": ["movie.mkv", "show/"],
"count": 2,
}
)
resp = uc.execute("download")
assert resp.status == "ok"
assert resp.folder_type == "download"
@@ -34,11 +34,13 @@ class TestListFolderUseCase:
assert resp.count == 2
def test_error_propagates(self):
uc = self._use_case({
"status": "error",
"error": "folder_not_set",
"message": "Download folder not configured.",
})
uc = self._use_case(
{
"status": "error",
"error": "folder_not_set",
"message": "Download folder not configured.",
}
)
resp = uc.execute("download")
assert resp.status == "error"
assert resp.error == "folder_not_set"
@@ -60,30 +62,37 @@ class TestListFolderUseCase:
def test_default_path_is_dot(self):
fm = MagicMock()
fm.list_folder.return_value = {
"status": "ok", "folder_type": "download",
"path": ".", "entries": [], "count": 0,
"status": "ok",
"folder_type": "download",
"path": ".",
"entries": [],
"count": 0,
}
uc = ListFolderUseCase(fm)
uc.execute("download")
fm.list_folder.assert_called_once_with("download", ".")
def test_success_response_has_no_error(self):
uc = self._use_case({
"status": "ok",
"folder_type": "movie",
"path": ".",
"entries": [],
"count": 0,
})
uc = self._use_case(
{
"status": "ok",
"folder_type": "movie",
"path": ".",
"entries": [],
"count": 0,
}
)
resp = uc.execute("movie")
assert resp.error is None
def test_error_response_has_no_entries(self):
uc = self._use_case({
"status": "error",
"error": "not_found",
"message": "Path does not exist",
})
uc = self._use_case(
{
"status": "error",
"error": "not_found",
"message": "Path does not exist",
}
)
resp = uc.execute("download", "some/path")
assert resp.entries is None
assert resp.count is None
@@ -93,8 +102,8 @@ class TestListFolderUseCase:
# MoveMediaUseCase
# ---------------------------------------------------------------------------
class TestMoveMediaUseCase:
class TestMoveMediaUseCase:
def _use_case(self, fm_result):
fm = MagicMock()
fm.move_file.return_value = fm_result
@@ -103,13 +112,15 @@ class TestMoveMediaUseCase:
def test_success_returns_response(self, tmp_path):
src = str(tmp_path / "src.mkv")
dst = str(tmp_path / "dst.mkv")
uc = self._use_case({
"status": "ok",
"source": src,
"destination": dst,
"filename": "dst.mkv",
"size": 1024,
})
uc = self._use_case(
{
"status": "ok",
"source": src,
"destination": dst,
"filename": "dst.mkv",
"size": 1024,
}
)
resp = uc.execute(src, dst)
assert resp.status == "ok"
assert resp.source == src
@@ -118,11 +129,13 @@ class TestMoveMediaUseCase:
assert resp.size == 1024
def test_error_propagates(self, tmp_path):
uc = self._use_case({
"status": "error",
"error": "source_not_found",
"message": "Source does not exist: /ghost.mkv",
})
uc = self._use_case(
{
"status": "error",
"error": "source_not_found",
"message": "Source does not exist: /ghost.mkv",
}
)
resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv"))
assert resp.status == "error"
assert resp.error == "source_not_found"
@@ -132,19 +145,24 @@ class TestMoveMediaUseCase:
dst = "/movies/Movie.2024/movie.mkv"
fm = MagicMock()
fm.move_file.return_value = {
"status": "ok", "source": src, "destination": dst,
"filename": "movie.mkv", "size": 1,
"status": "ok",
"source": src,
"destination": dst,
"filename": "movie.mkv",
"size": 1,
}
uc = MoveMediaUseCase(fm)
uc.execute(src, dst)
fm.move_file.assert_called_once_with(src, dst)
def test_error_response_has_no_paths(self):
uc = self._use_case({
"status": "error",
"error": "destination_exists",
"message": "File already exists",
})
uc = self._use_case(
{
"status": "error",
"error": "destination_exists",
"message": "File already exists",
}
)
resp = uc.execute("/src.mkv", "/dst.mkv")
assert resp.source is None
assert resp.destination is None
@@ -153,13 +171,15 @@ class TestMoveMediaUseCase:
def test_to_dict_success(self, tmp_path):
src = "/downloads/movie.mkv"
dst = "/movies/movie.mkv"
uc = self._use_case({
"status": "ok",
"source": src,
"destination": dst,
"filename": "movie.mkv",
"size": 2048,
})
uc = self._use_case(
{
"status": "ok",
"source": src,
"destination": dst,
"filename": "movie.mkv",
"size": 2048,
}
)
resp = uc.execute(src, dst)
d = resp.to_dict()
assert d["status"] == "ok"
@@ -167,11 +187,13 @@ class TestMoveMediaUseCase:
assert d["size"] == 2048
def test_to_dict_error(self):
uc = self._use_case({
"status": "error",
"error": "link_failed",
"message": "Cross-device link not permitted",
})
uc = self._use_case(
{
"status": "error",
"error": "link_failed",
"message": "Cross-device link not permitted",
}
)
resp = uc.execute("/src.mkv", "/dst.mkv")
d = resp.to_dict()
assert d["status"] == "error"
+19 -12
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) == []
@@ -284,7 +291,7 @@ class TestFindExistingSeriesFolders:
(tmp_path / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir()
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
assert len(result) == 2
assert sorted(result) == result # sorted
assert sorted(result) == result # sorted
def test_no_match_different_year(self, tmp_path):
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
+70 -27
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") \
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
assert (
_strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
)
def test_strips_multi_episode(self):
assert _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") \
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
assert (
_strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb")
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
)
def test_season_pack_unchanged(self):
assert _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") \
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
assert (
_strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST")
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
)
def test_case_insensitive(self):
assert _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") \
== "oz.s01.1080p-KONTRAST"
assert (
_strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST")
== "oz.s01.1080p-KONTRAST"
)
# ---------------------------------------------------------------------------
# parse_release — Season packs (dots)
# ---------------------------------------------------------------------------
class TestSeasonPackDots:
"""Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc."""
@@ -135,13 +155,17 @@ class TestSeasonPackDots:
assert p.group == "RARBG"
def test_gilmore_girls_s01_s07_repack(self):
p = parse_release("Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH")
p = parse_release(
"Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH"
)
# Season range — we parse the first season number found
assert p.season == 1
assert p.group == "MONOLITH"
def test_plot_against_america_4k(self):
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY")
p = parse_release(
"The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY"
)
assert p.title == "The.Plot.Against.America"
assert p.season == 1
assert p.quality == "2160p"
@@ -165,6 +189,7 @@ class TestSeasonPackDots:
# parse_release — Single episodes (dots)
# ---------------------------------------------------------------------------
class TestSingleEpisodeDots:
"""Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc."""
@@ -211,19 +236,23 @@ class TestSingleEpisodeDots:
# parse_release — Multi-episode
# ---------------------------------------------------------------------------
class TestMultiEpisode:
def test_archer_triple_episode(self):
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
p = parse_release("Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb")
p = parse_release(
"Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb"
)
assert p.season == 14
assert p.episode == 9
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
# ---------------------------------------------------------------------------
# parse_release — Movies
# ---------------------------------------------------------------------------
class TestMovies:
def test_another_round_yts(self):
# "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised
@@ -276,6 +305,7 @@ class TestMovies:
# parse_release — Space-separated (no dots)
# ---------------------------------------------------------------------------
class TestSpaceSeparated:
def test_oz_spaces(self):
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
@@ -285,7 +315,9 @@ class TestSpaceSeparated:
assert p.group == "KONTRAST"
def test_archer_spaces(self):
p = parse_release("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
p = parse_release(
"Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
)
assert p.season == 14
assert p.episode == 9
assert p.group == "NTb"
@@ -295,6 +327,7 @@ class TestSpaceSeparated:
# parse_release — tech_string
# ---------------------------------------------------------------------------
class TestTechString:
def test_full_tech(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
@@ -312,7 +345,9 @@ class TestTechString:
assert "Unknown" in folder
def test_4k_hdr(self):
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY")
p = parse_release(
"The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY"
)
assert p.quality == "2160p"
@@ -320,8 +355,8 @@ class TestTechString:
# ParsedRelease — naming methods
# ---------------------------------------------------------------------------
class TestNamingMethods:
class TestNamingMethods:
def test_show_folder_name(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
assert p.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
@@ -370,7 +405,10 @@ class TestNamingMethods:
def test_movie_folder_name(self):
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
assert p.movie_folder_name("Another Round", 2020) == "Another.Round.2020.1080p.BluRay.x264-YTS"
assert (
p.movie_folder_name("Another Round", 2020)
== "Another.Round.2020.1080p.BluRay.x264-YTS"
)
def test_movie_filename(self):
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
@@ -379,13 +417,16 @@ class TestNamingMethods:
def test_movie_folder_same_as_show_folder(self):
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name("Revolver", 2005)
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name(
"Revolver", 2005
)
# ---------------------------------------------------------------------------
# ParsedRelease — is_movie / is_season_pack
# ---------------------------------------------------------------------------
class TestMediaTypeFlags:
def test_season_pack_is_not_movie(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
@@ -412,11 +453,13 @@ class TestMediaTypeFlags:
# Tricky real-world releases
# ---------------------------------------------------------------------------
class TestRealWorldEdgeCases:
class TestRealWorldEdgeCases:
def test_angel_integrale_multi(self):
# "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
p = parse_release("Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod")
p = parse_release(
"Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
)
assert p.year == 1999
assert p.quality == "1080p"
assert p.source == "WEBRip"
+7 -7
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"
@@ -31,7 +31,7 @@ class TestImdbId:
def test_too_few_digits_raises(self):
with pytest.raises(ValidationError):
ImdbId("tt12345") # only 5 digits
ImdbId("tt12345") # only 5 digits
def test_too_many_digits_raises(self):
with pytest.raises(ValidationError):
@@ -58,8 +58,8 @@ class TestImdbId:
# FilePath
# ---------------------------------------------------------------------------
class TestFilePath:
class TestFilePath:
def test_from_string(self, tmp_path):
p = FilePath(str(tmp_path))
assert isinstance(p.value, Path)
@@ -98,8 +98,8 @@ class TestFilePath:
# FileSize
# ---------------------------------------------------------------------------
class TestFileSize:
class TestFileSize:
def test_bytes(self):
s = FileSize(500)
assert s.bytes == 500
@@ -128,7 +128,7 @@ class TestFileSize:
assert "MB" in result
def test_human_readable_gb(self):
result = FileSize(2 * 1024 ** 3).to_human_readable()
result = FileSize(2 * 1024**3).to_human_readable()
assert "GB" in result
def test_str_is_human_readable(self):
+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"
+21 -15
View File
@@ -16,7 +16,6 @@ from bootstrap import (
load_env_file,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -171,11 +170,11 @@ class TestBuildUris:
build_uris(alfred_file, secrets_file)
uri = load_env_file(secrets_file)["MONGO_URI"]
assert "alfred" in uri # user
assert "cafebabe" in uri # password from secrets
assert "mongodb" in uri # host
assert "27017" in uri # port
assert "mydb" in uri # dbname
assert "alfred" in uri # user
assert "cafebabe" in uri # password from secrets
assert "mongodb" in uri # host
assert "27017" in uri # port
assert "mydb" in uri # dbname
assert "authSource=admin" in uri
def test_postgres_uri_contains_all_components(self, alfred_file, secrets_file):
@@ -183,7 +182,7 @@ class TestBuildUris:
uri = load_env_file(secrets_file)["POSTGRES_URI"]
assert "alfred" in uri
assert "f00dface" in uri # password from secrets
assert "f00dface" in uri # password from secrets
assert "vectordb" in uri
assert "5432" in uri
assert uri.startswith("postgresql://")
@@ -191,7 +190,9 @@ class TestBuildUris:
def test_uri_is_updated_when_host_changes(self, tmp_path, secrets_file):
"""If MONGO_HOST changes in .env.alfred, the URI must reflect it."""
alfred = tmp_path / ".env.alfred"
alfred.write_text(ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost"))
alfred.write_text(
ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost")
)
build_uris(alfred, secrets_file)
uri = load_env_file(secrets_file)["MONGO_URI"]
@@ -217,7 +218,9 @@ class TestBuildUris:
uri_v1 = load_env_file(secrets_file)["MONGO_URI"]
alfred_v2 = tmp_path / "alfred_v2"
alfred_v2.write_text(ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb"))
alfred_v2.write_text(
ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb")
)
build_uris(alfred_v2, secrets_file)
uri_v2 = load_env_file(secrets_file)["MONGO_URI"]
@@ -265,12 +268,15 @@ class TestCopyExampleIfMissing:
class TestExtractPythonVersion:
@pytest.mark.parametrize("spec,expected_full,expected_short", [
("==3.14.3", "3.14.3", "3.14"),
("^3.12.0", "3.12.0", "3.12"),
("~3.11.1", "3.11.1", "3.11"),
("3.10.5", "3.10.5", "3.10"),
])
@pytest.mark.parametrize(
"spec,expected_full,expected_short",
[
("==3.14.3", "3.14.3", "3.14"),
("^3.12.0", "3.12.0", "3.12"),
("~3.11.1", "3.11.1", "3.11"),
("3.10.5", "3.10.5", "3.10"),
],
)
def test_parses_version_specifiers(self, spec, expected_full, expected_short):
full, short = extract_python_version(spec)
assert full == expected_full
+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()