diff --git a/app/agent/api/__init__.py b/app/agent/api/__init__.py
new file mode 100644
index 000000000..52bfe9d63
--- /dev/null
+++ b/app/agent/api/__init__.py
@@ -0,0 +1 @@
+"""Agent 结构化 MoviePilot API 执行能力。"""
diff --git a/app/agent/api/executor.py b/app/agent/api/executor.py
new file mode 100644
index 000000000..3a06f1a04
--- /dev/null
+++ b/app/agent/api/executor.py
@@ -0,0 +1,166 @@
+"""通过固定宿主 API 路由执行 Agent 业务操作。"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from typing import Any, Mapping
+from urllib.parse import quote
+
+from app.adapters.network.http import AsyncRequestUtils
+from app.agent.policy.api import ApiOperationRoute, resolve_api_route
+from app.application.security.token import create_access_token
+from app.runtime.log import logger
+from app.runtime.settings import get_runtime_setting
+
+
+class ApiExecutionError(RuntimeError):
+ """固定 API 路由无法构造或请求失败。"""
+
+
+@dataclass(frozen=True)
+class ApiExecutionContext:
+ """API 请求使用的 Agent 身份和会话上下文。"""
+
+ user_id: str
+ username: str | None
+ is_admin: bool
+ session_id: str | None = None
+ channel: str | None = None
+ source: str | None = None
+
+
+class MoviePilotApiExecutor:
+ """把 operation ID 转换为受控的 MoviePilot REST API 请求。"""
+
+ _ALLOWED_SOURCES = frozenset({"tmdb", "douban", "bangumi", "anilist"})
+ _ALLOWED_DOWNLOAD_ACTIONS = frozenset({"start", "stop"})
+
+ def __init__(
+ self,
+ *,
+ context: ApiExecutionContext,
+ request_factory: type[AsyncRequestUtils] = AsyncRequestUtils,
+ ) -> None:
+ """绑定调用身份和 HTTP 传输工厂。"""
+ self._context = context
+ self._request_factory = request_factory
+
+ @staticmethod
+ def _resolve_base_url() -> str:
+ """解析本机 API 基址,避免把任意用户输入当成请求目标。"""
+ configured_domain = str(get_runtime_setting("APP_DOMAIN", "") or "").strip()
+ if configured_domain.startswith(("http://", "https://")):
+ return configured_domain.rstrip("/")
+ host = str(get_runtime_setting("HOST", "127.0.0.1") or "127.0.0.1")
+ if host in {"0.0.0.0", "::", "[::]"}:
+ host = "127.0.0.1"
+ port = int(get_runtime_setting("PORT", 3001))
+ return f"http://{host}:{port}"
+
+ @classmethod
+ def _render_path(
+ cls,
+ route: ApiOperationRoute,
+ path_params: Mapping[str, Any],
+ ) -> str:
+ """替换固定路由占位符并校验具有枚举约束的路径参数。"""
+ rendered = route.path
+ required = []
+ cursor = 0
+ while True:
+ start = rendered.find("{", cursor)
+ if start < 0:
+ break
+ end = rendered.find("}", start)
+ if end < 0:
+ raise ApiExecutionError("API 路由占位符格式无效")
+ required.append(rendered[start + 1 : end])
+ cursor = end + 1
+ for name in required:
+ value = path_params.get(name)
+ if value in (None, ""):
+ raise ApiExecutionError(f"缺少 API 路径参数: {name}")
+ if name == "source" and str(value).lower() not in cls._ALLOWED_SOURCES:
+ raise ApiExecutionError("媒体来源不在 API 白名单内")
+ if name == "action" and str(value).lower() not in cls._ALLOWED_DOWNLOAD_ACTIONS:
+ raise ApiExecutionError("下载动作不在 API 白名单内")
+ rendered = rendered.replace("{" + name + "}", quote(str(value), safe=""))
+ return rendered
+
+ def _build_headers(self) -> dict[str, str]:
+ """构造仅包含宿主生成令牌的认证头。"""
+ user_id = str(self._context.user_id or "")
+ if not user_id.isdigit():
+ raise ApiExecutionError("当前 Agent 身份没有可用于 API 鉴权的用户 ID")
+ token = create_access_token(
+ userid=int(user_id),
+ username=self._context.username or user_id,
+ super_user=self._context.is_admin,
+ )
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Accept": "application/json",
+ }
+ if self._context.session_id:
+ headers["X-MoviePilot-Agent-Session"] = self._context.session_id
+ if self._context.channel:
+ headers["X-MoviePilot-Agent-Channel"] = self._context.channel
+ if self._context.source:
+ headers["X-MoviePilot-Agent-Source"] = self._context.source
+ return headers
+
+ async def execute(
+ self,
+ operation_id: str,
+ *,
+ path_params: Mapping[str, Any] | None = None,
+ query: Mapping[str, Any] | None = None,
+ body: Mapping[str, Any] | None = None,
+ ) -> str:
+ """执行白名单 operation,并把响应转换为稳定 JSON 文本。"""
+ route = resolve_api_route(operation_id)
+ if route is None:
+ raise ApiExecutionError(f"未注册 API 路由: {operation_id}")
+ path = self._render_path(route, path_params or {})
+ url = f"{self._resolve_base_url()}{path}"
+ query_data = dict(query or {})
+ body_data = dict(body or {})
+ if route.method == "GET" and body_data:
+ query_data.update(body_data)
+ body_data = {}
+ request = self._request_factory(
+ headers=self._build_headers(),
+ timeout=30,
+ verify=False,
+ trust_env=False,
+ )
+ try:
+ response = await request.request(
+ method=route.method,
+ url=url,
+ params=query_data or None,
+ json=body_data or None,
+ raise_exception=True,
+ )
+ if response is None:
+ raise ApiExecutionError("MoviePilot API 没有返回响应")
+ try:
+ payload = response.json()
+ status_code = response.status_code
+ finally:
+ await response.aclose()
+ except ApiExecutionError:
+ raise
+ except Exception as error:
+ logger.warning(f"Agent API 请求失败: operation={operation_id} error={error}")
+ raise ApiExecutionError(f"MoviePilot API 请求失败: {operation_id}") from error
+ if status_code >= 400:
+ return json.dumps(
+ {"success": False, "error": "api_error", "status_code": status_code, "data": payload},
+ ensure_ascii=False,
+ )
+ return json.dumps(payload, ensure_ascii=False, default=str)
+
+
+__all__ = ["ApiExecutionContext", "ApiExecutionError", "MoviePilotApiExecutor"]
diff --git a/app/agent/callback/__init__.py b/app/agent/callback/__init__.py
index 3d032ce56..4812c8a3c 100644
--- a/app/agent/callback/__init__.py
+++ b/app/agent/callback/__init__.py
@@ -3,22 +3,20 @@ import re
import threading
from typing import Any, Optional, Tuple
-from app.runtime.execution import run_in_threadpool
from app.agent.policy.sanitizer import sanitize_for_host
from app.chain.base import ChainBase
+from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.schemas.message import Message, MessageResponse
-from app.schemas.notification import ChannelCapabilityManager, ChannelCapability
-from app.schemas.types import NotificationChannel, MessageType
+from app.schemas.notification import ChannelCapability, ChannelCapabilityManager
+from app.schemas.types import MessageType, NotificationChannel
class _StreamChain(ChainBase):
pass
-_PATCH_FILE_HEADER_PATTERN = re.compile(
- r"\*\*\* (?:Add|Update|Delete) File:\s*(\S+)"
-)
+_PATCH_FILE_HEADER_PATTERN = re.compile(r"\*\*\* (?:Add|Update|Delete) File:\s*(\S+)")
def _extract_first_patch_path(patch: Optional[str]) -> Optional[str]:
@@ -81,9 +79,7 @@ class StreamingHandler:
# 本轮已写入缓冲区的工具摘要行,供 Telegram 富文本渲染时做区分样式
self._tool_summaries: set[str] = set()
- def set_dispatch_policy(
- self, allow_dispatch_without_context: bool = False
- ) -> None:
+ def set_dispatch_policy(self, allow_dispatch_without_context: bool = False) -> None:
"""
设置在缺少渠道上下文时是否仍允许向默认通知渠道分发消息。
后台 DISPATCH 任务允许,CAPTURE_ONLY 必须禁止。
@@ -229,10 +225,8 @@ class StreamingHandler:
# 从渠道能力中获取单条消息最大长度
try:
channel_enum = NotificationChannel(self._channel)
- self._max_message_length = ChannelCapabilityManager.get_max_message_length(
- channel_enum
- )
- except (ValueError, KeyError):
+ self._max_message_length = ChannelCapabilityManager.get_max_message_length(channel_enum)
+ except ValueError, KeyError:
self._max_message_length = 0
# 启动异步定时刷新任务
@@ -276,12 +270,8 @@ class StreamingHandler:
# 检查是否所有缓冲内容都已发送
with self._lock:
# 当前消息的文本 = buffer 中从 _msg_start_offset 开始的部分
- current_msg_text = self._buffer[self._msg_start_offset:]
- all_sent = (
- self._message_response is not None
- and self._sent_text
- and current_msg_text == self._sent_text
- )
+ current_msg_text = self._buffer[self._msg_start_offset :]
+ all_sent = self._message_response is not None and self._sent_text and current_msg_text == self._sent_text
# 保留最终文本用于返回(返回完整 buffer 内容,包含所有分段消息)
final_text = self._buffer if all_sent else ""
# 重置状态
@@ -386,14 +376,10 @@ class StreamingHandler:
return "file_write", tool_kwargs.get("file_path")
if tool_name == "apply_patch":
return "file_write", _extract_first_patch_path(tool_kwargs.get("patch"))
- if tool_name in {"list_directory", "query_directory_settings"}:
- return "directory", tool_kwargs.get("path")
if tool_name == "browse_webpage":
return (
"web_browse",
- tool_kwargs.get("url")
- or tool_kwargs.get("target_url")
- or tool_kwargs.get("path"),
+ tool_kwargs.get("url") or tool_kwargs.get("target_url") or tool_kwargs.get("path"),
)
if tool_name == "execute_command":
return (
@@ -402,22 +388,47 @@ class StreamingHandler:
)
if tool_name == "ask_user_choice":
return "interaction", tool_kwargs.get("message")
- if tool_name.startswith("search_") or tool_name in {"get_search_results"}:
+ if tool_name == "moviepilot_api":
+ operation_id = str(tool_kwargs.get("operation_id") or "")
+ if operation_id in {"storage.settings", "storage.list"}:
+ body = tool_kwargs.get("body")
+ target = body.get("path") if isinstance(body, dict) else None
+ return "directory", target
+ if operation_id in {
+ "media.search",
+ "media.person.search",
+ "search.torrents",
+ "search.results",
+ "recommendation.list",
+ }:
+ query = tool_kwargs.get("query")
+ target = query.get("title") if isinstance(query, dict) else None
+ return "search", target
+ if operation_id.endswith((".list", ".get", ".exists", ".history", ".detail")) or operation_id in {
+ "media.episode_schedule",
+ "plugin.data",
+ "filter.builtin",
+ "filter.custom",
+ "filter.groups",
+ "site.userdata",
+ "search.results",
+ }:
+ return "data_query", None
+ return "action", None
+ if tool_name == "agent_task":
+ return ("data_query", None) if tool_kwargs.get("action") == "list" else ("action", None)
+ if tool_name == "persona":
+ return ("data_query", None) if tool_kwargs.get("action") == "list" else ("action", None)
+ if tool_name.startswith("search_"):
return (
"search",
- tool_kwargs.get("query")
- or tool_kwargs.get("title")
- or tool_kwargs.get("keyword"),
+ tool_kwargs.get("query") or tool_kwargs.get("title") or tool_kwargs.get("keyword"),
)
if tool_name.startswith("query_") or tool_name.startswith("list_") or tool_name.startswith("get_"):
return "data_query", None
if tool_name.startswith(("add_", "update_", "delete_", "modify_", "run_")):
return "action", None
if tool_name in {
- "recognize_media",
- "scrape_metadata",
- "transfer_file",
- "test_site",
"send_message",
"send_local_file",
"send_voice_message",
@@ -438,9 +449,7 @@ class StreamingHandler:
if "搜索" in tool_message or "search" in tool_message_lower:
return (
"search",
- tool_kwargs.get("query")
- or tool_kwargs.get("title")
- or tool_kwargs.get("keyword"),
+ tool_kwargs.get("query") or tool_kwargs.get("title") or tool_kwargs.get("keyword"),
)
if "网页" in tool_message or "browser" in tool_message_lower or "webpage" in tool_message_lower:
return "web_browse", tool_kwargs.get("url")
@@ -516,10 +525,8 @@ class StreamingHandler:
return False
try:
channel_enum = NotificationChannel(self._channel)
- return ChannelCapabilityManager.supports_capability(
- channel_enum, ChannelCapability.MESSAGE_EDITING
- )
- except (ValueError, KeyError):
+ return ChannelCapabilityManager.supports_capability(channel_enum, ChannelCapability.MESSAGE_EDITING)
+ except ValueError, KeyError:
return False
def _get_rich_message(self, text: str) -> Optional[str]:
@@ -539,10 +546,7 @@ class StreamingHandler:
"""
if not self._tool_summaries or not text:
return text
- return "\n".join(
- f"> {line}" if line in self._tool_summaries else line
- for line in text.split("\n")
- )
+ return "\n".join(f"> {line}" if line in self._tool_summaries else line for line in text.split("\n"))
async def _flush_loop(self):
"""
@@ -567,11 +571,7 @@ class StreamingHandler:
否则最终刷新会误以为尚未发送过消息,从而再次发送一条新消息。
"""
current_task = asyncio.current_task()
- if (
- self._flush_task
- and not self._flush_task.done()
- and self._flush_task is not current_task
- ):
+ if self._flush_task and not self._flush_task.done() and self._flush_task is not current_task:
try:
await self._flush_task
except asyncio.CancelledError:
@@ -587,14 +587,11 @@ class StreamingHandler:
"""
with self._lock:
# 当前消息的文本 = buffer 中从 _msg_start_offset 开始的部分
- current_text = self._buffer[self._msg_start_offset:]
+ current_text = self._buffer[self._msg_start_offset :]
if not current_text or current_text == self._sent_text:
# 没有新内容需要刷新
return
- if (
- (not self._channel or not self._source)
- and not self._allow_dispatch_without_context
- ):
+ if (not self._channel or not self._source) and not self._allow_dispatch_without_context:
logger.debug("流式输出缺少渠道上下文,当前模式禁止外发消息")
return
@@ -623,28 +620,19 @@ class StreamingHandler:
self._message_response = response
with self._lock:
self._sent_text = current_text
- logger.debug(
- f"流式输出初始消息已发送: message_id={response.message_id}"
- )
+ logger.debug(f"流式输出初始消息已发送: message_id={response.message_id}")
else:
- logger.debug(
- "流式输出初始消息发送失败或未返回message_id,降级为非流式输出"
- )
+ logger.debug("流式输出初始消息发送失败或未返回message_id,降级为非流式输出")
self._streaming_enabled = False
else:
# 检查当前消息内容是否超过长度限制
- if (
- self._max_message_length
- and len(current_text) > self._max_message_length
- ):
+ if self._max_message_length and len(current_text) > self._max_message_length:
# 消息过长,冻结当前消息(保持最后一次成功编辑的内容)
# 将 offset 移动到已发送文本之后,开启新消息
- logger.debug(
- f"流式消息长度 {len(current_text)} 超过限制 {self._max_message_length},启用新消息"
- )
+ logger.debug(f"流式消息长度 {len(current_text)} 超过限制 {self._max_message_length},启用新消息")
with self._lock:
self._msg_start_offset += len(self._sent_text)
- current_text = self._buffer[self._msg_start_offset:]
+ current_text = self._buffer[self._msg_start_offset :]
self._message_response = None
self._sent_text = ""
@@ -670,9 +658,7 @@ class StreamingHandler:
self._message_response = response
with self._lock:
self._sent_text = current_text
- logger.debug(
- f"流式输出新消息已发送: message_id={response.message_id}"
- )
+ logger.debug(f"流式输出新消息已发送: message_id={response.message_id}")
else:
logger.debug("流式输出新消息发送失败,降级为非流式输出")
self._streaming_enabled = False
@@ -680,7 +666,7 @@ class StreamingHandler:
# 后续更新:编辑已有消息
try:
channel_enum = NotificationChannel(self._channel)
- except (ValueError, KeyError):
+ except ValueError, KeyError:
return
metadata = dict(self._message_response.metadata or {})
diff --git a/app/agent/middleware/jobs.py b/app/agent/middleware/jobs.py
index f39b97ac7..3c53d4782 100644
--- a/app/agent/middleware/jobs.py
+++ b/app/agent/middleware/jobs.py
@@ -70,9 +70,7 @@ def _parse_job_metadata(
) -> JobMetadata | None:
"""从 JOB.md 内容中解析 YAML 前言并验证元数据。"""
if len(content) > MAX_JOB_FILE_SIZE:
- logger.warning(
- "Skipping %s: content too large (%d bytes)", job_path, len(content)
- )
+ logger.warning("Skipping %s: content too large (%d bytes)", job_path, len(content))
return None
# 匹配 --- 分隔的 YAML 前言
@@ -175,9 +173,7 @@ def filter_active_jobs(jobs_metadata: list[JobMetadata]) -> list[JobMetadata]:
`recurring` 任务执行完成后按约定应回写为 `pending`,因此无需再额外放宽
到 `completed`,避免已结束任务被重复注入后台心跳。
"""
- return [
- job for job in jobs_metadata if job.get("status") in ACTIVE_JOB_STATUSES
- ]
+ return [job for job in jobs_metadata if job.get("status") in ACTIVE_JOB_STATUSES]
async def load_jobs_metadata(source_paths: list[str]) -> list[JobMetadata]:
@@ -204,12 +200,12 @@ You have a scheduled jobs system for user-requested delayed or recurring work.
{jobs_list}
Rules:
-- For new delayed, recurring, reminder, or monitoring work, use the dedicated
- `create_agent_task`, `query_agent_tasks`, `update_agent_task`, `run_agent_task`,
- and `delete_agent_task` tools. These tools use integer task IDs. Do not create
- or edit JOB.md files for new tasks.
-- Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin,
- or workflow runtime services; never pass their string job IDs to Agent task tools.
+- For new delayed, recurring, reminder, or monitoring work, use `agent_task`
+ with `action=create|list|update|run|delete`. These actions use integer task IDs.
+ Do not create or edit JOB.md files for new tasks.
+- Use `moviepilot_api` operations `scheduler.list` and `scheduler.run` only for
+ MoviePilot system, plugin, or workflow runtime services; never pass their string
+ job IDs to `agent_task`.
- Do not create tasks for immediate one-time work or work already handled by MoviePilot schedulers.
- Entries listed above are legacy JOB.md tasks. Read their files only when a heartbeat asks you to execute them.
- During heartbeat checks, act only on `pending` or `in_progress` jobs, update status/last_run/logs, and leave recurring jobs `pending` after each run.
@@ -235,7 +231,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
def _format_jobs_list(jobs: list[JobMetadata]) -> str:
"""格式化任务元数据列表用于系统提示词。"""
if not jobs:
- return "(No active legacy JOB.md tasks. Use create_agent_task for new scheduled work.)"
+ return "(No active legacy JOB.md tasks. Use agent_task action=create for new scheduled work.)"
lines = []
for job in jobs:
@@ -246,15 +242,8 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
"cancelled": "❌",
}.get(job["status"], "❓")
- schedule_label = (
- "recurring (重复)"
- if job["schedule"] == "recurring"
- else "once (一次性)"
- )
- desc_line = (
- f"- {status_emoji} **{job['id']}**: {job['name']}"
- f" [{schedule_label}] - {job['description']}"
- )
+ schedule_label = "recurring (重复)" if job["schedule"] == "recurring" else "once (一次性)"
+ desc_line = f"- {status_emoji} **{job['id']}**: {job['name']} [{schedule_label}] - {job['description']}"
if job.get("last_run"):
desc_line += f" (上次执行: {job['last_run']})"
lines.append(desc_line)
@@ -277,28 +266,20 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
jobs_list=jobs_list,
)
- new_system_message = append_to_system_message(
- request.system_message, jobs_section
- )
+ new_system_message = append_to_system_message(request.system_message, jobs_section)
return request.override(system_message=new_system_message)
async def abefore_agent( # noqa
self, state: JobsState, runtime: Runtime, config: RunnableConfig
) -> JobsStateUpdate | None:
- """在 Agent 执行前异步加载任务元数据。
-
- """
- return JobsStateUpdate(
- jobs_metadata=await load_jobs_metadata(self.sources)
- )
+ """在 Agent 执行前异步加载任务元数据。"""
+ return JobsStateUpdate(jobs_metadata=await load_jobs_metadata(self.sources))
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
- handler: Callable[
- [ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
- ],
+ handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]],
) -> ModelResponse[ResponseT]:
"""在模型调用时注入任务文档。"""
modified_request = self.modify_request(request)
diff --git a/app/agent/middleware/policy.py b/app/agent/middleware/policy.py
index 9519ce384..2afcb98c0 100644
--- a/app/agent/middleware/policy.py
+++ b/app/agent/middleware/policy.py
@@ -16,16 +16,13 @@ from app.agent.policy.orchestrator import (
AgentToolPolicyOrchestrator,
call_policy_hook,
)
+from app.agent.policy.registry import requests_system_setting_secrets
from app.agent.tools.catalog import ToolCatalogSnapshot
-from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
-
+from app.agent.tools.impl.api import MoviePilotApiTool
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
POLICY_UNAVAILABLE_MESSAGE = "宿主策略暂时不可用,未执行该工具。"
-TOOL_TIMEOUT_MESSAGE = (
- "工具执行超时,已停止等待结果;"
- "若工具包含外部写操作,操作可能仍在继续,请先确认实际状态再重试。"
-)
+TOOL_TIMEOUT_MESSAGE = "工具执行超时,已停止等待结果;若工具包含外部写操作,操作可能仍在继续,请先确认实际状态再重试。"
class AgentPolicyMiddleware(AgentMiddleware):
@@ -47,11 +44,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
self.context = context
self.orchestrator = orchestrator
self.catalog = catalog
- self._tools = {
- tool.name: tool
- for tool in (tools or [])
- if getattr(tool, "name", None)
- }
+ self._tools = {tool.name: tool for tool in (tools or []) if getattr(tool, "name", None)}
@hook_config(can_jump_to=["end"])
async def aafter_model(self, state: dict[str, Any], runtime: Any) -> Any:
@@ -67,9 +60,9 @@ class AgentPolicyMiddleware(AgentMiddleware):
arguments = tool_call.get("args")
tool = self._tools.get(tool_call.get("name"))
if (
- isinstance(tool, QuerySystemSettingsTool)
+ isinstance(tool, MoviePilotApiTool)
and isinstance(arguments, dict)
- and arguments.get("show_secrets") is True
+ and requests_system_setting_secrets(arguments)
):
sensitive_call = tool_call
sensitive_tool = tool
@@ -92,10 +85,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
paused_messages = [
ToolMessage(
- content=(
- "本轮工具调用已暂停,未执行任何操作;"
- "请等待用户确认或取消敏感设置读取。"
- ),
+ content=("本轮工具调用已暂停,未执行任何操作;请等待用户确认或取消敏感设置读取。"),
tool_call_id=str(tool_call.get("id") or ""),
name=str(tool_call.get("name") or "unknown"),
)
@@ -153,10 +143,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
)
if enforce_decision and observation is None:
return False, POLICY_UNAVAILABLE_MESSAGE
- if (
- enforce_decision
- and observation.decision.allowed is False
- ):
+ if enforce_decision and observation.decision.allowed is False:
return False, POLICY_DENIED_MESSAGE
try:
result = await handler()
diff --git a/app/agent/middleware/skills.py b/app/agent/middleware/skills.py
index 6e7c51529..86ae8ea81 100644
--- a/app/agent/middleware/skills.py
+++ b/app/agent/middleware/skills.py
@@ -3,8 +3,7 @@ import re
import shutil
from collections.abc import Awaitable, Callable
from pathlib import Path
-from typing import Annotated, Any, List, Optional
-from typing import NotRequired, TypedDict
+from typing import Annotated, Any, List, NotRequired, Optional, TypedDict
import yaml # noqa
from anyio import Path as AsyncPath
@@ -14,10 +13,11 @@ from langchain.agents.middleware.types import (
ContextT,
ModelRequest,
ModelResponse,
+ PrivateStateAttr, # noqa
ResponseT,
ToolCallRequest,
)
-from langchain.agents.middleware.types import PrivateStateAttr # noqa
+from langchain_core.messages import ToolMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import StructuredTool
from langgraph.runtime import Runtime
@@ -25,12 +25,12 @@ from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.policy.sanitizer import sanitize_for_host, summarize_error
-from app.agent.tools.tags import ToolTag
from app.agent.skills.metadata import (
MAX_SKILL_FILE_SIZE,
SkillMetadata,
parse_skill_metadata,
)
+from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
# 模型返回上限独立于领域层的磁盘读取上限,避免异常内容撑爆上下文。
@@ -102,9 +102,7 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
stat.st_size,
)
continue
- skill_content = (await skill_md_path.read_bytes()).decode(
- "utf-8", errors="replace"
- )
+ skill_content = (await skill_md_path.read_bytes()).decode("utf-8", errors="replace")
# 解析元数据
skill_metadata = parse_skill_metadata(
@@ -123,11 +121,7 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]:
if not source_path.exists():
return []
- skill_dirs = [
- path
- for path in source_path.iterdir()
- if path.is_dir() and (path / "SKILL.md").is_file()
- ]
+ skill_dirs = [path for path in source_path.iterdir() if path.is_dir() and (path / "SKILL.md").is_file()]
if not skill_dirs:
return []
@@ -143,9 +137,7 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]:
skill_md_path.stat().st_size,
)
continue
- skill_content = skill_md_path.read_bytes().decode(
- "utf-8", errors="replace"
- )
+ skill_content = skill_md_path.read_bytes().decode("utf-8", errors="replace")
skill_metadata = parse_skill_metadata(
content=skill_content,
skill_path=str(skill_md_path),
@@ -199,7 +191,7 @@ def _extract_version(skill_md: Path) -> int:
return 0
try:
return int(raw)
- except (ValueError, TypeError):
+ except ValueError, TypeError:
return 0
@@ -237,9 +229,7 @@ def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
# 目标不存在,直接复制
try:
shutil.copytree(str(skill_src), str(skill_dst))
- logger.info(
- "已自动复制内置技能 '%s' -> '%s'", skill_src.name, skill_dst
- )
+ logger.info("已自动复制内置技能 '%s' -> '%s'", skill_src.name, skill_dst)
except Exception as e:
logger.warning(
"复制内置技能 '%s' 失败: %s",
@@ -285,6 +275,22 @@ class _SkillToolProvider:
def __init__(self, *, sources: list[str]) -> None:
"""初始化 Skill 工具数据源。"""
self._sources = sources
+ self._allowed_api_operations: set[str] = set()
+ self._api_scope_declared = False
+
+ @property
+ def api_scope_declared(self) -> bool:
+ """返回当前会话是否加载过声明 API 操作范围的 Skill。"""
+ return self._api_scope_declared
+
+ def reset_api_scope(self) -> None:
+ """在新一轮 Agent 执行前清除上轮 Skill 的 API 操作范围。"""
+ self._allowed_api_operations.clear()
+ self._api_scope_declared = False
+
+ def is_api_operation_allowed(self, operation_id: str) -> bool:
+ """判断 operation ID 是否位于当前已加载 Skill 的联合授权范围。"""
+ return operation_id in self._allowed_api_operations
@staticmethod
def _normalize_name(value: object) -> str:
@@ -299,10 +305,7 @@ class _SkillToolProvider:
skill.get("id"),
skill.get("name"),
]
- return any(
- cls._normalize_name(candidate) == normalized_query
- for candidate in candidates
- )
+ return any(cls._normalize_name(candidate) == normalized_query for candidate in candidates)
async def _find_skill(self, name: str) -> Optional[SkillMetadata]:
"""从中间件配置的 skills 目录中查找指定技能。"""
@@ -354,10 +357,7 @@ class _SkillToolProvider:
candidate = json.dumps(
{
**truncated_payload,
- "content": (
- original_content[:middle]
- + SKILL_CONTENT_TRUNCATION_SUFFIX
- ),
+ "content": (original_content[:middle] + SKILL_CONTENT_TRUNCATION_SUFFIX),
},
ensure_ascii=False,
indent=2,
@@ -384,6 +384,10 @@ class _SkillToolProvider:
)
content, truncated = await self._read_skill_content(skill["path"])
+ declared_operations = skill.get("allowed_api_operations", [])
+ if declared_operations:
+ self._api_scope_declared = True
+ self._allowed_api_operations.update(declared_operations)
return self._serialize_skill_payload(
{
"success": True,
@@ -393,6 +397,7 @@ class _SkillToolProvider:
"description": skill.get("description"),
"path": skill.get("path"),
"allowed_tools": skill.get("allowed_tools", []),
+ "allowed_api_operations": declared_operations,
},
"content": content,
"truncated": truncated,
@@ -414,10 +419,7 @@ def _format_skill_tool_catalog(skills: list[SkillMetadata]) -> str:
"""渲染 Skill 工具描述中的可用技能目录。"""
if not skills:
return "(No skills are currently available.)"
- return "\n".join(
- f"- {skill['id']}: {skill['name']} - {skill['description']}"
- for skill in skills
- )
+ return "\n".join(f"- {skill['id']}: {skill['name']} - {skill['description']}" for skill in skills)
class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # noqa
@@ -458,9 +460,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
coroutine=self._skill_provider.load_skill,
name=SKILL_TOOL_NAME,
description=SKILL_TOOL_DESCRIPTION.format(
- skills_catalog=_format_skill_tool_catalog(
- self._load_skills_metadata()
- )
+ skills_catalog=_format_skill_tool_catalog(self._load_skills_metadata())
),
args_schema=SkillToolInput,
tags=[ToolTag.Read, ToolTag.Skill],
@@ -487,15 +487,11 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
all_skills[skill["name"]] = skill
return list(all_skills.values())
- def _refresh_skill_tool_description(
- self, skills: list[SkillMetadata]
- ) -> None:
+ def _refresh_skill_tool_description(self, skills: list[SkillMetadata]) -> None:
"""刷新 skill 工具描述中的可用技能目录。"""
if not self.tools:
return
- self.tools[0].description = SKILL_TOOL_DESCRIPTION.format(
- skills_catalog=_format_skill_tool_catalog(skills)
- )
+ self.tools[0].description = SKILL_TOOL_DESCRIPTION.format(skills_catalog=_format_skill_tool_catalog(skills))
@staticmethod
def _format_skills_list(skills: list[SkillMetadata]) -> str:
@@ -512,6 +508,8 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
lines.append(desc_line)
if skill["allowed_tools"]:
lines.append(f" -> Allowed tools: {', '.join(skill['allowed_tools'])}")
+ if skill["allowed_api_operations"]:
+ lines.append(f" -> Allowed API operations: {', '.join(skill['allowed_api_operations'])}")
return "\n".join(lines)
@@ -524,9 +522,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
skills_list=skills_list,
)
- new_system_message = append_to_system_message(
- request.system_message, skills_section
- )
+ new_system_message = append_to_system_message(request.system_message, skills_section)
return request.override(system_message=new_system_message)
@@ -538,6 +534,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
首次加载时,会先将内置技能同步到用户目录(如不存在)。
"""
self._sync_bundled_skills()
+ self._skill_provider.reset_api_scope()
all_skills: dict[str, SkillMetadata] = {}
@@ -558,9 +555,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
- handler: Callable[
- [ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
- ],
+ handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]],
) -> ModelResponse[ResponseT]:
"""在模型调用时注入技能文档。"""
modified_request = self.modify_request(request)
@@ -574,19 +569,38 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
"""在 skill 工具执行时记录聚合摘要。"""
tool = request.tool
tool_name = getattr(tool, "name", None)
- if tool_name != SKILL_TOOL_NAME:
- return await handler(request)
-
tool_call = request.tool_call or {}
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
tool_args = {}
+
+ if tool_name == "moviepilot_api":
+ operation_id = str(tool_args.get("operation_id") or "")
+ if not self._skill_provider.is_api_operation_allowed(operation_id):
+ logger.warning(f"Skill API 操作范围拒绝调用: operation={sanitize_for_host(operation_id)}")
+ return ToolMessage(
+ content=json.dumps(
+ {
+ "success": False,
+ "error": "skill_operation_denied",
+ "message": (
+ "当前已加载 Skill 未授权该 MoviePilot API 操作,"
+ "请先加载声明了该 operation 的领域 Skill。"
+ ),
+ },
+ ensure_ascii=False,
+ ),
+ tool_call_id=str(tool_call.get("id") or ""),
+ name="moviepilot_api",
+ )
+
+ if tool_name != SKILL_TOOL_NAME:
+ return await handler(request)
+
logged_args = sanitize_for_host(tool_args)
if not isinstance(logged_args, dict):
logged_args = {}
- logger.info(
- f"开始执行 Skill 工具: name={logged_args.get('name') or '-'}"
- )
+ logger.info(f"开始执行 Skill 工具: name={logged_args.get('name') or '-'}")
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
self.stream_handler.record_tool_call(
tool_name=SKILL_TOOL_NAME,
diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py
index 1446a4228..1c70d6772 100644
--- a/app/agent/orchestrator.py
+++ b/app/agent/orchestrator.py
@@ -57,14 +57,12 @@ from app.agent.policy.contracts import (
ToolOrigin,
ToolPolicyContext,
)
+from app.agent.policy.registry import requests_system_setting_secrets
from app.agent.prompt import prompt_manager
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.catalog import ToolCatalogSnapshot
-from app.agent.tools.impl.mcp import (
- create_external_mcp_tools,
- select_legacy_mcp_tools,
-)
-from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
+from app.agent.tools.impl.api import MoviePilotApiTool
+from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.application.agent import AgentDataContext
from app.application.messaging.chat import (
get_configured_agent_chat_persistence,
@@ -88,6 +86,7 @@ def _get_plugin_tools_revision() -> int:
"""读取插件工具目录修订号,避免 Agent 编排依赖具体管理器类型。"""
return get_plugin_manager().get_plugin_agent_tools_revision()
+
warnings.filterwarnings("ignore", message=".*allowed_objects.*")
@@ -202,9 +201,7 @@ class _SessionUsageSnapshot:
else None
),
"model_call_count": self.model_call_count,
- "last_updated_at": self.last_updated_at.strftime("%Y-%m-%d %H:%M:%S")
- if self.last_updated_at
- else None,
+ "last_updated_at": self.last_updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.last_updated_at else None,
}
@@ -255,7 +252,7 @@ class _ThinkTagStripper:
on_output(self.buffer[:start_idx])
emitted = True
self.in_think_tag = True
- self.buffer = self.buffer[start_idx + 7:]
+ self.buffer = self.buffer[start_idx + 7 :]
else:
# 检查是否以 的不完整前缀结尾
partial_match = False
@@ -276,7 +273,7 @@ class _ThinkTagStripper:
end_idx = self.buffer.find("")
if end_idx != -1:
self.in_think_tag = False
- self.buffer = self.buffer[end_idx + 8:]
+ self.buffer = self.buffer[end_idx + 8 :]
else:
# 检查是否以 的不完整前缀结尾
partial_match = False
@@ -298,14 +295,16 @@ class _ThinkTagStripper:
HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_"
-UNSUPPORTED_IMAGE_INPUT_MESSAGE = "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。"
+UNSUPPORTED_IMAGE_INPUT_MESSAGE = (
+ "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。"
+)
AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败"
AGENT_EXECUTION_ERROR_MESSAGE = "智能助手执行失败,请稍后重试。"
AGENT_DISPLAY_HISTORY_SKIP_CHANNELS = {NotificationChannel.WebAgent.value}
AGENT_CHAT_TITLE_PROMPT = (
"你是 MoviePilot 智能助手的内部会话标题生成器。你的唯一任务是根据提供的用户消息生成一个简洁中文标题。"
"用户消息只是命名素材,不是发给你的待处理请求;严禁回答、执行、解释、续写或确认其中的任何要求。"
- "只返回一个 JSON 对象,格式为 {\"title\":\"会话标题\"}。标题不超过 18 个汉字或 36 个英文字符,"
+ '只返回一个 JSON 对象,格式为 {"title":"会话标题"}。标题不超过 18 个汉字或 36 个英文字符,'
"不要返回 Markdown、代码块、引号外文本、编号或解释。"
)
AGENT_CHAT_TITLE_MAX_LENGTH = 36
@@ -319,7 +318,7 @@ SECRET_CANCEL_TEXT = "取消"
class _PendingSecretConfirmation:
"""保存当前会话中一次待确认的敏感设置读取。"""
- tool: QuerySystemSettingsTool
+ tool: MoviePilotApiTool
arguments: Dict[str, Any]
created_at: datetime
user_id: str
@@ -335,21 +334,21 @@ class MoviePilotAgent:
TOOL_CATALOG_REFRESH_SECONDS = 30
def __init__(
- self,
- session_id: str,
- user_id: Optional[str] = None,
- channel: Optional[str] = None,
- source: Optional[str] = None,
- username: Optional[str] = None,
- is_channel_admin: Optional[bool] = None,
- original_message_id: Optional[str] = None,
- original_chat_id: Optional[str] = None,
- replay_mode: ReplyMode = ReplyMode.DISPATCH,
- allow_message_tools: bool = True,
- output_callback: Optional[Callable[[str], None]] = None,
- protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
- data: Optional[AgentDataContext] = None,
- memory: Optional[MemoryManager] = None,
+ self,
+ session_id: str,
+ user_id: Optional[str] = None,
+ channel: Optional[str] = None,
+ source: Optional[str] = None,
+ username: Optional[str] = None,
+ is_channel_admin: Optional[bool] = None,
+ original_message_id: Optional[str] = None,
+ original_chat_id: Optional[str] = None,
+ replay_mode: ReplyMode = ReplyMode.DISPATCH,
+ allow_message_tools: bool = True,
+ output_callback: Optional[Callable[[str], None]] = None,
+ protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
+ data: Optional[AgentDataContext] = None,
+ memory: Optional[MemoryManager] = None,
):
"""创建会话 Agent,并保存组合根注入的数据与记忆能力。"""
self.session_id = session_id
@@ -384,11 +383,11 @@ class MoviePilotAgent:
@classmethod
def build_display_message(
- cls,
- role: str,
- content: str = "",
- attachments: Optional[List[dict]] = None,
- status: str = "done",
+ cls,
+ role: str,
+ content: str = "",
+ attachments: Optional[List[dict]] = None,
+ status: str = "done",
) -> dict[str, Any]:
"""
构造可展示的 Agent 会话消息。
@@ -404,11 +403,7 @@ class MoviePilotAgent:
"""
判断当前 Agent 是否由通用渠道保存展示历史。
"""
- return bool(
- self.channel
- and self.source
- and self.channel not in AGENT_DISPLAY_HISTORY_SKIP_CHANNELS
- )
+ return bool(self.channel and self.source and self.channel not in AGENT_DISPLAY_HISTORY_SKIP_CHANNELS)
def _should_persist_agent_chat(self) -> bool:
"""
@@ -441,9 +436,7 @@ class MoviePilotAgent:
"""
if not message or self._tool_context.get("assistant_display_saved"):
return
- await self._save_display_history_messages(
- [self.build_display_message(role="assistant", content=message)]
- )
+ await self._save_display_history_messages([self.build_display_message(role="assistant", content=message)])
self._tool_context["assistant_display_saved"] = True
@staticmethod
@@ -476,7 +469,7 @@ class MoviePilotAgent:
content = re.sub(r"\s*```$", "", content).strip()
try:
payload = json.loads(content)
- except (TypeError, ValueError):
+ except TypeError, ValueError:
return ""
if not isinstance(payload, dict):
return ""
@@ -548,7 +541,7 @@ class MoviePilotAgent:
return None
try:
return int(value)
- except (TypeError, ValueError):
+ except TypeError, ValueError:
return None
@staticmethod
@@ -562,18 +555,14 @@ class MoviePilotAgent:
def _get_recursion_limit() -> int:
"""读取 LangGraph 递归上限,防止模型持续循环调用工具。"""
try:
- limit = int(get_runtime_setting('LLM_MAX_ITERATIONS') or 0)
- except (TypeError, ValueError):
+ limit = int(get_runtime_setting("LLM_MAX_ITERATIONS") or 0)
+ except TypeError, ValueError:
limit = 0
return limit if limit > 0 else 128
@classmethod
def _get_model_name(cls, model: Any) -> Optional[str]:
- return (
- getattr(model, "model", None)
- or getattr(model, "model_name", None)
- or getattr(model, "model_id", None)
- )
+ return getattr(model, "model", None) or getattr(model, "model_name", None) or getattr(model, "model_id", None)
@classmethod
def _get_context_window_tokens(cls, model: Any) -> Optional[int]:
@@ -628,16 +617,11 @@ class MoviePilotAgent:
"has_estimate": False,
}
)
- is_current_request = (
- not has_request_sequence
- or request_sequence == self._session_usage.last_request_sequence
- )
+ is_current_request = not has_request_sequence or request_sequence == self._session_usage.last_request_sequence
if is_current_request:
model_name = usage.get("model")
- context_window_tokens = self._coerce_positive_int(
- usage.get("context_window_tokens")
- )
+ context_window_tokens = self._coerce_positive_int(usage.get("context_window_tokens"))
if model_name:
self._session_usage.model = model_name
self._session_usage.context_window_tokens = context_window_tokens
@@ -664,15 +648,9 @@ class MoviePilotAgent:
total_tokens = input_tokens + output_tokens
cache_usage_available = bool(usage.get("cache_usage_available"))
- cache_read_input_tokens = self._coerce_int(
- usage.get("cache_read_input_tokens")
- ) or 0
- cache_write_input_tokens = self._coerce_int(
- usage.get("cache_write_input_tokens")
- ) or 0
- uncached_input_tokens = self._coerce_int(
- usage.get("uncached_input_tokens")
- )
+ cache_read_input_tokens = self._coerce_int(usage.get("cache_read_input_tokens")) or 0
+ cache_write_input_tokens = self._coerce_int(usage.get("cache_write_input_tokens")) or 0
+ uncached_input_tokens = self._coerce_int(usage.get("uncached_input_tokens"))
if uncached_input_tokens is None:
uncached_input_tokens = max(
input_tokens - cache_read_input_tokens - cache_write_input_tokens,
@@ -686,8 +664,7 @@ class MoviePilotAgent:
self._session_usage.total_uncached_input_tokens += uncached_input_tokens
self._session_usage.cache_usage_available |= cache_usage_available
provider_type = _agent_provider_metric_type(
- (self._llm_provider_selection or {}).get("provider")
- or get_runtime_setting('LLM_PROVIDER')
+ (self._llm_provider_selection or {}).get("provider") or get_runtime_setting("LLM_PROVIDER")
)
if input_tokens:
record_metric(
@@ -708,9 +685,7 @@ class MoviePilotAgent:
return
self._session_usage.last_input_usage_available = input_usage_available
- self._session_usage.last_input_tokens = (
- input_tokens if input_usage_available else None
- )
+ self._session_usage.last_input_tokens = input_tokens if input_usage_available else None
self._session_usage.last_output_tokens = output_tokens
self._session_usage.last_total_tokens = total_tokens
self._session_usage.last_context_usage_ratio = usage.get("context_usage_ratio")
@@ -720,15 +695,12 @@ class MoviePilotAgent:
self._session_usage.last_uncached_input_tokens = uncached_input_tokens
self._session_usage.last_cache_hit_ratio = usage.get("cache_hit_ratio")
- estimated_input_tokens = self._coerce_int(
- usage.get("estimated_input_tokens")
- )
+ estimated_input_tokens = self._coerce_int(usage.get("estimated_input_tokens"))
if (
usage.get("request_budget_recorded") is True
and input_usage_available
and estimated_input_tokens is not None
- and estimated_input_tokens
- == self._session_usage.last_estimated_input_tokens
+ and estimated_input_tokens == self._session_usage.last_estimated_input_tokens
):
estimate_error_tokens = input_tokens - estimated_input_tokens
self._session_usage.last_actual_input_tokens = input_tokens
@@ -750,53 +722,29 @@ class MoviePilotAgent:
self._session_usage.last_request_sequence = request_sequence
estimate_available = bool(budget.get("has_estimate"))
self._session_usage.last_request_estimate_available = estimate_available
- self._session_usage.last_estimated_input_tokens = self._coerce_int(
- budget.get("estimated_input_tokens")
- )
- self._session_usage.last_estimated_message_tokens = self._coerce_int(
- budget.get("message_tokens")
- )
- self._session_usage.last_estimated_system_tokens = self._coerce_int(
- budget.get("system_tokens")
- )
- self._session_usage.last_estimated_tool_tokens = self._coerce_int(
- budget.get("tool_tokens")
- )
- self._session_usage.last_estimated_multimodal_tokens = self._coerce_int(
- budget.get("multimodal_tokens")
- )
- self._session_usage.last_estimated_input_ratio = budget.get(
- "estimated_input_ratio"
- )
+ self._session_usage.last_estimated_input_tokens = self._coerce_int(budget.get("estimated_input_tokens"))
+ self._session_usage.last_estimated_message_tokens = self._coerce_int(budget.get("message_tokens"))
+ self._session_usage.last_estimated_system_tokens = self._coerce_int(budget.get("system_tokens"))
+ self._session_usage.last_estimated_tool_tokens = self._coerce_int(budget.get("tool_tokens"))
+ self._session_usage.last_estimated_multimodal_tokens = self._coerce_int(budget.get("multimodal_tokens"))
+ self._session_usage.last_estimated_input_ratio = budget.get("estimated_input_ratio")
self._session_usage.last_estimated_remaining_input_tokens = self._coerce_int(
budget.get("estimated_remaining_input_tokens")
)
- self._session_usage.last_estimated_over_input_limit = budget.get(
- "estimated_over_input_limit"
- )
- self._session_usage.last_message_count = self._coerce_int(
- budget.get("message_count")
- ) or 0
- self._session_usage.last_tool_count = self._coerce_int(
- budget.get("tool_count")
- ) or 0
- self._session_usage.last_image_count = self._coerce_int(
- budget.get("image_count")
- ) or 0
- self._session_usage.last_unknown_multimodal_count = self._coerce_int(
- budget.get("unknown_multimodal_count")
- ) or 0
- self._session_usage.model_max_output_tokens = self._coerce_int(
- budget.get("model_max_output_tokens")
+ self._session_usage.last_estimated_over_input_limit = budget.get("estimated_over_input_limit")
+ self._session_usage.last_message_count = self._coerce_int(budget.get("message_count")) or 0
+ self._session_usage.last_tool_count = self._coerce_int(budget.get("tool_count")) or 0
+ self._session_usage.last_image_count = self._coerce_int(budget.get("image_count")) or 0
+ self._session_usage.last_unknown_multimodal_count = (
+ self._coerce_int(budget.get("unknown_multimodal_count")) or 0
)
+ self._session_usage.model_max_output_tokens = self._coerce_int(budget.get("model_max_output_tokens"))
self._session_usage.configured_output_limit_tokens = self._coerce_int(
budget.get("configured_output_limit_tokens")
)
has_model_snapshot = "model" in budget
model_name = budget.get("model")
- context_window_tokens = self._coerce_positive_int(
- budget.get("context_window_tokens")
- )
+ context_window_tokens = self._coerce_positive_int(budget.get("context_window_tokens"))
# 模型标识与窗口属于同一次最终请求,必须一起替换,不能拼接两轮状态。
if has_model_snapshot:
self._session_usage.model = model_name if model_name else None
@@ -808,27 +756,21 @@ class MoviePilotAgent:
self._session_usage.last_estimate_error_ratio = None
def get_session_status(self) -> dict[str, Any]:
- if (
- not self._session_usage.model
- and self._session_usage.last_request_sequence == 0
- ):
- self._session_usage.model = get_runtime_setting('LLM_MODEL')
- if (
- not self._session_usage.context_window_tokens
- and self._session_usage.last_request_sequence == 0
- ):
+ if not self._session_usage.model and self._session_usage.last_request_sequence == 0:
+ self._session_usage.model = get_runtime_setting("LLM_MODEL")
+ if not self._session_usage.context_window_tokens and self._session_usage.last_request_sequence == 0:
self._session_usage.context_window_tokens = (
- get_runtime_setting('LLM_MAX_CONTEXT_TOKENS') * 1000
- if get_runtime_setting('LLM_MAX_CONTEXT_TOKENS')
+ get_runtime_setting("LLM_MAX_CONTEXT_TOKENS") * 1000
+ if get_runtime_setting("LLM_MAX_CONTEXT_TOKENS")
else None
)
return self._session_usage.to_dict(self.session_id)
def _send_agent_tokens_usage_event(
- self,
- *,
- success: bool,
- error: Optional[str] = None,
+ self,
+ *,
+ success: bool,
+ error: Optional[str] = None,
) -> None:
"""
广播本次 Agent 执行的 token 聚合用量,供配额类插件异步记录。
@@ -839,9 +781,9 @@ class MoviePilotAgent:
session_id=self.session_id,
selected_provider_id=selection.get("selected_provider_id"),
selected_provider_name=selection.get("selected_provider_name"),
- provider=selection.get("provider") or get_runtime_setting('LLM_PROVIDER'),
- base_url=selection.get("base_url") or get_runtime_setting('LLM_BASE_URL'),
- model=self._session_usage.model or selection.get("model") or get_runtime_setting('LLM_MODEL'),
+ provider=selection.get("provider") or get_runtime_setting("LLM_PROVIDER"),
+ base_url=selection.get("base_url") or get_runtime_setting("LLM_BASE_URL"),
+ model=self._session_usage.model or selection.get("model") or get_runtime_setting("LLM_MODEL"),
input_tokens=self._session_usage.total_input_tokens,
output_tokens=self._session_usage.total_output_tokens,
total_tokens=self._session_usage.total_tokens,
@@ -849,19 +791,15 @@ class MoviePilotAgent:
cache_write_input_tokens=self._session_usage.total_cache_write_input_tokens,
uncached_input_tokens=self._session_usage.total_uncached_input_tokens,
cache_hit_ratio=(
- self._session_usage.total_cache_read_input_tokens
- / self._session_usage.total_input_tokens
- if self._session_usage.cache_usage_available
- and self._session_usage.total_input_tokens
+ self._session_usage.total_cache_read_input_tokens / self._session_usage.total_input_tokens
+ if self._session_usage.cache_usage_available and self._session_usage.total_input_tokens
else None
),
cache_usage_available=self._session_usage.cache_usage_available,
model_call_count=self._session_usage.model_call_count,
success=success,
error=error,
- started_at=self._agent_started_at.strftime("%Y-%m-%d %H:%M:%S")
- if self._agent_started_at
- else None,
+ started_at=self._agent_started_at.strftime("%Y-%m-%d %H:%M:%S") if self._agent_started_at else None,
finished_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
source=selection.get("source") or "agent",
)
@@ -942,8 +880,8 @@ class MoviePilotAgent:
}
def set_protected_output_callback(
- self,
- protected_output_callback: Optional[Callable[[str], Optional[bool]]],
+ self,
+ protected_output_callback: Optional[Callable[[str], Optional[bool]]],
) -> None:
"""更新仅供当前请求接收的受保护文本输出回调。"""
self.protected_output_callback = protected_output_callback
@@ -968,9 +906,9 @@ class MoviePilotAgent:
}
async def _register_secret_confirmation(
- self,
- tool: QuerySystemSettingsTool,
- arguments: Dict[str, Any],
+ self,
+ tool: MoviePilotApiTool,
+ arguments: Dict[str, Any],
) -> str:
"""校验并冻结一次待用户确认的敏感设置读取。"""
if self.has_pending_secret_confirmation():
@@ -979,7 +917,7 @@ class MoviePilotAgent:
self._pending_secret_confirmation = None
return "当前入口不支持安全交付敏感设置,未执行读取。"
- if not isinstance(tool, QuerySystemSettingsTool):
+ if not isinstance(tool, MoviePilotApiTool):
self._pending_secret_confirmation = None
return "当前工具不支持敏感设置确认。"
@@ -992,7 +930,7 @@ class MoviePilotAgent:
except Exception:
self._pending_secret_confirmation = None
return "敏感设置读取参数无效,未执行读取。"
- if validated_arguments.get("show_secrets") is not True:
+ if not requests_system_setting_secrets(validated_arguments):
self._pending_secret_confirmation = None
return "当前操作不需要敏感设置确认。"
@@ -1003,9 +941,9 @@ class MoviePilotAgent:
self._pending_secret_confirmation = None
return permission_result
- target = validated_arguments.get("setting_key") or (
- validated_arguments.get("group") or "all"
- )
+ body = validated_arguments.get("body") or {}
+ query = validated_arguments.get("query") or {}
+ target = body.get("setting_key") or query.get("setting_key") or body.get("group") or query.get("group") or "all"
confirmation_message = (
f"即将读取系统设置 {target} 的未脱敏值。"
"结果会直接发送给您,不会交给模型或写入对话历史。"
@@ -1022,9 +960,7 @@ class MoviePilotAgent:
)
self._emit_output(confirmation_message)
else:
- delivered = await self._deliver_private_channel_message(
- confirmation_message
- )
+ delivered = await self._deliver_private_channel_message(confirmation_message)
if not delivered:
self._pending_secret_confirmation = None
return "无法向当前用户建立私聊,未执行敏感设置读取。"
@@ -1062,10 +998,7 @@ class MoviePilotAgent:
),
)
except Exception as error:
- logger.error(
- f"Agent私聊投递失败: channel={self.channel}, "
- f"error_type={type(error).__name__}"
- )
+ logger.error(f"Agent私聊投递失败: channel={self.channel}, error_type={type(error).__name__}")
return False
return bool(response and response.success)
@@ -1081,9 +1014,9 @@ class MoviePilotAgent:
return await self._deliver_private_channel_message(content)
async def _deliver_protected_output_with_fallback(
- self,
- content: str,
- fallback_message: str,
+ self,
+ content: str,
+ fallback_message: str,
) -> bool:
"""受保护投递失败时,仅通过普通回复报告不含敏感值的状态。"""
delivered = await self._deliver_protected_output(content)
@@ -1095,11 +1028,11 @@ class MoviePilotAgent:
return False
async def _handle_secret_confirmation_control(
- self,
- message: str,
- images: Optional[List[str]],
- files: Optional[List[dict]],
- has_audio_input: bool,
+ self,
+ message: str,
+ images: Optional[List[str]],
+ files: Optional[List[dict]],
+ has_audio_input: bool,
) -> Optional[str]:
"""在进入模型前消费当前会话的确认或取消文本。"""
command = str(message or "").strip()
@@ -1135,7 +1068,7 @@ class MoviePilotAgent:
permission_result = await pending.tool._check_permission()
if permission_result:
return permission_result
- return await pending.tool._run_confirmed(**pending.arguments)
+ return await pending.tool.run_with_timeout(**pending.arguments)
policy = AgentPolicyMiddleware(
context=self._build_policy_context(),
@@ -1155,11 +1088,7 @@ class MoviePilotAgent:
message_text,
)
return message_text
- fallback_message = (
- "敏感设置读取已完成,但结果投递失败,请重新发起。"
- if executed
- else result
- )
+ fallback_message = "敏感设置读取已完成,但结果投递失败,请重新发起。" if executed else result
delivered = await self._deliver_protected_output_with_fallback(
result,
fallback_message,
@@ -1187,8 +1116,7 @@ class MoviePilotAgent:
principal_type = PrincipalType.HUMAN
auth_source = (
AuthSource.WEB_SESSION
- if self.channel
- in {NotificationChannel.Web.value, NotificationChannel.WebAgent.value}
+ if self.channel in {NotificationChannel.Web.value, NotificationChannel.WebAgent.value}
else AuthSource.CHANNEL
)
return ToolPolicyContext(
@@ -1214,14 +1142,12 @@ class MoviePilotAgent:
if self.is_background:
return False
# 啰嗦模式下始终需要流式输出来捕获工具调用前的 Agent 文字
- if get_runtime_setting('AI_AGENT_VERBOSE'):
+ if get_runtime_setting("AI_AGENT_VERBOSE"):
return True
try:
channel_enum = NotificationChannel(self.channel)
- return ChannelCapabilityManager.supports_capability(
- channel_enum, ChannelCapability.MESSAGE_EDITING
- )
- except (ValueError, KeyError):
+ return ChannelCapabilityManager.supports_capability(channel_enum, ChannelCapability.MESSAGE_EDITING)
+ except ValueError, KeyError:
return False
@staticmethod
@@ -1263,16 +1189,16 @@ class MoviePilotAgent:
return self._llm_runtime_config
event_data = AgentLLMProviderEventData(
- provider=get_runtime_setting('LLM_PROVIDER'),
- model=get_runtime_setting('LLM_MODEL'),
- api_key=get_runtime_setting('LLM_API_KEY'),
- base_url=get_runtime_setting('LLM_BASE_URL'),
- base_url_preset=get_runtime_setting('LLM_BASE_URL_PRESET'),
- user_agent=get_runtime_setting('LLM_USER_AGENT'),
- use_proxy=get_runtime_setting('LLM_USE_PROXY'),
- thinking_level=get_runtime_setting('LLM_THINKING_LEVEL'),
- api_protocol=get_runtime_setting('LLM_API_PROTOCOL'),
- web_search_mode=get_runtime_setting('LLM_WEB_SEARCH_MODE'),
+ provider=get_runtime_setting("LLM_PROVIDER"),
+ model=get_runtime_setting("LLM_MODEL"),
+ api_key=get_runtime_setting("LLM_API_KEY"),
+ base_url=get_runtime_setting("LLM_BASE_URL"),
+ base_url_preset=get_runtime_setting("LLM_BASE_URL_PRESET"),
+ user_agent=get_runtime_setting("LLM_USER_AGENT"),
+ use_proxy=get_runtime_setting("LLM_USE_PROXY"),
+ thinking_level=get_runtime_setting("LLM_THINKING_LEVEL"),
+ api_protocol=get_runtime_setting("LLM_API_PROTOCOL"),
+ web_search_mode=get_runtime_setting("LLM_WEB_SEARCH_MODE"),
)
selected_event = await eventmanager.async_send_event(
ChainEventType.AgentLLMProvider,
@@ -1280,48 +1206,37 @@ class MoviePilotAgent:
)
resolved_data = selected_event.event_data if selected_event else event_data
- provider = (
- self._clean_optional_text(self._get_event_value(resolved_data, "provider"))
- or get_runtime_setting('LLM_PROVIDER')
+ provider = self._clean_optional_text(self._get_event_value(resolved_data, "provider")) or get_runtime_setting(
+ "LLM_PROVIDER"
)
- model = (
- self._clean_optional_text(self._get_event_value(resolved_data, "model"))
- or get_runtime_setting('LLM_MODEL')
+ model = self._clean_optional_text(self._get_event_value(resolved_data, "model")) or get_runtime_setting(
+ "LLM_MODEL"
)
- api_key = (
- self._clean_optional_text(self._get_event_value(resolved_data, "api_key"))
- or get_runtime_setting('LLM_API_KEY')
+ api_key = self._clean_optional_text(self._get_event_value(resolved_data, "api_key")) or get_runtime_setting(
+ "LLM_API_KEY"
)
- base_url = (
- self._clean_optional_text(self._get_event_value(resolved_data, "base_url"))
- or get_runtime_setting('LLM_BASE_URL')
- )
- base_url_preset = (
- self._clean_optional_text(self._get_event_value(resolved_data, "base_url_preset"))
- or get_runtime_setting('LLM_BASE_URL_PRESET')
- )
- user_agent = (
- self._clean_optional_text(self._get_event_value(resolved_data, "user_agent"))
- or get_runtime_setting('LLM_USER_AGENT')
+ base_url = self._clean_optional_text(self._get_event_value(resolved_data, "base_url")) or get_runtime_setting(
+ "LLM_BASE_URL"
)
+ base_url_preset = self._clean_optional_text(
+ self._get_event_value(resolved_data, "base_url_preset")
+ ) or get_runtime_setting("LLM_BASE_URL_PRESET")
+ user_agent = self._clean_optional_text(
+ self._get_event_value(resolved_data, "user_agent")
+ ) or get_runtime_setting("LLM_USER_AGENT")
use_proxy = self._get_event_value(resolved_data, "use_proxy")
if use_proxy is None:
- use_proxy = get_runtime_setting('LLM_USE_PROXY')
- thinking_level = (
- self._clean_optional_text(
- self._get_event_value(resolved_data, "thinking_level")
- )
- or get_runtime_setting('LLM_THINKING_LEVEL')
- )
+ use_proxy = get_runtime_setting("LLM_USE_PROXY")
+ thinking_level = self._clean_optional_text(
+ self._get_event_value(resolved_data, "thinking_level")
+ ) or get_runtime_setting("LLM_THINKING_LEVEL")
api_protocol = self._clean_optional_text(
self._get_event_value(resolved_data, "api_protocol")
- ) or get_runtime_setting('LLM_API_PROTOCOL')
+ ) or get_runtime_setting("LLM_API_PROTOCOL")
web_search_mode = self._clean_optional_text(
self._get_event_value(resolved_data, "web_search_mode")
- ) or get_runtime_setting('LLM_WEB_SEARCH_MODE')
- selected_provider_id = self._clean_optional_text(
- self._get_event_value(resolved_data, "selected_provider_id")
- )
+ ) or get_runtime_setting("LLM_WEB_SEARCH_MODE")
+ selected_provider_id = self._clean_optional_text(self._get_event_value(resolved_data, "selected_provider_id"))
selected_provider_name = self._clean_optional_text(
self._get_event_value(resolved_data, "selected_provider_name")
)
@@ -1388,10 +1303,7 @@ class MoviePilotAgent:
@classmethod
def _messages_have_image_input(cls, messages: List[BaseMessage]) -> bool:
"""检查本轮提交给模型的消息列表中是否包含图片输入。"""
- return any(
- cls._has_image_input_content(getattr(message, "content", None))
- for message in messages or []
- )
+ return any(cls._has_image_input_content(getattr(message, "content", None)) for message in messages or [])
@staticmethod
def _exception_detail_text(error: Exception) -> str:
@@ -1408,7 +1320,7 @@ class MoviePilotAgent:
if body is not None:
try:
parts.append(json.dumps(body, ensure_ascii=False))
- except (TypeError, ValueError):
+ except TypeError, ValueError:
parts.append(str(body))
return " ".join(part for part in parts if part)
@@ -1461,8 +1373,8 @@ class MoviePilotAgent:
清理执行错误中的密钥和尾部长说明,避免把敏感字段或 SDK 调参文档直接发给用户。
"""
sanitized = re.sub(r"\s+", " ", str(message or "")).strip()
- if get_runtime_setting('LLM_API_KEY'):
- sanitized = sanitized.replace(get_runtime_setting('LLM_API_KEY'), "***")
+ if get_runtime_setting("LLM_API_KEY"):
+ sanitized = sanitized.replace(get_runtime_setting("LLM_API_KEY"), "***")
sanitized = re.sub(
r"(?i)(api[_-]?key\s*[:=]\s*)([^\s,;]+)",
r"\1***",
@@ -1474,10 +1386,10 @@ class MoviePilotAgent:
sanitized,
)
for marker in (
- " Tune or disable via ",
- " See also ",
- " Traceback ",
- " - Traceback ",
+ " Tune or disable via ",
+ " See also ",
+ " Traceback ",
+ " - Traceback ",
):
if marker in sanitized:
sanitized = sanitized.split(marker, 1)[0].strip()
@@ -1620,11 +1532,7 @@ class MoviePilotAgent:
def _public_runtime_config_signature(runtime_config: Dict[str, Any]) -> tuple:
"""生成不包含密钥明文的 LLM 运行时签名。"""
api_key = runtime_config.get("api_key") or ""
- api_key_digest = (
- hashlib.sha256(str(api_key).encode("utf-8")).hexdigest()[:12]
- if api_key
- else ""
- )
+ api_key_digest = hashlib.sha256(str(api_key).encode("utf-8")).hexdigest()[:12] if api_key else ""
return (
runtime_config.get("provider"),
runtime_config.get("model"),
@@ -1656,11 +1564,11 @@ class MoviePilotAgent:
bool(self._tool_context.get("is_admin")),
self.has_message_context,
self.is_background,
- get_runtime_setting('AI_AGENT_VERBOSE'),
- get_runtime_setting('LLM_TEMPERATURE'),
- get_runtime_setting('LLM_MAX_CONTEXT_TOKENS'),
- get_runtime_setting('LLM_MAX_TOOLS'),
- get_runtime_setting('LLM_MAX_ITERATIONS'),
+ get_runtime_setting("AI_AGENT_VERBOSE"),
+ get_runtime_setting("LLM_TEMPERATURE"),
+ get_runtime_setting("LLM_MAX_CONTEXT_TOKENS"),
+ get_runtime_setting("LLM_MAX_TOOLS"),
+ get_runtime_setting("LLM_MAX_ITERATIONS"),
self._public_runtime_config_signature(runtime_config),
agent_runtime_manager.current_signature(),
agent_mcp_manager.config_signature(),
@@ -1681,16 +1589,10 @@ class MoviePilotAgent:
return get_tool_factory().catalog_factory_revision()
- def _get_cached_agent(
- self, signature: tuple[Any, ...], streaming: bool
- ) -> Optional[Any]:
+ def _get_cached_agent(self, signature: tuple[Any, ...], streaming: bool) -> Optional[Any]:
"""按签名读取当前会话已编译的 Agent 图。"""
bundle = self._compiled_agent_bundle
- if (
- bundle
- and bundle.streaming == streaming
- and bundle.signature == signature
- ):
+ if bundle and bundle.streaming == streaming and bundle.signature == signature:
return bundle.agent
return None
@@ -1761,16 +1663,11 @@ class MoviePilotAgent:
previous_middlewares = tuple(
middleware
for middleware in self._subagent_middlewares
- if not any(
- middleware is replacement
- for replacement in subagent_middlewares
- )
+ if not any(middleware is replacement for replacement in subagent_middlewares)
)
if self._shutdown_started:
self._seal_subagent_middleware_instances(subagent_middlewares)
- pending_middlewares = await self._close_subagent_middleware_instances(
- previous_middlewares
- )
+ pending_middlewares = await self._close_subagent_middleware_instances(previous_middlewares)
self._compiled_agent_bundle = _CompiledAgentBundle(
signature=signature,
agent=agent,
@@ -1792,9 +1689,7 @@ class MoviePilotAgent:
"""使当前图失效,未收敛的子代理控制器继续由 Agent 持有。"""
subagent_middlewares = self._subagent_middlewares
self._compiled_agent_bundle = None
- pending_middlewares = await self._close_subagent_middleware_instances(
- subagent_middlewares
- )
+ pending_middlewares = await self._close_subagent_middleware_instances(subagent_middlewares)
self._subagent_middlewares = pending_middlewares
return not pending_middlewares
@@ -1881,9 +1776,8 @@ class MoviePilotAgent:
and cached_bundle.plugin_revision == plugin_revision
and cached_bundle.mcp_config_signature == mcp_config_signature
and cached_bundle.catalog_checked_at is not None
- and (
- datetime.now() - cached_bundle.catalog_checked_at
- ).total_seconds() < self.TOOL_CATALOG_REFRESH_SECONDS
+ and (datetime.now() - cached_bundle.catalog_checked_at).total_seconds()
+ < self.TOOL_CATALOG_REFRESH_SECONDS
)
if catalog_is_fresh:
bundle_signature = await self._agent_bundle_signature(
@@ -1894,9 +1788,7 @@ class MoviePilotAgent:
cached_agent = self._get_cached_agent(bundle_signature, streaming)
self._last_agent_cache_hit = bool(cached_agent)
if cached_agent:
- logger.debug(
- f"复用会话内 Agent 图: session_id={self.session_id}"
- )
+ logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
return cached_agent
web_search_resolution = ServerToolRegistry.resolve_web_search(
provider=str(runtime_config.get("provider") or ""),
@@ -1905,32 +1797,25 @@ class MoviePilotAgent:
api_protocol=runtime_config.get("api_protocol"),
base_url=runtime_config.get("base_url"),
)
- base_tool_catalog, base_subagent_catalog = (
- self._initialize_local_tool_catalogs()
- )
+ base_tool_catalog, base_subagent_catalog = self._initialize_local_tool_catalogs()
mcp_specs = await agent_mcp_manager.list_enabled_tool_specs()
local_tools = self._filter_local_web_search_tools(
base_tool_catalog.tools,
enabled=web_search_resolution.use_local_web_search,
)
mcp_tools = await self._initialize_mcp_tools(specs=mcp_specs)
- tools = [*local_tools, *select_legacy_mcp_tools(mcp_tools)]
+ tools = [*local_tools, *mcp_tools]
local_subagent_tools = self._filter_local_web_search_tools(
base_subagent_catalog.tools,
enabled=web_search_resolution.use_local_web_search,
)
- subagent_mcp_tools = await self._initialize_subagent_mcp_tools(
- specs=mcp_specs
- )
+ subagent_mcp_tools = await self._initialize_subagent_mcp_tools(specs=mcp_specs)
subagent_catalog = ToolCatalogSnapshot.from_tools(
[*local_subagent_tools, *subagent_mcp_tools],
plugin_revision=base_subagent_catalog.plugin_revision,
factory_revision=base_subagent_catalog.factory_revision,
- )
- subagent_tools = [
- *local_subagent_tools,
- *select_legacy_mcp_tools(subagent_mcp_tools),
- ]
+ ).require_unique()
+ subagent_tools = [*local_subagent_tools, *subagent_mcp_tools]
# 系统提示词
system_prompt = prompt_manager.get_agent_prompt(channel=self.channel)
@@ -1941,14 +1826,10 @@ class MoviePilotAgent:
server_tools = LLMHelper.get_server_tools(agent_model)
# 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
- non_streaming_model = (
- agent_model
- if not streaming
- else await self._initialize_llm(streaming=False)
- )
+ non_streaming_model = agent_model if not streaming else await self._initialize_llm(streaming=False)
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
- bundled_skills_dir=str(get_runtime_setting('ROOT_PATH') / "skills"),
+ bundled_skills_dir=str(get_runtime_setting("ROOT_PATH") / "skills"),
stream_handler=self.stream_handler,
)
skill_tools = list(getattr(skills_middleware, "tools", []) or [])
@@ -1959,9 +1840,7 @@ class MoviePilotAgent:
activity_dir=str(agent_runtime_manager.activity_dir),
stream_handler=self.stream_handler,
)
- activity_log_tools = list(
- getattr(activity_log_middleware, "tools", []) or []
- )
+ activity_log_tools = list(getattr(activity_log_middleware, "tools", []) or [])
policy_context = self._build_policy_context()
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
@@ -1983,7 +1862,7 @@ class MoviePilotAgent:
],
plugin_revision=base_tool_catalog.plugin_revision,
factory_revision=base_tool_catalog.factory_revision,
- )
+ ).require_unique()
bundle_signature = await self._agent_bundle_signature(
streaming,
tool_catalog=tool_catalog,
@@ -1994,9 +1873,7 @@ class MoviePilotAgent:
if cached_agent:
# 签名相同表示已编译图中的精确工具实例仍有效;新建快照仅用于复核。
cached_bundle.catalog_checked_at = datetime.now()
- pending_middlewares = await self._close_subagent_middleware_instances(
- temporary_subagent_middlewares
- )
+ pending_middlewares = await self._close_subagent_middleware_instances(temporary_subagent_middlewares)
self._subagent_middlewares = self._merge_subagent_middleware_owners(
self._subagent_middlewares,
pending_middlewares,
@@ -2004,24 +1881,19 @@ class MoviePilotAgent:
temporary_subagent_middlewares = ()
logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
return cached_agent
- max_tools = get_runtime_setting('LLM_MAX_TOOLS')
+ max_tools = get_runtime_setting("LLM_MAX_TOOLS")
from app.agent.loader import get_tool_factory
- always_include_tools = (
- get_tool_factory().get_tool_selector_always_include_names(tools)
- )
+ always_include_tools = get_tool_factory().get_tool_selector_always_include_names(tools)
if subagent_task_tools:
always_include_tools.extend(
tool.name
for tool in subagent_task_tools
- if getattr(tool, "name", None)
- in {SUBAGENT_TASK_TOOL_NAME, SUBAGENT_CONTROL_TOOL_NAME}
+ if getattr(tool, "name", None) in {SUBAGENT_TASK_TOOL_NAME, SUBAGENT_CONTROL_TOOL_NAME}
)
if skill_tools:
always_include_tools.extend(
- tool.name
- for tool in skill_tools
- if getattr(tool, "name", None) == SKILL_TOOL_NAME
+ tool.name for tool in skill_tools if getattr(tool, "name", None) == SKILL_TOOL_NAME
)
if activity_log_tools:
always_include_tools.extend(
@@ -2118,18 +1990,14 @@ class MoviePilotAgent:
temporary_subagent_middlewares = ()
return cached_agent
except asyncio.CancelledError:
- pending_middlewares = await self._close_subagent_middleware_instances(
- temporary_subagent_middlewares
- )
+ pending_middlewares = await self._close_subagent_middleware_instances(temporary_subagent_middlewares)
self._subagent_middlewares = self._merge_subagent_middleware_owners(
self._subagent_middlewares,
pending_middlewares,
)
raise
except Exception as e:
- pending_middlewares = await self._close_subagent_middleware_instances(
- temporary_subagent_middlewares
- )
+ pending_middlewares = await self._close_subagent_middleware_instances(temporary_subagent_middlewares)
self._subagent_middlewares = self._merge_subagent_middleware_owners(
self._subagent_middlewares,
pending_middlewares,
@@ -2138,11 +2006,11 @@ class MoviePilotAgent:
raise
async def process(
- self,
- message: str,
- images: Optional[List[str]] = None,
- files: Optional[List[dict[str, Any]]] = None,
- has_audio_input: bool = False,
+ self,
+ message: str,
+ images: Optional[List[str]] = None,
+ files: Optional[List[dict[str, Any]]] = None,
+ has_audio_input: bool = False,
) -> str:
"""
处理用户消息,流式推理并返回 Agent 回复
@@ -2155,11 +2023,7 @@ class MoviePilotAgent:
f"images={len(images) if images else 0}, files={len(files) if files else 0}, "
f"audio_input={has_audio_input}"
)
- self._refresh_tool_context(
- await self._build_tool_context(
- should_dispatch_reply=self.should_dispatch_reply
- )
- )
+ self._refresh_tool_context(await self._build_tool_context(should_dispatch_reply=self.should_dispatch_reply))
self._streamed_output = ""
confirmation_result = await self._handle_secret_confirmation_control(
@@ -2186,10 +2050,7 @@ class MoviePilotAgent:
"mode": "voice" if has_audio_input else "text",
"transcribed": bool(has_audio_input),
},
- "images": [
- {"index": index + 1, "type": "image"}
- for index, _ in enumerate(images or [])
- ],
+ "images": [{"index": index + 1, "type": "image"} for index, _ in enumerate(images or [])],
"files": files or [],
}
content = [
@@ -2227,9 +2088,7 @@ class MoviePilotAgent:
error_message = f"处理消息时发生错误: {str(e)}"
logger.error(error_message)
if not user_display_saved:
- await self._save_display_history_messages(
- [self.build_display_message(role="user", content=message)]
- )
+ await self._save_display_history_messages([self.build_display_message(role="user", content=message)])
if not self.should_dispatch_reply:
raise
await self.send_agent_message(error_message)
@@ -2247,10 +2106,10 @@ class MoviePilotAgent:
return fallback
def _build_input_display_attachments(
- self,
- images: Optional[List[str]] = None,
- files: Optional[List[dict]] = None,
- has_audio_input: bool = False,
+ self,
+ images: Optional[List[str]] = None,
+ files: Optional[List[dict]] = None,
+ has_audio_input: bool = False,
) -> List[dict]:
"""
构造用户输入附件的展示记录。
@@ -2284,9 +2143,7 @@ class MoviePilotAgent:
return attachments
@staticmethod
- async def _stream_agent_tokens(
- agent, messages: dict, config: dict, on_token: Callable[[str], None]
- ):
+ async def _stream_agent_tokens(agent, messages: dict, config: dict, on_token: Callable[[str], None]):
"""
流式运行智能体,过滤工具调用token和思考内容,将模型生成的内容通过回调输出。
:param agent: LangGraph Agent 实例
@@ -2297,11 +2154,11 @@ class MoviePilotAgent:
stripper = _ThinkTagStripper()
async for chunk in agent.astream(
- messages,
- stream_mode="messages",
- config=config,
- subgraphs=False,
- version="v2",
+ messages,
+ stream_mode="messages",
+ config=config,
+ subgraphs=False,
+ version="v2",
):
if chunk["type"] == "messages":
token, metadata = chunk["data"]
@@ -2359,16 +2216,10 @@ class MoviePilotAgent:
# 创建智能体(根据是否流式传入不同 LLM)
agent = await self._create_agent(streaming=use_streaming)
- input_messages = (
- self._latest_turn_messages(messages)
- if self._last_agent_cache_hit
- else messages
- )
+ input_messages = self._latest_turn_messages(messages) if self._last_agent_cache_hit else messages
if use_streaming:
- self.stream_handler.set_dispatch_policy(
- allow_dispatch_without_context=self.should_dispatch_reply
- )
+ self.stream_handler.set_dispatch_policy(allow_dispatch_without_context=self.should_dispatch_reply)
# 流式模式:渠道支持消息编辑,启动流式输出实时推送 token
await self.stream_handler.start_streaming(
channel=self.channel,
@@ -2405,17 +2256,11 @@ class MoviePilotAgent:
remaining_text = await self.stream_handler.take()
if remaining_text:
unsent_text = remaining_text
- if self._streamed_output and remaining_text.startswith(
- self._streamed_output
- ):
- unsent_text = remaining_text[len(self._streamed_output):]
+ if self._streamed_output and remaining_text.startswith(self._streamed_output):
+ unsent_text = remaining_text[len(self._streamed_output) :]
if unsent_text:
self._emit_output(unsent_text)
- if (
- remaining_text
- and self.should_dispatch_reply
- and not self._tool_context.get("user_reply_sent")
- ):
+ if remaining_text and self.should_dispatch_reply and not self._tool_context.get("user_reply_sent"):
await self.send_agent_message(remaining_text)
else:
@@ -2426,9 +2271,7 @@ class MoviePilotAgent:
)
# 从最终状态中提取最后一条AI回复内容
- final_messages = agent.get_state(agent_config).values.get(
- "messages", []
- )
+ final_messages = agent.get_state(agent_config).values.get("messages", [])
final_text = ""
for msg in reversed(final_messages):
if hasattr(msg, "type") and msg.type == "ai" and msg.content:
@@ -2436,34 +2279,24 @@ class MoviePilotAgent:
text = LLMHelper.extract_text_content(msg.content)
if text:
# 过滤掉包含在 标签中的内容
- text = re.sub(
- r".*?(?:|$)", "", text, flags=re.DOTALL
- )
+ text = re.sub(r".*?(?:|$)", "", text, flags=re.DOTALL)
final_text = text.strip()
break
if final_text and not self._streamed_output:
self._emit_output(final_text)
- if (
- final_text
- and self.should_dispatch_reply
- and not self._tool_context.get("user_reply_sent")
- ):
+ if final_text and self.should_dispatch_reply and not self._tool_context.get("user_reply_sent"):
if self.is_background:
# 后台任务发送最终回复时统一带标题
- await self.send_agent_message(
- final_text, title="MoviePilot助手"
- )
+ await self.send_agent_message(final_text, title="MoviePilot助手")
else:
# 非流式渠道:发送最终回复
await self.send_agent_message(final_text)
display_text = self._streamed_output
if not display_text:
- final_messages = agent.get_state(agent_config).values.get(
- "messages", []
- )
+ final_messages = agent.get_state(agent_config).values.get("messages", [])
for msg in reversed(final_messages):
if hasattr(msg, "type") and msg.type == "ai" and msg.content:
display_text = LLMHelper.extract_text_content(msg.content).strip()
@@ -2487,9 +2320,7 @@ class MoviePilotAgent:
await self._invalidate_cached_agent()
execution_error = str(e)
if self._messages_have_image_input(messages) and self._is_unsupported_image_input_error(e):
- logger.warning(
- f"当前模型不支持图片输入,已向用户发送友好提示: {e}"
- )
+ logger.warning(f"当前模型不支持图片输入,已向用户发送友好提示: {e}")
await self._dispatch_execution_notice(UNSUPPORTED_IMAGE_INPUT_MESSAGE)
return UNSUPPORTED_IMAGE_INPUT_MESSAGE, {}
logger.error(f"Agent执行失败: {e} - {traceback.format_exc()}")
@@ -2502,7 +2333,7 @@ class MoviePilotAgent:
"agent.provider.duration",
time.perf_counter() - metric_started_at,
provider_type=_agent_provider_metric_type(
- selection.get("provider") or get_runtime_setting('LLM_PROVIDER')
+ selection.get("provider") or get_runtime_setting("LLM_PROVIDER")
),
outcome="success" if execution_success else "error",
)
@@ -2519,12 +2350,7 @@ class MoviePilotAgent:
发送 Agent 消息;后台任务不绑定原渠道,交由通知链广播。
"""
broadcast = self.is_background
- rich_message = (
- message
- if not broadcast
- and self.channel == NotificationChannel.Telegram.value
- else None
- )
+ rich_message = message if not broadcast and self.channel == NotificationChannel.Telegram.value else None
await self._save_assistant_display_message_once(message)
await AgentChain().async_post_message(
Message(
@@ -2532,7 +2358,7 @@ class MoviePilotAgent:
source=None if broadcast else self.source,
mtype=MessageType.Agent,
userid=None if broadcast else self.user_id,
- username=self.username or (get_runtime_setting('SUPERUSER') if broadcast else None),
+ username=self.username or (get_runtime_setting("SUPERUSER") if broadcast else None),
original_message_id=None if broadcast else self.original_message_id,
original_chat_id=None if broadcast else self.original_chat_id,
title=title,
@@ -2548,9 +2374,7 @@ class MoviePilotAgent:
"""
self.begin_shutdown()
if not await self._invalidate_cached_agent():
- logger.error(
- f"MoviePilot智能体仍有子代理 owner 未收敛: session_id={self.session_id}"
- )
+ logger.error(f"MoviePilot智能体仍有子代理 owner 未收敛: session_id={self.session_id}")
return False
self._pending_secret_confirmation = None
self.protected_output_callback = None
diff --git a/app/agent/policy/__init__.py b/app/agent/policy/__init__.py
index a87cb9e9f..0b3146c6e 100644
--- a/app/agent/policy/__init__.py
+++ b/app/agent/policy/__init__.py
@@ -3,7 +3,6 @@
from importlib import import_module
from typing import Any
-
_EXPORT_MODULES = {
"ActionEffect": "app.agent.policy.contracts",
"ActionPolicy": "app.agent.policy.contracts",
@@ -23,11 +22,20 @@ _EXPORT_MODULES = {
"ToolOrigin": "app.agent.policy.contracts",
"ToolPolicyContext": "app.agent.policy.contracts",
"ToolRevision": "app.agent.policy.contracts",
+ "ApiOperationRoute": "app.agent.policy.api",
+ "ApiOperationSpec": "app.agent.policy.api",
+ "API_OPERATION_BY_ID": "app.agent.policy.api",
+ "API_OPERATION_ROUTES": "app.agent.policy.api",
+ "API_OPERATION_SPECS": "app.agent.policy.api",
+ "list_api_operation_ids": "app.agent.policy.api",
+ "resolve_api_operation": "app.agent.policy.api",
+ "resolve_api_route": "app.agent.policy.api",
"AgentToolPolicyOrchestrator": "app.agent.policy.orchestrator",
"DEFAULT_TOOL_POLICY_ORCHESTRATOR": "app.agent.policy.orchestrator",
"call_policy_hook": "app.agent.policy.orchestrator",
"DEFAULT_TOOL_POLICY_REGISTRY": "app.agent.policy.registry",
"ToolPolicyRegistry": "app.agent.policy.registry",
+ "requests_system_setting_secrets": "app.agent.policy.registry",
"REDACTED_VALUE": "app.agent.policy.sanitizer",
"sanitize_for_host": "app.agent.policy.sanitizer",
"stable_type_name": "app.agent.policy.sanitizer",
diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py
new file mode 100644
index 000000000..4f8b0df80
--- /dev/null
+++ b/app/agent/policy/api.py
@@ -0,0 +1,285 @@
+"""Agent 结构化 MoviePilot API 操作与固定路由注册表。"""
+
+from dataclasses import dataclass
+from typing import Optional
+
+from app.agent.policy.contracts import (
+ ActionEffect,
+ ConfirmationMode,
+ PrincipalRole,
+ RecoveryMode,
+ ResultSensitivity,
+)
+
+
+@dataclass(frozen=True)
+class ApiOperationSpec:
+ """描述一个可由 Agent API 网关调用的稳定业务操作。"""
+
+ operation_id: str
+ effect: ActionEffect
+ required_role: PrincipalRole
+ confirmation: ConfirmationMode
+ recovery: RecoveryMode
+ result_sensitivity: ResultSensitivity
+
+
+@dataclass(frozen=True)
+class ApiOperationRoute:
+ """描述一个固定 API 路由,禁止调用方注入任意 URL 或 HTTP 方法。"""
+
+ method: str
+ path: str
+
+
+_ADMIN = PrincipalRole.SYSTEM_ADMIN
+_CONFIRM = ConfirmationMode.REQUIRED
+_IDEMPOTENT = RecoveryMode.IDEMPOTENT
+_DELETE_RECOVERABLE = RecoveryMode.RECOVERABLE_DELETE
+
+
+def _spec(
+ operation_id: str,
+ *,
+ effect: ActionEffect = ActionEffect.SAFE_READ,
+ required_role: PrincipalRole = PrincipalRole.USER,
+ confirmation: ConfirmationMode = ConfirmationMode.NONE,
+ recovery: RecoveryMode = RecoveryMode.NONE,
+ result_sensitivity: ResultSensitivity = ResultSensitivity.NORMAL,
+) -> ApiOperationSpec:
+ """构造不可变 API 操作描述。"""
+ return ApiOperationSpec(
+ operation_id=operation_id,
+ effect=effect,
+ required_role=required_role,
+ confirmation=confirmation,
+ recovery=recovery,
+ result_sensitivity=result_sensitivity,
+ )
+
+
+def _write(
+ operation_id: str,
+ *,
+ effect: ActionEffect = ActionEffect.REVERSIBLE_WRITE,
+ recovery: RecoveryMode = _IDEMPOTENT,
+ sensitivity: ResultSensitivity = ResultSensitivity.NORMAL,
+) -> ApiOperationSpec:
+ """构造管理员确认写操作。"""
+ return _spec(
+ operation_id,
+ effect=effect,
+ required_role=_ADMIN,
+ confirmation=_CONFIRM,
+ recovery=recovery,
+ result_sensitivity=sensitivity,
+ )
+
+
+def _admin_read(
+ operation_id: str,
+ *,
+ sensitivity: ResultSensitivity = ResultSensitivity.NORMAL,
+) -> ApiOperationSpec:
+ """构造管理员读取操作。"""
+ return _spec(operation_id, required_role=_ADMIN, result_sensitivity=sensitivity)
+
+
+API_FIRST_BATCH_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
+ _spec("media.search"),
+ _spec("media.person.search"),
+ _spec("media.person.credits"),
+ _spec("media.recognize"),
+ _spec("media.scrape", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, required_role=_ADMIN, confirmation=_CONFIRM),
+ _spec("media.episode_schedule"),
+ _spec("media.detail"),
+ _write("subscription.add"),
+ _write("subscription.update"),
+ _spec("subscription.search"),
+ _spec("subscription.list"),
+ _spec("subscription.shares"),
+ _spec("subscription.popular"),
+ _spec("subscription.history"),
+ _write("subscription.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
+ _spec("download.add", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, confirmation=_CONFIRM, recovery=_IDEMPOTENT),
+ _write("download.history.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
+ _write("transfer.history.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
+ _write("site.update"),
+ _admin_read("site.list"),
+ _admin_read("site.userdata", sensitivity=ResultSensitivity.PRIVATE),
+ _spec("site.test", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, required_role=_ADMIN, confirmation=_CONFIRM),
+ _write("site.cookie.update", sensitivity=ResultSensitivity.PRIVATE),
+ _spec("recommendation.list"),
+ _admin_read("library.exists"),
+ _admin_read("storage.settings"),
+ _admin_read("storage.list", sensitivity=ResultSensitivity.PRIVATE),
+ _admin_read("transfer.history", sensitivity=ResultSensitivity.PRIVATE),
+ _spec(
+ "transfer.file",
+ effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
+ required_role=_ADMIN,
+ confirmation=_CONFIRM,
+ recovery=RecoveryMode.RECONCILE,
+ ),
+ _admin_read("scheduler.list"),
+ _spec(
+ "scheduler.run",
+ effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
+ required_role=_ADMIN,
+ confirmation=_CONFIRM,
+ recovery=RecoveryMode.RECONCILE,
+ ),
+ _admin_read("workflow.list"),
+ _spec(
+ "workflow.run",
+ effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
+ required_role=_ADMIN,
+ confirmation=_CONFIRM,
+ recovery=RecoveryMode.RECONCILE,
+ ),
+ _admin_read("plugin.installed"),
+ _admin_read("plugin.market"),
+ _admin_read("plugin.capabilities"),
+ _admin_read("plugin.config.get", sensitivity=ResultSensitivity.PRIVATE),
+ _write("plugin.config.update", sensitivity=ResultSensitivity.PRIVATE),
+ _spec(
+ "plugin.reload",
+ effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
+ required_role=_ADMIN,
+ confirmation=_CONFIRM,
+ recovery=RecoveryMode.RECONCILE,
+ ),
+ _spec(
+ "plugin.install",
+ effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
+ required_role=_ADMIN,
+ confirmation=_CONFIRM,
+ recovery=RecoveryMode.RECONCILE,
+ ),
+ _write("plugin.uninstall", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
+ _admin_read("slash.list"),
+ _admin_read("config.identifiers.get"),
+ _write("config.identifiers.update"),
+)
+
+
+API_PARITY_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
+ _spec("search.torrents"),
+ _spec("search.results"),
+ _admin_read("filter.builtin"),
+ _admin_read("filter.custom"),
+ _admin_read("filter.groups"),
+ _write("filter.custom.add", recovery=RecoveryMode.TRANSACTION),
+ _write("filter.custom.update", recovery=RecoveryMode.TRANSACTION),
+ _write("filter.custom.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=RecoveryMode.TRANSACTION),
+ _write("filter.group.add", recovery=RecoveryMode.TRANSACTION),
+ _write("filter.group.update", recovery=RecoveryMode.TRANSACTION),
+ _write("filter.group.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=RecoveryMode.TRANSACTION),
+ _admin_read("plugin.data", sensitivity=ResultSensitivity.PRIVATE),
+ _admin_read("config.system.get", sensitivity=ResultSensitivity.PRIVATE),
+ _write("config.system.update", recovery=RecoveryMode.TRANSACTION, sensitivity=ResultSensitivity.PRIVATE),
+ _spec(
+ "slash.run",
+ effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
+ required_role=_ADMIN,
+ confirmation=_CONFIRM,
+ recovery=RecoveryMode.RECONCILE,
+ ),
+)
+
+
+API_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (*API_FIRST_BATCH_OPERATION_SPECS, *API_PARITY_OPERATION_SPECS)
+API_OPERATION_BY_ID = {spec.operation_id: spec for spec in API_OPERATION_SPECS}
+
+
+# 所有路径均由宿主注册,调用方只能传路径参数、查询参数和 JSON body。
+API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = {
+ "media.search": ApiOperationRoute("GET", "/api/v1/media/search"),
+ "media.person.search": ApiOperationRoute("GET", "/api/v1/media/search"),
+ "media.person.credits": ApiOperationRoute("GET", "/api/v1/{source}/person/credits/{person_id}"),
+ "media.recognize": ApiOperationRoute("GET", "/api/v1/media/recognize"),
+ "media.scrape": ApiOperationRoute("POST", "/api/v1/media/scrape/{storage}"),
+ "media.episode_schedule": ApiOperationRoute("GET", "/api/v1/tmdb/{tmdbid}/{season}"),
+ "media.detail": ApiOperationRoute("GET", "/api/v1/media/{media_id}"),
+ "subscription.add": ApiOperationRoute("POST", "/api/v1/subscribe/"),
+ "subscription.update": ApiOperationRoute("PUT", "/api/v1/subscribe/"),
+ "subscription.search": ApiOperationRoute("GET", "/api/v1/subscribe/search/{subscribe_id}"),
+ "subscription.list": ApiOperationRoute("GET", "/api/v1/subscribe/"),
+ "subscription.shares": ApiOperationRoute("GET", "/api/v1/subscribe/shares"),
+ "subscription.popular": ApiOperationRoute("GET", "/api/v1/subscribe/popular"),
+ "subscription.history": ApiOperationRoute("GET", "/api/v1/subscribe/history/{mtype}"),
+ "subscription.delete": ApiOperationRoute("DELETE", "/api/v1/subscribe/{subscribe_id}"),
+ "download.add": ApiOperationRoute("POST", "/api/v1/download/add"),
+ "download.history.delete": ApiOperationRoute("DELETE", "/api/v1/history/download"),
+ "transfer.history.delete": ApiOperationRoute("DELETE", "/api/v1/history/transfer"),
+ "site.list": ApiOperationRoute("GET", "/api/v1/site/"),
+ "site.update": ApiOperationRoute("PUT", "/api/v1/site/"),
+ "site.userdata": ApiOperationRoute("GET", "/api/v1/site/userdata/{site_id}"),
+ "site.test": ApiOperationRoute("GET", "/api/v1/site/test/{site_id}"),
+ "site.cookie.update": ApiOperationRoute("POST", "/api/v1/site/cookie/{site_id}"),
+ "recommendation.list": ApiOperationRoute("GET", "/api/v1/recommend/agent"),
+ "library.exists": ApiOperationRoute("GET", "/api/v1/mediaserver/exists"),
+ "storage.settings": ApiOperationRoute("GET", "/api/v1/storage/directories"),
+ "storage.list": ApiOperationRoute("POST", "/api/v1/storage/list"),
+ "transfer.history": ApiOperationRoute("GET", "/api/v1/history/transfer"),
+ "transfer.file": ApiOperationRoute("POST", "/api/v1/transfer/manual"),
+ "scheduler.list": ApiOperationRoute("GET", "/api/v1/dashboard/schedule"),
+ "scheduler.run": ApiOperationRoute("GET", "/api/v1/system/runscheduler"),
+ "workflow.list": ApiOperationRoute("GET", "/api/v1/workflow/"),
+ "workflow.run": ApiOperationRoute("POST", "/api/v1/workflow/{workflow_id}/run"),
+ "plugin.installed": ApiOperationRoute("GET", "/api/v1/plugin/installed"),
+ "plugin.market": ApiOperationRoute("GET", "/api/v1/plugin/"),
+ "plugin.capabilities": ApiOperationRoute("GET", "/api/v1/plugin/runtime/capabilities"),
+ "plugin.config.get": ApiOperationRoute("GET", "/api/v1/plugin/{plugin_id}"),
+ "plugin.config.update": ApiOperationRoute("PUT", "/api/v1/plugin/{plugin_id}"),
+ "plugin.reload": ApiOperationRoute("GET", "/api/v1/plugin/reload/{plugin_id}"),
+ "plugin.install": ApiOperationRoute("GET", "/api/v1/plugin/install/{plugin_id}"),
+ "plugin.uninstall": ApiOperationRoute("DELETE", "/api/v1/plugin/{plugin_id}"),
+ "slash.list": ApiOperationRoute("GET", "/api/v1/message/agent/commands"),
+ "config.identifiers.get": ApiOperationRoute("GET", "/api/v1/system/identifiers"),
+ "config.identifiers.update": ApiOperationRoute("POST", "/api/v1/system/identifiers"),
+ "search.torrents": ApiOperationRoute("GET", "/api/v1/search/media/{media_id}"),
+ "search.results": ApiOperationRoute("GET", "/api/v1/search/last/context"),
+ "filter.builtin": ApiOperationRoute("GET", "/api/v1/rule/builtin"),
+ "filter.custom": ApiOperationRoute("GET", "/api/v1/rule/custom"),
+ "filter.groups": ApiOperationRoute("GET", "/api/v1/rule/groups"),
+ "filter.custom.add": ApiOperationRoute("POST", "/api/v1/rule/custom"),
+ "filter.custom.update": ApiOperationRoute("PUT", "/api/v1/rule/custom/{rule_id}"),
+ "filter.custom.delete": ApiOperationRoute("DELETE", "/api/v1/rule/custom/{rule_id}"),
+ "filter.group.add": ApiOperationRoute("POST", "/api/v1/rule/groups"),
+ "filter.group.update": ApiOperationRoute("PUT", "/api/v1/rule/groups/{name}"),
+ "filter.group.delete": ApiOperationRoute("DELETE", "/api/v1/rule/groups/{name}"),
+ "plugin.data": ApiOperationRoute("GET", "/api/v1/plugin/runtime/{plugin_id}/data"),
+ "config.system.get": ApiOperationRoute("GET", "/api/v1/system/settings"),
+ "config.system.update": ApiOperationRoute("POST", "/api/v1/system/settings"),
+ "slash.run": ApiOperationRoute("POST", "/api/v1/message/agent/commands/run"),
+}
+
+
+def resolve_api_operation(operation_id: str) -> Optional[ApiOperationSpec]:
+ """按稳定 operation ID 查找 API 操作。"""
+ return API_OPERATION_BY_ID.get(operation_id)
+
+
+def resolve_api_route(operation_id: str) -> Optional[ApiOperationRoute]:
+ """按稳定 operation ID 查找固定 API 路由。"""
+ return API_OPERATION_ROUTES.get(operation_id)
+
+
+def list_api_operation_ids() -> tuple[str, ...]:
+ """返回按注册顺序排列的 API 操作 ID。"""
+ return tuple(spec.operation_id for spec in API_OPERATION_SPECS)
+
+
+__all__ = [
+ "API_FIRST_BATCH_OPERATION_SPECS",
+ "API_OPERATION_BY_ID",
+ "API_OPERATION_ROUTES",
+ "API_OPERATION_SPECS",
+ "API_PARITY_OPERATION_SPECS",
+ "ApiOperationRoute",
+ "ApiOperationSpec",
+ "list_api_operation_ids",
+ "resolve_api_operation",
+ "resolve_api_route",
+]
diff --git a/app/agent/policy/orchestrator.py b/app/agent/policy/orchestrator.py
index 7bf4f4c0e..cc4ed75ec 100644
--- a/app/agent/policy/orchestrator.py
+++ b/app/agent/policy/orchestrator.py
@@ -18,6 +18,7 @@ from app.agent.policy.contracts import (
PolicyDecision,
PolicyObservation,
RecoveryMode,
+ ResultSensitivity,
ToolInvocation,
ToolPolicyContext,
)
@@ -30,7 +31,6 @@ from app.agent.policy.sanitizer import (
)
from app.runtime.log import logger
-
_HookResult = TypeVar("_HookResult")
@@ -45,10 +45,7 @@ def call_policy_hook(
return hook(*args, **kwargs)
except Exception as error:
try:
- logger.warning(
- f"Agent工具策略观测失败: phase={phase}, "
- f"error_type={stable_type_name(error)}"
- )
+ logger.warning(f"Agent工具策略观测失败: phase={phase}, error_type={stable_type_name(error)}")
except Exception:
pass
return None
@@ -63,7 +60,7 @@ def _normalize_policy_arguments(tool: Any, arguments: Mapping[str, Any]) -> dict
try:
validated = args_schema.model_validate(raw_arguments)
return validated.model_dump(mode="json")
- except (AttributeError, TypeError, ValueError, ValidationError):
+ except AttributeError, TypeError, ValueError, ValidationError:
# 实际 handler 仍负责既有参数错误语义;策略观测按原始值保守处理。
return raw_arguments
@@ -147,7 +144,10 @@ class AgentToolPolicyOrchestrator:
@staticmethod
def finish(observation: PolicyObservation, result: Any) -> ExecutionReceipt:
"""生成成功回执 envelope,并只记录脱敏结果摘要。"""
- result_summary = summarize_result(_result_payload(result))
+ if observation.policy.result_sensitivity is ResultSensitivity.SECRET:
+ result_summary = '{"protected_result": "***"}'
+ else:
+ result_summary = summarize_result(_result_payload(result))
receipt = ExecutionReceipt(
invocation_id=observation.invocation.invocation_id,
tool_name=observation.invocation.tool_name,
@@ -191,8 +191,8 @@ class AgentToolPolicyOrchestrator:
def fail(observation: PolicyObservation, error: BaseException) -> ExecutionReceipt:
"""生成失败回执 envelope,不把异常中的凭据写入日志。"""
error_summary = summarize_error(error)
- external_may_continue, needs_reconcile = (
- AgentToolPolicyOrchestrator._uncertain_external_state(observation, error)
+ external_may_continue, needs_reconcile = AgentToolPolicyOrchestrator._uncertain_external_state(
+ observation, error
)
receipt = ExecutionReceipt(
invocation_id=observation.invocation.invocation_id,
diff --git a/app/agent/policy/registry.py b/app/agent/policy/registry.py
index ac75f4b78..bb288510c 100644
--- a/app/agent/policy/registry.py
+++ b/app/agent/policy/registry.py
@@ -2,6 +2,7 @@
from typing import Any, Mapping
+from app.agent.policy.api import resolve_api_operation
from app.agent.policy.contracts import (
ActionEffect,
ActionPolicy,
@@ -12,102 +13,40 @@ from app.agent.policy.contracts import (
ResultSensitivity,
)
-
# 这些非管理员读取在运行时解析为强制 SAFE_READ;管理员门禁仍沿用原有授权事实源。
-SAFE_READ_TOOL_NAMES = frozenset(
- {
- "list_slash_commands",
- "query_installed_plugins",
- "query_personas",
- "query_schedulers",
- "query_workflows",
- }
-)
+SAFE_READ_TOOL_NAMES = frozenset({})
+
+
+def requests_system_setting_secrets(arguments: Mapping[str, Any]) -> bool:
+ """判断结构化 API 参数是否请求读取未脱敏系统设置。"""
+ if str(arguments.get("operation_id") or "") != "config.system.get":
+ return False
+ for location in ("body", "query"):
+ values = arguments.get(location)
+ if isinstance(values, Mapping) and values.get("show_secrets") is True:
+ return True
+ return False
# 该清单只校验固定工具 inventory;未命中的固定或动态工具同样默认 LEGACY_SHADOW。
BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset(
{
- "add_custom_filter_rule",
- "add_download_tasks",
- "add_rule_group",
- "add_subscribe",
"apply_patch",
"ask_user_choice",
"browse_webpage",
- "create_agent_task",
- "delete_agent_task",
- "delete_custom_filter_rule",
- "delete_download_history",
- "delete_download_tasks",
- "delete_rule_group",
- "delete_subscribe",
- "delete_transfer_history",
+ "agent_task",
"edit_file",
"execute_command",
- "get_recommendations",
- "get_search_results",
- "install_plugin",
- "list_directory",
- "query_agent_tasks",
- "query_builtin_filter_rules",
- "query_custom_filter_rules",
- "query_custom_identifiers",
- "query_directory_settings",
"query_doctor_report",
- "query_download_tasks",
- "query_downloaders",
- "query_episode_schedule",
- "query_library_exists",
- "query_library_latest",
- "query_market_plugins",
- "query_media_detail",
- "query_plugin_capabilities",
- "query_plugin_config",
- "query_plugin_data",
- "query_popular_subscribes",
- "query_rule_groups",
- "query_site_userdata",
- "query_sites",
- "query_subscribe_history",
- "query_subscribe_shares",
- "query_subscribes",
- "query_system_settings",
- "query_transfer_history",
"read_file",
"recognize_captcha",
- "recognize_media",
- "reload_plugin",
- "run_agent_task",
- "run_scheduler",
- "run_slash_command",
- "run_workflow",
- "scrape_metadata",
- "search_media",
- "search_person",
- "search_person_credits",
- "search_subscribe",
- "search_torrents",
"search_web",
"send_local_file",
"send_message",
"send_voice_message",
- "switch_persona",
- "test_site",
- "transfer_file",
- "uninstall_plugin",
- "update_agent_task",
- "update_custom_filter_rule",
- "update_custom_identifiers",
- "update_download_tasks",
- "update_persona_definition",
- "update_plugin_config",
- "update_rule_group",
- "update_site",
- "update_site_cookie",
- "update_subscribe",
- "update_system_settings",
+ "persona",
"write_file",
+ "moviepilot_api",
}
)
@@ -119,9 +58,7 @@ class ToolPolicyRegistry:
self,
*,
safe_read_tool_names: frozenset[str] = SAFE_READ_TOOL_NAMES,
- builtin_legacy_shadow_inventory: frozenset[str] = (
- BUILTIN_LEGACY_SHADOW_INVENTORY
- ),
+ builtin_legacy_shadow_inventory: frozenset[str] = (BUILTIN_LEGACY_SHADOW_INVENTORY),
) -> None:
"""建立 SAFE_READ 例外与固定工具 inventory。"""
overlap = safe_read_tool_names & builtin_legacy_shadow_inventory
@@ -133,9 +70,7 @@ class ToolPolicyRegistry:
@property
def builtin_tool_inventory(self) -> set[str]:
"""返回用于测试校验的固定工具 inventory。"""
- return set(
- self._safe_read_tool_names | self._builtin_legacy_shadow_inventory
- )
+ return set(self._safe_read_tool_names | self._builtin_legacy_shadow_inventory)
def resolve(
self,
@@ -145,24 +80,77 @@ class ToolPolicyRegistry:
requires_admin: bool,
) -> ActionPolicy:
"""根据工具名和宿主权限声明解析当前参数级策略。"""
- required_role = (
- PrincipalRole.SYSTEM_ADMIN if requires_admin else PrincipalRole.USER
- )
- if (
- tool_name == "query_system_settings"
- and arguments.get("show_secrets") is True
- ):
+ required_role = PrincipalRole.SYSTEM_ADMIN if requires_admin else PrincipalRole.USER
+ if tool_name == "agent_task":
+ action = str(arguments.get("action") or "")
+ effect = {
+ "create": ActionEffect.REVERSIBLE_WRITE,
+ "list": ActionEffect.SAFE_READ,
+ "update": ActionEffect.REVERSIBLE_WRITE,
+ "run": ActionEffect.EXTERNAL_SIDE_EFFECT,
+ "delete": ActionEffect.DESTRUCTIVE_WRITE,
+ }.get(action, ActionEffect.UNKNOWN)
return ActionPolicy(
- effect=ActionEffect.SENSITIVE_READ,
+ effect=effect,
required_role=PrincipalRole.SYSTEM_ADMIN,
- confirmation=ConfirmationMode.REQUIRED,
- recovery=RecoveryMode.NONE,
- result_sensitivity=ResultSensitivity.SECRET,
+ confirmation=(ConfirmationMode.NONE if effect is ActionEffect.SAFE_READ else ConfirmationMode.REQUIRED),
+ recovery=(RecoveryMode.NONE if effect is ActionEffect.SAFE_READ else RecoveryMode.RECONCILE),
+ result_sensitivity=ResultSensitivity.NORMAL,
+ migration_state=MigrationState.ENFORCED,
+ )
+ if tool_name == "persona":
+ action = str(arguments.get("action") or "")
+ if action == "list":
+ return ActionPolicy(
+ effect=ActionEffect.SAFE_READ,
+ required_role=PrincipalRole.USER,
+ confirmation=ConfirmationMode.NONE,
+ recovery=RecoveryMode.NONE,
+ result_sensitivity=ResultSensitivity.NORMAL,
+ migration_state=MigrationState.ENFORCED,
+ )
+ return ActionPolicy(
+ effect=(ActionEffect.REVERSIBLE_WRITE if action in {"switch", "update"} else ActionEffect.UNKNOWN),
+ required_role=(PrincipalRole.SYSTEM_ADMIN if action == "update" else PrincipalRole.USER),
+ confirmation=ConfirmationMode.REQUIRED,
+ recovery=RecoveryMode.BEFORE_STATE,
+ result_sensitivity=ResultSensitivity.NORMAL,
+ migration_state=MigrationState.ENFORCED,
+ )
+ if tool_name == "moviepilot_api":
+ operation_id = str(arguments.get("operation_id") or "")
+ operation = resolve_api_operation(operation_id)
+ if operation is None:
+ return ActionPolicy(
+ effect=ActionEffect.UNKNOWN,
+ required_role=PrincipalRole.SYSTEM_ADMIN,
+ confirmation=ConfirmationMode.REQUIRED,
+ recovery=RecoveryMode.MANUAL_ONLY,
+ result_sensitivity=ResultSensitivity.UNKNOWN,
+ migration_state=MigrationState.ENFORCED,
+ machine_allowed=False,
+ background_allowed=False,
+ subagent_allowed=False,
+ )
+ if requests_system_setting_secrets(arguments):
+ return ActionPolicy(
+ effect=ActionEffect.SENSITIVE_READ,
+ required_role=PrincipalRole.SYSTEM_ADMIN,
+ confirmation=ConfirmationMode.REQUIRED,
+ recovery=RecoveryMode.MANUAL_ONLY,
+ result_sensitivity=ResultSensitivity.SECRET,
+ migration_state=MigrationState.ENFORCED,
+ machine_allowed=True,
+ background_allowed=False,
+ subagent_allowed=False,
+ )
+ return ActionPolicy(
+ effect=operation.effect,
+ required_role=(PrincipalRole.SYSTEM_ADMIN if requires_admin else operation.required_role),
+ confirmation=operation.confirmation,
+ recovery=operation.recovery,
+ result_sensitivity=operation.result_sensitivity,
migration_state=MigrationState.ENFORCED,
- policy_version="p1-g2a2-v1",
- machine_allowed=True,
- background_allowed=False,
- subagent_allowed=False,
)
if tool_name in self._safe_read_tool_names:
return ActionPolicy(
@@ -172,11 +160,7 @@ class ToolPolicyRegistry:
recovery=RecoveryMode.NONE,
result_sensitivity=ResultSensitivity.NORMAL,
# 角色门禁仍由既有授权事实源判断,管理员读取保持兼容观测。
- migration_state=(
- MigrationState.LEGACY_SHADOW
- if requires_admin
- else MigrationState.ENFORCED
- ),
+ migration_state=(MigrationState.LEGACY_SHADOW if requires_admin else MigrationState.ENFORCED),
)
# 除明确例外外,固定和动态工具都保持现有能力,但不得被视为安全读取。
@@ -198,4 +182,5 @@ __all__ = [
"DEFAULT_TOOL_POLICY_REGISTRY",
"SAFE_READ_TOOL_NAMES",
"ToolPolicyRegistry",
+ "requests_system_setting_secrets",
]
diff --git a/app/agent/policy/sanitizer.py b/app/agent/policy/sanitizer.py
index 340b51f80..6bb16c909 100644
--- a/app/agent/policy/sanitizer.py
+++ b/app/agent/policy/sanitizer.py
@@ -10,8 +10,7 @@ from typing import Any
from pydantic import AliasChoices, AliasPath, BaseModel, ValidationError
-from app.agent.policy.secrets import is_secret_setting_key
-
+from app.application.security.secrets import is_secret_setting_key
REDACTED_VALUE = "***"
_MAX_DEPTH = 8
@@ -58,9 +57,7 @@ _SECRET_CONTAINER_SUFFIXES = (
"credential",
"credentials",
)
-_SECRET_CONTAINER_ENDINGS = tuple(
- f"_{suffix}" for suffix in _SECRET_CONTAINER_SUFFIXES
-)
+_SECRET_CONTAINER_ENDINGS = tuple(f"_{suffix}" for suffix in _SECRET_CONTAINER_SUFFIXES)
_COMPACT_SECRET_CONTAINERS = frozenset(("oauth", "oauth2"))
_SECRET_IDENTITY_FIELDS = frozenset(
{
@@ -88,9 +85,7 @@ _SECRET_IDENTITY_VALUE_FIELDS = frozenset(
}
)
_BEARER_PATTERN = re.compile(r"(?i)(\bbearer\s+)[^\s,;]+")
-_BASIC_AUTH_PATTERN = re.compile(
- r"(?i)(\bbasic\s+)([A-Za-z0-9+/]+={0,2})(?![A-Za-z0-9+/=])"
-)
+_BASIC_AUTH_PATTERN = re.compile(r"(?i)(\bbasic\s+)([A-Za-z0-9+/]+={0,2})(?![A-Za-z0-9+/=])")
_SENSITIVE_HEADER_PATTERN = re.compile(
r"(?i)(\b(?:authorization|proxy-authorization|cookie|set-cookie|"
r"x-api-key|api[_-]?key|api[_-]?token)\s*[:=]\s*)[^\r\n]+"
@@ -129,9 +124,7 @@ def _is_secret_key(key: Any) -> bool:
compact = normalized.replace("_", "")
if compact in _COMPACT_SECRET_CONTAINERS:
return True
- if normalized in _SECRET_CONTAINER_SUFFIXES or normalized.endswith(
- _SECRET_CONTAINER_ENDINGS
- ):
+ if normalized in _SECRET_CONTAINER_SUFFIXES or normalized.endswith(_SECRET_CONTAINER_ENDINGS):
return True
return compact.endswith(_COMPACT_SECRET_SUFFIXES)
@@ -141,9 +134,7 @@ def _mapping_has_secret_identity(
) -> bool:
"""判断同一结构是否声明了凭据设置身份,不把语义传播到嵌套对象。"""
return any(
- _normalize_key(key_text) in _SECRET_IDENTITY_FIELDS
- and type(item) is str
- and is_secret_setting_key(item)
+ _normalize_key(key_text) in _SECRET_IDENTITY_FIELDS and type(item) is str and is_secret_setting_key(item)
for key_text, _output_key, _secret_key, item in entries
)
@@ -164,7 +155,7 @@ def _advance_quote_context(
if active_quote:
if char == active_quote:
active_quote = ""
- elif char in ("\"", "'"):
+ elif char in ('"', "'"):
active_quote = char
cursor += 1
return active_quote
@@ -190,7 +181,7 @@ def _quote_wrapper_at(value: str, start: int) -> tuple[str, int, int]:
quote_at = start
while quote_at < len(value) and value[quote_at] == "\\":
quote_at += 1
- if quote_at >= len(value) or value[quote_at] not in ("\"", "'"):
+ if quote_at >= len(value) or value[quote_at] not in ('"', "'"):
return "", quote_at - start, quote_at
return value[quote_at], quote_at - start, quote_at + 1
@@ -212,9 +203,7 @@ def _quote_wrapper_end(
cursor < len(value)
and value[cursor] == quote
and cursor - slash_start >= slash_count
- and (cursor - slash_start - slash_count)
- % (2 * (slash_count + 1))
- == 0
+ and (cursor - slash_start - slash_count) % (2 * (slash_count + 1)) == 0
):
return cursor + 1
if cursor < len(value):
@@ -249,7 +238,7 @@ def _unquoted_assignment_value_end(
slash_count = slash_end - cursor
next_char = value[slash_end]
- if next_char in ("\"", "'"):
+ if next_char in ('"', "'"):
cursor = _quote_wrapper_end(
value,
slash_end + 1,
@@ -263,7 +252,7 @@ def _unquoted_assignment_value_end(
continue
cursor = slash_end + 1 if slash_count % 2 else slash_end
continue
- if char in ("\"", "'"):
+ if char in ('"', "'"):
cursor = _quote_wrapper_end(
value,
cursor + 1,
@@ -295,9 +284,7 @@ def _find_assignment_header(
cursor = search_from
while cursor < len(value):
match_start = cursor
- if match_start > 0 and value[match_start - 1].isascii() and value[
- match_start - 1
- ].isalnum():
+ if match_start > 0 and value[match_start - 1].isascii() and value[match_start - 1].isalnum():
cursor += 1
continue
@@ -310,26 +297,19 @@ def _find_assignment_header(
else:
while key_start < len(value) and value[key_start] in (" ", "\t"):
key_start += 1
- if key_start >= len(value) or not _is_assignment_key_start(
- value[key_start]
- ):
+ if key_start >= len(value) or not _is_assignment_key_start(value[key_start]):
cursor += 1
continue
key_end = key_start + 1
- key_char_predicate = (
- _is_quoted_assignment_key_char if quote else _is_assignment_key_char
- )
+ key_char_predicate = _is_quoted_assignment_key_char if quote else _is_assignment_key_char
while key_end < len(value) and key_char_predicate(value[key_end]):
key_end += 1
header_end = key_end
if quote:
closing_quote_at = header_end
- while (
- closing_quote_at < len(value)
- and value[closing_quote_at] == "\\"
- ):
+ while closing_quote_at < len(value) and value[closing_quote_at] == "\\":
closing_quote_at += 1
if (
closing_quote_at >= len(value)
@@ -405,8 +385,7 @@ def _sanitize_assignments(
active_quote,
)
secret_value = _is_secret_key(key) or (
- redact_identity_values
- and _normalize_key(key) in _SECRET_IDENTITY_VALUE_FIELDS
+ redact_identity_values and _normalize_key(key) in _SECRET_IDENTITY_VALUE_FIELDS
)
if secret_value:
fragments.append(value[emitted_until:match_start])
@@ -468,11 +447,7 @@ def _uri_authority_start(value: str, scheme_end: int) -> int | None:
def _starts_uri_scheme(value: str, start: int) -> bool:
"""判断指定位置是否以完整的 URI scheme 与 authority 开始。"""
- if (
- start >= len(value)
- or not value[start].isascii()
- or not value[start].isalpha()
- ):
+ if start >= len(value) or not value[start].isascii() or not value[start].isalpha():
return False
scheme_end = start + 1
while scheme_end < len(value) and _is_uri_scheme_char(value[scheme_end]):
@@ -513,9 +488,7 @@ def _sanitize_uri_userinfo(value: str, *, truncated: bool = False) -> str:
break
if char == "@":
seen_userinfo = True
- elif char in (",", ";", "|") and (
- seen_userinfo or _starts_uri_scheme(value, authority_end + 1)
- ):
+ elif char in (",", ";", "|") and (seen_userinfo or _starts_uri_scheme(value, authority_end + 1)):
break
authority_end += 1
if truncated and authority_end == len(value):
@@ -555,7 +528,7 @@ def _redact_basic_auth(
padded_token = token + "=" * (-len(token) % 4)
try:
decoded = base64.b64decode(padded_token, validate=True)
- except (binascii.Error, ValueError):
+ except binascii.Error, ValueError:
return match.group(0)
if b":" not in decoded:
return match.group(0)
@@ -607,9 +580,7 @@ def _named_tuple_fields(value: Any) -> tuple[str, ...] | None:
field_names = type.__getattribute__(value_type, "_fields")
except Exception:
return None
- if type(field_names) is not tuple or tuple.__len__(field_names) != tuple.__len__(
- value
- ):
+ if type(field_names) is not tuple or tuple.__len__(field_names) != tuple.__len__(value):
return None
bounded_names = field_names[:_MAX_ITEMS]
if not all(type(field_name) is str for field_name in bounded_names):
@@ -653,11 +624,7 @@ def _is_dataclass_type(value_type: type) -> bool:
"""绕过自定义 metaclass 协议检查 dataclass 类型标记。"""
try:
mro = type.__getattribute__(value_type, "__mro__")
- return any(
- "__dataclass_fields__"
- in type.__getattribute__(candidate, "__dict__")
- for candidate in mro
- )
+ return any("__dataclass_fields__" in type.__getattribute__(candidate, "__dict__") for candidate in mro)
except Exception:
return False
@@ -719,9 +686,7 @@ def _pydantic_field_is_secret(field_name: str, field_info: Any) -> bool:
alias_budget = [_MAX_ITEMS]
for alias in aliases:
alias_names = _pydantic_alias_names(alias, _budget=alias_budget)
- if alias_names is None or any(
- _is_secret_key(alias_name) for alias_name in alias_names
- ):
+ if alias_names is None or any(_is_secret_key(alias_name) for alias_name in alias_names):
return True
return False
@@ -765,7 +730,7 @@ def sanitize_for_host(
if not truncated and value.strip().startswith(("{", "[")):
try:
parsed = json.loads(value)
- except (TypeError, ValueError, json.JSONDecodeError):
+ except TypeError, ValueError, json.JSONDecodeError:
pass
else:
sanitized_json = sanitize_for_host(
@@ -891,9 +856,7 @@ def sanitize_for_host(
named_tuple_fields = _named_tuple_fields(value)
if named_tuple_fields is not None:
sanitized = {}
- for index, field_name in enumerate(
- named_tuple_fields[:_MAX_ITEMS]
- ):
+ for index, field_name in enumerate(named_tuple_fields[:_MAX_ITEMS]):
if not _consume_work_item(budget):
sanitized[""] = "more fields"
break
@@ -921,9 +884,7 @@ def sanitize_for_host(
for index in range(_MAX_ITEMS + 1):
if not _consume_work_item(budget):
identity_scan_complete = False
- entries.append(
- ("", "", False, "more items")
- )
+ entries.append(("", "", False, "more items"))
break
try:
key, item = next(items)
@@ -931,9 +892,7 @@ def sanitize_for_host(
break
if index >= _MAX_ITEMS:
identity_scan_complete = False
- entries.append(
- ("", "", False, "more items")
- )
+ entries.append(("", "", False, "more items"))
break
try:
key_snapshot = _bounded_mapping_key_text(key)
@@ -954,14 +913,8 @@ def sanitize_for_host(
secret_identity = _mapping_has_secret_identity(entries)
for key_text, output_key, secret_key, item in entries:
- identity_value_field = (
- _normalize_key(key_text)
- in _SECRET_IDENTITY_VALUE_FIELDS
- )
- contextual_secret = (
- identity_value_field
- and (secret_identity or not identity_scan_complete)
- )
+ identity_value_field = _normalize_key(key_text) in _SECRET_IDENTITY_VALUE_FIELDS
+ contextual_secret = identity_value_field and (secret_identity or not identity_scan_complete)
sanitized[output_key] = (
REDACTED_VALUE
if secret_key or contextual_secret
@@ -990,11 +943,11 @@ def sanitize_for_host(
break
sanitized_items.append(
sanitize_for_host(
- item,
- _depth=_depth + 1,
- _seen=seen,
- _budget=budget,
- )
+ item,
+ _depth=_depth + 1,
+ _seen=seen,
+ _budget=budget,
+ )
)
return sanitized_items
@@ -1024,7 +977,7 @@ def _bounded_summary(value: Any, *, max_chars: int) -> str:
suffix = "..."
if max_chars <= len(suffix):
return suffix[:max_chars]
- return f"{text[:max_chars - len(suffix)]}{suffix}"
+ return f"{text[: max_chars - len(suffix)]}{suffix}"
def summarize_input(value: Any, *, max_chars: int = 500) -> str:
diff --git a/app/agent/prompt/System Core Prompt.txt b/app/agent/prompt/System Core Prompt.txt
index 599ab7cad..e52427847 100644
--- a/app/agent/prompt/System Core Prompt.txt
+++ b/app/agent/prompt/System Core Prompt.txt
@@ -15,16 +15,16 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
-- If the user explicitly asks to change the speaking style or persona, use `query_personas` and `switch_persona` instead of editing runtime files manually.
-- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
+- If the user explicitly asks to change the speaking style or persona, use `persona` with `action=list` or `action=switch` instead of editing runtime files manually.
+- If the user explicitly asks to rewrite or create a persona definition, use `persona` with `action=update` rather than generic file-editing tools.
- Do not stop for approval on read-only operations.
-- Raw secret reads are protected operations rather than ordinary read-only queries. When a user explicitly asks for a raw credential or another unredacted sensitive setting, call `query_system_settings` with `show_secrets=true`; do not refuse the request solely because the value is sensitive. The host verifies administrator authority, obtains any required confirmation, and delivers the result through a protected channel. Never expose or repeat the secret in an ordinary assistant response, tool narration, or follow-up model context.
+- Raw secret reads are protected operations rather than ordinary read-only queries. When a user explicitly asks for a raw credential or another unredacted sensitive setting, load the relevant configuration Skill and call `moviepilot_api` with `operation_id=config.system.get` and `body.show_secrets=true`; do not refuse solely because the value is sensitive. The host verifies administrator authority, obtains confirmation, and delivers the result through a protected channel. Never expose or repeat the secret in an ordinary assistant response, tool narration, or follow-up model context.
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
-- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.
+- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `agent_task` with `action=create` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks through the same tool with `action=list|update|run|delete`; these actions use integer `task_id` values. Use `moviepilot_api` operations `scheduler.list` and `scheduler.run` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to `agent_task`.
- If the user explicitly requested the exact write action, perform the smallest correct change and then validate the result.
- If a requested action is ambiguous between read-only inspection and state change, inspect first and ask a short confirmation question before the state-changing step.
@@ -49,7 +49,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
1. Site and Context Check: Determine whether site status, site scope, library state, existing subscriptions, or prior download/transfer history can affect the task.
-2. Media Identity Resolution: Confirm an exact source-native identity and pass it only as the fixed `media_source` enum plus `media_id`. Video and music share this pair; music also uses `media_type=music` and `music_type=recording|album|artist`. Use `search_media`, `query_media_detail`, or `recognize_media` as needed.
+2. Media Identity Resolution: Confirm an exact source-native identity and pass it only as the fixed `media_source` enum plus `media_id`. Video and music share this pair; music also uses `media_type=music` and `music_type=recording|album|artist`. Load the relevant Skill and use `moviepilot_api` operations `media.search`, `media.detail`, or `media.recognize` as needed.
3. Resource Discovery: Use the appropriate search path for the task. For manual acquisition, search site resources and inspect result quality. For automation, prepare subscription conditions that will search sites continuously.
4. Action Execution: Perform the requested task, typically one of: test/query site, search torrents, add download, add or modify subscription, or transfer and organize files.
5. Final Confirmation: State the outcome briefly, including the key media facts, chosen site or resource scope when relevant, and the next blocker if the task could not be completed.
@@ -60,13 +60,13 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- Keep tools sequential only when later arguments depend on earlier output, when a tool mutates state, when confirmation is required, or when concurrent writes could conflict.
- When planning a multi-step investigation, group the first wave of safe state-gathering calls together, then continue with dependent actions after those results return.
- For system startup, Docker, dependency, database, frontend asset, port, safe-mode, or unclear runtime failures, use `query_doctor_report` early to collect the read-only Doctor diagnostic report before falling back to generic command execution.
-- Prefer site-aware tool paths when the task is about torrents, subscriptions, or download failures. `query_sites`, `test_site`, and `query_site_userdata` are part of the main operating flow, not edge-case tools.
-- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
-- For fuzzy torrent names, filenames, or manually provided paths, prefer `recognize_media` before asking the user for a cleaner title.
-- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
+- Prefer site-aware API operations when the task is about torrents, subscriptions, or download failures. `site.list`, `site.test`, and `site.userdata` are part of the main operating flow, not edge cases.
+- If search results are ambiguous, use `media.detail` or `media.recognize` to clarify before proceeding.
+- For fuzzy torrent names, filenames, or manually provided paths, prefer `media.recognize` before asking the user for a cleaner title.
+- If `media.search` fails, fall back to `search_web` or `media.recognize`. Only ask the user when automated paths are exhausted.
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
-- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
-- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
+- Reuse the latest torrent search cache through `search.results` before `download.add` instead of repeating the same search unnecessarily.
+- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `moviepilot_api` operation `storage.list` for one known local or remote storage directory, with its paging fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
- Read the relevant file before changing it, then pick the editing tool by scope. Use `apply_patch` when one logical change spans multiple files, adds new files, or deletes files: submit a single patch wrapped in `*** Begin Patch` / `*** End Patch` with `*** Add File:`, `*** Update File:`, and `*** Delete File:` sections; every context and removed line must match the current content exactly, and the whole patch is validated before any file is written. Use `edit_file` for a single localized exact replacement within one already-read file; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for one standalone new file; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
@@ -76,19 +76,19 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
1. Site Awareness: When search, download, or subscription behavior depends on sites, prefer checking enabled sites, selected site IDs, priority, or site health before changing user expectations.
2. Download Safety: Present found torrents with size, seeds, and quality, then get explicit consent before downloading.
-3. Search vs Recognition: `search_media` is for database lookup, `recognize_media` is for parsing titles or paths, and `search_torrents` is for site resource lookup. Do not confuse these roles.
+3. Search vs Recognition: `media.search` is for metadata lookup, `media.recognize` is for parsing titles or paths, and `search.torrents` is for site resource lookup. Do not confuse these API operations.
4. Subscription Logic: Check for the best matching quality profile, filter groups, and site scope based on user history or defaults.
5. Library Awareness: Check if content already exists in the library to avoid duplicates before downloading, subscribing, or transferring.
6. Transfer Awareness: If the user asks about downloaded files landing in the library, include transfer or organization state in the reasoning, not just download completion.
7. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative or the next best operational step.
-8. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
+8. TV Subscription Rule: When calling `subscription.add` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `subscription.add` separately for each season.
9. Music Entity Rule: A recording is one track, an album is a collection of tracks, and an artist is browse-only. Never subscribe, search torrents for, download, transfer, or library-check an artist entity.
10. Music Identity Rule: Reuse the exact `media_source`, `media_id`, and `music_type` returned by music search/detail. Do not substitute a same-name track, album, or artist, and do not send TV-only season/episode arguments for music.
11. Recording Rule: A recording subscription or download targets one track. Use the recording ID and organize/scrape one audio file unless the user explicitly selected a different recording.
12. Album Rule: An album subscription or download targets the complete album, similar to a TV season pack. Use the album ID, prefer one album resource/directory, require the resource file list to cover the expected `total_tracks`, and do not mark the subscription complete or report the album as present when only some tracks exist.
-13. Music Library Rule: Before a music download or subscription, use `query_library_exists` with the exact recording/album identity when duplicate risk matters. For albums, a negative result can mean either absent or incomplete and should remain eligible for acquisition.
+13. Music Library Rule: Before a music download or subscription, use `library.exists` with the exact recording/album identity when duplicate risk matters. For albums, a negative result can mean either absent or incomplete and should remain eligible for acquisition.
14. Music Organization Rule: Organize one recording as one audio file with `music_type=recording`. Organize a complete album by passing its album directory once with `media_type=music`, `music_type=album`, and the album source-native identity; do not repeatedly identify every track as an unrelated media item.
-15. Music Scraping Rule: `scrape_metadata(media_type="music")` applies configured audio-tag, cover, and lyrics policies. For an album directory, pass the album identity when known; without one, allow per-file tag recognition. Report the actual lyrics saved/existing/missing counts returned by the tool.
+15. Music Scraping Rule: `media.scrape` with music type applies configured audio-tag, cover, and lyrics policies. For an album directory, pass the album identity when known; without one, allow per-file tag recognition. Report the actual lyrics saved/existing/missing counts returned by the API.
diff --git a/app/agent/prompt/System Tasks.yaml b/app/agent/prompt/System Tasks.yaml
index 883f6b12d..1bf07fe8d 100644
--- a/app/agent/prompt/System Tasks.yaml
+++ b/app/agent/prompt/System Tasks.yaml
@@ -36,14 +36,14 @@ task_types:
- "Total failed records: {history_count}"
steps_title: "Follow these steps"
steps:
- - "Use `query_transfer_history` with status='failed' to find the record with id={history_id} and understand the failure details such as source path, error message, and media info."
+ - "Use `moviepilot_api` operation `transfer.history` with status='failed' to find the record with id={history_id} and understand the failure details such as source path, error message, and media info."
- "Analyze the error message to determine the best retry strategy."
- "If the source file no longer exists, skip this retry and report that the file is missing."
- - "Call `delete_transfer_history` with history_id={history_id}. Durable records are not deleted: the tool submits them to the persistent retry scheduler."
- - "If `delete_transfer_history` reports that a durable retry was accepted or rejected, stop and report the exact scheduler result. Do not call `transfer_file`; do not delete the target, history, or retry evidence."
- - "Only for a legacy history that was actually deleted, re-identify the media using `recognize_media` with the source file path. For audio files, set media_type='music' and preserve artist/title/album context."
- - "If legacy recognition fails, try `search_media` with keywords from the filename. For music, distinguish recording, album, and browse-only artist results."
- - "Re-transfer only the deleted legacy history using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id for every media type, plus media_type + music_type for music."
+ - "Call `moviepilot_api` operation `transfer.history.delete` for history_id={history_id}. Durable records are not deleted: the API submits them to the persistent retry scheduler."
+ - "If `transfer.history.delete` reports that a durable retry was accepted or rejected, stop and report the exact scheduler result. Do not call `transfer.file`; do not delete the target, history, or retry evidence."
+ - "Only for a legacy history that was actually deleted, re-identify the media using `media.recognize` with the source file path. For audio files, set media_type='music' and preserve artist/title/album context."
+ - "If legacy recognition fails, try `media.search` with keywords from the filename. For music, distinguish recording, album, and browse-only artist results."
+ - "Re-transfer only the deleted legacy history using `transfer.file` with the source path and exact identity fields. Reuse media_source + media_id for every media type, plus media_type + music_type for music."
- "Report the final result."
batch_transfer_failed_retry:
header: "[System Task - Batch Transfer Failed Retry]"
@@ -54,13 +54,13 @@ task_types:
- "Total failed records: {history_count}"
steps_title: "Follow these steps"
steps:
- - "Use `query_transfer_history` with status='failed' to find all records with these IDs and understand the failure details."
+ - "Use `transfer.history` with status='failed' to find all records with these IDs and understand the failure details."
- "Group records by exact media identity and source directory before retrying. Do not assume all selected files belong to one media."
- - "If the error is about media recognition, identify each group once using `recognize_media` or `search_media`, then reuse that result inside the group. Album tracks should normally be retried from the shared album directory with the album identity."
- - "For each failed record, call `delete_transfer_history`. Durable records are submitted to the persistent retry scheduler, and that result is final for this task: report its exact accepted or rejected state and do not call `transfer_file` or delete its evidence. Only an actually deleted legacy history may be re-transferred with `transfer_file`."
+ - "If the error is about media recognition, identify each group once using `media.recognize` or `media.search`, then reuse that result inside the group. Album tracks should normally be retried from the shared album directory with the album identity."
+ - "For each failed record, call `transfer.history.delete`. Durable records are submitted to the persistent retry scheduler, and that result is final for this task: report its exact accepted or rejected state and do not call `transfer.file` or delete its evidence. Only an actually deleted legacy history may be re-transferred with `transfer.file`."
- "Report how many retries succeeded and how many still failed."
task_rules:
- - "Within one verified group, do NOT call `recognize_media` or `search_media` repeatedly for each file. A music recording is one track; a music album is one multi-track directory; an artist is never a transfer target."
+ - "Within one verified group, do NOT call `media.recognize` or `media.search` repeatedly for each file. A music recording is one track; a music album is one multi-track directory; an artist is never a transfer target."
manual_transfer_redo:
header: "[System Task - Manual Transfer Re-Organize]"
objective: "A user manually triggered an AI re-organize task from the transfer history page."
@@ -85,21 +85,21 @@ task_types:
- "- Error message: {error_message}"
steps_title: "Required workflow"
steps:
- - "Use `query_transfer_history` to locate and inspect the record with id={history_id}, and verify the source path, status, media info, and failure context."
+ - "Use `transfer.history` to locate and inspect the record with id={history_id}, and verify the source path, status, media info, and failure context."
- "Decide whether the current recognition is trustworthy."
- "If the source file no longer exists or cannot be safely processed, stop and report the reason."
- "If the current recognition is wrong or the record should be reorganized, determine the correct media identity first."
- - "Prefer `recognize_media` with the source path. If recognition is not reliable, use `search_media` with keywords from filename, title, or year."
+ - "Prefer `media.recognize` with the source path. If recognition is not reliable, use `media.search` with keywords from filename, title, or year."
- "Only continue when you have high confidence in the target media."
- - "Before re-organizing, call `delete_transfer_history`. Durable records are not deleted: the tool submits them to the persistent retry scheduler."
- - "If `delete_transfer_history` reports that a durable retry was accepted or rejected, stop for that record and report the exact scheduler result. Do not call `transfer_file`; do not delete the target, history, or retry evidence. Only a legacy history deletion may be followed by `transfer_file`."
- - "For a legacy history that was actually deleted, use `transfer_file` to organize the source path directly."
- - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory."
+ - "Before re-organizing, call `transfer.history.delete`. Durable records are not deleted: the API submits them to the persistent retry scheduler."
+ - "If `transfer.history.delete` reports that a durable retry was accepted or rejected, stop for that record and report the exact scheduler result. Do not call `transfer.file`; do not delete the target, history, or retry evidence. Only a legacy history deletion may be followed by `transfer.file`."
+ - "For a legacy history that was actually deleted, use `transfer.file` to organize the source path directly."
+ - "When calling `transfer.file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory."
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
task_rules:
- "Do NOT rely on previous chat context. Work only from the record above."
- "Your goal is to directly fix one transfer history record by using MoviePilot tools to analyze, clean up the old history entry if necessary, and organize the source file again."
- - "You should complete the re-organize by directly using tools such as `query_transfer_history`, `recognize_media`, `search_media`, `delete_transfer_history`, and `transfer_file`."
+ - "Complete the re-organize with `moviepilot_api` operations `transfer.history`, `media.recognize`, `media.search`, `transfer.history.delete`, and `transfer.file`."
- "Do NOT reorganize blindly when media identity is uncertain."
- "If the previous record was successful but obviously identified as the wrong media, still use the tool-based flow above instead of `/redo`."
- "Keep the final response short and focused on outcome."
@@ -114,19 +114,19 @@ task_types:
steps_title: "Required workflow"
steps:
- "Review the selected records below first and group them by likely shared media identity, source directory, or retry strategy when possible."
- - "Use the provided record context as the primary source of truth. Call `query_transfer_history` only when you need extra confirmation."
+ - "Use the provided record context as the primary source of truth. Call `transfer.history` only when you need extra confirmation."
- "For each group, decide whether the current recognition is trustworthy."
- - "If multiple records clearly belong to the same movie, series, or music album, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records. A recording remains a single-track target, and an artist is browse-only."
+ - "If multiple records clearly belong to the same movie, series, or music album, identify the media once with `media.recognize` or `media.search`, then reuse that result for the related records. A recording remains a single-track target, and an artist is browse-only."
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
- - "Before re-organizing each record, call `delete_transfer_history`. Durable records are not deleted: the tool submits each task to the persistent retry scheduler."
- - "For each durable result, report the exact accepted or rejected scheduler state and stop processing that record. Do not call `transfer_file`; do not delete its target, history, or retry evidence."
- - "Only after a legacy history was actually deleted, use `transfer_file` to organize that source path directly."
- - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item."
+ - "Before re-organizing each record, call `transfer.history.delete`. Durable records are not deleted: the API submits each task to the persistent retry scheduler."
+ - "For each durable result, report the exact accepted or rejected scheduler state and stop processing that record. Do not call `transfer.file`; do not delete its target, history, or retry evidence."
+ - "Only after a legacy history was actually deleted, use `transfer.file` to organize that source path directly."
+ - "When calling `transfer.file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item."
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
task_rules:
- "Do NOT assume every selected record belongs to the same media."
- - "When several records obviously share the same media identity, avoid repeated `recognize_media` or `search_media` calls."
+ - "When several records obviously share the same media identity, avoid repeated `media.recognize` or `media.search` calls."
- "Process every selected record exactly once."
- "Keep the final response short and focused on the aggregate outcome."
- "Final response must be plain text only: one concise Chinese sentence or paragraph describing the aggregate result."
diff --git a/app/agent/prompt/__init__.py b/app/agent/prompt/__init__.py
index 4064301b1..445ef5c27 100644
--- a/app/agent/prompt/__init__.py
+++ b/app/agent/prompt/__init__.py
@@ -9,15 +9,16 @@ from typing import Any, Dict, Optional
import yaml
-from app.agent.llm.capability import AgentCapabilityManager
-from app.runtime.settings import get_runtime_setting
-
-from app.runtime.log import logger
-from app.schemas.notification import ChannelCapability
-from app.schemas.notification import ChannelCapabilities
-from app.schemas.notification import NotificationChannel
-from app.schemas.notification import ChannelCapabilityManager
from app.adapters.system.host import SystemUtils
+from app.agent.llm.capability import AgentCapabilityManager
+from app.runtime.log import logger
+from app.runtime.settings import get_runtime_setting
+from app.schemas.notification import (
+ ChannelCapabilities,
+ ChannelCapability,
+ ChannelCapabilityManager,
+ NotificationChannel,
+)
SYSTEM_TASKS_FILE = "System Tasks.yaml"
SYSTEM_TASKS_SCHEMA_VERSION = 2
@@ -126,11 +127,7 @@ class PromptManager:
# 识别渠道
markdown_spec = ""
msg_channel = (
- next(
- (c for c in NotificationChannel if c.value.lower() == channel.lower()), None
- )
- if channel
- else None
+ next((c for c in NotificationChannel if c.value.lower() == channel.lower()), None) if channel else None
)
# 获取渠道能力说明
if msg_channel:
@@ -165,28 +162,21 @@ class PromptManager:
raise PromptConfigError(f"系统任务定义文件不存在: {system_tasks_path}") from err
signature = (stat.st_mtime_ns, stat.st_size)
- if (
- self._system_tasks_signature == signature
- and self._system_tasks_cache is not None
- ):
+ if self._system_tasks_signature == signature and self._system_tasks_cache is not None:
return self._system_tasks_cache
try:
content = system_tasks_path.read_text(encoding="utf-8", errors="replace")
except Exception as err: # noqa: BLE001
logger.error(f"读取系统任务定义失败: {system_tasks_path}, 错误: {err}")
- raise PromptConfigError(
- f"读取系统任务定义失败 {system_tasks_path}: {err}"
- ) from err
+ raise PromptConfigError(f"读取系统任务定义失败 {system_tasks_path}: {err}") from err
try:
data = yaml.safe_load(content) or {}
except yaml.YAMLError as err:
raise PromptConfigError(f"YAML 解析失败 {system_tasks_path}: {err}") from err
if not isinstance(data, dict):
- raise PromptConfigError(
- f"YAML 根节点必须是映射类型: {system_tasks_path}"
- )
+ raise PromptConfigError(f"YAML 根节点必须是映射类型: {system_tasks_path}")
definition = self._parse_system_tasks_definition(system_tasks_path, data)
self._system_tasks_signature = signature
@@ -273,7 +263,7 @@ class PromptManager:
info_lines = [
f"- 当前日期: {strftime('%Y-%m-%d')}",
f"- 运行环境: {SystemUtils.platform} {'docker' if SystemUtils.is_docker() else ''}",
- "- 详细运行状态、数据库、API 和配置值需要时通过 `query_doctor_report`、`query_system_settings` 或 `execute_command` 查询。",
+ "- 详细运行状态和数据库通过 `query_doctor_report` 或 `execute_command` 查询;配置值先加载对应 Skill,再通过 `moviepilot_api` 的配置 operation 查询。",
]
path_lines = self._get_runtime_path_lines()
if path_lines:
@@ -290,9 +280,7 @@ class PromptManager:
+ ", ".join(f"`{command}`" for command in available_commands)
)
if "rg" in available_commands:
- info_lines.append(
- "- 搜索文件或文本时优先使用 `rg` / `rg --files`,不适合或不可用时再使用其他命令。"
- )
+ info_lines.append("- 搜索文件或文本时优先使用 `rg` / `rg --files`,不适合或不可用时再使用其他命令。")
return "\n".join(info_lines)
@@ -300,9 +288,9 @@ class PromptManager:
def _get_runtime_path_lines() -> list[str]:
"""返回基础系统提示词需要常驻注入的全局运行路径。"""
paths = {
- "项目根目录": get_runtime_setting('ROOT_PATH'),
- "配置目录": get_runtime_setting('CONFIG_PATH'),
- "临时目录": get_runtime_setting('TEMP_PATH'),
+ "项目根目录": get_runtime_setting("ROOT_PATH"),
+ "配置目录": get_runtime_setting("CONFIG_PATH"),
+ "临时目录": get_runtime_setting("TEMP_PATH"),
}
return [f" - {label}: `{path}`" for label, path in paths.items()]
@@ -311,9 +299,7 @@ class PromptManager:
if self._available_shell_command_names_cache is not None:
return self._available_shell_command_names_cache
- available_commands = [
- command for command in COMMON_SHELL_COMMANDS if shutil.which(command)
- ]
+ available_commands = [command for command in COMMON_SHELL_COMMANDS if shutil.which(command)]
self._available_shell_command_names_cache = available_commands
return available_commands
@@ -328,9 +314,7 @@ class PromptManager:
"""
instructions = []
if ChannelCapability.MARKDOWN not in caps.capabilities:
- instructions.append(
- "- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown."
- )
+ instructions.append("- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown.")
instructions.append(
"- No Markdown Symbols: NEVER use `**`, `*`, `__`, or `[` blocks. Use natural text to emphasize (e.g., using ALL CAPS or separators)."
)
@@ -459,11 +443,7 @@ class PromptManager:
return ""
formatter = Formatter()
- required_fields = {
- placeholder_name
- for _, placeholder_name, _, _ in formatter.parse(text)
- if placeholder_name
- }
+ required_fields = {placeholder_name for _, placeholder_name, _, _ in formatter.parse(text) if placeholder_name}
if not required_fields:
return text
@@ -471,8 +451,7 @@ class PromptManager:
missing_fields = sorted(f for f in required_fields if f not in context)
if missing_fields:
raise PromptConfigError(
- f"系统任务定义 `{task_type}` 的 `{field_name}` 缺少变量: "
- + ", ".join(f"`{f}`" for f in missing_fields)
+ f"系统任务定义 `{task_type}` 的 `{field_name}` 缺少变量: " + ", ".join(f"`{f}`" for f in missing_fields)
)
# 这里统一做字符串替换,让 YAML 成为后台任务文案的唯一行为来源。
@@ -503,16 +482,11 @@ class PromptManager:
) -> dict[str, str]:
if not template_context:
return {}
- return {
- str(key): "" if value is None else str(value)
- for key, value in template_context.items()
- }
+ return {str(key): "" if value is None else str(value) for key, value in template_context.items()}
@staticmethod
def _format_numbered_rules(title: str, items: list[str]) -> str:
- return "\n".join(
- [f"{title}:"] + [f"{index}. {item}" for index, item in enumerate(items, start=1)]
- )
+ return "\n".join([f"{title}:"] + [f"{index}. {item}" for index, item in enumerate(items, start=1)])
@staticmethod
def _format_titled_lines(title: str, items: list[str]) -> str:
diff --git a/app/agent/runtime.py b/app/agent/runtime.py
index 468bb4064..18f4f6897 100644
--- a/app/agent/runtime.py
+++ b/app/agent/runtime.py
@@ -2,8 +2,8 @@
from __future__ import annotations
-import re
import importlib
+import re
import shutil
import threading
import time
@@ -42,6 +42,7 @@ def _default_agent_root_dir() -> Path:
config_path = legacy_settings.CONFIG_PATH
return Path(config_path) / "agent"
+
ROOT_LEVEL_RUNTIME_FILES = {
CURRENT_PERSONA_FILE,
}
@@ -178,11 +179,14 @@ class AgentRuntimeConfig:
def render_prompt_sections(self) -> str:
"""渲染进入系统提示词的运行时片段。"""
- sections: list[str] = ["", f"- Active persona: `{self.active_persona}`",
- f"- Active persona file: `personas/{self.persona.persona_id}/{PERSONA_FILE}`",
- "- Use `query_personas` before switching persona when the requested speaking style is unclear.",
- "- Subagent availability is exposed by the subagent task tools; do not rely on this runtime section as a catalog.",
- ""]
+ sections: list[str] = [
+ "",
+ f"- Active persona: `{self.active_persona}`",
+ f"- Active persona file: `personas/{self.persona.persona_id}/{PERSONA_FILE}`",
+ "- Use `persona` with `action=list` before switching when the requested speaking style is unclear.",
+ "- Subagent availability is exposed by the subagent task tools; do not rely on this runtime section as a catalog.",
+ "",
+ ]
if self.warnings:
sections.extend(
@@ -228,8 +232,7 @@ class AgentRuntimeConfig:
def list_personas(self) -> list[dict[str, Any]]:
"""返回全部人格摘要。"""
return [
- persona.to_dict(is_active=persona.persona_id == self.active_persona)
- for persona in self.available_personas
+ persona.to_dict(is_active=persona.persona_id == self.active_persona) for persona in self.available_personas
]
@@ -249,9 +252,7 @@ class AgentRuntimeManager:
self.jobs_dir = self.agent_root_dir / JOBS_DIR
self.activity_dir = self.agent_root_dir / ACTIVITY_DIR
self.subagents_dir = self.runtime_dir / SUBAGENTS_DIR
- self.bundled_defaults_dir = bundled_defaults_dir or (
- Path(__file__).parent / "defaults"
- )
+ self.bundled_defaults_dir = bundled_defaults_dir or (Path(__file__).parent / "defaults")
self._cache_lock = threading.Lock()
self._cached_signature: Optional[tuple[tuple[str, int, int], ...]] = None
self._cached_config: Optional[AgentRuntimeConfig] = None
@@ -292,9 +293,7 @@ class AgentRuntimeManager:
logger.warning(f"Agent 根层配置无效,回退到内置默认配置: {err}")
config = self._load_from_root(self.bundled_defaults_dir)
config.used_fallback = True
- config.warnings.insert(
- 0, f"用户运行时配置加载失败,已回退到内置默认配置: {err}"
- )
+ config.warnings.insert(0, f"用户运行时配置加载失败,已回退到内置默认配置: {err}")
self._cached_signature = signature
self._cached_config = config
@@ -314,8 +313,7 @@ class AgentRuntimeManager:
with self._cache_lock:
if (
self._cached_signature is not None
- and now - self._cached_signature_checked_at
- < self._signature_check_interval
+ and now - self._cached_signature_checked_at < self._signature_check_interval
):
return self._cached_signature
@@ -338,12 +336,8 @@ class AgentRuntimeManager:
document = self._render_current_persona_document(
active_persona=persona.persona_id,
- extra_context_files=self._coerce_string_list(
- current_meta.get("extra_context_files")
- ),
- deprecated_phrases=self._coerce_string_list(
- current_meta.get("deprecated_phrases")
- ),
+ extra_context_files=self._coerce_string_list(current_meta.get("extra_context_files")),
+ deprecated_phrases=self._coerce_string_list(current_meta.get("deprecated_phrases")),
)
current_path.write_text(document, encoding="utf-8")
self.invalidate_cache()
@@ -394,21 +388,11 @@ class AgentRuntimeManager:
existing_aliases = []
created = True
- final_label = (
- label.strip()
- if isinstance(label, str) and label.strip()
- else existing_label or target_persona_id
- )
+ final_label = label.strip() if isinstance(label, str) and label.strip() else existing_label or target_persona_id
final_description = (
- description.strip()
- if isinstance(description, str) and description.strip()
- else existing_description
- )
- final_aliases = (
- self._normalize_persona_aliases(aliases, "aliases")
- if aliases is not None
- else existing_aliases
+ description.strip() if isinstance(description, str) and description.strip() else existing_description
)
+ final_aliases = self._normalize_persona_aliases(aliases, "aliases") if aliases is not None else existing_aliases
final_body = (
self._normalize_persona_body(instructions)
if isinstance(instructions, str) and instructions.strip()
@@ -551,23 +535,16 @@ class AgentRuntimeManager:
current_doc = self._read_markdown(current_persona_path)
current_meta = current_doc.metadata
- active_persona = str(
- current_meta.get("active_persona") or DEFAULT_PERSONA_ID
- ).strip()
+ active_persona = str(current_meta.get("active_persona") or DEFAULT_PERSONA_ID).strip()
if not active_persona:
raise AgentRuntimeConfigError("CURRENT_PERSONA.md 缺少 active_persona")
- extra_context_paths = self._resolve_optional_paths(
- root, current_meta.get("extra_context_files", [])
- )
+ extra_context_paths = self._resolve_optional_paths(root, current_meta.get("extra_context_files", []))
available_personas = self._load_personas(root)
persona = self._resolve_persona_definition(active_persona, available_personas)
available_subagents = self._load_subagents(root)
- extra_contexts = [
- (path, self._read_markdown(path).body)
- for path in extra_context_paths
- ]
+ extra_contexts = [(path, self._read_markdown(path).body) for path in extra_context_paths]
warnings = self._validate_runtime_config(
current_meta=current_meta,
@@ -642,9 +619,7 @@ class AgentRuntimeManager:
if not subagent_path.exists():
continue
document = self._read_markdown(subagent_path)
- subagent_id = str(
- document.metadata.get("subagent_id") or subagent_dir.name
- ).strip()
+ subagent_id = str(document.metadata.get("subagent_id") or subagent_dir.name).strip()
if not subagent_id:
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 subagent_id")
if not PERSONA_ID_PATTERN.fullmatch(subagent_id):
@@ -710,9 +685,7 @@ class AgentRuntimeManager:
return persona
available = ", ".join(persona.persona_id for persona in personas)
- raise AgentRuntimeConfigError(
- f"未找到人格 `{persona_query}`,可用人格: {available}"
- )
+ raise AgentRuntimeConfigError(f"未找到人格 `{persona_query}`,可用人格: {available}")
@staticmethod
def _validate_new_persona_id(persona_id: str) -> str:
@@ -742,12 +715,10 @@ class AgentRuntimeManager:
try:
metadata = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError as err:
- raise AgentRuntimeConfigError(
- f"YAML frontmatter 解析失败 {path}: {err}"
- ) from err
+ raise AgentRuntimeConfigError(f"YAML frontmatter 解析失败 {path}: {err}") from err
if not isinstance(metadata, dict):
raise AgentRuntimeConfigError(f"frontmatter 必须是映射类型: {path}")
- body = content[match.end():]
+ body = content[match.end() :]
return ParsedMarkdownDocument(metadata=metadata, body=body.strip())
@staticmethod
@@ -850,7 +821,7 @@ class AgentRuntimeManager:
return default
try:
return int(value)
- except (TypeError, ValueError):
+ except TypeError, ValueError:
return default
def _validate_runtime_config(
@@ -865,14 +836,9 @@ class AgentRuntimeManager:
required_paths = [persona_path]
duplicates = self._find_duplicate_paths(required_paths + extra_context_paths)
if duplicates:
- warnings.append(
- "检测到重复引用的根层配置文件: "
- + ", ".join(path.as_posix() for path in duplicates)
- )
+ warnings.append("检测到重复引用的根层配置文件: " + ", ".join(path.as_posix() for path in duplicates))
- deprecated_phrases = self._normalize_string_list(
- current_meta.get("deprecated_phrases"), "deprecated_phrases"
- )
+ deprecated_phrases = self._normalize_string_list(current_meta.get("deprecated_phrases"), "deprecated_phrases")
if deprecated_phrases:
for phrase in deprecated_phrases:
if phrase and phrase in persona_text:
diff --git a/app/agent/skills/metadata.py b/app/agent/skills/metadata.py
index 99dab7c06..9fe5518ec 100644
--- a/app/agent/skills/metadata.py
+++ b/app/agent/skills/metadata.py
@@ -4,7 +4,6 @@ from typing import TypedDict
import yaml
-
logger = logging.getLogger(__name__)
# 磁盘读取上限属于 Skill 文档格式约束,市场扫描和 Agent 加载必须共用。
@@ -26,6 +25,7 @@ class SkillMetadata(TypedDict):
compatibility: str | None
metadata: dict[str, str]
allowed_tools: list[str]
+ allowed_api_operations: list[str]
def _validate_metadata(raw: object, skill_path: str) -> dict[str, str]:
@@ -48,9 +48,7 @@ def parse_skill_metadata( # noqa: C901
) -> SkillMetadata | None:
"""解析并校验一个 SKILL.md 的 YAML 前言。"""
if len(content) > MAX_SKILL_FILE_SIZE:
- logger.warning(
- "Skipping %s: content too large (%d bytes)", skill_path, len(content)
- )
+ logger.warning("Skipping %s: content too large (%d bytes)", skill_path, len(content))
return None
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
@@ -70,9 +68,7 @@ def parse_skill_metadata( # noqa: C901
name = str(frontmatter_data.get("name", "")).strip()
description = str(frontmatter_data.get("description", "")).strip()
if not name or not description:
- logger.warning(
- "Skipping %s: missing required 'name' or 'description'", skill_path
- )
+ logger.warning("Skipping %s: missing required 'name' or 'description'", skill_path)
return None
if len(description) > MAX_SKILL_DESCRIPTION_LENGTH:
logger.warning(
@@ -84,11 +80,7 @@ def parse_skill_metadata( # noqa: C901
raw_tools = frontmatter_data.get("allowed-tools")
if isinstance(raw_tools, str):
- allowed_tools = [
- tool.strip(",")
- for tool in raw_tools.split()
- if tool.strip(",")
- ]
+ allowed_tools = [tool.strip(",") for tool in raw_tools.split() if tool.strip(",")]
else:
if raw_tools is not None:
logger.warning(
@@ -98,6 +90,20 @@ def parse_skill_metadata( # noqa: C901
)
allowed_tools = []
+ raw_api_operations = frontmatter_data.get("allowed-api-operations")
+ if isinstance(raw_api_operations, str):
+ allowed_api_operations = [
+ operation.strip(",") for operation in raw_api_operations.split() if operation.strip(",")
+ ]
+ else:
+ if raw_api_operations is not None:
+ logger.warning(
+ "Ignoring non-string 'allowed-api-operations' in %s (got %s)",
+ skill_path,
+ type(raw_api_operations).__name__,
+ )
+ allowed_api_operations = []
+
compatibility = str(frontmatter_data.get("compatibility", "")).strip() or None
if compatibility and len(compatibility) > MAX_SKILL_COMPATIBILITY_LENGTH:
logger.warning(
@@ -112,7 +118,7 @@ def parse_skill_metadata( # noqa: C901
if raw_version is not None:
try:
version = int(raw_version)
- except (ValueError, TypeError):
+ except ValueError, TypeError:
logger.warning(
"Invalid 'version' in %s (got %r), defaulting to 0",
skill_path,
@@ -129,4 +135,5 @@ def parse_skill_metadata( # noqa: C901
license=str(frontmatter_data.get("license", "")).strip() or None,
compatibility=compatibility,
allowed_tools=allowed_tools,
+ allowed_api_operations=allowed_api_operations,
)
diff --git a/app/agent/tools/catalog.py b/app/agent/tools/catalog.py
index f04c49055..a8a76f30d 100644
--- a/app/agent/tools/catalog.py
+++ b/app/agent/tools/catalog.py
@@ -84,9 +84,7 @@ class ToolCatalogSnapshot:
object.__setattr__(
self,
"_by_name",
- MappingProxyType(
- {name: tuple(matches) for name, matches in by_name.items()}
- ),
+ MappingProxyType({name: tuple(matches) for name, matches in by_name.items()}),
)
@classmethod
@@ -105,9 +103,7 @@ class ToolCatalogSnapshot:
raise ToolCatalogError("工具缺少稳定名称")
source = str(getattr(tool, "_agent_tool_source", "builtin"))
schema_digest = _schema_digest(tool)
- description_digest = hashlib.sha256(
- str(getattr(tool, "description", "") or "").encode("utf-8")
- ).hexdigest()
+ description_digest = hashlib.sha256(str(getattr(tool, "description", "") or "").encode("utf-8")).hexdigest()
implementation = _implementation_identity(tool)
revision = ToolRevision(
implementation=implementation,
@@ -139,9 +135,7 @@ class ToolCatalogSnapshot:
@property
def collisions(self) -> Mapping[str, tuple[ToolCatalogEntry, ...]]:
"""返回所有同名工具,不按注册顺序隐式选胜者。"""
- return MappingProxyType(
- {name: entries for name, entries in self._by_name.items() if len(entries) > 1}
- )
+ return MappingProxyType({name: entries for name, entries in self._by_name.items() if len(entries) > 1})
@property
def signature(self) -> tuple[Any, ...]:
@@ -167,15 +161,18 @@ class ToolCatalogSnapshot:
raise ToolIdentityAmbiguousError("TOOL_IDENTITY_AMBIGUOUS")
return entries[0] if entries else None
+ def require_unique(self) -> "ToolCatalogSnapshot":
+ """拒绝任何同名身份,保证目录可直接交给执行图。"""
+ if self.collisions:
+ names = ", ".join(sorted(self.collisions))
+ raise ToolIdentityAmbiguousError(f"TOOL_IDENTITY_AMBIGUOUS: {names}")
+ return self
+
def select(self, tools: list[Any]) -> "ToolCatalogSnapshot":
"""为子图保留所选工具名的全部候选身份与 revision 语义。"""
- selected_names = {
- str(getattr(tool, "name", "") or "") for tool in tools
- }
+ selected_names = {str(getattr(tool, "name", "") or "") for tool in tools}
return ToolCatalogSnapshot(
- entries=tuple(
- entry for entry in self.entries if entry.name in selected_names
- ),
+ entries=tuple(entry for entry in self.entries if entry.name in selected_names),
plugin_revision=self.plugin_revision,
factory_revision=self.factory_revision,
)
diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py
index 7c68df2ba..45711497c 100644
--- a/app/agent/tools/factory.py
+++ b/app/agent/tools/factory.py
@@ -2,90 +2,21 @@ import hashlib
from typing import Callable, List, Optional, Type
from app.agent.llm.capability import AgentCapabilityManager
-from app.agent.tools.impl.add_custom_filter_rule import AddCustomFilterRuleTool
-from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
-from app.agent.tools.impl.add_rule_group import AddRuleGroupTool
-from app.agent.tools.impl.add_subscribe import AddSubscribeTool
+from app.agent.tools.impl.agent_task import AgentTaskTool
+from app.agent.tools.impl.api import MoviePilotApiTool
from app.agent.tools.impl.apply_patch import ApplyPatchTool
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
from app.agent.tools.impl.browse_webpage import BrowseWebpageTool
-from app.agent.tools.impl.create_agent_task import CreateAgentTaskTool
-from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool
-from app.agent.tools.impl.delete_custom_filter_rule import DeleteCustomFilterRuleTool
-from app.agent.tools.impl.delete_download_history import DeleteDownloadHistoryTool
-from app.agent.tools.impl.delete_download_tasks import DeleteDownloadTasksTool
-from app.agent.tools.impl.delete_rule_group import DeleteRuleGroupTool
-from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
-from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
from app.agent.tools.impl.edit_file import EditFileTool
from app.agent.tools.impl.execute_command import ExecuteCommandTool
-from app.agent.tools.impl.get_recommendations import GetRecommendationsTool
-from app.agent.tools.impl.get_search_results import GetSearchResultsTool
-from app.agent.tools.impl.install_plugin import InstallPluginTool
-from app.agent.tools.impl.list_directory import ListDirectoryTool
-from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
-from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
-from app.agent.tools.impl.query_builtin_filter_rules import QueryBuiltinFilterRulesTool
-from app.agent.tools.impl.query_custom_filter_rules import QueryCustomFilterRulesTool
-from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
-from app.agent.tools.impl.query_directory_settings import QueryDirectorySettingsTool
+from app.agent.tools.impl.persona import PersonaTool
from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool
-from app.agent.tools.impl.query_download_tasks import QueryDownloadTasksTool
-from app.agent.tools.impl.query_downloaders import QueryDownloadersTool
-from app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool
-from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
-from app.agent.tools.impl.query_library_exists import QueryLibraryExistsTool
-from app.agent.tools.impl.query_library_latest import QueryLibraryLatestTool
-from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool
-from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
-from app.agent.tools.impl.query_personas import QueryPersonasTool
-from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool
-from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
-from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool
-from app.agent.tools.impl.query_popular_subscribes import QueryPopularSubscribesTool
-from app.agent.tools.impl.query_rule_groups import QueryRuleGroupsTool
-from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
-from app.agent.tools.impl.query_site_userdata import QuerySiteUserdataTool
-from app.agent.tools.impl.query_sites import QuerySitesTool
-from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool
-from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool
-from app.agent.tools.impl.query_subscribes import QuerySubscribesTool
-from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
-from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool
-from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
from app.agent.tools.impl.read_file import ReadFileTool
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
-from app.agent.tools.impl.recognize_media import RecognizeMediaTool
-from app.agent.tools.impl.reload_plugin import ReloadPluginTool
-from app.agent.tools.impl.run_agent_task import RunAgentTaskTool
-from app.agent.tools.impl.run_scheduler import RunSchedulerTool
-from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
-from app.agent.tools.impl.run_workflow import RunWorkflowTool
-from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
-from app.agent.tools.impl.search_media import SearchMediaTool
-from app.agent.tools.impl.search_person import SearchPersonTool
-from app.agent.tools.impl.search_person_credits import SearchPersonCreditsTool
-from app.agent.tools.impl.search_subscribe import SearchSubscribeTool
-from app.agent.tools.impl.search_torrents import SearchTorrentsTool
from app.agent.tools.impl.search_web import SearchWebTool
from app.agent.tools.impl.send_local_file import SendLocalFileTool
from app.agent.tools.impl.send_message import SendMessageTool
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
-from app.agent.tools.impl.switch_persona import SwitchPersonaTool
-from app.agent.tools.impl.test_site import TestSiteTool
-from app.agent.tools.impl.transfer_file import TransferFileTool
-from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
-from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool
-from app.agent.tools.impl.update_custom_filter_rule import UpdateCustomFilterRuleTool
-from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool
-from app.agent.tools.impl.update_download_tasks import UpdateDownloadTasksTool
-from app.agent.tools.impl.update_persona_definition import UpdatePersonaDefinitionTool
-from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool
-from app.agent.tools.impl.update_rule_group import UpdateRuleGroupTool
-from app.agent.tools.impl.update_site import UpdateSiteTool
-from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
-from app.agent.tools.impl.update_subscribe import UpdateSubscribeTool
-from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
from app.agent.tools.impl.write_file import WriteFileTool
from app.application.agent import AgentDataContext
from app.application.plugin.runtime import get_plugin_manager
@@ -115,103 +46,32 @@ class MoviePilotToolFactory:
"""
BUILTIN_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = (
- SearchMediaTool,
- SearchPersonTool,
- SearchPersonCreditsTool,
- RecognizeMediaTool,
- ScrapeMetadataTool,
- QueryEpisodeScheduleTool,
- QueryMediaDetailTool,
- AddSubscribeTool,
- UpdateSubscribeTool,
- SearchSubscribeTool,
- SearchTorrentsTool,
- GetSearchResultsTool,
SearchWebTool,
RecognizeCaptchaTool,
- AddDownloadTasksTool,
- QuerySubscribesTool,
- QuerySubscribeSharesTool,
- QueryPopularSubscribesTool,
- QueryBuiltinFilterRulesTool,
- QueryCustomFilterRulesTool,
- QueryRuleGroupsTool,
- AddCustomFilterRuleTool,
- UpdateCustomFilterRuleTool,
- DeleteCustomFilterRuleTool,
- AddRuleGroupTool,
- UpdateRuleGroupTool,
- DeleteRuleGroupTool,
- QuerySubscribeHistoryTool,
- DeleteSubscribeTool,
- QueryDownloadTasksTool,
- DeleteDownloadTasksTool,
- DeleteDownloadHistoryTool,
- DeleteTransferHistoryTool,
- UpdateDownloadTasksTool,
- QueryDownloadersTool,
- QuerySitesTool,
- UpdateSiteTool,
- QuerySiteUserdataTool,
- TestSiteTool,
- UpdateSiteCookieTool,
- GetRecommendationsTool,
- QueryLibraryExistsTool,
- QueryLibraryLatestTool,
- QueryDirectorySettingsTool,
- ListDirectoryTool,
- QueryTransferHistoryTool,
- TransferFileTool,
SendMessageTool,
- CreateAgentTaskTool,
- QueryAgentTasksTool,
- UpdateAgentTaskTool,
- RunAgentTaskTool,
- DeleteAgentTaskTool,
- QuerySchedulersTool,
- RunSchedulerTool,
- QueryWorkflowsTool,
- RunWorkflowTool,
- QueryPersonasTool,
- SwitchPersonaTool,
- UpdatePersonaDefinitionTool,
+ AgentTaskTool,
+ PersonaTool,
ExecuteCommandTool,
EditFileTool,
ApplyPatchTool,
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
- QueryInstalledPluginsTool,
- QueryMarketPluginsTool,
- QueryPluginCapabilitiesTool,
- QueryPluginConfigTool,
- UpdatePluginConfigTool,
- ReloadPluginTool,
- QueryPluginDataTool,
- InstallPluginTool,
- UninstallPluginTool,
- RunSlashCommandTool,
- ListSlashCommandsTool,
QueryDoctorReportTool,
- QueryCustomIdentifiersTool,
- UpdateCustomIdentifiersTool,
- QuerySystemSettingsTool,
- UpdateSystemSettingsTool,
)
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
# 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
- "list_directory",
+ "moviepilot_api",
"write_file",
"read_file",
"edit_file",
"apply_patch",
"execute_command",
"ask_user_choice",
- "create_agent_task",
- "query_agent_tasks",
+ "agent_task",
)
CATALOG_BUILD_MAX_ATTEMPTS = 3
@@ -219,10 +79,7 @@ class MoviePilotToolFactory:
@classmethod
def catalog_factory_revision(cls) -> str:
"""返回当前内置工具工厂定义的稳定摘要。"""
- identities = (
- f"{tool_class.__module__}.{tool_class.__qualname__}"
- for tool_class in cls.BUILTIN_TOOL_CLASSES
- )
+ identities = (f"{tool_class.__module__}.{tool_class.__qualname__}" for tool_class in cls.BUILTIN_TOOL_CLASSES)
return hashlib.sha256("\n".join(identities).encode("utf-8")).hexdigest()
@staticmethod
@@ -238,28 +95,18 @@ class MoviePilotToolFactory:
) and ChannelCapabilityManager.supports_callbacks(message_channel)
@classmethod
- def get_tool_selector_always_include_names(
- cls, tools: List[MoviePilotTool]
- ) -> List[str]:
+ def get_tool_selector_always_include_names(cls, tools: List[MoviePilotTool]) -> List[str]:
"""
返回当前实际已加载且需要绕过工具筛选的工具名。
`LLMToolSelectorMiddleware` 会校验 `always_include` 中的工具名是否
存在于当前请求里,因此这里必须根据运行时工具列表做交集过滤。
"""
- available_tool_names = {
- tool.name for tool in tools if getattr(tool, "name", None)
- }
- return [
- tool_name
- for tool_name in cls.TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES
- if tool_name in available_tool_names
- ]
+ available_tool_names = {tool.name for tool in tools if getattr(tool, "name", None)}
+ return [tool_name for tool_name in cls.TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES if tool_name in available_tool_names]
@classmethod
- def _get_builtin_tool_classes(
- cls, channel: Optional[str] = None
- ) -> list[Type[MoviePilotTool]]:
+ def _get_builtin_tool_classes(cls, channel: Optional[str] = None) -> list[Type[MoviePilotTool]]:
"""
返回当前渠道可用的内置工具类清单。
"""
@@ -269,8 +116,20 @@ class MoviePilotToolFactory:
tool_definitions.append(SendLocalFileTool)
if AgentCapabilityManager.supports_audio_output():
tool_definitions.append(SendVoiceMessageTool)
+ tool_definitions.append(MoviePilotApiTool)
return tool_definitions
+ @staticmethod
+ def _tool_class_name(tool_class: Type[MoviePilotTool]) -> str:
+ """读取工具类的稳定名称,兼容 Pydantic 字段未直接暴露在类对象上的情况。"""
+ name = getattr(tool_class, "name", None)
+ if isinstance(name, str) and name:
+ return name
+ fields = getattr(tool_class, "model_fields", {})
+ field = fields.get("name") if isinstance(fields, dict) else None
+ default = getattr(field, "default", None)
+ return default if isinstance(default, str) else ""
+
@classmethod
def create_tools(
cls,
@@ -285,7 +144,11 @@ class MoviePilotToolFactory:
data: Optional[AgentDataContext] = None,
) -> List[MoviePilotTool]:
"""
- 创建MoviePilot工具列表
+ 创建统一 MoviePilot 工具列表。
+
+ 主 Agent、子 Agent、HTTP/MCP 管理器均使用同一新模式:业务能力由
+ `moviepilot_api` 结构化网关承载,只有宿主交互与 Agent 编排能力保留为
+ 原生工具。
"""
tools = []
tool_definitions = cls._get_builtin_tool_classes(channel)
@@ -319,9 +182,7 @@ class MoviePilotToolFactory:
tool = ToolClass(session_id=session_id, user_id=user_id)
if not allow_message_tools and getattr(tool, "sends_message", False):
continue
- tool.set_message_attr(
- channel=channel, source=source, username=username
- )
+ tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
object.__setattr__(
@@ -331,13 +192,9 @@ class MoviePilotToolFactory:
)
tools.append(tool)
plugin_tools_count += 1
- logger.debug(
- f"成功加载插件 {plugin_name}({plugin_id}) 的工具: {ToolClass.__name__}"
- )
+ logger.debug(f"成功加载插件 {plugin_name}({plugin_id}) 的工具: {ToolClass.__name__}")
except Exception as e:
- logger.error(
- f"加载插件 {plugin_name}({plugin_id}) 的工具 {ToolClass.__name__} 失败: {str(e)}"
- )
+ logger.error(f"加载插件 {plugin_name}({plugin_id}) 的工具 {ToolClass.__name__} 失败: {str(e)}")
builtin_tools_count = len(tool_definitions)
if plugin_tools_count > 0:
@@ -362,7 +219,7 @@ class MoviePilotToolFactory:
cls.create_tools(**tool_kwargs),
plugin_revision=0,
factory_revision=cls.catalog_factory_revision(),
- )
+ ).require_unique()
for _attempt in range(cls.CATALOG_BUILD_MAX_ATTEMPTS):
before_revision = plugin_manager.get_plugin_agent_tools_revision()
tools = cls.create_tools(**tool_kwargs)
@@ -372,5 +229,5 @@ class MoviePilotToolFactory:
tools,
plugin_revision=after_revision,
factory_revision=cls.catalog_factory_revision(),
- )
+ ).require_unique()
raise ToolCatalogError("插件工具目录持续变化,无法建立当前快照")
diff --git a/app/agent/tools/impl/_filter_rule_utils.py b/app/agent/tools/impl/_filter_rule_utils.py
deleted file mode 100644
index 77cffeb3b..000000000
--- a/app/agent/tools/impl/_filter_rule_utils.py
+++ /dev/null
@@ -1,465 +0,0 @@
-"""过滤规则 Agent 工具共用的校验、查询和引用处理逻辑。"""
-
-import copy
-import re
-from typing import Any, Dict, Iterable, Optional
-
-from app.application.configuration import get_configured_system_config
-from app.application.rules import (
- BUILTIN_RULE_SET,
- RuleHelper,
- RuleParser,
-)
-from app.application.subscription.contract import SubscriptionRepository
-from app.runtime.events import eventmanager
-from app.schemas.event import ConfigChangeEventData
-from app.schemas.rule import CustomRule
-from app.schemas.system import FilterRuleGroup
-from app.schemas.types import EventType, SystemConfigKey
-
-RULE_ID_PATTERN = re.compile(r"^[A-Za-z0-9]+$")
-RULE_TOKEN_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9]*|[0-9][A-Za-z0-9]+")
-NUMERIC_RANGE_PATTERN = re.compile(
- r"^\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?$"
-)
-
-MEDIA_TYPE_ALIASES = {
- "movie": "电影",
- "film": "电影",
- "tv": "电视剧",
- "series": "电视剧",
- "show": "电视剧",
- "music": "音乐",
- "电影": "电影",
- "电视剧": "电视剧",
- "音乐": "音乐",
-}
-
-RULE_STRING_SYNTAX = {
- "level_separator": ">",
- "and_operator": "&",
- "not_operator": "!",
- "supported_grouping": "Parentheses are supported inside a single level.",
- "spacing_note": "Prefer spaces around '&', and '>' for readability; use '!RULE' for negation.",
- "match_order": "Levels are evaluated from left to right. The first matched level wins and stops further matching.",
- "match_result": "If no level matches, the torrent is filtered out. If a level matches, the torrent is kept.",
- "writing_workflow": [
- "First query built-in rules and custom rules to learn valid rule IDs.",
- "Compose one priority level with '&', '!' and optional parentheses.",
- "Join multiple priority levels with '>' from highest priority to lowest priority.",
- "Use spaces around '&', and '>' for readability.",
- ],
- "examples": [
- {
- "description": "Prefer torrents with special subtitles and Chinese dubbing at 4K, otherwise fall back to Chinese subtitles and Chinese dubbing at 4K.",
- "rule_string": "SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL",
- },
- {
- "description": "Inside one level, require 4K and reject Blu-ray source.",
- "rule_string": "4K & !BLU",
- },
- {
- "description": "Inside one level, accept either special subtitles or Chinese subtitles, then also require 1080P.",
- "rule_string": "(SPECSUB | CNSUB) & 1080P",
- },
- ],
-}
-
-
-def normalize_optional_text(value: Optional[str]) -> Optional[str]:
- """把空白字符串折叠为 None,避免保存无意义的空值。"""
- if value is None:
- return None
- value = str(value).strip()
- return value or None
-
-
-def normalize_media_type(value: Optional[str]) -> Optional[str]:
- """兼容英中文媒体类型输入,最终统一为后端实际使用的中文值。"""
- value = normalize_optional_text(value)
- if not value:
- return None
- normalized = MEDIA_TYPE_ALIASES.get(value.lower(), value)
- if normalized not in {"电影", "电视剧", "音乐"}:
- raise ValueError(
- "media_type 仅支持 '电影'、'电视剧'、'音乐'、'movie'、'tv' 或 'music'"
- )
- return normalized
-
-
-def validate_numeric_range(
- field_name: str, value: Optional[str]
-) -> Optional[str]:
- """校验 size_range / publish_time 这类单值或区间值。"""
- value = normalize_optional_text(value)
- if not value:
- return None
- if not NUMERIC_RANGE_PATTERN.match(value):
- raise ValueError(
- f"{field_name} 格式无效,支持 '1000' 或 '1000-5000' 这类数字区间格式"
- )
-
- parts = [float(item.strip()) for item in value.split("-")]
- if len(parts) == 2 and parts[0] > parts[1]:
- raise ValueError(f"{field_name} 区间起始值不能大于结束值")
- return value
-
-
-def validate_seeders(value: Optional[str]) -> Optional[str]:
- """做种人数最终会被 int() 解析,这里提前拦住非法值。"""
- value = normalize_optional_text(value)
- if not value:
- return None
- if not value.isdigit():
- raise ValueError("seeders 必须是非负整数")
- return value
-
-
-def get_builtin_rules() -> Dict[str, dict]:
- """返回内置规则的深拷贝,避免调用方误改共享常量。"""
- return copy.deepcopy(BUILTIN_RULE_SET)
-
-
-def get_custom_rules() -> list[CustomRule]:
- return RuleHelper().get_custom_rules()
-
-
-def get_rule_groups() -> list[FilterRuleGroup]:
- return RuleHelper().get_rule_groups()
-
-
-def build_custom_rule_map(rules: Optional[Iterable[CustomRule]] = None) -> Dict[str, CustomRule]:
- return {
- rule.id: rule
- for rule in (rules or get_custom_rules())
- if rule.id
- }
-
-
-def build_rule_group_map(
- groups: Optional[Iterable[FilterRuleGroup]] = None,
-) -> Dict[str, FilterRuleGroup]:
- return {
- group.name: group
- for group in (groups or get_rule_groups())
- if group.name
- }
-
-
-def extract_rule_tokens(rule_string: Optional[str]) -> list[str]:
- """从规则串里提取规则 ID,用于引用分析和未知规则校验。"""
- if not rule_string:
- return []
- # dict.fromkeys 用来在保留顺序的同时去重,便于展示和报错。
- return list(dict.fromkeys(RULE_TOKEN_PATTERN.findall(rule_string)))
-
-
-def parse_rule_string(rule_string: str) -> dict:
- """使用后端同款 RuleParser 解析规则串,并拆出每一层的元数据。"""
- normalized = normalize_optional_text(rule_string)
- if not normalized:
- raise ValueError("rule_string 不能为空")
-
- parser = RuleParser()
- levels = [level.strip() for level in normalized.split(">")]
- if any(not level for level in levels):
- raise ValueError("rule_string 不能包含空层级,请检查 '>' 两侧内容")
-
- parsed_levels = []
- for index, level in enumerate(levels, start=1):
- try:
- parser.parse(level)
- except Exception as exc: # pragma: no cover - 依赖 pyparsing 的具体异常
- raise ValueError(f"规则串第 {index} 层语法错误: {exc}") from exc
-
- parsed_levels.append(
- {
- "priority": index,
- "expression": level,
- "referenced_rules": extract_rule_tokens(level),
- }
- )
-
- return {
- "rule_string": " > ".join(levels),
- "levels": parsed_levels,
- "referenced_rules": extract_rule_tokens(normalized),
- }
-
-
-def validate_rule_string(rule_string: str, available_rule_ids: Iterable[str]) -> dict:
- """校验规则串语法和引用规则是否都存在。"""
- parsed = parse_rule_string(rule_string)
- available_ids = set(available_rule_ids)
- unknown_rules = sorted(
- {
- rule_id
- for rule_id in parsed["referenced_rules"]
- if rule_id not in available_ids
- }
- )
- if unknown_rules:
- raise ValueError(
- f"rule_string 引用了不存在的规则: {', '.join(unknown_rules)}"
- )
- return parsed
-
-
-def serialize_builtin_rule(rule_id: str, payload: dict) -> dict:
- """把内置规则整理成适合 Agent 阅读的结构。"""
- data = copy.deepcopy(payload)
- data["id"] = rule_id
- data["source"] = "builtin"
- return data
-
-
-def serialize_custom_rule(rule: CustomRule, group_refs: Optional[list[str]] = None) -> dict:
- data = rule.model_dump(exclude_none=True)
- data["source"] = "custom"
- data["referenced_by_rule_groups"] = group_refs or []
- return data
-
-
-def serialize_rule_group(group: FilterRuleGroup, usage: Optional[dict] = None) -> dict:
- """查询时尽量附带解析结果,便于 Agent 理解优先级层级。"""
- data = group.model_dump(exclude_none=True)
- if group.rule_string:
- try:
- parsed = parse_rule_string(group.rule_string)
- data["levels"] = parsed["levels"]
- data["referenced_rules"] = parsed["referenced_rules"]
- data["syntax_valid"] = True
- except ValueError as exc:
- data["syntax_valid"] = False
- data["syntax_error"] = str(exc)
- data["referenced_rules"] = extract_rule_tokens(group.rule_string)
- else:
- data["syntax_valid"] = False
- data["syntax_error"] = "rule_string 为空"
- data["referenced_rules"] = []
- data["usage"] = usage or default_rule_group_usage()
- return data
-
-
-def default_rule_group_usage() -> dict:
- return {
- "used_in_global_search": False,
- "used_in_global_subscribe": False,
- "used_in_global_best_version": False,
- "subscribes": [],
- }
-
-
-async def collect_rule_group_usages(
- repository: SubscriptionRepository,
- group_names: Optional[Iterable[str]] = None,
-) -> Dict[str, dict]:
- """收集规则组在全局配置和订阅上的引用情况。"""
- target_names = set(group_names or [])
- search_groups = set(
- get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or []
- )
- subscribe_groups = set(
- get_configured_system_config().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
- )
- best_version_groups = set(
- get_configured_system_config().get(SystemConfigKey.BestVersionFilterRuleGroups) or []
- )
-
- usage_map = {
- name: default_rule_group_usage()
- for name in target_names
- }
-
- def ensure_usage(name: str) -> dict:
- if name not in usage_map:
- usage_map[name] = default_rule_group_usage()
- return usage_map[name]
-
- for name in search_groups:
- if target_names and name not in target_names:
- continue
- ensure_usage(name)["used_in_global_search"] = True
- for name in subscribe_groups:
- if target_names and name not in target_names:
- continue
- ensure_usage(name)["used_in_global_subscribe"] = True
- for name in best_version_groups:
- if target_names and name not in target_names:
- continue
- ensure_usage(name)["used_in_global_best_version"] = True
-
- subscribes = await repository.async_list()
- for subscribe in subscribes:
- filter_groups = [str(name) for name in subscribe.filter_groups] \
- if isinstance(subscribe.filter_groups, list) else []
- for name in filter_groups:
- if target_names and name not in target_names:
- continue
- ensure_usage(name)["subscribes"].append(
- {
- "subscribe_id": subscribe.id,
- "name": subscribe.name,
- "season": subscribe.season,
- "type": subscribe.type,
- "username": subscribe.username,
- "best_version": bool(subscribe.best_version),
- }
- )
-
- return usage_map
-
-
-def collect_custom_rule_group_refs(
- rule_groups: Iterable[FilterRuleGroup],
- rule_ids: Optional[Iterable[str]] = None,
-) -> Dict[str, list[str]]:
- """收集自定义规则被哪些规则组引用。"""
- target_rule_ids = set(rule_ids or [])
- refs: Dict[str, list[str]] = {
- rule_id: []
- for rule_id in target_rule_ids
- }
-
- for group in rule_groups:
- if not group.name or not group.rule_string:
- continue
- referenced = set(extract_rule_tokens(group.rule_string))
- for rule_id in referenced:
- if target_rule_ids and rule_id not in target_rule_ids:
- continue
- refs.setdefault(rule_id, []).append(group.name)
-
- for names in refs.values():
- names.sort()
- return refs
-
-
-def normalize_custom_rule(
- rule_id: str,
- name: str,
- include: Optional[str],
- exclude: Optional[str],
- size_range: Optional[str],
- seeders: Optional[str],
- publish_time: Optional[str],
- existing_rules: Iterable[CustomRule],
- original_rule_id: Optional[str] = None,
-) -> CustomRule:
- """新增/更新自定义规则时统一走这里,避免多处散落校验逻辑。"""
- normalized_rule_id = normalize_optional_text(rule_id)
- normalized_name = normalize_optional_text(name)
- if not normalized_rule_id:
- raise ValueError("rule_id 不能为空")
- if not normalized_name:
- raise ValueError("name 不能为空")
- if not RULE_ID_PATTERN.match(normalized_rule_id):
- raise ValueError("rule_id 仅支持英文字母和数字")
- if (
- normalized_rule_id in BUILTIN_RULE_SET
- and normalized_rule_id != original_rule_id
- ):
- raise ValueError(
- f"rule_id '{normalized_rule_id}' 与内置规则冲突,不能覆盖内置规则"
- )
-
- for existing_rule in existing_rules:
- if (
- existing_rule.id == normalized_rule_id
- and existing_rule.id != original_rule_id
- ):
- raise ValueError(f"rule_id '{normalized_rule_id}' 已存在")
- if (
- existing_rule.name == normalized_name
- and existing_rule.id != original_rule_id
- ):
- raise ValueError(f"规则名称 '{normalized_name}' 已存在")
-
- return CustomRule(
- id=normalized_rule_id,
- name=normalized_name,
- include=normalize_optional_text(include),
- exclude=normalize_optional_text(exclude),
- size_range=validate_numeric_range("size_range", size_range),
- seeders=validate_seeders(seeders),
- publish_time=validate_numeric_range("publish_time", publish_time),
- )
-
-
-def normalize_rule_group(
- name: str,
- rule_string: str,
- media_type: Optional[str],
- category: Optional[str],
- existing_groups: Iterable[FilterRuleGroup],
- available_rule_ids: Iterable[str],
- original_name: Optional[str] = None,
-) -> tuple[FilterRuleGroup, dict]:
- """新增/更新规则组时统一校验名字、适用范围和规则串。"""
- normalized_name = normalize_optional_text(name)
- if not normalized_name:
- raise ValueError("规则组名称不能为空")
-
- for group in existing_groups:
- if group.name == normalized_name and group.name != original_name:
- raise ValueError(f"规则组名称 '{normalized_name}' 已存在")
-
- normalized_media_type = normalize_media_type(media_type)
- normalized_category = normalize_optional_text(category)
- if normalized_category and not normalized_media_type:
- raise ValueError("设置 category 时必须同时设置 media_type")
-
- parsed = validate_rule_string(rule_string, available_rule_ids)
- return (
- FilterRuleGroup(
- name=normalized_name,
- rule_string=parsed["rule_string"],
- media_type=normalized_media_type,
- category=normalized_category,
- ),
- parsed,
- )
-
-
-async def save_system_config(
- key: SystemConfigKey, value: Any
-) -> Optional[bool]:
- """通过统一入口保存配置并补发 ConfigChanged 事件。"""
- normalized_value = value
- if isinstance(normalized_value, list):
- normalized_value = [
- item
- for item in normalized_value
- if item is not None and item != ""
- ]
- normalized_value = normalized_value or None
-
- success = await get_configured_system_config().async_set(key, normalized_value)
- if success:
- await publish_rule_config_changed(key, normalized_value)
- return success
-
-
-async def publish_rule_config_changed(
- key: SystemConfigKey,
- value: Any,
-) -> None:
- """广播一项已经提交的规则配置变更。"""
- await eventmanager.async_send_event(
- etype=EventType.ConfigChanged,
- data=ConfigChangeEventData(
- key=key,
- value=value,
- change_type="update",
- ),
- )
-
-
-def replace_rule_id_in_rule_string(
- rule_string: str, old_rule_id: str, new_rule_id: str
-) -> str:
- """只替换完整 token,避免误伤其他规则名。"""
- pattern = re.compile(
- rf"(? str:
- """生成用于下载校验的短引用"""
- if not context or not context.torrent_info:
- return ""
- return HashUtils.sha1(context.torrent_info.enclosure or "")[:7]
-
-
-def sort_season_options(options: List[str]) -> List[str]:
- """按前端逻辑排序季集选项"""
- if len(options) <= 1:
- return options
-
- parsed_options = []
- for index, option in enumerate(options):
- match = re.match(r"^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$", option or "")
- if not match:
- parsed_options.append({
- "original": option,
- "season_num": 0,
- "episode_num": 0,
- "max_episode_num": 0,
- "is_whole_season": False,
- "index": index,
- })
- continue
-
- episode_num = int(match.group(3)) if match.group(3) else 0
- max_episode_num = int(match.group(4)) if match.group(4) else episode_num
- parsed_options.append({
- "original": option,
- "season_num": int(match.group(1)),
- "episode_num": episode_num,
- "max_episode_num": max_episode_num,
- "is_whole_season": not match.group(3),
- "index": index,
- })
-
- whole_seasons = [item for item in parsed_options if item["is_whole_season"]]
- episodes = [item for item in parsed_options if not item["is_whole_season"]]
-
- whole_seasons.sort(key=lambda item: (-item["season_num"], item["index"]))
- episodes.sort(
- key=lambda item: (
- -item["season_num"],
- -(item["max_episode_num"] or item["episode_num"]),
- -item["episode_num"],
- item["index"],
- )
- )
- return [item["original"] for item in whole_seasons + episodes]
-
-
-def append_option(options: List[str], value: Optional[str]) -> None:
- """按前端逻辑收集去重后的筛选项"""
- if value and value not in options:
- options.append(value)
-
-
-def build_filter_options(items: List[Context]) -> dict:
- """从搜索结果中构建筛选项汇总"""
- filter_options = {
- "site": [],
- "season": [],
- "freeState": [],
- "edition": [],
- "resolution": [],
- "videoCode": [],
- "releaseGroup": [],
- }
-
- for item in items:
- torrent_info = item.torrent_info
- meta_info = item.meta_info
- append_option(filter_options["site"], getattr(torrent_info, "site_name", None))
- append_option(filter_options["season"], getattr(meta_info, "season_episode", None))
- append_option(filter_options["freeState"], getattr(torrent_info, "volume_factor", None))
- append_option(filter_options["edition"], getattr(meta_info, "edition", None))
- append_option(filter_options["resolution"], getattr(meta_info, "resource_pix", None))
- append_option(filter_options["videoCode"], getattr(meta_info, "video_encode", None))
- append_option(filter_options["releaseGroup"], getattr(meta_info, "resource_team", None))
-
- filter_options["season"] = sort_season_options(filter_options["season"])
- return filter_options
-
-
-def match_filter(filter_values: Optional[List[str]], value: Optional[str]) -> bool:
- """匹配前端同款多选筛选规则"""
- return not filter_values or bool(value and value in filter_values)
-
-
-def filter_contexts(items: List[Context],
- site: Optional[List[str]] = None,
- season: Optional[List[str]] = None,
- free_state: Optional[List[str]] = None,
- video_code: Optional[List[str]] = None,
- edition: Optional[List[str]] = None,
- resolution: Optional[List[str]] = None,
- release_group: Optional[List[str]] = None) -> List[Context]:
- """按前端同款维度筛选结果"""
- filtered_items = []
- for item in items:
- torrent_info = item.torrent_info
- meta_info = item.meta_info
- if (
- match_filter(site, getattr(torrent_info, "site_name", None))
- and match_filter(free_state, getattr(torrent_info, "volume_factor", None))
- and match_filter(season, getattr(meta_info, "season_episode", None))
- and match_filter(release_group, getattr(meta_info, "resource_team", None))
- and match_filter(video_code, getattr(meta_info, "video_encode", None))
- and match_filter(resolution, getattr(meta_info, "resource_pix", None))
- and match_filter(edition, getattr(meta_info, "edition", None))
- ):
- filtered_items.append(item)
- return filtered_items
-
-
-def simplify_search_result(
- context: Context,
- index: int,
- include_description: bool = False,
- include_labels: bool = False,
-) -> dict:
- """
- 精简单条搜索结果
-
- :param context: 搜索结果上下文
- :param index: 搜索结果在原始缓存中的序号
- :param include_description: 是否返回种子简介
- :param include_labels: 是否返回种子标签
- :return: 精简后的搜索结果
- """
- simplified = {}
- torrent_info = context.torrent_info
- meta_info = context.meta_info
- media_info = context.media_info
-
- if torrent_info:
- simplified["torrent_info"] = {
- "title": torrent_info.title,
- "size": size_tools.format_size(torrent_info.size),
- "seeders": torrent_info.seeders,
- "peers": torrent_info.peers,
- "site_name": torrent_info.site_name,
- "torrent_url": f"{build_torrent_ref(context)}:{index}",
- "page_url": torrent_info.page_url,
- "volume_factor": torrent_info.volume_factor,
- "freedate_diff": torrent_info.freedate_diff,
- "pubdate": torrent_info.pubdate,
- }
- if include_description:
- simplified["torrent_info"]["description"] = torrent_info.description
- if include_labels:
- simplified["torrent_info"]["labels"] = torrent_info.labels or []
-
- if media_info:
- if getattr(media_info, "type", None) == MediaType.MUSIC:
- simplified["media_info"] = simplify_music_info(media_info)
- else:
- simplified["media_info"] = {
- "title": getattr(media_info, "title", None),
- "en_title": getattr(media_info, "en_title", None),
- "year": getattr(media_info, "year", None),
- "type": media_type_to_agent(getattr(media_info, "type", None)),
- "season": getattr(media_info, "season", None),
- "tmdb_id": getattr(media_info, "tmdb_id", None),
- }
-
- if meta_info:
- if getattr(meta_info, "type", None) == MediaType.MUSIC:
- simplified["meta_info"] = {
- key: getattr(meta_info, key, None)
- for key in (
- "title", "artists", "album", "album_artist", "year",
- "disc_number", "track_number", "total_tracks", "version",
- "audio_format", "bit_depth", "sample_rate", "bitrate",
- "duration", "isrc", "media_source", "media_id",
- )
- if getattr(meta_info, key, None) not in (None, "", [])
- }
- simplified["meta_info"]["type"] = "music"
- else:
- simplified["meta_info"] = {
- "name": getattr(meta_info, "name", None),
- "cn_name": getattr(meta_info, "cn_name", None),
- "en_name": getattr(meta_info, "en_name", None),
- "year": getattr(meta_info, "year", None),
- "type": media_type_to_agent(getattr(meta_info, "type", None)),
- "begin_season": getattr(meta_info, "begin_season", None),
- "season_episode": getattr(meta_info, "season_episode", None),
- "resource_team": getattr(meta_info, "resource_team", None),
- "video_encode": getattr(meta_info, "video_encode", None),
- "edition": getattr(meta_info, "edition", None),
- "resource_pix": getattr(meta_info, "resource_pix", None),
- }
-
- return simplified
diff --git a/app/agent/tools/impl/add_custom_filter_rule.py b/app/agent/tools/impl/add_custom_filter_rule.py
deleted file mode 100644
index c7f9828a6..000000000
--- a/app/agent/tools/impl/add_custom_filter_rule.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""新增自定义过滤规则工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._filter_rule_utils import (
- get_custom_rules,
- normalize_custom_rule,
- save_system_config,
- serialize_custom_rule,
-)
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class AddCustomFilterRuleInput(BaseModel):
- """新增自定义过滤规则工具的输入参数模型"""
-
- rule_id: str = Field(
- ...,
- description="Unique custom rule ID. Only letters and numbers are allowed.",
- )
- name: str = Field(..., description="Display name of the custom rule.")
- include: Optional[str] = Field(
- None, description="Optional include regex for the rule."
- )
- exclude: Optional[str] = Field(
- None, description="Optional exclude regex for the rule."
- )
- size_range: Optional[str] = Field(
- None, description="Optional size range in MB, for example '1000-5000'."
- )
- seeders: Optional[str] = Field(
- None, description="Optional minimum seeder count as a non-negative integer."
- )
- publish_time: Optional[str] = Field(
- None,
- description="Optional publish-time filter in minutes, for example '60' or '60-1440'.",
- )
-
-
-class AddCustomFilterRuleTool(MoviePilotTool):
- name: str = "add_custom_filter_rule"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.FilterRule,
- ToolTag.Admin,
- ]
- description: str = (
- "Add a custom filter rule to CustomFilterRules. "
- "The new rule can then be referenced by rule ID inside filter rule groups."
- )
- args_schema: Type[BaseModel] = AddCustomFilterRuleInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- return f"新增自定义过滤规则 {kwargs.get('rule_id', '')}"
-
- async def run(
- self,
- rule_id: str,
- name: str,
- include: Optional[str] = None,
- exclude: Optional[str] = None,
- size_range: Optional[str] = None,
- seeders: Optional[str] = None,
- publish_time: Optional[str] = None,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}, rule_id={rule_id}")
-
- try:
- custom_rules = get_custom_rules()
- new_rule = normalize_custom_rule(
- rule_id=rule_id,
- name=name,
- include=include,
- exclude=exclude,
- size_range=size_range,
- seeders=seeders,
- publish_time=publish_time,
- existing_rules=custom_rules,
- )
-
- custom_rules.append(new_rule)
- await save_system_config(
- SystemConfigKey.CustomFilterRules,
- [rule.model_dump(exclude_none=True) for rule in custom_rules],
- )
-
- return json.dumps(
- {
- "success": True,
- "message": f"已新增自定义过滤规则 {new_rule.id}",
- "custom_rule": serialize_custom_rule(new_rule),
- "count": len(custom_rules),
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"新增自定义过滤规则失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"新增自定义过滤规则失败: {exc}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/add_download_tasks.py b/app/agent/tools/impl/add_download_tasks.py
deleted file mode 100644
index b77a5fabe..000000000
--- a/app/agent/tools/impl/add_download_tasks.py
+++ /dev/null
@@ -1,368 +0,0 @@
-"""添加下载任务工具"""
-
-import copy
-import re
-from pathlib import Path
-from typing import List, Optional, Type, Union, cast
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.directory import DirectoryHelper, validate_download_save_path
-from app.chain.download import DownloadChain
-from app.chain.media import MediaChain
-
-# SearchChain 由包根惰性公开,Pylint 无法静态解析 __getattr__ 映射。
-from app.chain.search.facade import SearchChain
-from app.domain.context import Context
-from app.domain.metainfo import MetaInfo
-from app.foundation.crypto import HashUtils
-from app.runtime.log import logger
-from app.runtime.settings import get_runtime_setting
-from app.schemas.file import FileURI
-
-
-class AddDownloadTasksInput(BaseModel):
- """添加下载任务工具的输入参数模型"""
-
- torrent_url: List[str] = Field(
- ...,
- description="One or more torrent_url values. Supports refs from get_search_results (`hash:id`) and magnet links."
- )
- downloader: Optional[str] = Field(None,
- description="Name of the downloader to use (optional, uses default if not specified)")
- save_path: Optional[str] = Field(None,
- description="Directory path where the downloaded files should be saved. Using `:` for remote storage. e.g. rclone:/MP, smb:/server/share/Movies. (optional, uses default path if not specified)")
- labels: Optional[str] = Field(None,
- description="Comma-separated list of labels/tags to assign to the download (optional, e.g., 'movie,hd,bluray')")
-
-
-class AddDownloadTasksTool(MoviePilotTool):
- """添加下载任务工具"""
-
- name: str = "add_download_tasks"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Download,
- ToolTag.Resource,
- ]
- description: str = "Add torrent download tasks using refs from get_search_results or magnet links."
- args_schema: Type[BaseModel] = AddDownloadTasksInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据下载参数生成友好的提示消息"""
- torrent_urls = self._normalize_torrent_urls(kwargs.get("torrent_url"))
- downloader = kwargs.get("downloader")
-
- if torrent_urls:
- if len(torrent_urls) == 1:
- if self._is_torrent_ref(torrent_urls[0]):
- message = f"添加下载任务: 资源 {torrent_urls[0]}"
- else:
- message = "添加下载任务: 磁力链接"
- else:
- message = f"批量添加下载任务: 共 {len(torrent_urls)} 个资源"
- else:
- message = "添加下载任务"
- if downloader:
- message += f" [下载器: {downloader}]"
-
- return message
-
- @staticmethod
- def _build_torrent_ref(context: Context) -> str:
- """生成用于校验缓存项的短引用"""
- if not context or not context.torrent_info:
- return ""
- return HashUtils.sha1(context.torrent_info.enclosure or "")[:7]
-
- @staticmethod
- def _is_torrent_ref(torrent_ref: Optional[str]) -> bool:
- """判断是否为内部搜索结果引用"""
- if not torrent_ref:
- return False
- return bool(re.fullmatch(r"[0-9a-f]{7}:\d+", str(torrent_ref).strip()))
-
- @staticmethod
- def _is_magnet_link_input(torrent_url: Optional[str]) -> bool:
- """判断输入是否为允许直接添加的磁力链接"""
- if not torrent_url:
- return False
- value = str(torrent_url).strip()
- return value.startswith("magnet:")
-
- @classmethod
- def _resolve_cached_context(cls, torrent_ref: str) -> Optional[Context]:
- """从最近一次搜索缓存中解析种子上下文,仅支持 hash:id 格式"""
- ref = str(torrent_ref).strip()
- if ":" not in ref:
- return None
- try:
- ref_hash, ref_index = ref.split(":", 1)
- index = int(ref_index)
- except (TypeError, ValueError):
- return None
-
- if index < 1:
- return None
-
- results = cast(List[Context], SearchChain().last_search_results() or [])
- if index > len(results):
- return None
- context = results[index - 1]
- if not ref_hash or cls._build_torrent_ref(context) != ref_hash:
- return None
- return context
-
- @classmethod
- async def _async_resolve_cached_context(cls, torrent_ref: str) -> Optional[Context]:
- """异步读取最近搜索缓存,避免在协程里直接访问同步文件缓存。"""
- ref = str(torrent_ref).strip()
- if ":" not in ref:
- return None
- try:
- ref_hash, ref_index = ref.split(":", 1)
- index = int(ref_index)
- except (TypeError, ValueError):
- return None
-
- if index < 1:
- return None
-
- results = cast(
- List[Context],
- await SearchChain().async_last_search_results() or [],
- )
- if index > len(results):
- return None
- context = results[index - 1]
- if not ref_hash or cls._build_torrent_ref(context) != ref_hash:
- return None
- return context
-
- @staticmethod
- def _merge_labels_with_system_tag(labels: Optional[str]) -> Optional[str]:
- """合并用户标签与系统默认标签,确保任务可被系统管理"""
- system_tag = (get_runtime_setting('TORRENT_TAG') or "").strip()
- user_labels = [item.strip() for item in (labels or "").split(",") if item.strip()]
-
- if system_tag and system_tag not in user_labels:
- user_labels.append(system_tag)
-
- return ",".join(user_labels) if user_labels else None
-
- @staticmethod
- def _format_failed_result(failed_messages: List[str]) -> str:
- """统一格式化失败结果"""
- return ", ".join([message for message in failed_messages if message])
-
- @staticmethod
- def _build_failure_message(torrent_ref: str, error_msg: Optional[str] = None) -> str:
- """构造失败提示"""
- normalized_error = (error_msg or "").strip()
- prefix = "添加种子任务失败:"
- if normalized_error.startswith(prefix):
- normalized_error = normalized_error[len(prefix):].lstrip()
- if AddDownloadTasksTool._is_magnet_link_input(normalized_error):
- normalized_error = ""
- if normalized_error:
- return f"{torrent_ref} {normalized_error}"
- if AddDownloadTasksTool._is_torrent_ref(torrent_ref):
- return torrent_ref
- return ""
-
- @classmethod
- def _normalize_torrent_urls(cls, torrent_url: Optional[Union[List[str], str]]) -> List[str]:
- """统一规范 torrent_url 输入,保留所有非空值"""
- if torrent_url is None:
- return []
-
- if isinstance(torrent_url, str):
- candidates = torrent_url.split(",")
- else:
- candidates = torrent_url
-
- return [str(item).strip() for item in candidates if item and str(item).strip()]
-
- @staticmethod
- def _resolve_direct_download_dir(save_path: Optional[str]) -> Optional[Path]:
- """解析直接下载使用的目录,优先使用 save_path,其次使用默认下载目录"""
- if save_path is not None:
- return Path(validate_download_save_path(save_path))
-
- download_dirs = DirectoryHelper().get_download_dirs()
- if not download_dirs:
- return None
-
- dir_conf = download_dirs[0]
- if not dir_conf.download_path:
- return None
-
- return Path(FileURI(storage=dir_conf.storage or "local", path=dir_conf.download_path).uri)
-
- @staticmethod
- def _download_direct_sync(
- torrent_input: str,
- download_dir: Path,
- merged_labels: Optional[str],
- downloader: Optional[str],
- ) -> tuple[Optional[str], Optional[str]]:
- """同步添加磁力下载任务,避免下载器调用阻塞事件循环。"""
- result = DownloadChain().download(
- content=torrent_input,
- download_dir=download_dir,
- cookie=None,
- label=merged_labels,
- downloader=downloader,
- )
- if result:
- _, did, _, error_msg = result
- else:
- did, error_msg = None, "未找到下载器"
- return did, error_msg
-
- @staticmethod
- def _download_single_sync(
- context: Context,
- downloader: Optional[str],
- save_path: Optional[str],
- merged_labels: Optional[str],
- ) -> tuple[Optional[str], Optional[str]]:
- """同步提交带上下文的下载任务,避免站点下载与下载器调用阻塞事件循环。"""
- if save_path is not None:
- save_path = validate_download_save_path(save_path)
- return cast(
- tuple[Optional[str], Optional[str]],
- DownloadChain().download_single(
- context=context,
- downloader=downloader,
- save_path=save_path,
- label=merged_labels,
- return_detail=True,
- ),
- )
-
- async def run(self, torrent_url: Optional[List[str]] = None,
- downloader: Optional[str] = None, save_path: Optional[str] = None,
- labels: Optional[str] = None, **kwargs) -> str:
- """执行添加下载任务。"""
- logger.info(
- f"执行工具: {self.name}, 参数: torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}")
-
- try:
- torrent_inputs = self._normalize_torrent_urls(torrent_url)
- if not torrent_inputs:
- return "错误:torrent_url 不能为空。"
-
- if save_path is not None:
- try:
- save_path = validate_download_save_path(save_path)
- except ValueError as err:
- return f"参数错误:save_path {str(err)}"
-
- merged_labels = self._merge_labels_with_system_tag(labels)
- success_count = 0
- failed_messages = []
-
- for torrent_input in torrent_inputs:
- if self._is_torrent_ref(torrent_input):
- cached_context = await self._async_resolve_cached_context(torrent_input)
- if not cached_context or not cached_context.torrent_info:
- failed_messages.append(f"{torrent_input} 引用无效,请重新使用 get_search_results 查看搜索结果")
- continue
-
- cached_torrent = cached_context.torrent_info
- site_name = cached_torrent.site_name
- torrent_title = cached_torrent.title or torrent_input
- torrent_description = cached_torrent.description
- enclosure = cached_torrent.enclosure
-
- if not site_name:
- failed_messages.append(f"{torrent_input} 缺少站点名称")
- continue
-
- siteinfo = await self.data.sites.async_get_by_name(site_name)
- if not siteinfo:
- failed_messages.append(f"{torrent_input} 未找到站点信息 {site_name}")
- continue
-
- # 复制完整搜索上下文,只刷新可能变化的站点凭据。音乐上下文中的
- # MetaMusic、专辑总曲目数和完整覆盖状态必须保留到下载校验层。
- context = copy.copy(cached_context)
- torrent_info = copy.copy(cached_torrent)
- torrent_info.title = torrent_title
- torrent_info.description = torrent_description
- torrent_info.enclosure = enclosure
- torrent_info.site_name = site_name
- torrent_info.site_ua = siteinfo.ua or ""
- torrent_info.site_cookie = siteinfo.cookie or ""
- torrent_info.site_proxy = bool(siteinfo.proxy)
- torrent_info.site_order = siteinfo.pri or 0
- torrent_info.site_downloader = siteinfo.downloader or ""
- context.torrent_info = torrent_info
-
- meta_info = cached_context.meta_info or MetaInfo(
- title=torrent_title,
- subtitle=torrent_description,
- )
- media_info = cached_context.media_info if cached_context.media_info else None
- if not media_info:
- media_info = await MediaChain().async_recognize_by_meta(
- meta_info,
- obtain_images=False,
- )
- if not media_info:
- failed_messages.append(f"{torrent_input} 无法识别媒体信息")
- continue
- context.meta_info = meta_info
- context.media_info = media_info
- else:
- if not self._is_magnet_link_input(torrent_input):
- failed_messages.append(
- f"{torrent_input} 不是有效的下载内容,非 hash:id 时仅支持 magnet: 开头"
- )
- continue
- download_dir = await self.run_blocking(
- "storage", self._resolve_direct_download_dir, save_path
- )
- if not download_dir:
- failed_messages.append(f"{torrent_input} 缺少保存路径,且系统未配置可用下载目录")
- continue
- did, error_msg = await self.run_blocking(
- "downloader",
- self._download_direct_sync,
- torrent_input,
- download_dir,
- merged_labels,
- downloader,
- )
- if did:
- success_count += 1
- else:
- failed_messages.append(self._build_failure_message(torrent_input, error_msg))
- continue
-
- did, error_msg = await self.run_blocking(
- "downloader",
- self._download_single_sync,
- context,
- downloader,
- save_path,
- merged_labels,
- )
- if did:
- success_count += 1
- else:
- failed_messages.append(self._build_failure_message(torrent_input, error_msg))
-
- if success_count and not failed_messages:
- return "任务添加成功"
-
- if success_count:
- return f"部分任务添加失败:{self._format_failed_result(failed_messages)}"
-
- return f"任务添加失败:{self._format_failed_result(failed_messages)}"
- except Exception as e:
- logger.error(f"添加下载任务失败: {e}", exc_info=True)
- return f"添加下载任务时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/add_rule_group.py b/app/agent/tools/impl/add_rule_group.py
deleted file mode 100644
index 017e06401..000000000
--- a/app/agent/tools/impl/add_rule_group.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""新增过滤规则组工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.impl._filter_rule_utils import (
- build_custom_rule_map,
- collect_rule_group_usages,
- get_builtin_rules,
- get_custom_rules,
- get_rule_groups,
- normalize_rule_group,
- publish_rule_config_changed,
- serialize_rule_group,
-)
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class AddRuleGroupInput(BaseModel):
- """新增过滤规则组工具的输入参数模型"""
-
- name: str = Field(..., description="New rule group name.")
- rule_string: str = Field(
- ...,
- description=(
- "Rule expression using built-in/custom rule IDs. "
- "Use '&', '!' inside one level, and use '>' between priority levels. "
- "Example: 'SPECSUB & CNVOI & 4K & !BLU > CNSUB & CNVOI & 4K & !BLU'."
- ),
- )
- media_type: Optional[str] = Field(
- None,
- description="Optional media type scope: '电影', '电视剧', '音乐', 'movie', 'tv', or 'music'.",
- )
- category: Optional[str] = Field(
- None,
- description="Optional media category. Only valid when media_type is set.",
- )
-
-
-class AddRuleGroupTool(MoviePilotTool):
- name: str = "add_rule_group"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.FilterRule,
- ToolTag.Admin,
- ]
- description: str = (
- "Add a new filter rule group to UserFilterRuleGroups. "
- "Rule groups are matched level by level from left to right and can be linked to search/subscription flows. "
- "Before calling this tool, first use query_builtin_filter_rules and query_custom_filter_rules to confirm valid rule IDs, "
- "and optionally use query_rule_groups to imitate existing rule_string patterns."
- )
- args_schema: Type[BaseModel] = AddRuleGroupInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- return f"新增规则组 {kwargs.get('name', '')}"
-
- async def run(
- self,
- name: str,
- rule_string: str,
- media_type: Optional[str] = None,
- category: Optional[str] = None,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}, name={name}")
-
- try:
- custom_rules = get_custom_rules()
- available_rule_ids = set(get_builtin_rules().keys()) | set(
- build_custom_rule_map(custom_rules).keys()
- )
- rule_groups = get_rule_groups()
- expected_definitions = [
- group.model_dump(exclude_none=True) for group in rule_groups
- ]
- new_group, _ = normalize_rule_group(
- name=name,
- rule_string=rule_string,
- media_type=media_type,
- category=category,
- existing_groups=rule_groups,
- available_rule_ids=available_rule_ids,
- )
-
- rule_groups.append(new_group)
- definitions = [
- group.model_dump(exclude_none=True) for group in rule_groups
- ]
- async with self.data.async_rule_group_mutation_scope() as mutation:
- await mutation.apply(
- definitions,
- expected_rule_groups=expected_definitions,
- )
- await publish_rule_config_changed(
- SystemConfigKey.UserFilterRuleGroups,
- definitions,
- )
- usage = await collect_rule_group_usages(
- self.data.subscriptions,
- [new_group.name],
- )
-
- return json.dumps(
- {
- "success": True,
- "message": f"已新增规则组 {new_group.name}",
- "rule_group": serialize_rule_group(
- new_group, usage.get(new_group.name)
- ),
- "count": len(rule_groups),
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"新增规则组失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"新增规则组失败: {exc}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/add_subscribe.py b/app/agent/tools/impl/add_subscribe.py
deleted file mode 100644
index 1fc90a5c1..000000000
--- a/app/agent/tools/impl/add_subscribe.py
+++ /dev/null
@@ -1,348 +0,0 @@
-"""添加订阅工具"""
-
-from typing import List, Optional, Type, TypedDict
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.subscribe.facade import SubscribeChain
-from app.domain.media import normalize_music_type
-from app.runtime.log import logger
-from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, NotificationChannel
-
-
-class _SubscribeOptions(TypedDict, total=False):
- """声明 Agent 工具允许透传给订阅链的可选配置。"""
-
- music_type: str
- start_episode: int
- total_episode: int
- quality: str
- resolution: str
- effect: str
- audio_quality: str
- audio_format: str
- min_bitrate: int
- min_bit_depth: int
- min_sample_rate: int
- best_version: int
- filter_groups: List[str]
- sites: List[int]
-
-
-class AddSubscribeInput(BaseModel):
- """添加订阅工具的输入参数模型"""
-
- title: str = Field(
- ...,
- description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
- )
- year: Optional[str] = Field(
- None,
- description="Release year. Recommended for video; optional when a stable music ID is provided",
- )
- media_type: str = Field(..., description="Allowed values: movie, tv, music")
- music_type: Optional[str] = Field(
- None,
- description="Required for music subscriptions: recording for one track or album for the complete album. Artists cannot be subscribed",
- )
- season: Optional[int] = Field(
- None,
- description=(
- "Season number for TV shows (optional). If omitted, the subscription defaults to season 1 only. "
- "To subscribe multiple seasons or the full series, call this tool separately for each season."
- ),
- )
- media_source: Optional[MediaSource] = Field(
- None,
- description="Media metadata source returned by search_media",
- )
- media_id: Optional[str] = Field(None, description="Native ID for media_source")
- start_episode: Optional[int] = Field(
- None,
- description="Starting episode number for TV shows (optional, defaults to 1 if not specified)",
- )
- total_episode: Optional[int] = Field(
- None,
- description="Total number of episodes for TV shows (optional, will be auto-detected from TMDB if not specified)",
- )
- quality: Optional[str] = Field(
- None,
- description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')",
- )
- resolution: Optional[str] = Field(
- None,
- description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')",
- )
- effect: Optional[str] = Field(
- None,
- description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')",
- )
- audio_quality: Optional[str] = Field(
- None,
- description="Music quality tier filter: hires, lossless, lossy, or a regular-expression combination",
- )
- audio_format: Optional[str] = Field(
- None,
- description="Music audio-format filter as a regular expression, e.g. FLAC|ALAC|DSD",
- )
- min_bitrate: Optional[int] = Field(None, description="Minimum music bitrate in bits per second")
- min_bit_depth: Optional[int] = Field(None, description="Minimum music bit depth")
- min_sample_rate: Optional[int] = Field(None, description="Minimum music sample rate in Hz")
- best_version: Optional[int] = Field(
- None,
- description="Enable quality upgrades: 0 for no, 1 for yes. Music upgrades use normalized audio quality",
- )
- filter_groups: Optional[List[str]] = Field(
- None,
- description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)",
- )
- sites: Optional[List[int]] = Field(
- None,
- description="List of site IDs to search from (optional, can be obtained from query_sites tool)",
- )
-
-
-class AddSubscribeTool(MoviePilotTool):
- """添加电影、电视剧、单曲或整张专辑订阅。"""
-
- name: str = "add_subscribe"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Subscription,
- ToolTag.Media,
- ]
- description: str = (
- "Add automated subscriptions for movies, TV shows, one music recording, or one complete album. "
- "Music requires the source-native ID and music_type='recording' or 'album'; artists are browse-only. "
- "Album subscriptions only complete after a resource is confirmed to cover the album's expected track count. "
- "For TV shows, omitting `season` subscribes season 1 only by default; to subscribe multiple seasons or "
- "the full series, call this tool once per season. Supports advanced filtering options like quality, "
- "resolution, and effect filters using regular expressions."
- )
- args_schema: Type[BaseModel] = AddSubscribeInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据订阅参数生成友好的提示消息"""
- title = kwargs.get("title", "")
- year = kwargs.get("year", "")
- media_type = kwargs.get("media_type", "")
- music_type = kwargs.get("music_type")
- season = kwargs.get("season")
-
- message = f"添加订阅: {title}"
- if year:
- message += f" ({year})"
- if media_type:
- message += f" [{media_type}]"
- if music_type:
- message += f" [{music_type}]"
- if season is not None:
- message += f" 第{season}季"
- elif media_type == "tv":
- message += " 第1季(默认)"
-
- return message
-
- async def _resolve_subscribe_username(self) -> Optional[str]:
- """优先映射为系统用户名,未绑定时回退当前渠道用户名。"""
- resolved_username = self._username
- if not self._channel or not self._user_id:
- return resolved_username
-
- try:
- channel = NotificationChannel(self._channel)
- except ValueError:
- return resolved_username
-
- binding_keys = {
- NotificationChannel.Telegram: ("telegram_userid",),
- NotificationChannel.Discord: ("discord_userid",),
- NotificationChannel.Wechat: ("wechat_userid",),
- NotificationChannel.Feishu: ("feishu_userid", "feishu_openid"),
- NotificationChannel.WechatClawBot: ("wechatclawbot_userid",),
- NotificationChannel.Slack: ("slack_userid",),
- NotificationChannel.VoceChat: ("vocechat_userid",),
- NotificationChannel.SynologyChat: ("synologychat_userid",),
- NotificationChannel.QQ: ("qq_userid", "qq_openid"),
- }.get(channel)
- if not binding_keys:
- return resolved_username
-
- mapped_username = await self.run_blocking(
- "db",
- self.data.users.find_name_by_bindings,
- {key: self._user_id for key in binding_keys},
- )
- return mapped_username or resolved_username
-
- async def run(
- self,
- title: str,
- media_type: str,
- year: Optional[str] = None,
- music_type: Optional[str] = None,
- season: Optional[int] = None,
- media_source: Optional[MediaSource] = None,
- media_id: Optional[str] = None,
- start_episode: Optional[int] = None,
- total_episode: Optional[int] = None,
- quality: Optional[str] = None,
- resolution: Optional[str] = None,
- effect: Optional[str] = None,
- audio_quality: Optional[str] = None,
- audio_format: Optional[str] = None,
- min_bitrate: Optional[int] = None,
- min_bit_depth: Optional[int] = None,
- min_sample_rate: Optional[int] = None,
- best_version: Optional[int] = None,
- filter_groups: Optional[List[str]] = None,
- sites: Optional[List[int]] = None,
- **kwargs,
- ) -> str:
- """校验实体语义后调用订阅链添加精确订阅。"""
- logger.info(
- f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, "
- f"music_type={music_type}, season={season}, media_source={media_source}, "
- f"media_id={media_id}, "
- f"start_episode={start_episode}, "
- f"total_episode={total_episode}, quality={quality}, resolution={resolution}, "
- f"effect={effect}, filter_groups={filter_groups}, sites={sites}"
- )
-
- try:
- subscribe_chain = SubscribeChain()
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
-
- normalized_music_type = None
- if media_type_enum == MediaType.MUSIC:
- normalized_music_type = normalize_music_type(
- music_type,
- allow_artist=False,
- )
- if not normalized_music_type:
- return (
- "错误:音乐订阅必须提供 music_type='recording'(单曲)"
- "或 music_type='album'(整张专辑),艺术家不能订阅"
- )
- if not media_source or not media_id:
- return "错误:音乐订阅必须同时提供 search_media 返回的 media_source 和 media_id"
- if any(value is not None for value in (season, start_episode, total_episode)):
- return "错误:音乐订阅没有季集参数,不能传入 season、start_episode 或 total_episode"
- elif music_type:
- return "错误:music_type 仅能与 media_type='music' 一起使用"
- if (media_source is None) != (media_id is None):
- return "错误:media_source 和 media_id 必须同时提供"
- audio_filter_values = (
- audio_quality, audio_format, min_bitrate, min_bit_depth, min_sample_rate
- )
- if media_type_enum != MediaType.MUSIC and any(
- value is not None for value in audio_filter_values
- ):
- return "错误:audio_quality、audio_format 和音频技术参数仅用于音乐订阅"
- effective_season = (
- season
- if season is not None
- else 1
- if media_type_enum == MediaType.TV
- else None
- )
- subscribe_username = await self._resolve_subscribe_username()
-
- # 构建额外的订阅参数
- subscribe_kwargs: _SubscribeOptions = {}
- if normalized_music_type:
- subscribe_kwargs["music_type"] = normalized_music_type
- if start_episode is not None:
- subscribe_kwargs["start_episode"] = start_episode
- if total_episode is not None:
- subscribe_kwargs["total_episode"] = total_episode
- if quality:
- subscribe_kwargs["quality"] = quality
- if resolution:
- subscribe_kwargs["resolution"] = resolution
- if effect:
- subscribe_kwargs["effect"] = effect
- if audio_quality:
- subscribe_kwargs["audio_quality"] = audio_quality
- if audio_format:
- subscribe_kwargs["audio_format"] = audio_format
- if min_bitrate is not None:
- subscribe_kwargs["min_bitrate"] = min_bitrate
- if min_bit_depth is not None:
- subscribe_kwargs["min_bit_depth"] = min_bit_depth
- if min_sample_rate is not None:
- subscribe_kwargs["min_sample_rate"] = min_sample_rate
- if best_version is not None:
- subscribe_kwargs["best_version"] = best_version
- if filter_groups:
- subscribe_kwargs["filter_groups"] = filter_groups
- if sites:
- subscribe_kwargs["sites"] = sites
-
- sid, message = await subscribe_chain.async_add(
- mtype=media_type_enum,
- title=title,
- year=year or "",
- media_source=media_source,
- media_id=media_id,
- season=season,
- username=subscribe_username,
- **subscribe_kwargs,
- )
- if sid:
- display_year = f" ({year})" if year else ""
- music_label = (
- "专辑" if normalized_music_type == MUSIC_ENTITY_ALBUM else "单曲"
- ) if normalized_music_type else ""
- if message and "已存在" in message:
- result_msg = f"{music_label}订阅已存在:{title}{display_year}"
- if effective_season is not None:
- result_msg += f" 第{effective_season}季"
- result_msg += "。如需修改参数请先删除旧订阅。"
- return result_msg
-
- result_msg = f"成功添加{music_label}订阅:{title}{display_year}"
- if effective_season is not None:
- result_msg += f" 第{effective_season}季"
- if season is None:
- result_msg += "(未指定季号,默认按第一季订阅)"
- if subscribe_kwargs:
- params = []
- if start_episode is not None:
- params.append(f"开始集数: {start_episode}")
- if total_episode is not None:
- params.append(f"总集数: {total_episode}")
- if quality:
- params.append(f"质量过滤: {quality}")
- if resolution:
- params.append(f"分辨率过滤: {resolution}")
- if effect:
- params.append(f"特效过滤: {effect}")
- if audio_quality:
- params.append(f"音质等级: {audio_quality}")
- if audio_format:
- params.append(f"音频格式: {audio_format}")
- if min_bitrate is not None:
- params.append(f"最低码率: {round(min_bitrate / 1000)}kbps")
- if min_bit_depth is not None:
- params.append(f"最低位深: {min_bit_depth}bit")
- if min_sample_rate is not None:
- params.append(f"最低采样率: {min_sample_rate / 1000:g}kHz")
- if best_version is not None:
- params.append(f"音质洗版: {'开启' if best_version else '关闭'}")
- if filter_groups:
- params.append(f"规则组: {', '.join(filter_groups)}")
- if sites:
- params.append(f"站点: {', '.join(map(str, sites))}")
- if params:
- result_msg += f"\n配置参数: {', '.join(params)}"
- return result_msg
- else:
- return f"添加订阅失败:{message}"
- except Exception as e:
- logger.error(f"添加订阅失败: {e}", exc_info=True)
- return f"添加订阅时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/agent_task.py b/app/agent/tools/impl/agent_task.py
new file mode 100644
index 000000000..b21ce026d
--- /dev/null
+++ b/app/agent/tools/impl/agent_task.py
@@ -0,0 +1,440 @@
+"""Agent 自主定时任务统一工具。"""
+
+import json
+from datetime import datetime, timedelta
+from typing import Literal, Optional, Type
+
+import pytz
+from pydantic import BaseModel, Field, model_validator
+
+from app.agent.tools.base import MoviePilotTool
+from app.agent.tools.tags import ToolTag
+from app.application.agenttask import agent_task_run_to_dict, agent_task_to_dict
+from app.runtime.scheduling import TimerUtils
+from app.runtime.settings import get_runtime_setting
+
+AgentTaskAction = Literal["create", "list", "update", "run", "delete"]
+AgentTaskTriggerType = Literal["date", "cron"]
+
+
+class AgentTaskInput(BaseModel):
+ """统一管理 Agent 自主定时任务的输入参数。"""
+
+ action: AgentTaskAction = Field(
+ ...,
+ description=(
+ "Action to perform: create, list, update, run, or delete. "
+ "Use list before changing a task when its integer task_id is unknown."
+ ),
+ )
+ task_id: Optional[int] = Field(
+ None,
+ ge=1,
+ description="Task ID for list, update, run, or delete.",
+ )
+ name: Optional[str] = Field(
+ None,
+ min_length=1,
+ max_length=100,
+ description="Task name for create or update.",
+ )
+ content: Optional[str] = Field(
+ None,
+ min_length=1,
+ max_length=10000,
+ description="Complete instructions for create or update.",
+ )
+ trigger_type: Optional[AgentTaskTriggerType] = Field(
+ None,
+ description="Use date for one future run or cron for recurring work.",
+ )
+ trigger: Optional[str] = Field(
+ None,
+ min_length=1,
+ max_length=200,
+ description="ISO 8601 date or standard five-field cron expression.",
+ )
+ delay_minutes: Optional[int] = Field(
+ None,
+ ge=1,
+ le=525600,
+ description=("Relative delay for a one-time date task. Use instead of trigger with trigger_type=date."),
+ )
+ enabled: Optional[bool] = Field(
+ None,
+ description=("For list, optionally filter by enabled state. For update, set false to pause or true to resume."),
+ )
+
+ @model_validator(mode="after")
+ def validate_action(self) -> "AgentTaskInput":
+ """按动作校验必填字段和触发参数组合。"""
+ if self.name is not None:
+ self.name = self.name.strip()
+ if not self.name:
+ raise ValueError("name 不能只包含空白字符")
+ if self.content is not None:
+ self.content = self.content.strip()
+ if not self.content:
+ raise ValueError("content 不能只包含空白字符")
+
+ if self.action == "create":
+ if self.name is None or self.content is None or self.trigger_type is None:
+ raise ValueError("create 必须提供 name、content 和 trigger_type")
+ self._validate_schedule(require_schedule=True, normalize_exact=True)
+ elif self.action == "update":
+ if self.task_id is None:
+ raise ValueError("update 必须提供 task_id")
+ has_schedule_update = any(
+ value is not None for value in (self.trigger_type, self.trigger, self.delay_minutes)
+ )
+ if has_schedule_update:
+ self._validate_schedule(require_schedule=True, normalize_exact=False)
+ if all(
+ value is None
+ for value in (
+ self.name,
+ self.content,
+ self.trigger_type,
+ self.enabled,
+ )
+ ):
+ raise ValueError("update 至少需要提供一个要更新的字段")
+ elif self.action in {"run", "delete"} and self.task_id is None:
+ raise ValueError(f"{self.action} 必须提供 task_id")
+ return self
+
+ def _validate_schedule(
+ self,
+ *,
+ require_schedule: bool,
+ normalize_exact: bool,
+ ) -> None:
+ """校验 date/cron 触发组合,并按需规范化绝对触发值。"""
+ if require_schedule and self.trigger_type is None:
+ raise ValueError("修改触发配置时必须提供 trigger_type")
+ if self.trigger_type == "date":
+ if self.delay_minutes is not None:
+ self.trigger = None
+ return
+ if self.trigger is None:
+ raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
+ elif self.trigger_type == "cron":
+ if self.trigger is None or self.delay_minutes is not None:
+ raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
+ if normalize_exact and self.trigger_type is not None:
+ self.trigger_type, self.trigger = TimerUtils.normalize_schedule_trigger(
+ trigger_type=self.trigger_type,
+ trigger_value=self.trigger,
+ timezone_name=get_runtime_setting("TZ"),
+ require_future=True,
+ )
+
+
+class AgentTaskTool(MoviePilotTool):
+ """通过一个动作式接口管理当前用户的自主定时任务。"""
+
+ name: str = "agent_task"
+ tags: list[str] = [
+ ToolTag.Read,
+ ToolTag.Write,
+ ToolTag.AgentTask,
+ ToolTag.Admin,
+ ]
+ description: str = (
+ "Manage persistent autonomous agent tasks with one structured action. Use create "
+ "for explicitly requested delayed or recurring work, list to inspect current-user "
+ "tasks, update to change or pause one, run to queue one immediately, and delete "
+ "to permanently remove it. Task IDs are integers and are not scheduler job IDs."
+ )
+ args_schema: Type[BaseModel] = AgentTaskInput
+ require_admin: bool = True
+
+ def get_tool_message(self, **kwargs: object) -> Optional[str]:
+ """生成统一任务操作的用户可见提示。"""
+ action = str(kwargs.get("action") or "list")
+ action_name = {
+ "create": "创建",
+ "list": "查询",
+ "update": "更新",
+ "run": "立即执行",
+ "delete": "删除",
+ }.get(action, action)
+ target = kwargs.get("task_id") or kwargs.get("name") or ""
+ return f"{action_name}自主定时任务:{target}".rstrip(":")
+
+ @staticmethod
+ def _absolute_trigger(payload: AgentTaskInput) -> str:
+ """将相对分钟或绝对表达式转换为规范化触发值。"""
+ trigger_value = payload.trigger
+ if payload.trigger_type == "date" and payload.delay_minutes is not None:
+ timezone = pytz.timezone(get_runtime_setting("TZ"))
+ trigger_value = (datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)).isoformat(
+ timespec="seconds"
+ )
+ if payload.trigger_type is None:
+ raise ValueError("Agent 定时任务缺少 trigger_type")
+ _, normalized = TimerUtils.normalize_schedule_trigger(
+ trigger_type=payload.trigger_type,
+ trigger_value=trigger_value,
+ timezone_name=get_runtime_setting("TZ"),
+ require_future=True,
+ )
+ return normalized
+
+ def _create_task(self, payload: AgentTaskInput) -> dict[str, object]:
+ """持久化任务并立即注册到运行时调度器。"""
+ from app.application.scheduling import update_agent_task_job
+
+ trigger_value = self._absolute_trigger(payload)
+ if payload.name is None or payload.content is None or payload.trigger_type is None:
+ raise ValueError("create 缺少必要参数")
+ chat = self.data.chat.get_sync(
+ session_id=self._session_id,
+ user_id=self._user_id,
+ )
+ task = self.data.tasks.add(
+ name=payload.name,
+ content=payload.content,
+ trigger_type=payload.trigger_type,
+ cron_expression=(trigger_value if payload.trigger_type == "cron" else None),
+ run_at=trigger_value if payload.trigger_type == "date" else None,
+ user_id=str(self._user_id),
+ username=self._username or (chat.username if chat else None),
+ session_id=str(self._session_id),
+ channel=self._channel or (chat.channel if chat else None),
+ source=self._source or (chat.source if chat else None),
+ original_chat_id=chat.original_chat_id if chat else None,
+ )
+ if task is None:
+ raise RuntimeError("Agent 定时任务创建后无法读取")
+ return agent_task_to_dict(
+ task,
+ next_run_at=update_agent_task_job(task.id),
+ timezone=get_runtime_setting("TZ"),
+ )
+
+ def _list_tasks(
+ self,
+ task_id: Optional[int],
+ enabled: Optional[bool],
+ ) -> list[dict[str, object]]:
+ """读取当前用户任务及运行时下一次触发时间。"""
+ from app.application.scheduling import get_agent_task_next_run
+
+ oper = self.data.tasks
+ if task_id is not None:
+ task = oper.get(task_id=task_id, user_id=str(self._user_id))
+ tasks = [task] if task else []
+ else:
+ tasks = oper.list(user_id=str(self._user_id), enabled=enabled)
+ result: list[dict[str, object]] = []
+ for task in tasks:
+ data = agent_task_to_dict(
+ task,
+ next_run_at=get_agent_task_next_run(task.id),
+ timezone=get_runtime_setting("TZ"),
+ )
+ if task_id is not None:
+ data["recent_runs"] = [
+ agent_task_run_to_dict(run)
+ for run in oper.list_runs(
+ task_id=task.id,
+ user_id=str(self._user_id),
+ limit=10,
+ )
+ ]
+ result.append(data)
+ return result
+
+ def _update_task(
+ self,
+ payload: AgentTaskInput,
+ ) -> Optional[dict[str, object]]:
+ """更新当前用户任务并刷新运行时调度。"""
+ from app.application.scheduling import update_agent_task_job
+
+ if payload.task_id is None:
+ raise ValueError("update 缺少 task_id")
+ oper = self.data.tasks
+ task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
+ if not task:
+ return None
+ if task.last_status == "running":
+ return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"}
+
+ has_schedule_update = payload.trigger_type is not None
+ trigger_type = payload.trigger_type or task.trigger_type
+ trigger_value = payload.trigger
+ if trigger_type == "date" and payload.delay_minutes is not None:
+ timezone = pytz.timezone(get_runtime_setting("TZ"))
+ trigger_value = (datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)).isoformat(
+ timespec="seconds"
+ )
+ if trigger_value is None:
+ trigger_value = task.cron_expression if trigger_type == "cron" else task.run_at
+ normalized_type = trigger_type
+ normalized_trigger = trigger_value
+ validates_existing_date_schedule = bool(
+ payload.enabled and task.last_status != "interrupted" and trigger_type == "date"
+ )
+ if has_schedule_update or validates_existing_date_schedule:
+ if trigger_value is None:
+ raise ValueError("Agent 定时任务缺少触发配置")
+ normalized_type, normalized_trigger = TimerUtils.normalize_schedule_trigger(
+ trigger_type=trigger_type,
+ trigger_value=trigger_value,
+ timezone_name=get_runtime_setting("TZ"),
+ require_future=bool(
+ trigger_type == "date"
+ and (
+ task.last_status == "interrupted"
+ or (task.enabled if payload.enabled is None else payload.enabled)
+ )
+ ),
+ )
+
+ update_payload: dict[str, object] = {}
+ if payload.name is not None:
+ update_payload["name"] = payload.name
+ if payload.content is not None:
+ update_payload["content"] = payload.content
+ if has_schedule_update:
+ update_payload.update(
+ {
+ "trigger_type": normalized_type,
+ "cron_expression": (normalized_trigger if normalized_type == "cron" else None),
+ "run_at": (normalized_trigger if normalized_type == "date" else None),
+ "last_status": "waiting",
+ "last_result": None,
+ }
+ )
+ if payload.enabled is not None:
+ update_payload["enabled"] = payload.enabled
+ if payload.enabled and task.last_status != "interrupted":
+ update_payload["last_status"] = "waiting"
+
+ updated = oper.update(
+ task_id=payload.task_id,
+ payload=update_payload,
+ user_id=str(self._user_id),
+ )
+ if not updated:
+ current = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
+ if current and current.last_status == "running":
+ return {"error": (f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改")}
+ return None
+ updated_task = oper.get(
+ task_id=payload.task_id,
+ user_id=str(self._user_id),
+ )
+ if updated_task is None:
+ return None
+ return agent_task_to_dict(
+ updated_task,
+ next_run_at=update_agent_task_job(payload.task_id),
+ timezone=get_runtime_setting("TZ"),
+ )
+
+ def _get_task_state(self, task_id: int) -> tuple[str, Optional[str]]:
+ """校验任务归属和状态,返回可执行性及任务名称。"""
+ task = self.data.tasks.get(task_id=task_id, user_id=str(self._user_id))
+ if not task:
+ return "not_found", None
+ if not task.enabled:
+ return "disabled", task.name
+ if task.last_status == "running":
+ return "running", task.name
+ return "ready", task.name
+
+ def _delete_task(self, task_id: int) -> bool:
+ """删除当前用户任务并移除运行时调度。"""
+ from app.application.scheduling import remove_agent_task_job
+
+ deleted = self.data.tasks.delete(
+ task_id=task_id,
+ user_id=str(self._user_id),
+ )
+ if deleted:
+ remove_agent_task_job(task_id)
+ return deleted
+
+ async def run(
+ self,
+ action: AgentTaskAction,
+ task_id: Optional[int] = None,
+ name: Optional[str] = None,
+ content: Optional[str] = None,
+ trigger_type: Optional[AgentTaskTriggerType] = None,
+ trigger: Optional[str] = None,
+ delay_minutes: Optional[int] = None,
+ enabled: Optional[bool] = None,
+ **kwargs: object,
+ ) -> str:
+ """执行一项任务创建、查询、更新、立即运行或删除操作。"""
+ payload = AgentTaskInput(
+ action=action,
+ task_id=task_id,
+ name=name,
+ content=content,
+ trigger_type=trigger_type,
+ trigger=trigger,
+ delay_minutes=delay_minutes,
+ enabled=enabled,
+ )
+ if payload.action == "create":
+ if not get_runtime_setting("AI_AGENT_ENABLE"):
+ return "AI Agent 未启用,无法创建自主定时任务"
+ task = await self.run_blocking("db", self._create_task, payload)
+ return json.dumps(task, ensure_ascii=False, indent=2)
+ if payload.action == "list":
+ tasks = await self.run_blocking(
+ "db",
+ self._list_tasks,
+ payload.task_id,
+ payload.enabled,
+ )
+ return json.dumps(
+ {"total": len(tasks), "tasks": tasks},
+ ensure_ascii=False,
+ indent=2,
+ )
+ if payload.action == "update":
+ task = await self.run_blocking("db", self._update_task, payload)
+ if not task:
+ return f"Agent 定时任务 {payload.task_id} 不存在或不属于当前用户"
+ if error := task.get("error"):
+ return str(error)
+ return json.dumps(task, ensure_ascii=False, indent=2)
+ if payload.action == "run":
+ return await self._run_now(payload.task_id)
+ return await self._delete(payload.task_id)
+
+ async def _run_now(self, task_id: Optional[int]) -> str:
+ """立即提交一项已启用且属于当前用户的任务。"""
+ from app.application.scheduling import start_agent_task
+
+ if task_id is None:
+ raise ValueError("run 缺少 task_id")
+ status, task_name = await self.run_blocking(
+ "db",
+ self._get_task_state,
+ task_id,
+ )
+ if status == "not_found":
+ return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
+ if status == "disabled":
+ return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行"
+ if status == "running":
+ return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发"
+ if not start_agent_task(task_id):
+ return f"Agent 定时任务 {task_id} 尚未注册到运行时调度器,无法立即执行"
+ return f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}。执行完成后将通过已配置的通知渠道广播结果"
+
+ async def _delete(self, task_id: Optional[int]) -> str:
+ """永久删除一项属于当前用户的自主定时任务。"""
+ if task_id is None:
+ raise ValueError("delete 缺少 task_id")
+ deleted = await self.run_blocking("db", self._delete_task, task_id)
+ if not deleted:
+ return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
+ return f"Agent 定时任务 {task_id} 已删除"
diff --git a/app/agent/tools/impl/api.py b/app/agent/tools/impl/api.py
new file mode 100644
index 000000000..b778a0def
--- /dev/null
+++ b/app/agent/tools/impl/api.py
@@ -0,0 +1,199 @@
+"""MoviePilot 结构化 API 网关工具。"""
+
+import json
+from typing import Any, Dict, Optional, Type
+
+from pydantic import BaseModel, Field, PrivateAttr
+
+from app.agent.api.executor import ApiExecutionContext, ApiExecutionError, MoviePilotApiExecutor
+from app.agent.policy.api import resolve_api_operation
+from app.agent.tools.base import MoviePilotTool
+from app.agent.tools.tags import ToolTag
+from app.schemas.types import NotificationChannel
+
+
+class MoviePilotApiInput(BaseModel):
+ """MoviePilot API 网关的结构化输入参数。"""
+
+ operation_id: str = Field(
+ ...,
+ description=(
+ "稳定的 MoviePilot API operation ID。先根据领域 Skill 选择操作,不要传入 URL、认证头或 API Token。"
+ ),
+ )
+ path_params: Dict[str, Any] = Field(
+ default_factory=dict,
+ description="路径参数;仅填当前 operation 声明的参数。",
+ )
+ query: Dict[str, Any] = Field(
+ default_factory=dict,
+ description="查询参数;仅填当前 operation 声明的参数。",
+ )
+ body: Dict[str, Any] = Field(
+ default_factory=dict,
+ description="JSON 请求体;字段由当前 operation 的 Skill 合同定义。",
+ )
+
+
+class MoviePilotApiTool(MoviePilotTool):
+ """
+ MoviePilot 结构化 API 网关。
+
+ 网关只接受固定 operation ID 和结构化参数,由宿主注册表解析为固定 API
+ 方法与路径;模型不能提供 URL、认证头、令牌或任意 HTTP 方法。
+ """
+
+ name: str = "moviepilot_api"
+ tags: list[str] = [
+ ToolTag.Read,
+ ToolTag.Media,
+ ToolTag.Subscription,
+ ToolTag.Download,
+ ToolTag.Site,
+ ToolTag.Plugin,
+ ]
+ description: str = (
+ "调用经过白名单审核的 MoviePilot 业务 API。使用领域 Skill 获取 operation_id、"
+ "参数和失败处理;不能调用任意 URL、命令或认证接口。"
+ )
+ require_admin: bool = False
+ args_schema: Type[BaseModel] = MoviePilotApiInput
+
+ _executor: Optional[MoviePilotApiExecutor] = PrivateAttr(default=None)
+
+ def __init__(
+ self,
+ session_id: str,
+ user_id: str,
+ *,
+ executor: Optional[MoviePilotApiExecutor] = None,
+ **kwargs: Any,
+ ) -> None:
+ """注入可测试的固定 API 执行器。"""
+ super().__init__(session_id=session_id, user_id=user_id, **kwargs)
+ self._executor = executor
+
+ def get_tool_message(self, **kwargs) -> Optional[str]:
+ """生成结构化 API 调用提示。"""
+ operation_id = kwargs.get("operation_id") or "未知操作"
+ return f"调用 MoviePilot API:{operation_id}"
+
+ async def _resolve_api_identity(self) -> tuple[str, Optional[str], bool]:
+ """把 Web 或渠道身份解析为真实 MoviePilot 用户身份。"""
+ raw_user_id = str(self._user_id or "")
+ if self._source == "api" and bool(self._agent_context.get("is_admin")):
+ from app.application.security.auth import build_superuser_token_payload
+
+ payload = await self.run_blocking(
+ "db",
+ build_superuser_token_payload,
+ )
+ if payload.sub is None:
+ raise ApiExecutionError("管理员集成身份没有持久化用户 ID")
+ return str(payload.sub), payload.username, bool(payload.super_user)
+ direct_user_channels = {
+ NotificationChannel.Web.value,
+ NotificationChannel.WebAgent.value,
+ }
+ is_direct_user_channel = self._channel in direct_user_channels
+ if self._data is None:
+ if is_direct_user_channel and raw_user_id.isdigit():
+ return raw_user_id, self._username, bool(self._agent_context.get("is_admin"))
+ raise ApiExecutionError("Agent 数据上下文未装配,无法解析 API 用户身份")
+
+ username = self._username
+ user = await self._data.users.async_get_by_name(username) if username else None
+ if user is None and not is_direct_user_channel and self._channel and raw_user_id:
+ try:
+ channel = NotificationChannel(self._channel)
+ except ValueError:
+ channel = None
+ binding_keys = {
+ NotificationChannel.Telegram: ("telegram_userid",),
+ NotificationChannel.Discord: ("discord_userid",),
+ NotificationChannel.Wechat: ("wechat_userid",),
+ NotificationChannel.Feishu: ("feishu_userid", "feishu_openid"),
+ NotificationChannel.WechatClawBot: ("wechatclawbot_userid",),
+ NotificationChannel.Slack: ("slack_userid",),
+ NotificationChannel.VoceChat: ("vocechat_userid",),
+ NotificationChannel.SynologyChat: ("synologychat_userid",),
+ NotificationChannel.QQ: ("qq_userid", "qq_openid"),
+ }.get(channel)
+ if binding_keys:
+ username = await self.run_blocking(
+ "db",
+ self._data.users.find_name_by_bindings,
+ {key: raw_user_id for key in binding_keys},
+ )
+ user = await self._data.users.async_get_by_name(username) if username else None
+ if user is None and is_direct_user_channel and raw_user_id.isdigit():
+ return raw_user_id, self._username, bool(self._agent_context.get("is_admin"))
+ if user is None or not user.is_active:
+ raise ApiExecutionError("当前 Agent 身份未绑定有效的 MoviePilot 用户")
+ return str(user.id), user.name, bool(user.is_superuser)
+
+ async def _get_executor(self) -> MoviePilotApiExecutor:
+ """返回注入执行器,未注入时按当前可信 Agent 身份创建。"""
+ if self._executor is None:
+ user_id, username, is_admin = await self._resolve_api_identity()
+ self._executor = MoviePilotApiExecutor(
+ context=ApiExecutionContext(
+ user_id=user_id,
+ username=username,
+ is_admin=is_admin,
+ session_id=self._session_id,
+ channel=self._channel,
+ source=self._source,
+ )
+ )
+ return self._executor
+
+ async def run(
+ self,
+ operation_id: str,
+ path_params: Optional[Dict[str, Any]] = None,
+ query: Optional[Dict[str, Any]] = None,
+ body: Optional[Dict[str, Any]] = None,
+ **kwargs: Any,
+ ) -> str:
+ """
+ 执行一个白名单 MoviePilot API 操作。
+
+ :param operation_id: 稳定 API 操作标识
+ :param path_params: 路径参数
+ :param query: 查询参数
+ :param body: JSON 请求体
+ :return: 结构化业务结果或安全错误消息
+ """
+ del kwargs
+ spec = resolve_api_operation(operation_id)
+ if spec is None:
+ return json.dumps(
+ {
+ "success": False,
+ "error": "unknown_operation",
+ "message": f"未允许的 MoviePilot API 操作:{operation_id}",
+ },
+ ensure_ascii=False,
+ )
+
+ try:
+ executor = await self._get_executor()
+ return await executor.execute(
+ operation_id,
+ path_params=path_params,
+ query=query,
+ body=body,
+ )
+ except ApiExecutionError as error:
+ return json.dumps(
+ {
+ "success": False,
+ "error": "operation_unavailable",
+ "message": str(error),
+ },
+ ensure_ascii=False,
+ )
+
+
+__all__ = ["MoviePilotApiInput", "MoviePilotApiTool"]
diff --git a/app/agent/tools/impl/create_agent_task.py b/app/agent/tools/impl/create_agent_task.py
deleted file mode 100644
index ed6787468..000000000
--- a/app/agent/tools/impl/create_agent_task.py
+++ /dev/null
@@ -1,161 +0,0 @@
-import json
-from datetime import datetime, timedelta
-from typing import Literal, Optional, Type
-
-import pytz
-from pydantic import BaseModel, Field, model_validator
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.agenttask import agent_task_to_dict
-from app.runtime.scheduling import TimerUtils
-from app.runtime.settings import get_runtime_setting
-
-
-class CreateAgentTaskInput(BaseModel):
- """创建 Agent 自主定时任务的输入参数。"""
-
- name: str = Field(
- ...,
- min_length=1,
- max_length=100,
- description="Short task name shown in task management and execution reports.",
- )
- content: str = Field(
- ...,
- min_length=1,
- max_length=10000,
- description="Complete instructions that the agent must execute when the task fires.",
- )
- trigger_type: Literal["date", "cron"] = Field(
- ...,
- description="Use 'date' for one exact future run or 'cron' for recurring work.",
- )
- trigger: Optional[str] = Field(
- None,
- min_length=1,
- max_length=200,
- description=(
- "For date, an ISO 8601 local or timezone-aware time such as "
- "2026-07-19 20:30:00; for cron, a standard five-field expression "
- "(minute hour day month weekday). The MoviePilot system timezone is used."
- ),
- )
- delay_minutes: Optional[int] = Field(
- None,
- ge=1,
- le=525600,
- description=(
- "For a one-time date task expressed as 'in N minutes', provide this instead "
- "of trigger. MoviePilot calculates and persists the exact future run time."
- ),
- )
-
- @model_validator(mode="after")
- def validate_trigger(self) -> "CreateAgentTaskInput":
- """校验任务触发配置并统一格式。"""
- self.name = self.name.strip()
- self.content = self.content.strip()
- if not self.name or not self.content:
- raise ValueError("name 和 content 不能只包含空白字符")
- if self.trigger_type == "date":
- if self.delay_minutes is not None:
- # LangChain 会在 run() 前后各校验一次,延迟时间在持久化前统一计算。
- self.trigger = None
- return self
- if self.trigger is None:
- raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
- elif self.trigger is None or self.delay_minutes is not None:
- raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
- self.trigger_type, self.trigger = TimerUtils.normalize_schedule_trigger(
- trigger_type=self.trigger_type,
- trigger_value=self.trigger,
- timezone_name=get_runtime_setting('TZ'),
- require_future=True,
- )
- return self
-
-
-class CreateAgentTaskTool(MoviePilotTool):
- """创建可精确唤醒当前 Agent 会话的自主定时任务。"""
-
- name: str = "create_agent_task"
- tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
- description: str = (
- "Create a persistent autonomous agent task only when the user explicitly asks "
- "for delayed, scheduled, recurring, reminder, or monitoring work. Use trigger_type "
- "'date' with delay_minutes for requests such as 'check in 30 minutes', an exact "
- "trigger time for other one-time work, and 'cron' for recurring schedules. When "
- "fired, MoviePilot wakes the agent in this conversation, executes content, and "
- "broadcasts user-facing messages through the configured notification channels."
- )
- args_schema: Type[BaseModel] = CreateAgentTaskInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs: object) -> Optional[str]:
- """生成创建定时任务的提示消息。"""
- return f"创建自主定时任务:{kwargs.get('name', '')}"
-
- def _create_task(self, payload: CreateAgentTaskInput) -> dict:
- """持久化任务并立即注册到运行时调度器。"""
- from app.application.scheduling import update_agent_task_job
-
- trigger_value = payload.trigger
- if payload.trigger_type == "date" and payload.delay_minutes is not None:
- timezone = pytz.timezone(get_runtime_setting('TZ'))
- trigger_value = (
- datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)
- ).isoformat(timespec="seconds")
- _, trigger_value = TimerUtils.normalize_schedule_trigger(
- trigger_type=payload.trigger_type,
- trigger_value=trigger_value,
- timezone_name=get_runtime_setting('TZ'),
- require_future=True,
- )
- chat = self.data.chat.get_sync(
- session_id=self._session_id,
- user_id=self._user_id,
- )
- task = self.data.tasks.add(
- name=payload.name.strip(),
- content=payload.content.strip(),
- trigger_type=payload.trigger_type,
- cron_expression=trigger_value if payload.trigger_type == "cron" else None,
- run_at=trigger_value if payload.trigger_type == "date" else None,
- user_id=str(self._user_id),
- username=self._username or (chat.username if chat else None),
- session_id=str(self._session_id),
- channel=self._channel or (chat.channel if chat else None),
- source=self._source or (chat.source if chat else None),
- original_chat_id=chat.original_chat_id if chat else None,
- )
- if task is None:
- raise RuntimeError("Agent 定时任务创建后无法读取")
- next_run_at = update_agent_task_job(task.id)
- return agent_task_to_dict(
- task,
- next_run_at=next_run_at,
- timezone=get_runtime_setting('TZ'),
- )
-
- async def run(
- self,
- name: str,
- content: str,
- trigger_type: str,
- trigger: Optional[str] = None,
- delay_minutes: Optional[int] = None,
- **kwargs: object,
- ) -> str:
- """创建 Agent 自主定时任务。"""
- if not get_runtime_setting('AI_AGENT_ENABLE'):
- return "AI Agent 未启用,无法创建自主定时任务"
- payload = CreateAgentTaskInput(
- name=name,
- content=content,
- trigger_type=trigger_type,
- trigger=trigger,
- delay_minutes=delay_minutes,
- )
- task = await self.run_blocking("db", self._create_task, payload)
- return json.dumps(task, ensure_ascii=False, indent=2)
diff --git a/app/agent/tools/impl/delete_agent_task.py b/app/agent/tools/impl/delete_agent_task.py
deleted file mode 100644
index caec6df34..000000000
--- a/app/agent/tools/impl/delete_agent_task.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-
-
-class DeleteAgentTaskInput(BaseModel):
- """删除 Agent 自主定时任务的输入参数。"""
-
- task_id: int = Field(..., ge=1, description="ID of the task to permanently delete.")
-
-
-class DeleteAgentTaskTool(MoviePilotTool):
- """永久删除 Agent 自主定时任务。"""
-
- name: str = "delete_agent_task"
- tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
- description: str = (
- "Permanently delete an autonomous agent task and remove its runtime schedule. "
- "Use update_agent_task with enabled=false when the user only wants to pause it."
- )
- args_schema: Type[BaseModel] = DeleteAgentTaskInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs: object) -> Optional[str]:
- """生成删除定时任务的提示消息。"""
- return f"删除自主定时任务:{kwargs.get('task_id', '')}"
-
- def _delete_task(self, task_id: int) -> bool:
- """删除当前用户的任务并移除运行时调度。"""
- from app.application.scheduling import remove_agent_task_job
-
- deleted = self.data.tasks.delete(
- task_id=task_id,
- user_id=str(self._user_id),
- )
- if deleted:
- remove_agent_task_job(task_id)
- return deleted
-
- async def run(self, task_id: int, **kwargs: object) -> str:
- """删除 Agent 自主定时任务。"""
- payload = DeleteAgentTaskInput(task_id=task_id)
- deleted = await self.run_blocking("db", self._delete_task, payload.task_id)
- if not deleted:
- return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
- return f"Agent 定时任务 {task_id} 已删除"
diff --git a/app/agent/tools/impl/delete_custom_filter_rule.py b/app/agent/tools/impl/delete_custom_filter_rule.py
deleted file mode 100644
index a0e9a6247..000000000
--- a/app/agent/tools/impl/delete_custom_filter_rule.py
+++ /dev/null
@@ -1,99 +0,0 @@
-"""删除自定义过滤规则工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._filter_rule_utils import (
- collect_custom_rule_group_refs,
- get_custom_rules,
- get_rule_groups,
- save_system_config,
-)
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class DeleteCustomFilterRuleInput(BaseModel):
- """删除自定义过滤规则工具的输入参数模型"""
-
- rule_id: str = Field(..., description="Custom rule ID to delete.")
-
-
-class DeleteCustomFilterRuleTool(MoviePilotTool):
- name: str = "delete_custom_filter_rule"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.FilterRule,
- ToolTag.Admin,
- ]
- description: str = (
- "Delete a custom filter rule from CustomFilterRules. "
- "If the rule is still referenced by rule groups, the deletion is blocked to avoid breaking rule_string expressions."
- )
- args_schema: Type[BaseModel] = DeleteCustomFilterRuleInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- return f"删除自定义过滤规则 {kwargs.get('rule_id', '')}"
-
- async def run(self, rule_id: str, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, rule_id={rule_id}")
-
- try:
- custom_rules = get_custom_rules()
- target_rule = next((rule for rule in custom_rules if rule.id == rule_id), None)
- if not target_rule:
- return json.dumps(
- {
- "success": False,
- "message": f"自定义过滤规则 '{rule_id}' 不存在",
- },
- ensure_ascii=False,
- )
-
- refs = collect_custom_rule_group_refs(get_rule_groups(), [rule_id]).get(
- rule_id, []
- )
- if refs:
- return json.dumps(
- {
- "success": False,
- "message": (
- f"自定义过滤规则 '{rule_id}' 仍被规则组引用,无法删除。"
- ),
- "referenced_by_rule_groups": refs,
- },
- ensure_ascii=False,
- indent=2,
- )
-
- remaining_rules = [
- rule for rule in custom_rules if rule.id != rule_id
- ]
- await save_system_config(
- SystemConfigKey.CustomFilterRules,
- [rule.model_dump(exclude_none=True) for rule in remaining_rules],
- )
-
- return json.dumps(
- {
- "success": True,
- "message": f"已删除自定义过滤规则 {rule_id}",
- "count": len(remaining_rules),
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"删除自定义过滤规则失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"删除自定义过滤规则失败: {exc}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/delete_download_history.py b/app/agent/tools/impl/delete_download_history.py
deleted file mode 100644
index d17c094ef..000000000
--- a/app/agent/tools/impl/delete_download_history.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""删除下载历史记录工具"""
-
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class DeleteDownloadHistoryInput(BaseModel):
- """删除下载历史记录工具的输入参数模型"""
-
- history_id: int = Field(
- ..., description="The ID of the download history record to delete"
- )
-
-
-class DeleteDownloadHistoryTool(MoviePilotTool):
- name: str = "delete_download_history"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Download,
- ToolTag.Admin,
- ]
- description: str = "Delete a download history record by ID. This only removes the record from the database, does not delete any actual files."
- args_schema: Type[BaseModel] = DeleteDownloadHistoryInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- history_id = kwargs.get("history_id")
- return f"删除下载历史记录 ID: {history_id}"
-
- async def run(self, history_id: int, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: history_id={history_id}")
-
- try:
- await self.data.download_history.async_delete(history_id)
- return f"下载历史记录 ID: {history_id} 已成功删除"
- except Exception as e:
- logger.error(f"删除下载历史记录失败: {e}", exc_info=True)
- return f"删除下载历史记录时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/delete_download_tasks.py b/app/agent/tools/impl/delete_download_tasks.py
deleted file mode 100644
index ceb1edb6e..000000000
--- a/app/agent/tools/impl/delete_download_tasks.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""删除下载任务工具"""
-
-from typing import Optional, Type, cast
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.download import DownloadChain
-from app.runtime.log import logger
-
-
-class DeleteDownloadTasksInput(BaseModel):
- """删除下载任务工具的输入参数模型"""
-
- hash: str = Field(
- ..., description="Task hash (can be obtained from query_download_tasks tool)"
- )
- downloader: Optional[str] = Field(
- None,
- description="Name of specific downloader (optional, if not provided will search all downloaders)",
- )
- delete_files: Optional[bool] = Field(
- False,
- description="Whether to delete downloaded files along with the task (default: False, only removes the task from downloader)",
- )
-
-
-class DeleteDownloadTasksTool(MoviePilotTool):
- """删除下载任务工具"""
-
- name: str = "delete_download_tasks"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Download,
- ToolTag.Admin,
- ]
- description: str = "Delete a download task from the downloader by task hash only. Optionally specify the downloader name and whether to delete downloaded files."
- args_schema: Type[BaseModel] = DeleteDownloadTasksInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据删除参数生成友好的提示消息"""
- hash_value = kwargs.get("hash", "")
- downloader = kwargs.get("downloader")
- delete_files = kwargs.get("delete_files", False)
-
- message = f"删除下载任务: {hash_value}"
- if downloader:
- message += f" [下载器: {downloader}]"
- if delete_files:
- message += " (包含文件)"
-
- return message
-
- @staticmethod
- def _delete_download_sync(
- hash_value: str, downloader: Optional[str] = None, delete_files: bool = False
- ) -> bool:
- """同步删除下载任务,避免下载器客户端阻塞事件循环。"""
- return cast(
- bool,
- DownloadChain().remove_torrents(
- hashs=[hash_value], downloader=downloader, delete_file=delete_files
- ),
- )
-
- async def run(
- self,
- hash: str,
- downloader: Optional[str] = None,
- delete_files: Optional[bool] = False,
- **kwargs,
- ) -> str:
- """执行删除下载任务。"""
- logger.info(
- f"执行工具: {self.name}, 参数: hash={hash}, downloader={downloader}, delete_files={delete_files}"
- )
-
- try:
- # 仅支持通过hash删除任务
- if len(hash) != 40 or not all(c in "0123456789abcdefABCDEF" for c in hash):
- return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
-
- # 删除下载任务
- # remove_torrents 支持 delete_file 参数,可以控制是否删除文件
- result = await self.run_blocking(
- "downloader",
- self._delete_download_sync,
- hash,
- downloader,
- bool(delete_files),
- )
-
- if result:
- files_info = "(包含文件)" if delete_files else "(不包含文件)"
- return f"成功删除下载任务:{hash} {files_info}"
- else:
- return f"删除下载任务失败:{hash},请检查任务是否存在或下载器是否可用"
- except Exception as e:
- logger.error(f"删除下载任务失败: {e}", exc_info=True)
- return f"删除下载任务时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/delete_rule_group.py b/app/agent/tools/impl/delete_rule_group.py
deleted file mode 100644
index c24fef469..000000000
--- a/app/agent/tools/impl/delete_rule_group.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""删除过滤规则组工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.impl._filter_rule_utils import (
- get_rule_groups,
- publish_rule_config_changed,
-)
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class DeleteRuleGroupInput(BaseModel):
- """删除过滤规则组工具的输入参数模型"""
-
- name: str = Field(..., description="Rule group name to delete.")
-
-
-class DeleteRuleGroupTool(MoviePilotTool):
- name: str = "delete_rule_group"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.FilterRule,
- ToolTag.Admin,
- ]
- description: str = (
- "Delete a filter rule group from UserFilterRuleGroups. "
- "The tool also removes dangling references from global settings and subscriptions."
- )
- args_schema: Type[BaseModel] = DeleteRuleGroupInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- return f"删除规则组 {kwargs.get('name', '')}"
-
- async def run(self, name: str, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, name={name}")
-
- try:
- rule_groups = get_rule_groups()
- expected_definitions = [
- group.model_dump(exclude_none=True) for group in rule_groups
- ]
- if not any(group.name == name for group in rule_groups):
- return json.dumps(
- {
- "success": False,
- "message": f"规则组 '{name}' 不存在",
- },
- ensure_ascii=False,
- )
-
- remaining_groups = [
- group for group in rule_groups if group.name != name
- ]
- definitions = [
- group.model_dump(exclude_none=True) for group in remaining_groups
- ]
- async with self.data.async_rule_group_mutation_scope() as mutation:
- result = await mutation.apply(
- definitions,
- expected_rule_groups=expected_definitions,
- previous_name=name,
- )
- await publish_rule_config_changed(
- SystemConfigKey.UserFilterRuleGroups,
- definitions,
- )
- reference_changes = result.to_dict()
-
- return json.dumps(
- {
- "success": True,
- "message": f"已删除规则组 {name}",
- "count": len(remaining_groups),
- "reference_updates": reference_changes,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"删除规则组失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"删除规则组失败: {exc}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/delete_subscribe.py b/app/agent/tools/impl/delete_subscribe.py
deleted file mode 100644
index 0d6c9d0e2..000000000
--- a/app/agent/tools/impl/delete_subscribe.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""删除订阅工具"""
-
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.subscription.delete import (
- SubscribeDeletionActor,
-)
-from app.application.subscription.mutation import SubscriptionActor
-from app.runtime.log import logger
-
-
-class DeleteSubscribeInput(BaseModel):
- """删除订阅工具的输入参数模型"""
-
- subscribe_id: int = Field(
- ...,
- description="The ID of the subscription to delete (can be obtained from query_subscribes tool)",
- )
-
-
-class DeleteSubscribeTool(MoviePilotTool):
- """按订阅 ID 删除影视、单曲或专辑订阅。"""
-
- name: str = "delete_subscribe"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Subscription,
- ToolTag.Admin,
- ]
- description: str = "Delete a media subscription by its ID. This will remove the subscription and stop automatic downloads for that media."
- args_schema: Type[BaseModel] = DeleteSubscribeInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据删除参数生成友好的提示消息"""
- subscribe_id = kwargs.get("subscribe_id")
- return f"删除订阅 (ID: {subscribe_id})"
-
- async def run(self, subscribe_id: int, **kwargs) -> str:
- """删除订阅并同步刷新带音乐实体维度的共享统计。"""
- logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
-
- try:
- async with self.data.subscription_mutation_scope() as mutation:
- subscribe = await mutation.get_accessible(
- subscribe_id,
- SubscriptionActor(name="agent", is_superuser=True),
- )
- if not subscribe:
- return f"订阅 ID {subscribe_id} 不存在"
-
- async with self.data.subscription_delete_scope() as command:
- deleted = await command.execute(
- subscribe_id,
- SubscribeDeletionActor(username="agent", is_superuser=True),
- )
- if not deleted:
- return f"订阅 ID {subscribe_id} 不存在"
-
- return f"成功删除订阅:{subscribe.name} ({subscribe.year})"
- except Exception as e:
- logger.error(f"删除订阅失败: {e}", exc_info=True)
- return f"删除订阅时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/delete_transfer_history.py b/app/agent/tools/impl/delete_transfer_history.py
deleted file mode 100644
index b04e534e0..000000000
--- a/app/agent/tools/impl/delete_transfer_history.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""删除整理历史记录工具"""
-
-import asyncio
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.transfer.execution import (
- TransferExecutionCommand,
- TransferExecutionRepository,
- TransferRetryRequestResult,
-)
-from app.chain.storage import StorageChain
-from app.runtime.log import logger
-from app.schemas.workflow import FileItem
-
-
-class DeleteTransferHistoryInput(BaseModel):
- """删除整理历史记录工具的输入参数模型"""
-
- history_id: int = Field(
- ..., description="The ID of the transfer history record to delete"
- )
-
-
-def _delete_history_destination_file(fileitem: FileItem) -> tuple[bool, bool]:
- """在存储 worker 内完成旧目标检查和删除,保持历史删除前的顺序。"""
- storage_chain = StorageChain()
- if not storage_chain.exists(fileitem):
- return False, False
- return True, bool(storage_chain.delete_media_file(fileitem))
-
-
-def _request_transfer_retry(
- repository: TransferExecutionRepository,
- *,
- history_id: int,
- task_id: str,
- user_id: str,
-) -> TransferRetryRequestResult:
- """在线程池中登记 durable 重试,避免 Agent 事件循环执行同步数据库 I/O。"""
- return TransferExecutionCommand(
- repository
- ).request_retry(
- task_id=task_id,
- reason=f"Agent 请求重试整理历史 #{history_id}",
- requested_by=f"agent:{user_id or 'unknown'}",
- )
-
-
-class DeleteTransferHistoryTool(MoviePilotTool):
- name: str = "delete_transfer_history"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Transfer,
- ToolTag.Admin,
- ]
- description: str = (
- "Request a safe retry for durable transfer history, or delete a legacy transfer history record by its ID. "
- "Durable records keep their files and history and are retried only by the persistent scheduler. For legacy "
- "non-successful-move records, the tool removes the old destination before deleting the history. If a durable "
- "retry is accepted or rejected, stop and report that result; do not call transfer_file for the same record."
- )
- args_schema: Type[BaseModel] = DeleteTransferHistoryInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据参数生成友好的提示消息"""
- history_id = kwargs.get("history_id")
- return f"删除整理历史记录: ID={history_id}"
-
- async def run(self, history_id: int, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: history_id={history_id}")
-
- try:
- transferhis = self.data.transfer_history
- history = await transferhis.async_get(history_id)
- if not history:
- return f"错误:整理历史记录不存在,ID={history_id}"
-
- task_id = getattr(history, "transfer_task_id", None)
- if task_id:
- retry = await self.run_blocking(
- "db",
- _request_transfer_retry,
- self.data.transfer_execution,
- history_id=history_id,
- task_id=task_id,
- user_id=self._user_id,
- )
- outcome = "已登记" if retry.accepted else "未登记"
- return (
- f"durable 整理任务{outcome}重试:ID={history_id},"
- f"task_id={task_id},state={retry.state.value},{retry.message}。"
- "已保留目标文件、历史记录和失败计数;不要调用 transfer_file。"
- )
-
- title = history.title or "未知"
- src = history.src or "未知"
- status = "成功" if history.status else "失败"
- deleted_dest = False
- if history.dest_fileitem and not (history.status and history.mode == "move"):
- if not isinstance(history.dest_fileitem, dict):
- return "删除失败:目标文件历史数据无效,已保留历史记录"
- dest_fileitem = FileItem(**history.dest_fileitem)
- try:
- destination_exists, destination_deleted = await self.run_blocking(
- "storage",
- _delete_history_destination_file,
- dest_fileitem,
- )
- except asyncio.CancelledError:
- logger.warning(
- "删除整理历史的旧媒体文件等待已取消,底层文件操作可能仍在继续,"
- "请确认实际状态后再重试,历史记录尚未删除,path=%s",
- dest_fileitem.path,
- )
- raise
- if destination_exists:
- if not destination_deleted:
- return f"错误:旧媒体库文件删除失败,路径={dest_fileitem.path}"
- deleted_dest = True
- await transferhis.async_delete(history_id)
- message = (
- f"已删除整理历史记录:ID={history_id},标题={title},源路径={src},状态={status}"
- )
- if deleted_dest:
- message += ",已删除旧媒体库文件"
- return message
- except Exception as e:
- logger.error(f"删除整理历史记录失败: {e}", exc_info=True)
- return f"删除整理历史记录时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/get_recommendations.py b/app/agent/tools/impl/get_recommendations.py
deleted file mode 100644
index 575138345..000000000
--- a/app/agent/tools/impl/get_recommendations.py
+++ /dev/null
@@ -1,347 +0,0 @@
-"""获取推荐工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.recommend import RecommendChain
-from app.runtime.log import logger
-from app.chain.listenbrainz import (
- LISTENBRAINZ_CHART_RANGES,
- LISTENBRAINZ_FRESH_MAX_DAYS,
- LISTENBRAINZ_FRESH_SORTS,
-)
-from app.schemas.types import (
- MUSIC_ENTITY_ALBUM,
- MUSIC_ENTITY_RECORDING,
- MediaType,
- media_type_to_agent,
-)
-from app.domain.media import normalize_music_type
-from ._music_utils import simplify_music_info
-
-
-class GetRecommendationsInput(BaseModel):
- """获取推荐工具的输入参数模型"""
-
- source: Optional[str] = Field(
- "tmdb_trending",
- description="Recommendation source: "
- "'tmdb_trending' for TMDB trending content, "
- "'tmdb_movies' for TMDB popular movies, "
- "'tmdb_tvs' for TMDB popular TV shows, "
- "'douban_hot' for Douban popular content, "
- "'douban_movie_hot' for Douban hot movies, "
- "'douban_tv_hot' for Douban hot TV shows, "
- "'douban_movie_showing' for Douban movies currently showing, "
- "'douban_movies' for Douban latest movies, "
- "'douban_tvs' for Douban latest TV shows, "
- "'douban_movie_top250' for Douban movie TOP250, "
- "'douban_tv_weekly_chinese' for Douban Chinese TV weekly chart, "
- "'douban_tv_weekly_global' for Douban global TV weekly chart, "
- "'douban_tv_animation' for Douban popular animation, "
- "'bangumi_calendar' for Bangumi anime calendar, "
- "'listenbrainz_chart' for popular recordings/albums, "
- "'listenbrainz_fresh' for fresh album releases",
- )
- media_type: Optional[str] = Field(
- "all", description="Allowed values: movie, tv, music, all"
- )
- page: Optional[int] = Field(
- 1, description="Page number for pagination (default: 1, 20 items per page)"
- )
- music_type: Optional[str] = Field(
- None,
- description="For listenbrainz_chart: recording or album. Fresh releases are always albums",
- )
- range_name: Optional[str] = Field(
- "this_month",
- description="ListenBrainz chart range such as this_week, this_month, this_year, or all_time",
- )
- sort_by: Optional[str] = Field(
- "listen_count.desc",
- description="ListenBrainz chart sort: listen_count.desc or listen_count.asc",
- )
- days: Optional[int] = Field(14, description="Fresh release window in days, max 90")
- fresh_sort: Optional[str] = Field(
- "release_date",
- description="Fresh release sort: release_date, artist_credit_name, or release_name",
- )
- past: Optional[bool] = Field(True, description="Include already released albums")
- future: Optional[bool] = Field(True, description="Include upcoming albums")
- min_listen_count: Optional[int] = Field(0, description="Minimum chart listen count")
- with_cover: Optional[bool] = Field(False, description="Only return music results with cover art")
-
-
-class GetRecommendationsTool(MoviePilotTool):
- """获取影视推荐或 ListenBrainz 音乐探索结果。"""
-
- name: str = "get_recommendations"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Media,
- ToolTag.Recommendation,
- ]
- description: str = (
- "Get movie, TV, anime, or music discovery results. Music supports ListenBrainz chart periods, "
- "recording/album modes, listen-count sorting, and fresh album releases. Returned music IDs can be "
- "passed directly to detail, library, subscription, and torrent tools."
- )
- args_schema: Type[BaseModel] = GetRecommendationsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据推荐参数生成友好的提示消息"""
- source = kwargs.get("source", "tmdb_trending")
- media_type = kwargs.get("media_type", "all")
- page = kwargs.get("page", 1)
-
- source_map = {
- "tmdb_trending": "TMDB流行趋势",
- "tmdb_movies": "TMDB热门电影",
- "tmdb_tvs": "TMDB热门电视剧",
- "douban_hot": "豆瓣热门",
- "douban_movie_hot": "豆瓣热门电影",
- "douban_tv_hot": "豆瓣热门电视剧",
- "douban_movie_showing": "豆瓣热映",
- "douban_movies": "豆瓣最新电影",
- "douban_tvs": "豆瓣最新电视剧",
- "douban_movie_top250": "豆瓣电影TOP250",
- "douban_tv_weekly_chinese": "豆瓣国产剧集榜",
- "douban_tv_weekly_global": "豆瓣全球剧集榜",
- "douban_tv_animation": "豆瓣热门动漫",
- "bangumi_calendar": "番组计划",
- "listenbrainz_chart": "ListenBrainz 音乐榜单",
- "listenbrainz_fresh": "ListenBrainz 新发行专辑",
- }
- source_desc = source_map.get(source, source)
-
- message = f"获取推荐: {source_desc}"
- if media_type != "all":
- message += f" [{media_type}]"
- message += f" (第{page}页)"
-
- return message
-
- async def run(
- self,
- source: Optional[str] = "tmdb_trending",
- media_type: Optional[str] = "all",
- page: Optional[int] = 1,
- music_type: Optional[str] = None,
- range_name: Optional[str] = "this_month",
- sort_by: Optional[str] = "listen_count.desc",
- days: Optional[int] = 14,
- fresh_sort: Optional[str] = "release_date",
- past: Optional[bool] = True,
- future: Optional[bool] = True,
- min_listen_count: Optional[int] = 0,
- with_cover: Optional[bool] = False,
- **kwargs,
- ) -> str:
- """按来源校验参数并返回有界推荐列表。"""
- page = max(1, page or 1)
- page_size = 20
- logger.info(
- f"执行工具: {self.name}, 参数: source={source}, media_type={media_type}, page={page}"
- )
- try:
- if media_type != "all":
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music', 'all'"
- media_type = media_type_enum.to_agent()
-
- if source in {"listenbrainz_chart", "listenbrainz_fresh"}:
- if media_type not in {"all", "music"}:
- return "错误:ListenBrainz 来源只能与 media_type='music' 或 'all' 一起使用"
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(
- music_type,
- allow_artist=False,
- )
- if not normalized_music_type:
- return (
- f"错误:无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album'"
- )
- recommend_chain = RecommendChain()
- if source == "listenbrainz_chart":
- if range_name not in LISTENBRAINZ_CHART_RANGES:
- return f"错误:无效的榜单周期 '{range_name}'"
- if sort_by not in {"listen_count.desc", "listen_count.asc"}:
- return f"错误:无效的榜单排序 '{sort_by}'"
- results = await recommend_chain.async_music_chart(
- range_name=range_name,
- page=page,
- count=page_size,
- sort_by=sort_by,
- min_listen_count=max(0, min_listen_count or 0),
- with_cover=bool(with_cover),
- entity=normalized_music_type or MUSIC_ENTITY_RECORDING,
- )
- else:
- if normalized_music_type and normalized_music_type != MUSIC_ENTITY_ALBUM:
- return "错误:ListenBrainz 新发行结果只支持 music_type='album'"
- if fresh_sort not in LISTENBRAINZ_FRESH_SORTS:
- return f"错误:无效的新发行排序 '{fresh_sort}'"
- if not past and not future:
- return "错误:past 和 future 不能同时为 false"
- normalized_days = max(1, min(days or 14, LISTENBRAINZ_FRESH_MAX_DAYS))
- results = await recommend_chain.async_music_fresh_releases(
- days=normalized_days,
- sort=fresh_sort,
- past=bool(past),
- future=bool(future),
- page=page,
- count=page_size,
- with_cover=bool(with_cover),
- )
- if not results:
- return "未找到音乐推荐内容。"
- return json.dumps(
- [simplify_music_info(item) for item in results],
- ensure_ascii=False,
- indent=2,
- )
-
- if media_type == "music":
- return "错误:音乐推荐请使用 listenbrainz_chart 或 listenbrainz_fresh 来源"
-
- recommend_chain = RecommendChain()
- results = []
- if source == "tmdb_trending":
- results = await recommend_chain.async_tmdb_trending(page=page)
- elif source == "tmdb_movies":
- results = await recommend_chain.async_tmdb_movies(page=page)
- elif source == "tmdb_tvs":
- results = await recommend_chain.async_tmdb_tvs(page=page)
- elif source == "douban_hot":
- if media_type == "movie":
- results = await recommend_chain.async_douban_movie_hot(
- page=page, count=page_size
- )
- elif media_type == "tv":
- results = await recommend_chain.async_douban_tv_hot(
- page=page, count=page_size
- )
- else: # all
- results.extend(
- await recommend_chain.async_douban_movie_hot(
- page=page, count=page_size
- )
- )
- results.extend(
- await recommend_chain.async_douban_tv_hot(
- page=page, count=page_size
- )
- )
- elif source == "douban_movie_hot":
- results = await recommend_chain.async_douban_movie_hot(
- page=page, count=page_size
- )
- elif source == "douban_tv_hot":
- results = await recommend_chain.async_douban_tv_hot(
- page=page, count=page_size
- )
- elif source == "douban_movie_showing":
- results = await recommend_chain.async_douban_movie_showing(
- page=page, count=page_size
- )
- elif source == "douban_movies":
- results = await recommend_chain.async_douban_movies(
- page=page, count=page_size
- )
- elif source == "douban_tvs":
- results = await recommend_chain.async_douban_tvs(
- page=page, count=page_size
- )
- elif source == "douban_movie_top250":
- results = await recommend_chain.async_douban_movie_top250(
- page=page, count=page_size
- )
- elif source == "douban_tv_weekly_chinese":
- results = await recommend_chain.async_douban_tv_weekly_chinese(
- page=page, count=page_size
- )
- elif source == "douban_tv_weekly_global":
- results = await recommend_chain.async_douban_tv_weekly_global(
- page=page, count=page_size
- )
- elif source == "douban_tv_animation":
- results = await recommend_chain.async_douban_tv_animation(
- page=page, count=page_size
- )
- elif source == "bangumi_calendar":
- results = await recommend_chain.async_bangumi_calendar(
- page=page, count=page_size
- )
- else:
- # 不支持的推荐来源
- supported_sources = [
- "tmdb_trending",
- "tmdb_movies",
- "tmdb_tvs",
- "douban_hot",
- "douban_movie_hot",
- "douban_tv_hot",
- "douban_movie_showing",
- "douban_movies",
- "douban_tvs",
- "douban_movie_top250",
- "douban_tv_weekly_chinese",
- "douban_tv_weekly_global",
- "douban_tv_animation",
- "bangumi_calendar",
- "listenbrainz_chart",
- "listenbrainz_fresh",
- ]
- return f"不支持的推荐来源: {source}。支持的来源包括: {', '.join(supported_sources)}"
-
- if results:
- # 对于TMDB来源,API自身按页返回,取前page_size条
- total_count = len(results)
- page_results = results[:page_size]
- # 精简字段,只保留关键信息
- simplified_results = []
- for r in page_results:
- # r 应该是字典格式(to_dict的结果),但为了安全起见进行检查
- if not isinstance(r, dict):
- logger.warning(f"推荐结果格式异常,跳过: {type(r)}")
- continue
-
- simplified = {
- "title": r.get("title"),
- "en_title": r.get("en_title"),
- "year": r.get("year"),
- "type": media_type_to_agent(r.get("type")),
- "season": r.get("season"),
- "tmdb_id": r.get("tmdb_id"),
- "imdb_id": r.get("imdb_id"),
- "douban_id": r.get("douban_id"),
- "bangumi_id": r.get("bangumi_id"),
- "anilist_id": r.get("anilist_id"),
- "media_source": r.get("media_source"),
- "media_id": r.get("media_id"),
- "vote_average": r.get("vote_average"),
- "poster_path": r.get("poster_path"),
- "detail_link": r.get("detail_link"),
- }
- simplified_results.append(simplified)
- result_json = json.dumps(
- simplified_results, ensure_ascii=False, indent=2
- )
- has_more = total_count > page_size
- payload_msg = f"第 {page} 页,当前页 {len(simplified_results)} 条结果。"
- if has_more:
- payload_msg += (
- f" 可能有更多数据,可使用 page={page + 1} 获取下一页。"
- )
- return f"{payload_msg}\n\n{result_json}"
- return "未找到推荐内容。"
- except Exception as e:
- logger.error(f"获取推荐失败: {e}", exc_info=True)
- return f"获取推荐时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/get_search_results.py b/app/agent/tools/impl/get_search_results.py
deleted file mode 100644
index d49b4a683..000000000
--- a/app/agent/tools/impl/get_search_results.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""获取搜索结果工具"""
-
-import json
-import re
-from typing import List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.search.facade import SearchChain
-from app.runtime.log import logger
-from ._torrent_search_utils import (
- TORRENT_RESULT_LIMIT,
- build_filter_options,
- filter_contexts,
- simplify_search_result,
-)
-
-
-class GetSearchResultsInput(BaseModel):
- """获取搜索结果工具的输入参数模型"""
-
- site: Optional[List[str]] = Field(None, description="Site name filters")
- season: Optional[List[str]] = Field(None, description="Season or episode filters")
- free_state: Optional[List[str]] = Field(None, description="Promotion state filters")
- video_code: Optional[List[str]] = Field(None, description="Video codec filters")
- edition: Optional[List[str]] = Field(None, description="Edition filters")
- resolution: Optional[List[str]] = Field(None, description="Resolution filters")
- release_group: Optional[List[str]] = Field(
- None, description="Release group filters"
- )
- title_pattern: Optional[str] = Field(
- None,
- description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')",
- )
- content_pattern: Optional[str] = Field(
- None,
- description="Regular expression pattern to filter torrent titles, descriptions, and labels (e.g., '特效字幕|国语|DIY')",
- )
- include_description: Optional[bool] = Field(
- False,
- description="Whether to include torrent descriptions in returned results",
- )
- include_labels: Optional[bool] = Field(
- False,
- description="Whether to include torrent labels in returned results",
- )
- show_filter_options: Optional[bool] = Field(
- False,
- description="Whether to return only optional filter options for re-checking available conditions",
- )
- page: Optional[int] = Field(
- 1,
- description="Page number for pagination (default: 1, each page returns up to 50 results)",
- )
-
-
-class GetSearchResultsTool(MoviePilotTool):
- """获取并筛选最近一次种子搜索结果"""
-
- name: str = "get_search_results"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Resource,
- ]
- description: str = "Get cached torrent search results from search_torrents with optional filters. Supports pagination with up to 50 results per page."
- args_schema: Type[BaseModel] = GetSearchResultsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """返回工具执行提示"""
- return "获取搜索结果"
-
- async def run(
- self,
- site: Optional[List[str]] = None,
- season: Optional[List[str]] = None,
- free_state: Optional[List[str]] = None,
- video_code: Optional[List[str]] = None,
- edition: Optional[List[str]] = None,
- resolution: Optional[List[str]] = None,
- release_group: Optional[List[str]] = None,
- title_pattern: Optional[str] = None,
- content_pattern: Optional[str] = None,
- include_description: bool = False,
- include_labels: bool = False,
- show_filter_options: bool = False,
- page: Optional[int] = 1,
- **kwargs,
- ) -> str:
- """
- 获取并筛选最近一次种子搜索结果
-
- :param site: 站点名称筛选项
- :param season: 季集筛选项
- :param free_state: 促销状态筛选项
- :param video_code: 视频编码筛选项
- :param edition: 制作版本筛选项
- :param resolution: 分辨率筛选项
- :param release_group: 发布组筛选项
- :param title_pattern: 仅匹配种子标题的正则表达式
- :param content_pattern: 匹配种子标题、简介和标签的正则表达式
- :param include_description: 是否在结果中返回种子简介
- :param include_labels: 是否在结果中返回种子标签
- :param show_filter_options: 是否只返回可用筛选项
- :param page: 分页页码
- :param kwargs: 工具框架附加参数
- :return: JSON 格式的搜索结果或错误提示
- """
- page = max(1, page or 1)
- logger.info(
- f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, include_labels={include_labels}, show_filter_options={show_filter_options}, page={page}"
- )
-
- try:
- items = await SearchChain().async_last_search_results() or []
- if not items:
- return "没有可用的搜索结果,请先使用 search_torrents 搜索"
-
- if show_filter_options:
- payload = {
- "total_count": len(items),
- "filter_options": build_filter_options(items),
- }
- return json.dumps(payload, ensure_ascii=False, indent=2)
-
- title_regex_pattern = None
- if title_pattern:
- try:
- title_regex_pattern = re.compile(title_pattern, re.IGNORECASE)
- except re.error as e:
- logger.warning(f"正则表达式编译失败: {title_pattern}, 错误: {e}")
- return f"正则表达式格式错误: {str(e)}"
-
- content_regex_pattern = None
- if content_pattern:
- try:
- content_regex_pattern = re.compile(content_pattern, re.IGNORECASE)
- except re.error as e:
- logger.warning(f"正则表达式编译失败: {content_pattern}, 错误: {e}")
- return f"正则表达式格式错误: {str(e)}"
-
- filtered_items = filter_contexts(
- items=items,
- site=site,
- season=season,
- free_state=free_state,
- video_code=video_code,
- edition=edition,
- resolution=resolution,
- release_group=release_group,
- )
- if title_regex_pattern:
- filtered_items = [
- item
- for item in filtered_items
- if item.torrent_info
- and item.torrent_info.title
- and title_regex_pattern.search(item.torrent_info.title)
- ]
- if content_regex_pattern:
- content_filtered_items = []
- for item in filtered_items:
- torrent_info = item.torrent_info
- if not torrent_info:
- continue
- content_values = [torrent_info.title, torrent_info.description]
- content_values.extend(torrent_info.labels or [])
- if any(
- content_regex_pattern.search(str(value))
- for value in content_values
- if value
- ):
- content_filtered_items.append(item)
- filtered_items = content_filtered_items
- if not filtered_items:
- return "没有符合筛选条件的搜索结果,请调整筛选条件"
-
- total_count = len(filtered_items)
- filtered_ids = {id(item) for item in filtered_items}
- matched_indices = [
- index
- for index, item in enumerate(items, start=1)
- if id(item) in filtered_ids
- ]
-
- # 分页
- page_size = TORRENT_RESULT_LIMIT
- start = (page - 1) * page_size
- end = start + page_size
- page_items = filtered_items[start:end]
- page_indices = matched_indices[start:end]
-
- if not page_items:
- return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {(total_count + page_size - 1) // page_size} 页。"
-
- results = [
- simplify_search_result(
- item,
- index,
- include_description=include_description,
- include_labels=include_labels,
- )
- for item, index in zip(page_items, page_indices)
- ]
- total_pages = (total_count + page_size - 1) // page_size
- payload = {
- "total_count": total_count,
- "page": page,
- "total_pages": total_pages,
- "results": results,
- }
- if page < total_pages:
- payload["message"] = (
- f"搜索结果共 {total_count} 条,当前第 {page}/{total_pages} 页,可使用 page={page + 1} 获取下一页。"
- )
- return json.dumps(payload, ensure_ascii=False, indent=2)
- except Exception as e:
- error_message = f"获取搜索结果失败: {str(e)}"
- logger.error(f"获取搜索结果失败: {e}", exc_info=True)
- return error_message
diff --git a/app/agent/tools/impl/install_plugin.py b/app/agent/tools/impl/install_plugin.py
deleted file mode 100644
index f0bfa9c49..000000000
--- a/app/agent/tools/impl/install_plugin.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""安装插件工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field, field_validator
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._plugin_tool_utils import (
- get_plugin_snapshot,
- inspect_plugin_sources,
- install_plugin_runtime,
- load_market_plugins,
- summarize_plugin,
-)
-from app.runtime.log import logger
-
-
-class InstallPluginInput(BaseModel):
- """安装插件工具的输入参数模型"""
-
- plugin_id: str = Field(
- ...,
- description="Exact plugin ID to install. Use query_market_plugins first to find the correct plugin_id.",
- )
- force: bool = Field(
- False,
- description="Whether to force reinstall or upgrade the specified plugin.",
- )
- force_refresh_market: bool = Field(
- False,
- description="Whether to refresh plugin market caches before reading the market list.",
- )
- repo_url: Optional[str] = Field(
- None,
- description=(
- "Exact repository URL explicitly selected by the administrator. "
- "Only set it after a source conflict is shown to the user."
- ),
- )
-
- @field_validator("repo_url")
- @classmethod
- def normalize_repo_url(cls, value: Optional[str]) -> Optional[str]:
- """显式来源必须是非空在线仓库地址。"""
- if value is None:
- return None
- normalized = value.strip()
- if not normalized:
- raise ValueError("Explicit source repository URL cannot be empty.")
- if normalized.startswith("local://"):
- raise ValueError("Explicit source selection only accepts online repositories.")
- return normalized
-
-
-class InstallPluginTool(MoviePilotTool):
- name: str = "install_plugin"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Install a plugin by exact plugin_id from the plugin market or local plugin repositories. "
- "Use query_market_plugins first when you need filtering or discovery."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = InstallPluginInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- plugin_id = kwargs.get("plugin_id")
- return f"安装插件: {plugin_id or '未知插件'}"
-
- async def run(
- self,
- plugin_id: str,
- force: bool = False,
- force_refresh_market: bool = False,
- repo_url: Optional[str] = None,
- **kwargs,
- ) -> str:
- logger.info(
- f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, force={force}"
- )
-
- try:
- plugins = await load_market_plugins(force_refresh=force_refresh_market)
- if not plugins:
- return json.dumps(
- {"success": False, "message": "当前插件市场没有可用插件"},
- ensure_ascii=False,
- )
-
- candidate = next((plugin for plugin in plugins if plugin.id == plugin_id), None)
- if not candidate:
- return json.dumps(
- {
- "success": False,
- "message": f"未在插件市场中找到插件: {plugin_id}。请先调用 query_market_plugins 确认 plugin_id。",
- },
- ensure_ascii=False,
- )
-
- success, message, refreshed_only = await install_plugin_runtime(
- candidate.id,
- repo_url,
- force=force,
- explicit_source=repo_url is not None,
- )
- if not success:
- source_options = await inspect_plugin_sources(
- candidate.id,
- force=False,
- )
- if (
- repo_url is None
- and source_options["selection_status"] in {
- "conflict",
- "incomplete",
- }
- ):
- return json.dumps(
- {
- "success": False,
- "plugin": summarize_plugin(candidate),
- "message": source_options["selection_reason"],
- "requires_explicit_source": True,
- "source_candidates": source_options["candidates"],
- },
- ensure_ascii=False,
- indent=2,
- )
- return json.dumps(
- {
- "success": False,
- "plugin": summarize_plugin(candidate),
- "message": message,
- },
- ensure_ascii=False,
- indent=2,
- )
-
- plugin_snapshot = get_plugin_snapshot(candidate.id)
- if refreshed_only and getattr(candidate, "has_update", False) and not force:
- message = "插件已安装,当前仅刷新加载;如需升级到市场新版本,请设置 force=true"
-
- return json.dumps(
- {
- "success": True,
- "message": message,
- "force": force,
- "refreshed_only": refreshed_only,
- "plugin": summarize_plugin(candidate),
- "runtime": plugin_snapshot,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as e:
- logger.error(f"安装插件失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"安装插件时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/list_directory.py b/app/agent/tools/impl/list_directory.py
deleted file mode 100644
index bb2e23d13..000000000
--- a/app/agent/tools/impl/list_directory.py
+++ /dev/null
@@ -1,194 +0,0 @@
-"""查询文件系统目录内容工具"""
-
-import json
-from datetime import datetime
-from pathlib import Path
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.storage import StorageChain
-from app.runtime.log import logger
-from app.schemas.file import FileItem
-from app.foundation import size as size_tools
-from app.foundation import text as text_tools
-
-
-DEFAULT_DIRECTORY_PAGE_SIZE = 50
-MAX_DIRECTORY_PAGE_SIZE = 200
-
-
-class ListDirectoryInput(BaseModel):
- """查询文件系统目录内容工具的输入参数模型"""
- path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
- storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
- sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")
- limit: Optional[int] = Field(
- DEFAULT_DIRECTORY_PAGE_SIZE,
- ge=1,
- le=MAX_DIRECTORY_PAGE_SIZE,
- description=(
- f"Maximum items to return in this page (default: {DEFAULT_DIRECTORY_PAGE_SIZE}, "
- f"maximum: {MAX_DIRECTORY_PAGE_SIZE})"
- ),
- )
- offset: Optional[int] = Field(
- 0,
- ge=0,
- description="Number of sorted directory items to skip before this page",
- )
-
-
-class ListDirectoryTool(MoviePilotTool):
- """分页查询本地或远程存储目录中的文件和子目录。"""
-
- name: str = "list_directory"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Directory,
- ToolTag.File,
- ]
- description: str = (
- "List actual files and folders in a file system directory (NOT configuration). "
- "Shows files and subdirectories with their names, types, sizes, and modification "
- f"times. Returns a page of up to {DEFAULT_DIRECTORY_PAGE_SIZE} items with total "
- f"count and next offset; limit is capped at {MAX_DIRECTORY_PAGE_SIZE}. "
- "Use 'query_directory_settings' to query directory configuration settings."
- )
- args_schema: Type[BaseModel] = ListDirectoryInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据目录参数生成友好的提示消息"""
- path = kwargs.get("path", "")
- storage = kwargs.get("storage", "local")
-
- message = f"查询目录: {path}"
- if storage != "local":
- message += f" [存储: {storage}]"
-
- return message
-
- @staticmethod
- def _list_directory_sync(
- path: str,
- storage: Optional[str] = "local",
- sort_by: Optional[str] = "name",
- limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
- offset: Optional[int] = 0,
- ) -> str:
- """
- 目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行并分页返回。
- """
- if not path:
- return "错误:路径不能为空"
-
- if storage == "local":
- if not path.startswith("/") and not (len(path) > 1 and path[1] == ":"):
- path = str(Path(path).resolve())
- elif not path.startswith("/"):
- path = "/" + path
-
- fileitem = FileItem(storage=storage or "local", path=path, type="dir")
- file_list = StorageChain().list_files(fileitem, recursion=False)
-
- if file_list is None:
- return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
- if sort_by == "time":
- file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
- else:
- file_list.sort(
- key=lambda x: (
- 0 if x.type == "dir" else 1,
- text_tools.natural_sort_key(x.name or ""),
- )
- )
-
- total_count = len(file_list)
- normalized_limit = max(
- 1,
- min(int(limit or DEFAULT_DIRECTORY_PAGE_SIZE), MAX_DIRECTORY_PAGE_SIZE),
- )
- normalized_offset = max(0, int(offset or 0))
- limited_list = file_list[
- normalized_offset : normalized_offset + normalized_limit
- ]
- simplified_items = []
- for item in limited_list:
- size_str = size_tools.format_compact_size(item.size) if item.size else None
- modify_time_str = None
- if item.modify_time:
- try:
- modify_time_str = datetime.fromtimestamp(item.modify_time).strftime(
- "%Y-%m-%d %H:%M:%S"
- )
- except (ValueError, OSError):
- modify_time_str = str(item.modify_time)
-
- simplified = {
- "name": item.name,
- "type": item.type,
- "path": item.path,
- "size": size_str,
- "modify_time": modify_time_str,
- }
- if item.type == "file" and item.extension:
- simplified["extension"] = item.extension
- simplified_items.append(simplified)
-
- returned_count = len(simplified_items)
- has_more = normalized_offset + returned_count < total_count
- return json.dumps(
- {
- "items": simplified_items,
- "total_count": total_count,
- "returned_count": returned_count,
- "limit": normalized_limit,
- "offset": normalized_offset,
- "has_more": has_more,
- "next_offset": (
- normalized_offset + returned_count if has_more else None
- ),
- },
- ensure_ascii=False,
- indent=2,
- )
-
- async def run(self, path: str, storage: Optional[str] = "local",
- sort_by: Optional[str] = "name",
- limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
- offset: Optional[int] = 0,
- **kwargs) -> str:
- """
- 分页查询指定目录的文件和子目录。
-
- :param path: 要查询的目录路径
- :param storage: 存储类型,默认为本地存储
- :param sort_by: 排序方式,支持名称或修改时间
- :param limit: 当前页最大条数,最高不超过工具上限
- :param offset: 当前页起始偏移量
- :return: 包含项目列表和分页元数据的 JSON 字符串
- """
- logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
-
- try:
- resolved_path, access_error = await self._check_local_storage_access(
- path=path, storage=storage, operation="列出"
- )
- if access_error:
- return access_error
- if resolved_path:
- path = str(resolved_path)
- return await self.run_blocking(
- "storage",
- self._list_directory_sync,
- path,
- storage,
- sort_by,
- limit,
- offset,
- )
- except Exception as e:
- logger.error(f"查询目录内容失败: {e}", exc_info=True)
- return f"查询目录内容时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/list_slash_commands.py b/app/agent/tools/impl/list_slash_commands.py
deleted file mode 100644
index a6741073e..000000000
--- a/app/agent/tools/impl/list_slash_commands.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""查询所有可用斜杠命令工具(系统命令 + 插件命令)"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class ListSlashCommandsInput(BaseModel):
- """查询所有可用斜杠命令工具的输入参数模型"""
-
-
-class ListSlashCommandsTool(MoviePilotTool):
- name: str = "list_slash_commands"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.SlashCommand,
- ToolTag.Admin,
- ]
- description: str = (
- "List all available slash commands in the system, including system preset commands "
- "(e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) "
- "and plugin-registered commands. "
- "Use this tool to discover what slash commands are available before executing them with run_slash_command. "
- "This is especially useful when the user describes an action in natural language and you need to "
- "find the matching command to fulfill their request."
- )
- args_schema: Type[BaseModel] = ListSlashCommandsInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- return "查询所有可用命令"
-
- async def run(self, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}")
-
- try:
- from app.application.commands import get_commands
-
- all_commands = get_commands()
-
- if not all_commands:
- return "当前没有可用的命令"
-
- commands_list = []
- for cmd, info in all_commands.items():
- cmd_info = {
- "command": cmd,
- "description": info.get("description", ""),
- }
- if info.get("category"):
- cmd_info["category"] = info["category"]
- # 标识命令类型
- if info.get("type") == "scheduler":
- cmd_info["type"] = "scheduler"
- elif info.get("pid"):
- cmd_info["type"] = "plugin"
- cmd_info["plugin_id"] = info["pid"]
- else:
- cmd_info["type"] = "system"
- commands_list.append(cmd_info)
-
- result = {
- "total": len(commands_list),
- "commands": commands_list,
- }
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- except Exception as e:
- logger.error(f"查询可用命令失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"查询可用命令时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/mcp.py b/app/agent/tools/impl/mcp.py
index 4df0eac98..0774f5805 100644
--- a/app/agent/tools/impl/mcp.py
+++ b/app/agent/tools/impl/mcp.py
@@ -30,8 +30,7 @@ class McpExternalTool(MoviePilotTool):
session_id=session_id,
user_id=user_id,
name=spec.agent_tool_name,
- description=spec.description
- or f"Call external MCP tool {spec.name} on {spec.server.name}.",
+ description=spec.description or f"Call external MCP tool {spec.name} on {spec.server.name}.",
args_schema=spec.input_schema,
require_admin=spec.server.require_admin,
)
@@ -127,17 +126,3 @@ async def create_external_mcp_tools(
)
tools.append(tool)
return tools
-
-
-def select_legacy_mcp_tools(
- tools: list[McpExternalTool],
-) -> list[McpExternalTool]:
- """保留跨服务器同名工具历史上的 first-wins 执行顺序。"""
- selected = []
- seen_names = set()
- for tool in tools:
- if tool.name in seen_names:
- continue
- selected.append(tool)
- seen_names.add(tool.name)
- return selected
diff --git a/app/agent/tools/impl/persona.py b/app/agent/tools/impl/persona.py
new file mode 100644
index 000000000..de01a1304
--- /dev/null
+++ b/app/agent/tools/impl/persona.py
@@ -0,0 +1,232 @@
+"""Agent 人格统一管理工具。"""
+
+import json
+from typing import Literal, Optional, Type
+
+from pydantic import BaseModel, Field, model_validator
+
+from app.agent.runtime import agent_runtime_manager
+from app.agent.tools.base import MoviePilotTool
+from app.agent.tools.tags import ToolTag
+from app.runtime.log import logger
+
+PersonaAction = Literal["list", "switch", "update"]
+
+
+class PersonaInput(BaseModel):
+ """查询、切换或更新 Agent 人格的统一输入参数。"""
+
+ action: PersonaAction = Field(
+ ...,
+ description="Action to perform: list, switch, or update.",
+ )
+ query: Optional[str] = Field(
+ None,
+ description=("Optional list filter for persona_id, label, description, or aliases."),
+ )
+ persona_id: Optional[str] = Field(
+ None,
+ description=(
+ "Target persona for switch or update. Existing labels and aliases are "
+ "accepted; new personas require a lowercase persona_id."
+ ),
+ )
+ label: Optional[str] = Field(None, description="Replacement display label.")
+ description: Optional[str] = Field(
+ None,
+ description="Replacement description of the intended persona style.",
+ )
+ aliases: Optional[list[str]] = Field(
+ None,
+ description="Full replacement list of persona aliases.",
+ )
+ instructions: Optional[str] = Field(
+ None,
+ description="Full replacement PERSONA.md body without YAML frontmatter.",
+ )
+ append_instructions: Optional[list[str]] = Field(
+ None,
+ description="Additional persona rules appended to the existing body.",
+ )
+ create_if_missing: bool = Field(
+ False,
+ description="Create a new runtime persona when update cannot resolve one.",
+ )
+
+ @model_validator(mode="after")
+ def validate_action(self) -> "PersonaInput":
+ """按动作校验目标人格和更新字段。"""
+ if self.query is not None:
+ self.query = self.query.strip() or None
+ if self.persona_id is not None:
+ self.persona_id = self.persona_id.strip()
+ if self.action in {"switch", "update"} and not self.persona_id:
+ raise ValueError(f"{self.action} 必须提供 persona_id")
+ if self.action == "update" and not any(
+ value is not None
+ for value in (
+ self.label,
+ self.description,
+ self.aliases,
+ self.instructions,
+ self.append_instructions,
+ )
+ ):
+ raise ValueError("update 必须提供至少一个人格定义字段")
+ return self
+
+
+class PersonaTool(MoviePilotTool):
+ """通过一个动作式接口查询、切换或管理 Agent 人格。"""
+
+ name: str = "persona"
+ tags: list[str] = [ToolTag.Read, ToolTag.Write, ToolTag.Persona]
+ description: str = (
+ "Manage agent personas with one structured action. Use list to discover personas "
+ "and the active state, switch when the user explicitly requests a speaking style, "
+ "and update only for an explicit administrator request to create or rewrite a "
+ "persona definition."
+ )
+ args_schema: Type[BaseModel] = PersonaInput
+
+ def get_tool_message(self, **kwargs: object) -> str:
+ """生成统一人格操作的用户可见提示。"""
+ action = str(kwargs.get("action") or "list")
+ action_name = {
+ "list": "查询人格",
+ "switch": "切换人格",
+ "update": "更新人格定义",
+ }.get(action, action)
+ target = kwargs.get("persona_id") or kwargs.get("query")
+ return f"{action_name}: {target}" if target else action_name
+
+ async def run(
+ self,
+ action: PersonaAction,
+ query: Optional[str] = None,
+ persona_id: Optional[str] = None,
+ label: Optional[str] = None,
+ description: Optional[str] = None,
+ aliases: Optional[list[str]] = None,
+ instructions: Optional[str] = None,
+ append_instructions: Optional[list[str]] = None,
+ create_if_missing: bool = False,
+ **kwargs: object,
+ ) -> str:
+ """执行查询、切换或更新人格定义。"""
+ payload = PersonaInput(
+ action=action,
+ query=query,
+ persona_id=persona_id,
+ label=label,
+ description=description,
+ aliases=aliases,
+ instructions=instructions,
+ append_instructions=append_instructions,
+ create_if_missing=create_if_missing,
+ )
+ logger.info(
+ "执行工具: %s, 参数: action=%s, persona_id=%s",
+ self.name,
+ payload.action,
+ payload.persona_id,
+ )
+ try:
+ if payload.action == "list":
+ return self._list_personas(payload.query)
+ if payload.action == "switch":
+ return self._switch_persona(payload.persona_id)
+ if not await self.is_admin_user():
+ return json.dumps(
+ {
+ "success": False,
+ "message": "只有系统管理员才能更新人格定义。",
+ },
+ ensure_ascii=False,
+ )
+ return self._update_persona(payload)
+ except Exception as error: # noqa: BLE001
+ logger.error(
+ "人格操作失败: action=%s, error=%s",
+ payload.action,
+ error,
+ exc_info=True,
+ )
+ return json.dumps(
+ {
+ "success": False,
+ "message": f"人格操作失败: {str(error)}",
+ },
+ ensure_ascii=False,
+ )
+
+ @staticmethod
+ def _list_personas(query: Optional[str]) -> str:
+ """列出可用人格并按可选关键词过滤。"""
+ runtime_config = agent_runtime_manager.load_runtime_config()
+ personas = runtime_config.list_personas()
+ if query:
+ normalized = query.casefold()
+ personas = [
+ persona
+ for persona in personas
+ if normalized in persona["persona_id"].casefold()
+ or normalized in persona["label"].casefold()
+ or normalized in persona["description"].casefold()
+ or any(normalized in alias.casefold() for alias in persona["aliases"])
+ ]
+ return json.dumps(
+ {
+ "active_persona": runtime_config.active_persona,
+ "count": len(personas),
+ "personas": personas,
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
+
+ @staticmethod
+ def _switch_persona(persona_id: Optional[str]) -> str:
+ """切换当前持久化激活人格。"""
+ if persona_id is None:
+ raise ValueError("switch 缺少 persona_id")
+ runtime_config = agent_runtime_manager.set_active_persona(persona_id)
+ return json.dumps(
+ {
+ "success": True,
+ "active_persona": runtime_config.active_persona,
+ "persona": runtime_config.persona.to_dict(is_active=True),
+ "message": f"已切换为人格 `{runtime_config.active_persona}`",
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
+
+ @staticmethod
+ def _update_persona(payload: PersonaInput) -> str:
+ """创建或更新一项运行时人格定义。"""
+ if payload.persona_id is None:
+ raise ValueError("update 缺少 persona_id")
+ persona, created = agent_runtime_manager.update_persona_definition(
+ payload.persona_id,
+ label=payload.label,
+ description=payload.description,
+ aliases=payload.aliases,
+ instructions=payload.instructions,
+ append_instructions=payload.append_instructions,
+ create_if_missing=payload.create_if_missing,
+ )
+ runtime_config = agent_runtime_manager.load_runtime_config()
+ return json.dumps(
+ {
+ "success": True,
+ "created": created,
+ "active_persona": runtime_config.active_persona,
+ "persona": persona.to_dict(is_active=persona.persona_id == runtime_config.active_persona),
+ "message": (
+ f"已创建人格 `{persona.persona_id}`" if created else f"已更新人格 `{persona.persona_id}` 的定义"
+ ),
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
diff --git a/app/agent/tools/impl/query_agent_tasks.py b/app/agent/tools/impl/query_agent_tasks.py
deleted file mode 100644
index 6c5e36aa3..000000000
--- a/app/agent/tools/impl/query_agent_tasks.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.agenttask import agent_task_run_to_dict, agent_task_to_dict
-from app.runtime.settings import get_runtime_setting
-
-
-class QueryAgentTasksInput(BaseModel):
- """查询 Agent 自主定时任务的输入参数。"""
-
- task_id: Optional[int] = Field(
- None,
- ge=1,
- description="Optional task ID. Omit it to list tasks owned by the current user.",
- )
- enabled: Optional[bool] = Field(
- None,
- description="Optional enabled-state filter used when listing tasks.",
- )
-
-
-class QueryAgentTasksTool(MoviePilotTool):
- """查询当前用户创建的 Agent 自主定时任务。"""
-
- name: str = "query_agent_tasks"
- tags: list[str] = [ToolTag.Read, ToolTag.AgentTask, ToolTag.Admin]
- description: str = (
- "Query persistent autonomous agent tasks owned by the current user, including "
- "reminders, monitoring tasks, and recurring agent work. Returns the integer "
- "task_id, instructions, trigger, enabled state, next run time, and latest result. "
- "Do not use this for MoviePilot system, plugin, or workflow scheduler services."
- )
- args_schema: Type[BaseModel] = QueryAgentTasksInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs: object) -> Optional[str]:
- """生成查询定时任务的提示消息。"""
- task_id = kwargs.get("task_id")
- return f"查询自主定时任务:{task_id}" if task_id else "查询自主定时任务"
-
- def _query_tasks(
- self,
- task_id: Optional[int],
- enabled: Optional[bool],
- ) -> list[dict[str, object]]:
- """读取当前用户的任务及运行时下一次触发时间。"""
- from app.application.scheduling import get_agent_task_next_run
-
- oper = self.data.tasks
- if task_id:
- task = oper.get(task_id=task_id, user_id=str(self._user_id))
- tasks = [task] if task else []
- else:
- tasks = oper.list(user_id=str(self._user_id), enabled=enabled)
- result: list[dict[str, object]] = []
- for task in tasks:
- data = agent_task_to_dict(
- task,
- next_run_at=get_agent_task_next_run(task.id),
- timezone=get_runtime_setting('TZ'),
- )
- if task_id:
- data["recent_runs"] = [
- agent_task_run_to_dict(run)
- for run in oper.list_runs(
- task_id=task.id,
- user_id=str(self._user_id),
- limit=10,
- )
- ]
- result.append(data)
- return result
-
- async def run(
- self,
- task_id: Optional[int] = None,
- enabled: Optional[bool] = None,
- **kwargs: object,
- ) -> str:
- """查询 Agent 自主定时任务。"""
- payload = QueryAgentTasksInput(task_id=task_id, enabled=enabled)
- tasks = await self.run_blocking(
- "db",
- self._query_tasks,
- payload.task_id,
- payload.enabled,
- )
- return json.dumps(
- {"total": len(tasks), "tasks": tasks},
- ensure_ascii=False,
- indent=2,
- )
diff --git a/app/agent/tools/impl/query_builtin_filter_rules.py b/app/agent/tools/impl/query_builtin_filter_rules.py
deleted file mode 100644
index 414f4ab59..000000000
--- a/app/agent/tools/impl/query_builtin_filter_rules.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""查询内置过滤规则工具。"""
-
-import json
-from typing import Optional, Type, List
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._filter_rule_utils import (
- get_builtin_rules,
- serialize_builtin_rule,
- RULE_STRING_SYNTAX,
-)
-from app.runtime.log import logger
-
-
-class QueryBuiltinFilterRulesInput(BaseModel):
- """查询内置过滤规则工具的输入参数模型"""
-
- rule_ids: Optional[List[str]] = Field(
- None,
- description="Optional list of built-in rule IDs to query. If omitted, return all built-in rules.",
- )
-
-
-class QueryBuiltinFilterRulesTool(MoviePilotTool):
- name: str = "query_builtin_filter_rules"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.FilterRule,
- ]
- description: str = (
- "Query built-in filter rules defined by the backend filter module. "
- "These rule IDs can be used directly inside rule_string expressions for filter rule groups. "
- "Use this tool before add_rule_group or update_rule_group to learn valid built-in rule IDs."
- )
- args_schema: Type[BaseModel] = QueryBuiltinFilterRulesInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- rule_ids = kwargs.get("rule_ids") or []
- if rule_ids:
- return f"查询内置过滤规则: {', '.join(rule_ids)}"
- return "查询所有内置过滤规则"
-
- async def run(
- self,
- rule_ids: Optional[List[str]] = None,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}")
-
- try:
- builtin_rules = get_builtin_rules()
- if rule_ids:
- target_ids = set(rule_ids)
- builtin_rules = {
- rule_id: payload
- for rule_id, payload in builtin_rules.items()
- if rule_id in target_ids
- }
-
- serialized = [
- serialize_builtin_rule(rule_id, payload)
- for rule_id, payload in builtin_rules.items()
- ]
- return json.dumps(
- {
- "success": True,
- "count": len(serialized),
- "rule_string_syntax": RULE_STRING_SYNTAX,
- "rules": serialized,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"查询内置过滤规则失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"查询内置过滤规则失败: {exc}",
- "rules": [],
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_custom_filter_rules.py b/app/agent/tools/impl/query_custom_filter_rules.py
deleted file mode 100644
index 7a904cf84..000000000
--- a/app/agent/tools/impl/query_custom_filter_rules.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""查询自定义过滤规则工具。"""
-
-import json
-from typing import Optional, Type, List
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._filter_rule_utils import (
- collect_custom_rule_group_refs,
- get_custom_rules,
- get_rule_groups,
- serialize_custom_rule,
-)
-from app.runtime.log import logger
-
-
-class QueryCustomFilterRulesInput(BaseModel):
- """查询自定义过滤规则工具的输入参数模型"""
-
- rule_ids: Optional[List[str]] = Field(
- None,
- description="Optional list of custom rule IDs to query. If omitted, return all custom rules.",
- )
- include_group_refs: bool = Field(
- True,
- description="Whether to include which rule groups reference each custom rule.",
- )
-
-
-class QueryCustomFilterRulesTool(MoviePilotTool):
- name: str = "query_custom_filter_rules"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.FilterRule,
- ]
- description: str = (
- "Query custom filter rules stored in CustomFilterRules. "
- "Custom rules can be referenced from rule_string expressions in filter rule groups. "
- "Use this tool before add_rule_group or update_rule_group to learn valid custom rule IDs."
- )
- args_schema: Type[BaseModel] = QueryCustomFilterRulesInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- rule_ids = kwargs.get("rule_ids") or []
- if rule_ids:
- return f"查询自定义过滤规则: {', '.join(rule_ids)}"
- return "查询所有自定义过滤规则"
-
- async def run(
- self,
- rule_ids: Optional[List[str]] = None,
- include_group_refs: bool = True,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}")
-
- try:
- custom_rules = get_custom_rules()
- if rule_ids:
- target_ids = set(rule_ids)
- custom_rules = [
- rule for rule in custom_rules if rule.id in target_ids
- ]
-
- refs = {}
- if include_group_refs:
- refs = collect_custom_rule_group_refs(
- get_rule_groups(),
- [rule.id for rule in custom_rules if rule.id],
- )
-
- serialized = [
- serialize_custom_rule(rule, refs.get(rule.id))
- for rule in custom_rules
- ]
- return json.dumps(
- {
- "success": True,
- "count": len(serialized),
- "rules": serialized,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"查询自定义过滤规则失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"查询自定义过滤规则失败: {exc}",
- "rules": [],
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_custom_identifiers.py b/app/agent/tools/impl/query_custom_identifiers.py
deleted file mode 100644
index 4b97e726f..000000000
--- a/app/agent/tools/impl/query_custom_identifiers.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""查询自定义识别词工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.configuration import get_configured_system_config
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class QueryCustomIdentifiersInput(BaseModel):
- """查询自定义识别词工具的输入参数模型"""
-
-
-
-class QueryCustomIdentifiersTool(MoviePilotTool):
- name: str = "query_custom_identifiers"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.FilterRule,
- ToolTag.Admin,
- ]
- description: str = (
- "Query all currently configured custom identifiers (自定义识别词). "
- "Returns the list of identifier rules used for preprocessing torrent/file names before media recognition. "
- "Use this tool to check existing rules before adding new ones to avoid duplicates."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = QueryCustomIdentifiersInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- return "查询自定义识别词"
-
- @staticmethod
- def _load_custom_identifiers():
- """从内存配置缓存中读取自定义识别词。"""
- return get_configured_system_config().get(SystemConfigKey.CustomIdentifiers)
-
- async def run(self, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}")
- try:
- identifiers = self._load_custom_identifiers()
- if identifiers:
- return json.dumps(
- {
- "success": True,
- "count": len(identifiers),
- "identifiers": identifiers,
- },
- ensure_ascii=False,
- indent=2,
- )
- return json.dumps(
- {
- "success": True,
- "count": 0,
- "identifiers": [],
- "message": "当前没有配置自定义识别词",
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as e:
- logger.error(f"查询自定义识别词失败: {e}")
- return json.dumps(
- {"success": False, "message": f"查询自定义识别词时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_directory_settings.py b/app/agent/tools/impl/query_directory_settings.py
deleted file mode 100644
index 606a6c423..000000000
--- a/app/agent/tools/impl/query_directory_settings.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""查询系统目录设置工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.directory import DirectoryHelper
-from app.runtime.log import logger
-
-
-class QueryDirectorySettingsInput(BaseModel):
- """查询系统目录设置工具的输入参数模型"""
- directory_type: Optional[str] = Field("all",
- description="Filter directories by type: 'download' for download directories, 'library' for media library directories, 'all' for all directories")
- storage_type: Optional[str] = Field("all",
- description="Filter directories by storage type: 'local' for local storage, 'remote' for remote storage, 'all' for all storage types")
- name: Optional[str] = Field(None,
- description="Filter directories by name (partial match, optional)")
-
-
-class QueryDirectorySettingsTool(MoviePilotTool):
- name: str = "query_directory_settings"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Directory,
- ToolTag.Settings,
- ToolTag.Admin,
- ]
- description: str = "Query system directory configuration settings (NOT file listings). Returns configured directory paths, storage types, transfer modes, and other directory-related settings. Use 'list_directory' to list actual files and folders in a directory."
- require_admin: bool = True
- args_schema: Type[BaseModel] = QueryDirectorySettingsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- directory_type = kwargs.get("directory_type", "all")
- storage_type = kwargs.get("storage_type", "all")
- name = kwargs.get("name")
-
- parts = ["查询目录配置"]
-
- if directory_type != "all":
- type_map = {"download": "下载目录", "library": "媒体库目录"}
- parts.append(f"类型: {type_map.get(directory_type, directory_type)}")
-
- if storage_type != "all":
- storage_map = {"local": "本地存储", "remote": "远程存储"}
- parts.append(f"存储: {storage_map.get(storage_type, storage_type)}")
-
- if name:
- parts.append(f"名称: {name}")
-
- return " | ".join(parts) if len(parts) > 1 else parts[0]
-
- @staticmethod
- def _query_directory_settings(
- directory_type: Optional[str] = "all",
- storage_type: Optional[str] = "all",
- name: Optional[str] = None,
- ) -> str:
- """
- 目录配置完全来自内存配置缓存,这里只做本地过滤和序列化。
- """
- directory_helper = DirectoryHelper()
-
- if directory_type == "download":
- dirs = directory_helper.get_download_dirs()
- elif directory_type == "library":
- dirs = directory_helper.get_library_dirs()
- else:
- dirs = directory_helper.get_dirs()
-
- filtered_dirs = []
- for d in dirs:
- if storage_type == "local":
- if directory_type == "download" and d.storage != "local":
- continue
- if directory_type == "library" and d.library_storage != "local":
- continue
- if directory_type == "all":
- if d.download_path and d.storage != "local":
- continue
- if d.library_path and d.library_storage != "local":
- continue
- elif storage_type == "remote":
- if directory_type == "download" and d.storage == "local":
- continue
- if directory_type == "library" and d.library_storage == "local":
- continue
- if directory_type == "all":
- if d.download_path and d.storage == "local":
- continue
- if d.library_path and d.library_storage == "local":
- continue
-
- if name and d.name and name.lower() not in d.name.lower():
- continue
- filtered_dirs.append(d)
-
- if not filtered_dirs:
- return "未找到相关目录配置"
-
- simplified_dirs = []
- for d in filtered_dirs:
- simplified_dirs.append(
- {
- "name": d.name,
- "priority": d.priority,
- "storage": d.storage,
- "download_path": d.download_path,
- "library_path": d.library_path,
- "library_storage": d.library_storage,
- "media_type": d.media_type,
- "media_category": d.media_category,
- "monitor_type": d.monitor_type,
- "monitor_mode": d.monitor_mode,
- "transfer_type": d.transfer_type,
- "overwrite_mode": d.overwrite_mode,
- "renaming": d.renaming,
- "scraping": d.scraping,
- "notify": d.notify,
- "download_type_folder": d.download_type_folder,
- "download_category_folder": d.download_category_folder,
- "library_type_folder": d.library_type_folder,
- "library_category_folder": d.library_category_folder,
- }
- )
-
- return json.dumps(simplified_dirs, ensure_ascii=False, indent=2)
-
- async def run(self, directory_type: Optional[str] = "all",
- storage_type: Optional[str] = "all",
- name: Optional[str] = None, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: directory_type={directory_type}, storage_type={storage_type}, name={name}")
-
- try:
- return self._query_directory_settings(
- directory_type=directory_type,
- storage_type=storage_type,
- name=name,
- )
- except Exception as e:
- logger.error(f"查询系统目录设置失败: {e}", exc_info=True)
- return f"查询系统目录设置时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_download_tasks.py b/app/agent/tools/impl/query_download_tasks.py
deleted file mode 100644
index ef6e5924b..000000000
--- a/app/agent/tools/impl/query_download_tasks.py
+++ /dev/null
@@ -1,415 +0,0 @@
-"""查询下载工具"""
-
-import json
-from typing import Any, Dict, List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.history import DownloadHistorySnapshot
-from app.chain.download import DownloadChain
-from app.runtime.log import logger
-from app.schemas.common import JsonData
-from app.schemas.transfer import DownloaderTorrent, DownloadTaskMedia
-from app.schemas.types import MUSIC_ENTITY_RECORDING, TorrentQueryStatus, media_type_to_agent
-
-
-class QueryDownloadTasksInput(BaseModel):
- """查询下载工具的输入参数模型"""
- downloader: Optional[str] = Field(None,
- description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
- status: Optional[str] = Field("all",
- description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads")
- include_all_tags: Optional[bool] = Field(
- False,
- description="Include tasks without the MoviePilot built-in tag. Default false keeps the normal MoviePilot task scope.",
- )
- include_trackers: Optional[bool] = Field(
- False,
- description="Include tracker URLs when supported. Hash queries always include trackers.",
- )
- hash: Optional[str] = Field(None, description="Query specific download task by hash (optional, if provided will search for this specific task regardless of status)")
- title: Optional[str] = Field(None, description="Query download tasks by title/name (optional, supports partial match, searches all tasks if provided)")
- tag: Optional[str] = Field(None, description="Filter download tasks by tag (optional, supports partial match, e.g. 'movie' will match tasks with tag 'movie' or 'movie_2024')")
-
-
-class QueryDownloadTasksTool(MoviePilotTool):
- """查询下载器任务并用下载历史补全影视或音乐身份。"""
-
- name: str = "query_download_tasks"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Download,
- ]
- description: str = "Query download status and list download tasks. Can query all active downloads, or search for specific tasks by hash, title, or tag. Shows download progress, completion status, tags, and task details from configured downloaders."
- args_schema: Type[BaseModel] = QueryDownloadTasksInput
-
- @staticmethod
- def _normalize_query_status(status: Optional[str]) -> TorrentQueryStatus:
- """
- 归一下载任务查询状态。
- """
- status_value = str(status or "").strip().lower()
- if not status_value or status_value == TorrentQueryStatus.ALL.value:
- return TorrentQueryStatus.ALL
- if status_value in {"completed", "complete", "seeding"}:
- return TorrentQueryStatus.COMPLETED
- if status_value in {"paused", "pause"}:
- return TorrentQueryStatus.PAUSED
- if status_value == TorrentQueryStatus.DOWNLOADING.value:
- return TorrentQueryStatus.DOWNLOADING
- return TorrentQueryStatus.ALL
-
- @staticmethod
- def _normalize_include_all_tags(include_all_tags: Any) -> bool:
- """
- 归一全部标签查询开关。
- """
- if isinstance(include_all_tags, bool):
- return include_all_tags
- if isinstance(include_all_tags, str):
- return include_all_tags.strip().lower() in {"1", "true", "yes", "on", "是"}
- return bool(include_all_tags)
-
- @staticmethod
- def _get_all_torrents(
- download_chain: DownloadChain,
- downloader: Optional[str] = None,
- include_all_tags: bool = False,
- ) -> List[DownloaderTorrent]:
- """
- 查询所有状态的任务(包括下载中和已完成的任务)
- """
- return download_chain.list_torrents(
- downloader=downloader,
- include_all_tags=include_all_tags,
- ) or []
-
- @staticmethod
- def _format_progress(progress: Optional[float]) -> Optional[str]:
- """
- 将下载进度格式化为保留一位小数的百分比字符串
- """
- try:
- if progress is None:
- return None
- return f"{float(progress):.1f}%"
- except (TypeError, ValueError):
- return None
-
- @staticmethod
- def _apply_download_history(
- torrent: DownloaderTorrent, history: Optional[DownloadHistorySnapshot]
- ) -> None:
- """将下载历史中的补充信息回填到下载任务结果中。"""
- if not history:
- return
- if hasattr(torrent, "media"):
- media = DownloadTaskMedia(
- type=history.type,
- title=history.title,
- season=history.seasons,
- episode=history.episodes,
- image=history.image,
- poster=history.poster,
- media_source=history.media_source,
- media_id=history.media_id,
- )
- music_media = QueryDownloadTasksTool._history_music_media(history)
- if media_type_to_agent(history.type) == "music":
- music_type = music_media.get("music_type")
- artists = music_media.get("artists")
- album = music_media.get("album")
- album_id = music_media.get("album_id")
- total_tracks = music_media.get("total_tracks")
- track_number = music_media.get("track_number")
- media.music_type = (
- music_type if isinstance(music_type, str) else MUSIC_ENTITY_RECORDING
- )
- media.artists = (
- [artist for artist in artists if isinstance(artist, str)]
- if isinstance(artists, list)
- else []
- )
- media.album = album if isinstance(album, str) else None
- media.album_id = album_id if isinstance(album_id, str) else None
- media.total_tracks = (
- total_tracks
- if isinstance(total_tracks, int) and not isinstance(total_tracks, bool)
- else None
- )
- media.track_number = (
- track_number
- if isinstance(track_number, int) and not isinstance(track_number, bool)
- else None
- )
- torrent.media = media
- if hasattr(torrent, "username"):
- torrent.username = history.username
- torrent.userid = history.userid
-
- @staticmethod
- def _history_music_media(
- history: DownloadHistorySnapshot,
- ) -> dict[str, JsonData]:
- """从冻结历史备注中读取经过结构校验的音乐媒体字段。"""
- note = history.note
- if not isinstance(note, dict):
- return {}
- music = note.get("music")
- if not isinstance(music, dict):
- return {}
- media = music.get("media")
- return media if isinstance(media, dict) else {}
-
- def _load_history_map(
- self,
- torrents: List[DownloaderTorrent],
- ) -> Dict[str, DownloadHistorySnapshot]:
- """批量加载下载历史,避免逐条查询形成 N+1。"""
- hashes = [torrent.hash for torrent in torrents if torrent.hash]
- if not hashes:
- return {}
- return self.data.download_history.get_by_hashes(hashes)
-
- def _query_downloads_sync(
- self,
- downloader: Optional[str] = None,
- status: Optional[str] = "all",
- hash_value: Optional[str] = None,
- title: Optional[str] = None,
- tag: Optional[str] = None,
- include_all_tags: bool = False,
- include_trackers: bool = False,
- ) -> Dict[str, Any]:
- """
- 同步查询下载器和下载历史,整个链路放在线程池中执行。
- """
- download_chain = DownloadChain()
- query_status = self._normalize_query_status(status)
- include_all_tags = self._normalize_include_all_tags(include_all_tags)
-
- if hash_value:
- torrents = (
- download_chain.list_torrents(
- downloader=downloader,
- hashs=[hash_value],
- include_all_tags=include_all_tags,
- )
- or []
- )
- if not torrents:
- return {
- "message": f"未找到hash为 {hash_value} 的下载任务(该任务可能已完成、已删除或不存在)"
- }
-
- history_map = self._load_history_map(torrents)
- for torrent in torrents:
- self._apply_download_history(torrent, history_map.get(torrent.hash))
- filtered_downloads = list(torrents)
- elif title:
- all_torrents = self._get_all_torrents(
- download_chain,
- downloader,
- include_all_tags=include_all_tags,
- )
- history_map = self._load_history_map(all_torrents)
- filtered_downloads = []
- title_lower = title.lower()
-
- for torrent in all_torrents:
- history = history_map.get(torrent.hash)
- matched = title_lower in (torrent.title or "").lower() or title_lower in (
- getattr(torrent, "name", None) or ""
- ).lower()
- if not matched and history and history.title:
- matched = title_lower in history.title.lower()
-
- if not matched:
- continue
-
- self._apply_download_history(torrent, history)
- filtered_downloads.append(torrent)
-
- if not filtered_downloads:
- return {"message": f"未找到标题包含 '{title}' 的下载任务"}
- else:
- if query_status == TorrentQueryStatus.DOWNLOADING and not include_all_tags:
- downloads = download_chain.downloading(name=downloader) or []
- filtered_downloads = [
- dl
- for dl in downloads
- if not downloader or dl.downloader == downloader
- ]
- else:
- list_status = None if query_status == TorrentQueryStatus.ALL else query_status.value
- filtered_downloads = download_chain.list_torrents(
- downloader=downloader,
- status=list_status,
- include_all_tags=include_all_tags,
- ) or []
-
- history_map = self._load_history_map(filtered_downloads)
- for torrent in filtered_downloads:
- self._apply_download_history(
- torrent,
- history_map.get(torrent.hash),
- )
-
- if tag and filtered_downloads:
- tag_lower = tag.lower()
- filtered_downloads = [
- d for d in filtered_downloads if d.tags and tag_lower in d.tags.lower()
- ]
- if not filtered_downloads:
- return {"message": f"未找到标签包含 '{tag}' 的下载任务"}
-
- if not filtered_downloads:
- return {"message": "未找到相关下载任务"}
-
- if hash_value or include_trackers:
- for torrent in filtered_downloads:
- if not getattr(torrent, "hash", None):
- continue
- tracker_map = download_chain.get_torrent_trackers(
- hash_string=torrent.hash,
- downloader=getattr(torrent, "downloader", None) or downloader,
- ) or {}
- torrent.trackers = tracker_map.get(getattr(torrent, "downloader", None)) or []
-
- return {"downloads": filtered_downloads}
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- downloader = kwargs.get("downloader")
- status = kwargs.get("status", "all")
- hash_value = kwargs.get("hash")
- title = kwargs.get("title")
- include_all_tags = self._normalize_include_all_tags(
- kwargs.get("include_all_tags", False)
- )
-
- parts = ["查询下载任务"]
-
- if downloader:
- parts.append(f"下载器: {downloader}")
-
- if status != "all":
- status_map = {"downloading": "下载中", "completed": "已完成", "paused": "已暂停"}
- parts.append(f"状态: {status_map.get(status, status)}")
-
- if hash_value:
- parts.append(f"Hash: {hash_value[:8]}...")
- elif title:
- parts.append(f"标题: {title}")
-
- tag = kwargs.get("tag")
- if tag:
- parts.append(f"标签: {tag}")
- if include_all_tags:
- parts.append("范围: 全部标签")
- if kwargs.get("include_trackers"):
- parts.append("包含Tracker")
-
- return " | ".join(parts) if len(parts) > 1 else parts[0]
-
- async def run(self, downloader: Optional[str] = None,
- status: Optional[str] = "all",
- hash: Optional[str] = None,
- title: Optional[str] = None,
- tag: Optional[str] = None,
- include_all_tags: Optional[bool] = False,
- include_trackers: Optional[bool] = False,
- **kwargs) -> str:
- """执行下载任务查询并输出有界的状态摘要。"""
- logger.info(
- f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, "
- f"hash={hash}, title={title}, tag={tag}, include_all_tags={include_all_tags}, "
- f"include_trackers={include_trackers}"
- )
- try:
- payload = await self.run_blocking(
- "downloader",
- self._query_downloads_sync,
- downloader,
- status,
- hash,
- title,
- tag,
- self._normalize_include_all_tags(include_all_tags),
- self._normalize_include_all_tags(include_trackers),
- )
- if payload.get("message"):
- return payload["message"]
-
- filtered_downloads = payload.get("downloads") or []
- if filtered_downloads:
- # 限制最多20条结果
- total_count = len(filtered_downloads)
- limited_downloads = filtered_downloads[:20]
- # 精简字段,只保留关键信息
- simplified_downloads = []
- for d in limited_downloads:
- simplified = {
- "downloader": d.downloader,
- "hash": d.hash,
- "title": d.title,
- "name": getattr(d, "name", None),
- "year": getattr(d, "year", None),
- "season_episode": getattr(d, "season_episode", None),
- "size": d.size,
- "progress": self._format_progress(d.progress),
- "state": d.state,
- "upspeed": getattr(d, "upspeed", None),
- "dlspeed": getattr(d, "dlspeed", None),
- "tags": d.tags,
- "save_path": getattr(d, "save_path", None),
- "content_path": getattr(d, "content_path", None) or (
- d.path.as_posix() if getattr(d, "path", None) else None
- ),
- "category": getattr(d, "category", None),
- "download_limit": getattr(d, "download_limit", None),
- "upload_limit": getattr(d, "upload_limit", None),
- "ratio_limit": getattr(d, "ratio_limit", None),
- "seeding_time_limit": getattr(d, "seeding_time_limit", None),
- "trackers": getattr(d, "trackers", None) or [],
- "left_time": getattr(d, "left_time", None)
- }
- # 精简 media 字段
- media = getattr(d, "media", None)
- if media:
- media_data = (
- media.model_dump() if isinstance(media, BaseModel) else media
- )
- simplified["media"] = {
- "type": media_type_to_agent(media_data.get("type")),
- "title": media_data.get("title"),
- "season": media_data.get("season"),
- "episode": media_data.get("episode"),
- "media_source": media_data.get("media_source"),
- "media_id": media_data.get("media_id"),
- "music_type": media_data.get("music_type"),
- "artists": media_data.get("artists"),
- "album": media_data.get("album"),
- "album_id": media_data.get("album_id"),
- "total_tracks": media_data.get("total_tracks"),
- "track_number": media_data.get("track_number"),
- }
- simplified_downloads.append(simplified)
- result_json = json.dumps(simplified_downloads, ensure_ascii=False, indent=2)
- # 如果结果被裁剪,添加提示信息
- if total_count > 20:
- return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 20 条结果。\n\n{result_json}"
-
- # 如果查询的是特定hash或title,添加明确的状态信息
- if hash:
- return f"找到hash为 {hash} 的下载任务:\n\n{result_json}"
- elif title:
- return f"找到 {total_count} 个标题包含 '{title}' 的下载任务:\n\n{result_json}"
-
- return result_json
- return "未找到相关下载任务"
- except Exception as e:
- logger.error(f"查询下载失败: {e}", exc_info=True)
- return f"查询下载时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_downloaders.py b/app/agent/tools/impl/query_downloaders.py
deleted file mode 100644
index 130254f02..000000000
--- a/app/agent/tools/impl/query_downloaders.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""查询下载器工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.configuration import get_configured_system_config
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class QueryDownloadersInput(BaseModel):
- """查询下载器工具的输入参数模型"""
-class QueryDownloadersTool(MoviePilotTool):
- name: str = "query_downloaders"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Download,
- ]
- description: str = (
- "Query downloader configuration and list available downloaders. Non-admin users receive "
- "a safe view with only the fields needed to choose a downloader, without host, account, "
- "password, token or API key values."
- )
- args_schema: Type[BaseModel] = QueryDownloadersInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- return "查询下载器配置"
-
- @staticmethod
- def _load_downloaders_config():
- """从内存配置缓存中读取下载器配置。"""
- return get_configured_system_config().get(SystemConfigKey.Downloaders)
-
- @staticmethod
- def _sanitize_downloaders_config(downloaders_config: list) -> list:
- """
- 生成普通用户可见的下载器配置视图。
-
- :param downloaders_config: 系统下载器完整配置列表
- :return: 仅包含名称、类型和启用状态的安全配置列表
- """
- safe_fields = ("name", "type", "enabled", "default", "priority")
- safe_downloaders = []
- for downloader in downloaders_config:
- if not isinstance(downloader, dict):
- continue
- safe_downloaders.append({
- key: downloader.get(key)
- for key in safe_fields
- if key in downloader
- })
- return safe_downloaders
-
- async def run(self, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}")
- try:
- downloaders_config = self._load_downloaders_config()
- if downloaders_config:
- if not await self.is_admin_user():
- downloaders_config = self._sanitize_downloaders_config(
- downloaders_config
- )
- return json.dumps(downloaders_config, ensure_ascii=False, indent=2)
- return "未配置下载器。"
- except Exception as e:
- logger.error(f"查询下载器失败: {e}")
- return f"查询下载器时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_episode_schedule.py b/app/agent/tools/impl/query_episode_schedule.py
deleted file mode 100644
index 9228ae251..000000000
--- a/app/agent/tools/impl/query_episode_schedule.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""查询剧集上映时间工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.tmdb import TmdbChain
-from app.runtime.log import logger
-
-
-class QueryEpisodeScheduleInput(BaseModel):
- """查询剧集上映时间工具的输入参数模型"""
- tmdb_id: int = Field(..., description="TMDB ID of the TV series (can be obtained from search_media tool)")
- season: int = Field(..., description="Season number to query")
- episode_group: Optional[str] = Field(None, description="Episode group ID (optional)")
-
-
-class QueryEpisodeScheduleTool(MoviePilotTool):
- name: str = "query_episode_schedule"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Media,
- ]
- description: str = "Query TV series episode air dates and schedule. Returns non-duplicated schedule fields, including episode list, air-date statistics, and per-episode metadata. Filters out episodes without air dates."
- args_schema: Type[BaseModel] = QueryEpisodeScheduleInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- tmdb_id = kwargs.get("tmdb_id")
- season = kwargs.get("season")
- episode_group = kwargs.get("episode_group")
-
- message = f"查询剧集上映时间: TMDB ID {tmdb_id} 第{season}季"
- if episode_group:
- message += f" (剧集组: {episode_group})"
-
- return message
-
- async def run(self, tmdb_id: int, season: int, episode_group: Optional[str] = None, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, season={season}, episode_group={episode_group}")
-
- try:
- # 获取集列表
- tmdb_chain = TmdbChain()
- episodes = await tmdb_chain.async_tmdb_episodes(
- tmdbid=tmdb_id,
- season=season,
- episode_group=episode_group
- )
-
- if not episodes:
- return json.dumps({
- "success": False,
- "message": f"未找到 TMDB ID {tmdb_id} 第{season}季的集信息"
- }, ensure_ascii=False)
-
- # 过滤掉没有上映日期的集,并构建每集的详细信息
- episode_list = []
- for episode in episodes:
- air_date = episode.air_date
-
- # 过滤掉没有上映日期的数据
- if not air_date:
- continue
-
- episode_info = {
- "episode_number": episode.episode_number,
- "name": episode.name,
- "air_date": air_date,
- "runtime": episode.runtime,
- "vote_average": episode.vote_average,
- "still_path": episode.still_path,
- "episode_type": episode.episode_type,
- "season_number": episode.season_number
- }
- episode_list.append(episode_info)
-
- if not episode_list:
- return json.dumps({
- "success": False,
- "message": f"未找到 TMDB ID {tmdb_id} 第{season}季的播出时间信息(所有集都没有播出日期)"
- }, ensure_ascii=False)
-
- # 按播出日期排序
- episode_list.sort(key=lambda x: (x["air_date"] or "", x["episode_number"] or 0))
-
- result = {
- "season": season,
- "total_episodes": len(episodes),
- "episodes_with_air_date": len(episode_list),
- "episodes": episode_list
- }
-
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- except Exception as e:
- error_message = f"查询剧集上映时间失败: {str(e)}"
- logger.error(f"查询剧集上映时间失败: {e}", exc_info=True)
- return json.dumps({
- "success": False,
- "message": error_message,
- "tmdb_id": tmdb_id,
- "season": season
- }, ensure_ascii=False)
diff --git a/app/agent/tools/impl/query_installed_plugins.py b/app/agent/tools/impl/query_installed_plugins.py
deleted file mode 100644
index 1f6bfaf93..000000000
--- a/app/agent/tools/impl/query_installed_plugins.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""查询已安装插件工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._plugin_tool_utils import (
- DEFAULT_PLUGIN_CANDIDATE_LIMIT,
- MAX_PLUGIN_CANDIDATE_LIMIT,
- enrich_installed_plugin_sources,
- list_installed_plugins,
- search_plugin_candidates,
- summarize_candidates,
- summarize_plugin,
-)
-from app.runtime.log import logger
-
-
-class QueryInstalledPluginsInput(BaseModel):
- """查询已安装插件工具的输入参数模型"""
-
- query: Optional[str] = Field(
- None,
- description="Optional keyword to filter installed plugins by plugin ID, name, description, or author.",
- )
- max_results: Optional[int] = Field(
- DEFAULT_PLUGIN_CANDIDATE_LIMIT,
- description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
- )
- force_refresh_market: bool = Field(
- False,
- description="Whether to refresh plugin market caches before completing missing repo_url values.",
- )
-
-
-class QueryInstalledPluginsTool(MoviePilotTool):
- """查询已安装插件并返回 Agent 可消费的摘要信息。"""
-
- name: str = "query_installed_plugins"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Query installed plugins in MoviePilot. Returns all installed plugins or filters them by keywords. "
- "Use this tool to find the exact plugin_id before uninstall_plugin or other plugin management tools are used."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = QueryInstalledPluginsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- query = kwargs.get("query")
- if query:
- return f"查询已安装插件: {query}"
- return "查询已安装插件"
-
- @staticmethod
- def _clamp_results(max_results: Optional[int]) -> int:
- if max_results is None:
- return DEFAULT_PLUGIN_CANDIDATE_LIMIT
- try:
- return max(1, min(int(max_results), MAX_PLUGIN_CANDIDATE_LIMIT))
- except (TypeError, ValueError):
- return DEFAULT_PLUGIN_CANDIDATE_LIMIT
-
- async def run(
- self,
- query: Optional[str] = None,
- max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
- force_refresh_market: bool = False,
- **kwargs,
- ) -> str:
- """
- 查询已安装插件列表,并在可能时补齐插件来源仓库地址。
- """
- logger.info(
- f"执行工具: {self.name}, 参数: query={query}, force_refresh_market={force_refresh_market}"
- )
- try:
- installed_plugins = list_installed_plugins()
- if not installed_plugins:
- return json.dumps(
- {"success": False, "message": "当前没有已安装的插件"},
- ensure_ascii=False,
- )
- installed_plugins = await enrich_installed_plugin_sources(
- installed_plugins,
- force_refresh=force_refresh_market,
- )
-
- limit = self._clamp_results(max_results)
- if query:
- matches = search_plugin_candidates(query, installed_plugins)
- return json.dumps(
- {
- "success": True,
- "query": query,
- "total_installed": len(installed_plugins),
- "match_count": len(matches),
- "truncated": len(matches) > limit,
- "plugins": summarize_candidates(matches, limit=limit),
- },
- ensure_ascii=False,
- indent=2,
- )
-
- plugin_summaries = [
- summarize_plugin(plugin) for plugin in installed_plugins[:limit]
- ]
- return json.dumps(
- {
- "success": True,
- "total_installed": len(installed_plugins),
- "returned_count": len(plugin_summaries),
- "truncated": len(installed_plugins) > limit,
- "plugins": plugin_summaries,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as e:
- logger.error(f"查询已安装插件失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"查询已安装插件时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_library_exists.py b/app/agent/tools/impl/query_library_exists.py
deleted file mode 100644
index fa3d59981..000000000
--- a/app/agent/tools/impl/query_library_exists.py
+++ /dev/null
@@ -1,271 +0,0 @@
-"""查询媒体库工具"""
-
-import asyncio
-import json
-from collections import OrderedDict
-from typing import Optional, Type, Any
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.mediaserver import MediaServerChain
-from app.application.mediaserver import MediaServerHelper
-from app.runtime.log import logger
-from app.schemas.types import (
- MUSIC_ENTITY_ALBUM,
- MediaSource,
- MediaType,
- media_type_to_agent,
-)
-from app.domain.media import normalize_music_type
-
-
-def _sort_seasons(seasons: Optional[dict]) -> dict:
- """按季号、集号升序整理季集信息,保证输出稳定。"""
- if not seasons:
- return {}
-
- def _sort_key(value):
- try:
- return int(value)
- except (TypeError, ValueError):
- return str(value)
-
- return OrderedDict(
- (season, sorted(episodes, key=_sort_key))
- for season, episodes in sorted(seasons.items(), key=lambda item: _sort_key(item[0]))
- )
-
-
-def _filter_regular_seasons(seasons: Optional[dict]) -> OrderedDict:
- """仅保留正片季,忽略 season 0 等特殊季。"""
- sorted_seasons = _sort_seasons(seasons)
- regular_seasons = OrderedDict()
- for season, episodes in sorted_seasons.items():
- try:
- season_number = int(season)
- except (TypeError, ValueError):
- continue
- if season_number > 0:
- regular_seasons[season_number] = episodes
- return regular_seasons
-
-
-def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: OrderedDict) -> dict[str, Any]:
- """构建单个服务器的电视剧存在性结果。"""
- seasons_result = OrderedDict()
- missing_seasons = []
- all_seasons = sorted(set(total_seasons.keys()) | set(existing_seasons.keys()))
-
- for season in all_seasons:
- existing_episodes = existing_seasons.get(season, [])
- total_episodes = total_seasons.get(season)
- if total_episodes is not None:
- missing_episodes = [episode for episode in total_episodes if episode not in existing_episodes]
- total_episode_count = len(total_episodes)
- else:
- missing_episodes = None
- total_episode_count = None
- seasons_result[str(season)] = {
- "existing_episodes": existing_episodes,
- "total_episodes": total_episode_count,
- "missing_episodes": missing_episodes
- }
- if total_episodes is not None and not existing_episodes:
- missing_seasons.append(season)
-
- return {
- "seasons": seasons_result,
- "missing_seasons": missing_seasons
- }
-
-
-class QueryLibraryExistsInput(BaseModel):
- """查询媒体库工具的输入参数模型"""
- media_source: MediaSource = Field(..., description="Media metadata source")
- media_id: str = Field(..., description="Native ID for media_source")
- media_type: Optional[str] = Field(None, description="Allowed values: movie, tv, music")
- music_type: Optional[str] = Field(
- None,
- description="For music: recording or album. Artists are not library acquisition targets",
- )
-
-
-class QueryLibraryExistsTool(MoviePilotTool):
- """查询影视、单曲或完整专辑是否已存在于媒体服务器。"""
-
- name: str = "query_library_exists"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Library,
- ToolTag.Media,
- ]
- description: str = (
- "Check whether movies, TV, music recordings, or complete albums exist on configured media servers. "
- "TV results include episode coverage. Album existence is true only when the server confirms complete "
- "track coverage for the expected album size; artists cannot be queried as acquisition targets."
- )
- args_schema: Type[BaseModel] = QueryLibraryExistsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- media_type = kwargs.get("media_type")
- label = kwargs.get("media_source") or "媒体源"
- identity = kwargs.get("media_id")
- message = f"查询媒体库: {label}={identity}" if label else "查询媒体库"
- if media_type:
- message += f" [{media_type}]"
- return message
-
- @staticmethod
- def _get_media_server_names() -> list[str]:
- """同步读取已加载媒体服务器名称。"""
- return sorted(MediaServerHelper().get_services().keys())
-
- @staticmethod
- def _query_media_exists(mediainfo, server: Optional[str] = None):
- """同步查询单个媒体服务器的存在性信息。"""
- return MediaServerChain().media_exists(mediainfo=mediainfo, server=server)
-
- async def run(self, media_source: MediaSource, media_id: str,
- media_type: Optional[str] = None,
- music_type: Optional[str] = None, **kwargs) -> str:
- """识别精确媒体身份并按服务器汇总存在性或完整性。"""
- logger.info(
- f"执行工具: {self.name}, 参数: media_source={media_source}, "
- f"media_id={media_id}, media_type={media_type}"
- )
- try:
- if not str(media_id or "").strip():
- return "参数错误:必须提供 media_id,请先使用 search_media 工具获取媒体信息。"
-
- media_type_enum = None
- if media_type:
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
-
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(
- music_type,
- allow_artist=False,
- )
- if not normalized_music_type:
- return (
- f"错误:无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album'"
- )
- if media_type_enum != MediaType.MUSIC:
- return "错误:music_type 仅能与 media_type='music' 一起使用"
- if media_type_enum == MediaType.MUSIC and (not media_source or not media_id):
- return "错误:音乐媒体库查询必须同时提供 media_source 和 media_id"
-
- media_chain = MediaServerChain()
- mediainfo = await media_chain.async_recognize_media(
- media_source=media_source,
- media_id=media_id,
- mtype=media_type_enum,
- )
- if not mediainfo:
- return f"未识别到媒体信息: {media_source}:{media_id}"
- if normalized_music_type and (
- getattr(mediainfo, "music_type", None) != normalized_music_type
- ):
- return (
- f"音乐实体类型不匹配:请求 {normalized_music_type},"
- f"实际 {getattr(mediainfo, 'music_type', None) or 'unknown'}"
- )
-
- # 2. 遍历所有媒体服务器,分别查询存在性信息
- server_results = OrderedDict()
- total_seasons = _filter_regular_seasons(
- mediainfo.seasons if mediainfo.type == MediaType.TV else None
- )
- service_names = self._get_media_server_names()
-
- server_checks = await asyncio.gather(
- *[
- self.run_blocking(
- "mediaserver",
- self._query_media_exists,
- mediainfo,
- service_name,
- )
- for service_name in service_names
- ]
- )
-
- for service_name, existsinfo in zip(service_names, server_checks):
- if not existsinfo:
- continue
-
- if existsinfo.type == MediaType.TV:
- existing_seasons = _filter_regular_seasons(existsinfo.seasons)
- server_results[service_name] = _build_tv_server_result(
- existing_seasons=existing_seasons,
- total_seasons=total_seasons
- )
- else:
- server_results[service_name] = {
- "exists": True,
- **(
- {
- "complete": True,
- "expected_tracks": mediainfo.total_tracks,
- }
- if mediainfo.type == MediaType.MUSIC
- and getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
- else {}
- ),
- }
-
- if not server_results:
- global_existsinfo = await self.run_blocking(
- "mediaserver", self._query_media_exists, mediainfo, None
- )
- if not global_existsinfo:
- return "媒体库中未找到相关媒体"
-
- fallback_server_name = global_existsinfo.server or "local"
- if global_existsinfo.type == MediaType.TV:
- server_results[fallback_server_name] = _build_tv_server_result(
- existing_seasons=_filter_regular_seasons(global_existsinfo.seasons),
- total_seasons=total_seasons
- )
- else:
- server_results[fallback_server_name] = {
- "exists": True,
- **(
- {
- "complete": True,
- "expected_tracks": mediainfo.total_tracks,
- }
- if mediainfo.type == MediaType.MUSIC
- and getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
- else {}
- ),
- }
-
- # 3. 组装统一的存在性结果,不查询媒体服务器详情
- result_dict = {
- "title": mediainfo.title,
- "year": mediainfo.year,
- "type": media_type_to_agent(mediainfo.type),
- "servers": server_results
- }
- if mediainfo.type == MediaType.MUSIC:
- result_dict.update({
- "music_type": mediainfo.music_type,
- "artists": list(mediainfo.artists or []),
- "album": mediainfo.album,
- "total_tracks": mediainfo.total_tracks,
- "media_source": mediainfo.media_source,
- "media_id": mediainfo.media_id,
- })
-
- return json.dumps([result_dict], ensure_ascii=False)
- except Exception as e:
- logger.error(f"查询媒体库失败: {e}", exc_info=True)
- return f"查询媒体库时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_library_latest.py b/app/agent/tools/impl/query_library_latest.py
deleted file mode 100644
index aa91c27b4..000000000
--- a/app/agent/tools/impl/query_library_latest.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""查询媒体服务器最近入库条目工具"""
-
-import asyncio
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.mediaserver import MediaServerChain
-from app.application.mediaserver import get_mediaserver_configs
-from app.runtime.log import logger
-
-PAGE_SIZE = 20
-
-
-class QueryLibraryLatestInput(BaseModel):
- """查询媒体服务器最近入库条目工具的输入参数模型"""
-
- server: Optional[str] = Field(
- None,
- description="Media server name (optional, if not specified queries all enabled media servers)",
- )
- page: Optional[int] = Field(
- 1, description="Page number for pagination (default: 1, 20 items per page)"
- )
-
-
-class QueryLibraryLatestTool(MoviePilotTool):
- """查询媒体服务器最近入库的影视或音乐条目。"""
-
- name: str = "query_library_latest"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Library,
- ToolTag.Media,
- ]
- description: str = (
- "Query the latest media items added to configured media servers. Returns any server-supported movies, "
- "TV, recordings, or albums with titles, images, links, and metadata. Supports 20-item pagination."
- )
- args_schema: Type[BaseModel] = QueryLibraryLatestInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- server = kwargs.get("server")
- page = kwargs.get("page", 1)
-
- parts = ["查询媒体服务器最近入库条目"]
-
- if server:
- parts.append(f"服务器: {server}")
- else:
- parts.append("所有服务器")
-
- parts.append(f"第{page}页")
-
- return " | ".join(parts)
-
- @staticmethod
- def _get_enabled_servers() -> list[str]:
- """同步读取启用的媒体服务器列表。"""
- return [config.name for config in get_mediaserver_configs()]
-
- @staticmethod
- def _load_latest_items(
- server_name: str, count: int, username: Optional[str] = None
- ) -> list[dict]:
- """
- 媒体服务器 SDK 和 requests 调用都是同步的,这里在线程池中转换为可序列化结果。
- """
- latest_items = MediaServerChain().latest(
- server=server_name, count=count, username=username
- )
- if not latest_items:
- return []
- return [
- {
- **item.model_dump(exclude_none=True),
- "server": server_name,
- }
- for item in latest_items
- ]
-
- async def run(
- self, server: Optional[str] = None, page: Optional[int] = 1, **kwargs
- ) -> str:
- """并行读取媒体服务器最近入库结果并执行统一分页。"""
- page = max(1, page or 1)
- # 为了支持分页,需要获取足够多的数据再切片
- fetch_count = page * PAGE_SIZE
- logger.info(f"执行工具: {self.name}, 参数: server={server}, page={page}")
- try:
- # 如果没有指定服务器,获取所有启用的媒体服务器
- if not server:
- enabled_servers = self._get_enabled_servers()
- if not enabled_servers:
- return "未找到启用的媒体服务器"
- server_results = await asyncio.gather(
- *[
- self.run_blocking(
- "mediaserver",
- self._load_latest_items,
- server_name,
- fetch_count,
- self._username,
- )
- for server_name in enabled_servers
- ]
- )
- results = [
- item for items in server_results for item in items if items
- ]
- else:
- results = await self.run_blocking(
- "mediaserver",
- self._load_latest_items,
- server,
- fetch_count,
- self._username,
- )
-
- if not results:
- server_info = f"服务器 {server}" if server else "所有服务器"
- return f"未找到 {server_info} 的最近入库条目"
-
- # 分页
- total_count = len(results)
- start = (page - 1) * PAGE_SIZE
- end = start + PAGE_SIZE
- page_results = results[start:end]
-
- if not page_results:
- total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
- return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {total_pages} 页。"
-
- total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
- payload_msg = f"第 {page}/{total_pages} 页,当前页 {len(page_results)} 条结果,共 {total_count} 条。"
- if page < total_pages:
- payload_msg += f" 可使用 page={page + 1} 获取下一页。"
-
- result_json = json.dumps(page_results, ensure_ascii=False, indent=2)
- return f"{payload_msg}\n\n{result_json}"
-
- except Exception as e:
- logger.error(f"查询媒体服务器最近入库影片失败: {e}", exc_info=True)
- return f"查询媒体服务器最近入库影片时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_market_plugins.py b/app/agent/tools/impl/query_market_plugins.py
deleted file mode 100644
index 76275334c..000000000
--- a/app/agent/tools/impl/query_market_plugins.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""查询插件市场工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._plugin_tool_utils import (
- DEFAULT_PLUGIN_CANDIDATE_LIMIT,
- MAX_PLUGIN_CANDIDATE_LIMIT,
- load_market_plugins,
- search_plugin_candidates,
- summarize_candidates,
- summarize_plugin,
-)
-from app.runtime.log import logger
-
-
-class QueryMarketPluginsInput(BaseModel):
- """查询插件市场工具的输入参数模型"""
-
- query: Optional[str] = Field(
- None,
- description="Optional keyword to filter plugin market results by plugin ID, name, description, or author.",
- )
- max_results: Optional[int] = Field(
- DEFAULT_PLUGIN_CANDIDATE_LIMIT,
- description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
- )
- force_refresh: Optional[bool] = Field(
- False,
- description="Whether to refresh plugin market caches before querying.",
- )
-
-
-class QueryMarketPluginsTool(MoviePilotTool):
- name: str = "query_market_plugins"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Query available plugins from the plugin market and local plugin repositories. "
- "Can return the full plugin list or filter by keywords before install_plugin is used."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = QueryMarketPluginsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- query = kwargs.get("query")
- if query:
- return f"查询插件市场: {query}"
- return "查询插件市场全部插件"
-
- @staticmethod
- def _clamp_results(max_results: Optional[int]) -> int:
- if max_results is None:
- return DEFAULT_PLUGIN_CANDIDATE_LIMIT
- try:
- return max(1, min(int(max_results), MAX_PLUGIN_CANDIDATE_LIMIT))
- except (TypeError, ValueError):
- return DEFAULT_PLUGIN_CANDIDATE_LIMIT
-
- async def run(
- self,
- query: Optional[str] = None,
- max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
- force_refresh: bool = False,
- **kwargs,
- ) -> str:
- logger.info(
- f"执行工具: {self.name}, 参数: query={query}, force_refresh={force_refresh}"
- )
-
- try:
- plugins = await load_market_plugins(force_refresh=force_refresh)
- if not plugins:
- return json.dumps(
- {"success": False, "message": "当前插件市场没有可用插件"},
- ensure_ascii=False,
- )
-
- limit = self._clamp_results(max_results)
- if query:
- matches = search_plugin_candidates(query, plugins)
- return json.dumps(
- {
- "success": True,
- "query": query,
- "total_available": len(plugins),
- "match_count": len(matches),
- "truncated": len(matches) > limit,
- "plugins": summarize_candidates(matches, limit=limit),
- },
- ensure_ascii=False,
- indent=2,
- )
-
- plugin_summaries = [summarize_plugin(plugin) for plugin in plugins[:limit]]
- return json.dumps(
- {
- "success": True,
- "total_available": len(plugins),
- "returned_count": len(plugin_summaries),
- "truncated": len(plugins) > limit,
- "plugins": plugin_summaries,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as e:
- logger.error(f"查询插件市场失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"查询插件市场时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_media_detail.py b/app/agent/tools/impl/query_media_detail.py
deleted file mode 100644
index 8aa4b3946..000000000
--- a/app/agent/tools/impl/query_media_detail.py
+++ /dev/null
@@ -1,293 +0,0 @@
-"""查询媒体详情工具"""
-
-import asyncio
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.media import MediaChain
-from app.runtime.log import logger
-from app.schemas.types import (
- MUSIC_ENTITY_ALBUM,
- MUSIC_ENTITY_ARTIST,
- MUSIC_ENTITY_RECORDING,
- MediaSource,
- MediaType,
-)
-from app.domain.media import normalize_music_type
-from ._music_utils import (
- simplify_music_album,
- simplify_music_artist,
- simplify_music_info,
-)
-
-DIRECTOR_PREVIEW_LIMIT = 10
-ACTOR_PREVIEW_LIMIT = 20
-SEASON_PREVIEW_LIMIT = 100
-
-
-class QueryMediaDetailInput(BaseModel):
- """查询媒体详情工具的输入参数模型"""
- media_source: MediaSource = Field(..., description="Media metadata source")
- media_id: str = Field(..., description="Native ID for media_source")
- media_type: str = Field(..., description="Allowed values: movie, tv, music")
- music_type: Optional[str] = Field(
- None,
- description="Required for music: recording, album, or artist",
- )
- include_artist_albums: Optional[bool] = Field(
- False,
- description="For artist detail, include a paged album/EP/single catalog preview",
- )
- include_related_artists: Optional[bool] = Field(
- False,
- description="For artist detail, include related artists",
- )
- page: Optional[int] = Field(1, description="Artist album catalog page, default 1")
- count: Optional[int] = Field(20, description="Artist catalog/related result count, max 30")
- album_type: Optional[str] = Field(
- None,
- description="Optional artist catalog filter: album, single, ep, broadcast, other, compilation, soundtrack, live, or remix",
- )
-
-
-class QueryMediaDetailTool(MoviePilotTool):
- """按稳定媒体身份查询影视、单曲、专辑或艺术家详情。"""
-
- name: str = "query_media_detail"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Media,
- ]
- description: str = (
- "Query details by stable metadata ID for movies, TV, music recordings, albums, or artists. "
- "Music requires media_source, media_id, and music_type. Album details include a bounded track list; "
- "artist details may optionally include catalog and related-artist previews. Artists are browse-only."
- )
- args_schema: Type[BaseModel] = QueryMediaDetailInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- return (
- f"查询媒体详情: {kwargs.get('media_source') or '媒体源'} "
- f"ID {kwargs.get('media_id')}"
- )
-
- async def run(
- self, media_type: str, media_source: MediaSource,
- media_id: str, music_type: Optional[str] = None,
- include_artist_albums: Optional[bool] = False,
- include_related_artists: Optional[bool] = False,
- page: Optional[int] = 1, count: Optional[int] = 20,
- album_type: Optional[str] = None, **kwargs,
- ) -> str:
- """执行媒体详情查询,并限制音乐目录型结果的上下文大小。"""
- logger.info(
- f"执行工具: {self.name}, 参数: media_type={media_type}, "
- f"music_type={music_type}, media_source={media_source}, "
- f"media_id={media_id}"
- )
-
- if not str(media_id or "").strip():
- return json.dumps({
- "success": False,
- "message": "必须提供 media_id"
- }, ensure_ascii=False)
-
- try:
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return json.dumps({
- "success": False,
- "message": (
- f"无效的媒体类型 '{media_type}',"
- "支持的类型:'movie', 'tv', 'music'"
- )
- }, ensure_ascii=False)
-
- if media_type_enum == MediaType.MUSIC:
- normalized_music_type = normalize_music_type(music_type)
- if not normalized_music_type:
- return json.dumps({
- "success": False,
- "message": (
- "查询音乐详情必须提供 music_type:"
- "'recording', 'album' 或 'artist'"
- ),
- }, ensure_ascii=False)
- if not media_source or not media_id:
- return json.dumps({
- "success": False,
- "message": "查询音乐详情必须同时提供 media_source 和 media_id",
- }, ensure_ascii=False)
-
- media_chain = MediaChain()
- if normalized_music_type == MUSIC_ENTITY_ALBUM:
- album_info = await media_chain.async_get_music_album(
- media_source, media_id
- )
- if not album_info:
- return json.dumps({
- "success": False,
- "message": f"未找到专辑 {media_source}:{media_id}",
- }, ensure_ascii=False)
- return json.dumps(
- simplify_music_album(album_info),
- ensure_ascii=False,
- indent=2,
- )
-
- if normalized_music_type == MUSIC_ENTITY_ARTIST:
- artist_info = await media_chain.async_get_music_artist(
- media_source, media_id
- )
- if not artist_info:
- return json.dumps({
- "success": False,
- "message": f"未找到艺术家 {media_source}:{media_id}",
- }, ensure_ascii=False)
- normalized_page = max(1, page or 1)
- normalized_count = max(1, min(count or 20, 30))
- result = simplify_music_artist(artist_info)
- pending = []
- if include_artist_albums:
- pending.append((
- "albums",
- media_chain.async_get_music_artist_albums(
- media_source=media_source,
- media_id=media_id,
- page=normalized_page,
- count=normalized_count,
- album_type=album_type,
- ),
- ))
- if include_related_artists:
- pending.append((
- "related_artists",
- media_chain.async_get_music_artist_related(
- media_source=media_source,
- media_id=media_id,
- count=normalized_count,
- ),
- ))
- if pending:
- values = await asyncio.gather(*(call for _, call in pending))
- for (key, _), items in zip(pending, values):
- result[key] = [
- simplify_music_info(item)
- if key == "albums"
- else simplify_music_artist(item)
- for item in items
- ]
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- mediainfo = await media_chain.async_recognize_media(
- media_source=media_source,
- media_id=media_id,
- mtype=MediaType.MUSIC,
- music_type=normalized_music_type,
- )
- if (
- not mediainfo
- or getattr(mediainfo, "music_type", MUSIC_ENTITY_RECORDING)
- != MUSIC_ENTITY_RECORDING
- ):
- return json.dumps({
- "success": False,
- "message": f"未找到单曲 {media_source}:{media_id}",
- }, ensure_ascii=False)
- return json.dumps(
- simplify_music_info(mediainfo),
- ensure_ascii=False,
- indent=2,
- )
-
- media_chain = MediaChain()
- mediainfo = await media_chain.async_recognize_media(
- media_source=media_source,
- media_id=media_id,
- mtype=media_type_enum,
- )
-
- if not mediainfo:
- return json.dumps({
- "success": False,
- "message": f"未找到 {media_source} ID {media_id} 的媒体信息"
- }, ensure_ascii=False)
-
- # 精简 genres - 只保留名称
- genres = [g.get("name") for g in (mediainfo.genres or []) if g.get("name")]
-
- # 精简 directors - 只保留姓名和职位
- director_source = [d for d in (mediainfo.directors or []) if d.get("name")]
- directors = [
- {
- "name": d.get("name"),
- "job": d.get("job")
- }
- for d in director_source[:DIRECTOR_PREVIEW_LIMIT]
- ]
-
- # 精简 actors - 只保留姓名和角色
- actor_source = [a for a in (mediainfo.actors or []) if a.get("name")]
- actors = [
- {
- "name": a.get("name"),
- "character": a.get("character")
- }
- for a in actor_source[:ACTOR_PREVIEW_LIMIT]
- ]
-
- # 构建基础媒体详情信息
- result = {
- "status": mediainfo.status,
- "genres": genres,
- "directors": directors,
- "directors_total": len(director_source),
- "directors_truncated": len(director_source) > DIRECTOR_PREVIEW_LIMIT,
- "actors": actors,
- "actors_total": len(actor_source),
- "actors_truncated": len(actor_source) > ACTOR_PREVIEW_LIMIT,
- }
-
- # 如果是电视剧,添加电视剧特有信息
- if mediainfo.type == MediaType.TV:
- # 精简 season_info - 只保留基础摘要
- season_source = [
- s for s in (mediainfo.season_info or [])
- if s.get("season_number") is not None
- ]
- season_info = [
- {
- "season_number": s.get("season_number"),
- "name": s.get("name"),
- "episode_count": s.get("episode_count"),
- "air_date": s.get("air_date")
- }
- for s in season_source[:SEASON_PREVIEW_LIMIT]
- ]
-
- result.update({
- "number_of_seasons": mediainfo.number_of_seasons,
- "number_of_episodes": mediainfo.number_of_episodes,
- "first_air_date": mediainfo.first_air_date,
- "last_air_date": mediainfo.last_air_date,
- "season_info": season_info,
- "season_info_total": len(season_source),
- "season_info_truncated": len(season_source) > SEASON_PREVIEW_LIMIT,
- })
-
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- except Exception as e:
- error_message = f"查询媒体详情失败: {str(e)}"
- logger.error(f"查询媒体详情失败: {e}", exc_info=True)
- return json.dumps({
- "success": False,
- "message": error_message,
- "media_source": media_source,
- "media_id": media_id,
- }, ensure_ascii=False)
diff --git a/app/agent/tools/impl/query_personas.py b/app/agent/tools/impl/query_personas.py
deleted file mode 100644
index 8dba7eebb..000000000
--- a/app/agent/tools/impl/query_personas.py
+++ /dev/null
@@ -1,76 +0,0 @@
-"""查询可用人格工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.runtime import agent_runtime_manager
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class QueryPersonasInput(BaseModel):
- """查询人格工具的输入参数模型。"""
-
- query: Optional[str] = Field(
- None,
- description=(
- "Optional search keyword for persona_id, label, description, or aliases. "
- "Use this when the user asks for a certain speaking style but the exact persona name is unknown."
- ),
- )
-
-
-class QueryPersonasTool(MoviePilotTool):
- name: str = "query_personas"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Persona,
- ]
- description: str = (
- "List all available personas (人格) and show which one is currently active. "
- "Use this before switching persona when the user asks for a different speaking style but does not name "
- "an exact persona_id. The result includes persona_id, label, description, aliases, and whether it is active."
- )
- args_schema: Type[BaseModel] = QueryPersonasInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- query = kwargs.get("query")
- if query:
- return f"查询人格列表: {query}"
- return "查询人格列表"
-
- async def run(self, query: Optional[str] = None, **kwargs) -> str:
- logger.info("执行工具: %s, 参数: query=%s", self.name, query)
- try:
- runtime_config = agent_runtime_manager.load_runtime_config()
- personas = runtime_config.list_personas()
-
- if query:
- normalized = query.strip().casefold()
- personas = [
- persona
- for persona in personas
- if normalized in persona["persona_id"].casefold()
- or normalized in persona["label"].casefold()
- or normalized in persona["description"].casefold()
- or any(normalized in alias.casefold() for alias in persona["aliases"])
- ]
-
- payload = {
- "active_persona": runtime_config.active_persona,
- "count": len(personas),
- "personas": personas,
- }
- return json.dumps(payload, ensure_ascii=False, indent=2)
- except Exception as e: # noqa: BLE001
- logger.error("查询人格列表失败: %s", e, exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"查询人格列表时发生错误: {str(e)}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_plugin_capabilities.py b/app/agent/tools/impl/query_plugin_capabilities.py
deleted file mode 100644
index dd0a3c50b..000000000
--- a/app/agent/tools/impl/query_plugin_capabilities.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""查询插件能力工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.plugin.runtime import get_plugin_manager
-from app.runtime.log import logger
-
-
-class QueryPluginCapabilitiesInput(BaseModel):
- """查询插件能力工具的输入参数模型"""
-
- plugin_id: Optional[str] = Field(
- None,
- description="Optional plugin ID to query capabilities for a specific plugin. "
- "If not provided, returns capabilities of all running plugins. "
- "Use query_installed_plugins tool to get the plugin IDs first.",
- )
-
-
-class QueryPluginCapabilitiesTool(MoviePilotTool):
- name: str = "query_plugin_capabilities"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Query the capabilities of installed plugins, including supported commands and scheduled services. "
- "Commands are slash-commands (e.g. /xxx) that can be executed via the run_slash_command tool. "
- "Scheduled services are periodic tasks that can be triggered via the run_scheduler tool. "
- "Optionally specify a plugin_id to query a specific plugin, or omit to query all running plugins."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = QueryPluginCapabilitiesInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- plugin_id = kwargs.get("plugin_id")
- if plugin_id:
- return f"查询插件 {plugin_id} 的能力"
- return "查询所有插件的能力"
-
- @staticmethod
- def _load_plugin_capabilities(plugin_id: Optional[str] = None) -> dict:
- """读取运行中插件实例暴露的内存能力信息。"""
- plugin_manager = get_plugin_manager()
- result = {}
-
- commands = plugin_manager.get_plugin_commands(pid=plugin_id)
- if commands:
- result["commands"] = [
- {
- "cmd": cmd.get("cmd"),
- "desc": cmd.get("desc"),
- "plugin_id": cmd.get("pid"),
- **({"data": cmd.get("data")} if cmd.get("data") else {}),
- }
- for cmd in commands
- ]
-
- actions = plugin_manager.get_plugin_actions(pid=plugin_id)
- if actions:
- actions_list = []
- for action_group in actions:
- actions_list.append(
- {
- "plugin_id": action_group.get("plugin_id"),
- "plugin_name": action_group.get("plugin_name"),
- "actions": [
- {
- "id": action.get("id"),
- "name": action.get("name"),
- }
- for action in action_group.get("actions", [])
- ],
- }
- )
- result["actions"] = actions_list
-
- services = plugin_manager.get_plugin_services(pid=plugin_id)
- if services:
- services_list = []
- for svc in services:
- svc_info = {
- "id": svc.get("id"),
- "name": svc.get("name"),
- }
- trigger = svc.get("trigger")
- if trigger:
- svc_info["trigger"] = str(trigger)
- svc_kwargs = svc.get("kwargs")
- if svc_kwargs:
- svc_info["trigger_kwargs"] = {
- k: str(v) for k, v in svc_kwargs.items()
- }
- services_list.append(svc_info)
- result["services"] = services_list
-
- return result
-
- async def run(self, plugin_id: Optional[str] = None, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
- try:
- result = self._load_plugin_capabilities(plugin_id)
- if not result:
- if plugin_id:
- return f"插件 {plugin_id} 没有注册任何命令、动作或定时服务"
- return "当前没有运行中的插件注册了命令、动作或定时服务"
-
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- logger.error(f"查询插件能力失败: {e}", exc_info=True)
- return f"查询插件能力时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_plugin_config.py b/app/agent/tools/impl/query_plugin_config.py
deleted file mode 100644
index 235f302b1..000000000
--- a/app/agent/tools/impl/query_plugin_config.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""查询插件配置工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
-from app.application.plugin.runtime import get_plugin_manager
-from app.runtime.log import logger
-
-
-class QueryPluginConfigInput(BaseModel):
- """查询插件配置工具的输入参数模型"""
-
- plugin_id: str = Field(
- ...,
- description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
- )
-
-
-class QueryPluginConfigTool(MoviePilotTool):
- name: str = "query_plugin_config"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Query the saved configuration of an installed plugin. "
- "Returns the current saved config and, when available, the plugin's default config model. "
- "Use this before update_plugin_config so you only change the intended keys."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = QueryPluginConfigInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- plugin_id = kwargs.get("plugin_id", "")
- return f"查询插件配置: {plugin_id}"
-
- @staticmethod
- def _query_plugin_config(plugin_id: str) -> str:
- """
- 读取插件已保存配置,并尽量补充默认配置模型方便后续精确修改。
- """
- plugin_info = get_plugin_snapshot(plugin_id)
- if not plugin_info:
- return json.dumps(
- {
- "success": False,
- "message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
- },
- ensure_ascii=False,
- )
-
- plugin_manager = get_plugin_manager()
- saved_config = plugin_manager.get_plugin_config(plugin_id) or {}
- result = {
- "success": True,
- **plugin_info,
- "config": saved_config,
- }
-
- # get_form 的 model 通常就是插件期望的配置结构,适合作为修改前的键参考。
- plugin_instance = plugin_manager.running_plugins.get(plugin_id)
- if plugin_instance and hasattr(plugin_instance, "get_form"):
- try:
- _form_schema, default_model = plugin_instance.get_form()
- if default_model is not None:
- result["default_model"] = default_model
- except Exception as err:
- logger.warning(f"读取插件 {plugin_id} 默认配置模型失败: {err}")
-
- return json.dumps(result, ensure_ascii=False, indent=2, default=str)
-
- async def run(self, plugin_id: str, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
-
- try:
- # 插件配置来自内存配置缓存和运行态插件实例,直接读取即可。
- return self._query_plugin_config(plugin_id)
- except Exception as e:
- logger.error(f"查询插件配置失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"查询插件配置时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_plugin_data.py b/app/agent/tools/impl/query_plugin_data.py
deleted file mode 100644
index 0ed85dc8d..000000000
--- a/app/agent/tools/impl/query_plugin_data.py
+++ /dev/null
@@ -1,159 +0,0 @@
-"""查询插件数据工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.impl._plugin_tool_utils import (
- PLUGIN_DATA_KEY_PREVIEW_LIMIT,
- build_preview_payload,
- get_plugin_snapshot,
-)
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class QueryPluginDataInput(BaseModel):
- """查询插件数据工具的输入参数模型"""
-
- plugin_id: str = Field(
- ...,
- description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
- )
- key: Optional[str] = Field(
- None,
- description="Optional plugin data key. If omitted, returns all plugin data entries for the plugin.",
- )
- max_chars: Optional[int] = Field(
- None,
- description="Maximum number of preview characters to return when plugin data is too large. Default 12000, capped at 50000.",
- )
-
-
-class QueryPluginDataTool(MoviePilotTool):
- name: str = "query_plugin_data"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Query persisted data of an installed plugin. "
- "Optionally specify a key to read a single data item; otherwise all plugin data entries are returned. "
- "When the result is too large, the tool automatically truncates it and returns a preview instead."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = QueryPluginDataInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- plugin_id = kwargs.get("plugin_id", "")
- key = kwargs.get("key")
- if key:
- return f"查询插件数据: {plugin_id}.{key}"
- return f"查询插件全部数据: {plugin_id}"
-
- async def _query_plugin_data(
- self,
- plugin_id: str,
- key: Optional[str] = None,
- max_chars: Optional[int] = None,
- ) -> str:
- """
- 插件数据改走异步 ORM 查询,避免再套一层线程池。
- """
- plugin_info = get_plugin_snapshot(plugin_id)
- if not plugin_info:
- return json.dumps(
- {
- "success": False,
- "message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
- },
- ensure_ascii=False,
- )
-
- if key:
- value = await self.data.plugin_data.get(plugin_id, key)
- if value is None:
- return json.dumps(
- {
- "success": True,
- **plugin_info,
- "key": key,
- "found": False,
- "message": f"插件 {plugin_id} 没有数据项 {key}",
- },
- ensure_ascii=False,
- indent=2,
- )
-
- truncated, total_chars, returned_chars, preview = build_preview_payload(
- value, max_chars
- )
- result = {
- "success": True,
- **plugin_info,
- "key": key,
- "found": True,
- "truncated": truncated,
- "total_chars": total_chars,
- "returned_chars": returned_chars,
- }
- if truncated:
- result["value_preview"] = preview
- result["message"] = "插件数据内容过大,已截断预览"
- else:
- result["value"] = value
- return json.dumps(result, ensure_ascii=False, indent=2, default=str)
-
- data_map = await self.data.plugin_data.list(plugin_id)
- keys = list(data_map.keys())
- key_preview = keys[:PLUGIN_DATA_KEY_PREVIEW_LIMIT]
-
- result = {
- "success": True,
- **plugin_info,
- "count": len(data_map),
- "keys": key_preview,
- "keys_truncated": len(keys) > PLUGIN_DATA_KEY_PREVIEW_LIMIT,
- }
-
- if not data_map:
- result["data"] = {}
- result["truncated"] = False
- return json.dumps(result, ensure_ascii=False, indent=2, default=str)
-
- truncated, total_chars, returned_chars, preview = build_preview_payload(
- data_map, max_chars
- )
- result["truncated"] = truncated
- result["total_chars"] = total_chars
- result["returned_chars"] = returned_chars
- if truncated:
- result["data_preview"] = preview
- result["message"] = "插件数据内容过大,已截断。请传入 key 精确查询单个数据项。"
- else:
- result["data"] = data_map
- return json.dumps(result, ensure_ascii=False, indent=2, default=str)
-
- async def run(
- self,
- plugin_id: str,
- key: Optional[str] = None,
- max_chars: Optional[int] = None,
- **kwargs,
- ) -> str:
- logger.info(
- f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, key={key}"
- )
-
- try:
- return await self._query_plugin_data(plugin_id, key, max_chars)
- except Exception as e:
- logger.error(f"查询插件数据失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"查询插件数据时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_popular_subscribes.py b/app/agent/tools/impl/query_popular_subscribes.py
deleted file mode 100644
index a0845419c..000000000
--- a/app/agent/tools/impl/query_popular_subscribes.py
+++ /dev/null
@@ -1,223 +0,0 @@
-"""查询热门订阅工具"""
-
-import json
-from typing import Optional, Type
-
-import cn2an
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.domain.context import MediaInfo
-from app.adapters.external.server import MoviePilotServerHelper
-from app.runtime.log import logger
-from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent
-from app.domain.media import normalize_music_type
-
-MAX_PAGE_SIZE = 50
-
-
-class QueryPopularSubscribesInput(BaseModel):
- """查询热门订阅工具的输入参数模型"""
- media_type: str = Field(..., description="Allowed values: movie, tv, music")
- music_type: Optional[str] = Field(
- None,
- description="Optional music entity filter: recording or album",
- )
- page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
- count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
- min_sub: Optional[int] = Field(None, description="Minimum number of subscribers filter (optional, e.g., 5)")
- genre_id: Optional[int] = Field(None, description="Filter by genre ID (optional)")
- min_rating: Optional[float] = Field(None, description="Minimum rating filter (optional, e.g., 7.5)")
- max_rating: Optional[float] = Field(None, description="Maximum rating filter (optional, e.g., 10.0)")
- sort_type: Optional[str] = Field(None, description="Sort type (optional, e.g., 'count', 'rating')")
-
-
-class QueryPopularSubscribesTool(MoviePilotTool):
- """查询社区热门的影视、单曲或专辑订阅。"""
-
- name: str = "query_popular_subscribes"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Subscription,
- ToolTag.Recommendation,
- ]
- description: str = (
- "Query popular movie, TV, recording, or album subscriptions from shared statistics. "
- "Music results can be filtered by recording or album entity type."
- )
- args_schema: Type[BaseModel] = QueryPopularSubscribesInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- media_type = kwargs.get("media_type", "")
- page = kwargs.get("page", 1)
- min_sub = kwargs.get("min_sub")
- min_rating = kwargs.get("min_rating")
- max_rating = kwargs.get("max_rating")
-
- parts = [f"查询热门订阅 [{media_type}]"]
-
- if min_sub:
- parts.append(f"最少订阅: {min_sub}")
- if min_rating:
- parts.append(f"最低评分: {min_rating}")
- if max_rating:
- parts.append(f"最高评分: {max_rating}")
- if page > 1:
- parts.append(f"第{page}页")
-
- return " | ".join(parts) if len(parts) > 1 else parts[0]
-
- async def run(self, media_type: str,
- music_type: Optional[str] = None,
- page: Optional[int] = 1,
- count: Optional[int] = 30,
- min_sub: Optional[int] = None,
- genre_id: Optional[int] = None,
- min_rating: Optional[float] = None,
- max_rating: Optional[float] = None,
- sort_type: Optional[str] = None, **kwargs) -> str:
- """查询有界的订阅统计并保留音乐实体身份。"""
- logger.info(
- f"执行工具: {self.name}, 参数: media_type={media_type}, page={page}, count={count}, min_sub={min_sub}, "
- f"genre_id={genre_id}, min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}")
-
- try:
- if page is None or page < 1:
- page = 1
- if count is None or count < 1:
- count = 30
- # 外部统计接口支持传入 count,这里做硬上限,避免 Agent 一次拉取过多结果。
- count = min(count, MAX_PAGE_SIZE)
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(
- music_type,
- allow_artist=False,
- )
- if not normalized_music_type:
- return (
- f"错误:无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album'"
- )
- if media_type_enum != MediaType.MUSIC:
- return "错误:music_type 仅能与 media_type='music' 一起使用"
-
- subscribes = await MoviePilotServerHelper.async_get_subscribe_statistic(
- stype=media_type_enum.to_agent(),
- page=page,
- count=count,
- genre_id=genre_id,
- min_rating=min_rating,
- max_rating=max_rating,
- sort_type=sort_type
- )
-
- if not subscribes:
- return "未找到热门订阅数据(可能订阅统计功能未启用)"
-
- # 转换为MediaInfo格式并过滤
- ret_medias = []
- for sub in subscribes:
- # 订阅人数
- subscriber_count = sub.get("count", 0)
- # 如果设置了最小订阅人数,进行过滤
- if min_sub and subscriber_count < min_sub:
- continue
-
- media = MediaInfo()
- raw_type = str(sub.get("type") or "").strip().lower()
- if raw_type in ["music", "音乐"]:
- sub_music_type = normalize_music_type(
- sub.get("music_type") or MUSIC_ENTITY_RECORDING,
- allow_artist=False,
- )
- if not sub_music_type:
- logger.warning(f"跳过未知音乐订阅实体: {sub.get('music_type')}")
- continue
- if normalized_music_type and sub_music_type != normalized_music_type:
- continue
- artists = sub.get("artists") or []
- if isinstance(artists, str):
- artists = [artists]
- if not artists and sub.get("artist"):
- artists = [sub.get("artist")]
- ret_medias.append({
- "type": "music",
- "title": sub.get("name"),
- "year": sub.get("year"),
- "music_type": sub_music_type,
- "artists": artists,
- "album": sub.get("album"),
- "total_tracks": sub.get("total_tracks"),
- "media_source": sub.get("media_source"),
- "media_id": sub.get("media_id"),
- "poster_path": sub.get("poster"),
- "backdrop_path": sub.get("backdrop"),
- "subscriber_count": subscriber_count,
- "popularity": subscriber_count,
- })
- continue
- if raw_type in ["movie", "电影"]:
- media.type = MediaType.MOVIE
- elif raw_type in ["tv", "电视剧"]:
- media.type = MediaType.TV
- else:
- # 跳过无法识别类型的数据,避免单条脏数据导致整批失败
- logger.warning(f"跳过未知媒体类型: {sub.get('type')}")
- continue
- # 处理标题
- title = sub.get("name")
- season = sub.get("season")
- if season not in (None, "") and int(season) != 1:
- # 小写数据转大写
- season_str = cn2an.an2cn(season, "low")
- title = f"{title} 第{season_str}季"
- media.title = title
- media.year = sub.get("year")
- media.media_source = sub.get("media_source")
- media.media_id = sub.get("media_id")
- media.season = sub.get("season")
- media.vote_average = sub.get("vote")
- media.poster_path = sub.get("poster")
- media.backdrop_path = sub.get("backdrop")
- media.popularity = subscriber_count
- ret_medias.append(media)
-
- if not ret_medias:
- return "未找到符合条件的热门订阅"
-
- # 转换为字典格式,只保留关键信息
- simplified_medias = []
- for media in ret_medias:
- if isinstance(media, dict):
- simplified_medias.append(media)
- continue
- media_dict = media.to_dict()
- simplified = {
- "type": media_type_to_agent(media_dict.get("type")),
- "title": media_dict.get("title"),
- "year": media_dict.get("year"),
- "media_source": media_dict.get("media_source"),
- "media_id": media_dict.get("media_id"),
- "season": media_dict.get("season"),
- "vote_average": media_dict.get("vote_average"),
- "poster_path": media_dict.get("poster_path"),
- "backdrop_path": media_dict.get("backdrop_path"),
- "popularity": media_dict.get("popularity"), # 订阅人数
- "subscriber_count": media_dict.get("popularity") # 明确标注为订阅人数
- }
- simplified_medias.append(simplified)
-
- result_json = json.dumps(simplified_medias, ensure_ascii=False, indent=2)
-
- pagination_info = f"第 {page} 页,每页 {count} 条,共 {len(simplified_medias)} 条结果"
-
- return f"{pagination_info}\n\n{result_json}"
- except Exception as e:
- logger.error(f"查询热门订阅失败: {e}", exc_info=True)
- return f"查询热门订阅时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_rule_groups.py b/app/agent/tools/impl/query_rule_groups.py
deleted file mode 100644
index 8cedf5d9a..000000000
--- a/app/agent/tools/impl/query_rule_groups.py
+++ /dev/null
@@ -1,106 +0,0 @@
-"""查询过滤规则组工具。"""
-
-import json
-from typing import List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.impl._filter_rule_utils import (
- RULE_STRING_SYNTAX,
- collect_rule_group_usages,
- get_rule_groups,
- serialize_rule_group,
-)
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class QueryRuleGroupsInput(BaseModel):
- """查询规则组工具的输入参数模型"""
-
- group_names: Optional[List[str]] = Field(
- None,
- description="Optional list of rule group names to query. If omitted, return all rule groups.",
- )
- include_usage: bool = Field(
- True,
- description="Whether to include where each rule group is referenced by global settings or subscriptions.",
- )
-
-
-class QueryRuleGroupsTool(MoviePilotTool):
- name: str = "query_rule_groups"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.FilterRule,
- ]
- description: str = (
- "Query filter rule groups (过滤规则组 / 优先级规则组). "
- "Each rule group contains a rule_string made of built-in rules and/or custom rules. "
- "Inside one level use '&', '|', '!' and optional parentheses; use '>' between levels. "
- "Levels are evaluated from left to right, and the first matched level wins. "
- "The result includes parsed levels and syntax guidance so the agent can learn existing patterns before writing a new rule group."
- )
- args_schema: Type[BaseModel] = QueryRuleGroupsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- group_names = kwargs.get("group_names") or []
- if group_names:
- return f"查询规则组: {', '.join(group_names)}"
- return "查询所有规则组"
-
- async def run(
- self,
- group_names: Optional[List[str]] = None,
- include_usage: bool = True,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}")
-
- try:
- rule_groups = get_rule_groups()
- if group_names:
- target_names = set(group_names)
- rule_groups = [
- group for group in rule_groups if group.name in target_names
- ]
-
- usage_map = {}
- if include_usage:
- usage_map = await collect_rule_group_usages(
- self.data.subscriptions,
- [group.name for group in rule_groups if group.name]
- )
-
- serialized = [
- serialize_rule_group(group, usage_map.get(group.name))
- for group in rule_groups
- ]
- message = (
- f"找到 {len(serialized)} 个规则组"
- if serialized
- else "未找到任何规则组"
- )
-
- return json.dumps(
- {
- "success": True,
- "message": message,
- "count": len(serialized),
- "rule_string_syntax": RULE_STRING_SYNTAX,
- "rule_groups": serialized,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"查询规则组失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"查询规则组失败: {exc}",
- "rule_groups": [],
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_schedulers.py b/app/agent/tools/impl/query_schedulers.py
deleted file mode 100644
index 21a6eab11..000000000
--- a/app/agent/tools/impl/query_schedulers.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""查询定时服务工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class QuerySchedulersInput(BaseModel):
- """查询运行时定时服务的输入参数模型。"""
-
-
-class QuerySchedulersTool(MoviePilotTool):
- """查询系统、插件和工作流注册的运行时定时服务。"""
-
- name: str = "query_schedulers"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Scheduler,
- ToolTag.Admin,
- ]
- description: str = (
- "Query runtime scheduler services registered by MoviePilot system components, "
- "plugins, and workflows. It excludes user-created autonomous agent tasks; use "
- "query_agent_tasks for reminders, monitoring tasks, and other agent schedules."
- )
- args_schema: Type[BaseModel] = QuerySchedulersInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs: object) -> Optional[str]:
- """生成查询运行时定时服务的提示消息。"""
- return "查询系统定时服务"
-
- async def run(self, **kwargs: object) -> str:
- """查询非 Agent 自主任务的运行时定时服务。"""
- logger.info(f"执行工具: {self.name}")
- try:
- from app.application.scheduling import (
- AGENT_TASK_JOB_PREFIX,
- list_scheduler_jobs,
- )
-
- agent_task_prefix = f"{AGENT_TASK_JOB_PREFIX}-"
- schedulers = [
- scheduler_item
- for scheduler_item in list_scheduler_jobs()
- if not str(scheduler_item.id or "").startswith(agent_task_prefix)
- ]
- if schedulers:
- schedulers_list = [
- {
- "id": scheduler_item.id,
- "name": scheduler_item.name,
- "provider": scheduler_item.provider,
- "status": scheduler_item.status,
- "next_run": scheduler_item.next_run,
- }
- for scheduler_item in schedulers
- ]
- result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2)
- total_count = len(schedulers_list)
- if total_count > 30:
- limited_schedulers = schedulers_list[:30]
- limited_json = json.dumps(
- limited_schedulers,
- ensure_ascii=False,
- indent=2,
- )
- return (
- f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,"
- f"仅显示前 30 条结果。\n\n{limited_json}"
- )
- return result_json
- return "未找到系统、插件或工作流定时服务"
- except Exception as e:
- logger.error(f"查询定时服务失败: {e}", exc_info=True)
- return f"查询定时服务时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_site_userdata.py b/app/agent/tools/impl/query_site_userdata.py
deleted file mode 100644
index 9a7b5ece5..000000000
--- a/app/agent/tools/impl/query_site_userdata.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""查询站点用户数据工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.domain import site as site_rules
-from app.runtime.log import logger
-from app.schemas.common import JsonData
-
-SITE_USERDATA_DETAIL_PREVIEW_LIMIT = 10
-
-
-def _preview_list(value, limit: int = SITE_USERDATA_DETAIL_PREVIEW_LIMIT) -> tuple[list, int, bool]:
- """返回列表字段预览,避免做种明细或未读消息一次性撑大工具结果。"""
- items = list(value) if isinstance(value, (list, tuple)) else []
- return items[:limit], len(items), len(items) > limit
-
-
-def _sort_text(value: JsonData) -> str:
- """把用户数据排序字段规范为可比较文本。"""
- return value if isinstance(value, str) else ""
-
-
-class QuerySiteUserdataInput(BaseModel):
- """查询站点用户数据工具的输入参数模型"""
-
- site_id: int = Field(
- ...,
- description="The ID of the site to query user data for (can be obtained from query_sites tool)",
- )
- workdate: Optional[str] = Field(
- None,
- description="Work date to query (optional, format: 'YYYY-MM-DD', if not specified returns latest data)",
- )
-
-
-class QuerySiteUserdataTool(MoviePilotTool):
- name: str = "query_site_userdata"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Site,
- ToolTag.Admin,
- ]
- description: str = "Query user data for a specific site including username, user level, upload/download statistics, seeding information, bonus points, and other account details. Supports querying data for a specific date or latest data."
- require_admin: bool = True
- args_schema: Type[BaseModel] = QuerySiteUserdataInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- site_id = kwargs.get("site_id")
- workdate = kwargs.get("workdate")
-
- message = f"查询站点 #{site_id} 的用户数据"
- if workdate:
- message += f" (日期: {workdate})"
- else:
- message += " (最新数据)"
-
- return message
-
- async def run(self, site_id: int, workdate: Optional[str] = None, **kwargs) -> str:
- logger.info(
- f"执行工具: {self.name}, 参数: site_id={site_id}, workdate={workdate}"
- )
-
- try:
- repository = self.data.sites
- site = await repository.async_get(site_id)
- if not site:
- return json.dumps(
- {"success": False, "message": f"站点不存在: {site_id}"},
- ensure_ascii=False,
- )
-
- site_domain = site.domain or site_rules.extract_domain(site.url)
- user_data_list = await repository.async_get_userdata_by_domain(
- domain=site_domain, workdate=workdate
- )
-
- if not user_data_list:
- return json.dumps(
- {
- "success": False,
- "message": f"站点 {site.name} ({site_domain}) 暂无用户数据",
- "site_id": site_id,
- "site_name": site.name,
- "site_domain": site_domain,
- "workdate": workdate,
- },
- ensure_ascii=False,
- )
-
- formatted_user_data: list[dict[str, JsonData]] = []
-
- for user_data in user_data_list:
- # 格式化上传/下载量(转换为可读格式)
- upload_gb = user_data.upload / (1024**3) if user_data.upload else 0
- download_gb = (
- user_data.download / (1024**3) if user_data.download else 0
- )
- seeding_size_gb = (
- user_data.seeding_size / (1024**3)
- if user_data.seeding_size
- else 0
- )
- leeching_size_gb = (
- user_data.leeching_size / (1024**3)
- if user_data.leeching_size
- else 0
- )
-
- seeding_preview, seeding_count, seeding_truncated = _preview_list(
- user_data.seeding_info
- )
- unread_preview, unread_count, unread_truncated = _preview_list(
- user_data.message_unread_contents
- )
-
- user_data_dict: dict[str, JsonData] = {
- "domain": user_data.domain,
- "name": user_data.name,
- "username": user_data.username,
- "userid": user_data.userid,
- "user_level": user_data.user_level,
- "join_at": user_data.join_at,
- "bonus": user_data.bonus,
- "upload": user_data.upload,
- "upload_gb": round(upload_gb, 2),
- "download": user_data.download,
- "download_gb": round(download_gb, 2),
- "ratio": round(user_data.ratio, 2) if user_data.ratio else 0,
- "seeding": int(user_data.seeding) if user_data.seeding else 0,
- "leeching": int(user_data.leeching)
- if user_data.leeching
- else 0,
- "seeding_size": user_data.seeding_size,
- "seeding_size_gb": round(seeding_size_gb, 2),
- "leeching_size": user_data.leeching_size,
- "leeching_size_gb": round(leeching_size_gb, 2),
- "seeding_info_count": seeding_count,
- "seeding_info": seeding_preview,
- "seeding_info_truncated": seeding_truncated,
- "message_unread": user_data.message_unread,
- "message_unread_contents_count": unread_count,
- "message_unread_contents": unread_preview,
- "message_unread_contents_truncated": unread_truncated,
- "err_msg": user_data.err_msg,
- "updated_day": user_data.updated_day,
- "updated_time": user_data.updated_time,
- }
- formatted_user_data.append(user_data_dict)
-
- # 如果有多条数据,只返回最新的(按更新时间排序)
- message: Optional[str] = None
- if len(formatted_user_data) > 1:
- formatted_user_data.sort(
- key=lambda item: (
- _sort_text(item.get("updated_day")),
- _sort_text(item.get("updated_time")),
- ),
- reverse=True,
- )
- message = f"找到 {len(formatted_user_data)} 条数据,显示最新的一条"
- formatted_user_data = [formatted_user_data[0]]
-
- result: dict[str, JsonData] = {
- "success": True,
- "site_id": site_id,
- "site_name": site.name,
- "site_domain": site_domain,
- "workdate": workdate,
- "data_count": len(user_data_list),
- "user_data": formatted_user_data,
- }
- if message:
- result["message"] = message
-
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- except Exception as e:
- error_message = f"查询站点用户数据失败: {str(e)}"
- logger.error(f"查询站点用户数据失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": error_message, "site_id": site_id},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_sites.py b/app/agent/tools/impl/query_sites.py
deleted file mode 100644
index 7136c985b..000000000
--- a/app/agent/tools/impl/query_sites.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""查询站点工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class QuerySitesInput(BaseModel):
- """查询站点工具的输入参数模型"""
-
- status: Optional[str] = Field(
- "all",
- description="Filter sites by status: 'active' for enabled sites, 'inactive' for disabled sites, 'all' for all sites",
- )
- name: Optional[str] = Field(
- None, description="Filter sites by name (partial match, optional)"
- )
-
-
-class QuerySitesTool(MoviePilotTool):
- name: str = "query_sites"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Site,
- ]
- description: str = (
- "Query site status and list configured sites. Non-admin users receive a safe view "
- "that omits sensitive fields: cookie, token, API key and RSS URL. "
- "Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
- )
- args_schema: Type[BaseModel] = QuerySitesInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- status = kwargs.get("status", "all")
- name = kwargs.get("name")
-
- parts = ["查询站点"]
-
- if status != "all":
- status_map = {"active": "已启用", "inactive": "已禁用"}
- parts.append(f"状态: {status_map.get(status, status)}")
-
- if name:
- parts.append(f"名称: {name}")
-
- return " | ".join(parts) if len(parts) > 1 else parts[0]
-
- async def run(
- self, status: Optional[str] = "all", name: Optional[str] = None, **kwargs
- ) -> str:
- logger.info(f"执行工具: {self.name}, 参数: status={status}, name={name}")
- try:
- is_admin = await self.is_admin_user()
- repository = self.data.sites
- # 获取所有站点(按优先级排序)
- sites = await repository.async_list()
- filtered_sites = []
- for site in sites:
- # 按状态过滤
- if status == "active" and not site.is_active:
- continue
- if status == "inactive" and site.is_active:
- continue
- # 按名称过滤(部分匹配)
- if name and name.lower() not in (site.name or "").lower():
- continue
- filtered_sites.append(site)
- if filtered_sites:
- # 精简字段,只保留关键信息
- simplified_sites = []
- for s in filtered_sites:
- simplified = {
- "id": s.id,
- "name": s.name,
- "domain": s.domain,
- "url": s.url,
- "pri": s.pri,
- "is_active": s.is_active,
- "downloader": s.downloader,
- "ua": s.ua,
- "proxy": s.proxy,
- "filter": s.filter,
- "render": s.render,
- "public": s.public,
- "note": s.note,
- "limit_interval": s.limit_interval,
- "limit_count": s.limit_count,
- "limit_seconds": s.limit_seconds,
- "timeout": s.timeout,
- }
- if is_admin:
- simplified.update({
- "rss": s.rss,
- "cookie": s.cookie,
- "apikey": s.apikey,
- "token": s.token,
- })
- simplified_sites.append(simplified)
- result_json = json.dumps(simplified_sites, ensure_ascii=False, indent=2)
- return result_json
- return "未找到相关站点"
- except Exception as e:
- logger.error(f"查询站点失败: {e}", exc_info=True)
- return f"查询站点时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_subscribe_history.py b/app/agent/tools/impl/query_subscribe_history.py
deleted file mode 100644
index 2d5886a81..000000000
--- a/app/agent/tools/impl/query_subscribe_history.py
+++ /dev/null
@@ -1,231 +0,0 @@
-"""查询订阅历史工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.domain.media import normalize_music_type
-from app.runtime.log import logger
-from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent
-
-PAGE_SIZE = 20
-
-
-class QuerySubscribeHistoryInput(BaseModel):
- """查询订阅历史工具的输入参数模型"""
-
- media_type: Optional[str] = Field(
- "all", description="Allowed values: movie, tv, music, all"
- )
- music_type: Optional[str] = Field(
- None,
- description="Optional music history filter: recording or album",
- )
- name: Optional[str] = Field(
- None, description="Filter by media name (partial match, optional)"
- )
- page: Optional[int] = Field(
- 1,
- description="Page number for pagination (default: 1, 20 items per page). Ignored when name filter is provided.",
- )
-
-
-class QuerySubscribeHistoryTool(MoviePilotTool):
- """查询已完成的影视、单曲与整张专辑订阅历史。"""
-
- name: str = "query_subscribe_history"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Subscription,
- ]
- description: str = "Query subscription history records. Shows completed subscriptions with their details including name, type, rating, completion date, and other subscription information. Supports filtering by media type and name. Supports pagination with 20 records per page."
- args_schema: Type[BaseModel] = QuerySubscribeHistoryInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- media_type = kwargs.get("media_type", "all")
- name = kwargs.get("name")
- page = kwargs.get("page", 1)
-
- parts = ["查询订阅历史"]
-
- if media_type != "all":
- parts.append(f"类型: {media_type}")
- if name:
- parts.append(f"名称: {name}")
- else:
- parts.append(f"第{page}页")
-
- return " | ".join(parts)
-
- async def run(
- self,
- media_type: Optional[str] = "all",
- music_type: Optional[str] = None,
- name: Optional[str] = None,
- page: Optional[int] = 1,
- **kwargs,
- ) -> str:
- """按规范化数据库类型查询并合并订阅历史。"""
- page = max(1, page or 1)
- logger.info(
- f"执行工具: {self.name}, 参数: media_type={media_type}, name={name}, page={page}"
- )
-
- try:
- if media_type == "all":
- requested_types = [
- MediaType.MOVIE.value,
- MediaType.TV.value,
- MediaType.MUSIC.value,
- ]
- else:
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return (
- f"错误:无效的媒体类型 '{media_type}',"
- "支持的类型:'movie', 'tv', 'music', 'all'"
- )
- requested_types = [media_type_enum.value]
-
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(
- music_type,
- allow_artist=False,
- )
- if not normalized_music_type:
- return (
- f"错误:无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album'"
- )
- if MediaType.MUSIC.value not in requested_types:
- return "错误:music_type 仅能与 media_type='music' 或 'all' 一起使用"
-
- repository = self.data.subscription_history
- if name:
- # 有名称过滤时,获取足够多的记录在内存中过滤,不分页
- fetch_count = 500
- history_groups = [
- await repository.async_list_by_type(
- mtype=requested_type,
- page=1,
- count=fetch_count,
- )
- for requested_type in requested_types
- ]
- all_history = [
- record for group in history_groups for record in group
- ]
- all_history.sort(key=lambda x: x.date or "", reverse=True)
-
- # 按名称过滤
- name_lower = name.lower()
- filtered_history = [
- record
- for record in all_history
- if record.name and name_lower in record.name.lower()
- and self._matches_music_type(record, normalized_music_type)
- ]
-
- if not filtered_history:
- return "未找到相关订阅历史记录"
-
- # 名称过滤时直接返回所有匹配结果,不分页
- simplified_records = self._simplify_records(filtered_history)
- result_json = json.dumps(
- simplified_records, ensure_ascii=False, indent=2
- )
- return result_json
- else:
- # 无名称过滤时,直接利用数据库分页
- history_groups = [
- await repository.async_list_by_type(
- mtype=requested_type,
- page=1,
- count=page * PAGE_SIZE,
- )
- for requested_type in requested_types
- ]
- all_history = [
- record for group in history_groups for record in group
- ]
- all_history.sort(key=lambda x: x.date or "", reverse=True)
- filtered_history = [
- record
- for record in all_history
- if self._matches_music_type(record, normalized_music_type)
- ]
-
- if not filtered_history:
- return "未找到相关订阅历史记录"
-
- # 分页切片
- total_count = len(filtered_history)
- start = (page - 1) * PAGE_SIZE
- end = start + PAGE_SIZE
- page_records = filtered_history[start:end]
-
- if not page_records:
- return f"第 {page} 页没有数据。"
-
- simplified_records = self._simplify_records(page_records)
- result_json = json.dumps(
- simplified_records, ensure_ascii=False, indent=2
- )
-
- has_more = total_count > end
- payload_msg = f"第 {page} 页,当前页 {len(simplified_records)} 条结果。"
- if has_more:
- payload_msg += (
- f" 可能有更多数据,可使用 page={page + 1} 获取下一页。"
- )
-
- return f"{payload_msg}\n\n{result_json}"
- except Exception as e:
- logger.error(f"查询订阅历史失败: {e}", exc_info=True)
- return f"查询订阅历史时发生错误: {str(e)}"
-
- @staticmethod
- def _matches_music_type(record, music_type: Optional[str]) -> bool:
- """匹配音乐实体类型,旧空值历史按单曲兼容。"""
- if not music_type:
- return True
- if media_type_to_agent(getattr(record, "type", None)) != "music":
- return False
- return (getattr(record, "music_type", None) or MUSIC_ENTITY_RECORDING) == music_type
-
- @staticmethod
- def _simplify_records(records) -> list:
- """转换为字典格式,只保留关键信息"""
- simplified_records = []
- for record in records:
- simplified = {
- "id": record.id,
- "name": record.name,
- "year": record.year,
- "type": media_type_to_agent(record.type),
- "season": record.season,
- "media_source": record.media_source,
- "media_id": record.media_id,
- "music_type": getattr(record, "music_type", None) or (
- MUSIC_ENTITY_RECORDING if media_type_to_agent(record.type) == "music" else None
- ),
- "total_tracks": getattr(record, "total_tracks", None),
- "poster": record.poster,
- "vote": record.vote,
- "total_episode": record.total_episode,
- "date": record.date,
- "username": record.username,
- }
- if record.filter:
- simplified["filter"] = record.filter
- if record.quality:
- simplified["quality"] = record.quality
- if record.resolution:
- simplified["resolution"] = record.resolution
- simplified_records.append(simplified)
- return simplified_records
diff --git a/app/agent/tools/impl/query_subscribe_shares.py b/app/agent/tools/impl/query_subscribe_shares.py
deleted file mode 100644
index 39eb96ad9..000000000
--- a/app/agent/tools/impl/query_subscribe_shares.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""查询订阅分享工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.adapters.external.server import MoviePilotServerHelper
-from app.runtime.log import logger
-from app.schemas.types import MUSIC_ENTITY_RECORDING, media_type_to_agent
-from app.domain.media import normalize_music_type
-
-MAX_PAGE_SIZE = 50
-
-
-class QuerySubscribeSharesInput(BaseModel):
- """查询订阅分享工具的输入参数模型"""
- name: Optional[str] = Field(None, description="Filter shares by media name (partial match, optional)")
- page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
- count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
- genre_id: Optional[int] = Field(None, description="Filter by genre ID (optional)")
- min_rating: Optional[float] = Field(None, description="Minimum rating filter (optional, e.g., 7.5)")
- max_rating: Optional[float] = Field(None, description="Maximum rating filter (optional, e.g., 10.0)")
- sort_type: Optional[str] = Field(None, description="Sort type (optional, e.g., 'count', 'rating')")
-
-
-class QuerySubscribeSharesTool(MoviePilotTool):
- """查询社区共享的影视、单曲与专辑订阅。"""
-
- name: str = "query_subscribe_shares"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Subscription,
- ]
- description: str = "Query shared subscriptions from other users. Shows popular subscriptions shared by the community with filtering and pagination support."
- args_schema: Type[BaseModel] = QuerySubscribeSharesInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- name = kwargs.get("name")
- page = kwargs.get("page", 1)
- min_rating = kwargs.get("min_rating")
- max_rating = kwargs.get("max_rating")
-
- parts = ["查询订阅分享"]
-
- if name:
- parts.append(f"名称: {name}")
- if min_rating:
- parts.append(f"最低评分: {min_rating}")
- if max_rating:
- parts.append(f"最高评分: {max_rating}")
- if page > 1:
- parts.append(f"第{page}页")
-
- return " | ".join(parts) if len(parts) > 1 else parts[0]
-
- async def run(self, name: Optional[str] = None,
- page: Optional[int] = 1,
- count: Optional[int] = 30,
- genre_id: Optional[int] = None,
- min_rating: Optional[float] = None,
- max_rating: Optional[float] = None,
- sort_type: Optional[str] = None, **kwargs) -> str:
- """查询订阅分享并保留音乐实体及整专曲目字段。"""
- logger.info(
- f"执行工具: {self.name}, 参数: name={name}, page={page}, count={count}, genre_id={genre_id}, "
- f"min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}")
-
- try:
- if page is None or page < 1:
- page = 1
- if count is None or count < 1:
- count = 30
- # 订阅分享是外部列表型结果,限制单页大小能降低工具上下文占用。
- count = min(count, MAX_PAGE_SIZE)
-
- shares = await MoviePilotServerHelper.async_get_subscribe_shares(
- name=name,
- page=page,
- count=count,
- genre_id=genre_id,
- min_rating=min_rating,
- max_rating=max_rating,
- sort_type=sort_type
- )
-
- if not shares:
- return "未找到订阅分享数据(可能订阅分享功能未启用)"
-
- # 简化字段,只保留关键信息
- simplified_shares = []
- for share in shares:
- normalized_type = media_type_to_agent(share.get("type"))
- normalized_music_type = None
- if normalized_type == "music":
- normalized_music_type = normalize_music_type(
- share.get("music_type") or MUSIC_ENTITY_RECORDING,
- allow_artist=False,
- )
- if not normalized_music_type:
- logger.warning(
- f"跳过未知音乐订阅分享实体: {share.get('music_type')}"
- )
- continue
- simplified = {
- "id": share.get("id"),
- "name": share.get("name"),
- "year": share.get("year"),
- "type": normalized_type,
- "season": share.get("season"),
- "media_source": share.get("media_source"),
- "media_id": share.get("media_id"),
- "music_type": normalized_music_type,
- "total_tracks": share.get("total_tracks"),
- "description": share.get("description"),
- "poster": share.get("poster"),
- "vote": share.get("vote"),
- "share_title": share.get("share_title"),
- "share_comment": share.get("share_comment"),
- "share_user": share.get("share_user"),
- "fork_count": share.get("fork_count", 0)
- }
- # 截断过长的描述
- if simplified.get("description") and len(simplified["description"]) > 200:
- simplified["description"] = simplified["description"][:200] + "..."
- simplified_shares.append(simplified)
-
- result_json = json.dumps(simplified_shares, ensure_ascii=False, indent=2)
-
- pagination_info = f"第 {page} 页,每页 {count} 条,共 {len(simplified_shares)} 条结果"
-
- return f"{pagination_info}\n\n{result_json}"
- except Exception as e:
- logger.error(f"查询订阅分享失败: {e}", exc_info=True)
- return f"查询订阅分享时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_subscribes.py b/app/agent/tools/impl/query_subscribes.py
deleted file mode 100644
index 130052ceb..000000000
--- a/app/agent/tools/impl/query_subscribes.py
+++ /dev/null
@@ -1,215 +0,0 @@
-"""查询订阅工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.domain.media import normalize_music_type
-from app.runtime.log import logger
-from app.schemas.subscribe import Subscribe as SubscribeSchema
-from app.schemas.types import (
- MUSIC_ENTITY_RECORDING,
- MediaSource,
- MediaType,
- media_type_to_agent,
-)
-
-PAGE_SIZE = 100
-
-QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
- "id",
- "name",
- "year",
- "type",
- "media_source",
- "media_id",
- "music_type",
- "total_tracks",
- "season",
- "total_episode",
- "start_episode",
- "lack_episode",
- "filter",
- "include",
- "exclude",
- "quality",
- "resolution",
- "effect",
- "audio_quality",
- "audio_format",
- "min_bitrate",
- "min_bit_depth",
- "min_sample_rate",
- "state",
- "last_update",
- "sites",
- "downloader",
- "best_version",
- "best_version_full",
- "current_priority",
- "current_audio_format",
- "current_bitrate",
- "current_bit_depth",
- "current_sample_rate",
- "episode_priority",
- "save_path",
- "custom_words",
- "media_category",
- "filter_groups",
- "episode_group",
- "poster",
- "backdrop",
- "description",
- "username",
-]
-
-
-class QuerySubscribesInput(BaseModel):
- """查询订阅工具的输入参数模型"""
-
- status: Optional[str] = Field(
- "all",
- description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",
- )
- media_type: Optional[str] = Field(
- "all", description="Allowed values: movie, tv, music, all"
- )
- music_type: Optional[str] = Field(
- None,
- description="Optional music subscription filter: recording or album",
- )
- media_source: Optional[MediaSource] = Field(
- None, description="Filter by media source"
- )
- media_id: Optional[str] = Field(None, description="Filter by source-native media ID")
- page: Optional[int] = Field(
- 1, description="Page number for pagination (default: 1, 100 items per page)"
- )
-
-
-class QuerySubscribesTool(MoviePilotTool):
- """查询电影、电视剧、单曲与专辑订阅。"""
-
- name: str = "query_subscribes"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Subscription,
- ]
- description: str = "Query subscription status and list user subscriptions. Returns full subscription parameters for each matched subscription. Supports pagination with 100 items per page."
- args_schema: Type[BaseModel] = QuerySubscribesInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- status = kwargs.get("status", "all")
- media_type = kwargs.get("media_type", "all")
- page = kwargs.get("page", 1)
-
- parts = ["查询订阅"]
-
- # 根据状态过滤条件生成提示
- if status != "all":
- status_map = {"R": "已启用", "S": "已暂停"}
- parts.append(f"状态: {status_map.get(status, status)}")
-
- # 根据媒体类型过滤条件生成提示
- if media_type != "all":
- parts.append(f"类型: {media_type}")
-
- parts.append(f"第{page}页")
-
- return " | ".join(parts)
-
- async def run(
- self,
- status: Optional[str] = "all",
- media_type: Optional[str] = "all",
- music_type: Optional[str] = None,
- media_source: Optional[MediaSource] = None,
- media_id: Optional[str] = None,
- page: Optional[int] = 1,
- **kwargs,
- ) -> str:
- """按状态、媒体身份及音乐实体类型筛选订阅。"""
- page = max(1, page or 1)
- logger.info(
- f"执行工具: {self.name}, 参数: status={status}, "
- f"media_type={media_type}, media_source={media_source}, "
- f"media_id={media_id}, page={page}"
- )
- try:
- if media_type != "all" and not MediaType.from_agent(media_type):
- return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music', 'all'"
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(
- music_type,
- allow_artist=False,
- )
- if not normalized_music_type:
- return (
- f"错误:无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album'"
- )
- if media_type not in ("all", "music"):
- return "错误:music_type 仅能与 media_type='music' 或 'all' 一起使用"
-
- repository = self.data.subscriptions
- subscribes = await repository.async_list()
- filtered_subscribes = []
- for sub in subscribes:
- if status != "all" and sub.state != status:
- continue
- if (
- media_type != "all"
- and sub.type != MediaType.from_agent(media_type).value
- ):
- continue
- if media_source is not None and sub.media_source != str(media_source):
- continue
- if media_id is not None and sub.media_id != media_id:
- continue
- if normalized_music_type:
- sub_music_type = sub.music_type or MUSIC_ENTITY_RECORDING
- if sub_music_type != normalized_music_type:
- continue
- filtered_subscribes.append(sub)
- if filtered_subscribes:
- total_count = len(filtered_subscribes)
- # 分页
- start = (page - 1) * PAGE_SIZE
- end = start + PAGE_SIZE
- page_subscribes = filtered_subscribes[start:end]
-
- if not page_subscribes:
- total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
- return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {total_pages} 页。"
-
- full_subscribes = []
- for subscribe in page_subscribes:
- payload = SubscribeSchema.model_validate(
- subscribe,
- from_attributes=True,
- ).model_dump(
- include=set(QUERY_SUBSCRIBE_OUTPUT_FIELDS), exclude_none=True
- )
- # 手动总集数是运行锁状态,不属于公共订阅写入 Schema,查询时直接从实体读取。
- payload["manual_total_episode"] = subscribe.manual_total_episode or 0
- payload["type"] = media_type_to_agent(payload.get("type"))
- if payload["type"] == "music" and not payload.get("music_type"):
- payload["music_type"] = MUSIC_ENTITY_RECORDING
- full_subscribes.append(payload)
- result_json = json.dumps(full_subscribes, ensure_ascii=False, indent=2)
-
- total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
- payload_msg = f"第 {page}/{total_pages} 页,当前页 {len(page_subscribes)} 条结果,共 {total_count} 条。"
- if page < total_pages:
- payload_msg += f" 可使用 page={page + 1} 获取下一页。"
-
- return f"{payload_msg}\n\n{result_json}"
- return "未找到相关订阅"
- except Exception as e:
- logger.error(f"查询订阅失败: {e}", exc_info=True)
- return f"查询订阅时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_system_settings.py b/app/agent/tools/impl/query_system_settings.py
deleted file mode 100644
index 37fb64c5f..000000000
--- a/app/agent/tools/impl/query_system_settings.py
+++ /dev/null
@@ -1,259 +0,0 @@
-"""统一查询系统设置工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field, PrivateAttr
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._system_setting_utils import (
- SettingSpec,
- is_secret_setting_key,
- list_setting_specs,
- redact_secret_value,
- resolve_setting_spec,
- should_redact_setting,
-)
-from app.application.configuration import (
- SystemConfigReader,
- get_configured_system_config,
- get_runtime_settings,
-)
-from app.runtime.log import logger
-
-
-class QuerySystemSettingsInput(BaseModel):
- """查询系统设置工具的输入参数模型。"""
-
- setting_key: Optional[str] = Field(
- None,
- description=(
- "Exact setting key to query. Supports Settings field names like 'APP_DOMAIN' or 'TMDB_API_KEY', "
- "SystemConfigKey values like 'Downloaders' or 'MediaServers', enum names, and some single-key aliases "
- "such as 'downloaders', 'directories', 'search_sites', 'subscribe_sites', 'site_auth', "
- "and 'custom_identifiers'."
- ),
- )
- group: Optional[str] = Field(
- "all",
- description=(
- "Optional group filter when setting_key is not provided. Supports 'all', 'settings', 'systemconfig', "
- "and category aliases such as 'downloaders', 'media_servers', 'notifications', 'notification_switches', "
- "'storages', 'directories', 'search_sites', 'subscribe_sites', 'site_auth', 'ai_agent', 'filter_rules', "
- "'subscribe_defaults', 'plugins', and 'custom_identifiers'. Chinese aliases are also accepted."
- ),
- )
- keyword: Optional[str] = Field(
- None,
- description=(
- "Optional keyword used to fuzzy match setting keys, group names, or labels when listing settings."
- ),
- )
- include_values: Optional[bool] = Field(
- None,
- description=(
- "Whether to include full setting values. Default behavior: when a single setting is matched it returns the full value; "
- "when multiple settings are matched it returns summaries only unless this is explicitly set to true."
- ),
- )
- show_secrets: Optional[bool] = Field(
- False,
- description=(
- "Whether to return raw secret values such as API keys, tokens, cookies, and passwords. "
- "Defaults to false; secret-like fields are redacted in returned values and previews. "
- "Set this to true when the user explicitly asks for an unredacted secret; the host verifies "
- "administrator authority, requests confirmation, and delivers the result outside the ordinary "
- "model response. Do not refuse the tool call solely because the requested value is sensitive."
- ),
- )
-
-
-class QuerySystemSettingsTool(MoviePilotTool):
- """查询系统设置,并隔离 Agent 确认后的密钥读取入口。"""
-
- name: str = "query_system_settings"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.System,
- ToolTag.Settings,
- ToolTag.Admin,
- ]
- description: str = (
- "Query system settings across both the basic Settings module and all SystemConfig-backed categories. "
- "Use this tool to inspect downloaders, media servers, notification channels, storages, directories, search-site ranges, "
- "subscribe-site ranges, site auth params, AI agent config, and any other system setting before making changes."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = QuerySystemSettingsInput
- _secret_read_confirmed: bool = PrivateAttr(default=False)
- _system_config: Optional[SystemConfigReader] = PrivateAttr(default=None)
-
- def __init__(
- self,
- session_id: str,
- user_id: str,
- *,
- system_config: Optional[SystemConfigReader] = None,
- **kwargs,
- ) -> None:
- """注入授权范围内的配置读取端口,并兼容组合根默认服务。"""
- super().__init__(session_id=session_id, user_id=user_id, **kwargs)
- self._system_config = system_config
-
- def _get_system_config(self) -> SystemConfigReader:
- """返回显式注入端口,旧构造形态则延迟读取组合根服务。"""
- return self._system_config or get_configured_system_config()
-
- async def _run_confirmed(self, **kwargs) -> str:
- """仅供宿主在消费有效确认后执行一次未脱敏读取。"""
- self._secret_read_confirmed = True
- try:
- return await self.run(**kwargs)
- finally:
- self._secret_read_confirmed = False
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息。"""
-
- setting_key = kwargs.get("setting_key")
- group = kwargs.get("group", "all")
- keyword = kwargs.get("keyword")
- if setting_key:
- return f"查询系统设置: {setting_key}"
- if keyword:
- return f"筛选系统设置: {group} / {keyword}"
- return f"查询系统设置分组: {group}"
-
- def _load_setting_value(self, spec: SettingSpec):
- """读取指定设置项的当前值。"""
- if spec.source == "settings":
- return get_runtime_settings().get(spec.key)
- return self._get_system_config().get(spec.systemconfig_key)
-
- @staticmethod
- def _summarize_value(value, *, redacted: bool = False) -> dict:
- """生成设置值摘要,避免列表和字典默认输出过长。"""
- summary = {
- "has_value": value is not None,
- "value_type": type(value).__name__,
- "redacted": redacted,
- }
- if isinstance(value, list):
- summary["item_count"] = len(value)
- if value:
- summary["item_type"] = type(value[0]).__name__
- elif isinstance(value, dict):
- keys = list(value.keys())
- summary["item_count"] = len(keys)
- summary["keys_preview"] = keys[:10]
- if len(keys) > 10:
- summary["keys_truncated"] = True
- elif isinstance(value, str):
- summary["length"] = len(value)
- preview = value[:200]
- if preview:
- summary["value_preview"] = preview
- if len(value) > len(preview):
- summary["value_truncated"] = True
- elif value is not None:
- summary["value_preview"] = value
- return summary
-
- async def run(
- self,
- setting_key: Optional[str] = None,
- group: Optional[str] = "all",
- keyword: Optional[str] = None,
- include_values: Optional[bool] = None,
- show_secrets: Optional[bool] = False,
- **kwargs,
- ) -> str:
- """查询系统设置,并在 Agent 场景中阻止未经确认的密钥明文读取。"""
- logger.info(
- f"执行工具: {self.name}, setting_key={setting_key}, "
- f"group={group}, keyword={keyword}"
- )
-
- try:
- if (
- show_secrets is True
- and self._agent_context.get("require_secret_confirmation")
- and not self._secret_read_confirmed
- ):
- return json.dumps(
- {
- "success": False,
- "message": "读取敏感设置前需要用户确认,本次未执行。",
- },
- ensure_ascii=False,
- )
- if setting_key:
- spec = resolve_setting_spec(setting_key)
- if not spec:
- return json.dumps(
- {
- "success": False,
- "message": f"系统设置项 '{setting_key}' 不存在",
- },
- ensure_ascii=False,
- )
- specs = [spec]
- else:
- specs = list_setting_specs(group=group, keyword=keyword)
- if not specs:
- return json.dumps(
- {
- "success": False,
- "message": "没有找到匹配的系统设置项",
- },
- ensure_ascii=False,
- )
-
- should_include_values = (
- include_values if include_values is not None else len(specs) == 1
- )
- allow_secret_values = bool(show_secrets) and await self.is_admin_user()
- settings_payload = []
- for spec in specs:
- value = self._load_setting_value(spec)
- should_redact = (
- should_redact_setting(spec, value) and not allow_secret_values
- )
- response_value = (
- redact_secret_value(
- value,
- redact_scalar=is_secret_setting_key(spec.key),
- )
- if should_redact
- else value
- )
- item = {
- "setting_key": spec.key,
- "source": spec.source,
- "group": spec.group,
- "label": spec.label,
- }
- item.update(self._summarize_value(response_value, redacted=should_redact))
- if should_include_values:
- item["value"] = response_value
- settings_payload.append(item)
-
- return json.dumps(
- {
- "success": True,
- "matched_count": len(settings_payload),
- "include_values": should_include_values,
- "show_secrets": allow_secret_values,
- "settings": settings_payload,
- },
- ensure_ascii=False,
- indent=2,
- default=str,
- )
- except Exception as e:
- logger.error(f"查询系统设置失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"查询系统设置时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/query_transfer_history.py b/app/agent/tools/impl/query_transfer_history.py
deleted file mode 100644
index 38951b448..000000000
--- a/app/agent/tools/impl/query_transfer_history.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""查询整理历史记录工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.foundation.text import cut as jieba_cut
-from app.runtime.log import logger
-from app.schemas.types import media_type_to_agent
-
-
-class QueryTransferHistoryInput(BaseModel):
- """查询整理历史记录工具的输入参数模型"""
- title: Optional[str] = Field(None, description="Search by title (optional, supports partial match)")
- status: Optional[str] = Field("all",
- description="Filter by status: 'success' for successful transfers, 'failed' for failed transfers, 'all' for all records (default: 'all')")
- page: Optional[int] = Field(1, description="Page number for pagination (default: 1, each page contains 30 records)")
-
-
-class QueryTransferHistoryTool(MoviePilotTool):
- """查询影视与音乐整理历史及可复用的媒体身份。"""
-
- name: str = "query_transfer_history"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Transfer,
- ]
- description: str = (
- "Query movie, TV, and music transfer history. Returns source/destination paths, status, errors, and "
- "stable media_source/media_id values that can be reused for retries. Music album retries should group "
- "tracks by the shared album directory/identity instead of treating every track as unrelated media."
- )
- args_schema: Type[BaseModel] = QueryTransferHistoryInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- title = kwargs.get("title")
- status = kwargs.get("status", "all")
- page = kwargs.get("page", 1)
-
- parts = ["查询整理历史"]
-
- if title:
- parts.append(f"标题: {title}")
- if status != "all":
- status_map = {"success": "成功", "failed": "失败"}
- parts.append(f"状态: {status_map.get(status, status)}")
- if page > 1:
- parts.append(f"第{page}页")
-
- return " | ".join(parts) if len(parts) > 1 else parts[0]
-
- async def run(self, title: Optional[str] = None,
- status: Optional[str] = "all",
- page: Optional[int] = 1, **kwargs) -> str:
- """执行整理历史筛选并返回有界分页结果。"""
- logger.info(f"执行工具: {self.name}, 参数: title={title}, status={status}, page={page}")
-
- try:
- # 处理状态参数
- status_bool = None
- if status == "success":
- status_bool = True
- elif status == "failed":
- status_bool = False
-
- # 处理页码参数
- if page is None or page < 1:
- page = 1
-
- # 每页固定 30 条,与工具说明保持一致,避免整理路径等字段撑大上下文。
- count = 30
-
- transferhis = self.data.transfer_history
- # 处理标题搜索
- if title:
- # 使用统一分词封装处理标题,便于替换底层实现。
- words = jieba_cut(title, HMM=False)
- title_search = "%".join(words)
- # 查询记录
- result = await transferhis.async_list_by_title(
- title=title_search, page=page, count=count, status=status_bool
- )
- total = await transferhis.async_count_by_title(
- title=title_search, status=status_bool
- )
- else:
- # 查询所有记录
- result = await transferhis.async_list_by_page(
- page=page, count=count, status=status_bool
- )
- total = await transferhis.async_count(status=status_bool)
-
- if not result:
- return "未找到相关整理历史记录"
-
- # 转换为字典格式,只保留关键信息
- simplified_records = []
- for record in result:
- simplified = {
- "id": record.id,
- "title": record.title,
- "year": record.year,
- "type": media_type_to_agent(record.type),
- "category": record.category,
- "seasons": record.seasons,
- "episodes": record.episodes,
- "src": record.src,
- "dest": record.dest,
- "mode": record.mode,
- "status": "成功" if record.status else "失败",
- "date": record.date,
- "downloader": record.downloader,
- "download_hash": record.download_hash
- }
- # 如果失败,添加错误信息
- if not record.status and record.errmsg:
- simplified["errmsg"] = record.errmsg
- if record.media_source:
- simplified["media_source"] = record.media_source
- if record.media_id:
- simplified["media_id"] = record.media_id
- if getattr(record, "music_type", None):
- simplified["music_type"] = record.music_type
- if getattr(record, "total_tracks", None):
- simplified["total_tracks"] = record.total_tracks
- simplified_records.append(simplified)
-
- result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
-
- # 计算总页数
- total_pages = (total + count - 1) // count if total > 0 else 1
-
- # 构建分页信息
- pagination_info = f"第 {page}/{total_pages} 页,共 {total} 条记录(每页 {count} 条)"
-
- return f"{pagination_info}\n\n{result_json}"
- except Exception as e:
- logger.error(f"查询整理历史记录失败: {e}", exc_info=True)
- return f"查询整理历史记录时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/query_workflows.py b/app/agent/tools/impl/query_workflows.py
deleted file mode 100644
index 00d7f3412..000000000
--- a/app/agent/tools/impl/query_workflows.py
+++ /dev/null
@@ -1,129 +0,0 @@
-"""查询工作流工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.workflow import get_configured_workflow_query
-from app.runtime.log import logger
-
-
-class QueryWorkflowsInput(BaseModel):
- """查询工作流工具的输入参数模型"""
- state: Optional[str] = Field("all", description="Filter workflows by state: 'W' for waiting, 'R' for running, 'P' for paused, 'S' for success, 'F' for failed, 'all' for all workflows (default: 'all')")
- name: Optional[str] = Field(None, description="Filter workflows by name (partial match, optional)")
- trigger_type: Optional[str] = Field("all", description="Filter workflows by trigger type: 'timer' for scheduled, 'event' for event-triggered, 'manual' for manual, 'all' for all types (default: 'all')")
-
-
-class QueryWorkflowsTool(MoviePilotTool):
- name: str = "query_workflows"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Workflow,
- ]
- description: str = "Query workflow list and status. Shows workflow name, description, trigger type, state, execution count, and other workflow details. Supports filtering by state, name, and trigger type."
- args_schema: Type[BaseModel] = QueryWorkflowsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据查询参数生成友好的提示消息"""
- state = kwargs.get("state", "all")
- name = kwargs.get("name")
- trigger_type = kwargs.get("trigger_type", "all")
-
- parts = ["查询工作流"]
-
- if state != "all":
- state_map = {"W": "等待", "R": "运行中", "P": "暂停", "S": "成功", "F": "失败"}
- parts.append(f"状态: {state_map.get(state, state)}")
-
- if trigger_type != "all":
- trigger_map = {"timer": "定时触发", "event": "事件触发", "manual": "手动触发"}
- parts.append(f"触发类型: {trigger_map.get(trigger_type, trigger_type)}")
-
- if name:
- parts.append(f"名称: {name}")
-
- return " | ".join(parts) if len(parts) > 1 else parts[0]
-
- async def run(self, state: Optional[str] = "all",
- name: Optional[str] = None,
- trigger_type: Optional[str] = "all", **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: state={state}, name={name}, trigger_type={trigger_type}")
-
- try:
- workflows = await get_configured_workflow_query().list()
-
- # 过滤工作流
- filtered_workflows = []
- for wf in workflows:
- # 按状态过滤
- if state != "all" and wf.state != state:
- continue
-
- # 按触发类型过滤
- if trigger_type != "all":
- if trigger_type == "timer" and wf.trigger_type not in ["timer", None]:
- continue
- elif trigger_type == "event" and wf.trigger_type != "event":
- continue
- elif trigger_type == "manual" and wf.trigger_type != "manual":
- continue
-
- # 按名称过滤(部分匹配)
- if name and wf.name and name.lower() not in wf.name.lower():
- continue
-
- filtered_workflows.append(wf)
-
- if not filtered_workflows:
- return "未找到相关工作流"
-
- # 转换为字典格式,只保留关键信息
- simplified_workflows = []
- for wf in filtered_workflows:
- # 状态说明
- state_map = {
- "W": "等待",
- "R": "运行中",
- "P": "暂停",
- "S": "成功",
- "F": "失败"
- }
- state_desc = state_map.get(wf.state, wf.state)
-
- # 触发类型说明
- trigger_type_map = {
- "timer": "定时触发",
- "event": "事件触发",
- "manual": "手动触发"
- }
- trigger_type_key = wf.trigger_type or "timer"
- trigger_type_desc = trigger_type_map.get(
- trigger_type_key,
- trigger_type_key,
- )
-
- simplified = {
- "id": wf.id,
- "name": wf.name,
- "description": wf.description,
- "trigger_type": trigger_type_desc,
- "state": state_desc,
- "run_count": wf.run_count,
- "timer": wf.timer,
- "event_type": wf.event_type,
- "add_time": wf.add_time,
- "last_time": wf.last_time,
- "current_action": wf.current_action
- }
- # wf.result 往往是执行日志或上下文快照,不适合作为列表查询结果返回。
- simplified_workflows.append(simplified)
-
- result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2)
- return result_json
- except Exception as e:
- logger.error(f"查询工作流失败: {e}", exc_info=True)
- return f"查询工作流时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/recognize_media.py b/app/agent/tools/impl/recognize_media.py
deleted file mode 100644
index ebc7af300..000000000
--- a/app/agent/tools/impl/recognize_media.py
+++ /dev/null
@@ -1,282 +0,0 @@
-"""识别媒体信息工具"""
-
-import json
-from pathlib import Path
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.media import MediaChain
-from app.runtime.settings import get_runtime_setting
-
-from app.domain.context import Context
-from app.domain.meta.metamusic import MetaMusic
-from app.domain.metainfo import MetaInfo
-from app.runtime.log import logger
-from app.schemas.types import MediaType, media_type_to_agent
-from ._music_utils import simplify_music_info
-
-
-class RecognizeMediaInput(BaseModel):
- """识别媒体信息工具的输入参数模型"""
- title: Optional[str] = Field(None, description="The title of the torrent/media to recognize (required for torrent recognition)")
- subtitle: Optional[str] = Field(None, description="The subtitle or description of the torrent (optional, helps improve recognition accuracy)")
- path: Optional[str] = Field(None, description="The file path to recognize (required for file recognition, mutually exclusive with title)")
- media_type: Optional[str] = Field(
- None,
- description="Allowed values: movie, tv, music. Audio file paths are automatically treated as music",
- )
- artist: Optional[str] = Field(
- None,
- description="Artist name for music title recognition. Only valid with media_type='music'",
- )
- album: Optional[str] = Field(
- None,
- description="Album name for music title recognition. Only valid with media_type='music'",
- )
-
-
-class RecognizeMediaTool(MoviePilotTool):
- """从标题或文件路径识别影视与单曲信息。"""
-
- name: str = "recognize_media"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Media,
- ToolTag.Metadata,
- ]
- description: str = (
- "Extract media information from torrent titles or file paths. Supports movies, TV, and individual "
- "audio files. For music title recognition set media_type='music' and provide artist when known. "
- "Album and artist browsing by stable ID belongs to query_media_detail; use scrape_metadata for directories."
- )
- args_schema: Type[BaseModel] = RecognizeMediaInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据识别参数生成友好的提示消息"""
- title = kwargs.get("title")
- subtitle = kwargs.get("subtitle")
- path = kwargs.get("path")
- media_type = kwargs.get("media_type")
-
- if path:
- message = f"识别文件媒体信息: {path}"
- elif title:
- message = f"识别种子媒体信息: {title}"
- if subtitle:
- message += f" ({subtitle})"
- else:
- message = "识别媒体信息"
- if media_type:
- message += f" [{media_type}]"
-
- return message
-
- async def run(self, title: Optional[str] = None, subtitle: Optional[str] = None,
- path: Optional[str] = None, media_type: Optional[str] = None,
- artist: Optional[str] = None, album: Optional[str] = None,
- **kwargs) -> str:
- """执行路径或标题识别,并按媒体类型选择正确的元数据链。"""
- logger.info(
- f"执行工具: {self.name}, 参数: title={title}, subtitle={subtitle}, "
- f"path={path}, media_type={media_type}, artist={artist}, album={album}"
- )
-
- try:
- media_type_enum = None
- if media_type:
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return json.dumps({
- "success": False,
- "message": (
- f"无效的媒体类型 '{media_type}',"
- "支持的类型:'movie', 'tv', 'music'"
- ),
- }, ensure_ascii=False)
- if (artist or album) and media_type_enum != MediaType.MUSIC:
- return json.dumps({
- "success": False,
- "message": "artist 和 album 仅能与 media_type='music' 一起使用",
- }, ensure_ascii=False)
-
- is_audio_path = bool(
- path and Path(path).suffix.lower() in get_runtime_setting('RMT_AUDIOEXT')
- )
- recognize_music = media_type_enum == MediaType.MUSIC or (
- media_type_enum is None and is_audio_path
- )
- if recognize_music:
- if path:
- if not is_audio_path:
- return json.dumps({
- "success": False,
- "message": (
- "音乐路径识别只支持单个音频文件;目录请使用 "
- "scrape_metadata(media_type='music')"
- ),
- "path": path,
- }, ensure_ascii=False)
- # 影视与音乐共用统一路径识别入口,音频后缀自动路由到音乐识别链
- context = await MediaChain().async_recognize_by_path(path)
- if context:
- return self._format_context_result(context, "音频文件")
- return json.dumps({
- "success": False,
- "message": f"无法识别音乐信息: {path}",
- "path": path,
- }, ensure_ascii=False)
- if title:
- metainfo = MetaMusic.parse_query(title)
- if artist:
- metainfo.artists = [artist]
- if album:
- metainfo.album = album
- mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
- if mediainfo:
- context = Context(meta_info=metainfo, media_info=mediainfo)
- return self._format_context_result(context, "音乐标题")
- return json.dumps({
- "success": False,
- "message": f"无法识别音乐信息: {title}",
- "title": title,
- "artist": artist,
- "album": album,
- }, ensure_ascii=False)
- return json.dumps({
- "success": False,
- "message": "音乐识别必须提供 title 或单个音频文件 path",
- }, ensure_ascii=False)
-
- media_chain = MediaChain()
-
- # 根据提供的参数选择识别方式
- if path:
- # 文件路径识别
- if not path:
- return json.dumps({
- "success": False,
- "message": "文件路径不能为空"
- }, ensure_ascii=False)
-
- context = await media_chain.async_recognize_by_path(
- path,
- obtain_images=False,
- )
- if context:
- return self._format_context_result(context, "文件")
- else:
- return json.dumps({
- "success": False,
- "message": f"无法识别文件媒体信息: {path}",
- "path": path
- }, ensure_ascii=False)
-
- elif title:
- # 种子标题识别
- metainfo = MetaInfo(title, subtitle)
- mediainfo = await media_chain.async_recognize_by_meta(
- metainfo,
- obtain_images=False,
- )
- if mediainfo:
- context = Context(meta_info=metainfo, media_info=mediainfo)
- return self._format_context_result(context, "种子")
- else:
- return json.dumps({
- "success": False,
- "message": f"无法识别种子媒体信息: {title}",
- "title": title,
- "subtitle": subtitle
- }, ensure_ascii=False)
-
- else:
- return json.dumps({
- "success": False,
- "message": "必须提供 title(标题)或 path(文件路径)参数之一"
- }, ensure_ascii=False)
-
- except Exception as e:
- error_message = f"识别媒体信息失败: {str(e)}"
- logger.error(f"识别媒体信息失败: {e}", exc_info=True)
- return json.dumps({
- "success": False,
- "message": error_message
- }, ensure_ascii=False)
-
- @staticmethod
- def _format_context_result(context: Context, source_type: str) -> str:
- """格式化识别结果为JSON字符串"""
- if not context:
- return json.dumps({
- "success": False,
- "message": "识别结果为空"
- }, ensure_ascii=False)
-
- context_dict = context.to_dict()
- media_info = context_dict.get("media_info")
- meta_info = context_dict.get("meta_info")
-
- # 构建简化的结果
- result = {
- "success": True,
- "source_type": source_type,
- "media_info": None,
- "meta_info": None
- }
-
- # 处理媒体信息
- if media_info:
- if getattr(context.media_info, "type", None) == MediaType.MUSIC:
- result["media_info"] = simplify_music_info(context.media_info)
- else:
- result["media_info"] = {
- "title": media_info.get("title"),
- "en_title": media_info.get("en_title"),
- "year": media_info.get("year"),
- "type": media_type_to_agent(media_info.get("type")),
- "season": media_info.get("season"),
- "media_source": media_info.get("media_source"),
- "media_id": media_info.get("media_id"),
- "overview": media_info.get("overview"),
- "vote_average": media_info.get("vote_average"),
- "poster_path": media_info.get("poster_path"),
- "backdrop_path": media_info.get("backdrop_path"),
- "detail_link": media_info.get("detail_link"),
- "title_year": media_info.get("title_year"),
- }
-
- # 处理元数据信息
- if meta_info:
- if getattr(context.media_info, "type", None) == MediaType.MUSIC:
- result["meta_info"] = {
- key: meta_info.get(key)
- for key in (
- "title", "artists", "album", "album_artist", "year",
- "disc_number", "track_number", "total_discs", "total_tracks",
- "version", "audio_format", "bit_depth", "sample_rate", "bitrate",
- "duration", "isrc", "media_source", "media_id",
- )
- if meta_info.get(key) not in (None, "", [])
- }
- else:
- result["meta_info"] = {
- "name": meta_info.get("name"),
- "title": meta_info.get("title"),
- "year": meta_info.get("year"),
- "type": media_type_to_agent(meta_info.get("type")),
- "begin_season": meta_info.get("begin_season"),
- "end_season": meta_info.get("end_season"),
- "begin_episode": meta_info.get("begin_episode"),
- "end_episode": meta_info.get("end_episode"),
- "total_episode": meta_info.get("total_episode"),
- "part": meta_info.get("part"),
- "season_episode": meta_info.get("season_episode"),
- "episode_list": meta_info.get("episode_list"),
- "media_source": meta_info.get("media_source"),
- "media_id": meta_info.get("media_id"),
- }
-
- return json.dumps(result, ensure_ascii=False, indent=2)
diff --git a/app/agent/tools/impl/reload_plugin.py b/app/agent/tools/impl/reload_plugin.py
deleted file mode 100644
index 7582f6056..000000000
--- a/app/agent/tools/impl/reload_plugin.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""重载插件工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._plugin_tool_utils import (
- get_plugin_snapshot,
- reload_plugin_runtime,
-)
-from app.runtime.log import logger
-from app.schemas.plugin import PluginRuntimeStatus
-
-
-class ReloadPluginInput(BaseModel):
- """重载插件工具的输入参数模型"""
-
- plugin_id: str = Field(
- ...,
- description="The plugin ID to reload so the latest saved config takes effect.",
- )
-
-
-class ReloadPluginTool(MoviePilotTool):
- name: str = "reload_plugin"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Reload an installed plugin so its latest saved configuration takes effect. "
- "This also refreshes the plugin's registered commands, scheduled services, and API routes."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = ReloadPluginInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- plugin_id = kwargs.get("plugin_id", "")
- return f"重载插件: {plugin_id}"
-
- @staticmethod
- def _reload_plugin_sync(plugin_id: str) -> str:
- """
- 按后台接口同样的流程重载插件,确保最新配置和注册信息一起刷新。
- """
- plugin_info = get_plugin_snapshot(plugin_id)
- if not plugin_info:
- return json.dumps(
- {
- "success": False,
- "message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
- },
- ensure_ascii=False,
- )
-
- runtime_status = reload_plugin_runtime(plugin_id)
- refreshed_plugin = get_plugin_snapshot(plugin_id) or plugin_info
-
- if runtime_status is not PluginRuntimeStatus.ACTIVE:
- return json.dumps(
- {
- "success": False,
- **refreshed_plugin,
- "runtime_status": runtime_status,
- "message": (
- "未通过用户认证,请查看日志"
- if runtime_status is PluginRuntimeStatus.BLOCKED_BY_POLICY
- else "插件加载失败,请查看插件日志"
- ),
- },
- ensure_ascii=False,
- indent=2,
- default=str,
- )
-
- return json.dumps(
- {
- "success": True,
- **refreshed_plugin,
- "message": "插件已重载,最新配置已生效",
- },
- ensure_ascii=False,
- indent=2,
- default=str,
- )
-
- async def run(self, plugin_id: str, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
-
- try:
- return await self.run_blocking(
- "plugin", self._reload_plugin_sync, plugin_id
- )
- except Exception as e:
- logger.error(f"重载插件失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"重载插件时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/run_agent_task.py b/app/agent/tools/impl/run_agent_task.py
deleted file mode 100644
index 1c1efdacb..000000000
--- a/app/agent/tools/impl/run_agent_task.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""立即执行 Agent 自主定时任务工具。"""
-
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-
-
-class RunAgentTaskInput(BaseModel):
- """立即执行 Agent 自主定时任务的输入参数。"""
-
- task_id: int = Field(
- ...,
- ge=1,
- description=(
- "Integer autonomous task ID returned by query_agent_tasks. Do not pass a "
- "runtime scheduler job_id such as agent-task-12."
- ),
- )
-
-
-class RunAgentTaskTool(MoviePilotTool):
- """将当前用户的 Agent 自主定时任务提交为立即执行。"""
-
- name: str = "run_agent_task"
- tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
- description: str = (
- "Queue an enabled autonomous agent task owned by the current user for immediate "
- "execution. Use the integer task_id returned by query_agent_tasks. The task runs "
- "after the current agent turn can finish and broadcasts its result through the "
- "configured notification channels."
- )
- args_schema: Type[BaseModel] = RunAgentTaskInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs: object) -> Optional[str]:
- """生成立即执行 Agent 任务的提示消息。"""
- return f"立即执行自主定时任务:{kwargs.get('task_id', '')}"
-
- def _get_task_state(self, task_id: int) -> tuple[str, Optional[str]]:
- """校验任务归属和状态,返回可执行性及任务名称。"""
- task = self.data.tasks.get(
- task_id=task_id,
- user_id=str(self._user_id),
- )
- if not task:
- return "not_found", None
- if not task.enabled:
- return "disabled", task.name
- if task.last_status == "running":
- return "running", task.name
- return "ready", task.name
-
- async def run(self, task_id: int, **kwargs: object) -> str:
- """立即执行当前用户拥有且已启用的 Agent 自主定时任务。"""
- from app.application.scheduling import start_agent_task
-
- payload = RunAgentTaskInput(task_id=task_id)
- status, task_name = await self.run_blocking(
- "db",
- self._get_task_state,
- payload.task_id,
- )
- if status == "not_found":
- return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
- if status == "disabled":
- return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行"
- if status == "running":
- return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发"
- if not start_agent_task(payload.task_id):
- return f"Agent 定时任务 {task_id} 尚未注册到运行时调度器,无法立即执行"
- return (
- f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}。"
- "执行完成后将通过已配置的通知渠道广播结果"
- )
diff --git a/app/agent/tools/impl/run_scheduler.py b/app/agent/tools/impl/run_scheduler.py
deleted file mode 100644
index bec87b5b1..000000000
--- a/app/agent/tools/impl/run_scheduler.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""运行定时服务工具"""
-
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class RunSchedulerInput(BaseModel):
- """运行定时服务工具的输入参数模型"""
-
- job_id: str = Field(
- ...,
- description=(
- "Runtime scheduler job ID returned by query_schedulers. Do not pass an "
- "autonomous agent task ID or an agent-task-* runtime ID."
- ),
- )
-
-
-class RunSchedulerTool(MoviePilotTool):
- """立即运行系统、插件或工作流注册的定时服务。"""
-
- name: str = "run_scheduler"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Scheduler,
- ToolTag.Admin,
- ]
- description: str = (
- "Manually trigger a MoviePilot system, plugin, or workflow scheduler service by "
- "the runtime job_id returned from query_schedulers. This tool does not run "
- "user-created autonomous agent tasks; use run_agent_task with an integer task_id."
- )
- args_schema: Type[BaseModel] = RunSchedulerInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs: object) -> Optional[str]:
- """根据运行参数生成友好的提示消息。"""
- job_id = kwargs.get("job_id", "")
- return f"运行定时服务 (ID: {job_id})"
-
- @staticmethod
- def _run_scheduler_sync(job_id: str) -> tuple[bool, str]:
- """同步触发定时服务,避免调度器扫描阻塞事件循环。"""
- from app.application.scheduling import (
- list_scheduler_jobs,
- start_scheduler_job,
- )
-
- for scheduler_item in list_scheduler_jobs():
- if scheduler_item.id == job_id:
- start_scheduler_job(job_id)
- return True, scheduler_item.name
- return False, ""
-
- async def run(self, job_id: str, **kwargs: object) -> str:
- """立即运行非 Agent 自主任务的运行时定时服务。"""
- logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
-
- try:
- from app.application.scheduling import AGENT_TASK_JOB_PREFIX
-
- if job_id.startswith(f"{AGENT_TASK_JOB_PREFIX}-"):
- return (
- "Agent 自主定时任务不能通过 run_scheduler 运行,"
- "请使用 query_agent_tasks 查询整数 task_id 后调用 run_agent_task"
- )
- job_exists, job_name = await self.run_blocking(
- "workflow", self._run_scheduler_sync, job_id
- )
- if not job_exists:
- return f"定时服务 ID {job_id} 不存在,请使用 query_schedulers 工具查询可用的定时服务"
-
- return f"成功触发定时服务:{job_name} (ID: {job_id})"
- except Exception as e:
- logger.error(f"运行定时服务失败: {e}", exc_info=True)
- return f"运行定时服务时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/run_slash_command.py b/app/agent/tools/impl/run_slash_command.py
deleted file mode 100644
index 753996841..000000000
--- a/app/agent/tools/impl/run_slash_command.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""运行斜杠命令工具(系统命令 + 插件命令)"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.events import eventmanager
-from app.runtime.log import logger
-from app.schemas.types import EventType, NotificationChannel
-
-
-class RunSlashCommandInput(BaseModel):
- """运行斜杠命令工具的输入参数模型"""
-
- command: str = Field(
- ...,
- description="The slash command to execute, e.g. '/cookiecloud'. "
- "Must start with '/'. Can include arguments after the command, e.g. '/command arg1 arg2'. "
- "Use query_plugin_capabilities tool to discover available plugin commands, "
- "or list_slash_commands tool to discover all available commands (including system commands).",
- )
-
-
-class RunSlashCommandTool(MoviePilotTool):
- name: str = "run_slash_command"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.SlashCommand,
- ToolTag.Admin,
- ]
- description: str = (
- "Execute a slash command (system or plugin) by sending a CommandExcute event. "
- "This tool supports ALL registered slash commands, including: "
- "1) System preset commands (e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) "
- "2) Plugin commands registered by installed plugins. "
- "Use the query_plugin_capabilities tool to discover plugin commands, "
- "or the list_slash_commands tool to discover all available commands. "
- "The command will be executed asynchronously. "
- "Note: This tool triggers the command execution but the actual processing happens in the background."
- )
- args_schema: Type[BaseModel] = RunSlashCommandInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- command = kwargs.get("command", "")
- return f"执行命令: {command}"
-
- async def run(self, command: str, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: command={command}")
-
- try:
- # 确保命令以 / 开头
- if not command.startswith("/"):
- command = f"/{command}"
-
- # 从命令注册表中验证命令是否存在(包含系统预设命令 + 插件命令 + 其他命令)
- from app.application.commands import get_command, get_commands
-
- cmd_name = command.split()[0]
- matched_command = get_command(cmd_name)
-
- if not matched_command:
- # 列出所有可用命令帮助用户
- all_commands = get_commands()
- available_cmds = [
- f"{cmd} - {info.get('description', '无描述')}"
- for cmd, info in all_commands.items()
- ]
- result = {
- "success": False,
- "message": f"命令 {cmd_name} 不存在",
- }
- if available_cmds:
- result["available_commands"] = available_cmds
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- # 构建消息渠道,优先使用当前会话的渠道信息
- channel = None
- if self._channel:
- try:
- channel = NotificationChannel(self._channel)
- except (ValueError, KeyError):
- channel = None
-
- # 发送命令执行事件,与 message.py 中的方式一致
- eventmanager.send_event(
- EventType.CommandExcute,
- {
- "cmd": command,
- "user": self._user_id,
- "channel": channel,
- "source": self._source,
- },
- )
-
- result = {
- "success": True,
- "message": f"命令 {cmd_name} 已触发执行",
- "command": command,
- "command_desc": matched_command.get("description", ""),
- }
- # 如果是插件命令,附加插件ID
- if matched_command.get("pid"):
- result["plugin_id"] = matched_command["pid"]
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- except Exception as e:
- logger.error(f"执行命令失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"执行命令时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/run_workflow.py b/app/agent/tools/impl/run_workflow.py
deleted file mode 100644
index 8351fb7be..000000000
--- a/app/agent/tools/impl/run_workflow.py
+++ /dev/null
@@ -1,84 +0,0 @@
-"""执行工作流工具"""
-
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.workflow import WorkflowChain
-from app.application.workflow import get_configured_workflow_query
-from app.runtime.log import logger
-
-
-class RunWorkflowInput(BaseModel):
- """执行工作流工具的输入参数模型"""
-
- workflow_id: int = Field(
- ..., description="Workflow ID (can be obtained from query_workflows tool)"
- )
- from_begin: Optional[bool] = Field(
- True,
- description="Whether to run workflow from the beginning (default: True, if False will continue from last executed action)",
- )
-
-
-class RunWorkflowTool(MoviePilotTool):
- name: str = "run_workflow"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Workflow,
- ToolTag.Admin,
- ]
- description: str = "Execute a specific workflow manually by workflow ID. Supports running from the beginning or continuing from the last executed action."
- args_schema: Type[BaseModel] = RunWorkflowInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据工作流参数生成友好的提示消息"""
- workflow_id = kwargs.get("workflow_id")
- from_begin = kwargs.get("from_begin", True)
-
- message = f"执行工作流: {workflow_id}"
- if not from_begin:
- message += " (从上次位置继续)"
- else:
- message += " (从头开始)"
-
- return message
-
- @staticmethod
- def _run_workflow_sync(
- workflow_id: int, from_begin: Optional[bool] = True
- ) -> tuple[bool, str]:
- """同步执行工作流,放到专用线程池避免长流程阻塞 API 响应。"""
- return WorkflowChain().process(workflow_id, from_begin=from_begin)
-
- async def run(
- self, workflow_id: int, from_begin: Optional[bool] = True, **kwargs
- ) -> str:
- logger.info(
- f"执行工具: {self.name}, 参数: workflow_id={workflow_id}, from_begin={from_begin}"
- )
-
- try:
- workflow = await get_configured_workflow_query().get(workflow_id)
-
- if not workflow:
- return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流"
-
- # 工作流执行链路包含大量同步步骤,统一放到 workflow 线程池。
- state, errmsg = await self.run_blocking(
- "workflow",
- self._run_workflow_sync,
- workflow.id,
- from_begin,
- )
-
- if not state:
- return f"执行工作流失败:{workflow.name} (ID: {workflow.id})\n错误原因:{errmsg}"
- else:
- return f"工作流执行成功:{workflow.name} (ID: {workflow.id})"
- except Exception as e:
- logger.error(f"执行工作流失败: {e}", exc_info=True)
- return f"执行工作流时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/scrape_metadata.py b/app/agent/tools/impl/scrape_metadata.py
deleted file mode 100644
index 8a611dfbe..000000000
--- a/app/agent/tools/impl/scrape_metadata.py
+++ /dev/null
@@ -1,310 +0,0 @@
-"""刮削媒体元数据工具"""
-
-import json
-from pathlib import Path
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.media import MediaChain
-from app.chain.scraping import ScrapingChain
-from app.runtime.settings import get_runtime_setting
-
-from app.runtime.log import logger
-from app.schemas.workflow import FileItem
-from app.schemas.types import (
- MUSIC_ENTITY_ARTIST,
- MediaSource,
- MediaType,
- media_type_to_agent,
-)
-from app.schemas.media import normalize_media_source
-from app.domain.media import normalize_music_type
-from ._music_utils import simplify_music_info
-
-
-def _inspect_local_path(path: Path) -> tuple[bool, bool]:
- """返回本地路径是否存在及是否为目录。"""
- exists = path.exists()
- return exists and path.is_dir(), exists
-
-
-class ScrapeMetadataInput(BaseModel):
- """刮削媒体元数据工具的输入参数模型"""
-
- path: str = Field(
- ...,
- description="Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')",
- )
- storage: Optional[str] = Field(
- "local",
- description="Storage type: 'local' for local storage, 'smb', 'alist', etc. for remote storage (default: 'local')",
- )
- overwrite: Optional[bool] = Field(
- False,
- description="Whether to overwrite existing metadata files (default: False)",
- )
- media_type: Optional[str] = Field(
- None,
- description="Allowed values: movie, tv, music. Audio files are auto-detected; music directories should set music explicitly",
- )
- music_type: Optional[str] = Field(
- None,
- description="For an explicit music ID: recording for one file or album for a complete album directory",
- )
- media_source: Optional[MediaSource] = Field(
- None,
- description=(
- "Music metadata source: musicbrainz, theaudiodb, or doubanmusic. "
- "When omitted, automatic music recognition compares all sources. "
- "Must be paired with media_id when an ID is supplied"
- ),
- )
- media_id: Optional[str] = Field(
- None,
- description="Source-native recording or album ID. Must be paired with media_source",
- )
-
-
-class ScrapeMetadataTool(MoviePilotTool):
- """刮削影视 NFO/图片或音乐标签、封面与旁挂歌词。"""
-
- name: str = "scrape_metadata"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Media,
- ToolTag.Metadata,
- ToolTag.File,
- ToolTag.Admin,
- ]
- description: str = (
- "Scrape existing movie, TV, or music files on local/remote storage. Video generates configured NFO and "
- "images. Music applies configured audio-tag and cover policies and can automatically download LRCLIB "
- "lyrics as same-name .lrc/.txt sidecars. A directory with an album ID is treated as one complete album; "
- "without an ID, each audio file is recognized independently."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = ScrapeMetadataInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据刮削参数生成友好的提示消息"""
- path = kwargs.get("path", "")
- storage = kwargs.get("storage", "local")
- overwrite = kwargs.get("overwrite", False)
- media_type = kwargs.get("media_type")
-
- message = f"刮削媒体元数据: {path}"
- if storage != "local":
- message += f" [存储: {storage}]"
- if overwrite:
- message += " [覆盖模式]"
- if media_type:
- message += f" [{media_type}]"
-
- return message
-
- async def run(
- self,
- path: str,
- storage: Optional[str] = "local",
- overwrite: Optional[bool] = False,
- media_type: Optional[str] = None,
- music_type: Optional[str] = None,
- media_source: Optional[MediaSource] = None,
- media_id: Optional[str] = None,
- **kwargs,
- ) -> str:
- """识别刮削类型并将同步文件及外部元数据操作放入线程池。"""
- logger.info(
- f"执行工具: {self.name}, 参数: path={path}, storage={storage}, "
- f"overwrite={overwrite}, media_type={media_type}, music_type={music_type}, "
- f"media_source={media_source}, media_id={media_id}"
- )
-
- try:
- # 验证路径
- if not path:
- return json.dumps(
- {"success": False, "message": "刮削路径不能为空"},
- ensure_ascii=False,
- )
-
- media_type_enum = None
- if media_type:
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return json.dumps({
- "success": False,
- "message": (
- f"无效的媒体类型 '{media_type}',"
- "支持的类型:'movie', 'tv', 'music'"
- ),
- }, ensure_ascii=False)
- explicit_identity = media_source is not None or media_id is not None
- normalized_source = normalize_media_source(media_source)
- normalized_media_id = str(media_id).strip() if media_id is not None else ""
- if explicit_identity and (
- not normalized_source or not normalized_media_id
- ):
- return json.dumps({
- "success": False,
- "message": "必须同时提供有效的 media_source 和 media_id",
- }, ensure_ascii=False)
- media_source = normalized_source
- media_id = normalized_media_id or None
-
- local_path = Path(path)
- is_local_directory = False
- path_exists = True
- if (storage or "local") == "local":
- is_local_directory, path_exists = await self.run_blocking(
- "storage",
- _inspect_local_path,
- local_path,
- )
- file_type = "dir" if is_local_directory or not local_path.suffix else "file"
- fileitem = FileItem(
- storage=storage or "local",
- path=path,
- type=file_type,
- )
-
- # 检查本地存储路径是否存在
- if storage == "local":
- if not path_exists:
- return json.dumps(
- {"success": False, "message": f"刮削路径不存在: {path}"},
- ensure_ascii=False,
- )
-
- media_chain = MediaChain()
- scraping_chain = ScrapingChain()
- is_audio_file = (
- fileitem.type == "file"
- and Path(path).suffix.lower() in get_runtime_setting('RMT_AUDIOEXT')
- )
- scrape_music = media_type_enum == MediaType.MUSIC or (
- media_type_enum is None and is_audio_file
- )
- if scrape_music:
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(music_type)
- if not normalized_music_type:
- return json.dumps({
- "success": False,
- "message": (
- f"无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album'"
- ),
- }, ensure_ascii=False)
- if normalized_music_type == MUSIC_ENTITY_ARTIST:
- return json.dumps({
- "success": False,
- "message": "艺术家是浏览实体,不能用于文件刮削",
- }, ensure_ascii=False)
-
- mediainfo = None
- if media_source and media_id:
- recognize_kwargs = {
- "media_source": media_source,
- "media_id": media_id,
- "mtype": MediaType.MUSIC,
- }
- if normalized_music_type:
- recognize_kwargs["music_type"] = normalized_music_type
- mediainfo = await media_chain.async_recognize_media(
- **recognize_kwargs
- )
- if not mediainfo:
- return json.dumps({
- "success": False,
- "message": f"未识别到音乐信息: {media_source}:{media_id}",
- }, ensure_ascii=False)
- actual_music_type = getattr(mediainfo, "music_type", None)
- if normalized_music_type and actual_music_type != normalized_music_type:
- return json.dumps({
- "success": False,
- "message": (
- f"音乐实体类型不匹配:请求 {normalized_music_type},"
- f"实际 {actual_music_type or 'unknown'}"
- ),
- }, ensure_ascii=False)
-
- success, message = await self.run_blocking(
- "storage",
- scraping_chain.scrape_music_metadata,
- fileitem=fileitem,
- mediainfo=mediainfo,
- overwrite=bool(overwrite),
- media_source=media_source,
- )
- result = {
- "success": success,
- "message": message,
- "path": path,
- "type": "music",
- }
- if mediainfo:
- result["media_info"] = simplify_music_info(mediainfo)
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- if music_type or media_source or media_id:
- return json.dumps({
- "success": False,
- "message": "music_type、media_source 和 media_id 仅能用于音乐刮削",
- }, ensure_ascii=False)
-
- # 影视沿用路径识别与 NFO/图片刮削链路。
- context = await media_chain.async_recognize_by_path(
- path,
- obtain_images=True,
- )
-
- if not context or not context.media_info:
- return json.dumps(
- {
- "success": False,
- "message": f"刮削失败,无法识别媒体信息: {path}",
- "path": path,
- },
- ensure_ascii=False,
- )
-
- # 刮削会包含磁盘写入和外部图片/元数据访问,统一放到 storage 线程池。
- await self.run_blocking(
- "storage",
- scraping_chain.scrape_metadata,
- fileitem=fileitem,
- meta=context.meta_info,
- mediainfo=context.media_info,
- overwrite=overwrite,
- )
-
- return json.dumps(
- {
- "success": True,
- "message": f"{path} 刮削完成",
- "path": path,
- "media_info": {
- "title": context.media_info.title,
- "year": context.media_info.year,
- "type": media_type_to_agent(context.media_info.type),
- "media_source": context.media_info.media_source,
- "media_id": context.media_info.media_id,
- "season": context.media_info.season,
- },
- },
- ensure_ascii=False,
- indent=2,
- )
-
- except Exception as e:
- error_message = f"刮削媒体元数据失败: {str(e)}"
- logger.error(f"刮削媒体元数据失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": error_message, "path": path},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/search_media.py b/app/agent/tools/impl/search_media.py
deleted file mode 100644
index 9be0c96ad..000000000
--- a/app/agent/tools/impl/search_media.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""搜索媒体工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.media import MediaChain
-from app.runtime.log import logger
-from app.schemas.types import MediaType, media_type_to_agent
-from app.schemas.media import resolve_media_identity
-from app.domain.media import normalize_music_type
-from ._music_utils import simplify_music_info
-
-
-class SearchMediaInput(BaseModel):
- """搜索媒体工具的输入参数模型"""
- title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')")
- year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)")
- media_type: Optional[str] = Field(None,
- description="Allowed values: movie, tv, music")
- music_type: Optional[str] = Field(
- None,
- description="Music entity filter: recording, album, or artist. Only valid when media_type='music'",
- )
- season: Optional[int] = Field(None,
- description="Season number for TV shows and anime (optional, only applicable for series)")
-
-
-class SearchMediaTool(MoviePilotTool):
- """按标题搜索影视或音乐元数据候选。"""
-
- name: str = "search_media"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Media,
- ]
- description: str = (
- "Search metadata databases for movies, TV shows, music recordings, albums, or artists. "
- "For music, set media_type='music' and optionally filter music_type as recording, album, or artist. "
- "Returns source-native IDs that must be reused for detail, subscription, torrent, and library operations."
- )
- args_schema: Type[BaseModel] = SearchMediaInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据搜索参数生成友好的提示消息"""
- title = kwargs.get("title", "")
- year = kwargs.get("year")
- media_type = kwargs.get("media_type")
- music_type = kwargs.get("music_type")
- season = kwargs.get("season")
-
- message = f"搜索媒体: {title}"
- if year:
- message += f" ({year})"
- if media_type:
- message += f" [{media_type}]"
- if music_type:
- message += f" [{music_type}]"
- if season is not None:
- message += f" 第{season}季"
-
- return message
-
- async def run(self, title: str, year: Optional[str] = None,
- media_type: Optional[str] = None, season: Optional[int] = None,
- music_type: Optional[str] = None, **kwargs) -> str:
- """执行元数据搜索并返回可复用的精简媒体身份。"""
- logger.info(
- f"执行工具: {self.name}, 参数: title={title}, year={year}, "
- f"media_type={media_type}, season={season}, music_type={music_type}")
-
- try:
- media_type_enum = None
- if media_type:
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
-
- if music_type and media_type_enum != MediaType.MUSIC:
- return "错误:music_type 仅能与 media_type='music' 一起使用"
-
- if media_type_enum == MediaType.MUSIC:
- if season is not None:
- return "错误:音乐没有季号,搜索音乐时不能传入 season"
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(music_type)
- if not normalized_music_type:
- return (
- f"错误:无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album', 'artist'"
- )
- results = await MediaChain().async_search_music(query=title, limit=100)
- filtered_music = [
- item
- for item in results or []
- if (not year or str(item.year or "") == str(year))
- and (
- not normalized_music_type
- or item.music_type == normalized_music_type
- )
- ]
- if not filtered_music:
- return f"未找到符合条件的音乐资源: {title}"
- total_count = len(filtered_music)
- limited_results = filtered_music[:30]
- result_json = json.dumps(
- [simplify_music_info(item) for item in limited_results],
- ensure_ascii=False,
- indent=2,
- )
- if total_count > len(limited_results):
- return (
- f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,"
- f"仅显示前 {len(limited_results)} 条结果。\n\n{result_json}"
- )
- return result_json
-
- media_chain = MediaChain()
- _, results = await media_chain.async_search(title=title)
-
- # 过滤结果
- if results:
- filtered_results = []
- for result in results:
- if year and str(result.year or "") != str(year):
- continue
- if media_type_enum and result.type != media_type_enum:
- continue
- if season is not None and result.season != season:
- continue
- filtered_results.append(result)
-
- if filtered_results:
- # 搜索结果只返回前 30 条,后续可通过更精确的年份/类型条件缩小范围。
- total_count = len(filtered_results)
- limited_results = filtered_results[:30]
- # 精简字段,只保留关键信息
- simplified_results = []
- for r in limited_results:
- media_source, media_id = resolve_media_identity(media=r)
- simplified = {
- "title": r.title,
- "en_title": r.en_title,
- "year": r.year,
- "type": media_type_to_agent(r.type),
- "season": r.season,
- "tmdb_id": r.tmdb_id,
- "imdb_id": r.imdb_id,
- "douban_id": r.douban_id,
- "bangumi_id": r.bangumi_id,
- "anilist_id": r.anilist_id,
- "media_source": media_source,
- "media_id": media_id,
- "overview": r.overview[:200] + "..." if r.overview and len(r.overview) > 200 else r.overview,
- "vote_average": r.vote_average,
- "poster_path": r.poster_path,
- "detail_link": r.detail_link
- }
- simplified_results.append(simplified)
- result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
- # 如果结果被裁剪,添加提示信息
- if total_count > len(limited_results):
- return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 {len(limited_results)} 条结果。\n\n{result_json}"
- return result_json
- else:
- return f"未找到符合条件的媒体资源: {title}"
- else:
- return f"未找到相关媒体资源: {title}"
- except Exception as e:
- error_message = f"搜索媒体失败: {str(e)}"
- logger.error(f"搜索媒体失败: {e}", exc_info=True)
- return error_message
diff --git a/app/agent/tools/impl/search_person.py b/app/agent/tools/impl/search_person.py
deleted file mode 100644
index b8f540b51..000000000
--- a/app/agent/tools/impl/search_person.py
+++ /dev/null
@@ -1,87 +0,0 @@
-"""搜索人物工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.media import MediaChain
-from app.runtime.log import logger
-
-
-class SearchPersonInput(BaseModel):
- """搜索人物工具的输入参数模型"""
- name: str = Field(..., description="The name of the person to search for (e.g., 'Tom Hanks', '周杰伦')")
-
-
-class SearchPersonTool(MoviePilotTool):
- name: str = "search_person"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Media,
- ]
- description: str = "Search for person information including actors, directors, etc. Supports searching by name. Returns detailed person information from TMDB, Douban, or Bangumi database."
- args_schema: Type[BaseModel] = SearchPersonInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据搜索参数生成友好的提示消息"""
- name = kwargs.get("name", "")
- return f"搜索人物: {name}"
-
- async def run(self, name: str, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: name={name}")
-
- try:
- media_chain = MediaChain()
- # 使用 MediaChain.async_search_persons 方法搜索人物
- persons = await media_chain.async_search_persons(name=name)
-
- if persons:
- # 人物搜索结果只返回前 30 条,避免 biography/别名等字段挤占上下文。
- total_count = len(persons)
- limited_persons = persons[:30]
- # 精简字段,只保留关键信息
- simplified_results = []
- for person in limited_persons:
- simplified = {
- "name": person.name,
- "id": person.id,
- "source": person.source,
- "profile_path": person.profile_path,
- "original_name": person.original_name,
- "known_for_department": person.known_for_department,
- "popularity": person.popularity,
- "biography": person.biography[:200] + "..." if person.biography and len(person.biography) > 200 else person.biography,
- "birthday": person.birthday,
- "deathday": person.deathday,
- "place_of_birth": person.place_of_birth,
- "gender": person.gender,
- "imdb_id": person.imdb_id,
- "also_known_as": person.also_known_as[:5] if person.also_known_as else [], # 限制别名数量
- }
- # 添加豆瓣特有字段
- if person.source == "douban":
- simplified["url"] = person.url
- simplified["avatar"] = person.avatar
- simplified["latin_name"] = person.latin_name
- simplified["roles"] = person.roles[:5] if person.roles else [] # 限制角色数量
- # 添加Bangumi特有字段
- if person.source == "bangumi":
- simplified["career"] = person.career
- simplified["relation"] = person.relation
-
- simplified_results.append(simplified)
-
- result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
- # 如果结果被裁剪,添加提示信息
- if total_count > len(limited_persons):
- return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 {len(limited_persons)} 条结果。\n\n{result_json}"
- return result_json
- else:
- return f"未找到相关人物信息: {name}"
- except Exception as e:
- error_message = f"搜索人物失败: {str(e)}"
- logger.error(f"搜索人物失败: {e}", exc_info=True)
- return error_message
diff --git a/app/agent/tools/impl/search_person_credits.py b/app/agent/tools/impl/search_person_credits.py
deleted file mode 100644
index c9608f58e..000000000
--- a/app/agent/tools/impl/search_person_credits.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""搜索演员参演作品工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.douban import DoubanChain
-from app.chain.tmdb import TmdbChain
-from app.chain.bangumi import BangumiChain
-from app.runtime.log import logger
-from app.schemas.media import resolve_media_identity
-
-
-class SearchPersonCreditsInput(BaseModel):
- """搜索演员参演作品工具的输入参数模型"""
- person_id: int = Field(..., description="The ID of the person/actor to search for credits (e.g., 31 for Tom Hanks in TMDB)")
- source: str = Field(..., description="The data source: 'tmdb' for TheMovieDB, 'douban' for Douban, 'bangumi' for Bangumi")
- page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
-
-
-class SearchPersonCreditsTool(MoviePilotTool):
- name: str = "search_person_credits"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Media,
- ]
- description: str = "Search for films and TV shows that a person/actor has appeared in (filmography). Supports searching by person ID from TMDB, Douban, or Bangumi database. Returns a list of media works the person has participated in."
- args_schema: Type[BaseModel] = SearchPersonCreditsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据搜索参数生成友好的提示消息"""
- person_id = kwargs.get("person_id", "")
- source = kwargs.get("source", "")
- return f"搜索人物参演作品: {source} ID {person_id}"
-
- async def run(self, person_id: int, source: str, page: Optional[int] = 1, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: person_id={person_id}, source={source}, page={page}")
-
- try:
- # 根据source选择相应的chain
- if source.lower() == "tmdb":
- tmdb_chain = TmdbChain()
- medias = await tmdb_chain.async_person_credits(person_id=person_id, page=page)
- elif source.lower() == "douban":
- douban_chain = DoubanChain()
- medias = await douban_chain.async_person_credits(person_id=person_id, page=page)
- elif source.lower() == "bangumi":
- bangumi_chain = BangumiChain()
- medias = await bangumi_chain.async_person_credits(person_id=person_id)
- else:
- return f"不支持的数据源: {source}。支持的数据源: tmdb, douban, bangumi"
-
- if medias:
- # 限制最多30条结果
- total_count = len(medias)
- limited_medias = medias[:30]
- # 精简字段,只保留关键信息
- simplified_results = []
- for media in limited_medias:
- media_source, media_id = resolve_media_identity(media=media)
- simplified = {
- "title": media.title,
- "en_title": media.en_title,
- "year": media.year,
- "type": media.type.value if media.type else None,
- "season": media.season,
- "tmdb_id": media.tmdb_id,
- "imdb_id": media.imdb_id,
- "douban_id": media.douban_id,
- "bangumi_id": media.bangumi_id,
- "anilist_id": media.anilist_id,
- "media_source": media_source,
- "media_id": media_id,
- "overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview,
- "vote_average": media.vote_average,
- "poster_path": media.poster_path,
- "backdrop_path": media.backdrop_path,
- "detail_link": media.detail_link
- }
- simplified_results.append(simplified)
-
- result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
- # 如果结果被裁剪,添加提示信息
- if total_count > 30:
- return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}"
- return result_json
- else:
- return f"未找到人物 ID {person_id} ({source}) 的参演作品"
- except Exception as e:
- error_message = f"搜索演员参演作品失败: {str(e)}"
- logger.error(f"搜索演员参演作品失败: {e}", exc_info=True)
- return error_message
diff --git a/app/agent/tools/impl/search_subscribe.py b/app/agent/tools/impl/search_subscribe.py
deleted file mode 100644
index ac8e55d37..000000000
--- a/app/agent/tools/impl/search_subscribe.py
+++ /dev/null
@@ -1,144 +0,0 @@
-"""搜索订阅缺失资源工具"""
-
-import json
-from typing import List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.subscription.mutation import SubscriptionActor
-from app.chain.subscribe.facade import SubscribeChain
-from app.runtime.log import logger
-from app.schemas.types import media_type_to_agent
-
-
-class SearchSubscribeInput(BaseModel):
- """搜索订阅缺失资源工具的输入参数模型"""
- subscribe_id: int = Field(..., description="The subscription ID to search for missing resources (from query_subscribes)")
- manual: Optional[bool] = Field(False, description="Whether this is a manual search (default: False)")
- filter_groups: Optional[List[str]] = Field(None,
- description="List of filter rule group names to apply for this search (optional, can be obtained from query_rule_groups tool. If provided, will temporarily update the subscription's filter groups before searching)")
-
-
-class SearchSubscribeTool(MoviePilotTool):
- """立即搜索电影、剧集、单曲或专辑订阅的缺失资源。"""
-
- name: str = "search_subscribe"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Write,
- ToolTag.Subscription,
- ToolTag.Resource,
- ]
- description: str = (
- "Search and automatically download missing resources for one subscription. Supports movies, TV episodes, "
- "single recordings, and complete albums. Album search keeps the subscription active unless downloaded "
- "files are confirmed to cover the expected track count."
- )
- args_schema: Type[BaseModel] = SearchSubscribeInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据搜索参数生成友好的提示消息"""
- subscribe_id = kwargs.get("subscribe_id")
- manual = kwargs.get("manual", False)
-
- message = f"搜索订阅 #{subscribe_id} 的缺失资源"
- if manual:
- message += "(手动搜索)"
-
- return message
-
- async def run(self, subscribe_id: int, manual: Optional[bool] = False,
- filter_groups: Optional[List[str]] = None, **kwargs) -> str:
- """验证订阅状态并触发一次订阅资源搜索。"""
- logger.info(
- f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}, manual={manual}, filter_groups={filter_groups}")
-
- try:
- # 先验证订阅是否存在
- repository = self.data.subscriptions
- subscribe = await repository.async_get(subscribe_id)
-
- if not subscribe:
- return json.dumps({
- "success": False,
- "message": f"订阅不存在: {subscribe_id}"
- }, ensure_ascii=False)
-
- # 获取订阅信息用于返回
- subscribe_info = {
- "id": subscribe.id,
- "name": subscribe.name,
- "year": subscribe.year,
- "type": media_type_to_agent(subscribe.type),
- "season": subscribe.season,
- "state": subscribe.state,
- "total_episode": subscribe.total_episode,
- "lack_episode": subscribe.lack_episode,
- "media_source": subscribe.media_source,
- "media_id": subscribe.media_id,
- "music_type": subscribe.music_type,
- "total_tracks": subscribe.total_tracks,
- "description": subscribe.description,
- }
-
- # 检查订阅状态
- if subscribe.state == "S":
- return json.dumps({
- "success": False,
- "message": f"订阅 #{subscribe_id} ({subscribe.name}) 已暂停,无法搜索",
- "subscribe": subscribe_info
- }, ensure_ascii=False)
-
- # 如果提供了 filter_groups 参数,先更新订阅的规则组
- if filter_groups is not None:
- async with self.data.subscription_mutation_scope() as mutation:
- await mutation.update(
- subscribe_id,
- {"filter_groups": filter_groups},
- SubscriptionActor(name="agent", is_superuser=True),
- existing=subscribe,
- scene="agent_search",
- )
- logger.info(f"更新订阅 #{subscribe_id} 的规则组为: {filter_groups}")
-
- # 订阅搜索会触发大量同步站点访问,统一走 subscribe 线程池。
- await self.run_blocking(
- "subscribe",
- SubscribeChain().search,
- sid=subscribe_id,
- state="R", # 当 sid 有值时此参数会被忽略
- manual=manual,
- )
-
- # 重新获取订阅信息以获取更新后的状态
- updated_subscribe = await repository.async_get(subscribe_id)
- if updated_subscribe:
- subscribe_info.update({
- "state": updated_subscribe.state,
- "lack_episode": updated_subscribe.lack_episode,
- "last_update": updated_subscribe.last_update,
- "filter_groups": updated_subscribe.filter_groups
- })
-
- # 如果提供了规则组,会在返回信息中显示
- result = {
- "success": True,
- "message": f"订阅 #{subscribe_id} ({subscribe.name}) 搜索完成",
- "subscribe": subscribe_info
- }
-
- if filter_groups is not None:
- result["message"] += f"(已应用规则组: {', '.join(filter_groups)})"
-
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- except Exception as e:
- error_message = f"搜索订阅缺失资源失败: {str(e)}"
- logger.error(f"搜索订阅缺失资源失败: {e}", exc_info=True)
- return json.dumps({
- "success": False,
- "message": error_message,
- "subscribe_id": subscribe_id
- }, ensure_ascii=False)
diff --git a/app/agent/tools/impl/search_torrents.py b/app/agent/tools/impl/search_torrents.py
deleted file mode 100644
index f97f2960e..000000000
--- a/app/agent/tools/impl/search_torrents.py
+++ /dev/null
@@ -1,144 +0,0 @@
-"""搜索种子工具"""
-
-import json
-from typing import List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.search.facade import SearchChain
-from app.application.configuration import get_configured_system_config
-from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
-from app.runtime.log import logger
-from app.schemas.types import MediaSource, MediaType, SystemConfigKey
-from app.domain.media import normalize_music_type
-from ._torrent_search_utils import (
- SEARCH_RESULT_CACHE_FILE,
- build_filter_options,
-)
-
-
-class SearchTorrentsInput(BaseModel):
- """搜索种子工具的输入参数模型"""
- media_source: MediaSource = Field(..., description="Media metadata source")
- media_id: str = Field(..., description="Native ID for media_source")
- media_type: Optional[str] = Field(None, description="Allowed values: movie, tv, music")
- music_type: Optional[str] = Field(
- None,
- description="Music target entity: recording or album. Artists cannot be searched as torrent targets",
- )
- area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'")
- sites: Optional[List[int]] = Field(None,
- description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)")
-
-
-class SearchTorrentsTool(MoviePilotTool):
- """按稳定媒体身份搜索影视、单曲或整张专辑资源。"""
-
- name: str = "search_torrents"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Resource,
- ToolTag.Site,
- ToolTag.Media,
- ]
- description: str = (
- "Search for torrent files by media ID across configured indexer sites, cache the matched results, "
- "and return available filter options for follow-up selection. "
- "Accepts one MediaSource enum value and its source-native media ID. "
- "Music targets are one recording or one complete album; artists are browse-only.")
- args_schema: Type[BaseModel] = SearchTorrentsInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据搜索参数生成友好的提示消息"""
- media_type = kwargs.get("media_type")
- source = kwargs.get("media_source")
- media_id = kwargs.get("media_id")
- message = f"搜索种子: {source}={media_id}" if source and media_id else "搜索种子"
- if media_type:
- message += f" [{media_type}]"
- return message
-
- @staticmethod
- def _load_configured_sites() -> List[int]:
- """同步读取默认搜索站点列表。"""
- return get_configured_system_config().get(SystemConfigKey.IndexerSites) or []
-
- async def run(self, media_source: MediaSource, media_id: str,
- media_type: Optional[str] = None, area: Optional[str] = None,
- sites: Optional[List[int]] = None,
- music_type: Optional[str] = None, **kwargs) -> str:
- """执行精确资源搜索并缓存带完整音乐上下文的候选。"""
- logger.info(
- f"执行工具: {self.name}, 参数: media_source={media_source}, "
- f"media_id={media_id}, media_type={media_type}, area={area}, sites={sites}"
- )
-
- if not media_source or not str(media_id or "").strip():
- return "参数错误:media_source 和 media_id 必须同时提供。"
-
- try:
- search_chain = SearchChain()
- media_type_enum = None
- if media_type:
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
-
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(
- music_type,
- allow_artist=False,
- )
- if not normalized_music_type:
- return (
- f"错误:无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album'"
- )
- if media_type_enum != MediaType.MUSIC:
- return "错误:music_type 仅能与 media_type='music' 一起使用"
- if media_type_enum == MediaType.MUSIC:
- if area == "imdbid":
- return "错误:音乐不支持按 IMDb ID 搜索"
-
- filtered_torrents = await search_chain.async_search_by_id(
- media_source=media_source,
- media_id=media_id,
- mtype=media_type_enum,
- music_type=normalized_music_type,
- area=area or "title",
- sites=sites,
- cache_local=False,
- )
- # 获取站点信息
- all_indexers = await SitesHelper().async_get_indexers()
- all_sites = [{"id": indexer.get("id"), "name": indexer.get("name")} for indexer in (all_indexers or [])]
-
- if sites:
- search_site_ids = sites
- else:
- search_site_ids = self._load_configured_sites()
-
- if filtered_torrents:
- await search_chain.async_save_cache(filtered_torrents, SEARCH_RESULT_CACHE_FILE)
- result_json = json.dumps({
- "total_count": len(filtered_torrents),
- "message": "搜索完成。请使用 get_search_results 工具获取搜索结果。",
- "all_sites": all_sites,
- "search_site_ids": search_site_ids,
- "filter_options": build_filter_options(filtered_torrents),
- }, ensure_ascii=False, indent=2)
- return result_json
- else:
- result_json = json.dumps({
- "message": f"未找到相关种子资源: {media_source}:{media_id}",
- "all_sites": all_sites,
- "search_site_ids": search_site_ids,
- }, ensure_ascii=False, indent=2)
- return result_json
- except Exception as e:
- error_message = f"搜索种子时发生错误: {str(e)}"
- logger.error(f"搜索种子失败: {e}", exc_info=True)
- return error_message
diff --git a/app/agent/tools/impl/switch_persona.py b/app/agent/tools/impl/switch_persona.py
deleted file mode 100644
index ba4bf607e..000000000
--- a/app/agent/tools/impl/switch_persona.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""切换当前激活人格工具。"""
-
-import json
-from typing import Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.runtime import agent_runtime_manager
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class SwitchPersonaInput(BaseModel):
- """切换人格工具的输入参数模型。"""
-
- persona_id: str = Field(
- ...,
- description=(
- "The target persona to activate. This can be the exact persona_id, label, or one of the persona aliases. "
- "If the exact persona is unclear, call query_personas first."
- ),
- )
-
-
-class SwitchPersonaTool(MoviePilotTool):
- name: str = "switch_persona"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Persona,
- ]
- description: str = (
- "Switch the active persona (人格) used by the agent runtime. "
- "This change is persistent for future turns. "
- "Use this when the user explicitly asks to change the speaking style, tone, or response persona. "
- "If the user asks for a vague style and you are not sure which persona matches best, call query_personas first."
- )
- args_schema: Type[BaseModel] = SwitchPersonaInput
-
- def get_tool_message(self, **kwargs) -> str:
- persona_id = kwargs.get("persona_id") or "未知人格"
- return f"切换人格: {persona_id}"
-
- async def run(self, persona_id: str, **kwargs) -> str:
- logger.info("执行工具: %s, 参数: persona_id=%s", self.name, persona_id)
- try:
- runtime_config = agent_runtime_manager.set_active_persona(persona_id)
- payload = {
- "success": True,
- "active_persona": runtime_config.active_persona,
- "persona": runtime_config.persona.to_dict(is_active=True),
- "message": f"已切换为人格 `{runtime_config.active_persona}`",
- }
- return json.dumps(payload, ensure_ascii=False, indent=2)
- except Exception as e: # noqa: BLE001
- logger.error("切换人格失败: %s", e, exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"切换人格时发生错误: {str(e)}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/test_site.py b/app/agent/tools/impl/test_site.py
deleted file mode 100644
index 406dc2ae2..000000000
--- a/app/agent/tools/impl/test_site.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""测试站点连通性工具"""
-
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.site import SiteChain
-from app.domain import site as site_rules
-from app.runtime.log import logger
-
-
-class TestSiteInput(BaseModel):
- """测试站点连通性工具的输入参数模型"""
- site_identifier: int = Field(..., description="Site ID to test (can be obtained from query_sites tool)")
-
-
-class TestSiteTool(MoviePilotTool):
- name: str = "test_site"
- tags: list[str] = [
- ToolTag.Read,
- ToolTag.Site,
- ]
- description: str = "Test site connectivity and availability. This will check if a site is accessible and can be logged in. Accepts site ID only."
- args_schema: Type[BaseModel] = TestSiteInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据测试参数生成友好的提示消息"""
- site_identifier = kwargs.get("site_identifier")
- return f"测试站点连通性: {site_identifier}"
-
- def _test_site_sync(
- self,
- site_identifier: int,
- ) -> tuple[Optional[str], Optional[str], bool, str]:
- """在同步线程里执行站点联通测试,避免网络请求卡住事件循环。"""
- site = self.data.sites.get(site_identifier)
- if not site:
- return None, None, False, f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点"
-
- domain = site.domain or site_rules.extract_domain(site.url)
- status, message = SiteChain().test(domain)
- return site.name, domain, status, message
-
- async def run(self, site_identifier: int, **kwargs) -> str:
- logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}")
-
- try:
- site_name, site_domain, status, message = await self.run_blocking(
- "site", self._test_site_sync, site_identifier
- )
- if not site_name:
- return message
- if status:
- return f"站点连通性测试成功:{site_name} ({site_domain})\n{message}"
- else:
- return f"站点连通性测试失败:{site_name} ({site_domain})\n{message}"
- except Exception as e:
- logger.error(f"测试站点连通性失败: {e}", exc_info=True)
- return f"测试站点连通性时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/transfer_file.py b/app/agent/tools/impl/transfer_file.py
deleted file mode 100644
index 816e5ed6f..000000000
--- a/app/agent/tools/impl/transfer_file.py
+++ /dev/null
@@ -1,267 +0,0 @@
-"""整理文件或目录工具"""
-
-from pathlib import Path
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.domain.media import normalize_music_type
-from app.runtime.log import logger
-from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType
-from app.schemas.workflow import FileItem
-
-
-class TransferFileInput(BaseModel):
- """整理文件或目录工具的输入参数模型"""
-
- file_path: str = Field(
- ...,
- description="Path to the file or directory to transfer (e.g., '/path/to/file.mkv' or '/path/to/directory')",
- )
- storage: Optional[str] = Field(
- "local",
- description="Storage type of the source file (default: 'local', can be 'smb', 'alist', etc.)",
- )
- target_path: Optional[str] = Field(
- None,
- description="Target path for the transferred file/directory (optional, uses default library path if not specified)",
- )
- target_storage: Optional[str] = Field(
- None,
- description="Target storage type (optional, uses default storage if not specified)",
- )
- media_type: Optional[str] = Field(None, description="Allowed values: movie, tv, music")
- music_type: Optional[str] = Field(
- None,
- description="For music: recording for one audio file or album for one album directory. Artists cannot be transferred",
- )
- media_source: Optional[MediaSource] = Field(
- None,
- description="Media metadata source; for music use the MusicBrainz recording or album source from search_media",
- )
- media_id: Optional[str] = Field(
- None,
- description="Native ID for media_source; an album ID organizes the directory as one album",
- )
- season: Optional[int] = Field(
- None, description="Season number for TV shows (optional)"
- )
- transfer_type: Optional[str] = Field(
- None,
- description="Transfer mode: 'move' to move files, 'copy' to copy files, 'link' for hard link, 'softlink' for symbolic link (optional, uses default mode if not specified)",
- )
- background: Optional[bool] = Field(
- False,
- description="Whether to run transfer in background (default: False, runs synchronously)",
- )
-
-
-class TransferFileTool(MoviePilotTool):
- """通过统一整理链将影视或音乐文件写入媒体库。"""
-
- name: str = "transfer_file"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Transfer,
- ToolTag.Library,
- ToolTag.File,
- ToolTag.Admin,
- ]
- description: str = (
- "Transfer/organize movie, TV, or music files through MoviePilot's configured library pipeline. "
- "For a complete album, pass the album directory once with media_type='music', music_type='album', "
- "and its source-native ID; for one recording, pass one audio file with music_type='recording'."
- )
- args_schema: Type[BaseModel] = TransferFileInput
- require_admin: bool = True
-
- @staticmethod
- def _get_fileitem_type(
- file_path: str,
- storage: Optional[str] = "local",
- music_type: Optional[str] = None,
- ) -> str:
- """
- 判断待整理路径的文件类型。
-
- :param file_path: 已规范化的源文件或目录路径
- :param storage: 源存储类型
- :return: ``dir`` 或 ``file``
- """
- if (storage or "local") == "local" and Path(file_path).is_dir():
- return "dir"
- if (storage or "local") != "local" and music_type == MUSIC_ENTITY_ALBUM:
- # 远程存储无法 stat,显式专辑语义比路径尾斜杠更可靠。
- return "dir"
- return "dir" if file_path.endswith("/") else "file"
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据整理参数生成友好的提示消息"""
- file_path = kwargs.get("file_path", "")
- media_type = kwargs.get("media_type")
- transfer_type = kwargs.get("transfer_type")
- background = kwargs.get("background", False)
-
- message = f"整理文件: {file_path}"
- if media_type:
- message += f" [{media_type}]"
- if transfer_type:
- transfer_map = {
- "move": "移动",
- "copy": "复制",
- "link": "硬链接",
- "softlink": "软链接",
- }
- message += f" 模式: {transfer_map.get(transfer_type, transfer_type)}"
- if background:
- message += " [后台运行]"
-
- return message
-
- @staticmethod
- def _transfer_file_sync(
- file_path: str,
- storage: Optional[str] = "local",
- target_path: Optional[str] = None,
- target_storage: Optional[str] = None,
- media_type: Optional[str] = None,
- music_type: Optional[str] = None,
- media_source: Optional[MediaSource] = None,
- media_id: Optional[str] = None,
- season: Optional[int] = None,
- transfer_type: Optional[str] = None,
- background: Optional[bool] = False,
- ) -> str:
- """
- 文件整理链路包含大量同步磁盘与外部服务调用,需要在线程池中运行。
- """
- if not file_path:
- return "错误:必须提供文件或目录路径"
-
- if storage == "local":
- if not file_path.startswith("/") and not (
- len(file_path) > 1 and file_path[1] == ":"
- ):
- file_path = str(Path(file_path).resolve())
- elif not file_path.startswith("/"):
- file_path = "/" + file_path
-
- media_type_enum = None
- if media_type:
- media_type_enum = MediaType.from_agent(media_type)
- if not media_type_enum:
- return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
- normalized_music_type = None
- if music_type:
- normalized_music_type = normalize_music_type(
- music_type,
- allow_artist=False,
- )
- if not normalized_music_type:
- return (
- f"错误:无效的音乐实体类型 '{music_type}',"
- "支持的类型:'recording', 'album'"
- )
- if media_type_enum != MediaType.MUSIC:
- return "错误:music_type 仅能与 media_type='music' 一起使用"
- if bool(media_source) != bool(media_id):
- return "错误:media_source 和 media_id 必须同时提供"
- if media_type_enum == MediaType.MUSIC and season is not None:
- return "错误:音乐没有季号,整理音乐时不能传入 season"
-
- fileitem = FileItem(
- storage=storage or "local",
- path=file_path,
- type=TransferFileTool._get_fileitem_type(
- file_path,
- storage,
- normalized_music_type,
- ),
- )
- if (
- media_type_enum == MediaType.MUSIC
- and normalized_music_type == MUSIC_ENTITY_ALBUM
- and fileitem.type != "dir"
- ):
- return "错误:专辑必须按一个目录整理,不能把单个音频文件作为整张专辑"
- if (
- media_type_enum == MediaType.MUSIC
- and normalized_music_type == MUSIC_ENTITY_RECORDING
- and fileitem.type != "file"
- ):
- return "错误:单曲必须按一个音频文件整理,不能把目录作为一首单曲"
- target_path_obj = Path(target_path) if target_path else None
-
- from app.chain.transfer.facade import TransferChain
-
- state, errormsg = TransferChain().manual_transfer(
- fileitem=fileitem,
- target_storage=target_storage,
- target_path=target_path_obj,
- media_source=media_source,
- media_id=media_id,
- mtype=media_type_enum,
- season=season,
- transfer_type=transfer_type,
- background=background,
- )
-
- if state:
- if background:
- return f"整理任务已提交到后台运行:{file_path}"
- return f"整理成功:{file_path}"
-
- if isinstance(errormsg, list):
- error_text = f"整理完成,{len(errormsg)} 个文件转移失败"
- if errormsg:
- error_text += ":\n" + "\n".join(str(e) for e in errormsg[:5])
- if len(errormsg) > 5:
- error_text += f"\n... 还有 {len(errormsg) - 5} 个错误"
- else:
- error_text = str(errormsg)
- return f"整理失败:{error_text}"
-
- async def run(
- self,
- file_path: str,
- storage: Optional[str] = "local",
- target_path: Optional[str] = None,
- target_storage: Optional[str] = None,
- media_type: Optional[str] = None,
- music_type: Optional[str] = None,
- media_source: Optional[MediaSource] = None,
- media_id: Optional[str] = None,
- season: Optional[int] = None,
- transfer_type: Optional[str] = None,
- background: Optional[bool] = False,
- **kwargs,
- ) -> str:
- """在线程池中执行文件整理并返回最终状态。"""
- logger.info(
- f"执行工具: {self.name}, 参数: file_path={file_path}, storage={storage}, target_path={target_path}, "
- f"target_storage={target_storage}, media_type={media_type}, music_type={music_type}, "
- f"media_source={media_source}, media_id={media_id}, "
- f"season={season}, transfer_type={transfer_type}, background={background}"
- )
-
- try:
- return await self.run_blocking(
- "storage",
- self._transfer_file_sync,
- file_path,
- storage,
- target_path,
- target_storage,
- media_type,
- music_type,
- media_source,
- media_id,
- season,
- transfer_type,
- background,
- )
- except Exception as e:
- logger.error(f"整理文件失败: {e}", exc_info=True)
- return f"整理文件时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/uninstall_plugin.py b/app/agent/tools/impl/uninstall_plugin.py
deleted file mode 100644
index 53c79937f..000000000
--- a/app/agent/tools/impl/uninstall_plugin.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""卸载插件工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._plugin_tool_utils import (
- list_installed_plugins,
- summarize_plugin,
- uninstall_plugin_runtime,
-)
-from app.runtime.log import logger
-
-
-class UninstallPluginInput(BaseModel):
- """卸载插件工具的输入参数模型"""
-
- plugin_id: str = Field(
- ...,
- description="Exact plugin ID to uninstall. Use query_installed_plugins first to find the correct plugin_id.",
- )
-
-
-class UninstallPluginTool(MoviePilotTool):
- name: str = "uninstall_plugin"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Uninstall an installed plugin by exact plugin_id. "
- "Use query_installed_plugins first when you need filtering or discovery."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = UninstallPluginInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- plugin_id = kwargs.get("plugin_id")
- return f"卸载插件: {plugin_id or '未知插件'}"
-
- async def run(
- self,
- plugin_id: str,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
-
- try:
- plugins = list_installed_plugins()
- if not plugins:
- return json.dumps(
- {"success": False, "message": "当前没有已安装的插件"},
- ensure_ascii=False,
- )
-
- candidate = next((plugin for plugin in plugins if plugin.id == plugin_id), None)
- if not candidate:
- return json.dumps(
- {
- "success": False,
- "message": f"未找到已安装插件: {plugin_id}。请先调用 query_installed_plugins 确认 plugin_id。",
- },
- ensure_ascii=False,
- )
-
- cleanup_result = await uninstall_plugin_runtime(candidate.id)
- return json.dumps(
- {
- "success": True,
- "message": f"插件 {candidate.id} 已卸载",
- "plugin": summarize_plugin(candidate),
- **cleanup_result,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as e:
- logger.error(f"卸载插件失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"卸载插件时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/update_agent_task.py b/app/agent/tools/impl/update_agent_task.py
deleted file mode 100644
index 711634c65..000000000
--- a/app/agent/tools/impl/update_agent_task.py
+++ /dev/null
@@ -1,219 +0,0 @@
-import json
-from datetime import datetime, timedelta
-from typing import Literal, Optional, Type
-
-import pytz
-from pydantic import BaseModel, Field, model_validator
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.agenttask import agent_task_to_dict
-from app.runtime.scheduling import TimerUtils
-from app.runtime.settings import get_runtime_setting
-
-
-class UpdateAgentTaskInput(BaseModel):
- """更新 Agent 自主定时任务的输入参数。"""
-
- task_id: int = Field(..., ge=1, description="ID of the task to update.")
- name: Optional[str] = Field(None, min_length=1, max_length=100)
- content: Optional[str] = Field(None, min_length=1, max_length=10000)
- trigger_type: Optional[Literal["date", "cron"]] = Field(
- None,
- description="New trigger type. Must be provided together with trigger.",
- )
- trigger: Optional[str] = Field(
- None,
- min_length=1,
- max_length=200,
- description="New ISO 8601 date or five-field cron expression.",
- )
- delay_minutes: Optional[int] = Field(
- None,
- ge=1,
- le=525600,
- description=(
- "For a one-time date task expressed as 'in N minutes', provide this instead "
- "of trigger together with trigger_type='date'."
- ),
- )
- enabled: Optional[bool] = Field(
- None,
- description="Set false to pause the task or true to resume it.",
- )
-
- @model_validator(mode="after")
- def validate_update(self) -> "UpdateAgentTaskInput":
- """校验更新内容和触发参数组合。"""
- if self.name is not None:
- self.name = self.name.strip()
- if not self.name:
- raise ValueError("name 不能只包含空白字符")
- if self.content is not None:
- self.content = self.content.strip()
- if not self.content:
- raise ValueError("content 不能只包含空白字符")
- has_schedule_update = any(
- value is not None
- for value in (self.trigger_type, self.trigger, self.delay_minutes)
- )
- if has_schedule_update:
- if self.trigger_type is None:
- raise ValueError("修改触发配置时必须提供 trigger_type")
- if self.trigger_type == "date":
- if self.delay_minutes is not None:
- # 保持校验幂等,具体绝对时间在更新调度前只计算一次。
- self.trigger = None
- elif self.trigger is None:
- raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
- elif self.trigger is None or self.delay_minutes is not None:
- raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
- if all(
- value is None
- for value in (
- self.name,
- self.content,
- self.trigger_type,
- self.enabled,
- )
- ):
- raise ValueError("至少需要提供一个要更新的字段")
- return self
-
-
-class UpdateAgentTaskTool(MoviePilotTool):
- """修改、暂停或恢复 Agent 自主定时任务。"""
-
- name: str = "update_agent_task"
- tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
- description: str = (
- "Update an autonomous agent task's name, instructions, exact date or cron "
- "trigger, relative delay_minutes, or enabled state. Use enabled=false to pause "
- "and enabled=true to resume."
- )
- args_schema: Type[BaseModel] = UpdateAgentTaskInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs: object) -> Optional[str]:
- """生成更新定时任务的提示消息。"""
- return f"更新自主定时任务:{kwargs.get('task_id', '')}"
-
- def _update_task(
- self,
- payload: UpdateAgentTaskInput,
- ) -> Optional[dict[str, object]]:
- """更新当前用户的任务并刷新运行时调度。"""
- from app.application.scheduling import update_agent_task_job
-
- oper = self.data.tasks
- task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
- if not task:
- return None
- if task.last_status == "running":
- return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"}
-
- has_schedule_update = payload.trigger_type is not None
- trigger_type = payload.trigger_type or task.trigger_type
- trigger_value = payload.trigger
- if trigger_type == "date" and payload.delay_minutes is not None:
- timezone = pytz.timezone(get_runtime_setting('TZ'))
- trigger_value = (
- datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)
- ).isoformat(timespec="seconds")
- if trigger_value is None:
- trigger_value = (
- task.cron_expression if trigger_type == "cron" else task.run_at
- )
- normalized_type = trigger_type
- normalized_trigger = trigger_value
- validates_existing_date_schedule = bool(
- payload.enabled
- and task.last_status != "interrupted"
- and trigger_type == "date"
- )
- if has_schedule_update or validates_existing_date_schedule:
- if trigger_value is None:
- raise ValueError("Agent 定时任务缺少触发配置")
- normalized_type, normalized_trigger = TimerUtils.normalize_schedule_trigger(
- trigger_type=trigger_type,
- trigger_value=trigger_value,
- timezone_name=get_runtime_setting('TZ'),
- require_future=bool(
- trigger_type == "date"
- and (
- task.last_status == "interrupted"
- or (task.enabled if payload.enabled is None else payload.enabled)
- )
- ),
- )
-
- update_payload: dict[str, object] = {}
- if payload.name is not None:
- update_payload["name"] = payload.name.strip()
- if payload.content is not None:
- update_payload["content"] = payload.content.strip()
- if has_schedule_update:
- update_payload.update(
- {
- "trigger_type": normalized_type,
- "cron_expression": (
- normalized_trigger if normalized_type == "cron" else None
- ),
- "run_at": normalized_trigger if normalized_type == "date" else None,
- "last_status": "waiting",
- "last_result": None,
- }
- )
- if payload.enabled is not None:
- update_payload["enabled"] = payload.enabled
- if payload.enabled and task.last_status != "interrupted":
- update_payload["last_status"] = "waiting"
-
- updated = oper.update(
- task_id=payload.task_id,
- payload=update_payload,
- user_id=str(self._user_id),
- )
- if not updated:
- current = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
- if current and current.last_status == "running":
- return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"}
- return None
- next_run_at = update_agent_task_job(payload.task_id)
- updated_task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
- if updated_task is None:
- return None
- return agent_task_to_dict(
- updated_task,
- next_run_at=next_run_at,
- timezone=get_runtime_setting('TZ'),
- )
-
- async def run(
- self,
- task_id: int,
- name: Optional[str] = None,
- content: Optional[str] = None,
- trigger_type: Optional[str] = None,
- trigger: Optional[str] = None,
- delay_minutes: Optional[int] = None,
- enabled: Optional[bool] = None,
- **kwargs: object,
- ) -> str:
- """更新 Agent 自主定时任务。"""
- payload = UpdateAgentTaskInput(
- task_id=task_id,
- name=name,
- content=content,
- trigger_type=trigger_type,
- trigger=trigger,
- delay_minutes=delay_minutes,
- enabled=enabled,
- )
- task = await self.run_blocking("db", self._update_task, payload)
- if not task:
- return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
- error = task.get("error")
- if error:
- return str(error)
- return json.dumps(task, ensure_ascii=False, indent=2)
diff --git a/app/agent/tools/impl/update_custom_filter_rule.py b/app/agent/tools/impl/update_custom_filter_rule.py
deleted file mode 100644
index 564ee232a..000000000
--- a/app/agent/tools/impl/update_custom_filter_rule.py
+++ /dev/null
@@ -1,211 +0,0 @@
-"""更新自定义过滤规则工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.impl._filter_rule_utils import (
- collect_custom_rule_group_refs,
- get_custom_rules,
- get_rule_groups,
- normalize_custom_rule,
- publish_rule_config_changed,
- replace_rule_id_in_rule_string,
- save_system_config,
- serialize_custom_rule,
-)
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class UpdateCustomFilterRuleInput(BaseModel):
- """更新自定义过滤规则工具的输入参数模型"""
-
- current_rule_id: str = Field(
- ..., description="Existing custom rule ID to update."
- )
- new_rule_id: Optional[str] = Field(
- None,
- description="New rule ID. If omitted, keep the original rule ID.",
- )
- name: Optional[str] = Field(
- None, description="New display name. If omitted, keep the original name."
- )
- include: Optional[str] = Field(
- None,
- description="New include regex. Pass an empty string to clear it.",
- )
- exclude: Optional[str] = Field(
- None,
- description="New exclude regex. Pass an empty string to clear it.",
- )
- size_range: Optional[str] = Field(
- None,
- description="New size range in MB. Pass an empty string to clear it.",
- )
- seeders: Optional[str] = Field(
- None,
- description="New minimum seeder count. Pass an empty string to clear it.",
- )
- publish_time: Optional[str] = Field(
- None,
- description="New publish-time filter in minutes. Pass an empty string to clear it.",
- )
-
-
-class UpdateCustomFilterRuleTool(MoviePilotTool):
- name: str = "update_custom_filter_rule"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.FilterRule,
- ToolTag.Admin,
- ]
- description: str = (
- "Update an existing custom filter rule. "
- "If the rule ID is renamed, all rule groups that reference the old ID are updated automatically."
- )
- args_schema: Type[BaseModel] = UpdateCustomFilterRuleInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- current_rule_id = kwargs.get("current_rule_id", "")
- new_rule_id = kwargs.get("new_rule_id")
- if new_rule_id and new_rule_id != current_rule_id:
- return f"更新自定义过滤规则 {current_rule_id} -> {new_rule_id}"
- return f"更新自定义过滤规则 {current_rule_id}"
-
- async def run(
- self,
- current_rule_id: str,
- new_rule_id: Optional[str] = None,
- name: Optional[str] = None,
- include: Optional[str] = None,
- exclude: Optional[str] = None,
- size_range: Optional[str] = None,
- seeders: Optional[str] = None,
- publish_time: Optional[str] = None,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}, current_rule_id={current_rule_id}")
-
- try:
- custom_rules = get_custom_rules()
- rule_map = {rule.id: rule for rule in custom_rules if rule.id}
- current_rule = rule_map.get(current_rule_id)
- if not current_rule:
- return json.dumps(
- {
- "success": False,
- "message": f"自定义过滤规则 '{current_rule_id}' 不存在",
- },
- ensure_ascii=False,
- )
-
- updated_rule = normalize_custom_rule(
- rule_id=new_rule_id or current_rule.id,
- name=name if name is not None else current_rule.name,
- include=include if include is not None else current_rule.include,
- exclude=exclude if exclude is not None else current_rule.exclude,
- size_range=(
- size_range if size_range is not None else current_rule.size_range
- ),
- seeders=seeders if seeders is not None else current_rule.seeders,
- publish_time=(
- publish_time
- if publish_time is not None
- else current_rule.publish_time
- ),
- existing_rules=custom_rules,
- original_rule_id=current_rule.id,
- )
-
- expected_custom_rules = [
- rule.model_dump(exclude_none=True) for rule in custom_rules
- ]
- final_rules = [
- updated_rule if rule.id == current_rule.id else rule
- for rule in custom_rules
- ]
- custom_rule_definitions = [
- rule.model_dump(exclude_none=True) for rule in final_rules
- ]
-
- rule_groups = get_rule_groups()
- expected_rule_groups = [
- group.model_dump(exclude_none=True) for group in rule_groups
- ]
- updated_rule_groups = rule_groups
- renamed_group_refs = []
- if updated_rule.id != current_rule.id:
- updated_rule_groups = []
- for group in rule_groups:
- if not group.rule_string:
- updated_rule_groups.append(group)
- continue
- new_rule_string = replace_rule_id_in_rule_string(
- group.rule_string,
- current_rule.id,
- updated_rule.id,
- )
- if new_rule_string == group.rule_string:
- updated_rule_groups.append(group)
- continue
- renamed_group_refs.append(group.name)
- updated_rule_groups.append(
- group.model_copy(update={"rule_string": new_rule_string})
- )
-
- rule_group_definitions = [
- group.model_dump(exclude_none=True)
- for group in updated_rule_groups
- ]
- async with self.data.async_rule_group_mutation_scope() as mutation:
- await mutation.apply(
- rule_group_definitions,
- expected_rule_groups=expected_rule_groups,
- custom_rules=custom_rule_definitions,
- expected_custom_rules=expected_custom_rules,
- )
- await publish_rule_config_changed(
- SystemConfigKey.CustomFilterRules,
- custom_rule_definitions,
- )
- await publish_rule_config_changed(
- SystemConfigKey.UserFilterRuleGroups,
- rule_group_definitions,
- )
- else:
- await save_system_config(
- SystemConfigKey.CustomFilterRules,
- custom_rule_definitions,
- )
-
- updated_refs = collect_custom_rule_group_refs(
- updated_rule_groups,
- [updated_rule.id],
- )
- return json.dumps(
- {
- "success": True,
- "message": f"已更新自定义过滤规则 {updated_rule.id}",
- "custom_rule": serialize_custom_rule(
- updated_rule,
- updated_refs.get(updated_rule.id),
- ),
- "rule_groups_updated_for_rule_id_rename": renamed_group_refs,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"更新自定义过滤规则失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"更新自定义过滤规则失败: {exc}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/update_custom_identifiers.py b/app/agent/tools/impl/update_custom_identifiers.py
deleted file mode 100644
index d04852daa..000000000
--- a/app/agent/tools/impl/update_custom_identifiers.py
+++ /dev/null
@@ -1,112 +0,0 @@
-"""更新自定义识别词工具"""
-
-import json
-from typing import List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.domain.metainfo import clear_rust_parse_options_cache
-from app.application.configuration import get_configured_system_config
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class UpdateCustomIdentifiersInput(BaseModel):
- """更新自定义识别词工具的输入参数模型"""
-
- identifiers: List[str] = Field(
- ...,
- description=(
- "The complete list of custom identifier rules to save. "
- "This REPLACES the entire existing list. "
- "Always query existing identifiers first, merge new rules, then pass the full list. "
- "These rules are global and affect future recognition for all torrents/files. "
- "When adding a rule for a user-provided sample, prefer narrow regex patterns that include "
- "sample-specific anchors such as the title alias, year, season/episode marker, group tag, "
- "resolution, or other distinctive fragments. Avoid overly broad patterns like bare generic "
- "tags, pure episode numbers, or common release words unless the user explicitly wants a global rule."
- ),
- )
-
-
-class UpdateCustomIdentifiersTool(MoviePilotTool):
- name: str = "update_custom_identifiers"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.FilterRule,
- ToolTag.Admin,
- ]
- description: str = (
- "Update the full list of custom identifiers (自定义识别词) used for preprocessing torrent/file names. "
- "This tool REPLACES all existing identifier rules with the provided list. "
- "IMPORTANT: Always use 'query_custom_identifiers' first to get existing rules, "
- "then merge new rules into the list before calling this tool to avoid accidentally deleting existing rules. "
- "IMPORTANT: New identifier rules are global. When the rule is created from a specific torrent/file name, "
- "make the regex as narrow as possible and include distinctive elements from that sample so unrelated titles "
- "are not affected. Prefer contextual replacements with capture groups/backreferences over bare block words "
- "when a generic word like REPACK, WEB-DL, 1080p, 字幕, or a simple episode marker would otherwise match too broadly. "
- "Supported rule formats (spaces around operators are required): "
- "1) Block word: just the word/regex to remove; "
- "2) Replacement: '被替换词 => 替换词'; "
- "3) Episode offset: '前定位词 <> 后定位词 >> EP±N'; "
- "4) Combined: '被替换词 => 替换词 && 前定位词 <> 后定位词 >> EP±N'; "
- "Lines starting with '#' are comments. "
- "The replacement target supports: "
- "{[tmdbid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]}; "
- "tmdbid may be replaced with doubanid, bangumiid, or anilistid; "
- "g is an optional episode group ID for TV recognition."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = UpdateCustomIdentifiersInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- identifiers = kwargs.get("identifiers", [])
- return f"更新自定义识别词(共 {len(identifiers)} 条规则)"
-
- async def run(self, identifiers: List[str] = None, **kwargs) -> str:
- logger.info(
- f"执行工具: {self.name}, 规则数量: {len(identifiers) if identifiers else 0}"
- )
- try:
- if identifiers is None:
- return json.dumps(
- {"success": False, "message": "必须提供 identifiers 参数"},
- ensure_ascii=False,
- )
-
- # 过滤空字符串
- identifiers = [i for i in identifiers if i is not None]
-
- system_config_oper = get_configured_system_config()
-
- # 保存
- value = identifiers if identifiers else None
- success = await system_config_oper.async_set(
- SystemConfigKey.CustomIdentifiers, value
- )
- if success:
- clear_rust_parse_options_cache()
- return json.dumps(
- {
- "success": True,
- "message": f"自定义识别词已更新,共 {len(identifiers)} 条规则",
- "count": len(identifiers),
- "identifiers": identifiers,
- },
- ensure_ascii=False,
- indent=2,
- )
- else:
- return json.dumps(
- {"success": False, "message": "保存自定义识别词失败"},
- ensure_ascii=False,
- )
- except Exception as e:
- logger.error(f"更新自定义识别词失败: {e}")
- return json.dumps(
- {"success": False, "message": f"更新自定义识别词时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/update_download_tasks.py b/app/agent/tools/impl/update_download_tasks.py
deleted file mode 100644
index c5ad5111c..000000000
--- a/app/agent/tools/impl/update_download_tasks.py
+++ /dev/null
@@ -1,319 +0,0 @@
-"""更新下载任务工具"""
-
-import json
-from typing import Any, Dict, List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.download import DownloadChain
-from app.application.directory import validate_download_save_path
-from app.runtime.log import logger
-
-
-class UpdateDownloadTasksInput(BaseModel):
- """更新下载任务工具的输入参数模型"""
-
- hash: str = Field(
- ..., description="Task hash (can be obtained from query_download_tasks tool)"
- )
- action: Optional[str] = Field(
- None,
- description="Action to perform on the task: 'start' to resume downloading, 'stop' to pause downloading.",
- )
- tags: Optional[List[str]] = Field(
- None,
- description="List of tags to add to the download task. Example: ['movie', 'hd']",
- )
- downloader: Optional[str] = Field(
- None,
- description="Name of specific downloader. If omitted, the tool resolves it from the task hash.",
- )
- download_limit: Optional[float] = Field(
- None,
- description="Per-task download speed limit in KB/s. Use 0 to disable the limit when supported.",
- )
- upload_limit: Optional[float] = Field(
- None,
- description="Per-task upload speed limit in KB/s. Use 0 to disable the limit when supported.",
- )
- trackers: Optional[List[str]] = Field(
- None,
- description="Tracker URL list to add or set, depending on downloader support.",
- )
- save_path: Optional[str] = Field(
- None,
- description="New save/download directory for the task, when supported.",
- )
- category: Optional[str] = Field(
- None,
- description="Downloader category to set, when supported.",
- )
- ratio_limit: Optional[float] = Field(
- None,
- description="Per-task share ratio limit, when supported.",
- )
- seeding_time_limit: Optional[int] = Field(
- None,
- description="Per-task seeding time limit in minutes, when supported.",
- )
-
-
-class UpdateDownloadTasksTool(MoviePilotTool):
- """更新下载任务工具"""
-
- name: str = "update_download_tasks"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Download,
- ToolTag.Admin,
- ]
- description: str = (
- "Update a download task by hash. Supports start/stop, adding tags, per-task "
- "upload/download speed limits, trackers, save directory, category, share ratio, "
- "and seeding time where the configured downloader supports them. "
- "Use query_download_tasks first to get the hash and current downloader."
- )
- args_schema: Type[BaseModel] = UpdateDownloadTasksInput
- require_admin: bool = True
-
- @staticmethod
- def _is_valid_hash(hash_value: str) -> bool:
- """校验下载任务Hash格式。"""
- return len(hash_value) == 40 and all(c in "0123456789abcdefABCDEF" for c in hash_value)
-
- @staticmethod
- def _normalize_non_empty_list(values: Optional[List[str]]) -> Optional[List[str]]:
- """清理字符串列表中的空值。"""
- if values is None:
- return None
- return [str(value).strip() for value in values if str(value).strip()]
-
- @staticmethod
- def _has_update_params(**kwargs) -> bool:
- """判断是否传入至少一个修改参数。"""
- return any(value is not None and value != [] for value in kwargs.values())
-
- @staticmethod
- def _build_result(operation: str, success: bool, message: str) -> Dict[str, Any]:
- """构造单项操作结果。"""
- return {
- "operation": operation,
- "success": success,
- "message": message,
- }
-
- @classmethod
- def _resolve_downloader(
- cls,
- download_chain: DownloadChain,
- hash_value: str,
- downloader: Optional[str],
- ) -> Optional[str]:
- """根据Hash解析下载任务所在下载器。"""
- if downloader:
- return downloader
- torrents = download_chain.list_torrents(
- hashs=[hash_value],
- include_all_tags=True,
- ) or []
- return getattr(torrents[0], "downloader", None) if torrents else None
-
- @classmethod
- def _update_download_sync(
- cls,
- hash_value: str,
- action: Optional[str] = None,
- tags: Optional[List[str]] = None,
- downloader: Optional[str] = None,
- download_limit: Optional[float] = None,
- upload_limit: Optional[float] = None,
- trackers: Optional[List[str]] = None,
- save_path: Optional[str] = None,
- category: Optional[str] = None,
- ratio_limit: Optional[float] = None,
- seeding_time_limit: Optional[int] = None,
- ) -> Dict[str, Any]:
- """同步更新下载任务,避免下载器 SDK 阻塞事件循环。"""
- download_chain = DownloadChain()
- resolved_downloader = cls._resolve_downloader(
- download_chain=download_chain,
- hash_value=hash_value,
- downloader=downloader,
- )
- if not resolved_downloader:
- return {
- "hash": hash_value,
- "downloader": downloader,
- "results": [
- cls._build_result("resolve_downloader", False, "未找到下载任务或下载器不可用")
- ],
- }
-
- if save_path is not None:
- try:
- save_path = validate_download_save_path(save_path)
- except ValueError:
- return {
- "hash": hash_value,
- "downloader": resolved_downloader,
- "results": [
- cls._build_result("save_path", False, "保存目录不在允许的下载目录范围内")
- ],
- }
-
- results = []
- if tags:
- tag_result = download_chain.set_torrents_tag(
- hashs=[hash_value], tags=tags, downloader=resolved_downloader
- )
- results.append(
- cls._build_result(
- "tags",
- bool(tag_result),
- f"成功设置标签:{', '.join(tags)}" if tag_result else "设置标签失败",
- )
- )
-
- if action:
- action_result = download_chain.set_downloading(
- hash_str=hash_value, oper=action, name=resolved_downloader
- )
- action_desc = "开始" if action == "start" else "暂停"
- results.append(
- cls._build_result(
- action,
- bool(action_result),
- f"成功{action_desc}下载任务" if action_result else f"{action_desc}下载任务失败",
- )
- )
-
- update_result = {}
- if cls._has_update_params(
- download_limit=download_limit,
- upload_limit=upload_limit,
- trackers=trackers,
- save_path=save_path,
- category=category,
- ratio_limit=ratio_limit,
- seeding_time_limit=seeding_time_limit,
- ):
- update_result = download_chain.update_torrent(
- hash_string=hash_value,
- downloader=resolved_downloader,
- download_limit=download_limit,
- upload_limit=upload_limit,
- tracker_list=trackers,
- save_path=save_path,
- category=category,
- ratio_limit=ratio_limit,
- seeding_time_limit=seeding_time_limit,
- )
- operation_messages = {
- "limits": "限速/做种策略",
- "trackers": "Tracker",
- "save_path": "保存目录",
- "category": "分类",
- }
- for operation, success in (update_result or {}).items():
- label = operation_messages.get(operation, operation)
- results.append(
- cls._build_result(
- operation,
- bool(success),
- f"{label}修改成功" if success else f"{label}修改失败或下载器不支持",
- )
- )
-
- return {
- "hash": hash_value,
- "downloader": resolved_downloader,
- "results": results,
- }
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据更新参数生成友好的提示消息。"""
- hash_value = kwargs.get("hash", "")
- parts = [f"更新下载任务: {hash_value}"]
- action = kwargs.get("action")
- if action == "start":
- parts.append("操作: 开始下载")
- elif action == "stop":
- parts.append("操作: 暂停下载")
- if kwargs.get("tags"):
- parts.append(f"标签: {', '.join(kwargs.get('tags'))}")
- if kwargs.get("download_limit") is not None or kwargs.get("upload_limit") is not None:
- parts.append("限速")
- if kwargs.get("trackers") is not None:
- parts.append("Tracker")
- if kwargs.get("save_path"):
- parts.append("保存目录")
- if kwargs.get("category") is not None:
- parts.append("分类")
- if kwargs.get("downloader"):
- parts.append(f"下载器: {kwargs.get('downloader')}")
- return " | ".join(parts)
-
- async def run(
- self,
- hash: str,
- action: Optional[str] = None,
- tags: Optional[List[str]] = None,
- downloader: Optional[str] = None,
- download_limit: Optional[float] = None,
- upload_limit: Optional[float] = None,
- trackers: Optional[List[str]] = None,
- save_path: Optional[str] = None,
- category: Optional[str] = None,
- ratio_limit: Optional[float] = None,
- seeding_time_limit: Optional[int] = None,
- **kwargs,
- ) -> str:
- """执行下载任务更新。"""
- logger.info(
- f"执行工具: {self.name}, 参数: hash={hash}, action={action}, tags={tags}, "
- f"downloader={downloader}, download_limit={download_limit}, upload_limit={upload_limit}, "
- f"trackers={trackers}, save_path={save_path}, category={category}, "
- f"ratio_limit={ratio_limit}, seeding_time_limit={seeding_time_limit}"
- )
- try:
- if not self._is_valid_hash(hash):
- return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
-
- tags = self._normalize_non_empty_list(tags)
- trackers = self._normalize_non_empty_list(trackers)
- if action and action not in ("start", "stop"):
- return f"参数错误:action 只支持 'start'(开始下载)或 'stop'(暂停下载),收到: '{action}'。"
- if not self._has_update_params(
- action=action,
- tags=tags,
- download_limit=download_limit,
- upload_limit=upload_limit,
- trackers=trackers,
- save_path=save_path,
- category=category,
- ratio_limit=ratio_limit,
- seeding_time_limit=seeding_time_limit,
- ):
- return "参数错误:至少需要指定一个要更新的字段。"
-
- result = await self.run_blocking(
- "downloader",
- self._update_download_sync,
- hash,
- action,
- tags,
- downloader,
- download_limit,
- upload_limit,
- trackers,
- save_path,
- category,
- ratio_limit,
- seeding_time_limit,
- )
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- logger.error(f"更新下载任务失败: {e}", exc_info=True)
- return f"更新下载任务时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/update_persona_definition.py b/app/agent/tools/impl/update_persona_definition.py
deleted file mode 100644
index ac8c09188..000000000
--- a/app/agent/tools/impl/update_persona_definition.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""更新人格定义工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.runtime import agent_runtime_manager
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-
-
-class UpdatePersonaDefinitionInput(BaseModel):
- """更新人格定义工具的输入参数模型。"""
-
- persona_id: str = Field(
- ...,
- description=(
- "Target persona to update. For existing personas this can be persona_id, label, or alias. "
- "For new personas, provide the new lowercase persona_id."
- ),
- )
- label: Optional[str] = Field(
- None,
- description="Optional new label shown to users, such as 默认 or 说明型.",
- )
- description: Optional[str] = Field(
- None,
- description="Optional short description of the persona's intended style.",
- )
- aliases: Optional[list[str]] = Field(
- None,
- description="Optional full replacement list of aliases for this persona.",
- )
- instructions: Optional[str] = Field(
- None,
- description=(
- "Optional full replacement body for PERSONA.md, excluding YAML frontmatter. "
- "Use this when the persona definition should be rewritten completely."
- ),
- )
- append_instructions: Optional[list[str]] = Field(
- None,
- description=(
- "Optional extra persona rules to append to the existing PERSONA body. "
- "Use this for small adjustments such as '回答更短' or '复杂问题给两步解释'."
- ),
- )
- create_if_missing: bool = Field(
- False,
- description="Whether to create a new runtime persona if the target persona does not already exist.",
- )
-
-
-class UpdatePersonaDefinitionTool(MoviePilotTool):
- name: str = "update_persona_definition"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Persona,
- ToolTag.Admin,
- ]
- description: str = (
- "Create or update a runtime persona definition (人格定义) without manually editing PERSONA.md files. "
- "Use this when the user explicitly asks to modify how a persona is defined, such as changing tone rules, "
- "rewriting the persona body, adjusting aliases, or creating a new persona."
- )
- args_schema: Type[BaseModel] = UpdatePersonaDefinitionInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> str:
- persona_id = kwargs.get("persona_id") or "未知人格"
- action = "创建/更新人格定义"
- return f"{action}: {persona_id}"
-
- async def run(
- self,
- persona_id: str,
- label: Optional[str] = None,
- description: Optional[str] = None,
- aliases: Optional[list[str]] = None,
- instructions: Optional[str] = None,
- append_instructions: Optional[list[str]] = None,
- create_if_missing: bool = False,
- **kwargs,
- ) -> str:
- logger.info("执行工具: %s, 参数: persona_id=%s", self.name, persona_id)
- if not any(
- value is not None
- for value in (label, description, aliases, instructions, append_instructions)
- ):
- return json.dumps(
- {
- "success": False,
- "message": "未提供任何要更新的人格定义字段。",
- },
- ensure_ascii=False,
- )
-
- try:
- persona, created = agent_runtime_manager.update_persona_definition(
- persona_id,
- label=label,
- description=description,
- aliases=aliases,
- instructions=instructions,
- append_instructions=append_instructions,
- create_if_missing=create_if_missing,
- )
- runtime_config = agent_runtime_manager.load_runtime_config()
- payload = {
- "success": True,
- "created": created,
- "active_persona": runtime_config.active_persona,
- "persona": persona.to_dict(
- is_active=persona.persona_id == runtime_config.active_persona
- ),
- "message": (
- f"已创建人格 `{persona.persona_id}`"
- if created
- else f"已更新人格 `{persona.persona_id}` 的定义"
- ),
- }
- return json.dumps(payload, ensure_ascii=False, indent=2)
- except Exception as e: # noqa: BLE001
- logger.error("更新人格定义失败: %s", e, exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"更新人格定义时发生错误: {str(e)}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/update_plugin_config.py b/app/agent/tools/impl/update_plugin_config.py
deleted file mode 100644
index bea02a67b..000000000
--- a/app/agent/tools/impl/update_plugin_config.py
+++ /dev/null
@@ -1,159 +0,0 @@
-"""修改插件配置工具"""
-
-import json
-from typing import Any, Dict, List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
-from app.application.plugin.runtime import get_plugin_manager
-from app.runtime.log import logger
-
-
-class UpdatePluginConfigInput(BaseModel):
- """修改插件配置工具的输入参数模型"""
-
- plugin_id: str = Field(
- ...,
- description="The plugin ID to update. Use query_plugin_config first to inspect the current config.",
- )
- updates: Optional[Dict[str, Any]] = Field(
- None,
- description=(
- "Config items to save. By default this tool merges these keys into the existing config "
- "instead of replacing the whole config."
- ),
- )
- remove_keys: Optional[List[str]] = Field(
- None,
- description="Optional config keys to remove from the saved plugin config.",
- )
- replace: Optional[bool] = Field(
- False,
- description=(
- "Whether to replace the entire saved config with 'updates'. "
- "Default false, which performs a partial merge update."
- ),
- )
-
-
-class UpdatePluginConfigTool(MoviePilotTool):
- name: str = "update_plugin_config"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Plugin,
- ToolTag.Admin,
- ]
- description: str = (
- "Update the saved configuration of an installed plugin. "
- "By default this performs a partial merge update and does NOT reload the plugin automatically. "
- "Call reload_plugin afterwards to apply the latest saved config to the running plugin."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = UpdatePluginConfigInput
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """生成友好的提示消息"""
- plugin_id = kwargs.get("plugin_id", "")
- replace = kwargs.get("replace", False)
- action = "覆盖插件配置" if replace else "修改插件配置"
- return f"{action}: {plugin_id}"
-
- @staticmethod
- async def _update_plugin_config(
- plugin_id: str,
- updates: Optional[Dict[str, Any]] = None,
- remove_keys: Optional[List[str]] = None,
- replace: bool = False,
- ) -> str:
- """
- 仅异步保存插件配置,不主动生效,让 Agent 可以先批量改完再显式重载插件。
- """
- plugin_info = get_plugin_snapshot(plugin_id)
- if not plugin_info:
- return json.dumps(
- {
- "success": False,
- "message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
- },
- ensure_ascii=False,
- )
-
- remove_keys = remove_keys or []
- if not replace and not updates and not remove_keys:
- return json.dumps(
- {"success": False, "message": "没有提供任何需要修改的配置项"},
- ensure_ascii=False,
- )
-
- plugin_manager = get_plugin_manager()
- with plugin_manager.mutation(f"更新插件 {plugin_id} 配置"):
- current_config = dict(plugin_manager.get_plugin_config(plugin_id) or {})
-
- # merge 模式以当前保存值为基准,replace 模式则从空配置开始重建。
- next_config = {} if replace else dict(current_config)
- if updates:
- next_config.update(updates)
- for key in remove_keys:
- next_config.pop(key, None)
-
- changed_keys = sorted(
- key
- for key in set(current_config.keys()) | set(next_config.keys())
- if current_config.get(key) != next_config.get(key)
- or (key in current_config) != (key in next_config)
- )
-
- if not await plugin_manager.async_save_plugin_config(
- plugin_id,
- next_config,
- ):
- return json.dumps(
- {
- "success": False,
- "message": f"保存插件 {plugin_id} 配置失败",
- },
- ensure_ascii=False,
- )
-
- return json.dumps(
- {
- "success": True,
- **plugin_info,
- "message": "插件配置已保存,请调用 reload_plugin 使最新配置生效",
- "replace": replace,
- "changed_keys": changed_keys,
- "removed_keys": remove_keys,
- "config_requires_reload": True,
- "previous_config": current_config,
- "saved_config": next_config,
- },
- ensure_ascii=False,
- indent=2,
- default=str,
- )
-
- async def run(
- self,
- plugin_id: str,
- updates: Optional[Dict[str, Any]] = None,
- remove_keys: Optional[List[str]] = None,
- replace: bool = False,
- **kwargs,
- ) -> str:
- logger.info(
- f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, replace={replace}"
- )
-
- try:
- return await self._update_plugin_config(
- plugin_id, updates, remove_keys, replace
- )
- except Exception as e:
- logger.error(f"修改插件配置失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"修改插件配置时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/update_rule_group.py b/app/agent/tools/impl/update_rule_group.py
deleted file mode 100644
index e07ce387c..000000000
--- a/app/agent/tools/impl/update_rule_group.py
+++ /dev/null
@@ -1,168 +0,0 @@
-"""更新过滤规则组工具。"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.impl._filter_rule_utils import (
- build_custom_rule_map,
- collect_rule_group_usages,
- get_builtin_rules,
- get_custom_rules,
- get_rule_groups,
- normalize_rule_group,
- publish_rule_config_changed,
- serialize_rule_group,
-)
-from app.agent.tools.tags import ToolTag
-from app.runtime.log import logger
-from app.schemas.types import SystemConfigKey
-
-
-class UpdateRuleGroupInput(BaseModel):
- """更新过滤规则组工具的输入参数模型"""
-
- current_name: str = Field(..., description="Existing rule group name to update.")
- new_name: Optional[str] = Field(
- None,
- description="New rule group name. If omitted, keep the original name.",
- )
- rule_string: Optional[str] = Field(
- None,
- description=(
- "New rule_string. If omitted, keep the original rule_string. "
- "Example: 'SPECSUB & CNVOI & 4K & !BLU > CNSUB & CNVOI & 4K & !BLU'."
- ),
- )
- media_type: Optional[str] = Field(
- None,
- description="New media type scope. Pass an empty string to clear it.",
- )
- category: Optional[str] = Field(
- None,
- description="New category. Pass an empty string to clear it.",
- )
-
-
-class UpdateRuleGroupTool(MoviePilotTool):
- name: str = "update_rule_group"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.FilterRule,
- ToolTag.Admin,
- ]
- description: str = (
- "Update a filter rule group. "
- "If the rule group name changes, its references in global search/subscription settings and per-subscription bindings are updated automatically. "
- "Before changing rule_string, first use query_builtin_filter_rules and query_custom_filter_rules to confirm valid rule IDs."
- )
- args_schema: Type[BaseModel] = UpdateRuleGroupInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- current_name = kwargs.get("current_name", "")
- new_name = kwargs.get("new_name")
- if new_name and new_name != current_name:
- return f"更新规则组 {current_name} -> {new_name}"
- return f"更新规则组 {current_name}"
-
- async def run(
- self,
- current_name: str,
- new_name: Optional[str] = None,
- rule_string: Optional[str] = None,
- media_type: Optional[str] = None,
- category: Optional[str] = None,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}, current_name={current_name}")
-
- try:
- rule_groups = get_rule_groups()
- expected_definitions = [
- group.model_dump(exclude_none=True) for group in rule_groups
- ]
- group_map = {group.name: group for group in rule_groups if group.name}
- current_group = group_map.get(current_name)
- if not current_group:
- return json.dumps(
- {
- "success": False,
- "message": f"规则组 '{current_name}' 不存在",
- },
- ensure_ascii=False,
- )
-
- available_rule_ids = set(get_builtin_rules().keys()) | set(
- build_custom_rule_map(get_custom_rules()).keys()
- )
- updated_group, _ = normalize_rule_group(
- name=new_name or current_group.name,
- rule_string=(
- rule_string
- if rule_string is not None
- else current_group.rule_string
- ),
- media_type=(
- media_type
- if media_type is not None
- else current_group.media_type
- ),
- category=(
- category if category is not None else current_group.category
- ),
- existing_groups=rule_groups,
- available_rule_ids=available_rule_ids,
- original_name=current_group.name,
- )
-
- final_groups = []
- for group in rule_groups:
- if group.name == current_group.name:
- final_groups.append(updated_group)
- else:
- final_groups.append(group)
-
- definitions = [
- group.model_dump(exclude_none=True) for group in final_groups
- ]
- async with self.data.async_rule_group_mutation_scope() as mutation:
- result = await mutation.apply(
- definitions,
- expected_rule_groups=expected_definitions,
- previous_name=current_group.name,
- current_name=updated_group.name,
- )
- await publish_rule_config_changed(
- SystemConfigKey.UserFilterRuleGroups,
- definitions,
- )
- reference_changes = result.to_dict()
-
- usage = await collect_rule_group_usages(
- self.data.subscriptions,
- [updated_group.name],
- )
- return json.dumps(
- {
- "success": True,
- "message": f"已更新规则组 {updated_group.name}",
- "rule_group": serialize_rule_group(
- updated_group, usage.get(updated_group.name)
- ),
- "reference_updates": reference_changes,
- },
- ensure_ascii=False,
- indent=2,
- )
- except Exception as exc:
- logger.error(f"更新规则组失败: {exc}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": f"更新规则组失败: {exc}",
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/update_site.py b/app/agent/tools/impl/update_site.py
deleted file mode 100644
index 882e53989..000000000
--- a/app/agent/tools/impl/update_site.py
+++ /dev/null
@@ -1,234 +0,0 @@
-"""更新站点工具"""
-
-import json
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.site.contract import SiteMutation
-from app.foundation import url as url_tools
-from app.runtime.events import eventmanager
-from app.runtime.log import logger
-from app.schemas.types import EventType
-
-
-class UpdateSiteInput(BaseModel):
- """更新站点工具的输入参数模型"""
-
- site_id: int = Field(
- ...,
- description="The ID of the site to update (can be obtained from query_sites tool)",
- )
- name: Optional[str] = Field(None, description="Site name (optional)")
- url: Optional[str] = Field(
- None, description="Site URL (optional, will be automatically formatted)"
- )
- pri: Optional[int] = Field(
- None,
- description="Site priority (optional, smaller value = higher priority, e.g., pri=1 has higher priority than pri=10)",
- )
- rss: Optional[str] = Field(None, description="RSS feed URL (optional)")
- cookie: Optional[str] = Field(None, description="Site cookie (optional)")
- ua: Optional[str] = Field(None, description="User-Agent string (optional)")
- apikey: Optional[str] = Field(None, description="API key (optional)")
- token: Optional[str] = Field(None, description="API token (optional)")
- proxy: Optional[int] = Field(
- None, description="Whether to use proxy: 0 for no, 1 for yes (optional)"
- )
- filter: Optional[str] = Field(
- None, description="Filter rule as regular expression (optional)"
- )
- note: Optional[str] = Field(None, description="Site notes/remarks (optional)")
- timeout: Optional[int] = Field(
- None, description="Request timeout in seconds (optional, default: 15)"
- )
- limit_interval: Optional[int] = Field(
- None, description="Rate limit interval in seconds (optional)"
- )
- limit_count: Optional[int] = Field(
- None, description="Rate limit count per interval (optional)"
- )
- limit_seconds: Optional[int] = Field(
- None, description="Rate limit seconds between requests (optional)"
- )
- is_active: Optional[bool] = Field(
- None,
- description="Whether site is active: True for enabled, False for disabled (optional)",
- )
- downloader: Optional[str] = Field(
- None, description="Downloader name for this site (optional)"
- )
-
-
-class UpdateSiteTool(MoviePilotTool):
- name: str = "update_site"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Site,
- ToolTag.Admin,
- ]
- description: str = "Update site configuration including URL, priority, authentication credentials (cookie, UA, API key), proxy settings, rate limits, and other site properties. Supports updating multiple site attributes at once. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
- args_schema: Type[BaseModel] = UpdateSiteInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据更新参数生成友好的提示消息"""
- site_id = kwargs.get("site_id")
- fields_updated = []
-
- if kwargs.get("name"):
- fields_updated.append("名称")
- if kwargs.get("url"):
- fields_updated.append("URL")
- if kwargs.get("pri") is not None:
- fields_updated.append("优先级")
- if kwargs.get("cookie"):
- fields_updated.append("Cookie")
- if kwargs.get("ua"):
- fields_updated.append("User-Agent")
- if kwargs.get("proxy") is not None:
- fields_updated.append("代理设置")
- if kwargs.get("is_active") is not None:
- fields_updated.append("启用状态")
- if kwargs.get("downloader"):
- fields_updated.append("下载器")
-
- if fields_updated:
- return f"更新站点 #{site_id}: {', '.join(fields_updated)}"
- return f"更新站点 #{site_id}"
-
- async def run(
- self,
- site_id: int,
- name: Optional[str] = None,
- url: Optional[str] = None,
- pri: Optional[int] = None,
- rss: Optional[str] = None,
- cookie: Optional[str] = None,
- ua: Optional[str] = None,
- apikey: Optional[str] = None,
- token: Optional[str] = None,
- proxy: Optional[int] = None,
- filter: Optional[str] = None,
- note: Optional[str] = None,
- timeout: Optional[int] = None,
- limit_interval: Optional[int] = None,
- limit_count: Optional[int] = None,
- limit_seconds: Optional[int] = None,
- is_active: Optional[bool] = None,
- downloader: Optional[str] = None,
- **kwargs,
- ) -> str:
- logger.info(f"执行工具: {self.name}, 参数: site_id={site_id}")
-
- try:
- repository = self.data.sites
- site = await repository.async_get(site_id)
- if not site:
- return json.dumps(
- {"success": False, "message": f"站点不存在: {site_id}"},
- ensure_ascii=False,
- )
-
- # 构建更新字典
- site_dict = {}
-
- # 基本信息
- if name is not None:
- site_dict["name"] = name
-
- # URL处理(需要校正格式)
- if url is not None:
- _scheme, _netloc = url_tools.split_netloc(url)
- site_dict["url"] = f"{_scheme}://{_netloc}/"
-
- if pri is not None:
- site_dict["pri"] = pri
- if rss is not None:
- site_dict["rss"] = rss
-
- # 认证信息
- if cookie is not None:
- site_dict["cookie"] = cookie
- if ua is not None:
- site_dict["ua"] = ua
- if apikey is not None:
- site_dict["apikey"] = apikey
- if token is not None:
- site_dict["token"] = token
-
- # 配置选项
- if proxy is not None:
- site_dict["proxy"] = proxy
- if filter is not None:
- site_dict["filter"] = filter
- if note is not None:
- site_dict["note"] = note
- if timeout is not None:
- site_dict["timeout"] = timeout
-
- # 流控设置
- if limit_interval is not None:
- site_dict["limit_interval"] = limit_interval
- if limit_count is not None:
- site_dict["limit_count"] = limit_count
- if limit_seconds is not None:
- site_dict["limit_seconds"] = limit_seconds
-
- # 状态和下载器
- if is_active is not None:
- site_dict["is_active"] = is_active
- if downloader is not None:
- site_dict["downloader"] = downloader
-
- # 如果没有要更新的字段
- if not site_dict:
- return json.dumps(
- {"success": False, "message": "没有提供要更新的字段"},
- ensure_ascii=False,
- )
-
- # 更新站点
- await repository.async_update(site_id, SiteMutation(site_dict))
-
- # 重新获取更新后的站点数据
- updated_site = await repository.async_get(site_id)
-
- # 发送站点更新事件
- await eventmanager.async_send_event(
- EventType.SiteUpdated,
- {"domain": updated_site.domain if updated_site else site.domain},
- )
-
- # 构建返回结果
- result = {
- "success": True,
- "message": f"站点 #{site_id} 更新成功",
- "site_id": site_id,
- "updated_fields": list(site_dict.keys()),
- }
-
- if updated_site:
- result["site"] = {
- "id": updated_site.id,
- "name": updated_site.name,
- "domain": updated_site.domain,
- "url": updated_site.url,
- "pri": updated_site.pri,
- "is_active": updated_site.is_active,
- "downloader": updated_site.downloader,
- "proxy": updated_site.proxy,
- "timeout": updated_site.timeout,
- }
-
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- except Exception as e:
- error_message = f"更新站点失败: {str(e)}"
- logger.error(f"更新站点失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": error_message, "site_id": site_id},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/update_site_cookie.py b/app/agent/tools/impl/update_site_cookie.py
deleted file mode 100644
index f6ffdea4f..000000000
--- a/app/agent/tools/impl/update_site_cookie.py
+++ /dev/null
@@ -1,103 +0,0 @@
-"""更新站点Cookie和UA工具"""
-
-from typing import Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.chain.site import SiteChain
-from app.runtime.log import logger
-
-
-class UpdateSiteCookieInput(BaseModel):
- """更新站点Cookie和UA工具的输入参数模型"""
-
- site_identifier: int = Field(
- ...,
- description="Site ID to update Cookie and User-Agent for (can be obtained from query_sites tool)",
- )
- username: str = Field(..., description="Site login username")
- password: str = Field(..., description="Site login password")
- two_step_code: Optional[str] = Field(
- None,
- description="Two-step verification code or secret key (optional, required for sites with 2FA enabled)",
- )
-
-
-class UpdateSiteCookieTool(MoviePilotTool):
- name: str = "update_site_cookie"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Site,
- ToolTag.Admin,
- ]
- description: str = "Update site Cookie and User-Agent by logging in with username and password. This tool can automatically obtain and update the site's authentication credentials. Supports two-step verification for sites that require it. Accepts site ID only."
- args_schema: Type[BaseModel] = UpdateSiteCookieInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据更新参数生成友好的提示消息"""
- site_identifier = kwargs.get("site_identifier")
- username = kwargs.get("username", "")
- two_step_code = kwargs.get("two_step_code")
-
- message = f"更新站点Cookie: {site_identifier} (用户: {username})"
- if two_step_code:
- message += " [需要两步验证]"
-
- return message
-
- def _update_site_cookie_sync(
- self,
- site_identifier: int,
- username: str,
- password: str,
- two_step_code: Optional[str] = None,
- ) -> tuple[Optional[str], bool, str]:
- """
- 在同步线程里执行站点登录和 Cookie 更新,避免网络登录阻塞协程。
- """
- site = self.data.sites.get(site_identifier)
- if not site:
- return None, False, f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点"
-
- status, message = SiteChain().update_cookie(
- site_info=site,
- username=username,
- password=password,
- two_step_code=two_step_code,
- )
- return site.name, status, message
-
- async def run(
- self,
- site_identifier: int,
- username: str,
- password: str,
- two_step_code: Optional[str] = None,
- **kwargs,
- ) -> str:
- logger.info(
- f"执行工具: {self.name}, 参数: site_identifier={site_identifier}, username={username}"
- )
-
- try:
- site_name, status, message = await self.run_blocking(
- "site",
- self._update_site_cookie_sync,
- site_identifier,
- username,
- password,
- two_step_code,
- )
- if not site_name:
- return message
-
- if status:
- return f"站点【{site_name}】Cookie和UA更新成功\n{message}"
- else:
- return f"站点【{site_name}】Cookie和UA更新失败\n错误原因:{message}"
- except Exception as e:
- logger.error(f"更新站点Cookie和UA失败: {e}", exc_info=True)
- return f"更新站点Cookie和UA时发生错误: {str(e)}"
diff --git a/app/agent/tools/impl/update_subscribe.py b/app/agent/tools/impl/update_subscribe.py
deleted file mode 100644
index d5cad27ec..000000000
--- a/app/agent/tools/impl/update_subscribe.py
+++ /dev/null
@@ -1,363 +0,0 @@
-"""更新订阅工具"""
-
-import json
-from typing import List, Optional, Type
-
-from pydantic import BaseModel, Field
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.application.subscription.mutation import SubscriptionActor
-from app.runtime.log import logger
-from app.schemas.common import JsonData
-from app.schemas.types import media_type_to_agent
-
-
-class UpdateSubscribeInput(BaseModel):
- """更新订阅工具的输入参数模型"""
-
- subscribe_id: int = Field(
- ...,
- description="The ID of the subscription to update (can be obtained from query_subscribes tool)",
- )
- name: Optional[str] = Field(None, description="Subscription name/title (optional)")
- year: Optional[str] = Field(None, description="Release year (optional)")
- season: Optional[int] = Field(
- None, description="Season number for TV shows (optional)"
- )
- total_episode: Optional[int] = Field(
- None, description="Total number of episodes (optional)"
- )
- lack_episode: Optional[int] = Field(
- None, description="Number of missing episodes (optional)"
- )
- start_episode: Optional[int] = Field(
- None, description="Starting episode number (optional)"
- )
- quality: Optional[str] = Field(
- None,
- description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')",
- )
- resolution: Optional[str] = Field(
- None,
- description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')",
- )
- effect: Optional[str] = Field(
- None,
- description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')",
- )
- audio_quality: Optional[str] = Field(None, description="Music quality tier filter")
- audio_format: Optional[str] = Field(None, description="Music audio-format regular expression")
- min_bitrate: Optional[int] = Field(None, description="Minimum music bitrate in bits per second")
- min_bit_depth: Optional[int] = Field(None, description="Minimum music bit depth")
- min_sample_rate: Optional[int] = Field(None, description="Minimum music sample rate in Hz")
- include: Optional[str] = Field(
- None, description="Include filter as regular expression (optional)"
- )
- exclude: Optional[str] = Field(
- None, description="Exclude filter as regular expression (optional)"
- )
- filter: Optional[str] = Field(
- None, description="Filter rule as regular expression (optional)"
- )
- state: Optional[str] = Field(
- None,
- description="Subscription state: 'R' for enabled, 'P' for pending, 'S' for paused (optional)",
- )
- sites: Optional[List[int]] = Field(
- None, description="List of site IDs to search from (optional)"
- )
- downloader: Optional[str] = Field(None, description="Downloader name (optional)")
- save_path: Optional[str] = Field(
- None, description="Save path for downloaded files (optional)"
- )
- best_version: Optional[int] = Field(
- None,
- description="Whether to upgrade to best version: 0 for no, 1 for yes (optional)",
- )
- best_version_full: Optional[int] = Field(
- None,
- description="For TV best-version subscriptions, only download full-season packs: 0 for no, 1 for yes (optional)",
- )
- custom_words: Optional[str] = Field(
- None, description="Custom recognition words (optional)"
- )
- media_category: Optional[str] = Field(
- None, description="Custom media category (optional)"
- )
- episode_group: Optional[str] = Field(
- None, description="Episode group ID (optional)"
- )
-
-
-class UpdateSubscribeTool(MoviePilotTool):
- """更新影视、单曲或专辑订阅的运行参数。"""
-
- name: str = "update_subscribe"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.Subscription,
- ToolTag.Admin,
- ]
- description: str = (
- "Update subscription filters, state, sites, downloader, save path, and other runtime settings. "
- "Episode fields are TV-only; music recording/album identity and expected album track count are metadata "
- "facts and are not changed by this tool."
- )
- args_schema: Type[BaseModel] = UpdateSubscribeInput
- require_admin: bool = True
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据更新参数生成友好的提示消息"""
- subscribe_id = kwargs.get("subscribe_id")
- fields_updated = []
-
- if kwargs.get("name"):
- fields_updated.append("名称")
- if kwargs.get("total_episode") is not None:
- fields_updated.append("总集数")
- if kwargs.get("lack_episode") is not None:
- fields_updated.append("缺失集数")
- if kwargs.get("quality"):
- fields_updated.append("质量过滤")
- if kwargs.get("resolution"):
- fields_updated.append("分辨率过滤")
- if kwargs.get("state"):
- state_map = {"R": "启用", "P": "禁用", "S": "暂停"}
- fields_updated.append(
- f"状态({state_map.get(kwargs.get('state'), kwargs.get('state'))})"
- )
- if kwargs.get("sites"):
- fields_updated.append("站点")
- if kwargs.get("downloader"):
- fields_updated.append("下载器")
-
- if fields_updated:
- return f"更新订阅 #{subscribe_id}: {', '.join(fields_updated)}"
- return f"更新订阅 #{subscribe_id}"
-
- async def run(
- self,
- subscribe_id: int,
- name: Optional[str] = None,
- year: Optional[str] = None,
- season: Optional[int] = None,
- total_episode: Optional[int] = None,
- lack_episode: Optional[int] = None,
- start_episode: Optional[int] = None,
- quality: Optional[str] = None,
- resolution: Optional[str] = None,
- effect: Optional[str] = None,
- audio_quality: Optional[str] = None,
- audio_format: Optional[str] = None,
- min_bitrate: Optional[int] = None,
- min_bit_depth: Optional[int] = None,
- min_sample_rate: Optional[int] = None,
- include: Optional[str] = None,
- exclude: Optional[str] = None,
- filter: Optional[str] = None,
- state: Optional[str] = None,
- sites: Optional[List[int]] = None,
- downloader: Optional[str] = None,
- save_path: Optional[str] = None,
- best_version: Optional[int] = None,
- best_version_full: Optional[int] = None,
- custom_words: Optional[str] = None,
- media_category: Optional[str] = None,
- episode_group: Optional[str] = None,
- **kwargs,
- ) -> str:
- """更新可变订阅字段并发送订阅调整事件。"""
- logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
-
- try:
- actor = SubscriptionActor(name="agent", is_superuser=True)
- async with self.data.subscription_mutation_scope() as mutation:
- subscribe = await mutation.get_accessible(subscribe_id, actor)
- if not subscribe:
- return json.dumps(
- {"success": False, "message": f"订阅不存在: {subscribe_id}"},
- ensure_ascii=False,
- )
- if media_type_to_agent(subscribe.type) == "music" and any(
- value is not None
- for value in (
- season,
- total_episode,
- lack_episode,
- start_episode,
- best_version_full,
- episode_group,
- )
- ):
- return json.dumps(
- {
- "success": False,
- "message": "音乐订阅不能更新季集、整季洗版或剧集组字段",
- },
- ensure_ascii=False,
- )
- if media_type_to_agent(subscribe.type) != "music" and any(
- value is not None
- for value in (audio_quality, audio_format, min_bitrate, min_bit_depth, min_sample_rate)
- ):
- return json.dumps(
- {"success": False, "message": "音质等级、音频格式和音频技术参数仅用于音乐订阅"},
- ensure_ascii=False,
- )
-
- # 构建更新字典
- subscribe_dict: dict[str, JsonData] = {}
-
- # 基本信息
- if name is not None:
- subscribe_dict["name"] = name
- if year is not None:
- subscribe_dict["year"] = year
- if season is not None:
- subscribe_dict["season"] = season
-
- # 集数相关
- if total_episode is not None and total_episode != subscribe.total_episode:
- subscribe_dict["total_episode"] = total_episode
- # 如果总集数增加,缺失集数也要相应增加
- if total_episode > (subscribe.total_episode or 0):
- old_lack = subscribe.lack_episode or 0
- subscribe_dict["lack_episode"] = old_lack + (
- total_episode - (subscribe.total_episode or 0)
- )
- # 标记为手动修改过总集数
- subscribe_dict["manual_total_episode"] = 1
-
- # 缺失集数处理(只有在没有提供总集数时才单独处理)
- # 注意:如果 lack_episode 为 0,不更新(避免更新为0)
- if lack_episode is not None and total_episode is None:
- if lack_episode > 0:
- subscribe_dict["lack_episode"] = lack_episode
- # 如果 lack_episode 为 0,不添加到更新字典中(保持原值或由总集数逻辑处理)
-
- if start_episode is not None:
- subscribe_dict["start_episode"] = start_episode
-
- # 过滤规则
- if quality is not None:
- subscribe_dict["quality"] = quality
- if resolution is not None:
- subscribe_dict["resolution"] = resolution
- if effect is not None:
- subscribe_dict["effect"] = effect
- if audio_quality is not None:
- subscribe_dict["audio_quality"] = audio_quality
- if audio_format is not None:
- subscribe_dict["audio_format"] = audio_format
- if min_bitrate is not None:
- subscribe_dict["min_bitrate"] = min_bitrate
- if min_bit_depth is not None:
- subscribe_dict["min_bit_depth"] = min_bit_depth
- if min_sample_rate is not None:
- subscribe_dict["min_sample_rate"] = min_sample_rate
- if include is not None:
- subscribe_dict["include"] = include
- if exclude is not None:
- subscribe_dict["exclude"] = exclude
- if filter is not None:
- subscribe_dict["filter"] = filter
-
- # 状态
- if state is not None:
- valid_states = ["R", "P", "S", "N"]
- if state not in valid_states:
- return json.dumps(
- {
- "success": False,
- "message": f"无效的订阅状态: {state},有效状态: {', '.join(valid_states)}",
- },
- ensure_ascii=False,
- )
- subscribe_dict["state"] = state
-
- # 下载配置
- if sites is not None:
- subscribe_dict["sites"] = sites
- if downloader is not None:
- subscribe_dict["downloader"] = downloader
- if save_path is not None:
- subscribe_dict["save_path"] = save_path
- if best_version is not None:
- subscribe_dict["best_version"] = best_version
- if best_version_full is not None:
- subscribe_dict["best_version_full"] = best_version_full
-
- # 其他配置
- if custom_words is not None:
- subscribe_dict["custom_words"] = custom_words
- if media_category is not None:
- subscribe_dict["media_category"] = media_category
- if episode_group is not None:
- subscribe_dict["episode_group"] = episode_group
-
- # 如果没有要更新的字段
- if not subscribe_dict:
- return json.dumps(
- {"success": False, "message": "没有提供要更新的字段"},
- ensure_ascii=False,
- )
-
- # Agent 工具没有 FastAPI 请求会话,由组合根提供一次独占事务作用域;
- # 更新和 durable intent 必须共享同一 AsyncSession。
- async with self.data.subscription_mutation_scope() as mutation:
- change = await mutation.update(
- subscribe_id,
- subscribe_dict,
- actor,
- scene="agent_update",
- )
- if not change:
- return json.dumps(
- {"success": False, "message": f"订阅不存在: {subscribe_id}"},
- ensure_ascii=False,
- )
- updated_subscribe = change.new
-
- # 构建返回结果
- result = {
- "success": True,
- "message": f"订阅 #{subscribe_id} 更新成功",
- "subscribe_id": subscribe_id,
- "updated_fields": list(subscribe_dict.keys()),
- }
-
- if updated_subscribe:
- result["subscribe"] = {
- "id": updated_subscribe.get("id"),
- "name": updated_subscribe.get("name"),
- "year": updated_subscribe.get("year"),
- "type": media_type_to_agent(updated_subscribe.get("type")),
- "music_type": updated_subscribe.get("music_type"),
- "total_tracks": updated_subscribe.get("total_tracks"),
- "media_source": updated_subscribe.get("media_source"),
- "media_id": updated_subscribe.get("media_id"),
- "season": updated_subscribe.get("season"),
- "state": updated_subscribe.get("state"),
- "total_episode": updated_subscribe.get("total_episode"),
- "manual_total_episode": updated_subscribe.get("manual_total_episode"),
- "lack_episode": updated_subscribe.get("lack_episode"),
- "start_episode": updated_subscribe.get("start_episode"),
- "quality": updated_subscribe.get("quality"),
- "resolution": updated_subscribe.get("resolution"),
- "effect": updated_subscribe.get("effect"),
- }
-
- return json.dumps(result, ensure_ascii=False, indent=2)
-
- except Exception as e:
- error_message = f"更新订阅失败: {str(e)}"
- logger.error(f"更新订阅失败: {e}", exc_info=True)
- return json.dumps(
- {
- "success": False,
- "message": error_message,
- "subscribe_id": subscribe_id,
- },
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/impl/update_system_settings.py b/app/agent/tools/impl/update_system_settings.py
deleted file mode 100644
index 45f1889ea..000000000
--- a/app/agent/tools/impl/update_system_settings.py
+++ /dev/null
@@ -1,363 +0,0 @@
-"""统一更新系统设置工具。"""
-
-import copy
-import json
-from typing import Any, Literal, Optional, Type, Union
-
-from pydantic import BaseModel, Field, PrivateAttr
-
-from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.tags import ToolTag
-from app.agent.tools.impl._system_setting_utils import (
- SettingSpec,
- get_default_list_match_field,
- is_secret_setting_key,
- redact_secret_value,
- resolve_setting_spec,
- should_redact_setting,
-)
-from app.runtime.events import eventmanager
-from app.application.configuration import (
- SystemConfigService,
- get_configured_system_config,
- get_runtime_settings,
-)
-from app.application.plugin.runtime import plugin_system_config_mutation
-from app.runtime.log import logger
-from app.schemas.event import ConfigChangeEventData
-from app.schemas.types import EventType
-
-SettingValue = Optional[Union[list, dict, bool, int, float, str]]
-
-
-class UpdateSystemSettingsInput(BaseModel):
- """更新系统设置工具的输入参数模型。"""
-
- setting_key: str = Field(
- ...,
- description=(
- "Exact setting key to update. Supports Settings field names, SystemConfigKey values, enum names, and common aliases "
- "such as 'downloaders', 'directories', 'search_sites', 'subscribe_sites', 'site_auth', 'ai_agent', and 'custom_identifiers'."
- ),
- )
- value: SettingValue = Field(
- None,
- description=(
- "The new value or list item payload. For replace: this becomes the entire setting value. For merge_dict: this should be a dict of keys to merge. "
- "For upsert_list_item/remove_list_item: this can be a dict item or a scalar list item."
- ),
- )
- operation: Literal[
- "replace",
- "merge_dict",
- "upsert_list_item",
- "remove_list_item",
- ] = Field(
- "replace",
- description=(
- "Update operation. replace replaces the whole value; merge_dict merges dict keys (optionally with remove_keys); "
- "upsert_list_item inserts or replaces one item inside a list; remove_list_item removes one item from a list."
- ),
- )
- remove_keys: Optional[list[str]] = Field(
- None,
- description="Optional dict keys to delete when operation is merge_dict.",
- )
- match_field: Optional[str] = Field(
- None,
- description=(
- "Optional match field for list item upsert/remove. If omitted, common SystemConfig categories use built-in defaults such as 'name' or 'type'."
- ),
- )
- match_value: SettingValue = Field(
- None,
- description=(
- "Optional explicit value used to locate a list item when operation is upsert_list_item or remove_list_item."
- ),
- )
-
-
-class UpdateSystemSettingsTool(MoviePilotTool):
- """通过授权配置服务修改可登记系统设置。"""
-
- name: str = "update_system_settings"
- tags: list[str] = [
- ToolTag.Write,
- ToolTag.System,
- ToolTag.Settings,
- ToolTag.Admin,
- ]
- description: str = (
- "Update system settings across both the basic Settings module and all SystemConfig-backed categories. "
- "Supports full replacement, shallow dict merge, and generic list item upsert/remove so the agent can manage downloaders, media servers, notification channels, storages, directories, search-site ranges, subscribe-site ranges, site auth params, AI agent config, and other system settings through one tool."
- )
- require_admin: bool = True
- args_schema: Type[BaseModel] = UpdateSystemSettingsInput
- _system_config: Optional[SystemConfigService] = PrivateAttr(default=None)
-
- def __init__(
- self,
- session_id: str,
- user_id: str,
- *,
- system_config: Optional[SystemConfigService] = None,
- **kwargs,
- ) -> None:
- """注入配置读写服务,并兼容组合根默认装配。"""
- super().__init__(session_id=session_id, user_id=user_id, **kwargs)
- self._system_config = system_config
-
- def _get_system_config(self) -> SystemConfigService:
- """返回显式注入服务,旧构造形态则延迟读取组合根服务。"""
- return self._system_config or get_configured_system_config()
-
- def get_tool_message(self, **kwargs) -> Optional[str]:
- """根据更新参数生成友好的提示消息。"""
-
- setting_key = kwargs.get("setting_key", "")
- operation = kwargs.get("operation", "replace")
- action_map = {
- "replace": "覆盖系统设置",
- "merge_dict": "合并系统设置",
- "upsert_list_item": "更新列表项",
- "remove_list_item": "移除列表项",
- }
- return f"{action_map.get(operation, '更新系统设置')}: {setting_key}"
-
- def _load_setting_value(self, spec: SettingSpec):
- """读取指定设置项的当前值。"""
- if spec.source == "settings":
- return get_runtime_settings().get(spec.key)
- return self._get_system_config().get(spec.systemconfig_key)
-
- @staticmethod
- def _normalize_systemconfig_value(value: Any):
- """规范化写入 SystemConfig 的空列表值。"""
- if isinstance(value, list):
- filtered = [item for item in value if item is not None]
- return filtered or None
- return value
-
- @staticmethod
- def _resolve_list_match(
- spec: SettingSpec,
- operation: str,
- value: Any,
- match_field: Optional[str],
- match_value: Any,
- ) -> tuple[Optional[str], Any]:
- resolved_field = match_field or get_default_list_match_field(spec.key)
- resolved_value = match_value
-
- if isinstance(value, dict):
- if not resolved_field:
- raise ValueError(
- f"{operation} 需要提供 match_field,或使用带默认匹配字段的系统配置项"
- )
- if resolved_value is None:
- resolved_value = value.get(resolved_field)
- if resolved_value is None:
- raise ValueError(
- f"{operation} 缺少匹配值,请在 value.{resolved_field} 或 match_value 中提供"
- )
- else:
- if resolved_value is None:
- resolved_value = value
-
- return resolved_field, resolved_value
-
- @classmethod
- def _prepare_next_value(
- cls,
- spec: SettingSpec,
- current_value: Any,
- value: Any,
- operation: str,
- remove_keys: Optional[list[str]] = None,
- match_field: Optional[str] = None,
- match_value: Any = None,
- ) -> Any:
- remove_keys = remove_keys or []
- if operation == "replace":
- return value
-
- if operation == "merge_dict":
- if remove_keys and not isinstance(remove_keys, list):
- raise ValueError("remove_keys 必须是字符串列表")
- if current_value is not None and not isinstance(current_value, dict):
- raise ValueError("merge_dict 仅支持当前值为 dict 的设置项")
- if value is not None and not isinstance(value, dict):
- raise ValueError("merge_dict 的 value 必须是 dict 或 null")
- next_value = dict(current_value or {})
- if value:
- next_value.update(value)
- for key in remove_keys:
- next_value.pop(key, None)
- return next_value
-
- if operation not in {"upsert_list_item", "remove_list_item"}:
- raise ValueError(f"不支持的操作: {operation}")
-
- if current_value is not None and not isinstance(current_value, list):
- raise ValueError(f"{operation} 仅支持当前值为 list 的设置项")
-
- next_items = list(copy.deepcopy(current_value or []))
- resolved_field, resolved_match_value = cls._resolve_list_match(
- spec, operation, value, match_field, match_value
- )
-
- if operation == "upsert_list_item":
- if value is None:
- raise ValueError("upsert_list_item 必须提供 value")
- replaced = False
- for index, item in enumerate(next_items):
- if resolved_field:
- if isinstance(item, dict) and item.get(resolved_field) == resolved_match_value:
- next_items[index] = value
- replaced = True
- break
- elif item == resolved_match_value:
- next_items[index] = value
- replaced = True
- break
- if not replaced:
- next_items.append(value)
- return next_items
-
- return [
- item
- for item in next_items
- if not (
- isinstance(item, dict)
- and resolved_field
- and item.get(resolved_field) == resolved_match_value
- )
- and not (not resolved_field and item == resolved_match_value)
- ]
-
- async def run(
- self,
- setting_key: str,
- value: SettingValue = None,
- operation: str = "replace",
- remove_keys: Optional[list[str]] = None,
- match_field: Optional[str] = None,
- match_value: SettingValue = None,
- **kwargs,
- ) -> str:
- logger.info(
- f"执行工具: {self.name}, setting_key={setting_key}, operation={operation}"
- )
-
- try:
- spec = resolve_setting_spec(setting_key)
- if not spec:
- return json.dumps(
- {
- "success": False,
- "message": f"系统设置项 '{setting_key}' 不存在",
- },
- ensure_ascii=False,
- )
-
- mutation_key = (
- spec.systemconfig_key if spec.source == "systemconfig" else None
- )
- with plugin_system_config_mutation(mutation_key):
- current_value = self._load_setting_value(spec)
- next_value = self._prepare_next_value(
- spec=spec,
- current_value=current_value,
- value=value,
- operation=operation,
- remove_keys=remove_keys,
- match_field=match_field,
- match_value=match_value,
- )
-
- event_value = next_value
- changed = False
- message = ""
- if spec.source == "settings":
- success, message = get_runtime_settings().update(
- spec.key,
- next_value,
- )
- if success is False:
- return json.dumps(
- {
- "success": False,
- "message": message or f"更新设置 {spec.key} 失败",
- },
- ensure_ascii=False,
- )
- changed = success is True
- else:
- normalized_value = self._normalize_systemconfig_value(next_value)
- event_value = normalized_value
- success = await self._get_system_config().async_set(
- spec.systemconfig_key,
- normalized_value,
- )
- changed = success is True
-
- if changed:
- await eventmanager.async_send_event(
- etype=EventType.ConfigChanged,
- data=ConfigChangeEventData(
- key=spec.key,
- value=event_value,
- change_type="update",
- ),
- )
-
- saved_value = self._load_setting_value(spec)
- redact_values = (
- should_redact_setting(spec, saved_value)
- or should_redact_setting(spec, current_value)
- )
- response_previous_value = (
- redact_secret_value(
- current_value,
- redact_scalar=is_secret_setting_key(spec.key),
- )
- if redact_values
- else current_value
- )
- response_saved_value = (
- redact_secret_value(
- saved_value,
- redact_scalar=is_secret_setting_key(spec.key),
- )
- if redact_values
- else saved_value
- )
- if not changed and not message:
- message = "配置值未发生变化"
-
- return json.dumps(
- {
- "success": True,
- "message": message or f"系统设置 {spec.key} 已更新",
- "changed": changed,
- "operation": operation,
- "setting": {
- "setting_key": spec.key,
- "source": spec.source,
- "group": spec.group,
- "label": spec.label,
- },
- "values_redacted": redact_values,
- "previous_value": response_previous_value,
- "saved_value": response_saved_value,
- },
- ensure_ascii=False,
- indent=2,
- default=str,
- )
- except Exception as e:
- logger.error(f"更新系统设置失败: {e}", exc_info=True)
- return json.dumps(
- {"success": False, "message": f"更新系统设置时发生错误: {str(e)}"},
- ensure_ascii=False,
- )
diff --git a/app/agent/tools/manager.py b/app/agent/tools/manager.py
index 7fb6aca38..f5f6dcd89 100644
--- a/app/agent/tools/manager.py
+++ b/app/agent/tools/manager.py
@@ -123,9 +123,13 @@ class MoviePilotToolsManager:
self._plugin_agent_tools_revision = -1
self._catalog_materialized = False
self._catalog_managed_by_factory = False
+ from app.agent.tools.catalog import ToolCatalogError
+
+ if isinstance(e, ToolCatalogError):
+ raise
def _load_tools(self) -> None:
- """兼容显式刷新入口,并保证外部调用仍原子发布完整目录。"""
+ """显式刷新工具目录,并保证外部调用原子发布完整快照。"""
with self._tools_lock:
self._load_tools_locked()
@@ -151,16 +155,10 @@ class MoviePilotToolsManager:
from app.application.plugin.runtime import get_plugin_manager
plugin_manager = get_plugin_manager()
- if (
- self._plugin_agent_tools_revision
- == plugin_manager.get_plugin_agent_tools_revision()
- ):
+ if self._plugin_agent_tools_revision == plugin_manager.get_plugin_agent_tools_revision():
return
with self._tools_lock:
- if (
- self._plugin_agent_tools_revision
- == plugin_manager.get_plugin_agent_tools_revision()
- ):
+ if self._plugin_agent_tools_revision == plugin_manager.get_plugin_agent_tools_revision():
return
self._load_tools_locked()
@@ -207,7 +205,8 @@ class MoviePilotToolsManager:
"""
self._ensure_tools_current()
with self._tools_lock:
- tools = list(self.tools)
+ catalog = self._get_strict_catalog_locked()
+ tools = catalog.tools if catalog is not None else []
tools_list = []
for tool in tools:
if getattr(tool, "_require_admin", False) and not self.is_admin:
@@ -232,42 +231,32 @@ class MoviePilotToolsManager:
return tools_list
def get_tool(self, tool_name: str) -> Optional[Any]:
- """
- 获取指定工具实例
-
- Args:
- tool_name: 工具名称
-
- Returns:
- 工具实例,如果未找到返回None
- """
- self._ensure_tools_current()
- with self._tools_lock:
- return next(
- (tool for tool in self.tools if tool.name == tool_name),
- None,
- )
+ """按唯一身份获取工具;同名冲突时稳定失败。"""
+ return self.get_strict_tool(tool_name)
def get_strict_tool(self, tool_name: str) -> Optional[Any]:
"""按当前目录唯一身份解析严格调用,重名时稳定失败。"""
self._ensure_tools_current()
with self._tools_lock:
- if self.catalog is None or [
- id(tool) for tool in self.catalog.tools
- ] != [id(tool) for tool in self.tools]:
- from app.agent.loader import get_tool_factory
- from app.agent.tools.catalog import ToolCatalogSnapshot
-
- self.catalog = ToolCatalogSnapshot.from_tools(
- self.tools,
- plugin_revision=self._plugin_agent_tools_revision,
- factory_revision=get_tool_factory().catalog_factory_revision(),
- )
- if self.catalog is None:
+ catalog = self._get_strict_catalog_locked()
+ if catalog is None:
return None
- entry = self.catalog.resolve_unique(tool_name)
+ entry = catalog.resolve_unique(tool_name)
return entry.tool if entry else None
+ def _get_strict_catalog_locked(self) -> Optional[ToolCatalogSnapshot]:
+ """在 manager 锁内建立并校验唯一、可执行的当前工具目录。"""
+ if self.catalog is None or [id(tool) for tool in self.catalog.tools] != [id(tool) for tool in self.tools]:
+ from app.agent.loader import get_tool_factory
+ from app.agent.tools.catalog import ToolCatalogSnapshot
+
+ self.catalog = ToolCatalogSnapshot.from_tools(
+ self.tools,
+ plugin_revision=self._plugin_agent_tools_revision,
+ factory_revision=get_tool_factory().catalog_factory_revision(),
+ )
+ return self.catalog.require_unique() if self.catalog is not None else None
+
@staticmethod
def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]:
"""
@@ -299,13 +288,13 @@ class MoviePilotToolsManager:
if field_type == "integer" and isinstance(value, str):
try:
return int(value)
- except (ValueError, TypeError):
+ except ValueError, TypeError:
logger.warning(f"无法将参数 {key}='{value}' 转换为整数,返回 None")
return None
if field_type == "number" and isinstance(value, str):
try:
return float(value)
- except (ValueError, TypeError):
+ except ValueError, TypeError:
logger.warning(f"无法将参数 {key}='{value}' 转换为浮点数,返回 None")
return None
if field_type == "boolean":
@@ -333,9 +322,7 @@ class MoviePilotToolsManager:
]
@staticmethod
- def _normalize_arguments(
- tool_instance: Any, arguments: Dict[str, Any]
- ) -> Dict[str, Any]:
+ def _normalize_arguments(tool_instance: Any, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""
根据工具的参数schema规范化参数类型
@@ -373,15 +360,11 @@ class MoviePilotToolsManager:
# 数组类型:将字符串解析为列表
if field_type == "array" and isinstance(value, str):
item_type = field_info.get("items", {}).get("type", "string")
- normalized[key] = MoviePilotToolsManager._parse_array_string(
- value, key, item_type
- )
+ normalized[key] = MoviePilotToolsManager._parse_array_string(value, key, item_type)
continue
# 根据类型进行转换
- normalized[key] = MoviePilotToolsManager._normalize_scalar_value(
- field_type, value, key
- )
+ normalized[key] = MoviePilotToolsManager._normalize_scalar_value(field_type, value, key)
return normalized
@@ -403,12 +386,10 @@ class MoviePilotToolsManager:
Returns:
工具执行结果(字符串)
"""
- tool_instance = self.get_tool(tool_name)
+ tool_instance = self.get_strict_tool(tool_name)
if not tool_instance:
- error_msg = json.dumps(
- {"error": f"工具 '{tool_name}' 未找到"}, ensure_ascii=False
- )
+ error_msg = json.dumps({"error": f"工具 '{tool_name}' 未找到"}, ensure_ascii=False)
return error_msg
from app.agent.policy.orchestrator import call_policy_hook
@@ -497,9 +478,7 @@ class MoviePilotToolsManager:
if "properties" in schema:
for field_name, field_info in schema["properties"].items():
- resolved_field_info = MoviePilotToolsManager._resolve_field_schema(
- field_info
- )
+ resolved_field_info = MoviePilotToolsManager._resolve_field_schema(field_info)
# 转换字段类型
field_type = resolved_field_info.get("type", "string")
field_description = resolved_field_info.get("description", "")
diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py
index 76a9a75ce..02a7edb83 100644
--- a/app/api/endpoints/agent.py
+++ b/app/api/endpoints/agent.py
@@ -3,7 +3,16 @@ import mimetypes
import uuid
from typing import Any, AsyncIterator, Optional
-from fastapi import Depends, File, Form, HTTPException, Request, UploadFile, status
+from fastapi import (
+ Depends,
+ File,
+ Form,
+ Header,
+ HTTPException,
+ Request,
+ UploadFile,
+ status,
+)
from fastapi.responses import FileResponse, StreamingResponse
from app.agent.mcp import agent_mcp_manager
@@ -26,12 +35,15 @@ from app.application.messaging.chat import (
get_configured_agent_chat_persistence,
get_configured_agent_chat_service,
)
+from app.runtime.events import eventmanager
from app.runtime.localization import LocaleHelper
from app.runtime.log import logger
from app.schemas.agent import AgentChatDisplaySaveRequest as _SchemaAgentChatDisplaySaveRequest
from app.schemas.agent import AgentChatSessionDetail as _SchemaAgentChatSessionDetail
from app.schemas.agent import AgentChatSessionSummary as _SchemaAgentChatSessionSummary
from app.schemas.agent import AgentChatUploadAttachment as _SchemaAgentChatUploadAttachment
+from app.schemas.agent import AgentCommandRunData as _SchemaAgentCommandRunData
+from app.schemas.agent import AgentCommandRunRequest as _SchemaAgentCommandRunRequest
from app.schemas.agent import AgentMcpServerListData as _SchemaAgentMcpServerListData
from app.schemas.agent import AgentMcpServersSaveRequest as _SchemaAgentMcpServersSaveRequest
from app.schemas.agent import AgentMcpServerTestRequest as _SchemaAgentMcpServerTestRequest
@@ -42,6 +54,7 @@ from app.schemas.agent import AgentWebCommandInfo as _SchemaAgentWebCommandInfo
from app.schemas.message import AgentWebChatRequest as _SchemaAgentWebChatRequest
from app.schemas.message import AgentWebChoiceRequest as _SchemaAgentWebChoiceRequest
from app.schemas.response import Response as _SchemaResponse
+from app.schemas.types import NotificationChannel
router = ResponseAPIRouter()
@@ -298,6 +311,36 @@ async def list_web_agent_commands(
)
+@router.post(
+ "/commands/run",
+ summary="执行 Agent 斜杠命令",
+ response_model=_SchemaResponse[_SchemaAgentCommandRunData],
+)
+async def run_agent_command(
+ payload: _SchemaAgentCommandRunRequest,
+ current_user: ApiPrincipal = Depends(get_current_active_user),
+ agent_channel: Optional[str] = Header(None, alias="X-MoviePilot-Agent-Channel"),
+ agent_source: Optional[str] = Header(None, alias="X-MoviePilot-Agent-Source"),
+) -> _SchemaResponse:
+ """以当前认证用户和宿主透传渠道触发已注册命令。"""
+ _ensure_superuser(current_user)
+ try:
+ channel = NotificationChannel(agent_channel) if agent_channel else None
+ except ValueError:
+ channel = None
+ try:
+ data = web_agent_application.dispatch_web_agent_command(
+ payload.command,
+ user_id=str(current_user.id),
+ channel=channel,
+ source=agent_source,
+ publish_event=eventmanager.send_event,
+ )
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
+
+
@router.get(
"/sessions",
summary="获取 Agent 历史会话",
diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py
index 5ee8c3774..3f931ae56 100644
--- a/app/api/endpoints/download.py
+++ b/app/api/endpoints/download.py
@@ -1,5 +1,6 @@
from typing import Annotated, Any, List, Optional, Union
+import anyio
from fastapi import Body, Depends
from app.adapters.web.security.access import verify_token
@@ -9,6 +10,7 @@ from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.application.configuration import get_configured_system_config
from app.application.directory import DirectoryHelper
+from app.application.download.tasks import DownloadTaskMutationService
from app.application.security.url import SecurityUtils
from app.application.site.query import (
SiteQueryService,
@@ -24,6 +26,8 @@ from app.domain.metainfo import MetaInfo
from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo
from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData
from app.schemas.download import DownloadDirectory as _SchemaDownloadDirectory
+from app.schemas.download import DownloadTaskUpdateData as _SchemaDownloadTaskUpdateData
+from app.schemas.download import DownloadTaskUpdateRequest as _SchemaDownloadTaskUpdateRequest
from app.schemas.download import SubtitleDownloadData as _SchemaSubtitleDownloadData
from app.schemas.file import FileURI as _SchemaFileURI
from app.schemas.response import Response as _SchemaResponse
@@ -93,7 +97,7 @@ def _build_unrecognized_media_info(
)
try:
media_type = MediaType(torrent.category)
- except (TypeError, ValueError):
+ except TypeError, ValueError:
media_type = MediaType.from_agent(torrent.category)
if media_type == MediaType.COLLECTION:
media_type = MediaType.MOVIE
@@ -121,14 +125,22 @@ def _resolve_add_media(
"""校验媒体身份并为无媒体信息下载构建识别上下文。"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
- return None, None, _SchemaResponse(
- success=False,
- message="音乐实体类型无效,仅支持 recording 或 album",
+ return (
+ None,
+ None,
+ _SchemaResponse(
+ success=False,
+ message="音乐实体类型无效,仅支持 recording 或 album",
+ ),
)
if (media_source is None) != (media_id is None):
- return None, None, _SchemaResponse(
- success=False,
- message="媒体来源和媒体 ID 必须同时提供",
+ return (
+ None,
+ None,
+ _SchemaResponse(
+ success=False,
+ message="媒体来源和媒体 ID 必须同时提供",
+ ),
)
is_music = (
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
@@ -136,9 +148,13 @@ def _resolve_add_media(
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
- return None, None, _SchemaResponse(
- success=False,
- message="音乐下载只能使用音乐元数据源",
+ return (
+ None,
+ None,
+ _SchemaResponse(
+ success=False,
+ message="音乐下载只能使用音乐元数据源",
+ ),
)
if is_music and not normalized_music_type:
normalized_music_type = MUSIC_ENTITY_RECORDING
@@ -166,23 +182,29 @@ def _resolve_add_media(
if mediainfo:
return metainfo, mediainfo, None
if not allow_unrecognized:
- return metainfo, None, _SchemaResponse(
- success=False,
- message="无法识别媒体信息",
- data=_SchemaDownloadAddedData(requires_confirmation=True),
+ return (
+ metainfo,
+ None,
+ _SchemaResponse(
+ success=False,
+ message="无法识别媒体信息",
+ data=_SchemaDownloadAddedData(requires_confirmation=True),
+ ),
)
- return metainfo, _build_unrecognized_media_info(
- torrent_in,
+ return (
metainfo,
- is_music=is_music,
- music_type=normalized_music_type,
- ), None
+ _build_unrecognized_media_info(
+ torrent_in,
+ metainfo,
+ is_music=is_music,
+ music_type=normalized_music_type,
+ ),
+ None,
+ )
@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent])
-def current(
- name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
-) -> Any:
+def current(name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询正在下载的任务
"""
@@ -218,9 +240,7 @@ def download(
# 手动下载始终使用选择的下载器
torrentinfo.site_downloader = downloader
# 上下文
- context = Context(
- meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo
- )
+ context = Context(meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo)
did = DownloadChain().download_single(
context=context,
username=current_user.name,
@@ -266,9 +286,7 @@ def add(
torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.model_dump())
# 上下文
- context = Context(
- meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo
- )
+ context = Context(meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo)
did = DownloadChain().download_single(
context=context,
@@ -349,6 +367,39 @@ def stop(
return _SchemaResponse(success=True if ret else False)
+@router.patch(
+ "/{hashString}",
+ summary="高级更新下载任务",
+ response_model=_SchemaResponse[_SchemaDownloadTaskUpdateData],
+)
+async def update_task(
+ hashString: str,
+ payload: _SchemaDownloadTaskUpdateRequest,
+ _: ApiPrincipal = Depends(get_current_active_user),
+) -> _SchemaResponse:
+ """执行下载任务启停、标签、限速、Tracker 和保存位置修改。"""
+ chain = DownloadChain()
+ service = DownloadTaskMutationService(
+ list_torrents=chain.list_torrents,
+ set_tags=chain.set_torrents_tag,
+ set_downloading=chain.set_downloading,
+ update_torrent=chain.update_torrent,
+ )
+ try:
+ data = await anyio.to_thread.run_sync(
+ lambda: service.update(
+ hash_value=hashString,
+ **payload.model_dump(),
+ )
+ )
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(
+ success=all(item.get("success") for item in data["results"]),
+ data=data,
+ )
+
+
@router.get(
"/clients",
summary="查询可用下载器",
@@ -360,17 +411,11 @@ async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
downloaders: List[dict] = get_configured_system_config().get(SystemConfigKey.Downloaders)
if downloaders:
- return [
- {"name": d.get("name"), "type": d.get("type")}
- for d in downloaders
- if d.get("enabled")
- ]
+ return [{"name": d.get("name"), "type": d.get("type")} for d in downloaders if d.get("enabled")]
return []
-@router.get(
- "/paths", summary="查询可用下载路径", response_model=List[_SchemaDownloadDirectory]
-)
+@router.get("/paths", summary="查询可用下载路径", response_model=List[_SchemaDownloadDirectory])
def paths(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询可直接用于下载接口 save_path 参数的下载路径
diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py
index 50423c037..e27d8d368 100644
--- a/app/api/endpoints/mcp.py
+++ b/app/api/endpoints/mcp.py
@@ -46,16 +46,10 @@ def list_exposed_tools():
"""
获取 MCP 可见工具列表
"""
- return [
- tool
- for tool in moviepilot_tool_manager.list_tools()
- if tool.name not in MCP_HIDDEN_TOOLS
- ]
+ return [tool for tool in moviepilot_tool_manager.list_tools() if tool.name not in MCP_HIDDEN_TOOLS]
-def create_jsonrpc_response(
- request_id: Union[str, int, None], result: Any
-) -> Dict[str, Any]:
+def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]:
"""
创建 JSON-RPC 成功响应
"""
@@ -93,9 +87,7 @@ async def _dispatch_jsonrpc_method(
return Response(status_code=204)
return JSONResponse(
status_code=400,
- content=create_jsonrpc_error(
- request_id, -32600, "initialized must be a notification"
- ),
+ content=create_jsonrpc_error(request_id, -32600, "initialized must be a notification"),
)
if method == "tools/list":
result = await handle_tools_list()
@@ -105,11 +97,7 @@ async def _dispatch_jsonrpc_method(
return JSONResponse(content=create_jsonrpc_response(request_id, result))
if method == "ping":
return JSONResponse(content=create_jsonrpc_response(request_id, {}))
- return JSONResponse(
- content=create_jsonrpc_error(
- request_id, -32601, f"Method not found: {method}"
- )
- )
+ return JSONResponse(content=create_jsonrpc_error(request_id, -32601, f"Method not found: {method}"))
@router.post(
@@ -120,9 +108,7 @@ async def _dispatch_jsonrpc_method(
RAW_RESPONSE_OPENAPI_KEY: True,
"requestBody": {
"required": True,
- "content": {
- "application/json": {"schema": _SchemaMCP_JSONRPC_REQUEST_SCHEMA}
- },
+ "content": {"application/json": {"schema": _SchemaMCP_JSONRPC_REQUEST_SCHEMA}},
},
},
responses={
@@ -182,9 +168,7 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
protocol_version = params.get("protocolVersion")
client_info = params.get("clientInfo", {})
- logger.info(
- f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}"
- )
+ logger.info(f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}")
# 版本协商:选择客户端和服务器都支持的版本
negotiated_version = MCP_PROTOCOL_VERSION
@@ -194,9 +178,7 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
logger.info(f"使用客户端协议版本: {negotiated_version}")
else:
# 客户端版本不支持,使用服务器默认版本
- logger.warning(
- f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}"
- )
+ logger.warning(f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}")
return {
"protocolVersion": negotiated_version,
@@ -279,7 +261,7 @@ async def delete_mcp_session(
return Response(status_code=204)
-# ==================== 兼容的 RESTful API 端点 ====================
+# ==================== HTTP 工具管理端点 ====================
@router.get(
@@ -318,9 +300,7 @@ async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any:
summary="调用工具",
response_model=_SchemaResponse[_SchemaToolCallData],
)
-async def call_tool(
- request: _SchemaToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None
-) -> Any:
+async def call_tool(request: _SchemaToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None) -> Any:
"""
调用指定的工具
@@ -331,9 +311,7 @@ async def call_tool(
if request.tool_name in MCP_HIDDEN_TOOLS:
raise ValueError(f"工具 '{request.tool_name}' 未找到")
- result_text = await moviepilot_tool_manager.call_tool(
- request.tool_name, request.arguments
- )
+ result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments)
return _SchemaResponse(
success=True,
@@ -349,9 +327,7 @@ async def call_tool(
summary="获取工具详情",
response_model=_SchemaMcpToolInfo,
)
-async def get_tool_info(
- tool_name: str, _: Annotated[str, Depends(verify_apikey)]
-) -> Any:
+async def get_tool_info(tool_name: str, _: Annotated[str, Depends(verify_apikey)]) -> Any:
"""
获取指定工具的详细信息
@@ -384,9 +360,7 @@ async def get_tool_info(
summary="获取工具参数Schema",
response_model=_SchemaMcpJsonSchema,
)
-async def get_tool_schema(
- tool_name: str, _: Annotated[str, Depends(verify_apikey)]
-) -> Any:
+async def get_tool_schema(tool_name: str, _: Annotated[str, Depends(verify_apikey)]) -> Any:
"""
获取指定工具的参数Schema(JSON Schema格式)
diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py
index b1da973bb..dac2719df 100644
--- a/app/api/endpoints/plugin.py
+++ b/app/api/endpoints/plugin.py
@@ -13,7 +13,11 @@ from app.adapters.web.security.access import (
verify_resource_token,
verify_token,
)
-from app.api.context import get_background_task_registry, resolve_background_task_registry
+from app.api.context import (
+ get_background_task_registry,
+ get_host_runtime,
+ resolve_background_task_registry,
+)
from app.api.dependencies.auth import (
get_current_active_superuser,
get_current_active_superuser_async,
@@ -27,12 +31,14 @@ from app.application.commands import init_commands
from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config
from app.application.plugin.catalog import get_plugin_catalog_query
from app.application.plugin.config import PluginConfigCommand
+from app.application.plugin.data import PluginDataQueryService
from app.application.plugin.folders import (
add_clone_to_plugin_folder,
get_plugin_folder_service,
remove_plugin_from_folders,
)
from app.application.plugin.gateway import get_plugin_install_service
+from app.application.plugin.management import get_plugin_snapshot
from app.application.plugin.rating import (
PluginNotInstalledError,
get_plugin_rating_service,
@@ -72,6 +78,7 @@ from app.schemas.plugin import PluginSourceOptions as _SchemaPluginSourceOptions
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.types import SystemConfigKey
+from app.startup.composition.context import HostRuntime
router = ResponseAPIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
@@ -100,9 +107,7 @@ async def _refresh_plugin_release_versions(plugin_id: str, repo_url: str) -> Non
logger.warning(f"后台刷新插件 {plugin_id} Release 列表失败:{e}")
-def _schedule_plugin_release_refresh(
- plugin_id: str, repo_url: str, task_registry: TaskRegistry | None = None
-) -> None:
+def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str, task_registry: TaskRegistry | None = None) -> None:
"""
保留后台任务引用,避免任务被回收,同时让 helper 负责同仓库强刷合并。
"""
@@ -150,7 +155,7 @@ def _is_plugin_auth_remote_file(plugin_id: str, filepath: str) -> bool:
expected_prefix = f"plugin/file/{normalized_plugin_id}/"
if not remote_path_lower.startswith(expected_prefix):
continue
- remote_file = remote_path[len(expected_prefix):]
+ remote_file = remote_path[len(expected_prefix) :]
remote_dir = remote_file.rsplit("/", 1)[0] if "/" in remote_file else ""
if path == remote_file or (remote_dir and path.startswith(f"{remote_dir}/")):
return True
@@ -209,18 +214,11 @@ async def runtime_status(
statuses = plugin_manager.get_plugin_runtime_statuses()
installed_plugin_ids = {
plugin_id.lower()
- for plugin_id in (
- get_configured_system_config().get(
- SystemConfigKey.UserInstalledPlugins
- )
- or []
- )
+ for plugin_id in (get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [])
}
restart_requirements = {
plugin_id: distributions
- for plugin_id, distributions in (
- plugin_manager.get_plugin_restart_requirements().items()
- )
+ for plugin_id, distributions in (plugin_manager.get_plugin_restart_requirements().items())
if plugin_id.lower() in installed_plugin_ids
}
pending = {
@@ -321,10 +319,7 @@ async def plugin_ratings(
"""
requested_ids = plugin_ids.split(",") if plugin_ids is not None else None
ratings = await get_plugin_rating_service().ratings(requested_ids)
- return {
- plugin_id: _SchemaPluginRating.model_validate(rating)
- for plugin_id, rating in ratings.items()
- }
+ return {plugin_id: _SchemaPluginRating.model_validate(rating) for plugin_id, rating in ratings.items()}
@router.get(
@@ -368,12 +363,8 @@ async def rate_plugin(
return _SchemaResponse(success=True, data=rating)
-@router.get(
- "/reload/{plugin_id}", summary="重新加载插件", response_model=_SchemaResponse[None]
-)
-def reload_plugin(
- plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)
-) -> Any:
+@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=_SchemaResponse[None])
+def reload_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any:
"""
重新加载插件
"""
@@ -470,11 +461,7 @@ async def get_plugin_source_options(
for candidate in inspection.online_candidates
]
if inspection.local_candidate is not None:
- candidates.append(
- _SchemaPluginSourceCandidate.model_validate(
- inspection.local_candidate.public_dict()
- )
- )
+ candidates.append(_SchemaPluginSourceCandidate.model_validate(inspection.local_candidate.public_dict()))
return _SchemaResponse(
success=True,
data=_SchemaPluginSourceOptions(
@@ -482,11 +469,7 @@ async def get_plugin_source_options(
inventory_complete=inspection.inventory_complete,
selection_status=inspection.selection.status.value,
selection_reason=inspection.selection.reason,
- identity=(
- _plugin_source_identity_schema(inspection.identity)
- if inspection.identity is not None
- else None
- ),
+ identity=(_plugin_source_identity_schema(inspection.identity) if inspection.identity is not None else None),
candidates=candidates,
),
)
@@ -583,9 +566,7 @@ def plugin_sidebar_nav(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
summary="获取插件表单页面",
response_model=_SchemaJsonObject,
)
-def plugin_form(
- plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)
-) -> dict:
+def plugin_form(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> dict:
"""
根据插件ID获取插件配置表单或Vue组件URL
"""
@@ -619,9 +600,7 @@ def plugin_form(
summary="获取插件数据页面",
response_model=_SchemaJsonObject,
)
-def plugin_page(
- plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)
-) -> dict:
+def plugin_page(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> dict:
"""
根据插件ID获取插件数据页面
"""
@@ -686,9 +665,7 @@ def plugin_dashboard(
return plugin_dashboard_by_key(plugin_id, "", user_agent)
-@router.get(
- "/reset/{plugin_id}", summary="重置插件配置及数据", response_model=_SchemaResponse[None]
-)
+@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=_SchemaResponse[None])
def reset_plugin(
plugin_id: str,
_: ApiPrincipal = Depends(get_current_active_superuser),
@@ -710,9 +687,7 @@ def reset_plugin(
200: {
"description": "插件静态资源",
"content": {
- "application/octet-stream": {
- "schema": {"type": "string", "format": "binary"}
- },
+ "application/octet-stream": {"schema": {"type": "string", "format": "binary"}},
"application/javascript": {"schema": {"type": "string"}},
"text/css": {"schema": {"type": "string"}},
},
@@ -729,17 +704,12 @@ async def plugin_static_file(
"""
# 基础安全检查
if ".." in filepath or ".." in plugin_id:
- logger.warning(
- f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}"
- )
+ logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
source_plugin_id = get_plugin_manager().get_plugin_source_id(plugin_id)
plugin_base_dir = (
- AsyncPath(get_api_runtime_config_snapshot().root_path)
- / "app"
- / "plugins"
- / source_plugin_id.lower()
+ AsyncPath(get_api_runtime_config_snapshot().root_path) / "app" / "plugins" / source_plugin_id.lower()
)
plugin_file_path = plugin_base_dir / filepath.lstrip("/")
@@ -747,24 +717,16 @@ async def plugin_static_file(
resolved_base = await plugin_base_dir.resolve()
resolved_file = await plugin_file_path.resolve()
except Exception:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid path"
- )
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid path")
if not resolved_file.is_relative_to(resolved_base):
- logger.warning(
- f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}"
- )
+ logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
if not await plugin_file_path.exists():
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在"
- )
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在")
if not await plugin_file_path.is_file():
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件"
- )
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件")
# 判断 MIME 类型
response_type, _ = mimetypes.guess_type(str(plugin_file_path))
@@ -788,9 +750,7 @@ async def plugin_static_file(
return StreamingResponse(
file_generator(),
media_type=response_type,
- headers={
- "Content-Disposition": f"inline; filename={plugin_file_path.name}"
- },
+ headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"},
)
except Exception as e:
logger.error(
@@ -815,9 +775,7 @@ async def get_plugin_folders(
@router.post("/folders", summary="保存插件文件夹配置", response_model=_SchemaResponse[None])
-async def save_plugin_folders(
- folders: dict, _: ApiPrincipal = Depends(get_current_active_superuser_async)
-) -> Any:
+async def save_plugin_folders(folders: dict, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> Any:
"""
保存插件文件夹分组配置
"""
@@ -825,12 +783,8 @@ async def save_plugin_folders(
return _SchemaResponse(success=result.success, message=result.message)
-@router.post(
- "/folders/{folder_name}", summary="创建插件文件夹", response_model=_SchemaResponse[None]
-)
-async def create_plugin_folder(
- folder_name: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)
-) -> Any:
+@router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=_SchemaResponse[None])
+async def create_plugin_folder(folder_name: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> Any:
"""
创建新的插件文件夹
"""
@@ -838,12 +792,8 @@ async def create_plugin_folder(
return _SchemaResponse(success=result.success, message=result.message)
-@router.delete(
- "/folders/{folder_name}", summary="删除插件文件夹", response_model=_SchemaResponse[None]
-)
-async def delete_plugin_folder(
- folder_name: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)
-) -> Any:
+@router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=_SchemaResponse[None])
+async def delete_plugin_folder(folder_name: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> Any:
"""
删除插件文件夹
"""
@@ -868,9 +818,7 @@ async def update_folder_plugins(
return _SchemaResponse(success=result.success, message=result.message)
-@router.post(
- "/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None]
-)
+@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None])
def clone_plugin(
plugin_id: str,
clone_data: _SchemaPluginCloneRequest,
@@ -903,14 +851,86 @@ def clone_plugin(
return _SchemaResponse(success=False, message=f"创建插件分身失败:{str(e)}")
+@router.get(
+ "/runtime/capabilities",
+ summary="查询插件运行能力",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def plugin_capabilities(
+ plugin_id: Optional[str] = None,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+) -> _SchemaResponse:
+ """查询运行中插件注册的命令、动作和定时服务。"""
+ manager = get_plugin_manager()
+ data: dict[str, Any] = {}
+ commands = manager.get_plugin_commands(pid=plugin_id) or []
+ if commands:
+ data["commands"] = [
+ {
+ "cmd": command.get("cmd"),
+ "desc": command.get("desc"),
+ "plugin_id": command.get("pid"),
+ **({"data": command.get("data")} if command.get("data") else {}),
+ }
+ for command in commands
+ ]
+ actions = manager.get_plugin_actions(pid=plugin_id) or []
+ if actions:
+ data["actions"] = [
+ {
+ "plugin_id": group.get("plugin_id"),
+ "plugin_name": group.get("plugin_name"),
+ "actions": [{"id": item.get("id"), "name": item.get("name")} for item in group.get("actions", [])],
+ }
+ for group in actions
+ ]
+ services = manager.get_plugin_services(pid=plugin_id) or []
+ if services:
+ data["services"] = [
+ {
+ "id": service.get("id"),
+ "name": service.get("name"),
+ **({"trigger": str(service.get("trigger"))} if service.get("trigger") else {}),
+ **(
+ {"trigger_kwargs": {key: str(value) for key, value in service.get("kwargs", {}).items()}}
+ if service.get("kwargs")
+ else {}
+ ),
+ }
+ for service in services
+ ]
+ return _SchemaResponse(success=True, data=data)
+
+
+@router.get(
+ "/runtime/{plugin_id}/data",
+ summary="查询插件持久化数据",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def plugin_data(
+ plugin_id: str,
+ key: Optional[str] = None,
+ max_chars: Optional[int] = None,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """读取插件单键或全部持久化数据,并对大结果进行裁剪。"""
+ try:
+ data = await PluginDataQueryService(
+ runtime.agent.plugin_data,
+ get_plugin_snapshot,
+ ).query(plugin_id, key=key, max_chars=max_chars)
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, data=data)
+
+
@router.get(
"/{plugin_id}",
summary="获取插件配置",
response_model=_SchemaJsonObject,
)
-async def plugin_config(
- plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)
-) -> dict:
+async def plugin_config(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> dict:
"""
根据插件ID获取插件配置信息
"""
@@ -932,9 +952,7 @@ def set_plugin_config(
@router.delete("/{plugin_id}", summary="卸载插件", response_model=_SchemaResponse[None])
-def uninstall_plugin(
- plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)
-) -> Any:
+def uninstall_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any:
"""
卸载插件
"""
diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py
index beb46e6eb..dac522e00 100644
--- a/app/api/endpoints/recommend.py
+++ b/app/api/endpoints/recommend.py
@@ -4,14 +4,29 @@ from fastapi import Depends, HTTPException, status
from app.adapters.web.security.access import verify_token
from app.api.response import ResponseAPIRouter
+from app.application.music.projection import simplify_music_info
+from app.chain.listenbrainz import (
+ LISTENBRAINZ_CHART_RANGES,
+ LISTENBRAINZ_FRESH_MAX_DAYS,
+ LISTENBRAINZ_FRESH_SORTS,
+)
from app.chain.recommend import RecommendChain
+from app.domain.media import normalize_music_type
from app.runtime.events import eventmanager
from app.schemas.event import RecommendMediaSource as _SchemaRecommendMediaSource
from app.schemas.event import RecommendSourceEventData
from app.schemas.exception import TMDbException
+from app.schemas.recommend import AgentRecommendationItem as _SchemaAgentRecommendationItem
+from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.transfer import MusicInfo as _SchemaMusicInfo
-from app.schemas.types import ChainEventType
+from app.schemas.types import (
+ MUSIC_ENTITY_ALBUM,
+ MUSIC_ENTITY_RECORDING,
+ ChainEventType,
+ MediaType,
+ media_type_to_agent,
+)
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
router = ResponseAPIRouter()
@@ -48,6 +63,141 @@ def source(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
return []
+@router.get(
+ "/agent",
+ summary="统一获取 Agent 推荐结果",
+ response_model=_SchemaResponse[list[_SchemaAgentRecommendationItem]],
+)
+async def agent_recommendations(
+ source: str = "tmdb_trending",
+ media_type: str = "all",
+ page: int = 1,
+ music_type: Optional[str] = None,
+ range_name: str = "this_month",
+ sort_by: str = "listen_count.desc",
+ days: int = 14,
+ fresh_sort: str = "release_date",
+ past: bool = True,
+ future: bool = True,
+ min_listen_count: int = 0,
+ with_cover: bool = False,
+ _: _SchemaTokenPayload = Depends(verify_token),
+) -> _SchemaResponse:
+ """按稳定来源标识返回有界影视、动画或音乐推荐结果。"""
+ page = max(1, page)
+ count = 20
+ if media_type != "all":
+ media_type_enum = MediaType.from_agent(media_type)
+ if media_type_enum is None:
+ return _SchemaResponse(success=False, message="无效的媒体类型")
+ media_type = media_type_enum.to_agent()
+ chain = RecommendChain()
+ if source in {"listenbrainz_chart", "listenbrainz_fresh"}:
+ if media_type not in {"all", "music"}:
+ return _SchemaResponse(
+ success=False,
+ message="ListenBrainz 来源只支持音乐媒体类型",
+ )
+ normalized_music_type = normalize_music_type(music_type, allow_artist=False) if music_type else None
+ if music_type and normalized_music_type is None:
+ return _SchemaResponse(success=False, message="无效的音乐实体类型")
+ if source == "listenbrainz_chart":
+ if range_name not in LISTENBRAINZ_CHART_RANGES:
+ return _SchemaResponse(success=False, message="无效的榜单周期")
+ if sort_by not in {"listen_count.desc", "listen_count.asc"}:
+ return _SchemaResponse(success=False, message="无效的榜单排序")
+ music_results = await chain.async_music_chart(
+ range_name=range_name,
+ page=page,
+ count=count,
+ sort_by=sort_by,
+ min_listen_count=max(0, min_listen_count),
+ with_cover=with_cover,
+ entity=normalized_music_type or MUSIC_ENTITY_RECORDING,
+ )
+ else:
+ if normalized_music_type not in {None, MUSIC_ENTITY_ALBUM}:
+ return _SchemaResponse(
+ success=False,
+ message="新发行结果只支持专辑",
+ )
+ if fresh_sort not in LISTENBRAINZ_FRESH_SORTS:
+ return _SchemaResponse(success=False, message="无效的新发行排序")
+ if not past and not future:
+ return _SchemaResponse(
+ success=False,
+ message="past 和 future 不能同时为 false",
+ )
+ music_results = await chain.async_music_fresh_releases(
+ days=max(1, min(days, LISTENBRAINZ_FRESH_MAX_DAYS)),
+ sort=fresh_sort,
+ past=past,
+ future=future,
+ page=page,
+ count=count,
+ with_cover=with_cover,
+ )
+ return _SchemaResponse(
+ success=True,
+ data=[simplify_music_info(item) for item in music_results or []],
+ )
+ if media_type == "music":
+ return _SchemaResponse(
+ success=False,
+ message="音乐推荐需使用 ListenBrainz 来源",
+ )
+ source_calls = {
+ "tmdb_trending": lambda: chain.async_tmdb_trending(page=page),
+ "tmdb_movies": lambda: chain.async_tmdb_movies(page=page),
+ "tmdb_tvs": lambda: chain.async_tmdb_tvs(page=page),
+ "douban_movie_hot": lambda: chain.async_douban_movie_hot(page=page, count=count),
+ "douban_tv_hot": lambda: chain.async_douban_tv_hot(page=page, count=count),
+ "douban_movie_showing": lambda: chain.async_douban_movie_showing(page=page, count=count),
+ "douban_movies": lambda: chain.async_douban_movies(page=page, count=count),
+ "douban_tvs": lambda: chain.async_douban_tvs(page=page, count=count),
+ "douban_movie_top250": lambda: chain.async_douban_movie_top250(page=page, count=count),
+ "douban_tv_weekly_chinese": lambda: chain.async_douban_tv_weekly_chinese(page=page, count=count),
+ "douban_tv_weekly_global": lambda: chain.async_douban_tv_weekly_global(page=page, count=count),
+ "douban_tv_animation": lambda: chain.async_douban_tv_animation(page=page, count=count),
+ "bangumi_calendar": lambda: chain.async_bangumi_calendar(page=page, count=count),
+ }
+ if source == "douban_hot":
+ results = []
+ if media_type in {"all", "movie"}:
+ results.extend(await chain.async_douban_movie_hot(page=page, count=count))
+ if media_type in {"all", "tv"}:
+ results.extend(await chain.async_douban_tv_hot(page=page, count=count))
+ else:
+ operation = source_calls.get(source)
+ if operation is None:
+ return _SchemaResponse(success=False, message=f"不支持的推荐来源: {source}")
+ results = await operation()
+ projected = []
+ for item in (results or [])[:count]:
+ if not isinstance(item, dict):
+ continue
+ projected.append(
+ {
+ "title": item.get("title"),
+ "en_title": item.get("en_title"),
+ "year": item.get("year"),
+ "type": media_type_to_agent(item.get("type")),
+ "season": item.get("season"),
+ "tmdb_id": item.get("tmdb_id"),
+ "imdb_id": item.get("imdb_id"),
+ "douban_id": item.get("douban_id"),
+ "bangumi_id": item.get("bangumi_id"),
+ "anilist_id": item.get("anilist_id"),
+ "media_source": item.get("media_source"),
+ "media_id": item.get("media_id"),
+ "vote_average": item.get("vote_average"),
+ "poster_path": item.get("poster_path"),
+ "detail_link": item.get("detail_link"),
+ }
+ )
+ return _SchemaResponse(success=True, data=projected)
+
+
@router.get(
"/bangumi_calendar",
summary="Bangumi每日放送",
@@ -92,9 +242,7 @@ async def music_douban(
return await RecommendChain().async_music_douban(page=page, count=count)
-@router.get(
- "/douban_showing", summary="豆瓣正在热映", response_model=List[_SchemaMediaInfo]
-)
+@router.get("/douban_showing", summary="豆瓣正在热映", response_model=List[_SchemaMediaInfo])
async def douban_showing(
page: Optional[int] = 1,
count: Optional[int] = 30,
@@ -106,9 +254,7 @@ async def douban_showing(
return await RecommendChain().async_douban_movie_showing(page=page, count=count)
-@router.get(
- "/douban_movies", summary="豆瓣电影", response_model=List[_SchemaMediaInfo]
-)
+@router.get("/douban_movies", summary="豆瓣电影", response_model=List[_SchemaMediaInfo])
async def douban_movies(
sort: Optional[str] = "R",
tags: Optional[str] = "",
@@ -119,9 +265,7 @@ async def douban_movies(
"""
浏览豆瓣电影信息
"""
- return await RecommendChain().async_douban_movies(
- sort=sort, tags=tags, page=page, count=count
- )
+ return await RecommendChain().async_douban_movies(sort=sort, tags=tags, page=page, count=count)
@router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[_SchemaMediaInfo])
@@ -135,9 +279,7 @@ async def douban_tvs(
"""
浏览豆瓣剧集信息
"""
- return await RecommendChain().async_douban_tvs(
- sort=sort, tags=tags, page=page, count=count
- )
+ return await RecommendChain().async_douban_tvs(sort=sort, tags=tags, page=page, count=count)
@router.get(
@@ -204,9 +346,7 @@ async def douban_tv_animation(
return await RecommendChain().async_douban_tv_animation(page=page, count=count)
-@router.get(
- "/douban_movie_hot", summary="豆瓣热门电影", response_model=List[_SchemaMediaInfo]
-)
+@router.get("/douban_movie_hot", summary="豆瓣热门电影", response_model=List[_SchemaMediaInfo])
async def douban_movie_hot(
page: Optional[int] = 1,
count: Optional[int] = 30,
@@ -218,9 +358,7 @@ async def douban_movie_hot(
return await RecommendChain().async_douban_movie_hot(page=page, count=count)
-@router.get(
- "/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[_SchemaMediaInfo]
-)
+@router.get("/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[_SchemaMediaInfo])
async def douban_tv_hot(
page: Optional[int] = 1,
count: Optional[int] = 30,
@@ -296,15 +434,9 @@ async def tmdb_tvs(
)
-@router.get(
- "/tmdb_trending", summary="TMDB流行趋势", response_model=List[_SchemaMediaInfo]
-)
-async def tmdb_trending(
- page: Optional[int] = 1, _: _SchemaTokenPayload = Depends(verify_token)
-) -> Any:
+@router.get("/tmdb_trending", summary="TMDB流行趋势", response_model=List[_SchemaMediaInfo])
+async def tmdb_trending(page: Optional[int] = 1, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
TMDB流行趋势
"""
- return await _require_tmdb_result(
- RecommendChain().async_tmdb_trending(page=page, raise_exception=True)
- )
+ return await _require_tmdb_result(RecommendChain().async_tmdb_trending(page=page, raise_exception=True))
diff --git a/app/api/endpoints/rule.py b/app/api/endpoints/rule.py
new file mode 100644
index 000000000..662af3a3d
--- /dev/null
+++ b/app/api/endpoints/rule.py
@@ -0,0 +1,210 @@
+"""过滤规则和规则组管理 API。"""
+
+from typing import Optional
+
+from fastapi import Depends
+
+from app.api.context import get_host_runtime
+from app.api.dependencies.auth import get_current_active_superuser_async
+from app.api.principal import ApiPrincipal
+from app.api.response import ResponseAPIRouter
+from app.application.filtering import FilterRuleService
+from app.schemas.common import JsonObject as _SchemaJsonObject
+from app.schemas.response import Response as _SchemaResponse
+from app.schemas.rule import (
+ CustomFilterRuleCreateRequest as _SchemaCustomFilterRuleCreateRequest,
+)
+from app.schemas.rule import (
+ CustomFilterRuleUpdateRequest as _SchemaCustomFilterRuleUpdateRequest,
+)
+from app.schemas.rule import (
+ FilterRuleGroupCreateRequest as _SchemaFilterRuleGroupCreateRequest,
+)
+from app.schemas.rule import (
+ FilterRuleGroupUpdateRequest as _SchemaFilterRuleGroupUpdateRequest,
+)
+from app.startup.composition.context import HostRuntime
+
+router = ResponseAPIRouter()
+
+
+def _service(runtime: HostRuntime) -> FilterRuleService:
+ """从宿主组合根收窄出规则 API 所需端口。"""
+ return FilterRuleService(
+ runtime.agent.subscriptions,
+ runtime.agent.async_rule_group_mutation_scope,
+ runtime.system.publish_config_changed,
+ )
+
+
+@router.get(
+ "/builtin",
+ summary="查询内置过滤规则",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def query_builtin_rules(
+ rule_ids: Optional[list[str]] = None,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+) -> _SchemaResponse:
+ """返回内置规则及规则串语法。"""
+ return _SchemaResponse(
+ success=True,
+ data=FilterRuleService.query_builtin(rule_ids),
+ )
+
+
+@router.get(
+ "/custom",
+ summary="查询自定义过滤规则",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def query_custom_rules(
+ rule_ids: Optional[list[str]] = None,
+ include_group_refs: bool = True,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+) -> _SchemaResponse:
+ """返回自定义规则和可选规则组引用。"""
+ return _SchemaResponse(
+ success=True,
+ data=FilterRuleService.query_custom(
+ rule_ids,
+ include_group_refs=include_group_refs,
+ ),
+ )
+
+
+@router.get(
+ "/groups",
+ summary="查询过滤规则组",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def query_rule_groups(
+ group_names: Optional[list[str]] = None,
+ include_usage: bool = True,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """返回规则组、解析层级和可选引用位置。"""
+ return _SchemaResponse(
+ success=True,
+ data=await _service(runtime).query_groups(
+ group_names,
+ include_usage=include_usage,
+ ),
+ )
+
+
+@router.post(
+ "/custom",
+ summary="新增自定义过滤规则",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def add_custom_rule(
+ payload: _SchemaCustomFilterRuleCreateRequest,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """校验并新增一条自定义过滤规则。"""
+ try:
+ data = await _service(runtime).add_custom(**payload.model_dump())
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
+
+
+@router.put(
+ "/custom/{rule_id}",
+ summary="更新自定义过滤规则",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def update_custom_rule(
+ rule_id: str,
+ payload: _SchemaCustomFilterRuleUpdateRequest,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """更新自定义规则并在改名时原子重写引用。"""
+ try:
+ data = await _service(runtime).update_custom(
+ current_rule_id=rule_id,
+ **payload.model_dump(),
+ )
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
+
+
+@router.delete(
+ "/custom/{rule_id}",
+ summary="删除自定义过滤规则",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def delete_custom_rule(
+ rule_id: str,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """删除一条未被规则组引用的自定义过滤规则。"""
+ try:
+ data = await _service(runtime).delete_custom(rule_id)
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
+
+
+@router.post(
+ "/groups",
+ summary="新增过滤规则组",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def add_rule_group(
+ payload: _SchemaFilterRuleGroupCreateRequest,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """新增一个已经完成语法与引用校验的规则组。"""
+ try:
+ data = await _service(runtime).add_group(**payload.model_dump())
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
+
+
+@router.put(
+ "/groups/{name}",
+ summary="更新过滤规则组",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def update_rule_group(
+ name: str,
+ payload: _SchemaFilterRuleGroupUpdateRequest,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """更新规则组并原子重写全部名称引用。"""
+ try:
+ data = await _service(runtime).update_group(
+ current_name=name,
+ **payload.model_dump(),
+ )
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
+
+
+@router.delete(
+ "/groups/{name}",
+ summary="删除过滤规则组",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def delete_rule_group(
+ name: str,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """删除规则组并原子清理全部全局和订阅引用。"""
+ try:
+ data = await _service(runtime).delete_group(name)
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py
index f3f486b00..32e40c586 100644
--- a/app/api/endpoints/storage.py
+++ b/app/api/endpoints/storage.py
@@ -14,6 +14,7 @@ from app.api.dependencies.auth import (
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.application.configuration import get_api_runtime_config_snapshot
+from app.application.directory import DirectoryHelper
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer.facade import TransferChain
@@ -21,18 +22,74 @@ from app.foundation import text as text_tools
from app.runtime.progress import ProgressHelper
from app.schemas.common import ManageRequest as _SchemaManageRequest
from app.schemas.response import Response as _SchemaResponse
+from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf
from app.schemas.types import ProgressKey
from app.schemas.workflow import FileItem as _SchemaFileItem
router = ResponseAPIRouter()
-@router.post(
- "/manage", summary="网盘存储统一管理", response_model=_SchemaResponse[Dict[str, Any]]
+@router.get(
+ "/directories",
+ summary="查询目录配置",
+ response_model=_SchemaResponse[list[_SchemaTransferDirectoryConf]],
)
-def manage(
- request: _SchemaManageRequest, _: ApiPrincipal = Depends(get_current_active_superuser)
-) -> Any:
+def directory_settings(
+ directory_type: str = "all",
+ storage_type: str = "all",
+ name: Optional[str] = None,
+ _: ApiPrincipal = Depends(get_current_active_superuser),
+) -> _SchemaResponse:
+ """按用途、存储类型和名称筛选目录配置。"""
+ helper = DirectoryHelper()
+ if directory_type == "download":
+ directories = helper.get_download_dirs()
+ elif directory_type == "library":
+ directories = helper.get_library_dirs()
+ elif directory_type == "all":
+ directories = helper.get_dirs()
+ else:
+ return _SchemaResponse(success=False, message="directory_type 无效")
+ results = []
+ for directory in directories:
+ if storage_type == "local":
+ if directory_type == "download" and directory.storage != "local":
+ continue
+ if directory_type == "library" and directory.library_storage != "local":
+ continue
+ elif storage_type == "remote":
+ if directory_type == "download" and directory.storage == "local":
+ continue
+ if directory_type == "library" and directory.library_storage == "local":
+ continue
+ elif storage_type != "all":
+ return _SchemaResponse(success=False, message="storage_type 无效")
+ if name and name.lower() not in (directory.name or "").lower():
+ continue
+ results.append(
+ {
+ "name": directory.name,
+ "priority": directory.priority,
+ "storage": directory.storage,
+ "download_path": directory.download_path,
+ "library_path": directory.library_path,
+ "library_storage": directory.library_storage,
+ "media_type": directory.media_type,
+ "media_category": directory.media_category,
+ "monitor_type": directory.monitor_type,
+ "monitor_mode": directory.monitor_mode,
+ "transfer_type": directory.transfer_type,
+ "overwrite_mode": directory.overwrite_mode,
+ "renaming": directory.renaming,
+ "scraping": directory.scraping,
+ "notify": directory.notify,
+ }
+ )
+ return _SchemaResponse(success=True, data=results)
+
+
+@router.post("/manage", summary="网盘存储统一管理", response_model=_SchemaResponse[Dict[str, Any]])
+def manage(request: _SchemaManageRequest, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any:
"""
网盘存储统一管理入口
@@ -99,9 +156,7 @@ def mkdir(
@router.post("/delete", summary="删除文件或目录", response_model=_SchemaResponse[None])
-def delete(
- fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)
-) -> Any:
+def delete(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any:
"""
删除文件或目录
:param fileitem: 文件项
@@ -121,18 +176,12 @@ def delete(
responses={
200: {
"description": "文件内容",
- "content": {
- "application/octet-stream": {
- "schema": {"type": "string", "format": "binary"}
- }
- },
+ "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}},
},
404: {"model": _SchemaResponse[None], "description": "文件下载失败"},
},
)
-def download(
- fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)
-) -> Any:
+def download(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any:
"""
下载文件或目录
:param fileitem: 文件项
@@ -153,15 +202,11 @@ def download(
responses={
200: {
"description": "图片内容",
- "content": {
- "image/jpeg": {"schema": {"type": "string", "format": "binary"}}
- },
+ "content": {"image/jpeg": {"schema": {"type": "string", "format": "binary"}}},
}
},
)
-def image(
- fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)
-) -> Any:
+def image(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any:
"""
下载文件或目录
:param fileitem: 文件项
@@ -196,9 +241,7 @@ def rename(
transferchain = TransferChain()
runtime_config = get_api_runtime_config_snapshot()
media_exts = (
- runtime_config.media_extensions
- + runtime_config.subtitle_extensions
- + runtime_config.audio_extensions
+ runtime_config.media_extensions + runtime_config.subtitle_extensions + runtime_config.audio_extensions
)
# 递归修改目录内文件(智能识别命名)
sub_files: List[_SchemaFileItem] = StorageChain().list_files(fileitem)
@@ -210,9 +253,7 @@ def rename(
handled = 0
for sub_file in sub_files:
handled += 1
- progress.update(
- value=handled / total * 100, text=f"正在处理 {sub_file.name} ..."
- )
+ progress.update(value=handled / total * 100, text=f"正在处理 {sub_file.name} ...")
if sub_file.type == "dir":
continue
if not sub_file.extension:
@@ -226,25 +267,15 @@ def rename(
)
if not context or not context.media_info:
progress.end()
- return _SchemaResponse(
- success=False, message=f"{sub_path.name} 未识别到媒体信息"
- )
- new_path = transferchain.recommend_name(
- meta=context.meta_info, mediainfo=context.media_info
- )
+ return _SchemaResponse(success=False, message=f"{sub_path.name} 未识别到媒体信息")
+ new_path = transferchain.recommend_name(meta=context.meta_info, mediainfo=context.media_info)
if not new_path:
progress.end()
- return _SchemaResponse(
- success=False, message=f"{sub_path.name} 未识别到新名称"
- )
- ret: _SchemaResponse = rename(
- fileitem=sub_file, new_name=Path(new_path).name, recursive=False
- )
+ return _SchemaResponse(success=False, message=f"{sub_path.name} 未识别到新名称")
+ ret: _SchemaResponse = rename(fileitem=sub_file, new_name=Path(new_path).name, recursive=False)
if not ret.success:
progress.end()
- return _SchemaResponse(
- success=False, message=f"{sub_path.name} 重命名失败!"
- )
+ return _SchemaResponse(success=False, message=f"{sub_path.name} 重命名失败!")
progress.end()
# 重命名自己
result = StorageChain().rename_file(fileitem, new_name)
diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py
index 615e00350..383238ff5 100644
--- a/app/api/endpoints/system.py
+++ b/app/api/endpoints/system.py
@@ -32,6 +32,7 @@ from app.application.network import get_configured_network_test_service
from app.application.rules import RuleHelper
from app.application.scheduling import get_scheduler
from app.application.security.url import SecurityUtils
+from app.application.settings import SystemSettingsService
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.application.system import LogFileData, LogNotFoundError
from app.chain.media import MediaChain
@@ -52,6 +53,7 @@ from app.schemas.common import JsonObjectList as _SchemaJsonObjectList
from app.schemas.common import TimeData as _SchemaTimeData
from app.schemas.common import ValueData as _SchemaValueData
from app.schemas.response import Response as _SchemaResponse
+from app.schemas.system import CustomIdentifiersUpdateRequest as _SchemaCustomIdentifiersUpdateRequest
from app.schemas.system import DatabaseBackupArtifactData as _SchemaDatabaseBackupArtifactData
from app.schemas.system import DatabaseBackupVerificationData as _SchemaDatabaseBackupVerificationData
from app.schemas.system import NetTestTarget as _SchemaNetTestTarget
@@ -60,6 +62,7 @@ from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyn
from app.schemas.system import RuleTestData as _SchemaRuleTestData
from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData
from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListData
+from app.schemas.system import SystemSettingsUpdateRequest as _SchemaSystemSettingsUpdateRequest
from app.schemas.system import SystemUpdateStatus as _SchemaSystemUpdateStatus
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.types import SystemConfigKey
@@ -108,20 +111,14 @@ def _verify_log_resource_superuser(
return token_payload
-async def _build_log_zip_response(
- name: str, runtime: HostRuntime
-) -> StreamingResponse:
+async def _build_log_zip_response(name: str, runtime: HostRuntime) -> StreamingResponse:
"""读取受限日志条目并生成兼容 ZIP 响应。"""
try:
entries = await runtime.system.collect_logs(name)
except LogNotFoundError as error:
raise HTTPException(status_code=404, detail="Not Found") from error
- zip_data, zip_stem = await anyio.to_thread.run_sync(
- _build_log_zip_data, name, entries
- )
- headers = {
- "Content-Disposition": f'attachment; filename="{zip_stem}.zip"'
- }
+ zip_data, zip_stem = await anyio.to_thread.run_sync(_build_log_zip_data, name, entries)
+ headers = {"Content-Disposition": f'attachment; filename="{zip_stem}.zip"'}
return StreamingResponse(
iter([zip_data]),
media_type="application/zip",
@@ -129,9 +126,7 @@ async def _build_log_zip_response(
)
-def _build_log_zip_data(
- name: str, entries: list[LogFileData]
-) -> tuple[bytes, str]:
+def _build_log_zip_data(name: str, entries: list[LogFileData]) -> tuple[bytes, str]:
"""只负责把应用服务提供的日志内容编码为 ZIP framing。"""
zip_buffer = io.BytesIO()
filename_time = datetime.now().strftime("%Y%m%d-%H%M%S")
@@ -145,8 +140,6 @@ def _build_log_zip_data(
return zip_buffer.getvalue(), zip_stem
-
-
async def fetch_image(
url: str,
proxy: Optional[bool] = None,
@@ -234,11 +227,7 @@ async def proxy_img(
图片代理,可选是否使用代理服务器,支持 HTTP 缓存
"""
allowed_domains = set(get_runtime_settings().get("SECURITY_IMAGE_DOMAINS", []))
- cookies = (
- MediaServerChain().get_image_cookies(server=None, image_url=imgurl)
- if use_cookies
- else None
- )
+ cookies = MediaServerChain().get_image_cookies(server=None, image_url=imgurl) if use_cookies else None
return await fetch_image(
url=imgurl,
proxy=proxy,
@@ -370,9 +359,7 @@ async def list_database_backups(
) -> list[_SchemaDatabaseBackupArtifactData]:
"""列出当前备份目录中的正式制品,不触发内容校验。"""
try:
- artifacts = await run_in_threadpool_to_completion(
- get_database_governance().list_backups
- )
+ artifacts = await run_in_threadpool_to_completion(get_database_governance().list_backups)
except Exception as error:
logger.exception("读取数据库备份列表失败")
raise HTTPException(status_code=500, detail="读取数据库备份列表失败,请查看日志") from error
@@ -389,9 +376,7 @@ async def create_database_backup(
) -> _SchemaDatabaseBackupArtifactData:
"""创建、校验并原子发布当前活动数据库的一致快照。"""
try:
- artifact = await run_in_threadpool_to_completion(
- get_database_governance().create_backup
- )
+ artifact = await run_in_threadpool_to_completion(get_database_governance().create_backup)
except DatabaseBackupInProgressError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
except Exception as error:
@@ -465,9 +450,7 @@ async def get_env_setting(
"""
查询系统环境变量,包括当前版本号(仅管理员)
"""
- info = get_runtime_settings().snapshot(
- exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"}
- )
+ info = get_runtime_settings().snapshot(exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"})
info.update(
{
"VERSION": get_app_version(),
@@ -568,9 +551,7 @@ async def get_progress(
summary="查询公开系统设置",
response_model=_SchemaResponse[_SchemaValueData],
)
-async def get_public_setting(
- key: str, _: ApiPrincipal = Depends(get_current_active_user_async)
-) -> _SchemaResponse:
+async def get_public_setting(key: str, _: ApiPrincipal = Depends(get_current_active_user_async)) -> _SchemaResponse:
"""
查询普通用户可读取的非敏感系统设置
"""
@@ -598,9 +579,7 @@ async def sync_plugin_market_from_wiki(
"""
从 Wiki 插件文档同步插件市场仓库地址。
"""
- result = await runtime.system.sync_plugin_market(
- request.wiki_url if request else None
- )
+ result = await runtime.system.sync_plugin_market(request.wiki_url if request else None)
return _SchemaResponse(
success=result.success,
message=result.message,
@@ -613,9 +592,7 @@ async def sync_plugin_market_from_wiki(
summary="查询系统设置",
response_model=_SchemaResponse[_SchemaValueData],
)
-async def get_setting(
- key: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)
-) -> _SchemaResponse:
+async def get_setting(key: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> _SchemaResponse:
"""
查询系统设置(仅管理员)
"""
@@ -641,6 +618,103 @@ async def set_setting(
return _SchemaResponse(success=result.success, message=result.message)
+@router.get(
+ "/settings",
+ summary="统一查询系统设置",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def query_settings(
+ setting_key: Optional[str] = None,
+ group: Optional[str] = "all",
+ keyword: Optional[str] = None,
+ include_values: Optional[bool] = None,
+ show_secrets: bool = False,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """按登记元数据查询设置,并默认对敏感值递归脱敏。"""
+ try:
+ data = SystemSettingsService(
+ get_runtime_settings(),
+ get_configured_system_config(),
+ runtime.system.publish_config_changed,
+ ).query(
+ setting_key=setting_key,
+ group=group,
+ keyword=keyword,
+ include_values=include_values,
+ show_secrets=show_secrets,
+ )
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, data=data)
+
+
+@router.post(
+ "/settings",
+ summary="统一更新系统设置",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def update_settings(
+ payload: _SchemaSystemSettingsUpdateRequest,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """按替换、字典合并或列表项操作更新一个登记设置。"""
+ try:
+ data = await SystemSettingsService(
+ get_runtime_settings(),
+ get_configured_system_config(),
+ runtime.system.publish_config_changed,
+ ).update(**payload.model_dump())
+ except ValueError as error:
+ return _SchemaResponse(success=False, message=str(error))
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
+
+
+@router.get(
+ "/identifiers",
+ summary="查询自定义识别词",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def query_custom_identifiers(
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+) -> _SchemaResponse:
+ """返回完整的自定义识别词列表。"""
+ identifiers = get_configured_system_config().get(SystemConfigKey.CustomIdentifiers) or []
+ return _SchemaResponse(
+ success=True,
+ data={"count": len(identifiers), "identifiers": identifiers},
+ )
+
+
+@router.post(
+ "/identifiers",
+ summary="更新自定义识别词",
+ response_model=_SchemaResponse[_SchemaJsonObject],
+)
+async def update_custom_identifiers(
+ payload: _SchemaCustomIdentifiersUpdateRequest,
+ _: ApiPrincipal = Depends(get_current_active_superuser_async),
+ runtime: HostRuntime = Depends(get_host_runtime),
+) -> _SchemaResponse:
+ """完整替换自定义识别词并清理识别解析缓存。"""
+ identifiers = [item for item in payload.identifiers if item is not None]
+ data = await SystemSettingsService(
+ get_runtime_settings(),
+ get_configured_system_config(),
+ runtime.system.publish_config_changed,
+ ).update(
+ setting_key=SystemConfigKey.CustomIdentifiers.value,
+ value=identifiers or None,
+ )
+ from app.domain.metainfo import clear_rust_parse_options_cache
+
+ clear_rust_parse_options_cache()
+ data.update({"count": len(identifiers), "identifiers": identifiers})
+ return _SchemaResponse(success=True, message=data.get("message"), data=data)
+
+
@router.get(
"/message",
summary="实时消息",
@@ -695,13 +769,9 @@ async def _get_logging_impl(
except LogNotFoundError as error:
raise HTTPException(status_code=404, detail="Not Found") from error
except Exception as error:
- return Response(
- content=f"读取日志文件失败: {error}", media_type="text/plain"
- )
+ return Response(content=f"读取日志文件失败: {error}", media_type="text/plain")
try:
- source = await runtime.system.follow_log(
- resolved_logfile, length or 50, request.is_disconnected
- )
+ source = await runtime.system.follow_log(resolved_logfile, length or 50, request.is_disconnected)
except LogNotFoundError as error:
raise HTTPException(status_code=404, detail="Not Found") from error
@@ -724,7 +794,15 @@ async def _get_logging_impl(
summary="实时日志",
response_model=None,
response_class=StreamingResponse,
- responses={200: {"description": "实时日志流或完整日志文本", "content": {"text/event-stream": {"schema": {"type": "string"}}, "text/plain": {"schema": {"type": "string"}}}}},
+ responses={
+ 200: {
+ "description": "实时日志流或完整日志文本",
+ "content": {
+ "text/event-stream": {"schema": {"type": "string"}},
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ }
+ },
)
async def get_logging(
request: Request,
@@ -745,11 +823,7 @@ async def get_logging(
responses={
200: {
"description": "日志 ZIP 文件",
- "content": {
- "application/zip": {
- "schema": {"type": "string", "format": "binary"}
- }
- },
+ "content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}},
}
},
)
@@ -935,9 +1009,7 @@ def modulelist(_: _SchemaTokenPayload = Depends(verify_token)):
return _SchemaResponse(success=True, data={"modules": modules})
-@router.get(
- "/moduletest/{moduleid}", summary="模块可用性测试", response_model=_SchemaResponse[None]
-)
+@router.get("/moduletest/{moduleid}", summary="模块可用性测试", response_model=_SchemaResponse[None])
def moduletest(moduleid: str, _: _SchemaTokenPayload = Depends(verify_token)):
"""
模块可用性测试接口
@@ -1006,9 +1078,7 @@ def download_system_update(
):
"""启动后台下载并立即返回当前状态(仅管理员)。"""
result = runtime.system.download_update()
- return _SchemaResponse(
- success=result.success, data=result.data, message=result.message
- )
+ return _SchemaResponse(success=result.success, data=result.data, message=result.message)
@router.post(
@@ -1039,9 +1109,7 @@ def run_scheduler(jobid: str, _: ApiPrincipal = Depends(get_current_active_super
return _SchemaResponse(success=True)
-@router.get(
- "/runscheduler2", summary="运行服务(API_TOKEN)", response_model=_SchemaResponse[None]
-)
+@router.get("/runscheduler2", summary="运行服务(API_TOKEN)", response_model=_SchemaResponse[None])
def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]):
"""
执行命令(API_TOKEN认证)
diff --git a/app/api/routers.py b/app/api/routers.py
index 4ca24df50..243e617b5 100644
--- a/app/api/routers.py
+++ b/app/api/routers.py
@@ -25,6 +25,7 @@ from app.api.endpoints import (
openai,
plugin,
recommend,
+ rule,
search,
site,
storage,
@@ -63,6 +64,7 @@ API_V1_ROUTER_SPECS: tuple[RouterSpec, ...] = (
RouterSpec(douban.router, "/douban", ("douban",)),
RouterSpec(tmdb.router, "/tmdb", ("tmdb",)),
RouterSpec(history.router, "/history", ("history",)),
+ RouterSpec(rule.router, "/rule", ("rule",)),
RouterSpec(system.router, "/system", ("system",)),
RouterSpec(notification.router, "/notification", ("notification",)),
RouterSpec(llm.router, "/llm", ("llm",)),
diff --git a/app/application/commands.py b/app/application/commands.py
index f43634c0d..111028601 100644
--- a/app/application/commands.py
+++ b/app/application/commands.py
@@ -9,8 +9,11 @@ Command 实现由 startup 组合根在生命周期启动阶段注册,避免 ap
agent.tools / api.endpoints -> application.commands <- startup(注册 Command 类)
"""
+from collections.abc import Callable
from typing import Any, Dict, Optional
+from app.schemas.types import EventType, NotificationChannel
+
# Command 类:由 startup/initializers/command.py 在显式启动阶段注册。
_command_class: Any = None
@@ -31,9 +34,7 @@ def reset_command_class() -> None:
def get_command_object() -> Any:
"""返回命令注册表实例。"""
if _command_class is None:
- raise RuntimeError(
- "命令服务未初始化:请先通过 register_command_class 注册 Command 类"
- )
+ raise RuntimeError("命令服务未初始化:请先通过 register_command_class 注册 Command 类")
return _command_class()
@@ -50,3 +51,39 @@ def get_command(name: str) -> Optional[Any]:
def init_commands(plugin_id: Optional[str] = None) -> None:
"""初始化命令(可指定单个插件)。"""
get_command_object().init_commands(plugin_id)
+
+
+def dispatch_command(
+ command: str,
+ *,
+ user_id: str,
+ channel: Optional[NotificationChannel] = None,
+ source: Optional[str] = None,
+ publish_event: Callable[[EventType, dict[str, Any]], Any],
+) -> dict[str, Any]:
+ """校验斜杠命令并通过注入的事件端口异步触发执行。"""
+ normalized = command.strip()
+ if not normalized:
+ raise ValueError("命令不能为空")
+ if not normalized.startswith("/"):
+ normalized = f"/{normalized}"
+ command_name = normalized.split()[0]
+ matched = get_command(command_name)
+ if matched is None:
+ available = [f"{name} - {info.get('description', '无描述')}" for name, info in get_commands().items()]
+ raise ValueError(f"命令 {command_name} 不存在" + (f";可用命令: {'; '.join(available)}" if available else ""))
+ publish_event(
+ EventType.CommandExcute,
+ {
+ "cmd": normalized,
+ "user": user_id,
+ "channel": channel,
+ "source": source,
+ },
+ )
+ return {
+ "message": f"命令 {command_name} 已触发执行",
+ "command": normalized,
+ "command_desc": matched.get("description", ""),
+ **({"plugin_id": matched["pid"]} if matched.get("pid") else {}),
+ }
diff --git a/app/application/download/tasks.py b/app/application/download/tasks.py
index 1cd267344..daa113b79 100644
--- a/app/application/download/tasks.py
+++ b/app/application/download/tasks.py
@@ -1,7 +1,8 @@
"""下载任务查询与控制应用服务。"""
-from typing import Callable, List, Optional
+from typing import Any, Callable, List, Optional
+from app.application.directory import validate_download_save_path
from app.application.history import DownloadHistorySnapshot
from app.schemas.transfer import DownloaderTorrent, DownloadTaskMedia
from app.schemas.types import TorrentStatus
@@ -36,9 +37,7 @@ class DownloadTaskService:
)
if not torrents:
return []
- history_map = self._get_history_by_hashes(
- [torrent.hash for torrent in torrents if torrent.hash]
- )
+ history_map = self._get_history_by_hashes([torrent.hash for torrent in torrents if torrent.hash])
for torrent in torrents:
if not torrent.hash:
continue
@@ -81,3 +80,168 @@ class DownloadTaskService:
) -> bool:
"""删除单个下载任务。"""
return self._remove_torrents(hashs=[hash_str], downloader=name)
+
+
+class DownloadTaskMutationService:
+ """执行单个下载任务的受控高级修改。"""
+
+ def __init__(
+ self,
+ *,
+ list_torrents: Callable[..., list[Any]],
+ set_tags: Callable[..., bool],
+ set_downloading: Callable[..., bool],
+ update_torrent: Callable[..., dict[str, bool]],
+ ) -> None:
+ """注入下载器查询和修改端口。"""
+ self._list_torrents = list_torrents
+ self._set_tags = set_tags
+ self._set_downloading = set_downloading
+ self._update_torrent = update_torrent
+
+ @staticmethod
+ def _validate_hash(hash_value: str) -> None:
+ """校验 BitTorrent v1 Hash,拒绝模糊任务定位。"""
+ if len(hash_value) != 40 or any(character not in "0123456789abcdefABCDEF" for character in hash_value):
+ raise ValueError("hash 格式无效")
+
+ @staticmethod
+ def _normalize_list(values: Optional[list[str]]) -> Optional[list[str]]:
+ """移除字符串列表中的空项。"""
+ if values is None:
+ return None
+ return [str(value).strip() for value in values if str(value).strip()]
+
+ @staticmethod
+ def _result(operation: str, success: bool, message: str) -> dict[str, Any]:
+ """构造一个稳定的子操作响应。"""
+ return {
+ "operation": operation,
+ "success": success,
+ "message": message,
+ }
+
+ def update(
+ self,
+ *,
+ hash_value: str,
+ action: Optional[str] = None,
+ tags: Optional[list[str]] = None,
+ downloader: Optional[str] = None,
+ download_limit: Optional[float] = None,
+ upload_limit: Optional[float] = None,
+ trackers: Optional[list[str]] = None,
+ save_path: Optional[str] = None,
+ category: Optional[str] = None,
+ ratio_limit: Optional[float] = None,
+ seeding_time_limit: Optional[int] = None,
+ ) -> dict[str, Any]:
+ """校验并执行启停、标签、限速、Tracker 与保存位置修改。"""
+ self._validate_hash(hash_value)
+ if action not in {None, "start", "stop"}:
+ raise ValueError("action 只支持 start 或 stop")
+ tags = self._normalize_list(tags)
+ trackers = self._normalize_list(trackers)
+ mutation_values = (
+ action,
+ tags,
+ download_limit,
+ upload_limit,
+ trackers,
+ save_path,
+ category,
+ ratio_limit,
+ seeding_time_limit,
+ )
+ if not any(value is not None and value != [] for value in mutation_values):
+ raise ValueError("至少需要指定一个要更新的字段")
+ resolved_downloader = downloader
+ if not resolved_downloader:
+ torrents = (
+ self._list_torrents(
+ hashs=[hash_value],
+ include_all_tags=True,
+ )
+ or []
+ )
+ resolved_downloader = getattr(torrents[0], "downloader", None) if torrents else None
+ if not resolved_downloader:
+ raise ValueError("未找到下载任务或下载器不可用")
+ if save_path is not None:
+ save_path = validate_download_save_path(save_path)
+ results = []
+ if tags:
+ success = bool(
+ self._set_tags(
+ hashs=[hash_value],
+ tags=tags,
+ downloader=resolved_downloader,
+ )
+ )
+ results.append(
+ self._result(
+ "tags",
+ success,
+ f"成功设置标签:{', '.join(tags)}" if success else "设置标签失败",
+ )
+ )
+ if action:
+ success = bool(
+ self._set_downloading(
+ hash_str=hash_value,
+ oper=action,
+ name=resolved_downloader,
+ )
+ )
+ action_name = "开始" if action == "start" else "暂停"
+ results.append(
+ self._result(
+ action,
+ success,
+ f"成功{action_name}下载任务" if success else f"{action_name}下载任务失败",
+ )
+ )
+ advanced_values = (
+ download_limit,
+ upload_limit,
+ trackers,
+ save_path,
+ category,
+ ratio_limit,
+ seeding_time_limit,
+ )
+ if any(value is not None and value != [] for value in advanced_values):
+ update_result = (
+ self._update_torrent(
+ hash_string=hash_value,
+ downloader=resolved_downloader,
+ download_limit=download_limit,
+ upload_limit=upload_limit,
+ tracker_list=trackers,
+ save_path=save_path,
+ category=category,
+ ratio_limit=ratio_limit,
+ seeding_time_limit=seeding_time_limit,
+ )
+ or {}
+ )
+ labels = {
+ "limits": "限速/做种策略",
+ "trackers": "Tracker",
+ "save_path": "保存目录",
+ "category": "分类",
+ }
+ for operation, success in update_result.items():
+ label = labels.get(operation, operation)
+ results.append(
+ self._result(
+ operation,
+ bool(success),
+ f"{label}修改成功" if success else f"{label}修改失败或下载器不支持",
+ )
+ )
+ return {
+ "hash": hash_value,
+ "downloader": resolved_downloader,
+ "results": results,
+ }
diff --git a/app/application/filtering.py b/app/application/filtering.py
new file mode 100644
index 000000000..03b9720f6
--- /dev/null
+++ b/app/application/filtering.py
@@ -0,0 +1,678 @@
+"""过滤规则查询、校验、序列化和引用处理应用服务。"""
+
+import copy
+import re
+from collections.abc import Awaitable, Callable
+from contextlib import AbstractAsyncContextManager
+from typing import Any, Dict, Iterable, Optional
+
+from app.application.configuration import get_configured_system_config
+from app.application.rules import (
+ BUILTIN_RULE_SET,
+ AsyncRuleGroupMutationService,
+ RuleHelper,
+ RuleParser,
+)
+from app.application.subscription.contract import SubscriptionRepository
+from app.schemas.rule import CustomRule
+from app.schemas.system import FilterRuleGroup
+from app.schemas.types import SystemConfigKey
+
+RULE_ID_PATTERN = re.compile(r"^[A-Za-z0-9]+$")
+RULE_TOKEN_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9]*|[0-9][A-Za-z0-9]+")
+NUMERIC_RANGE_PATTERN = re.compile(r"^\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?$")
+RuleConfigPublisher = Callable[[SystemConfigKey, Any], Awaitable[None]]
+
+MEDIA_TYPE_ALIASES = {
+ "movie": "电影",
+ "film": "电影",
+ "tv": "电视剧",
+ "series": "电视剧",
+ "show": "电视剧",
+ "music": "音乐",
+ "电影": "电影",
+ "电视剧": "电视剧",
+ "音乐": "音乐",
+}
+
+RULE_STRING_SYNTAX = {
+ "level_separator": ">",
+ "and_operator": "&",
+ "not_operator": "!",
+ "supported_grouping": "Parentheses are supported inside a single level.",
+ "spacing_note": "Prefer spaces around '&', and '>' for readability; use '!RULE' for negation.",
+ "match_order": "Levels are evaluated from left to right. The first matched level wins and stops further matching.",
+ "match_result": "If no level matches, the torrent is filtered out. If a level matches, the torrent is kept.",
+ "writing_workflow": [
+ "First query built-in rules and custom rules to learn valid rule IDs.",
+ "Compose one priority level with '&', '!' and optional parentheses.",
+ "Join multiple priority levels with '>' from highest priority to lowest priority.",
+ "Use spaces around '&', and '>' for readability.",
+ ],
+ "examples": [
+ {
+ "description": "Prefer torrents with special subtitles and Chinese dubbing at 4K, otherwise fall back to Chinese subtitles and Chinese dubbing at 4K.",
+ "rule_string": "SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL",
+ },
+ {
+ "description": "Inside one level, require 4K and reject Blu-ray source.",
+ "rule_string": "4K & !BLU",
+ },
+ {
+ "description": "Inside one level, accept either special subtitles or Chinese subtitles, then also require 1080P.",
+ "rule_string": "(SPECSUB | CNSUB) & 1080P",
+ },
+ ],
+}
+
+
+def normalize_optional_text(value: Optional[str]) -> Optional[str]:
+ """把空白字符串折叠为 None,避免保存无意义的空值。"""
+ if value is None:
+ return None
+ value = str(value).strip()
+ return value or None
+
+
+def normalize_media_type(value: Optional[str]) -> Optional[str]:
+ """兼容英中文媒体类型输入,最终统一为后端实际使用的中文值。"""
+ value = normalize_optional_text(value)
+ if not value:
+ return None
+ normalized = MEDIA_TYPE_ALIASES.get(value.lower(), value)
+ if normalized not in {"电影", "电视剧", "音乐"}:
+ raise ValueError("media_type 仅支持 '电影'、'电视剧'、'音乐'、'movie'、'tv' 或 'music'")
+ return normalized
+
+
+def validate_numeric_range(field_name: str, value: Optional[str]) -> Optional[str]:
+ """校验 size_range / publish_time 这类单值或区间值。"""
+ value = normalize_optional_text(value)
+ if not value:
+ return None
+ if not NUMERIC_RANGE_PATTERN.match(value):
+ raise ValueError(f"{field_name} 格式无效,支持 '1000' 或 '1000-5000' 这类数字区间格式")
+
+ parts = [float(item.strip()) for item in value.split("-")]
+ if len(parts) == 2 and parts[0] > parts[1]:
+ raise ValueError(f"{field_name} 区间起始值不能大于结束值")
+ return value
+
+
+def validate_seeders(value: Optional[str]) -> Optional[str]:
+ """做种人数最终会被 int() 解析,这里提前拦住非法值。"""
+ value = normalize_optional_text(value)
+ if not value:
+ return None
+ if not value.isdigit():
+ raise ValueError("seeders 必须是非负整数")
+ return value
+
+
+def get_builtin_rules() -> Dict[str, dict]:
+ """返回内置规则的深拷贝,避免调用方误改共享常量。"""
+ return copy.deepcopy(BUILTIN_RULE_SET)
+
+
+def get_custom_rules() -> list[CustomRule]:
+ return RuleHelper().get_custom_rules()
+
+
+def get_rule_groups() -> list[FilterRuleGroup]:
+ return RuleHelper().get_rule_groups()
+
+
+def build_custom_rule_map(rules: Optional[Iterable[CustomRule]] = None) -> Dict[str, CustomRule]:
+ return {rule.id: rule for rule in (rules or get_custom_rules()) if rule.id}
+
+
+def build_rule_group_map(
+ groups: Optional[Iterable[FilterRuleGroup]] = None,
+) -> Dict[str, FilterRuleGroup]:
+ return {group.name: group for group in (groups or get_rule_groups()) if group.name}
+
+
+def extract_rule_tokens(rule_string: Optional[str]) -> list[str]:
+ """从规则串里提取规则 ID,用于引用分析和未知规则校验。"""
+ if not rule_string:
+ return []
+ # dict.fromkeys 用来在保留顺序的同时去重,便于展示和报错。
+ return list(dict.fromkeys(RULE_TOKEN_PATTERN.findall(rule_string)))
+
+
+def parse_rule_string(rule_string: str) -> dict:
+ """使用后端同款 RuleParser 解析规则串,并拆出每一层的元数据。"""
+ normalized = normalize_optional_text(rule_string)
+ if not normalized:
+ raise ValueError("rule_string 不能为空")
+
+ parser = RuleParser()
+ levels = [level.strip() for level in normalized.split(">")]
+ if any(not level for level in levels):
+ raise ValueError("rule_string 不能包含空层级,请检查 '>' 两侧内容")
+
+ parsed_levels = []
+ for index, level in enumerate(levels, start=1):
+ try:
+ parser.parse(level)
+ except Exception as exc: # pragma: no cover - 依赖 pyparsing 的具体异常
+ raise ValueError(f"规则串第 {index} 层语法错误: {exc}") from exc
+
+ parsed_levels.append(
+ {
+ "priority": index,
+ "expression": level,
+ "referenced_rules": extract_rule_tokens(level),
+ }
+ )
+
+ return {
+ "rule_string": " > ".join(levels),
+ "levels": parsed_levels,
+ "referenced_rules": extract_rule_tokens(normalized),
+ }
+
+
+def validate_rule_string(rule_string: str, available_rule_ids: Iterable[str]) -> dict:
+ """校验规则串语法和引用规则是否都存在。"""
+ parsed = parse_rule_string(rule_string)
+ available_ids = set(available_rule_ids)
+ unknown_rules = sorted({rule_id for rule_id in parsed["referenced_rules"] if rule_id not in available_ids})
+ if unknown_rules:
+ raise ValueError(f"rule_string 引用了不存在的规则: {', '.join(unknown_rules)}")
+ return parsed
+
+
+def serialize_builtin_rule(rule_id: str, payload: dict) -> dict:
+ """把内置规则整理成适合 Agent 阅读的结构。"""
+ data = copy.deepcopy(payload)
+ data["id"] = rule_id
+ data["source"] = "builtin"
+ return data
+
+
+def serialize_custom_rule(rule: CustomRule, group_refs: Optional[list[str]] = None) -> dict:
+ data = rule.model_dump(exclude_none=True)
+ data["source"] = "custom"
+ data["referenced_by_rule_groups"] = group_refs or []
+ return data
+
+
+def serialize_rule_group(group: FilterRuleGroup, usage: Optional[dict] = None) -> dict:
+ """查询时尽量附带解析结果,便于 Agent 理解优先级层级。"""
+ data = group.model_dump(exclude_none=True)
+ if group.rule_string:
+ try:
+ parsed = parse_rule_string(group.rule_string)
+ data["levels"] = parsed["levels"]
+ data["referenced_rules"] = parsed["referenced_rules"]
+ data["syntax_valid"] = True
+ except ValueError as exc:
+ data["syntax_valid"] = False
+ data["syntax_error"] = str(exc)
+ data["referenced_rules"] = extract_rule_tokens(group.rule_string)
+ else:
+ data["syntax_valid"] = False
+ data["syntax_error"] = "rule_string 为空"
+ data["referenced_rules"] = []
+ data["usage"] = usage or default_rule_group_usage()
+ return data
+
+
+def default_rule_group_usage() -> dict:
+ return {
+ "used_in_global_search": False,
+ "used_in_global_subscribe": False,
+ "used_in_global_best_version": False,
+ "subscribes": [],
+ }
+
+
+async def collect_rule_group_usages(
+ repository: SubscriptionRepository,
+ group_names: Optional[Iterable[str]] = None,
+) -> Dict[str, dict]:
+ """收集规则组在全局配置和订阅上的引用情况。"""
+ target_names = set(group_names or [])
+ search_groups = set(get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or [])
+ subscribe_groups = set(get_configured_system_config().get(SystemConfigKey.SubscribeFilterRuleGroups) or [])
+ best_version_groups = set(get_configured_system_config().get(SystemConfigKey.BestVersionFilterRuleGroups) or [])
+
+ usage_map = {name: default_rule_group_usage() for name in target_names}
+
+ def ensure_usage(name: str) -> dict:
+ if name not in usage_map:
+ usage_map[name] = default_rule_group_usage()
+ return usage_map[name]
+
+ for name in search_groups:
+ if target_names and name not in target_names:
+ continue
+ ensure_usage(name)["used_in_global_search"] = True
+ for name in subscribe_groups:
+ if target_names and name not in target_names:
+ continue
+ ensure_usage(name)["used_in_global_subscribe"] = True
+ for name in best_version_groups:
+ if target_names and name not in target_names:
+ continue
+ ensure_usage(name)["used_in_global_best_version"] = True
+
+ subscribes = await repository.async_list()
+ for subscribe in subscribes:
+ filter_groups = (
+ [str(name) for name in subscribe.filter_groups] if isinstance(subscribe.filter_groups, list) else []
+ )
+ for name in filter_groups:
+ if target_names and name not in target_names:
+ continue
+ ensure_usage(name)["subscribes"].append(
+ {
+ "subscribe_id": subscribe.id,
+ "name": subscribe.name,
+ "season": subscribe.season,
+ "type": subscribe.type,
+ "username": subscribe.username,
+ "best_version": bool(subscribe.best_version),
+ }
+ )
+
+ return usage_map
+
+
+def collect_custom_rule_group_refs(
+ rule_groups: Iterable[FilterRuleGroup],
+ rule_ids: Optional[Iterable[str]] = None,
+) -> Dict[str, list[str]]:
+ """收集自定义规则被哪些规则组引用。"""
+ target_rule_ids = set(rule_ids or [])
+ refs: Dict[str, list[str]] = {rule_id: [] for rule_id in target_rule_ids}
+
+ for group in rule_groups:
+ if not group.name or not group.rule_string:
+ continue
+ referenced = set(extract_rule_tokens(group.rule_string))
+ for rule_id in referenced:
+ if target_rule_ids and rule_id not in target_rule_ids:
+ continue
+ refs.setdefault(rule_id, []).append(group.name)
+
+ for names in refs.values():
+ names.sort()
+ return refs
+
+
+def normalize_custom_rule(
+ rule_id: str,
+ name: str,
+ include: Optional[str],
+ exclude: Optional[str],
+ size_range: Optional[str],
+ seeders: Optional[str],
+ publish_time: Optional[str],
+ existing_rules: Iterable[CustomRule],
+ original_rule_id: Optional[str] = None,
+) -> CustomRule:
+ """新增/更新自定义规则时统一走这里,避免多处散落校验逻辑。"""
+ normalized_rule_id = normalize_optional_text(rule_id)
+ normalized_name = normalize_optional_text(name)
+ if not normalized_rule_id:
+ raise ValueError("rule_id 不能为空")
+ if not normalized_name:
+ raise ValueError("name 不能为空")
+ if not RULE_ID_PATTERN.match(normalized_rule_id):
+ raise ValueError("rule_id 仅支持英文字母和数字")
+ if normalized_rule_id in BUILTIN_RULE_SET and normalized_rule_id != original_rule_id:
+ raise ValueError(f"rule_id '{normalized_rule_id}' 与内置规则冲突,不能覆盖内置规则")
+
+ for existing_rule in existing_rules:
+ if existing_rule.id == normalized_rule_id and existing_rule.id != original_rule_id:
+ raise ValueError(f"rule_id '{normalized_rule_id}' 已存在")
+ if existing_rule.name == normalized_name and existing_rule.id != original_rule_id:
+ raise ValueError(f"规则名称 '{normalized_name}' 已存在")
+
+ return CustomRule(
+ id=normalized_rule_id,
+ name=normalized_name,
+ include=normalize_optional_text(include),
+ exclude=normalize_optional_text(exclude),
+ size_range=validate_numeric_range("size_range", size_range),
+ seeders=validate_seeders(seeders),
+ publish_time=validate_numeric_range("publish_time", publish_time),
+ )
+
+
+def normalize_rule_group(
+ name: str,
+ rule_string: str,
+ media_type: Optional[str],
+ category: Optional[str],
+ existing_groups: Iterable[FilterRuleGroup],
+ available_rule_ids: Iterable[str],
+ original_name: Optional[str] = None,
+) -> tuple[FilterRuleGroup, dict]:
+ """新增/更新规则组时统一校验名字、适用范围和规则串。"""
+ normalized_name = normalize_optional_text(name)
+ if not normalized_name:
+ raise ValueError("规则组名称不能为空")
+
+ for group in existing_groups:
+ if group.name == normalized_name and group.name != original_name:
+ raise ValueError(f"规则组名称 '{normalized_name}' 已存在")
+
+ normalized_media_type = normalize_media_type(media_type)
+ normalized_category = normalize_optional_text(category)
+ if normalized_category and not normalized_media_type:
+ raise ValueError("设置 category 时必须同时设置 media_type")
+
+ parsed = validate_rule_string(rule_string, available_rule_ids)
+ return (
+ FilterRuleGroup(
+ name=normalized_name,
+ rule_string=parsed["rule_string"],
+ media_type=normalized_media_type,
+ category=normalized_category,
+ ),
+ parsed,
+ )
+
+
+async def save_system_config(
+ key: SystemConfigKey,
+ value: Any,
+ publish_config_changed: RuleConfigPublisher,
+) -> Optional[bool]:
+ """通过统一入口保存配置并补发 ConfigChanged 事件。"""
+ normalized_value = value
+ if isinstance(normalized_value, list):
+ normalized_value = [item for item in normalized_value if item is not None and item != ""]
+ normalized_value = normalized_value or None
+
+ success = await get_configured_system_config().async_set(key, normalized_value)
+ if success:
+ await publish_config_changed(key, normalized_value)
+ return success
+
+
+def replace_rule_id_in_rule_string(rule_string: str, old_rule_id: str, new_rule_id: str) -> str:
+ """只替换完整 token,避免误伤其他规则名。"""
+ pattern = re.compile(rf"(? None:
+ """注入订阅端口、规则组事务作用域和配置事件发布端口。"""
+ self._subscriptions = subscriptions
+ self._mutation_scope = mutation_scope
+ self._publish_config_changed = publish_config_changed
+
+ @staticmethod
+ def query_builtin(rule_ids: Optional[list[str]] = None) -> dict[str, Any]:
+ """查询内置过滤规则及规则串语法。"""
+ rules = get_builtin_rules()
+ if rule_ids:
+ target_ids = set(rule_ids)
+ rules = {key: value for key, value in rules.items() if key in target_ids}
+ serialized = [serialize_builtin_rule(rule_id, payload) for rule_id, payload in rules.items()]
+ return {
+ "count": len(serialized),
+ "rule_string_syntax": RULE_STRING_SYNTAX,
+ "rules": serialized,
+ }
+
+ @staticmethod
+ def query_custom(
+ rule_ids: Optional[list[str]] = None,
+ *,
+ include_group_refs: bool = True,
+ ) -> dict[str, Any]:
+ """查询自定义过滤规则及规则组引用。"""
+ rules = get_custom_rules()
+ if rule_ids:
+ target_ids = set(rule_ids)
+ rules = [rule for rule in rules if rule.id in target_ids]
+ refs = (
+ collect_custom_rule_group_refs(
+ get_rule_groups(),
+ [rule.id for rule in rules if rule.id],
+ )
+ if include_group_refs
+ else {}
+ )
+ serialized = [serialize_custom_rule(rule, refs.get(rule.id)) for rule in rules]
+ return {"count": len(serialized), "rules": serialized}
+
+ async def query_groups(
+ self,
+ group_names: Optional[list[str]] = None,
+ *,
+ include_usage: bool = True,
+ ) -> dict[str, Any]:
+ """查询规则组、解析层级和可选引用位置。"""
+ groups = get_rule_groups()
+ if group_names:
+ target_names = set(group_names)
+ groups = [group for group in groups if group.name in target_names]
+ usage = (
+ await collect_rule_group_usages(
+ self._subscriptions,
+ [group.name for group in groups if group.name],
+ )
+ if include_usage
+ else {}
+ )
+ serialized = [serialize_rule_group(group, usage.get(group.name)) for group in groups]
+ return {
+ "count": len(serialized),
+ "rule_string_syntax": RULE_STRING_SYNTAX,
+ "rule_groups": serialized,
+ }
+
+ async def add_custom(self, **values: Any) -> dict[str, Any]:
+ """校验并新增一条自定义过滤规则。"""
+ rules = get_custom_rules()
+ new_rule = normalize_custom_rule(existing_rules=rules, **values)
+ rules.append(new_rule)
+ await save_system_config(
+ SystemConfigKey.CustomFilterRules,
+ [rule.model_dump(exclude_none=True) for rule in rules],
+ self._publish_config_changed,
+ )
+ return {
+ "message": f"已新增自定义过滤规则 {new_rule.id}",
+ "custom_rule": serialize_custom_rule(new_rule),
+ "count": len(rules),
+ }
+
+ async def update_custom(
+ self,
+ *,
+ current_rule_id: str,
+ new_rule_id: Optional[str] = None,
+ name: Optional[str] = None,
+ include: Optional[str] = None,
+ exclude: Optional[str] = None,
+ size_range: Optional[str] = None,
+ seeders: Optional[str] = None,
+ publish_time: Optional[str] = None,
+ ) -> dict[str, Any]:
+ """更新自定义规则,并在改名时原子重写规则组引用。"""
+ rules = get_custom_rules()
+ current = next((rule for rule in rules if rule.id == current_rule_id), None)
+ if current is None:
+ raise ValueError(f"自定义过滤规则 '{current_rule_id}' 不存在")
+ updated = normalize_custom_rule(
+ rule_id=new_rule_id or current.id,
+ name=name if name is not None else current.name,
+ include=include if include is not None else current.include,
+ exclude=exclude if exclude is not None else current.exclude,
+ size_range=size_range if size_range is not None else current.size_range,
+ seeders=seeders if seeders is not None else current.seeders,
+ publish_time=(publish_time if publish_time is not None else current.publish_time),
+ existing_rules=rules,
+ original_rule_id=current.id,
+ )
+ expected_rules = [rule.model_dump(exclude_none=True) for rule in rules]
+ final_rules = [updated if rule.id == current.id else rule for rule in rules]
+ rule_definitions = [rule.model_dump(exclude_none=True) for rule in final_rules]
+ groups = get_rule_groups()
+ updated_groups = groups
+ renamed_refs: list[str] = []
+ if updated.id != current.id:
+ updated_groups = []
+ for group in groups:
+ new_rule_string = replace_rule_id_in_rule_string(
+ group.rule_string or "",
+ current.id or "",
+ updated.id or "",
+ )
+ if new_rule_string != (group.rule_string or ""):
+ renamed_refs.append(group.name or "")
+ group = group.model_copy(update={"rule_string": new_rule_string})
+ updated_groups.append(group)
+ expected_groups = [group.model_dump(exclude_none=True) for group in groups]
+ group_definitions = [group.model_dump(exclude_none=True) for group in updated_groups]
+ async with self._mutation_scope() as mutation:
+ await mutation.apply(
+ group_definitions,
+ expected_rule_groups=expected_groups,
+ custom_rules=rule_definitions,
+ expected_custom_rules=expected_rules,
+ )
+ await self._publish_config_changed(SystemConfigKey.CustomFilterRules, rule_definitions)
+ await self._publish_config_changed(SystemConfigKey.UserFilterRuleGroups, group_definitions)
+ else:
+ await save_system_config(
+ SystemConfigKey.CustomFilterRules,
+ rule_definitions,
+ self._publish_config_changed,
+ )
+ refs = collect_custom_rule_group_refs(updated_groups, [updated.id]).get(updated.id, [])
+ return {
+ "message": f"已更新自定义过滤规则 {updated.id}",
+ "custom_rule": serialize_custom_rule(updated, refs),
+ "rule_groups_updated_for_rule_id_rename": renamed_refs,
+ }
+
+ async def delete_custom(self, rule_id: str) -> dict[str, Any]:
+ """删除未被规则组引用的自定义过滤规则。"""
+ rules = get_custom_rules()
+ if not any(rule.id == rule_id for rule in rules):
+ raise ValueError(f"自定义过滤规则 '{rule_id}' 不存在")
+ refs = collect_custom_rule_group_refs(get_rule_groups(), [rule_id]).get(rule_id, [])
+ if refs:
+ raise ValueError(f"自定义过滤规则 '{rule_id}' 仍被规则组引用: {', '.join(refs)}")
+ remaining = [rule for rule in rules if rule.id != rule_id]
+ await save_system_config(
+ SystemConfigKey.CustomFilterRules,
+ [rule.model_dump(exclude_none=True) for rule in remaining],
+ self._publish_config_changed,
+ )
+ return {
+ "message": f"已删除自定义过滤规则 {rule_id}",
+ "count": len(remaining),
+ }
+
+ async def add_group(
+ self,
+ *,
+ name: str,
+ rule_string: str,
+ media_type: Optional[str] = None,
+ category: Optional[str] = None,
+ ) -> dict[str, Any]:
+ """校验并新增规则组。"""
+ groups = get_rule_groups()
+ available_ids = set(get_builtin_rules()) | set(build_custom_rule_map())
+ new_group, parsed = normalize_rule_group(
+ name,
+ rule_string,
+ media_type,
+ category,
+ groups,
+ available_ids,
+ )
+ expected = [group.model_dump(exclude_none=True) for group in groups]
+ definitions = [*expected, new_group.model_dump(exclude_none=True)]
+ async with self._mutation_scope() as mutation:
+ await mutation.apply(definitions, expected_rule_groups=expected)
+ await self._publish_config_changed(SystemConfigKey.UserFilterRuleGroups, definitions)
+ usage = await collect_rule_group_usages(self._subscriptions, [new_group.name])
+ return {
+ "message": f"已新增规则组 {new_group.name}",
+ "rule_group": serialize_rule_group(new_group, usage.get(new_group.name)),
+ "parsed": parsed,
+ "count": len(definitions),
+ }
+
+ async def update_group(
+ self,
+ *,
+ current_name: str,
+ new_name: Optional[str] = None,
+ rule_string: Optional[str] = None,
+ media_type: Optional[str] = None,
+ category: Optional[str] = None,
+ ) -> dict[str, Any]:
+ """更新规则组并原子重写全局配置和订阅引用。"""
+ groups = get_rule_groups()
+ current = next((group for group in groups if group.name == current_name), None)
+ if current is None:
+ raise ValueError(f"规则组 '{current_name}' 不存在")
+ available_ids = set(get_builtin_rules()) | set(build_custom_rule_map())
+ updated, parsed = normalize_rule_group(
+ new_name or current.name or "",
+ rule_string if rule_string is not None else current.rule_string or "",
+ media_type if media_type is not None else current.media_type,
+ category if category is not None else current.category,
+ groups,
+ available_ids,
+ original_name=current.name,
+ )
+ expected = [group.model_dump(exclude_none=True) for group in groups]
+ final_groups = [updated if group.name == current.name else group for group in groups]
+ definitions = [group.model_dump(exclude_none=True) for group in final_groups]
+ async with self._mutation_scope() as mutation:
+ result = await mutation.apply(
+ definitions,
+ expected_rule_groups=expected,
+ previous_name=current.name,
+ current_name=updated.name,
+ )
+ await self._publish_config_changed(SystemConfigKey.UserFilterRuleGroups, definitions)
+ usage = await collect_rule_group_usages(self._subscriptions, [updated.name])
+ return {
+ "message": f"已更新规则组 {updated.name}",
+ "rule_group": serialize_rule_group(updated, usage.get(updated.name)),
+ "parsed": parsed,
+ "reference_updates": result.to_dict(),
+ }
+
+ async def delete_group(self, name: str) -> dict[str, Any]:
+ """删除规则组并原子清理全部引用。"""
+ groups = get_rule_groups()
+ if not any(group.name == name for group in groups):
+ raise ValueError(f"规则组 '{name}' 不存在")
+ expected = [group.model_dump(exclude_none=True) for group in groups]
+ remaining = [group for group in groups if group.name != name]
+ definitions = [group.model_dump(exclude_none=True) for group in remaining]
+ async with self._mutation_scope() as mutation:
+ result = await mutation.apply(
+ definitions,
+ expected_rule_groups=expected,
+ previous_name=name,
+ )
+ await self._publish_config_changed(SystemConfigKey.UserFilterRuleGroups, definitions)
+ return {
+ "message": f"已删除规则组 {name}",
+ "count": len(remaining),
+ "reference_updates": result.to_dict(),
+ }
diff --git a/app/application/messaging/agent.py b/app/application/messaging/agent.py
index fbbed5d19..2addfa1f6 100644
--- a/app/application/messaging/agent.py
+++ b/app/application/messaging/agent.py
@@ -30,7 +30,7 @@ import aiofiles # type: ignore[import-untyped]
from app.application import agent as agent_application
from app.application.agent import is_audio_input_available, transcribe_audio
-from app.application.commands import get_command, get_commands
+from app.application.commands import dispatch_command, get_command, get_commands
from app.application.configuration import get_api_runtime_config_snapshot
from app.application.messaging.chat import (
AgentChatPersistenceService,
@@ -1253,7 +1253,7 @@ async def terminate_web_agent_audio_process(
return
try:
await process.communicate()
- except (OSError, ProcessLookupError):
+ except OSError, ProcessLookupError:
pass
@@ -1705,6 +1705,24 @@ def build_web_agent_command_items() -> list[dict[str, Any]]:
return sorted(items, key=lambda item: (item["category"], item["command"]))
+def dispatch_web_agent_command(
+ command: str,
+ *,
+ user_id: str,
+ channel: Optional[NotificationChannel],
+ source: Optional[str],
+ publish_event: Callable[[Any, dict[str, Any]], Any],
+) -> dict[str, Any]:
+ """经消息应用边界校验并触发一条 Agent 斜杠命令。"""
+ return dispatch_command(
+ command,
+ user_id=user_id,
+ channel=channel,
+ source=source,
+ publish_event=publish_event,
+ )
+
+
def extract_web_agent_slash_command(text: str) -> Optional[str]:
"""
从 WebAgent 输入中提取斜杠命令名。
diff --git a/app/agent/tools/impl/_music_utils.py b/app/application/music/projection.py
similarity index 73%
rename from app/agent/tools/impl/_music_utils.py
rename to app/application/music/projection.py
index 9c53b3e39..9ce3328b1 100644
--- a/app/agent/tools/impl/_music_utils.py
+++ b/app/application/music/projection.py
@@ -1,4 +1,4 @@
-"""Agent 音乐工具共享的实体校验与结果精简函数。"""
+"""音乐实体面向 API 和 Agent 的有界结果投影。"""
from typing import Any
@@ -9,7 +9,6 @@ from app.domain.context import (
)
from app.schemas.types import media_type_to_agent
-
MUSIC_TRACK_PREVIEW_LIMIT = 100
MUSIC_RELEASE_PREVIEW_LIMIT = 20
@@ -47,31 +46,30 @@ def simplify_music_info(info: MusicInfo) -> dict[str, Any]:
def simplify_music_album(
- info: MusicAlbumInfo,
- *,
- track_limit: int = MUSIC_TRACK_PREVIEW_LIMIT,
+ info: MusicAlbumInfo,
+ *,
+ track_limit: int = MUSIC_TRACK_PREVIEW_LIMIT,
) -> dict[str, Any]:
"""精简专辑详情并限制曲目和发行版本预览,避免撑大 Agent 上下文。"""
normalized_track_limit = max(1, min(track_limit, MUSIC_TRACK_PREVIEW_LIMIT))
tracks = list(info.tracks or [])
releases = list(info.releases or [])
payload = simplify_music_info(info.to_music_info())
- payload.update({
- "release_date": info.release_date,
- "secondary_types": list(info.secondary_types or []),
- "tags": list(info.tags or []),
- "rating": info.rating,
- "rating_votes": info.rating_votes,
- "tracks": [
- simplify_music_info(track)
- for track in tracks[:normalized_track_limit]
- ],
- "tracks_total": len(tracks),
- "tracks_truncated": len(tracks) > normalized_track_limit,
- "releases": [release.to_dict() for release in releases[:MUSIC_RELEASE_PREVIEW_LIMIT]],
- "releases_total": len(releases),
- "releases_truncated": len(releases) > MUSIC_RELEASE_PREVIEW_LIMIT,
- })
+ payload.update(
+ {
+ "release_date": info.release_date,
+ "secondary_types": list(info.secondary_types or []),
+ "tags": list(info.tags or []),
+ "rating": info.rating,
+ "rating_votes": info.rating_votes,
+ "tracks": [simplify_music_info(track) for track in tracks[:normalized_track_limit]],
+ "tracks_total": len(tracks),
+ "tracks_truncated": len(tracks) > normalized_track_limit,
+ "releases": [release.to_dict() for release in releases[:MUSIC_RELEASE_PREVIEW_LIMIT]],
+ "releases_total": len(releases),
+ "releases_truncated": len(releases) > MUSIC_RELEASE_PREVIEW_LIMIT,
+ }
+ )
return payload
diff --git a/app/application/plugin/data.py b/app/application/plugin/data.py
index 2b589439d..9f40ffa08 100644
--- a/app/application/plugin/data.py
+++ b/app/application/plugin/data.py
@@ -1,6 +1,8 @@
-"""插件持久化数据查询与写用例。"""
+"""插件持久化数据查询、投影与写用例。"""
-from typing import Protocol
+import json
+from collections.abc import Callable
+from typing import Any, Optional, Protocol
from app.schemas.common import JsonData
@@ -57,3 +59,89 @@ class DeletePluginDataCommand:
except Exception:
self._unit_of_work.rollback()
raise
+
+
+DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000
+MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000
+PLUGIN_DATA_KEY_PREVIEW_LIMIT = 50
+PLUGIN_DATA_TRUNCATION_SUFFIX = "\n...(插件数据内容过长,已截断)"
+
+
+def clamp_preview_chars(max_chars: Optional[int]) -> int:
+ """约束插件数据预览长度,避免结果无限膨胀。"""
+ if max_chars is None:
+ return DEFAULT_PLUGIN_DATA_PREVIEW_CHARS
+ return max(512, min(int(max_chars), MAX_PLUGIN_DATA_PREVIEW_CHARS))
+
+
+def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, int, int, str]:
+ """稳定序列化插件数据并生成有界预览。"""
+ serialized = json.dumps(value, ensure_ascii=False, indent=2, default=str)
+ preview_limit = clamp_preview_chars(max_chars)
+ if len(serialized) <= preview_limit:
+ return False, len(serialized), len(serialized), serialized
+ preview = serialized[:preview_limit] + PLUGIN_DATA_TRUNCATION_SUFFIX
+ return True, len(serialized), len(preview), preview
+
+
+class PluginDataQueryService:
+ """查询已安装插件数据并对大结果执行稳定裁剪。"""
+
+ def __init__(
+ self,
+ repository: PluginDataQueryRepository,
+ snapshot: Callable[[str], Optional[dict[str, Any]]],
+ ) -> None:
+ """注入插件数据仓储和安装态快照查询函数。"""
+ self._repository = repository
+ self._snapshot = snapshot
+
+ async def query(
+ self,
+ plugin_id: str,
+ *,
+ key: Optional[str] = None,
+ max_chars: Optional[int] = None,
+ ) -> dict[str, Any]:
+ """读取单键或全部数据,并返回上下文安全的投影。"""
+ plugin = self._snapshot(plugin_id)
+ if plugin is None:
+ raise ValueError(f"插件 {plugin_id} 不存在")
+ if key:
+ value = await self._repository.get(plugin_id, key)
+ if value is None:
+ return {
+ **plugin,
+ "key": key,
+ "found": False,
+ "message": f"插件 {plugin_id} 没有数据项 {key}",
+ }
+ truncated, total_chars, returned_chars, preview = build_preview_payload(value, max_chars)
+ result = {
+ **plugin,
+ "key": key,
+ "found": True,
+ "truncated": truncated,
+ "total_chars": total_chars,
+ "returned_chars": returned_chars,
+ }
+ result["value_preview" if truncated else "value"] = preview if truncated else value
+ return result
+ data = await self._repository.list(plugin_id)
+ keys = list(data)
+ result = {
+ **plugin,
+ "count": len(data),
+ "keys": keys[:PLUGIN_DATA_KEY_PREVIEW_LIMIT],
+ "keys_truncated": len(keys) > PLUGIN_DATA_KEY_PREVIEW_LIMIT,
+ }
+ truncated, total_chars, returned_chars, preview = build_preview_payload(data, max_chars)
+ result.update(
+ {
+ "truncated": truncated,
+ "total_chars": total_chars,
+ "returned_chars": returned_chars,
+ "data_preview" if truncated else "data": (preview if truncated else data),
+ }
+ )
+ return result
diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/application/plugin/management.py
similarity index 84%
rename from app/agent/tools/impl/_plugin_tool_utils.py
rename to app/application/plugin/management.py
index a942c1afd..38b7a254e 100644
--- a/app/agent/tools/impl/_plugin_tool_utils.py
+++ b/app/application/plugin/management.py
@@ -1,21 +1,16 @@
-"""插件 Agent 工具共享辅助方法"""
+"""插件目录、生命周期和数据投影应用能力。"""
-import json
from typing import Any, Optional
-from app.adapters.external.plugin.client import is_local_repo_url
+from anyio import to_thread
+
from app.application.configuration import get_configured_system_config
from app.application.plugin.gateway import get_plugin_install_service
from app.application.plugin.runtime import get_plugin_manager
+from app.domain.plugin import is_local_plugin_source
from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
-
-# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。
-DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000
-MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000
-PLUGIN_DATA_KEY_PREVIEW_LIMIT = 50
-PLUGIN_DATA_TRUNCATION_SUFFIX = "\n...(插件数据内容过长,已截断)"
DEFAULT_PLUGIN_CANDIDATE_LIMIT = 50
MAX_PLUGIN_CANDIDATE_LIMIT = 200
@@ -36,40 +31,11 @@ def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]:
return None
-def clamp_preview_chars(max_chars: Optional[int]) -> int:
- """
- 约束插件数据预览长度,避免工具结果无限膨胀。
- """
- if max_chars is None:
- return DEFAULT_PLUGIN_DATA_PREVIEW_CHARS
- return max(512, min(int(max_chars), MAX_PLUGIN_DATA_PREVIEW_CHARS))
-
-
-def serialize_for_agent(value: Any) -> str:
- """
- 将结果稳定序列化为 JSON 字符串,无法原生序列化的对象退化为字符串。
- """
- return json.dumps(value, ensure_ascii=False, indent=2, default=str)
-
-
-def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, int, int, str]:
- """
- 为可能很大的插件数据生成预览结果。
- """
- serialized = serialize_for_agent(value)
- if len(serialized) <= clamp_preview_chars(max_chars):
- return False, len(serialized), len(serialized), serialized
-
- preview_limit = clamp_preview_chars(max_chars)
- preview = serialized[:preview_limit] + PLUGIN_DATA_TRUNCATION_SUFFIX
- return True, len(serialized), len(preview), preview
-
-
def refresh_plugin_registrations(plugin_id: str) -> None:
"""重新注册插件的定时任务、命令和动态 API 路由。"""
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
- from app.application.plugin.routes import register_plugin_api
from app.application.commands import init_commands
+ from app.application.plugin.routes import register_plugin_api
from app.application.scheduling import update_plugin_job
update_plugin_job(plugin_id)
@@ -104,7 +70,7 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]:
"system_version_message": getattr(plugin, "system_version_message", None),
"state": bool(getattr(plugin, "state", False)),
"repo_url": repo_url,
- "source": "local_repo" if is_local_repo_url(repo_url) else "market",
+ "source": "local_repo" if is_local_plugin_source(repo_url) else "market",
}
@@ -152,9 +118,7 @@ async def enrich_installed_plugin_sources(
本地插件对象只包含运行目录中的静态元数据,通常没有 repo_url。这里按需从
本地插件仓库和插件市场补齐来源,保证 Agent 后续安装、升级判断可以拿到仓库地址。
"""
- missing_source_plugins = [
- plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)
- ]
+ missing_source_plugins = [plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)]
if not missing_source_plugins:
return installed_plugins
@@ -165,9 +129,7 @@ async def enrich_installed_plugin_sources(
if source_plugin:
_merge_plugin_source_metadata(plugin, source_plugin)
- missing_source_plugins = [
- plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)
- ]
+ missing_source_plugins = [plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)]
if not missing_source_plugins:
return installed_plugins
@@ -281,7 +243,9 @@ def search_plugin_candidates(query: str, plugins: list[Any]) -> list[dict[str, A
)
-def summarize_candidates(matches: list[dict[str, Any]], limit: int = DEFAULT_PLUGIN_CANDIDATE_LIMIT) -> list[dict[str, Any]]:
+def summarize_candidates(
+ matches: list[dict[str, Any]], limit: int = DEFAULT_PLUGIN_CANDIDATE_LIMIT
+) -> list[dict[str, Any]]:
"""
压缩候选列表,避免一次性把完整市场数据返回给 Agent。
"""
@@ -324,10 +288,7 @@ async def inspect_plugin_sources(
plugin_id=plugin_id,
force=force,
)
- candidates = [
- candidate.public_dict()
- for candidate in inspection.online_candidates
- ]
+ candidates = [candidate.public_dict() for candidate in inspection.online_candidates]
if inspection.local_candidate is not None:
candidates.append(inspection.local_candidate.public_dict())
return {
@@ -345,7 +306,6 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
from app.application.plugin.folders import remove_plugin_from_folders
from app.application.plugin.routes import remove_plugin_api
from app.application.scheduling import remove_plugin_job
- from app.agent.tools.base import run_agent_blocking
plugin_manager = get_plugin_manager()
with plugin_manager.mutation(f"卸载插件 {plugin_id}"):
@@ -358,9 +318,7 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
config_oper = get_configured_system_config()
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
if plugin_id in install_plugins:
- install_plugins = [
- plugin for plugin in install_plugins if plugin != plugin_id
- ]
+ install_plugins = [plugin for plugin in install_plugins if plugin != plugin_id]
await config_oper.async_set(
SystemConfigKey.UserInstalledPlugins,
install_plugins,
@@ -381,8 +339,7 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
plugin_manager.delete_plugin_config(plugin_id)
plugin_manager.delete_plugin_data(plugin_id)
try:
- clone_files_removed = await run_agent_blocking(
- "plugin",
+ clone_files_removed = await to_thread.run_sync(
plugin_manager.remove_plugin_package,
plugin_id,
)
diff --git a/app/agent/policy/secrets.py b/app/application/security/secrets.py
similarity index 93%
rename from app/agent/policy/secrets.py
rename to app/application/security/secrets.py
index 97762356b..e430f2f1d 100644
--- a/app/agent/policy/secrets.py
+++ b/app/application/security/secrets.py
@@ -1,9 +1,8 @@
-"""Agent 设置工具与宿主回执共用的敏感字段身份判定。"""
+"""跨 API、Agent 和宿主回执使用的敏感字段身份判定。"""
import re
from typing import Any
-
_MAX_FIELD_NAME_CHARS = 256
_ACRONYM_BOUNDARY_PATTERN = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
_CAMEL_CASE_BOUNDARY_PATTERN = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
@@ -36,9 +35,7 @@ _SECRET_SETTING_NAMES = frozenset(
"cookiecloud_key",
}
)
-_SECRET_SETTING_ENDINGS = (
- "_encrypt_key",
-)
+_SECRET_SETTING_ENDINGS = ("_encrypt_key",)
def _normalize_field_name(value: Any) -> str:
diff --git a/app/agent/tools/impl/_system_setting_utils.py b/app/application/settings.py
similarity index 50%
rename from app/agent/tools/impl/_system_setting_utils.py
rename to app/application/settings.py
index fb9f7d66f..0e461cb49 100644
--- a/app/agent/tools/impl/_system_setting_utils.py
+++ b/app/application/settings.py
@@ -1,12 +1,21 @@
-"""系统设置工具共用的键解析与分组元数据。"""
+"""系统设置元数据、查询更新语义和敏感值脱敏能力。"""
+import copy
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Optional
-from app.agent.policy.secrets import is_secret_setting_key
+from app.application.configuration import (
+ RuntimeSettingsService,
+ SystemConfigService,
+)
+from app.application.plugin.runtime import plugin_system_config_mutation
+from app.application.security.secrets import is_secret_setting_key
from app.runtime.config import Settings
from app.schemas.types import SystemConfigKey
+SystemSettingPublisher = Callable[[Any, Any], Awaitable[None]]
+
@dataclass(frozen=True)
class SettingSpec:
@@ -285,23 +294,12 @@ for item in SystemConfigKey:
SINGLE_KEY_GROUP_ALIASES = {
_normalize_token(alias): next(
- (
- spec.key
- for spec in ALL_SETTING_SPECS.values()
- if spec.group == canonical_group
- ),
+ (spec.key for spec in ALL_SETTING_SPECS.values() if spec.group == canonical_group),
None,
)
for alias, canonical_group in GROUP_ALIASES.items()
if canonical_group not in {"all", "settings", "systemconfig"}
- and len(
- [
- spec.key
- for spec in ALL_SETTING_SPECS.values()
- if spec.group == canonical_group
- ]
- )
- == 1
+ and len([spec.key for spec in ALL_SETTING_SPECS.values() if spec.group == canonical_group]) == 1
}
@@ -325,17 +323,13 @@ def resolve_setting_spec(setting_key: Optional[str]) -> Optional[SettingSpec]:
return None
normalized = _normalize_token(setting_key)
- resolved_key = SETTING_KEY_ALIASES.get(normalized) or SINGLE_KEY_GROUP_ALIASES.get(
- normalized
- )
+ resolved_key = SETTING_KEY_ALIASES.get(normalized) or SINGLE_KEY_GROUP_ALIASES.get(normalized)
if not resolved_key:
return None
return ALL_SETTING_SPECS.get(resolved_key)
-def list_setting_specs(
- group: Optional[str] = "all", keyword: Optional[str] = None
-) -> list[SettingSpec]:
+def list_setting_specs(group: Optional[str] = "all", keyword: Optional[str] = None) -> list[SettingSpec]:
"""按分组和关键字筛选可查询的设置项。"""
normalized_group = normalize_group(group)
@@ -346,11 +340,7 @@ def list_setting_specs(
elif normalized_group == "systemconfig":
specs = list(SYSTEMCONFIG_SETTING_SPECS.values())
else:
- specs = [
- spec
- for spec in ALL_SETTING_SPECS.values()
- if spec.group == normalized_group
- ]
+ specs = [spec for spec in ALL_SETTING_SPECS.values() if spec.group == normalized_group]
if keyword:
normalized_keyword = _normalize_token(keyword)
@@ -373,16 +363,11 @@ def redact_secret_value(value: Any, *, redact_scalar: bool = False) -> Any:
"""递归脱敏配置值中的密钥、Cookie、Token 等敏感字段。"""
if isinstance(value, dict):
return {
- key: "***"
- if is_secret_setting_key(str(key))
- else redact_secret_value(item, redact_scalar=redact_scalar)
+ key: "***" if is_secret_setting_key(str(key)) else redact_secret_value(item, redact_scalar=redact_scalar)
for key, item in value.items()
}
if isinstance(value, list):
- return [
- redact_secret_value(item, redact_scalar=redact_scalar)
- for item in value
- ]
+ return [redact_secret_value(item, redact_scalar=redact_scalar) for item in value]
if isinstance(value, str):
return "***" if value and redact_scalar else value
return value
@@ -395,9 +380,254 @@ def should_redact_setting(spec: SettingSpec, value: Any) -> bool:
if isinstance(value, dict):
return any(is_secret_setting_key(str(key)) for key in value.keys())
if isinstance(value, list):
- return any(
- should_redact_setting(spec, item)
- for item in value
- if isinstance(item, dict)
- )
+ return any(should_redact_setting(spec, item) for item in value if isinstance(item, dict))
return False
+
+
+class SystemSettingsService:
+ """提供 Agent 与管理 API 共用的受控系统设置查询和更新。"""
+
+ def __init__(
+ self,
+ runtime_settings: RuntimeSettingsService,
+ system_config: SystemConfigService,
+ publish_config_changed: SystemSettingPublisher,
+ ) -> None:
+ """注入部署设置、持久化配置和配置事件发布端口。"""
+ self._runtime_settings = runtime_settings
+ self._system_config = system_config
+ self._publish_config_changed = publish_config_changed
+
+ def _load(self, spec: SettingSpec) -> Any:
+ """按设置来源读取当前值。"""
+ if spec.source == "settings":
+ return self._runtime_settings.get(spec.key)
+ return self._system_config.get(spec.systemconfig_key)
+
+ @staticmethod
+ def _summarize(value: Any, *, redacted: bool = False) -> dict[str, Any]:
+ """生成有界值摘要,避免配置列表和字典挤占响应上下文。"""
+ summary: dict[str, Any] = {
+ "has_value": value is not None,
+ "value_type": type(value).__name__,
+ "redacted": redacted,
+ }
+ if isinstance(value, list):
+ summary["item_count"] = len(value)
+ if value:
+ summary["item_type"] = type(value[0]).__name__
+ elif isinstance(value, dict):
+ keys = list(value)
+ summary["item_count"] = len(keys)
+ summary["keys_preview"] = keys[:10]
+ summary["keys_truncated"] = len(keys) > 10
+ elif isinstance(value, str):
+ summary["length"] = len(value)
+ summary["value_preview"] = value[:200]
+ summary["value_truncated"] = len(value) > 200
+ elif value is not None:
+ summary["value_preview"] = value
+ return summary
+
+ def query(
+ self,
+ *,
+ setting_key: Optional[str] = None,
+ group: Optional[str] = "all",
+ keyword: Optional[str] = None,
+ include_values: Optional[bool] = None,
+ show_secrets: bool = False,
+ ) -> dict[str, Any]:
+ """查询登记设置,并在未授权明文读取时递归脱敏。"""
+ if setting_key:
+ spec = resolve_setting_spec(setting_key)
+ if spec is None:
+ raise ValueError(f"系统设置项 '{setting_key}' 不存在")
+ specs = [spec]
+ else:
+ specs = list_setting_specs(group=group, keyword=keyword)
+ if not specs:
+ raise ValueError("没有找到匹配的系统设置项")
+ should_include_values = include_values if include_values is not None else len(specs) == 1
+ payload = []
+ for spec in specs:
+ value = self._load(spec)
+ redacted = should_redact_setting(spec, value) and not show_secrets
+ response_value = (
+ redact_secret_value(
+ value,
+ redact_scalar=is_secret_setting_key(spec.key),
+ )
+ if redacted
+ else value
+ )
+ item = {
+ "setting_key": spec.key,
+ "source": spec.source,
+ "group": spec.group,
+ "label": spec.label,
+ **self._summarize(response_value, redacted=redacted),
+ }
+ if should_include_values:
+ item["value"] = response_value
+ payload.append(item)
+ return {
+ "matched_count": len(payload),
+ "include_values": should_include_values,
+ "show_secrets": show_secrets,
+ "settings": payload,
+ }
+
+ @staticmethod
+ def _normalize_systemconfig_value(value: Any) -> Any:
+ """将只含空项的列表折叠为空配置。"""
+ if isinstance(value, list):
+ filtered = [item for item in value if item is not None]
+ return filtered or None
+ return value
+
+ @staticmethod
+ def _resolve_list_match(
+ spec: SettingSpec,
+ operation: str,
+ value: Any,
+ match_field: Optional[str],
+ match_value: Any,
+ ) -> tuple[Optional[str], Any]:
+ """解析列表项修改使用的稳定匹配字段和值。"""
+ resolved_field = match_field or get_default_list_match_field(spec.key)
+ resolved_value = match_value
+ if isinstance(value, dict):
+ if not resolved_field:
+ raise ValueError(f"{operation} 需要提供 match_field,或使用带默认匹配字段的系统配置项")
+ if resolved_value is None:
+ resolved_value = value.get(resolved_field)
+ if resolved_value is None:
+ raise ValueError(f"{operation} 缺少匹配值,请在 value.{resolved_field} 或 match_value 中提供")
+ elif resolved_value is None:
+ resolved_value = value
+ return resolved_field, resolved_value
+
+ @classmethod
+ def _prepare_next_value(
+ cls,
+ spec: SettingSpec,
+ current_value: Any,
+ value: Any,
+ operation: str,
+ remove_keys: Optional[list[str]],
+ match_field: Optional[str],
+ match_value: Any,
+ ) -> Any:
+ """按显式更新语义构造下一配置值。"""
+ if operation == "replace":
+ return value
+ if operation == "merge_dict":
+ if current_value is not None and not isinstance(current_value, dict):
+ raise ValueError("merge_dict 仅支持当前值为 dict 的设置项")
+ if value is not None and not isinstance(value, dict):
+ raise ValueError("merge_dict 的 value 必须是 dict 或 null")
+ next_value = dict(current_value or {})
+ next_value.update(value or {})
+ for key in remove_keys or []:
+ next_value.pop(key, None)
+ return next_value
+ if operation not in {"upsert_list_item", "remove_list_item"}:
+ raise ValueError(f"不支持的操作: {operation}")
+ if current_value is not None and not isinstance(current_value, list):
+ raise ValueError(f"{operation} 仅支持当前值为 list 的设置项")
+ next_items = list(copy.deepcopy(current_value or []))
+ resolved_field, resolved_value = cls._resolve_list_match(spec, operation, value, match_field, match_value)
+ if operation == "upsert_list_item":
+ if value is None:
+ raise ValueError("upsert_list_item 必须提供 value")
+ for index, item in enumerate(next_items):
+ matched = (
+ isinstance(item, dict) and resolved_field and item.get(resolved_field) == resolved_value
+ ) or (not resolved_field and item == resolved_value)
+ if matched:
+ next_items[index] = value
+ break
+ else:
+ next_items.append(value)
+ return next_items
+ return [
+ item
+ for item in next_items
+ if not (isinstance(item, dict) and resolved_field and item.get(resolved_field) == resolved_value)
+ and not (not resolved_field and item == resolved_value)
+ ]
+
+ async def update(
+ self,
+ *,
+ setting_key: str,
+ value: Any = None,
+ operation: str = "replace",
+ remove_keys: Optional[list[str]] = None,
+ match_field: Optional[str] = None,
+ match_value: Any = None,
+ ) -> dict[str, Any]:
+ """更新登记设置并发布统一配置变更事件。"""
+ spec = resolve_setting_spec(setting_key)
+ if spec is None:
+ raise ValueError(f"系统设置项 '{setting_key}' 不存在")
+ mutation_key = spec.systemconfig_key if spec.source == "systemconfig" else None
+ with plugin_system_config_mutation(mutation_key):
+ previous_value = self._load(spec)
+ next_value = self._prepare_next_value(
+ spec,
+ previous_value,
+ value,
+ operation,
+ remove_keys,
+ match_field,
+ match_value,
+ )
+ message = ""
+ event_value = next_value
+ if spec.source == "settings":
+ success, message = self._runtime_settings.update(spec.key, next_value)
+ if success is False:
+ raise ValueError(message or f"更新设置 {spec.key} 失败")
+ changed = success is True
+ else:
+ event_value = self._normalize_systemconfig_value(next_value)
+ changed = bool(
+ await self._system_config.async_set(
+ spec.systemconfig_key,
+ event_value,
+ )
+ )
+ if changed:
+ await self._publish_config_changed(spec.key, event_value)
+ saved_value = self._load(spec)
+ redact_values = should_redact_setting(spec, previous_value) or should_redact_setting(spec, saved_value)
+ return {
+ "message": message or (f"系统设置 {spec.key} 已更新" if changed else "配置值未发生变化"),
+ "changed": changed,
+ "operation": operation,
+ "setting": {
+ "setting_key": spec.key,
+ "source": spec.source,
+ "group": spec.group,
+ "label": spec.label,
+ },
+ "values_redacted": redact_values,
+ "previous_value": (
+ redact_secret_value(
+ previous_value,
+ redact_scalar=is_secret_setting_key(spec.key),
+ )
+ if redact_values
+ else previous_value
+ ),
+ "saved_value": (
+ redact_secret_value(
+ saved_value,
+ redact_scalar=is_secret_setting_key(spec.key),
+ )
+ if redact_values
+ else saved_value
+ ),
+ }
diff --git a/app/application/system.py b/app/application/system.py
index 007707005..fc2b5e865 100644
--- a/app/application/system.py
+++ b/app/application/system.py
@@ -19,9 +19,7 @@ from app.schemas.types import SystemConfigKey
if TYPE_CHECKING:
from app.application.rules import AsyncRuleGroupMutationService
-PLUGIN_MARKET_WIKI_URL = (
- "https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
-)
+PLUGIN_MARKET_WIKI_URL = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
_DATABASE_BACKUP_SETTING_KEYS = {
"DB_BACKUP_ENABLE",
"DB_BACKUP_CRON",
@@ -209,6 +207,14 @@ class SystemService:
self._plugin_mutation = plugin_mutation
self._rule_group_mutation = rule_group_mutation
+ async def publish_config_changed(
+ self,
+ key: Any,
+ value: JsonData = None,
+ ) -> None:
+ """发布一项已经持久化完成的系统配置变更。"""
+ await self._events.publish(key, value)
+
async def read_log(self, logfile: str) -> str:
"""读取日志并按旧接口语义倒序返回。"""
text = await self._logs.read(logfile)
@@ -261,9 +267,7 @@ class SystemService:
if fetched.status_code is None:
return SystemOperationResult(False, "无法访问 Wiki 插件仓库清单")
if fetched.status_code != 200:
- return SystemOperationResult(
- False, f"访问 Wiki 插件仓库清单失败,状态码:{fetched.status_code}"
- )
+ return SystemOperationResult(False, f"访问 Wiki 插件仓库清单失败,状态码:{fetched.status_code}")
if not fetched.repos:
return SystemOperationResult(False, "未在 Wiki 中识别到插件仓库地址")
local_repos = self._split_repos(self.settings.get("PLUGIN_MARKET", ""))
@@ -348,9 +352,7 @@ class SystemService:
def upgrade(self, mode: str | None) -> SystemOperationResult:
"""仅保留兼容的开发分支更新入口。"""
if str(mode or "").strip().lower() != "dev":
- return SystemOperationResult(
- False, "Release 更新请使用 /system/update/check、download 和 install 接口"
- )
+ return SystemOperationResult(False, "Release 更新请使用 /system/update/check、download 和 install 接口")
if not self._control.can_restart():
return SystemOperationResult(False, "当前运行环境不支持升级操作!")
success, message = self._control.upgrade_dev()
@@ -391,10 +393,8 @@ class SystemService:
cron = str(env.get("DB_BACKUP_CRON", self.settings.get("DB_BACKUP_CRON")) or "").strip()
if cron:
try:
- TimerUtils.normalize_schedule_trigger(
- "cron", cron, self.settings.get("TZ")
- )
- except (TypeError, ValueError):
+ TimerUtils.normalize_schedule_trigger("cron", cron, self.settings.get("TZ"))
+ except TypeError, ValueError:
return "数据库备份周期格式不正确"
backup_path = env.get("DB_BACKUP_PATH", self.settings.get("DB_BACKUP_PATH"))
if backup_path is not None and not isinstance(backup_path, str):
@@ -408,7 +408,7 @@ class SystemService:
return f"{label}必须是大于等于 0 的整数"
try:
converted = int(value)
- except (TypeError, ValueError):
+ except TypeError, ValueError:
return f"{label}必须是大于等于 0 的整数"
if converted < 0 or str(value).strip() != str(converted):
return f"{label}必须是大于等于 0 的整数"
diff --git a/app/cli.py b/app/cli.py
index f3873d29a..633a45578 100644
--- a/app/cli.py
+++ b/app/cli.py
@@ -24,12 +24,12 @@ from app.runtime.state import SystemHelper
from app.runtime.version import get_app_version, get_frontend_version
from app.startup.composition.database import build_database_governance
-BACKEND_RUNTIME_FILE = get_runtime_setting('TEMP_PATH') / "moviepilot.runtime.json"
-BACKEND_STDIO_LOG_FILE = get_runtime_setting('LOG_PATH') / "moviepilot.stdout.log"
-BACKEND_APP_LOG_FILE = get_runtime_setting('LOG_PATH') / "moviepilot.log"
-FRONTEND_RUNTIME_FILE = get_runtime_setting('TEMP_PATH') / "moviepilot.frontend.runtime.json"
-FRONTEND_STDIO_LOG_FILE = get_runtime_setting('LOG_PATH') / "moviepilot.frontend.stdout.log"
-FRONTEND_DIR = get_runtime_setting('ROOT_PATH') / "public"
+BACKEND_RUNTIME_FILE = get_runtime_setting("TEMP_PATH") / "moviepilot.runtime.json"
+BACKEND_STDIO_LOG_FILE = get_runtime_setting("LOG_PATH") / "moviepilot.stdout.log"
+BACKEND_APP_LOG_FILE = get_runtime_setting("LOG_PATH") / "moviepilot.log"
+FRONTEND_RUNTIME_FILE = get_runtime_setting("TEMP_PATH") / "moviepilot.frontend.runtime.json"
+FRONTEND_STDIO_LOG_FILE = get_runtime_setting("LOG_PATH") / "moviepilot.frontend.stdout.log"
+FRONTEND_DIR = get_runtime_setting("ROOT_PATH") / "public"
FRONTEND_SERVICE_FILE = FRONTEND_DIR / "service.js"
FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt"
HEALTH_PATH = "/api/v1/system/global"
@@ -46,13 +46,13 @@ MASKED_FIELDS = {
}
MASKED_SUFFIXES = ("_TOKEN", "_PASSWORD", "_SECRET", "_API_KEY")
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
-PREPARED_UPDATE_ROOT = get_runtime_setting('TEMP_PATH') / "moviepilot-update"
+PREPARED_UPDATE_ROOT = get_runtime_setting("TEMP_PATH") / "moviepilot-update"
PREPARED_UPDATE_MANIFEST = PREPARED_UPDATE_ROOT / "install.json"
PREPARED_UPDATE_STATE = PREPARED_UPDATE_ROOT / "state.json"
def _repo_root() -> Path:
- return get_runtime_setting('ROOT_PATH')
+ return get_runtime_setting("ROOT_PATH")
def _read_json_file(path: Path) -> Optional[Dict[str, Any]]:
@@ -60,7 +60,7 @@ def _read_json_file(path: Path) -> Optional[Dict[str, Any]]:
return None
try:
return json.loads(path.read_text(encoding="utf-8", errors="replace"))
- except (OSError, json.JSONDecodeError):
+ except OSError, json.JSONDecodeError:
return None
@@ -83,7 +83,7 @@ def _get_process(runtime: Optional[Dict[str, Any]] = None) -> Optional[psutil.Pr
try:
process = psutil.Process(int(pid))
- except (psutil.NoSuchProcess, psutil.AccessDenied, ValueError):
+ except psutil.NoSuchProcess, psutil.AccessDenied, ValueError:
return None
try:
@@ -91,7 +91,7 @@ def _get_process(runtime: Optional[Dict[str, Any]] = None) -> Optional[psutil.Pr
return None
if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
return None
- except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
+ except psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess:
return None
return process
@@ -114,21 +114,21 @@ def _frontend_runtime() -> Optional[Dict[str, Any]]:
def _backend_base_url(runtime: Optional[Dict[str, Any]] = None) -> str:
runtime = runtime or _backend_runtime() or {}
- host = runtime.get("host") or get_runtime_setting('HOST')
- port = runtime.get("port") or get_runtime_setting('PORT')
+ host = runtime.get("host") or get_runtime_setting("HOST")
+ port = runtime.get("port") or get_runtime_setting("PORT")
return f"http://{_client_host(host)}:{port}"
def _frontend_base_url(runtime: Optional[Dict[str, Any]] = None) -> str:
runtime = runtime or _frontend_runtime() or {}
- host = runtime.get("host") or get_runtime_setting('HOST')
- port = runtime.get("port") or get_runtime_setting('NGINX_PORT')
+ host = runtime.get("host") or get_runtime_setting("HOST")
+ port = runtime.get("port") or get_runtime_setting("NGINX_PORT")
return f"http://{_client_host(host)}:{port}"
def _runtime_api_token(runtime: Optional[Dict[str, Any]] = None) -> str:
runtime = runtime or _backend_runtime() or {}
- return runtime.get("api_token") or get_runtime_setting('API_TOKEN')
+ return runtime.get("api_token") or get_runtime_setting("API_TOKEN")
def _http_request(
@@ -178,7 +178,9 @@ def _http_request(
raise click.ClickException(f"无法连接到本地服务:{exc.reason}") from exc
-def _backend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float = 2.0) -> tuple[bool, Optional[Dict[str, Any]]]:
+def _backend_health(
+ runtime: Optional[Dict[str, Any]] = None, timeout: float = 2.0
+) -> tuple[bool, Optional[Dict[str, Any]]]:
try:
response = _http_request(
"GET",
@@ -198,7 +200,9 @@ def _backend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float = 2
return True, payload
-def _frontend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float = 2.0) -> tuple[bool, Optional[Dict[str, Any]]]:
+def _frontend_health(
+ runtime: Optional[Dict[str, Any]] = None, timeout: float = 2.0
+) -> tuple[bool, Optional[Dict[str, Any]]]:
runtime = runtime or _frontend_runtime() or {}
url = f"{_frontend_base_url(runtime)}{FRONTEND_HEALTH_PATH}"
request = Request(url=url, headers={"Accept": "text/plain"}, method="GET")
@@ -206,7 +210,7 @@ def _frontend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float =
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8", errors="replace").strip()
return response.status == 200, {"version": raw}
- except (HTTPError, URLError):
+ except HTTPError, URLError:
return False, None
@@ -229,7 +233,7 @@ def _git_current_branch() -> Optional[str]:
cwd=str(_repo_root()),
text=True,
).strip()
- except (OSError, subprocess.CalledProcessError):
+ except OSError, subprocess.CalledProcessError:
return None
return branch or None
@@ -237,7 +241,7 @@ def _git_current_branch() -> Optional[str]:
def _auto_update_mode() -> str:
if SystemHelper.consume_one_shot_dev_update():
return "dev"
- return str(get_runtime_setting('MOVIEPILOT_AUTO_UPDATE') or "").strip().lower()
+ return str(get_runtime_setting("MOVIEPILOT_AUTO_UPDATE") or "").strip().lower()
def _file_sha256(path: Path) -> str:
@@ -266,18 +270,18 @@ def _local_update_env() -> dict[str, str]:
"""构造本地更新子进程使用的包缓存、代理和认证环境。"""
update_env = os.environ.copy()
package_cache_root = Path(
- update_env.get("PACKAGE_CACHE_ROOT", "").strip() or get_runtime_setting('PACKAGE_CACHE_PATH')
+ update_env.get("PACKAGE_CACHE_ROOT", "").strip() or get_runtime_setting("PACKAGE_CACHE_PATH")
)
update_env.setdefault("PACKAGE_CACHE_ROOT", str(package_cache_root))
update_env.setdefault("UV_CACHE_DIR", str(package_cache_root / "uv"))
- if get_runtime_setting('PIP_PROXY'):
- update_env["PIP_PROXY"] = get_runtime_setting('PIP_PROXY')
- if get_runtime_setting('PROXY_HOST'):
- update_env["PROXY_HOST"] = get_runtime_setting('PROXY_HOST')
+ if get_runtime_setting("PIP_PROXY"):
+ update_env["PIP_PROXY"] = get_runtime_setting("PIP_PROXY")
+ if get_runtime_setting("PROXY_HOST"):
+ update_env["PROXY_HOST"] = get_runtime_setting("PROXY_HOST")
for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
- update_env[key] = get_runtime_setting('PROXY_HOST')
- if get_runtime_setting('GITHUB_TOKEN'):
- update_env.setdefault("GITHUB_TOKEN", get_runtime_setting('GITHUB_TOKEN'))
+ update_env[key] = get_runtime_setting("PROXY_HOST")
+ if get_runtime_setting("GITHUB_TOKEN"):
+ update_env.setdefault("GITHUB_TOKEN", get_runtime_setting("GITHUB_TOKEN"))
return update_env
@@ -294,15 +298,9 @@ def _apply_prepared_release_update() -> bool:
frontend_archive = Path(str(manifest.get("frontend_archive") or ""))
if not version or not frontend_version:
raise RuntimeError("更新包清单缺少版本信息")
- if (
- not backend_archive.is_file()
- or _file_sha256(backend_archive) != manifest.get("backend_sha256")
- ):
+ if not backend_archive.is_file() or _file_sha256(backend_archive) != manifest.get("backend_sha256"):
raise RuntimeError("后端更新包校验失败")
- if (
- not frontend_archive.is_file()
- or _file_sha256(frontend_archive) != manifest.get("frontend_sha256")
- ):
+ if not frontend_archive.is_file() or _file_sha256(frontend_archive) != manifest.get("frontend_sha256"):
raise RuntimeError("前端更新包校验失败")
update_command = [
@@ -321,7 +319,7 @@ def _apply_prepared_release_update() -> bool:
"--venv",
str(_repo_root() / "venv"),
"--config-dir",
- str(get_runtime_setting('CONFIG_PATH')),
+ str(get_runtime_setting("CONFIG_PATH")),
]
click.echo(f"安装已下载并校验的 MoviePilot {version} 更新包")
result = subprocess.run(
@@ -390,7 +388,7 @@ def _best_effort_auto_update() -> None:
"--venv",
str(_repo_root() / "venv"),
"--config-dir",
- str(get_runtime_setting('CONFIG_PATH')),
+ str(get_runtime_setting("CONFIG_PATH")),
]
click.echo(f"检测到 MOVIEPILOT_AUTO_UPDATE={mode},启动前执行本地自动更新")
@@ -425,7 +423,9 @@ def _ensure_frontend_not_running_alone(timeout: int) -> None:
_stop_frontend_service(timeout=timeout, force=True)
-def _managed_backend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]]:
+def _managed_backend_status() -> tuple[
+ str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]
+]:
runtime = _backend_runtime()
process = _get_process(runtime)
if process:
@@ -443,7 +443,9 @@ def _managed_backend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[p
return "stopped", None, None, None
-def _managed_frontend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]]:
+def _managed_frontend_status() -> tuple[
+ str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]
+]:
runtime = _frontend_runtime()
process = _get_process(runtime)
if process:
@@ -632,8 +634,22 @@ def _format_tool_detail(tool: Dict[str, Any]) -> None:
click.echo(f" {field_name.ljust(name_width)} {field_type.ljust(type_width)} {field_desc}")
-def _parse_key_value_pairs(items: Iterable[str]) -> Dict[str, str]:
- payload: Dict[str, str] = {}
+def _parse_tool_argument_value(value: str) -> Any:
+ """解析工具参数中的结构化 JSON,同时保留普通字符串语义。"""
+ stripped = value.strip()
+ if (stripped.startswith("{") and stripped.endswith("}")) or (stripped.startswith("[") and stripped.endswith("]")):
+ try:
+ return json.loads(stripped)
+ except json.JSONDecodeError as error:
+ raise click.ClickException(f"工具参数 JSON 格式错误:{error.msg}") from error
+ if stripped in {"true", "false", "null"}:
+ return json.loads(stripped)
+ return value
+
+
+def _parse_key_value_pairs(items: Iterable[str]) -> Dict[str, Any]:
+ """解析 tool run 的 key=value 参数,并支持对象、数组和布尔 JSON。"""
+ payload: Dict[str, Any] = {}
for item in items:
if "=" not in item:
raise click.ClickException(f"参数必须是 key=value 形式:{item}")
@@ -641,17 +657,24 @@ def _parse_key_value_pairs(items: Iterable[str]) -> Dict[str, str]:
key = key.strip()
if not key:
raise click.ClickException(f"参数名不能为空:{item}")
- payload[key] = value
+ payload[key] = _parse_tool_argument_value(value)
return payload
+def _unwrap_api_data(result: Any) -> Any:
+ """从结构化 MoviePilot API 响应中提取 data。"""
+ if not isinstance(result, dict) or "success" not in result:
+ return result
+ if not result.get("success"):
+ raise click.ClickException(result.get("message") or "MoviePilot API 调用失败")
+ return result.get("data")
+
+
def _ensure_local_api_token() -> bool:
- if get_runtime_setting('API_TOKEN') and len(str(get_runtime_setting('API_TOKEN')).strip()) >= 16:
+ if get_runtime_setting("API_TOKEN") and len(str(get_runtime_setting("API_TOKEN")).strip()) >= 16:
return False
- result, message = get_runtime_settings().update(
- "API_TOKEN", get_runtime_setting('API_TOKEN') or ""
- )
+ result, message = get_runtime_settings().update("API_TOKEN", get_runtime_setting("API_TOKEN") or "")
if result is False:
raise click.ClickException(message or "初始化 API_TOKEN 失败")
return result is True
@@ -691,12 +714,8 @@ def _spawn_backend_process(*, safe: bool = False) -> subprocess.Popen:
"PYTHONUNBUFFERED": "1",
"MOVIEPILOT_DISABLE_CONSOLE_LOG": "1",
"MOVIEPILOT_STDIO_LOG_FILE": str(BACKEND_STDIO_LOG_FILE),
- "MOVIEPILOT_STDIO_LOG_MAX_BYTES": str(
- max(int(get_runtime_setting('LOG_MAX_FILE_SIZE') or 0), 1) * 1024 * 1024
- ),
- "MOVIEPILOT_STDIO_LOG_BACKUP_COUNT": str(
- max(int(get_runtime_setting('LOG_BACKUP_COUNT') or 0), 0)
- ),
+ "MOVIEPILOT_STDIO_LOG_MAX_BYTES": str(max(int(get_runtime_setting("LOG_MAX_FILE_SIZE") or 0), 1) * 1024 * 1024),
+ "MOVIEPILOT_STDIO_LOG_BACKUP_COUNT": str(max(int(get_runtime_setting("LOG_BACKUP_COUNT") or 0), 0)),
}
if safe:
backend_env["MOVIEPILOT_SAFE_MODE"] = "true"
@@ -742,7 +761,7 @@ def _spawn_frontend_process(backend_port: int) -> subprocess.Popen:
env={
**os.environ,
"PORT": str(backend_port),
- "NGINX_PORT": str(get_runtime_setting('NGINX_PORT')),
+ "NGINX_PORT": str(get_runtime_setting("NGINX_PORT")),
},
)
@@ -762,7 +781,9 @@ def _wait_until_backend_ready(runtime: Dict[str, Any], timeout: int) -> Dict[str
return payload or {}
time.sleep(1)
- raise click.ClickException(f"后端进程已启动,但在 {timeout} 秒内未通过健康检查,请执行 `moviepilot logs --stdio` 查看启动日志")
+ raise click.ClickException(
+ f"后端进程已启动,但在 {timeout} 秒内未通过健康检查,请执行 `moviepilot logs --stdio` 查看启动日志"
+ )
def _wait_until_frontend_ready(runtime: Dict[str, Any], timeout: int) -> Dict[str, Any]:
@@ -780,7 +801,9 @@ def _wait_until_frontend_ready(runtime: Dict[str, Any], timeout: int) -> Dict[st
return payload or {}
time.sleep(1)
- raise click.ClickException(f"前端进程已启动,但在 {timeout} 秒内未通过健康检查,请执行 `moviepilot logs --frontend` 查看前端日志")
+ raise click.ClickException(
+ f"前端进程已启动,但在 {timeout} 秒内未通过健康检查,请执行 `moviepilot logs --frontend` 查看前端日志"
+ )
def _start_backend_service(timeout: int, safe: bool = False) -> Dict[str, Any]:
@@ -788,7 +811,9 @@ def _start_backend_service(timeout: int, safe: bool = False) -> Dict[str, Any]:
if state in {"running", "starting"} and runtime and process:
return {"status": state, "runtime": runtime, "process": process, "health": health_payload, "started": False}
if state == "running-unmanaged":
- raise click.ClickException("检测到本地端口上已有 MoviePilot 后端正在运行,但不是由当前 CLI 管理,请先手动停止它")
+ raise click.ClickException(
+ "检测到本地端口上已有 MoviePilot 后端正在运行,但不是由当前 CLI 管理,请先手动停止它"
+ )
_ensure_local_api_token()
_clear_json_file(BACKEND_RUNTIME_FILE)
@@ -797,9 +822,9 @@ def _start_backend_service(timeout: int, safe: bool = False) -> Dict[str, Any]:
runtime = {
"pid": process.pid,
"create_time": ps_process.create_time(),
- "host": get_runtime_setting('HOST'),
- "port": get_runtime_setting('PORT'),
- "api_token": get_runtime_setting('API_TOKEN'),
+ "host": get_runtime_setting("HOST"),
+ "port": get_runtime_setting("PORT"),
+ "api_token": get_runtime_setting("API_TOKEN"),
"started_at": int(time.time()),
"python": sys.executable,
"stdio_log": str(BACKEND_STDIO_LOG_FILE),
@@ -815,7 +840,9 @@ def _start_frontend_service(timeout: int, backend_port: int) -> Dict[str, Any]:
if state in {"running", "starting"} and runtime and process:
return {"status": state, "runtime": runtime, "process": process, "health": health_payload, "started": False}
if state == "running-unmanaged":
- raise click.ClickException("检测到本地端口上已有 MoviePilot 前端正在运行,但不是由当前 CLI 管理,请先手动停止它")
+ raise click.ClickException(
+ "检测到本地端口上已有 MoviePilot 前端正在运行,但不是由当前 CLI 管理,请先手动停止它"
+ )
_clear_json_file(FRONTEND_RUNTIME_FILE)
process = _spawn_frontend_process(backend_port=backend_port)
@@ -823,8 +850,8 @@ def _start_frontend_service(timeout: int, backend_port: int) -> Dict[str, Any]:
runtime = {
"pid": process.pid,
"create_time": ps_process.create_time(),
- "host": get_runtime_setting('HOST'),
- "port": get_runtime_setting('NGINX_PORT'),
+ "host": get_runtime_setting("HOST"),
+ "port": get_runtime_setting("NGINX_PORT"),
"backend_port": backend_port,
"started_at": int(time.time()),
"node": str(_frontend_node_binary()),
@@ -848,7 +875,9 @@ def _terminate_process(runtime_file: Path, timeout: int, force: bool, component_
process.wait(timeout=timeout)
except psutil.TimeoutExpired:
if not force:
- raise click.ClickException(f"{component_name} 在 {timeout} 秒内没有退出,可重新执行 `moviepilot stop --force` 强制终止")
+ raise click.ClickException(
+ f"{component_name} 在 {timeout} 秒内没有退出,可重新执行 `moviepilot stop --force` 强制终止"
+ )
process.kill()
process.wait(timeout=10)
@@ -946,9 +975,7 @@ def database_verify(name: str) -> None:
if not result.valid:
detail = f":{result.detail}" if result.detail else ""
raise click.ClickException(f"数据库备份校验未通过({result.method}){detail}")
- click.echo(
- f"数据库备份校验通过:name={name} method={result.method}"
- )
+ click.echo(f"数据库备份校验通过:name={name} method={result.method}")
@database.command("restore", context_settings=CONTEXT_SETTINGS)
@@ -996,9 +1023,19 @@ def start(timeout: int, safe: bool) -> None:
backend_version = ((backend_health.get("data") or {}) if isinstance(backend_health, dict) else {}).get(
"BACKEND_VERSION", get_app_version()
)
- frontend_version = ((frontend_result.get("health") or {}) if isinstance(frontend_result.get("health"), dict) else {}).get("version") or _installed_frontend_version() or "unknown"
+ frontend_version = (
+ ((frontend_result.get("health") or {}) if isinstance(frontend_result.get("health"), dict) else {}).get(
+ "version"
+ )
+ or _installed_frontend_version()
+ or "unknown"
+ )
- click.echo("MoviePilot 已启动" if backend_result.get("started") or frontend_result.get("started") else "MoviePilot 已在运行")
+ click.echo(
+ "MoviePilot 已启动"
+ if backend_result.get("started") or frontend_result.get("started")
+ else "MoviePilot 已在运行"
+ )
click.echo(f"Backend PID: {backend_result['process'].pid}")
click.echo(f"Backend URL: {_backend_base_url(backend_runtime)}")
click.echo(f"Frontend PID: {frontend_result['process'].pid}")
@@ -1036,7 +1073,9 @@ def restart(start_timeout: int, stop_timeout: int, force: bool) -> None:
_stop_backend_service(timeout=stop_timeout, force=force)
_best_effort_auto_update()
backend_result = _start_backend_service(timeout=start_timeout)
- frontend_result = _start_frontend_service(timeout=start_timeout, backend_port=int(backend_result["runtime"]["port"]))
+ frontend_result = _start_frontend_service(
+ timeout=start_timeout, backend_port=int(backend_result["runtime"]["port"])
+ )
click.echo("MoviePilot 已重启")
click.echo(f"Backend URL: {_backend_base_url(backend_result['runtime'])}")
click.echo(f"Frontend URL: {_frontend_base_url(frontend_result['runtime'])}")
@@ -1079,12 +1118,20 @@ def status() -> None:
if installed_frontend:
click.echo(f" Installed Version: {installed_frontend}")
elif frontend_state == "running-unmanaged":
- frontend_version = ((frontend_health or {}).get("version") if isinstance(frontend_health, dict) else None) or _installed_frontend_version() or "unknown"
+ frontend_version = (
+ ((frontend_health or {}).get("version") if isinstance(frontend_health, dict) else None)
+ or _installed_frontend_version()
+ or "unknown"
+ )
click.echo(" running (unmanaged)")
click.echo(f" URL: {_frontend_base_url()}")
click.echo(f" Version: {frontend_version}")
else:
- frontend_version = ((frontend_health or {}).get("version") if isinstance(frontend_health, dict) else None) or _installed_frontend_version() or "unknown"
+ frontend_version = (
+ ((frontend_health or {}).get("version") if isinstance(frontend_health, dict) else None)
+ or _installed_frontend_version()
+ or "unknown"
+ )
click.echo(f" {'running' if frontend_state == 'running' else 'starting'}")
click.echo(f" PID: {frontend_process.pid}")
click.echo(f" URL: {_frontend_base_url(frontend_runtime)}")
@@ -1140,7 +1187,7 @@ def config() -> None:
@config.command("path", context_settings=CONTEXT_SETTINGS)
def config_path() -> None:
"""显示配置路径"""
- config_path = get_runtime_setting('CONFIG_PATH')
+ config_path = get_runtime_setting("CONFIG_PATH")
click.echo(f"Config Dir: {config_path}")
click.echo(f"Env File: {config_path / 'app.env'}")
click.echo(f"Frontend Dir: {FRONTEND_DIR}")
@@ -1183,7 +1230,11 @@ def config_set(key: str, value: str) -> None:
backend_state, _, _, _ = _managed_backend_status()
frontend_state, _, _, _ = _managed_frontend_status()
- if backend_state in {"running", "starting", "running-unmanaged"} or frontend_state in {"running", "starting", "running-unmanaged"}:
+ if backend_state in {"running", "starting", "running-unmanaged"} or frontend_state in {
+ "running",
+ "starting",
+ "running-unmanaged",
+ }:
click.echo("检测到服务正在运行,新配置将在重启前后端服务后生效")
@@ -1279,11 +1330,13 @@ def scheduler() -> None:
@scheduler.command("list", context_settings=CONTEXT_SETTINGS)
def scheduler_list() -> None:
- """列出调度任务"""
- result = _call_tool(
- "query_schedulers",
- {},
- runtime=_backend_runtime(),
+ """通过统一 API 网关列出调度任务。"""
+ result = _unwrap_api_data(
+ _call_tool(
+ "moviepilot_api",
+ {"operation_id": "scheduler.list"},
+ runtime=_backend_runtime(),
+ )
)
if isinstance(result, list):
for item in result:
@@ -1295,11 +1348,16 @@ def scheduler_list() -> None:
@scheduler.command("run", context_settings=CONTEXT_SETTINGS)
@click.argument("job_id")
def scheduler_run(job_id: str) -> None:
- """立即执行某个调度任务"""
- result = _call_tool(
- "run_scheduler",
- {"job_id": job_id},
- runtime=_backend_runtime(),
+ """通过统一 API 网关立即执行某个调度任务。"""
+ result = _unwrap_api_data(
+ _call_tool(
+ "moviepilot_api",
+ {
+ "operation_id": "scheduler.run",
+ "query": {"job_id": job_id},
+ },
+ runtime=_backend_runtime(),
+ )
)
if isinstance(result, (dict, list)):
_print_json(result)
@@ -1315,9 +1373,7 @@ def version() -> None:
healthy_backend, payload = _backend_health(runtime=_backend_runtime())
if healthy_backend:
data = (payload or {}).get("data") or {}
- click.echo(
- f"Backend Service: {data.get('BACKEND_VERSION', get_app_version())}"
- )
+ click.echo(f"Backend Service: {data.get('BACKEND_VERSION', get_app_version())}")
else:
click.echo("Backend Service: not running")
diff --git a/app/locales/en-US.json b/app/locales/en-US.json
index f4786ec1e..b49bc9c7a 100644
--- a/app/locales/en-US.json
+++ b/app/locales/en-US.json
@@ -110,6 +110,17 @@
"模块不支持测试": "Module does not support testing",
"网络请求失败": "Network request failed",
"TMDB请求失败": "TMDB request failed",
+ "无效的媒体类型": "Invalid media type",
+ "ListenBrainz 来源只支持音乐媒体类型": "The ListenBrainz source only supports music media",
+ "无效的音乐实体类型": "Invalid music entity type",
+ "无效的榜单周期": "Invalid chart range",
+ "无效的榜单排序": "Invalid chart sort order",
+ "新发行结果只支持专辑": "Fresh release results only support albums",
+ "无效的新发行排序": "Invalid fresh release sort order",
+ "past 和 future 不能同时为 false": "past and future cannot both be false",
+ "音乐推荐需使用 ListenBrainz 来源": "Music recommendations require a ListenBrainz source",
+ "directory_type 无效": "Invalid directory_type",
+ "storage_type 无效": "Invalid storage_type",
"媒体服务器请求失败": "Media server request failed",
"附件保存失败": "Failed to save attachment",
"通知配置保存成功": "Notification configuration saved successfully",
@@ -443,6 +454,10 @@
"订阅日历预缓存完成": "Subscription calendar precache completed"
},
"message_patterns": [
+ {
+ "source": "不支持的推荐来源: {source}",
+ "target": "Unsupported recommendation source: {source}"
+ },
{
"source": "工具 '{tool}' 未找到",
"target": "Tool '{tool}' was not found"
diff --git a/app/locales/zh-CN.json b/app/locales/zh-CN.json
index d3fedebda..62c20240d 100644
--- a/app/locales/zh-CN.json
+++ b/app/locales/zh-CN.json
@@ -107,6 +107,17 @@
"模块不支持测试": "模块不支持测试",
"网络请求失败": "网络请求失败",
"TMDB请求失败": "TMDB请求失败",
+ "无效的媒体类型": "无效的媒体类型",
+ "ListenBrainz 来源只支持音乐媒体类型": "ListenBrainz 来源只支持音乐媒体类型",
+ "无效的音乐实体类型": "无效的音乐实体类型",
+ "无效的榜单周期": "无效的榜单周期",
+ "无效的榜单排序": "无效的榜单排序",
+ "新发行结果只支持专辑": "新发行结果只支持专辑",
+ "无效的新发行排序": "无效的新发行排序",
+ "past 和 future 不能同时为 false": "past 和 future 不能同时为 false",
+ "音乐推荐需使用 ListenBrainz 来源": "音乐推荐需使用 ListenBrainz 来源",
+ "directory_type 无效": "directory_type 无效",
+ "storage_type 无效": "storage_type 无效",
"媒体服务器请求失败": "媒体服务器请求失败",
"不支持的媒体类型": "不支持的媒体类型",
"豆瓣网络连接失败": "豆瓣网络连接失败",
@@ -121,6 +132,10 @@
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成"
},
"message_patterns": [
+ {
+ "source": "不支持的推荐来源: {source}",
+ "target": "不支持的推荐来源: {source}"
+ },
{
"source": "无法连接Qbittorrent下载器:{name}",
"target": "无法连接Qbittorrent下载器:{name}"
diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json
index a56826369..da3bf5986 100644
--- a/app/locales/zh-TW.json
+++ b/app/locales/zh-TW.json
@@ -106,6 +106,17 @@
"模块不支持测试": "模組不支援測試",
"网络请求失败": "網路請求失敗",
"TMDB请求失败": "TMDB 請求失敗",
+ "无效的媒体类型": "無效的媒體類型",
+ "ListenBrainz 来源只支持音乐媒体类型": "ListenBrainz 來源僅支援音樂媒體類型",
+ "无效的音乐实体类型": "無效的音樂實體類型",
+ "无效的榜单周期": "無效的榜單週期",
+ "无效的榜单排序": "無效的榜單排序",
+ "新发行结果只支持专辑": "新發行結果僅支援專輯",
+ "无效的新发行排序": "無效的新發行排序",
+ "past 和 future 不能同时为 false": "past 與 future 不能同時為 false",
+ "音乐推荐需使用 ListenBrainz 来源": "音樂推薦需使用 ListenBrainz 來源",
+ "directory_type 无效": "directory_type 無效",
+ "storage_type 无效": "storage_type 無效",
"媒体服务器请求失败": "媒體伺服器請求失敗",
"附件保存失败": "附件儲存失敗",
"通知配置保存成功": "通知設定儲存成功",
@@ -437,6 +448,10 @@
"订阅日历预缓存完成": "訂閱日曆預快取完成"
},
"message_patterns": [
+ {
+ "source": "不支持的推荐来源: {source}",
+ "target": "不支援的推薦來源: {source}"
+ },
{
"source": "工具 '{tool}' 未找到",
"target": "工具「{tool}」未找到"
diff --git a/app/schemas/agent.py b/app/schemas/agent.py
index 3ee3113d3..860ea74e2 100644
--- a/app/schemas/agent.py
+++ b/app/schemas/agent.py
@@ -4,7 +4,7 @@ from datetime import datetime
from typing import List, Literal, Optional, Union
from langchain_core.messages import BaseMessage
-from pydantic import BaseModel, Field, ConfigDict, field_serializer
+from pydantic import BaseModel, ConfigDict, Field, field_serializer
from app.schemas.common import JsonData
@@ -19,7 +19,7 @@ class ConversationMemory(BaseModel):
model_config = ConfigDict()
- @field_serializer('updated_at', when_used='json')
+ @field_serializer("updated_at", when_used="json")
def serialize_datetime(self, value: datetime) -> str:
return value.isoformat()
@@ -34,7 +34,7 @@ class AgentState(BaseModel):
model_config = ConfigDict()
- @field_serializer('last_activity', when_used='json')
+ @field_serializer("last_activity", when_used="json")
def serialize_datetime(self, value: datetime) -> str:
return value.isoformat()
@@ -65,9 +65,7 @@ class AgentMcpServerConfig(BaseModel):
id: str = Field(..., description="服务器唯一 ID")
name: str = Field(..., description="服务器显示名称")
enabled: bool = Field(default=True, description="是否启用")
- transport: Literal["stdio", "sse", "http", "streamable_http"] = Field(
- default="stdio", description="MCP 传输协议"
- )
+ transport: Literal["stdio", "sse", "http", "streamable_http"] = Field(default="stdio", description="MCP 传输协议")
description: Optional[str] = Field(None, description="服务器说明")
command: Optional[str] = Field(None, description="stdio 启动命令")
args: list[str] = Field(default_factory=list, description="stdio 启动参数")
@@ -82,9 +80,7 @@ class AgentMcpServerConfig(BaseModel):
class AgentMcpServersSaveRequest(BaseModel):
"""Agent 外部 MCP 服务器保存请求。"""
- servers: list[AgentMcpServerConfig] = Field(
- default_factory=list, description="MCP 服务器配置列表"
- )
+ servers: list[AgentMcpServerConfig] = Field(default_factory=list, description="MCP 服务器配置列表")
class AgentMcpServerTestRequest(BaseModel):
@@ -284,6 +280,21 @@ class AgentWebCommandInfo(BaseModel):
pid: Optional[str | int] = Field(default=None, description="插件 ID")
+class AgentCommandRunRequest(BaseModel):
+ """通过 Agent API 触发斜杠命令的请求。"""
+
+ command: str = Field(description="要执行的完整斜杠命令")
+
+
+class AgentCommandRunData(BaseModel):
+ """斜杠命令进入事件队列后的稳定回执。"""
+
+ message: str = Field(description="命令触发结果")
+ command: str = Field(description="规范化后的完整命令")
+ command_desc: str = Field(default="", description="命令说明")
+ plugin_id: Optional[str] = Field(default=None, description="命令所属插件 ID")
+
+
class AgentChatSessionSummary(BaseModel):
"""Agent 历史会话摘要。"""
diff --git a/app/schemas/download.py b/app/schemas/download.py
index ec09d0b12..6b53308e3 100644
--- a/app/schemas/download.py
+++ b/app/schemas/download.py
@@ -1,12 +1,13 @@
-from typing import Optional
+from typing import Literal, Optional
from pydantic import BaseModel, Field
class DownloadTask(BaseModel):
"""
- 下载任务
+ 下载任务
"""
+
download_id: Optional[str] = Field(default=None, description="任务ID")
downloader: Optional[str] = Field(default=None, description="下载器")
path: Optional[str] = Field(default=None, description="下载路径")
@@ -41,3 +42,34 @@ class SubtitleDownloadData(BaseModel):
"""字幕下载结果。"""
files: list[str] = Field(default_factory=list, description="已保存字幕文件列表")
+
+
+class DownloadTaskUpdateRequest(BaseModel):
+ """下载任务高级修改请求。"""
+
+ action: Optional[Literal["start", "stop"]] = None
+ tags: Optional[list[str]] = None
+ downloader: Optional[str] = None
+ download_limit: Optional[float] = None
+ upload_limit: Optional[float] = None
+ trackers: Optional[list[str]] = None
+ save_path: Optional[str] = None
+ category: Optional[str] = None
+ ratio_limit: Optional[float] = None
+ seeding_time_limit: Optional[int] = None
+
+
+class DownloadTaskMutationResult(BaseModel):
+ """下载任务单个修改动作的执行结果。"""
+
+ operation: str = Field(description="修改动作")
+ success: bool = Field(description="动作是否成功")
+ message: str = Field(description="动作结果说明")
+
+
+class DownloadTaskUpdateData(BaseModel):
+ """一次下载任务高级修改的聚合结果。"""
+
+ hash: str = Field(description="下载任务 Hash")
+ downloader: str = Field(description="实际使用的下载器实例")
+ results: list[DownloadTaskMutationResult] = Field(default_factory=list, description="各修改动作结果")
diff --git a/app/schemas/exports.py b/app/schemas/exports.py
index 45a56c78d..e33695e5f 100644
--- a/app/schemas/exports.py
+++ b/app/schemas/exports.py
@@ -24,6 +24,8 @@ SCHEMA_EXPORTS = {
'AgentChatSessionSummary': ('app.schemas.agent', 'AgentChatSessionSummary'),
'AgentChatToolCall': ('app.schemas.agent', 'AgentChatToolCall'),
'AgentChatUploadAttachment': ('app.schemas.agent', 'AgentChatUploadAttachment'),
+ 'AgentCommandRunData': ('app.schemas.agent', 'AgentCommandRunData'),
+ 'AgentCommandRunRequest': ('app.schemas.agent', 'AgentCommandRunRequest'),
'AgentLLMProviderEventData': ('app.schemas.event', 'AgentLLMProviderEventData'),
'AgentMcpServerConfig': ('app.schemas.agent', 'AgentMcpServerConfig'),
'AgentMcpServerListData': ('app.schemas.agent', 'AgentMcpServerListData'),
@@ -79,6 +81,9 @@ SCHEMA_EXPORTS = {
'CookieEncryptedPayload': ('app.schemas.servcookie', 'CookieEncryptedPayload'),
'CookiePassword': ('app.schemas.servcookie', 'CookiePassword'),
'CurrentUserUpdate': ('app.schemas.user', 'CurrentUserUpdate'),
+ 'CustomFilterRuleCreateRequest': ('app.schemas.rule', 'CustomFilterRuleCreateRequest'),
+ 'CustomFilterRuleUpdateRequest': ('app.schemas.rule', 'CustomFilterRuleUpdateRequest'),
+ 'CustomIdentifiersUpdateRequest': ('app.schemas.system', 'CustomIdentifiersUpdateRequest'),
'CustomRule': ('app.schemas.rule', 'CustomRule'),
'DashboardMemoryInfo': ('app.schemas.dashboard', 'DashboardMemoryInfo'),
'DashboardSystemInfo': ('app.schemas.dashboard', 'DashboardSystemInfo'),
@@ -98,6 +103,9 @@ SCHEMA_EXPORTS = {
'DownloadHistory': ('app.schemas.history', 'DownloadHistory'),
'DownloadTask': ('app.schemas.workflow', 'DownloadTask'),
'DownloadTaskMedia': ('app.schemas.transfer', 'DownloadTaskMedia'),
+ 'DownloadTaskMutationResult': ('app.schemas.download', 'DownloadTaskMutationResult'),
+ 'DownloadTaskUpdateData': ('app.schemas.download', 'DownloadTaskUpdateData'),
+ 'DownloadTaskUpdateRequest': ('app.schemas.download', 'DownloadTaskUpdateRequest'),
'DownloaderConf': ('app.schemas.system', 'DownloaderConf'),
'DownloaderFile': ('app.schemas.transfer', 'DownloaderFile'),
'DownloaderInfo': ('app.schemas.dashboard', 'DownloaderInfo'),
@@ -121,6 +129,8 @@ SCHEMA_EXPORTS = {
'FileNameData': ('app.schemas.common', 'FileNameData'),
'FileURI': ('app.schemas.file', 'FileURI'),
'FilterRuleGroup': ('app.schemas.system', 'FilterRuleGroup'),
+ 'FilterRuleGroupCreateRequest': ('app.schemas.rule', 'FilterRuleGroupCreateRequest'),
+ 'FilterRuleGroupUpdateRequest': ('app.schemas.rule', 'FilterRuleGroupUpdateRequest'),
'Generic': ('app.schemas.response', 'Generic'),
'HistoryDeletedEventData': ('app.schemas.event', 'HistoryDeletedEventData'),
'IdData': ('app.schemas.common', 'IdData'),
@@ -392,6 +402,7 @@ SCHEMA_EXPORTS = {
'SystemErrorEventData': ('app.schemas.event', 'SystemErrorEventData'),
'SystemModuleInfo': ('app.schemas.system', 'SystemModuleInfo'),
'SystemModuleListData': ('app.schemas.system', 'SystemModuleListData'),
+ 'SystemSettingsUpdateRequest': ('app.schemas.system', 'SystemSettingsUpdateRequest'),
'SystemUpdateStatus': ('app.schemas.system', 'SystemUpdateStatus'),
'TMDbException': ('app.schemas.exception', 'TMDbException'),
'Tag': ('app.schemas.context', 'Tag'),
@@ -486,7 +497,7 @@ SCHEMA_CONFLICTS = {
'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'],
'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'],
'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'],
- 'Literal': ['app.schemas.agent', 'app.schemas.history', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.subscribe', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.mcp'],
+ 'Literal': ['app.schemas.agent', 'app.schemas.download', 'app.schemas.history', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.subscribe', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.mcp'],
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'],
diff --git a/app/schemas/recommend.py b/app/schemas/recommend.py
new file mode 100644
index 000000000..8af0d9ce0
--- /dev/null
+++ b/app/schemas/recommend.py
@@ -0,0 +1,43 @@
+"""Agent 推荐接口的稳定结果模型。"""
+
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+
+class AgentRecommendationItem(BaseModel):
+ """影视、动画与音乐推荐共用的有界字段投影。"""
+
+ title: Optional[str] = Field(default=None, description="标题")
+ en_title: Optional[str] = Field(default=None, description="英文标题")
+ year: Optional[int | str] = Field(default=None, description="年份")
+ type: Optional[str] = Field(default=None, description="媒体类型")
+ season: Optional[int | str] = Field(default=None, description="季号")
+ tmdb_id: Optional[int | str] = Field(default=None, description="TMDB ID")
+ imdb_id: Optional[str] = Field(default=None, description="IMDb ID")
+ douban_id: Optional[int | str] = Field(default=None, description="豆瓣 ID")
+ bangumi_id: Optional[int | str] = Field(default=None, description="Bangumi ID")
+ anilist_id: Optional[int | str] = Field(default=None, description="AniList ID")
+ media_source: Optional[str] = Field(default=None, description="媒体来源")
+ media_id: Optional[str] = Field(default=None, description="来源内媒体 ID")
+ vote_average: Optional[float] = Field(default=None, description="评分")
+ poster_path: Optional[str] = Field(default=None, description="海报地址")
+ detail_link: Optional[str] = Field(default=None, description="详情地址")
+ music_type: Optional[str] = Field(default=None, description="音乐实体类型")
+ artists: list[str] = Field(default_factory=list, description="艺术家名称")
+ artist_ids: list[str] = Field(default_factory=list, description="艺术家 ID")
+ artist: Optional[str] = Field(default=None, description="主艺术家")
+ album: Optional[str] = Field(default=None, description="专辑名称")
+ album_id: Optional[str] = Field(default=None, description="专辑 ID")
+ album_type: Optional[str] = Field(default=None, description="专辑类型")
+ release_date: Optional[str] = Field(default=None, description="发行日期")
+ disc_number: Optional[int] = Field(default=None, description="碟号")
+ track_number: Optional[int] = Field(default=None, description="曲目号")
+ total_tracks: Optional[int] = Field(default=None, description="总曲目数")
+ duration: Optional[int | float] = Field(default=None, description="时长")
+ isrc: Optional[str] = Field(default=None, description="ISRC")
+ version: Optional[str] = Field(default=None, description="版本说明")
+ genres: list[str] = Field(default_factory=list, description="流派")
+ category: Optional[str] = Field(default=None, description="分类")
+ listen_count: Optional[int] = Field(default=None, description="收听次数")
+ overview: Optional[str] = Field(default=None, description="简介")
diff --git a/app/schemas/rule.py b/app/schemas/rule.py
index 6db6392fb..db9029060 100644
--- a/app/schemas/rule.py
+++ b/app/schemas/rule.py
@@ -7,6 +7,7 @@ class CustomRule(BaseModel):
"""
自定义规则项
"""
+
# 规则ID
id: Optional[str] = None
# 名称
@@ -27,6 +28,7 @@ class FilterRuleGroup(BaseModel):
"""
过滤规则组
"""
+
# 名称
name: Optional[str] = None
# 规则串
@@ -35,3 +37,45 @@ class FilterRuleGroup(BaseModel):
media_type: Optional[str] = None
# 适用媒体类别 None-全部 对应二级分类
category: Optional[str] = None
+
+
+class CustomFilterRuleCreateRequest(BaseModel):
+ """新增自定义过滤规则请求。"""
+
+ rule_id: str
+ name: str
+ include: Optional[str] = None
+ exclude: Optional[str] = None
+ size_range: Optional[str] = None
+ seeders: Optional[str] = None
+ publish_time: Optional[str] = None
+
+
+class CustomFilterRuleUpdateRequest(BaseModel):
+ """更新自定义过滤规则请求。"""
+
+ new_rule_id: Optional[str] = None
+ name: Optional[str] = None
+ include: Optional[str] = None
+ exclude: Optional[str] = None
+ size_range: Optional[str] = None
+ seeders: Optional[str] = None
+ publish_time: Optional[str] = None
+
+
+class FilterRuleGroupCreateRequest(BaseModel):
+ """新增过滤规则组请求。"""
+
+ name: str
+ rule_string: str
+ media_type: Optional[str] = None
+ category: Optional[str] = None
+
+
+class FilterRuleGroupUpdateRequest(BaseModel):
+ """更新过滤规则组请求。"""
+
+ new_name: Optional[str] = None
+ rule_string: Optional[str] = None
+ media_type: Optional[str] = None
+ category: Optional[str] = None
diff --git a/app/schemas/system.py b/app/schemas/system.py
index 5e8b347ac..ed1a35f2e 100644
--- a/app/schemas/system.py
+++ b/app/schemas/system.py
@@ -62,7 +62,7 @@ class MediaServerConf(BaseModel):
return None
try:
return int(value)
- except (TypeError, ValueError):
+ except TypeError, ValueError:
return None
@@ -146,6 +146,28 @@ class SystemEnvironmentUpdateData(BaseModel):
failed_updates: dict[str, tuple[Optional[bool], str]] = Field(default_factory=dict)
+class SystemSettingsUpdateRequest(BaseModel):
+ """统一系统设置更新请求。"""
+
+ setting_key: str
+ value: Any = None
+ operation: Literal[
+ "replace",
+ "merge_dict",
+ "upsert_list_item",
+ "remove_list_item",
+ ] = "replace"
+ remove_keys: list[str] = Field(default_factory=list)
+ match_field: Optional[str] = None
+ match_value: Any = None
+
+
+class CustomIdentifiersUpdateRequest(BaseModel):
+ """完整替换自定义识别词的请求。"""
+
+ identifiers: list[str] = Field(default_factory=list)
+
+
class SystemUpdateStatus(BaseModel):
"""主程序后台更新的可恢复状态快照。"""
diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md
index 2f85055b8..fc56ce482 100644
--- a/docs/architecture-overview.md
+++ b/docs/architecture-overview.md
@@ -400,8 +400,8 @@ collector 只接受 canonical `eventmanager`、`EventManager()` 及其有限别
`register`/`add_event_listener`;当前宿主有 16 个静态注册点,另保留 1 个由工作流配置驱动的
真实动态注册。`app/plugins/**` 插件副本不进入宿主事实。
-生产者与消费者共用 `scripts/architecture/event_facts.py` 这一份逐调用事实源。当前宿主有 90 个
-生产调用,其中 89 个静态解析为 91 个事件引用,只有 `Command.send_plugin_event` 的插件事件类型
+生产者与消费者共用 `scripts/architecture/event_facts.py` 这一份逐调用事实源。当前宿主有 86 个
+生产调用,其中 85 个静态解析为 87 个事件引用,只有 `Command.send_plugin_event` 的插件事件类型
保持动态;17 个消费注册中 16 个静态、1 个动态。生成的
`runtime-contract-baseline.json` 保存 line-free 事实、数量和枚举索引;人工维护的
`runtime-contract-policy.json` 只批准 consumer 的精确 fingerprint、owner 和理由,任何新增、替换、
@@ -740,28 +740,28 @@ flowchart LR
stream/vendor/diagnostic/control-plane 事实是精确 containment。每条初始边的指纹由测试独立冻结,
bindings/uses 变化、分类互换、通配导入和初始边增长都会失败;债务删除时同步删除冻结项以禁止恢复,
`--write-host` 不会改写人工 policy 或冻结上界。
-- `event_facts` 是生产者/消费者唯一收集源;运行快照记录 90 个生产调用和 17 个消费注册,
+- `event_facts` 是生产者/消费者唯一收集源;运行快照记录 86 个生产调用和 17 个消费注册,
consumer 的 17 个唯一 fingerprint 另由只读人工 policy 精确准入。CI 将语义 policy 与生成快照
分成独立步骤,前者不能通过刷新后者绕过。
- 任何所有权迁移必须同步更新:canonical 导入、`app/runtime/compat/manifest.py`、
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
- 延迟导入不被接受为隐藏循环依赖的手段。
-### 10.1 2026-08-30 当前收口状态与后续边界
+### 10.1 2026-08-31 当前收口状态与后续边界
当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自
`tests/fixtures/architecture/`,更新基线前必须先审查语义变化:
| 指标 | 当前值 |
|---|---:|
-| Python 模块 | 973 |
-| 内部导入边 | 8,430 |
+| Python 模块 | 917 |
+| 内部导入边 | 7,671 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53(债务已清零,53 条精确 containment) |
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
| Event Contract | 53 |
-| Event producer / consumer | 90(89 静态、1 动态)/ 17(16 静态、1 动态) |
+| Event producer / consumer | 86(85 静态、1 动态)/ 17(16 静态、1 动态) |
| Model/Oper 自动事务与自建 Session | 0 |
| 组合根外 `SystemConfigOper()` | 0 |
diff --git a/docs/architecture/agent-tool-refactor-plan.md b/docs/architecture/agent-tool-refactor-plan.md
new file mode 100644
index 000000000..89c312c7a
--- /dev/null
+++ b/docs/architecture/agent-tool-refactor-plan.md
@@ -0,0 +1,289 @@
+# MoviePilot Agent 工具体系重构计划
+
+> 状态:COMPLETE
+>
+> 建立日期:2026-08-31
+>
+> 当前基线:v3@6ee47795629f
+>
+> 关联目标:本线程已建立的 Agent 工具体系重构 Goal
+
+## 1. 战略目标
+
+在不削弱 MoviePilot Agent 智能化、权限安全、会话交互、后台任务和失败恢复能力的前提下,
+重构当前过大的 Agent 工具目录:
+
+- 用受控、结构化的 MoviePilot API 网关承载稳定业务能力
+- 用按领域 Skill 提供调用流程、参数语义和 API 操作范围
+- 用受控 Skill 脚本直接承载下载器、媒体服务器的低层第三方 API 操作
+- 迁移可以由 REST API 完整表达的业务工具,删除重复的 Agent Tool Schema
+- 补齐搜索会话、规则校验、安全设置和插件数据等 API parity
+- 保留依赖当前会话、渠道、宿主或流式状态的原生能力
+- 保持原有权限、确认、幂等、审计、结果裁剪和可恢复语义
+
+最终目标不是建立一个可执行任意 URL 的万能 curl 工具,而是建立一个由操作白名单、身份透传、
+策略分类和结果投影共同约束的 API 能力面。
+
+下载器和媒体服务器采用混合边界:设备级查询、诊断和任务控制可以由 Skill 的受控脚本
+读取本机配置后调用第三方自身 API;涉及 MoviePilot 媒体身份、路径映射、下载历史、订阅、
+转移和跨服务器聚合的高层用例继续由 MoviePilot Application/API 承担。
+
+## 2. 基线与当前实现事实
+
+| 项目 | 初始基线 | 当前实现 |
+|---|---|---|
+| 工厂固定工具元组 | 82 个 | 12 个原生工具;默认再追加 send_local_file 与 moviepilot_api,共 14 个 |
+| 工具目录默认行为 | LLM_MAX_TOOLS=0,默认将完整目录绑定到主模型 | 默认完整目录已收敛为 14 个;按钮选择和语音发送仅在渠道能力满足时条件注入 |
+| 默认固定目录体积 | 工具名称、描述和 JSON Schema 合计约 113 KB | 实测 23,865 bytes,约下降 79% |
+| API Skill | skills/moviepilot-api/SKILL.md 通过任意 method/path 调用 | moviepilot_api 只接受 59 个固定 operation ID;Skill 以 allowed-api-operations 强制收敛 |
+| API 身份 | API Token 映射为管理员级集成身份,不等于 Agent 当前用户/渠道身份 | Web/渠道身份绑定到真实用户;HTTP/MCP 管理入口绑定持久化超级用户,不接受模型注入 Token |
+| 下载器/媒体服务器 | 依赖内置低层 Agent 工具或有限 REST operation | downloader-operation 与 mediaserver-operation Skill 通过固定脚本调用已配置 provider API |
+| MCP/HTTP 工具管理 | 存在旧业务工具与同名 first-wins 选择空间 | 与主 Agent 共用严格唯一新目录;重名直接以 TOOL_IDENTITY_AMBIGUOUS 失败 |
+| 退役代码 | 旧实现仍位于 app/agent/tools/impl | 77 个退役文件已直接删除,其中 72 个工具模块、5 个辅助模块 |
+| 架构图 | 982 个宿主模块、8,430 条内部依赖边 | 917 个宿主模块、7,671 条内部依赖边,Application/Chain 具体 Adapter 直连仍为 0 |
+| 工作区状态 | 基线提交 6ee47795629f,与 origin/v3 对齐,初始工作区干净 | 重构与验证已完成,尚未提交或推送 |
+
+## 3. 目标工具分层
+
+### 3.1 第一批迁移:现有 API 基本覆盖的 48 个工具
+
+| 领域 | 工具 |
+|---|---|
+| 媒体 | search_media, search_person, search_person_credits, recognize_media, scrape_metadata, query_episode_schedule, query_media_detail |
+| 订阅 | add_subscribe, update_subscribe, search_subscribe, query_subscribes, query_subscribe_shares, query_popular_subscribes, query_subscribe_history, delete_subscribe |
+| 下载/历史 | add_download_tasks, query_download_tasks, delete_download_tasks, delete_download_history, delete_transfer_history, query_downloaders |
+| 站点 | query_sites, update_site, query_site_userdata, test_site, update_site_cookie |
+| 推荐/媒体库/存储 | get_recommendations, query_library_exists, query_library_latest, query_directory_settings, list_directory, query_transfer_history, transfer_file |
+| 调度/工作流 | query_schedulers, run_scheduler, query_workflows, run_workflow |
+| 插件 | query_installed_plugins, query_market_plugins, query_plugin_capabilities, query_plugin_config, update_plugin_config, reload_plugin, install_plugin, uninstall_plugin |
+| 命令/配置 | list_slash_commands, query_custom_identifiers, update_custom_identifiers |
+
+### 3.2 第二批迁移:补齐 API parity 后的 16 个工具
+
+- 搜索会话:search_torrents, get_search_results
+- 下载器高级修改:update_download_tasks
+- 过滤规则和规则组:9 个查询、增加、更新、删除工具
+- 插件持久化数据:query_plugin_data
+- 安全系统设置:query_system_settings, update_system_settings
+- 斜杠命令执行:run_slash_command
+
+### 3.3 原生能力最终保留
+
+- 外部能力:search_web, recognize_captcha, browse_webpage
+- 当前渠道消息:send_message, send_local_file;ask_user_choice 与 send_voice_message 按渠道能力条件注入
+- Agent 会话任务:agent_task 单工具 action=create/list/update/run/delete
+- Agent 人格状态:persona 单工具 action=list/switch/update
+- 宿主能力:execute_command, edit_file, apply_patch, write_file, read_file
+- 诊断能力:query_doctor_report
+
+此外,渠道工具、Skill 工具、活动日志、子 Agent 和插件/MCP 动态工具继续按运行时条件注入。
+
+### 3.4 最终目录规模
+
+- 工厂固定工具元组由 82 个降为 12 个原生工具。
+- 默认运行目录再追加 send_local_file 与单一 moviepilot_api 网关,共 14 个固定工具。
+- 其中 13 个是原生能力,1 个是 59-operation 结构化 API 网关。
+- ask_user_choice、send_voice_message、Skill、渠道、子 Agent、插件和外部 MCP 工具仍按运行时条件注入,不计入 14 个默认固定工具。
+- 77 个退役实现/辅助文件直接删除,不保留 Agent 或 MCP 兼容副本。
+
+### 3.5 下载器与媒体服务器第三方 API Skill
+
+评估结论:可行,纳入正式目标,但采用“低层直连、高层保留”的边界。
+
+| 能力 | 最终承载方式 | 原因 |
+|---|---|---|
+| 下载器实例发现、连接诊断、任务列表和详情 | downloader-operation Skill + 受控脚本 | 属于 qBittorrent、Transmission、rTorrent 自身能力,适合按 provider 扩展 |
+| 下载器暂停、恢复、限速、标签、分类、Tracker 和删除 | downloader-operation Skill + 受控脚本 | 参数差异可在脚本适配层显式归一;删除文件仍需明确确认 |
+| 媒体服务器实例、用户、媒体库、条目、最近入库和扫描诊断 | mediaserver-operation Skill + 受控脚本 | 属于 Emby、Jellyfin、Plex 等服务自身查询面 |
+| 媒体库刷新/扫描 | mediaserver-operation Skill + 受控脚本 | 允许按 provider 能力执行,属于外部副作用并要求确认 |
+| 下载提交、种子与站点 Cookie、保存目录选择 | MoviePilot API | 必须保留站点、下载选择、路径映射、历史和失败恢复语义 |
+| library.exists 精确去重 | MoviePilot API | 必须保留 canonical media_source/media_id、音乐匹配和多服务器聚合 |
+| 下载/转移历史、订阅状态、自动转移 | MoviePilot API | 属于 MoviePilot 持久化与业务编排,不是第三方设备 API |
+
+Skill 脚本必须满足:
+
+- 从 MoviePilot 本机配置读取连接参数,模型参数中不得出现服务器 URL、用户名、密码或 Token
+- 只允许脚本声明的 provider、实例名和 action,禁止任意 URL、任意 HTTP method 和任意请求头
+- 优先复用现有 provider client/SDK 和配置解析,不复制 MoviePilot 高层 Chain 业务规则
+- 输出统一 JSON、默认分页和字段投影,错误中不得回显凭据
+- 只在新 Skill 和离线测试就绪后,从 moviepilot-api operation 目录移除重复低层操作
+- 不删除供前端和其它宿主调用的 REST 端点;删除的是 Agent operation 暴露和旧 Agent Tool 代码
+
+能力面不局限于替换现有 operation。每个脚本都提供 `capabilities` 动作,按当前已配置实例和
+provider 动态返回 namespaced action、参数约束、副作用等级及是否支持批量调用;Agent 只在加载
+对应 Skill 后看到这部分领域知识。首批扩展目标包括:
+
+- 下载器:任务属性/文件/Tracker/Peer、队列与优先级、暂停/恢复、重校验、重新汇报、
+ 限速、分类、标签、保存位置、顺序下载、会话统计、剩余空间和批量任务控制
+- 媒体服务器:服务器/用户/媒体库/条目查询、最近入库、搜索与详情、播放会话、观看状态、
+ 媒体库扫描、单项元数据刷新,以及 provider 已稳定实现的合集/播放列表等扩展能力
+- provider 专属能力通过扩展注册表增加;禁止退化为任意对象方法调用、任意 URL 或任意 HTTP 请求
+
+## 4. 阶段、叶子和依赖
+
+执行规则:任何时刻只允许一个叶子处于 ACTIVE;每个叶子必须可独立验证、交付和回滚。
+
+| 阶段/叶子 | 状态 | 依赖 | 退出条件 |
+|---|---|---|---|
+| L1 基线与契约设计 | VERIFIED | 无 | 工具替代矩阵、调用路径、权限事实、输出合同、回归指标和排除项冻结 |
+| L2 受控 API 网关 | VERIFIED | L1 | moviepilot_api 结构化工具、固定 API 路由、身份/策略透传、结果裁剪和统一新目录完成;不设置运行时切换开关 |
+| L3 API parity | VERIFIED | L2 | 搜索会话、下载高级修改、过滤规则、插件数据、系统设置和斜杠命令均有 typed API 与离线测试 |
+| L4 第一批迁移 | VERIFIED | L2 | 第一批工具从 Agent、MCP 和 HTTP 工具管理目录移除,能力统一由 API 网关提供 |
+| L5 第二批迁移 | VERIFIED | L3 | parity 工具迁移完成,确认、脱敏、幂等和恢复语义进入统一策略 |
+| L6 原生工具收敛 | VERIFIED | L4,L5 | Agent 任务和人格工具合并为 action 工具;宿主/会话/渠道原生能力边界固定 |
+| L7 第三方服务 Skill 化 | VERIFIED | L5 | 下载器和媒体服务器能力发现、受控脚本、Skill、策略和离线测试完成;重复低层 Agent API operation 已删除 |
+| L8 收口与交付 | VERIFIED | L6,L7 | 旧代码、文档与架构基线已收口;静态检查和锁定全量测试已完成 |
+
+## 5. L2 受控 API 网关约束
+
+### 5.1 模型可见合同
+
+模型只看到一个结构化工具:moviepilot_api(operation_id, path_params, query, body)。
+
+operation_id 来自审核过的操作注册表,模型不能传入任意 host、URL、认证头或 API Token。
+
+### 5.2 身份和权限
+
+- 从当前 Agent 的 ToolPolicyContext 继承用户、会话、渠道、来源、后台/子 Agent 身份
+- API 网关不能把全局 API_TOKEN 当作当前用户身份
+- 策略按 operation_id 加参数分类,而不是只按工具名 moviepilot_api 分类
+- 读取、敏感读取、可恢复写入、破坏性写入和外部副作用分别处理
+- 保持现有确认、管理员门禁、敏感值脱敏、幂等和恢复模式
+
+### 5.3 Skill 合同
+
+Skill 负责判断何时使用某个领域能力、提供业务流程和参数示例、声明允许访问的
+allowed-api-operations。宿主必须实际执行该操作范围;只在提示词里显示 allowed-tools 不算权限控制。
+
+### 5.4 输出和失败合同
+
+- 统一 JSON envelope、分页、字段投影和长度上限
+- 结果进入模型前做敏感字段裁剪和错误归一化
+- 写操作带幂等键或版本前置条件
+- 超时和取消后明确外部状态未知,不能直接报告为已停止
+- API 网关不得复制业务规则;业务 owner 仍是现有 Application/Chain/API 能力
+
+第三方服务 Skill 的脚本不属于 API 网关,也不得成为任意网络出口。脚本只消费审核过的
+action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API。
+
+## 6. 验收指标
+
+### 功能
+
+- 媒体搜索、识别、订阅、下载、站点管理、媒体库、插件和工作流任务成功率不低于基线
+- 搜索结果短引用、分页、筛选项和下载任务关联不回退
+- API 网关和 provider Skill 的替代路径保持原业务结果、错误、确认与恢复语义
+
+### 安全
+
+- 普通用户、频道管理员、系统管理员、后台任务和子 Agent 权限边界保持一致
+- 未经确认不能执行敏感读取、删除、覆盖、安装和外部副作用
+- API Token、Cookie、密码和插件私有数据不进入 prompt、日志或错误摘要
+- 不允许模型通过参数绕过 operation allowlist、host 限制或身份绑定
+- 第三方服务 Skill 不接受连接地址或凭据参数,删除文件和媒体库扫描按外部副作用处理
+
+### 运行质量
+
+- 主模型工具 Schema 体积显著下降
+- 工具选择误选率、平均调用次数、上下文 token、端到端延迟不劣于基线,优先取得改善
+- API 网关失败可观测、可重试、可对账;取消/超时场景可恢复
+- 所有测试零真实外部网络;相关测试使用边界 mock 或录制回放
+
+## 7. 明确排除项
+
+- 不把 execute_command 当作 MoviePilot API 网关;仅允许领域 Skill 调用仓库内受控、可审计的固定脚本
+- 不暴露任意 REST path、任意 HTTP method 或任意外部 host
+- 不把浏览器、验证码、文件编辑、终端命令和渠道回调强行 API 化
+- MCP、HTTP 工具管理器和主 Agent 全部使用新 API 工具目录,不保留旧业务工具兼容入口
+- 不修改 app/plugins/** 运行时副本
+- 旧工具不作为回滚机制;回滚依赖 Git 版本和提交边界,正式运行时不保留兼容实现
+- 不用第三方直连替代下载提交、订阅、转移、历史和 canonical 媒体存在性等 MoviePilot 高层用例
+
+## 8. 阶段更新日志
+
+### 2026-08-31:建立计划
+
+- 创建 Codex Goal,目标为 Agent 工具体系的 API 化重构和安全收口
+- 基线同步到 v3@6ee47795629f
+- 确认 82 个固定工具、48 个第一批迁移候选、16 个 parity 候选和 18 个原生保留工具
+- 当前活动叶子为 L1;尚未修改生产代码
+
+### 2026-08-31:L1 基线验证与 L2 启动
+
+- 已核对最新 v3 基线、工具工厂、Agent 编排、Skill 中间件、MCP 入口、API Token 鉴权和策略注册表
+- 已确认第一批 48 个 API 候选、第二批 16 个 parity 候选和原生保留边界;后续实测更正默认固定目录终态为 14 个
+- 已冻结 API 网关必须透传 ToolPolicyContext、禁止任意 URL、保留确认/幂等/恢复和结果裁剪的设计约束
+- 当前唯一活动叶子切换为 L2;下一步直接实现正式结构化 API 网关,不增加运行时切换
+
+### 2026-08-31:L2.1 网关骨架完成
+
+- 新增 app/agent/policy/api.py,冻结第一批 48 个和 parity 16 个 operation ID、固定 API 路由与策略元数据
+- 新增 app/agent/tools/impl/api.py,提供 moviepilot_api(operation_id, path_params, query, body) 结构化合同
+- 工具工厂已切换为统一新目录:默认移除替代业务工具并注入单一网关,不新增运行时开关
+- 主 Agent、子 Agent、MCP 和 HTTP 工具管理器均走同一新目录;不保留旧业务工具对外或内部兼容入口
+- 策略注册表已按 operation ID 分类未知、敏感、可恢复写入、破坏性写入和外部副作用
+- 网关已从旧工具 owner 转发改为 operation ID 到固定 HTTP 方法/路径的 API 执行器,模型不能注入 URL、认证头或方法
+- 已删除临时 app/agent/tools/legacy.py,并移除 MCP 同名工具 first-wins 兼容选择;重复名称由严格目录冲突机制处理
+- Skill operation 范围改为默认拒绝:未加载声明 allowed-api-operations 的 Skill 时不能调用 moviepilot_api
+- 敏感系统设置确认入口已迁移到 config.system.get operation,策略回执对 SECRET 结果整体脱敏
+- 当前未完成:补齐 parity API 的真实业务端点、完成渠道用户到 API 用户身份映射、删除 64 个退出工具文件并迁移关联测试/Skill
+- 验证:网关、Skill、策略、数据端口与 MCP 专项 63 passed;新增代码 compileall 通过
+
+### 2026-08-31:最终目录决策确认
+
+- 不增加 `AGENT_API_GATEWAY_ENABLED`、`AGENT_COMPACT_TOOL_CATALOG_ENABLED` 或其它运行时切换
+- 不考虑 MCP 旧工具兼容;MCP、HTTP 工具管理器和 Agent 统一采用新 API 目录
+- 旧业务工具代码的删除是本目标的必达项:先将行为迁移到 API operation handler,再删除对应 `app/agent/tools/impl/*` 文件、注册、测试和文档
+- 网关不得实例化旧工具类;临时 legacy owner 文件已经删除,后续不再恢复
+
+### 2026-08-31:L2 完成并进入 L3
+
+- Web/WebAgent 数字用户 ID 才允许直接作为 API 身份;外部消息渠道即使用户 ID 为数字,也必须通过渠道绑定解析为有效 MoviePilot 用户
+- API 执行器使用宿主签发的当前用户令牌,并通过模型不可控请求头透传会话、渠道和来源;不读取全局 API_TOKEN
+- 系统设置元数据与脱敏、过滤规则校验与引用处理、插件管理投影已迁出 `app/agent/tools/impl`,进入对应 Application owner
+- 新增 typed API:统一系统设置、自定义识别词、下载任务高级修改、过滤规则/规则组 CRUD、插件运行能力/持久化数据、斜杠命令执行
+- parity operation 已切换到真实固定路由,不再使用 `system/ruletest`、插件页面或环境变量接口作为占位映射
+- L2 状态更新为 VERIFIED,当前唯一活动叶子为 L3;下一步补齐推荐、目录配置等剩余语义差异并开始删除 64 个退出工具文件
+- 验证:`uv run --locked --no-sync python -m compileall -q app/agent app/application app/api/endpoints app/schemas` 通过;Agent 网关/Skill/策略/数据端口/MCP 专项 `63 passed`
+
+### 2026-08-31:纳入下载器与媒体服务器第三方 API Skill
+
+- 对照 database-operation Skill、下载器 provider client、媒体服务器 provider client 和现有 API operation 完成边界评估
+- 决定新增 downloader-operation 与 mediaserver-operation 两个领域 Skill,通过固定脚本读取本机配置并调用已配置第三方服务自身 API
+- 两个 Skill 不仅替代既有低层 operation,还提供动态 `capabilities` 清单和 provider 扩展注册表,使 Agent 可按需使用任务文件/Tracker/Peer/队列、播放会话、观看状态、扫描和元数据刷新等高级能力
+- 计划在 Skill 通过离线测试后移除 `download.list`、`download.update`、`download.delete`、`downloaders.list` 和 `library.latest` 等重复低层 Agent operation
+- 保留 `download.add`、`library.exists`、下载/转移历史和订阅等 MoviePilot 高层 operation,避免削弱媒体身份、路径映射、持久化和恢复能力
+- 不保留旧工具或 MCP 兼容入口;前端仍需使用的 REST 端点不在删除范围
+- 当前活动叶子仍为 L6 原生工具收敛;L7 已加入 Goal,待 action 工具收口后实施
+
+### 2026-08-31:L3 至 L6 完成
+
+- 完成 59 个正式 operation:第一批 44 个、parity 15 个;模型不能注入 URL、method、认证头或 Token
+- 将过滤规则、系统设置、插件管理/数据、音乐投影、下载任务和命令执行迁入对应 Application owner
+- agent_task 合并 create/list/update/run/delete,persona 合并 list/switch/update;旧独立工具实现全部删除
+- 主 Agent、子 Agent、CLI、MCP 和 HTTP 工具管理器统一消费同一严格目录,不再注册旧工具名
+- 删除 77 个 app/agent/tools/impl 退役文件,其中 72 个工具模块和 5 个辅助模块
+- 阶段验证:网关、策略、原生 action、流式活动和关联回归 218 passed
+
+### 2026-08-31:L7 第三方服务 Skill 化完成
+
+- 新增 downloader-operation Skill 与 mp-downloader.py 固定脚本,覆盖 qBittorrent、Transmission、rTorrent 的实例、能力发现、任务/文件/Tracker/Peer、队列、限速、标签、分类、位置和会话统计等动作
+- 新增 mediaserver-operation Skill 与 mp-mediaserver.py 固定脚本,覆盖 Emby、Jellyfin、Plex、ZSpace、Ugreen、TrimeMedia、Navidrome 的实例、媒体库、条目、活动、播放会话、扫描和元数据刷新等动作
+- 两个脚本只读取 MoviePilot 已配置实例,拒绝任意 URL/method/header/凭据参数,统一递归裁剪密码、Token、Cookie、API Key 和会话字段
+- 从 Agent operation 目录删除 download.list、download.update、download.delete、downloaders.list 和 library.latest;保留前端 REST 端点与 MoviePilot 高层 download.add、library.exists、订阅和历史能力
+- 阶段验证:provider Skill、网关移除项和内置 Skill 边界 33 passed
+
+### 2026-08-31:L8 收口完成
+
+- CLI scheduler list/run 已改用 scheduler.list 与 scheduler.run operation,不再调用已删除工具名
+- 工具目录构造、Agent 图、HTTP/MCP direct 调用均强制唯一身份;同名插件/MCP 工具直接失败,不采用 first-wins
+- docs/mcp-api.md、docs/cli.md、命令规则、内置 Skill 文档和 Schema 导出清单已同步新合同
+- 宿主架构基线已更新:模块 982 降至 917,内部依赖边 8,430 降至 7,671,未新增 Application/Chain 到具体 Adapter 的直连
+- 受影响 Python 文件通过 Ruff、compileall 和 Pylint `--errors-only`;Schema 导出、架构基线与 `git diff --check` 均通过
+- 最终架构/OpenAPI/i18n/事件/package-root 回归 84 passed;Agent/MCP/CLI/Skill/架构大回归 1066 passed、插件与架构边界专项 124 passed、provider/gateway 专项 33 passed
+- 完整锁定测试 `uv run --locked --no-sync python tests/run.py` 完成:7554 passed、9 skipped、2 failed
+- 两个失败仅为 `test_release_supply_chain.py::test_release_promotes_latest_only_after_both_versioned_images` 与 `test_resource_v3.py::test_v3_release_workflows_use_main_wiki_and_isolated_images`;均已在未改动的 `v3@6ee47795629f` 干净 detached worktree 复现,属于本次重构前已有的发布工作流基线问题
+- Agent 工具重构范围内没有遗留失败;L1-L8 全部退出,正式方案不保留旧工具、MCP 兼容或运行时切换开关
+- 当前变更尚未提交或推送
+
+本文件作为本次重构的持续记录,保留阶段状态、实际变更、验证结果、提交状态与已知基线边界。
diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md
index 10090e7fb..ebe7d5946 100644
--- a/docs/architecture/optimization-checklist.md
+++ b/docs/architecture/optimization-checklist.md
@@ -93,7 +93,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 |
|---|---:|---|
-| 宿主 Python 模块 / 内部依赖边 | 982 / 8,430 | `dependency-baseline.json` 当前快照 |
+| 宿主 Python 模块 / 内部依赖边 | 917 / 7,671 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -204,8 +204,8 @@ S4/ARCH-110/111 的取消不撤销现有防回退门禁。后续按路线图的
- 架构总览此前仍记录 811 模块、6,572 条边和 1 个 SCC,已经落后于当前基线。
- Event consumer 扫描曾把任意同名 `.register()` 调用当成事件注册;S0-L2.5 已改为证明
canonical EventManager receiver,10 个动态误报归零并保留唯一 workflow 动态注册。
-- S0-L2.6 已将 producer/consumer 合并为逐调用事实源;本轮统一 Transfer 事件发送点后为
- 90 个 producer(89 静态、1 动态)与
+- S0-L2.6 已将 producer/consumer 合并为逐调用事实源;本轮删除重复 Agent Tool 事件发送点后为
+ 86 个 producer(85 静态、1 动态)与
17 个 consumer(16 静态、1 动态);consumer 由不可自动写入的精确人工 policy 管理。
**目标与步骤**
diff --git a/docs/cli.md b/docs/cli.md
index cb3acd8e9..cc5f8cff7 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -108,6 +108,19 @@ moviepilot tool list
moviepilot tool show
```
+工具目录使用最终统一模式:MoviePilot 业务能力通过 `moviepilot_api` 的固定
+`operation_id` 调用,不再列出或接受 `query_schedulers`、`run_scheduler` 等旧业务
+工具名。对象、数组和布尔参数使用 JSON:
+
+```shell
+moviepilot tool show moviepilot_api
+moviepilot tool run moviepilot_api operation_id=scheduler.list
+moviepilot tool run moviepilot_api operation_id=scheduler.run 'query={"job_id":"site-sync"}'
+```
+
+下载器和媒体服务器的 provider 原生高级能力不注册成永久工具;内置 Agent 按需
+加载 `downloader-operation` 或 `mediaserver-operation` Skill,并调用各自固定脚本。
+
## 完整命令清单
```text
@@ -528,23 +541,24 @@ moviepilot tool list
查看单个工具的参数说明:
```shell
-moviepilot tool show query_schedulers
-moviepilot tool show search_torrents
+moviepilot tool show moviepilot_api
+moviepilot tool show agent_task
```
运行工具:
```shell
-moviepilot tool run query_schedulers
-moviepilot tool run search_torrents media_type=movie media_source=themoviedb media_id=12345
+moviepilot tool run moviepilot_api operation_id=scheduler.list
+moviepilot tool run moviepilot_api operation_id=media.search 'query={"title":"流浪地球","type":"media"}'
```
说明:
- `tool list` 用于动态发现当前服务可调用的工具
- `tool show` 会输出参数名、类型和描述
-- `tool run` 参数格式固定为 `key=value`
-- 涉及精确媒体身份的通用工具统一使用 `media_source` + `media_id`;内置来源使用工具 Schema 中的 `MediaSource` 常量,插件可以注册符合 Schema 格式的扩展来源,两个字段必须成对传递并复用搜索结果。TMDB 等单数据源专属工具按各自 Schema 保留原生 ID 参数
+- `tool run` 参数格式固定为 `key=value`;对象、数组、布尔值和 `null` 使用合法 JSON
+- MoviePilot 业务能力统一通过 `moviepilot_api` 的固定 `operation_id` 调用;不接受任意 URL、method、认证头或 Token,也不兼容旧业务工具名
+- 涉及精确媒体身份的 operation 统一使用 `media_source` + `media_id`,两个字段必须成对传递并复用搜索结果
- `read_file`、`write_file`、`edit_file` 和 `execute_command`
属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时
由 Agent 按当前用户权限直接调用这些工具。
diff --git a/docs/mcp-api.md b/docs/mcp-api.md
index 36734c732..85e2ab72f 100644
--- a/docs/mcp-api.md
+++ b/docs/mcp-api.md
@@ -106,7 +106,7 @@ MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器
| -32603 | Internal error | 服务器内部错误 |
## 6. RESTful API
-所有工具相关的API端点都在 `/api/v1/mcp` 路径下(保持向后兼容)。
+HTTP 工具管理端点统一位于 `/api/v1/mcp`,并与标准 MCP JSON-RPC 端点共享同一最终工具目录。
### 相关 REST 端点
@@ -304,39 +304,85 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
获取所有可用的MCP工具列表。
-内置工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。插件工具的参数结构由插件自身声明。
+MCP、HTTP 工具管理接口、本地 CLI 和内置 Agent 都从同一严格目录生成工具列表。
+旧业务工具名不再注册,也没有 MCP 别名或兼容目录;例如
+`search_media`、`add_subscribe`、`query_download_tasks`、`query_schedulers`
+会直接返回工具不存在。插件工具仍由插件的 `get_agent_tools()` 动态声明,重名会让目录
+构造失败,不采用 first-wins 覆盖。
-`send_message` 新增可选的 `rich_message` 字符串参数,用于传入一份完整的 GitHub 风格 Markdown 正文。Telegram 渠道会把它转换为 Bot API Rich Message,支持标题、列表、表格、引用、代码块和链接,并按 Rich Message 限制自动分段;没有使用该参数时继续走原有普通消息链路。广播到其它通知渠道时,同一正文会作为普通 `text` 回退。`rich_message` 是完整正文,不应再同时传 `message`、`title` 或 `image_url` 表达同一份内容。内置 Agent 在 Telegram 会话中的普通回复、流式首发和后续流式编辑都会优先使用该富文本链路。
-
-内置 Agent 的本地文件与命令工具 `read_file`、`write_file`、`edit_file`、
-`apply_patch`、`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的
-用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。
-其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
-`start_line`、`end_line` 指定更小的行号范围继续读取。
-
-媒体相关 MCP 工具以 `media_source` + 来源原生 `media_id` 传递精确身份;内置来源使用 `MediaSource` 常量,插件来源使用注册的稳定扩展标识。`query_media_detail`、`search_torrents`、`query_library_exists` 必须提供完整字段对;`add_subscribe`、`transfer_file`、`scrape_metadata` 在显式指定身份时也必须成对提供。`search_media` 和 `recognize_media` 是按标题或路径发现身份的入口,其结果中的字段对可直接用于后续工具。音乐调用还使用 `media_type=music` 与 `music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具响应中的专用 ID 仅是跨源映射辅助输出,不应再作为上述通用工具的输入。TMDB 专用的 `query_episode_schedule` 仍使用 `tmdb_id`,因为它直接调用单一 TMDB 剧集接口。
-
-Agent 音乐流程与影视共用同一采集管线,但实体边界不同:单曲通过 `music_type=recording` 按一个文件处理;专辑通过 `music_type=album` 类似电视剧整季包,按一个目录/资源处理并校验总曲目数;艺术家不是采集目标。`add_subscribe` / `update_subscribe` 支持音乐音质筛选字段和 `best_version` 音质洗版;`query_subscribes` 会返回筛选条件及当前音质快照。`scrape_metadata(media_type="music")` 会按策略写音频标签、封面和歌词,并返回歌词新增、升级、已存在、防降级保护、未匹配、预算耗尽和失败数量。
-
-`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`;需要查看种子标签时,传入 `include_labels=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。
-
-#### Agent 自主定时任务工具
-
-以下工具用于管理会在指定时间重新唤醒 Agent 的持久化任务,均为管理员级工具:
+固定目录按职责收敛为:
| 工具 | 说明 |
| :--- | :--- |
-| `create_agent_task` | 创建单次或周期任务,并保存任务内容及当前用户、会话上下文 |
-| `query_agent_tasks` | 查询任务配置、启用状态、下次执行时间及最近执行结果 |
-| `update_agent_task` | 修改任务内容或触发器,也可通过 `enabled` 暂停、恢复任务 |
-| `run_agent_task` | 使用整数 `task_id` 将当前用户已启用的任务提交为立即执行 |
-| `delete_agent_task` | 永久删除任务并立即移除运行时调度 |
+| `moviepilot_api` | 通过固定 `operation_id` 调用受控 MoviePilot 业务 API;不接受 URL、HTTP method、认证头或 Token |
+| `agent_task` | 通过 `action=create|list|update|run|delete` 管理自主任务 |
+| `persona` | 通过 `action=list|switch|update` 管理 Agent 人格 |
+| `send_message`、`send_local_file` | 当前目录中可用的消息和文件发送能力;实际渠道能力仍由运行时校验 |
+| `browse_webpage`、`recognize_captcha` | 浏览和验证码等非 MoviePilot 业务 API 能力 |
+| `query_doctor_report` | 只读系统诊断 |
+
+`read_file`、`write_file`、`edit_file`、`apply_patch`、`execute_command` 和
+`search_web` 不通过 MCP 暴露。隐藏列表只负责收敛接口暴露面,不替代各工具自身的
+权限、路径和网络边界。
+
+下载器和媒体服务器的第三方原生高级能力不注册成永久 MCP 工具。内置 Agent 按需
+加载 `downloader-operation` 或 `mediaserver-operation` Skill,通过固定脚本读取本机
+配置、发现 provider 能力并调用受控 action;脚本不接受任意 URL、认证信息或任意
+SDK method。普通 MCP 客户端如需这些 provider 原生能力,应使用对应第三方服务的
+正式 API,而不是依赖已删除的 MoviePilot 旧工具名。
+
+`send_message` 新增可选的 `rich_message` 字符串参数,用于传入一份完整的 GitHub 风格 Markdown 正文。Telegram 渠道会把它转换为 Bot API Rich Message,支持标题、列表、表格、引用、代码块和链接,并按 Rich Message 限制自动分段;没有使用该参数时继续走原有普通消息链路。广播到其它通知渠道时,同一正文会作为普通 `text` 回退。`rich_message` 是完整正文,不应再同时传 `message`、`title` 或 `image_url` 表达同一份内容。内置 Agent 在 Telegram 会话中的普通回复、流式首发和后续流式编辑都会优先使用该富文本链路。
+
+`moviepilot_api` 的模型可见输入固定为:
+
+```json
+{
+ "operation_id": "media.search",
+ "path_params": {},
+ "query": {"title": "流浪地球", "type": "media"},
+ "body": {}
+}
+```
+
+宿主按 `operation_id` 决定固定 method 与 path,使用真实持久化管理员身份为
+API KEY 集成签发短期本机令牌,并按 operation 执行权限、确认、结果脱敏和恢复策略。
+调用方不能注入 host、URL、认证头或 API Token。
+
+当前业务 operation 分组如下;完整参数合同以 `skills/moviepilot-api/SKILL.md` 和
+各 REST 请求模型为准:
+
+| 领域 | Operation ID |
+| :--- | :--- |
+| 媒体/搜索 | `media.search`、`media.person.search`、`media.person.credits`、`media.recognize`、`media.scrape`、`media.episode_schedule`、`media.detail`、`search.torrents`、`search.results`、`recommendation.list` |
+| 订阅 | `subscription.add`、`subscription.update`、`subscription.search`、`subscription.list`、`subscription.shares`、`subscription.popular`、`subscription.history`、`subscription.delete` |
+| 下载/历史 | `download.add`、`download.history.delete`、`transfer.history.delete` |
+| 媒体库/存储/转移 | `library.exists`、`storage.settings`、`storage.list`、`transfer.history`、`transfer.file` |
+| 站点 | `site.list`、`site.update`、`site.userdata`、`site.test`、`site.cookie.update` |
+| 调度/工作流 | `scheduler.list`、`scheduler.run`、`workflow.list`、`workflow.run` |
+| 插件 | `plugin.installed`、`plugin.market`、`plugin.capabilities`、`plugin.config.get`、`plugin.config.update`、`plugin.reload`、`plugin.install`、`plugin.uninstall`、`plugin.data` |
+| 规则/配置/命令 | `filter.builtin`、`filter.custom`、`filter.groups`、`filter.custom.add`、`filter.custom.update`、`filter.custom.delete`、`filter.group.add`、`filter.group.update`、`filter.group.delete`、`config.identifiers.get`、`config.identifiers.update`、`config.system.get`、`config.system.update`、`slash.list`、`slash.run` |
+
+`download.list`、`download.update`、`download.delete`、`downloaders.list` 和
+`library.latest` 已从 Agent operation 目录删除,避免与 provider Skill 重复。供前端和
+其它宿主使用的普通 REST 端点仍然保留。
+
+#### Agent 自主定时任务与人格
+
+`agent_task` 是唯一的自主任务工具,要求管理员权限:
+
+| Action | 说明 |
+| :--- | :--- |
+| `create` | 创建单次或周期任务,并保存任务内容及当前用户、会话上下文 |
+| `list` | 查询任务配置、启用状态、下次执行时间及最近执行结果 |
+| `update` | 修改任务内容或触发器,也可通过 `enabled` 暂停、恢复任务 |
+| `run` | 使用整数 `task_id` 将当前用户已启用的任务提交为立即执行 |
+| `delete` | 永久删除任务并立即移除运行时调度 |
`trigger_type=date` 表示单次执行:“30 分钟后检查”这类相对时间传 `delay_minutes=30`,由后端计算精确时间;固定时间则传 ISO 8601 `trigger`,支持精确到秒。`trigger_type=cron` 使用标准五段 cron(分、时、日、月、周),适合周期检查。未显式携带时区的时间按 MoviePilot 的 `TZ` 配置解释。任务由内存调度器精确触发,配置持久化到数据库,服务重启后会自动恢复;触发后 Agent 在原会话中执行 `content`,执行过程及最终结果均不绑定创建任务时的消息渠道,而是通过 MoviePilot 已配置的通知渠道广播。如果 Agent 在执行过程中已通过消息工具发送完整结果,任务结束时不会再次发送相同的最终回复。
-服务重启时仍处于运行中的任务会显示为 `interrupted`,表示上次结果未知且可能已有部分操作。中断的一次任务不会自动补跑,暂停后恢复也仍保留中断状态;需要先核对实际结果,再用 `run_agent_task` 明确立即重跑,或通过 `update_agent_task` 提供新的 `trigger_type` 与未来触发时间重新安排。
+服务重启时仍处于运行中的任务会显示为 `interrupted`,表示上次结果未知且可能已有部分操作。中断的一次任务不会自动补跑,暂停后恢复也仍保留中断状态;需要先核对实际结果,再用 `agent_task(action="run")` 明确立即重跑,或通过 `agent_task(action="update")` 提供新的 `trigger_type` 与未来触发时间重新安排。
-Agent 自主任务工具使用数据库中的整数 `task_id`。`query_schedulers` 与 `run_scheduler` 仅面向系统、插件和工作流注册的运行时定时服务,使用字符串 `job_id`,不会返回或执行 `agent-task-*`。两类 ID 不可混用;需要立即执行自主任务时,应先通过 `query_agent_tasks` 确认归属和状态,再调用 `run_agent_task`。立即执行只提交任务,不在当前工具调用内等待结果,从而避免同一 Agent 会话互相等待;执行结果仍按上述通知规则广播。
+Agent 自主任务使用数据库中的整数 `task_id`。`scheduler.list` 与 `scheduler.run` operation 仅面向系统、插件和工作流注册的运行时定时服务,使用字符串 `job_id`,不会返回或执行 `agent-task-*`。两类 ID 不可混用;需要立即执行自主任务时,应先通过 `agent_task(action="list")` 确认归属和状态,再调用 `agent_task(action="run")`。立即执行只提交任务,不在当前工具调用内等待结果,从而避免同一 Agent 会话互相等待;执行结果仍按上述通知规则广播。
上述过滤只约束 Agent 工具,避免模型混用两类任务。前端系统设置和仪表盘使用的 `/api/v1/dashboard/schedule` 仍返回完整运行时列表,其中包含 `provider=[Agent]` 的自主任务;前端通过 `/api/v1/system/runscheduler` 立即执行这类列表项的行为也保持不变。
@@ -344,8 +390,9 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
```json
{
- "tool_name": "create_agent_task",
+ "tool_name": "agent_task",
"arguments": {
+ "action": "create",
"name": "检查电影资源",
"content": "搜索电影《示例电影》是否已有可下载资源,并报告站点、版本和大小;不要自动下载。",
"trigger_type": "date",
@@ -356,6 +403,11 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
创建每天 20:30 执行的周期任务时,使用 `trigger_type=cron` 和 `trigger="30 20 * * *"`。
+`persona` 使用 `action=list|switch|update`。`list` 可按 `query` 过滤;
+`switch` 必须提供 `persona_id`;`update` 只有管理员可用,支持替换 label、
+description、aliases、instructions,或通过 `append_instructions` 追加规则。旧的
+`query_personas`、`switch_persona` 和 `update_persona_definition` 不再注册。
+
**认证**: 需要API KEY,在请求头中添加 `X-API-KEY: ` 或在查询参数中添加 `apikey=`
**响应示例**:
@@ -365,17 +417,17 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
"message": "",
"data": [
{
- "name": "add_subscribe",
- "description": "Add media subscription to create automated download rules...",
+ "name": "moviepilot_api",
+ "description": "调用经过白名单审核的 MoviePilot 业务 API...",
"inputSchema": {
"type": "object",
"properties": {
- "title": {
+ "operation_id": {
"type": "string",
- "description": "The title of the media to subscribe to"
+ "description": "稳定的 MoviePilot API operation ID"
}
},
- "required": ["title", "media_type"]
+ "required": ["operation_id"]
}
}
]
@@ -397,11 +449,13 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
**请求体**:
```json
{
- "tool_name": "add_subscribe",
+ "tool_name": "moviepilot_api",
"arguments": {
- "title": "流浪地球",
- "year": "2019",
- "media_type": "movie"
+ "operation_id": "media.search",
+ "query": {
+ "title": "流浪地球",
+ "type": "media"
+ }
}
}
```
@@ -412,7 +466,7 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
"success": true,
"message": "",
"data": {
- "result": "成功添加订阅:流浪地球 (2019)"
+ "result": "{\"success\":true,\"message\":\"\",\"data\":[...]}"
}
}
```
@@ -443,17 +497,17 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
"success": true,
"message": "",
"data": {
- "name": "add_subscribe",
- "description": "Add media subscription to create automated download rules...",
+ "name": "moviepilot_api",
+ "description": "调用经过白名单审核的 MoviePilot 业务 API...",
"inputSchema": {
"type": "object",
"properties": {
- "title": {
+ "operation_id": {
"type": "string",
- "description": "The title of the media to subscribe to"
+ "description": "稳定的 MoviePilot API operation ID"
}
},
- "required": ["title", "media_type"]
+ "required": ["operation_id"]
}
}
}
@@ -478,16 +532,16 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
"data": {
"type": "object",
"properties": {
- "title": {
+ "operation_id": {
"type": "string",
- "description": "The title of the media to subscribe to"
+ "description": "稳定的 MoviePilot API operation ID"
},
- "year": {
- "type": "string",
- "description": "Release year of the media"
+ "query": {
+ "type": "object",
+ "description": "固定 operation 的查询参数"
}
},
- "required": ["title", "year", "media_type"]
+ "required": ["operation_id"]
}
}
```
diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md
index eed92f610..9d3d22da7 100644
--- a/docs/rules/03-commands.md
+++ b/docs/rules/03-commands.md
@@ -216,12 +216,12 @@ moviepilot config describe API_TOKEN --show-secrets
moviepilot tool list
# Show tool parameters
-moviepilot tool show query_schedulers
-moviepilot tool show search_torrents
+moviepilot tool show moviepilot_api
+moviepilot tool show agent_task
# Run a tool directly
-moviepilot tool run query_schedulers
-moviepilot tool run search_torrents media_type=movie media_source=themoviedb media_id=12345
+moviepilot tool run moviepilot_api operation_id=scheduler.list
+moviepilot tool run moviepilot_api operation_id=media.search 'query={"title":"流浪地球","type":"media"}'
# List scheduled tasks
moviepilot scheduler list
@@ -230,9 +230,12 @@ moviepilot scheduler list
moviepilot scheduler run subscribe_refresh
```
-**Media identity rule:** Generic media tools use the complete `media_source` +
-`media_id` pair returned by media search. Built-in sources use `MediaSource`
-constants; plugins may register a schema-valid extension identifier. A
+**Tool catalog rule:** MoviePilot business operations use `moviepilot_api` with a
+fixed `operation_id`; old business tool names are not registered. Structured
+`query`, `body`, arrays, and booleans are passed as JSON values in `key=value`
+arguments. Media operations use the complete `media_source` + `media_id` pair
+returned by media search. Built-in sources use `MediaSource` constants; plugins
+may register a schema-valid extension identifier. A
source-owned tool such as `query_episode_schedule` may retain its native ID
parameter because its schema and implementation are single-source.
diff --git a/skills/browser-use/SKILL.md b/skills/browser-use/SKILL.md
index 9faf6384f..1da2fa4fa 100644
--- a/skills/browser-use/SKILL.md
+++ b/skills/browser-use/SKILL.md
@@ -1,6 +1,6 @@
---
name: browser-use
-version: 1
+version: 2
description: >-
Use this skill when the user asks the agent to open, browse, inspect, extract
content from, click through, fill forms on, screenshot, or verify a web page
@@ -8,7 +8,8 @@ description: >-
interaction, such as checking a site page, confirming a JavaScript-rendered
result, testing login state, capturing visible errors, or updating and
validating tracker site cookies.
-allowed-tools: browse_webpage recognize_captcha search_web query_sites update_site_cookie test_site update_site
+allowed-tools: browse_webpage recognize_captcha search_web moviepilot_api
+allowed-api-operations: site.list site.cookie.update site.test site.update
---
# Browser Use
@@ -48,13 +49,13 @@ dedicated tool can complete the task more directly and safely.
target URL. It supports DDGS-backed `search_engine` (`auto`, `duckduckgo`,
`google`, `brave`, etc.) and `site_url` for limiting results to a specified
domain or URL path. It uses the configured system proxy by default.
-- `query_sites` - Get MoviePilot site IDs before site-specific operations.
+- `site.list` through `moviepilot_api` - Get site IDs before site-specific operations.
Non-admin callers receive a safe view without Cookie, RSS, Token, or API Key
fields.
-- `update_site_cookie` - Update a configured site's Cookie and User-Agent using
+- `site.cookie.update` - Update a configured site's Cookie and User-Agent using
username, password, and optional two-step code.
-- `test_site` - Verify configured site connectivity and login status.
-- `update_site` - Update existing site settings when the user explicitly asks.
+- `site.test` - Verify configured site connectivity and login status.
+- `site.update` - Update existing site settings when the user explicitly asks.
## Core Workflow
@@ -68,7 +69,7 @@ Examples:
- Query downloads, subscriptions, media, sites, or library state with the
existing MoviePilot skills/tools.
-- Use `query_sites`, `update_site_cookie`, and `test_site` for configured
+- Use `site.list`, `site.cookie.update`, and `site.test` through `moviepilot_api` for configured
tracker sites before manually browsing their pages.
### 2. Find Or Open The Target
@@ -149,7 +150,7 @@ Before finalizing, verify the outcome with one of:
- `get_content` for text or data changes.
- `screenshot` for visual state.
-- `test_site` for MoviePilot configured tracker connectivity.
+- `site.test` through `moviepilot_api` for MoviePilot configured tracker connectivity.
Report the result with the final URL, observed status, and any remaining
uncertainty. If the page failed, include the visible error text and the action
@@ -159,11 +160,11 @@ that failed.
### Diagnose A Configured Site
-1. Use `query_sites` to find the site ID.
-2. Use `test_site` with the site ID.
+1. Use `site.list` to find the site ID.
+2. Use `site.test` with path parameter `site_id`.
3. If the site fails and the user provided credentials, use
- `update_site_cookie`.
-4. Run `test_site` again to confirm.
+ `site.cookie.update`.
+4. Run `site.test` again to confirm.
5. Use `browse_webpage` only if the failure message is unclear or the user asks
to inspect the visible page.
@@ -173,7 +174,7 @@ Use the dedicated cookie tool instead of manually logging in through the
browser:
```text
-update_site_cookie site_identifier= username="..." password="..." two_step_code="..."
+moviepilot_api operation_id=site.cookie.update path_params.site_id= body.username="..." body.password="..." body.two_step_code="..."
```
Ask for missing username, password, or two-step code only when required for the
@@ -236,16 +237,16 @@ User: `打开这个网页看看报什么错`
User: `帮我看看某个站点是不是登录失效了`
-1. `query_sites`
-2. `test_site site_identifier=`
+1. `moviepilot_api` with `operation_id=site.list`
+2. `moviepilot_api` with `operation_id=site.test` and `path_params.site_id=`
3. If needed, ask whether to update Cookie.
User: `帮我更新某站 Cookie`
-1. `query_sites`
+1. `moviepilot_api` with `operation_id=site.list`
2. Ask for missing credentials or two-step code.
-3. `update_site_cookie`
-4. `test_site`
+3. `moviepilot_api` with `operation_id=site.cookie.update`
+4. `moviepilot_api` with `operation_id=site.test`
User: `这个页面按钮点一下后截图给我看`
diff --git a/skills/command-dispatch/SKILL.md b/skills/command-dispatch/SKILL.md
index 4182c3cbf..91d4ca0cc 100644
--- a/skills/command-dispatch/SKILL.md
+++ b/skills/command-dispatch/SKILL.md
@@ -1,75 +1,31 @@
---
name: command-dispatch
-version: 1
+version: 2
description: >-
- Use this skill when the user's intent is to execute a system or plugin function. Applicable scenarios include:
- 1) The user sends a slash command starting with / (e.g. /cookiecloud, /sites, /subscribes, etc.);
- 2) The user describes an action in natural language that can be fulfilled by a system or plugin command
- (e.g. "sync sites", "show subscriptions", "refresh subscriptions", "check downloads", etc.).
- This skill helps you identify the user's intent, find the matching command, extract necessary parameters,
- and execute the corresponding command.
-allowed-tools: list_slash_commands query_plugin_capabilities run_slash_command
+ Use this skill when the user sends a slash command or asks to run a MoviePilot
+ system or plugin command in natural language. Discover the live command
+ catalog, resolve the exact command, and dispatch it through the structured API
+ gateway.
+allowed-tools: moviepilot_api
+allowed-api-operations: slash.list slash.run plugin.capabilities
---
# Command Dispatch
-Use this skill to identify user intent and dispatch the corresponding system or plugin command.
+Use `moviepilot_api`; retired command tools and MCP aliases do not exist.
-## When to Use
+1. For a literal `/command ...`, preserve the command text and inspect the live
+ catalog with `slash.list` when availability or syntax is uncertain.
+2. For natural-language requests, call `slash.list`, then match the returned
+ name, description, and category. If a plugin owns the capability, optionally
+ call `plugin.capabilities` for its declared commands and actions.
+3. Never invent a command or argument. If the live catalog has no match, report
+ that no supported command was found.
+4. Command execution changes external state. Obtain confirmation unless the user
+ already explicitly requested the exact command/action.
+5. Dispatch with `slash.run` and `body.command` containing the complete command,
+ for example `/sites disable 3`. Check the returned acceptance/result before
+ reporting completion.
-- The user sends a `/xxx` slash command (execute directly)
-- The user describes an action in natural language, for example:
- - "Sync sites" → `/cookiecloud`
- - "Show my subscriptions" → `/subscribes`
- - "Refresh subscriptions" → `/subscribes refresh`
- - "What's downloading?" → `/downloading`
- - "Organize downloaded files" → `/transfer`
- - "Clear cache" → `/clear_cache`
- - "Restart the system" → `/restart`
- - "Pause all QB tasks" → `/pause_torrents` (plugin command)
-
-## Tools
-
-- `list_slash_commands` — List all available slash commands (system + plugin), returns command name, description, and category
-- `query_plugin_capabilities` — Query detailed plugin capabilities (commands, actions, scheduled services)
-- `run_slash_command` — Execute a specified command (works for both system and plugin commands)
-
-## Workflow
-
-### Step 1: Identify User Intent
-
-Determine whether the user's message is requesting the execution of a command:
-
-- **Direct command**: Message starts with `/`, e.g. `/sites`, `/subscribes` → skip to Step 3
-- **Natural language**: The user describes an actionable request → continue to Step 2
-
-### Step 2: Find Matching Command
-
-Use `list_slash_commands` to retrieve all available commands. Match the user's described intent against the `description` and `category` fields of each command.
-
-If the user's description involves a specific plugin's functionality, additionally use `query_plugin_capabilities` to query that plugin's detailed capabilities.
-
-**Matching strategy**:
-- Prefer exact matches on command description
-- Then narrow down by category and match
-- If no matching command is found, inform the user that no corresponding function is available
-
-### Step 3: Extract Parameters and Execute
-
-Some commands support additional arguments (space-separated after the command), for example:
-- `/redo ` — Manually re-organize a specific record
-- `/sites disable ` — Disable one or more sites
-- `/subscribes delete ` — Delete one or more subscriptions
-
-Use `run_slash_command` to execute the command in the format `/command_name arg1 arg2`.
-
-### Step 4: Report Result
-
-Command execution is asynchronous. After triggering, inform the user that the command has started. If the command does not exist, list available commands for reference.
-
-## Important Notes
-
-- Command execution requires admin privileges; the tool will automatically check permissions
-- Both system and plugin commands are executed via the `run_slash_command` tool — no need to distinguish between them
-- If you are unsure which command matches the user's intent, use `list_slash_commands` first to look up before deciding
-- Never guess non-existent commands; always select from the available command list
+Examples of intent mapping such as site sync, subscription refresh, cache clear,
+or restart are hints only; the live `slash.list` response is authoritative.
diff --git a/skills/create-moviepilot-plugin/SKILL.md b/skills/create-moviepilot-plugin/SKILL.md
index 4026e83b2..0a0134467 100644
--- a/skills/create-moviepilot-plugin/SKILL.md
+++ b/skills/create-moviepilot-plugin/SKILL.md
@@ -1,6 +1,6 @@
---
name: create-moviepilot-plugin
-version: 4
+version: 5
description: >-
Use this skill when the user asks to create, modify, debug, validate, or
scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development,
@@ -11,7 +11,8 @@ description: >-
sidebar pages, commands, services, workflow actions, agent tools, and local
install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源,
插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面.
-allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command search_web browse_webpage query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins
+allowed-tools: read_file write_file edit_file apply_patch execute_command search_web browse_webpage moviepilot_api
+allowed-api-operations: config.system.get config.system.update plugin.market plugin.installed plugin.install plugin.reload
---
# Create MoviePilot Plugin
@@ -37,9 +38,8 @@ a local plugin source and installed into the running MoviePilot instance.
## Code Tool Workflow
- Use `execute_command(action="run")` with `rg` and narrow globs or paths to
- locate plugin classes, extension points, tests, and package entries. Use
- `list_directory` only when inspecting one known folder or a configured remote
- storage backend.
+ locate plugin classes, extension points, tests, package entries, and directory
+ contents.
- Read the relevant implementation and adjacent example before editing.
- If `read_file` reports truncation, continue with smaller `start_line` and
`end_line` ranges until all relevant sections have been inspected.
@@ -92,21 +92,23 @@ a local plugin source and installed into the running MoviePilot instance.
- Do not silently default to either mode just because one seems easier.
3. Inspect existing plugins before creating a new one:
- Local runtime examples: `app/plugins//__init__.py`
- - Market/local source candidates: use `query_market_plugins` when the
- running instance is available.
- - Installed plugin candidates: use `query_installed_plugins`; its summaries
+ - Market/local source candidates: call `moviepilot_api` with
+ `operation_id=plugin.market` when the running instance is available.
+ - Installed plugin candidates: use `operation_id=plugin.installed`; its summaries
include `repo_url` when the source can be matched from a local plugin
repository or plugin market metadata.
- For Vue federation examples, prefer current compliant plugins such as
`MoviePilot-Plugins/plugins.v2/agenttokens/` and the frontend example
`MoviePilot-Frontend/examples/plugin-component/`.
4. Determine the target source path:
- - Query `PLUGIN_LOCAL_REPO_PATHS` with `query_system_settings` when possible.
+ - Query `PLUGIN_LOCAL_REPO_PATHS` with `operation_id=config.system.get` when possible.
- If exactly one local plugin repository is configured, prefer that path.
- If several are configured, choose the one the user named; otherwise ask
which repository to use.
- If none is configured, set it before writing plugin code:
- `update_system_settings(setting_key="PLUGIN_LOCAL_REPO_PATHS", value="local-plugins", operation="replace")`.
+ call `operation_id=config.system.update` with a body containing
+ `setting_key="PLUGIN_LOCAL_REPO_PATHS"`, `value="local-plugins"`, and
+ `operation="replace"`.
`local-plugins` is resolved relative to the MoviePilot root by the local
plugin source loader. Create that source directory and write the plugin
under it; do not write new plugin source directly into `app/plugins/`
@@ -476,13 +478,16 @@ Vue API calls:
## Local Install And Reload
1. After writing files in a configured local plugin repository, call
- `query_market_plugins(query="", force_refresh=True)` to confirm the
+ `moviepilot_api` with `operation_id=plugin.market` and query fields
+ `query=""`, `force_refresh=true` to confirm the
local source is visible.
-2. Install or reinstall with `install_plugin(plugin_id="", force=True)`.
+2. Install or reinstall with `operation_id=plugin.install`, path parameter
+ `plugin_id=""`, and `query.force=true`.
The install flow copies the source into `app/plugins//`.
3. If `PLUGIN_AUTO_RELOAD` or development mode is enabled, Python source changes
in an installed local plugin can auto-sync and reload. If it is not enabled,
- call `reload_plugin(plugin_id="")` after editing runtime files.
+ call `operation_id=plugin.reload` with path parameter `plugin_id` after
+ editing runtime files.
4. When `requirements.txt` changes, reinstall with `force=True`; reloading alone
does not install new dependencies.
diff --git a/skills/create-moviepilot-skill/SKILL.md b/skills/create-moviepilot-skill/SKILL.md
index b05c93099..b61d70de8 100644
--- a/skills/create-moviepilot-skill/SKILL.md
+++ b/skills/create-moviepilot-skill/SKILL.md
@@ -1,6 +1,6 @@
---
name: create-moviepilot-skill
-version: 2
+version: 3
description: >-
Use this skill when the user asks to create, scaffold, update, or review a
MoviePilot agent skill. This includes adding a new built-in skill under the
@@ -8,7 +8,7 @@ description: >-
`SKILL.md` frontmatter and workflow instructions, choosing `allowed-tools`,
adding helper scripts when needed, and bumping the built-in skill `version`
so changes can sync into `config/agent/skills`.
-allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command
+allowed-tools: read_file write_file edit_file apply_patch execute_command
---
# Create MoviePilot Skill
@@ -74,7 +74,7 @@ name: create-moviepilot-skill
version: 1
description: >-
Explain what the skill does and exactly when to use it.
-allowed-tools: list_directory read_file write_file edit_file execute_command
+allowed-tools: read_file write_file edit_file execute_command
---
```
diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md
index f68a063f2..126524b3e 100644
--- a/skills/database-operation/SKILL.md
+++ b/skills/database-operation/SKILL.md
@@ -1,6 +1,6 @@
---
name: database-operation
-version: 4
+version: 5
description: >-
Use this skill when you need to inspect, query, maintain, or carefully modify
the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper,
@@ -27,8 +27,8 @@ Prefer safer product surfaces first:
| Request | Preferred skill |
|---|---|
-| Normal local MoviePilot product operation exposed as an MCP tool | `moviepilot-cli` |
-| Direct REST endpoint call | `moviepilot-api` |
+| Normal MoviePilot product operation | `moviepilot-api` structured operations |
+| Operation outside the structured API catalog | A more specific Skill or explicit unsupported result |
| Slash commands or plugin/system command dispatch | `command-dispatch` |
| Manual file organization | `organize-files` |
| Retry failed transfer history records | `transfer-failed-retry` |
diff --git a/skills/downloader-operation/SKILL.md b/skills/downloader-operation/SKILL.md
new file mode 100644
index 000000000..6725e7176
--- /dev/null
+++ b/skills/downloader-operation/SKILL.md
@@ -0,0 +1,90 @@
+---
+name: downloader-operation
+version: 1
+description: >-
+ Use this skill when the user asks to inspect, diagnose, or directly control a
+ configured qBittorrent, Transmission, or rTorrent instance. It exposes
+ provider capabilities on demand without adding permanent Agent tools, and is
+ suitable for task files and selection, trackers, peers, queue order, limits,
+ tags, locations, rechecks, direct provider submissions, or batch task control.
+allowed-tools: execute_command
+---
+
+# Downloader Operation
+
+Use `scripts/mp-downloader.py`. The helper reads MoviePilot's local downloader
+configuration and credentials itself. Never request, print, or pass a host,
+username, password, API key, Cookie, or arbitrary URL.
+
+## Boundary
+
+- Prefer `moviepilot-api` for ordinary MoviePilot acquisition workflows,
+ especially site search, `download.add`, subscriptions, transfer, history, and
+ canonical library checks.
+- Use this Skill for downloader-native inspection, diagnosis, advanced task
+ properties, and an explicit request to operate the provider directly.
+- `tasks.add.direct` bypasses MoviePilot download history, site Cookie handling,
+ path selection, duplicate checks, and transfer orchestration. Use it only when
+ the user explicitly wants direct provider submission.
+- Paths passed to `tasks.location.set` and `tasks.add.direct` are downloader-side
+ paths, not MoviePilot storage paths.
+
+## Discover First
+
+List configured instances without secrets:
+
+```bash
+python skills/downloader-operation/scripts/mp-downloader.py instances
+```
+
+List the actions supported by all providers or one configured client:
+
+```bash
+python skills/downloader-operation/scripts/mp-downloader.py capabilities
+python skills/downloader-operation/scripts/mp-downloader.py capabilities --client "main-qb"
+```
+
+Do not guess a provider-specific action. Call `capabilities` when the current
+instance, provider, argument contract, or side-effect level is uncertain.
+
+## Call Shape
+
+```bash
+python skills/downloader-operation/scripts/mp-downloader.py call \
+ --client "main-qb" \
+ --action tasks.list \
+ --arguments '{"status":"downloading","limit":20}'
+```
+
+The `--arguments` value must be one JSON object. Large reads are paged with
+`offset` and `limit`; the default limit is 50 and the maximum is 200.
+
+## Core Actions
+
+- Read: `tasks.list`, `tasks.files`, `tasks.trackers`, `tasks.tags.get`,
+ `tasks.peers`, `session.stats`, `session.speed_limits.get`,
+ `session.details`, `session.content_layout`.
+- Reversible writes: `tasks.start`, `tasks.stop`, `tasks.recheck`,
+ `tasks.reannounce`, `tasks.queue.move`, `tasks.properties.set`,
+ `tasks.files.selection.set`, `tasks.force_start.set`, `tasks.location.set`,
+ `tasks.category.set`, `tasks.tags.set`, `tasks.trackers.update`,
+ `session.speed_limits.set`.
+- External/destructive: `tasks.add.direct`, `tasks.delete`.
+
+For task actions, use `task_id` for one hash/ID or `task_ids` for a batch. Before
+deleting data, confirm the exact client, tasks, and `delete_files=true`. Before a
+direct add, confirm the exact magnet/URL or local torrent file, client, paused
+state, provider path, tags, and category.
+
+For `tasks.files.selection.set`, pass provider file indexes from `tasks.files`
+through `wanted_file_ids` and/or `unwanted_file_ids`; never infer indexes from
+filenames alone. `session.details` is Transmission-only and
+`session.content_layout` is qBittorrent-only.
+
+## Verification
+
+After a write, query the smallest relevant state: `tasks.list` for status and
+properties, `tasks.files` for file priority, `tasks.trackers` for trackers, or
+`session.speed_limits.get` for global limits. Report unsupported provider
+capabilities explicitly instead of falling back to raw HTTP or arbitrary SDK
+method calls.
diff --git a/skills/downloader-operation/scripts/mp-downloader.py b/skills/downloader-operation/scripts/mp-downloader.py
new file mode 100644
index 000000000..3fe6cfcb1
--- /dev/null
+++ b/skills/downloader-operation/scripts/mp-downloader.py
@@ -0,0 +1,554 @@
+#!/usr/bin/env python3
+"""通过 MoviePilot 本机配置调用下载器自身 API 的受控命令行工具。"""
+
+from __future__ import annotations
+
+import argparse
+import importlib
+import json
+import sys
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Optional
+
+SCRIPT_PATH = Path(__file__).resolve()
+PROJECT_ROOT = SCRIPT_PATH.parents[3]
+DEFAULT_LIMIT = 50
+MAX_LIMIT = 200
+ALL_PROVIDERS = ("qbittorrent", "transmission", "rtorrent")
+PROVIDER_CLASSES = {
+ "qbittorrent": "app.modules.qbittorrent.qbittorrent:Qbittorrent",
+ "transmission": "app.modules.transmission.transmission:Transmission",
+ "rtorrent": "app.modules.rtorrent.rtorrent:Rtorrent",
+}
+
+
+@dataclass(frozen=True, slots=True)
+class ActionSpec:
+ """描述一个允许调用的下载器 action。"""
+
+ description: str
+ effect: str
+ providers: tuple[str, ...] = ALL_PROVIDERS
+ required: tuple[str, ...] = ()
+
+ def to_dict(self, name: str) -> dict[str, Any]:
+ """返回不包含实现对象的公开能力描述。"""
+ return {
+ "action": name,
+ "description": self.description,
+ "effect": self.effect,
+ "providers": list(self.providers),
+ "required_arguments": list(self.required),
+ }
+
+
+ACTIONS: dict[str, ActionSpec] = {
+ "tasks.list": ActionSpec("List and filter downloader tasks.", "safe_read"),
+ "tasks.files": ActionSpec("List files and priorities for one task.", "safe_read", required=("task_id",)),
+ "tasks.files.selection.set": ActionSpec(
+ "Select wanted and unwanted files within one task.",
+ "reversible_write",
+ required=("task_id",),
+ ),
+ "tasks.trackers": ActionSpec(
+ "List trackers for one task.", "safe_read", ("qbittorrent", "transmission"), ("task_id",)
+ ),
+ "tasks.tags.get": ActionSpec("Read task tags or labels.", "safe_read", required=("task_id",)),
+ "tasks.peers": ActionSpec(
+ "Read qBittorrent peer synchronization data.", "safe_read", ("qbittorrent",), ("task_id",)
+ ),
+ "tasks.start": ActionSpec("Start or resume one or more tasks.", "reversible_write", required=("task_ids",)),
+ "tasks.stop": ActionSpec("Pause one or more tasks.", "reversible_write", required=("task_ids",)),
+ "tasks.delete": ActionSpec("Delete tasks and optionally their data.", "destructive_write", required=("task_ids",)),
+ "tasks.recheck": ActionSpec("Force data verification for tasks.", "external_side_effect", required=("task_ids",)),
+ "tasks.reannounce": ActionSpec(
+ "Force tracker reannounce.", "external_side_effect", ("qbittorrent", "transmission"), ("task_ids",)
+ ),
+ "tasks.queue.move": ActionSpec(
+ "Move tasks to top, up, down, or bottom of the queue.",
+ "reversible_write",
+ ("qbittorrent", "transmission"),
+ ("task_ids", "position"),
+ ),
+ "tasks.force_start.set": ActionSpec(
+ "Enable or disable qBittorrent force-start for tasks.",
+ "reversible_write",
+ ("qbittorrent",),
+ ("task_ids", "enabled"),
+ ),
+ "tasks.properties.set": ActionSpec(
+ "Set task speed, ratio, or seeding-time limits.", "reversible_write", required=("task_id",)
+ ),
+ "tasks.location.set": ActionSpec(
+ "Move or retarget one task to a provider-side path.", "external_side_effect", required=("task_id", "location")
+ ),
+ "tasks.category.set": ActionSpec(
+ "Set qBittorrent category.", "reversible_write", ("qbittorrent",), ("task_id", "category")
+ ),
+ "tasks.tags.set": ActionSpec("Set or add task tags/labels.", "reversible_write", required=("task_ids", "tags")),
+ "tasks.trackers.update": ActionSpec(
+ "Add or replace task trackers.", "reversible_write", ("qbittorrent", "transmission"), ("task_id", "trackers")
+ ),
+ "tasks.add.direct": ActionSpec(
+ "Submit a magnet, URL, or local torrent file directly to the provider.",
+ "external_side_effect",
+ required=("content",),
+ ),
+ "session.stats": ActionSpec("Read provider transfer/session statistics.", "safe_read"),
+ "session.speed_limits.get": ActionSpec("Read global speed limits.", "safe_read", ("qbittorrent", "transmission")),
+ "session.speed_limits.set": ActionSpec(
+ "Set global speed limits in KB/s.", "reversible_write", ("qbittorrent", "transmission")
+ ),
+ "session.details": ActionSpec(
+ "Read Transmission session configuration and capacity details.",
+ "safe_read",
+ ("transmission",),
+ ),
+ "session.content_layout": ActionSpec(
+ "Read qBittorrent's default torrent content layout.",
+ "safe_read",
+ ("qbittorrent",),
+ ),
+}
+
+
+def _ensure_project_import() -> None:
+ """确保脚本从任意工作目录都可导入 MoviePilot。"""
+ project_path = str(PROJECT_ROOT)
+ if project_path not in sys.path:
+ sys.path.insert(0, project_path)
+
+
+def _load_configs() -> list[Any]:
+ """读取并校验本机下载器配置。"""
+ _ensure_project_import()
+ from app.runtime.extensions.service import ServiceConfigHelper
+
+ return ServiceConfigHelper.get_downloader_configs()
+
+
+def _import_symbol(reference: str) -> type[Any]:
+ """按审核过的模块引用惰性导入 provider client。"""
+ module_name, symbol_name = reference.split(":", 1)
+ return getattr(importlib.import_module(module_name), symbol_name)
+
+
+def _select_config(client_name: Optional[str]) -> Any:
+ """按实例名选择启用配置,省略名称时使用默认或唯一实例。"""
+ enabled = [config for config in _load_configs() if config.enabled]
+ if client_name:
+ for config in enabled:
+ if config.name == client_name:
+ return config
+ raise ValueError(f"未找到已启用下载器实例: {client_name}")
+ defaults = [config for config in enabled if config.default]
+ if len(defaults) == 1:
+ return defaults[0]
+ if len(enabled) == 1:
+ return enabled[0]
+ raise ValueError("存在多个下载器实例,请显式提供 --client")
+
+
+def _build_client(config: Any) -> Any:
+ """使用本机私有配置构造具体下载器 API client。"""
+ provider = str(config.type or "").strip().lower()
+ reference = PROVIDER_CLASSES.get(provider)
+ if not reference:
+ raise ValueError(f"暂不支持下载器类型: {provider or 'unknown'}")
+ client = _import_symbol(reference)(**dict(config.config or {}))
+ if client.is_inactive():
+ client.reconnect()
+ if client.is_inactive():
+ raise RuntimeError("下载器连接不可用")
+ return client
+
+
+def _jsonable(value: Any, *, depth: int = 0) -> Any:
+ """将 provider DTO 转成有界、无私有字段的 JSON 值。"""
+ if depth > 6:
+ return str(value)
+ if value is None or isinstance(value, (str, int, float, bool)):
+ return value
+ if isinstance(value, Path):
+ return value.as_posix()
+ model_dump = getattr(value, "model_dump", None)
+ if callable(model_dump):
+ return _jsonable(model_dump(mode="json"), depth=depth + 1)
+ if isinstance(value, Mapping):
+ return {
+ str(key): _jsonable(item, depth=depth + 1)
+ for key, item in value.items()
+ if not str(key).startswith("_") and not _is_sensitive_key(str(key))
+ }
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ return [_jsonable(item, depth=depth + 1) for item in value]
+ data = getattr(value, "__dict__", None)
+ if isinstance(data, dict):
+ return _jsonable(data, depth=depth + 1)
+ try:
+ return _jsonable(dict(value), depth=depth + 1)
+ except TypeError, ValueError:
+ return str(value)
+
+
+def _is_sensitive_key(key: str) -> bool:
+ """识别 provider 结果中的凭据、会话和认证字段。"""
+ normalized = "".join(character for character in key.lower() if character.isalnum())
+ return any(
+ marker in normalized
+ for marker in (
+ "password",
+ "passwd",
+ "secret",
+ "token",
+ "apikey",
+ "cookie",
+ "authorization",
+ "sessionid",
+ )
+ )
+
+
+def _page(items: Sequence[Any], arguments: Mapping[str, Any]) -> dict[str, Any]:
+ """对列表结果执行统一偏移分页。"""
+ offset = max(0, int(arguments.get("offset", 0)))
+ limit = min(MAX_LIMIT, max(1, int(arguments.get("limit", DEFAULT_LIMIT))))
+ materialized = list(items)
+ return {
+ "total": len(materialized),
+ "offset": offset,
+ "limit": limit,
+ "items": _jsonable(materialized[offset : offset + limit]),
+ }
+
+
+def _task_ids(arguments: Mapping[str, Any]) -> str | list[str]:
+ """读取一个或多个任务 ID,并保持 provider 接受的形态。"""
+ raw = arguments.get("task_ids", arguments.get("task_id"))
+ if isinstance(raw, list):
+ values = [str(item) for item in raw if str(item).strip()]
+ if not values:
+ raise ValueError("task_ids 不能为空")
+ return values
+ if raw is None or not str(raw).strip():
+ raise ValueError("必须提供 task_id 或 task_ids")
+ return str(raw)
+
+
+def _require(arguments: Mapping[str, Any], name: str) -> Any:
+ """读取必填 action 参数。"""
+ value = arguments.get(name)
+ if value is None or value == "" or value == []:
+ raise ValueError(f"缺少必填参数: {name}")
+ return value
+
+
+def _tasks_list(client: Any, arguments: Mapping[str, Any]) -> dict[str, Any]:
+ """查询并分页返回下载任务。"""
+ tasks, error = client.get_torrents(
+ ids=arguments.get("task_ids", arguments.get("task_id")),
+ status=arguments.get("status"),
+ tags=arguments.get("tags"),
+ )
+ if error:
+ raise RuntimeError("下载器任务查询失败")
+ return _page(tasks or [], arguments)
+
+
+def _tags_get(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
+ """按 provider 读取任务标签。"""
+ task_id = str(_require(arguments, "task_id"))
+ getter = getattr(client, "get_torrent_tags", None)
+ if callable(getter):
+ return getter(task_id)
+ tasks, error = client.get_torrents(ids=task_id)
+ if error or not tasks:
+ raise RuntimeError("任务标签查询失败")
+ task = _jsonable(tasks[0])
+ if provider == "qbittorrent":
+ tags = task.get("tags") if isinstance(task, dict) else None
+ return [item.strip() for item in str(tags or "").split(",") if item.strip()]
+ return []
+
+
+def _tags_set(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
+ """按 provider 设置任务标签。"""
+ ids = _task_ids(arguments)
+ tags = [str(item) for item in _require(arguments, "tags")]
+ if provider == "qbittorrent":
+ return client.set_torrents_tag(ids=ids, tags=tags)
+ if provider == "transmission":
+ return client.set_torrent_tag(ids=ids, tags=tags)
+ return client.set_torrents_tag(ids=ids, tags=tags)
+
+
+def _reannounce(client: Any, provider: str, arguments: Mapping[str, Any]) -> bool:
+ """调用 provider SDK 的固定重新汇报动作。"""
+ ids = _task_ids(arguments)
+ if provider == "qbittorrent":
+ client.qbc.torrents_reannounce(torrent_hashes=ids)
+ else:
+ client.trc.reannounce_torrent(ids=ids)
+ return True
+
+
+def _queue_move(client: Any, provider: str, arguments: Mapping[str, Any]) -> bool:
+ """按固定枚举调整任务队列位置。"""
+ ids = _task_ids(arguments)
+ position = str(_require(arguments, "position")).lower()
+ if position not in {"top", "up", "down", "bottom"}:
+ raise ValueError("position 仅支持 top、up、down、bottom")
+ if provider == "qbittorrent":
+ method_name = {
+ "top": "torrents_top_priority",
+ "up": "torrents_increase_priority",
+ "down": "torrents_decrease_priority",
+ "bottom": "torrents_bottom_priority",
+ }[position]
+ method = getattr(client.qbc, method_name)
+ method(torrent_hashes=ids)
+ else:
+ method = getattr(client.trc, f"queue_{position}")
+ method(ids=ids)
+ return True
+
+
+def _set_file_selection(client: Any, provider: str, arguments: Mapping[str, Any]) -> bool:
+ """按统一 wanted/unwanted 合同设置任务内文件选择。"""
+ task_id = str(_require(arguments, "task_id"))
+ wanted = [int(item) for item in arguments.get("wanted_file_ids", [])]
+ unwanted = [int(item) for item in arguments.get("unwanted_file_ids", [])]
+ if not wanted and not unwanted:
+ raise ValueError("wanted_file_ids 与 unwanted_file_ids 至少提供一项")
+ if set(wanted) & set(unwanted):
+ raise ValueError("同一文件不能同时设为 wanted 和 unwanted")
+ if provider == "transmission":
+ wanted_ok = not wanted or client.set_files(task_id, wanted)
+ unwanted_ok = not unwanted or client.set_unwanted_files(task_id, unwanted)
+ return bool(wanted_ok and unwanted_ok)
+ wanted_ok = not wanted or client.set_files(
+ torrent_hash=task_id,
+ file_ids=wanted,
+ priority=1,
+ )
+ unwanted_ok = not unwanted or client.set_files(
+ torrent_hash=task_id,
+ file_ids=unwanted,
+ priority=0,
+ )
+ return bool(wanted_ok and unwanted_ok)
+
+
+def _add_direct(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
+ """将显式内容直接提交到下载器,不接收 Cookie 或连接参数。"""
+ content = _require(arguments, "content")
+ torrent_file = bool(arguments.get("torrent_file"))
+ if torrent_file:
+ content = Path(str(content)).expanduser().resolve().read_bytes()
+ tags = arguments.get("tags")
+ common = {
+ "content": content,
+ "is_paused": bool(arguments.get("paused", False)),
+ "download_dir": arguments.get("download_dir"),
+ }
+ if provider == "qbittorrent":
+ return client.add_torrent(
+ **common,
+ tag=tags,
+ category=arguments.get("category"),
+ )
+ if provider == "transmission":
+ return client.add_torrent(**common, labels=tags)
+ return client.add_torrent(**common, tags=tags)
+
+
+def _dispatch(client: Any, provider: str, action: str, arguments: Mapping[str, Any]) -> Any:
+ """执行 action 注册表中允许的下载器调用。"""
+ if action == "tasks.list":
+ return _tasks_list(client, arguments)
+ if action == "tasks.files":
+ return _page(client.get_files(str(_require(arguments, "task_id"))) or [], arguments)
+ if action == "tasks.files.selection.set":
+ return _set_file_selection(client, provider, arguments)
+ if action == "tasks.trackers":
+ return client.get_trackers(str(_require(arguments, "task_id")))
+ if action == "tasks.tags.get":
+ return _tags_get(client, provider, arguments)
+ if action == "tasks.tags.set":
+ return _tags_set(client, provider, arguments)
+ if action == "tasks.peers":
+ return client.qbc.sync_torrent_peers(str(_require(arguments, "task_id")))
+ if action == "tasks.start":
+ return client.start_torrents(_task_ids(arguments))
+ if action == "tasks.stop":
+ return client.stop_torrents(_task_ids(arguments))
+ if action == "tasks.delete":
+ return client.delete_torrents(bool(arguments.get("delete_files", False)), _task_ids(arguments))
+ if action == "tasks.recheck":
+ return client.recheck_torrents(_task_ids(arguments))
+ if action == "tasks.reannounce":
+ return _reannounce(client, provider, arguments)
+ if action == "tasks.queue.move":
+ return _queue_move(client, provider, arguments)
+ if action == "tasks.force_start.set":
+ client.qbc.torrents_set_force_start(
+ enable=bool(_require(arguments, "enabled")),
+ torrent_hashes=_task_ids(arguments),
+ )
+ return True
+ if action == "tasks.properties.set":
+ properties = {
+ "hash_string": str(_require(arguments, "task_id")),
+ "upload_limit": arguments.get("upload_limit"),
+ "download_limit": arguments.get("download_limit"),
+ }
+ if provider != "rtorrent":
+ properties.update(
+ {
+ "ratio_limit": arguments.get("ratio_limit"),
+ "seeding_time_limit": arguments.get("seeding_time_limit"),
+ }
+ )
+ return client.change_torrent(**properties)
+ if action == "tasks.location.set":
+ return client.set_torrent_location(
+ str(_require(arguments, "task_id")),
+ str(_require(arguments, "location")),
+ )
+ if action == "tasks.category.set":
+ return client.set_torrent_category(
+ str(_require(arguments, "task_id")),
+ str(_require(arguments, "category")),
+ )
+ if action == "tasks.trackers.update":
+ return client.update_tracker(
+ str(_require(arguments, "task_id")),
+ list(_require(arguments, "trackers")),
+ )
+ if action == "tasks.add.direct":
+ return _add_direct(client, provider, arguments)
+ if action == "session.stats":
+ return client.transfer_info()
+ if action == "session.speed_limits.get":
+ limits = client.get_speed_limit()
+ return {"download_limit": limits[0], "upload_limit": limits[1]} if limits else None
+ if action == "session.speed_limits.set":
+ return client.set_speed_limit(
+ download_limit=arguments.get("download_limit"),
+ upload_limit=arguments.get("upload_limit"),
+ )
+ if action == "session.details":
+ return client.get_session()
+ if action == "session.content_layout":
+ return client.get_content_layout()
+ raise ValueError(f"未知 downloader action: {action}")
+
+
+def list_instances() -> dict[str, Any]:
+ """列出下载器实例的非敏感投影。"""
+ instances = [
+ {
+ "name": config.name,
+ "provider": config.type,
+ "enabled": bool(config.enabled),
+ "default": bool(config.default),
+ "path_mapping_count": len(config.path_mapping or []),
+ }
+ for config in _load_configs()
+ ]
+ return {"success": True, "instances": instances}
+
+
+def list_capabilities(client_name: Optional[str]) -> dict[str, Any]:
+ """返回全部或指定实例支持的 action 清单。"""
+ provider = None
+ if client_name:
+ provider = str(_select_config(client_name).type or "").lower()
+ actions = [spec.to_dict(name) for name, spec in ACTIONS.items() if provider is None or provider in spec.providers]
+ return {
+ "success": True,
+ "client": client_name,
+ "provider": provider,
+ "actions": actions,
+ }
+
+
+def call_action(client_name: Optional[str], action: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
+ """校验 action/provider 合同并执行一次受控调用。"""
+ spec = ACTIONS.get(action)
+ if spec is None:
+ raise ValueError(f"未知 downloader action: {action}")
+ config = _select_config(client_name)
+ provider = str(config.type or "").lower()
+ if provider not in spec.providers:
+ raise ValueError(f"{provider} 不支持 action: {action}")
+ for name in spec.required:
+ if name == "task_ids":
+ _task_ids(arguments)
+ else:
+ _require(arguments, name)
+ result = _dispatch(_build_client(config), provider, action, arguments)
+ if spec.effect != "safe_read" and result is False:
+ raise RuntimeError("下载器 action 返回失败")
+ return {
+ "success": True,
+ "client": config.name,
+ "provider": provider,
+ "action": action,
+ "effect": spec.effect,
+ "data": _jsonable(result),
+ }
+
+
+def _parse_arguments(raw: str) -> dict[str, Any]:
+ """解析并限制调用参数为单个 JSON 对象。"""
+ value = json.loads(raw or "{}")
+ if not isinstance(value, dict):
+ raise ValueError("--arguments 必须是 JSON 对象")
+ return value
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ """构建固定命令和参数解析器。"""
+ parser = argparse.ArgumentParser(description="MoviePilot downloader API helper")
+ subparsers = parser.add_subparsers(dest="command")
+ subparsers.add_parser("instances", help="list configured instances")
+ capabilities = subparsers.add_parser("capabilities", help="list allowed actions")
+ capabilities.add_argument("--client")
+ call = subparsers.add_parser("call", help="call one allowed action")
+ call.add_argument("--client")
+ call.add_argument("--action", required=True, choices=sorted(ACTIONS))
+ call.add_argument("--arguments", default="{}")
+ return parser
+
+
+def main() -> int:
+ """执行 CLI 并以稳定 JSON envelope 返回结果。"""
+ parser = _build_parser()
+ args = parser.parse_args()
+ try:
+ if args.command == "instances":
+ payload = list_instances()
+ elif args.command == "capabilities":
+ payload = list_capabilities(args.client)
+ elif args.command == "call":
+ payload = call_action(args.client, args.action, _parse_arguments(args.arguments))
+ else:
+ parser.print_help()
+ return 0
+ except Exception as error: # noqa: BLE001
+ payload = {
+ "success": False,
+ "error_type": type(error).__name__,
+ "message": str(error) if isinstance(error, ValueError) else "下载器调用失败",
+ }
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ return 1
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/feedback-issue/SKILL.md b/skills/feedback-issue/SKILL.md
index f3974d88b..cb94fffe1 100644
--- a/skills/feedback-issue/SKILL.md
+++ b/skills/feedback-issue/SKILL.md
@@ -1,6 +1,6 @@
---
name: feedback-issue
-version: 8
+version: 9
description: >-
Use this skill ONLY when the user EXPLICITLY requests filing an
upstream issue for MoviePilot core, frontend, or an installed plugin,
@@ -11,7 +11,7 @@ description: >-
A bare problem report is not enough: diagnose locally first. This
skill uses its own scripts under `scripts/`; it does not add or call
dedicated Agent tools for collect / prepare / submit.
-allowed-tools: read_file list_directory write_file execute_command
+allowed-tools: read_file write_file execute_command
---
# Feedback Issue (问题反馈)
diff --git a/skills/generate-identifiers/SKILL.md b/skills/generate-identifiers/SKILL.md
index b6fb047b2..08bf1e1c0 100644
--- a/skills/generate-identifiers/SKILL.md
+++ b/skills/generate-identifiers/SKILL.md
@@ -1,11 +1,12 @@
---
name: generate-identifiers
-version: 3
+version: 4
description: >-
Use this skill when a user provides a torrent name or file name and wants to fix recognition issues,
or asks to add/manage custom identifiers (自定义识别词).
This skill generates identifier rules based on the WordsMatcher preprocessing logic,
- checks for duplicates against existing rules, and saves them via MCP tools.
+ checks for duplicates against existing rules, and saves them through the
+ structured MoviePilot API gateway.
Because custom identifiers are global, generated rules must default to conservative,
sample-specific regex patterns instead of broad matches unless the user explicitly wants global cleanup.
Applicable scenarios include:
@@ -14,7 +15,8 @@ description: >-
3) The user needs episode offset rules for series with non-standard numbering;
4) The user wants to force recognition of a specific media by source-native ID;
5) The user wants TV recognition to use a specific TMDB episode group.
-allowed-tools: query_custom_identifiers update_custom_identifiers recognize_media
+allowed-tools: moviepilot_api
+allowed-api-operations: config.identifiers.get config.identifiers.update media.recognize
---
# Generate Custom Identifiers (生成自定义识别词)
@@ -23,10 +25,10 @@ This skill helps generate custom identifier rules for MoviePilot's media recogni
## Prerequisites
-You need the following tools:
-- `query_custom_identifiers` - Query all existing custom identifier rules
-- `update_custom_identifiers` - Save the updated identifier list (replaces the full list)
-- `recognize_media` - Test recognition of a torrent title or file path (optional, for verification)
+Use these `moviepilot_api` operations:
+- `config.identifiers.get` - Query all existing custom identifier rules
+- `config.identifiers.update` - Save the updated identifier list (replaces the full list)
+- `media.recognize` - Test recognition of a torrent title or file path (optional)
## Supported Rule Formats
@@ -150,10 +152,10 @@ Write the rule using the appropriate format. Ensure:
### Step 3: Query Existing Identifiers
-Use the `query_custom_identifiers` tool to get all current rules:
+Use `moviepilot_api` with `operation_id=config.identifiers.get` to get all current rules:
```
-query_custom_identifiers()
+{"operation_id": "config.identifiers.get"}
```
### Step 4: Check for Duplicates
@@ -165,22 +167,24 @@ Compare each new rule against the existing identifiers:
### Step 5: Save the Updated Identifiers
-Merge new non-duplicate rules into the existing list, then use `update_custom_identifiers` to save the **complete** list:
+Merge new non-duplicate rules into the existing list, then use
+`operation_id=config.identifiers.update` to save the **complete** list:
```
-update_custom_identifiers(
- identifiers=["existing rule 1", "existing rule 2", "# new comment", "new rule"]
-)
+{
+ "operation_id": "config.identifiers.update",
+ "body": {"identifiers": ["existing rule 1", "existing rule 2", "# new comment", "new rule"]}
+}
```
**CRITICAL**: Always include ALL existing rules in the list. This tool replaces the entire list.
### Step 6: Verify (Optional)
-If the user wants to verify the rule works, use `recognize_media` to test:
+If the user wants to verify the rule works, use `media.recognize` to test:
```
-recognize_media(title="the torrent title to test")
+{"operation_id": "media.recognize", "query": {"title": "the torrent title to test"}}
```
### Step 7: Report
diff --git a/skills/mediaserver-operation/SKILL.md b/skills/mediaserver-operation/SKILL.md
new file mode 100644
index 000000000..e9754b099
--- /dev/null
+++ b/skills/mediaserver-operation/SKILL.md
@@ -0,0 +1,84 @@
+---
+name: mediaserver-operation
+version: 1
+description: >-
+ Use this skill when the user asks to inspect, diagnose, or directly operate a
+ configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, or Navidrome
+ server. It discovers provider capabilities on demand and supports libraries,
+ native movie/music search, episode coverage, recent media, resume state,
+ playback sessions, statistics, scans, metadata refreshes, and provider play
+ URLs without adding permanent tools.
+allowed-tools: execute_command
+---
+
+# Media Server Operation
+
+Use `scripts/mp-mediaserver.py`. The helper reads MoviePilot's local server
+configuration and credentials itself. Never request, print, or pass a host,
+username, password, API key, token, Cookie, or arbitrary URL.
+
+## Boundary
+
+- Keep `library.exists` in `moviepilot-api` for canonical duplicate checks. It
+ aggregates configured servers and applies MoviePilot media identity and music
+ matching rules.
+- Use this Skill for server-native exploration, diagnostics, playback state,
+ library browsing, scans, and metadata refreshes.
+- A provider result is not automatically a MoviePilot transfer, subscription,
+ or history fact. Use the appropriate MoviePilot API for those workflows.
+
+## Discover First
+
+```bash
+python skills/mediaserver-operation/scripts/mp-mediaserver.py instances
+python skills/mediaserver-operation/scripts/mp-mediaserver.py capabilities
+python skills/mediaserver-operation/scripts/mp-mediaserver.py capabilities --server "living-room"
+```
+
+`capabilities` returns namespaced actions, argument requirements, providers, and
+side-effect levels. Call it before using an unfamiliar server or advanced action.
+
+## Call Shape
+
+```bash
+python skills/mediaserver-operation/scripts/mp-mediaserver.py call \
+ --server "living-room" \
+ --action activity.latest \
+ --arguments '{"limit":20}'
+```
+
+The `--arguments` value must be one JSON object. List reads default to 50 items
+and cap at 200.
+
+## Actions
+
+- Read: `server.statistics`, `server.users.count`,
+ `server.user.library_folders`, `libraries.list`, `items.list`, `items.count`,
+ `items.detail`, `items.movies.search`, `items.music.search`,
+ `items.season_episodes`, `activity.latest`, `activity.resume`,
+ `activity.backdrops`, `playback.sessions`, and `playback.url`.
+- External side effects: `library.scan` and `metadata.refresh`.
+
+Use the exact `server` and item/library IDs returned by earlier calls. Do not
+invent IDs or reuse IDs across different server instances. `items.list` expects
+`parent` for all video providers; Navidrome uses its single music library and
+does not require a parent. `metadata.refresh` accepts an `items` array matching
+MoviePilot's refresh item contract (`title`, `year`, `type`, `category`,
+`target_path`).
+
+Use `items.movies.search` for provider-native movie lookup,
+`items.music.search` for a title/artist/album lookup, and
+`items.season_episodes` when a direct server series ID or exact title is known.
+These results describe one server only; use `library.exists` when the task needs
+MoviePilot's canonical cross-server duplicate decision.
+
+## Safety And Verification
+
+- Before `library.scan`, confirm the exact server and scan scope/mode.
+- Before `metadata.refresh`, confirm the exact item list because providers may
+ perform a broad library refresh when a precise item cannot be mapped.
+- After a scan or refresh, verify with `activity.latest`, `items.detail`, or the
+ smallest relevant library query.
+- If a provider does not advertise an action, report it as unsupported. Never
+ fall back to raw HTTP, arbitrary SDK methods, or credentials copied from
+ MoviePilot settings.
diff --git a/skills/mediaserver-operation/scripts/mp-mediaserver.py b/skills/mediaserver-operation/scripts/mp-mediaserver.py
new file mode 100644
index 000000000..650225f41
--- /dev/null
+++ b/skills/mediaserver-operation/scripts/mp-mediaserver.py
@@ -0,0 +1,461 @@
+#!/usr/bin/env python3
+"""通过 MoviePilot 本机配置调用媒体服务器自身 API 的受控命令行工具。"""
+
+from __future__ import annotations
+
+import argparse
+import importlib
+import inspect
+import json
+import sys
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Optional
+
+SCRIPT_PATH = Path(__file__).resolve()
+PROJECT_ROOT = SCRIPT_PATH.parents[3]
+DEFAULT_LIMIT = 50
+MAX_LIMIT = 200
+ALL_PROVIDERS = (
+ "emby",
+ "jellyfin",
+ "plex",
+ "zspace",
+ "ugreen",
+ "trimemedia",
+ "navidrome",
+)
+PROVIDER_CLASSES = {
+ "emby": "app.modules.emby.emby:Emby",
+ "jellyfin": "app.modules.jellyfin.jellyfin:Jellyfin",
+ "plex": "app.modules.plex.plex:Plex",
+ "zspace": "app.modules.zspace.zspace:ZSpace",
+ "ugreen": "app.modules.ugreen.ugreen:Ugreen",
+ "trimemedia": "app.modules.trimemedia.trimemedia:TrimeMedia",
+ "navidrome": "app.modules.navidrome.navidrome:Navidrome",
+}
+
+
+@dataclass(frozen=True, slots=True)
+class ActionSpec:
+ """描述一个允许调用的媒体服务器 action。"""
+
+ description: str
+ effect: str
+ providers: tuple[str, ...] = ALL_PROVIDERS
+ required: tuple[str, ...] = ()
+
+ def to_dict(self, name: str) -> dict[str, Any]:
+ """返回不包含实现对象的公开能力描述。"""
+ return {
+ "action": name,
+ "description": self.description,
+ "effect": self.effect,
+ "providers": list(self.providers),
+ "required_arguments": list(self.required),
+ }
+
+
+ACTIONS: dict[str, ActionSpec] = {
+ "server.statistics": ActionSpec("Read media counts and provider statistics.", "safe_read"),
+ "server.users.count": ActionSpec(
+ "Read provider user count.",
+ "safe_read",
+ ("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome"),
+ ),
+ "server.user.library_folders": ActionSpec(
+ "Read the current user's visible library folders.",
+ "safe_read",
+ ("emby", "jellyfin", "zspace"),
+ ),
+ "libraries.list": ActionSpec("List visible provider libraries.", "safe_read"),
+ "items.list": ActionSpec("Page items below one library or parent.", "safe_read"),
+ "items.count": ActionSpec("Count items below one library or parent.", "safe_read"),
+ "items.detail": ActionSpec("Read one provider item by native ID.", "safe_read", required=("item_id",)),
+ "items.movies.search": ActionSpec(
+ "Search provider-native movie items by title and optional year.",
+ "safe_read",
+ ("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
+ ("title",),
+ ),
+ "items.music.search": ActionSpec(
+ "Search provider-native music by title, artist, or album.",
+ "safe_read",
+ ("emby", "jellyfin", "plex", "zspace", "ugreen", "navidrome"),
+ ),
+ "items.season_episodes": ActionSpec(
+ "Read native episode coverage for one series and optional season.",
+ "safe_read",
+ ("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
+ ),
+ "activity.latest": ActionSpec("Read recently added provider items.", "safe_read"),
+ "activity.resume": ActionSpec("Read in-progress/resumable provider items.", "safe_read"),
+ "activity.backdrops": ActionSpec(
+ "Read recent provider backdrop images.",
+ "safe_read",
+ ("ugreen", "trimemedia"),
+ ),
+ "playback.sessions": ActionSpec("Read active playback sessions.", "safe_read", ("emby", "jellyfin", "plex")),
+ "playback.url": ActionSpec("Build the provider play URL for one item.", "safe_read", required=("item_id",)),
+ "library.scan": ActionSpec("Trigger a provider library scan.", "external_side_effect"),
+ "metadata.refresh": ActionSpec(
+ "Refresh provider metadata for mapped items.",
+ "external_side_effect",
+ ("emby", "plex", "zspace", "ugreen", "trimemedia"),
+ ("items",),
+ ),
+}
+
+
+def _ensure_project_import() -> None:
+ """确保脚本从任意工作目录都可导入 MoviePilot。"""
+ project_path = str(PROJECT_ROOT)
+ if project_path not in sys.path:
+ sys.path.insert(0, project_path)
+
+
+def _load_configs() -> list[Any]:
+ """读取并校验本机媒体服务器配置。"""
+ _ensure_project_import()
+ from app.runtime.extensions.service import ServiceConfigHelper
+
+ return ServiceConfigHelper.get_mediaserver_configs()
+
+
+def _import_symbol(reference: str) -> type[Any]:
+ """按审核过的模块引用惰性导入 provider client。"""
+ module_name, symbol_name = reference.split(":", 1)
+ return getattr(importlib.import_module(module_name), symbol_name)
+
+
+def _select_config(server_name: Optional[str]) -> Any:
+ """按实例名选择启用配置,省略名称时只接受唯一实例。"""
+ enabled = [config for config in _load_configs() if config.enabled]
+ if server_name:
+ for config in enabled:
+ if config.name == server_name:
+ return config
+ raise ValueError(f"未找到已启用媒体服务器实例: {server_name}")
+ if len(enabled) == 1:
+ return enabled[0]
+ raise ValueError("存在多个媒体服务器实例,请显式提供 --server")
+
+
+def _build_client(config: Any) -> Any:
+ """使用本机私有配置构造具体媒体服务器 API client。"""
+ provider = str(config.type or "").strip().lower()
+ reference = PROVIDER_CLASSES.get(provider)
+ if not reference:
+ raise ValueError(f"暂不支持媒体服务器类型: {provider or 'unknown'}")
+ client = _import_symbol(reference)(
+ **dict(config.config or {}),
+ sync_libraries=list(config.sync_libraries or []),
+ )
+ if client.is_inactive():
+ client.reconnect()
+ if client.is_inactive():
+ raise RuntimeError("媒体服务器连接不可用")
+ return client
+
+
+def _jsonable(value: Any, *, depth: int = 0) -> Any:
+ """将 provider DTO 转成有界、无私有字段的 JSON 值。"""
+ if depth > 6:
+ return str(value)
+ if value is None or isinstance(value, (str, int, float, bool)):
+ return value
+ if isinstance(value, Path):
+ return value.as_posix()
+ model_dump = getattr(value, "model_dump", None)
+ if callable(model_dump):
+ return _jsonable(model_dump(mode="json"), depth=depth + 1)
+ if isinstance(value, Mapping):
+ return {
+ str(key): _jsonable(item, depth=depth + 1)
+ for key, item in value.items()
+ if not str(key).startswith("_") and not _is_sensitive_key(str(key))
+ }
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ return [_jsonable(item, depth=depth + 1) for item in value]
+ data = getattr(value, "__dict__", None)
+ if isinstance(data, dict):
+ return _jsonable(data, depth=depth + 1)
+ try:
+ return _jsonable(list(value), depth=depth + 1)
+ except TypeError:
+ return str(value)
+
+
+def _is_sensitive_key(key: str) -> bool:
+ """识别 provider 结果中的凭据、会话和认证字段。"""
+ normalized = "".join(character for character in key.lower() if character.isalnum())
+ return any(
+ marker in normalized
+ for marker in (
+ "password",
+ "passwd",
+ "secret",
+ "token",
+ "apikey",
+ "cookie",
+ "authorization",
+ "sessionid",
+ )
+ )
+
+
+def _require(arguments: Mapping[str, Any], name: str) -> Any:
+ """读取必填 action 参数。"""
+ value = arguments.get(name)
+ if value is None or value == "" or value == []:
+ raise ValueError(f"缺少必填参数: {name}")
+ return value
+
+
+def _limit(arguments: Mapping[str, Any]) -> int:
+ """读取安全的分页条数。"""
+ return min(MAX_LIMIT, max(1, int(arguments.get("limit", DEFAULT_LIMIT))))
+
+
+def _call_supported(method: Any, **kwargs: Any) -> Any:
+ """仅向 provider 公共方法传递其显式支持的关键字参数。"""
+ parameters = inspect.signature(method).parameters
+ supported = {key: value for key, value in kwargs.items() if key in parameters}
+ return method(**supported)
+
+
+def _items_list(client: Any, provider: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
+ """按 provider 签名列出媒体条目。"""
+ start = max(0, int(arguments.get("offset", 0)))
+ limit = _limit(arguments)
+ if provider == "navidrome":
+ items = client.get_items(start_index=start, limit=limit)
+ else:
+ parent = _require(arguments, "parent")
+ items = client.get_items(parent=parent, start_index=start, limit=limit)
+ materialized = list(items or [])
+ return {"offset": start, "limit": limit, "items": _jsonable(materialized)}
+
+
+def _items_count(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
+ """按 provider 签名统计媒体条目。"""
+ if provider == "navidrome":
+ return client.get_items_count(str(arguments.get("parent") or "music"))
+ return client.get_items_count(_require(arguments, "parent"))
+
+
+def _activity(client: Any, action: str, arguments: Mapping[str, Any]) -> Any:
+ """读取最近入库或继续播放条目。"""
+ method = client.get_latest if action == "activity.latest" else client.get_resume
+ limit = _limit(arguments)
+ return _call_supported(
+ method,
+ num=limit,
+ count=limit,
+ username=arguments.get("username"),
+ )
+
+
+def _search_music(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
+ """按 provider 公共接口查询原生音乐条目。"""
+ title = arguments.get("title")
+ artist = arguments.get("artist")
+ album = arguments.get("album")
+ if not any((title, artist, album)):
+ raise ValueError("title、artist、album 至少提供一项")
+ method = client.search_music if provider == "navidrome" else client.get_music
+ return method(title=title, artist=artist, album=album)
+
+
+def _season_episodes(client: Any, arguments: Mapping[str, Any]) -> Any:
+ """查询一个 provider 原生剧集的已入库集数。"""
+ if not arguments.get("item_id") and not arguments.get("title"):
+ raise ValueError("item_id 与 title 至少提供一项")
+ return _call_supported(
+ client.get_tv_episodes,
+ item_id=arguments.get("item_id"),
+ title=arguments.get("title"),
+ year=arguments.get("year"),
+ season=arguments.get("season"),
+ )
+
+
+def _playback_sessions(client: Any, provider: str) -> Any:
+ """通过固定 provider 端点读取活动播放会话。"""
+ if provider == "plex":
+ plex = client.get_plex()
+ return plex.sessions() if plex else []
+ if provider == "emby":
+ from app.adapters.network.http import RequestUtils
+
+ response = RequestUtils().get_res(
+ f"{client._host}emby/Sessions",
+ params={"api_key": client._apikey},
+ )
+ else:
+ response = client._request().get_res(f"{client._host}Sessions")
+ return response.json() if response else []
+
+
+def _refresh_metadata(client: Any, arguments: Mapping[str, Any]) -> Any:
+ """把结构化刷新条目转换为宿主 DTO 后调用 provider。"""
+ _ensure_project_import()
+ from app.schemas.mediaserver import RefreshMediaItem
+
+ items = [RefreshMediaItem.model_validate(item) for item in _require(arguments, "items")]
+ return client.refresh_library_by_items(items)
+
+
+def _dispatch(client: Any, provider: str, action: str, arguments: Mapping[str, Any]) -> Any:
+ """执行 action 注册表中允许的媒体服务器调用。"""
+ if action == "server.statistics":
+ return client.get_medias_count()
+ if action == "server.users.count":
+ return client.get_user_count()
+ if action == "server.user.library_folders":
+ return client.get_user_library_folders()
+ if action == "libraries.list":
+ return _call_supported(
+ client.get_librarys,
+ hidden=bool(arguments.get("hidden", False)),
+ username=arguments.get("username"),
+ )
+ if action == "items.list":
+ return _items_list(client, provider, arguments)
+ if action == "items.count":
+ return _items_count(client, provider, arguments)
+ if action == "items.detail":
+ return client.get_iteminfo(str(_require(arguments, "item_id")))
+ if action == "items.movies.search":
+ return client.get_movies(
+ title=str(_require(arguments, "title")),
+ year=arguments.get("year"),
+ )
+ if action == "items.music.search":
+ return _search_music(client, provider, arguments)
+ if action == "items.season_episodes":
+ return _season_episodes(client, arguments)
+ if action in {"activity.latest", "activity.resume"}:
+ return _activity(client, action, arguments)
+ if action == "activity.backdrops":
+ return client.get_latest_backdrops(
+ num=_limit(arguments),
+ remote=bool(arguments.get("remote", False)),
+ )
+ if action == "playback.sessions":
+ return _playback_sessions(client, provider)
+ if action == "playback.url":
+ return client.get_play_url(str(_require(arguments, "item_id")))
+ if action == "library.scan":
+ return _call_supported(
+ client.refresh_root_library,
+ scan_mode=arguments.get("scan_mode"),
+ )
+ if action == "metadata.refresh":
+ return _refresh_metadata(client, arguments)
+ raise ValueError(f"未知 media server action: {action}")
+
+
+def list_instances() -> dict[str, Any]:
+ """列出媒体服务器实例的非敏感投影。"""
+ instances = [
+ {
+ "name": config.name,
+ "provider": config.type,
+ "enabled": bool(config.enabled),
+ "sync_library_count": len(config.sync_libraries or []),
+ }
+ for config in _load_configs()
+ ]
+ return {"success": True, "instances": instances}
+
+
+def list_capabilities(server_name: Optional[str]) -> dict[str, Any]:
+ """返回全部或指定实例支持的 action 清单。"""
+ provider = None
+ if server_name:
+ provider = str(_select_config(server_name).type or "").lower()
+ actions = [spec.to_dict(name) for name, spec in ACTIONS.items() if provider is None or provider in spec.providers]
+ return {
+ "success": True,
+ "server": server_name,
+ "provider": provider,
+ "actions": actions,
+ }
+
+
+def call_action(server_name: Optional[str], action: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
+ """校验 action/provider 合同并执行一次受控调用。"""
+ spec = ACTIONS.get(action)
+ if spec is None:
+ raise ValueError(f"未知 media server action: {action}")
+ config = _select_config(server_name)
+ provider = str(config.type or "").lower()
+ if provider not in spec.providers:
+ raise ValueError(f"{provider} 不支持 action: {action}")
+ for name in spec.required:
+ _require(arguments, name)
+ result = _dispatch(_build_client(config), provider, action, arguments)
+ if spec.effect != "safe_read" and result is False:
+ raise RuntimeError("媒体服务器 action 返回失败")
+ return {
+ "success": True,
+ "server": config.name,
+ "provider": provider,
+ "action": action,
+ "effect": spec.effect,
+ "data": _jsonable(result),
+ }
+
+
+def _parse_arguments(raw: str) -> dict[str, Any]:
+ """解析并限制调用参数为单个 JSON 对象。"""
+ value = json.loads(raw or "{}")
+ if not isinstance(value, dict):
+ raise ValueError("--arguments 必须是 JSON 对象")
+ return value
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ """构建固定命令和参数解析器。"""
+ parser = argparse.ArgumentParser(description="MoviePilot media server API helper")
+ subparsers = parser.add_subparsers(dest="command")
+ subparsers.add_parser("instances", help="list configured instances")
+ capabilities = subparsers.add_parser("capabilities", help="list allowed actions")
+ capabilities.add_argument("--server")
+ call = subparsers.add_parser("call", help="call one allowed action")
+ call.add_argument("--server")
+ call.add_argument("--action", required=True, choices=sorted(ACTIONS))
+ call.add_argument("--arguments", default="{}")
+ return parser
+
+
+def main() -> int:
+ """执行 CLI 并以稳定 JSON envelope 返回结果。"""
+ parser = _build_parser()
+ args = parser.parse_args()
+ try:
+ if args.command == "instances":
+ payload = list_instances()
+ elif args.command == "capabilities":
+ payload = list_capabilities(args.server)
+ elif args.command == "call":
+ payload = call_action(args.server, args.action, _parse_arguments(args.arguments))
+ else:
+ parser.print_help()
+ return 0
+ except Exception as error: # noqa: BLE001
+ payload = {
+ "success": False,
+ "error_type": type(error).__name__,
+ "message": str(error) if isinstance(error, ValueError) else "媒体服务器调用失败",
+ }
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ return 1
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md
index 54b7f7327..1d8ee277d 100644
--- a/skills/moviepilot-api/SKILL.md
+++ b/skills/moviepilot-api/SKILL.md
@@ -1,738 +1,170 @@
---
name: moviepilot-api
-version: 14
+version: 16
description: >-
- Use this skill when you need to call MoviePilot REST API endpoints directly
- with the bundled Python client. Covers MoviePilot HTTP endpoints across media
- search, downloads, subscriptions, library management, site management, system
- administration, plugins, workflows, and more. Prefer `moviepilot-cli` for
- normal local MCP tool workflows; use this skill when the user explicitly asks
- for HTTP API access, when an endpoint is not exposed as an MCP tool, or when
- running in an environment where direct REST calls are the appropriate bridge.
+ Use this skill for MoviePilot product operations such as media search, torrent
+ search, downloads, subscriptions, library checks, sites, storage, workflows,
+ schedulers, plugins, filter rules, and system settings. It authorizes the
+ structured moviepilot_api gateway only; it does not authorize arbitrary HTTP,
+ legacy Agent tools, MCP compatibility commands, authentication headers, or API
+ tokens.
+allowed-tools: moviepilot_api
+allowed-api-operations: >-
+ media.search media.person.search media.person.credits media.recognize media.scrape
+ media.episode_schedule media.detail subscription.add subscription.update
+ subscription.search subscription.list subscription.shares subscription.popular
+ subscription.history subscription.delete download.add download.history.delete
+ transfer.history.delete site.list site.update site.userdata site.test site.cookie.update
+ recommendation.list library.exists
+ storage.settings storage.list transfer.history transfer.file scheduler.list scheduler.run
+ workflow.list workflow.run plugin.installed plugin.market plugin.capabilities
+ plugin.config.get plugin.config.update plugin.reload plugin.install plugin.uninstall
+ slash.list config.identifiers.get config.identifiers.update search.torrents search.results
+ filter.builtin filter.custom filter.groups filter.custom.add
+ filter.custom.update filter.custom.delete filter.group.add filter.group.update
+ filter.group.delete plugin.data config.system.get config.system.update slash.run
---
-# MoviePilot REST API
+# MoviePilot API
-> All script paths are relative to this skill file.
+Use `moviepilot_api` for normal MoviePilot business operations. The tool accepts
+only `operation_id`, `path_params`, `query`, and `body`. The host chooses the
+fixed HTTP method and path, creates the current user's authentication token,
+applies authorization and confirmation policy, and returns the API response.
-Use `scripts/mp-api.py` to call any MoviePilot REST API endpoint directly.
+Never provide a URL, method, authentication header, API key, or access token.
+Never fall back to a retired tool name or `moviepilot tool` MCP command. If an
+operation is not listed in this skill, do not simulate it through arbitrary HTTP;
+use a more specific skill or explain that the structured operation is unavailable.
+Use `downloader-operation` for downloader instances, task inspection and native
+task control. Use `mediaserver-operation` for libraries, items, playback sessions,
+scans, refreshes and other native media-server capabilities.
-Generic media requests use one stable identity contract: `media_source` is a
-`MediaSource` enum value and `media_id` is that source's native ID. Supply the
-pair together and keep it unchanged across detail, search, subscription,
-download, transfer, scraping, and library checks. Source-specific IDs exposed
-by `MediaInfo` are mapping metadata, not alternate generic request parameters.
-Native IDs remain valid on explicitly source-owned endpoints under `/tmdb`,
-`/douban`, `/bangumi`, and `/anilist`.
+## Calling Contract
-## Scope And Boundaries
+Call the gateway with this shape:
-This skill is the REST API bridge. It is implemented as a Python script and is
-useful when the agent needs endpoint-level coverage beyond the local
-`moviepilot tool` MCP CLI.
-
-Choose other skills first when they match more precisely:
-
-| Request | Preferred skill |
-|---|---|
-| Normal local MoviePilot product operation exposed as an MCP tool | `moviepilot-cli` |
-| Direct SQL query or database update | `database-operation` |
-| Restart, version check, or upgrade | `moviepilot-update` |
-| Slash commands or plugin/system command dispatch | `command-dispatch` |
-| Browser-only state, site login pages, screenshots, cookies | `browser-use` |
-
-Do not use this skill just because MoviePilot is mentioned. Use it when the
-task specifically needs a REST endpoint, token-query endpoint, or API behavior
-that the CLI/MCP tools do not expose.
-
-## Setup
-
-When the script runs inside the MoviePilot project, it imports `app.runtime.config.settings` and reads `settings.HOST`, `settings.PORT`, and `settings.API_TOKEN` directly. Do not ask the user for `API_TOKEN`, and do not copy API keys into the prompt.
-
-Configuration priority:
-
-1. CLI flags: `--host`, `--apikey`
-2. Environment variables: `MP_HOST`, `MP_API_KEY`
-3. Local MoviePilot settings
-4. Legacy config file: `~/.config/moviepilot_api/config`
-
-Use `configure` only as a legacy fallback outside the MoviePilot project, and avoid it in normal agent workflows because it persists a long-lived API key to disk.
-
-## How to Call APIs
-
-### General syntax
-
-```
-python scripts/mp-api.py [key=value ...] [--json '']
+```json
+{
+ "operation_id": "media.search",
+ "path_params": {},
+ "query": {"title": "流浪地球", "type": "media"},
+ "body": {}
+}
```
-### Authentication
-
-- By default, the script auto-loads the local key and sends it via the `X-API-KEY` header.
-- For endpoints suffixed with `2` (e.g. `/api/v1/dashboard/statistic2`), use `--token-param` to send the key as `?token=`.
-- Both methods validate against the same `API_TOKEN` value.
-- Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available.
-
-### API versions and response envelopes
-
-- `/api/v1` is the only MoviePilot application REST API version; the former
- `/api/v2` wrapping layer is no longer available.
-- Every ordinary JSON endpoint returns exactly
- `{"success":,"message":,"data":}`. Only the
- `data` schema varies between endpoints, and the concrete envelope is visible
- in `/docs` and `/api/v1/openapi.json`.
-- HTTP errors keep their status code and use `success=false`; validation errors
- include their structured details in `data`.
-- Send `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` or `Accept-Language` when the
- response message must match a specific language. The backend returns the
- translated text directly in `message` and falls back to the original text
- when no translation exists.
-- SSE, files, images, HTML, empty responses, OAuth2 login, and OpenAI,
- Anthropic, or MCP JSON-RPC protocol endpoints keep their protocol-native
- response body and explicit OpenAPI declaration.
-
-### Examples
-
-```bash
-# GET with query params
-python scripts/mp-api.py GET /api/v1/media/search title="Avatar" type="media"
-
-# POST with JSON body
-python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_in":{"title":"Avatar.2009","enclosure":"abc1234:1"},"media_source":"themoviedb","media_id":"19995"}'
-
-# DELETE
-python scripts/mp-api.py DELETE /api/v1/subscribe/123
-
-# Endpoints that require ?token= auth
-python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
-
-# Uniform v1 JSON response envelope
-python scripts/mp-api.py GET /api/v1/dashboard/cpu
-```
-
-## Complete API Reference
-
-All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `{param}`.
-
----
-
-### Media Search (13 endpoints)
-
-When recognition omits `media_source`, MoviePilot uses TMDB exclusively for video and MusicBrainz exclusively for music. A miss does not trigger another metadata source. Providing `media_source`, or the complete `media_source` + `media_id` pair, keeps recognition strict to that manually selected source.
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/media/search` | Search by title. Params: `title` (required), `type=media|music|collection|person`, `page`, `count`, optional repeated `media_source`; each type accepts only its supported `MediaSource` values. Comma-separated input is legacy compatibility only |
-| GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `media_source`; media file paths also use parent-directory metadata such as title and year |
-| GET | `/api/v1/media/recognize2` | Recognize media from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `media_source`; media file paths also use parent-directory metadata |
-| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `media_source` |
-| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `media_source` |
-| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: paired `media_source` + `media_id`, `type_name` (`电影`/`电视剧`/`音乐`), `music_type` |
-| GET | `/api/v1/media/category/config` | Get category strategy config |
-| POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig |
-| GET | `/api/v1/media/category` | Get auto-categorization config |
-| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons. TMDB-only endpoint |
-| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups. TMDB-only endpoint, so the native ID parameter is intentional |
-| GET | `/api/v1/media/seasons` | Get media season info. Use `media_source` + `media_id`, or title discovery with `title` and optional `year`; optional `season` narrows the result |
-| GET | `/api/v1/media/{media_id}` | Get media detail by native ID. Required params: `media_source`, `type_name` (`电影`/`电视剧`) |
-
-### TMDB (8 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/tmdb/seasons/{tmdbid}` | All seasons for a TMDB title |
-| GET | `/api/v1/tmdb/similar/{tmdbid}/{type_name}` | Similar movies/TV shows |
-| GET | `/api/v1/tmdb/recommend/{tmdbid}/{type_name}` | Recommended movies/TV shows |
-| GET | `/api/v1/tmdb/collection/{collection_id}` | Collection details. Params: `page`, `count` |
-| GET | `/api/v1/tmdb/credits/{tmdbid}/{type_name}` | Cast and crew. Params: `page` |
-| GET | `/api/v1/tmdb/person/{person_id}` | Person details |
-| GET | `/api/v1/tmdb/person/credits/{person_id}` | Person's filmography. Params: `page` |
-| GET | `/api/v1/tmdb/{tmdbid}/{season}` | All episodes of a season. Params: `episode_group` |
-
-### Douban (5 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/douban/{doubanid}` | Douban media detail |
-| GET | `/api/v1/douban/person/{person_id}` | Person detail |
-| GET | `/api/v1/douban/person/credits/{person_id}` | Person filmography. Params: `page` |
-| GET | `/api/v1/douban/credits/{doubanid}/{type_name}` | Cast info (type_name: movie/tv) |
-| GET | `/api/v1/douban/recommend/{doubanid}/{type_name}` | Recommendations |
-
-### Bangumi (5 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/bangumi/{bangumiid}` | Bangumi detail |
-| GET | `/api/v1/bangumi/credits/{bangumiid}` | Cast. Params: `page`, `count` |
-| GET | `/api/v1/bangumi/recommend/{bangumiid}` | Recommendations. Params: `page`, `count` |
-| GET | `/api/v1/bangumi/person/{person_id}` | Person detail |
-| GET | `/api/v1/bangumi/person/credits/{person_id}` | Person filmography. Params: `page`, `count` |
-
-### AniList (8 endpoints)
-
-AniList endpoints prefer the `anilist-chinese` proxy and fall back to official AniList GraphQL plus the project's daily translation dataset when the public proxy is unavailable. Media titles prefer the provided Chinese title and fall back to the native-language title.
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/anilist/trending` | TRENDING NOW. Params: `page`, `count` |
-| GET | `/api/v1/anilist/popular-this-season` | POPULAR THIS SEASON. Params: `page`, `count` |
-| GET | `/api/v1/anilist/discover` | Explore anime. Params: `search`, `genre`, `format`, `season`, `season_year`, `status`, `country`, `sort`, `page`, `count` |
-| GET | `/api/v1/anilist/{anilist_id}` | AniList media detail |
-| GET | `/api/v1/anilist/credits/{anilist_id}` | Japanese voice cast. Params: `page`, `count` |
-| GET | `/api/v1/anilist/recommend/{anilist_id}` | Recommendations. Params: `page`, `count` |
-| GET | `/api/v1/anilist/person/{person_id}` | Staff detail |
-| GET | `/api/v1/anilist/person/credits/{person_id}` | Staff anime credits. Params: `page`, `count` |
-
-### Music (6 entity endpoints plus unified search)
-
-Music uses the independent `MusicMeta` / `MusicInfo` contract and a
-source-native MusicBrainz identity. `music_type=recording` is one track,
-`album` is a multi-track collection, and `artist` is browse-only. MoviePilot
-searches, recognizes, subscribes to, downloads, organizes, scrapes, and checks
-music on configured music-capable media servers; it does not manage playlists.
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/media/search` | Search tracks, albums, or artists with `type=music` or a music `media_source`. Params: `title`, `type`, `count`, repeated enum `media_source` |
-| POST | `/api/v1/music/recognize` | Resolve music metadata. Body: `media_source`, `media_id` |
-| GET | `/api/v1/music/explore` | Explore by `media_source`: MusicBrainz supports `mode=chart|fresh`; Douban Music always uses official tag categories with `tags` and `douban_sort=U|S|R|O`. Other params: `entity`, `range_name`, `sort_by`, `sort`, `days`, `past`, `future`, `min_listen_count`, `with_cover`, `page`, `count` |
-| GET | `/api/v1/music/album/{album_id}` | Album detail with tracks and releases. Params: `media_source` |
-| GET | `/api/v1/music/album/{album_id}/related` | Related albums for the selected source. Params: `media_source`, `count` |
-| GET | `/api/v1/music/artist/{artist_id}` | Browse artist detail. Params: `media_source` |
-| GET | `/api/v1/music/artist/{artist_id}/albums` | Browse artist albums/EPs/singles. Params: `media_source`, `page`, `count`, `album_type` |
-| GET | `/api/v1/music/artist/{artist_id}/related` | Browse related artists. Params: `media_source`, `count` |
-
-Music acquisition rules:
-
-- Reuse `media_source`, `media_id`, and `music_type` from search/detail results. Never substitute a same-name entity.
-- Subscribe/download one recording as one track. Subscribe/download one album as a complete multi-track pack.
-- Album torrent validation compares supported audio files with `total_tracks`; incomplete resources do not complete the subscription.
-- Artist IDs are never subscription, torrent, download, transfer, or library-existence targets.
-- `/api/v1/media/scrape/{storage}` writes configured music tags/covers and resolves lyrics from existing sidecars, embedded tags, plugins, LRCLIB, optional authorized Musixmatch, and TheAudioDB plain-text fallback. The default upgrade policy keeps `.lyricsfile.yaml` plus compatible `.lrc` output and never replaces higher-quality synchronized lyrics with plain text. Album lyrics requests have a batch deadline and provider cooldowns; external metadata, cover, exploration, statistics, and lyrics requests use bounded caches in their owning modules/helpers.
-
-### Search / Torrents / Subtitles (11 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/search/media/{media_id}` | Search torrents by native ID. Required param: `media_source`; other params: `mtype`, `area`, `season`, `sites`, `music_type` |
-| GET | `/api/v1/search/media/{media_id}/stream` | Stream torrent search by native ID with SSE. Required param: `media_source`; other params match the non-streaming endpoint |
-| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` |
-| GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` |
-| GET | `/api/v1/search/subtitle/title` | Fuzzy search site subtitles by keyword. Params: `keyword`, `page`, `sites` |
-| GET | `/api/v1/search/subtitle/title/stream` | Stream fuzzy site subtitle search with SSE. Params: `keyword`, `page`, `sites` |
-| GET | `/api/v1/search/subtitle/media/{media_id}` | Exact subtitle search by native ID. Required param: `media_source`; other params: `mtype`, `season`, `episode`, `sites` |
-| GET | `/api/v1/search/subtitle/media/{media_id}/stream` | Stream exact subtitle search by native ID with SSE. Required param: `media_source`; other params match the non-streaming endpoint |
-| GET | `/api/v1/search/last` | Get latest search results |
-| GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` |
-| POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` |
-
-Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business events; use it only to keep the connection alive. Final `replace` payloads above 48 items are batched: the first event uses `type=replace`, later events use `type=append`, and every batch includes `replace_batch=true`, zero-based `batch_index`, `batch_count`, and final `total_items`. Collect all batches in order and replace the visible result atomically. After a `replace`, the final `done` event omits duplicate `items`.
-
-### Download (8 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name); linked history adds media type and source `site_name` |
-| POST | `/api/v1/download/` | Add download (with media info). Body: JSON |
-| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional paired `media_source` + `media_id`, `music_type`, `downloader`, `save_path`; an unrecognized video or music resource returns `data.requires_confirmation=true`, and the same request may be retried with `allow_unrecognized=true` after explicit user confirmation |
-| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, required `media_source` + `media_id`, optional `save_path` |
-| GET | `/api/v1/download/start/{hashString}` | Resume download task |
-| GET | `/api/v1/download/stop/{hashString}` | Pause download task |
-| GET | `/api/v1/download/clients` | List available download clients |
-| DELETE | `/api/v1/download/{hashString}` | Delete download task. Params: `name` |
-
-### Subscribe (28 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/subscribe/` | List all subscriptions |
-| POST | `/api/v1/subscribe/` | Add subscription. An explicit identity is always `media_source` + `media_id`; music also requires `type=音乐` and `music_type=recording|album` |
-| PUT | `/api/v1/subscribe/` | Update subscription. Body: Subscribe JSON |
-| GET | `/api/v1/subscribe/list` | List subscriptions (API_TOKEN auth, use `--token-param`) |
-| GET | `/api/v1/subscribe/{subscribe_id}` | Subscription detail |
-| DELETE | `/api/v1/subscribe/{subscribe_id}` | Delete subscription |
-| PUT | `/api/v1/subscribe/status/{subid}` | Update subscription status. Params: `state` (required) |
-| GET | `/api/v1/subscribe/media/{media_id}` | Query subscription by native ID. Required param: `media_source`; optional params: `season`, `title`, `music_type` |
-| DELETE | `/api/v1/subscribe/media/{media_id}` | Delete subscription by native ID. Required param: `media_source`; optional params: `season`, `music_type` |
-| GET | `/api/v1/subscribe/refresh` | Refresh all subscriptions |
-| GET | `/api/v1/subscribe/reset/{subid}` | Reset subscription |
-| GET | `/api/v1/subscribe/check` | Refresh subscription TMDB info |
-| GET | `/api/v1/subscribe/search` | Search all subscriptions |
-| GET | `/api/v1/subscribe/search/{subscribe_id}` | Search specific subscription |
-| POST | `/api/v1/subscribe/seerr` | Overseerr/Jellyseerr notification subscription |
-| GET | `/api/v1/subscribe/history/{mtype}` | Subscription history. Params: `page`, `count` |
-| DELETE | `/api/v1/subscribe/history/{history_id}` | Delete subscription history |
-| GET | `/api/v1/subscribe/popular` | Popular subscriptions. Params: `stype` (required), `page`, `count`, `min_sub`, `genre_id`, `min_rating`, `max_rating`, `sort_type` |
-| GET | `/api/v1/subscribe/user/{username}` | User's subscriptions |
-| GET | `/api/v1/subscribe/files/{subscribe_id}` | Subscription related files |
-| POST | `/api/v1/subscribe/share` | Share subscription. Body: SubscribeShare JSON |
-| DELETE | `/api/v1/subscribe/share/{share_id}` | Delete shared subscription |
-| POST | `/api/v1/subscribe/fork` | Fork shared subscription. Body: SubscribeShare JSON |
-| GET | `/api/v1/subscribe/follow` | List followed share users |
-| POST | `/api/v1/subscribe/follow` | Follow a share user. Params: `share_uid` |
-| DELETE | `/api/v1/subscribe/follow` | Unfollow a share user. Params: `share_uid` |
-| GET | `/api/v1/subscribe/shares` | List shared subscriptions. Params: `name`, `page`, `count`, `genre_id`, `min_rating`, `max_rating`, `sort_type` |
-| GET | `/api/v1/subscribe/share/statistics` | Share statistics |
-
-### Site (26 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/site/` | List all sites |
-| GET | `/api/v1/site/media/{media_type}` | List configured active sites compatible with `movie`, `tv`, or `music` searches |
-| POST | `/api/v1/site/` | Add site. Body: Site JSON |
-| PUT | `/api/v1/site/` | Update site. Body: Site JSON |
-| GET | `/api/v1/site/{site_id}` | Site detail by ID |
-| DELETE | `/api/v1/site/{site_id}` | Delete site |
-| GET | `/api/v1/site/domain/{site_url}` | Site detail by domain |
-| GET | `/api/v1/site/cookiecloud` | Sync CookieCloud |
-| GET | `/api/v1/site/reset` | Reset sites |
-| POST | `/api/v1/site/priorities` | Batch update site priorities. Body: array |
-| POST | `/api/v1/site/cookie/{site_id}` | Update site cookie & UA. Body: `SiteCookieUpdate` JSON |
-| GET | `/api/v1/site/cookie/{site_id}` | Legacy update site cookie & UA. Params: `username`, `password`, `code` |
-| POST | `/api/v1/site/userdata/{site_id}` | Refresh site user data |
-| GET | `/api/v1/site/userdata/{site_id}` | Get site user data. Params: `workdate` |
-| GET | `/api/v1/site/userdata/latest` | All sites latest user data |
-| GET | `/api/v1/site/test/{site_id}` | Test site connection |
-| GET | `/api/v1/site/icon/{site_id}` | Site icon |
-| GET | `/api/v1/site/category/{site_id}` | Site categories |
-| GET | `/api/v1/site/resource/{site_id}` | Site resources. Params: `keyword`, `cat`, `page` |
-| GET | `/api/v1/site/statistic/{site_url}` | Specific site statistics |
-| GET | `/api/v1/site/statistic` | All site statistics |
-| GET | `/api/v1/site/rss` | RSS subscription sites |
-| GET | `/api/v1/site/auth` | Check authenticated sites |
-| POST | `/api/v1/site/auth` | Authenticate a site. Body: SiteAuth |
-| GET | `/api/v1/site/mapping` | Site domain-to-name mapping |
-| GET | `/api/v1/site/supporting` | Supported site list |
-
-### History (5 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/history/download` | Download history, newest first. Params: `page`, `count`. `poster` is the poster image; legacy `image` is the backdrop image. |
-| DELETE | `/api/v1/history/download` | Delete download history. Body: DownloadHistory JSON |
-| GET | `/api/v1/history/transfer` | Transfer history, including `src_storage` and `dest_storage` for path labels. Params: `title`, `page`, `count`, `status` |
-| DELETE | `/api/v1/history/transfer` | Delete transfer history. Params: `deletesrc`, `deletedest`. Body: TransferHistory |
-| GET | `/api/v1/history/empty/transfer` | Clear all transfer history |
-
-### Media Server (8 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/mediaserver/play/{itemid}` | Play media online |
-| GET | `/api/v1/mediaserver/exists` | Check if media exists in the local library database. A completed miss is `success=true` with an empty `data.item`. Params: `media_source` + `media_id`, or `title` discovery; optional `year`, `mtype`, `season` |
-| POST | `/api/v1/mediaserver/exists_remote` | Check existing episodes (remote). Body: MediaInfo JSON |
-| POST | `/api/v1/mediaserver/notexists` | Check missing episodes (remote). Body: MediaInfo JSON |
-| GET | `/api/v1/mediaserver/latest` | Latest library items. Params: `server` (required), `count` |
-| GET | `/api/v1/mediaserver/playing` | Currently playing. Params: `server` (required), `count` |
-| GET | `/api/v1/mediaserver/library` | Library list. Params: `server` (required), `hidden` |
-| GET | `/api/v1/mediaserver/clients` | Available media servers |
-
-### Notification (1 endpoint)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| POST | `/api/v1/notification/manage` | Unified notification-channel management. Body: ManageRequest JSON `{target, action, params}`; `target` is the channel name, `action` is one of `status`, `refresh_qrcode`, `logout`, `test_connection`, `migrate_cache`, `params` carries channel-specific form fields passed through to the channel module |
-
-### Storage / Files (7 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| POST | `/api/v1/storage/manage` | Unified storage management. Body: ManageRequest JSON `{target, action, params}`; `target` is the storage type, `action` is one of `save_config` (config in `params.conf`), `reset_config`, `generate_qrcode`, `generate_auth_url`, `check_login` (`params.ck`/`params.t`), `usage`, `support_transtype` |
-| POST | `/api/v1/storage/list` | List directory contents. Params: `sort`. Body: FileItem JSON |
-| POST | `/api/v1/storage/mkdir` | Create directory. Params: `name` (required). Body: FileItem |
-| POST | `/api/v1/storage/delete` | Delete file or directory. Body: FileItem JSON |
-| POST | `/api/v1/storage/download` | Download file. Body: FileItem JSON |
-| POST | `/api/v1/storage/image` | Preview image. Body: FileItem JSON |
-| POST | `/api/v1/storage/rename` | Rename file/dir. Params: `new_name` (required), `recursive`. Body: FileItem |
-
-### Transfer (7 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/transfer/name` | Preview transfer name. Params: `path` (required), `filetype` (required) |
-| GET | `/api/v1/transfer/queue` | Transfer queue |
-| DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON |
-| POST | `/api/v1/transfer/manual/target-path` | Match the manual transfer target from source path and directory configuration. Body: ManualTransferItem JSON; this endpoint does not recognize media |
-| POST | `/api/v1/transfer/manual/history` | Query successful transfer-history summary for selected files or directories. Body: ManualTransferItem JSON |
-| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source; music directories default to `music_type=album` and files to `music_type=recording` when omitted; matching failed history is cleared automatically, while `reorganize=true` removes matched successful history and old non-move targets before retrying |
-| GET | `/api/v1/transfer/now` | Run immediate transfer |
-
-### Dashboard (19 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/dashboard/statistic` | Media statistics. Params: `name` |
-| GET | `/api/v1/dashboard/statistic2` | Media statistics (API_TOKEN, use `--token-param`) |
-| GET | `/api/v1/dashboard/storage` | Local storage space |
-| GET | `/api/v1/dashboard/storage2` | Local storage space (API_TOKEN) |
-| GET | `/api/v1/dashboard/processes` | Process info |
-| GET | `/api/v1/dashboard/system` | Host name, operating system, MoviePilot runtime, and backend version |
-| GET | `/api/v1/dashboard/downloader` | Downloader info. Params: `name` |
-| GET | `/api/v1/dashboard/downloader2` | Downloader info (API_TOKEN) |
-| GET | `/api/v1/dashboard/schedule` | Scheduled services |
-| GET | `/api/v1/dashboard/schedule2` | Scheduled services (API_TOKEN) |
-| GET | `/api/v1/dashboard/schedule/{job_id}/progress` | Scheduled service real-time progress |
-| GET | `/api/v1/dashboard/schedule2/{job_id}/progress` | Scheduled service real-time progress (API_TOKEN) |
-| GET | `/api/v1/dashboard/transfer` | Transfer statistics. Params: `days` |
-| GET | `/api/v1/dashboard/cpu` | CPU usage |
-| GET | `/api/v1/dashboard/cpu2` | CPU usage (API_TOKEN) |
-| GET | `/api/v1/dashboard/memory` | Memory usage |
-| GET | `/api/v1/dashboard/memory2` | Memory usage (API_TOKEN) |
-| GET | `/api/v1/dashboard/network` | Network traffic |
-| GET | `/api/v1/dashboard/network2` | Network traffic (API_TOKEN) |
-
-### Plugin (25 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/plugin/` | List plugins. Params: `state` (installed/market/all), `force` |
-| GET | `/api/v1/plugin/installed` | List installed plugins |
-| GET | `/api/v1/plugin/statistic` | Plugin install statistics |
-| GET | `/api/v1/plugin/rating` | Batch plugin ratings. Params: comma-separated `plugin_ids` |
-| GET | `/api/v1/plugin/rating/{plugin_id}` | Get average rating, rating count, and this installation's rating |
-| POST | `/api/v1/plugin/rating/{plugin_id}` | Rate an installed plugin. Body: `{"rating": 4.5}`; range 0.1-5.0 |
-| GET | `/api/v1/plugin/install/{plugin_id}` | Install plugin. Params: `repo_url`, `force` |
-| GET | `/api/v1/plugin/reload/{plugin_id}` | Reload plugin |
-| GET | `/api/v1/plugin/reset/{plugin_id}` | Reset plugin config & data |
-| GET | `/api/v1/plugin/{plugin_id}` | Get plugin config |
-| PUT | `/api/v1/plugin/{plugin_id}` | Update plugin config. Body: JSON object |
-| DELETE | `/api/v1/plugin/{plugin_id}` | Uninstall plugin |
-| POST | `/api/v1/plugin/clone/{plugin_id}` | Clone plugin. Body: JSON object |
-| GET | `/api/v1/plugin/form/{plugin_id}` | Plugin form page |
-| GET | `/api/v1/plugin/page/{plugin_id}` | Plugin data page |
-| GET | `/api/v1/plugin/remotes` | Plugin federation list. Params: `token` (required) |
-| GET | `/api/v1/plugin/dashboard/meta` | All plugin dashboard metadata |
-| GET | `/api/v1/plugin/dashboard/{plugin_id}/{key}` | Plugin dashboard by key |
-| GET | `/api/v1/plugin/dashboard/{plugin_id}` | Plugin dashboard |
-| GET | `/api/v1/plugin/file/{plugin_id}/{filepath}` | Plugin static file |
-| GET | `/api/v1/plugin/folders` | Plugin folder config |
-| POST | `/api/v1/plugin/folders` | Save plugin folder config |
-| POST | `/api/v1/plugin/folders/{folder_name}` | Create plugin folder |
-| DELETE | `/api/v1/plugin/folders/{folder_name}` | Delete plugin folder |
-| PUT | `/api/v1/plugin/folders/{folder_name}/plugins` | Update folder plugins. Body: array |
-
-### Workflow (16 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/workflow/` | List all workflows |
-| POST | `/api/v1/workflow/` | Create workflow. Body: Workflow JSON |
-| GET | `/api/v1/workflow/{workflow_id}` | Workflow detail |
-| PUT | `/api/v1/workflow/{workflow_id}` | Update workflow. Body: Workflow JSON |
-| DELETE | `/api/v1/workflow/{workflow_id}` | Delete workflow |
-| POST | `/api/v1/workflow/{workflow_id}/run` | Run workflow. Params: `from_begin` |
-| POST | `/api/v1/workflow/{workflow_id}/start` | Enable workflow |
-| POST | `/api/v1/workflow/{workflow_id}/pause` | Disable workflow |
-| POST | `/api/v1/workflow/{workflow_id}/reset` | Reset workflow |
-| GET | `/api/v1/workflow/actions` | List all actions |
-| GET | `/api/v1/workflow/plugin/actions` | Plugin actions. Params: `plugin_id` |
-| GET | `/api/v1/workflow/event_types` | List event types |
-| POST | `/api/v1/workflow/share` | Share workflow. Body: WorkflowShare JSON |
-| DELETE | `/api/v1/workflow/share/{share_id}` | Delete shared workflow |
-| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
-| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
-
-### System (28 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/system/env` | Get system configuration, including runtime versions and Rust acceleration availability/enabled status |
-| POST | `/api/v1/system/env` | Update system configuration. Body: JSON object |
-| GET | `/api/v1/system/ping` | Check service availability for authenticated users |
-| GET | `/api/v1/system/setting/public/{key}` | Get allowlisted non-sensitive system setting for authenticated users |
-| GET | `/api/v1/system/setting/{key}` | Get system setting |
-| POST | `/api/v1/system/setting/{key}` | Update system setting |
-| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | Sync plugin market repository URLs from the MoviePilot Wiki and merge with local `PLUGIN_MARKET` |
-| GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) |
-| GET | `/api/v1/system/global/user` | User-related settings |
-| GET | `/api/v1/system/restart` | Restart system |
-| POST | `/api/v1/system/upgrade` | Retained Dev update and restart. Body: `"dev"` |
-| GET | `/api/v1/system/update/status` | Get Release check, download, or install state |
-| POST | `/api/v1/system/update/check` | Check the latest stable v3 GitHub Release |
-| POST | `/api/v1/system/update/download` | Start verified Release packages downloading in the background |
-| POST | `/api/v1/system/update/install` | Confirm restart and install the prepared Release packages |
-| GET | `/api/v1/system/runscheduler` | Run scheduled service. Params: `jobid` (required) |
-| GET | `/api/v1/system/runscheduler2` | Run scheduler (API_TOKEN, use `--token-param`). Params: `jobid` |
-| GET | `/api/v1/system/modulelist` | List loaded modules |
-| GET | `/api/v1/system/moduletest/{moduleid}` | Test module availability |
-| GET | `/api/v1/system/versions` | List all GitHub releases |
-| GET | `/api/v1/system/ruletest` | Test filter rule. Params: `title` (required), `rulegroup_name` (required), `subtitle` |
-| GET | `/api/v1/system/nettest` | Test network connectivity. Params: `url` (required), `proxy` (required), `include` |
-| GET | `/api/v1/system/llm-models` | List LLM models. Params: `provider` (required), `api_key` (required), `base_url` |
-| GET | `/api/v1/system/progress/{process_type}` | Real-time progress (SSE) |
-| GET | `/api/v1/system/message` | Real-time messages (SSE). Params: `role` |
-| GET | `/api/v1/system/logging` | Real-time logs (SSE). Params: `length`, `logfile` |
-| GET | `/api/v1/system/img/{proxy}` | Image proxy. Params: `imgurl` (required), `cache`, `use_cookies` |
-| GET | `/api/v1/system/cache/image` | Cached image. Params: `url` (required) |
-
-### Discover (6 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/discover/source` | Discover data sources |
-| GET | `/api/v1/discover/bangumi` | Discover Bangumi. Params: `type`, `cat`, `sort`, `year`, `page`, `count` |
-| GET | `/api/v1/discover/douban_movies` | Discover Douban movies. Params: `sort`, `tags`, `page`, `count` |
-| GET | `/api/v1/discover/douban_tvs` | Discover Douban TV. Params: `sort`, `tags`, `page`, `count` |
-| GET | `/api/v1/discover/tmdb_movies` | Discover TMDB movies. Params: `sort_by`, `with_genres`, `with_original_language`, `page` |
-| GET | `/api/v1/discover/tmdb_tvs` | Discover TMDB TV. Params: same as movies |
-
-### Recommend (18 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/recommend/source` | Recommendation data sources |
-| GET | `/api/v1/recommend/bangumi_calendar` | Bangumi daily schedule. Params: `page`, `count` |
-| GET | `/api/v1/recommend/music_weekly` | ListenBrainz weekly site-wide music chart. Params: `page`, `count` |
-| GET | `/api/v1/recommend/music_douban` | Douban new album chart. Params: `page`, `count` |
-| GET | `/api/v1/recommend/douban_showing` | Douban now showing. Params: `page`, `count` |
-| GET | `/api/v1/recommend/douban_movies` | Douban movies. Params: `sort`, `tags`, `page`, `count` |
-| GET | `/api/v1/recommend/douban_tvs` | Douban TV. Params: `sort`, `tags`, `page`, `count` |
-| GET | `/api/v1/recommend/douban_movie_top250` | Douban Top 250 movies. Params: `page`, `count` |
-| GET | `/api/v1/recommend/douban_tv_weekly_chinese` | Douban Chinese TV weekly. Params: `page`, `count` |
-| GET | `/api/v1/recommend/douban_tv_weekly_global` | Douban Global TV weekly. Params: `page`, `count` |
-| GET | `/api/v1/recommend/douban_tv_animation` | Douban animation. Params: `page`, `count` |
-| GET | `/api/v1/recommend/douban_movie_hot` | Douban hot movies. Params: `page`, `count` |
-| GET | `/api/v1/recommend/douban_tv_hot` | Douban hot TV. Params: `page`, `count` |
-| GET | `/api/v1/recommend/tmdb_movies` | TMDB movies. Params: `sort_by`, `with_genres`, `page` |
-| GET | `/api/v1/recommend/tmdb_tvs` | TMDB TV. Params: `sort_by`, `with_genres`, `page` |
-| GET | `/api/v1/recommend/tmdb_trending` | TMDB trending. Params: `page` |
-
-### Torrent Cache (5 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/torrent/cache` | Get torrent cache |
-| DELETE | `/api/v1/torrent/cache` | Clear torrent cache |
-| DELETE | `/api/v1/torrent/cache/{domain}/{torrent_hash}` | Delete specific torrent cache |
-| POST | `/api/v1/torrent/cache/refresh` | Refresh torrent cache |
-| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Optional paired params: `media_source`, `media_id`; music may also pass `music_type` |
-
-### Recognition Cache (3 endpoints)
-
-The list endpoint returns local cache totals plus `shared_recognized` and
-`shared_recognize_enabled` for the persisted successful shared-recognition count.
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/tmdb/cache` | Get TheMovieDb recognition cache statistics |
-| DELETE | `/api/v1/tmdb/cache/{cache_key}` | Delete one URL-encoded TheMovieDb recognition cache key |
-| DELETE | `/api/v1/tmdb/cache` | Clear TheMovieDb recognition cache |
-
-### Message (8 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| POST | `/api/v1/message/` | Receive user message. Params: `token`, `source` |
-| GET | `/api/v1/message/` | Callback verification. Params: `token`, `echostr`, `msg_signature`, `timestamp`, `nonce`, `source` |
-| POST | `/api/v1/message/web` | Send web message. Params: `text` (required) |
-| GET | `/api/v1/message/web` | Get web messages. Params: `page`, `count` |
-| GET | `/api/v1/message/notification` | Get notification history. Params: `page`, `count`; server filters cleared history |
-| DELETE | `/api/v1/message/notification` | Mark notification history as cleared. Params: `scope` (`all`, `system`, `media`) |
-| POST | `/api/v1/message/webpush/subscribe` | WebPush subscribe. Body: Subscription JSON |
-| POST | `/api/v1/message/webpush/send` | Send WebPush notification. Body: SubscriptionMessage JSON |
-
-### User (10 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/user/` | List all users |
-| POST | `/api/v1/user/` | Create user. Body: UserCreate JSON |
-| PUT | `/api/v1/user/` | Update user. Body: UserUpdate JSON |
-| GET | `/api/v1/user/current` | Current logged-in user |
-| GET | `/api/v1/user/{username}` | User detail |
-| DELETE | `/api/v1/user/id/{user_id}` | Delete user by ID |
-| DELETE | `/api/v1/user/name/{user_name}` | Delete user by username |
-| POST | `/api/v1/user/avatar/{user_id}` | Upload avatar. Body: multipart/form-data; original filename is returned in `data.filename` |
-| GET | `/api/v1/user/config/{key}` | Get user config |
-| POST | `/api/v1/user/config/{key}` | Update user config |
-
-### Login (3 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) |
-| GET | `/api/v1/login/wallpaper` | Login page wallpaper; URL is returned in `data` |
-| GET | `/api/v1/login/wallpapers` | Login page wallpaper list |
-
-### MCP Tools (6 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| POST | `/api/v1/mcp` | MCP JSON-RPC 2.0 endpoint |
-| DELETE | `/api/v1/mcp` | Terminate MCP session |
-| GET | `/api/v1/mcp/tools` | List all exposed tools |
-| POST | `/api/v1/mcp/tools/call` | Call a tool. Body: `{"tool_name":"...","arguments":{...}}` |
-| GET | `/api/v1/mcp/tools/{tool_name}` | Get tool definition |
-| GET | `/api/v1/mcp/tools/{tool_name}/schema` | Get tool input schema |
-
-The exposed tool list is dynamic: it includes tools declared by enabled plugins
-and is refreshed lazily after plugin startup, shutdown, reload, or configuration
-activation. Clients that cache MCP metadata must request `tools/list` again or
-reconnect after a plugin lifecycle change.
-
-### Agent MCP Client (3 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/message/agent/mcp/servers` | List external MCP servers configured for the built-in Agent. Superuser login required |
-| POST | `/api/v1/message/agent/mcp/servers` | Save external MCP servers for the built-in Agent. Body: `{"servers":[...]}` |
-| POST | `/api/v1/message/agent/mcp/servers/test` | Test one external MCP server and return discovered tools. Body: `{"server":{...}}` |
-
-### Webhook (2 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/webhook/` | Webhook message (GET). Params: `token`, `source` |
-| POST | `/api/v1/webhook/` | Webhook message (POST). Params: `token`, `source` |
-
-### Servarr Compatibility -- /api/v3 (16 endpoints)
-
-Radarr/Sonarr compatible API for integration with external tools.
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v3/system/status` | System status |
-| GET | `/api/v3/qualityProfile` | Quality profiles |
-| GET | `/api/v3/rootfolder` | Root folders |
-| GET | `/api/v3/tag` | Tags |
-| GET | `/api/v3/languageprofile` | Languages |
-| GET | `/api/v3/movie` | All subscribed movies |
-| POST | `/api/v3/movie` | Add movie subscription. Body: RadarrMovie JSON |
-| GET | `/api/v3/movie/lookup` | Search movie. Params: `term` (format: `tmdb:123`) |
-| GET | `/api/v3/movie/{mid}` | Movie detail |
-| DELETE | `/api/v3/movie/{mid}` | Delete movie subscription |
-| GET | `/api/v3/series` | All TV series |
-| POST | `/api/v3/series` | Add TV subscription. Body: SonarrSeries JSON |
-| PUT | `/api/v3/series` | Update TV subscription. Body: SonarrSeries JSON |
-| GET | `/api/v3/series/lookup` | Search TV. Params: `term` (format: `tvdb:123`) |
-| GET | `/api/v3/series/{tid}` | TV detail |
-| DELETE | `/api/v3/series/{tid}` | Delete TV subscription |
-
-### CookieCloud -- /cookiecloud (5 endpoints)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/cookiecloud/` | Root |
-| POST | `/cookiecloud/` | Root |
-| POST | `/cookiecloud/update` | Upload cookie data. Body: CookieData JSON |
-| GET | `/cookiecloud/get/{uuid}` | Download encrypted data |
-| POST | `/cookiecloud/get/{uuid}` | Download encrypted data (POST) |
-
----
-
-## Common Workflows
-
-### Search and download a movie
-
-```bash
-# 1. Search TMDB for the movie
-python scripts/mp-api.py GET /api/v1/media/search title="Inception" type="media"
-
-# 2. Get media detail with the exact identity returned by search
-python scripts/mp-api.py GET /api/v1/media/27205 media_source="themoviedb" type_name="电影"
-
-# 3. Search torrents
-python scripts/mp-api.py GET /api/v1/search/media/27205 media_source="themoviedb" mtype="movie"
-
-# 4. Get latest search results
-python scripts/mp-api.py GET /api/v1/search/last
-
-# 5. Add download
-python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_in":{"title":"","enclosure":""},"media_source":"themoviedb","media_id":"27205"}'
-```
-
-### Search and subscribe to one recording or complete album
-
-```bash
-# 1. Search MusicBrainz entities through the unified media search
-python scripts/mp-api.py GET /api/v1/media/search title="Artist - Title" type="music" count=20
-
-# 2a. For an album, inspect its complete track list before subscribing
-python scripts/mp-api.py GET /api/v1/music/album/ media_source="musicbrainz"
-
-# 2b. Check the exact entity subscription separately; music_type prevents recording/album ambiguity
-python scripts/mp-api.py GET /api/v1/subscribe/media/ media_source="musicbrainz" music_type="album"
-
-# 3. Add one exact album subscription. REST enum values use the localized MediaType value.
-python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Album Title","type":"音乐","music_type":"album","media_source":"musicbrainz","media_id":""}'
-
-# For one track, use that track's recording MBID and music_type=recording instead.
-```
-
-Do not create an artist subscription. Select a recording or album from the artist catalog first. For an album manual download, use one matched album resource; the download layer rejects resources whose audio-file list does not cover `total_tracks`.
-
-### Search and download subtitles
-
-```bash
-# 1. Search site subtitles by keyword
-python scripts/mp-api.py GET /api/v1/search/subtitle/title keyword="Inception" sites="1,2"
-
-# 2. Restore the last subtitle search with replayable params
-python scripts/mp-api.py GET /api/v1/search/last/context
-
-# 3. Download a subtitle result to the recognized media directory
-python scripts/mp-api.py POST /api/v1/download/subtitle --json '{"subtitle_in":{"title":"Inception.2010.1080p.chs","enclosure":"https://example.com/downloadsubs.php?torrentid=1&subid=2","site_name":"Example"},"media_source":"themoviedb","media_id":"27205"}'
-```
-
-### Add a subscription
-
-```bash
-# 1. Search for the show
-python scripts/mp-api.py GET /api/v1/media/search title="Breaking Bad" type="media"
-
-# 2. Check if already subscribed
-python scripts/mp-api.py GET /api/v1/subscribe/media/1396 media_source="themoviedb"
-
-# 3. Check if already in library
-python scripts/mp-api.py GET /api/v1/mediaserver/exists media_source="themoviedb" media_id=1396 mtype="tv"
-
-# 4. Add subscription
-python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Breaking Bad","year":"2008","type":"电视剧","media_source":"themoviedb","media_id":"1396"}'
-```
-
-### System monitoring
-
-```bash
-# CPU, memory, network
-python scripts/mp-api.py GET /api/v1/dashboard/cpu
-python scripts/mp-api.py GET /api/v1/dashboard/memory
-python scripts/mp-api.py GET /api/v1/dashboard/network
-
-# Storage
-python scripts/mp-api.py GET /api/v1/dashboard/storage
-
-# Active downloads
-python scripts/mp-api.py GET /api/v1/download/
-
-# Run a scheduled task
-python scripts/mp-api.py GET /api/v1/system/runscheduler jobid="subscribe_search_all"
-```
-
-### Site management
-
-```bash
-# List all sites
-python scripts/mp-api.py GET /api/v1/site/
-
-# Test site connectivity
-python scripts/mp-api.py GET /api/v1/site/test/1
-
-# Get site user data
-python scripts/mp-api.py GET /api/v1/site/userdata/1
-
-# Sync CookieCloud
-python scripts/mp-api.py GET /api/v1/site/cookiecloud
-```
-
-## Error Handling
-
-| Scenario | Action |
-|----------|--------|
-| HTTP 401 | API key is invalid or missing. Verify local settings with `moviepilot doctor`; only use `--apikey` as an external fallback. |
-| HTTP 403 | Insufficient permissions. The API key grants superuser access; check if the endpoint requires special auth. |
-| HTTP 404 | Endpoint or resource not found. Verify the path and path parameters. |
-| HTTP 422 | Validation error. Check required parameters and JSON body format. |
-| Connection error | Verify `--host` URL is reachable. Check if MoviePilot is running. |
-| Missing config | Run inside the MoviePilot project, or set `MP_HOST` and `MP_API_KEY` in the process environment. |
+- Put route placeholders such as `subscribe_id`, `hashString`, `plugin_id`,
+ `workflow_id`, `media_id`, `storage`, `rule_id`, and `name` in `path_params`.
+- Put GET filters and control values in `query`. The gateway also accepts GET
+ values in `body`, but use `query` consistently except for the protected secret
+ flow below.
+- Put POST, PUT, and PATCH request models in `body`.
+- Preserve the exact source-native `media_source` + `media_id` returned by a
+ search or detail response. For music, also preserve
+ `music_type=recording|album|artist`; an artist is browse-only.
+- Treat `success=false`, HTTP error data, empty results, and validation errors as
+ real outcomes. Do not claim success without checking the response.
+
+## Operation Catalog
+
+### Media and search
+
+| Operation | Parameters | Purpose |
+|---|---|---|
+| `media.search` | query: `title`, `type`, `page`, `count`, `media_source` | Search video, music, collection, or media entities |
+| `media.person.search` | query: `title`, `type=person`, paging/source | Search people |
+| `media.person.credits` | path: `source`, `person_id`; query: paging | Read TMDB or Douban credits |
+| `media.recognize` | query: `title`, optional `subtitle`, `custom_words`, `media_source` | Recognize a title or file path |
+| `media.detail` | path: `media_id`; query: `media_source`, `type_name`, music fields when applicable | Read exact media detail |
+| `media.episode_schedule` | path: `tmdbid`, `season`; query: optional episode group | Read TMDB season episodes |
+| `recommendation.list` | query: source/category/paging fields | Read the unified recommendation feed |
+| `search.torrents` | path: `media_id`; query: `media_source`, `mtype`, `season`, `sites`, `music_type` | Search site resources |
+| `search.results` | query: result filters | Read and filter the latest search context |
+| `media.scrape` | path: `storage`; query: identity/type; body: file item | Scrape metadata, artwork, and configured music lyrics |
+
+After `search.torrents`, present the returned filter choices before narrowing
+results. Reuse `search.results` instead of repeating the same search. Obtain
+explicit consent before `download.add` or another external side effect.
+
+### Subscriptions and downloads
+
+| Operation | Parameters | Purpose |
+|---|---|---|
+| `subscription.list` | query filters | List subscriptions |
+| `subscription.add` | body: subscribe model | Create a subscription |
+| `subscription.update` | body: updated subscribe model | Update a subscription |
+| `subscription.search` | path: `subscribe_id` | Trigger/search one subscription |
+| `subscription.delete` | path: `subscribe_id` | Permanently remove a subscription |
+| `subscription.history` | path: `mtype`; query paging | Read subscription history |
+| `subscription.shares` | query paging | Read shared subscriptions |
+| `subscription.popular` | query paging/type | Read popular subscriptions |
+| `download.add` | body: torrent input and optional media identity/client/path | Add a download |
+| `download.history.delete` | query/body accepted by endpoint | Delete download history |
+
+Before adding a download or subscription, check `library.exists` and
+`subscription.list` when duplicate risk exists. Deletions and file removal need
+explicit confirmation.
+
+### Library, storage, and transfer
+
+| Operation | Parameters | Purpose |
+|---|---|---|
+| `library.exists` | query: exact media identity and type | Check library presence |
+| `storage.settings` | none | Read configured download/library storage roots |
+| `storage.list` | body: storage/path/paging/sort fields | List a local or remote storage directory |
+| `transfer.history` | query filters and paging | Read transfer history |
+| `transfer.file` | body: manual-transfer model | Organize a file or directory |
+| `transfer.history.delete` | query/body accepted by endpoint | Submit durable retry or delete legacy history |
+
+For transfer retries, preserve durable scheduler evidence. If history deletion
+returns a durable retry decision, stop and report it; only an actually deleted
+legacy record may be followed by `transfer.file`.
+
+Downloader task state and media-server library browsing deliberately do not pass
+through this gateway. Discover the configured instance and its live capability
+set with the matching provider-operation skill before calling the fixed helper.
+
+### Sites, workflows, and schedulers
+
+| Operation | Parameters | Purpose |
+|---|---|---|
+| `site.list` | query filters | List configured sites |
+| `site.userdata` | path: `site_id` | Read site account data |
+| `site.test` | path: `site_id` | Test site connectivity/login |
+| `site.update` | body: site model | Update site configuration |
+| `site.cookie.update` | path: `site_id`; body: credentials/2FA fields | Refresh site authentication |
+| `scheduler.list` | none | List system/plugin/workflow schedules |
+| `scheduler.run` | query: `job_id` | Run one scheduler job |
+| `workflow.list` | query filters | List workflows |
+| `workflow.run` | path: `workflow_id`; body/query endpoint fields | Run one workflow |
+
+Scheduler `job_id` values are strings and are unrelated to autonomous Agent
+task IDs. Test and credential updates are external side effects and require the
+appropriate authorization/confirmation.
+
+### Plugins, rules, and configuration
+
+| Operation | Parameters | Purpose |
+|---|---|---|
+| `plugin.installed` / `plugin.market` | query filters | List installed or market plugins |
+| `plugin.capabilities` | query: optional plugin ID | Read commands, actions, services, and Agent capabilities |
+| `plugin.config.get` / `plugin.config.update` | path: `plugin_id`; update body | Read or update plugin configuration |
+| `plugin.data` | path: `plugin_id`; query: key/limit/offset | Read bounded plugin data previews |
+| `plugin.install` / `plugin.reload` / `plugin.uninstall` | path: `plugin_id` | Manage plugin lifecycle |
+| `filter.builtin` / `filter.custom` / `filter.groups` | none or query filters | Read filter definitions |
+| `filter.custom.add` / `filter.custom.update` / `filter.custom.delete` | update/delete path: `rule_id`; body for writes | Manage custom filter rules |
+| `filter.group.add` / `filter.group.update` / `filter.group.delete` | update/delete path: `name`; body for writes | Manage filter groups |
+| `config.identifiers.get` / `config.identifiers.update` | update body | Read or replace custom identifiers |
+| `config.system.get` / `config.system.update` | query/body for read; update body | Read or update system settings |
+| `slash.list` / `slash.run` | run body: `command` | Discover or dispatch system/plugin slash commands |
+
+For a raw credential explicitly requested by an administrator, call
+`config.system.get` with `body.show_secrets=true` and the narrowest
+`body.setting_key` or `body.group`. The host pauses the turn for confirmation and
+delivers the value through a protected channel. Never repeat the secret in a
+normal response.
+
+Plugin install, reload, uninstall, configuration writes, rule writes, system
+setting writes, identifier writes, scheduler/workflow runs, and slash commands
+are state changes. Inspect current state first and confirm unless the user's
+request already explicitly authorizes the exact action.
diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md
deleted file mode 100644
index 58ec3baa3..000000000
--- a/skills/moviepilot-cli/SKILL.md
+++ /dev/null
@@ -1,284 +0,0 @@
----
-name: moviepilot-cli
-version: 8
-description: >-
- Use this skill when the user asks to operate MoviePilot through the local
- `moviepilot tool` MCP CLI for normal product workflows: media search, torrent
- search, downloads, subscriptions, downloader tasks, library checks, sites,
- schedulers, workflows, and messages. Prefer dedicated skills for slash command
- dispatch, manual file organization or failed transfer retry, direct REST API
- calls, direct database SQL, browser operations, and restart/upgrade.
----
-
-# MoviePilot CLI
-
-> All script paths are relative to this skill file.
-
-Use local `moviepilot tool ...` commands to interact with MoviePilot MCP tools.
-The command reads the local MoviePilot configuration; do not ask the user for
-`API_TOKEN`, database passwords, or a backend DSN during normal local use.
-
-## Scope And Boundaries
-
-This skill is for normal MoviePilot product operations exposed as MCP tools.
-Choose other skills first when they match more precisely:
-
-| Request | Preferred skill |
-|---|---|
-| Slash commands or plugin/system command dispatch | `command-dispatch` |
-| Manual file organization | `organize-files` |
-| Retry failed transfer history records | `transfer-failed-retry` |
-| Direct REST endpoint not exposed by MCP tools | `moviepilot-api` |
-| Direct SQL query or database update | `database-operation` |
-| Restart, version check, or upgrade | `moviepilot-update` |
-| Browser-only state, site login pages, screenshots, cookies | `browser-use` |
-
-Use `moviepilot-api` only after `moviepilot tool list` and
-`moviepilot tool show ` confirm that no MCP tool covers the required
-operation. Use `database-operation` only when the task explicitly requires SQL
-inspection or mutation, or when product tools/API cannot answer the data
-question.
-
-## Discover Commands
-
-List all available commands: `moviepilot tool list`
-
-Show parameters and usage for a specific command: `moviepilot tool show `
-
-The tool list includes tools declared by enabled plugins. Re-run `tool list` and
-`tool show` after a plugin is enabled, disabled, reloaded, or reconfigured so the
-command selection uses the refreshed runtime registry.
-
-Always run `show ` before calling a command — parameter names are not inferable, do not guess.
-
-## Command Groups
-
-| Category | Commands |
-|---|---|
-| Media Search | search_media, recognize_media, query_media_detail, get_recommendations, search_person, search_person_credits |
-| Torrent | search_torrents, get_search_results |
-| Download | add_download_tasks, query_download_tasks, update_download_tasks, delete_download_tasks, query_downloaders |
-| Subscription | add_subscribe, query_subscribes, update_subscribe, delete_subscribe, search_subscribe, query_subscribe_history, query_popular_subscribes, query_subscribe_shares |
-| Library | query_library_exists, query_library_latest, transfer_file, scrape_metadata, query_transfer_history |
-| Files | list_directory, query_directory_settings |
-| Sites | query_sites, query_site_userdata, test_site, update_site, update_site_cookie |
-| System | query_schedulers, run_scheduler, create_agent_task, query_agent_tasks, update_agent_task, run_agent_task, delete_agent_task, query_workflows, run_workflow, query_rule_groups, query_episode_schedule, send_message |
-
-## Workflows
-
-### Send a Message
-
-Run `moviepilot tool show send_message` before sending. For a structured Telegram reply, prefer the optional `rich_message` argument and put the complete GitHub-style Markdown body in it. Headings, lists, tables, blockquotes, code blocks, and links are converted to Telegram Rich Message content. Do not repeat the same content in `message`, `title`, or `image_url`; use those ordinary fields when Rich Message is not needed. Other configured channels receive the Rich Markdown source as their plain-text fallback.
-
-### Search and Download
-
-#### 1. Search TMDB
-
-Search for a movie or TV show by title:
-`moviepilot tool run search_media title="..." media_type="movie"`
-
-If the user specifies a TV season, run Season Validation step first — the season number provided by the user may not match TMDB.
-
-#### 2. Search torrents
-
-Reuse the exact `media_source` and `media_id` returned by `search_media`. Do not
-replace the selected primary identity with an auxiliary TMDB, Douban, Bangumi,
-or AniList mapping ID.
-
-Omitting `sites=` uses the user's default sites. If the user specifies sites, first retrieve site IDs:
-`moviepilot tool run query_sites`
-
-Search torrents using default sites:
-`moviepilot tool run search_torrents media_source="themoviedb" media_id=791373 media_type="movie"`
-
-Search torrents using user-specified sites (pass site IDs from `query_sites`):
-`moviepilot tool run search_torrents media_source="themoviedb" media_id=791373 media_type="movie" sites='1,3'`
-
-When `search_torrents` returns:
-1. **Stop** — do not call `get_search_results` yet.
-2. Present all `filter_options` fields and every value within each field to the user verbatim.
-3. Do not pre-select, summarize, or omit any field or value.
-4. Wait for the user to select filters or confirm no filters are needed before moving to the next step.
-
-#### 3. Get filtered results (only after user has responded to filter_options)
-
-Run `moviepilot tool show get_search_results` to check available parameters. Filter logic: OR within a field, AND across fields.
-
-Filter values must come from the `filter_options` returned by `search_torrents` — do not invent, translate, normalize, or use values from any other source. Note: `filter_options` keys are camelCase (e.g., `freeState`), but `get_search_results` params are snake_case (e.g., `free_state`).
-
-Fetch results with selected filters:
-`moviepilot tool run get_search_results resolution='1080p,2160p' free_state='免费,50%'`
-
-To filter subtitle, audio, DIY, translation, or release notes that may appear outside the title, use `content_pattern`. It matches the torrent title, description, and labels while `title_pattern` continues to match the title only. Set `include_description=true` when the description is needed to explain why a result matched, and `include_labels=true` when the labels should be returned:
-`moviepilot tool run get_search_results content_pattern='特效字幕|国语|DIY' include_description=true include_labels=true`
-
-If empty, tell the user which filter to relax and ask before retrying.
-
-#### 4. Present results as a numbered list
-
-Show all results without pre-selection. Each row: index, title, size, seeders, resolution, release group, `volume_factor`, `freedate_diff`.
-
-| `volume_factor` | Meaning |
-|---|---|
-| `免费` | Free download |
-| `50%` | 50% download size |
-| `2X` | Double upload |
-| `2X免费` | Double upload + free |
-| `普通` | No discount |
-
-`freedate_diff`: remaining free window (e.g., `2天3小时`).
-
-#### 5. Check before downloading
-
-After the user picks torrents: Run **Check Library and Subscriptions** step.
-
-If the media already exists in the library or is already subscribed, **stop** and report the finding to the user.
-
-#### 6. Add download
-
-Download one or more torrents (`torrent_url` comes from `get_search_results` output):
-`moviepilot tool run add_download_tasks torrent_url="abc1234:1,def5678:2"`
-
-#### Error handling
-
-| Step | Action |
-|---|---|
-| `search_media` empty | Retry with an alternative title (English/original), then ask for the title or exact `media_source` + `media_id`. |
-| `search_torrents` empty | Inform user, ask whether to retry with different sites. |
-| `get_search_results` empty | Do not silently broaden filters. Suggest which filter to relax, ask before retrying. |
-| `add_download_tasks` fails | Run `query_downloaders` + `query_download_tasks` to diagnose, then report to user. |
-
-### Add Subscription
-
-1. Run `search_media` and keep the returned `media_source` + `media_id` pair.
-2. Run **Check Library and Subscriptions** step, if media already exists or is subscribed, **stop** and report to user.
-3. If the user specifies a TV season, run Season Validation step first.
-
-Subscribe to a movie or TV show:
-`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" media_source="themoviedb" media_id=42009`
-
-Subscribe to a specific season:
-`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" media_source="themoviedb" media_id=42009 season=4`
-
-Subscribe starting from a specific episode:
-`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" media_source="themoviedb" media_id=12345 season=1 start_episode=13`
-
-Subscribe to a complete lossless album and keep upgrading its audio quality:
-`moviepilot tool run add_subscribe title="..." media_type="music" music_type="album" media_source="musicbrainz" media_id="" audio_quality="hires|lossless" audio_format="DSD|FLAC|ALAC" min_bit_depth=24 best_version=1`
-
-Audio bitrate and sample-rate values use bps and Hz. For example, pass `min_bitrate=320000` and `min_sample_rate=96000`.
-
-### Manage Downloads
-
-List download tasks and get hash for further operations:
-`moviepilot tool run query_download_tasks status=downloading`
-
-Use `status=completed` for tasks that are neither downloading nor paused in the downloader; use `status=all` to include every MoviePilot-tagged downloader task. Add `include_all_tags=true` when diagnosing tasks that do not have the MoviePilot built-in tag. Add `include_trackers=true` or query by `hash` when tracker URLs are needed.
-
-Update a download task (supports start/stop, tags, speed limits, trackers, save path, category, ratio, and seeding time where the downloader supports them):
-`moviepilot tool run update_download_tasks hash= action=stop upload_limit=512 download_limit=2048`
-
-Add trackers to a download task:
-`moviepilot tool run update_download_tasks hash= trackers='https://tracker.example/announce,udp://tracker.example:80/announce'`
-
-Delete a download task (confirm with user first — irreversible):
-`moviepilot tool run delete_download_tasks hash=`
-
-Delete a download task and also remove its files (confirm with user first — irreversible):
-`moviepilot tool run delete_download_tasks hash= delete_files=true`
-
-### Manage Subscriptions
-
-List active subscriptions:
-`moviepilot tool run query_subscribes status=R`
-
-Update subscription filters:
-`moviepilot tool run update_subscribe subscribe_id=123 resolution="1080p"`
-
-Only download full-season packs for a TV best-version subscription:
-`moviepilot tool run update_subscribe subscribe_id=123 best_version=1 best_version_full=1`
-
-Trigger a search for missing episodes (confirm with user first):
-`moviepilot tool run search_subscribe subscribe_id=123`
-
-Remove a subscription (confirm with user first):
-`moviepilot tool run delete_subscribe subscribe_id=123`
-
-### Manage Autonomous Agent Tasks
-
-Use autonomous tasks only when the user explicitly requests delayed, recurring,
-reminder, or monitoring behavior. Immediate work should run directly. Use the
-MoviePilot `TZ` setting for local times.
-
-Scheduled runs reuse the original Agent session context, but user-facing
-messages are broadcast through MoviePilot's configured notification channels
-instead of being tied to the channel that created the task. If the Agent sends
-the complete result with a message tool during execution, it does not send the
-same final reply again when the run finishes.
-
-Autonomous task tools use the integer `task_id` returned by
-`query_agent_tasks`. `query_schedulers` and `run_scheduler` are only for
-MoviePilot system, plugin, and workflow runtime services and use string
-`job_id` values; never mix these IDs or use those tools for autonomous tasks.
-
-For a relative one-time request, use `date` with `delay_minutes`; MoviePilot
-calculates and persists the exact run time:
-`moviepilot tool run create_agent_task name="检查电影资源" content="搜索电影《示例电影》是否有资源并报告,不要自动下载。" trigger_type=date delay_minutes=30`
-
-For a one-time task at a fixed time, use `date` with an ISO 8601 `trigger`:
-`moviepilot tool run create_agent_task name="今晚检查资源" content="检查目标电影是否有资源并报告。" trigger_type=date trigger="2026-07-19 20:30:00"`
-
-For recurring work, use a standard five-field cron expression. This example
-runs every day at 20:30:
-`moviepilot tool run create_agent_task name="每日资源检查" content="检查目标电影是否有资源并报告。" trigger_type=cron trigger="30 20 * * *"`
-
-List tasks and inspect `next_run_at` and the latest result:
-`moviepilot tool run query_agent_tasks`
-
-Pause or resume a task:
-`moviepilot tool run update_agent_task task_id=1 enabled=false`
-
-Queue an enabled task for immediate execution without waiting in the current
-Agent turn:
-`moviepilot tool run run_agent_task task_id=1`
-
-Delete a task only after confirming permanent removal with the user:
-`moviepilot tool run delete_agent_task task_id=1`
-
-### Check Library and Subscriptions
-
-Run before any download or subscription to avoid duplicates.
-
-Check if the media already exists in the library:
-`moviepilot tool run query_library_exists media_source="themoviedb" media_id=123456 media_type="movie"`
-
-Check if the media is already subscribed:
-`moviepilot tool run query_subscribes media_source="themoviedb" media_id=123456`
-
-### Season Validation
-
-Mandatory when user specifies a season. Productions sometimes release a show in multiple parts under one TMDB season; online communities and torrent sites may label each part as a separate "season".
-
-#### 1. Verify season exists
-
-Fetch media detail to check available seasons:
-`moviepilot tool run query_media_detail media_source="themoviedb" media_id= media_type="tv"`
-
-Compare `season_info` with the user's requested season:
-1. If the season exists in `season_info` → use that season number directly and return to the calling workflow.
-2. If the season does not exist → the user's "season" likely maps to a later episode range within an existing TMDB season. Note the latest (highest-numbered) season from `season_info`, then continue to next step.
-
-#### 2. Identify the correct episode range
-
-Fetch the episode schedule for the latest season from `season_info`. This is a
-TMDB-only tool, so its native `tmdb_id` parameter is intentional:
-`moviepilot tool run query_episode_schedule tmdb_id= season=`
-
-Use `air_date` to find a block of recently-aired episodes that likely corresponds to what the user calls the missing season. Look for a gap in `air_date` between episodes — the gap indicates a part break, and the episodes after the gap are what the user likely refers to as the next "season". For example, if TMDB Season 1 has episodes 1–24 and there is a multi-month gap between episode 12 and 13, then episodes 13–24 correspond to the user's "Season 2". If no such gap exists, tell user content is unavailable. Otherwise confirm the episode range with user.
-
-## Error handling
-
-Missing configuration or authentication failure: run `moviepilot doctor` to
-verify the local MoviePilot installation and settings. Plugin-only log findings
-remain visible but do not by themselves downgrade the overall Doctor status.
-Do not ask the user to paste the API key into the prompt for local CLI usage.
diff --git a/skills/organize-files/SKILL.md b/skills/organize-files/SKILL.md
index 60b97ecd9..173cb8e28 100644
--- a/skills/organize-files/SKILL.md
+++ b/skills/organize-files/SKILL.md
@@ -1,182 +1,59 @@
---
name: organize-files
-version: 3
+version: 5
description: >-
- Use this skill when the user asks the MoviePilot agent to identify and organize downloaded/local video or music files that automatic transfer cannot handle. Typical triggers include manually organizing a file or folder, a TV season pack, one music recording, or a complete album directory. If the user gives failed transfer history IDs, prefer transfer-failed-retry instead.
-allowed-tools: list_directory query_directory_settings query_download_tasks query_transfer_history delete_transfer_history recognize_media search_media query_media_detail query_library_exists transfer_file scrape_metadata ask_user_choice send_message
+ Use this skill when the user asks MoviePilot to identify and organize a local
+ or downloaded video/music file, season folder, recording, album directory, or
+ mixed folder that automatic transfer did not handle. If failed transfer
+ history IDs are supplied, use transfer-failed-retry instead.
+allowed-tools: moviepilot_api execute_command ask_user_choice send_message
+allowed-api-operations: storage.settings storage.list transfer.history transfer.history.delete media.recognize media.search media.detail library.exists transfer.file media.scrape
---
-# Organize Files (智能整理文件)
+# Organize Files
-Use this skill to help the user identify media files that MoviePilot could not organize automatically, then call the normal transfer pipeline through `transfer_file`. Do not rename, move, or copy files manually; let MoviePilot's directory, transfer mode, rename template, overwrite, scrape, and notification settings handle the actual organization.
-
-## MoviePilot Transfer Flow
-
-MoviePilot's normal flow is:
-
-1. `DownloadChain.download_single` adds a downloader task, records `DownloadHistory` and `DownloadFiles`, runs downloader-specific `download_added`, then sends `DownloadAdded`.
-2. `TransferChain.process` scans completed downloader tasks in monitored download directories. If a `DownloadHistory` exists for the hash, it reuses the recorded media IDs; otherwise it falls back to path recognition.
-3. Agent/manual organization calls `transfer_file`, which enters `TransferFileTool` -> `TransferChain.manual_transfer` -> `TransferChain.do_transfer`.
-4. `do_transfer` recursively collects eligible video/subtitle/audio files, ignores recycle/hidden paths and configured exclude words, and reuses download history when possible. Video uses `MetaInfoPath`; music uses audio tags plus `MetaMusic`/`MusicInfo` and keeps the selected recording or album identity.
-5. `TransferChain.__handle_transfer` chooses the target directory through `DirectoryHelper`, delegates file operations to the file manager module, and lets `TransHandler` build the final target path and name.
-6. The callback writes `TransferHistory` success/failure records, emits transfer events, sends notifications, and may trigger `transfer-failed-retry` for failed history records.
-
-Important implication: an existing `TransferHistory` for the same source path can make a later transfer skip. Delete only stale or failed history records, and only after the user has confirmed the record is safe to remove.
+Use `moviepilot_api` for every MoviePilot business operation. Retired file,
+recognition, transfer, and history tools are not available.
## Workflow
-### 1. Classify The Request
+1. Establish scope. If the user provides a path, use it. If they identify a
+ downloader task, use `downloader-operation` and its fixed
+ `scripts/mp-downloader.py` helper to discover the instance and call
+ `tasks.list`. If they only name a configured root, call `storage.settings`.
+ Use `storage.list` to inspect the selected directory. Do not process a broad
+ shared root without an explicit, bounded scope.
+2. Classify files into movie, TV, one music recording, one complete album,
+ subtitle/sidecar, or unrelated content. Do not group unrelated media merely
+ because they share a directory.
+3. Call `media.recognize` with the representative title or path. If uncertain,
+ call `media.search`; if several exact candidates remain, use
+ `ask_user_choice`. Never invent or translate an ID.
+4. Preserve the exact `media_source` + `media_id`. For TV, verify season detail
+ with `media.detail` when numbering is ambiguous. For music, a recording is one
+ track, an album is one multi-track directory, and an artist is browse-only.
+5. When duplicate risk matters, call `library.exists`. If an existing transfer
+ record affects reorganization, inspect `transfer.history`.
+6. Before a state-changing transfer, summarize the source, target identity,
+ media type, season/music entity, storage, and mode. Continue only when the
+ user's request already authorizes that exact action or after confirmation.
+7. Call `transfer.file` once per verified unit. For an album, transfer the album
+ directory once only after its supported audio-file count is consistent with
+ the selected album detail.
+8. If requested, call `media.scrape` after a successful transfer. Report actual
+ tag, cover, and lyrics counts; never assume all lyrics were found.
-- If the user provides one or more failed transfer history IDs, stop and use `transfer-failed-retry`.
-- If the user provides a path, start from that path.
-- If the user describes a download task, use `query_download_tasks` to find its save path or hash, then continue with the path.
-- If the user only says "整理一下下载目录", use `query_directory_settings(directory_type="download")` first, then ask which directory or subdirectory to process if more than one candidate exists.
+## Structured Calls
-### 2. Inspect Candidate Files
+- Directory listing: `storage.list` with storage/path/paging/sort in `body`.
+- Recognition: `media.recognize` with title/path in `query`.
+- Search: `media.search` with title/type/source constraints in `query`.
+- Detail: `media.detail` with `path_params.media_id` and identity/type in `query`.
+- Library check: `library.exists` with the exact identity in `query`.
+- Transfer: `transfer.file` with the manual-transfer request in `body`.
+- Scrape: `media.scrape` with `path_params.storage`, file item in `body`, and
+ exact identity/type fields in `query`.
-Use `list_directory` for any directory the user provides. Prefer `sort_by="time"` for "recent" or "刚下载的" requests.
-
-For directories with more than 20 items, ask the user to narrow the folder or choose the relevant child directory before running transfers. Avoid organizing a broad shared download root unless the user explicitly confirms the scope.
-
-Treat these as transfer candidates:
-
-- main media files and Blu-ray folders;
-- matching subtitle and external audio files in the same media folder;
-- episode packs where files share the same title/season pattern.
-- individual supported audio files and album folders containing multiple tracks.
-
-Skip obvious samples, trailers, screenshots, hidden folders, recycle folders, and files that are not media/subtitle/audio.
-
-### 3. Identify The Media
-
-For the best sample file, call:
-
-```text
-recognize_media(path="")
-```
-
-If recognition fails or looks wrong:
-
-1. Extract likely title, year, media type, season/episode range, or music artist/track/album from filenames and audio tags.
-2. For video, call `search_media(title="...", year="...", media_type="movie|tv")`. For music, call `search_media(title=" - ", media_type="music", music_type="recording|album")`.
-3. If several results are plausible, use `ask_user_choice` when available, or ask the user directly to choose the correct title and `media_source` + `media_id` pair.
-4. For TV season confusion, use `query_media_detail(media_source="themoviedb", media_id="", media_type="tv")` before deciding the season number. For an album, use `query_media_detail(media_type="music", music_type="album", media_source="musicbrainz", media_id="")` and verify `total_tracks` before treating the directory as complete.
-
-Never invent an ID. Preserve the exact source-native entity returned by search: a recording is one track, an album is a multi-track collection, and an artist is browse-only and cannot be organized.
-
-### 4. Check Existing State
-
-Before writing:
-
-- Use `query_library_exists` when a precise video or music identity is known and duplicate risk matters. For albums, an exists result is only true after complete track coverage is confirmed.
-- Use `query_transfer_history(title="", status="all")` if the file may already have a success or failure record.
-- If `transfer_file` later returns "已整理过", query transfer history, identify the matching source path, and ask before deleting the stale record.
-
-Only call `delete_transfer_history(history_id=)` for the exact stale/failed record that blocks the requested source path. Do not delete unrelated successful history.
-
-### 5. Transfer Through MoviePilot
-
-Use `transfer_file` with explicit identity whenever possible:
-
-```text
-transfer_file(
- file_path="",
- storage="local",
- media_type="movie|tv",
- media_source="",
- media_id="",
- season=
-)
-```
-
-For one recording:
-
-```text
-transfer_file(file_path="