feat(agent): expose self-describing service operations

This commit is contained in:
jxxghp
2026-08-31 20:45:33 +08:00
parent 9371f7d183
commit 632363730a
17 changed files with 7072 additions and 104 deletions
+136 -18
View File
@@ -1,6 +1,6 @@
---
name: downloader-operation
version: 1
version: 2
description: >-
Use this skill when the user asks to inspect, diagnose, or directly control a
configured qBittorrent, Transmission, or rTorrent instance. It exposes
@@ -29,7 +29,30 @@ username, password, API key, Cookie, or arbitrary URL.
- Paths passed to `tasks.location.set` and `tasks.add.direct` are downloader-side
paths, not MoviePilot storage paths.
## Discover First
## Instance And Provider Discovery
### Fast path: call directly
Do not routinely call `instances` or `capabilities` before an operation. This
Skill already contains the full action contract, and the helper performs
instance resolution, provider support checks, complete argument validation, and
the action in one `call` invocation.
- If the user or prior context provides the exact client name, pass it with
`--client` and call the action immediately.
- If no client name is known, omit `--client`. The helper automatically uses the
single default downloader, or the only enabled downloader.
- If multiple clients remain ambiguous, the failed call lists every valid
client name. Reuse that list for the next direct call; do not add a separate
`instances` call unless the user explicitly asks to inspect instances.
- Do not probe an action with empty or guessed arguments. Compose the complete
JSON object from the contract below before calling.
The helper rejects unknown fields and reports all detectable argument errors in
one response before connecting to the provider, so correct every reported field
together instead of retrying one field at a time.
### Optional discovery
List configured instances without secrets:
@@ -44,8 +67,19 @@ 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.
The complete action and argument contract is documented below. Use
`capabilities` only to confirm which documented actions a configured provider
supports. For a compact machine-readable copy of one action's same contract:
```bash
python skills/downloader-operation/scripts/mp-downloader.py capabilities \
--client "main-qb" \
--action tasks.properties.set
```
Do not inspect the helper source to discover arguments and do not guess a
provider-specific action. `capabilities` is optional and should be used only
when the configured provider itself is unknown or support must be diagnosed.
## Call Shape
@@ -59,22 +93,106 @@ python skills/downloader-operation/scripts/mp-downloader.py call \
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
## External MCP Contract
- 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`.
External MCP clients do not receive this `SKILL.md` and cannot use the hidden
`execute_command` tool. MoviePilot therefore exposes a separate admin-only MCP
tool named `downloader_operation`. Its `tools/list` `inputSchema` contains one
`oneOf` branch for every action below, including the function description,
supported providers, effect, field types, required/default values, enums, and
cross-field rules. The external client should select the matching branch and
make one `tools/call`; it does not need to call a discovery tool first.
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.
MCP call arguments use the same contract without shell quoting:
```json
{
"client": "main-qb",
"action": "tasks.properties.set",
"arguments": {
"task_id": "exact-provider-hash",
"download_limit": 2048,
"upload_limit": 512
}
}
```
`client` may be omitted for the default or only enabled downloader. If multiple
instances remain ambiguous, the result lists the valid client names.
## Complete Action Contract
In the tables below, `*` means required. Every listed field belongs inside the
single `--arguments` JSON object. Do not send fields that are not listed.
Shared rules:
- Task batch actions require exactly one of `task_id:string` or
`task_ids:string[]`.
- Paged reads accept `offset:integer=0` and `limit:integer=50`; `offset` must be
non-negative and `limit` is clamped to `1..200`.
- Speed values are numbers in `KB/s`. A value of `0` means unlimited.
- Task IDs, file indexes, tags, tracker URLs, and provider paths must come from
the selected downloader or the user's explicit input; never invent them.
### Task reads
| Action | Function and providers | `--arguments` fields |
|---|---|---|
| `tasks.list` | List/filter tasks; all | `task_id:string` or `task_ids:string[]`; `status:string`; `tags:string\|string[]`; `offset:integer=0`; `limit:integer=50` |
| `tasks.files` | List files and priorities for one task; all | `task_id*:string`; `offset:integer=0`; `limit:integer=50` |
| `tasks.trackers` | List tracker URLs; qBittorrent, Transmission | `task_id*:string` |
| `tasks.tags.get` | Read tags/labels for one task; all | `task_id*:string` |
| `tasks.peers` | Read peer synchronization data; qBittorrent | `task_id*:string` |
### Task control
| Action | Function and effect | `--arguments` fields |
|---|---|---|
| `tasks.start` | Start/resume tasks; reversible write | exactly one of `task_id:string`, `task_ids:string[]` |
| `tasks.stop` | Pause tasks; reversible write | exactly one of `task_id:string`, `task_ids:string[]` |
| `tasks.recheck` | Force data verification; external side effect | exactly one of `task_id:string`, `task_ids:string[]` |
| `tasks.reannounce` | Force tracker reannounce; qBittorrent/Transmission, external side effect | exactly one of `task_id:string`, `task_ids:string[]` |
| `tasks.queue.move` | Move queue position; qBittorrent/Transmission, reversible write | exactly one of `task_id:string`, `task_ids:string[]`; `position*:string` = `top\|up\|down\|bottom` |
| `tasks.force_start.set` | Toggle force-start; qBittorrent, reversible write | exactly one of `task_id:string`, `task_ids:string[]`; `enabled*:boolean` |
| `tasks.files.selection.set` | Select files within one task; reversible write | `task_id*:string`; `wanted_file_ids:integer[]`; `unwanted_file_ids:integer[]`; at least one list, with no overlapping index |
| `tasks.properties.set` | Set per-task limits; reversible write | `task_id*:string`; at least one of `upload_limit:number`, `download_limit:number`, `ratio_limit:number`, `seeding_time_limit:integer` minutes. rTorrent supports only speed fields |
| `tasks.location.set` | Move/retarget data to a downloader-side path; external side effect | `task_id*:string`; `location*:string` |
| `tasks.category.set` | Set a non-empty category; qBittorrent, reversible write | `task_id*:string`; `category*:string` |
| `tasks.tags.set` | Set/add tags or labels; reversible write | exactly one of `task_id:string`, `task_ids:string[]`; `tags*:string[]` |
| `tasks.trackers.update` | Add/replace trackers; qBittorrent/Transmission, reversible write | `task_id*:string`; `trackers*:string[]` of URLs |
| `tasks.delete` | Delete tasks and optionally data; destructive write | exactly one of `task_id:string`, `task_ids:string[]`; `delete_files:boolean=false` |
| `tasks.add.direct` | Submit directly to provider, bypassing MoviePilot orchestration; external side effect | `content*:string` magnet/URL/path; `torrent_file:boolean=false`; `paused:boolean=false`; `download_dir:string`; `tags:string[]`; `category:string` (qBittorrent only) |
### Session operations
| Action | Function and providers | `--arguments` fields |
|---|---|---|
| `session.stats` | Read transfer/session statistics; all | none (`{}`) |
| `session.speed_limits.get` | Read global download/upload limits; qBittorrent, Transmission | none (`{}`) |
| `session.speed_limits.set` | Set global limits; qBittorrent, Transmission | at least one of `download_limit:number`, `upload_limit:number`; use explicit `0` to clear a limit |
| `session.details` | Read Transmission session configuration/capacity; Transmission | none (`{}`) |
| `session.content_layout` | Read default torrent content layout; qBittorrent | none (`{}`) |
Examples:
```bash
# Read one task's files.
python skills/downloader-operation/scripts/mp-downloader.py call \
--client "main-qb" \
--action tasks.files \
--arguments '{"task_id":"exact-provider-hash","offset":0,"limit":50}'
# Limit one task to 2048 KB/s download and 512 KB/s upload.
python skills/downloader-operation/scripts/mp-downloader.py call \
--client "main-qb" \
--action tasks.properties.set \
--arguments '{"task_id":"exact-provider-hash","download_limit":2048,"upload_limit":512}'
```
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
@@ -22,6 +22,37 @@ PROVIDER_CLASSES = {
"transmission": "app.modules.transmission.transmission:Transmission",
"rtorrent": "app.modules.rtorrent.rtorrent:Rtorrent",
}
_UNSET = object()
class OperationError(RuntimeError):
"""可安全返回给 Agent 的下载器操作错误。"""
@dataclass(frozen=True, slots=True)
class ArgumentSpec:
"""描述一个 action 参数的公开调用合同。"""
name: str
type: str
description: str
required: bool = False
default: Any = _UNSET
enum: tuple[Any, ...] = ()
def to_dict(self) -> dict[str, Any]:
"""返回可直接交给 Agent 的参数 schema。"""
result: dict[str, Any] = {
"name": self.name,
"type": self.type,
"required": self.required,
"description": self.description,
}
if self.default is not _UNSET:
result["default"] = self.default
if self.enum:
result["enum"] = list(self.enum)
return result
@dataclass(frozen=True, slots=True)
@@ -31,7 +62,13 @@ class ActionSpec:
description: str
effect: str
providers: tuple[str, ...] = ALL_PROVIDERS
required: tuple[str, ...] = ()
arguments: tuple[ArgumentSpec, ...] = ()
argument_rules: tuple[str, ...] = ()
@property
def required(self) -> tuple[str, ...]:
"""返回保持旧能力合同兼容的必填参数名。"""
return tuple(argument.name for argument in self.arguments if argument.required)
def to_dict(self, name: str) -> dict[str, Any]:
"""返回不包含实现对象的公开能力描述。"""
@@ -41,65 +78,194 @@ class ActionSpec:
"effect": self.effect,
"providers": list(self.providers),
"required_arguments": list(self.required),
"arguments": [argument.to_dict() for argument in self.arguments],
"argument_rules": list(self.argument_rules),
}
TASK_ID = ArgumentSpec("task_id", "string", "单个任务的 provider 原生 hash 或 ID。")
TASK_IDS = ArgumentSpec("task_ids", "string[]", "多个任务的 provider 原生 hash 或 ID;与 task_id 二选一。")
OFFSET = ArgumentSpec("offset", "integer", "列表起始偏移,必须大于等于 0。", default=0)
LIMIT = ArgumentSpec("limit", "integer", "返回条数,范围 1..200。", default=DEFAULT_LIMIT)
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.list": ActionSpec(
"List and filter downloader tasks.",
"safe_read",
arguments=(
TASK_ID,
TASK_IDS,
ArgumentSpec("status", "string", "按 provider 原生任务状态过滤。"),
ArgumentSpec("tags", "string|string[]", "只返回同时包含这些标签的任务。"),
OFFSET,
LIMIT,
),
),
"tasks.files": ActionSpec(
"List files and priorities for one task.",
"safe_read",
arguments=(ArgumentSpec("task_id", "string", TASK_ID.description, required=True), OFFSET, LIMIT),
),
"tasks.files.selection.set": ActionSpec(
"Select wanted and unwanted files within one task.",
"reversible_write",
required=("task_id",),
arguments=(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("wanted_file_ids", "integer[]", "要下载的 provider 文件索引;与 unwanted_file_ids 至少提供一项。"),
ArgumentSpec("unwanted_file_ids", "integer[]", "跳过的 provider 文件索引;与 wanted_file_ids 至少提供一项。"),
),
argument_rules=("wanted_file_ids 与 unwanted_file_ids 至少提供一项,且同一索引不能同时出现。",),
),
"tasks.trackers": ActionSpec(
"List trackers for one task.", "safe_read", ("qbittorrent", "transmission"), ("task_id",)
"List trackers for one task.",
"safe_read",
("qbittorrent", "transmission"),
(ArgumentSpec("task_id", "string", TASK_ID.description, required=True),),
),
"tasks.tags.get": ActionSpec(
"Read task tags or labels.",
"safe_read",
arguments=(ArgumentSpec("task_id", "string", TASK_ID.description, required=True),),
),
"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",)
"Read qBittorrent peer synchronization data.",
"safe_read",
("qbittorrent",),
(ArgumentSpec("task_id", "string", TASK_ID.description, required=True),),
),
"tasks.start": ActionSpec(
"Start or resume one or more tasks.",
"reversible_write",
arguments=(TASK_ID, TASK_IDS),
argument_rules=("task_id 与 task_ids 必须提供且只能选择一种。",),
),
"tasks.stop": ActionSpec(
"Pause one or more tasks.",
"reversible_write",
arguments=(TASK_ID, TASK_IDS),
argument_rules=("task_id 与 task_ids 必须提供且只能选择一种。",),
),
"tasks.delete": ActionSpec(
"Delete tasks and optionally their data.",
"destructive_write",
arguments=(
TASK_ID,
TASK_IDS,
ArgumentSpec("delete_files", "boolean", "同时永久删除任务数据文件。", default=False),
),
argument_rules=("task_id 与 task_ids 必须提供且只能选择一种。",),
),
"tasks.recheck": ActionSpec(
"Force data verification for tasks.",
"external_side_effect",
arguments=(TASK_ID, TASK_IDS),
argument_rules=("task_id 与 task_ids 必须提供且只能选择一种。",),
),
"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",)
"Force tracker reannounce.",
"external_side_effect",
("qbittorrent", "transmission"),
(TASK_ID, TASK_IDS),
("task_id 与 task_ids 必须提供且只能选择一种。",),
),
"tasks.queue.move": ActionSpec(
"Move tasks to top, up, down, or bottom of the queue.",
"reversible_write",
("qbittorrent", "transmission"),
("task_ids", "position"),
(
TASK_ID,
TASK_IDS,
ArgumentSpec(
"position",
"string",
"目标队列位置。",
required=True,
enum=("top", "up", "down", "bottom"),
),
),
("task_id 与 task_ids 必须提供且只能选择一种。",),
),
"tasks.force_start.set": ActionSpec(
"Enable or disable qBittorrent force-start for tasks.",
"reversible_write",
("qbittorrent",),
("task_ids", "enabled"),
(
TASK_ID,
TASK_IDS,
ArgumentSpec("enabled", "boolean", "是否启用强制开始。", required=True),
),
("task_id 与 task_ids 必须提供且只能选择一种。",),
),
"tasks.properties.set": ActionSpec(
"Set task speed, ratio, or seeding-time limits.", "reversible_write", required=("task_id",)
"Set task speed, ratio, or seeding-time limits.",
"reversible_write",
arguments=(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("upload_limit", "number", "上传限速,单位 KB/s;0 表示不限速。"),
ArgumentSpec("download_limit", "number", "下载限速,单位 KB/s;0 表示不限速。"),
ArgumentSpec("ratio_limit", "number", "分享率上限;rTorrent 不支持。"),
ArgumentSpec("seeding_time_limit", "integer", "做种时间上限,单位分钟;rTorrent 不支持。"),
),
),
"tasks.location.set": ActionSpec(
"Move or retarget one task to a provider-side path.", "external_side_effect", required=("task_id", "location")
"Move or retarget one task to a provider-side path.",
"external_side_effect",
arguments=(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("location", "string", "下载器侧的新保存路径。", required=True),
),
),
"tasks.category.set": ActionSpec(
"Set qBittorrent category.", "reversible_write", ("qbittorrent",), ("task_id", "category")
"Set qBittorrent category.",
"reversible_write",
("qbittorrent",),
(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("category", "string", "非空分类名称。", required=True),
),
),
"tasks.tags.set": ActionSpec(
"Set or add task tags/labels.",
"reversible_write",
arguments=(
TASK_ID,
TASK_IDS,
ArgumentSpec("tags", "string[]", "要设置或添加的标签列表。", required=True),
),
argument_rules=("task_id 与 task_ids 必须提供且只能选择一种。",),
),
"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")
"Add or replace task trackers.",
"reversible_write",
("qbittorrent", "transmission"),
(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("trackers", "string[]", "Tracker URL 列表。", required=True),
),
),
"tasks.add.direct": ActionSpec(
"Submit a magnet, URL, or local torrent file directly to the provider.",
"external_side_effect",
required=("content",),
arguments=(
ArgumentSpec("content", "string", "Magnet、torrent URL,或 torrent_file=true 时的本地种子文件路径。", required=True),
ArgumentSpec("torrent_file", "boolean", "将 content 解释为本地种子文件路径。", default=False),
ArgumentSpec("paused", "boolean", "以暂停状态添加任务。", default=False),
ArgumentSpec("download_dir", "string", "下载器侧保存路径。"),
ArgumentSpec("tags", "string[]", "添加到任务的标签。"),
ArgumentSpec("category", "string", "qBittorrent 分类;其他 provider 忽略。"),
),
),
"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")
"Set global speed limits in KB/s.",
"reversible_write",
("qbittorrent", "transmission"),
(
ArgumentSpec("download_limit", "number", "全局下载限速,单位 KB/s;0 或省略表示不限速。"),
ArgumentSpec("upload_limit", "number", "全局上传限速,单位 KB/s;0 或省略表示不限速。"),
),
),
"session.details": ActionSpec(
"Read Transmission session configuration and capacity details.",
@@ -126,8 +292,7 @@ def _load_configs() -> list[Any]:
_ensure_project_import()
from app.db.oper.systemconfig import SystemConfigOper
from app.db.session import SessionFactory
from app.runtime.extensions.service import ServiceConfigHelper
from app.runtime.extensions.service import configure_service_config_reader
from app.runtime.extensions.service import ServiceConfigHelper, configure_service_config_reader
system_config = SystemConfigOper()
# Skill 在独立 CLI 进程中运行,没有 lifespan 为无会话 Oper 装配事务执行器。
@@ -151,12 +316,15 @@ def _select_config(client_name: Optional[str]) -> Any:
if config.name == client_name:
return config
raise ValueError(f"未找到已启用下载器实例: {client_name}")
if not enabled:
raise ValueError("没有已启用的下载器实例")
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")
names = "".join(str(config.name) for config in enabled)
raise ValueError(f"存在多个下载器实例,请用 --client 指定以下之一:{names}")
def _build_client(config: Any) -> Any:
@@ -169,7 +337,7 @@ def _build_client(config: Any) -> Any:
if client.is_inactive():
client.reconnect()
if client.is_inactive():
raise RuntimeError("下载器连接不可用")
raise OperationError("下载器连接不可用")
return client
@@ -253,6 +421,99 @@ def _require(arguments: Mapping[str, Any], name: str) -> Any:
return value
def _matches_argument_type(value: Any, declared_type: str) -> bool:
"""判断 JSON 值是否符合公开参数合同中的紧凑类型表达式。"""
for candidate in declared_type.split("|"):
if candidate.endswith("[]"):
if isinstance(value, list) and all(
_matches_argument_type(item, candidate[:-2]) for item in value
):
return True
continue
if candidate == "string" and isinstance(value, str):
return True
if candidate == "integer" and isinstance(value, int) and not isinstance(value, bool):
return True
if candidate == "number" and isinstance(value, (int, float)) and not isinstance(value, bool):
return True
if candidate == "boolean" and isinstance(value, bool):
return True
if candidate == "object" and isinstance(value, Mapping):
return True
return False
def _validate_action_arguments(action: str, spec: ActionSpec, arguments: Mapping[str, Any]) -> None:
"""一次性校验 action 的全部参数,避免 Agent 按单个错误反复试调用。"""
errors: list[str] = []
argument_specs = {argument.name: argument for argument in spec.arguments}
unknown = sorted(set(arguments) - set(argument_specs))
if unknown:
errors.append(f"未知参数: {', '.join(unknown)}")
for name, argument in argument_specs.items():
value = arguments.get(name)
if argument.required and (value is None or value == "" or value == []):
errors.append(f"缺少必填参数: {name}")
continue
if value is not None and not _matches_argument_type(value, argument.type):
errors.append(f"参数 {name} 必须是 {argument.type}")
if value is not None and argument.enum and value not in argument.enum:
errors.append(f"参数 {name} 仅支持: {', '.join(map(str, argument.enum))}")
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:
selector_count = int(bool(arguments.get("task_id"))) + int(bool(arguments.get("task_ids")))
if selector_count != 1:
errors.append("task_id 与 task_ids 必须提供且只能选择一种")
if action == "tasks.files.selection.set":
wanted = arguments.get("wanted_file_ids") or []
unwanted = arguments.get("unwanted_file_ids") or []
if not wanted and not unwanted:
errors.append("wanted_file_ids 与 unwanted_file_ids 至少提供一项")
comparable_indexes = isinstance(wanted, list) and isinstance(unwanted, list) and all(
isinstance(item, int) and not isinstance(item, bool) for item in [*wanted, *unwanted]
)
if comparable_indexes and set(wanted) & set(unwanted):
errors.append("同一文件不能同时出现在 wanted_file_ids 和 unwanted_file_ids")
if action == "tasks.properties.set" and not any(
arguments.get(name) is not None
for name in ("upload_limit", "download_limit", "ratio_limit", "seeding_time_limit")
):
errors.append("至少提供一个要修改的任务属性")
if action == "session.speed_limits.set" and not any(
arguments.get(name) is not None for name in ("download_limit", "upload_limit")
):
errors.append("至少提供 download_limit 或 upload_limit;清除限速请显式传 0")
if errors:
raise ValueError("参数校验失败:" + "".join(errors))
def _validate_provider_arguments(action: str, provider: str, arguments: Mapping[str, Any]) -> None:
"""拒绝会被特定 provider 静默忽略的参数。"""
errors: list[str] = []
if action == "tasks.properties.set" and provider == "rtorrent":
unsupported = [
name
for name in ("ratio_limit", "seeding_time_limit")
if arguments.get(name) is not None
]
if unsupported:
errors.append(f"rTorrent 不支持参数: {', '.join(unsupported)}")
if action == "tasks.add.direct" and provider != "qbittorrent" and arguments.get("category") is not None:
errors.append(f"{provider} 不支持参数: category")
if errors:
raise ValueError("参数校验失败:" + "".join(errors))
def _tasks_list(client: Any, arguments: Mapping[str, Any]) -> dict[str, Any]:
"""查询并分页返回下载任务。"""
tasks, error = client.get_torrents(
@@ -261,7 +522,7 @@ def _tasks_list(client: Any, arguments: Mapping[str, Any]) -> dict[str, Any]:
tags=arguments.get("tags"),
)
if error:
raise RuntimeError("下载器任务查询失败")
raise OperationError("下载器任务查询失败")
return _page(tasks or [], arguments)
@@ -273,7 +534,7 @@ def _tags_get(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
return getter(task_id)
tasks, error = client.get_torrents(ids=task_id)
if error or not tasks:
raise RuntimeError("任务标签查询失败")
raise OperationError("任务标签查询失败")
task = _jsonable(tasks[0])
if provider == "qbittorrent":
tags = task.get("tags") if isinstance(task, dict) else None
@@ -469,12 +730,20 @@ def list_instances() -> dict[str, Any]:
return {"success": True, "instances": instances}
def list_capabilities(client_name: Optional[str]) -> dict[str, Any]:
"""返回全部或指定实例支持的 action 清单"""
def list_capabilities(client_name: Optional[str], action: Optional[str] = None) -> 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]
if action and action not in ACTIONS:
raise ValueError(f"未知 downloader action: {action}")
actions = [
spec.to_dict(name)
for name, spec in ACTIONS.items()
if (not action or name == action) and (provider is None or provider in spec.providers)
]
if action and not actions:
raise ValueError(f"{provider} 不支持 action: {action}")
return {
"success": True,
"client": client_name,
@@ -488,18 +757,15 @@ def call_action(client_name: Optional[str], action: str, arguments: Mapping[str,
spec = ACTIONS.get(action)
if spec is None:
raise ValueError(f"未知 downloader action: {action}")
_validate_action_arguments(action, spec, arguments)
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)
_validate_provider_arguments(action, provider, arguments)
result = _dispatch(_build_client(config), provider, action, arguments)
if spec.effect != "safe_read" and result is False:
raise RuntimeError("下载器 action 返回失败")
raise OperationError("下载器 action 返回失败")
return {
"success": True,
"client": config.name,
@@ -525,9 +791,10 @@ def _build_parser() -> argparse.ArgumentParser:
subparsers.add_parser("instances", help="list configured instances")
capabilities = subparsers.add_parser("capabilities", help="list allowed actions")
capabilities.add_argument("--client")
capabilities.add_argument("--action")
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("--action", required=True)
call.add_argument("--arguments", default="{}")
return parser
@@ -540,7 +807,7 @@ def main() -> int:
if args.command == "instances":
payload = list_instances()
elif args.command == "capabilities":
payload = list_capabilities(args.client)
payload = list_capabilities(args.client, args.action)
elif args.command == "call":
payload = call_action(args.client, args.action, _parse_arguments(args.arguments))
else:
@@ -550,7 +817,7 @@ def main() -> int:
payload = {
"success": False,
"error_type": type(error).__name__,
"message": str(error) if isinstance(error, ValueError) else "下载器调用失败",
"message": str(error) if isinstance(error, (ValueError, OperationError)) else "下载器调用失败",
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
+131 -11
View File
@@ -1,6 +1,6 @@
---
name: mediaserver-operation
version: 1
version: 2
description: >-
Use this skill when the user asks to inspect, diagnose, or directly operate a
configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, or Navidrome
@@ -27,7 +27,30 @@ username, password, API key, token, Cookie, or arbitrary URL.
- A provider result is not automatically a MoviePilot transfer, subscription,
or history fact. Use the appropriate MoviePilot API for those workflows.
## Discover First
## Instance And Provider Discovery
### Fast path: call directly
Do not routinely call `instances` or `capabilities` before an operation. This
Skill already contains the full action contract, and the helper performs
instance resolution, provider support checks, complete argument validation, and
the action in one `call` invocation.
- If the user or prior context provides the exact server name, pass it with
`--server` and call the action immediately.
- If no server name is known, omit `--server`. The helper automatically uses the
only enabled media server.
- If multiple servers remain ambiguous, the failed call lists every valid
server name. Reuse that list for the next direct call; do not add a separate
`instances` call unless the user explicitly asks to inspect instances.
- Do not probe an action with empty or guessed arguments. Compose the complete
JSON object from the contract below before calling.
The helper rejects unknown fields and reports all detectable argument errors in
one response before connecting to the provider, so correct every reported field
together instead of retrying one field at a time.
### Optional discovery
```bash
python skills/mediaserver-operation/scripts/mp-mediaserver.py instances
@@ -35,8 +58,19 @@ 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.
The complete action and argument contract is documented below. Use
`capabilities` only to confirm which documented actions a configured provider
supports. For a compact machine-readable copy of one action's same contract:
```bash
python skills/mediaserver-operation/scripts/mp-mediaserver.py capabilities \
--server "living-room" \
--action items.season_episodes
```
Do not inspect the helper source to discover arguments and do not guess a
provider-specific action. `capabilities` is optional and should be used only
when the configured provider itself is unknown or support must be diagnosed.
## Call Shape
@@ -50,14 +84,100 @@ python skills/mediaserver-operation/scripts/mp-mediaserver.py call \
The `--arguments` value must be one JSON object. List reads default to 50 items
and cap at 200.
## Actions
## External MCP Contract
- 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`.
External MCP clients do not receive this `SKILL.md` and cannot use the hidden
`execute_command` tool. MoviePilot therefore exposes a separate admin-only MCP
tool named `mediaserver_operation`. Its `tools/list` `inputSchema` contains one
`oneOf` branch for every action below, including the function description,
supported providers, effect, field types, required/default values, enums,
nested `metadata.refresh` item fields, and cross-field rules. The external
client should select the matching branch and make one `tools/call`; it does not
need to call a discovery tool first.
MCP call arguments use the same contract without shell quoting:
```json
{
"server": "living-room",
"action": "items.season_episodes",
"arguments": {
"item_id": "exact-series-id",
"season": 2
}
}
```
`server` may be omitted when only one media server is enabled. If multiple
instances remain ambiguous, the result lists the valid server names.
## Complete Action Contract
In the tables below, `*` means required. Every listed field belongs inside the
single `--arguments` JSON object. Do not send fields that are not listed.
Shared rules:
- Paged reads accept `offset:integer=0` where documented and
`limit:integer=50`; `offset` must be non-negative and `limit` is clamped to
`1..200`.
- `parent`, `item_id`, library IDs, and usernames are native to the selected
server. Obtain them from that server's earlier response; never reuse IDs from
another instance.
- All actions support only the providers shown by `capabilities`. The provider
list below lets the Agent choose without inspecting source; query the selected
instance only when provider support must be confirmed.
Provider abbreviations used below: all = Emby, Jellyfin, Plex, ZSpace, UGREEN,
TrimeMedia, and Navidrome.
### Server and library reads
| Action | Function and providers | `--arguments` fields |
|---|---|---|
| `server.statistics` | Read media counts/provider statistics; all | none (`{}`) |
| `server.users.count` | Read provider user count; Emby, Jellyfin, ZSpace, UGREEN, TrimeMedia, Navidrome | none (`{}`) |
| `server.user.library_folders` | Read current user's visible folders; Emby, Jellyfin, ZSpace | none (`{}`) |
| `libraries.list` | List visible libraries; all | `hidden:boolean=false` (true = configured sync scope only); `username:string` only for Emby/Jellyfin/ZSpace |
| `items.list` | Page items below a library/parent; all | `parent:string\|integer` required except Navidrome; `offset:integer=0`; `limit:integer=50` |
| `items.count` | Count items below a library/parent; all | `parent:string\|integer` required except Navidrome; omitted on Navidrome uses `music` |
| `items.detail` | Read one provider item; all | `item_id*:string` |
### Native search and activity
| Action | Function and providers | `--arguments` fields |
|---|---|---|
| `items.movies.search` | Search movies; Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia | `title*:string`; `year:string\|integer` |
| `items.music.search` | Search music; Emby, Jellyfin, Plex, ZSpace, UGREEN, Navidrome | `title:string`; `artist:string`; `album:string`; at least one is required |
| `items.season_episodes` | Read existing episode coverage for a series; Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia | `item_id:string`; `title:string`; at least one is required; optional `year:string\|integer`; `season:integer` |
| `activity.latest` | Read recently added items; all | `limit:integer=50`; `username:string` only for Emby/Jellyfin/ZSpace |
| `activity.resume` | Read in-progress/resumable items; all | `limit:integer=50`; `username:string` only for Emby/Jellyfin/ZSpace |
| `activity.backdrops` | Read recent backdrop URLs; UGREEN, TrimeMedia | `limit:integer=50`; `remote:boolean=false` |
### Playback and writes
| Action | Function, providers, and effect | `--arguments` fields |
|---|---|---|
| `playback.sessions` | Read active sessions; Emby, Jellyfin, Plex; safe read | none (`{}`) |
| `playback.url` | Build provider play URL; all; safe read | `item_id*:string` |
| `library.scan` | Trigger provider root-library scan; all; external side effect | `scan_mode:string\|integer` only for UGREEN; otherwise omit |
| `metadata.refresh` | Refresh metadata for mapped items; Emby, Plex, ZSpace, UGREEN, TrimeMedia; external side effect | `items*:object[]`; each object supports `title:string`, `year:string\|integer`, `type:string` (`电影\|电视剧\|音乐`), `category:string`, `target_path:string` |
Examples:
```bash
# List the first page below an exact library ID.
python skills/mediaserver-operation/scripts/mp-mediaserver.py call \
--server "living-room" \
--action items.list \
--arguments '{"parent":"exact-library-id","offset":0,"limit":50}'
# Read season 2 coverage using an exact provider series ID.
python skills/mediaserver-operation/scripts/mp-mediaserver.py call \
--server "living-room" \
--action items.season_episodes \
--arguments '{"item_id":"exact-series-id","season":2}'
```
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
@@ -35,6 +35,37 @@ PROVIDER_CLASSES = {
"trimemedia": "app.modules.trimemedia.trimemedia:TrimeMedia",
"navidrome": "app.modules.navidrome.navidrome:Navidrome",
}
_UNSET = object()
class OperationError(RuntimeError):
"""可安全返回给 Agent 的媒体服务器操作错误。"""
@dataclass(frozen=True, slots=True)
class ArgumentSpec:
"""描述一个 action 参数的公开调用合同。"""
name: str
type: str
description: str
required: bool = False
default: Any = _UNSET
enum: tuple[Any, ...] = ()
def to_dict(self) -> dict[str, Any]:
"""返回可直接交给 Agent 的参数 schema。"""
result: dict[str, Any] = {
"name": self.name,
"type": self.type,
"required": self.required,
"description": self.description,
}
if self.default is not _UNSET:
result["default"] = self.default
if self.enum:
result["enum"] = list(self.enum)
return result
@dataclass(frozen=True, slots=True)
@@ -44,7 +75,13 @@ class ActionSpec:
description: str
effect: str
providers: tuple[str, ...] = ALL_PROVIDERS
required: tuple[str, ...] = ()
arguments: tuple[ArgumentSpec, ...] = ()
argument_rules: tuple[str, ...] = ()
@property
def required(self) -> tuple[str, ...]:
"""返回保持旧能力合同兼容的必填参数名。"""
return tuple(argument.name for argument in self.arguments if argument.required)
def to_dict(self, name: str) -> dict[str, Any]:
"""返回不包含实现对象的公开能力描述。"""
@@ -54,9 +91,17 @@ class ActionSpec:
"effect": self.effect,
"providers": list(self.providers),
"required_arguments": list(self.required),
"arguments": [argument.to_dict() for argument in self.arguments],
"argument_rules": list(self.argument_rules),
}
ITEM_ID = ArgumentSpec("item_id", "string", "当前媒体服务器返回的 provider 原生条目 ID。")
PARENT = ArgumentSpec("parent", "string|integer", "媒体库或父条目 ID;Navidrome 可省略并使用 music。")
OFFSET = ArgumentSpec("offset", "integer", "列表起始偏移,必须大于等于 0。", default=0)
LIMIT = ArgumentSpec("limit", "integer", "返回条数,范围 1..200。", default=DEFAULT_LIMIT)
ACTIONS: dict[str, ActionSpec] = {
"server.statistics": ActionSpec("Read media counts and provider statistics.", "safe_read"),
"server.users.count": ActionSpec(
@@ -69,41 +114,107 @@ ACTIONS: dict[str, ActionSpec] = {
"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",)),
"libraries.list": ActionSpec(
"List visible provider libraries.",
"safe_read",
arguments=(
ArgumentSpec("hidden", "boolean", "仅返回配置为同步范围的媒体库。", default=False),
ArgumentSpec("username", "string", "按用户名读取可见媒体库;仅 Emby、Jellyfin、ZSpace 支持。"),
),
),
"items.list": ActionSpec(
"Page items below one library or parent.",
"safe_read",
arguments=(PARENT, OFFSET, LIMIT),
argument_rules=("除 Navidrome 外必须提供 parentNavidrome 忽略 parent。",),
),
"items.count": ActionSpec(
"Count items below one library or parent.",
"safe_read",
arguments=(PARENT,),
argument_rules=("除 Navidrome 外必须提供 parentNavidrome 省略时使用 music。",),
),
"items.detail": ActionSpec(
"Read one provider item by native ID.",
"safe_read",
arguments=(ArgumentSpec("item_id", "string", ITEM_ID.description, required=True),),
),
"items.movies.search": ActionSpec(
"Search provider-native movie items by title and optional year.",
"safe_read",
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
("title",),
(
ArgumentSpec("title", "string", "电影标题。", required=True),
ArgumentSpec("year", "string|integer", "可选发行年份。"),
),
),
"items.music.search": ActionSpec(
"Search provider-native music by title, artist, or album.",
"safe_read",
("emby", "jellyfin", "plex", "zspace", "ugreen", "navidrome"),
(
ArgumentSpec("title", "string", "歌曲、专辑或音乐条目标题。"),
ArgumentSpec("artist", "string", "艺人名称。"),
ArgumentSpec("album", "string", "专辑名称;title、artist、album 至少提供一项。"),
),
("title、artist、album 至少提供一项。",),
),
"items.season_episodes": ActionSpec(
"Read native episode coverage for one series and optional season.",
"safe_read",
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
(
ITEM_ID,
ArgumentSpec("title", "string", "剧集标题;与 item_id 至少提供一项。"),
ArgumentSpec("year", "string|integer", "可选首播年份。"),
ArgumentSpec("season", "integer", "可选季号。"),
),
("item_id 与 title 至少提供一项。",),
),
"activity.latest": ActionSpec(
"Read recently added provider items.",
"safe_read",
arguments=(LIMIT, ArgumentSpec("username", "string", "按用户名读取;仅 Emby、Jellyfin、ZSpace 支持。")),
),
"activity.resume": ActionSpec(
"Read in-progress/resumable provider items.",
"safe_read",
arguments=(LIMIT, ArgumentSpec("username", "string", "按用户名读取;仅 Emby、Jellyfin、ZSpace 支持。")),
),
"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"),
(
LIMIT,
ArgumentSpec("remote", "boolean", "返回 provider 可远程访问的图片地址。", default=False),
),
),
"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"),
"playback.url": ActionSpec(
"Build the provider play URL for one item.",
"safe_read",
arguments=(ArgumentSpec("item_id", "string", ITEM_ID.description, required=True),),
),
"library.scan": ActionSpec(
"Trigger a provider library scan.",
"external_side_effect",
arguments=(
ArgumentSpec("scan_mode", "string|integer", "UGREEN 原生扫描模式;其他 provider 必须省略。"),
),
),
"metadata.refresh": ActionSpec(
"Refresh provider metadata for mapped items.",
"external_side_effect",
("emby", "plex", "zspace", "ugreen", "trimemedia"),
("items",),
(
ArgumentSpec(
"items",
"object[]",
"刷新条目;每项支持 title:string、year:string|integer、type:电影|电视剧|音乐、category:string、target_path:string。",
required=True,
),
),
),
}
@@ -120,8 +231,7 @@ def _load_configs() -> list[Any]:
_ensure_project_import()
from app.db.oper.systemconfig import SystemConfigOper
from app.db.session import SessionFactory
from app.runtime.extensions.service import ServiceConfigHelper
from app.runtime.extensions.service import configure_service_config_reader
from app.runtime.extensions.service import ServiceConfigHelper, configure_service_config_reader
system_config = SystemConfigOper()
# Skill 在独立 CLI 进程中运行,没有 lifespan 为无会话 Oper 装配事务执行器。
@@ -145,9 +255,12 @@ def _select_config(server_name: Optional[str]) -> Any:
if config.name == server_name:
return config
raise ValueError(f"未找到已启用媒体服务器实例: {server_name}")
if not enabled:
raise ValueError("没有已启用的媒体服务器实例")
if len(enabled) == 1:
return enabled[0]
raise ValueError("存在多个媒体服务器实例,请显式提供 --server")
names = "".join(str(config.name) for config in enabled)
raise ValueError(f"存在多个媒体服务器实例,请用 --server 指定以下之一:{names}")
def _build_client(config: Any) -> Any:
@@ -163,7 +276,7 @@ def _build_client(config: Any) -> Any:
if client.is_inactive():
client.reconnect()
if client.is_inactive():
raise RuntimeError("媒体服务器连接不可用")
raise OperationError("媒体服务器连接不可用")
return client
@@ -221,6 +334,101 @@ def _require(arguments: Mapping[str, Any], name: str) -> Any:
return value
def _matches_argument_type(value: Any, declared_type: str) -> bool:
"""判断 JSON 值是否符合公开参数合同中的紧凑类型表达式。"""
for candidate in declared_type.split("|"):
if candidate.endswith("[]"):
if isinstance(value, list) and all(
_matches_argument_type(item, candidate[:-2]) for item in value
):
return True
continue
if candidate == "string" and isinstance(value, str):
return True
if candidate == "integer" and isinstance(value, int) and not isinstance(value, bool):
return True
if candidate == "boolean" and isinstance(value, bool):
return True
if candidate == "object" and isinstance(value, Mapping):
return True
return False
def _validate_refresh_items(items: Any) -> list[str]:
"""校验 metadata.refresh 的嵌套条目并返回全部错误。"""
if not isinstance(items, list):
return []
errors: list[str] = []
allowed_fields = {"title", "year", "type", "category", "target_path"}
allowed_types = {"电影", "电视剧", "音乐"}
for index, item in enumerate(items):
if not isinstance(item, Mapping):
continue
unknown = sorted(set(item) - allowed_fields)
if unknown:
errors.append(f"items[{index}] 未知字段: {', '.join(unknown)}")
if item.get("type") is not None and item.get("type") not in allowed_types:
errors.append(f"items[{index}].type 仅支持: 电影、电视剧、音乐")
for name in ("title", "category", "target_path"):
if item.get(name) is not None and not isinstance(item.get(name), str):
errors.append(f"items[{index}].{name} 必须是 string")
year = item.get("year")
if year is not None and not (
isinstance(year, str) or (isinstance(year, int) and not isinstance(year, bool))
):
errors.append(f"items[{index}].year 必须是 string|integer")
return errors
def _validate_action_arguments(action: str, spec: ActionSpec, arguments: Mapping[str, Any]) -> None:
"""一次性校验 action 的全部参数,避免 Agent 按单个错误反复试调用。"""
errors: list[str] = []
argument_specs = {argument.name: argument for argument in spec.arguments}
unknown = sorted(set(arguments) - set(argument_specs))
if unknown:
errors.append(f"未知参数: {', '.join(unknown)}")
for name, argument in argument_specs.items():
value = arguments.get(name)
if argument.required and (value is None or value == "" or value == []):
errors.append(f"缺少必填参数: {name}")
continue
if value is not None and not _matches_argument_type(value, argument.type):
errors.append(f"参数 {name} 必须是 {argument.type}")
if value is not None and argument.enum and value not in argument.enum:
errors.append(f"参数 {name} 仅支持: {', '.join(map(str, argument.enum))}")
if action == "items.music.search" and not any(
arguments.get(name) for name in ("title", "artist", "album")
):
errors.append("title、artist、album 至少提供一项")
if action == "items.season_episodes" and not any(
arguments.get(name) for name in ("item_id", "title")
):
errors.append("item_id 与 title 至少提供一项")
if action == "metadata.refresh":
errors.extend(_validate_refresh_items(arguments.get("items")))
if errors:
raise ValueError("参数校验失败:" + "".join(errors))
def _validate_provider_arguments(action: str, provider: str, arguments: Mapping[str, Any]) -> None:
"""校验需要知道 provider 后才能确定的条件参数。"""
errors: list[str] = []
if action in {"items.list", "items.count"} and provider != "navidrome" and not arguments.get("parent"):
errors.append(f"{provider}{action} 必须提供 parent")
username_providers = {"emby", "jellyfin", "zspace"}
if (
action in {"libraries.list", "activity.latest", "activity.resume"}
and arguments.get("username") is not None
and provider not in username_providers
):
errors.append(f"{provider}{action} 不支持参数: username")
if action == "library.scan" and arguments.get("scan_mode") is not None and provider != "ugreen":
errors.append(f"{provider} 的 library.scan 不支持参数: scan_mode")
if errors:
raise ValueError("参数校验失败:" + "".join(errors))
def _limit(arguments: Mapping[str, Any]) -> int:
"""读取安全的分页条数。"""
return min(MAX_LIMIT, max(1, int(arguments.get("limit", DEFAULT_LIMIT))))
@@ -379,12 +587,20 @@ def list_instances() -> dict[str, Any]:
return {"success": True, "instances": instances}
def list_capabilities(server_name: Optional[str]) -> dict[str, Any]:
"""返回全部或指定实例支持的 action 清单"""
def list_capabilities(server_name: Optional[str], action: Optional[str] = None) -> 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]
if action and action not in ACTIONS:
raise ValueError(f"未知 media server action: {action}")
actions = [
spec.to_dict(name)
for name, spec in ACTIONS.items()
if (not action or name == action) and (provider is None or provider in spec.providers)
]
if action and not actions:
raise ValueError(f"{provider} 不支持 action: {action}")
return {
"success": True,
"server": server_name,
@@ -398,15 +614,15 @@ def call_action(server_name: Optional[str], action: str, arguments: Mapping[str,
spec = ACTIONS.get(action)
if spec is None:
raise ValueError(f"未知 media server action: {action}")
_validate_action_arguments(action, spec, arguments)
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)
_validate_provider_arguments(action, provider, arguments)
result = _dispatch(_build_client(config), provider, action, arguments)
if spec.effect != "safe_read" and result is False:
raise RuntimeError("媒体服务器 action 返回失败")
raise OperationError("媒体服务器 action 返回失败")
return {
"success": True,
"server": config.name,
@@ -432,9 +648,10 @@ def _build_parser() -> argparse.ArgumentParser:
subparsers.add_parser("instances", help="list configured instances")
capabilities = subparsers.add_parser("capabilities", help="list allowed actions")
capabilities.add_argument("--server")
capabilities.add_argument("--action")
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("--action", required=True)
call.add_argument("--arguments", default="{}")
return parser
@@ -447,7 +664,7 @@ def main() -> int:
if args.command == "instances":
payload = list_instances()
elif args.command == "capabilities":
payload = list_capabilities(args.server)
payload = list_capabilities(args.server, args.action)
elif args.command == "call":
payload = call_action(args.server, args.action, _parse_arguments(args.arguments))
else:
@@ -457,7 +674,7 @@ def main() -> int:
payload = {
"success": False,
"error_type": type(error).__name__,
"message": str(error) if isinstance(error, ValueError) else "媒体服务器调用失败",
"message": str(error) if isinstance(error, (ValueError, OperationError)) else "媒体服务器调用失败",
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1