Files
MoviePilot/app/agent/tools/impl/service.py
thsrite 4395ddecf2 feat(mediaserver): 内置媒体服务 MediaVault 更名为 Vyo
主项目更名为 Muvyo,内置媒体服务更名为 Vyo。模块目录、文件、类名、模块 ID、枚举、
名称描述、日志与文档统一改为 Vyo;配置类型值 mediavault 保持不变,
已保存的媒体服务器配置无需迁移。
2026-09-30 19:46:09 +08:00

574 lines
21 KiB
Python

"""向 HTTP/MCP 管理入口暴露自描述的服务操作工具。"""
from __future__ import annotations
import json
import runpy
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any, ClassVar, Dict, Optional, Type
from pydantic import BaseModel, ConfigDict, Field
from app.agent.shell import agent_text_subprocess_kwargs
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.settings import get_runtime_setting
class DownloaderOperationInput(BaseModel): # type: ignore[misc]
"""下载器操作工具的运行时输入模型。"""
client: Optional[str] = Field(
default=None,
description="Configured downloader instance name; omit it to use the default or only enabled instance.",
)
action: str = Field(
description="Exact downloader action. Select one action branch from the MCP input schema.",
)
arguments: Dict[str, Any] = Field(
default_factory=dict,
description="Structured arguments declared by the selected downloader action.",
)
model_config = ConfigDict(extra="forbid")
class MediaServerOperationInput(BaseModel): # type: ignore[misc]
"""媒体服务器操作工具的运行时输入模型。"""
server: Optional[str] = Field(
default=None,
description="Configured media-server instance name; omit it to use the only enabled instance.",
)
action: str = Field(
description="Exact media-server action. Select one action branch from the MCP input schema.",
)
arguments: Dict[str, Any] = Field(
default_factory=dict,
description="Structured arguments declared by the selected media-server action.",
)
model_config = ConfigDict(extra="forbid")
class DatabaseOperationInput(BaseModel): # type: ignore[misc]
"""数据库操作工具的运行时输入模型。"""
action: str = Field(
description="Exact database action. Select one action branch from the MCP input schema.",
)
arguments: Dict[str, Any] = Field(
default_factory=dict,
description="Structured arguments declared by the selected database action.",
)
model_config = ConfigDict(extra="forbid")
@lru_cache(maxsize=3)
def _load_action_contracts(script_path: str) -> dict[str, Any]:
"""从固定 Skill 脚本加载无配置副作用的 action 注册表。"""
namespace = runpy.run_path(script_path)
actions = namespace.get("ACTIONS")
if not isinstance(actions, dict):
raise RuntimeError("服务操作脚本缺少 ACTIONS 合同")
return actions
def _compact_type_schema(declared_type: str) -> dict[str, Any]:
"""把 Skill 的紧凑参数类型转换为标准 JSON Schema。"""
variants: list[dict[str, Any]] = []
for candidate in declared_type.split("|"):
if candidate.endswith("[]"):
variants.append(
{
"type": "array",
"items": _compact_type_schema(candidate[:-2]),
}
)
else:
variants.append({"type": candidate})
return variants[0] if len(variants) == 1 else {"anyOf": variants}
def _argument_schema(argument: dict[str, Any]) -> dict[str, Any]:
"""构建一个包含类型、说明、默认值和枚举的参数 schema。"""
schema = _compact_type_schema(str(argument["type"]))
name = str(argument["name"])
schema["description"] = str(argument.get("description") or "")
if "default" in argument:
schema["default"] = argument["default"]
if argument.get("enum"):
schema["enum"] = list(argument["enum"])
if name == "offset":
schema["minimum"] = 0
if name == "limit":
schema.update({"minimum": 1, "maximum": 200})
if name in {"upload_limit", "download_limit", "ratio_limit", "seeding_time_limit"}:
schema["minimum"] = 0
if name in {
"task_id",
"item_id",
"content",
"location",
"category",
}:
schema["minLength"] = 1
if name in {
"task_ids",
"wanted_file_ids",
"unwanted_file_ids",
"tags",
"trackers",
"items",
}:
schema["minItems"] = 1
return schema
def _metadata_refresh_items_schema() -> dict[str, Any]:
"""返回 metadata.refresh.items 的完整嵌套条目合同。"""
return {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Media title used for recognition."},
"year": {
"anyOf": [{"type": "string"}, {"type": "integer"}],
"description": "Release or premiere year used to disambiguate the title.",
},
"type": {
"type": "string",
"enum": ["电影", "电视剧", "音乐"],
"description": (
"Exact MoviePilot media-type literal: 电影 (movie), 电视剧 (TV), or 音乐 (music)."
),
},
"category": {"type": "string", "description": "Optional MoviePilot library category."},
"target_path": {
"type": "string",
"description": "Media file or directory path whose metadata should be refreshed.",
},
},
"additionalProperties": False,
},
}
def _add_action_argument_rules(action: str, schema: dict[str, Any]) -> None:
"""把跨字段约束补充为机器可校验的 JSON Schema。"""
task_selector_actions = {
"tasks.start",
"tasks.stop",
"tasks.delete",
"tasks.recheck",
"tasks.reannounce",
"tasks.queue.move",
"tasks.force_start.set",
"tasks.tags.set",
}
if action in task_selector_actions:
schema["oneOf"] = [
{"required": ["task_id"], "not": {"required": ["task_ids"]}},
{"required": ["task_ids"], "not": {"required": ["task_id"]}},
]
if action == "tasks.files.selection.set":
schema["anyOf"] = [
{"required": ["wanted_file_ids"]},
{"required": ["unwanted_file_ids"]},
]
if action == "tasks.properties.set":
schema["anyOf"] = [
{"required": [name]}
for name in ("upload_limit", "download_limit", "ratio_limit", "seeding_time_limit")
]
if action == "session.speed_limits.set":
schema["anyOf"] = [
{"required": ["download_limit"]},
{"required": ["upload_limit"]},
]
if action == "items.music.search":
schema["anyOf"] = [
{"required": ["title"]},
{"required": ["artist"]},
{"required": ["album"]},
]
if action == "items.season_episodes":
schema["anyOf"] = [{"required": ["item_id"]}, {"required": ["title"]}]
if action in {"query", "write"}:
schema["oneOf"] = [
{"required": ["sql"], "not": {"required": ["file"]}},
{"required": ["file"], "not": {"required": ["sql"]}},
]
def _build_arguments_schema(action: str, spec: Any) -> dict[str, Any]:
"""把一个 action 的脚本合同转换为 MCP arguments schema。"""
contract = spec.to_dict(action)
properties = {
argument["name"]: _argument_schema(argument)
for argument in contract["arguments"]
}
if action == "metadata.refresh":
properties["items"] = _metadata_refresh_items_schema()
properties["items"]["description"] = contract["arguments"][0]["description"]
schema: dict[str, Any] = {
"type": "object",
"description": " ".join(contract.get("argument_rules") or []),
"properties": properties,
"required": list(contract.get("required_arguments") or []),
"additionalProperties": False,
}
_add_action_argument_rules(action, schema)
return schema
def _build_mcp_input_schema(
*,
actions: dict[str, Any],
selector_name: Optional[str],
selector_description: Optional[str],
title: str,
) -> dict[str, Any]:
"""构建按 action 分支且可由外部 MCP Client 直接发现的输入合同。"""
action_names = sorted(actions)
branches = []
for action in action_names:
contract = actions[action].to_dict(action)
properties = {
"action": {"type": "string", "const": action},
"arguments": _build_arguments_schema(action, actions[action]),
}
if selector_name:
properties[selector_name] = {
"type": "string",
"description": selector_description or "",
}
branches.append(
{
"type": "object",
"title": action,
"description": (
f"{contract['description']} Effect: {contract['effect']}. "
f"Providers: {', '.join(contract['providers'])}."
),
"properties": properties,
"required": ["action", "arguments"],
"additionalProperties": False,
}
)
properties = {
"action": {"type": "string", "enum": action_names},
"arguments": {
"type": "object",
"description": "Arguments must match the oneOf branch selected by action.",
},
}
if selector_name:
properties[selector_name] = {
"type": "string",
"description": selector_description or "",
}
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": title,
"type": "object",
"properties": properties,
"required": ["action", "arguments"],
"oneOf": branches,
}
def _parse_script_payload(stdout: str) -> dict[str, Any]:
"""从可能带启动日志的 stdout 中提取最后一个完整 JSON 对象。"""
decoder = json.JSONDecoder()
for index in range(len(stdout) - 1, -1, -1):
if stdout[index] != "{":
continue
try:
payload, end = decoder.raw_decode(stdout[index:])
except json.JSONDecodeError:
continue
if not stdout[index + end :].strip() and isinstance(payload, dict):
return payload
raise RuntimeError("服务操作脚本未返回有效 JSON")
def _run_service_script(
*,
relative_script: str,
selector_flag: Optional[str],
selector_value: Optional[str],
action: str,
arguments: Dict[str, Any],
) -> dict[str, Any]:
"""不经 shell 调用固定 Skill 脚本,并只返回其结构化 JSON envelope。"""
root_path = Path(get_runtime_setting("ROOT_PATH"))
script_path = root_path / relative_script
command = [
sys.executable,
str(script_path),
"call",
"--action",
action,
"--arguments",
json.dumps(arguments, ensure_ascii=False, separators=(",", ":")),
]
if selector_value and selector_flag:
command.extend([selector_flag, selector_value])
completed = subprocess.run(
command,
cwd=root_path,
capture_output=True,
text=True,
check=False,
**agent_text_subprocess_kwargs(),
)
return _parse_script_payload(completed.stdout)
class _ServiceOperationTool(MoviePilotTool):
"""固定 Skill 脚本的 MCP-only 安全包装基类。"""
require_admin: bool = True
tags: list[str] = [ToolTag.Admin]
_relative_script: ClassVar[str]
_selector_name: ClassVar[Optional[str]]
_selector_flag: ClassVar[Optional[str]]
_blocking_bucket: ClassVar[str]
def get_mcp_input_schema(self) -> dict[str, Any]:
"""返回保留 action 条件分支的完整 MCP JSON Schema。"""
root_path = Path(get_runtime_setting("ROOT_PATH"))
actions = _load_action_contracts(str(root_path / self._relative_script))
selector_description = None
if self._selector_name:
selector_description = self.args_schema.model_json_schema()["properties"][self._selector_name][
"description"
]
return _build_mcp_input_schema(
actions=actions,
selector_name=self._selector_name,
selector_description=selector_description,
title=self.name,
)
async def _run_operation(
self,
*,
selector_value: Optional[str],
action: str,
arguments: Dict[str, Any],
) -> str:
"""在线程池中运行固定脚本并序列化安全结果。"""
payload = await self.run_blocking(
self._blocking_bucket,
_run_service_script,
relative_script=self._relative_script,
selector_flag=self._selector_flag,
selector_value=selector_value,
action=action,
arguments=arguments,
)
return json.dumps(payload, ensure_ascii=False, indent=2)
class DownloaderOperationTool(_ServiceOperationTool):
"""外部 HTTP/MCP 调用下载器原生能力的结构化工具。"""
name: str = "downloader_operation"
description: str = (
"Operate a configured qBittorrent, Transmission, or rTorrent instance. "
"The input schema contains one exact branch per action, including providers, "
"effects, required fields, types, defaults, enums, and cross-field constraints."
)
tags: list[str] = [ToolTag.Download]
args_schema: Type[BaseModel] = DownloaderOperationInput
_relative_script: ClassVar[str] = "skills/downloader-operation/scripts/mp-downloader.py"
_selector_name: ClassVar[Optional[str]] = "client"
_selector_flag: ClassVar[Optional[str]] = "--client"
_blocking_bucket: ClassVar[str] = "downloader"
async def run( # type: ignore[override]
self,
action: str,
arguments: Optional[Dict[str, Any]] = None,
client: Optional[str] = None,
) -> str:
"""执行一次自描述的下载器操作。"""
return await self._run_operation(
selector_value=client,
action=action,
arguments=arguments or {},
)
class MediaServerOperationTool(_ServiceOperationTool):
"""外部 HTTP/MCP 调用媒体服务器原生能力的结构化工具。"""
name: str = "mediaserver_operation"
description: str = (
"Operate a configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, "
"Navidrome, or Vyo server. The input schema contains one exact branch per action, "
"including providers, effects, required fields, types, defaults, enums, nested "
"item fields, and cross-field constraints."
)
tags: list[str] = [ToolTag.Media]
args_schema: Type[BaseModel] = MediaServerOperationInput
_relative_script: ClassVar[str] = "skills/mediaserver-operation/scripts/mp-mediaserver.py"
_selector_name: ClassVar[Optional[str]] = "server"
_selector_flag: ClassVar[Optional[str]] = "--server"
_blocking_bucket: ClassVar[str] = "mediaserver"
async def run( # type: ignore[override]
self,
action: str,
arguments: Optional[Dict[str, Any]] = None,
server: Optional[str] = None,
) -> str:
"""执行一次自描述的媒体服务器操作。"""
return await self._run_operation(
selector_value=server,
action=action,
arguments=arguments or {},
)
def _run_database_script(arguments: Dict[str, Any], *, root_path: Path) -> dict[str, Any]:
"""不经 shell 调用数据库 Skill 脚本,并返回结构化结果。"""
script_path = root_path / "skills/database-operation/scripts/mp-db.py"
action = str(arguments.get("action") or "")
action_arguments = arguments.get("arguments") or {}
if not isinstance(action_arguments, dict):
raise ValueError("数据库 action arguments 必须是对象")
contracts = _load_action_contracts(str(script_path))
contract = contracts.get(action)
if contract is None:
raise ValueError(f"未知数据库 action: {action}")
contract_data = contract.to_dict(action)
declared_arguments = {item["name"]: item for item in contract_data["arguments"]}
unknown_arguments = sorted(set(action_arguments) - set(declared_arguments))
if unknown_arguments:
raise ValueError(f"数据库 action 存在未知参数: {', '.join(unknown_arguments)}")
missing_arguments = [
name
for name in contract_data["required_arguments"]
if name not in action_arguments
]
if missing_arguments:
raise ValueError(f"数据库 action 缺少必填参数: {', '.join(missing_arguments)}")
if action in {"query", "write"}:
has_sql = bool(action_arguments.get("sql"))
has_file = bool(action_arguments.get("file"))
if has_sql == has_file:
raise ValueError("数据库 query/write 必须在 sql 与 file 中二选一")
for field_name in ("sql", "file"):
if field_name in action_arguments and (
not isinstance(action_arguments[field_name], str)
or not action_arguments[field_name].strip()
):
raise ValueError(f"数据库参数 {field_name} 必须是非空 string")
if action == "schema" and (
not isinstance(action_arguments.get("table_name"), str)
or not action_arguments["table_name"].strip()
):
raise ValueError("数据库参数 table_name 必须是非空 string")
if action == "query":
limit = action_arguments.get("limit", 100)
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 200:
raise ValueError("数据库参数 limit 必须是 1 到 200 的 integer")
write_flag = action_arguments.get("write", False)
if not isinstance(write_flag, bool):
raise ValueError("数据库参数 write 必须是 boolean")
command = [sys.executable, str(script_path), action]
if action == "schema":
command.append(str(action_arguments.get("table_name") or ""))
elif action in {"query", "write"}:
sql = action_arguments.get("sql")
sql_file = action_arguments.get("file")
if sql:
command.append(str(sql))
if sql_file:
command.extend(["--file", str(sql_file)])
if action == "query":
command.extend(["--limit", str(action_arguments.get("limit", 100))])
if action_arguments.get("write") is True:
command.append("--write")
completed = subprocess.run(
command,
cwd=root_path,
capture_output=True,
text=True,
check=False,
**agent_text_subprocess_kwargs(),
)
if completed.returncode != 0:
error_text = completed.stderr.strip() or completed.stdout.strip() or "数据库脚本执行失败"
return {"success": False, "error": "database_operation_failed", "message": error_text}
return _parse_script_payload(completed.stdout)
class DatabaseOperationTool(_ServiceOperationTool):
"""外部 HTTP/MCP 调用 MoviePilot 数据库 Skill 的结构化工具。"""
name: str = "database_operation"
description: str = (
"Inspect or explicitly modify the configured MoviePilot database. The input schema "
"contains exact branches for tables, schema, query, and write, including field types, "
"defaults, mutually exclusive SQL sources, and safety rules."
)
tags: list[str] = [ToolTag.Admin]
args_schema: Type[BaseModel] = DatabaseOperationInput
_relative_script: ClassVar[str] = "skills/database-operation/scripts/mp-db.py"
_selector_name: ClassVar[Optional[str]] = None
_selector_flag: ClassVar[Optional[str]] = None
_blocking_bucket: ClassVar[str] = "db"
def get_mcp_input_schema(self) -> dict[str, Any]:
"""返回数据库 action 的完整 MCP JSON Schema。"""
root_path = Path(get_runtime_setting("ROOT_PATH"))
actions = _load_action_contracts(str(root_path / self._relative_script))
return _build_mcp_input_schema(
actions=actions,
selector_name=None,
selector_description=None,
title=self.name,
)
async def run( # type: ignore[override]
self,
action: str,
arguments: Optional[Dict[str, Any]] = None,
) -> str:
"""
执行一次数据库 Skill 操作。
:param action: tables、schema、query 或 write
:param arguments: 当前 action 的结构化参数
:return: 结构化数据库结果
"""
payload = await self.run_blocking(
self._blocking_bucket,
_run_database_script,
root_path=Path(get_runtime_setting("ROOT_PATH")),
arguments={"action": action, "arguments": arguments or {}},
)
return json.dumps(payload, ensure_ascii=False, indent=2)
__all__ = [
"DownloaderOperationInput",
"DownloaderOperationTool",
"DatabaseOperationInput",
"DatabaseOperationTool",
"MediaServerOperationInput",
"MediaServerOperationTool",
]