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
|
||||||
# → QBITTORRENT_PASSWORD goes in .env.secrets
|
# → QBITTORRENT_PASSWORD goes in .env.secrets
|
||||||
QBITTORRENT_URL=http://qbittorrent:16140
|
QBITTORRENT_URL=https://qb.lan.anustart.top
|
||||||
QBITTORRENT_USERNAME=admin
|
QBITTORRENT_USERNAME=letmein
|
||||||
QBITTORRENT_PORT=16140
|
QBITTORRENT_PORT=16140
|
||||||
|
# Path translation: host-side prefix → container-side prefix
|
||||||
|
QBITTORRENT_HOST_PATH=/mnt/testipool
|
||||||
|
QBITTORRENT_CONTAINER_PATH=/mnt/data
|
||||||
|
|
||||||
# Meilisearch
|
# Meilisearch
|
||||||
# → MEILI_MASTER_KEY goes in .env.secrets
|
# → MEILI_MASTER_KEY goes in .env.secrets
|
||||||
@@ -60,7 +63,7 @@ MEILI_HOST=http://meilisearch:7700
|
|||||||
# --- LLM CONFIGURATION ---
|
# --- LLM CONFIGURATION ---
|
||||||
# Providers: local, openai, anthropic, deepseek, google, kimi
|
# Providers: local, openai, anthropic, deepseek, google, kimi
|
||||||
# → API keys go in .env.secrets
|
# → API keys go in .env.secrets
|
||||||
DEFAULT_LLM_PROVIDER=local
|
DEFAULT_LLM_PROVIDER=deepseek
|
||||||
|
|
||||||
# Local LLM (Ollama)
|
# Local LLM (Ollama)
|
||||||
#OLLAMA_BASE_URL=http://ollama:11434
|
#OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class Agent:
|
|||||||
|
|
||||||
async def step_streaming(
|
async def step_streaming(
|
||||||
self, user_input: str, completion_id: str, created_ts: int, model: str
|
self, user_input: str, completion_id: str, created_ts: int, model: str
|
||||||
) -> AsyncGenerator[dict[str, Any], None]:
|
) -> AsyncGenerator[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Execute agent step with streaming support for LibreChat.
|
Execute agent step with streaming support for LibreChat.
|
||||||
|
|
||||||
|
|||||||
@@ -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.list_folder,
|
||||||
fs_tools.analyze_release,
|
fs_tools.analyze_release,
|
||||||
fs_tools.probe_media,
|
fs_tools.probe_media,
|
||||||
fs_tools.resolve_destination,
|
fs_tools.resolve_season_destination_tool,
|
||||||
|
fs_tools.resolve_episode_destination_tool,
|
||||||
|
fs_tools.resolve_movie_destination_tool,
|
||||||
|
fs_tools.resolve_series_destination_tool,
|
||||||
fs_tools.move_media,
|
fs_tools.move_media,
|
||||||
|
fs_tools.move_to_destination,
|
||||||
fs_tools.manage_subtitles,
|
fs_tools.manage_subtitles,
|
||||||
fs_tools.create_seed_links,
|
fs_tools.create_seed_links,
|
||||||
fs_tools.learn,
|
fs_tools.learn,
|
||||||
|
|||||||
@@ -3,20 +3,25 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import alfred as _alfred_pkg
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
import alfred as _alfred_pkg
|
||||||
from alfred.application.filesystem import (
|
from alfred.application.filesystem import (
|
||||||
CreateSeedLinksUseCase,
|
CreateSeedLinksUseCase,
|
||||||
ListFolderUseCase,
|
ListFolderUseCase,
|
||||||
ManageSubtitlesUseCase,
|
ManageSubtitlesUseCase,
|
||||||
MoveMediaUseCase,
|
MoveMediaUseCase,
|
||||||
ResolveDestinationUseCase,
|
|
||||||
SetFolderPathUseCase,
|
SetFolderPathUseCase,
|
||||||
)
|
)
|
||||||
from alfred.application.filesystem.detect_media_type import detect_media_type
|
from alfred.application.filesystem.detect_media_type import detect_media_type
|
||||||
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
||||||
from alfred.infrastructure.filesystem import FileManager
|
from alfred.application.filesystem.resolve_destination import (
|
||||||
|
resolve_episode_destination,
|
||||||
|
resolve_movie_destination,
|
||||||
|
resolve_season_destination,
|
||||||
|
resolve_series_destination,
|
||||||
|
)
|
||||||
|
from alfred.infrastructure.filesystem import FileManager, create_folder, move
|
||||||
from alfred.infrastructure.filesystem.ffprobe import probe
|
from alfred.infrastructure.filesystem.ffprobe import probe
|
||||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||||
|
|
||||||
@@ -42,7 +47,57 @@ def move_media(source: str, destination: str) -> dict[str, Any]:
|
|||||||
return use_case.execute(source, destination).to_dict()
|
return use_case.execute(source, destination).to_dict()
|
||||||
|
|
||||||
|
|
||||||
def resolve_destination(
|
def move_to_destination(source: str, destination: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Move a file or folder to a destination, creating parent directories if needed.
|
||||||
|
|
||||||
|
Use this after resolve_*_destination to perform the actual move.
|
||||||
|
The destination parent is created automatically if it doesn't exist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Absolute path to the source file or folder.
|
||||||
|
destination: Absolute path to the destination.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status, source, destination — or error details.
|
||||||
|
"""
|
||||||
|
parent = str(Path(destination).parent)
|
||||||
|
result = create_folder(parent)
|
||||||
|
if result["status"] != "ok":
|
||||||
|
return result
|
||||||
|
return move(source, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_season_destination_tool(
|
||||||
|
release_name: str,
|
||||||
|
tmdb_title: str,
|
||||||
|
tmdb_year: int,
|
||||||
|
confirmed_folder: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Compute destination paths for a season pack (folder move).
|
||||||
|
|
||||||
|
Returns series_folder + season_folder. No file paths — the whole
|
||||||
|
source folder is moved as-is into season_folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
release_name: Raw release folder name (e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
|
||||||
|
tmdb_title: Canonical show title from TMDB (e.g. "Oz").
|
||||||
|
tmdb_year: Show start year from TMDB (e.g. 1997).
|
||||||
|
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
On success: dict with status, series_folder, season_folder, series_folder_name,
|
||||||
|
season_folder_name, is_new_series_folder.
|
||||||
|
On ambiguity: dict with status="needs_clarification", question, options.
|
||||||
|
On error: dict with status="error", error, message.
|
||||||
|
"""
|
||||||
|
return resolve_season_destination(
|
||||||
|
release_name, tmdb_title, tmdb_year, confirmed_folder
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_episode_destination_tool(
|
||||||
release_name: str,
|
release_name: str,
|
||||||
source_file: str,
|
source_file: str,
|
||||||
tmdb_title: str,
|
tmdb_title: str,
|
||||||
@@ -51,44 +106,91 @@ def resolve_destination(
|
|||||||
confirmed_folder: str | None = None,
|
confirmed_folder: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Compute the destination path in the media library for a release.
|
Compute destination paths for a single episode file.
|
||||||
|
|
||||||
Call this before move_media to get the correct library path. Handles:
|
Returns series_folder + season_folder + library_file (full path to destination .mkv).
|
||||||
- Parsing the release name (quality, codec, group, season/episode)
|
|
||||||
- Looking up any existing series folder in the library
|
|
||||||
- Applying group-conflict rules (asks user if ambiguous)
|
|
||||||
- Building the full destination path with correct naming conventions
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
release_name: Raw release folder or file name
|
release_name: Raw release file name (e.g. "Oz.S03E01.1080p.WEBRip.x265-KONTRAST.mkv").
|
||||||
(e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
|
|
||||||
source_file: Absolute path to the source video file (used for extension).
|
source_file: Absolute path to the source video file (used for extension).
|
||||||
tmdb_title: Canonical show/movie title from TMDB (e.g. "Oz").
|
tmdb_title: Canonical show title from TMDB (e.g. "Oz").
|
||||||
tmdb_year: Release/start year from TMDB (e.g. 1997).
|
tmdb_year: Show start year from TMDB (e.g. 1997).
|
||||||
tmdb_episode_title: Episode title from TMDB for single-episode releases
|
tmdb_episode_title: Episode title from TMDB (e.g. "The Routine"). Optional.
|
||||||
(e.g. "The Routine"). Omit for season packs and movies.
|
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||||
confirmed_folder: If a previous call returned needs_clarification, pass
|
|
||||||
the user-chosen folder name here to proceed.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
On success: dict with status, library_file, series_folder, season_folder,
|
On success: dict with status, series_folder, season_folder, library_file,
|
||||||
series_folder_name, season_folder_name, filename,
|
series_folder_name, season_folder_name, filename, is_new_series_folder.
|
||||||
is_new_series_folder.
|
|
||||||
On ambiguity: dict with status="needs_clarification", question, options.
|
On ambiguity: dict with status="needs_clarification", question, options.
|
||||||
On error: dict with status="error", error, message.
|
On error: dict with status="error", error, message.
|
||||||
"""
|
"""
|
||||||
use_case = ResolveDestinationUseCase()
|
return resolve_episode_destination(
|
||||||
return use_case.execute(
|
release_name,
|
||||||
release_name=release_name,
|
source_file,
|
||||||
source_file=source_file,
|
tmdb_title,
|
||||||
tmdb_title=tmdb_title,
|
tmdb_year,
|
||||||
tmdb_year=tmdb_year,
|
tmdb_episode_title,
|
||||||
tmdb_episode_title=tmdb_episode_title,
|
confirmed_folder,
|
||||||
confirmed_folder=confirmed_folder,
|
|
||||||
).to_dict()
|
).to_dict()
|
||||||
|
|
||||||
|
|
||||||
def create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]:
|
def resolve_movie_destination_tool(
|
||||||
|
release_name: str,
|
||||||
|
source_file: str,
|
||||||
|
tmdb_title: str,
|
||||||
|
tmdb_year: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Compute destination paths for a movie file.
|
||||||
|
|
||||||
|
Returns movie_folder + library_file (full path to destination .mkv).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
release_name: Raw release folder/file name (e.g. "Inception.2010.1080p.BluRay.x265-GROUP").
|
||||||
|
source_file: Absolute path to the source video file (used for extension).
|
||||||
|
tmdb_title: Canonical movie title from TMDB (e.g. "Inception").
|
||||||
|
tmdb_year: Movie release year from TMDB (e.g. 2010).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
On success: dict with status, movie_folder, library_file, movie_folder_name,
|
||||||
|
filename, is_new_folder.
|
||||||
|
On error: dict with status="error", error, message.
|
||||||
|
"""
|
||||||
|
return resolve_movie_destination(
|
||||||
|
release_name, source_file, tmdb_title, tmdb_year
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_series_destination_tool(
|
||||||
|
release_name: str,
|
||||||
|
tmdb_title: str,
|
||||||
|
tmdb_year: int,
|
||||||
|
confirmed_folder: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Compute destination path for a complete multi-season series pack (folder move).
|
||||||
|
|
||||||
|
Returns only series_folder — the whole pack lands directly inside it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
release_name: Raw release folder name.
|
||||||
|
tmdb_title: Canonical show title from TMDB.
|
||||||
|
tmdb_year: Show start year from TMDB.
|
||||||
|
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
On success: dict with status, series_folder, series_folder_name, is_new_series_folder.
|
||||||
|
On ambiguity: dict with status="needs_clarification", question, options.
|
||||||
|
On error: dict with status="error", error, message.
|
||||||
|
"""
|
||||||
|
return resolve_series_destination(
|
||||||
|
release_name, tmdb_title, tmdb_year, confirmed_folder
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def create_seed_links(
|
||||||
|
library_file: str, original_download_folder: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Prepare a torrent subfolder so qBittorrent can keep seeding after a move.
|
Prepare a torrent subfolder so qBittorrent can keep seeding after a move.
|
||||||
|
|
||||||
@@ -159,10 +261,18 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
|
|||||||
_VALID_CATEGORIES = {"languages", "types", "formats"}
|
_VALID_CATEGORIES = {"languages", "types", "formats"}
|
||||||
|
|
||||||
if pack not in _VALID_PACKS:
|
if pack not in _VALID_PACKS:
|
||||||
return {"status": "error", "error": "unknown_pack", "message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "unknown_pack",
|
||||||
|
"message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}",
|
||||||
|
}
|
||||||
|
|
||||||
if category not in _VALID_CATEGORIES:
|
if category not in _VALID_CATEGORIES:
|
||||||
return {"status": "error", "error": "unknown_category", "message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "unknown_category",
|
||||||
|
"message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}",
|
||||||
|
}
|
||||||
|
|
||||||
learned_path = _LEARNED_ROOT / "subtitles_learned.yaml"
|
learned_path = _LEARNED_ROOT / "subtitles_learned.yaml"
|
||||||
_LEARNED_ROOT.mkdir(parents=True, exist_ok=True)
|
_LEARNED_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -184,7 +294,9 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
|
|||||||
tmp = learned_path.with_suffix(".yaml.tmp")
|
tmp = learned_path.with_suffix(".yaml.tmp")
|
||||||
try:
|
try:
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
yaml.safe_dump(
|
||||||
|
data, f, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||||
|
)
|
||||||
tmp.rename(learned_path)
|
tmp.rename(learned_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
@@ -293,11 +405,19 @@ def probe_media(source_path: str) -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
path = Path(source_path)
|
path = Path(source_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {"status": "error", "error": "not_found", "message": f"{source_path} does not exist"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "not_found",
|
||||||
|
"message": f"{source_path} does not exist",
|
||||||
|
}
|
||||||
|
|
||||||
media_info = probe(path)
|
media_info = probe(path)
|
||||||
if media_info is None:
|
if media_info is None:
|
||||||
return {"status": "error", "error": "probe_failed", "message": "ffprobe failed to read the file"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "probe_failed",
|
||||||
|
"message": "ffprobe failed to read the file",
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
|||||||
@@ -12,7 +12,16 @@ from .dto import (
|
|||||||
from .list_folder import ListFolderUseCase
|
from .list_folder import ListFolderUseCase
|
||||||
from .manage_subtitles import ManageSubtitlesUseCase
|
from .manage_subtitles import ManageSubtitlesUseCase
|
||||||
from .move_media import MoveMediaUseCase
|
from .move_media import MoveMediaUseCase
|
||||||
from .resolve_destination import ResolveDestinationUseCase, ResolvedDestination
|
from .resolve_destination import (
|
||||||
|
ResolvedEpisodeDestination,
|
||||||
|
ResolvedMovieDestination,
|
||||||
|
ResolvedSeasonDestination,
|
||||||
|
ResolvedSeriesDestination,
|
||||||
|
resolve_episode_destination,
|
||||||
|
resolve_movie_destination,
|
||||||
|
resolve_season_destination,
|
||||||
|
resolve_series_destination,
|
||||||
|
)
|
||||||
from .set_folder_path import SetFolderPathUseCase
|
from .set_folder_path import SetFolderPathUseCase
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -21,8 +30,14 @@ __all__ = [
|
|||||||
"CreateSeedLinksUseCase",
|
"CreateSeedLinksUseCase",
|
||||||
"MoveMediaUseCase",
|
"MoveMediaUseCase",
|
||||||
"ManageSubtitlesUseCase",
|
"ManageSubtitlesUseCase",
|
||||||
"ResolveDestinationUseCase",
|
"ResolvedSeasonDestination",
|
||||||
"ResolvedDestination",
|
"ResolvedEpisodeDestination",
|
||||||
|
"ResolvedMovieDestination",
|
||||||
|
"ResolvedSeriesDestination",
|
||||||
|
"resolve_season_destination",
|
||||||
|
"resolve_episode_destination",
|
||||||
|
"resolve_movie_destination",
|
||||||
|
"resolve_series_destination",
|
||||||
"SetFolderPathResponse",
|
"SetFolderPathResponse",
|
||||||
"ListFolderResponse",
|
"ListFolderResponse",
|
||||||
"CreateSeedLinksResponse",
|
"CreateSeedLinksResponse",
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alfred.domain.release.value_objects import (
|
from alfred.domain.release.value_objects import (
|
||||||
ParsedRelease,
|
|
||||||
_METADATA_EXTENSIONS,
|
_METADATA_EXTENSIONS,
|
||||||
_NON_VIDEO_EXTENSIONS,
|
_NON_VIDEO_EXTENSIONS,
|
||||||
_VIDEO_EXTENSIONS,
|
_VIDEO_EXTENSIONS,
|
||||||
|
ParsedRelease,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -88,7 +88,11 @@ class PlacedSubtitle:
|
|||||||
filename: str
|
filename: str
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {"source": self.source, "destination": self.destination, "filename": self.filename}
|
return {
|
||||||
|
"source": self.source,
|
||||||
|
"destination": self.destination,
|
||||||
|
"filename": self.filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
|
|||||||
parsed.quality = info.resolution
|
parsed.quality = info.resolution
|
||||||
|
|
||||||
if parsed.codec is None and info.video_codec:
|
if parsed.codec is None and info.video_codec:
|
||||||
parsed.codec = _VIDEO_CODEC_MAP.get(info.video_codec.lower(), info.video_codec.upper())
|
parsed.codec = _VIDEO_CODEC_MAP.get(
|
||||||
|
info.video_codec.lower(), info.video_codec.upper()
|
||||||
|
)
|
||||||
|
|
||||||
if parsed.bit_depth is None and info.video_codec:
|
if parsed.bit_depth is None and info.video_codec:
|
||||||
# ffprobe exposes bit depth via pix_fmt — not in MediaInfo yet, skip for now
|
# ffprobe exposes bit depth via pix_fmt — not in MediaInfo yet, skip for now
|
||||||
@@ -62,10 +64,14 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
|
|||||||
|
|
||||||
if track:
|
if track:
|
||||||
if parsed.audio_codec is None and track.codec:
|
if parsed.audio_codec is None and track.codec:
|
||||||
parsed.audio_codec = _AUDIO_CODEC_MAP.get(track.codec.lower(), track.codec.upper())
|
parsed.audio_codec = _AUDIO_CODEC_MAP.get(
|
||||||
|
track.codec.lower(), track.codec.upper()
|
||||||
|
)
|
||||||
|
|
||||||
if parsed.audio_channels is None and track.channels:
|
if parsed.audio_channels is None and track.channels:
|
||||||
parsed.audio_channels = _CHANNEL_MAP.get(track.channels, f"{track.channels}ch")
|
parsed.audio_channels = _CHANNEL_MAP.get(
|
||||||
|
track.channels, f"{track.channels}ch"
|
||||||
|
)
|
||||||
|
|
||||||
# Languages — merge ffprobe languages with token-level ones
|
# Languages — merge ffprobe languages with token-level ones
|
||||||
# "und" = undetermined, not useful
|
# "und" = undetermined, not useful
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ from alfred.infrastructure.persistence.context import get_memory
|
|||||||
from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore
|
from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore
|
||||||
from alfred.infrastructure.subtitle.rule_repository import RuleSetRepository
|
from alfred.infrastructure.subtitle.rule_repository import RuleSetRepository
|
||||||
|
|
||||||
from .dto import AvailableSubtitle, ManageSubtitlesResponse, PlacedSubtitle, UnresolvedTrack
|
from .dto import (
|
||||||
|
AvailableSubtitle,
|
||||||
|
ManageSubtitlesResponse,
|
||||||
|
PlacedSubtitle,
|
||||||
|
UnresolvedTrack,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,11 +74,12 @@ class ManageSubtitlesUseCase:
|
|||||||
season: int | None = None,
|
season: int | None = None,
|
||||||
episode: int | None = None,
|
episode: int | None = None,
|
||||||
confirmed_pattern_id: str | None = None,
|
confirmed_pattern_id: str | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
) -> ManageSubtitlesResponse:
|
) -> ManageSubtitlesResponse:
|
||||||
source_path = Path(source_video)
|
source_path = Path(source_video)
|
||||||
dest_path = Path(destination_video)
|
dest_path = Path(destination_video)
|
||||||
|
|
||||||
if not source_path.exists():
|
if not source_path.exists() and not source_path.parent.exists():
|
||||||
return ManageSubtitlesResponse(
|
return ManageSubtitlesResponse(
|
||||||
status="error",
|
status="error",
|
||||||
error="source_not_found",
|
error="source_not_found",
|
||||||
@@ -108,7 +114,9 @@ class ManageSubtitlesUseCase:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if metadata.total_count == 0:
|
if metadata.total_count == 0:
|
||||||
logger.info(f"ManageSubtitles: no subtitle tracks found for {source_path.name}")
|
logger.info(
|
||||||
|
f"ManageSubtitles: no subtitle tracks found for {source_path.name}"
|
||||||
|
)
|
||||||
return ManageSubtitlesResponse(
|
return ManageSubtitlesResponse(
|
||||||
status="ok",
|
status="ok",
|
||||||
video_path=destination_video,
|
video_path=destination_video,
|
||||||
@@ -164,6 +172,32 @@ class ManageSubtitlesUseCase:
|
|||||||
skipped_count=metadata.total_count,
|
skipped_count=metadata.total_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Dry run: skip placement ---
|
||||||
|
if dry_run:
|
||||||
|
from alfred.domain.subtitles.services.placer import _build_dest_name
|
||||||
|
|
||||||
|
placed_dtos = []
|
||||||
|
for t in matched:
|
||||||
|
if not t.file_path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
filename = _build_dest_name(t, dest_path.stem)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
placed_dtos.append(
|
||||||
|
PlacedSubtitle(
|
||||||
|
source=str(t.file_path),
|
||||||
|
destination=str(dest_path.parent / filename),
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ManageSubtitlesResponse(
|
||||||
|
status="ok",
|
||||||
|
video_path=destination_video,
|
||||||
|
placed=placed_dtos,
|
||||||
|
skipped_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Place ---
|
# --- Place ---
|
||||||
placer = SubtitlePlacer()
|
placer = SubtitlePlacer()
|
||||||
place_result = placer.place(matched, dest_path)
|
place_result = placer.place(matched, dest_path)
|
||||||
@@ -229,7 +263,9 @@ class ManageSubtitlesUseCase:
|
|||||||
return kb.pattern("adjacent")
|
return kb.pattern("adjacent")
|
||||||
|
|
||||||
|
|
||||||
def _to_unresolved_dto(track: SubtitleTrack, min_confidence: float = 0.7) -> UnresolvedTrack:
|
def _to_unresolved_dto(
|
||||||
|
track: SubtitleTrack, min_confidence: float = 0.7
|
||||||
|
) -> UnresolvedTrack:
|
||||||
reason = "unknown_language" if track.language is None else "low_confidence"
|
reason = "unknown_language" if track.language is None else "low_confidence"
|
||||||
return UnresolvedTrack(
|
return UnresolvedTrack(
|
||||||
raw_tokens=track.raw_tokens,
|
raw_tokens=track.raw_tokens,
|
||||||
|
|||||||
@@ -1,58 +1,82 @@
|
|||||||
"""
|
"""
|
||||||
ResolveDestinationUseCase — compute the library destination path for a release.
|
Destination resolution — compute library paths for releases.
|
||||||
|
|
||||||
Steps:
|
Four distinct use cases, one per release type:
|
||||||
1. Parse the release name
|
- resolve_season_destination : season pack (folder move)
|
||||||
2. Look up TMDB for title + year (+ episode title if single episode)
|
- resolve_episode_destination : single episode (file move)
|
||||||
3. Scan the library for an existing series folder
|
- resolve_movie_destination : movie (file move)
|
||||||
4. Apply group-conflict rules
|
- resolve_series_destination : complete series multi-season pack (folder move)
|
||||||
5. Return the computed paths (or needs_clarification if ambiguous)
|
|
||||||
|
Each returns a dedicated DTO with only the fields that make sense for that type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alfred.domain.release import ParsedRelease, parse_release
|
from alfred.domain.release import parse_release
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Characters forbidden on Windows filesystems (served via NFS)
|
|
||||||
_WIN_FORBIDDEN = re.compile(r'[?:*"<>|\\]')
|
_WIN_FORBIDDEN = re.compile(r'[?:*"<>|\\]')
|
||||||
|
|
||||||
|
|
||||||
def _sanitise(text: str) -> str:
|
def _sanitize(text: str) -> str:
|
||||||
return _WIN_FORBIDDEN.sub("", text)
|
return _WIN_FORBIDDEN.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_existing_tvshow_folders(
|
||||||
|
tv_root: Path, tmdb_title: str, tmdb_year: int
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return folder names in tv_root that match title + year prefix."""
|
||||||
|
if not tv_root.exists():
|
||||||
|
return []
|
||||||
|
clean_title = _sanitize(tmdb_title).replace(" ", ".")
|
||||||
|
prefix = f"{clean_title}.{tmdb_year}".lower()
|
||||||
|
return sorted(
|
||||||
|
entry.name
|
||||||
|
for entry in tv_root.iterdir()
|
||||||
|
if entry.is_dir() and entry.name.lower().startswith(prefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tv_root() -> Path | None:
|
||||||
|
memory = get_memory()
|
||||||
|
tv_root = memory.ltm.library_paths.get("tv_show")
|
||||||
|
return Path(tv_root) if tv_root else None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DTOs
|
# DTOs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResolvedDestination:
|
class ResolvedSeasonDestination:
|
||||||
"""All computed paths for a release, ready to hand to move_media."""
|
"""Paths for a season pack — folder move, no individual file paths."""
|
||||||
|
|
||||||
status: str # "ok" | "needs_clarification" | "error"
|
status: str # "ok" | "needs_clarification" | "error"
|
||||||
|
|
||||||
# Populated on "ok"
|
# ok
|
||||||
library_file: str | None = None # absolute path of the destination video file
|
series_folder: str | None = (
|
||||||
series_folder: str | None = None # absolute path of the series root folder
|
None # /tv_shows/A.Knight.of.the.Seven.Kingdoms.2024.1080p.WEBRip.x265-KONTRAST
|
||||||
season_folder: str | None = None # absolute path of the season subfolder
|
)
|
||||||
series_folder_name: str | None = None # just the folder name (for display)
|
season_folder: str | None = (
|
||||||
|
None # .../A.Knight.of.the.Seven.Kingdoms.S01.1080p.WEBRip.x265-KONTRAST
|
||||||
|
)
|
||||||
|
series_folder_name: str | None = None
|
||||||
season_folder_name: str | None = None
|
season_folder_name: str | None = None
|
||||||
filename: str | None = None
|
is_new_series_folder: bool = False
|
||||||
is_new_series_folder: bool = False # True if we're creating the folder
|
|
||||||
|
|
||||||
# Populated on "needs_clarification"
|
# needs_clarification
|
||||||
question: str | None = None
|
question: str | None = None
|
||||||
options: list[str] | None = None # existing group folder names to pick from
|
options: list[str] | None = None
|
||||||
|
|
||||||
# Populated on "error"
|
# error
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
message: str | None = None
|
message: str | None = None
|
||||||
|
|
||||||
@@ -67,161 +91,267 @@ class ResolvedDestination:
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"library_file": self.library_file,
|
|
||||||
"series_folder": self.series_folder,
|
"series_folder": self.series_folder,
|
||||||
"season_folder": self.season_folder,
|
"season_folder": self.season_folder,
|
||||||
"series_folder_name": self.series_folder_name,
|
"series_folder_name": self.series_folder_name,
|
||||||
"season_folder_name": self.season_folder_name,
|
"season_folder_name": self.season_folder_name,
|
||||||
|
"is_new_series_folder": self.is_new_series_folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedEpisodeDestination:
|
||||||
|
"""Paths for a single episode — file move."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
|
||||||
|
# ok
|
||||||
|
series_folder: str | None = None
|
||||||
|
season_folder: str | None = None
|
||||||
|
library_file: str | None = None # full path to destination .mkv
|
||||||
|
series_folder_name: str | None = None
|
||||||
|
season_folder_name: str | None = None
|
||||||
|
filename: str | None = None
|
||||||
|
is_new_series_folder: bool = False
|
||||||
|
|
||||||
|
# needs_clarification
|
||||||
|
question: str | None = None
|
||||||
|
options: list[str] | None = None
|
||||||
|
|
||||||
|
# error
|
||||||
|
error: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
if self.status == "error":
|
||||||
|
return {"status": self.status, "error": self.error, "message": self.message}
|
||||||
|
if self.status == "needs_clarification":
|
||||||
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"question": self.question,
|
||||||
|
"options": self.options or [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"series_folder": self.series_folder,
|
||||||
|
"season_folder": self.season_folder,
|
||||||
|
"library_file": self.library_file,
|
||||||
|
"series_folder_name": self.series_folder_name,
|
||||||
|
"season_folder_name": self.season_folder_name,
|
||||||
"filename": self.filename,
|
"filename": self.filename,
|
||||||
"is_new_series_folder": self.is_new_series_folder,
|
"is_new_series_folder": self.is_new_series_folder,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedMovieDestination:
|
||||||
|
"""Paths for a movie — file move."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
|
||||||
|
# ok
|
||||||
|
movie_folder: str | None = None
|
||||||
|
library_file: str | None = None
|
||||||
|
movie_folder_name: str | None = None
|
||||||
|
filename: str | None = None
|
||||||
|
is_new_folder: bool = False
|
||||||
|
|
||||||
|
# needs_clarification
|
||||||
|
question: str | None = None
|
||||||
|
options: list[str] | None = None
|
||||||
|
|
||||||
|
# error
|
||||||
|
error: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
if self.status == "error":
|
||||||
|
return {"status": self.status, "error": self.error, "message": self.message}
|
||||||
|
if self.status == "needs_clarification":
|
||||||
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"question": self.question,
|
||||||
|
"options": self.options or [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"movie_folder": self.movie_folder,
|
||||||
|
"library_file": self.library_file,
|
||||||
|
"movie_folder_name": self.movie_folder_name,
|
||||||
|
"filename": self.filename,
|
||||||
|
"is_new_folder": self.is_new_folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedSeriesDestination:
|
||||||
|
"""Paths for a complete multi-season series pack — folder move."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
|
||||||
|
# ok
|
||||||
|
series_folder: str | None = None
|
||||||
|
series_folder_name: str | None = None
|
||||||
|
is_new_series_folder: bool = False
|
||||||
|
|
||||||
|
# needs_clarification
|
||||||
|
question: str | None = None
|
||||||
|
options: list[str] | None = None
|
||||||
|
|
||||||
|
# error
|
||||||
|
error: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
if self.status == "error":
|
||||||
|
return {"status": self.status, "error": self.error, "message": self.message}
|
||||||
|
if self.status == "needs_clarification":
|
||||||
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"question": self.question,
|
||||||
|
"options": self.options or [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"series_folder": self.series_folder,
|
||||||
|
"series_folder_name": self.series_folder_name,
|
||||||
|
"is_new_series_folder": self.is_new_series_folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Use case
|
# Use cases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class ResolveDestinationUseCase:
|
|
||||||
|
def resolve_season_destination(
|
||||||
|
release_name: str,
|
||||||
|
tmdb_title: str,
|
||||||
|
tmdb_year: int,
|
||||||
|
confirmed_folder: str | None = None,
|
||||||
|
) -> ResolvedSeasonDestination:
|
||||||
"""
|
"""
|
||||||
Compute the full destination path for a media file being organised.
|
Compute destination paths for a season pack.
|
||||||
|
|
||||||
The caller provides:
|
Returns series_folder + season_folder. No file paths — the whole
|
||||||
- release_name: the raw release folder/file name
|
source folder is moved as-is into season_folder.
|
||||||
- source_file: path to the actual video file (to get extension)
|
|
||||||
- tmdb_title: canonical title from TMDB
|
|
||||||
- tmdb_year: release year from TMDB
|
|
||||||
- tmdb_episode_title: episode title from TMDB (None for movies / season packs)
|
|
||||||
- confirmed_folder: if the user already answered needs_clarification, pass
|
|
||||||
the chosen folder name here to skip the check
|
|
||||||
|
|
||||||
Returns a ResolvedDestination.
|
|
||||||
"""
|
"""
|
||||||
|
tv_root = _get_tv_root()
|
||||||
|
if not tv_root:
|
||||||
|
return ResolvedSeasonDestination(
|
||||||
|
status="error",
|
||||||
|
error="library_not_set",
|
||||||
|
message="TV show library path is not configured.",
|
||||||
|
)
|
||||||
|
|
||||||
def execute(
|
parsed = parse_release(release_name)
|
||||||
self,
|
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||||
|
|
||||||
|
if confirmed_folder:
|
||||||
|
series_folder_name = confirmed_folder
|
||||||
|
is_new = not (tv_root / confirmed_folder).exists()
|
||||||
|
else:
|
||||||
|
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
|
||||||
|
|
||||||
|
if len(existing) == 0:
|
||||||
|
series_folder_name = computed_name
|
||||||
|
is_new = True
|
||||||
|
elif len(existing) == 1 and existing[0] == computed_name:
|
||||||
|
# Exact match — use it
|
||||||
|
series_folder_name = existing[0]
|
||||||
|
is_new = False
|
||||||
|
else:
|
||||||
|
# One folder with a different name, or multiple — ask user
|
||||||
|
options = existing + (
|
||||||
|
[computed_name] if computed_name not in existing else []
|
||||||
|
)
|
||||||
|
return ResolvedSeasonDestination(
|
||||||
|
status="needs_clarification",
|
||||||
|
question=(
|
||||||
|
f"Un dossier série existe déjà pour '{tmdb_title}' "
|
||||||
|
f"mais son nom diffère du nom calculé ({computed_name}). "
|
||||||
|
f"Lequel utiliser ?"
|
||||||
|
),
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
season_folder_name = parsed.season_folder_name()
|
||||||
|
series_path = tv_root / series_folder_name
|
||||||
|
season_path = series_path / season_folder_name
|
||||||
|
|
||||||
|
return ResolvedSeasonDestination(
|
||||||
|
status="ok",
|
||||||
|
series_folder=str(series_path),
|
||||||
|
season_folder=str(season_path),
|
||||||
|
series_folder_name=series_folder_name,
|
||||||
|
season_folder_name=season_folder_name,
|
||||||
|
is_new_series_folder=is_new,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_episode_destination(
|
||||||
release_name: str,
|
release_name: str,
|
||||||
source_file: str,
|
source_file: str,
|
||||||
tmdb_title: str,
|
tmdb_title: str,
|
||||||
tmdb_year: int,
|
tmdb_year: int,
|
||||||
tmdb_episode_title: str | None = None,
|
tmdb_episode_title: str | None = None,
|
||||||
confirmed_folder: str | None = None,
|
confirmed_folder: str | None = None,
|
||||||
) -> ResolvedDestination:
|
) -> ResolvedEpisodeDestination:
|
||||||
parsed = parse_release(release_name)
|
"""
|
||||||
ext = Path(source_file).suffix # ".mkv"
|
Compute destination paths for a single episode file.
|
||||||
|
|
||||||
if parsed.media_type == "movie":
|
Returns series_folder + season_folder + library_file (full path to .mkv).
|
||||||
return self._resolve_movie(parsed, tmdb_title, tmdb_year, ext)
|
"""
|
||||||
if parsed.media_type == "tv_show":
|
tv_root = _get_tv_root()
|
||||||
return self._resolve_tvshow(
|
|
||||||
parsed, tmdb_title, tmdb_year, tmdb_episode_title, ext, confirmed_folder
|
|
||||||
)
|
|
||||||
return ResolvedDestination(
|
|
||||||
status="error",
|
|
||||||
error="unsupported_media_type",
|
|
||||||
message=(
|
|
||||||
f"Cannot organize '{release_name}': detected as '{parsed.media_type}'. "
|
|
||||||
"Only movies and TV shows are supported."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Movie
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _resolve_movie(
|
|
||||||
self, parsed: ParsedRelease, tmdb_title: str, tmdb_year: int, ext: str
|
|
||||||
) -> ResolvedDestination:
|
|
||||||
memory = get_memory()
|
|
||||||
movies_root = memory.ltm.library_paths.get("movie")
|
|
||||||
if not movies_root:
|
|
||||||
return ResolvedDestination(
|
|
||||||
status="error",
|
|
||||||
error="library_not_set",
|
|
||||||
message="Movie library path is not configured.",
|
|
||||||
)
|
|
||||||
|
|
||||||
folder_name = _sanitise(parsed.movie_folder_name(tmdb_title, tmdb_year))
|
|
||||||
filename = _sanitise(parsed.movie_filename(tmdb_title, tmdb_year, ext))
|
|
||||||
|
|
||||||
folder_path = Path(movies_root) / folder_name
|
|
||||||
file_path = folder_path / filename
|
|
||||||
|
|
||||||
return ResolvedDestination(
|
|
||||||
status="ok",
|
|
||||||
library_file=str(file_path),
|
|
||||||
series_folder=str(folder_path),
|
|
||||||
series_folder_name=folder_name,
|
|
||||||
filename=filename,
|
|
||||||
is_new_series_folder=not folder_path.exists(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# TV show
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _resolve_tvshow(
|
|
||||||
self,
|
|
||||||
parsed: ParsedRelease,
|
|
||||||
tmdb_title: str,
|
|
||||||
tmdb_year: int,
|
|
||||||
tmdb_episode_title: str | None,
|
|
||||||
ext: str,
|
|
||||||
confirmed_folder: str | None,
|
|
||||||
) -> ResolvedDestination:
|
|
||||||
memory = get_memory()
|
|
||||||
tv_root = memory.ltm.library_paths.get("tv_show")
|
|
||||||
if not tv_root:
|
if not tv_root:
|
||||||
return ResolvedDestination(
|
return ResolvedEpisodeDestination(
|
||||||
status="error",
|
status="error",
|
||||||
error="library_not_set",
|
error="library_not_set",
|
||||||
message="TV show library path is not configured.",
|
message="TV show library path is not configured.",
|
||||||
)
|
)
|
||||||
|
|
||||||
tv_root_path = Path(tv_root)
|
parsed = parse_release(release_name)
|
||||||
|
ext = Path(source_file).suffix
|
||||||
|
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||||
|
|
||||||
# --- Find existing series folders for this title ---
|
|
||||||
existing = _find_existing_series_folders(tv_root_path, tmdb_title, tmdb_year)
|
|
||||||
|
|
||||||
# --- Determine series folder name ---
|
|
||||||
if confirmed_folder:
|
if confirmed_folder:
|
||||||
series_folder_name = confirmed_folder
|
series_folder_name = confirmed_folder
|
||||||
is_new = not (tv_root_path / confirmed_folder).exists()
|
is_new = not (tv_root / confirmed_folder).exists()
|
||||||
elif len(existing) == 0:
|
else:
|
||||||
# No existing folder — create with release group
|
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
|
||||||
series_folder_name = _sanitise(parsed.show_folder_name(tmdb_title, tmdb_year))
|
|
||||||
|
if len(existing) == 0:
|
||||||
|
series_folder_name = computed_name
|
||||||
is_new = True
|
is_new = True
|
||||||
elif len(existing) == 1:
|
elif len(existing) == 1 and existing[0] == computed_name:
|
||||||
# Exactly one match — use it regardless of group
|
|
||||||
series_folder_name = existing[0]
|
series_folder_name = existing[0]
|
||||||
is_new = False
|
is_new = False
|
||||||
else:
|
else:
|
||||||
# Multiple folders — ask user
|
options = existing + (
|
||||||
return ResolvedDestination(
|
[computed_name] if computed_name not in existing else []
|
||||||
|
)
|
||||||
|
return ResolvedEpisodeDestination(
|
||||||
status="needs_clarification",
|
status="needs_clarification",
|
||||||
question=(
|
question=(
|
||||||
f"Multiple folders found for '{tmdb_title}' in your library. "
|
f"Un dossier série existe déjà pour '{tmdb_title}' "
|
||||||
f"Which one should I use for this release ({parsed.group})?"
|
f"mais son nom diffère du nom calculé ({computed_name}). "
|
||||||
|
f"Lequel utiliser ?"
|
||||||
),
|
),
|
||||||
options=existing,
|
options=options,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Build paths ---
|
|
||||||
season_folder_name = parsed.season_folder_name()
|
season_folder_name = parsed.season_folder_name()
|
||||||
filename = _sanitise(
|
filename = _sanitize(parsed.episode_filename(tmdb_episode_title, ext))
|
||||||
parsed.episode_filename(tmdb_episode_title, ext)
|
|
||||||
if not parsed.is_season_pack
|
|
||||||
else parsed.season_folder_name() + ext
|
|
||||||
)
|
|
||||||
|
|
||||||
series_path = tv_root_path / series_folder_name
|
series_path = tv_root / series_folder_name
|
||||||
season_path = series_path / season_folder_name
|
season_path = series_path / season_folder_name
|
||||||
file_path = season_path / filename
|
file_path = season_path / filename
|
||||||
|
|
||||||
return ResolvedDestination(
|
return ResolvedEpisodeDestination(
|
||||||
status="ok",
|
status="ok",
|
||||||
library_file=str(file_path),
|
|
||||||
series_folder=str(series_path),
|
series_folder=str(series_path),
|
||||||
season_folder=str(season_path),
|
season_folder=str(season_path),
|
||||||
|
library_file=str(file_path),
|
||||||
series_folder_name=series_folder_name,
|
series_folder_name=series_folder_name,
|
||||||
season_folder_name=season_folder_name,
|
season_folder_name=season_folder_name,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
@@ -229,27 +359,98 @@ class ResolveDestinationUseCase:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def resolve_movie_destination(
|
||||||
# Helpers
|
release_name: str,
|
||||||
# ---------------------------------------------------------------------------
|
source_file: str,
|
||||||
|
tmdb_title: str,
|
||||||
def _find_existing_series_folders(tv_root: Path, tmdb_title: str, tmdb_year: int) -> list[str]:
|
tmdb_year: int,
|
||||||
|
) -> ResolvedMovieDestination:
|
||||||
"""
|
"""
|
||||||
Return names of folders in tv_root that match the given title + year.
|
Compute destination paths for a movie file.
|
||||||
|
|
||||||
Matching is loose: normalised title (dots, no special chars) + year must
|
Returns movie_folder + library_file (full path to .mkv).
|
||||||
appear at the start of the folder name.
|
|
||||||
"""
|
"""
|
||||||
if not tv_root.exists():
|
memory = get_memory()
|
||||||
return []
|
movies_root = memory.ltm.library_paths.get("movie")
|
||||||
|
if not movies_root:
|
||||||
|
return ResolvedMovieDestination(
|
||||||
|
status="error",
|
||||||
|
error="library_not_set",
|
||||||
|
message="Movie library path is not configured.",
|
||||||
|
)
|
||||||
|
|
||||||
# Build a normalised prefix to match against: "Oz.1997"
|
parsed = parse_release(release_name)
|
||||||
clean_title = _sanitise(tmdb_title).replace(" ", ".")
|
ext = Path(source_file).suffix
|
||||||
prefix = f"{clean_title}.{tmdb_year}".lower()
|
|
||||||
|
|
||||||
matches = []
|
folder_name = _sanitize(parsed.movie_folder_name(tmdb_title, tmdb_year))
|
||||||
for entry in tv_root.iterdir():
|
filename = _sanitize(parsed.movie_filename(tmdb_title, tmdb_year, ext))
|
||||||
if entry.is_dir() and entry.name.lower().startswith(prefix):
|
|
||||||
matches.append(entry.name)
|
|
||||||
|
|
||||||
return sorted(matches)
|
folder_path = Path(movies_root) / folder_name
|
||||||
|
file_path = folder_path / filename
|
||||||
|
|
||||||
|
return ResolvedMovieDestination(
|
||||||
|
status="ok",
|
||||||
|
movie_folder=str(folder_path),
|
||||||
|
library_file=str(file_path),
|
||||||
|
movie_folder_name=folder_name,
|
||||||
|
filename=filename,
|
||||||
|
is_new_folder=not folder_path.exists(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_series_destination(
|
||||||
|
release_name: str,
|
||||||
|
tmdb_title: str,
|
||||||
|
tmdb_year: int,
|
||||||
|
confirmed_folder: str | None = None,
|
||||||
|
) -> ResolvedSeriesDestination:
|
||||||
|
"""
|
||||||
|
Compute destination path for a complete multi-season series pack.
|
||||||
|
|
||||||
|
Returns only series_folder — the whole pack lands directly inside it.
|
||||||
|
"""
|
||||||
|
tv_root = _get_tv_root()
|
||||||
|
if not tv_root:
|
||||||
|
return ResolvedSeriesDestination(
|
||||||
|
status="error",
|
||||||
|
error="library_not_set",
|
||||||
|
message="TV show library path is not configured.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = parse_release(release_name)
|
||||||
|
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||||
|
|
||||||
|
if confirmed_folder:
|
||||||
|
series_folder_name = confirmed_folder
|
||||||
|
is_new = not (tv_root / confirmed_folder).exists()
|
||||||
|
else:
|
||||||
|
existing = _find_existing_tvshow_folders(tv_root, tmdb_title, tmdb_year)
|
||||||
|
|
||||||
|
if len(existing) == 0:
|
||||||
|
series_folder_name = computed_name
|
||||||
|
is_new = True
|
||||||
|
elif len(existing) == 1 and existing[0] == computed_name:
|
||||||
|
series_folder_name = existing[0]
|
||||||
|
is_new = False
|
||||||
|
else:
|
||||||
|
options = existing + (
|
||||||
|
[computed_name] if computed_name not in existing else []
|
||||||
|
)
|
||||||
|
return ResolvedSeriesDestination(
|
||||||
|
status="needs_clarification",
|
||||||
|
question=(
|
||||||
|
f"Un dossier série existe déjà pour '{tmdb_title}' "
|
||||||
|
f"mais son nom diffère du nom calculé ({computed_name}). "
|
||||||
|
f"Lequel utiliser ?"
|
||||||
|
),
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
series_path = tv_root / series_folder_name
|
||||||
|
|
||||||
|
return ResolvedSeriesDestination(
|
||||||
|
status="ok",
|
||||||
|
series_folder=str(series_path),
|
||||||
|
series_folder_name=series_folder_name,
|
||||||
|
is_new_series_folder=is_new,
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class Quality(Enum):
|
|||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_string(cls, quality_str: str) -> "Quality":
|
def from_string(cls, quality_str: str) -> Quality:
|
||||||
"""
|
"""
|
||||||
Parse quality from string.
|
Parse quality from string.
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ Lists are extended additively, scalars from higher layers win.
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import alfred as _alfred_pkg
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
import alfred as _alfred_pkg
|
||||||
|
|
||||||
_BUILTIN_ROOT = Path(_alfred_pkg.__file__).parent / "knowledge" / "release"
|
_BUILTIN_ROOT = Path(_alfred_pkg.__file__).parent / "knowledge" / "release"
|
||||||
_SITES_ROOT = _BUILTIN_ROOT / "sites"
|
_SITES_ROOT = _BUILTIN_ROOT / "sites"
|
||||||
_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release"
|
_LEARNED_ROOT = (
|
||||||
|
Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge" / "release"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _merge(base: dict, overlay: dict) -> dict:
|
def _merge(base: dict, overlay: dict) -> dict:
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .value_objects import (
|
from .value_objects import (
|
||||||
ParsedRelease,
|
|
||||||
_AUDIO,
|
_AUDIO,
|
||||||
_CODECS,
|
_CODECS,
|
||||||
_EDITIONS,
|
_EDITIONS,
|
||||||
@@ -13,9 +12,8 @@ from .value_objects import (
|
|||||||
_MEDIA_TYPE_TOKENS,
|
_MEDIA_TYPE_TOKENS,
|
||||||
_RESOLUTIONS,
|
_RESOLUTIONS,
|
||||||
_SOURCES,
|
_SOURCES,
|
||||||
_VIDEO_EXTENSIONS,
|
|
||||||
_VIDEO_META,
|
_VIDEO_META,
|
||||||
_NON_VIDEO_EXTENSIONS,
|
ParsedRelease,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +65,9 @@ def parse_release(name: str) -> ParsedRelease:
|
|||||||
tech_tokens | lang_tokens | audio_tokens | video_tokens | edition_tokens,
|
tech_tokens | lang_tokens | audio_tokens | video_tokens | edition_tokens,
|
||||||
)
|
)
|
||||||
year = _extract_year(tokens, title)
|
year = _extract_year(tokens, title)
|
||||||
media_type = _infer_media_type(season, quality, source, codec, year, edition, tokens)
|
media_type = _infer_media_type(
|
||||||
|
season, quality, source, codec, year, edition, tokens
|
||||||
|
)
|
||||||
|
|
||||||
tech_parts = [p for p in [quality, source, codec] if p]
|
tech_parts = [p for p in [quality, source, codec] if p]
|
||||||
tech_string = ".".join(tech_parts)
|
tech_string = ".".join(tech_parts)
|
||||||
@@ -126,7 +126,10 @@ def _infer_media_type(
|
|||||||
return "documentary"
|
return "documentary"
|
||||||
if upper_tokens & concert_tokens:
|
if upper_tokens & concert_tokens:
|
||||||
return "concert"
|
return "concert"
|
||||||
if (edition in {"COMPLETE", "INTEGRALE", "COLLECTION"} or upper_tokens & integrale_tokens) and season is None:
|
if (
|
||||||
|
edition in {"COMPLETE", "INTEGRALE", "COLLECTION"}
|
||||||
|
or upper_tokens & integrale_tokens
|
||||||
|
) and season is None:
|
||||||
return "tv_complete"
|
return "tv_complete"
|
||||||
if season is not None:
|
if season is not None:
|
||||||
return "tv_show"
|
return "tv_show"
|
||||||
@@ -172,14 +175,14 @@ def _strip_site_tag(name: str) -> tuple[str, str | None]:
|
|||||||
close = s.find("]")
|
close = s.find("]")
|
||||||
if close != -1:
|
if close != -1:
|
||||||
tag = s[1:close].strip()
|
tag = s[1:close].strip()
|
||||||
remainder = s[close + 1:].strip()
|
remainder = s[close + 1 :].strip()
|
||||||
if tag and remainder:
|
if tag and remainder:
|
||||||
return remainder, tag
|
return remainder, tag
|
||||||
|
|
||||||
if s.endswith("]"):
|
if s.endswith("]"):
|
||||||
open_bracket = s.rfind("[")
|
open_bracket = s.rfind("[")
|
||||||
if open_bracket != -1:
|
if open_bracket != -1:
|
||||||
tag = s[open_bracket + 1:-1].strip()
|
tag = s[open_bracket + 1 : -1].strip()
|
||||||
remainder = s[:open_bracket].strip()
|
remainder = s[:open_bracket].strip()
|
||||||
if tag and remainder:
|
if tag and remainder:
|
||||||
return remainder, tag
|
return remainder, tag
|
||||||
@@ -226,7 +229,9 @@ def _parse_season_episode(tok: str) -> tuple[int, int | None, int | None] | None
|
|||||||
return season, episode, episode_end
|
return season, episode, episode_end
|
||||||
|
|
||||||
|
|
||||||
def _extract_season_episode(tokens: list[str]) -> tuple[int | None, int | None, int | None]:
|
def _extract_season_episode(
|
||||||
|
tokens: list[str],
|
||||||
|
) -> tuple[int | None, int | None, int | None]:
|
||||||
for tok in tokens:
|
for tok in tokens:
|
||||||
parsed = _parse_season_episode(tok)
|
parsed = _parse_season_episode(tok)
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
@@ -333,6 +338,7 @@ def _extract_year(tokens: list[str], title: str) -> int | None:
|
|||||||
# Sequence matcher
|
# Sequence matcher
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _match_sequences(
|
def _match_sequences(
|
||||||
tokens: list[str],
|
tokens: list[str],
|
||||||
sequences: list[dict],
|
sequences: list[dict],
|
||||||
@@ -349,8 +355,8 @@ def _match_sequences(
|
|||||||
seq_upper = [s.upper() for s in seq["tokens"]]
|
seq_upper = [s.upper() for s in seq["tokens"]]
|
||||||
n = len(seq_upper)
|
n = len(seq_upper)
|
||||||
for i in range(len(upper_tokens) - n + 1):
|
for i in range(len(upper_tokens) - n + 1):
|
||||||
if upper_tokens[i:i + n] == seq_upper:
|
if upper_tokens[i : i + n] == seq_upper:
|
||||||
matched = set(tokens[i:i + n])
|
matched = set(tokens[i : i + n])
|
||||||
return seq[key], matched
|
return seq[key], matched
|
||||||
return None, set()
|
return None, set()
|
||||||
|
|
||||||
@@ -359,6 +365,7 @@ def _match_sequences(
|
|||||||
# Language extraction
|
# Language extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]:
|
def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]:
|
||||||
"""Extract language tokens. Returns (languages, matched_token_set)."""
|
"""Extract language tokens. Returns (languages, matched_token_set)."""
|
||||||
languages = []
|
languages = []
|
||||||
@@ -374,6 +381,7 @@ def _extract_languages(tokens: list[str]) -> tuple[list[str], set[str]]:
|
|||||||
# Audio extraction
|
# Audio extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _extract_audio(
|
def _extract_audio(
|
||||||
tokens: list[str],
|
tokens: list[str],
|
||||||
) -> tuple[str | None, str | None, set[str]]:
|
) -> tuple[str | None, str | None, set[str]]:
|
||||||
@@ -391,7 +399,9 @@ def _extract_audio(
|
|||||||
known_channels = set(_AUDIO.get("channels", []))
|
known_channels = set(_AUDIO.get("channels", []))
|
||||||
|
|
||||||
# Try multi-token sequences first
|
# Try multi-token sequences first
|
||||||
matched_codec, matched_set = _match_sequences(tokens, _AUDIO.get("sequences", []), "codec")
|
matched_codec, matched_set = _match_sequences(
|
||||||
|
tokens, _AUDIO.get("sequences", []), "codec"
|
||||||
|
)
|
||||||
if matched_codec:
|
if matched_codec:
|
||||||
audio_codec = matched_codec
|
audio_codec = matched_codec
|
||||||
audio_tokens |= matched_set
|
audio_tokens |= matched_set
|
||||||
@@ -424,6 +434,7 @@ def _extract_audio(
|
|||||||
# Video metadata extraction (bit depth, HDR)
|
# Video metadata extraction (bit depth, HDR)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _extract_video_meta(
|
def _extract_video_meta(
|
||||||
tokens: list[str],
|
tokens: list[str],
|
||||||
) -> tuple[str | None, str | None, set[str]]:
|
) -> tuple[str | None, str | None, set[str]]:
|
||||||
@@ -440,7 +451,9 @@ def _extract_video_meta(
|
|||||||
known_depth = {d.lower() for d in _VIDEO_META.get("bit_depth", [])}
|
known_depth = {d.lower() for d in _VIDEO_META.get("bit_depth", [])}
|
||||||
|
|
||||||
# Try HDR sequences first
|
# Try HDR sequences first
|
||||||
matched_hdr, matched_set = _match_sequences(tokens, _VIDEO_META.get("sequences", []), "hdr")
|
matched_hdr, matched_set = _match_sequences(
|
||||||
|
tokens, _VIDEO_META.get("sequences", []), "hdr"
|
||||||
|
)
|
||||||
if matched_hdr:
|
if matched_hdr:
|
||||||
hdr_format = matched_hdr
|
hdr_format = matched_hdr
|
||||||
video_tokens |= matched_set
|
video_tokens |= matched_set
|
||||||
@@ -462,6 +475,7 @@ def _extract_video_meta(
|
|||||||
# Edition extraction
|
# Edition extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _extract_edition(tokens: list[str]) -> tuple[str | None, set[str]]:
|
def _extract_edition(tokens: list[str]) -> tuple[str | None, set[str]]:
|
||||||
"""
|
"""
|
||||||
Extract release edition (UNRATED, EXTENDED, DIRECTORS.CUT, …).
|
Extract release edition (UNRATED, EXTENDED, DIRECTORS.CUT, …).
|
||||||
|
|||||||
@@ -86,8 +86,12 @@ class ParsedRelease:
|
|||||||
codec: str | None # x265, HEVC, …
|
codec: str | None # x265, HEVC, …
|
||||||
group: str # release group, "UNKNOWN" if missing
|
group: str # release group, "UNKNOWN" if missing
|
||||||
tech_string: str # quality.source.codec joined with dots
|
tech_string: str # quality.source.codec joined with dots
|
||||||
media_type: str = "unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown"
|
media_type: str = (
|
||||||
site_tag: str | None = None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
|
"unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown"
|
||||||
|
)
|
||||||
|
site_tag: str | None = (
|
||||||
|
None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
|
||||||
|
)
|
||||||
parse_path: str = "direct" # "direct" | "sanitized" | "ai"
|
parse_path: str = "direct" # "direct" | "sanitized" | "ai"
|
||||||
languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], …
|
languages: list[str] = None # ["MULTI", "VFF"], ["FRENCH"], …
|
||||||
audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", …
|
audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", …
|
||||||
|
|||||||
@@ -63,20 +63,32 @@ class MediaInfo:
|
|||||||
return None
|
return None
|
||||||
case (w, h) if w is not None:
|
case (w, h) if w is not None:
|
||||||
match True:
|
match True:
|
||||||
case _ if w >= 3840: return "2160p"
|
case _ if w >= 3840:
|
||||||
case _ if w >= 1920: return "1080p"
|
return "2160p"
|
||||||
case _ if w >= 1280: return "720p"
|
case _ if w >= 1920:
|
||||||
case _ if w >= 720: return "576p"
|
return "1080p"
|
||||||
case _ if w >= 640: return "480p"
|
case _ if w >= 1280:
|
||||||
case _: return f"{h}p" if h else f"{w}w"
|
return "720p"
|
||||||
|
case _ if w >= 720:
|
||||||
|
return "576p"
|
||||||
|
case _ if w >= 640:
|
||||||
|
return "480p"
|
||||||
|
case _:
|
||||||
|
return f"{h}p" if h else f"{w}w"
|
||||||
case (None, h):
|
case (None, h):
|
||||||
match True:
|
match True:
|
||||||
case _ if h >= 2160: return "2160p"
|
case _ if h >= 2160:
|
||||||
case _ if h >= 1080: return "1080p"
|
return "2160p"
|
||||||
case _ if h >= 720: return "720p"
|
case _ if h >= 1080:
|
||||||
case _ if h >= 576: return "576p"
|
return "1080p"
|
||||||
case _ if h >= 480: return "480p"
|
case _ if h >= 720:
|
||||||
case _: return f"{h}p"
|
return "720p"
|
||||||
|
case _ if h >= 576:
|
||||||
|
return "576p"
|
||||||
|
case _ if h >= 480:
|
||||||
|
return "480p"
|
||||||
|
case _:
|
||||||
|
return f"{h}p"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def audio_languages(self) -> list[str]:
|
def audio_languages(self) -> list[str]:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SubtitleRuleSet:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
scope: RuleScope
|
scope: RuleScope
|
||||||
parent: "SubtitleRuleSet | None" = None
|
parent: SubtitleRuleSet | None = None
|
||||||
pinned_to: ImdbId | None = None
|
pinned_to: ImdbId | None = None
|
||||||
|
|
||||||
# Deltas — None = inherit
|
# Deltas — None = inherit
|
||||||
@@ -47,7 +47,9 @@ class SubtitleRuleSet:
|
|||||||
preferred_formats=self._formats or base.preferred_formats,
|
preferred_formats=self._formats or base.preferred_formats,
|
||||||
allowed_types=self._types or base.allowed_types,
|
allowed_types=self._types or base.allowed_types,
|
||||||
format_priority=self._format_priority or base.format_priority,
|
format_priority=self._format_priority or base.format_priority,
|
||||||
min_confidence=self._min_confidence if self._min_confidence is not None else base.min_confidence,
|
min_confidence=self._min_confidence
|
||||||
|
if self._min_confidence is not None
|
||||||
|
else base.min_confidence,
|
||||||
)
|
)
|
||||||
|
|
||||||
def override(
|
def override(
|
||||||
@@ -83,8 +85,11 @@ class SubtitleRuleSet:
|
|||||||
delta["format_priority"] = self._format_priority
|
delta["format_priority"] = self._format_priority
|
||||||
if self._min_confidence is not None:
|
if self._min_confidence is not None:
|
||||||
delta["min_confidence"] = self._min_confidence
|
delta["min_confidence"] = self._min_confidence
|
||||||
return {"scope": {"level": self.scope.level, "identifier": self.scope.identifier}, "override": delta}
|
return {
|
||||||
|
"scope": {"level": self.scope.level, "identifier": self.scope.identifier},
|
||||||
|
"override": delta,
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def global_default(cls) -> "SubtitleRuleSet":
|
def global_default(cls) -> SubtitleRuleSet:
|
||||||
return cls(scope=RuleScope(level="global"))
|
return cls(scope=RuleScope(level="global"))
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..shared.value_objects import ImdbId
|
from ..shared.value_objects import ImdbId
|
||||||
from .value_objects import SubtitleFormat, SubtitleLanguage, SubtitleMatchingRules, SubtitleType
|
from .value_objects import (
|
||||||
|
SubtitleFormat,
|
||||||
|
SubtitleLanguage,
|
||||||
|
SubtitleType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -29,7 +33,9 @@ class SubtitleTrack:
|
|||||||
|
|
||||||
# Matching state
|
# Matching state
|
||||||
confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded
|
confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded
|
||||||
raw_tokens: list[str] = field(default_factory=list) # tokens extracted from filename
|
raw_tokens: list[str] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # tokens extracted from filename
|
||||||
|
|
||||||
def is_resolved(self) -> bool:
|
def is_resolved(self) -> bool:
|
||||||
return self.language is not None
|
return self.language is not None
|
||||||
@@ -43,7 +49,9 @@ class SubtitleTrack:
|
|||||||
{lang}.forced.{ext}
|
{lang}.forced.{ext}
|
||||||
"""
|
"""
|
||||||
if not self.language or not self.format:
|
if not self.language or not self.format:
|
||||||
raise ValueError("Cannot compute destination_name: language or format missing")
|
raise ValueError(
|
||||||
|
"Cannot compute destination_name: language or format missing"
|
||||||
|
)
|
||||||
ext = self.format.extensions[0].lstrip(".")
|
ext = self.format.extensions[0].lstrip(".")
|
||||||
parts = [self.language.code]
|
parts = [self.language.code]
|
||||||
if self.subtitle_type == SubtitleType.SDH:
|
if self.subtitle_type == SubtitleType.SDH:
|
||||||
@@ -55,7 +63,11 @@ class SubtitleTrack:
|
|||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
lang = self.language.code if self.language else "?"
|
lang = self.language.code if self.language else "?"
|
||||||
fmt = self.format.id if self.format else "?"
|
fmt = self.format.id if self.format else "?"
|
||||||
src = "embedded" if self.is_embedded else str(self.file_path.name if self.file_path else "?")
|
src = (
|
||||||
|
"embedded"
|
||||||
|
if self.is_embedded
|
||||||
|
else str(self.file_path.name if self.file_path else "?")
|
||||||
|
)
|
||||||
return f"SubtitleTrack({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})"
|
return f"SubtitleTrack({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""SubtitleKnowledgeBase — parsed, typed view of the loaded knowledge."""
|
"""SubtitleKnowledgeBase — parsed, typed view of the loaded knowledge."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from functools import cached_property
|
|
||||||
|
|
||||||
from ..value_objects import (
|
from ..value_objects import (
|
||||||
ScanStrategy,
|
ScanStrategy,
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ class KnowledgeLoader:
|
|||||||
data = _load_yaml(path)
|
data = _load_yaml(path)
|
||||||
pid = data.get("id", path.stem)
|
pid = data.get("id", path.stem)
|
||||||
if pid in self._cache["patterns"]:
|
if pid in self._cache["patterns"]:
|
||||||
self._cache["patterns"][pid] = _merge(self._cache["patterns"][pid], data)
|
self._cache["patterns"][pid] = _merge(
|
||||||
|
self._cache["patterns"][pid], data
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._cache["patterns"][pid] = data
|
self._cache["patterns"][pid] = data
|
||||||
logger.info(f"KnowledgeLoader: learned new pattern '{pid}'")
|
logger.info(f"KnowledgeLoader: learned new pattern '{pid}'")
|
||||||
@@ -100,7 +102,9 @@ class KnowledgeLoader:
|
|||||||
data = _load_yaml(path)
|
data = _load_yaml(path)
|
||||||
name = data.get("name", path.stem)
|
name = data.get("name", path.stem)
|
||||||
if name in self._cache["release_groups"]:
|
if name in self._cache["release_groups"]:
|
||||||
self._cache["release_groups"][name] = _merge(self._cache["release_groups"][name], data)
|
self._cache["release_groups"][name] = _merge(
|
||||||
|
self._cache["release_groups"][name], data
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._cache["release_groups"][name] = data
|
self._cache["release_groups"][name] = data
|
||||||
logger.info(f"KnowledgeLoader: learned new release group '{name}'")
|
logger.info(f"KnowledgeLoader: learned new release group '{name}'")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Output naming convention (matches SubtitlePreferences docstring):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -120,6 +120,7 @@ def _classify(path: Path) -> tuple[str | None, bool, bool]:
|
|||||||
stem = path.stem.lower()
|
stem = path.stem.lower()
|
||||||
# Split on dots, spaces, underscores, hyphens
|
# Split on dots, spaces, underscores, hyphens
|
||||||
import re
|
import re
|
||||||
|
|
||||||
tokens = re.split(r"[\.\s_\-]+", stem)
|
tokens = re.split(r"[\.\s_\-]+", stem)
|
||||||
|
|
||||||
language: str | None = None
|
language: str | None = None
|
||||||
@@ -147,7 +148,9 @@ class SubtitleScanner:
|
|||||||
# Each candidate has .source_path and .destination_name
|
# Each candidate has .source_path and .destination_name
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, languages: list[str], min_size_kb: int, keep_sdh: bool, keep_forced: bool):
|
def __init__(
|
||||||
|
self, languages: list[str], min_size_kb: int, keep_sdh: bool, keep_forced: bool
|
||||||
|
):
|
||||||
self.languages = [l.lower() for l in languages]
|
self.languages = [l.lower() for l in languages]
|
||||||
self.min_size_kb = min_size_kb
|
self.min_size_kb = min_size_kb
|
||||||
self.keep_sdh = keep_sdh
|
self.keep_sdh = keep_sdh
|
||||||
@@ -180,7 +183,9 @@ class SubtitleScanner:
|
|||||||
if candidate is not None:
|
if candidate is not None:
|
||||||
candidates.append(candidate)
|
candidates.append(candidate)
|
||||||
|
|
||||||
logger.info(f"SubtitleScanner: {len(candidates)} candidate(s) found for {video_path.name}")
|
logger.info(
|
||||||
|
f"SubtitleScanner: {len(candidates)} candidate(s) found for {video_path.name}"
|
||||||
|
)
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
def _evaluate(self, path: Path) -> SubtitleCandidate | None:
|
def _evaluate(self, path: Path) -> SubtitleCandidate | None:
|
||||||
@@ -188,7 +193,9 @@ class SubtitleScanner:
|
|||||||
# Size filter
|
# Size filter
|
||||||
size_kb = path.stat().st_size / 1024
|
size_kb = path.stat().st_size / 1024
|
||||||
if size_kb < self.min_size_kb:
|
if size_kb < self.min_size_kb:
|
||||||
logger.debug(f"SubtitleScanner: skip {path.name} (too small: {size_kb:.1f} KB)")
|
logger.debug(
|
||||||
|
f"SubtitleScanner: skip {path.name} (too small: {size_kb:.1f} KB)"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
language, is_sdh, is_forced = _classify(path)
|
language, is_sdh, is_forced = _classify(path)
|
||||||
@@ -199,7 +206,9 @@ class SubtitleScanner:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if language not in self.languages:
|
if language not in self.languages:
|
||||||
logger.debug(f"SubtitleScanner: skip {path.name} (language '{language}' not in prefs)")
|
logger.debug(
|
||||||
|
f"SubtitleScanner: skip {path.name} (language '{language}' not in prefs)"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# SDH filter
|
# SDH filter
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""SubtitleIdentifier — finds and classifies all subtitle tracks for a video file."""
|
"""SubtitleIdentifier — finds and classifies all subtitle tracks for a video file."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import json
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...shared.value_objects import ImdbId
|
from ...shared.value_objects import ImdbId
|
||||||
@@ -15,10 +15,28 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def _tokenize(name: str) -> list[str]:
|
def _tokenize(name: str) -> list[str]:
|
||||||
"""Split a filename stem into lowercase tokens."""
|
"""Split a filename stem into lowercase tokens, stripping parentheses."""
|
||||||
|
# Strip parenthesized qualifiers like (simplified), (canada), (brazil)
|
||||||
|
name = re.sub(r"\([^)]*\)", "", name)
|
||||||
return [t.lower() for t in re.split(r"[\.\s_\-]+", name) if t]
|
return [t.lower() for t in re.split(r"[\.\s_\-]+", name) if t]
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize_suffix(stem: str, episode_stem: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
For episode_subfolder pattern: the filename is {episode_stem}.{lang_tokens}.
|
||||||
|
Return only the tokens that come after the episode stem portion.
|
||||||
|
Falls back to full tokenization if the stem doesn't start with episode_stem.
|
||||||
|
"""
|
||||||
|
stem_lower = stem.lower()
|
||||||
|
prefix = episode_stem.lower()
|
||||||
|
if stem_lower.startswith(prefix):
|
||||||
|
suffix = stem[len(prefix) :]
|
||||||
|
tokens = _tokenize(suffix)
|
||||||
|
if tokens:
|
||||||
|
return tokens
|
||||||
|
return _tokenize(stem)
|
||||||
|
|
||||||
|
|
||||||
def _count_entries(path: Path) -> int:
|
def _count_entries(path: Path) -> int:
|
||||||
"""Return the entry count of an SRT file by finding the last cue number."""
|
"""Return the entry count of an SRT file by finding the last cue number."""
|
||||||
try:
|
try:
|
||||||
@@ -79,17 +97,29 @@ class SubtitleIdentifier:
|
|||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"ffprobe", "-v", "quiet",
|
"ffprobe",
|
||||||
"-print_format", "json",
|
"-v",
|
||||||
|
"quiet",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
"-show_streams",
|
"-show_streams",
|
||||||
"-select_streams", "s",
|
"-select_streams",
|
||||||
|
"s",
|
||||||
str(video_path),
|
str(video_path),
|
||||||
],
|
],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
|
except (
|
||||||
logger.debug(f"SubtitleIdentifier: ffprobe failed for {video_path.name}: {e}")
|
subprocess.TimeoutExpired,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
FileNotFoundError,
|
||||||
|
) as e:
|
||||||
|
logger.debug(
|
||||||
|
f"SubtitleIdentifier: ffprobe failed for {video_path.name}: {e}"
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
@@ -108,39 +138,50 @@ class SubtitleIdentifier:
|
|||||||
else:
|
else:
|
||||||
stype = SubtitleType.STANDARD
|
stype = SubtitleType.STANDARD
|
||||||
|
|
||||||
tracks.append(SubtitleTrack(
|
tracks.append(
|
||||||
|
SubtitleTrack(
|
||||||
language=lang,
|
language=lang,
|
||||||
format=None,
|
format=None,
|
||||||
subtitle_type=stype,
|
subtitle_type=stype,
|
||||||
is_embedded=True,
|
is_embedded=True,
|
||||||
raw_tokens=[lang_code] if lang_code else [],
|
raw_tokens=[lang_code] if lang_code else [],
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}")
|
logger.debug(
|
||||||
|
f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}"
|
||||||
|
)
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# External tracks — filesystem scan per pattern strategy
|
# External tracks — filesystem scan per pattern strategy
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _scan_external(self, video_path: Path, pattern: SubtitlePattern) -> list[SubtitleTrack]:
|
def _scan_external(
|
||||||
|
self, video_path: Path, pattern: SubtitlePattern
|
||||||
|
) -> list[SubtitleTrack]:
|
||||||
strategy = pattern.scan_strategy
|
strategy = pattern.scan_strategy
|
||||||
|
episode_stem: str | None = None
|
||||||
|
|
||||||
if strategy == ScanStrategy.ADJACENT:
|
if strategy == ScanStrategy.ADJACENT:
|
||||||
candidates = self._find_adjacent(video_path)
|
candidates = self._find_adjacent(video_path)
|
||||||
elif strategy == ScanStrategy.FLAT:
|
elif strategy == ScanStrategy.FLAT:
|
||||||
candidates = self._find_flat(video_path, pattern.root_folder or "Subs")
|
candidates = self._find_flat(video_path, pattern.root_folder or "Subs")
|
||||||
elif strategy == ScanStrategy.EPISODE_SUBFOLDER:
|
elif strategy == ScanStrategy.EPISODE_SUBFOLDER:
|
||||||
candidates = self._find_episode_subfolder(video_path, pattern.root_folder or "Subs")
|
candidates, episode_stem = self._find_episode_subfolder(
|
||||||
|
video_path, pattern.root_folder or "Subs"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return self._classify_files(candidates, pattern)
|
return self._classify_files(candidates, pattern, episode_stem=episode_stem)
|
||||||
|
|
||||||
def _find_adjacent(self, video_path: Path) -> list[Path]:
|
def _find_adjacent(self, video_path: Path) -> list[Path]:
|
||||||
return [
|
return [
|
||||||
p for p in sorted(video_path.parent.iterdir())
|
p
|
||||||
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
|
for p in sorted(video_path.parent.iterdir())
|
||||||
|
if p.is_file()
|
||||||
|
and p.suffix.lower() in self.kb.known_extensions()
|
||||||
and p.stem != video_path.stem
|
and p.stem != video_path.stem
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -152,17 +193,22 @@ class SubtitleIdentifier:
|
|||||||
if not subs_dir.is_dir():
|
if not subs_dir.is_dir():
|
||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
p for p in sorted(subs_dir.iterdir())
|
p
|
||||||
|
for p in sorted(subs_dir.iterdir())
|
||||||
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
|
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
|
||||||
]
|
]
|
||||||
|
|
||||||
def _find_episode_subfolder(self, video_path: Path, root_folder: str) -> list[Path]:
|
def _find_episode_subfolder(
|
||||||
|
self, video_path: Path, root_folder: str
|
||||||
|
) -> tuple[list[Path], str]:
|
||||||
"""
|
"""
|
||||||
Look for Subs/{episode_stem}/*.srt
|
Look for Subs/{episode_stem}/*.srt
|
||||||
|
|
||||||
Checks two locations:
|
Checks two locations:
|
||||||
1. Adjacent to the video: video_path.parent / root_folder / video_path.stem
|
1. Adjacent to the video: video_path.parent / root_folder / video_path.stem
|
||||||
2. Release root (one level up): video_path.parent.parent / root_folder / video_path.stem
|
2. Release root (one level up): video_path.parent.parent / root_folder / video_path.stem
|
||||||
|
|
||||||
|
Returns (files, episode_stem) so the classifier can strip the prefix.
|
||||||
"""
|
"""
|
||||||
episode_stem = video_path.stem
|
episode_stem = video_path.stem
|
||||||
candidates_dirs = [
|
candidates_dirs = [
|
||||||
@@ -172,22 +218,30 @@ class SubtitleIdentifier:
|
|||||||
for subs_dir in candidates_dirs:
|
for subs_dir in candidates_dirs:
|
||||||
if subs_dir.is_dir():
|
if subs_dir.is_dir():
|
||||||
files = [
|
files = [
|
||||||
p for p in sorted(subs_dir.iterdir())
|
p
|
||||||
|
for p in sorted(subs_dir.iterdir())
|
||||||
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
|
if p.is_file() and p.suffix.lower() in self.kb.known_extensions()
|
||||||
]
|
]
|
||||||
if files:
|
if files:
|
||||||
logger.debug(f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}")
|
logger.debug(
|
||||||
return files
|
f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}"
|
||||||
return []
|
)
|
||||||
|
return files, episode_stem
|
||||||
|
return [], episode_stem
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Classification
|
# Classification
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _classify_files(self, paths: list[Path], pattern: SubtitlePattern) -> list[SubtitleTrack]:
|
def _classify_files(
|
||||||
|
self,
|
||||||
|
paths: list[Path],
|
||||||
|
pattern: SubtitlePattern,
|
||||||
|
episode_stem: str | None = None,
|
||||||
|
) -> list[SubtitleTrack]:
|
||||||
tracks = []
|
tracks = []
|
||||||
for path in paths:
|
for path in paths:
|
||||||
track = self._classify_single(path)
|
track = self._classify_single(path, episode_stem=episode_stem)
|
||||||
tracks.append(track)
|
tracks.append(track)
|
||||||
|
|
||||||
# Post-process: if multiple tracks share same language but type is ambiguous,
|
# Post-process: if multiple tracks share same language but type is ambiguous,
|
||||||
@@ -197,9 +251,15 @@ class SubtitleIdentifier:
|
|||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
def _classify_single(self, path: Path) -> SubtitleTrack:
|
def _classify_single(
|
||||||
|
self, path: Path, episode_stem: str | None = None
|
||||||
|
) -> SubtitleTrack:
|
||||||
fmt = self.kb.format_for_extension(path.suffix)
|
fmt = self.kb.format_for_extension(path.suffix)
|
||||||
tokens = _tokenize(path.stem)
|
tokens = (
|
||||||
|
_tokenize_suffix(path.stem, episode_stem)
|
||||||
|
if episode_stem
|
||||||
|
else _tokenize(path.stem)
|
||||||
|
)
|
||||||
|
|
||||||
language = None
|
language = None
|
||||||
subtitle_type = SubtitleType.UNKNOWN
|
subtitle_type = SubtitleType.UNKNOWN
|
||||||
@@ -250,7 +310,6 @@ class SubtitleIdentifier:
|
|||||||
|
|
||||||
Only applied when type_detection = size_and_count.
|
Only applied when type_detection = size_and_count.
|
||||||
"""
|
"""
|
||||||
from itertools import groupby
|
|
||||||
|
|
||||||
# Group by language code
|
# Group by language code
|
||||||
lang_groups: dict[str, list[SubtitleTrack]] = {}
|
lang_groups: dict[str, list[SubtitleTrack]] = {}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ..entities import SubtitleTrack
|
from ..entities import SubtitleTrack
|
||||||
from ..value_objects import SubtitleMatchingRules, SubtitleType
|
from ..value_objects import SubtitleMatchingRules
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,7 +50,9 @@ class SubtitleMatcher:
|
|||||||
)
|
)
|
||||||
return matched, unresolved
|
return matched, unresolved
|
||||||
|
|
||||||
def _passes_filters(self, track: SubtitleTrack, rules: SubtitleMatchingRules) -> bool:
|
def _passes_filters(
|
||||||
|
self, track: SubtitleTrack, rules: SubtitleMatchingRules
|
||||||
|
) -> bool:
|
||||||
# Language filter
|
# Language filter
|
||||||
if rules.preferred_languages:
|
if rules.preferred_languages:
|
||||||
if not track.language:
|
if not track.language:
|
||||||
|
|||||||
@@ -49,13 +49,19 @@ class PatternDetector:
|
|||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"ffprobe", "-v", "quiet",
|
"ffprobe",
|
||||||
"-print_format", "json",
|
"-v",
|
||||||
|
"quiet",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
"-show_streams",
|
"-show_streams",
|
||||||
"-select_streams", "s",
|
"-select_streams",
|
||||||
|
"s",
|
||||||
str(video_path),
|
str(video_path),
|
||||||
],
|
],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
return len(data.get("streams", [])) > 0
|
return len(data.get("streams", [])) > 0
|
||||||
@@ -87,15 +93,22 @@ class PatternDetector:
|
|||||||
|
|
||||||
# Is it flat or episode_subfolder?
|
# Is it flat or episode_subfolder?
|
||||||
children = list(subs_candidate.iterdir())
|
children = list(subs_candidate.iterdir())
|
||||||
sub_files = [c for c in children if c.is_file() and c.suffix.lower() in known_exts]
|
sub_files = [
|
||||||
|
c
|
||||||
|
for c in children
|
||||||
|
if c.is_file() and c.suffix.lower() in known_exts
|
||||||
|
]
|
||||||
sub_dirs = [c for c in children if c.is_dir()]
|
sub_dirs = [c for c in children if c.is_dir()]
|
||||||
|
|
||||||
if sub_dirs and not sub_files:
|
if sub_dirs and not sub_files:
|
||||||
findings["subs_strategy"] = "episode_subfolder"
|
findings["subs_strategy"] = "episode_subfolder"
|
||||||
# Count files in a sample subfolder
|
# Count files in a sample subfolder
|
||||||
sample_sub = sub_dirs[0]
|
sample_sub = sub_dirs[0]
|
||||||
sample_files = [f for f in sample_sub.iterdir()
|
sample_files = [
|
||||||
if f.is_file() and f.suffix.lower() in known_exts]
|
f
|
||||||
|
for f in sample_sub.iterdir()
|
||||||
|
if f.is_file() and f.suffix.lower() in known_exts
|
||||||
|
]
|
||||||
findings["files_per_episode"] = len(sample_files)
|
findings["files_per_episode"] = len(sample_files)
|
||||||
# Check naming conventions
|
# Check naming conventions
|
||||||
for f in sample_files:
|
for f in sample_files:
|
||||||
@@ -103,22 +116,27 @@ class PatternDetector:
|
|||||||
parts = stem.split("_")
|
parts = stem.split("_")
|
||||||
if parts[0].isdigit():
|
if parts[0].isdigit():
|
||||||
findings["has_numeric_prefix"] = True
|
findings["has_numeric_prefix"] = True
|
||||||
if any(self.kb.is_known_lang_token(t.lower())
|
if any(
|
||||||
for t in stem.replace("_", ".").split(".")):
|
self.kb.is_known_lang_token(t.lower())
|
||||||
|
for t in stem.replace("_", ".").split(".")
|
||||||
|
):
|
||||||
findings["has_lang_tokens"] = True
|
findings["has_lang_tokens"] = True
|
||||||
else:
|
else:
|
||||||
findings["subs_strategy"] = "flat"
|
findings["subs_strategy"] = "flat"
|
||||||
findings["files_per_episode"] = len(sub_files)
|
findings["files_per_episode"] = len(sub_files)
|
||||||
for f in sub_files:
|
for f in sub_files:
|
||||||
if any(self.kb.is_known_lang_token(t.lower())
|
if any(
|
||||||
for t in f.stem.replace("_", ".").split(".")):
|
self.kb.is_known_lang_token(t.lower())
|
||||||
|
for t in f.stem.replace("_", ".").split(".")
|
||||||
|
):
|
||||||
findings["has_lang_tokens"] = True
|
findings["has_lang_tokens"] = True
|
||||||
break
|
break
|
||||||
|
|
||||||
# Check adjacent subs (next to the video)
|
# Check adjacent subs (next to the video)
|
||||||
if not findings["has_subs_folder"]:
|
if not findings["has_subs_folder"]:
|
||||||
adjacent = [
|
adjacent = [
|
||||||
p for p in sample_video.parent.iterdir()
|
p
|
||||||
|
for p in sample_video.parent.iterdir()
|
||||||
if p.is_file() and p.suffix.lower() in known_exts
|
if p.is_file() and p.suffix.lower() in known_exts
|
||||||
]
|
]
|
||||||
if adjacent:
|
if adjacent:
|
||||||
@@ -157,7 +175,9 @@ class PatternDetector:
|
|||||||
total += 1
|
total += 1
|
||||||
if findings.get("has_embedded"):
|
if findings.get("has_embedded"):
|
||||||
score += 1.0
|
score += 1.0
|
||||||
if not findings.get("has_subs_folder") and not findings.get("adjacent_subs"):
|
if not findings.get("has_subs_folder") and not findings.get(
|
||||||
|
"adjacent_subs"
|
||||||
|
):
|
||||||
score += 0.5
|
score += 0.5
|
||||||
total += 0.5
|
total += 0.5
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,28 @@ from ..entities import SubtitleTrack
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dest_name(track: SubtitleTrack, video_stem: str) -> str:
|
||||||
|
"""
|
||||||
|
Build the destination filename for a subtitle track.
|
||||||
|
|
||||||
|
Format: {video_stem}.{lang}.{ext}
|
||||||
|
{video_stem}.{lang}.sdh.{ext}
|
||||||
|
{video_stem}.{lang}.forced.{ext}
|
||||||
|
"""
|
||||||
|
from ..value_objects import SubtitleType
|
||||||
|
|
||||||
|
if not track.language or not track.format:
|
||||||
|
raise ValueError("Cannot compute destination name: language or format missing")
|
||||||
|
|
||||||
|
ext = track.format.extensions[0].lstrip(".")
|
||||||
|
parts = [video_stem, track.language.code]
|
||||||
|
if track.subtitle_type == SubtitleType.SDH:
|
||||||
|
parts.append("sdh")
|
||||||
|
elif track.subtitle_type == SubtitleType.FORCED:
|
||||||
|
parts.append("forced")
|
||||||
|
return ".".join(parts) + "." + ext
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlacedTrack:
|
class PlacedTrack:
|
||||||
source: Path
|
source: Path
|
||||||
@@ -62,7 +84,7 @@ class SubtitlePlacer:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dest_name = track.destination_name
|
dest_name = _build_dest_name(track, destination_video.stem)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
skipped.append((track, str(e)))
|
skipped.append((track, str(e)))
|
||||||
continue
|
continue
|
||||||
@@ -76,11 +98,13 @@ class SubtitlePlacer:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
os.link(track.file_path, dest_path)
|
os.link(track.file_path, dest_path)
|
||||||
placed.append(PlacedTrack(
|
placed.append(
|
||||||
|
PlacedTrack(
|
||||||
source=track.file_path,
|
source=track.file_path,
|
||||||
destination=dest_path,
|
destination=dest_path,
|
||||||
filename=dest_name,
|
filename=dest_name,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
logger.info(f"SubtitlePlacer: placed {dest_name}")
|
logger.info(f"SubtitlePlacer: placed {dest_name}")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning(f"SubtitlePlacer: failed to place {dest_name}: {e}")
|
logger.warning(f"SubtitlePlacer: failed to place {dest_name}: {e}")
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class ScanStrategy(Enum):
|
class ScanStrategy(Enum):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""TV Show domain entities."""
|
"""TV Show domain entities."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from ..shared.value_objects import FilePath, FileSize, ImdbId
|
from ..shared.value_objects import FilePath, FileSize, ImdbId
|
||||||
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class ShowStatus(Enum):
|
|||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_string(cls, status_str: str) -> "ShowStatus":
|
def from_string(cls, status_str: str) -> ShowStatus:
|
||||||
"""
|
"""
|
||||||
Parse status from string.
|
Parse status from string.
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ class QBittorrentClient:
|
|||||||
"""
|
"""
|
||||||
cfg = config or settings
|
cfg = config or settings
|
||||||
|
|
||||||
self.host = host or "http://192.168.178.47:30024"
|
self.host = host or cfg.qbittorrent_url
|
||||||
self.username = username or "admin"
|
self.username = username or cfg.qbittorrent_username
|
||||||
self.password = password or "adminadmin"
|
self.password = password or cfg.qbittorrent_password
|
||||||
self.timeout = timeout or cfg.request_timeout
|
self.timeout = timeout or cfg.request_timeout
|
||||||
|
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
@@ -336,6 +336,90 @@ class QBittorrentClient:
|
|||||||
logger.error(f"Failed to resume torrent: {e}")
|
logger.error(f"Failed to resume torrent: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def find_by_name(self, name: str) -> TorrentInfo | None:
|
||||||
|
"""
|
||||||
|
Find a torrent by release folder name.
|
||||||
|
|
||||||
|
Matching strategy (in order):
|
||||||
|
1. Exact name match (torrent.name == name)
|
||||||
|
2. Case-insensitive name match
|
||||||
|
3. save_path ends with the name (folder moved but name intact)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Release folder name (e.g. "Foundation.2021.S01.1080p...")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TorrentInfo if found, None otherwise
|
||||||
|
"""
|
||||||
|
torrents = self.get_torrents()
|
||||||
|
|
||||||
|
# 1. Exact
|
||||||
|
for t in torrents:
|
||||||
|
if t.name == name:
|
||||||
|
return t
|
||||||
|
|
||||||
|
# 2. Case-insensitive
|
||||||
|
name_lower = name.lower()
|
||||||
|
for t in torrents:
|
||||||
|
if t.name.lower() == name_lower:
|
||||||
|
return t
|
||||||
|
|
||||||
|
# 3. save_path ends with the folder name
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
for t in torrents:
|
||||||
|
if t.save_path and Path(t.save_path).name.lower() == name_lower:
|
||||||
|
return t
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_location(self, torrent_hash: str, location: str) -> bool:
|
||||||
|
"""
|
||||||
|
Change the save path of a torrent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
torrent_hash: Hash of the torrent
|
||||||
|
location: New save path (must exist on the server)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if location changed successfully
|
||||||
|
"""
|
||||||
|
if not self._authenticated:
|
||||||
|
self.login()
|
||||||
|
|
||||||
|
data = {"hashes": torrent_hash, "location": location}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._make_request("POST", "/api/v2/torrents/setLocation", data=data)
|
||||||
|
logger.info(f"Set location for {torrent_hash} → {location}")
|
||||||
|
return True
|
||||||
|
except QBittorrentAPIError as e:
|
||||||
|
logger.error(f"Failed to set location for {torrent_hash}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def recheck(self, torrent_hash: str) -> bool:
|
||||||
|
"""
|
||||||
|
Force recheck (hash verification) of a torrent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
torrent_hash: Hash of the torrent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if recheck triggered successfully
|
||||||
|
"""
|
||||||
|
if not self._authenticated:
|
||||||
|
self.login()
|
||||||
|
|
||||||
|
data = {"hashes": torrent_hash}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._make_request("POST", "/api/v2/torrents/recheck", data=data)
|
||||||
|
logger.info(f"Recheck triggered for {torrent_hash}")
|
||||||
|
return True
|
||||||
|
except QBittorrentAPIError as e:
|
||||||
|
logger.error(f"Failed to recheck {torrent_hash}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def get_torrent_properties(self, torrent_hash: str) -> dict[str, Any]:
|
def get_torrent_properties(self, torrent_hash: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get detailed properties of a torrent.
|
Get detailed properties of a torrent.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from .exceptions import FilesystemError, PathTraversalError
|
from .exceptions import FilesystemError, PathTraversalError
|
||||||
from .file_manager import FileManager
|
from .file_manager import FileManager
|
||||||
|
from .filesystem_operations import create_folder, move
|
||||||
from .organizer import MediaOrganizer
|
from .organizer import MediaOrganizer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -9,4 +10,6 @@ __all__ = [
|
|||||||
"MediaOrganizer",
|
"MediaOrganizer",
|
||||||
"FilesystemError",
|
"FilesystemError",
|
||||||
"PathTraversalError",
|
"PathTraversalError",
|
||||||
|
"create_folder",
|
||||||
|
"move",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_FFPROBE_CMD = [
|
_FFPROBE_CMD = [
|
||||||
"ffprobe",
|
"ffprobe",
|
||||||
"-v", "quiet",
|
"-v",
|
||||||
"-print_format", "json",
|
"quiet",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
"-show_streams",
|
"-show_streams",
|
||||||
"-show_format",
|
"-show_format",
|
||||||
]
|
]
|
||||||
@@ -77,22 +79,26 @@ def _parse(data: dict) -> MediaInfo:
|
|||||||
info.height = stream.get("height")
|
info.height = stream.get("height")
|
||||||
|
|
||||||
elif codec_type == "audio":
|
elif codec_type == "audio":
|
||||||
info.audio_tracks.append(AudioTrack(
|
info.audio_tracks.append(
|
||||||
|
AudioTrack(
|
||||||
index=stream.get("index", len(info.audio_tracks)),
|
index=stream.get("index", len(info.audio_tracks)),
|
||||||
codec=stream.get("codec_name"),
|
codec=stream.get("codec_name"),
|
||||||
channels=stream.get("channels"),
|
channels=stream.get("channels"),
|
||||||
channel_layout=stream.get("channel_layout"),
|
channel_layout=stream.get("channel_layout"),
|
||||||
language=stream.get("tags", {}).get("language"),
|
language=stream.get("tags", {}).get("language"),
|
||||||
is_default=stream.get("disposition", {}).get("default", 0) == 1,
|
is_default=stream.get("disposition", {}).get("default", 0) == 1,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
elif codec_type == "subtitle":
|
elif codec_type == "subtitle":
|
||||||
info.subtitle_tracks.append(SubtitleTrack(
|
info.subtitle_tracks.append(
|
||||||
|
SubtitleTrack(
|
||||||
index=stream.get("index", len(info.subtitle_tracks)),
|
index=stream.get("index", len(info.subtitle_tracks)),
|
||||||
codec=stream.get("codec_name"),
|
codec=stream.get("codec_name"),
|
||||||
language=stream.get("tags", {}).get("language"),
|
language=stream.get("tags", {}).get("language"),
|
||||||
is_default=stream.get("disposition", {}).get("default", 0) == 1,
|
is_default=stream.get("disposition", {}).get("default", 0) == 1,
|
||||||
is_forced=stream.get("disposition", {}).get("forced", 0) == 1,
|
is_forced=stream.get("disposition", {}).get("forced", 0) == 1,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|||||||
@@ -89,13 +89,18 @@ class FileManager:
|
|||||||
folder_path = memory.ltm.library_paths.get(folder_type)
|
folder_path = memory.ltm.library_paths.get(folder_type)
|
||||||
|
|
||||||
if not folder_path:
|
if not folder_path:
|
||||||
return _err("folder_not_set", f"{folder_type.capitalize()} folder not configured.")
|
return _err(
|
||||||
|
"folder_not_set",
|
||||||
|
f"{folder_type.capitalize()} folder not configured.",
|
||||||
|
)
|
||||||
|
|
||||||
root = Path(folder_path)
|
root = Path(folder_path)
|
||||||
target = root / safe_path
|
target = root / safe_path
|
||||||
|
|
||||||
if not self._is_safe_path(root, target):
|
if not self._is_safe_path(root, target):
|
||||||
return _err("forbidden", "Access denied: path outside allowed directory")
|
return _err(
|
||||||
|
"forbidden", "Access denied: path outside allowed directory"
|
||||||
|
)
|
||||||
|
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
return _err("not_found", f"Path does not exist: {safe_path}")
|
return _err("not_found", f"Path does not exist: {safe_path}")
|
||||||
@@ -153,10 +158,15 @@ class FileManager:
|
|||||||
return _err("source_not_file", f"Source is not a file: {source}")
|
return _err("source_not_file", f"Source is not a file: {source}")
|
||||||
|
|
||||||
if not dest_path.parent.exists():
|
if not dest_path.parent.exists():
|
||||||
return _err("destination_dir_not_found", f"Destination directory does not exist: {dest_path.parent}")
|
return _err(
|
||||||
|
"destination_dir_not_found",
|
||||||
|
f"Destination directory does not exist: {dest_path.parent}",
|
||||||
|
)
|
||||||
|
|
||||||
if dest_path.exists():
|
if dest_path.exists():
|
||||||
return _err("destination_exists", f"Destination already exists: {destination}")
|
return _err(
|
||||||
|
"destination_exists", f"Destination already exists: {destination}"
|
||||||
|
)
|
||||||
|
|
||||||
os.link(source_path, dest_path)
|
os.link(source_path, dest_path)
|
||||||
|
|
||||||
@@ -197,7 +207,9 @@ class FileManager:
|
|||||||
|
|
||||||
source_path.unlink()
|
source_path.unlink()
|
||||||
|
|
||||||
logger.info(f"File moved: {source_path.name} -> {link_result['destination']}")
|
logger.info(
|
||||||
|
f"File moved: {source_path.name} -> {link_result['destination']}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"source": str(source_path),
|
"source": str(source_path),
|
||||||
@@ -237,11 +249,19 @@ class FileManager:
|
|||||||
torrent_root = Path(torrent_folder).resolve()
|
torrent_root = Path(torrent_folder).resolve()
|
||||||
|
|
||||||
if not lib_path.exists():
|
if not lib_path.exists():
|
||||||
return _err("library_file_not_found", f"Library file not found: {library_file}")
|
return _err(
|
||||||
|
"library_file_not_found", f"Library file not found: {library_file}"
|
||||||
|
)
|
||||||
if not src_folder.exists():
|
if not src_folder.exists():
|
||||||
return _err("source_folder_not_found", f"Download folder not found: {original_download_folder}")
|
return _err(
|
||||||
|
"source_folder_not_found",
|
||||||
|
f"Download folder not found: {original_download_folder}",
|
||||||
|
)
|
||||||
if not torrent_root.exists():
|
if not torrent_root.exists():
|
||||||
return _err("torrent_folder_not_found", f"Torrent folder not found: {torrent_folder}")
|
return _err(
|
||||||
|
"torrent_folder_not_found",
|
||||||
|
f"Torrent folder not found: {torrent_folder}",
|
||||||
|
)
|
||||||
|
|
||||||
dest_folder = torrent_root / src_folder.name
|
dest_folder = torrent_root / src_folder.name
|
||||||
dest_folder.mkdir(parents=True, exist_ok=True)
|
dest_folder.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -266,6 +286,7 @@ class FileManager:
|
|||||||
skipped.append(str(rel))
|
skipped.append(str(rel))
|
||||||
continue
|
continue
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
shutil.copy2(item, dest_item)
|
shutil.copy2(item, dest_item)
|
||||||
copied.append(str(rel))
|
copied.append(str(rel))
|
||||||
logger.debug(f"Copied for seeding: {rel}")
|
logger.debug(f"Copied for seeding: {rel}")
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Low-level filesystem operations — one responsibility per function."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(error: str, message: str) -> dict[str, Any]:
|
||||||
|
return {"status": "error", "error": error, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
def create_folder(path: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a directory and all missing parents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Absolute path to the directory to create.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status and path, or error details.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
p = Path(path)
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info(f"Folder ready: {p}")
|
||||||
|
return {"status": "ok", "path": str(p)}
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"create_folder failed: {e}")
|
||||||
|
return _err("mkdir_failed", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def move(source: str, destination: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Move a file or folder to a destination path.
|
||||||
|
|
||||||
|
Uses the system mv command — instant on the same filesystem (ZFS rename).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Absolute path to the source file or folder.
|
||||||
|
destination: Absolute path to the destination.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status, source, destination — or error details.
|
||||||
|
"""
|
||||||
|
src = Path(source)
|
||||||
|
dst = Path(destination)
|
||||||
|
|
||||||
|
if not src.exists():
|
||||||
|
return _err("source_not_found", f"Source does not exist: {source}")
|
||||||
|
|
||||||
|
if dst.exists():
|
||||||
|
return _err("destination_exists", f"Destination already exists: {destination}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["mv", str(src), str(dst)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"mv failed: {result.stderr}")
|
||||||
|
return _err("move_failed", result.stderr.strip())
|
||||||
|
|
||||||
|
logger.info(f"Moved: {src} -> {dst}")
|
||||||
|
return {"status": "ok", "source": str(src), "destination": str(dst)}
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"move failed: {e}")
|
||||||
|
return _err("move_failed", str(e))
|
||||||
@@ -67,12 +67,20 @@ class Memory:
|
|||||||
"current_topic": self.stm.entities.topic,
|
"current_topic": self.stm.entities.topic,
|
||||||
"extracted_entities": self.stm.entities.data,
|
"extracted_entities": self.stm.entities.data,
|
||||||
"last_search": {
|
"last_search": {
|
||||||
"query": self.episodic.search_results.last.get("query") if self.episodic.search_results.last else None,
|
"query": self.episodic.search_results.last.get("query")
|
||||||
"result_count": len(self.episodic.search_results.last.get("results", [])) if self.episodic.search_results.last else 0,
|
if self.episodic.search_results.last
|
||||||
|
else None,
|
||||||
|
"result_count": len(
|
||||||
|
self.episodic.search_results.last.get("results", [])
|
||||||
|
)
|
||||||
|
if self.episodic.search_results.last
|
||||||
|
else 0,
|
||||||
},
|
},
|
||||||
"active_downloads_count": len(self.episodic.downloads.active),
|
"active_downloads_count": len(self.episodic.downloads.active),
|
||||||
"pending_question": self.episodic.pending_question is not None,
|
"pending_question": self.episodic.pending_question is not None,
|
||||||
"unread_events": len([e for e in self.episodic.events.items if not e.get("read")]),
|
"unread_events": len(
|
||||||
|
[e for e in self.episodic.events.items if not e.get("read")]
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_full_state(self) -> dict:
|
def get_full_state(self) -> dict:
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ class Downloads:
|
|||||||
self.active.append(download)
|
self.active.append(download)
|
||||||
logger.info(f"Downloads: Added '{download.get('name')}'")
|
logger.info(f"Downloads: Added '{download.get('name')}'")
|
||||||
|
|
||||||
def update_progress(self, task_id: str, progress: int, status: str = "downloading") -> None:
|
def update_progress(
|
||||||
|
self, task_id: str, progress: int, status: str = "downloading"
|
||||||
|
) -> None:
|
||||||
for dl in self.active:
|
for dl in self.active:
|
||||||
if dl.get("task_id") == task_id:
|
if dl.get("task_id") == task_id:
|
||||||
dl["progress"] = progress
|
dl["progress"] = progress
|
||||||
@@ -28,7 +30,13 @@ class Downloads:
|
|||||||
for i, dl in enumerate(self.active):
|
for i, dl in enumerate(self.active):
|
||||||
if dl.get("task_id") == task_id:
|
if dl.get("task_id") == task_id:
|
||||||
completed = self.active.pop(i)
|
completed = self.active.pop(i)
|
||||||
completed.update({"status": "completed", "file_path": file_path, "completed_at": datetime.now().isoformat()})
|
completed.update(
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"file_path": file_path,
|
||||||
|
"completed_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
logger.info(f"Downloads: Completed '{completed.get('name')}'")
|
logger.info(f"Downloads: Completed '{completed.get('name')}'")
|
||||||
return completed
|
return completed
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ class Errors:
|
|||||||
max_errors: int = MAX_ERRORS
|
max_errors: int = MAX_ERRORS
|
||||||
|
|
||||||
def add(self, action: str, error: str, context: dict | None = None) -> None:
|
def add(self, action: str, error: str, context: dict | None = None) -> None:
|
||||||
self.items.append({
|
self.items.append(
|
||||||
|
{
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"action": action,
|
"action": action,
|
||||||
"error": error,
|
"error": error,
|
||||||
"context": context or {},
|
"context": context or {},
|
||||||
})
|
}
|
||||||
self.items = self.items[-self.max_errors:]
|
)
|
||||||
|
self.items = self.items[-self.max_errors :]
|
||||||
logger.warning(f"Errors: '{action}': {error}")
|
logger.warning(f"Errors: '{action}': {error}")
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
|
|||||||
@@ -15,8 +15,15 @@ class Events:
|
|||||||
max_events: int = MAX_EVENTS
|
max_events: int = MAX_EVENTS
|
||||||
|
|
||||||
def add(self, event_type: str, data: dict) -> None:
|
def add(self, event_type: str, data: dict) -> None:
|
||||||
self.items.append({"type": event_type, "timestamp": datetime.now().isoformat(), "data": data, "read": False})
|
self.items.append(
|
||||||
self.items = self.items[-self.max_events:]
|
{
|
||||||
|
"type": event_type,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"data": data,
|
||||||
|
"read": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.items = self.items[-self.max_events :]
|
||||||
logger.info(f"Events: '{event_type}'")
|
logger.info(f"Events: '{event_type}'")
|
||||||
|
|
||||||
def get_unread(self) -> list[dict]:
|
def get_unread(self) -> list[dict]:
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ logger = logging.getLogger(__name__)
|
|||||||
class SearchResults:
|
class SearchResults:
|
||||||
last: dict | None = None
|
last: dict | None = None
|
||||||
|
|
||||||
def store(self, query: str, results: list[dict], search_type: str = "torrent") -> None:
|
def store(
|
||||||
|
self, query: str, results: list[dict], search_type: str = "torrent"
|
||||||
|
) -> None:
|
||||||
self.last = {
|
self.last = {
|
||||||
"query": query,
|
"query": query,
|
||||||
"type": search_type,
|
"type": search_type,
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ class EpisodicMemory:
|
|||||||
pending_question: dict | None = None
|
pending_question: dict | None = None
|
||||||
|
|
||||||
# Convenience methods forwarded to components
|
# Convenience methods forwarded to components
|
||||||
def store_search_results(self, query: str, results: list[dict], search_type: str = "torrent") -> None:
|
def store_search_results(
|
||||||
|
self, query: str, results: list[dict], search_type: str = "torrent"
|
||||||
|
) -> None:
|
||||||
self.search_results.store(query, results, search_type)
|
self.search_results.store(query, results, search_type)
|
||||||
|
|
||||||
def get_result_by_index(self, index: int) -> dict | None:
|
def get_result_by_index(self, index: int) -> dict | None:
|
||||||
@@ -61,13 +63,18 @@ class EpisodicMemory:
|
|||||||
def add_active_download(self, download: dict) -> None:
|
def add_active_download(self, download: dict) -> None:
|
||||||
self.downloads.add(download)
|
self.downloads.add(download)
|
||||||
|
|
||||||
def update_download_progress(self, task_id: str, progress: int, status: str = "downloading") -> None:
|
def update_download_progress(
|
||||||
|
self, task_id: str, progress: int, status: str = "downloading"
|
||||||
|
) -> None:
|
||||||
self.downloads.update_progress(task_id, progress, status)
|
self.downloads.update_progress(task_id, progress, status)
|
||||||
|
|
||||||
def complete_download(self, task_id: str, file_path: str) -> dict | None:
|
def complete_download(self, task_id: str, file_path: str) -> dict | None:
|
||||||
completed = self.downloads.complete(task_id, file_path)
|
completed = self.downloads.complete(task_id, file_path)
|
||||||
if completed:
|
if completed:
|
||||||
self.events.add("download_complete", {"name": completed.get("name"), "file_path": file_path})
|
self.events.add(
|
||||||
|
"download_complete",
|
||||||
|
{"name": completed.get("name"), "file_path": file_path},
|
||||||
|
)
|
||||||
return completed
|
return completed
|
||||||
|
|
||||||
def get_active_downloads(self) -> list[dict]:
|
def get_active_downloads(self) -> list[dict]:
|
||||||
@@ -79,7 +86,13 @@ class EpisodicMemory:
|
|||||||
def get_recent_errors(self) -> list[dict]:
|
def get_recent_errors(self) -> list[dict]:
|
||||||
return self.errors.items
|
return self.errors.items
|
||||||
|
|
||||||
def set_pending_question(self, question: str, options: list[dict], context: dict, question_type: str = "choice") -> None:
|
def set_pending_question(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
options: list[dict],
|
||||||
|
context: dict,
|
||||||
|
question_type: str = "choice",
|
||||||
|
) -> None:
|
||||||
self.pending_question = {
|
self.pending_question = {
|
||||||
"type": question_type,
|
"type": question_type,
|
||||||
"question": question,
|
"question": question,
|
||||||
|
|||||||
@@ -39,5 +39,5 @@ class Following:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: list) -> "Following":
|
def from_dict(cls, data: list) -> Following:
|
||||||
return cls(shows=data)
|
return cls(shows=data)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class Library:
|
|||||||
return {"movies": self.movies, "tv_shows": self.tv_shows}
|
return {"movies": self.movies, "tv_shows": self.tv_shows}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "Library":
|
def from_dict(cls, data: dict) -> Library:
|
||||||
return cls(
|
return cls(
|
||||||
movies=data.get("movies", []),
|
movies=data.get("movies", []),
|
||||||
tv_shows=data.get("tv_shows", []),
|
tv_shows=data.get("tv_shows", []),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class LibraryPaths:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "LibraryPaths":
|
def from_dict(cls, data: dict) -> LibraryPaths:
|
||||||
# Migrate from old flat format (tvshow_folder, movie_folder)
|
# Migrate from old flat format (tvshow_folder, movie_folder)
|
||||||
folders = dict(data)
|
folders = dict(data)
|
||||||
if not folders:
|
if not folders:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class MediaPreferences:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "MediaPreferences":
|
def from_dict(cls, data: dict) -> MediaPreferences:
|
||||||
return cls(
|
return cls(
|
||||||
# migration: old key was preferred_quality / preferred_languages
|
# migration: old key was preferred_quality / preferred_languages
|
||||||
quality=data.get("quality") or data.get("preferred_quality", "1080p"),
|
quality=data.get("quality") or data.get("preferred_quality", "1080p"),
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class SubtitlePreferences:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "SubtitlePreferences":
|
def from_dict(cls, data: dict) -> SubtitlePreferences:
|
||||||
# Migration: old fields (min_size_kb, keep_sdh, keep_forced, link_subs_folder) are silently dropped
|
# Migration: old fields (min_size_kb, keep_sdh, keep_forced, link_subs_folder) are silently dropped
|
||||||
prefs = cls(
|
prefs = cls(
|
||||||
languages=data.get("languages", ["fr", "en"]),
|
languages=data.get("languages", ["fr", "en"]),
|
||||||
|
|||||||
@@ -20,16 +20,22 @@ class WorkspacePaths:
|
|||||||
|
|
||||||
download: str | None = None
|
download: str | None = None
|
||||||
torrent: str | None = None
|
torrent: str | None = None
|
||||||
|
trash: str | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, str]:
|
def as_dict(self) -> dict[str, str]:
|
||||||
"""Return configured paths, skipping unset values."""
|
"""Return configured paths, skipping unset values."""
|
||||||
return {k: v for k, v in {
|
return {
|
||||||
|
k: v
|
||||||
|
for k, v in {
|
||||||
"download": self.download,
|
"download": self.download,
|
||||||
"torrent": self.torrent,
|
"torrent": self.torrent,
|
||||||
}.items() if v is not None}
|
"trash": self.trash,
|
||||||
|
}.items()
|
||||||
|
if v is not None
|
||||||
|
}
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {"download": self.download, "torrent": self.torrent}
|
return {"download": self.download, "torrent": self.torrent, "trash": self.trash}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def describe(cls) -> dict:
|
def describe(cls) -> dict:
|
||||||
@@ -45,13 +51,15 @@ class WorkspacePaths:
|
|||||||
"fields": {
|
"fields": {
|
||||||
"download": "Root folder where qBittorrent drops completed downloads.",
|
"download": "Root folder where qBittorrent drops completed downloads.",
|
||||||
"torrent": "Folder where .torrent files are stored.",
|
"torrent": "Folder where .torrent files are stored.",
|
||||||
|
"trash": "Trash folder — files moved here instead of deleted, for manual review.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "WorkspacePaths":
|
def from_dict(cls, data: dict) -> WorkspacePaths:
|
||||||
# Migrate from old flat format (download_folder, torrent_folder)
|
# Migrate from old flat format (download_folder, torrent_folder)
|
||||||
return cls(
|
return cls(
|
||||||
download=data.get("download") or data.get("download_folder"),
|
download=data.get("download") or data.get("download_folder"),
|
||||||
torrent=data.get("torrent") or data.get("torrent_folder"),
|
torrent=data.get("torrent") or data.get("torrent_folder"),
|
||||||
|
trash=data.get("trash"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ class LongTermMemory:
|
|||||||
workspace: WorkspacePaths = field(default_factory=WorkspacePaths)
|
workspace: WorkspacePaths = field(default_factory=WorkspacePaths)
|
||||||
library_paths: LibraryPaths = field(default_factory=LibraryPaths)
|
library_paths: LibraryPaths = field(default_factory=LibraryPaths)
|
||||||
media_preferences: MediaPreferences = field(default_factory=MediaPreferences)
|
media_preferences: MediaPreferences = field(default_factory=MediaPreferences)
|
||||||
subtitle_preferences: SubtitlePreferences = field(default_factory=SubtitlePreferences)
|
subtitle_preferences: SubtitlePreferences = field(
|
||||||
|
default_factory=SubtitlePreferences
|
||||||
|
)
|
||||||
library: Library = field(default_factory=Library)
|
library: Library = field(default_factory=Library)
|
||||||
following: Following = field(default_factory=Following)
|
following: Following = field(default_factory=Following)
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ class LongTermMemory:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "LongTermMemory":
|
def from_dict(cls, data: dict) -> LongTermMemory:
|
||||||
# Migration: old flat format had paths at the top level
|
# Migration: old flat format had paths at the top level
|
||||||
workspace_data = data.get("workspace") or data
|
workspace_data = data.get("workspace") or data
|
||||||
library_paths_data = data.get("library_paths") or data.get("paths") or data
|
library_paths_data = data.get("library_paths") or data.get("paths") or data
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ def _load_components(package_name: str) -> list[dict]:
|
|||||||
try:
|
try:
|
||||||
descriptions.append(cls.describe())
|
descriptions.append(cls.describe())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MemoryRegistry: describe() failed on {cls.__name__}: {e}")
|
logger.warning(
|
||||||
|
f"MemoryRegistry: describe() failed on {cls.__name__}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MemoryRegistry: Could not load package {package_name}: {e}")
|
logger.warning(f"MemoryRegistry: Could not load package {package_name}: {e}")
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ class Conversation:
|
|||||||
|
|
||||||
def add(self, role: str, content: str) -> None:
|
def add(self, role: str, content: str) -> None:
|
||||||
"""Append a message, capping at max_history."""
|
"""Append a message, capping at max_history."""
|
||||||
self.messages.append({"role": role, "content": content, "timestamp": datetime.now().isoformat()})
|
self.messages.append(
|
||||||
|
{"role": role, "content": content, "timestamp": datetime.now().isoformat()}
|
||||||
|
)
|
||||||
if len(self.messages) > self.max_history:
|
if len(self.messages) > self.max_history:
|
||||||
self.messages = self.messages[-self.max_history:]
|
self.messages = self.messages[-self.max_history :]
|
||||||
logger.debug(f"Conversation: Added {role} message")
|
logger.debug(f"Conversation: Added {role} message")
|
||||||
|
|
||||||
def recent(self, n: int = 10) -> list[dict]:
|
def recent(self, n: int = 10) -> list[dict]:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""SubtitleMetadataStore — reads/writes .alfred/metadata.yaml colocated with media."""
|
"""SubtitleMetadataStore — reads/writes .alfred/metadata.yaml colocated with media."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -50,7 +50,13 @@ class SubtitleMetadataStore:
|
|||||||
tmp = self._metadata_path.with_suffix(".yaml.tmp")
|
tmp = self._metadata_path.with_suffix(".yaml.tmp")
|
||||||
try:
|
try:
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
yaml.safe_dump(
|
||||||
|
data,
|
||||||
|
f,
|
||||||
|
allow_unicode=True,
|
||||||
|
default_flow_style=False,
|
||||||
|
sort_keys=False,
|
||||||
|
)
|
||||||
tmp.rename(self._metadata_path)
|
tmp.rename(self._metadata_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"MetadataStore: could not write {self._metadata_path}: {e}")
|
logger.error(f"MetadataStore: could not write {self._metadata_path}: {e}")
|
||||||
@@ -68,7 +74,9 @@ class SubtitleMetadataStore:
|
|||||||
return data.get("detected_pattern")
|
return data.get("detected_pattern")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def mark_pattern_confirmed(self, pattern_id: str, media_info: dict | None = None) -> None:
|
def mark_pattern_confirmed(
|
||||||
|
self, pattern_id: str, media_info: dict | None = None
|
||||||
|
) -> None:
|
||||||
"""Persist detected_pattern + pattern_confirmed=true."""
|
"""Persist detected_pattern + pattern_confirmed=true."""
|
||||||
data = self.load()
|
data = self.load()
|
||||||
data["detected_pattern"] = pattern_id
|
data["detected_pattern"] = pattern_id
|
||||||
@@ -78,7 +86,9 @@ class SubtitleMetadataStore:
|
|||||||
data.setdefault("imdb_id", media_info.get("imdb_id"))
|
data.setdefault("imdb_id", media_info.get("imdb_id"))
|
||||||
data.setdefault("title", media_info.get("title"))
|
data.setdefault("title", media_info.get("title"))
|
||||||
self.save(data)
|
self.save(data)
|
||||||
logger.info(f"MetadataStore: confirmed pattern '{pattern_id}' for {self._root.name}")
|
logger.info(
|
||||||
|
f"MetadataStore: confirmed pattern '{pattern_id}' for {self._root.name}"
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Subtitle history
|
# Subtitle history
|
||||||
@@ -101,10 +111,13 @@ class SubtitleMetadataStore:
|
|||||||
tracks_data: list[dict[str, Any]] = []
|
tracks_data: list[dict[str, Any]] = []
|
||||||
for placed, track in placed_pairs:
|
for placed, track in placed_pairs:
|
||||||
# Infer type from destination filename parts (e.g. en.sdh.srt → sdh)
|
# Infer type from destination filename parts (e.g. en.sdh.srt → sdh)
|
||||||
parts = placed.filename.rsplit(".", 2) # ["en", "sdh", "srt"] or ["en", "srt"]
|
parts = placed.filename.rsplit(
|
||||||
|
".", 2
|
||||||
|
) # ["en", "sdh", "srt"] or ["en", "srt"]
|
||||||
inferred_type = parts[1] if len(parts) == 3 else "standard"
|
inferred_type = parts[1] if len(parts) == 3 else "standard"
|
||||||
|
|
||||||
tracks_data.append({
|
tracks_data.append(
|
||||||
|
{
|
||||||
"language": track.language.code if track.language else "unknown",
|
"language": track.language.code if track.language else "unknown",
|
||||||
"type": inferred_type,
|
"type": inferred_type,
|
||||||
"format": placed.destination.suffix.lstrip("."),
|
"format": placed.destination.suffix.lstrip("."),
|
||||||
@@ -112,10 +125,11 @@ class SubtitleMetadataStore:
|
|||||||
"source_file": placed.source.name,
|
"source_file": placed.source.name,
|
||||||
"placed_as": placed.filename,
|
"placed_as": placed.filename,
|
||||||
"confidence": round(track.confidence, 3),
|
"confidence": round(track.confidence, 3),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"placed_at": datetime.now(timezone.utc).isoformat(),
|
"placed_at": datetime.now(UTC).isoformat(),
|
||||||
"release_group": release_group,
|
"release_group": release_group,
|
||||||
"tracks": tracks_data,
|
"tracks": tracks_data,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ from alfred.domain.subtitles.aggregates import SubtitleRuleSet
|
|||||||
from alfred.domain.subtitles.value_objects import RuleScope
|
from alfred.domain.subtitles.value_objects import RuleScope
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import SubtitlePreferences
|
from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import (
|
||||||
|
SubtitlePreferences,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ class RuleSetRepository:
|
|||||||
def load(
|
def load(
|
||||||
self,
|
self,
|
||||||
release_group: str | None = None,
|
release_group: str | None = None,
|
||||||
subtitle_preferences: "SubtitlePreferences | None" = None,
|
subtitle_preferences: SubtitlePreferences | None = None,
|
||||||
) -> SubtitleRuleSet:
|
) -> SubtitleRuleSet:
|
||||||
"""
|
"""
|
||||||
Build and return the resolved RuleSet chain.
|
Build and return the resolved RuleSet chain.
|
||||||
@@ -75,7 +77,9 @@ class RuleSetRepository:
|
|||||||
)
|
)
|
||||||
rg_ruleset.override(**_filter_override(rg_data))
|
rg_ruleset.override(**_filter_override(rg_data))
|
||||||
current = rg_ruleset
|
current = rg_ruleset
|
||||||
logger.debug(f"RuleSetRepository: loaded release_group override for '{release_group}'")
|
logger.debug(
|
||||||
|
f"RuleSetRepository: loaded release_group override for '{release_group}'"
|
||||||
|
)
|
||||||
|
|
||||||
# Local (show/movie) level
|
# Local (show/movie) level
|
||||||
local_data = _load_yaml(self._alfred_dir / "rules.yaml").get("override", {})
|
local_data = _load_yaml(self._alfred_dir / "rules.yaml").get("override", {})
|
||||||
@@ -101,7 +105,13 @@ class RuleSetRepository:
|
|||||||
tmp = path.with_suffix(".yaml.tmp")
|
tmp = path.with_suffix(".yaml.tmp")
|
||||||
try:
|
try:
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
yaml.safe_dump(
|
||||||
|
data,
|
||||||
|
f,
|
||||||
|
allow_unicode=True,
|
||||||
|
default_flow_style=False,
|
||||||
|
sort_keys=False,
|
||||||
|
)
|
||||||
tmp.rename(path)
|
tmp.rename(path)
|
||||||
logger.info(f"RuleSetRepository: saved local rules to {path}")
|
logger.info(f"RuleSetRepository: saved local rules to {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ types:
|
|||||||
|
|
||||||
languages:
|
languages:
|
||||||
fra:
|
fra:
|
||||||
tokens: ["fr", "fra", "french", "francais", "vf", "vff", "vostfr"]
|
tokens: ["fr", "fra", "fre", "french", "francais", "vf", "vff", "vostfr"]
|
||||||
eng:
|
eng:
|
||||||
tokens: ["en", "eng", "english"]
|
tokens: ["en", "eng", "english"]
|
||||||
spa:
|
spa:
|
||||||
@@ -80,10 +80,34 @@ languages:
|
|||||||
jpn:
|
jpn:
|
||||||
tokens: ["ja", "jpn", "japanese"]
|
tokens: ["ja", "jpn", "japanese"]
|
||||||
zho:
|
zho:
|
||||||
tokens: ["zh", "zho", "chi", "chinese"]
|
tokens: ["zh", "zho", "chi", "chinese", "simplified", "traditional"]
|
||||||
|
yue:
|
||||||
|
tokens: ["yue", "cantonese"]
|
||||||
kor:
|
kor:
|
||||||
tokens: ["ko", "kor", "korean"]
|
tokens: ["ko", "kor", "korean"]
|
||||||
ara:
|
ara:
|
||||||
tokens: ["ar", "ara", "arabic"]
|
tokens: ["ar", "ara", "arabic"]
|
||||||
tur:
|
tur:
|
||||||
tokens: ["tr", "tur", "turkish"]
|
tokens: ["tr", "tur", "turkish"]
|
||||||
|
ell:
|
||||||
|
tokens: ["el", "ell", "gre", "greek"]
|
||||||
|
ind:
|
||||||
|
tokens: ["id", "ind", "indonesian"]
|
||||||
|
msa:
|
||||||
|
tokens: ["ms", "msa", "may", "malay", "malayalam"]
|
||||||
|
rus:
|
||||||
|
tokens: ["ru", "rus", "russian"]
|
||||||
|
vie:
|
||||||
|
tokens: ["vi", "vie", "vietnamese"]
|
||||||
|
heb:
|
||||||
|
tokens: ["he", "heb", "hebrew"]
|
||||||
|
tam:
|
||||||
|
tokens: ["ta", "tam", "tamil"]
|
||||||
|
tel:
|
||||||
|
tokens: ["te", "tel", "telugu"]
|
||||||
|
tha:
|
||||||
|
tokens: ["th", "tha", "thai"]
|
||||||
|
hin:
|
||||||
|
tokens: ["hi", "hin", "hindi"]
|
||||||
|
ukr:
|
||||||
|
tokens: ["uk", "ukr", "ukrainian"]
|
||||||
|
|||||||
@@ -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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=[BASE_DIR / ".env.alfred", BASE_DIR / ".env.secrets", BASE_DIR / ".env.make"],
|
env_file=[
|
||||||
|
BASE_DIR / ".env.alfred",
|
||||||
|
BASE_DIR / ".env.secrets",
|
||||||
|
BASE_DIR / ".env.make",
|
||||||
|
],
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
@@ -41,7 +45,16 @@ class Settings(BaseSettings):
|
|||||||
ollama_base_url: str = "http://ollama:11434"
|
ollama_base_url: str = "http://ollama:11434"
|
||||||
ollama_model: str = "llama3.3:latest"
|
ollama_model: str = "llama3.3:latest"
|
||||||
deepseek_base_url: str = "https://api.deepseek.com"
|
deepseek_base_url: str = "https://api.deepseek.com"
|
||||||
deepseek_model: str = "deepseek-chat"
|
deepseek_model: str = "deepseek-chat" # TODO: update => https://api-docs.deepseek.com/quick_start/pricing
|
||||||
|
|
||||||
|
# --- QBITTORRENT ---
|
||||||
|
qbittorrent_url: str = "http://localhost:8080"
|
||||||
|
qbittorrent_username: str = "admin"
|
||||||
|
qbittorrent_password: str = "adminadmin"
|
||||||
|
# Path translation: local path prefix → qBittorrent container path prefix
|
||||||
|
# e.g. QBITTORRENT_HOST_PATH=/mnt/testipool QBITTORRENT_CONTAINER_PATH=/mnt/data
|
||||||
|
qbittorrent_host_path: str | None = None
|
||||||
|
qbittorrent_container_path: str | None = None
|
||||||
|
|
||||||
# --- API KEYS ---
|
# --- API KEYS ---
|
||||||
tmdb_api_key: str | None = None
|
tmdb_api_key: str | None = None
|
||||||
@@ -57,21 +70,27 @@ class Settings(BaseSettings):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def validate_temperature(cls, v: float) -> float:
|
def validate_temperature(cls, v: float) -> float:
|
||||||
if not 0.0 <= v <= 2.0:
|
if not 0.0 <= v <= 2.0:
|
||||||
raise ConfigurationError(f"Temperature must be between 0.0 and 2.0, got {v}")
|
raise ConfigurationError(
|
||||||
|
f"Temperature must be between 0.0 and 2.0, got {v}"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("max_tool_iterations")
|
@field_validator("max_tool_iterations")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_max_iterations(cls, v: int) -> int:
|
def validate_max_iterations(cls, v: int) -> int:
|
||||||
if not 1 <= v <= 20:
|
if not 1 <= v <= 20:
|
||||||
raise ConfigurationError(f"max_tool_iterations must be between 1 and 20, got {v}")
|
raise ConfigurationError(
|
||||||
|
f"max_tool_iterations must be between 1 and 20, got {v}"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("request_timeout")
|
@field_validator("request_timeout")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_timeout(cls, v: int) -> int:
|
def validate_timeout(cls, v: int) -> int:
|
||||||
if not 1 <= v <= 300:
|
if not 1 <= v <= 300:
|
||||||
raise ConfigurationError(f"request_timeout must be between 1 and 300 seconds, got {v}")
|
raise ConfigurationError(
|
||||||
|
f"request_timeout must be between 1 and 300 seconds, got {v}"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
# --- HELPERS ---
|
# --- HELPERS ---
|
||||||
|
|||||||
+10
-4
@@ -4,12 +4,12 @@
|
|||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
def load_secrets_spec(toml_data: dict) -> dict[str, tuple[int, str]]:
|
def load_secrets_spec(toml_data: dict) -> dict[str, tuple[int, str]]:
|
||||||
"""Load secrets spec from pyproject.toml [tool.alfred.secrets]."""
|
"""Load secrets spec from pyproject.toml [tool.alfred.secrets]."""
|
||||||
raw = toml_data.get("tool", {}).get("alfred", {}).get("secrets", {})
|
raw = toml_data.get("tool", {}).get("alfred", {}).get("secrets", {})
|
||||||
@@ -58,11 +58,15 @@ def copy_example_if_missing(src: Path, dst: Path, label: str) -> None:
|
|||||||
def generate_secrets_file(path: Path, secrets_spec: dict[str, tuple[int, str]]) -> None:
|
def generate_secrets_file(path: Path, secrets_spec: dict[str, tuple[int, str]]) -> None:
|
||||||
"""Generate .env.secrets with missing secrets, never overwrite existing ones."""
|
"""Generate .env.secrets with missing secrets, never overwrite existing ones."""
|
||||||
existing = load_env_file(path)
|
existing = load_env_file(path)
|
||||||
lines = list(path.read_text().splitlines()) if path.exists() else [
|
lines = (
|
||||||
|
list(path.read_text().splitlines())
|
||||||
|
if path.exists()
|
||||||
|
else [
|
||||||
"# Auto-generated secrets — DO NOT COMMIT",
|
"# Auto-generated secrets — DO NOT COMMIT",
|
||||||
"# Run 'make bootstrap' to generate missing secrets",
|
"# Run 'make bootstrap' to generate missing secrets",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
|
||||||
added = []
|
added = []
|
||||||
for key, (size, fmt) in secrets_spec.items():
|
for key, (size, fmt) in secrets_spec.items():
|
||||||
@@ -108,7 +112,9 @@ def build_uris(env_alfred: Path, env_secrets: Path) -> None:
|
|||||||
added = []
|
added = []
|
||||||
for key, value in computed.items():
|
for key, value in computed.items():
|
||||||
if key in existing:
|
if key in existing:
|
||||||
content = re.sub(rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE)
|
content = re.sub(
|
||||||
|
rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
content = content.rstrip("\n") + f"\n{key}={value}\n"
|
content = content.rstrip("\n") + f"\n{key}={value}\n"
|
||||||
added.append(key)
|
added.append(key)
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""Shared configuration loader — reads build config from pyproject.toml."""
|
"""Shared configuration loader — reads build config from pyproject.toml."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
|
|
||||||
class BuildConfig(NamedTuple):
|
class BuildConfig(NamedTuple):
|
||||||
"""Build configuration extracted from pyproject.toml."""
|
"""Build configuration extracted from pyproject.toml."""
|
||||||
|
|||||||
@@ -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
|
# TMDB lookup
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
||||||
"""
|
"""
|
||||||
Call TMDBClient.search_media() and return (canonical_title, year).
|
Call TMDBClient.search_media() and return (canonical_title, year).
|
||||||
@@ -58,6 +59,7 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from alfred.infrastructure.api.tmdb import TMDBClient
|
from alfred.infrastructure.api.tmdb import TMDBClient
|
||||||
|
|
||||||
client = TMDBClient()
|
client = TMDBClient()
|
||||||
result = client.search_media(title)
|
result = client.search_media(title)
|
||||||
year: int | None = None
|
year: int | None = None
|
||||||
@@ -66,7 +68,12 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
|||||||
year = int(result.release_date[:4])
|
year = int(result.release_date[:4])
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
print(c(f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}", DIM))
|
print(
|
||||||
|
c(
|
||||||
|
f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}",
|
||||||
|
DIM,
|
||||||
|
)
|
||||||
|
)
|
||||||
return result.title, year
|
return result.title, year
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(c(f" TMDB lookup failed: {e}", YELLOW))
|
print(c(f" TMDB lookup failed: {e}", YELLOW))
|
||||||
@@ -77,8 +84,14 @@ def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
|||||||
# Display
|
# Display
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
|
||||||
tmdb_episode_title: str | None, ext: str) -> None:
|
def _show(
|
||||||
|
release_name: str,
|
||||||
|
tmdb_title: str | None,
|
||||||
|
tmdb_year: int | None,
|
||||||
|
tmdb_episode_title: str | None,
|
||||||
|
ext: str,
|
||||||
|
) -> None:
|
||||||
from alfred.domain.release import parse_release
|
from alfred.domain.release import parse_release
|
||||||
|
|
||||||
p = parse_release(release_name)
|
p = parse_release(release_name)
|
||||||
@@ -102,7 +115,10 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
|||||||
kv("year", str(p.year) if p.year else c("None", DIM))
|
kv("year", str(p.year) if p.year else c("None", DIM))
|
||||||
kv("season", str(p.season) if p.season is not None else c("None", DIM))
|
kv("season", str(p.season) if p.season is not None else c("None", DIM))
|
||||||
kv("episode", str(p.episode) if p.episode is not None else c("None", DIM))
|
kv("episode", str(p.episode) if p.episode is not None else c("None", DIM))
|
||||||
kv("episode_end", str(p.episode_end) if p.episode_end is not None else c("None", DIM))
|
kv(
|
||||||
|
"episode_end",
|
||||||
|
str(p.episode_end) if p.episode_end is not None else c("None", DIM),
|
||||||
|
)
|
||||||
kv("quality", p.quality or c("None", DIM))
|
kv("quality", p.quality or c("None", DIM))
|
||||||
kv("source", p.source or c("None", DIM))
|
kv("source", p.source or c("None", DIM))
|
||||||
kv("codec", p.codec or c("None", DIM))
|
kv("codec", p.codec or c("None", DIM))
|
||||||
@@ -133,9 +149,12 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
|||||||
if tmdb_title or tmdb_year or tmdb_episode_title:
|
if tmdb_title or tmdb_year or tmdb_episode_title:
|
||||||
hr()
|
hr()
|
||||||
print(c(" TMDB data used:", DIM))
|
print(c(" TMDB data used:", DIM))
|
||||||
if tmdb_title: kv(" tmdb_title", tmdb_title)
|
if tmdb_title:
|
||||||
if tmdb_year: kv(" tmdb_year", str(tmdb_year))
|
kv(" tmdb_title", tmdb_title)
|
||||||
if tmdb_episode_title: kv(" tmdb_episode_title", tmdb_episode_title)
|
if tmdb_year:
|
||||||
|
kv(" tmdb_year", str(tmdb_year))
|
||||||
|
if tmdb_episode_title:
|
||||||
|
kv(" tmdb_episode_title", tmdb_episode_title)
|
||||||
|
|
||||||
print(c("━" * 64, BOLD))
|
print(c("━" * 64, BOLD))
|
||||||
print()
|
print()
|
||||||
@@ -145,10 +164,16 @@ def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
|||||||
# Interactive mode
|
# Interactive mode
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _interactive() -> None:
|
def _interactive() -> None:
|
||||||
print(c("\n Alfred — Release Parser REPL", BOLD, CYAN))
|
print(c("\n Alfred — Release Parser REPL", BOLD, CYAN))
|
||||||
print(c(" Type a release name, or 'q' to quit.", DIM))
|
print(c(" Type a release name, or 'q' to quit.", DIM))
|
||||||
print(c(" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n", DIM))
|
print(
|
||||||
|
c(
|
||||||
|
" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n",
|
||||||
|
DIM,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -186,6 +211,7 @@ def _interactive() -> None:
|
|||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
global USE_COLOR
|
global USE_COLOR
|
||||||
|
|
||||||
@@ -194,16 +220,29 @@ def main() -> None:
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
)
|
)
|
||||||
parser.add_argument("release", nargs="?", help="Release name to parse")
|
parser.add_argument("release", nargs="?", help="Release name to parse")
|
||||||
parser.add_argument("-i", "--interactive", action="store_true",
|
parser.add_argument(
|
||||||
help="Interactive REPL mode")
|
"-i", "--interactive", action="store_true", help="Interactive REPL mode"
|
||||||
parser.add_argument("--tmdb-title", metavar="TITLE",
|
)
|
||||||
help="Override TMDB title for name generation")
|
parser.add_argument(
|
||||||
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
|
"--tmdb-title", metavar="TITLE", help="Override TMDB title for name generation"
|
||||||
help="Override TMDB year for name generation")
|
)
|
||||||
parser.add_argument("--episode-title", metavar="TITLE",
|
parser.add_argument(
|
||||||
help="TMDB episode title for episode_filename()")
|
"--tmdb-year",
|
||||||
parser.add_argument("--ext", default=".mkv", metavar="EXT",
|
metavar="YEAR",
|
||||||
help="File extension for filename generation (default: .mkv)")
|
type=int,
|
||||||
|
help="Override TMDB year for name generation",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--episode-title",
|
||||||
|
metavar="TITLE",
|
||||||
|
help="TMDB episode title for episode_filename()",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ext",
|
||||||
|
default=".mkv",
|
||||||
|
metavar="EXT",
|
||||||
|
help="File extension for filename generation (default: .mkv)",
|
||||||
|
)
|
||||||
parser.add_argument("--no-color", action="store_true")
|
parser.add_argument("--no-color", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -219,7 +258,9 @@ def main() -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_show(args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext)
|
_show(
|
||||||
|
args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(c(f"Error: {e}", RED), file=sys.stderr)
|
print(c(f"Error: {e}", RED), file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ def hr() -> None:
|
|||||||
# Formatting helpers
|
# Formatting helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def fmt_duration(seconds: float) -> str:
|
def fmt_duration(seconds: float) -> str:
|
||||||
h = int(seconds // 3600)
|
h = int(seconds // 3600)
|
||||||
m = int((seconds % 3600) // 60)
|
m = int((seconds % 3600) // 60)
|
||||||
@@ -80,6 +81,7 @@ def flag(val: bool) -> str:
|
|||||||
# Main
|
# Main
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
global USE_COLOR
|
global USE_COLOR
|
||||||
|
|
||||||
@@ -151,7 +153,9 @@ def main() -> None:
|
|||||||
hr()
|
hr()
|
||||||
multi = c("yes", GREEN) if info.is_multi_audio else c("no", DIM)
|
multi = c("yes", GREEN) if info.is_multi_audio else c("no", DIM)
|
||||||
langs = ", ".join(info.audio_languages) if info.audio_languages else c("—", DIM)
|
langs = ", ".join(info.audio_languages) if info.audio_languages else c("—", DIM)
|
||||||
print(f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}")
|
print(
|
||||||
|
f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}"
|
||||||
|
)
|
||||||
hr()
|
hr()
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ def hr() -> None:
|
|||||||
# Parsing quality check
|
# Parsing quality check
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _assess(p) -> list[str]:
|
def _assess(p) -> list[str]:
|
||||||
"""Return a list of warning strings for fields that look wrong."""
|
"""Return a list of warning strings for fields that look wrong."""
|
||||||
if p.media_type in ("other", "unknown"):
|
if p.media_type in ("other", "unknown"):
|
||||||
@@ -70,16 +71,24 @@ def _assess(p) -> list[str]:
|
|||||||
# Main
|
# Main
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
global USE_COLOR
|
global USE_COLOR
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Recognize release folders in downloads")
|
parser = argparse.ArgumentParser(
|
||||||
parser.add_argument("--path", default="/mnt/testipool/downloads",
|
description="Recognize release folders in downloads"
|
||||||
help="Downloads directory (default: /mnt/testipool/downloads)")
|
)
|
||||||
parser.add_argument("--failures-only", action="store_true",
|
parser.add_argument(
|
||||||
help="Show only entries with warnings")
|
"--path",
|
||||||
parser.add_argument("--successes-only", action="store_true",
|
default="/mnt/testipool/downloads",
|
||||||
help="Show only fully parsed entries")
|
help="Downloads directory (default: /mnt/testipool/downloads)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--failures-only", action="store_true", help="Show only entries with warnings"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--successes-only", action="store_true", help="Show only fully parsed entries"
|
||||||
|
)
|
||||||
parser.add_argument("--no-color", action="store_true")
|
parser.add_argument("--no-color", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -91,11 +100,11 @@ def main() -> None:
|
|||||||
print(c(f"Error: {downloads} does not exist", RED), file=sys.stderr)
|
print(c(f"Error: {downloads} does not exist", RED), file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
from alfred.domain.release.services import parse_release
|
|
||||||
from alfred.application.filesystem.detect_media_type import detect_media_type
|
from alfred.application.filesystem.detect_media_type import detect_media_type
|
||||||
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
||||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
from alfred.domain.release.services import parse_release
|
||||||
from alfred.infrastructure.filesystem.ffprobe import probe
|
from alfred.infrastructure.filesystem.ffprobe import probe
|
||||||
|
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||||
|
|
||||||
entries = sorted(downloads.iterdir(), key=lambda p: p.name.lower())
|
entries = sorted(downloads.iterdir(), key=lambda p: p.name.lower())
|
||||||
total = len(entries)
|
total = len(entries)
|
||||||
@@ -178,8 +187,7 @@ def main() -> None:
|
|||||||
kv("hdr/depth", " ".join(hdr_parts))
|
kv("hdr/depth", " ".join(hdr_parts))
|
||||||
if p.edition:
|
if p.edition:
|
||||||
kv("edition", p.edition, color=YELLOW)
|
kv("edition", p.edition, color=YELLOW)
|
||||||
kv("group", p.group,
|
kv("group", p.group, color=YELLOW if p.group == "UNKNOWN" else GREEN)
|
||||||
color=YELLOW if p.group == "UNKNOWN" else GREEN)
|
|
||||||
if p.site_tag:
|
if p.site_tag:
|
||||||
kv("site tag", p.site_tag, color=YELLOW)
|
kv("site tag", p.site_tag, color=YELLOW)
|
||||||
|
|
||||||
@@ -191,10 +199,12 @@ def main() -> None:
|
|||||||
print()
|
print()
|
||||||
hr()
|
hr()
|
||||||
skipped = total - ok_count - warn_count
|
skipped = total - ok_count - warn_count
|
||||||
print(f" {c('Total:', BOLD)} {total} "
|
print(
|
||||||
|
f" {c('Total:', BOLD)} {total} "
|
||||||
f"{c(str(ok_count) + ' ok', GREEN, BOLD)} "
|
f"{c(str(ok_count) + ' ok', GREEN, BOLD)} "
|
||||||
f"{c(str(warn_count) + ' warnings', YELLOW, BOLD)}"
|
f"{c(str(warn_count) + ' warnings', YELLOW, BOLD)}"
|
||||||
+ (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else ""))
|
+ (f" {c(str(skipped) + ' filtered', DIM)}" if skipped else "")
|
||||||
|
)
|
||||||
hr()
|
hr()
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".mov", ".ts", ".m2ts"}
|
|||||||
|
|
||||||
def find_videos(folder: Path) -> list[Path]:
|
def find_videos(folder: Path) -> list[Path]:
|
||||||
return sorted(
|
return sorted(
|
||||||
p for p in folder.iterdir()
|
p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS
|
||||||
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +108,11 @@ def track_summary(track, verbose: bool = False) -> None:
|
|||||||
lang = track.language.code if track.language else c("?", RED)
|
lang = track.language.code if track.language else c("?", RED)
|
||||||
fmt = track.format.id if track.format else c("?", RED)
|
fmt = track.format.id if track.format else c("?", RED)
|
||||||
typ = track.subtitle_type.value
|
typ = track.subtitle_type.value
|
||||||
src = "embedded" if track.is_embedded else (track.file_path.name if track.file_path else "?")
|
src = (
|
||||||
|
"embedded"
|
||||||
|
if track.is_embedded
|
||||||
|
else (track.file_path.name if track.file_path else "?")
|
||||||
|
)
|
||||||
|
|
||||||
# Couleur du type
|
# Couleur du type
|
||||||
type_colors = {
|
type_colors = {
|
||||||
@@ -125,11 +128,19 @@ def track_summary(track, verbose: bool = False) -> None:
|
|||||||
|
|
||||||
print(f" {c(src, BOLD)}")
|
print(f" {c(src, BOLD)}")
|
||||||
print(f" lang={c(lang, CYAN)} type={typ_str} format={fmt}")
|
print(f" lang={c(lang, CYAN)} type={typ_str} format={fmt}")
|
||||||
conf_str = c("n/a (embedded)", DIM) if track.is_embedded else confidence_bar(track.confidence)
|
conf_str = (
|
||||||
|
c("n/a (embedded)", DIM)
|
||||||
|
if track.is_embedded
|
||||||
|
else confidence_bar(track.confidence)
|
||||||
|
)
|
||||||
print(f" confidence={conf_str}{clarif}")
|
print(f" confidence={conf_str}{clarif}")
|
||||||
|
|
||||||
if track.entry_count is not None:
|
if track.entry_count is not None:
|
||||||
print(f" entries={track.entry_count} size={track.file_size_kb:.1f} KB" if track.file_size_kb else f" entries={track.entry_count}")
|
print(
|
||||||
|
f" entries={track.entry_count} size={track.file_size_kb:.1f} KB"
|
||||||
|
if track.file_size_kb
|
||||||
|
else f" entries={track.entry_count}"
|
||||||
|
)
|
||||||
|
|
||||||
if verbose and track.raw_tokens:
|
if verbose and track.raw_tokens:
|
||||||
print(f" tokens={track.raw_tokens}")
|
print(f" tokens={track.raw_tokens}")
|
||||||
@@ -146,7 +157,8 @@ def track_summary(track, verbose: bool = False) -> None:
|
|||||||
# Étapes du pipeline
|
# Étapes du pipeline
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def step_load_kb() -> "SubtitleKnowledgeBase":
|
|
||||||
|
def step_load_kb() -> SubtitleKnowledgeBase:
|
||||||
from alfred.domain.subtitles.knowledge.base import SubtitleKnowledgeBase
|
from alfred.domain.subtitles.knowledge.base import SubtitleKnowledgeBase
|
||||||
from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader
|
from alfred.domain.subtitles.knowledge.loader import KnowledgeLoader
|
||||||
|
|
||||||
@@ -168,12 +180,12 @@ def step_load_kb() -> "SubtitleKnowledgeBase":
|
|||||||
|
|
||||||
|
|
||||||
def step_detect_pattern(
|
def step_detect_pattern(
|
||||||
kb: "SubtitleKnowledgeBase",
|
kb: SubtitleKnowledgeBase,
|
||||||
season_folder: Path,
|
season_folder: Path,
|
||||||
sample_video: Path,
|
sample_video: Path,
|
||||||
release_group: str | None,
|
release_group: str | None,
|
||||||
forced_pattern: str | None,
|
forced_pattern: str | None,
|
||||||
) -> "SubtitlePattern":
|
) -> SubtitlePattern:
|
||||||
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
|
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
|
||||||
|
|
||||||
section("ÉTAPE 2 — Détection du pattern de release")
|
section("ÉTAPE 2 — Détection du pattern de release")
|
||||||
@@ -192,7 +204,9 @@ def step_detect_pattern(
|
|||||||
known = kb.patterns_for_group(release_group)
|
known = kb.patterns_for_group(release_group)
|
||||||
if known:
|
if known:
|
||||||
kv("Release group", release_group)
|
kv("Release group", release_group)
|
||||||
ok(f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}")
|
ok(
|
||||||
|
f"Pattern(s) connu(s) pour {release_group}: {', '.join(p.id for p in known)}"
|
||||||
|
)
|
||||||
pattern = known[0]
|
pattern = known[0]
|
||||||
kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD))
|
kv("Pattern sélectionné", c(pattern.id, CYAN, BOLD))
|
||||||
return pattern
|
return pattern
|
||||||
@@ -237,12 +251,12 @@ def step_detect_pattern(
|
|||||||
|
|
||||||
|
|
||||||
def step_identify_tracks(
|
def step_identify_tracks(
|
||||||
kb: "SubtitleKnowledgeBase",
|
kb: SubtitleKnowledgeBase,
|
||||||
sample_video: Path,
|
sample_video: Path,
|
||||||
pattern: "SubtitlePattern",
|
pattern: SubtitlePattern,
|
||||||
release_group: str | None,
|
release_group: str | None,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> "MediaSubtitleMetadata":
|
) -> MediaSubtitleMetadata:
|
||||||
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
|
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
|
||||||
|
|
||||||
section("ÉTAPE 3 — Identification des pistes")
|
section("ÉTAPE 3 — Identification des pistes")
|
||||||
@@ -286,9 +300,9 @@ def step_identify_tracks(
|
|||||||
|
|
||||||
|
|
||||||
def step_apply_rules(
|
def step_apply_rules(
|
||||||
metadata: "MediaSubtitleMetadata",
|
metadata: MediaSubtitleMetadata,
|
||||||
release_group: str | None,
|
release_group: str | None,
|
||||||
) -> tuple["SubtitleMatchingRules | None", list, list]:
|
) -> tuple[SubtitleMatchingRules | None, list, list]:
|
||||||
from alfred.domain.subtitles.aggregates import DEFAULT_RULES
|
from alfred.domain.subtitles.aggregates import DEFAULT_RULES
|
||||||
from alfred.domain.subtitles.services.matcher import SubtitleMatcher
|
from alfred.domain.subtitles.services.matcher import SubtitleMatcher
|
||||||
from alfred.domain.subtitles.services.utils import available_subtitles
|
from alfred.domain.subtitles.services.utils import available_subtitles
|
||||||
@@ -308,7 +322,9 @@ def step_apply_rules(
|
|||||||
kv("Formats préférés", str(rules.preferred_formats))
|
kv("Formats préférés", str(rules.preferred_formats))
|
||||||
kv("Types autorisés", str(rules.allowed_types))
|
kv("Types autorisés", str(rules.allowed_types))
|
||||||
kv("Confiance min", str(rules.min_confidence))
|
kv("Confiance min", str(rules.min_confidence))
|
||||||
info(c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4)
|
info(
|
||||||
|
c("(règles globales par défaut — pas de .alfred/ en mode scan)", DIM), indent=4
|
||||||
|
)
|
||||||
|
|
||||||
matcher = SubtitleMatcher()
|
matcher = SubtitleMatcher()
|
||||||
matched, unresolved = matcher.match(metadata.external_tracks, rules)
|
matched, unresolved = matcher.match(metadata.external_tracks, rules)
|
||||||
@@ -330,7 +346,9 @@ def step_show_results(
|
|||||||
section("RÉSULTAT FINAL")
|
section("RÉSULTAT FINAL")
|
||||||
|
|
||||||
if matched:
|
if matched:
|
||||||
label = "piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
|
label = (
|
||||||
|
"piste(s) disponible(s)" if is_embedded else "piste(s) qui seraient placées"
|
||||||
|
)
|
||||||
ok(f"{len(matched)} {label}:")
|
ok(f"{len(matched)} {label}:")
|
||||||
for track in matched:
|
for track in matched:
|
||||||
lang = track.language.code if track.language else "?"
|
lang = track.language.code if track.language else "?"
|
||||||
@@ -352,7 +370,11 @@ def step_show_results(
|
|||||||
warn(f"{len(unresolved)} piste(s) écartées ou à clarifier:")
|
warn(f"{len(unresolved)} piste(s) écartées ou à clarifier:")
|
||||||
for track in unresolved:
|
for track in unresolved:
|
||||||
src = track.file_path.name if track.file_path else "?"
|
src = track.file_path.name if track.file_path else "?"
|
||||||
reason = "langue inconnue" if track.language is None else "confiance insuffisante"
|
reason = (
|
||||||
|
"langue inconnue"
|
||||||
|
if track.language is None
|
||||||
|
else "confiance insuffisante"
|
||||||
|
)
|
||||||
line = f" {c(src, DIM)} ({reason})"
|
line = f" {c(src, DIM)} ({reason})"
|
||||||
if verbose and track.raw_tokens:
|
if verbose and track.raw_tokens:
|
||||||
line += c(f" tokens: {track.raw_tokens}", YELLOW)
|
line += c(f" tokens: {track.raw_tokens}", YELLOW)
|
||||||
@@ -365,9 +387,10 @@ def step_show_results(
|
|||||||
# Scan multi-épisodes (résumé)
|
# Scan multi-épisodes (résumé)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def scan_season(
|
def scan_season(
|
||||||
kb: "SubtitleKnowledgeBase",
|
kb: SubtitleKnowledgeBase,
|
||||||
pattern: "SubtitlePattern",
|
pattern: SubtitlePattern,
|
||||||
season_folder: Path,
|
season_folder: Path,
|
||||||
release_group: str | None,
|
release_group: str | None,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
@@ -408,15 +431,20 @@ def scan_season(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
status_icon = c("✓", GREEN, BOLD) if placed_names else c("✗", RED, BOLD)
|
status_icon = c("✓", GREEN, BOLD) if placed_names else c("✗", RED, BOLD)
|
||||||
warn_icon = c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
|
warn_icon = (
|
||||||
|
c(f" [{len(unresolved)} non-résolue(s)]", YELLOW) if unresolved else ""
|
||||||
|
)
|
||||||
|
|
||||||
print(f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '—', GREEN if placed_names else DIM)}{warn_icon}")
|
print(
|
||||||
|
f" {status_icon} {video.name:{col_w}} {c(', '.join(placed_names) or '—', GREEN if placed_names else DIM)}{warn_icon}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main
|
# Main
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Scanner de sous-titres Alfred — pipeline de diagnostic",
|
description="Scanner de sous-titres Alfred — pipeline de diagnostic",
|
||||||
@@ -424,18 +452,35 @@ def parse_args() -> argparse.Namespace:
|
|||||||
epilog=textwrap.dedent(__doc__ or ""),
|
epilog=textwrap.dedent(__doc__ or ""),
|
||||||
)
|
)
|
||||||
parser.add_argument("season_folder", help="Dossier de la saison (ou du film)")
|
parser.add_argument("season_folder", help="Dossier de la saison (ou du film)")
|
||||||
parser.add_argument("--release-group", "-g", metavar="GROUP",
|
parser.add_argument(
|
||||||
help="Groupe de release (ex: RARBG, KONSTRAST)")
|
"--release-group",
|
||||||
parser.add_argument("--pattern", "-p", metavar="PATTERN",
|
"-g",
|
||||||
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)")
|
metavar="GROUP",
|
||||||
parser.add_argument("--video", "-v", metavar="FILE",
|
help="Groupe de release (ex: RARBG, KONSTRAST)",
|
||||||
help="Fichier vidéo de référence (défaut: premier trouvé)")
|
)
|
||||||
parser.add_argument("--verbose", action="store_true",
|
parser.add_argument(
|
||||||
help="Affiche les tokens bruts par piste")
|
"--pattern",
|
||||||
parser.add_argument("--no-color", action="store_true",
|
"-p",
|
||||||
help="Désactive la colorisation ANSI")
|
metavar="PATTERN",
|
||||||
parser.add_argument("--season-scan", action="store_true",
|
help="Forcer un pattern (adjacent|flat|episode_subfolder|embedded)",
|
||||||
help="Après le diagnostic, scanner tous les épisodes de la saison")
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--video",
|
||||||
|
"-v",
|
||||||
|
metavar="FILE",
|
||||||
|
help="Fichier vidéo de référence (défaut: premier trouvé)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", action="store_true", help="Affiche les tokens bruts par piste"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-color", action="store_true", help="Désactive la colorisation ANSI"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--season-scan",
|
||||||
|
action="store_true",
|
||||||
|
help="Après le diagnostic, scanner tous les épisodes de la saison",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +519,9 @@ def main() -> None:
|
|||||||
if videos:
|
if videos:
|
||||||
break
|
break
|
||||||
if not videos:
|
if not videos:
|
||||||
print("Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr)
|
print(
|
||||||
|
"Erreur: aucun fichier vidéo trouvé dans ce dossier.", file=sys.stderr
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
sample_video = videos[0]
|
sample_video = videos[0]
|
||||||
|
|
||||||
|
|||||||
@@ -67,10 +67,22 @@ def section(title: str) -> None:
|
|||||||
print(c("─" * 70, DIM))
|
print(c("─" * 70, DIM))
|
||||||
|
|
||||||
|
|
||||||
def ok(msg: str) -> None: print(c(" ✓ ", GREEN, BOLD) + msg)
|
def ok(msg: str) -> None:
|
||||||
def warn(msg: str) -> None: print(c(" ⚠ ", YELLOW, BOLD) + msg)
|
print(c(" ✓ ", GREEN, BOLD) + msg)
|
||||||
def err(msg: str) -> None: print(c(" ✗ ", RED, BOLD) + msg)
|
|
||||||
def info(msg: str) -> None: print(f" {msg}")
|
|
||||||
|
def warn(msg: str) -> None:
|
||||||
|
print(c(" ⚠ ", YELLOW, BOLD) + msg)
|
||||||
|
|
||||||
|
|
||||||
|
def err(msg: str) -> None:
|
||||||
|
print(c(" ✗ ", RED, BOLD) + msg)
|
||||||
|
|
||||||
|
|
||||||
|
def info(msg: str) -> None:
|
||||||
|
print(f" {msg}")
|
||||||
|
|
||||||
|
|
||||||
def kv(key: str, val: str) -> None:
|
def kv(key: str, val: str) -> None:
|
||||||
print(f" {c(key + ':', BOLD)} {val}")
|
print(f" {c(key + ':', BOLD)} {val}")
|
||||||
|
|
||||||
@@ -79,6 +91,7 @@ def kv(key: str, val: str) -> None:
|
|||||||
# Dry-run tool stubs
|
# Dry-run tool stubs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
||||||
"""Call the real list_folder (read-only, safe in dry-run)."""
|
"""Call the real list_folder (read-only, safe in dry-run)."""
|
||||||
# TODO: remove hardcoded fallback once download path is configured in LTM
|
# TODO: remove hardcoded fallback once download path is configured in LTM
|
||||||
@@ -86,20 +99,29 @@ def _real_list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from alfred.infrastructure.persistence import get_memory, init_memory
|
from alfred.infrastructure.persistence import get_memory, init_memory
|
||||||
|
|
||||||
try:
|
try:
|
||||||
get_memory()
|
get_memory()
|
||||||
except Exception:
|
except Exception:
|
||||||
init_memory()
|
init_memory()
|
||||||
from alfred.agent.tools.filesystem import list_folder
|
from alfred.agent.tools.filesystem import list_folder
|
||||||
|
|
||||||
result = list_folder(folder_type=folder_type, path=path)
|
result = list_folder(folder_type=folder_type, path=path)
|
||||||
if result.get("status") == "error" and folder_type == "download":
|
if result.get("status") == "error" and folder_type == "download":
|
||||||
raise RuntimeError(result.get("message", "not configured"))
|
raise RuntimeError(result.get("message", "not configured"))
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if folder_type == "download":
|
if folder_type == "download":
|
||||||
warn(f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}")
|
warn(
|
||||||
|
f"list_folder: {e} — using hardcoded download root: {_HARDCODED_DOWNLOAD_ROOT}"
|
||||||
|
)
|
||||||
import os
|
import os
|
||||||
resolved = os.path.join(_HARDCODED_DOWNLOAD_ROOT, path) if path != "." else _HARDCODED_DOWNLOAD_ROOT
|
|
||||||
|
resolved = (
|
||||||
|
os.path.join(_HARDCODED_DOWNLOAD_ROOT, path)
|
||||||
|
if path != "."
|
||||||
|
else _HARDCODED_DOWNLOAD_ROOT
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
entries = sorted(os.listdir(resolved))
|
entries = sorted(os.listdir(resolved))
|
||||||
except OSError as oe:
|
except OSError as oe:
|
||||||
@@ -125,11 +147,13 @@ def _real_find_media_imdb_id(media_title: str, **kwargs) -> dict[str, Any]:
|
|||||||
"""Call the real TMDB API even in dry-run (read-only, no filesystem side effects)."""
|
"""Call the real TMDB API even in dry-run (read-only, no filesystem side effects)."""
|
||||||
try:
|
try:
|
||||||
from alfred.infrastructure.persistence import get_memory, init_memory
|
from alfred.infrastructure.persistence import get_memory, init_memory
|
||||||
|
|
||||||
try:
|
try:
|
||||||
get_memory()
|
get_memory()
|
||||||
except Exception:
|
except Exception:
|
||||||
init_memory()
|
init_memory()
|
||||||
from alfred.agent.tools.api import find_media_imdb_id
|
from alfred.agent.tools.api import find_media_imdb_id
|
||||||
|
|
||||||
return find_media_imdb_id(media_title=media_title)
|
return find_media_imdb_id(media_title=media_title)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
warn(f"find_media_imdb_id: TMDB unavailable ({e}), falling back to stub")
|
warn(f"find_media_imdb_id: TMDB unavailable ({e}), falling back to stub")
|
||||||
@@ -151,6 +175,7 @@ def _dry_resolve_destination(
|
|||||||
confirmed_folder: str | None = None,
|
confirmed_folder: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from alfred.domain.release import parse_release
|
from alfred.domain.release import parse_release
|
||||||
|
|
||||||
parsed = parse_release(release_name)
|
parsed = parse_release(release_name)
|
||||||
ext = Path(source_file).suffix
|
ext = Path(source_file).suffix
|
||||||
if parsed.is_movie:
|
if parsed.is_movie:
|
||||||
@@ -168,7 +193,11 @@ def _dry_resolve_destination(
|
|||||||
}
|
}
|
||||||
season_folder = parsed.season_folder_name()
|
season_folder = parsed.season_folder_name()
|
||||||
show_folder = confirmed_folder or parsed.show_folder_name(tmdb_title, tmdb_year)
|
show_folder = confirmed_folder or parsed.show_folder_name(tmdb_title, tmdb_year)
|
||||||
fname = parsed.episode_filename(tmdb_episode_title, ext) if not parsed.is_season_pack else season_folder + ext
|
fname = (
|
||||||
|
parsed.episode_filename(tmdb_episode_title, ext)
|
||||||
|
if not parsed.is_season_pack
|
||||||
|
else season_folder + ext
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"library_file": f"/tv/{show_folder}/{season_folder}/{fname}",
|
"library_file": f"/tv/{show_folder}/{season_folder}/{fname}",
|
||||||
@@ -201,7 +230,9 @@ def _dry_manage_subtitles(source_video: str, destination_video: str) -> dict[str
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _dry_create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]:
|
def _dry_create_seed_links(
|
||||||
|
library_file: str, original_download_folder: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"torrent_subfolder": f"/torrents/{Path(original_download_folder).name}",
|
"torrent_subfolder": f"/torrents/{Path(original_download_folder).name}",
|
||||||
@@ -226,6 +257,7 @@ DRY_RUN_TOOLS: dict[str, Any] = {
|
|||||||
# Live tools
|
# Live tools
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _load_live_tools() -> dict[str, Any]:
|
def _load_live_tools() -> dict[str, Any]:
|
||||||
from alfred.agent.tools.filesystem import (
|
from alfred.agent.tools.filesystem import (
|
||||||
create_seed_links,
|
create_seed_links,
|
||||||
@@ -233,12 +265,18 @@ def _load_live_tools() -> dict[str, Any]:
|
|||||||
manage_subtitles,
|
manage_subtitles,
|
||||||
move_media,
|
move_media,
|
||||||
)
|
)
|
||||||
|
|
||||||
# find_media_imdb_id lives in the api tools
|
# find_media_imdb_id lives in the api tools
|
||||||
try:
|
try:
|
||||||
from alfred.agent.tools.api import find_media_imdb_id
|
from alfred.agent.tools.api import find_media_imdb_id
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
||||||
def find_media_imdb_id(**kwargs): # type: ignore[misc]
|
def find_media_imdb_id(**kwargs): # type: ignore[misc]
|
||||||
return {"status": "error", "error": "not_available", "message": "api tools not loaded"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "not_available",
|
||||||
|
"message": "api tools not loaded",
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"list_folder": list_folder,
|
"list_folder": list_folder,
|
||||||
@@ -253,8 +291,15 @@ def _load_live_tools() -> dict[str, Any]:
|
|||||||
# Workflow runner
|
# Workflow runner
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class WorkflowRunner:
|
class WorkflowRunner:
|
||||||
def __init__(self, workflow: dict, tools: dict[str, Any], live: bool, args: argparse.Namespace):
|
def __init__(
|
||||||
|
self,
|
||||||
|
workflow: dict,
|
||||||
|
tools: dict[str, Any],
|
||||||
|
live: bool,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
):
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
self.tools = tools
|
self.tools = tools
|
||||||
self.live = live
|
self.live = live
|
||||||
@@ -281,11 +326,15 @@ class WorkflowRunner:
|
|||||||
|
|
||||||
section("SIMULATION TERMINÉE")
|
section("SIMULATION TERMINÉE")
|
||||||
ok(f"{len(self.step_results)} step(s) exécuté(s)")
|
ok(f"{len(self.step_results)} step(s) exécuté(s)")
|
||||||
errors = [r for r in self.step_results if r.get("result", {}).get("status") == "error"]
|
errors = [
|
||||||
|
r for r in self.step_results if r.get("result", {}).get("status") == "error"
|
||||||
|
]
|
||||||
if errors:
|
if errors:
|
||||||
warn(f"{len(errors)} step(s) en erreur")
|
warn(f"{len(errors)} step(s) en erreur")
|
||||||
for r in errors:
|
for r in errors:
|
||||||
err(f" {r['id']}: {r['result'].get('error')} — {r['result'].get('message')}")
|
err(
|
||||||
|
f" {r['id']}: {r['result'].get('error')} — {r['result'].get('message')}"
|
||||||
|
)
|
||||||
print()
|
print()
|
||||||
print(c("━" * 70, BOLD))
|
print(c("━" * 70, BOLD))
|
||||||
print()
|
print()
|
||||||
@@ -306,7 +355,7 @@ class WorkflowRunner:
|
|||||||
answers_str = {str(k): v for k, v in answers.items()}
|
answers_str = {str(k): v for k, v in answers.items()}
|
||||||
next_step = answers_str.get(answer, {}).get("next_step", "update_library")
|
next_step = answers_str.get(answer, {}).get("next_step", "update_library")
|
||||||
ok(f"Réponse simulée: {c(answer, CYAN)} → next: {c(next_step, CYAN)}")
|
ok(f"Réponse simulée: {c(answer, CYAN)} → next: {c(next_step, CYAN)}")
|
||||||
self.context["seeding"] = (answer == "yes")
|
self.context["seeding"] = answer == "yes"
|
||||||
self.context["ask_seeding_answer"] = answer
|
self.context["ask_seeding_answer"] = answer
|
||||||
self.context["next_after_ask"] = next_step
|
self.context["next_after_ask"] = next_step
|
||||||
|
|
||||||
@@ -332,7 +381,9 @@ class WorkflowRunner:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Skip create_seed_links if user said no to seeding
|
# Skip create_seed_links if user said no to seeding
|
||||||
if tool_name == "create_seed_links" and self.context.get("skip_create_seed_links"):
|
if tool_name == "create_seed_links" and self.context.get(
|
||||||
|
"skip_create_seed_links"
|
||||||
|
):
|
||||||
section(f"STEP [{step_id}] — {tool_name}")
|
section(f"STEP [{step_id}] — {tool_name}")
|
||||||
warn("Skipped (user chose not to seed)")
|
warn("Skipped (user chose not to seed)")
|
||||||
return
|
return
|
||||||
@@ -349,14 +400,18 @@ class WorkflowRunner:
|
|||||||
|
|
||||||
if tool_name not in self.tools:
|
if tool_name not in self.tools:
|
||||||
err(f"Tool '{tool_name}' not found in tool registry")
|
err(f"Tool '{tool_name}' not found in tool registry")
|
||||||
self.step_results.append({"id": step_id, "result": {"status": "error", "error": "unknown_tool"}})
|
self.step_results.append(
|
||||||
|
{"id": step_id, "result": {"status": "error", "error": "unknown_tool"}}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.tools[tool_name](**kwargs)
|
result = self.tools[tool_name](**kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err(f"Tool raised an exception: {e}")
|
err(f"Tool raised an exception: {e}")
|
||||||
self.step_results.append({"id": step_id, "result": {"status": "error", "error": str(e)}})
|
self.step_results.append(
|
||||||
|
{"id": step_id, "result": {"status": "error", "error": str(e)}}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._print_result(result, tool_name=tool_name)
|
self._print_result(result, tool_name=tool_name)
|
||||||
@@ -364,14 +419,20 @@ class WorkflowRunner:
|
|||||||
self.step_results.append({"id": step_id, "result": result})
|
self.step_results.append({"id": step_id, "result": result})
|
||||||
|
|
||||||
# After list_downloads: confirm the requested media folder exists in downloads
|
# After list_downloads: confirm the requested media folder exists in downloads
|
||||||
if tool_name == "list_folder" and result.get("status") == "ok" and self.args.source:
|
if (
|
||||||
|
tool_name == "list_folder"
|
||||||
|
and result.get("status") == "ok"
|
||||||
|
and self.args.source
|
||||||
|
):
|
||||||
folder_path = result.get("path", "")
|
folder_path = result.get("path", "")
|
||||||
entries = result.get("entries", [])
|
entries = result.get("entries", [])
|
||||||
if self.args.source in entries:
|
if self.args.source in entries:
|
||||||
media_folder = str(Path(folder_path) / self.args.source)
|
media_folder = str(Path(folder_path) / self.args.source)
|
||||||
self.context["media_folder"] = media_folder
|
self.context["media_folder"] = media_folder
|
||||||
print()
|
print()
|
||||||
print(f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}")
|
print(
|
||||||
|
f" {c('Dossier media trouvé:', BOLD, GREEN)} {c(media_folder, CYAN, BOLD)}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}")
|
warn(f"Dossier '{self.args.source}' introuvable dans {folder_path}")
|
||||||
|
|
||||||
@@ -446,13 +507,17 @@ class WorkflowRunner:
|
|||||||
elif status == "needs_clarification":
|
elif status == "needs_clarification":
|
||||||
warn(f"status={c('needs_clarification', YELLOW)}")
|
warn(f"status={c('needs_clarification', YELLOW)}")
|
||||||
else:
|
else:
|
||||||
err(f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}")
|
err(
|
||||||
|
f"status={c(status, RED)} error={result.get('error')} msg={result.get('message')}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Highlight resolved folder path for list_folder
|
# Highlight resolved folder path for list_folder
|
||||||
if tool_name == "list_folder" and result.get("path"):
|
if tool_name == "list_folder" and result.get("path"):
|
||||||
print()
|
print()
|
||||||
print(f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}")
|
print(
|
||||||
|
f" {c('Dossier résolu:', BOLD, GREEN)} {c(result['path'], CYAN, BOLD)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Pretty-print notable fields
|
# Pretty-print notable fields
|
||||||
skip = {"status", "error", "message"}
|
skip = {"status", "error", "message"}
|
||||||
@@ -476,6 +541,7 @@ class WorkflowRunner:
|
|||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Alfred workflow simulator",
|
description="Alfred workflow simulator",
|
||||||
@@ -483,28 +549,58 @@ def parse_args() -> argparse.Namespace:
|
|||||||
epilog=textwrap.dedent(__doc__ or ""),
|
epilog=textwrap.dedent(__doc__ or ""),
|
||||||
)
|
)
|
||||||
parser.add_argument("workflow", help="Workflow name (e.g. organize_media)")
|
parser.add_argument("workflow", help="Workflow name (e.g. organize_media)")
|
||||||
parser.add_argument("--dry-run", dest="dry_run", action="store_true", default=True,
|
parser.add_argument(
|
||||||
help="Simulate steps without executing tools (default)")
|
"--dry-run",
|
||||||
parser.add_argument("--live", action="store_true",
|
dest="dry_run",
|
||||||
help="Actually execute tools against the real filesystem")
|
action="store_true",
|
||||||
parser.add_argument("--source", metavar="FOLDER_NAME",
|
default=True,
|
||||||
help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)")
|
help="Simulate steps without executing tools (default)",
|
||||||
parser.add_argument("--dest", metavar="PATH",
|
)
|
||||||
help="Destination video file (in library, overrides resolve_destination)")
|
parser.add_argument(
|
||||||
parser.add_argument("--download-folder", metavar="PATH",
|
"--live",
|
||||||
help="Original download folder (for create_seed_links)")
|
action="store_true",
|
||||||
parser.add_argument("--imdb-id", metavar="ID",
|
help="Actually execute tools against the real filesystem",
|
||||||
help="IMDb ID for identify_media (tt1234567)")
|
)
|
||||||
parser.add_argument("--release", metavar="NAME",
|
parser.add_argument(
|
||||||
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)")
|
"--source",
|
||||||
parser.add_argument("--tmdb-title", metavar="TITLE",
|
metavar="FOLDER_NAME",
|
||||||
help="Canonical title from TMDB (e.g. 'Oz')")
|
help="Release folder name inside the download root (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)",
|
||||||
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
|
)
|
||||||
help="Start/release year from TMDB (e.g. 1997)")
|
parser.add_argument(
|
||||||
parser.add_argument("--episode-title", metavar="TITLE",
|
"--dest",
|
||||||
help="Episode title from TMDB for single-episode releases")
|
metavar="PATH",
|
||||||
parser.add_argument("--seed", action="store_true",
|
help="Destination video file (in library, overrides resolve_destination)",
|
||||||
help='Answer "yes" to the seeding question')
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--download-folder",
|
||||||
|
metavar="PATH",
|
||||||
|
help="Original download folder (for create_seed_links)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--imdb-id", metavar="ID", help="IMDb ID for identify_media (tt1234567)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--release",
|
||||||
|
metavar="NAME",
|
||||||
|
help="Release name (e.g. Oz.S03.1080p.WEBRip.x265-KONTRAST)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tmdb-title", metavar="TITLE", help="Canonical title from TMDB (e.g. 'Oz')"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tmdb-year",
|
||||||
|
metavar="YEAR",
|
||||||
|
type=int,
|
||||||
|
help="Start/release year from TMDB (e.g. 1997)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--episode-title",
|
||||||
|
metavar="TITLE",
|
||||||
|
help="Episode title from TMDB for single-episode releases",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--seed", action="store_true", help='Answer "yes" to the seeding question'
|
||||||
|
)
|
||||||
parser.add_argument("--no-color", action="store_true")
|
parser.add_argument("--no-color", action="store_true")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
@@ -521,6 +617,7 @@ def main() -> None:
|
|||||||
|
|
||||||
# Load workflow
|
# Load workflow
|
||||||
from alfred.agent.workflows.loader import WorkflowLoader
|
from alfred.agent.workflows.loader import WorkflowLoader
|
||||||
|
|
||||||
loader = WorkflowLoader()
|
loader = WorkflowLoader()
|
||||||
workflow = loader.get(args.workflow)
|
workflow = loader.get(args.workflow)
|
||||||
if not workflow:
|
if not workflow:
|
||||||
|
|||||||
@@ -2,22 +2,20 @@
|
|||||||
Tests for alfred.agent.registry — tool registration and JSON schema generation.
|
Tests for alfred.agent.registry — tool registration and JSON schema generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools
|
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools
|
||||||
from alfred.settings import settings
|
from alfred.settings import settings
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _create_tool_from_function
|
# _create_tool_from_function
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestCreateToolFromFunction:
|
|
||||||
|
|
||||||
|
class TestCreateToolFromFunction:
|
||||||
def test_name_from_function(self):
|
def test_name_from_function(self):
|
||||||
def my_tool(x: str) -> dict:
|
def my_tool(x: str) -> dict:
|
||||||
"""Does something."""
|
"""Does something."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
tool = _create_tool_from_function(my_tool)
|
tool = _create_tool_from_function(my_tool)
|
||||||
assert tool.name == "my_tool"
|
assert tool.name == "my_tool"
|
||||||
|
|
||||||
@@ -28,12 +26,14 @@ class TestCreateToolFromFunction:
|
|||||||
More details here.
|
More details here.
|
||||||
"""
|
"""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
tool = _create_tool_from_function(my_tool)
|
tool = _create_tool_from_function(my_tool)
|
||||||
assert tool.description == "First line description."
|
assert tool.description == "First line description."
|
||||||
|
|
||||||
def test_description_fallback_to_name(self):
|
def test_description_fallback_to_name(self):
|
||||||
def no_doc(x: str) -> dict:
|
def no_doc(x: str) -> dict:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
tool = _create_tool_from_function(no_doc)
|
tool = _create_tool_from_function(no_doc)
|
||||||
assert tool.description == "no_doc"
|
assert tool.description == "no_doc"
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(a: str, b: int) -> dict:
|
def tool(a: str, b: int) -> dict:
|
||||||
"""Tool."""
|
"""Tool."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert "a" in t.parameters["required"]
|
assert "a" in t.parameters["required"]
|
||||||
assert "b" in t.parameters["required"]
|
assert "b" in t.parameters["required"]
|
||||||
@@ -49,6 +50,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(a: str, b: str = "default") -> dict:
|
def tool(a: str, b: str = "default") -> dict:
|
||||||
"""Tool."""
|
"""Tool."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert "a" in t.parameters["required"]
|
assert "a" in t.parameters["required"]
|
||||||
assert "b" not in t.parameters["required"]
|
assert "b" not in t.parameters["required"]
|
||||||
@@ -57,6 +59,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(a: str, b: str | None = None) -> dict:
|
def tool(a: str, b: str | None = None) -> dict:
|
||||||
"""Tool."""
|
"""Tool."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert "b" not in t.parameters["required"]
|
assert "b" not in t.parameters["required"]
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(x: str) -> dict:
|
def tool(x: str) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert t.parameters["properties"]["x"]["type"] == "string"
|
assert t.parameters["properties"]["x"]["type"] == "string"
|
||||||
|
|
||||||
@@ -71,6 +75,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(x: int) -> dict:
|
def tool(x: int) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert t.parameters["properties"]["x"]["type"] == "integer"
|
assert t.parameters["properties"]["x"]["type"] == "integer"
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(x: float) -> dict:
|
def tool(x: float) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert t.parameters["properties"]["x"]["type"] == "number"
|
assert t.parameters["properties"]["x"]["type"] == "number"
|
||||||
|
|
||||||
@@ -85,6 +91,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(x: bool) -> dict:
|
def tool(x: bool) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert t.parameters["properties"]["x"]["type"] == "boolean"
|
assert t.parameters["properties"]["x"]["type"] == "boolean"
|
||||||
|
|
||||||
@@ -92,6 +99,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(x: list) -> dict:
|
def tool(x: list) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert t.parameters["properties"]["x"]["type"] == "string"
|
assert t.parameters["properties"]["x"]["type"] == "string"
|
||||||
|
|
||||||
@@ -99,6 +107,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(x) -> dict:
|
def tool(x) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert t.parameters["properties"]["x"]["type"] == "string"
|
assert t.parameters["properties"]["x"]["type"] == "string"
|
||||||
|
|
||||||
@@ -107,6 +116,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(self, x: str) -> dict:
|
def tool(self, x: str) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(MyClass().tool)
|
t = _create_tool_from_function(MyClass().tool)
|
||||||
assert "self" not in t.parameters["properties"]
|
assert "self" not in t.parameters["properties"]
|
||||||
|
|
||||||
@@ -114,6 +124,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(a: str, b: int = 0) -> dict:
|
def tool(a: str, b: int = 0) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert t.parameters["type"] == "object"
|
assert t.parameters["type"] == "object"
|
||||||
assert "properties" in t.parameters
|
assert "properties" in t.parameters
|
||||||
@@ -123,6 +134,7 @@ class TestCreateToolFromFunction:
|
|||||||
def tool(x: str) -> dict:
|
def tool(x: str) -> dict:
|
||||||
"""T."""
|
"""T."""
|
||||||
return {"x": x}
|
return {"x": x}
|
||||||
|
|
||||||
t = _create_tool_from_function(tool)
|
t = _create_tool_from_function(tool)
|
||||||
assert t.func("hello") == {"x": "hello"}
|
assert t.func("hello") == {"x": "hello"}
|
||||||
|
|
||||||
@@ -131,8 +143,8 @@ class TestCreateToolFromFunction:
|
|||||||
# make_tools
|
# make_tools
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestMakeTools:
|
|
||||||
|
|
||||||
|
class TestMakeTools:
|
||||||
def test_returns_dict(self):
|
def test_returns_dict(self):
|
||||||
tools = make_tools(settings)
|
tools = make_tools(settings)
|
||||||
assert isinstance(tools, dict)
|
assert isinstance(tools, dict)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ def memory_configured(app_temp, tmp_path):
|
|||||||
Fresh Memory with library_paths and workspace configured using the real API.
|
Fresh Memory with library_paths and workspace configured using the real API.
|
||||||
Replaces the broken memory_with_config from root conftest for these tests.
|
Replaces the broken memory_with_config from root conftest for these tests.
|
||||||
"""
|
"""
|
||||||
import tempfile, os
|
|
||||||
storage = tempfile.mkdtemp()
|
storage = tempfile.mkdtemp()
|
||||||
mem = Memory(storage_dir=storage)
|
mem = Memory(storage_dir=storage)
|
||||||
set_memory(mem)
|
set_memory(mem)
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
Tests for alfred.application.filesystem.create_seed_links.CreateSeedLinksUseCase
|
Tests for alfred.application.filesystem.create_seed_links.CreateSeedLinksUseCase
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from alfred.application.filesystem.create_seed_links import CreateSeedLinksUseCase
|
from alfred.application.filesystem.create_seed_links import CreateSeedLinksUseCase
|
||||||
@@ -32,7 +28,12 @@ def seed_env(tmp_path_factory):
|
|||||||
"""
|
"""
|
||||||
d = tmp_path_factory.mktemp("seed_env")
|
d = tmp_path_factory.mktemp("seed_env")
|
||||||
|
|
||||||
lib_dir = d / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
lib_dir = (
|
||||||
|
d
|
||||||
|
/ "tv"
|
||||||
|
/ "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
)
|
||||||
lib_dir.mkdir(parents=True)
|
lib_dir.mkdir(parents=True)
|
||||||
lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||||
lib_video.write_bytes(b"video")
|
lib_video.write_bytes(b"video")
|
||||||
@@ -43,7 +44,9 @@ def seed_env(tmp_path_factory):
|
|||||||
(dl / "[TGx]info.txt").write_text("tgx")
|
(dl / "[TGx]info.txt").write_text("tgx")
|
||||||
subs = dl / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
|
subs = dl / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
|
||||||
subs.mkdir(parents=True)
|
subs.mkdir(parents=True)
|
||||||
(subs / "2_eng,English [CC][SDH].srt").write_text("1\n00:00:01 --> 00:00:02\nHello\n")
|
(subs / "2_eng,English [CC][SDH].srt").write_text(
|
||||||
|
"1\n00:00:01 --> 00:00:02\nHello\n"
|
||||||
|
)
|
||||||
|
|
||||||
torrents = d / "torrents"
|
torrents = d / "torrents"
|
||||||
torrents.mkdir()
|
torrents.mkdir()
|
||||||
@@ -55,10 +58,13 @@ def seed_env(tmp_path_factory):
|
|||||||
# Happy path
|
# Happy path
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestCreateSeedLinksHappyPath:
|
|
||||||
|
|
||||||
def test_ok_when_torrent_folder_configured(self, use_case, seed_env, memory_configured):
|
class TestCreateSeedLinksHappyPath:
|
||||||
|
def test_ok_when_torrent_folder_configured(
|
||||||
|
self, use_case, seed_env, memory_configured
|
||||||
|
):
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
mem = get_memory()
|
mem = get_memory()
|
||||||
lib_video, dl, torrents = seed_env
|
lib_video, dl, torrents = seed_env
|
||||||
mem.ltm.workspace.torrent = str(torrents)
|
mem.ltm.workspace.torrent = str(torrents)
|
||||||
@@ -73,6 +79,7 @@ class TestCreateSeedLinksHappyPath:
|
|||||||
|
|
||||||
def test_to_dict_ok(self, use_case, seed_env, memory_configured):
|
def test_to_dict_ok(self, use_case, seed_env, memory_configured):
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
mem = get_memory()
|
mem = get_memory()
|
||||||
lib_video, dl, torrents = seed_env
|
lib_video, dl, torrents = seed_env
|
||||||
mem.ltm.workspace.torrent = str(torrents)
|
mem.ltm.workspace.torrent = str(torrents)
|
||||||
@@ -89,8 +96,8 @@ class TestCreateSeedLinksHappyPath:
|
|||||||
# Error: torrent folder not configured
|
# Error: torrent folder not configured
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestCreateSeedLinksErrors:
|
|
||||||
|
|
||||||
|
class TestCreateSeedLinksErrors:
|
||||||
def test_error_when_torrent_not_configured(self, use_case, seed_env, memory):
|
def test_error_when_torrent_not_configured(self, use_case, seed_env, memory):
|
||||||
lib_video, dl, _ = seed_env
|
lib_video, dl, _ = seed_env
|
||||||
result = use_case.execute(str(lib_video), str(dl))
|
result = use_case.execute(str(lib_video), str(dl))
|
||||||
@@ -109,6 +116,7 @@ class TestCreateSeedLinksErrors:
|
|||||||
def test_error_delegates_to_file_manager(self, memory_configured):
|
def test_error_delegates_to_file_manager(self, memory_configured):
|
||||||
"""FileManager errors are propagated correctly."""
|
"""FileManager errors are propagated correctly."""
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
mem = get_memory()
|
mem = get_memory()
|
||||||
# torrent already configured by memory_configured fixture
|
# torrent already configured by memory_configured fixture
|
||||||
# library_file does not exist → should propagate error from FileManager
|
# library_file does not exist → should propagate error from FileManager
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
"""Tests for ListFolderUseCase and MoveMediaUseCase."""
|
"""Tests for ListFolderUseCase and MoveMediaUseCase."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from alfred.application.filesystem.list_folder import ListFolderUseCase
|
from alfred.application.filesystem.list_folder import ListFolderUseCase
|
||||||
from alfred.application.filesystem.move_media import MoveMediaUseCase
|
from alfred.application.filesystem.move_media import MoveMediaUseCase
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ListFolderUseCase
|
# ListFolderUseCase
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestListFolderUseCase:
|
|
||||||
|
|
||||||
|
class TestListFolderUseCase:
|
||||||
def _use_case(self, fm_result):
|
def _use_case(self, fm_result):
|
||||||
fm = MagicMock()
|
fm = MagicMock()
|
||||||
fm.list_folder.return_value = fm_result
|
fm.list_folder.return_value = fm_result
|
||||||
return ListFolderUseCase(fm)
|
return ListFolderUseCase(fm)
|
||||||
|
|
||||||
def test_success_returns_response(self):
|
def test_success_returns_response(self):
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"folder_type": "download",
|
"folder_type": "download",
|
||||||
"path": ".",
|
"path": ".",
|
||||||
"entries": ["movie.mkv", "show/"],
|
"entries": ["movie.mkv", "show/"],
|
||||||
"count": 2,
|
"count": 2,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute("download")
|
resp = uc.execute("download")
|
||||||
assert resp.status == "ok"
|
assert resp.status == "ok"
|
||||||
assert resp.folder_type == "download"
|
assert resp.folder_type == "download"
|
||||||
@@ -34,11 +34,13 @@ class TestListFolderUseCase:
|
|||||||
assert resp.count == 2
|
assert resp.count == 2
|
||||||
|
|
||||||
def test_error_propagates(self):
|
def test_error_propagates(self):
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": "folder_not_set",
|
"error": "folder_not_set",
|
||||||
"message": "Download folder not configured.",
|
"message": "Download folder not configured.",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute("download")
|
resp = uc.execute("download")
|
||||||
assert resp.status == "error"
|
assert resp.status == "error"
|
||||||
assert resp.error == "folder_not_set"
|
assert resp.error == "folder_not_set"
|
||||||
@@ -60,30 +62,37 @@ class TestListFolderUseCase:
|
|||||||
def test_default_path_is_dot(self):
|
def test_default_path_is_dot(self):
|
||||||
fm = MagicMock()
|
fm = MagicMock()
|
||||||
fm.list_folder.return_value = {
|
fm.list_folder.return_value = {
|
||||||
"status": "ok", "folder_type": "download",
|
"status": "ok",
|
||||||
"path": ".", "entries": [], "count": 0,
|
"folder_type": "download",
|
||||||
|
"path": ".",
|
||||||
|
"entries": [],
|
||||||
|
"count": 0,
|
||||||
}
|
}
|
||||||
uc = ListFolderUseCase(fm)
|
uc = ListFolderUseCase(fm)
|
||||||
uc.execute("download")
|
uc.execute("download")
|
||||||
fm.list_folder.assert_called_once_with("download", ".")
|
fm.list_folder.assert_called_once_with("download", ".")
|
||||||
|
|
||||||
def test_success_response_has_no_error(self):
|
def test_success_response_has_no_error(self):
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"folder_type": "movie",
|
"folder_type": "movie",
|
||||||
"path": ".",
|
"path": ".",
|
||||||
"entries": [],
|
"entries": [],
|
||||||
"count": 0,
|
"count": 0,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute("movie")
|
resp = uc.execute("movie")
|
||||||
assert resp.error is None
|
assert resp.error is None
|
||||||
|
|
||||||
def test_error_response_has_no_entries(self):
|
def test_error_response_has_no_entries(self):
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": "not_found",
|
"error": "not_found",
|
||||||
"message": "Path does not exist",
|
"message": "Path does not exist",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute("download", "some/path")
|
resp = uc.execute("download", "some/path")
|
||||||
assert resp.entries is None
|
assert resp.entries is None
|
||||||
assert resp.count is None
|
assert resp.count is None
|
||||||
@@ -93,8 +102,8 @@ class TestListFolderUseCase:
|
|||||||
# MoveMediaUseCase
|
# MoveMediaUseCase
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestMoveMediaUseCase:
|
|
||||||
|
|
||||||
|
class TestMoveMediaUseCase:
|
||||||
def _use_case(self, fm_result):
|
def _use_case(self, fm_result):
|
||||||
fm = MagicMock()
|
fm = MagicMock()
|
||||||
fm.move_file.return_value = fm_result
|
fm.move_file.return_value = fm_result
|
||||||
@@ -103,13 +112,15 @@ class TestMoveMediaUseCase:
|
|||||||
def test_success_returns_response(self, tmp_path):
|
def test_success_returns_response(self, tmp_path):
|
||||||
src = str(tmp_path / "src.mkv")
|
src = str(tmp_path / "src.mkv")
|
||||||
dst = str(tmp_path / "dst.mkv")
|
dst = str(tmp_path / "dst.mkv")
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"source": src,
|
"source": src,
|
||||||
"destination": dst,
|
"destination": dst,
|
||||||
"filename": "dst.mkv",
|
"filename": "dst.mkv",
|
||||||
"size": 1024,
|
"size": 1024,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute(src, dst)
|
resp = uc.execute(src, dst)
|
||||||
assert resp.status == "ok"
|
assert resp.status == "ok"
|
||||||
assert resp.source == src
|
assert resp.source == src
|
||||||
@@ -118,11 +129,13 @@ class TestMoveMediaUseCase:
|
|||||||
assert resp.size == 1024
|
assert resp.size == 1024
|
||||||
|
|
||||||
def test_error_propagates(self, tmp_path):
|
def test_error_propagates(self, tmp_path):
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": "source_not_found",
|
"error": "source_not_found",
|
||||||
"message": "Source does not exist: /ghost.mkv",
|
"message": "Source does not exist: /ghost.mkv",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv"))
|
resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv"))
|
||||||
assert resp.status == "error"
|
assert resp.status == "error"
|
||||||
assert resp.error == "source_not_found"
|
assert resp.error == "source_not_found"
|
||||||
@@ -132,19 +145,24 @@ class TestMoveMediaUseCase:
|
|||||||
dst = "/movies/Movie.2024/movie.mkv"
|
dst = "/movies/Movie.2024/movie.mkv"
|
||||||
fm = MagicMock()
|
fm = MagicMock()
|
||||||
fm.move_file.return_value = {
|
fm.move_file.return_value = {
|
||||||
"status": "ok", "source": src, "destination": dst,
|
"status": "ok",
|
||||||
"filename": "movie.mkv", "size": 1,
|
"source": src,
|
||||||
|
"destination": dst,
|
||||||
|
"filename": "movie.mkv",
|
||||||
|
"size": 1,
|
||||||
}
|
}
|
||||||
uc = MoveMediaUseCase(fm)
|
uc = MoveMediaUseCase(fm)
|
||||||
uc.execute(src, dst)
|
uc.execute(src, dst)
|
||||||
fm.move_file.assert_called_once_with(src, dst)
|
fm.move_file.assert_called_once_with(src, dst)
|
||||||
|
|
||||||
def test_error_response_has_no_paths(self):
|
def test_error_response_has_no_paths(self):
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": "destination_exists",
|
"error": "destination_exists",
|
||||||
"message": "File already exists",
|
"message": "File already exists",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute("/src.mkv", "/dst.mkv")
|
resp = uc.execute("/src.mkv", "/dst.mkv")
|
||||||
assert resp.source is None
|
assert resp.source is None
|
||||||
assert resp.destination is None
|
assert resp.destination is None
|
||||||
@@ -153,13 +171,15 @@ class TestMoveMediaUseCase:
|
|||||||
def test_to_dict_success(self, tmp_path):
|
def test_to_dict_success(self, tmp_path):
|
||||||
src = "/downloads/movie.mkv"
|
src = "/downloads/movie.mkv"
|
||||||
dst = "/movies/movie.mkv"
|
dst = "/movies/movie.mkv"
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"source": src,
|
"source": src,
|
||||||
"destination": dst,
|
"destination": dst,
|
||||||
"filename": "movie.mkv",
|
"filename": "movie.mkv",
|
||||||
"size": 2048,
|
"size": 2048,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute(src, dst)
|
resp = uc.execute(src, dst)
|
||||||
d = resp.to_dict()
|
d = resp.to_dict()
|
||||||
assert d["status"] == "ok"
|
assert d["status"] == "ok"
|
||||||
@@ -167,11 +187,13 @@ class TestMoveMediaUseCase:
|
|||||||
assert d["size"] == 2048
|
assert d["size"] == 2048
|
||||||
|
|
||||||
def test_to_dict_error(self):
|
def test_to_dict_error(self):
|
||||||
uc = self._use_case({
|
uc = self._use_case(
|
||||||
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": "link_failed",
|
"error": "link_failed",
|
||||||
"message": "Cross-device link not permitted",
|
"message": "Cross-device link not permitted",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
resp = uc.execute("/src.mkv", "/dst.mkv")
|
resp = uc.execute("/src.mkv", "/dst.mkv")
|
||||||
d = resp.to_dict()
|
d = resp.to_dict()
|
||||||
assert d["status"] == "error"
|
assert d["status"] == "error"
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ No network calls — TMDB data is passed in directly.
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from alfred.application.filesystem.resolve_destination import (
|
from alfred.application.filesystem.resolve_destination import (
|
||||||
ResolveDestinationUseCase,
|
ResolveDestinationUseCase,
|
||||||
_find_existing_series_folders,
|
_find_existing_series_folders,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _use_case():
|
def _use_case():
|
||||||
return ResolveDestinationUseCase()
|
return ResolveDestinationUseCase()
|
||||||
|
|
||||||
@@ -27,8 +25,8 @@ def _use_case():
|
|||||||
# Movies
|
# Movies
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestResolveMovie:
|
|
||||||
|
|
||||||
|
class TestResolveMovie:
|
||||||
def test_basic_movie(self, memory_configured):
|
def test_basic_movie(self, memory_configured):
|
||||||
result = _use_case().execute(
|
result = _use_case().execute(
|
||||||
release_name="Another.Round.2020.1080p.BluRay.x264-YTS",
|
release_name="Another.Round.2020.1080p.BluRay.x264-YTS",
|
||||||
@@ -101,8 +99,8 @@ class TestResolveMovie:
|
|||||||
# TV shows — no existing folder
|
# TV shows — no existing folder
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestResolveTVShowNewFolder:
|
|
||||||
|
|
||||||
|
class TestResolveTVShowNewFolder:
|
||||||
def test_oz_s01_creates_new_folder(self, memory_configured):
|
def test_oz_s01_creates_new_folder(self, memory_configured):
|
||||||
result = _use_case().execute(
|
result = _use_case().execute(
|
||||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||||
@@ -164,11 +162,10 @@ class TestResolveTVShowNewFolder:
|
|||||||
# TV shows — existing folder matching
|
# TV shows — existing folder matching
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestResolveTVShowExistingFolder:
|
|
||||||
|
|
||||||
|
class TestResolveTVShowExistingFolder:
|
||||||
def _make_series_folder(self, tv_root, name):
|
def _make_series_folder(self, tv_root, name):
|
||||||
"""Create a series folder in the tv library."""
|
"""Create a series folder in the tv library."""
|
||||||
import os
|
|
||||||
path = tv_root / name
|
path = tv_root / name
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
@@ -176,6 +173,7 @@ class TestResolveTVShowExistingFolder:
|
|||||||
def test_uses_existing_single_folder(self, memory_configured, app_temp):
|
def test_uses_existing_single_folder(self, memory_configured, app_temp):
|
||||||
"""When exactly one folder matches title+year, use it regardless of group."""
|
"""When exactly one folder matches title+year, use it regardless of group."""
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
mem = get_memory()
|
mem = get_memory()
|
||||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||||
|
|
||||||
@@ -195,11 +193,16 @@ class TestResolveTVShowExistingFolder:
|
|||||||
def test_needs_clarification_on_multiple_folders(self, memory_configured, app_temp):
|
def test_needs_clarification_on_multiple_folders(self, memory_configured, app_temp):
|
||||||
"""When multiple folders match, return needs_clarification with options."""
|
"""When multiple folders match, return needs_clarification with options."""
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
mem = get_memory()
|
mem = get_memory()
|
||||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||||
|
|
||||||
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True)
|
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-RARBG").mkdir(
|
||||||
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir(parents=True, exist_ok=True)
|
parents=True, exist_ok=True
|
||||||
|
)
|
||||||
|
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir(
|
||||||
|
parents=True, exist_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
result = _use_case().execute(
|
result = _use_case().execute(
|
||||||
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
|
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
|
||||||
@@ -216,6 +219,7 @@ class TestResolveTVShowExistingFolder:
|
|||||||
def test_confirmed_folder_bypasses_detection(self, memory_configured, app_temp):
|
def test_confirmed_folder_bypasses_detection(self, memory_configured, app_temp):
|
||||||
"""confirmed_folder skips the folder search."""
|
"""confirmed_folder skips the folder search."""
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
mem = get_memory()
|
mem = get_memory()
|
||||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||||
chosen = "Slow.Horses.2022.1080p.WEBRip.x265-RARBG"
|
chosen = "Slow.Horses.2022.1080p.WEBRip.x265-RARBG"
|
||||||
@@ -233,10 +237,13 @@ class TestResolveTVShowExistingFolder:
|
|||||||
|
|
||||||
def test_to_dict_needs_clarification(self, memory_configured, app_temp):
|
def test_to_dict_needs_clarification(self, memory_configured, app_temp):
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
mem = get_memory()
|
mem = get_memory()
|
||||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||||
(tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True)
|
(tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True)
|
||||||
(tv_root / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir(parents=True, exist_ok=True)
|
(tv_root / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir(
|
||||||
|
parents=True, exist_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
result = _use_case().execute(
|
result = _use_case().execute(
|
||||||
release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST",
|
release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST",
|
||||||
@@ -266,8 +273,8 @@ class TestResolveTVShowExistingFolder:
|
|||||||
# _find_existing_series_folders
|
# _find_existing_series_folders
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestFindExistingSeriesFolders:
|
|
||||||
|
|
||||||
|
class TestFindExistingSeriesFolders:
|
||||||
def test_empty_library(self, tmp_path):
|
def test_empty_library(self, tmp_path):
|
||||||
assert _find_existing_series_folders(tmp_path, "Oz", 1997) == []
|
assert _find_existing_series_folders(tmp_path, "Oz", 1997) == []
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,30 @@ Real-data cases sourced from /mnt/testipool/downloads/.
|
|||||||
Covers: parsing, normalisation, naming methods, edge cases.
|
Covers: parsing, normalisation, naming methods, edge cases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
from alfred.domain.release import parse_release
|
||||||
|
|
||||||
from alfred.domain.release import ParsedRelease, parse_release
|
|
||||||
from alfred.domain.release.services import _normalise
|
from alfred.domain.release.services import _normalise
|
||||||
from alfred.domain.release.value_objects import _sanitise_for_fs, _strip_episode_from_normalised
|
from alfred.domain.release.value_objects import (
|
||||||
|
_sanitise_for_fs,
|
||||||
|
_strip_episode_from_normalised,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _normalise
|
# _normalise
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestNormalise:
|
class TestNormalise:
|
||||||
def test_dots_unchanged(self):
|
def test_dots_unchanged(self):
|
||||||
assert _normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
assert (
|
||||||
|
_normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||||
|
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
)
|
||||||
|
|
||||||
def test_spaces_become_dots(self):
|
def test_spaces_become_dots(self):
|
||||||
assert _normalise("Oz S01 1080p WEBRip x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
assert (
|
||||||
|
_normalise("Oz S01 1080p WEBRip x265-KONTRAST")
|
||||||
|
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
)
|
||||||
|
|
||||||
def test_double_dots_collapsed(self):
|
def test_double_dots_collapsed(self):
|
||||||
assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p"
|
assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p"
|
||||||
@@ -31,7 +38,9 @@ class TestNormalise:
|
|||||||
|
|
||||||
def test_mixed_spaces_and_dots(self):
|
def test_mixed_spaces_and_dots(self):
|
||||||
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||||
result = _normalise("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
|
result = _normalise(
|
||||||
|
"Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||||
|
)
|
||||||
assert " " not in result
|
assert " " not in result
|
||||||
assert ".." not in result
|
assert ".." not in result
|
||||||
|
|
||||||
@@ -40,6 +49,7 @@ class TestNormalise:
|
|||||||
# _sanitise_for_fs
|
# _sanitise_for_fs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestSanitiseForFs:
|
class TestSanitiseForFs:
|
||||||
def test_clean_string_unchanged(self):
|
def test_clean_string_unchanged(self):
|
||||||
assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST"
|
assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST"
|
||||||
@@ -65,28 +75,38 @@ class TestSanitiseForFs:
|
|||||||
# _strip_episode_from_normalised
|
# _strip_episode_from_normalised
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestStripEpisode:
|
class TestStripEpisode:
|
||||||
def test_strips_single_episode(self):
|
def test_strips_single_episode(self):
|
||||||
assert _strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST") \
|
assert (
|
||||||
|
_strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
|
||||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
)
|
||||||
|
|
||||||
def test_strips_multi_episode(self):
|
def test_strips_multi_episode(self):
|
||||||
assert _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") \
|
assert (
|
||||||
|
_strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb")
|
||||||
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
|
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
|
||||||
|
)
|
||||||
|
|
||||||
def test_season_pack_unchanged(self):
|
def test_season_pack_unchanged(self):
|
||||||
assert _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") \
|
assert (
|
||||||
|
_strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
)
|
||||||
|
|
||||||
def test_case_insensitive(self):
|
def test_case_insensitive(self):
|
||||||
assert _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") \
|
assert (
|
||||||
|
_strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST")
|
||||||
== "oz.s01.1080p-KONTRAST"
|
== "oz.s01.1080p-KONTRAST"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# parse_release — Season packs (dots)
|
# parse_release — Season packs (dots)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestSeasonPackDots:
|
class TestSeasonPackDots:
|
||||||
"""Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc."""
|
"""Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc."""
|
||||||
|
|
||||||
@@ -135,13 +155,17 @@ class TestSeasonPackDots:
|
|||||||
assert p.group == "RARBG"
|
assert p.group == "RARBG"
|
||||||
|
|
||||||
def test_gilmore_girls_s01_s07_repack(self):
|
def test_gilmore_girls_s01_s07_repack(self):
|
||||||
p = parse_release("Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH")
|
p = parse_release(
|
||||||
|
"Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH"
|
||||||
|
)
|
||||||
# Season range — we parse the first season number found
|
# Season range — we parse the first season number found
|
||||||
assert p.season == 1
|
assert p.season == 1
|
||||||
assert p.group == "MONOLITH"
|
assert p.group == "MONOLITH"
|
||||||
|
|
||||||
def test_plot_against_america_4k(self):
|
def test_plot_against_america_4k(self):
|
||||||
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY")
|
p = parse_release(
|
||||||
|
"The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY"
|
||||||
|
)
|
||||||
assert p.title == "The.Plot.Against.America"
|
assert p.title == "The.Plot.Against.America"
|
||||||
assert p.season == 1
|
assert p.season == 1
|
||||||
assert p.quality == "2160p"
|
assert p.quality == "2160p"
|
||||||
@@ -165,6 +189,7 @@ class TestSeasonPackDots:
|
|||||||
# parse_release — Single episodes (dots)
|
# parse_release — Single episodes (dots)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestSingleEpisodeDots:
|
class TestSingleEpisodeDots:
|
||||||
"""Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc."""
|
"""Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc."""
|
||||||
|
|
||||||
@@ -211,10 +236,13 @@ class TestSingleEpisodeDots:
|
|||||||
# parse_release — Multi-episode
|
# parse_release — Multi-episode
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestMultiEpisode:
|
class TestMultiEpisode:
|
||||||
def test_archer_triple_episode(self):
|
def test_archer_triple_episode(self):
|
||||||
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||||
p = parse_release("Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb")
|
p = parse_release(
|
||||||
|
"Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb"
|
||||||
|
)
|
||||||
assert p.season == 14
|
assert p.season == 14
|
||||||
assert p.episode == 9
|
assert p.episode == 9
|
||||||
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
|
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
|
||||||
@@ -224,6 +252,7 @@ class TestMultiEpisode:
|
|||||||
# parse_release — Movies
|
# parse_release — Movies
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestMovies:
|
class TestMovies:
|
||||||
def test_another_round_yts(self):
|
def test_another_round_yts(self):
|
||||||
# "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised
|
# "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised
|
||||||
@@ -276,6 +305,7 @@ class TestMovies:
|
|||||||
# parse_release — Space-separated (no dots)
|
# parse_release — Space-separated (no dots)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestSpaceSeparated:
|
class TestSpaceSeparated:
|
||||||
def test_oz_spaces(self):
|
def test_oz_spaces(self):
|
||||||
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
|
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
|
||||||
@@ -285,7 +315,9 @@ class TestSpaceSeparated:
|
|||||||
assert p.group == "KONTRAST"
|
assert p.group == "KONTRAST"
|
||||||
|
|
||||||
def test_archer_spaces(self):
|
def test_archer_spaces(self):
|
||||||
p = parse_release("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
|
p = parse_release(
|
||||||
|
"Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||||
|
)
|
||||||
assert p.season == 14
|
assert p.season == 14
|
||||||
assert p.episode == 9
|
assert p.episode == 9
|
||||||
assert p.group == "NTb"
|
assert p.group == "NTb"
|
||||||
@@ -295,6 +327,7 @@ class TestSpaceSeparated:
|
|||||||
# parse_release — tech_string
|
# parse_release — tech_string
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestTechString:
|
class TestTechString:
|
||||||
def test_full_tech(self):
|
def test_full_tech(self):
|
||||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||||
@@ -312,7 +345,9 @@ class TestTechString:
|
|||||||
assert "Unknown" in folder
|
assert "Unknown" in folder
|
||||||
|
|
||||||
def test_4k_hdr(self):
|
def test_4k_hdr(self):
|
||||||
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY")
|
p = parse_release(
|
||||||
|
"The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY"
|
||||||
|
)
|
||||||
assert p.quality == "2160p"
|
assert p.quality == "2160p"
|
||||||
|
|
||||||
|
|
||||||
@@ -320,8 +355,8 @@ class TestTechString:
|
|||||||
# ParsedRelease — naming methods
|
# ParsedRelease — naming methods
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestNamingMethods:
|
|
||||||
|
|
||||||
|
class TestNamingMethods:
|
||||||
def test_show_folder_name(self):
|
def test_show_folder_name(self):
|
||||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||||
assert p.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
assert p.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||||
@@ -370,7 +405,10 @@ class TestNamingMethods:
|
|||||||
|
|
||||||
def test_movie_folder_name(self):
|
def test_movie_folder_name(self):
|
||||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||||
assert p.movie_folder_name("Another Round", 2020) == "Another.Round.2020.1080p.BluRay.x264-YTS"
|
assert (
|
||||||
|
p.movie_folder_name("Another Round", 2020)
|
||||||
|
== "Another.Round.2020.1080p.BluRay.x264-YTS"
|
||||||
|
)
|
||||||
|
|
||||||
def test_movie_filename(self):
|
def test_movie_filename(self):
|
||||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||||
@@ -379,13 +417,16 @@ class TestNamingMethods:
|
|||||||
|
|
||||||
def test_movie_folder_same_as_show_folder(self):
|
def test_movie_folder_same_as_show_folder(self):
|
||||||
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
|
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
|
||||||
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name("Revolver", 2005)
|
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name(
|
||||||
|
"Revolver", 2005
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ParsedRelease — is_movie / is_season_pack
|
# ParsedRelease — is_movie / is_season_pack
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestMediaTypeFlags:
|
class TestMediaTypeFlags:
|
||||||
def test_season_pack_is_not_movie(self):
|
def test_season_pack_is_not_movie(self):
|
||||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||||
@@ -412,11 +453,13 @@ class TestMediaTypeFlags:
|
|||||||
# Tricky real-world releases
|
# Tricky real-world releases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestRealWorldEdgeCases:
|
|
||||||
|
|
||||||
|
class TestRealWorldEdgeCases:
|
||||||
def test_angel_integrale_multi(self):
|
def test_angel_integrale_multi(self):
|
||||||
# "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
|
# "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
|
||||||
p = parse_release("Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod")
|
p = parse_release(
|
||||||
|
"Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
|
||||||
|
)
|
||||||
assert p.year == 1999
|
assert p.year == 1999
|
||||||
assert p.quality == "1080p"
|
assert p.quality == "1080p"
|
||||||
assert p.source == "WEBRip"
|
assert p.source == "WEBRip"
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
|
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from alfred.domain.shared.exceptions import ValidationError
|
from alfred.domain.shared.exceptions import ValidationError
|
||||||
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
|
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ImdbId
|
# ImdbId
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestImdbId:
|
|
||||||
|
|
||||||
|
class TestImdbId:
|
||||||
def test_valid_7_digits(self):
|
def test_valid_7_digits(self):
|
||||||
id_ = ImdbId("tt1375666")
|
id_ = ImdbId("tt1375666")
|
||||||
assert str(id_) == "tt1375666"
|
assert str(id_) == "tt1375666"
|
||||||
@@ -58,8 +58,8 @@ class TestImdbId:
|
|||||||
# FilePath
|
# FilePath
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestFilePath:
|
|
||||||
|
|
||||||
|
class TestFilePath:
|
||||||
def test_from_string(self, tmp_path):
|
def test_from_string(self, tmp_path):
|
||||||
p = FilePath(str(tmp_path))
|
p = FilePath(str(tmp_path))
|
||||||
assert isinstance(p.value, Path)
|
assert isinstance(p.value, Path)
|
||||||
@@ -98,8 +98,8 @@ class TestFilePath:
|
|||||||
# FileSize
|
# FileSize
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestFileSize:
|
|
||||||
|
|
||||||
|
class TestFileSize:
|
||||||
def test_bytes(self):
|
def test_bytes(self):
|
||||||
s = FileSize(500)
|
s = FileSize(500)
|
||||||
assert s.bytes == 500
|
assert s.bytes == 500
|
||||||
@@ -128,7 +128,7 @@ class TestFileSize:
|
|||||||
assert "MB" in result
|
assert "MB" in result
|
||||||
|
|
||||||
def test_human_readable_gb(self):
|
def test_human_readable_gb(self):
|
||||||
result = FileSize(2 * 1024 ** 3).to_human_readable()
|
result = FileSize(2 * 1024**3).to_human_readable()
|
||||||
assert "GB" in result
|
assert "GB" in result
|
||||||
|
|
||||||
def test_str_is_human_readable(self):
|
def test_str_is_human_readable(self):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for SubtitleScanner and _classify helper."""
|
"""Tests for SubtitleScanner and _classify helper."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alfred.domain.subtitles.scanner import (
|
from alfred.domain.subtitles.scanner import (
|
||||||
@@ -9,13 +8,12 @@ from alfred.domain.subtitles.scanner import (
|
|||||||
_classify,
|
_classify,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _classify — unit tests for the filename parser
|
# _classify — unit tests for the filename parser
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestClassify:
|
|
||||||
|
|
||||||
|
class TestClassify:
|
||||||
def test_iso_lang_code(self, tmp_path):
|
def test_iso_lang_code(self, tmp_path):
|
||||||
p = tmp_path / "fr.srt"
|
p = tmp_path / "fr.srt"
|
||||||
p.write_text("")
|
p.write_text("")
|
||||||
@@ -86,8 +84,8 @@ class TestClassify:
|
|||||||
# SubtitleCandidate.destination_name
|
# SubtitleCandidate.destination_name
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestSubtitleCandidateDestinationName:
|
|
||||||
|
|
||||||
|
class TestSubtitleCandidateDestinationName:
|
||||||
def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None):
|
def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None):
|
||||||
return SubtitleCandidate(
|
return SubtitleCandidate(
|
||||||
source_path=path or Path("/fake/fr.srt"),
|
source_path=path or Path("/fake/fr.srt"),
|
||||||
@@ -117,8 +115,8 @@ class TestSubtitleCandidateDestinationName:
|
|||||||
# SubtitleScanner — integration with real filesystem
|
# SubtitleScanner — integration with real filesystem
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestSubtitleScanner:
|
|
||||||
|
|
||||||
|
class TestSubtitleScanner:
|
||||||
def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True):
|
def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True):
|
||||||
return SubtitleScanner(
|
return SubtitleScanner(
|
||||||
languages=languages or ["fr", "en"],
|
languages=languages or ["fr", "en"],
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ from alfred.domain.shared.exceptions import ValidationError
|
|||||||
from alfred.domain.tv_shows.entities import Episode, Season, TVShow
|
from alfred.domain.tv_shows.entities import Episode, Season, TVShow
|
||||||
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ShowStatus
|
# ShowStatus
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestShowStatus:
|
|
||||||
|
|
||||||
|
class TestShowStatus:
|
||||||
def test_from_string_ongoing(self):
|
def test_from_string_ongoing(self):
|
||||||
assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING
|
assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING
|
||||||
|
|
||||||
@@ -32,8 +31,8 @@ class TestShowStatus:
|
|||||||
# SeasonNumber
|
# SeasonNumber
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestSeasonNumber:
|
|
||||||
|
|
||||||
|
class TestSeasonNumber:
|
||||||
def test_valid_season(self):
|
def test_valid_season(self):
|
||||||
s = SeasonNumber(1)
|
s = SeasonNumber(1)
|
||||||
assert s.value == 1
|
assert s.value == 1
|
||||||
@@ -67,8 +66,8 @@ class TestSeasonNumber:
|
|||||||
# EpisodeNumber
|
# EpisodeNumber
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestEpisodeNumber:
|
|
||||||
|
|
||||||
|
class TestEpisodeNumber:
|
||||||
def test_valid_episode(self):
|
def test_valid_episode(self):
|
||||||
e = EpisodeNumber(1)
|
e = EpisodeNumber(1)
|
||||||
assert e.value == 1
|
assert e.value == 1
|
||||||
@@ -95,10 +94,14 @@ class TestEpisodeNumber:
|
|||||||
# TVShow entity
|
# TVShow entity
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestTVShow:
|
|
||||||
|
|
||||||
def _make(self, imdb_id="tt0903747", title="Breaking Bad", seasons=5, status="ended"):
|
class TestTVShow:
|
||||||
return TVShow(imdb_id=imdb_id, title=title, seasons_count=seasons, status=status)
|
def _make(
|
||||||
|
self, imdb_id="tt0903747", title="Breaking Bad", seasons=5, status="ended"
|
||||||
|
):
|
||||||
|
return TVShow(
|
||||||
|
imdb_id=imdb_id, title=title, seasons_count=seasons, status=status
|
||||||
|
)
|
||||||
|
|
||||||
def test_basic_creation(self):
|
def test_basic_creation(self):
|
||||||
show = self._make()
|
show = self._make()
|
||||||
@@ -108,6 +111,7 @@ class TestTVShow:
|
|||||||
def test_coerces_string_imdb_id(self):
|
def test_coerces_string_imdb_id(self):
|
||||||
show = self._make()
|
show = self._make()
|
||||||
from alfred.domain.shared.value_objects import ImdbId
|
from alfred.domain.shared.value_objects import ImdbId
|
||||||
|
|
||||||
assert isinstance(show.imdb_id, ImdbId)
|
assert isinstance(show.imdb_id, ImdbId)
|
||||||
|
|
||||||
def test_coerces_string_status(self):
|
def test_coerces_string_status(self):
|
||||||
@@ -151,8 +155,8 @@ class TestTVShow:
|
|||||||
# Season entity
|
# Season entity
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestSeason:
|
|
||||||
|
|
||||||
|
class TestSeason:
|
||||||
def test_basic_creation(self):
|
def test_basic_creation(self):
|
||||||
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7)
|
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7)
|
||||||
assert s.episode_count == 7
|
assert s.episode_count == 7
|
||||||
@@ -171,7 +175,12 @@ class TestSeason:
|
|||||||
Season(show_imdb_id="tt0903747", season_number=1, episode_count=-1)
|
Season(show_imdb_id="tt0903747", season_number=1, episode_count=-1)
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7, name="Pilot Season")
|
s = Season(
|
||||||
|
show_imdb_id="tt0903747",
|
||||||
|
season_number=1,
|
||||||
|
episode_count=7,
|
||||||
|
name="Pilot Season",
|
||||||
|
)
|
||||||
assert "Pilot Season" in str(s)
|
assert "Pilot Season" in str(s)
|
||||||
|
|
||||||
|
|
||||||
@@ -179,8 +188,8 @@ class TestSeason:
|
|||||||
# Episode entity
|
# Episode entity
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestEpisode:
|
|
||||||
|
|
||||||
|
class TestEpisode:
|
||||||
def test_basic_creation(self):
|
def test_basic_creation(self):
|
||||||
e = Episode(
|
e = Episode(
|
||||||
show_imdb_id="tt0903747",
|
show_imdb_id="tt0903747",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ Uses real temp filesystem. No mocks on os.link — we test the actual behavior.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import stat
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from alfred.infrastructure.filesystem.file_manager import FileManager
|
|
||||||
from alfred.infrastructure.filesystem.exceptions import PathTraversalError
|
from alfred.infrastructure.filesystem.exceptions import PathTraversalError
|
||||||
|
from alfred.infrastructure.filesystem.file_manager import FileManager
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -23,8 +22,8 @@ def fm():
|
|||||||
# copy_file (hard-link)
|
# copy_file (hard-link)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestCopyFile:
|
|
||||||
|
|
||||||
|
class TestCopyFile:
|
||||||
def test_creates_hard_link(self, fm, tmp_path):
|
def test_creates_hard_link(self, fm, tmp_path):
|
||||||
src = tmp_path / "source.mkv"
|
src = tmp_path / "source.mkv"
|
||||||
src.write_bytes(b"video data")
|
src.write_bytes(b"video data")
|
||||||
@@ -80,8 +79,8 @@ class TestCopyFile:
|
|||||||
# move_file
|
# move_file
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestMoveFile:
|
|
||||||
|
|
||||||
|
class TestMoveFile:
|
||||||
def test_moves_file(self, fm, tmp_path):
|
def test_moves_file(self, fm, tmp_path):
|
||||||
src = tmp_path / "episode.mkv"
|
src = tmp_path / "episode.mkv"
|
||||||
src.write_bytes(b"video")
|
src.write_bytes(b"video")
|
||||||
@@ -132,8 +131,8 @@ class TestMoveFile:
|
|||||||
# create_seed_links
|
# create_seed_links
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestCreateSeedLinks:
|
|
||||||
|
|
||||||
|
class TestCreateSeedLinks:
|
||||||
def _setup(self, tmp_path):
|
def _setup(self, tmp_path):
|
||||||
"""Create realistic download + library + torrent structure."""
|
"""Create realistic download + library + torrent structure."""
|
||||||
download = tmp_path / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
download = tmp_path / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||||
@@ -146,7 +145,12 @@ class TestCreateSeedLinks:
|
|||||||
subs.mkdir(parents=True)
|
subs.mkdir(parents=True)
|
||||||
(subs / "2_eng.srt").write_text("subtitle content")
|
(subs / "2_eng.srt").write_text("subtitle content")
|
||||||
|
|
||||||
library = tmp_path / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
library = (
|
||||||
|
tmp_path
|
||||||
|
/ "tv"
|
||||||
|
/ "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
)
|
||||||
library.mkdir(parents=True)
|
library.mkdir(parents=True)
|
||||||
lib_video = library / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
lib_video = library / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||||
# Hard-link the video to simulate post-move state
|
# Hard-link the video to simulate post-move state
|
||||||
@@ -188,7 +192,13 @@ class TestCreateSeedLinks:
|
|||||||
lib_video, download, torrents = self._setup(tmp_path)
|
lib_video, download, torrents = self._setup(tmp_path)
|
||||||
fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||||
|
|
||||||
srt = torrents / "Oz.S01.1080p.WEBRip.x265-KONTRAST" / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST" / "2_eng.srt"
|
srt = (
|
||||||
|
torrents
|
||||||
|
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
/ "Subs"
|
||||||
|
/ "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
/ "2_eng.srt"
|
||||||
|
)
|
||||||
assert srt.exists()
|
assert srt.exists()
|
||||||
|
|
||||||
def test_returns_copied_and_skipped(self, fm, tmp_path):
|
def test_returns_copied_and_skipped(self, fm, tmp_path):
|
||||||
@@ -270,8 +280,8 @@ class TestCreateSeedLinks:
|
|||||||
# list_folder
|
# list_folder
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestListFolder:
|
|
||||||
|
|
||||||
|
class TestListFolder:
|
||||||
def test_lists_entries(self, fm, memory_configured, infra_temp):
|
def test_lists_entries(self, fm, memory_configured, infra_temp):
|
||||||
result = fm.list_folder("download")
|
result = fm.list_folder("download")
|
||||||
assert result["status"] == "ok"
|
assert result["status"] == "ok"
|
||||||
@@ -300,8 +310,8 @@ class TestListFolder:
|
|||||||
# _sanitize_path
|
# _sanitize_path
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestSanitizePath:
|
|
||||||
|
|
||||||
|
class TestSanitizePath:
|
||||||
def test_normal_path(self, fm):
|
def test_normal_path(self, fm):
|
||||||
assert fm._sanitize_path("some/path") == "some/path"
|
assert fm._sanitize_path("some/path") == "some/path"
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -16,7 +16,6 @@ from bootstrap import (
|
|||||||
load_env_file,
|
load_env_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -191,7 +190,9 @@ class TestBuildUris:
|
|||||||
def test_uri_is_updated_when_host_changes(self, tmp_path, secrets_file):
|
def test_uri_is_updated_when_host_changes(self, tmp_path, secrets_file):
|
||||||
"""If MONGO_HOST changes in .env.alfred, the URI must reflect it."""
|
"""If MONGO_HOST changes in .env.alfred, the URI must reflect it."""
|
||||||
alfred = tmp_path / ".env.alfred"
|
alfred = tmp_path / ".env.alfred"
|
||||||
alfred.write_text(ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost"))
|
alfred.write_text(
|
||||||
|
ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost")
|
||||||
|
)
|
||||||
|
|
||||||
build_uris(alfred, secrets_file)
|
build_uris(alfred, secrets_file)
|
||||||
uri = load_env_file(secrets_file)["MONGO_URI"]
|
uri = load_env_file(secrets_file)["MONGO_URI"]
|
||||||
@@ -217,7 +218,9 @@ class TestBuildUris:
|
|||||||
uri_v1 = load_env_file(secrets_file)["MONGO_URI"]
|
uri_v1 = load_env_file(secrets_file)["MONGO_URI"]
|
||||||
|
|
||||||
alfred_v2 = tmp_path / "alfred_v2"
|
alfred_v2 = tmp_path / "alfred_v2"
|
||||||
alfred_v2.write_text(ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb"))
|
alfred_v2.write_text(
|
||||||
|
ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb")
|
||||||
|
)
|
||||||
build_uris(alfred_v2, secrets_file)
|
build_uris(alfred_v2, secrets_file)
|
||||||
uri_v2 = load_env_file(secrets_file)["MONGO_URI"]
|
uri_v2 = load_env_file(secrets_file)["MONGO_URI"]
|
||||||
|
|
||||||
@@ -265,12 +268,15 @@ class TestCopyExampleIfMissing:
|
|||||||
|
|
||||||
|
|
||||||
class TestExtractPythonVersion:
|
class TestExtractPythonVersion:
|
||||||
@pytest.mark.parametrize("spec,expected_full,expected_short", [
|
@pytest.mark.parametrize(
|
||||||
|
"spec,expected_full,expected_short",
|
||||||
|
[
|
||||||
("==3.14.3", "3.14.3", "3.14"),
|
("==3.14.3", "3.14.3", "3.14"),
|
||||||
("^3.12.0", "3.12.0", "3.12"),
|
("^3.12.0", "3.12.0", "3.12"),
|
||||||
("~3.11.1", "3.11.1", "3.11"),
|
("~3.11.1", "3.11.1", "3.11"),
|
||||||
("3.10.5", "3.10.5", "3.10"),
|
("3.10.5", "3.10.5", "3.10"),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
def test_parses_version_specifiers(self, spec, expected_full, expected_short):
|
def test_parses_version_specifiers(self, spec, expected_full, expected_short):
|
||||||
full, short = extract_python_version(spec)
|
full, short = extract_python_version(spec)
|
||||||
assert full == expected_full
|
assert full == expected_full
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for PromptBuilder."""
|
"""Tests for PromptBuilder."""
|
||||||
|
|
||||||
from alfred.agent.prompts import PromptBuilder
|
from alfred.agent.prompts import PromptBuilder
|
||||||
|
|
||||||
from alfred.agent.registry import make_tools
|
from alfred.agent.registry import make_tools
|
||||||
from alfred.settings import settings
|
from alfred.settings import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Critical tests for prompt builder - Tests that would have caught bugs."""
|
"""Critical tests for prompt builder - Tests that would have caught bugs."""
|
||||||
|
|
||||||
from alfred.agent.prompts import PromptBuilder
|
from alfred.agent.prompts import PromptBuilder
|
||||||
|
|
||||||
from alfred.agent.registry import make_tools
|
from alfred.agent.registry import make_tools
|
||||||
from alfred.settings import settings
|
from alfred.settings import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Edge case tests for PromptBuilder."""
|
"""Edge case tests for PromptBuilder."""
|
||||||
|
|
||||||
from alfred.agent.prompts import PromptBuilder
|
from alfred.agent.prompts import PromptBuilder
|
||||||
|
|
||||||
from alfred.agent.registry import make_tools
|
from alfred.agent.registry import make_tools
|
||||||
from alfred.settings import settings
|
from alfred.settings import settings
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from alfred.agent.prompts import PromptBuilder
|
from alfred.agent.prompts import PromptBuilder
|
||||||
|
|
||||||
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools
|
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools
|
||||||
from alfred.settings import settings
|
from alfred.settings import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"""Tests for language tools."""
|
"""Tests for language tools."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from alfred.agent.tools.language import set_language
|
from alfred.agent.tools.language import set_language
|
||||||
|
|
||||||
|
|
||||||
class TestSetLanguage:
|
class TestSetLanguage:
|
||||||
|
|
||||||
def test_success_returns_ok(self, memory):
|
def test_success_returns_ok(self, memory):
|
||||||
result = set_language("fr")
|
result = set_language("fr")
|
||||||
assert result["status"] == "ok"
|
assert result["status"] == "ok"
|
||||||
@@ -20,6 +17,7 @@ class TestSetLanguage:
|
|||||||
set_language("es")
|
set_language("es")
|
||||||
# Verify it's stored in STM
|
# Verify it's stored in STM
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
mem = get_memory()
|
mem = get_memory()
|
||||||
assert mem.stm.language == "es"
|
assert mem.stm.language == "es"
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ Tests for alfred.agent.workflows.loader.WorkflowLoader
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from alfred.agent.workflows.loader import WorkflowLoader
|
from alfred.agent.workflows.loader import WorkflowLoader
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def workflows_dir(tmp_path):
|
def workflows_dir(tmp_path):
|
||||||
"""A temp directory pre-populated with one valid workflow YAML."""
|
"""A temp directory pre-populated with one valid workflow YAML."""
|
||||||
@@ -32,6 +31,7 @@ def workflows_dir(tmp_path):
|
|||||||
def loader_from_dir(workflows_dir, monkeypatch):
|
def loader_from_dir(workflows_dir, monkeypatch):
|
||||||
"""WorkflowLoader pointed at our temp dir."""
|
"""WorkflowLoader pointed at our temp dir."""
|
||||||
import alfred.agent.workflows.loader as loader_module
|
import alfred.agent.workflows.loader as loader_module
|
||||||
|
|
||||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", workflows_dir)
|
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", workflows_dir)
|
||||||
return WorkflowLoader()
|
return WorkflowLoader()
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ def loader_from_dir(workflows_dir, monkeypatch):
|
|||||||
# Real loader (loads actual YAML files from the repo)
|
# Real loader (loads actual YAML files from the repo)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestRealWorkflows:
|
|
||||||
|
|
||||||
|
class TestRealWorkflows:
|
||||||
def test_organize_media_loaded(self):
|
def test_organize_media_loaded(self):
|
||||||
loader = WorkflowLoader()
|
loader = WorkflowLoader()
|
||||||
assert "organize_media" in loader.names()
|
assert "organize_media" in loader.names()
|
||||||
@@ -96,8 +96,8 @@ class TestRealWorkflows:
|
|||||||
# WorkflowLoader mechanics (via monkeypatched dir)
|
# WorkflowLoader mechanics (via monkeypatched dir)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestLoaderMechanics:
|
|
||||||
|
|
||||||
|
class TestLoaderMechanics:
|
||||||
def test_get_returns_workflow(self, loader_from_dir):
|
def test_get_returns_workflow(self, loader_from_dir):
|
||||||
wf = loader_from_dir.get("test_workflow")
|
wf = loader_from_dir.get("test_workflow")
|
||||||
assert wf is not None
|
assert wf is not None
|
||||||
@@ -119,6 +119,7 @@ class TestLoaderMechanics:
|
|||||||
def test_uses_yaml_name_field(self, tmp_path, monkeypatch):
|
def test_uses_yaml_name_field(self, tmp_path, monkeypatch):
|
||||||
"""name from YAML content takes priority over filename stem."""
|
"""name from YAML content takes priority over filename stem."""
|
||||||
import alfred.agent.workflows.loader as loader_module
|
import alfred.agent.workflows.loader as loader_module
|
||||||
|
|
||||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||||
|
|
||||||
wf = {"name": "my_custom_name", "steps": []}
|
wf = {"name": "my_custom_name", "steps": []}
|
||||||
@@ -130,6 +131,7 @@ class TestLoaderMechanics:
|
|||||||
|
|
||||||
def test_falls_back_to_stem_when_no_name(self, tmp_path, monkeypatch):
|
def test_falls_back_to_stem_when_no_name(self, tmp_path, monkeypatch):
|
||||||
import alfred.agent.workflows.loader as loader_module
|
import alfred.agent.workflows.loader as loader_module
|
||||||
|
|
||||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||||
|
|
||||||
(tmp_path / "my_workflow.yaml").write_text(yaml.dump({"steps": []}))
|
(tmp_path / "my_workflow.yaml").write_text(yaml.dump({"steps": []}))
|
||||||
@@ -138,6 +140,7 @@ class TestLoaderMechanics:
|
|||||||
|
|
||||||
def test_skips_malformed_yaml(self, tmp_path, monkeypatch):
|
def test_skips_malformed_yaml(self, tmp_path, monkeypatch):
|
||||||
import alfred.agent.workflows.loader as loader_module
|
import alfred.agent.workflows.loader as loader_module
|
||||||
|
|
||||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||||
|
|
||||||
(tmp_path / "valid.yaml").write_text(yaml.dump({"name": "valid", "steps": []}))
|
(tmp_path / "valid.yaml").write_text(yaml.dump({"name": "valid", "steps": []}))
|
||||||
@@ -150,10 +153,15 @@ class TestLoaderMechanics:
|
|||||||
def test_deterministic_load_order(self, tmp_path, monkeypatch):
|
def test_deterministic_load_order(self, tmp_path, monkeypatch):
|
||||||
"""Files loaded in sorted order — later file wins on name collision."""
|
"""Files loaded in sorted order — later file wins on name collision."""
|
||||||
import alfred.agent.workflows.loader as loader_module
|
import alfred.agent.workflows.loader as loader_module
|
||||||
|
|
||||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||||
|
|
||||||
(tmp_path / "a_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 1}))
|
(tmp_path / "a_workflow.yaml").write_text(
|
||||||
(tmp_path / "b_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 2}))
|
yaml.dump({"name": "duplicate", "version": 1})
|
||||||
|
)
|
||||||
|
(tmp_path / "b_workflow.yaml").write_text(
|
||||||
|
yaml.dump({"name": "duplicate", "version": 2})
|
||||||
|
)
|
||||||
|
|
||||||
loader = WorkflowLoader()
|
loader = WorkflowLoader()
|
||||||
# b_workflow loaded last → version 2 wins
|
# b_workflow loaded last → version 2 wins
|
||||||
@@ -161,6 +169,7 @@ class TestLoaderMechanics:
|
|||||||
|
|
||||||
def test_empty_directory(self, tmp_path, monkeypatch):
|
def test_empty_directory(self, tmp_path, monkeypatch):
|
||||||
import alfred.agent.workflows.loader as loader_module
|
import alfred.agent.workflows.loader as loader_module
|
||||||
|
|
||||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||||
|
|
||||||
loader = WorkflowLoader()
|
loader = WorkflowLoader()
|
||||||
|
|||||||
Reference in New Issue
Block a user