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:
+6
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
|
||||
@@ -3,20 +3,25 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import alfred as _alfred_pkg
|
||||
import yaml
|
||||
|
||||
import alfred as _alfred_pkg
|
||||
from alfred.application.filesystem import (
|
||||
CreateSeedLinksUseCase,
|
||||
ListFolderUseCase,
|
||||
ManageSubtitlesUseCase,
|
||||
MoveMediaUseCase,
|
||||
ResolveDestinationUseCase,
|
||||
SetFolderPathUseCase,
|
||||
)
|
||||
from alfred.application.filesystem.detect_media_type import detect_media_type
|
||||
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
||||
from alfred.infrastructure.filesystem import FileManager
|
||||
from alfred.application.filesystem.resolve_destination import (
|
||||
resolve_episode_destination,
|
||||
resolve_movie_destination,
|
||||
resolve_season_destination,
|
||||
resolve_series_destination,
|
||||
)
|
||||
from alfred.infrastructure.filesystem import FileManager, create_folder, move
|
||||
from alfred.infrastructure.filesystem.ffprobe import probe
|
||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||
|
||||
@@ -42,7 +47,57 @@ def move_media(source: str, destination: str) -> dict[str, Any]:
|
||||
return use_case.execute(source, destination).to_dict()
|
||||
|
||||
|
||||
def resolve_destination(
|
||||
def move_to_destination(source: str, destination: str) -> dict[str, Any]:
|
||||
"""
|
||||
Move a file or folder to a destination, creating parent directories if needed.
|
||||
|
||||
Use this after resolve_*_destination to perform the actual move.
|
||||
The destination parent is created automatically if it doesn't exist.
|
||||
|
||||
Args:
|
||||
source: Absolute path to the source file or folder.
|
||||
destination: Absolute path to the destination.
|
||||
|
||||
Returns:
|
||||
Dict with status, source, destination — or error details.
|
||||
"""
|
||||
parent = str(Path(destination).parent)
|
||||
result = create_folder(parent)
|
||||
if result["status"] != "ok":
|
||||
return result
|
||||
return move(source, destination)
|
||||
|
||||
|
||||
def resolve_season_destination_tool(
|
||||
release_name: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
confirmed_folder: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute destination paths for a season pack (folder move).
|
||||
|
||||
Returns series_folder + season_folder. No file paths — the whole
|
||||
source folder is moved as-is into season_folder.
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder name (e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
|
||||
tmdb_title: Canonical show title from TMDB (e.g. "Oz").
|
||||
tmdb_year: Show start year from TMDB (e.g. 1997).
|
||||
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||
|
||||
Returns:
|
||||
On success: dict with status, series_folder, season_folder, series_folder_name,
|
||||
season_folder_name, is_new_series_folder.
|
||||
On ambiguity: dict with status="needs_clarification", question, options.
|
||||
On error: dict with status="error", error, message.
|
||||
"""
|
||||
return resolve_season_destination(
|
||||
release_name, tmdb_title, tmdb_year, confirmed_folder
|
||||
).to_dict()
|
||||
|
||||
|
||||
def resolve_episode_destination_tool(
|
||||
release_name: str,
|
||||
source_file: str,
|
||||
tmdb_title: str,
|
||||
@@ -51,44 +106,91 @@ def resolve_destination(
|
||||
confirmed_folder: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute the destination path in the media library for a release.
|
||||
Compute destination paths for a single episode file.
|
||||
|
||||
Call this before move_media to get the correct library path. Handles:
|
||||
- Parsing the release name (quality, codec, group, season/episode)
|
||||
- Looking up any existing series folder in the library
|
||||
- Applying group-conflict rules (asks user if ambiguous)
|
||||
- Building the full destination path with correct naming conventions
|
||||
Returns series_folder + season_folder + library_file (full path to destination .mkv).
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder or file name
|
||||
(e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
|
||||
release_name: Raw release file name (e.g. "Oz.S03E01.1080p.WEBRip.x265-KONTRAST.mkv").
|
||||
source_file: Absolute path to the source video file (used for extension).
|
||||
tmdb_title: Canonical show/movie title from TMDB (e.g. "Oz").
|
||||
tmdb_year: Release/start year from TMDB (e.g. 1997).
|
||||
tmdb_episode_title: Episode title from TMDB for single-episode releases
|
||||
(e.g. "The Routine"). Omit for season packs and movies.
|
||||
confirmed_folder: If a previous call returned needs_clarification, pass
|
||||
the user-chosen folder name here to proceed.
|
||||
tmdb_title: Canonical show title from TMDB (e.g. "Oz").
|
||||
tmdb_year: Show start year from TMDB (e.g. 1997).
|
||||
tmdb_episode_title: Episode title from TMDB (e.g. "The Routine"). Optional.
|
||||
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||
|
||||
Returns:
|
||||
On success: dict with status, library_file, series_folder, season_folder,
|
||||
series_folder_name, season_folder_name, filename,
|
||||
is_new_series_folder.
|
||||
On success: dict with status, series_folder, season_folder, library_file,
|
||||
series_folder_name, season_folder_name, filename, is_new_series_folder.
|
||||
On ambiguity: dict with status="needs_clarification", question, options.
|
||||
On error: dict with status="error", error, message.
|
||||
"""
|
||||
use_case = ResolveDestinationUseCase()
|
||||
return use_case.execute(
|
||||
release_name=release_name,
|
||||
source_file=source_file,
|
||||
tmdb_title=tmdb_title,
|
||||
tmdb_year=tmdb_year,
|
||||
tmdb_episode_title=tmdb_episode_title,
|
||||
confirmed_folder=confirmed_folder,
|
||||
return resolve_episode_destination(
|
||||
release_name,
|
||||
source_file,
|
||||
tmdb_title,
|
||||
tmdb_year,
|
||||
tmdb_episode_title,
|
||||
confirmed_folder,
|
||||
).to_dict()
|
||||
|
||||
|
||||
def create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]:
|
||||
def resolve_movie_destination_tool(
|
||||
release_name: str,
|
||||
source_file: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute destination paths for a movie file.
|
||||
|
||||
Returns movie_folder + library_file (full path to destination .mkv).
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder/file name (e.g. "Inception.2010.1080p.BluRay.x265-GROUP").
|
||||
source_file: Absolute path to the source video file (used for extension).
|
||||
tmdb_title: Canonical movie title from TMDB (e.g. "Inception").
|
||||
tmdb_year: Movie release year from TMDB (e.g. 2010).
|
||||
|
||||
Returns:
|
||||
On success: dict with status, movie_folder, library_file, movie_folder_name,
|
||||
filename, is_new_folder.
|
||||
On error: dict with status="error", error, message.
|
||||
"""
|
||||
return resolve_movie_destination(
|
||||
release_name, source_file, tmdb_title, tmdb_year
|
||||
).to_dict()
|
||||
|
||||
|
||||
def resolve_series_destination_tool(
|
||||
release_name: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
confirmed_folder: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute destination path for a complete multi-season series pack (folder move).
|
||||
|
||||
Returns only series_folder — the whole pack lands directly inside it.
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder name.
|
||||
tmdb_title: Canonical show title from TMDB.
|
||||
tmdb_year: Show start year from TMDB.
|
||||
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||
|
||||
Returns:
|
||||
On success: dict with status, series_folder, series_folder_name, is_new_series_folder.
|
||||
On ambiguity: dict with status="needs_clarification", question, options.
|
||||
On error: dict with status="error", error, message.
|
||||
"""
|
||||
return resolve_series_destination(
|
||||
release_name, tmdb_title, tmdb_year, confirmed_folder
|
||||
).to_dict()
|
||||
|
||||
|
||||
def create_seed_links(
|
||||
library_file: str, original_download_folder: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Prepare a torrent subfolder so qBittorrent can keep seeding after a move.
|
||||
|
||||
@@ -159,10 +261,18 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
|
||||
_VALID_CATEGORIES = {"languages", "types", "formats"}
|
||||
|
||||
if pack not in _VALID_PACKS:
|
||||
return {"status": "error", "error": "unknown_pack", "message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "unknown_pack",
|
||||
"message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}",
|
||||
}
|
||||
|
||||
if category not in _VALID_CATEGORIES:
|
||||
return {"status": "error", "error": "unknown_category", "message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "unknown_category",
|
||||
"message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}",
|
||||
}
|
||||
|
||||
learned_path = _LEARNED_ROOT / "subtitles_learned.yaml"
|
||||
_LEARNED_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
@@ -184,7 +294,9 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
|
||||
tmp = learned_path.with_suffix(".yaml.tmp")
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||
yaml.safe_dump(
|
||||
data, f, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||
)
|
||||
tmp.rename(learned_path)
|
||||
except Exception as e:
|
||||
tmp.unlink(missing_ok=True)
|
||||
@@ -293,11 +405,19 @@ def probe_media(source_path: str) -> dict[str, Any]:
|
||||
"""
|
||||
path = Path(source_path)
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "not_found", "message": f"{source_path} does not exist"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "not_found",
|
||||
"message": f"{source_path} does not exist",
|
||||
}
|
||||
|
||||
media_info = probe(path)
|
||||
if media_info is None:
|
||||
return {"status": "error", "error": "probe_failed", "message": "ffprobe failed to read the file"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "probe_failed",
|
||||
"message": "ffprobe failed to read the file",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -88,7 +88,11 @@ class PlacedSubtitle:
|
||||
filename: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"source": self.source, "destination": self.destination, "filename": self.filename}
|
||||
return {
|
||||
"source": self.source,
|
||||
"destination": self.destination,
|
||||
"filename": self.filename,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -50,7 +50,9 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
|
||||
parsed.quality = info.resolution
|
||||
|
||||
if parsed.codec is None and info.video_codec:
|
||||
parsed.codec = _VIDEO_CODEC_MAP.get(info.video_codec.lower(), info.video_codec.upper())
|
||||
parsed.codec = _VIDEO_CODEC_MAP.get(
|
||||
info.video_codec.lower(), info.video_codec.upper()
|
||||
)
|
||||
|
||||
if parsed.bit_depth is None and info.video_codec:
|
||||
# ffprobe exposes bit depth via pix_fmt — not in MediaInfo yet, skip for now
|
||||
@@ -62,10 +64,14 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
|
||||
|
||||
if track:
|
||||
if parsed.audio_codec is None and track.codec:
|
||||
parsed.audio_codec = _AUDIO_CODEC_MAP.get(track.codec.lower(), track.codec.upper())
|
||||
parsed.audio_codec = _AUDIO_CODEC_MAP.get(
|
||||
track.codec.lower(), track.codec.upper()
|
||||
)
|
||||
|
||||
if parsed.audio_channels is None and track.channels:
|
||||
parsed.audio_channels = _CHANNEL_MAP.get(track.channels, f"{track.channels}ch")
|
||||
parsed.audio_channels = _CHANNEL_MAP.get(
|
||||
track.channels, f"{track.channels}ch"
|
||||
)
|
||||
|
||||
# Languages — merge ffprobe languages with token-level ones
|
||||
# "und" = undetermined, not useful
|
||||
|
||||
@@ -17,7 +17,12 @@ from alfred.infrastructure.persistence.context import get_memory
|
||||
from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore
|
||||
from alfred.infrastructure.subtitle.rule_repository import RuleSetRepository
|
||||
|
||||
from .dto import AvailableSubtitle, ManageSubtitlesResponse, PlacedSubtitle, UnresolvedTrack
|
||||
from .dto import (
|
||||
AvailableSubtitle,
|
||||
ManageSubtitlesResponse,
|
||||
PlacedSubtitle,
|
||||
UnresolvedTrack,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,11 +74,12 @@ class ManageSubtitlesUseCase:
|
||||
season: int | None = None,
|
||||
episode: int | None = None,
|
||||
confirmed_pattern_id: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> ManageSubtitlesResponse:
|
||||
source_path = Path(source_video)
|
||||
dest_path = Path(destination_video)
|
||||
|
||||
if not source_path.exists():
|
||||
if not source_path.exists() and not source_path.parent.exists():
|
||||
return ManageSubtitlesResponse(
|
||||
status="error",
|
||||
error="source_not_found",
|
||||
@@ -108,7 +114,9 @@ class ManageSubtitlesUseCase:
|
||||
)
|
||||
|
||||
if metadata.total_count == 0:
|
||||
logger.info(f"ManageSubtitles: no subtitle tracks found for {source_path.name}")
|
||||
logger.info(
|
||||
f"ManageSubtitles: no subtitle tracks found for {source_path.name}"
|
||||
)
|
||||
return ManageSubtitlesResponse(
|
||||
status="ok",
|
||||
video_path=destination_video,
|
||||
@@ -164,6 +172,32 @@ class ManageSubtitlesUseCase:
|
||||
skipped_count=metadata.total_count,
|
||||
)
|
||||
|
||||
# --- Dry run: skip placement ---
|
||||
if dry_run:
|
||||
from alfred.domain.subtitles.services.placer import _build_dest_name
|
||||
|
||||
placed_dtos = []
|
||||
for t in matched:
|
||||
if not t.file_path:
|
||||
continue
|
||||
try:
|
||||
filename = _build_dest_name(t, dest_path.stem)
|
||||
except ValueError:
|
||||
continue
|
||||
placed_dtos.append(
|
||||
PlacedSubtitle(
|
||||
source=str(t.file_path),
|
||||
destination=str(dest_path.parent / filename),
|
||||
filename=filename,
|
||||
)
|
||||
)
|
||||
return ManageSubtitlesResponse(
|
||||
status="ok",
|
||||
video_path=destination_video,
|
||||
placed=placed_dtos,
|
||||
skipped_count=0,
|
||||
)
|
||||
|
||||
# --- Place ---
|
||||
placer = SubtitlePlacer()
|
||||
place_result = placer.place(matched, dest_path)
|
||||
@@ -229,7 +263,9 @@ class ManageSubtitlesUseCase:
|
||||
return kb.pattern("adjacent")
|
||||
|
||||
|
||||
def _to_unresolved_dto(track: SubtitleTrack, min_confidence: float = 0.7) -> UnresolvedTrack:
|
||||
def _to_unresolved_dto(
|
||||
track: SubtitleTrack, min_confidence: float = 0.7
|
||||
) -> UnresolvedTrack:
|
||||
reason = "unknown_language" if track.language is None else "low_confidence"
|
||||
return UnresolvedTrack(
|
||||
raw_tokens=track.raw_tokens,
|
||||
|
||||
@@ -1,58 +1,82 @@
|
||||
"""
|
||||
ResolveDestinationUseCase — compute the library destination path for a release.
|
||||
Destination resolution — compute library paths for releases.
|
||||
|
||||
Steps:
|
||||
1. Parse the release name
|
||||
2. Look up TMDB for title + year (+ episode title if single episode)
|
||||
3. Scan the library for an existing series folder
|
||||
4. Apply group-conflict rules
|
||||
5. Return the computed paths (or needs_clarification if ambiguous)
|
||||
Four distinct use cases, one per release type:
|
||||
- resolve_season_destination : season pack (folder move)
|
||||
- resolve_episode_destination : single episode (file move)
|
||||
- resolve_movie_destination : movie (file move)
|
||||
- resolve_series_destination : complete series multi-season pack (folder move)
|
||||
|
||||
Each returns a dedicated DTO with only the fields that make sense for that type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from alfred.domain.release import ParsedRelease, parse_release
|
||||
from alfred.domain.release import parse_release
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Characters forbidden on Windows filesystems (served via NFS)
|
||||
_WIN_FORBIDDEN = re.compile(r'[?:*"<>|\\]')
|
||||
|
||||
|
||||
def _sanitise(text: str) -> str:
|
||||
def _sanitize(text: str) -> str:
|
||||
return _WIN_FORBIDDEN.sub("", text)
|
||||
|
||||
|
||||
def _find_existing_tvshow_folders(
|
||||
tv_root: Path, tmdb_title: str, tmdb_year: int
|
||||
) -> list[str]:
|
||||
"""Return folder names in tv_root that match title + year prefix."""
|
||||
if not tv_root.exists():
|
||||
return []
|
||||
clean_title = _sanitize(tmdb_title).replace(" ", ".")
|
||||
prefix = f"{clean_title}.{tmdb_year}".lower()
|
||||
return sorted(
|
||||
entry.name
|
||||
for entry in tv_root.iterdir()
|
||||
if entry.is_dir() and entry.name.lower().startswith(prefix)
|
||||
)
|
||||
|
||||
|
||||
def _get_tv_root() -> Path | None:
|
||||
memory = get_memory()
|
||||
tv_root = memory.ltm.library_paths.get("tv_show")
|
||||
return Path(tv_root) if tv_root else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DTOs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedDestination:
|
||||
"""All computed paths for a release, ready to hand to move_media."""
|
||||
class ResolvedSeasonDestination:
|
||||
"""Paths for a season pack — folder move, no individual file paths."""
|
||||
|
||||
status: str # "ok" | "needs_clarification" | "error"
|
||||
|
||||
# Populated on "ok"
|
||||
library_file: str | None = None # absolute path of the destination video file
|
||||
series_folder: str | None = None # absolute path of the series root folder
|
||||
season_folder: str | None = None # absolute path of the season subfolder
|
||||
series_folder_name: str | None = None # just the folder name (for display)
|
||||
# ok
|
||||
series_folder: str | None = (
|
||||
None # /tv_shows/A.Knight.of.the.Seven.Kingdoms.2024.1080p.WEBRip.x265-KONTRAST
|
||||
)
|
||||
season_folder: str | None = (
|
||||
None # .../A.Knight.of.the.Seven.Kingdoms.S01.1080p.WEBRip.x265-KONTRAST
|
||||
)
|
||||
series_folder_name: str | None = None
|
||||
season_folder_name: str | None = None
|
||||
filename: str | None = None
|
||||
is_new_series_folder: bool = False # True if we're creating the folder
|
||||
is_new_series_folder: bool = False
|
||||
|
||||
# Populated on "needs_clarification"
|
||||
# needs_clarification
|
||||
question: str | None = None
|
||||
options: list[str] | None = None # existing group folder names to pick from
|
||||
options: list[str] | None = None
|
||||
|
||||
# Populated on "error"
|
||||
# error
|
||||
error: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
@@ -67,161 +91,267 @@ class ResolvedDestination:
|
||||
}
|
||||
return {
|
||||
"status": self.status,
|
||||
"library_file": self.library_file,
|
||||
"series_folder": self.series_folder,
|
||||
"season_folder": self.season_folder,
|
||||
"series_folder_name": self.series_folder_name,
|
||||
"season_folder_name": self.season_folder_name,
|
||||
"is_new_series_folder": self.is_new_series_folder,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedEpisodeDestination:
|
||||
"""Paths for a single episode — file move."""
|
||||
|
||||
status: str
|
||||
|
||||
# ok
|
||||
series_folder: str | None = None
|
||||
season_folder: str | None = None
|
||||
library_file: str | None = None # full path to destination .mkv
|
||||
series_folder_name: str | None = None
|
||||
season_folder_name: str | None = None
|
||||
filename: str | None = None
|
||||
is_new_series_folder: bool = False
|
||||
|
||||
# needs_clarification
|
||||
question: str | None = None
|
||||
options: list[str] | None = None
|
||||
|
||||
# error
|
||||
error: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
if self.status == "error":
|
||||
return {"status": self.status, "error": self.error, "message": self.message}
|
||||
if self.status == "needs_clarification":
|
||||
return {
|
||||
"status": self.status,
|
||||
"question": self.question,
|
||||
"options": self.options or [],
|
||||
}
|
||||
return {
|
||||
"status": self.status,
|
||||
"series_folder": self.series_folder,
|
||||
"season_folder": self.season_folder,
|
||||
"library_file": self.library_file,
|
||||
"series_folder_name": self.series_folder_name,
|
||||
"season_folder_name": self.season_folder_name,
|
||||
"filename": self.filename,
|
||||
"is_new_series_folder": self.is_new_series_folder,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedMovieDestination:
|
||||
"""Paths for a movie — file move."""
|
||||
|
||||
status: str
|
||||
|
||||
# ok
|
||||
movie_folder: str | None = None
|
||||
library_file: str | None = None
|
||||
movie_folder_name: str | None = None
|
||||
filename: str | None = None
|
||||
is_new_folder: bool = False
|
||||
|
||||
# needs_clarification
|
||||
question: str | None = None
|
||||
options: list[str] | None = None
|
||||
|
||||
# error
|
||||
error: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
if self.status == "error":
|
||||
return {"status": self.status, "error": self.error, "message": self.message}
|
||||
if self.status == "needs_clarification":
|
||||
return {
|
||||
"status": self.status,
|
||||
"question": self.question,
|
||||
"options": self.options or [],
|
||||
}
|
||||
return {
|
||||
"status": self.status,
|
||||
"movie_folder": self.movie_folder,
|
||||
"library_file": self.library_file,
|
||||
"movie_folder_name": self.movie_folder_name,
|
||||
"filename": self.filename,
|
||||
"is_new_folder": self.is_new_folder,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedSeriesDestination:
|
||||
"""Paths for a complete multi-season series pack — folder move."""
|
||||
|
||||
status: str
|
||||
|
||||
# ok
|
||||
series_folder: str | None = None
|
||||
series_folder_name: str | None = None
|
||||
is_new_series_folder: bool = False
|
||||
|
||||
# needs_clarification
|
||||
question: str | None = None
|
||||
options: list[str] | None = None
|
||||
|
||||
# error
|
||||
error: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
if self.status == "error":
|
||||
return {"status": self.status, "error": self.error, "message": self.message}
|
||||
if self.status == "needs_clarification":
|
||||
return {
|
||||
"status": self.status,
|
||||
"question": self.question,
|
||||
"options": self.options or [],
|
||||
}
|
||||
return {
|
||||
"status": self.status,
|
||||
"series_folder": self.series_folder,
|
||||
"series_folder_name": self.series_folder_name,
|
||||
"is_new_series_folder": self.is_new_series_folder,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Use case
|
||||
# Use cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ResolveDestinationUseCase:
|
||||
|
||||
def resolve_season_destination(
|
||||
release_name: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
confirmed_folder: str | None = None,
|
||||
) -> ResolvedSeasonDestination:
|
||||
"""
|
||||
Compute the full destination path for a media file being organised.
|
||||
Compute destination paths for a season pack.
|
||||
|
||||
The caller provides:
|
||||
- release_name: the raw release folder/file name
|
||||
- source_file: path to the actual video file (to get extension)
|
||||
- tmdb_title: canonical title from TMDB
|
||||
- tmdb_year: release year from TMDB
|
||||
- tmdb_episode_title: episode title from TMDB (None for movies / season packs)
|
||||
- confirmed_folder: if the user already answered needs_clarification, pass
|
||||
the chosen folder name here to skip the check
|
||||
|
||||
Returns a ResolvedDestination.
|
||||
Returns series_folder + season_folder. No file paths — the whole
|
||||
source folder is moved as-is into season_folder.
|
||||
"""
|
||||
tv_root = _get_tv_root()
|
||||
if not tv_root:
|
||||
return ResolvedSeasonDestination(
|
||||
status="error",
|
||||
error="library_not_set",
|
||||
message="TV show library path is not configured.",
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
parsed = parse_release(release_name)
|
||||
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||
|
||||
if confirmed_folder:
|
||||
series_folder_name = confirmed_folder
|
||||
is_new = not (tv_root / confirmed_folder).exists()
|
||||
else:
|
||||
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
|
||||
|
||||
if len(existing) == 0:
|
||||
series_folder_name = computed_name
|
||||
is_new = True
|
||||
elif len(existing) == 1 and existing[0] == computed_name:
|
||||
# Exact match — use it
|
||||
series_folder_name = existing[0]
|
||||
is_new = False
|
||||
else:
|
||||
# One folder with a different name, or multiple — ask user
|
||||
options = existing + (
|
||||
[computed_name] if computed_name not in existing else []
|
||||
)
|
||||
return ResolvedSeasonDestination(
|
||||
status="needs_clarification",
|
||||
question=(
|
||||
f"Un dossier série existe déjà pour '{tmdb_title}' "
|
||||
f"mais son nom diffère du nom calculé ({computed_name}). "
|
||||
f"Lequel utiliser ?"
|
||||
),
|
||||
options=options,
|
||||
)
|
||||
|
||||
season_folder_name = parsed.season_folder_name()
|
||||
series_path = tv_root / series_folder_name
|
||||
season_path = series_path / season_folder_name
|
||||
|
||||
return ResolvedSeasonDestination(
|
||||
status="ok",
|
||||
series_folder=str(series_path),
|
||||
season_folder=str(season_path),
|
||||
series_folder_name=series_folder_name,
|
||||
season_folder_name=season_folder_name,
|
||||
is_new_series_folder=is_new,
|
||||
)
|
||||
|
||||
|
||||
def resolve_episode_destination(
|
||||
release_name: str,
|
||||
source_file: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
tmdb_episode_title: str | None = None,
|
||||
confirmed_folder: str | None = None,
|
||||
) -> ResolvedDestination:
|
||||
parsed = parse_release(release_name)
|
||||
ext = Path(source_file).suffix # ".mkv"
|
||||
) -> ResolvedEpisodeDestination:
|
||||
"""
|
||||
Compute destination paths for a single episode file.
|
||||
|
||||
if parsed.media_type == "movie":
|
||||
return self._resolve_movie(parsed, tmdb_title, tmdb_year, ext)
|
||||
if parsed.media_type == "tv_show":
|
||||
return self._resolve_tvshow(
|
||||
parsed, tmdb_title, tmdb_year, tmdb_episode_title, ext, confirmed_folder
|
||||
)
|
||||
return ResolvedDestination(
|
||||
status="error",
|
||||
error="unsupported_media_type",
|
||||
message=(
|
||||
f"Cannot organize '{release_name}': detected as '{parsed.media_type}'. "
|
||||
"Only movies and TV shows are supported."
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Movie
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_movie(
|
||||
self, parsed: ParsedRelease, tmdb_title: str, tmdb_year: int, ext: str
|
||||
) -> ResolvedDestination:
|
||||
memory = get_memory()
|
||||
movies_root = memory.ltm.library_paths.get("movie")
|
||||
if not movies_root:
|
||||
return ResolvedDestination(
|
||||
status="error",
|
||||
error="library_not_set",
|
||||
message="Movie library path is not configured.",
|
||||
)
|
||||
|
||||
folder_name = _sanitise(parsed.movie_folder_name(tmdb_title, tmdb_year))
|
||||
filename = _sanitise(parsed.movie_filename(tmdb_title, tmdb_year, ext))
|
||||
|
||||
folder_path = Path(movies_root) / folder_name
|
||||
file_path = folder_path / filename
|
||||
|
||||
return ResolvedDestination(
|
||||
status="ok",
|
||||
library_file=str(file_path),
|
||||
series_folder=str(folder_path),
|
||||
series_folder_name=folder_name,
|
||||
filename=filename,
|
||||
is_new_series_folder=not folder_path.exists(),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TV show
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_tvshow(
|
||||
self,
|
||||
parsed: ParsedRelease,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
tmdb_episode_title: str | None,
|
||||
ext: str,
|
||||
confirmed_folder: str | None,
|
||||
) -> ResolvedDestination:
|
||||
memory = get_memory()
|
||||
tv_root = memory.ltm.library_paths.get("tv_show")
|
||||
Returns series_folder + season_folder + library_file (full path to .mkv).
|
||||
"""
|
||||
tv_root = _get_tv_root()
|
||||
if not tv_root:
|
||||
return ResolvedDestination(
|
||||
return ResolvedEpisodeDestination(
|
||||
status="error",
|
||||
error="library_not_set",
|
||||
message="TV show library path is not configured.",
|
||||
)
|
||||
|
||||
tv_root_path = Path(tv_root)
|
||||
parsed = parse_release(release_name)
|
||||
ext = Path(source_file).suffix
|
||||
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||
|
||||
# --- Find existing series folders for this title ---
|
||||
existing = _find_existing_series_folders(tv_root_path, tmdb_title, tmdb_year)
|
||||
|
||||
# --- Determine series folder name ---
|
||||
if confirmed_folder:
|
||||
series_folder_name = confirmed_folder
|
||||
is_new = not (tv_root_path / confirmed_folder).exists()
|
||||
elif len(existing) == 0:
|
||||
# No existing folder — create with release group
|
||||
series_folder_name = _sanitise(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||
is_new = not (tv_root / confirmed_folder).exists()
|
||||
else:
|
||||
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
|
||||
|
||||
if len(existing) == 0:
|
||||
series_folder_name = computed_name
|
||||
is_new = True
|
||||
elif len(existing) == 1:
|
||||
# Exactly one match — use it regardless of group
|
||||
elif len(existing) == 1 and existing[0] == computed_name:
|
||||
series_folder_name = existing[0]
|
||||
is_new = False
|
||||
else:
|
||||
# Multiple folders — ask user
|
||||
return ResolvedDestination(
|
||||
options = existing + (
|
||||
[computed_name] if computed_name not in existing else []
|
||||
)
|
||||
return ResolvedEpisodeDestination(
|
||||
status="needs_clarification",
|
||||
question=(
|
||||
f"Multiple folders found for '{tmdb_title}' in your library. "
|
||||
f"Which one should I use for this release ({parsed.group})?"
|
||||
f"Un dossier série existe déjà pour '{tmdb_title}' "
|
||||
f"mais son nom diffère du nom calculé ({computed_name}). "
|
||||
f"Lequel utiliser ?"
|
||||
),
|
||||
options=existing,
|
||||
options=options,
|
||||
)
|
||||
|
||||
# --- Build paths ---
|
||||
season_folder_name = parsed.season_folder_name()
|
||||
filename = _sanitise(
|
||||
parsed.episode_filename(tmdb_episode_title, ext)
|
||||
if not parsed.is_season_pack
|
||||
else parsed.season_folder_name() + ext
|
||||
)
|
||||
filename = _sanitize(parsed.episode_filename(tmdb_episode_title, ext))
|
||||
|
||||
series_path = tv_root_path / series_folder_name
|
||||
series_path = tv_root / series_folder_name
|
||||
season_path = series_path / season_folder_name
|
||||
file_path = season_path / filename
|
||||
|
||||
return ResolvedDestination(
|
||||
return ResolvedEpisodeDestination(
|
||||
status="ok",
|
||||
library_file=str(file_path),
|
||||
series_folder=str(series_path),
|
||||
season_folder=str(season_path),
|
||||
library_file=str(file_path),
|
||||
series_folder_name=series_folder_name,
|
||||
season_folder_name=season_folder_name,
|
||||
filename=filename,
|
||||
@@ -229,27 +359,98 @@ class ResolveDestinationUseCase:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_existing_series_folders(tv_root: Path, tmdb_title: str, tmdb_year: int) -> list[str]:
|
||||
def resolve_movie_destination(
|
||||
release_name: str,
|
||||
source_file: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
) -> ResolvedMovieDestination:
|
||||
"""
|
||||
Return names of folders in tv_root that match the given title + year.
|
||||
Compute destination paths for a movie file.
|
||||
|
||||
Matching is loose: normalised title (dots, no special chars) + year must
|
||||
appear at the start of the folder name.
|
||||
Returns movie_folder + library_file (full path to .mkv).
|
||||
"""
|
||||
if not tv_root.exists():
|
||||
return []
|
||||
memory = get_memory()
|
||||
movies_root = memory.ltm.library_paths.get("movie")
|
||||
if not movies_root:
|
||||
return ResolvedMovieDestination(
|
||||
status="error",
|
||||
error="library_not_set",
|
||||
message="Movie library path is not configured.",
|
||||
)
|
||||
|
||||
# Build a normalised prefix to match against: "Oz.1997"
|
||||
clean_title = _sanitise(tmdb_title).replace(" ", ".")
|
||||
prefix = f"{clean_title}.{tmdb_year}".lower()
|
||||
parsed = parse_release(release_name)
|
||||
ext = Path(source_file).suffix
|
||||
|
||||
matches = []
|
||||
for entry in tv_root.iterdir():
|
||||
if entry.is_dir() and entry.name.lower().startswith(prefix):
|
||||
matches.append(entry.name)
|
||||
folder_name = _sanitize(parsed.movie_folder_name(tmdb_title, tmdb_year))
|
||||
filename = _sanitize(parsed.movie_filename(tmdb_title, tmdb_year, ext))
|
||||
|
||||
return sorted(matches)
|
||||
folder_path = Path(movies_root) / folder_name
|
||||
file_path = folder_path / filename
|
||||
|
||||
return ResolvedMovieDestination(
|
||||
status="ok",
|
||||
movie_folder=str(folder_path),
|
||||
library_file=str(file_path),
|
||||
movie_folder_name=folder_name,
|
||||
filename=filename,
|
||||
is_new_folder=not folder_path.exists(),
|
||||
)
|
||||
|
||||
|
||||
def resolve_series_destination(
|
||||
release_name: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
confirmed_folder: str | None = None,
|
||||
) -> ResolvedSeriesDestination:
|
||||
"""
|
||||
Compute destination path for a complete multi-season series pack.
|
||||
|
||||
Returns only series_folder — the whole pack lands directly inside it.
|
||||
"""
|
||||
tv_root = _get_tv_root()
|
||||
if not tv_root:
|
||||
return ResolvedSeriesDestination(
|
||||
status="error",
|
||||
error="library_not_set",
|
||||
message="TV show library path is not configured.",
|
||||
)
|
||||
|
||||
parsed = parse_release(release_name)
|
||||
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||
|
||||
if confirmed_folder:
|
||||
series_folder_name = confirmed_folder
|
||||
is_new = not (tv_root / confirmed_folder).exists()
|
||||
else:
|
||||
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
|
||||
|
||||
if len(existing) == 0:
|
||||
series_folder_name = computed_name
|
||||
is_new = True
|
||||
elif len(existing) == 1 and existing[0] == computed_name:
|
||||
series_folder_name = existing[0]
|
||||
is_new = False
|
||||
else:
|
||||
options = existing + (
|
||||
[computed_name] if computed_name not in existing else []
|
||||
)
|
||||
return ResolvedSeriesDestination(
|
||||
status="needs_clarification",
|
||||
question=(
|
||||
f"Un dossier série existe déjà pour '{tmdb_title}' "
|
||||
f"mais son nom diffère du nom calculé ({computed_name}). "
|
||||
f"Lequel utiliser ?"
|
||||
),
|
||||
options=options,
|
||||
)
|
||||
|
||||
series_path = tv_root / series_folder_name
|
||||
|
||||
return ResolvedSeriesDestination(
|
||||
status="ok",
|
||||
series_folder=str(series_path),
|
||||
series_folder_name=series_folder_name,
|
||||
is_new_series_folder=is_new,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -10,12 +10,15 @@ Lists are extended additively, scalars from higher layers win.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import alfred as _alfred_pkg
|
||||
import yaml
|
||||
|
||||
import alfred as _alfred_pkg
|
||||
|
||||
_BUILTIN_ROOT = Path(_alfred_pkg.__file__).parent / "knowledge" / "release"
|
||||
_SITES_ROOT = _BUILTIN_ROOT / "sites"
|
||||
_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release"
|
||||
_LEARNED_ROOT = (
|
||||
Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release"
|
||||
)
|
||||
|
||||
|
||||
def _merge(base: dict, overlay: dict) -> dict:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .value_objects import (
|
||||
ParsedRelease,
|
||||
_AUDIO,
|
||||
_CODECS,
|
||||
_EDITIONS,
|
||||
@@ -13,9 +12,8 @@ from .value_objects import (
|
||||
_MEDIA_TYPE_TOKENS,
|
||||
_RESOLUTIONS,
|
||||
_SOURCES,
|
||||
_VIDEO_EXTENSIONS,
|
||||
_VIDEO_META,
|
||||
_NON_VIDEO_EXTENSIONS,
|
||||
ParsedRelease,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +65,9 @@ def parse_release(name: str) -> ParsedRelease:
|
||||
tech_tokens | lang_tokens | audio_tokens | video_tokens | edition_tokens,
|
||||
)
|
||||
year = _extract_year(tokens, title)
|
||||
media_type = _infer_media_type(season, quality, source, codec, year, edition, tokens)
|
||||
media_type = _infer_media_type(
|
||||
season, quality, source, codec, year, edition, tokens
|
||||
)
|
||||
|
||||
tech_parts = [p for p in [quality, source, codec] if p]
|
||||
tech_string = ".".join(tech_parts)
|
||||
@@ -126,7 +126,10 @@ def _infer_media_type(
|
||||
return "documentary"
|
||||
if upper_tokens & concert_tokens:
|
||||
return "concert"
|
||||
if (edition in {"COMPLETE", "INTEGRALE", "COLLECTION"} or upper_tokens & integrale_tokens) and season is None:
|
||||
if (
|
||||
edition in {"COMPLETE", "INTEGRALE", "COLLECTION"}
|
||||
or upper_tokens & integrale_tokens
|
||||
) and season is None:
|
||||
return "tv_complete"
|
||||
if season is not None:
|
||||
return "tv_show"
|
||||
@@ -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],
|
||||
@@ -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, …).
|
||||
|
||||
@@ -86,8 +86,12 @@ class ParsedRelease:
|
||||
codec: str | None # x265, HEVC, …
|
||||
group: str # release group, "UNKNOWN" if missing
|
||||
tech_string: str # quality.source.codec joined with dots
|
||||
media_type: str = "unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown"
|
||||
site_tag: str | None = None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
|
||||
media_type: str = (
|
||||
"unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown"
|
||||
)
|
||||
site_tag: str | None = (
|
||||
None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
|
||||
)
|
||||
parse_path: str = "direct" # "direct" | "sanitized" | "ai"
|
||||
languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], …
|
||||
audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", …
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -4,7 +4,11 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..shared.value_objects import ImdbId
|
||||
from .value_objects import SubtitleFormat, SubtitleLanguage, SubtitleMatchingRules, SubtitleType
|
||||
from .value_objects import (
|
||||
SubtitleFormat,
|
||||
SubtitleLanguage,
|
||||
SubtitleType,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -29,7 +33,9 @@ class SubtitleTrack:
|
||||
|
||||
# Matching state
|
||||
confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded
|
||||
raw_tokens: list[str] = field(default_factory=list) # tokens extracted from filename
|
||||
raw_tokens: list[str] = field(
|
||||
default_factory=list
|
||||
) # tokens extracted from filename
|
||||
|
||||
def is_resolved(self) -> bool:
|
||||
return self.language is not None
|
||||
@@ -43,7 +49,9 @@ class SubtitleTrack:
|
||||
{lang}.forced.{ext}
|
||||
"""
|
||||
if not self.language or not self.format:
|
||||
raise ValueError("Cannot compute destination_name: language or format missing")
|
||||
raise ValueError(
|
||||
"Cannot compute destination_name: language or format missing"
|
||||
)
|
||||
ext = self.format.extensions[0].lstrip(".")
|
||||
parts = [self.language.code]
|
||||
if self.subtitle_type == SubtitleType.SDH:
|
||||
@@ -55,7 +63,11 @@ class SubtitleTrack:
|
||||
def __repr__(self) -> str:
|
||||
lang = self.language.code if self.language else "?"
|
||||
fmt = self.format.id if self.format else "?"
|
||||
src = "embedded" if self.is_embedded else str(self.file_path.name if self.file_path else "?")
|
||||
src = (
|
||||
"embedded"
|
||||
if self.is_embedded
|
||||
else str(self.file_path.name if self.file_path else "?")
|
||||
)
|
||||
return f"SubtitleTrack({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})"
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""SubtitleKnowledgeBase — parsed, typed view of the loaded knowledge."""
|
||||
|
||||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from ..value_objects import (
|
||||
ScanStrategy,
|
||||
|
||||
@@ -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}'")
|
||||
|
||||
@@ -26,7 +26,7 @@ Output naming convention (matches SubtitlePreferences docstring):
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -120,6 +120,7 @@ def _classify(path: Path) -> tuple[str | None, bool, bool]:
|
||||
stem = path.stem.lower()
|
||||
# Split on dots, spaces, underscores, hyphens
|
||||
import re
|
||||
|
||||
tokens = re.split(r"[\.\s_\-]+", stem)
|
||||
|
||||
language: str | None = None
|
||||
@@ -147,7 +148,9 @@ class SubtitleScanner:
|
||||
# Each candidate has .source_path and .destination_name
|
||||
"""
|
||||
|
||||
def __init__(self, languages: list[str], min_size_kb: int, keep_sdh: bool, keep_forced: bool):
|
||||
def __init__(
|
||||
self, languages: list[str], min_size_kb: int, keep_sdh: bool, keep_forced: bool
|
||||
):
|
||||
self.languages = [l.lower() for l in languages]
|
||||
self.min_size_kb = min_size_kb
|
||||
self.keep_sdh = keep_sdh
|
||||
@@ -180,7 +183,9 @@ class SubtitleScanner:
|
||||
if candidate is not None:
|
||||
candidates.append(candidate)
|
||||
|
||||
logger.info(f"SubtitleScanner: {len(candidates)} candidate(s) found for {video_path.name}")
|
||||
logger.info(
|
||||
f"SubtitleScanner: {len(candidates)} candidate(s) found for {video_path.name}"
|
||||
)
|
||||
return candidates
|
||||
|
||||
def _evaluate(self, path: Path) -> SubtitleCandidate | None:
|
||||
@@ -188,7 +193,9 @@ class SubtitleScanner:
|
||||
# Size filter
|
||||
size_kb = path.stat().st_size / 1024
|
||||
if size_kb < self.min_size_kb:
|
||||
logger.debug(f"SubtitleScanner: skip {path.name} (too small: {size_kb:.1f} KB)")
|
||||
logger.debug(
|
||||
f"SubtitleScanner: skip {path.name} (too small: {size_kb:.1f} KB)"
|
||||
)
|
||||
return None
|
||||
|
||||
language, is_sdh, is_forced = _classify(path)
|
||||
@@ -199,7 +206,9 @@ class SubtitleScanner:
|
||||
return None
|
||||
|
||||
if language not in self.languages:
|
||||
logger.debug(f"SubtitleScanner: skip {path.name} (language '{language}' not in prefs)")
|
||||
logger.debug(
|
||||
f"SubtitleScanner: skip {path.name} (language '{language}' not in prefs)"
|
||||
)
|
||||
return None
|
||||
|
||||
# SDH filter
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""SubtitleIdentifier — finds and classifies all subtitle tracks for a video file."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ...shared.value_objects import ImdbId
|
||||
@@ -15,10 +15,28 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _tokenize(name: str) -> list[str]:
|
||||
"""Split a filename stem into lowercase tokens."""
|
||||
"""Split a filename stem into lowercase tokens, stripping parentheses."""
|
||||
# Strip parenthesized qualifiers like (simplified), (canada), (brazil)
|
||||
name = re.sub(r"\([^)]*\)", "", name)
|
||||
return [t.lower() for t in re.split(r"[\.\s_\-]+", name) if t]
|
||||
|
||||
|
||||
def _tokenize_suffix(stem: str, episode_stem: str) -> list[str]:
|
||||
"""
|
||||
For episode_subfolder pattern: the filename is {episode_stem}.{lang_tokens}.
|
||||
Return only the tokens that come after the episode stem portion.
|
||||
Falls back to full tokenization if the stem doesn't start with episode_stem.
|
||||
"""
|
||||
stem_lower = stem.lower()
|
||||
prefix = episode_stem.lower()
|
||||
if stem_lower.startswith(prefix):
|
||||
suffix = stem[len(prefix) :]
|
||||
tokens = _tokenize(suffix)
|
||||
if tokens:
|
||||
return tokens
|
||||
return _tokenize(stem)
|
||||
|
||||
|
||||
def _count_entries(path: Path) -> int:
|
||||
"""Return the entry count of an SRT file by finding the last cue number."""
|
||||
try:
|
||||
@@ -79,17 +97,29 @@ class SubtitleIdentifier:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_streams",
|
||||
"-select_streams", "s",
|
||||
"-select_streams",
|
||||
"s",
|
||||
str(video_path),
|
||||
],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
|
||||
logger.debug(f"SubtitleIdentifier: ffprobe failed for {video_path.name}: {e}")
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
json.JSONDecodeError,
|
||||
FileNotFoundError,
|
||||
) as e:
|
||||
logger.debug(
|
||||
f"SubtitleIdentifier: ffprobe failed for {video_path.name}: {e}"
|
||||
)
|
||||
return []
|
||||
|
||||
tracks = []
|
||||
@@ -108,39 +138,50 @@ class SubtitleIdentifier:
|
||||
else:
|
||||
stype = SubtitleType.STANDARD
|
||||
|
||||
tracks.append(SubtitleTrack(
|
||||
tracks.append(
|
||||
SubtitleTrack(
|
||||
language=lang,
|
||||
format=None,
|
||||
subtitle_type=stype,
|
||||
is_embedded=True,
|
||||
raw_tokens=[lang_code] if lang_code else [],
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}")
|
||||
logger.debug(
|
||||
f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}"
|
||||
)
|
||||
return tracks
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External tracks — filesystem scan per pattern strategy
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_external(self, video_path: Path, pattern: SubtitlePattern) -> list[SubtitleTrack]:
|
||||
def _scan_external(
|
||||
self, video_path: Path, pattern: SubtitlePattern
|
||||
) -> list[SubtitleTrack]:
|
||||
strategy = pattern.scan_strategy
|
||||
episode_stem: str | None = None
|
||||
|
||||
if strategy == ScanStrategy.ADJACENT:
|
||||
candidates = self._find_adjacent(video_path)
|
||||
elif strategy == ScanStrategy.FLAT:
|
||||
candidates = self._find_flat(video_path, pattern.root_folder or "Subs")
|
||||
elif strategy == ScanStrategy.EPISODE_SUBFOLDER:
|
||||
candidates = self._find_episode_subfolder(video_path, pattern.root_folder or "Subs")
|
||||
candidates, episode_stem = self._find_episode_subfolder(
|
||||
video_path, pattern.root_folder or "Subs"
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
return self._classify_files(candidates, pattern)
|
||||
return self._classify_files(candidates, pattern, episode_stem=episode_stem)
|
||||
|
||||
def _find_adjacent(self, video_path: Path) -> list[Path]:
|
||||
return [
|
||||
p for p in sorted(video_path.parent.iterdir())
|
||||
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
|
||||
p
|
||||
for p in sorted(video_path.parent.iterdir())
|
||||
if p.is_file()
|
||||
and p.suffix.lower() in self.kb.known_extensions()
|
||||
and p.stem != video_path.stem
|
||||
]
|
||||
|
||||
@@ -152,17 +193,22 @@ class SubtitleIdentifier:
|
||||
if not subs_dir.is_dir():
|
||||
return []
|
||||
return [
|
||||
p for p in sorted(subs_dir.iterdir())
|
||||
p
|
||||
for p in sorted(subs_dir.iterdir())
|
||||
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
|
||||
]
|
||||
|
||||
def _find_episode_subfolder(self, video_path: Path, root_folder: str) -> list[Path]:
|
||||
def _find_episode_subfolder(
|
||||
self, video_path: Path, root_folder: str
|
||||
) -> tuple[list[Path], str]:
|
||||
"""
|
||||
Look for Subs/{episode_stem}/*.srt
|
||||
|
||||
Checks two locations:
|
||||
1. Adjacent to the video: video_path.parent / root_folder / video_path.stem
|
||||
2. Release root (one level up): video_path.parent.parent / root_folder / video_path.stem
|
||||
|
||||
Returns (files, episode_stem) so the classifier can strip the prefix.
|
||||
"""
|
||||
episode_stem = video_path.stem
|
||||
candidates_dirs = [
|
||||
@@ -172,22 +218,30 @@ class SubtitleIdentifier:
|
||||
for subs_dir in candidates_dirs:
|
||||
if subs_dir.is_dir():
|
||||
files = [
|
||||
p for p in sorted(subs_dir.iterdir())
|
||||
p
|
||||
for p in sorted(subs_dir.iterdir())
|
||||
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
|
||||
]
|
||||
if files:
|
||||
logger.debug(f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}")
|
||||
return files
|
||||
return []
|
||||
logger.debug(
|
||||
f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}"
|
||||
)
|
||||
return files, episode_stem
|
||||
return [], episode_stem
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Classification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _classify_files(self, paths: list[Path], pattern: SubtitlePattern) -> list[SubtitleTrack]:
|
||||
def _classify_files(
|
||||
self,
|
||||
paths: list[Path],
|
||||
pattern: SubtitlePattern,
|
||||
episode_stem: str | None = None,
|
||||
) -> list[SubtitleTrack]:
|
||||
tracks = []
|
||||
for path in paths:
|
||||
track = self._classify_single(path)
|
||||
track = self._classify_single(path, episode_stem=episode_stem)
|
||||
tracks.append(track)
|
||||
|
||||
# Post-process: if multiple tracks share same language but type is ambiguous,
|
||||
@@ -197,9 +251,15 @@ class SubtitleIdentifier:
|
||||
|
||||
return tracks
|
||||
|
||||
def _classify_single(self, path: Path) -> SubtitleTrack:
|
||||
def _classify_single(
|
||||
self, path: Path, episode_stem: str | None = None
|
||||
) -> SubtitleTrack:
|
||||
fmt = self.kb.format_for_extension(path.suffix)
|
||||
tokens = _tokenize(path.stem)
|
||||
tokens = (
|
||||
_tokenize_suffix(path.stem, episode_stem)
|
||||
if episode_stem
|
||||
else _tokenize(path.stem)
|
||||
)
|
||||
|
||||
language = None
|
||||
subtitle_type = SubtitleType.UNKNOWN
|
||||
@@ -250,7 +310,6 @@ class SubtitleIdentifier:
|
||||
|
||||
Only applied when type_detection = size_and_count.
|
||||
"""
|
||||
from itertools import groupby
|
||||
|
||||
# Group by language code
|
||||
lang_groups: dict[str, list[SubtitleTrack]] = {}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
|
||||
from ..entities import SubtitleTrack
|
||||
from ..value_objects import SubtitleMatchingRules, SubtitleType
|
||||
from ..value_objects import SubtitleMatchingRules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,7 +50,9 @@ class SubtitleMatcher:
|
||||
)
|
||||
return matched, unresolved
|
||||
|
||||
def _passes_filters(self, track: SubtitleTrack, rules: SubtitleMatchingRules) -> bool:
|
||||
def _passes_filters(
|
||||
self, track: SubtitleTrack, rules: SubtitleMatchingRules
|
||||
) -> bool:
|
||||
# Language filter
|
||||
if rules.preferred_languages:
|
||||
if not track.language:
|
||||
|
||||
@@ -49,13 +49,19 @@ class PatternDetector:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_streams",
|
||||
"-select_streams", "s",
|
||||
"-select_streams",
|
||||
"s",
|
||||
str(video_path),
|
||||
],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
return len(data.get("streams", [])) > 0
|
||||
@@ -87,15 +93,22 @@ class PatternDetector:
|
||||
|
||||
# Is it flat or episode_subfolder?
|
||||
children = list(subs_candidate.iterdir())
|
||||
sub_files = [c for c in children if c.is_file() and c.suffix.lower() in known_exts]
|
||||
sub_files = [
|
||||
c
|
||||
for c in children
|
||||
if c.is_file() and c.suffix.lower() in known_exts
|
||||
]
|
||||
sub_dirs = [c for c in children if c.is_dir()]
|
||||
|
||||
if sub_dirs and not sub_files:
|
||||
findings["subs_strategy"] = "episode_subfolder"
|
||||
# Count files in a sample subfolder
|
||||
sample_sub = sub_dirs[0]
|
||||
sample_files = [f for f in sample_sub.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in known_exts]
|
||||
sample_files = [
|
||||
f
|
||||
for f in sample_sub.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in known_exts
|
||||
]
|
||||
findings["files_per_episode"] = len(sample_files)
|
||||
# Check naming conventions
|
||||
for f in sample_files:
|
||||
@@ -103,22 +116,27 @@ class PatternDetector:
|
||||
parts = stem.split("_")
|
||||
if parts[0].isdigit():
|
||||
findings["has_numeric_prefix"] = True
|
||||
if any(self.kb.is_known_lang_token(t.lower())
|
||||
for t in stem.replace("_", ".").split(".")):
|
||||
if any(
|
||||
self.kb.is_known_lang_token(t.lower())
|
||||
for t in stem.replace("_", ".").split(".")
|
||||
):
|
||||
findings["has_lang_tokens"] = True
|
||||
else:
|
||||
findings["subs_strategy"] = "flat"
|
||||
findings["files_per_episode"] = len(sub_files)
|
||||
for f in sub_files:
|
||||
if any(self.kb.is_known_lang_token(t.lower())
|
||||
for t in f.stem.replace("_", ".").split(".")):
|
||||
if any(
|
||||
self.kb.is_known_lang_token(t.lower())
|
||||
for t in f.stem.replace("_", ".").split(".")
|
||||
):
|
||||
findings["has_lang_tokens"] = True
|
||||
break
|
||||
|
||||
# Check adjacent subs (next to the video)
|
||||
if not findings["has_subs_folder"]:
|
||||
adjacent = [
|
||||
p for p in sample_video.parent.iterdir()
|
||||
p
|
||||
for p in sample_video.parent.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in known_exts
|
||||
]
|
||||
if adjacent:
|
||||
@@ -157,7 +175,9 @@ class PatternDetector:
|
||||
total += 1
|
||||
if findings.get("has_embedded"):
|
||||
score += 1.0
|
||||
if not findings.get("has_subs_folder") and not findings.get("adjacent_subs"):
|
||||
if not findings.get("has_subs_folder") and not findings.get(
|
||||
"adjacent_subs"
|
||||
):
|
||||
score += 0.5
|
||||
total += 0.5
|
||||
|
||||
|
||||
@@ -10,6 +10,28 @@ from ..entities import SubtitleTrack
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_dest_name(track: SubtitleTrack, video_stem: str) -> str:
|
||||
"""
|
||||
Build the destination filename for a subtitle track.
|
||||
|
||||
Format: {video_stem}.{lang}.{ext}
|
||||
{video_stem}.{lang}.sdh.{ext}
|
||||
{video_stem}.{lang}.forced.{ext}
|
||||
"""
|
||||
from ..value_objects import SubtitleType
|
||||
|
||||
if not track.language or not track.format:
|
||||
raise ValueError("Cannot compute destination name: language or format missing")
|
||||
|
||||
ext = track.format.extensions[0].lstrip(".")
|
||||
parts = [video_stem, track.language.code]
|
||||
if track.subtitle_type == SubtitleType.SDH:
|
||||
parts.append("sdh")
|
||||
elif track.subtitle_type == SubtitleType.FORCED:
|
||||
parts.append("forced")
|
||||
return ".".join(parts) + "." + ext
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlacedTrack:
|
||||
source: Path
|
||||
@@ -62,7 +84,7 @@ class SubtitlePlacer:
|
||||
continue
|
||||
|
||||
try:
|
||||
dest_name = track.destination_name
|
||||
dest_name = _build_dest_name(track, destination_video.stem)
|
||||
except ValueError as e:
|
||||
skipped.append((track, str(e)))
|
||||
continue
|
||||
@@ -76,11 +98,13 @@ class SubtitlePlacer:
|
||||
|
||||
try:
|
||||
os.link(track.file_path, dest_path)
|
||||
placed.append(PlacedTrack(
|
||||
placed.append(
|
||||
PlacedTrack(
|
||||
source=track.file_path,
|
||||
destination=dest_path,
|
||||
filename=dest_name,
|
||||
))
|
||||
)
|
||||
)
|
||||
logger.info(f"SubtitlePlacer: placed {dest_name}")
|
||||
except OSError as e:
|
||||
logger.warning(f"SubtitlePlacer: failed to place {dest_name}: {e}")
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ScanStrategy(Enum):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -13,8 +13,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_FFPROBE_CMD = [
|
||||
"ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
]
|
||||
@@ -77,22 +79,26 @@ def _parse(data: dict) -> MediaInfo:
|
||||
info.height = stream.get("height")
|
||||
|
||||
elif codec_type == "audio":
|
||||
info.audio_tracks.append(AudioTrack(
|
||||
info.audio_tracks.append(
|
||||
AudioTrack(
|
||||
index=stream.get("index", len(info.audio_tracks)),
|
||||
codec=stream.get("codec_name"),
|
||||
channels=stream.get("channels"),
|
||||
channel_layout=stream.get("channel_layout"),
|
||||
language=stream.get("tags", {}).get("language"),
|
||||
is_default=stream.get("disposition", {}).get("default", 0) == 1,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
elif codec_type == "subtitle":
|
||||
info.subtitle_tracks.append(SubtitleTrack(
|
||||
info.subtitle_tracks.append(
|
||||
SubtitleTrack(
|
||||
index=stream.get("index", len(info.subtitle_tracks)),
|
||||
codec=stream.get("codec_name"),
|
||||
language=stream.get("tags", {}).get("language"),
|
||||
is_default=stream.get("disposition", {}).get("default", 0) == 1,
|
||||
is_forced=stream.get("disposition", {}).get("forced", 0) == 1,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
return info
|
||||
|
||||
@@ -89,13 +89,18 @@ class FileManager:
|
||||
folder_path = memory.ltm.library_paths.get(folder_type)
|
||||
|
||||
if not folder_path:
|
||||
return _err("folder_not_set", f"{folder_type.capitalize()} folder not configured.")
|
||||
return _err(
|
||||
"folder_not_set",
|
||||
f"{folder_type.capitalize()} folder not configured.",
|
||||
)
|
||||
|
||||
root = Path(folder_path)
|
||||
target = root / safe_path
|
||||
|
||||
if not self._is_safe_path(root, target):
|
||||
return _err("forbidden", "Access denied: path outside allowed directory")
|
||||
return _err(
|
||||
"forbidden", "Access denied: path outside allowed directory"
|
||||
)
|
||||
|
||||
if not target.exists():
|
||||
return _err("not_found", f"Path does not exist: {safe_path}")
|
||||
@@ -153,10 +158,15 @@ class FileManager:
|
||||
return _err("source_not_file", f"Source is not a file: {source}")
|
||||
|
||||
if not dest_path.parent.exists():
|
||||
return _err("destination_dir_not_found", f"Destination directory does not exist: {dest_path.parent}")
|
||||
return _err(
|
||||
"destination_dir_not_found",
|
||||
f"Destination directory does not exist: {dest_path.parent}",
|
||||
)
|
||||
|
||||
if dest_path.exists():
|
||||
return _err("destination_exists", f"Destination already exists: {destination}")
|
||||
return _err(
|
||||
"destination_exists", f"Destination already exists: {destination}"
|
||||
)
|
||||
|
||||
os.link(source_path, dest_path)
|
||||
|
||||
@@ -197,7 +207,9 @@ class FileManager:
|
||||
|
||||
source_path.unlink()
|
||||
|
||||
logger.info(f"File moved: {source_path.name} -> {link_result['destination']}")
|
||||
logger.info(
|
||||
f"File moved: {source_path.name} -> {link_result['destination']}"
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"source": str(source_path),
|
||||
@@ -237,11 +249,19 @@ class FileManager:
|
||||
torrent_root = Path(torrent_folder).resolve()
|
||||
|
||||
if not lib_path.exists():
|
||||
return _err("library_file_not_found", f"Library file not found: {library_file}")
|
||||
return _err(
|
||||
"library_file_not_found", f"Library file not found: {library_file}"
|
||||
)
|
||||
if not src_folder.exists():
|
||||
return _err("source_folder_not_found", f"Download folder not found: {original_download_folder}")
|
||||
return _err(
|
||||
"source_folder_not_found",
|
||||
f"Download folder not found: {original_download_folder}",
|
||||
)
|
||||
if not torrent_root.exists():
|
||||
return _err("torrent_folder_not_found", f"Torrent folder not found: {torrent_folder}")
|
||||
return _err(
|
||||
"torrent_folder_not_found",
|
||||
f"Torrent folder not found: {torrent_folder}",
|
||||
)
|
||||
|
||||
dest_folder = torrent_root / src_folder.name
|
||||
dest_folder.mkdir(parents=True, exist_ok=True)
|
||||
@@ -266,6 +286,7 @@ class FileManager:
|
||||
skipped.append(str(rel))
|
||||
continue
|
||||
import shutil
|
||||
|
||||
shutil.copy2(item, dest_item)
|
||||
copied.append(str(rel))
|
||||
logger.debug(f"Copied for seeding: {rel}")
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Low-level filesystem operations — one responsibility per function."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _err(error: str, message: str) -> dict[str, Any]:
|
||||
return {"status": "error", "error": error, "message": message}
|
||||
|
||||
|
||||
def create_folder(path: str) -> dict[str, Any]:
|
||||
"""
|
||||
Create a directory and all missing parents.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the directory to create.
|
||||
|
||||
Returns:
|
||||
Dict with status and path, or error details.
|
||||
"""
|
||||
try:
|
||||
p = Path(path)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Folder ready: {p}")
|
||||
return {"status": "ok", "path": str(p)}
|
||||
except OSError as e:
|
||||
logger.error(f"create_folder failed: {e}")
|
||||
return _err("mkdir_failed", str(e))
|
||||
|
||||
|
||||
def move(source: str, destination: str) -> dict[str, Any]:
|
||||
"""
|
||||
Move a file or folder to a destination path.
|
||||
|
||||
Uses the system mv command — instant on the same filesystem (ZFS rename).
|
||||
|
||||
Args:
|
||||
source: Absolute path to the source file or folder.
|
||||
destination: Absolute path to the destination.
|
||||
|
||||
Returns:
|
||||
Dict with status, source, destination — or error details.
|
||||
"""
|
||||
src = Path(source)
|
||||
dst = Path(destination)
|
||||
|
||||
if not src.exists():
|
||||
return _err("source_not_found", f"Source does not exist: {source}")
|
||||
|
||||
if dst.exists():
|
||||
return _err("destination_exists", f"Destination already exists: {destination}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["mv", str(src), str(dst)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"mv failed: {result.stderr}")
|
||||
return _err("move_failed", result.stderr.strip())
|
||||
|
||||
logger.info(f"Moved: {src} -> {dst}")
|
||||
return {"status": "ok", "source": str(src), "destination": str(dst)}
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"move failed: {e}")
|
||||
return _err("move_failed", str(e))
|
||||
@@ -67,12 +67,20 @@ class Memory:
|
||||
"current_topic": self.stm.entities.topic,
|
||||
"extracted_entities": self.stm.entities.data,
|
||||
"last_search": {
|
||||
"query": self.episodic.search_results.last.get("query") if self.episodic.search_results.last else None,
|
||||
"result_count": len(self.episodic.search_results.last.get("results", [])) if self.episodic.search_results.last else 0,
|
||||
"query": self.episodic.search_results.last.get("query")
|
||||
if self.episodic.search_results.last
|
||||
else None,
|
||||
"result_count": len(
|
||||
self.episodic.search_results.last.get("results", [])
|
||||
)
|
||||
if self.episodic.search_results.last
|
||||
else 0,
|
||||
},
|
||||
"active_downloads_count": len(self.episodic.downloads.active),
|
||||
"pending_question": self.episodic.pending_question is not None,
|
||||
"unread_events": len([e for e in self.episodic.events.items if not e.get("read")]),
|
||||
"unread_events": len(
|
||||
[e for e in self.episodic.events.items if not e.get("read")]
|
||||
),
|
||||
}
|
||||
|
||||
def get_full_state(self) -> dict:
|
||||
|
||||
@@ -16,7 +16,9 @@ class Downloads:
|
||||
self.active.append(download)
|
||||
logger.info(f"Downloads: Added '{download.get('name')}'")
|
||||
|
||||
def update_progress(self, task_id: str, progress: int, status: str = "downloading") -> None:
|
||||
def update_progress(
|
||||
self, task_id: str, progress: int, status: str = "downloading"
|
||||
) -> None:
|
||||
for dl in self.active:
|
||||
if dl.get("task_id") == task_id:
|
||||
dl["progress"] = progress
|
||||
@@ -28,7 +30,13 @@ class Downloads:
|
||||
for i, dl in enumerate(self.active):
|
||||
if dl.get("task_id") == task_id:
|
||||
completed = self.active.pop(i)
|
||||
completed.update({"status": "completed", "file_path": file_path, "completed_at": datetime.now().isoformat()})
|
||||
completed.update(
|
||||
{
|
||||
"status": "completed",
|
||||
"file_path": file_path,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
logger.info(f"Downloads: Completed '{completed.get('name')}'")
|
||||
return completed
|
||||
return None
|
||||
|
||||
@@ -15,12 +15,14 @@ class Errors:
|
||||
max_errors: int = MAX_ERRORS
|
||||
|
||||
def add(self, action: str, error: str, context: dict | None = None) -> None:
|
||||
self.items.append({
|
||||
self.items.append(
|
||||
{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"action": action,
|
||||
"error": error,
|
||||
"context": context or {},
|
||||
})
|
||||
}
|
||||
)
|
||||
self.items = self.items[-self.max_errors :]
|
||||
logger.warning(f"Errors: '{action}': {error}")
|
||||
|
||||
|
||||
@@ -15,7 +15,14 @@ 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.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}'")
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ logger = logging.getLogger(__name__)
|
||||
class SearchResults:
|
||||
last: dict | None = None
|
||||
|
||||
def store(self, query: str, results: list[dict], search_type: str = "torrent") -> None:
|
||||
def store(
|
||||
self, query: str, results: list[dict], search_type: str = "torrent"
|
||||
) -> None:
|
||||
self.last = {
|
||||
"query": query,
|
||||
"type": search_type,
|
||||
|
||||
@@ -46,7 +46,9 @@ class EpisodicMemory:
|
||||
pending_question: dict | None = None
|
||||
|
||||
# Convenience methods forwarded to components
|
||||
def store_search_results(self, query: str, results: list[dict], search_type: str = "torrent") -> None:
|
||||
def store_search_results(
|
||||
self, query: str, results: list[dict], search_type: str = "torrent"
|
||||
) -> None:
|
||||
self.search_results.store(query, results, search_type)
|
||||
|
||||
def get_result_by_index(self, index: int) -> dict | None:
|
||||
@@ -61,13 +63,18 @@ class EpisodicMemory:
|
||||
def add_active_download(self, download: dict) -> None:
|
||||
self.downloads.add(download)
|
||||
|
||||
def update_download_progress(self, task_id: str, progress: int, status: str = "downloading") -> None:
|
||||
def update_download_progress(
|
||||
self, task_id: str, progress: int, status: str = "downloading"
|
||||
) -> None:
|
||||
self.downloads.update_progress(task_id, progress, status)
|
||||
|
||||
def complete_download(self, task_id: str, file_path: str) -> dict | None:
|
||||
completed = self.downloads.complete(task_id, file_path)
|
||||
if completed:
|
||||
self.events.add("download_complete", {"name": completed.get("name"), "file_path": file_path})
|
||||
self.events.add(
|
||||
"download_complete",
|
||||
{"name": completed.get("name"), "file_path": file_path},
|
||||
)
|
||||
return completed
|
||||
|
||||
def get_active_downloads(self) -> list[dict]:
|
||||
@@ -79,7 +86,13 @@ class EpisodicMemory:
|
||||
def get_recent_errors(self) -> list[dict]:
|
||||
return self.errors.items
|
||||
|
||||
def set_pending_question(self, question: str, options: list[dict], context: dict, question_type: str = "choice") -> None:
|
||||
def set_pending_question(
|
||||
self,
|
||||
question: str,
|
||||
options: list[dict],
|
||||
context: dict,
|
||||
question_type: str = "choice",
|
||||
) -> None:
|
||||
self.pending_question = {
|
||||
"type": question_type,
|
||||
"question": question,
|
||||
|
||||
@@ -39,5 +39,5 @@ class Following:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: list) -> "Following":
|
||||
def from_dict(cls, data: list) -> Following:
|
||||
return cls(shows=data)
|
||||
|
||||
@@ -57,7 +57,7 @@ class Library:
|
||||
return {"movies": self.movies, "tv_shows": self.tv_shows}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Library":
|
||||
def from_dict(cls, data: dict) -> Library:
|
||||
return cls(
|
||||
movies=data.get("movies", []),
|
||||
tv_shows=data.get("tv_shows", []),
|
||||
|
||||
@@ -50,7 +50,7 @@ class LibraryPaths:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "LibraryPaths":
|
||||
def from_dict(cls, data: dict) -> LibraryPaths:
|
||||
# Migrate from old flat format (tvshow_folder, movie_folder)
|
||||
folders = dict(data)
|
||||
if not folders:
|
||||
|
||||
@@ -40,7 +40,7 @@ class MediaPreferences:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "MediaPreferences":
|
||||
def from_dict(cls, data: dict) -> MediaPreferences:
|
||||
return cls(
|
||||
# migration: old key was preferred_quality / preferred_languages
|
||||
quality=data.get("quality") or data.get("preferred_quality", "1080p"),
|
||||
|
||||
@@ -62,7 +62,7 @@ class SubtitlePreferences:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SubtitlePreferences":
|
||||
def from_dict(cls, data: dict) -> SubtitlePreferences:
|
||||
# Migration: old fields (min_size_kb, keep_sdh, keep_forced, link_subs_folder) are silently dropped
|
||||
prefs = cls(
|
||||
languages=data.get("languages", ["fr", "en"]),
|
||||
|
||||
@@ -20,16 +20,22 @@ class WorkspacePaths:
|
||||
|
||||
download: str | None = None
|
||||
torrent: str | None = None
|
||||
trash: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
"""Return configured paths, skipping unset values."""
|
||||
return {k: v for k, v in {
|
||||
return {
|
||||
k: v
|
||||
for k, v in {
|
||||
"download": self.download,
|
||||
"torrent": self.torrent,
|
||||
}.items() if v is not None}
|
||||
"trash": self.trash,
|
||||
}.items()
|
||||
if v is not None
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"download": self.download, "torrent": self.torrent}
|
||||
return {"download": self.download, "torrent": self.torrent, "trash": self.trash}
|
||||
|
||||
@classmethod
|
||||
def describe(cls) -> dict:
|
||||
@@ -45,13 +51,15 @@ class WorkspacePaths:
|
||||
"fields": {
|
||||
"download": "Root folder where qBittorrent drops completed downloads.",
|
||||
"torrent": "Folder where .torrent files are stored.",
|
||||
"trash": "Trash folder — files moved here instead of deleted, for manual review.",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "WorkspacePaths":
|
||||
def from_dict(cls, data: dict) -> WorkspacePaths:
|
||||
# Migrate from old flat format (download_folder, torrent_folder)
|
||||
return cls(
|
||||
download=data.get("download") or data.get("download_folder"),
|
||||
torrent=data.get("torrent") or data.get("torrent_folder"),
|
||||
trash=data.get("trash"),
|
||||
)
|
||||
|
||||
@@ -31,7 +31,9 @@ class LongTermMemory:
|
||||
workspace: WorkspacePaths = field(default_factory=WorkspacePaths)
|
||||
library_paths: LibraryPaths = field(default_factory=LibraryPaths)
|
||||
media_preferences: MediaPreferences = field(default_factory=MediaPreferences)
|
||||
subtitle_preferences: SubtitlePreferences = field(default_factory=SubtitlePreferences)
|
||||
subtitle_preferences: SubtitlePreferences = field(
|
||||
default_factory=SubtitlePreferences
|
||||
)
|
||||
library: Library = field(default_factory=Library)
|
||||
following: Following = field(default_factory=Following)
|
||||
|
||||
@@ -46,7 +48,7 @@ class LongTermMemory:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "LongTermMemory":
|
||||
def from_dict(cls, data: dict) -> LongTermMemory:
|
||||
# Migration: old flat format had paths at the top level
|
||||
workspace_data = data.get("workspace") or data
|
||||
library_paths_data = data.get("library_paths") or data.get("paths") or data
|
||||
|
||||
@@ -38,7 +38,9 @@ def _load_components(package_name: str) -> list[dict]:
|
||||
try:
|
||||
descriptions.append(cls.describe())
|
||||
except Exception as e:
|
||||
logger.warning(f"MemoryRegistry: describe() failed on {cls.__name__}: {e}")
|
||||
logger.warning(
|
||||
f"MemoryRegistry: describe() failed on {cls.__name__}: {e}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MemoryRegistry: Could not load package {package_name}: {e}")
|
||||
|
||||
@@ -17,7 +17,9 @@ 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 :]
|
||||
logger.debug(f"Conversation: Added {role} message")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""SubtitleMetadataStore — reads/writes .alfred/metadata.yaml colocated with media."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -50,7 +50,13 @@ class SubtitleMetadataStore:
|
||||
tmp = self._metadata_path.with_suffix(".yaml.tmp")
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||
yaml.safe_dump(
|
||||
data,
|
||||
f,
|
||||
allow_unicode=True,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
)
|
||||
tmp.rename(self._metadata_path)
|
||||
except Exception as e:
|
||||
logger.error(f"MetadataStore: could not write {self._metadata_path}: {e}")
|
||||
@@ -68,7 +74,9 @@ class SubtitleMetadataStore:
|
||||
return data.get("detected_pattern")
|
||||
return None
|
||||
|
||||
def mark_pattern_confirmed(self, pattern_id: str, media_info: dict | None = None) -> None:
|
||||
def mark_pattern_confirmed(
|
||||
self, pattern_id: str, media_info: dict | None = None
|
||||
) -> None:
|
||||
"""Persist detected_pattern + pattern_confirmed=true."""
|
||||
data = self.load()
|
||||
data["detected_pattern"] = pattern_id
|
||||
@@ -78,7 +86,9 @@ class SubtitleMetadataStore:
|
||||
data.setdefault("imdb_id", media_info.get("imdb_id"))
|
||||
data.setdefault("title", media_info.get("title"))
|
||||
self.save(data)
|
||||
logger.info(f"MetadataStore: confirmed pattern '{pattern_id}' for {self._root.name}")
|
||||
logger.info(
|
||||
f"MetadataStore: confirmed pattern '{pattern_id}' for {self._root.name}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subtitle history
|
||||
@@ -101,10 +111,13 @@ class SubtitleMetadataStore:
|
||||
tracks_data: list[dict[str, Any]] = []
|
||||
for placed, track in placed_pairs:
|
||||
# Infer type from destination filename parts (e.g. en.sdh.srt → sdh)
|
||||
parts = placed.filename.rsplit(".", 2) # ["en", "sdh", "srt"] or ["en", "srt"]
|
||||
parts = placed.filename.rsplit(
|
||||
".", 2
|
||||
) # ["en", "sdh", "srt"] or ["en", "srt"]
|
||||
inferred_type = parts[1] if len(parts) == 3 else "standard"
|
||||
|
||||
tracks_data.append({
|
||||
tracks_data.append(
|
||||
{
|
||||
"language": track.language.code if track.language else "unknown",
|
||||
"type": inferred_type,
|
||||
"format": placed.destination.suffix.lstrip("."),
|
||||
@@ -112,10 +125,11 @@ class SubtitleMetadataStore:
|
||||
"source_file": placed.source.name,
|
||||
"placed_as": placed.filename,
|
||||
"confidence": round(track.confidence, 3),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"placed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"placed_at": datetime.now(UTC).isoformat(),
|
||||
"release_group": release_group,
|
||||
"tracks": tracks_data,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ from alfred.domain.subtitles.aggregates import SubtitleRuleSet
|
||||
from alfred.domain.subtitles.value_objects import RuleScope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import SubtitlePreferences
|
||||
from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import (
|
||||
SubtitlePreferences,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,7 +48,7 @@ class RuleSetRepository:
|
||||
def load(
|
||||
self,
|
||||
release_group: str | None = None,
|
||||
subtitle_preferences: "SubtitlePreferences | None" = None,
|
||||
subtitle_preferences: SubtitlePreferences | None = None,
|
||||
) -> SubtitleRuleSet:
|
||||
"""
|
||||
Build and return the resolved RuleSet chain.
|
||||
@@ -75,7 +77,9 @@ class RuleSetRepository:
|
||||
)
|
||||
rg_ruleset.override(**_filter_override(rg_data))
|
||||
current = rg_ruleset
|
||||
logger.debug(f"RuleSetRepository: loaded release_group override for '{release_group}'")
|
||||
logger.debug(
|
||||
f"RuleSetRepository: loaded release_group override for '{release_group}'"
|
||||
)
|
||||
|
||||
# Local (show/movie) level
|
||||
local_data = _load_yaml(self._alfred_dir / "rules.yaml").get("override", {})
|
||||
@@ -101,7 +105,13 @@ class RuleSetRepository:
|
||||
tmp = path.with_suffix(".yaml.tmp")
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||
yaml.safe_dump(
|
||||
data,
|
||||
f,
|
||||
allow_unicode=True,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
)
|
||||
tmp.rename(path)
|
||||
logger.info(f"RuleSetRepository: saved local rules to {path}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."
|
||||
@@ -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
@@ -20,7 +20,11 @@ class ConfigurationError(Exception):
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=[BASE_DIR / ".env.alfred", BASE_DIR / ".env.secrets", BASE_DIR / ".env.make"],
|
||||
env_file=[
|
||||
BASE_DIR / ".env.alfred",
|
||||
BASE_DIR / ".env.secrets",
|
||||
BASE_DIR / ".env.make",
|
||||
],
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
@@ -41,7 +45,16 @@ class Settings(BaseSettings):
|
||||
ollama_base_url: str = "http://ollama:11434"
|
||||
ollama_model: str = "llama3.3:latest"
|
||||
deepseek_base_url: str = "https://api.deepseek.com"
|
||||
deepseek_model: str = "deepseek-chat"
|
||||
deepseek_model: str = "deepseek-chat" # TODO: update => https://api-docs.deepseek.com/quick_start/pricing
|
||||
|
||||
# --- QBITTORRENT ---
|
||||
qbittorrent_url: str = "http://localhost:8080"
|
||||
qbittorrent_username: str = "admin"
|
||||
qbittorrent_password: str = "adminadmin"
|
||||
# Path translation: local path prefix → qBittorrent container path prefix
|
||||
# e.g. QBITTORRENT_HOST_PATH=/mnt/testipool QBITTORRENT_CONTAINER_PATH=/mnt/data
|
||||
qbittorrent_host_path: str | None = None
|
||||
qbittorrent_container_path: str | None = None
|
||||
|
||||
# --- API KEYS ---
|
||||
tmdb_api_key: str | None = None
|
||||
@@ -57,21 +70,27 @@ class Settings(BaseSettings):
|
||||
@classmethod
|
||||
def validate_temperature(cls, v: float) -> float:
|
||||
if not 0.0 <= v <= 2.0:
|
||||
raise ConfigurationError(f"Temperature must be between 0.0 and 2.0, got {v}")
|
||||
raise ConfigurationError(
|
||||
f"Temperature must be between 0.0 and 2.0, got {v}"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("max_tool_iterations")
|
||||
@classmethod
|
||||
def validate_max_iterations(cls, v: int) -> int:
|
||||
if not 1 <= v <= 20:
|
||||
raise ConfigurationError(f"max_tool_iterations must be between 1 and 20, got {v}")
|
||||
raise ConfigurationError(
|
||||
f"max_tool_iterations must be between 1 and 20, got {v}"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("request_timeout")
|
||||
@classmethod
|
||||
def validate_timeout(cls, v: int) -> int:
|
||||
if not 1 <= v <= 300:
|
||||
raise ConfigurationError(f"request_timeout must be between 1 and 300 seconds, got {v}")
|
||||
raise ConfigurationError(
|
||||
f"request_timeout must be between 1 and 300 seconds, got {v}"
|
||||
)
|
||||
return v
|
||||
|
||||
# --- HELPERS ---
|
||||
|
||||
+10
-4
@@ -4,12 +4,12 @@
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def load_secrets_spec(toml_data: dict) -> dict[str, tuple[int, str]]:
|
||||
"""Load secrets spec from pyproject.toml [tool.alfred.secrets]."""
|
||||
raw = toml_data.get("tool", {}).get("alfred", {}).get("secrets", {})
|
||||
@@ -58,11 +58,15 @@ def copy_example_if_missing(src: Path, dst: Path, label: str) -> None:
|
||||
def generate_secrets_file(path: Path, secrets_spec: dict[str, tuple[int, str]]) -> None:
|
||||
"""Generate .env.secrets with missing secrets, never overwrite existing ones."""
|
||||
existing = load_env_file(path)
|
||||
lines = list(path.read_text().splitlines()) if path.exists() else [
|
||||
lines = (
|
||||
list(path.read_text().splitlines())
|
||||
if path.exists()
|
||||
else [
|
||||
"# Auto-generated secrets — DO NOT COMMIT",
|
||||
"# Run 'make bootstrap' to generate missing secrets",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
added = []
|
||||
for key, (size, fmt) in secrets_spec.items():
|
||||
@@ -108,7 +112,9 @@ def build_uris(env_alfred: Path, env_secrets: Path) -> None:
|
||||
added = []
|
||||
for key, value in computed.items():
|
||||
if key in existing:
|
||||
content = re.sub(rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE)
|
||||
content = re.sub(
|
||||
rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE
|
||||
)
|
||||
else:
|
||||
content = content.rstrip("\n") + f"\n{key}={value}\n"
|
||||
added.append(key)
|
||||
|
||||
@@ -1,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."""
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""CLI de debug pour analyser une release et dry-run le déplacement."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Permet de lancer le script depuis n'importe où sans install du package
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def _init_memory():
|
||||
from alfred.infrastructure.persistence import init_memory
|
||||
from alfred.settings import settings
|
||||
|
||||
init_memory(settings.data_storage_dir)
|
||||
|
||||
|
||||
def _resolve_via_tmdb(release_name: str) -> tuple[str, int] | None:
|
||||
"""Parse le release name, interroge TMDB, retourne (tmdb_title, tmdb_year)."""
|
||||
from alfred.application.movies import SearchMovieUseCase
|
||||
from alfred.domain.release.services import parse_release
|
||||
from alfred.infrastructure.api.tmdb import tmdb_client
|
||||
|
||||
parsed = parse_release(release_name)
|
||||
raw_title = parsed.title.replace(".", " ").strip()
|
||||
|
||||
print(f" titre extrait : {raw_title}")
|
||||
print(" interrogation TMDB...")
|
||||
|
||||
use_case = SearchMovieUseCase(tmdb_client)
|
||||
result = use_case.execute(raw_title).to_dict()
|
||||
|
||||
if result.get("status") != "ok":
|
||||
print(f" TMDB error: {result.get('message')}")
|
||||
return None
|
||||
|
||||
title = result["title"]
|
||||
release_date = result.get("release_date", "")
|
||||
year = int(release_date[:4]) if release_date and len(release_date) >= 4 else None
|
||||
|
||||
if not year:
|
||||
print(f" TMDB: pas d'année trouvée pour '{title}'")
|
||||
return None
|
||||
|
||||
print(f" TMDB: {title} ({year})")
|
||||
return title, year
|
||||
|
||||
|
||||
def _extract_release_name(release_name: str) -> tuple[str, str]:
|
||||
"""
|
||||
Retourne (release_name, source_path).
|
||||
|
||||
Si c'est un path absolu existant → extrait le basename et utilise le path comme source.
|
||||
Sinon → cherche le dossier dans workspace.download configuré en LTM.
|
||||
"""
|
||||
p = Path(release_name)
|
||||
if p.is_absolute() and p.exists():
|
||||
return p.name, str(p)
|
||||
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
memory = get_memory()
|
||||
download_root = memory.ltm.workspace.download
|
||||
if download_root:
|
||||
candidate = Path(download_root) / release_name
|
||||
if candidate.exists():
|
||||
return release_name, str(candidate)
|
||||
|
||||
return release_name, ""
|
||||
|
||||
|
||||
def analyze(release_name: str, source_path: str | None = None) -> None:
|
||||
from alfred.domain.release.services import parse_release
|
||||
|
||||
release_name, resolved_path = _extract_release_name(release_name)
|
||||
if source_path is None and resolved_path:
|
||||
source_path = resolved_path
|
||||
|
||||
print(f"\n=== PARSE: {release_name} ===")
|
||||
r = parse_release(release_name)
|
||||
for k, v in vars(r).items():
|
||||
if v is not None and v != [] and v != "":
|
||||
print(f" {k}: {v}")
|
||||
|
||||
if source_path:
|
||||
path = Path(source_path)
|
||||
print(f"\n=== PROBE: {path} ===")
|
||||
if not path.exists():
|
||||
print(" (chemin inexistant, probe skipped)")
|
||||
else:
|
||||
from alfred.infrastructure.filesystem.ffprobe import probe
|
||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||
|
||||
video = find_video_file(path) if path.is_dir() else path
|
||||
if video:
|
||||
print(f" video file: {video.name}")
|
||||
info = probe(video)
|
||||
if info:
|
||||
print(f" codec: {info.video_codec}")
|
||||
print(f" resolution: {info.resolution}")
|
||||
print(
|
||||
f" audio_tracks: {[(t.codec, t.language) for t in info.audio_tracks]}"
|
||||
)
|
||||
print(
|
||||
f" subtitle_tracks: {[(t.codec, t.language) for t in info.subtitle_tracks]}"
|
||||
)
|
||||
else:
|
||||
print(" probe failed (ffprobe dispo ?)")
|
||||
else:
|
||||
print(" aucun fichier vidéo trouvé")
|
||||
|
||||
|
||||
def dry_run(release_name: str) -> None:
|
||||
_init_memory()
|
||||
release_name, _ = _extract_release_name(release_name)
|
||||
|
||||
print(f"\n=== DRY-RUN: {release_name} ===")
|
||||
tmdb = _resolve_via_tmdb(release_name)
|
||||
if not tmdb:
|
||||
sys.exit(1)
|
||||
|
||||
tmdb_title, tmdb_year = tmdb
|
||||
|
||||
from alfred.application.filesystem.resolve_destination import (
|
||||
resolve_season_destination,
|
||||
)
|
||||
|
||||
result = resolve_season_destination(release_name, tmdb_title, tmdb_year)
|
||||
d = result.to_dict()
|
||||
print()
|
||||
print(json.dumps(d, indent=2, ensure_ascii=False))
|
||||
|
||||
if d["status"] == "ok":
|
||||
print("\n=== MOVE PREVIEW ===")
|
||||
print(" src : <source_folder>")
|
||||
print(f" dst : {d['season_folder']}")
|
||||
|
||||
|
||||
def _translate_path(path: str) -> str:
|
||||
"""Translate a host-side path to the qBittorrent container path."""
|
||||
from alfred.settings import settings
|
||||
|
||||
host_prefix = settings.qbittorrent_host_path
|
||||
container_prefix = settings.qbittorrent_container_path
|
||||
if host_prefix and container_prefix and path.startswith(host_prefix):
|
||||
return container_prefix + path[len(host_prefix) :]
|
||||
return path
|
||||
|
||||
|
||||
def _qbittorrent_update(torrent_name: str, new_location: str | None) -> None:
|
||||
"""
|
||||
Find the torrent in qBittorrent by name, update its save_path, and force recheck.
|
||||
|
||||
Args:
|
||||
torrent_name: Exact torrent name (release folder basename)
|
||||
new_location: New save path on the host (parent of the torrent folder).
|
||||
None if the torrent was sent to trash — skip location change.
|
||||
"""
|
||||
try:
|
||||
from alfred.infrastructure.api.qbittorrent.client import QBittorrentClient
|
||||
|
||||
client = QBittorrentClient()
|
||||
client.login()
|
||||
|
||||
torrent = client.find_by_name(torrent_name)
|
||||
if torrent is None:
|
||||
print(f" ⚠ qBittorrent: torrent '{torrent_name}' not found — skipping")
|
||||
return
|
||||
|
||||
print(f" qBittorrent: found '{torrent.name}' (hash={torrent.hash[:8]}…)")
|
||||
|
||||
if new_location:
|
||||
container_location = _translate_path(new_location)
|
||||
client.set_location(torrent.hash, container_location)
|
||||
print(f" ✓ qBittorrent: location → {container_location}")
|
||||
|
||||
client.recheck(torrent.hash)
|
||||
print(" ✓ qBittorrent: recheck triggered")
|
||||
|
||||
except Exception as e:
|
||||
# Non-fatal — the files are already in place
|
||||
print(f" ⚠ qBittorrent update failed (non-fatal): {e}")
|
||||
|
||||
|
||||
def do_move(release_name: str, source_folder: str | None = None) -> None:
|
||||
_init_memory()
|
||||
release_name, resolved_path = _extract_release_name(release_name)
|
||||
if source_folder is None:
|
||||
source_folder = resolved_path
|
||||
if not source_folder:
|
||||
print(
|
||||
" Erreur: source introuvable. Configure workspace.download ou passe le path complet."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n=== MOVE: {release_name} ===")
|
||||
tmdb = _resolve_via_tmdb(release_name)
|
||||
if not tmdb:
|
||||
sys.exit(1)
|
||||
|
||||
tmdb_title, tmdb_year = tmdb
|
||||
|
||||
from alfred.application.filesystem.resolve_destination import (
|
||||
resolve_season_destination,
|
||||
)
|
||||
|
||||
result = resolve_season_destination(release_name, tmdb_title, tmdb_year)
|
||||
d = result.to_dict()
|
||||
|
||||
if d["status"] == "needs_clarification":
|
||||
print(f"\n {d['question']}")
|
||||
for i, opt in enumerate(d["options"]):
|
||||
print(f" {i + 1}. {opt}")
|
||||
choice = input(" Choix (numéro) : ").strip()
|
||||
try:
|
||||
chosen = d["options"][int(choice) - 1]
|
||||
except (ValueError, IndexError):
|
||||
print(" Choix invalide.")
|
||||
sys.exit(1)
|
||||
result = resolve_season_destination(
|
||||
release_name, tmdb_title, tmdb_year, confirmed_folder=chosen
|
||||
)
|
||||
d = result.to_dict()
|
||||
|
||||
if d["status"] != "ok":
|
||||
print(json.dumps(d, indent=2, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
src_path = Path(source_folder)
|
||||
season_folder = d["season_folder"]
|
||||
mkv_files = sorted(src_path.glob("*.mkv")) or sorted(src_path.glob("*.mp4"))
|
||||
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
memory = get_memory()
|
||||
torrent_root = memory.ltm.workspace.torrent
|
||||
trash_root = memory.ltm.workspace.trash
|
||||
torrent_dst = str(Path(torrent_root) / src_path.name) if torrent_root else None
|
||||
trash_dst = str(Path(trash_root) / src_path.name) if trash_root else None
|
||||
|
||||
rebuild = input(" Recréer le torrent ? [y/N] : ").strip().lower() == "y"
|
||||
|
||||
# --- PHASE 1: PLAN ---
|
||||
print("\n=== PLAN ===")
|
||||
print(f" destination : {season_folder}")
|
||||
|
||||
from alfred.application.filesystem.manage_subtitles import ManageSubtitlesUseCase
|
||||
from alfred.domain.release.services import parse_release
|
||||
|
||||
parsed = parse_release(release_name)
|
||||
|
||||
# Dict: video_path → sub_result (pre-scanned, files not yet moved)
|
||||
plan: list[tuple[Path, str, object]] = [] # (src_file, dst_path, sub_result)
|
||||
has_errors = False
|
||||
|
||||
for f in mkv_files:
|
||||
dst = str(Path(season_folder) / f.name)
|
||||
ghost_src = str(src_path / f.name)
|
||||
sub_result = ManageSubtitlesUseCase().execute(
|
||||
source_video=ghost_src,
|
||||
destination_video=dst,
|
||||
media_type="tv_show",
|
||||
release_group=parsed.group,
|
||||
season=parsed.season,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
print(f"\n {f.name}")
|
||||
print(f" → {dst}")
|
||||
|
||||
if sub_result.status == "ok":
|
||||
if sub_result.placed:
|
||||
for p in sub_result.placed:
|
||||
print(f" sub: {p.filename}")
|
||||
elif sub_result.available:
|
||||
for a in sub_result.available:
|
||||
print(f" sub (embedded): {a.language} {a.subtitle_type}")
|
||||
else:
|
||||
print(" subs: aucun")
|
||||
elif sub_result.status == "needs_clarification":
|
||||
print(" ✗ subs non résolus:")
|
||||
for u in sub_result.unresolved:
|
||||
print(f" {u.raw_tokens} ({u.reason})")
|
||||
has_errors = True
|
||||
elif sub_result.status == "error":
|
||||
print(f" ✗ erreur subs: {sub_result.message}")
|
||||
has_errors = True
|
||||
|
||||
plan.append((f, dst, sub_result))
|
||||
|
||||
if rebuild and torrent_dst:
|
||||
print(f"\n source → torrents : {torrent_dst}")
|
||||
print(f" hard-links : {len(mkv_files)} fichier(s)")
|
||||
elif trash_dst:
|
||||
print(f"\n source → trash : {trash_dst}")
|
||||
else:
|
||||
print("\n source : laissée en place")
|
||||
|
||||
if has_errors:
|
||||
print("\n ✗ Plan invalide — subs non résolus, abandon.")
|
||||
sys.exit(1)
|
||||
|
||||
# --- CONFIRMATION ---
|
||||
print()
|
||||
confirm = input(" Confirmer ? [y/N] : ").strip().lower()
|
||||
if confirm != "y":
|
||||
print(" Annulé.")
|
||||
sys.exit(0)
|
||||
|
||||
# --- PHASE 2: EXECUTE ---
|
||||
import os
|
||||
|
||||
from alfred.infrastructure.filesystem.filesystem_operations import (
|
||||
create_folder,
|
||||
move,
|
||||
)
|
||||
|
||||
print("\n=== EXECUTE ===")
|
||||
|
||||
# 1. Créer le season_folder
|
||||
r = create_folder(season_folder)
|
||||
if r["status"] != "ok":
|
||||
print(f" ✗ create_folder: {r}")
|
||||
sys.exit(1)
|
||||
print(f" ✓ dossier : {season_folder}")
|
||||
|
||||
# 2. Déplacer chaque fichier vidéo + placer les subs (re-run après move)
|
||||
for f, dst, _pre_scan in plan:
|
||||
r = move(str(f), dst)
|
||||
if r["status"] != "ok":
|
||||
print(f" ✗ {f.name}: {r['message']}")
|
||||
sys.exit(1)
|
||||
print(f" ✓ {f.name}")
|
||||
|
||||
# Re-run manage_subtitles maintenant que dst existe (pour le hard-link)
|
||||
ghost_src = str(src_path / f.name)
|
||||
sub_result = ManageSubtitlesUseCase().execute(
|
||||
source_video=ghost_src,
|
||||
destination_video=dst,
|
||||
media_type="tv_show",
|
||||
release_group=parsed.group,
|
||||
season=parsed.season,
|
||||
)
|
||||
if sub_result.status == "ok" and sub_result.placed:
|
||||
for p in sub_result.placed:
|
||||
print(f" ✓ sub: {p.filename}")
|
||||
|
||||
# 3. Dossier source → torrents ou trash
|
||||
if rebuild and torrent_dst:
|
||||
r = move(source_folder, torrent_dst)
|
||||
if r["status"] != "ok":
|
||||
print(f" ✗ source → torrents: {r['message']}")
|
||||
sys.exit(1)
|
||||
print(" ✓ source → torrents")
|
||||
|
||||
# 4. Hard-link depuis season_folder → torrent_dst
|
||||
torrent_dst_path = Path(torrent_dst)
|
||||
for f, dst, _ in plan:
|
||||
lib_file = Path(season_folder) / f.name
|
||||
link_dst = torrent_dst_path / f.name
|
||||
try:
|
||||
os.link(lib_file, link_dst)
|
||||
print(f" ✓ hard-link: {f.name}")
|
||||
except OSError as e:
|
||||
print(f" ✗ hard-link {f.name}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
elif trash_dst:
|
||||
r = move(source_folder, trash_dst)
|
||||
if r["status"] != "ok":
|
||||
print(f" ✗ source → trash: {r['message']}")
|
||||
sys.exit(1)
|
||||
print(" ✓ source → trash")
|
||||
|
||||
# 5. qBittorrent: update location + recheck
|
||||
qb_location = torrent_root if (rebuild and torrent_dst) else None
|
||||
_qbittorrent_update(src_path.name, qb_location)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Debug release parsing + dry-run/move")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
p_analyze = sub.add_parser(
|
||||
"analyze", help="Parser une release (+ probe si path fourni)"
|
||||
)
|
||||
p_analyze.add_argument("release_name")
|
||||
p_analyze.add_argument("--path", help="Chemin vers le dossier/fichier source")
|
||||
|
||||
p_dry = sub.add_parser(
|
||||
"dryrun", help="Résout via TMDB et affiche les chemins sans rien bouger"
|
||||
)
|
||||
p_dry.add_argument("release_name")
|
||||
|
||||
p_move = sub.add_parser(
|
||||
"move", help="Résout via TMDB et déplace le dossier (confirmation requise)"
|
||||
)
|
||||
p_move.add_argument("release_name")
|
||||
p_move.add_argument(
|
||||
"source_folder",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Chemin absolu du dossier source (optionnel si workspace.download est configuré)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "analyze":
|
||||
analyze(args.release_name, args.path)
|
||||
elif args.cmd == "dryrun":
|
||||
dry_run(args.release_name)
|
||||
elif args.cmd == "move":
|
||||
do_move(args.release_name, args.source_folder)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
+60
-19
@@ -51,6 +51,7 @@ def hr() -> None:
|
||||
# TMDB lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
||||
"""
|
||||
Call TMDBClient.search_media() and return (canonical_title, year).
|
||||
@@ -58,6 +59,7 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
||||
"""
|
||||
try:
|
||||
from alfred.infrastructure.api.tmdb import TMDBClient
|
||||
|
||||
client = TMDBClient()
|
||||
result = client.search_media(title)
|
||||
year: int | None = None
|
||||
@@ -66,7 +68,12 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
||||
year = int(result.release_date[:4])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
print(c(f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}", DIM))
|
||||
print(
|
||||
c(
|
||||
f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}",
|
||||
DIM,
|
||||
)
|
||||
)
|
||||
return result.title, year
|
||||
except Exception as e:
|
||||
print(c(f" TMDB lookup failed: {e}", YELLOW))
|
||||
@@ -77,8 +84,14 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
||||
# Display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
||||
tmdb_episode_title: str | None, ext: str) -> None:
|
||||
|
||||
def _show(
|
||||
release_name: str,
|
||||
tmdb_title: str | None,
|
||||
tmdb_year: int | None,
|
||||
tmdb_episode_title: str | None,
|
||||
ext: str,
|
||||
) -> None:
|
||||
from alfred.domain.release import parse_release
|
||||
|
||||
p = parse_release(release_name)
|
||||
@@ -102,7 +115,10 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
||||
kv("year", str(p.year) if p.year else c("None", DIM))
|
||||
kv("season", str(p.season) if p.season is not None else c("None", DIM))
|
||||
kv("episode", str(p.episode) if p.episode is not None else c("None", DIM))
|
||||
kv("episode_end", str(p.episode_end) if p.episode_end is not None else c("None", DIM))
|
||||
kv(
|
||||
"episode_end",
|
||||
str(p.episode_end) if p.episode_end is not None else c("None", DIM),
|
||||
)
|
||||
kv("quality", p.quality or c("None", DIM))
|
||||
kv("source", p.source or c("None", DIM))
|
||||
kv("codec", p.codec or c("None", DIM))
|
||||
@@ -133,9 +149,12 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
||||
if tmdb_title or tmdb_year or tmdb_episode_title:
|
||||
hr()
|
||||
print(c(" TMDB data used:", DIM))
|
||||
if tmdb_title: kv(" tmdb_title", tmdb_title)
|
||||
if tmdb_year: kv(" tmdb_year", str(tmdb_year))
|
||||
if tmdb_episode_title: kv(" tmdb_episode_title", tmdb_episode_title)
|
||||
if tmdb_title:
|
||||
kv(" tmdb_title", tmdb_title)
|
||||
if tmdb_year:
|
||||
kv(" tmdb_year", str(tmdb_year))
|
||||
if tmdb_episode_title:
|
||||
kv(" tmdb_episode_title", tmdb_episode_title)
|
||||
|
||||
print(c("━" * 64, BOLD))
|
||||
print()
|
||||
@@ -145,10 +164,16 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
||||
# Interactive mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _interactive() -> None:
|
||||
print(c("\n Alfred — Release Parser REPL", BOLD, CYAN))
|
||||
print(c(" Type a release name, or 'q' to quit.", DIM))
|
||||
print(c(" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n", DIM))
|
||||
print(
|
||||
c(
|
||||
" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n",
|
||||
DIM,
|
||||
)
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -186,6 +211,7 @@ def _interactive() -> None:
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global USE_COLOR
|
||||
|
||||
@@ -194,16 +220,29 @@ def main() -> None:
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("release", nargs="?", help="Release name to parse")
|
||||
parser.add_argument("-i", "--interactive", action="store_true",
|
||||
help="Interactive REPL mode")
|
||||
parser.add_argument("--tmdb-title", metavar="TITLE",
|
||||
help="Override TMDB title for name generation")
|
||||
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
|
||||
help="Override TMDB year for name generation")
|
||||
parser.add_argument("--episode-title", metavar="TITLE",
|
||||
help="TMDB episode title for episode_filename()")
|
||||
parser.add_argument("--ext", default=".mkv", metavar="EXT",
|
||||
help="File extension for filename generation (default: .mkv)")
|
||||
parser.add_argument(
|
||||
"-i", "--interactive", action="store_true", help="Interactive REPL mode"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tmdb-title", metavar="TITLE", help="Override TMDB title for name generation"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tmdb-year",
|
||||
metavar="YEAR",
|
||||
type=int,
|
||||
help="Override TMDB year for name generation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--episode-title",
|
||||
metavar="TITLE",
|
||||
help="TMDB episode title for episode_filename()",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ext",
|
||||
default=".mkv",
|
||||
metavar="EXT",
|
||||
help="File extension for filename generation (default: .mkv)",
|
||||
)
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -219,7 +258,9 @@ def main() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
_show(args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext)
|
||||
_show(
|
||||
args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext
|
||||
)
|
||||
except Exception as e:
|
||||
print(c(f"Error: {e}", RED), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -54,6 +54,7 @@ def hr() -> None:
|
||||
# Formatting helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def fmt_duration(seconds: float) -> str:
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
@@ -80,6 +81,7 @@ def flag(val: bool) -> str:
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global USE_COLOR
|
||||
|
||||
@@ -151,7 +153,9 @@ def main() -> None:
|
||||
hr()
|
||||
multi = c("yes", GREEN) if info.is_multi_audio else c("no", DIM)
|
||||
langs = ", ".join(info.audio_languages) if info.audio_languages else c("—", DIM)
|
||||
print(f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}")
|
||||
print(
|
||||
f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}"
|
||||
)
|
||||
hr()
|
||||
print()
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ def hr() -> None:
|
||||
# Parsing quality check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _assess(p) -> list[str]:
|
||||
"""Return a list of warning strings for fields that look wrong."""
|
||||
if p.media_type in ("other", "unknown"):
|
||||
@@ -70,16 +71,24 @@ def _assess(p) -> list[str]:
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global USE_COLOR
|
||||
|
||||
parser = argparse.ArgumentParser(description="Recognize release folders in downloads")
|
||||
parser.add_argument("--path", default="/mnt/testipool/downloads",
|
||||
help="Downloads directory (default: /mnt/testipool/downloads)")
|
||||
parser.add_argument("--failures-only", action="store_true",
|
||||
help="Show only entries with warnings")
|
||||
parser.add_argument("--successes-only", action="store_true",
|
||||
help="Show only fully parsed entries")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Recognize release folders in downloads"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
default="/mnt/testipool/downloads",
|
||||
help="Downloads directory (default: /mnt/testipool/downloads)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--failures-only", action="store_true", help="Show only entries with warnings"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--successes-only", action="store_true", help="Show only fully parsed entries"
|
||||
)
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -91,11 +100,11 @@ def main() -> None:
|
||||
print(c(f"Error: {downloads} does not exist", RED), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
from alfred.domain.release.services import parse_release
|
||||
from alfred.application.filesystem.detect_media_type import detect_media_type
|
||||
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||
from alfred.domain.release.services import parse_release
|
||||
from alfred.infrastructure.filesystem.ffprobe import probe
|
||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||
|
||||
entries = sorted(downloads.iterdir(), key=lambda p: p.name.lower())
|
||||
total = len(entries)
|
||||
@@ -178,8 +187,7 @@ def main() -> None:
|
||||
kv("hdr/depth", " ".join(hdr_parts))
|
||||
if p.edition:
|
||||
kv("edition", p.edition, color=YELLOW)
|
||||
kv("group", p.group,
|
||||
color=YELLOW if p.group == "UNKNOWN" else GREEN)
|
||||
kv("group", p.group, color=YELLOW if p.group == "UNKNOWN" else GREEN)
|
||||
if p.site_tag:
|
||||
kv("site tag", p.site_tag, color=YELLOW)
|
||||
|
||||
@@ -191,10 +199,12 @@ def main() -> None:
|
||||
print()
|
||||
hr()
|
||||
skipped = total - ok_count - warn_count
|
||||
print(f" {c('Total:', BOLD)} {total} "
|
||||
print(
|
||||
f" {c('Total:', BOLD)} {total} "
|
||||
f"{c(str(ok_count) + ' ok', GREEN, BOLD)} "
|
||||
f"{c(str(warn_count) + ' warnings', YELLOW, BOLD)}"
|
||||
+ (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else ""))
|
||||
+ (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else "")
|
||||
)
|
||||
hr()
|
||||
print()
|
||||
|
||||
|
||||
@@ -88,8 +88,7 @@ VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".mov", ".ts", ".m2ts"}
|
||||
|
||||
def find_videos(folder: Path) -> list[Path]:
|
||||
return sorted(
|
||||
p for p in folder.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
|
||||
p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS
|
||||
)
|
||||
|
||||
|
||||
@@ -109,7 +108,11 @@ def track_summary(track, verbose: bool = False) -> None:
|
||||
lang = track.language.code if track.language else c("?", RED)
|
||||
fmt = track.format.id if track.format else c("?", RED)
|
||||
typ = track.subtitle_type.value
|
||||
src = "embedded" if track.is_embedded else (track.file_path.name if track.file_path else "?")
|
||||
src = (
|
||||
"embedded"
|
||||
if track.is_embedded
|
||||
else (track.file_path.name if track.file_path else "?")
|
||||
)
|
||||
|
||||
# Couleur du type
|
||||
type_colors = {
|
||||
@@ -125,11 +128,19 @@ def track_summary(track, verbose: bool = False) -> None:
|
||||
|
||||
print(f" {c(src, BOLD)}")
|
||||
print(f" lang={c(lang, CYAN)} type={typ_str} format={fmt}")
|
||||
conf_str = c("n/a (embedded)", DIM) if track.is_embedded else confidence_bar(track.confidence)
|
||||
conf_str = (
|
||||
c("n/a (embedded)", DIM)
|
||||
if track.is_embedded
|
||||
else confidence_bar(track.confidence)
|
||||
)
|
||||
print(f" confidence={conf_str}{clarif}")
|
||||
|
||||
if track.entry_count is not None:
|
||||
print(f" entries={track.entry_count} size={track.file_size_kb:.1f} KB" if track.file_size_kb else f" entries={track.entry_count}")
|
||||
print(
|
||||
f" entries={track.entry_count} size={track.file_size_kb:.1f} KB"
|
||||
if track.file_size_kb
|
||||
else f" entries={track.entry_count}"
|
||||
)
|
||||
|
||||
if verbose and track.raw_tokens:
|
||||
print(f" tokens={track.raw_tokens}")
|
||||
@@ -146,7 +157,8 @@ def track_summary(track, verbose: bool = False) -> None:
|
||||
# Étapes du pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def step_load_kb() -> "SubtitleKnowledgeBase":
|
||||
|
||||
def step_load_kb() -> SubtitleKnowledgeBase:
|
||||
from alfred.domain.subtitles.knowledge.base import SubtitleKnowledgeBase
|
||||
from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader
|
||||
|
||||
@@ -168,12 +180,12 @@ def step_load_kb() -> "SubtitleKnowledgeBase":
|
||||
|
||||
|
||||
def step_detect_pattern(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
kb: SubtitleKnowledgeBase,
|
||||
season_folder: Path,
|
||||
sample_video: Path,
|
||||
release_group: str | None,
|
||||
forced_pattern: str | None,
|
||||
) -> "SubtitlePattern":
|
||||
) -> SubtitlePattern:
|
||||
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
|
||||
|
||||
section("ÉTAPE 2 — Détection du pattern de release")
|
||||
@@ -192,7 +204,9 @@ def step_detect_pattern(
|
||||
known = kb.patterns_for_group(release_group)
|
||||
if known:
|
||||
kv("Release group", release_group)
|
||||
ok(f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}")
|
||||
ok(
|
||||
f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}"
|
||||
)
|
||||
pattern = known[0]
|
||||
kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD))
|
||||
return pattern
|
||||
@@ -237,12 +251,12 @@ def step_detect_pattern(
|
||||
|
||||
|
||||
def step_identify_tracks(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
kb: SubtitleKnowledgeBase,
|
||||
sample_video: Path,
|
||||
pattern: "SubtitlePattern",
|
||||
pattern: SubtitlePattern,
|
||||
release_group: str | None,
|
||||
verbose: bool,
|
||||
) -> "MediaSubtitleMetadata":
|
||||
) -> MediaSubtitleMetadata:
|
||||
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
|
||||
|
||||
section("ÉTAPE 3 — Identification des pistes")
|
||||
@@ -286,9 +300,9 @@ def step_identify_tracks(
|
||||
|
||||
|
||||
def step_apply_rules(
|
||||
metadata: "MediaSubtitleMetadata",
|
||||
metadata: MediaSubtitleMetadata,
|
||||
release_group: str | None,
|
||||
) -> tuple["SubtitleMatchingRules | None", list, list]:
|
||||
) -> tuple[SubtitleMatchingRules | None, list, list]:
|
||||
from alfred.domain.subtitles.aggregates import DEFAULT_RULES
|
||||
from alfred.domain.subtitles.services.matcher import SubtitleMatcher
|
||||
from alfred.domain.subtitles.services.utils import available_subtitles
|
||||
@@ -308,7 +322,9 @@ def step_apply_rules(
|
||||
kv("Formats préférés", str(rules.preferred_formats))
|
||||
kv("Types autorisés", str(rules.allowed_types))
|
||||
kv("Confiance min", str(rules.min_confidence))
|
||||
info(c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4)
|
||||
info(
|
||||
c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4
|
||||
)
|
||||
|
||||
matcher = SubtitleMatcher()
|
||||
matched, unresolved = matcher.match(metadata.external_tracks, rules)
|
||||
@@ -330,7 +346,9 @@ def step_show_results(
|
||||
section("RÉSULTAT FINAL")
|
||||
|
||||
if matched:
|
||||
label = "piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
|
||||
label = (
|
||||
"piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
|
||||
)
|
||||
ok(f"{len(matched)} {label}:")
|
||||
for track in matched:
|
||||
lang = track.language.code if track.language else "?"
|
||||
@@ -352,7 +370,11 @@ def step_show_results(
|
||||
warn(f"{len(unresolved)} piste(s) écartées ou à clarifier:")
|
||||
for track in unresolved:
|
||||
src = track.file_path.name if track.file_path else "?"
|
||||
reason = "langue inconnue" if track.language is None else "confiance insuffisante"
|
||||
reason = (
|
||||
"langue inconnue"
|
||||
if track.language is None
|
||||
else "confiance insuffisante"
|
||||
)
|
||||
line = f" {c(src, DIM)} ({reason})"
|
||||
if verbose and track.raw_tokens:
|
||||
line += c(f" tokens: {track.raw_tokens}", YELLOW)
|
||||
@@ -365,9 +387,10 @@ def step_show_results(
|
||||
# Scan multi-épisodes (résumé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def scan_season(
|
||||
kb: "SubtitleKnowledgeBase",
|
||||
pattern: "SubtitlePattern",
|
||||
kb: SubtitleKnowledgeBase,
|
||||
pattern: SubtitlePattern,
|
||||
season_folder: Path,
|
||||
release_group: str | None,
|
||||
verbose: bool,
|
||||
@@ -408,15 +431,20 @@ def scan_season(
|
||||
pass
|
||||
|
||||
status_icon = c("✓", GREEN, BOLD) if placed_names else c("✗", RED, BOLD)
|
||||
warn_icon = c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
|
||||
warn_icon = (
|
||||
c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
|
||||
)
|
||||
|
||||
print(f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '—', GREEN if placed_names else DIM)}{warn_icon}")
|
||||
print(
|
||||
f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '—', GREEN if placed_names else DIM)}{warn_icon}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scanner de sous-titres Alfred — pipeline de diagnostic",
|
||||
@@ -424,18 +452,35 @@ def parse_args() -> argparse.Namespace:
|
||||
epilog=textwrap.dedent(__doc__ or ""),
|
||||
)
|
||||
parser.add_argument("season_folder", help="Dossier de la saison (ou du film)")
|
||||
parser.add_argument("--release-group", "-g", metavar="GROUP",
|
||||
help="Groupe de release (ex: RARBG, KONSTRAST)")
|
||||
parser.add_argument("--pattern", "-p", metavar="PATTERN",
|
||||
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)")
|
||||
parser.add_argument("--video", "-v", metavar="FILE",
|
||||
help="Fichier vidéo de référence (défaut: premier trouvé)")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Affiche les tokens bruts par piste")
|
||||
parser.add_argument("--no-color", action="store_true",
|
||||
help="Désactive la colorisation ANSI")
|
||||
parser.add_argument("--season-scan", action="store_true",
|
||||
help="Après le diagnostic, scanner tous les épisodes de la saison")
|
||||
parser.add_argument(
|
||||
"--release-group",
|
||||
"-g",
|
||||
metavar="GROUP",
|
||||
help="Groupe de release (ex: RARBG, KONSTRAST)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pattern",
|
||||
"-p",
|
||||
metavar="PATTERN",
|
||||
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--video",
|
||||
"-v",
|
||||
metavar="FILE",
|
||||
help="Fichier vidéo de référence (défaut: premier trouvé)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", action="store_true", help="Affiche les tokens bruts par piste"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-color", action="store_true", help="Désactive la colorisation ANSI"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--season-scan",
|
||||
action="store_true",
|
||||
help="Après le diagnostic, scanner tous les épisodes de la saison",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -474,7 +519,9 @@ def main() -> None:
|
||||
if videos:
|
||||
break
|
||||
if not videos:
|
||||
print("Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr)
|
||||
print(
|
||||
"Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr
|
||||
)
|
||||
sys.exit(1)
|
||||
sample_video = videos[0]
|
||||
|
||||
|
||||
@@ -67,10 +67,22 @@ def section(title: str) -> None:
|
||||
print(c("─" * 70, DIM))
|
||||
|
||||
|
||||
def ok(msg: str) -> None: print(c(" ✓ ", GREEN, BOLD) + msg)
|
||||
def warn(msg: str) -> None: print(c(" ⚠ ", YELLOW, BOLD) + msg)
|
||||
def err(msg: str) -> None: print(c(" ✗ ", RED, BOLD) + msg)
|
||||
def info(msg: str) -> None: print(f" {msg}")
|
||||
def ok(msg: str) -> None:
|
||||
print(c(" ✓ ", GREEN, BOLD) + msg)
|
||||
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
print(c(" ⚠ ", YELLOW, BOLD) + msg)
|
||||
|
||||
|
||||
def err(msg: str) -> None:
|
||||
print(c(" ✗ ", RED, BOLD) + msg)
|
||||
|
||||
|
||||
def info(msg: str) -> None:
|
||||
print(f" {msg}")
|
||||
|
||||
|
||||
def kv(key: str, val: str) -> None:
|
||||
print(f" {c(key + ':', BOLD)} {val}")
|
||||
|
||||
@@ -79,6 +91,7 @@ def kv(key: str, val: str) -> None:
|
||||
# Dry-run tool stubs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
||||
"""Call the real list_folder (read-only, safe in dry-run)."""
|
||||
# TODO: remove hardcoded fallback once download path is configured in LTM
|
||||
@@ -86,20 +99,29 @@ def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
from alfred.infrastructure.persistence import get_memory, init_memory
|
||||
|
||||
try:
|
||||
get_memory()
|
||||
except Exception:
|
||||
init_memory()
|
||||
from alfred.agent.tools.filesystem import list_folder
|
||||
|
||||
result = list_folder(folder_type=folder_type, path=path)
|
||||
if result.get("status") == "error" and folder_type == "download":
|
||||
raise RuntimeError(result.get("message", "not configured"))
|
||||
return result
|
||||
except Exception as e:
|
||||
if folder_type == "download":
|
||||
warn(f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}")
|
||||
warn(
|
||||
f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}"
|
||||
)
|
||||
import os
|
||||
resolved = os.path.join(_HARDCODED_DOWNLOAD_ROOT, path) if path != "." else _HARDCODED_DOWNLOAD_ROOT
|
||||
|
||||
resolved = (
|
||||
os.path.join(_HARDCODED_DOWNLOAD_ROOT, path)
|
||||
if path != "."
|
||||
else _HARDCODED_DOWNLOAD_ROOT
|
||||
)
|
||||
try:
|
||||
entries = sorted(os.listdir(resolved))
|
||||
except OSError as oe:
|
||||
@@ -125,11 +147,13 @@ def _real_find_media_imdb_id(media_title: str, **kwargs) -> dict[str, Any]:
|
||||
"""Call the real TMDB API even in dry-run (read-only, no filesystem side effects)."""
|
||||
try:
|
||||
from alfred.infrastructure.persistence import get_memory, init_memory
|
||||
|
||||
try:
|
||||
get_memory()
|
||||
except Exception:
|
||||
init_memory()
|
||||
from alfred.agent.tools.api import find_media_imdb_id
|
||||
|
||||
return find_media_imdb_id(media_title=media_title)
|
||||
except Exception as e:
|
||||
warn(f"find_media_imdb_id: TMDB unavailable ({e}), falling back to stub")
|
||||
@@ -151,6 +175,7 @@ def _dry_resolve_destination(
|
||||
confirmed_folder: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from alfred.domain.release import parse_release
|
||||
|
||||
parsed = parse_release(release_name)
|
||||
ext = Path(source_file).suffix
|
||||
if parsed.is_movie:
|
||||
@@ -168,7 +193,11 @@ def _dry_resolve_destination(
|
||||
}
|
||||
season_folder = parsed.season_folder_name()
|
||||
show_folder = confirmed_folder or parsed.show_folder_name(tmdb_title, tmdb_year)
|
||||
fname = parsed.episode_filename(tmdb_episode_title, ext) if not parsed.is_season_pack else season_folder + ext
|
||||
fname = (
|
||||
parsed.episode_filename(tmdb_episode_title, ext)
|
||||
if not parsed.is_season_pack
|
||||
else season_folder + ext
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"library_file": f"/tv/{show_folder}/{season_folder}/{fname}",
|
||||
@@ -201,7 +230,9 @@ def _dry_manage_subtitles(source_video: str, destination_video: str) -> dict[str
|
||||
}
|
||||
|
||||
|
||||
def _dry_create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]:
|
||||
def _dry_create_seed_links(
|
||||
library_file: str, original_download_folder: str
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"torrent_subfolder": f"/torrents/{Path(original_download_folder).name}",
|
||||
@@ -226,6 +257,7 @@ DRY_RUN_TOOLS: dict[str, Any] = {
|
||||
# Live tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_live_tools() -> dict[str, Any]:
|
||||
from alfred.agent.tools.filesystem import (
|
||||
create_seed_links,
|
||||
@@ -233,12 +265,18 @@ def _load_live_tools() -> dict[str, Any]:
|
||||
manage_subtitles,
|
||||
move_media,
|
||||
)
|
||||
|
||||
# find_media_imdb_id lives in the api tools
|
||||
try:
|
||||
from alfred.agent.tools.api import find_media_imdb_id
|
||||
except ImportError:
|
||||
|
||||
def find_media_imdb_id(**kwargs): # type: ignore[misc]
|
||||
return {"status": "error", "error": "not_available", "message": "api tools not loaded"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "not_available",
|
||||
"message": "api tools not loaded",
|
||||
}
|
||||
|
||||
return {
|
||||
"list_folder": list_folder,
|
||||
@@ -253,8 +291,15 @@ def _load_live_tools() -> dict[str, Any]:
|
||||
# Workflow runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WorkflowRunner:
|
||||
def __init__(self, workflow: dict, tools: dict[str, Any], live: bool, args: argparse.Namespace):
|
||||
def __init__(
|
||||
self,
|
||||
workflow: dict,
|
||||
tools: dict[str, Any],
|
||||
live: bool,
|
||||
args: argparse.Namespace,
|
||||
):
|
||||
self.workflow = workflow
|
||||
self.tools = tools
|
||||
self.live = live
|
||||
@@ -281,11 +326,15 @@ class WorkflowRunner:
|
||||
|
||||
section("SIMULATION TERMINÉE")
|
||||
ok(f"{len(self.step_results)} step(s) exécuté(s)")
|
||||
errors = [r for r in self.step_results if r.get("result", {}).get("status") == "error"]
|
||||
errors = [
|
||||
r for r in self.step_results if r.get("result", {}).get("status") == "error"
|
||||
]
|
||||
if errors:
|
||||
warn(f"{len(errors)} step(s) en erreur")
|
||||
for r in errors:
|
||||
err(f" {r['id']}: {r['result'].get('error')} — {r['result'].get('message')}")
|
||||
err(
|
||||
f" {r['id']}: {r['result'].get('error')} — {r['result'].get('message')}"
|
||||
)
|
||||
print()
|
||||
print(c("━" * 70, BOLD))
|
||||
print()
|
||||
@@ -306,7 +355,7 @@ class WorkflowRunner:
|
||||
answers_str = {str(k): v for k, v in answers.items()}
|
||||
next_step = answers_str.get(answer, {}).get("next_step", "update_library")
|
||||
ok(f"Réponse simulée: {c(answer, CYAN)} → next: {c(next_step, CYAN)}")
|
||||
self.context["seeding"] = (answer == "yes")
|
||||
self.context["seeding"] = answer == "yes"
|
||||
self.context["ask_seeding_answer"] = answer
|
||||
self.context["next_after_ask"] = next_step
|
||||
|
||||
@@ -332,7 +381,9 @@ class WorkflowRunner:
|
||||
return
|
||||
|
||||
# Skip create_seed_links if user said no to seeding
|
||||
if tool_name == "create_seed_links" and self.context.get("skip_create_seed_links"):
|
||||
if tool_name == "create_seed_links" and self.context.get(
|
||||
"skip_create_seed_links"
|
||||
):
|
||||
section(f"STEP [{step_id}] — {tool_name}")
|
||||
warn("Skipped (user chose not to seed)")
|
||||
return
|
||||
@@ -349,14 +400,18 @@ class WorkflowRunner:
|
||||
|
||||
if tool_name not in self.tools:
|
||||
err(f"Tool '{tool_name}' not found in tool registry")
|
||||
self.step_results.append({"id": step_id, "result": {"status": "error", "error": "unknown_tool"}})
|
||||
self.step_results.append(
|
||||
{"id": step_id, "result": {"status": "error", "error": "unknown_tool"}}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = self.tools[tool_name](**kwargs)
|
||||
except Exception as e:
|
||||
err(f"Tool raised an exception: {e}")
|
||||
self.step_results.append({"id": step_id, "result": {"status": "error", "error": str(e)}})
|
||||
self.step_results.append(
|
||||
{"id": step_id, "result": {"status": "error", "error": str(e)}}
|
||||
)
|
||||
return
|
||||
|
||||
self._print_result(result, tool_name=tool_name)
|
||||
@@ -364,14 +419,20 @@ class WorkflowRunner:
|
||||
self.step_results.append({"id": step_id, "result": result})
|
||||
|
||||
# After list_downloads: confirm the requested media folder exists in downloads
|
||||
if tool_name == "list_folder" and result.get("status") == "ok" and self.args.source:
|
||||
if (
|
||||
tool_name == "list_folder"
|
||||
and result.get("status") == "ok"
|
||||
and self.args.source
|
||||
):
|
||||
folder_path = result.get("path", "")
|
||||
entries = result.get("entries", [])
|
||||
if self.args.source in entries:
|
||||
media_folder = str(Path(folder_path) / self.args.source)
|
||||
self.context["media_folder"] = media_folder
|
||||
print()
|
||||
print(f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}")
|
||||
print(
|
||||
f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}"
|
||||
)
|
||||
else:
|
||||
warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}")
|
||||
|
||||
@@ -446,13 +507,17 @@ class WorkflowRunner:
|
||||
elif status == "needs_clarification":
|
||||
warn(f"status={c('needs_clarification', YELLOW)}")
|
||||
else:
|
||||
err(f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}")
|
||||
err(
|
||||
f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}"
|
||||
)
|
||||
return
|
||||
|
||||
# Highlight resolved folder path for list_folder
|
||||
if tool_name == "list_folder" and result.get("path"):
|
||||
print()
|
||||
print(f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}")
|
||||
print(
|
||||
f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}"
|
||||
)
|
||||
|
||||
# Pretty-print notable fields
|
||||
skip = {"status", "error", "message"}
|
||||
@@ -476,6 +541,7 @@ class WorkflowRunner:
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Alfred workflow simulator",
|
||||
@@ -483,28 +549,58 @@ def parse_args() -> argparse.Namespace:
|
||||
epilog=textwrap.dedent(__doc__ or ""),
|
||||
)
|
||||
parser.add_argument("workflow", help="Workflow name (e.g. organize_media)")
|
||||
parser.add_argument("--dry-run", dest="dry_run", action="store_true", default=True,
|
||||
help="Simulate steps without executing tools (default)")
|
||||
parser.add_argument("--live", action="store_true",
|
||||
help="Actually execute tools against the real filesystem")
|
||||
parser.add_argument("--source", metavar="FOLDER_NAME",
|
||||
help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)")
|
||||
parser.add_argument("--dest", metavar="PATH",
|
||||
help="Destination video file (in library, overrides resolve_destination)")
|
||||
parser.add_argument("--download-folder", metavar="PATH",
|
||||
help="Original download folder (for create_seed_links)")
|
||||
parser.add_argument("--imdb-id", metavar="ID",
|
||||
help="IMDb ID for identify_media (tt1234567)")
|
||||
parser.add_argument("--release", metavar="NAME",
|
||||
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)")
|
||||
parser.add_argument("--tmdb-title", metavar="TITLE",
|
||||
help="Canonical title from TMDB (e.g. 'Oz')")
|
||||
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
|
||||
help="Start/release year from TMDB (e.g. 1997)")
|
||||
parser.add_argument("--episode-title", metavar="TITLE",
|
||||
help="Episode title from TMDB for single-episode releases")
|
||||
parser.add_argument("--seed", action="store_true",
|
||||
help='Answer "yes" to the seeding question')
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
dest="dry_run",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Simulate steps without executing tools (default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--live",
|
||||
action="store_true",
|
||||
help="Actually execute tools against the real filesystem",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
metavar="FOLDER_NAME",
|
||||
help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dest",
|
||||
metavar="PATH",
|
||||
help="Destination video file (in library, overrides resolve_destination)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--download-folder",
|
||||
metavar="PATH",
|
||||
help="Original download folder (for create_seed_links)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--imdb-id", metavar="ID", help="IMDb ID for identify_media (tt1234567)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release",
|
||||
metavar="NAME",
|
||||
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tmdb-title", metavar="TITLE", help="Canonical title from TMDB (e.g. 'Oz')"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tmdb-year",
|
||||
metavar="YEAR",
|
||||
type=int,
|
||||
help="Start/release year from TMDB (e.g. 1997)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--episode-title",
|
||||
metavar="TITLE",
|
||||
help="Episode title from TMDB for single-episode releases",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed", action="store_true", help='Answer "yes" to the seeding question'
|
||||
)
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -521,6 +617,7 @@ def main() -> None:
|
||||
|
||||
# Load workflow
|
||||
from alfred.agent.workflows.loader import WorkflowLoader
|
||||
|
||||
loader = WorkflowLoader()
|
||||
workflow = loader.get(args.workflow)
|
||||
if not workflow:
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
Tests for alfred.application.filesystem.create_seed_links.CreateSeedLinksUseCase
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.filesystem.create_seed_links import CreateSeedLinksUseCase
|
||||
@@ -32,7 +28,12 @@ def seed_env(tmp_path_factory):
|
||||
"""
|
||||
d = tmp_path_factory.mktemp("seed_env")
|
||||
|
||||
lib_dir = d / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
lib_dir = (
|
||||
d
|
||||
/ "tv"
|
||||
/ "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
lib_dir.mkdir(parents=True)
|
||||
lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||
lib_video.write_bytes(b"video")
|
||||
@@ -43,7 +44,9 @@ def seed_env(tmp_path_factory):
|
||||
(dl / "[TGx]info.txt").write_text("tgx")
|
||||
subs = dl / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
|
||||
subs.mkdir(parents=True)
|
||||
(subs / "2_eng,English [CC][SDH].srt").write_text("1\n00:00:01 --> 00:00:02\nHello\n")
|
||||
(subs / "2_eng,English [CC][SDH].srt").write_text(
|
||||
"1\n00:00:01 --> 00:00:02\nHello\n"
|
||||
)
|
||||
|
||||
torrents = d / "torrents"
|
||||
torrents.mkdir()
|
||||
@@ -55,10 +58,13 @@ def seed_env(tmp_path_factory):
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateSeedLinksHappyPath:
|
||||
|
||||
def test_ok_when_torrent_folder_configured(self, use_case, seed_env, memory_configured):
|
||||
class TestCreateSeedLinksHappyPath:
|
||||
def test_ok_when_torrent_folder_configured(
|
||||
self, use_case, seed_env, memory_configured
|
||||
):
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
mem = get_memory()
|
||||
lib_video, dl, torrents = seed_env
|
||||
mem.ltm.workspace.torrent = str(torrents)
|
||||
@@ -73,6 +79,7 @@ class TestCreateSeedLinksHappyPath:
|
||||
|
||||
def test_to_dict_ok(self, use_case, seed_env, memory_configured):
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
mem = get_memory()
|
||||
lib_video, dl, torrents = seed_env
|
||||
mem.ltm.workspace.torrent = str(torrents)
|
||||
@@ -89,8 +96,8 @@ class TestCreateSeedLinksHappyPath:
|
||||
# Error: torrent folder not configured
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateSeedLinksErrors:
|
||||
|
||||
class TestCreateSeedLinksErrors:
|
||||
def test_error_when_torrent_not_configured(self, use_case, seed_env, memory):
|
||||
lib_video, dl, _ = seed_env
|
||||
result = use_case.execute(str(lib_video), str(dl))
|
||||
@@ -109,6 +116,7 @@ class TestCreateSeedLinksErrors:
|
||||
def test_error_delegates_to_file_manager(self, memory_configured):
|
||||
"""FileManager errors are propagated correctly."""
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
mem = get_memory()
|
||||
# torrent already configured by memory_configured fixture
|
||||
# library_file does not exist → should propagate error from FileManager
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
"""Tests for ListFolderUseCase and MoveMediaUseCase."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from alfred.application.filesystem.list_folder import ListFolderUseCase
|
||||
from alfred.application.filesystem.move_media import MoveMediaUseCase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ListFolderUseCase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListFolderUseCase:
|
||||
|
||||
class TestListFolderUseCase:
|
||||
def _use_case(self, fm_result):
|
||||
fm = MagicMock()
|
||||
fm.list_folder.return_value = fm_result
|
||||
return ListFolderUseCase(fm)
|
||||
|
||||
def test_success_returns_response(self):
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "ok",
|
||||
"folder_type": "download",
|
||||
"path": ".",
|
||||
"entries": ["movie.mkv", "show/"],
|
||||
"count": 2,
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute("download")
|
||||
assert resp.status == "ok"
|
||||
assert resp.folder_type == "download"
|
||||
@@ -34,11 +34,13 @@ class TestListFolderUseCase:
|
||||
assert resp.count == 2
|
||||
|
||||
def test_error_propagates(self):
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "error",
|
||||
"error": "folder_not_set",
|
||||
"message": "Download folder not configured.",
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute("download")
|
||||
assert resp.status == "error"
|
||||
assert resp.error == "folder_not_set"
|
||||
@@ -60,30 +62,37 @@ class TestListFolderUseCase:
|
||||
def test_default_path_is_dot(self):
|
||||
fm = MagicMock()
|
||||
fm.list_folder.return_value = {
|
||||
"status": "ok", "folder_type": "download",
|
||||
"path": ".", "entries": [], "count": 0,
|
||||
"status": "ok",
|
||||
"folder_type": "download",
|
||||
"path": ".",
|
||||
"entries": [],
|
||||
"count": 0,
|
||||
}
|
||||
uc = ListFolderUseCase(fm)
|
||||
uc.execute("download")
|
||||
fm.list_folder.assert_called_once_with("download", ".")
|
||||
|
||||
def test_success_response_has_no_error(self):
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "ok",
|
||||
"folder_type": "movie",
|
||||
"path": ".",
|
||||
"entries": [],
|
||||
"count": 0,
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute("movie")
|
||||
assert resp.error is None
|
||||
|
||||
def test_error_response_has_no_entries(self):
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "error",
|
||||
"error": "not_found",
|
||||
"message": "Path does not exist",
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute("download", "some/path")
|
||||
assert resp.entries is None
|
||||
assert resp.count is None
|
||||
@@ -93,8 +102,8 @@ class TestListFolderUseCase:
|
||||
# MoveMediaUseCase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMoveMediaUseCase:
|
||||
|
||||
class TestMoveMediaUseCase:
|
||||
def _use_case(self, fm_result):
|
||||
fm = MagicMock()
|
||||
fm.move_file.return_value = fm_result
|
||||
@@ -103,13 +112,15 @@ class TestMoveMediaUseCase:
|
||||
def test_success_returns_response(self, tmp_path):
|
||||
src = str(tmp_path / "src.mkv")
|
||||
dst = str(tmp_path / "dst.mkv")
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "ok",
|
||||
"source": src,
|
||||
"destination": dst,
|
||||
"filename": "dst.mkv",
|
||||
"size": 1024,
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute(src, dst)
|
||||
assert resp.status == "ok"
|
||||
assert resp.source == src
|
||||
@@ -118,11 +129,13 @@ class TestMoveMediaUseCase:
|
||||
assert resp.size == 1024
|
||||
|
||||
def test_error_propagates(self, tmp_path):
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "error",
|
||||
"error": "source_not_found",
|
||||
"message": "Source does not exist: /ghost.mkv",
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv"))
|
||||
assert resp.status == "error"
|
||||
assert resp.error == "source_not_found"
|
||||
@@ -132,19 +145,24 @@ class TestMoveMediaUseCase:
|
||||
dst = "/movies/Movie.2024/movie.mkv"
|
||||
fm = MagicMock()
|
||||
fm.move_file.return_value = {
|
||||
"status": "ok", "source": src, "destination": dst,
|
||||
"filename": "movie.mkv", "size": 1,
|
||||
"status": "ok",
|
||||
"source": src,
|
||||
"destination": dst,
|
||||
"filename": "movie.mkv",
|
||||
"size": 1,
|
||||
}
|
||||
uc = MoveMediaUseCase(fm)
|
||||
uc.execute(src, dst)
|
||||
fm.move_file.assert_called_once_with(src, dst)
|
||||
|
||||
def test_error_response_has_no_paths(self):
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "error",
|
||||
"error": "destination_exists",
|
||||
"message": "File already exists",
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute("/src.mkv", "/dst.mkv")
|
||||
assert resp.source is None
|
||||
assert resp.destination is None
|
||||
@@ -153,13 +171,15 @@ class TestMoveMediaUseCase:
|
||||
def test_to_dict_success(self, tmp_path):
|
||||
src = "/downloads/movie.mkv"
|
||||
dst = "/movies/movie.mkv"
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "ok",
|
||||
"source": src,
|
||||
"destination": dst,
|
||||
"filename": "movie.mkv",
|
||||
"size": 2048,
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute(src, dst)
|
||||
d = resp.to_dict()
|
||||
assert d["status"] == "ok"
|
||||
@@ -167,11 +187,13 @@ class TestMoveMediaUseCase:
|
||||
assert d["size"] == 2048
|
||||
|
||||
def test_to_dict_error(self):
|
||||
uc = self._use_case({
|
||||
uc = self._use_case(
|
||||
{
|
||||
"status": "error",
|
||||
"error": "link_failed",
|
||||
"message": "Cross-device link not permitted",
|
||||
})
|
||||
}
|
||||
)
|
||||
resp = uc.execute("/src.mkv", "/dst.mkv")
|
||||
d = resp.to_dict()
|
||||
assert d["status"] == "error"
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
|
||||
@@ -5,23 +5,30 @@ Real-data cases sourced from /mnt/testipool/downloads/.
|
||||
Covers: parsing, normalisation, naming methods, edge cases.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.release import ParsedRelease, parse_release
|
||||
from alfred.domain.release import parse_release
|
||||
from alfred.domain.release.services import _normalise
|
||||
from alfred.domain.release.value_objects import _sanitise_for_fs, _strip_episode_from_normalised
|
||||
|
||||
from alfred.domain.release.value_objects import (
|
||||
_sanitise_for_fs,
|
||||
_strip_episode_from_normalised,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _normalise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalise:
|
||||
def test_dots_unchanged(self):
|
||||
assert _normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
assert (
|
||||
_normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
|
||||
def test_spaces_become_dots(self):
|
||||
assert _normalise("Oz S01 1080p WEBRip x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
assert (
|
||||
_normalise("Oz S01 1080p WEBRip x265-KONTRAST")
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
|
||||
def test_double_dots_collapsed(self):
|
||||
assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p"
|
||||
@@ -31,7 +38,9 @@ class TestNormalise:
|
||||
|
||||
def test_mixed_spaces_and_dots(self):
|
||||
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
result = _normalise("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
|
||||
result = _normalise(
|
||||
"Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
)
|
||||
assert " " not in result
|
||||
assert ".." not in result
|
||||
|
||||
@@ -40,6 +49,7 @@ class TestNormalise:
|
||||
# _sanitise_for_fs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSanitiseForFs:
|
||||
def test_clean_string_unchanged(self):
|
||||
assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST"
|
||||
@@ -65,28 +75,38 @@ class TestSanitiseForFs:
|
||||
# _strip_episode_from_normalised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStripEpisode:
|
||||
def test_strips_single_episode(self):
|
||||
assert _strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST") \
|
||||
assert (
|
||||
_strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
|
||||
def test_strips_multi_episode(self):
|
||||
assert _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") \
|
||||
assert (
|
||||
_strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb")
|
||||
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
|
||||
)
|
||||
|
||||
def test_season_pack_unchanged(self):
|
||||
assert _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") \
|
||||
assert (
|
||||
_strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") \
|
||||
assert (
|
||||
_strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST")
|
||||
== "oz.s01.1080p-KONTRAST"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — Season packs (dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSeasonPackDots:
|
||||
"""Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc."""
|
||||
|
||||
@@ -135,13 +155,17 @@ class TestSeasonPackDots:
|
||||
assert p.group == "RARBG"
|
||||
|
||||
def test_gilmore_girls_s01_s07_repack(self):
|
||||
p = parse_release("Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH")
|
||||
p = parse_release(
|
||||
"Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH"
|
||||
)
|
||||
# Season range — we parse the first season number found
|
||||
assert p.season == 1
|
||||
assert p.group == "MONOLITH"
|
||||
|
||||
def test_plot_against_america_4k(self):
|
||||
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY")
|
||||
p = parse_release(
|
||||
"The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY"
|
||||
)
|
||||
assert p.title == "The.Plot.Against.America"
|
||||
assert p.season == 1
|
||||
assert p.quality == "2160p"
|
||||
@@ -165,6 +189,7 @@ class TestSeasonPackDots:
|
||||
# parse_release — Single episodes (dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleEpisodeDots:
|
||||
"""Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc."""
|
||||
|
||||
@@ -211,10 +236,13 @@ class TestSingleEpisodeDots:
|
||||
# parse_release — Multi-episode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiEpisode:
|
||||
def test_archer_triple_episode(self):
|
||||
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
p = parse_release("Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb")
|
||||
p = parse_release(
|
||||
"Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb"
|
||||
)
|
||||
assert p.season == 14
|
||||
assert p.episode == 9
|
||||
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
|
||||
@@ -224,6 +252,7 @@ class TestMultiEpisode:
|
||||
# parse_release — Movies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMovies:
|
||||
def test_another_round_yts(self):
|
||||
# "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised
|
||||
@@ -276,6 +305,7 @@ class TestMovies:
|
||||
# parse_release — Space-separated (no dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSpaceSeparated:
|
||||
def test_oz_spaces(self):
|
||||
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
|
||||
@@ -285,7 +315,9 @@ class TestSpaceSeparated:
|
||||
assert p.group == "KONTRAST"
|
||||
|
||||
def test_archer_spaces(self):
|
||||
p = parse_release("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
|
||||
p = parse_release(
|
||||
"Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
)
|
||||
assert p.season == 14
|
||||
assert p.episode == 9
|
||||
assert p.group == "NTb"
|
||||
@@ -295,6 +327,7 @@ class TestSpaceSeparated:
|
||||
# parse_release — tech_string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTechString:
|
||||
def test_full_tech(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
@@ -312,7 +345,9 @@ class TestTechString:
|
||||
assert "Unknown" in folder
|
||||
|
||||
def test_4k_hdr(self):
|
||||
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY")
|
||||
p = parse_release(
|
||||
"The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY"
|
||||
)
|
||||
assert p.quality == "2160p"
|
||||
|
||||
|
||||
@@ -320,8 +355,8 @@ class TestTechString:
|
||||
# ParsedRelease — naming methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNamingMethods:
|
||||
|
||||
class TestNamingMethods:
|
||||
def test_show_folder_name(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
assert p.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||
@@ -370,7 +405,10 @@ class TestNamingMethods:
|
||||
|
||||
def test_movie_folder_name(self):
|
||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||
assert p.movie_folder_name("Another Round", 2020) == "Another.Round.2020.1080p.BluRay.x264-YTS"
|
||||
assert (
|
||||
p.movie_folder_name("Another Round", 2020)
|
||||
== "Another.Round.2020.1080p.BluRay.x264-YTS"
|
||||
)
|
||||
|
||||
def test_movie_filename(self):
|
||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||
@@ -379,13 +417,16 @@ class TestNamingMethods:
|
||||
|
||||
def test_movie_folder_same_as_show_folder(self):
|
||||
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
|
||||
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name("Revolver", 2005)
|
||||
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name(
|
||||
"Revolver", 2005
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ParsedRelease — is_movie / is_season_pack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMediaTypeFlags:
|
||||
def test_season_pack_is_not_movie(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
@@ -412,11 +453,13 @@ class TestMediaTypeFlags:
|
||||
# Tricky real-world releases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRealWorldEdgeCases:
|
||||
|
||||
class TestRealWorldEdgeCases:
|
||||
def test_angel_integrale_multi(self):
|
||||
# "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
|
||||
p = parse_release("Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod")
|
||||
p = parse_release(
|
||||
"Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
|
||||
)
|
||||
assert p.year == 1999
|
||||
assert p.quality == "1080p"
|
||||
assert p.source == "WEBRip"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.shared.exceptions import ValidationError
|
||||
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ImdbId
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestImdbId:
|
||||
|
||||
class TestImdbId:
|
||||
def test_valid_7_digits(self):
|
||||
id_ = ImdbId("tt1375666")
|
||||
assert str(id_) == "tt1375666"
|
||||
@@ -58,8 +58,8 @@ class TestImdbId:
|
||||
# FilePath
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilePath:
|
||||
|
||||
class TestFilePath:
|
||||
def test_from_string(self, tmp_path):
|
||||
p = FilePath(str(tmp_path))
|
||||
assert isinstance(p.value, Path)
|
||||
@@ -98,8 +98,8 @@ class TestFilePath:
|
||||
# FileSize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFileSize:
|
||||
|
||||
class TestFileSize:
|
||||
def test_bytes(self):
|
||||
s = FileSize(500)
|
||||
assert s.bytes == 500
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -5,13 +5,12 @@ Uses real temp filesystem. No mocks on os.link — we test the actual behavior.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.infrastructure.filesystem.file_manager import FileManager
|
||||
from alfred.infrastructure.filesystem.exceptions import PathTraversalError
|
||||
from alfred.infrastructure.filesystem.file_manager import FileManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -23,8 +22,8 @@ def fm():
|
||||
# copy_file (hard-link)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCopyFile:
|
||||
|
||||
class TestCopyFile:
|
||||
def test_creates_hard_link(self, fm, tmp_path):
|
||||
src = tmp_path / "source.mkv"
|
||||
src.write_bytes(b"video data")
|
||||
@@ -80,8 +79,8 @@ class TestCopyFile:
|
||||
# move_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMoveFile:
|
||||
|
||||
class TestMoveFile:
|
||||
def test_moves_file(self, fm, tmp_path):
|
||||
src = tmp_path / "episode.mkv"
|
||||
src.write_bytes(b"video")
|
||||
@@ -132,8 +131,8 @@ class TestMoveFile:
|
||||
# create_seed_links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateSeedLinks:
|
||||
|
||||
class TestCreateSeedLinks:
|
||||
def _setup(self, tmp_path):
|
||||
"""Create realistic download + library + torrent structure."""
|
||||
download = tmp_path / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
@@ -146,7 +145,12 @@ class TestCreateSeedLinks:
|
||||
subs.mkdir(parents=True)
|
||||
(subs / "2_eng.srt").write_text("subtitle content")
|
||||
|
||||
library = tmp_path / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
library = (
|
||||
tmp_path
|
||||
/ "tv"
|
||||
/ "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
library.mkdir(parents=True)
|
||||
lib_video = library / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||
# Hard-link the video to simulate post-move state
|
||||
@@ -188,7 +192,13 @@ class TestCreateSeedLinks:
|
||||
lib_video, download, torrents = self._setup(tmp_path)
|
||||
fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
|
||||
srt = torrents / "Oz.S01.1080p.WEBRip.x265-KONTRAST" / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST" / "2_eng.srt"
|
||||
srt = (
|
||||
torrents
|
||||
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
/ "Subs"
|
||||
/ "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
|
||||
/ "2_eng.srt"
|
||||
)
|
||||
assert srt.exists()
|
||||
|
||||
def test_returns_copied_and_skipped(self, fm, tmp_path):
|
||||
@@ -270,8 +280,8 @@ class TestCreateSeedLinks:
|
||||
# list_folder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListFolder:
|
||||
|
||||
class TestListFolder:
|
||||
def test_lists_entries(self, fm, memory_configured, infra_temp):
|
||||
result = fm.list_folder("download")
|
||||
assert result["status"] == "ok"
|
||||
@@ -300,8 +310,8 @@ class TestListFolder:
|
||||
# _sanitize_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSanitizePath:
|
||||
|
||||
class TestSanitizePath:
|
||||
def test_normal_path(self, fm):
|
||||
assert fm._sanitize_path("some/path") == "some/path"
|
||||
|
||||
|
||||
+11
-5
@@ -16,7 +16,6 @@ from bootstrap import (
|
||||
load_env_file,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -191,7 +190,9 @@ class TestBuildUris:
|
||||
def test_uri_is_updated_when_host_changes(self, tmp_path, secrets_file):
|
||||
"""If MONGO_HOST changes in .env.alfred, the URI must reflect it."""
|
||||
alfred = tmp_path / ".env.alfred"
|
||||
alfred.write_text(ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost"))
|
||||
alfred.write_text(
|
||||
ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost")
|
||||
)
|
||||
|
||||
build_uris(alfred, secrets_file)
|
||||
uri = load_env_file(secrets_file)["MONGO_URI"]
|
||||
@@ -217,7 +218,9 @@ class TestBuildUris:
|
||||
uri_v1 = load_env_file(secrets_file)["MONGO_URI"]
|
||||
|
||||
alfred_v2 = tmp_path / "alfred_v2"
|
||||
alfred_v2.write_text(ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb"))
|
||||
alfred_v2.write_text(
|
||||
ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb")
|
||||
)
|
||||
build_uris(alfred_v2, secrets_file)
|
||||
uri_v2 = load_env_file(secrets_file)["MONGO_URI"]
|
||||
|
||||
@@ -265,12 +268,15 @@ class TestCopyExampleIfMissing:
|
||||
|
||||
|
||||
class TestExtractPythonVersion:
|
||||
@pytest.mark.parametrize("spec,expected_full,expected_short", [
|
||||
@pytest.mark.parametrize(
|
||||
"spec,expected_full,expected_short",
|
||||
[
|
||||
("==3.14.3", "3.14.3", "3.14"),
|
||||
("^3.12.0", "3.12.0", "3.12"),
|
||||
("~3.11.1", "3.11.1", "3.11"),
|
||||
("3.10.5", "3.10.5", "3.10"),
|
||||
])
|
||||
],
|
||||
)
|
||||
def test_parses_version_specifiers(self, spec, expected_full, expected_short):
|
||||
full, short = extract_python_version(spec)
|
||||
assert full == expected_full
|
||||
|
||||
@@ -1,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,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,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
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user