mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-10 09:56:48 +08:00
feat(agent): expose self-describing service operations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 外必须提供 parent;Navidrome 忽略 parent。",),
|
||||
),
|
||||
"items.count": ActionSpec(
|
||||
"Count items below one library or parent.",
|
||||
"safe_read",
|
||||
arguments=(PARENT,),
|
||||
argument_rules=("除 Navidrome 外必须提供 parent;Navidrome 省略时使用 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
|
||||
|
||||
Reference in New Issue
Block a user