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:
@@ -190,7 +190,7 @@ class Agent:
|
||||
|
||||
async def step_streaming(
|
||||
self, user_input: str, completion_id: str, created_ts: int, model: str
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
) -> AsyncGenerator[dict[str, Any]]:
|
||||
"""
|
||||
Execute agent step with streaming support for LibreChat.
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Expression loader — charge et merge les fichiers YAML d'expressions par user."""
|
||||
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
_USERS_DIR = Path(__file__).parent.parent / "knowledge" / "users"
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def load_expressions(username: str | None) -> dict:
|
||||
"""
|
||||
Charge common.yaml et le merge avec {username}.yaml.
|
||||
|
||||
Retourne un dict avec :
|
||||
- nickname: str (surnom de l'user, ou username en fallback)
|
||||
- expressions: dict[situation -> list[str]]
|
||||
"""
|
||||
common = _load_yaml(_USERS_DIR / "common.yaml")
|
||||
user_data = _load_yaml(_USERS_DIR / f"{username}.yaml") if username else {}
|
||||
|
||||
# Merge expressions : common + user (les phrases user s'ajoutent)
|
||||
common_exprs: dict[str, list] = common.get("expressions", {})
|
||||
user_exprs: dict[str, list] = user_data.get("expressions", {})
|
||||
|
||||
merged: dict[str, list] = {}
|
||||
all_situations = set(common_exprs) | set(user_exprs)
|
||||
for situation in all_situations:
|
||||
base = list(common_exprs.get(situation, []))
|
||||
extra = list(user_exprs.get(situation, []))
|
||||
merged[situation] = base + extra
|
||||
|
||||
nickname = user_data.get("user", {}).get("nickname") or username or "mec"
|
||||
|
||||
return {
|
||||
"nickname": nickname,
|
||||
"expressions": merged,
|
||||
}
|
||||
|
||||
|
||||
def pick(expressions: dict, situation: str, nickname: str | None = None) -> str:
|
||||
"""
|
||||
Pioche une expression aléatoire pour une situation donnée.
|
||||
|
||||
Résout {user} avec le nickname si fourni.
|
||||
Retourne une string vide si la situation n'existe pas.
|
||||
"""
|
||||
options = expressions.get("expressions", {}).get(situation, [])
|
||||
if not options:
|
||||
return ""
|
||||
chosen = random.choice(options)
|
||||
if nickname:
|
||||
chosen = chosen.replace("{user}", nickname)
|
||||
return chosen
|
||||
|
||||
|
||||
def build_expressions_context(username: str | None) -> dict:
|
||||
"""
|
||||
Point d'entrée principal.
|
||||
|
||||
Retourne :
|
||||
- nickname: str
|
||||
- samples: dict[situation -> une phrase résolue] — une seule par situation
|
||||
"""
|
||||
data = load_expressions(username)
|
||||
nickname = data["nickname"]
|
||||
samples = {
|
||||
situation: pick(data, situation, nickname) for situation in data["expressions"]
|
||||
}
|
||||
return {
|
||||
"nickname": nickname,
|
||||
"samples": samples,
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Prompt builder for the agent system."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
from alfred.infrastructure.persistence.memory import MemoryRegistry
|
||||
|
||||
from .expressions import build_expressions_context
|
||||
from .registry import Tool
|
||||
|
||||
|
||||
class PromptBuilder:
|
||||
"""Builds system prompts for the agent with memory context."""
|
||||
|
||||
def __init__(self, tools: dict[str, Tool]):
|
||||
self.tools = tools
|
||||
self._memory_registry = MemoryRegistry()
|
||||
|
||||
def _format_identity(self, memory) -> str:
|
||||
"""Build Alfred's identity and personality section."""
|
||||
username = memory.stm.get_entity("username")
|
||||
expr = build_expressions_context(username)
|
||||
nickname = expr["nickname"]
|
||||
samples = expr["samples"]
|
||||
|
||||
# Format expressions as situational guidance for the LLM
|
||||
expr_lines = []
|
||||
situation_labels = {
|
||||
"greeting": "Salutation",
|
||||
"success": "Succès",
|
||||
"working": "En cours",
|
||||
"error": "Erreur",
|
||||
"unclear": "Demande floue",
|
||||
"warning": "Avertissement",
|
||||
"not_found": "Introuvable",
|
||||
}
|
||||
for situation, label in situation_labels.items():
|
||||
phrase = samples.get(situation, "")
|
||||
if phrase:
|
||||
expr_lines.append(f' {label}: "{phrase}"')
|
||||
|
||||
expressions_block = "\n".join(expr_lines)
|
||||
|
||||
return f"""Tu t'appelles Alfred. Tu es un assistant d'organisation de médiathèque — direct, opérationnel, légèrement impertinent.
|
||||
|
||||
PERSONNALITÉ:
|
||||
- Tu parles franglais : français avec des touches d'anglais quand ça colle mieux
|
||||
- Tu es sarcastique et n'as pas peur de te moquer si l'user fait une connerie
|
||||
- Tu envoies chier poliment (mais clairement) quand la demande est trop floue
|
||||
- Tu ne fais pas de blabla inutile. Si "ok c'est fait" suffit, c'est tout ce que tu dis
|
||||
- Tu peux jurer (putain, merde, con, ...) — c'est naturel, pas du remplissage
|
||||
- Jamais de "Great question!" ou de politesse creuse
|
||||
|
||||
USER COURANT: {nickname}
|
||||
|
||||
EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta réponse) :
|
||||
{expressions_block}"""
|
||||
|
||||
def build_tools_spec(self) -> list[dict[str, Any]]:
|
||||
"""Build the tool specification for the LLM API."""
|
||||
tool_specs = []
|
||||
for tool in self.tools.values():
|
||||
spec = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
},
|
||||
}
|
||||
tool_specs.append(spec)
|
||||
return tool_specs
|
||||
|
||||
def _format_tools_description(self) -> str:
|
||||
"""Format tools with their descriptions and parameters."""
|
||||
if not self.tools:
|
||||
return ""
|
||||
return "\n".join(
|
||||
f"- {tool.name}: {tool.description}\n"
|
||||
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
|
||||
for tool in self.tools.values()
|
||||
)
|
||||
|
||||
def _format_episodic_context(self, memory) -> str:
|
||||
"""Format episodic memory context for the prompt."""
|
||||
lines = []
|
||||
|
||||
if memory.episodic.last_search_results:
|
||||
results = memory.episodic.last_search_results
|
||||
result_list = results.get("results", [])
|
||||
lines.append(
|
||||
f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)"
|
||||
)
|
||||
# Show first 5 results
|
||||
for i, result in enumerate(result_list[:5]):
|
||||
name = result.get("name", "Unknown")
|
||||
lines.append(f" {i + 1}. {name}")
|
||||
if len(result_list) > 5:
|
||||
lines.append(f" ... and {len(result_list) - 5} more")
|
||||
|
||||
if memory.episodic.pending_question:
|
||||
question = memory.episodic.pending_question
|
||||
lines.append(f"\nPENDING QUESTION: {question.get('question')}")
|
||||
lines.append(f" Type: {question.get('type')}")
|
||||
if question.get("options"):
|
||||
lines.append(f" Options: {len(question.get('options'))}")
|
||||
|
||||
if memory.episodic.active_downloads:
|
||||
lines.append(f"\nACTIVE DOWNLOADS: {len(memory.episodic.active_downloads)}")
|
||||
for dl in memory.episodic.active_downloads[:3]:
|
||||
lines.append(f" - {dl.get('name')}: {dl.get('progress', 0)}%")
|
||||
|
||||
if memory.episodic.recent_errors:
|
||||
lines.append("\nRECENT ERRORS (up to 3):")
|
||||
for error in memory.episodic.recent_errors[-3:]:
|
||||
lines.append(
|
||||
f" - Action '{error.get('action')}' failed: {error.get('error')}"
|
||||
)
|
||||
|
||||
# Unread events
|
||||
unread = [e for e in memory.episodic.background_events if not e.get("read")]
|
||||
if unread:
|
||||
lines.append(f"\nUNREAD EVENTS: {len(unread)}")
|
||||
for event in unread[:3]:
|
||||
lines.append(f" - {event.get('type')}: {event.get('data')}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_stm_context(self, memory) -> str:
|
||||
"""Format short-term memory context for the prompt."""
|
||||
lines = []
|
||||
|
||||
if memory.stm.current_workflow:
|
||||
workflow = memory.stm.current_workflow
|
||||
lines.append(
|
||||
f"CURRENT WORKFLOW: {workflow.get('type')} (stage: {workflow.get('stage')})"
|
||||
)
|
||||
if workflow.get("target"):
|
||||
lines.append(f" Target: {workflow.get('target')}")
|
||||
|
||||
if memory.stm.current_topic:
|
||||
lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}")
|
||||
|
||||
if memory.stm.extracted_entities:
|
||||
lines.append("EXTRACTED ENTITIES:")
|
||||
for key, value in memory.stm.extracted_entities.items():
|
||||
lines.append(f" - {key}: {value}")
|
||||
|
||||
if memory.stm.language:
|
||||
lines.append(f"CONVERSATION LANGUAGE: {memory.stm.language}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_memory_schema(self) -> str:
|
||||
"""Describe available memory components so the agent knows what to read/write and when."""
|
||||
schema = self._memory_registry.schema()
|
||||
tier_labels = {
|
||||
"ltm": "LONG-TERM (persisted)",
|
||||
"stm": "SHORT-TERM (session)",
|
||||
"episodic": "EPISODIC (volatile)",
|
||||
}
|
||||
lines = ["MEMORY COMPONENTS:"]
|
||||
|
||||
for tier, components in schema.items():
|
||||
if not components:
|
||||
continue
|
||||
lines.append(f"\n [{tier_labels.get(tier, tier.upper())}]")
|
||||
for c in components:
|
||||
access = c.get("access", "read")
|
||||
lines.append(f" {c['name']} ({access}): {c['description']}")
|
||||
for field_name, field_desc in c.get("fields", {}).items():
|
||||
lines.append(f" · {field_name}: {field_desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_config_context(self, memory) -> str:
|
||||
"""Format configuration context."""
|
||||
lines = ["CURRENT CONFIGURATION:"]
|
||||
folders = {
|
||||
**memory.ltm.workspace.as_dict(),
|
||||
**memory.ltm.library_paths.to_dict(),
|
||||
}
|
||||
if folders:
|
||||
for key, value in folders.items():
|
||||
lines.append(f" - {key}: {value}")
|
||||
else:
|
||||
lines.append(" (no configuration set)")
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_system_prompt(self) -> str:
|
||||
"""Build the complete system prompt."""
|
||||
memory = get_memory()
|
||||
|
||||
# Identity + personality
|
||||
identity = self._format_identity(memory)
|
||||
|
||||
# Language instruction
|
||||
language_instruction = (
|
||||
"Si la langue de l'user est différente de la langue courante en STM, "
|
||||
"appelle `set_language` en premier avant de répondre."
|
||||
)
|
||||
|
||||
# Configuration
|
||||
config_section = self._format_config_context(memory)
|
||||
|
||||
# STM context
|
||||
stm_context = self._format_stm_context(memory)
|
||||
|
||||
# Episodic context
|
||||
episodic_context = self._format_episodic_context(memory)
|
||||
|
||||
# Memory schema
|
||||
memory_schema = self._format_memory_schema()
|
||||
|
||||
# Available tools
|
||||
tools_desc = self._format_tools_description()
|
||||
tools_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else ""
|
||||
|
||||
rules = """
|
||||
RÈGLES:
|
||||
- Utilise les outils pour accomplir les tâches, pas pour décorer
|
||||
- Si des résultats de recherche sont dispo en mémoire épisodique, référence-les par index
|
||||
- Confirme toujours avant une opération destructive (move, delete, overwrite)
|
||||
- Réponses courtes — si c'est fait, dis-le en une ligne
|
||||
- Si la demande est floue, demande un éclaircissement AVANT de lancer quoi que ce soit
|
||||
"""
|
||||
|
||||
sections = [
|
||||
identity,
|
||||
language_instruction,
|
||||
config_section,
|
||||
stm_context,
|
||||
episodic_context,
|
||||
memory_schema,
|
||||
tools_section,
|
||||
rules,
|
||||
]
|
||||
return "\n\n".join(s for s in sections if s and s.strip())
|
||||
@@ -99,8 +99,12 @@ def make_tools(settings) -> dict[str, Tool]:
|
||||
fs_tools.list_folder,
|
||||
fs_tools.analyze_release,
|
||||
fs_tools.probe_media,
|
||||
fs_tools.resolve_destination,
|
||||
fs_tools.resolve_season_destination_tool,
|
||||
fs_tools.resolve_episode_destination_tool,
|
||||
fs_tools.resolve_movie_destination_tool,
|
||||
fs_tools.resolve_series_destination_tool,
|
||||
fs_tools.move_media,
|
||||
fs_tools.move_to_destination,
|
||||
fs_tools.manage_subtitles,
|
||||
fs_tools.create_seed_links,
|
||||
fs_tools.learn,
|
||||
|
||||
@@ -3,20 +3,25 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import alfred as _alfred_pkg
|
||||
import yaml
|
||||
|
||||
import alfred as _alfred_pkg
|
||||
from alfred.application.filesystem import (
|
||||
CreateSeedLinksUseCase,
|
||||
ListFolderUseCase,
|
||||
ManageSubtitlesUseCase,
|
||||
MoveMediaUseCase,
|
||||
ResolveDestinationUseCase,
|
||||
SetFolderPathUseCase,
|
||||
)
|
||||
from alfred.application.filesystem.detect_media_type import detect_media_type
|
||||
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
||||
from alfred.infrastructure.filesystem import FileManager
|
||||
from alfred.application.filesystem.resolve_destination import (
|
||||
resolve_episode_destination,
|
||||
resolve_movie_destination,
|
||||
resolve_season_destination,
|
||||
resolve_series_destination,
|
||||
)
|
||||
from alfred.infrastructure.filesystem import FileManager, create_folder, move
|
||||
from alfred.infrastructure.filesystem.ffprobe import probe
|
||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||
|
||||
@@ -42,7 +47,57 @@ def move_media(source: str, destination: str) -> dict[str, Any]:
|
||||
return use_case.execute(source, destination).to_dict()
|
||||
|
||||
|
||||
def resolve_destination(
|
||||
def move_to_destination(source: str, destination: str) -> dict[str, Any]:
|
||||
"""
|
||||
Move a file or folder to a destination, creating parent directories if needed.
|
||||
|
||||
Use this after resolve_*_destination to perform the actual move.
|
||||
The destination parent is created automatically if it doesn't exist.
|
||||
|
||||
Args:
|
||||
source: Absolute path to the source file or folder.
|
||||
destination: Absolute path to the destination.
|
||||
|
||||
Returns:
|
||||
Dict with status, source, destination — or error details.
|
||||
"""
|
||||
parent = str(Path(destination).parent)
|
||||
result = create_folder(parent)
|
||||
if result["status"] != "ok":
|
||||
return result
|
||||
return move(source, destination)
|
||||
|
||||
|
||||
def resolve_season_destination_tool(
|
||||
release_name: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
confirmed_folder: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute destination paths for a season pack (folder move).
|
||||
|
||||
Returns series_folder + season_folder. No file paths — the whole
|
||||
source folder is moved as-is into season_folder.
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder name (e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
|
||||
tmdb_title: Canonical show title from TMDB (e.g. "Oz").
|
||||
tmdb_year: Show start year from TMDB (e.g. 1997).
|
||||
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||
|
||||
Returns:
|
||||
On success: dict with status, series_folder, season_folder, series_folder_name,
|
||||
season_folder_name, is_new_series_folder.
|
||||
On ambiguity: dict with status="needs_clarification", question, options.
|
||||
On error: dict with status="error", error, message.
|
||||
"""
|
||||
return resolve_season_destination(
|
||||
release_name, tmdb_title, tmdb_year, confirmed_folder
|
||||
).to_dict()
|
||||
|
||||
|
||||
def resolve_episode_destination_tool(
|
||||
release_name: str,
|
||||
source_file: str,
|
||||
tmdb_title: str,
|
||||
@@ -51,44 +106,91 @@ def resolve_destination(
|
||||
confirmed_folder: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute the destination path in the media library for a release.
|
||||
Compute destination paths for a single episode file.
|
||||
|
||||
Call this before move_media to get the correct library path. Handles:
|
||||
- Parsing the release name (quality, codec, group, season/episode)
|
||||
- Looking up any existing series folder in the library
|
||||
- Applying group-conflict rules (asks user if ambiguous)
|
||||
- Building the full destination path with correct naming conventions
|
||||
Returns series_folder + season_folder + library_file (full path to destination .mkv).
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder or file name
|
||||
(e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
|
||||
release_name: Raw release file name (e.g. "Oz.S03E01.1080p.WEBRip.x265-KONTRAST.mkv").
|
||||
source_file: Absolute path to the source video file (used for extension).
|
||||
tmdb_title: Canonical show/movie title from TMDB (e.g. "Oz").
|
||||
tmdb_year: Release/start year from TMDB (e.g. 1997).
|
||||
tmdb_episode_title: Episode title from TMDB for single-episode releases
|
||||
(e.g. "The Routine"). Omit for season packs and movies.
|
||||
confirmed_folder: If a previous call returned needs_clarification, pass
|
||||
the user-chosen folder name here to proceed.
|
||||
tmdb_title: Canonical show title from TMDB (e.g. "Oz").
|
||||
tmdb_year: Show start year from TMDB (e.g. 1997).
|
||||
tmdb_episode_title: Episode title from TMDB (e.g. "The Routine"). Optional.
|
||||
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||
|
||||
Returns:
|
||||
On success: dict with status, library_file, series_folder, season_folder,
|
||||
series_folder_name, season_folder_name, filename,
|
||||
is_new_series_folder.
|
||||
On success: dict with status, series_folder, season_folder, library_file,
|
||||
series_folder_name, season_folder_name, filename, is_new_series_folder.
|
||||
On ambiguity: dict with status="needs_clarification", question, options.
|
||||
On error: dict with status="error", error, message.
|
||||
"""
|
||||
use_case = ResolveDestinationUseCase()
|
||||
return use_case.execute(
|
||||
release_name=release_name,
|
||||
source_file=source_file,
|
||||
tmdb_title=tmdb_title,
|
||||
tmdb_year=tmdb_year,
|
||||
tmdb_episode_title=tmdb_episode_title,
|
||||
confirmed_folder=confirmed_folder,
|
||||
return resolve_episode_destination(
|
||||
release_name,
|
||||
source_file,
|
||||
tmdb_title,
|
||||
tmdb_year,
|
||||
tmdb_episode_title,
|
||||
confirmed_folder,
|
||||
).to_dict()
|
||||
|
||||
|
||||
def create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]:
|
||||
def resolve_movie_destination_tool(
|
||||
release_name: str,
|
||||
source_file: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute destination paths for a movie file.
|
||||
|
||||
Returns movie_folder + library_file (full path to destination .mkv).
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder/file name (e.g. "Inception.2010.1080p.BluRay.x265-GROUP").
|
||||
source_file: Absolute path to the source video file (used for extension).
|
||||
tmdb_title: Canonical movie title from TMDB (e.g. "Inception").
|
||||
tmdb_year: Movie release year from TMDB (e.g. 2010).
|
||||
|
||||
Returns:
|
||||
On success: dict with status, movie_folder, library_file, movie_folder_name,
|
||||
filename, is_new_folder.
|
||||
On error: dict with status="error", error, message.
|
||||
"""
|
||||
return resolve_movie_destination(
|
||||
release_name, source_file, tmdb_title, tmdb_year
|
||||
).to_dict()
|
||||
|
||||
|
||||
def resolve_series_destination_tool(
|
||||
release_name: str,
|
||||
tmdb_title: str,
|
||||
tmdb_year: int,
|
||||
confirmed_folder: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute destination path for a complete multi-season series pack (folder move).
|
||||
|
||||
Returns only series_folder — the whole pack lands directly inside it.
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder name.
|
||||
tmdb_title: Canonical show title from TMDB.
|
||||
tmdb_year: Show start year from TMDB.
|
||||
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
|
||||
|
||||
Returns:
|
||||
On success: dict with status, series_folder, series_folder_name, is_new_series_folder.
|
||||
On ambiguity: dict with status="needs_clarification", question, options.
|
||||
On error: dict with status="error", error, message.
|
||||
"""
|
||||
return resolve_series_destination(
|
||||
release_name, tmdb_title, tmdb_year, confirmed_folder
|
||||
).to_dict()
|
||||
|
||||
|
||||
def create_seed_links(
|
||||
library_file: str, original_download_folder: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Prepare a torrent subfolder so qBittorrent can keep seeding after a move.
|
||||
|
||||
@@ -159,10 +261,18 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
|
||||
_VALID_CATEGORIES = {"languages", "types", "formats"}
|
||||
|
||||
if pack not in _VALID_PACKS:
|
||||
return {"status": "error", "error": "unknown_pack", "message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "unknown_pack",
|
||||
"message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}",
|
||||
}
|
||||
|
||||
if category not in _VALID_CATEGORIES:
|
||||
return {"status": "error", "error": "unknown_category", "message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "unknown_category",
|
||||
"message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}",
|
||||
}
|
||||
|
||||
learned_path = _LEARNED_ROOT / "subtitles_learned.yaml"
|
||||
_LEARNED_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
@@ -184,7 +294,9 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
|
||||
tmp = learned_path.with_suffix(".yaml.tmp")
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||
yaml.safe_dump(
|
||||
data, f, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||
)
|
||||
tmp.rename(learned_path)
|
||||
except Exception as e:
|
||||
tmp.unlink(missing_ok=True)
|
||||
@@ -252,26 +364,26 @@ def analyze_release(release_name: str, source_path: str) -> dict[str, Any]:
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"media_type": parsed.media_type,
|
||||
"parse_path": parsed.parse_path,
|
||||
"title": parsed.title,
|
||||
"year": parsed.year,
|
||||
"season": parsed.season,
|
||||
"episode": parsed.episode,
|
||||
"episode_end": parsed.episode_end,
|
||||
"quality": parsed.quality,
|
||||
"source": parsed.source,
|
||||
"codec": parsed.codec,
|
||||
"group": parsed.group,
|
||||
"languages": parsed.languages,
|
||||
"audio_codec": parsed.audio_codec,
|
||||
"audio_channels": parsed.audio_channels,
|
||||
"bit_depth": parsed.bit_depth,
|
||||
"hdr_format": parsed.hdr_format,
|
||||
"edition": parsed.edition,
|
||||
"site_tag": parsed.site_tag,
|
||||
"is_season_pack": parsed.is_season_pack,
|
||||
"probe_used": probe_used,
|
||||
"media_type": parsed.media_type,
|
||||
"parse_path": parsed.parse_path,
|
||||
"title": parsed.title,
|
||||
"year": parsed.year,
|
||||
"season": parsed.season,
|
||||
"episode": parsed.episode,
|
||||
"episode_end": parsed.episode_end,
|
||||
"quality": parsed.quality,
|
||||
"source": parsed.source,
|
||||
"codec": parsed.codec,
|
||||
"group": parsed.group,
|
||||
"languages": parsed.languages,
|
||||
"audio_codec": parsed.audio_codec,
|
||||
"audio_channels": parsed.audio_channels,
|
||||
"bit_depth": parsed.bit_depth,
|
||||
"hdr_format": parsed.hdr_format,
|
||||
"edition": parsed.edition,
|
||||
"site_tag": parsed.site_tag,
|
||||
"is_season_pack": parsed.is_season_pack,
|
||||
"probe_used": probe_used,
|
||||
}
|
||||
|
||||
|
||||
@@ -293,45 +405,53 @@ def probe_media(source_path: str) -> dict[str, Any]:
|
||||
"""
|
||||
path = Path(source_path)
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "not_found", "message": f"{source_path} does not exist"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "not_found",
|
||||
"message": f"{source_path} does not exist",
|
||||
}
|
||||
|
||||
media_info = probe(path)
|
||||
if media_info is None:
|
||||
return {"status": "error", "error": "probe_failed", "message": "ffprobe failed to read the file"}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "probe_failed",
|
||||
"message": "ffprobe failed to read the file",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"video": {
|
||||
"codec": media_info.video_codec,
|
||||
"resolution": media_info.resolution,
|
||||
"width": media_info.width,
|
||||
"height": media_info.height,
|
||||
"codec": media_info.video_codec,
|
||||
"resolution": media_info.resolution,
|
||||
"width": media_info.width,
|
||||
"height": media_info.height,
|
||||
"duration_seconds": media_info.duration_seconds,
|
||||
"bitrate_kbps": media_info.bitrate_kbps,
|
||||
"bitrate_kbps": media_info.bitrate_kbps,
|
||||
},
|
||||
"audio_tracks": [
|
||||
{
|
||||
"index": t.index,
|
||||
"codec": t.codec,
|
||||
"channels": t.channels,
|
||||
"index": t.index,
|
||||
"codec": t.codec,
|
||||
"channels": t.channels,
|
||||
"channel_layout": t.channel_layout,
|
||||
"language": t.language,
|
||||
"is_default": t.is_default,
|
||||
"language": t.language,
|
||||
"is_default": t.is_default,
|
||||
}
|
||||
for t in media_info.audio_tracks
|
||||
],
|
||||
"subtitle_tracks": [
|
||||
{
|
||||
"index": t.index,
|
||||
"codec": t.codec,
|
||||
"language": t.language,
|
||||
"index": t.index,
|
||||
"codec": t.codec,
|
||||
"language": t.language,
|
||||
"is_default": t.is_default,
|
||||
"is_forced": t.is_forced,
|
||||
"is_forced": t.is_forced,
|
||||
}
|
||||
for t in media_info.subtitle_tracks
|
||||
],
|
||||
"audio_languages": media_info.audio_languages,
|
||||
"is_multi_audio": media_info.is_multi_audio,
|
||||
"is_multi_audio": media_info.is_multi_audio,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user