From 632363730a194c9409e7e7528a836e79b1a66635 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 31 Aug 2026 20:45:33 +0800 Subject: [PATCH] feat(agent): expose self-describing service operations --- app/agent/policy/api_mcp_contract.py | 295 + app/agent/policy/api_mcp_schema.json | 5148 +++++++++++++++++ app/agent/policy/registry.py | 2 + app/agent/tools/factory.py | 15 +- app/agent/tools/impl/api.py | 20 +- app/agent/tools/impl/service_operation.py | 391 ++ app/agent/tools/manager.py | 18 +- scripts/generate_agent_api_mcp_schema.py | 46 + skills/downloader-operation/SKILL.md | 154 +- .../scripts/mp-downloader.py | 345 +- skills/mediaserver-operation/SKILL.md | 142 +- .../scripts/mp-mediaserver.py | 265 +- tests/test_agent_tool_policy.py | 4 + tests/test_builtin_skill_boundaries.py | 4 +- tests/test_mcp_plugin_tools.py | 2 +- tests/test_service_operation_mcp_tools.py | 130 + tests/test_service_operation_skills.py | 195 + 17 files changed, 7072 insertions(+), 104 deletions(-) create mode 100644 app/agent/policy/api_mcp_contract.py create mode 100644 app/agent/policy/api_mcp_schema.json create mode 100644 app/agent/tools/impl/service_operation.py create mode 100644 scripts/generate_agent_api_mcp_schema.py create mode 100644 tests/test_service_operation_mcp_tools.py diff --git a/app/agent/policy/api_mcp_contract.py b/app/agent/policy/api_mcp_contract.py new file mode 100644 index 000000000..f12a77aa1 --- /dev/null +++ b/app/agent/policy/api_mcp_contract.py @@ -0,0 +1,295 @@ +"""从业务 OpenAPI 构建 moviepilot_api 的外部 MCP 输入合同。""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Mapping, Sequence + + +def _rewrite_schema_refs( + schema: Mapping[str, Any], + *, + components: Mapping[str, Any], + definitions: dict[str, Any], +) -> dict[str, Any]: + """把 OpenAPI components 引用改写为独立 MCP schema 的 $defs 引用。""" + reference = schema.get("$ref") + if isinstance(reference, str) and reference.startswith("#/components/schemas/"): + name = reference.rsplit("/", 1)[-1] + if name not in definitions: + definitions[name] = {} + source = components.get(name) + if not isinstance(source, Mapping): + raise ValueError(f"OpenAPI 缺少请求模型: {name}") + definitions[name] = _rewrite_schema_refs( + source, + components=components, + definitions=definitions, + ) + return {"$ref": f"#/$defs/{name}"} + + rewritten: dict[str, Any] = {} + for key, value in schema.items(): + if isinstance(value, Mapping): + rewritten[key] = _rewrite_schema_refs( + value, + components=components, + definitions=definitions, + ) + elif isinstance(value, list): + rewritten[key] = [ + _rewrite_schema_refs( + item, + components=components, + definitions=definitions, + ) + if isinstance(item, Mapping) + else deepcopy(item) + for item in value + ] + else: + rewritten[key] = deepcopy(value) + return rewritten + + +def _parameter_object_schema( + parameters: Sequence[Mapping[str, Any]], + *, + location: str, + components: Mapping[str, Any], + definitions: dict[str, Any], +) -> dict[str, Any] | None: + """把 OpenAPI path/query 参数投影为网关结构化对象。""" + selected = [parameter for parameter in parameters if parameter.get("in") == location] + if not selected: + return None + properties: dict[str, Any] = {} + required: list[str] = [] + for parameter in selected: + name = str(parameter["name"]) + raw_schema = parameter.get("schema") + if not isinstance(raw_schema, Mapping): + raw_schema = {} + field_schema = _rewrite_schema_refs( + raw_schema, + components=components, + definitions=definitions, + ) + if parameter.get("description") and "description" not in field_schema: + field_schema["description"] = parameter["description"] + properties[name] = field_schema + if parameter.get("required"): + required.append(name) + result: dict[str, Any] = { + "type": "object", + "properties": properties, + "additionalProperties": False, + } + if required: + result["required"] = required + return result + + +def _request_body_schema( + operation: Mapping[str, Any], + *, + components: Mapping[str, Any], + definitions: dict[str, Any], +) -> tuple[dict[str, Any] | None, bool]: + """读取一个 OpenAPI operation 的 JSON 请求体及必填性。""" + request_body = operation.get("requestBody") + if not isinstance(request_body, Mapping): + return None, False + content = request_body.get("content") + if not isinstance(content, Mapping): + return None, bool(request_body.get("required")) + media = content.get("application/json") + if not isinstance(media, Mapping): + media = next((item for item in content.values() if isinstance(item, Mapping)), None) + raw_schema = media.get("schema") if isinstance(media, Mapping) else None + if not isinstance(raw_schema, Mapping): + return None, bool(request_body.get("required")) + return ( + _rewrite_schema_refs( + raw_schema, + components=components, + definitions=definitions, + ), + bool(request_body.get("required")), + ) + + +def _person_credits_operation(openapi: Mapping[str, Any]) -> dict[str, Any]: + """合并四个来源端点为稳定 media.person.credits 网关合同。""" + paths = openapi.get("paths", {}) + source_paths = { + "douban": "/api/v1/douban/person/credits/{person_id}", + "tmdb": "/api/v1/tmdb/person/credits/{person_id}", + "bangumi": "/api/v1/bangumi/person/credits/{person_id}", + "anilist": "/api/v1/anilist/person/credits/{person_id}", + } + for source_path in source_paths.values(): + if source_path not in paths: + raise ValueError(f"OpenAPI 缺少人物作品端点: {source_path}") + return { + "summary": "读取人物作品", + "parameters": [ + { + "name": "source", + "in": "path", + "required": True, + "schema": {"type": "string", "enum": list(source_paths)}, + "description": "人物数据来源。", + }, + { + "name": "person_id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + "description": "来源原生人物 ID。", + }, + { + "name": "page", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1, "default": 1}, + }, + { + "name": "count", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}, + "description": "Bangumi 与 AniList 支持的每页条数;其他来源忽略。", + }, + ], + } + + +def _resolve_openapi_operation( + operation_id: str, + route: Any, + openapi: Mapping[str, Any], +) -> Mapping[str, Any]: + """按固定路由读取 OpenAPI operation,并处理多来源合成路由。""" + if operation_id == "media.person.credits": + return _person_credits_operation(openapi) + path_item = openapi.get("paths", {}).get(route.path) + if not isinstance(path_item, Mapping): + raise ValueError(f"OpenAPI 缺少 operation 路径: {operation_id} -> {route.path}") + operation = path_item.get(str(route.method).lower()) + if not isinstance(operation, Mapping): + raise ValueError(f"OpenAPI 缺少 operation 方法: {operation_id} -> {route.method} {route.path}") + return operation + + +def _apply_operation_overrides( + operation_id: str, + query_schema: dict[str, Any] | None, +) -> None: + """补充同一路由多 operation 时无法由 FastAPI 自动表达的语义约束。""" + if operation_id != "media.person.search" or query_schema is None: + return + query_schema["properties"]["type"] = { + "type": "string", + "const": "person", + "description": "人物搜索固定传 person。", + } + required = query_schema.setdefault("required", []) + if "type" not in required: + required.append("type") + + +def build_api_mcp_input_schema( + *, + openapi: Mapping[str, Any], + routes: Mapping[str, Any], + specs: Sequence[Any], +) -> dict[str, Any]: + """构建 59 个白名单 operation 的完整 MCP oneOf 输入合同。""" + components = openapi.get("components", {}).get("schemas", {}) + if not isinstance(components, Mapping): + components = {} + definitions: dict[str, Any] = {} + spec_by_id = {spec.operation_id: spec for spec in specs} + if set(spec_by_id) != set(routes): + raise ValueError("API operation 策略与路由注册表不一致") + + branches: list[dict[str, Any]] = [] + for operation_id in sorted(routes): + route = routes[operation_id] + operation = _resolve_openapi_operation(operation_id, route, openapi) + parameters = operation.get("parameters") + if not isinstance(parameters, list): + parameters = [] + path_schema = _parameter_object_schema( + parameters, + location="path", + components=components, + definitions=definitions, + ) + query_schema = _parameter_object_schema( + parameters, + location="query", + components=components, + definitions=definitions, + ) + _apply_operation_overrides(operation_id, query_schema) + body_schema, body_required = _request_body_schema( + operation, + components=components, + definitions=definitions, + ) + + properties: dict[str, Any] = { + "operation_id": {"type": "string", "const": operation_id}, + } + required = ["operation_id"] + if path_schema is not None: + properties["path_params"] = path_schema + if path_schema.get("required"): + required.append("path_params") + if query_schema is not None: + properties["query"] = query_schema + if query_schema.get("required"): + required.append("query") + if body_schema is not None: + properties["body"] = body_schema + if body_required: + required.append("body") + + summary = str(operation.get("summary") or operation.get("description") or operation_id) + spec = spec_by_id[operation_id] + branches.append( + { + "type": "object", + "title": operation_id, + "description": ( + f"{summary} Method: {route.method}. Path: {route.path}. " + f"Effect: {spec.effect.value}." + ), + "properties": properties, + "required": required, + "additionalProperties": False, + } + ) + + schema: dict[str, Any] = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "moviepilot_api", + "type": "object", + "description": "Select the oneOf branch matching operation_id and send exactly its documented fields.", + "properties": { + "operation_id": {"type": "string", "enum": sorted(routes)}, + "path_params": {"type": "object"}, + "query": {"type": "object"}, + "body": {"type": "object"}, + }, + "required": ["operation_id"], + "oneOf": branches, + } + if definitions: + schema["$defs"] = definitions + return schema + + +__all__ = ["build_api_mcp_input_schema"] diff --git a/app/agent/policy/api_mcp_schema.json b/app/agent/policy/api_mcp_schema.json new file mode 100644 index 000000000..183d1a59b --- /dev/null +++ b/app/agent/policy/api_mcp_schema.json @@ -0,0 +1,5148 @@ +{ + "$defs": { + "AgentCommandRunRequest": { + "description": "通过 Agent API 触发斜杠命令的请求。", + "properties": { + "command": { + "description": "要执行的完整斜杠命令", + "title": "Command", + "type": "string" + } + }, + "required": [ + "command" + ], + "title": "AgentCommandRunRequest", + "type": "object" + }, + "Body_add_api_v1_download_add_post": { + "properties": { + "allow_unrecognized": { + "default": false, + "title": "Allow Unrecognized", + "type": "boolean" + }, + "downloader": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Downloader" + }, + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ] + }, + "music_type": { + "anyOf": [ + { + "enum": [ + "recording", + "album" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Music Type" + }, + "save_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Save Path" + }, + "torrent_in": { + "$ref": "#/$defs/TorrentInfo" + } + }, + "required": [ + "torrent_in" + ], + "title": "Body_add_api_v1_download_add_post", + "type": "object" + }, + "CustomFilterRuleCreateRequest": { + "description": "新增自定义过滤规则请求。", + "properties": { + "exclude": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude" + }, + "include": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Include" + }, + "name": { + "title": "Name", + "type": "string" + }, + "publish_time": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Publish Time" + }, + "rule_id": { + "title": "Rule Id", + "type": "string" + }, + "seeders": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Seeders" + }, + "size_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Size Range" + } + }, + "required": [ + "rule_id", + "name" + ], + "title": "CustomFilterRuleCreateRequest", + "type": "object" + }, + "CustomFilterRuleUpdateRequest": { + "description": "更新自定义过滤规则请求。", + "properties": { + "exclude": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude" + }, + "include": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Include" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "new_rule_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "New Rule Id" + }, + "publish_time": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Publish Time" + }, + "seeders": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Seeders" + }, + "size_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Size Range" + } + }, + "title": "CustomFilterRuleUpdateRequest", + "type": "object" + }, + "CustomIdentifiersUpdateRequest": { + "description": "完整替换自定义识别词的请求。", + "properties": { + "identifiers": { + "items": { + "type": "string" + }, + "title": "Identifiers", + "type": "array" + } + }, + "title": "CustomIdentifiersUpdateRequest", + "type": "object" + }, + "DownloadHistory-Input": { + "description": "下载历史记录", + "properties": { + "channel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Channel" + }, + "date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date" + }, + "download_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Download Hash" + }, + "episode_group": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Group" + }, + "episodes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episodes" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Image" + }, + "media_category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Category" + }, + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ] + }, + "music_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Music Type" + }, + "note": { + "anyOf": [ + { + "$ref": "#/$defs/JsonData-Input" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + }, + "poster": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Poster" + }, + "seasons": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Seasons" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "torrent_description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Torrent Description" + }, + "torrent_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Torrent Name" + }, + "torrent_site": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Torrent Site" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "userid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Userid" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Username" + }, + "year": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Year" + } + }, + "required": [ + "id" + ], + "title": "DownloadHistory", + "type": "object" + }, + "FileItem-Input": { + "description": "文件或目录条目,目录可递归包含子条目。", + "properties": { + "basename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Basename" + }, + "children": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/FileItem-Input" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Children" + }, + "drive_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Drive Id" + }, + "extension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Extension" + }, + "fileid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Fileid" + }, + "modify_time": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Modify Time" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "parent_fileid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Fileid" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "/", + "title": "Path" + }, + "pickcode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pickcode" + }, + "size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Size" + }, + "storage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "local", + "title": "Storage" + }, + "thumbnail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Thumbnail" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + } + }, + "title": "FileItem", + "type": "object" + }, + "FilterRuleGroupCreateRequest": { + "description": "新增过滤规则组请求。", + "properties": { + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + }, + "media_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Type" + }, + "name": { + "title": "Name", + "type": "string" + }, + "rule_string": { + "title": "Rule String", + "type": "string" + } + }, + "required": [ + "name", + "rule_string" + ], + "title": "FilterRuleGroupCreateRequest", + "type": "object" + }, + "FilterRuleGroupUpdateRequest": { + "description": "更新过滤规则组请求。", + "properties": { + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + }, + "media_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Type" + }, + "new_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "New Name" + }, + "rule_string": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rule String" + } + }, + "title": "FilterRuleGroupUpdateRequest", + "type": "object" + }, + "JsonData-Input": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/$defs/JsonData-Input" + }, + "type": "object" + }, + { + "items": { + "$ref": "#/$defs/JsonData-Input" + }, + "type": "array" + }, + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManualTransferItem": { + "description": "手动整理请求,媒体身份接受内置或插件来源与原生 ID。", + "properties": { + "episode_detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Detail" + }, + "episode_format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Format" + }, + "episode_group": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Group" + }, + "episode_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Offset" + }, + "episode_part": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Part" + }, + "fileitem": { + "$ref": "#/$defs/FileItem-Input" + }, + "fileitems": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/FileItem-Input" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Fileitems" + }, + "from_history": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "From History" + }, + "library_category_folder": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Library Category Folder" + }, + "library_type_folder": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Library Type Folder" + }, + "logid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Logid" + }, + "logids": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Logids" + }, + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ] + }, + "min_filesize": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Min Filesize" + }, + "music_type": { + "anyOf": [ + { + "enum": [ + "recording", + "album" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Music Type" + }, + "preview": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Preview" + }, + "reorganize": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Reorganize" + }, + "scrape": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Scrape" + }, + "season": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Season" + }, + "target_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Path" + }, + "target_storage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Storage" + }, + "transfer_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Transfer Type" + }, + "type_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type Name" + } + }, + "title": "ManualTransferItem", + "type": "object" + }, + "MediaSource": { + "description": "媒体主身份的数据来源,内置来源为常量,插件来源为动态扩展成员。", + "examples": [ + "themoviedb", + "douban", + "bangumi", + "anilist", + "imdb", + "tvdb", + "musicbrainz", + "theaudiodb", + "doubanmusic", + "bilibili", + "mangguodiscover", + "migu", + "tencentvideodiscover", + "iqiyidiscover" + ], + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "title": "MediaSource", + "type": "string" + }, + "MediaType": { + "enum": [ + "电影", + "电视剧", + "音乐", + "系列", + "未知" + ], + "title": "MediaType", + "type": "string" + }, + "Site-Input": { + "description": "站点配置及运行状态。", + "properties": { + "apikey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Apikey" + }, + "cookie": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cookie" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + }, + "downloader": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Downloader" + }, + "filter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "is_active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "title": "Is Active" + }, + "limit_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit Count" + }, + "limit_interval": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit Interval" + }, + "limit_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit Seconds" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "note": { + "anyOf": [ + { + "$ref": "#/$defs/JsonData-Input" + }, + { + "type": "null" + } + ] + }, + "pri": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Pri" + }, + "proxy": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Proxy" + }, + "public": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Public" + }, + "render": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Render" + }, + "rss": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rss" + }, + "timeout": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 15, + "title": "Timeout" + }, + "token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token" + }, + "ua": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ua" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + } + }, + "title": "Site", + "type": "object" + }, + "SiteCookieUpdate": { + "description": "站点 Cookie 与 UA 更新请求。", + "properties": { + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "二步验证码或密钥", + "title": "Code" + }, + "password": { + "description": "站点登录密码", + "title": "Password", + "type": "string" + }, + "username": { + "description": "站点登录用户名", + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "SiteCookieUpdate", + "type": "object" + }, + "Subscribe": { + "description": "订阅输入与响应模型,媒体身份必须为空对或完整有效对。", + "properties": { + "audio_format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audio Format" + }, + "audio_quality": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audio Quality" + }, + "backdrop": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backdrop" + }, + "best_version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Best Version" + }, + "best_version_full": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Best Version Full" + }, + "completed_episode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Completed Episode" + }, + "current_audio_format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Current Audio Format" + }, + "current_bit_depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Bit Depth" + }, + "current_bitrate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Bitrate" + }, + "current_priority": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Priority" + }, + "current_sample_rate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Sample Rate" + }, + "custom_words": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Custom Words" + }, + "date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "downloader": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Downloader" + }, + "effect": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Effect" + }, + "episode_group": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Group" + }, + "episode_priority": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Episode Priority" + }, + "exclude": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude" + }, + "filter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter" + }, + "filter_groups": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter Groups" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "include": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Include" + }, + "keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keyword" + }, + "lack_episode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Lack Episode" + }, + "last_update": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Update" + }, + "media_category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Category" + }, + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ] + }, + "min_bit_depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Min Bit Depth" + }, + "min_bitrate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Min Bitrate" + }, + "min_sample_rate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Min Sample Rate" + }, + "music_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Music Type" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "note": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "poster": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Poster" + }, + "quality": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Quality" + }, + "resolution": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resolution" + }, + "save_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Save Path" + }, + "search_imdbid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Search Imdbid" + }, + "season": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Season" + }, + "sites": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sites" + }, + "start_episode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Start Episode" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State" + }, + "total_episode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Total Episode" + }, + "total_tracks": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total Tracks" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Username" + }, + "vote": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": 0.0, + "title": "Vote" + }, + "year": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Year" + } + }, + "title": "Subscribe", + "type": "object" + }, + "SystemSettingsUpdateRequest": { + "description": "统一系统设置更新请求。", + "properties": { + "match_field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Match Field" + }, + "match_value": { + "title": "Match Value" + }, + "operation": { + "default": "replace", + "enum": [ + "replace", + "merge_dict", + "upsert_list_item", + "remove_list_item" + ], + "title": "Operation", + "type": "string" + }, + "remove_keys": { + "items": { + "type": "string" + }, + "title": "Remove Keys", + "type": "array" + }, + "setting_key": { + "title": "Setting Key", + "type": "string" + }, + "value": { + "title": "Value" + } + }, + "required": [ + "setting_key" + ], + "title": "SystemSettingsUpdateRequest", + "type": "object" + }, + "TorrentInfo": { + "description": "搜索种子信息", + "properties": { + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + }, + "date_elapsed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date Elapsed" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "downloadvolumefactor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Downloadvolumefactor" + }, + "enclosure": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Enclosure" + }, + "freedate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Freedate" + }, + "freedate_diff": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Freedate Diff" + }, + "grabs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Grabs" + }, + "hit_and_run": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Hit And Run" + }, + "labels": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Labels" + }, + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ] + }, + "page_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Page Url" + }, + "peers": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Peers" + }, + "pri_order": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Pri Order" + }, + "pubdate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pubdate" + }, + "seeders": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Seeders" + }, + "site": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Site" + }, + "site_cookie": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Cookie" + }, + "site_downloader": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Downloader" + }, + "site_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Name" + }, + "site_order": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Site Order" + }, + "site_proxy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Site Proxy" + }, + "site_ua": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Ua" + }, + "size": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": 0.0, + "title": "Size" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "uploadvolumefactor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Uploadvolumefactor" + }, + "volume_factor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Volume Factor" + } + }, + "title": "TorrentInfo", + "type": "object" + }, + "TransferHistory-Input": { + "description": "文件整理历史记录", + "properties": { + "audio_format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audio Format" + }, + "audio_lossless": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Audio Lossless" + }, + "bit_depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Bit Depth" + }, + "bitrate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Bitrate" + }, + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + }, + "date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date" + }, + "dest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dest" + }, + "dest_fileitem": { + "anyOf": [ + { + "$ref": "#/$defs/JsonData-Input" + }, + { + "type": "null" + } + ] + }, + "dest_storage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dest Storage" + }, + "download_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Download Hash" + }, + "episode_group": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Group" + }, + "episodes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episodes" + }, + "errmsg": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Errmsg" + }, + "files": { + "anyOf": [ + { + "$ref": "#/$defs/JsonData-Input" + }, + { + "type": "null" + } + ] + }, + "id": { + "title": "Id", + "type": "integer" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Image" + }, + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ] + }, + "mode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mode" + }, + "music_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Music Type" + }, + "sample_rate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sample Rate" + }, + "seasons": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Seasons" + }, + "src": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Src" + }, + "src_fileitem": { + "anyOf": [ + { + "$ref": "#/$defs/JsonData-Input" + }, + { + "type": "null" + } + ] + }, + "src_storage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Src Storage" + }, + "status": { + "default": true, + "title": "Status", + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "total_tracks": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total Tracks" + }, + "transfer_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Transfer Task Id" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "year": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Year" + } + }, + "required": [ + "id" + ], + "title": "TransferHistory", + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Select the oneOf branch matching operation_id and send exactly its documented fields.", + "oneOf": [ + { + "additionalProperties": false, + "description": "查询自定义识别词 Method: GET. Path: /api/v1/system/identifiers. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "config.identifiers.get", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "config.identifiers.get", + "type": "object" + }, + { + "additionalProperties": false, + "description": "更新自定义识别词 Method: POST. Path: /api/v1/system/identifiers. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/CustomIdentifiersUpdateRequest" + }, + "operation_id": { + "const": "config.identifiers.update", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "config.identifiers.update", + "type": "object" + }, + { + "additionalProperties": false, + "description": "统一查询系统设置 Method: GET. Path: /api/v1/system/settings. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "config.system.get", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "group": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "all", + "title": "Group" + }, + "include_values": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Include Values" + }, + "keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keyword" + }, + "setting_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Setting Key" + }, + "show_secrets": { + "default": false, + "title": "Show Secrets", + "type": "boolean" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "config.system.get", + "type": "object" + }, + { + "additionalProperties": false, + "description": "统一更新系统设置 Method: POST. Path: /api/v1/system/settings. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/SystemSettingsUpdateRequest" + }, + "operation_id": { + "const": "config.system.update", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "config.system.update", + "type": "object" + }, + { + "additionalProperties": false, + "description": "添加下载(不含媒体信息) Method: POST. Path: /api/v1/download/add. Effect: external_side_effect.", + "properties": { + "body": { + "$ref": "#/$defs/Body_add_api_v1_download_add_post" + }, + "operation_id": { + "const": "download.add", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "download.add", + "type": "object" + }, + { + "additionalProperties": false, + "description": "删除下载历史记录 Method: DELETE. Path: /api/v1/history/download. Effect: destructive_write.", + "properties": { + "body": { + "$ref": "#/$defs/DownloadHistory-Input" + }, + "operation_id": { + "const": "download.history.delete", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "download.history.delete", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询内置过滤规则 Method: GET. Path: /api/v1/rule/builtin. Effect: safe_read.", + "properties": { + "body": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Rule Ids" + }, + "operation_id": { + "const": "filter.builtin", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "filter.builtin", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询自定义过滤规则 Method: GET. Path: /api/v1/rule/custom. Effect: safe_read.", + "properties": { + "body": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Rule Ids" + }, + "operation_id": { + "const": "filter.custom", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "include_group_refs": { + "default": true, + "title": "Include Group Refs", + "type": "boolean" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "filter.custom", + "type": "object" + }, + { + "additionalProperties": false, + "description": "新增自定义过滤规则 Method: POST. Path: /api/v1/rule/custom. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/CustomFilterRuleCreateRequest" + }, + "operation_id": { + "const": "filter.custom.add", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "filter.custom.add", + "type": "object" + }, + { + "additionalProperties": false, + "description": "删除自定义过滤规则 Method: DELETE. Path: /api/v1/rule/custom/{rule_id}. Effect: destructive_write.", + "properties": { + "operation_id": { + "const": "filter.custom.delete", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "rule_id": { + "title": "Rule Id", + "type": "string" + } + }, + "required": [ + "rule_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "filter.custom.delete", + "type": "object" + }, + { + "additionalProperties": false, + "description": "更新自定义过滤规则 Method: PUT. Path: /api/v1/rule/custom/{rule_id}. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/CustomFilterRuleUpdateRequest" + }, + "operation_id": { + "const": "filter.custom.update", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "rule_id": { + "title": "Rule Id", + "type": "string" + } + }, + "required": [ + "rule_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "filter.custom.update", + "type": "object" + }, + { + "additionalProperties": false, + "description": "新增过滤规则组 Method: POST. Path: /api/v1/rule/groups. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/FilterRuleGroupCreateRequest" + }, + "operation_id": { + "const": "filter.group.add", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "filter.group.add", + "type": "object" + }, + { + "additionalProperties": false, + "description": "删除过滤规则组 Method: DELETE. Path: /api/v1/rule/groups/{name}. Effect: destructive_write.", + "properties": { + "operation_id": { + "const": "filter.group.delete", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "filter.group.delete", + "type": "object" + }, + { + "additionalProperties": false, + "description": "更新过滤规则组 Method: PUT. Path: /api/v1/rule/groups/{name}. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/FilterRuleGroupUpdateRequest" + }, + "operation_id": { + "const": "filter.group.update", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "filter.group.update", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询过滤规则组 Method: GET. Path: /api/v1/rule/groups. Effect: safe_read.", + "properties": { + "body": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Group Names" + }, + "operation_id": { + "const": "filter.groups", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "include_usage": { + "default": true, + "title": "Include Usage", + "type": "boolean" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "filter.groups", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询本地是否存在(数据库) Method: GET. Path: /api/v1/mediaserver/exists. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "library.exists", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ], + "title": "Media Source" + }, + "mtype": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mtype" + }, + "season": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Season" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "year": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Year" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "library.exists", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询媒体详情 Method: GET. Path: /api/v1/media/{media_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "media.detail", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "media_id": { + "title": "Media Id", + "type": "string" + } + }, + "required": [ + "media_id" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "media_source": { + "$ref": "#/$defs/MediaSource" + }, + "type_name": { + "title": "Type Name", + "type": "string" + } + }, + "required": [ + "media_source", + "type_name" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "query" + ], + "title": "media.detail", + "type": "object" + }, + { + "additionalProperties": false, + "description": "TMDB季所有集 Method: GET. Path: /api/v1/tmdb/{tmdbid}/{season}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "media.episode_schedule", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "season": { + "title": "Season", + "type": "integer" + }, + "tmdbid": { + "title": "Tmdbid", + "type": "integer" + } + }, + "required": [ + "tmdbid", + "season" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "episode_group": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Episode Group" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "media.episode_schedule", + "type": "object" + }, + { + "additionalProperties": false, + "description": "读取人物作品 Method: GET. Path: /api/v1/{source}/person/credits/{person_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "media.person.credits", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "person_id": { + "description": "来源原生人物 ID。", + "type": "integer" + }, + "source": { + "description": "人物数据来源。", + "enum": [ + "douban", + "tmdb", + "bangumi", + "anilist" + ], + "type": "string" + } + }, + "required": [ + "source", + "person_id" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "count": { + "default": 20, + "description": "Bangumi 与 AniList 支持的每页条数;其他来源忽略。", + "maximum": 50, + "minimum": 1, + "type": "integer" + }, + "page": { + "default": 1, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "media.person.credits", + "type": "object" + }, + { + "additionalProperties": false, + "description": "搜索媒体/人物信息 Method: GET. Path: /api/v1/media/search. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "media.person.search", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "count": { + "default": 8, + "title": "Count", + "type": "integer" + }, + "media_source": { + "default": [], + "items": { + "$ref": "#/$defs/MediaSource" + }, + "title": "Media Source", + "type": "array" + }, + "page": { + "default": 1, + "title": "Page", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "const": "person", + "description": "人物搜索固定传 person。", + "type": "string" + } + }, + "required": [ + "title", + "type" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "query" + ], + "title": "media.person.search", + "type": "object" + }, + { + "additionalProperties": false, + "description": "识别媒体信息(种子) Method: GET. Path: /api/v1/media/recognize. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "media.recognize", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "custom_words": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Custom Words" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ], + "title": "Media Source" + }, + "subtitle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subtitle" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "query" + ], + "title": "media.recognize", + "type": "object" + }, + { + "additionalProperties": false, + "description": "刮削媒体信息 Method: POST. Path: /api/v1/media/scrape/{storage}. Effect: external_side_effect.", + "properties": { + "body": { + "$ref": "#/$defs/FileItem-Input" + }, + "operation_id": { + "const": "media.scrape", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "storage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Storage" + } + }, + "required": [ + "storage" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, + "media_source": { + "anyOf": [ + { + "$ref": "#/$defs/MediaSource" + }, + { + "type": "null" + } + ], + "title": "Media Source" + }, + "music_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Music Type" + }, + "type_name": { + "anyOf": [ + { + "$ref": "#/$defs/MediaType" + }, + { + "type": "null" + } + ], + "title": "Type Name" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "media.scrape", + "type": "object" + }, + { + "additionalProperties": false, + "description": "搜索媒体/人物信息 Method: GET. Path: /api/v1/media/search. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "media.search", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "count": { + "default": 8, + "title": "Count", + "type": "integer" + }, + "media_source": { + "default": [], + "items": { + "$ref": "#/$defs/MediaSource" + }, + "title": "Media Source", + "type": "array" + }, + "page": { + "default": 1, + "title": "Page", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "media", + "title": "Type" + } + }, + "required": [ + "title" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "query" + ], + "title": "media.search", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询插件运行能力 Method: GET. Path: /api/v1/plugin/runtime/capabilities. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "plugin.capabilities", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "plugin_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Plugin Id" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "plugin.capabilities", + "type": "object" + }, + { + "additionalProperties": false, + "description": "获取插件配置 Method: GET. Path: /api/v1/plugin/{plugin_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "plugin.config.get", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "plugin_id": { + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.config.get", + "type": "object" + }, + { + "additionalProperties": false, + "description": "更新插件配置 Method: PUT. Path: /api/v1/plugin/{plugin_id}. Effect: reversible_write.", + "properties": { + "body": { + "additionalProperties": true, + "title": "Conf", + "type": "object" + }, + "operation_id": { + "const": "plugin.config.update", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "plugin_id": { + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "plugin.config.update", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询插件持久化数据 Method: GET. Path: /api/v1/plugin/runtime/{plugin_id}/data. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "plugin.data", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "plugin_id": { + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key" + }, + "max_chars": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Chars" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.data", + "type": "object" + }, + { + "additionalProperties": false, + "description": "安装插件 Method: GET. Path: /api/v1/plugin/install/{plugin_id}. Effect: external_side_effect.", + "properties": { + "operation_id": { + "const": "plugin.install", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "plugin_id": { + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Force" + }, + "release_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Release Version" + }, + "repo_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "title": "Repo Url" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.install", + "type": "object" + }, + { + "additionalProperties": false, + "description": "已安装插件 Method: GET. Path: /api/v1/plugin/installed. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "plugin.installed", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "plugin.installed", + "type": "object" + }, + { + "additionalProperties": false, + "description": "所有插件 Method: GET. Path: /api/v1/plugin/. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "plugin.market", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "force": { + "default": false, + "title": "Force", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "all", + "title": "State" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "plugin.market", + "type": "object" + }, + { + "additionalProperties": false, + "description": "重新加载插件 Method: GET. Path: /api/v1/plugin/reload/{plugin_id}. Effect: external_side_effect.", + "properties": { + "operation_id": { + "const": "plugin.reload", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "plugin_id": { + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.reload", + "type": "object" + }, + { + "additionalProperties": false, + "description": "卸载插件 Method: DELETE. Path: /api/v1/plugin/{plugin_id}. Effect: destructive_write.", + "properties": { + "operation_id": { + "const": "plugin.uninstall", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "plugin_id": { + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.uninstall", + "type": "object" + }, + { + "additionalProperties": false, + "description": "统一获取 Agent 推荐结果 Method: GET. Path: /api/v1/recommend/agent. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "recommendation.list", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "days": { + "default": 14, + "title": "Days", + "type": "integer" + }, + "fresh_sort": { + "default": "release_date", + "title": "Fresh Sort", + "type": "string" + }, + "future": { + "default": true, + "title": "Future", + "type": "boolean" + }, + "media_type": { + "default": "all", + "title": "Media Type", + "type": "string" + }, + "min_listen_count": { + "default": 0, + "title": "Min Listen Count", + "type": "integer" + }, + "music_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Music Type" + }, + "page": { + "default": 1, + "title": "Page", + "type": "integer" + }, + "past": { + "default": true, + "title": "Past", + "type": "boolean" + }, + "range_name": { + "default": "this_month", + "title": "Range Name", + "type": "string" + }, + "sort_by": { + "default": "listen_count.desc", + "title": "Sort By", + "type": "string" + }, + "source": { + "default": "tmdb_trending", + "title": "Source", + "type": "string" + }, + "with_cover": { + "default": false, + "title": "With Cover", + "type": "boolean" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "recommendation.list", + "type": "object" + }, + { + "additionalProperties": false, + "description": "后台服务 Method: GET. Path: /api/v1/dashboard/schedule. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "scheduler.list", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "scheduler.list", + "type": "object" + }, + { + "additionalProperties": false, + "description": "运行服务 Method: GET. Path: /api/v1/system/runscheduler. Effect: external_side_effect.", + "properties": { + "operation_id": { + "const": "scheduler.run", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "jobid": { + "title": "Jobid", + "type": "string" + } + }, + "required": [ + "jobid" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "query" + ], + "title": "scheduler.run", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询上次搜索上下文 Method: GET. Path: /api/v1/search/last/context. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "search.results", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "search.results", + "type": "object" + }, + { + "additionalProperties": false, + "description": "精确搜索资源 Method: GET. Path: /api/v1/search/media/{media_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "search.torrents", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "media_id": { + "title": "Media Id", + "type": "string" + } + }, + "required": [ + "media_id" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "area": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "title", + "title": "Area" + }, + "media_source": { + "$ref": "#/$defs/MediaSource" + }, + "mtype": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mtype" + }, + "music_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Music Type" + }, + "season": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Season" + }, + "sites": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sites" + } + }, + "required": [ + "media_source" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "query" + ], + "title": "search.torrents", + "type": "object" + }, + { + "additionalProperties": false, + "description": "更新站点Cookie&UA Method: POST. Path: /api/v1/site/cookie/{site_id}. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/SiteCookieUpdate" + }, + "operation_id": { + "const": "site.cookie.update", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "site_id": { + "title": "Site Id", + "type": "integer" + } + }, + "required": [ + "site_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "site.cookie.update", + "type": "object" + }, + { + "additionalProperties": false, + "description": "所有站点 Method: GET. Path: /api/v1/site/. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "site.list", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "site.list", + "type": "object" + }, + { + "additionalProperties": false, + "description": "连接测试 Method: GET. Path: /api/v1/site/test/{site_id}. Effect: external_side_effect.", + "properties": { + "operation_id": { + "const": "site.test", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "site_id": { + "title": "Site Id", + "type": "integer" + } + }, + "required": [ + "site_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "site.test", + "type": "object" + }, + { + "additionalProperties": false, + "description": "更新站点 Method: PUT. Path: /api/v1/site/. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/Site-Input" + }, + "operation_id": { + "const": "site.update", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "site.update", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询某站点用户数据 Method: GET. Path: /api/v1/site/userdata/{site_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "site.userdata", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "site_id": { + "title": "Site Id", + "type": "integer" + } + }, + "required": [ + "site_id" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "workdate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workdate" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "site.userdata", + "type": "object" + }, + { + "additionalProperties": false, + "description": "获取 Web 智能助手可用命令 Method: GET. Path: /api/v1/message/agent/commands. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "slash.list", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "slash.list", + "type": "object" + }, + { + "additionalProperties": false, + "description": "执行 Agent 斜杠命令 Method: POST. Path: /api/v1/message/agent/commands/run. Effect: external_side_effect.", + "properties": { + "body": { + "$ref": "#/$defs/AgentCommandRunRequest" + }, + "operation_id": { + "const": "slash.run", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "slash.run", + "type": "object" + }, + { + "additionalProperties": false, + "description": "所有目录和文件 Method: POST. Path: /api/v1/storage/list. Effect: safe_read.", + "properties": { + "body": { + "$ref": "#/$defs/FileItem-Input" + }, + "operation_id": { + "const": "storage.list", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keyword" + }, + "sort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "updated_at", + "title": "Sort" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "storage.list", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询目录配置 Method: GET. Path: /api/v1/storage/directories. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "storage.settings", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "directory_type": { + "default": "all", + "title": "Directory Type", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "storage_type": { + "default": "all", + "title": "Storage Type", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "storage.settings", + "type": "object" + }, + { + "additionalProperties": false, + "description": "新增订阅 Method: POST. Path: /api/v1/subscribe/. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/Subscribe" + }, + "operation_id": { + "const": "subscription.add", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "subscription.add", + "type": "object" + }, + { + "additionalProperties": false, + "description": "删除订阅 Method: DELETE. Path: /api/v1/subscribe/{subscribe_id}. Effect: destructive_write.", + "properties": { + "operation_id": { + "const": "subscription.delete", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "subscribe_id": { + "title": "Subscribe Id", + "type": "integer" + } + }, + "required": [ + "subscribe_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "subscription.delete", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询订阅历史 Method: GET. Path: /api/v1/subscribe/history/{mtype}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "subscription.history", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "mtype": { + "title": "Mtype", + "type": "string" + } + }, + "required": [ + "mtype" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 30, + "title": "Count" + }, + "page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 1, + "title": "Page" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "subscription.history", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询所有订阅 Method: GET. Path: /api/v1/subscribe/. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "subscription.list", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "subscription.list", + "type": "object" + }, + { + "additionalProperties": false, + "description": "热门订阅(基于用户共享数据) Method: GET. Path: /api/v1/subscribe/popular. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "subscription.popular", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 30, + "title": "Count" + }, + "genre_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Genre Id" + }, + "max_rating": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Max Rating" + }, + "min_rating": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Min Rating" + }, + "min_sub": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Min Sub" + }, + "page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 1, + "title": "Page" + }, + "sort_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort Type" + }, + "stype": { + "title": "Stype", + "type": "string" + } + }, + "required": [ + "stype" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "query" + ], + "title": "subscription.popular", + "type": "object" + }, + { + "additionalProperties": false, + "description": "搜索订阅 Method: GET. Path: /api/v1/subscribe/search/{subscribe_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "subscription.search", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "subscribe_id": { + "title": "Subscribe Id", + "type": "integer" + } + }, + "required": [ + "subscribe_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "subscription.search", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询分享的订阅 Method: GET. Path: /api/v1/subscribe/shares. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "subscription.shares", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 30, + "title": "Count" + }, + "genre_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Genre Id" + }, + "max_rating": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Max Rating" + }, + "min_rating": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Min Rating" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 1, + "title": "Page" + }, + "sort_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort Type" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "subscription.shares", + "type": "object" + }, + { + "additionalProperties": false, + "description": "更新订阅 Method: PUT. Path: /api/v1/subscribe/. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/Subscribe" + }, + "operation_id": { + "const": "subscription.update", + "type": "string" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "subscription.update", + "type": "object" + }, + { + "additionalProperties": false, + "description": "手动转移 Method: POST. Path: /api/v1/transfer/manual. Effect: external_side_effect.", + "properties": { + "body": { + "$ref": "#/$defs/ManualTransferItem" + }, + "operation_id": { + "const": "transfer.file", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "background": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Background" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "transfer.file", + "type": "object" + }, + { + "additionalProperties": false, + "description": "查询整理记录 Method: GET. Path: /api/v1/history/transfer. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "transfer.history", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 30, + "title": "Count" + }, + "page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 1, + "title": "Page" + }, + "status": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Status" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "transfer.history", + "type": "object" + }, + { + "additionalProperties": false, + "description": "删除整理记录 Method: DELETE. Path: /api/v1/history/transfer. Effect: destructive_write.", + "properties": { + "body": { + "$ref": "#/$defs/TransferHistory-Input" + }, + "operation_id": { + "const": "transfer.history.delete", + "type": "string" + }, + "query": { + "additionalProperties": false, + "properties": { + "deletedest": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Deletedest" + }, + "deletesrc": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Deletesrc" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "body" + ], + "title": "transfer.history.delete", + "type": "object" + }, + { + "additionalProperties": false, + "description": "所有工作流 Method: GET. Path: /api/v1/workflow/. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "workflow.list", + "type": "string" + } + }, + "required": [ + "operation_id" + ], + "title": "workflow.list", + "type": "object" + }, + { + "additionalProperties": false, + "description": "执行工作流 Method: POST. Path: /api/v1/workflow/{workflow_id}/run. Effect: external_side_effect.", + "properties": { + "operation_id": { + "const": "workflow.run", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "properties": { + "workflow_id": { + "title": "Workflow Id", + "type": "integer" + } + }, + "required": [ + "workflow_id" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "from_begin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "title": "From Begin" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "workflow.run", + "type": "object" + } + ], + "properties": { + "body": { + "type": "object" + }, + "operation_id": { + "enum": [ + "config.identifiers.get", + "config.identifiers.update", + "config.system.get", + "config.system.update", + "download.add", + "download.history.delete", + "filter.builtin", + "filter.custom", + "filter.custom.add", + "filter.custom.delete", + "filter.custom.update", + "filter.group.add", + "filter.group.delete", + "filter.group.update", + "filter.groups", + "library.exists", + "media.detail", + "media.episode_schedule", + "media.person.credits", + "media.person.search", + "media.recognize", + "media.scrape", + "media.search", + "plugin.capabilities", + "plugin.config.get", + "plugin.config.update", + "plugin.data", + "plugin.install", + "plugin.installed", + "plugin.market", + "plugin.reload", + "plugin.uninstall", + "recommendation.list", + "scheduler.list", + "scheduler.run", + "search.results", + "search.torrents", + "site.cookie.update", + "site.list", + "site.test", + "site.update", + "site.userdata", + "slash.list", + "slash.run", + "storage.list", + "storage.settings", + "subscription.add", + "subscription.delete", + "subscription.history", + "subscription.list", + "subscription.popular", + "subscription.search", + "subscription.shares", + "subscription.update", + "transfer.file", + "transfer.history", + "transfer.history.delete", + "workflow.list", + "workflow.run" + ], + "type": "string" + }, + "path_params": { + "type": "object" + }, + "query": { + "type": "object" + } + }, + "required": [ + "operation_id" + ], + "title": "moviepilot_api", + "type": "object" +} diff --git a/app/agent/policy/registry.py b/app/agent/policy/registry.py index 72351bc3e..81f038939 100644 --- a/app/agent/policy/registry.py +++ b/app/agent/policy/registry.py @@ -47,6 +47,8 @@ BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset( "persona", "write_file", "moviepilot_api", + "downloader_operation", + "mediaserver_operation", } ) diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index 45711497c..380096426 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -17,6 +17,7 @@ from app.agent.tools.impl.search_web import SearchWebTool from app.agent.tools.impl.send_local_file import SendLocalFileTool from app.agent.tools.impl.send_message import SendMessageTool from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool +from app.agent.tools.impl.service_operation import DownloaderOperationTool, MediaServerOperationTool from app.agent.tools.impl.write_file import WriteFileTool from app.application.agent import AgentDataContext from app.application.plugin.runtime import get_plugin_manager @@ -59,6 +60,12 @@ class MoviePilotToolFactory: BrowseWebpageTool, QueryDoctorReportTool, ) + # 下载器与媒体服务器原生操作通过 Skill 按需提供给内置 Agent;只有外部 + # HTTP/MCP 工具管理器需要常驻、自描述的结构化入口。 + EXTERNAL_SERVICE_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = ( + DownloaderOperationTool, + MediaServerOperationTool, + ) # 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的 # 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮 @@ -79,7 +86,10 @@ class MoviePilotToolFactory: @classmethod def catalog_factory_revision(cls) -> str: """返回当前内置工具工厂定义的稳定摘要。""" - identities = (f"{tool_class.__module__}.{tool_class.__qualname__}" for tool_class in cls.BUILTIN_TOOL_CLASSES) + identities = ( + f"{tool_class.__module__}.{tool_class.__qualname__}" + for tool_class in (*cls.BUILTIN_TOOL_CLASSES, *cls.EXTERNAL_SERVICE_TOOL_CLASSES) + ) return hashlib.sha256("\n".join(identities).encode("utf-8")).hexdigest() @staticmethod @@ -141,6 +151,7 @@ class MoviePilotToolFactory: stream_handler: Callable = None, agent_context: dict = None, allow_message_tools: bool = True, + include_external_service_tools: bool = False, data: Optional[AgentDataContext] = None, ) -> List[MoviePilotTool]: """ @@ -152,6 +163,8 @@ class MoviePilotToolFactory: """ tools = [] tool_definitions = cls._get_builtin_tool_classes(channel) + if include_external_service_tools: + tool_definitions.extend(cls.EXTERNAL_SERVICE_TOOL_CLASSES) # 创建内置工具 for ToolClass in tool_definitions: tool = ToolClass(session_id=session_id, user_id=user_id, data=data) diff --git a/app/agent/tools/impl/api.py b/app/agent/tools/impl/api.py index fc58fea98..46297b3f9 100644 --- a/app/agent/tools/impl/api.py +++ b/app/agent/tools/impl/api.py @@ -1,6 +1,9 @@ """MoviePilot 结构化 API 网关工具。""" import json +from copy import deepcopy +from functools import lru_cache +from pathlib import Path from typing import Any, Dict, Optional, Type from pydantic import BaseModel, Field, PrivateAttr @@ -12,6 +15,16 @@ from app.agent.tools.tags import ToolTag from app.schemas.types import NotificationChannel +@lru_cache(maxsize=1) +def _load_api_mcp_input_schema() -> dict[str, Any]: + """读取由业务 OpenAPI 生成并经漂移测试锁定的外部 MCP schema。""" + schema_path = Path(__file__).resolve().parents[2] / "policy" / "api_mcp_schema.json" + payload = json.loads(schema_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or not isinstance(payload.get("oneOf"), list): + raise RuntimeError("moviepilot_api MCP schema 无效") + return payload + + class MoviePilotApiInput(BaseModel): # type: ignore[misc] """MoviePilot API 网关的结构化输入参数。""" @@ -54,7 +67,8 @@ class MoviePilotApiTool(MoviePilotTool): ] description: str = ( "调用经过白名单审核的 MoviePilot 业务 API。使用领域 Skill 获取 operation_id、" - "参数和失败处理;不能调用任意 URL、命令或认证接口。" + "参数和失败处理;外部 MCP tools/list 会为每个 operation 提供完整 oneOf 参数合同;" + "不能调用任意 URL、命令或认证接口。" ) require_admin: bool = False args_schema: Type[BaseModel] = MoviePilotApiInput @@ -78,6 +92,10 @@ class MoviePilotApiTool(MoviePilotTool): operation_id = kwargs.get("operation_id") or "未知操作" return f"调用 MoviePilot API:{operation_id}" + def get_mcp_input_schema(self) -> dict[str, Any]: + """返回包含全部白名单 operation 精确参数的 MCP JSON Schema。""" + return deepcopy(_load_api_mcp_input_schema()) + async def _resolve_api_identity(self) -> tuple[str, Optional[str], bool]: """把 Web 或渠道身份解析为真实 MoviePilot 用户身份。""" raw_user_id = str(self._user_id or "") diff --git a/app/agent/tools/impl/service_operation.py b/app/agent/tools/impl/service_operation.py new file mode 100644 index 000000000..9f2a79e57 --- /dev/null +++ b/app/agent/tools/impl/service_operation.py @@ -0,0 +1,391 @@ +"""向 HTTP/MCP 管理入口暴露自描述的服务操作工具。""" + +from __future__ import annotations + +import json +import runpy +import subprocess +import sys +from functools import lru_cache +from pathlib import Path +from typing import Any, ClassVar, Dict, Optional, Type + +from pydantic import BaseModel, ConfigDict, Field + +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.tags import ToolTag +from app.runtime.settings import get_runtime_setting + + +class DownloaderOperationInput(BaseModel): + """下载器操作工具的运行时输入模型。""" + + client: Optional[str] = Field(default=None, description="下载器实例名;省略时使用默认或唯一实例。") + action: str = Field(description="固定下载器 action;MCP inputSchema 会按 action 展示精确枚举和参数。") + arguments: Dict[str, Any] = Field(default_factory=dict, description="当前 action 的结构化参数对象。") + + model_config = ConfigDict(extra="forbid") + + +class MediaServerOperationInput(BaseModel): + """媒体服务器操作工具的运行时输入模型。""" + + server: Optional[str] = Field(default=None, description="媒体服务器实例名;省略时使用唯一启用实例。") + action: str = Field(description="固定媒体服务器 action;MCP inputSchema 会按 action 展示精确枚举和参数。") + arguments: Dict[str, Any] = Field(default_factory=dict, description="当前 action 的结构化参数对象。") + + model_config = ConfigDict(extra="forbid") + + +@lru_cache(maxsize=2) +def _load_action_contracts(script_path: str) -> dict[str, Any]: + """从固定 Skill 脚本加载无配置副作用的 action 注册表。""" + namespace = runpy.run_path(script_path) + actions = namespace.get("ACTIONS") + if not isinstance(actions, dict): + raise RuntimeError("服务操作脚本缺少 ACTIONS 合同") + return actions + + +def _compact_type_schema(declared_type: str) -> dict[str, Any]: + """把 Skill 的紧凑参数类型转换为标准 JSON Schema。""" + variants: list[dict[str, Any]] = [] + for candidate in declared_type.split("|"): + if candidate.endswith("[]"): + variants.append( + { + "type": "array", + "items": _compact_type_schema(candidate[:-2]), + } + ) + else: + variants.append({"type": candidate}) + return variants[0] if len(variants) == 1 else {"anyOf": variants} + + +def _argument_schema(argument: dict[str, Any]) -> dict[str, Any]: + """构建一个包含类型、说明、默认值和枚举的参数 schema。""" + schema = _compact_type_schema(str(argument["type"])) + name = str(argument["name"]) + schema["description"] = str(argument.get("description") or "") + if "default" in argument: + schema["default"] = argument["default"] + if argument.get("enum"): + schema["enum"] = list(argument["enum"]) + if name == "offset": + schema["minimum"] = 0 + if name == "limit": + schema.update({"minimum": 1, "maximum": 200}) + if name in {"upload_limit", "download_limit", "ratio_limit", "seeding_time_limit"}: + schema["minimum"] = 0 + if name in { + "task_id", + "item_id", + "content", + "location", + "category", + }: + schema["minLength"] = 1 + if name in { + "task_ids", + "wanted_file_ids", + "unwanted_file_ids", + "tags", + "trackers", + "items", + }: + schema["minItems"] = 1 + return schema + + +def _metadata_refresh_items_schema() -> dict[str, Any]: + """返回 metadata.refresh.items 的完整嵌套条目合同。""" + return { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "媒体标题。"}, + "year": { + "anyOf": [{"type": "string"}, {"type": "integer"}], + "description": "媒体年份。", + }, + "type": { + "type": "string", + "enum": ["电影", "电视剧", "音乐"], + "description": "MoviePilot 媒体类型。", + }, + "category": {"type": "string", "description": "媒体分类。"}, + "target_path": {"type": "string", "description": "媒体文件或目录路径。"}, + }, + "additionalProperties": False, + }, + } + + +def _add_action_argument_rules(action: str, schema: dict[str, Any]) -> None: + """把跨字段约束补充为机器可校验的 JSON Schema。""" + task_selector_actions = { + "tasks.start", + "tasks.stop", + "tasks.delete", + "tasks.recheck", + "tasks.reannounce", + "tasks.queue.move", + "tasks.force_start.set", + "tasks.tags.set", + } + if action in task_selector_actions: + schema["oneOf"] = [ + {"required": ["task_id"], "not": {"required": ["task_ids"]}}, + {"required": ["task_ids"], "not": {"required": ["task_id"]}}, + ] + if action == "tasks.files.selection.set": + schema["anyOf"] = [ + {"required": ["wanted_file_ids"]}, + {"required": ["unwanted_file_ids"]}, + ] + if action == "tasks.properties.set": + schema["anyOf"] = [ + {"required": [name]} + for name in ("upload_limit", "download_limit", "ratio_limit", "seeding_time_limit") + ] + if action == "session.speed_limits.set": + schema["anyOf"] = [ + {"required": ["download_limit"]}, + {"required": ["upload_limit"]}, + ] + if action == "items.music.search": + schema["anyOf"] = [ + {"required": ["title"]}, + {"required": ["artist"]}, + {"required": ["album"]}, + ] + if action == "items.season_episodes": + schema["anyOf"] = [{"required": ["item_id"]}, {"required": ["title"]}] + + +def _build_arguments_schema(action: str, spec: Any) -> dict[str, Any]: + """把一个 action 的脚本合同转换为 MCP arguments schema。""" + contract = spec.to_dict(action) + properties = { + argument["name"]: _argument_schema(argument) + for argument in contract["arguments"] + } + if action == "metadata.refresh": + properties["items"] = _metadata_refresh_items_schema() + properties["items"]["description"] = contract["arguments"][0]["description"] + schema: dict[str, Any] = { + "type": "object", + "description": ";".join(contract.get("argument_rules") or []), + "properties": properties, + "required": list(contract.get("required_arguments") or []), + "additionalProperties": False, + } + _add_action_argument_rules(action, schema) + return schema + + +def _build_mcp_input_schema( + *, + actions: dict[str, Any], + selector_name: str, + selector_description: str, + title: str, +) -> dict[str, Any]: + """构建按 action 分支且可由外部 MCP Client 直接发现的输入合同。""" + action_names = sorted(actions) + branches = [] + for action in action_names: + contract = actions[action].to_dict(action) + branches.append( + { + "type": "object", + "title": action, + "description": ( + f"{contract['description']} Effect: {contract['effect']}. " + f"Providers: {', '.join(contract['providers'])}." + ), + "properties": { + selector_name: {"type": "string", "description": selector_description}, + "action": {"type": "string", "const": action}, + "arguments": _build_arguments_schema(action, actions[action]), + }, + "required": ["action", "arguments"], + "additionalProperties": False, + } + ) + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": title, + "type": "object", + "properties": { + selector_name: {"type": "string", "description": selector_description}, + "action": {"type": "string", "enum": action_names}, + "arguments": { + "type": "object", + "description": "参数必须匹配所选 action 的 oneOf 分支。", + }, + }, + "required": ["action", "arguments"], + "oneOf": branches, + } + + +def _parse_script_payload(stdout: str) -> dict[str, Any]: + """从可能带启动日志的 stdout 中提取最后一个完整 JSON 对象。""" + decoder = json.JSONDecoder() + for index in range(len(stdout) - 1, -1, -1): + if stdout[index] != "{": + continue + try: + payload, end = decoder.raw_decode(stdout[index:]) + except json.JSONDecodeError: + continue + if not stdout[index + end :].strip() and isinstance(payload, dict): + return payload + raise RuntimeError("服务操作脚本未返回有效 JSON") + + +def _run_service_script( + *, + relative_script: str, + selector_flag: str, + selector_value: Optional[str], + action: str, + arguments: Dict[str, Any], +) -> dict[str, Any]: + """不经 shell 调用固定 Skill 脚本,并只返回其结构化 JSON envelope。""" + root_path = Path(get_runtime_setting("ROOT_PATH")) + script_path = root_path / relative_script + command = [ + sys.executable, + str(script_path), + "call", + "--action", + action, + "--arguments", + json.dumps(arguments, ensure_ascii=False, separators=(",", ":")), + ] + if selector_value: + command.extend([selector_flag, selector_value]) + completed = subprocess.run( + command, + cwd=root_path, + capture_output=True, + text=True, + check=False, + ) + return _parse_script_payload(completed.stdout) + + +class _ServiceOperationTool(MoviePilotTool): + """固定 Skill 脚本的 MCP-only 安全包装基类。""" + + require_admin: bool = True + _relative_script: ClassVar[str] + _selector_name: ClassVar[str] + _selector_flag: ClassVar[str] + _blocking_bucket: ClassVar[str] + + def get_mcp_input_schema(self) -> dict[str, Any]: + """返回保留 action 条件分支的完整 MCP JSON Schema。""" + root_path = Path(get_runtime_setting("ROOT_PATH")) + actions = _load_action_contracts(str(root_path / self._relative_script)) + selector_description = self.args_schema.model_json_schema()["properties"][self._selector_name][ + "description" + ] + return _build_mcp_input_schema( + actions=actions, + selector_name=self._selector_name, + selector_description=selector_description, + title=self.name, + ) + + async def _run_operation( + self, + *, + selector_value: Optional[str], + action: str, + arguments: Dict[str, Any], + ) -> str: + """在线程池中运行固定脚本并序列化安全结果。""" + payload = await self.run_blocking( + self._blocking_bucket, + _run_service_script, + relative_script=self._relative_script, + selector_flag=self._selector_flag, + selector_value=selector_value, + action=action, + arguments=arguments, + ) + return json.dumps(payload, ensure_ascii=False, indent=2) + + +class DownloaderOperationTool(_ServiceOperationTool): + """外部 HTTP/MCP 调用下载器原生能力的结构化工具。""" + + name: str = "downloader_operation" + description: str = ( + "Operate a configured qBittorrent, Transmission, or rTorrent instance. " + "The input schema contains one exact branch per action, including providers, " + "effects, required fields, types, defaults, enums, and cross-field constraints." + ) + tags: list[str] = [ToolTag.Download] + args_schema: Type[BaseModel] = DownloaderOperationInput + _relative_script = "skills/downloader-operation/scripts/mp-downloader.py" + _selector_name = "client" + _selector_flag = "--client" + _blocking_bucket = "downloader" + + async def run( + self, + action: str, + arguments: Optional[Dict[str, Any]] = None, + client: Optional[str] = None, + ) -> str: + """执行一次自描述的下载器操作。""" + return await self._run_operation( + selector_value=client, + action=action, + arguments=arguments or {}, + ) + + +class MediaServerOperationTool(_ServiceOperationTool): + """外部 HTTP/MCP 调用媒体服务器原生能力的结构化工具。""" + + name: str = "mediaserver_operation" + description: str = ( + "Operate a configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, " + "or Navidrome server. The input schema contains one exact branch per action, " + "including providers, effects, required fields, types, defaults, enums, nested " + "item fields, and cross-field constraints." + ) + tags: list[str] = [ToolTag.Media] + args_schema: Type[BaseModel] = MediaServerOperationInput + _relative_script = "skills/mediaserver-operation/scripts/mp-mediaserver.py" + _selector_name = "server" + _selector_flag = "--server" + _blocking_bucket = "mediaserver" + + async def run( + self, + action: str, + arguments: Optional[Dict[str, Any]] = None, + server: Optional[str] = None, + ) -> str: + """执行一次自描述的媒体服务器操作。""" + return await self._run_operation( + selector_value=server, + action=action, + arguments=arguments or {}, + ) + + +__all__ = [ + "DownloaderOperationInput", + "DownloaderOperationTool", + "MediaServerOperationInput", + "MediaServerOperationTool", +] diff --git a/app/agent/tools/manager.py b/app/agent/tools/manager.py index f5f6dcd89..4fc80b6c5 100644 --- a/app/agent/tools/manager.py +++ b/app/agent/tools/manager.py @@ -108,6 +108,7 @@ class MoviePilotToolsManager: username="API Client", stream_handler=None, agent_context={"is_admin": self.is_admin}, + include_external_service_tools=True, data=self._data, ) self.catalog = catalog @@ -211,14 +212,17 @@ class MoviePilotToolsManager: for tool in tools: if getattr(tool, "_require_admin", False) and not self.is_admin: continue - # 获取工具的输入参数模型 - args_schema = getattr(tool, "args_schema", None) - if args_schema: - # 将Pydantic模型转换为JSON Schema - input_schema = self._convert_to_json_schema(args_schema) + # MCP-only 复杂工具可以保留 oneOf、嵌套对象和跨字段约束;普通工具 + # 继续沿用兼容投影,避免一次性改变已有外部合同。 + schema_factory = getattr(tool, "get_mcp_input_schema", None) + if callable(schema_factory): + input_schema = schema_factory() else: - # 如果没有args_schema,使用基本信息 - input_schema = {"type": "object", "properties": {}, "required": []} + args_schema = getattr(tool, "args_schema", None) + if args_schema: + input_schema = self._convert_to_json_schema(args_schema) + else: + input_schema = {"type": "object", "properties": {}, "required": []} tools_list.append( ToolDefinition( diff --git a/scripts/generate_agent_api_mcp_schema.py b/scripts/generate_agent_api_mcp_schema.py new file mode 100644 index 000000000..1beee79f2 --- /dev/null +++ b/scripts/generate_agent_api_mcp_schema.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""从 FastAPI OpenAPI 生成 moviepilot_api 的外部 MCP 输入合同。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from fastapi import FastAPI + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS # noqa: E402 +from app.agent.policy.api_mcp_contract import build_api_mcp_input_schema # noqa: E402 +from app.api.apiv1 import api_router # noqa: E402 + +OUTPUT_PATH = PROJECT_ROOT / "app/agent/policy/api_mcp_schema.json" + + +def generate_schema() -> dict: + """聚合 v1 路由 OpenAPI 并生成稳定的网关 MCP schema。""" + app = FastAPI() + app.include_router(api_router, prefix="/api/v1") + return build_api_mcp_input_schema( + openapi=app.openapi(), + routes=API_OPERATION_ROUTES, + specs=API_OPERATION_SPECS, + ) + + +def main() -> int: + """写入格式稳定的生成文件。""" + schema = generate_schema() + OUTPUT_PATH.write_text( + json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"generated {OUTPUT_PATH.relative_to(PROJECT_ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/downloader-operation/SKILL.md b/skills/downloader-operation/SKILL.md index 6725e7176..63a15d8b5 100644 --- a/skills/downloader-operation/SKILL.md +++ b/skills/downloader-operation/SKILL.md @@ -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 diff --git a/skills/downloader-operation/scripts/mp-downloader.py b/skills/downloader-operation/scripts/mp-downloader.py index 9ea128f1c..3add916a7 100644 --- a/skills/downloader-operation/scripts/mp-downloader.py +++ b/skills/downloader-operation/scripts/mp-downloader.py @@ -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 diff --git a/skills/mediaserver-operation/SKILL.md b/skills/mediaserver-operation/SKILL.md index e9754b099..64f0a5fa1 100644 --- a/skills/mediaserver-operation/SKILL.md +++ b/skills/mediaserver-operation/SKILL.md @@ -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 diff --git a/skills/mediaserver-operation/scripts/mp-mediaserver.py b/skills/mediaserver-operation/scripts/mp-mediaserver.py index d02fa7a84..b98e45e2c 100644 --- a/skills/mediaserver-operation/scripts/mp-mediaserver.py +++ b/skills/mediaserver-operation/scripts/mp-mediaserver.py @@ -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 diff --git a/tests/test_agent_tool_policy.py b/tests/test_agent_tool_policy.py index fcea9b3c3..c63e59680 100644 --- a/tests/test_agent_tool_policy.py +++ b/tests/test_agent_tool_policy.py @@ -140,6 +140,10 @@ def test_builtin_policy_inventory_covers_every_fixed_tool() -> None: _tool_class_name(MoviePilotApiTool), } ) + fixed_tool_names.update( + _tool_class_name(tool_class) + for tool_class in MoviePilotToolFactory.EXTERNAL_SERVICE_TOOL_CLASSES + ) assert DEFAULT_TOOL_POLICY_REGISTRY.builtin_tool_inventory == fixed_tool_names diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 7a140b7ec..c75b2c913 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -33,8 +33,8 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "create-moviepilot-plugin": "5", "create-moviepilot-skill": "3", "publish-moviepilot-plugin": "3", - "downloader-operation": "1", - "mediaserver-operation": "1", + "downloader-operation": "2", + "mediaserver-operation": "2", } for skill_name, expected_version in expected_versions.items(): diff --git a/tests/test_mcp_plugin_tools.py b/tests/test_mcp_plugin_tools.py index 0aa583edb..7f1ab01a6 100644 --- a/tests/test_mcp_plugin_tools.py +++ b/tests/test_mcp_plugin_tools.py @@ -56,7 +56,7 @@ def test_mcp_refreshes_tools_after_plugin_lifecycle_change( MoviePilotToolFactory, "_get_builtin_tool_classes", return_value=[], - ): + ), patch.object(MoviePilotToolFactory, "EXTERNAL_SERVICE_TOOL_CLASSES", ()): tool_manager = MoviePilotToolsManager( session_id="mcp-plugin-test", user_id="api_user", diff --git a/tests/test_service_operation_mcp_tools.py b/tests/test_service_operation_mcp_tools.py new file mode 100644 index 000000000..6bf65f41a --- /dev/null +++ b/tests/test_service_operation_mcp_tools.py @@ -0,0 +1,130 @@ +"""下载器与媒体服务器外部 MCP 工具合同测试。""" + +import asyncio +import json +from unittest.mock import patch + +from app.agent.tools.factory import MoviePilotToolFactory +from app.agent.tools.impl.service_operation import ( + DownloaderOperationTool, + MediaServerOperationTool, +) +from app.agent.tools.manager import MoviePilotToolsManager + + +def _branch(schema: dict, action: str) -> dict: + """从 MCP oneOf schema 中读取指定 action 分支。""" + return next( + branch + for branch in schema["oneOf"] + if branch["properties"]["action"].get("const") == action + ) + + +def test_downloader_mcp_schema_exposes_every_action_argument_and_rule() -> None: + """外部 MCP Client 应在 tools/list 中直接看到下载器条件参数合同。""" + tool = DownloaderOperationTool(session_id="session", user_id="api_user") + schema = tool.get_mcp_input_schema() + branch = _branch(schema, "tasks.queue.move") + arguments = branch["properties"]["arguments"] + + assert len(schema["oneOf"]) == len(schema["properties"]["action"]["enum"]) + assert arguments["properties"]["task_ids"] == { + "type": "array", + "items": {"type": "string"}, + "description": "多个任务的 provider 原生 hash 或 ID;与 task_id 二选一。", + "minItems": 1, + } + assert arguments["properties"]["position"]["enum"] == ["top", "up", "down", "bottom"] + assert arguments["required"] == ["position"] + assert arguments["oneOf"] == [ + {"required": ["task_id"], "not": {"required": ["task_ids"]}}, + {"required": ["task_ids"], "not": {"required": ["task_id"]}}, + ] + assert arguments["additionalProperties"] is False + + +def test_mediaserver_mcp_schema_exposes_nested_refresh_item_fields() -> None: + """外部 MCP Client 应直接看到 metadata.refresh 的嵌套字段与枚举。""" + tool = MediaServerOperationTool(session_id="session", user_id="api_user") + schema = tool.get_mcp_input_schema() + branch = _branch(schema, "metadata.refresh") + arguments = branch["properties"]["arguments"] + item_schema = arguments["properties"]["items"]["items"] + + assert arguments["required"] == ["items"] + assert item_schema["properties"]["type"]["enum"] == ["电影", "电视剧", "音乐"] + assert item_schema["properties"]["year"]["anyOf"] == [ + {"type": "string"}, + {"type": "integer"}, + ] + assert set(item_schema["properties"]) == { + "title", + "year", + "type", + "category", + "target_path", + } + assert item_schema["additionalProperties"] is False + + +def test_direct_manager_preserves_service_operation_mcp_schema() -> None: + """工具管理器不得把服务操作的 oneOf 和嵌套 schema 压平成普通对象。""" + manager = MoviePilotToolsManager(session_id="session", user_id="api_user") + manager.tools = [DownloaderOperationTool(session_id="session", user_id="api_user")] + + definition = manager.list_tools()[0] + + assert definition.name == "downloader_operation" + assert definition.input_schema["oneOf"] + assert _branch(definition.input_schema, "tasks.files")["properties"]["arguments"][ + "required" + ] == ["task_id"] + + +def test_factory_only_adds_service_operation_tools_for_external_manager() -> None: + """内置 Agent 保持 Skill 按需加载,外部管理入口才增加两个常驻工具。""" + with ( + patch.object(MoviePilotToolFactory, "BUILTIN_TOOL_CLASSES", ()), + patch("app.agent.tools.factory._get_plugin_agent_tools", return_value=[]), + ): + internal = MoviePilotToolFactory.create_tools( + session_id="session", + user_id="user", + ) + external = MoviePilotToolFactory.create_tools( + session_id="session", + user_id="api_user", + include_external_service_tools=True, + ) + + internal_names = {tool.name for tool in internal} + external_names = {tool.name for tool in external} + assert "downloader_operation" not in internal_names + assert "mediaserver_operation" not in internal_names + assert {"downloader_operation", "mediaserver_operation"}.issubset(external_names) + + +def test_service_operation_tool_calls_fixed_script_once() -> None: + """一次 MCP tools/call 应只执行一次固定脚本并返回其 JSON envelope。""" + tool = DownloaderOperationTool(session_id="session", user_id="api_user") + with patch( + "app.agent.tools.impl.service_operation._run_service_script", + return_value={"success": True, "action": "tasks.list", "data": {"items": []}}, + ) as runner: + result = asyncio.run( + tool.run( + client="main", + action="tasks.list", + arguments={"limit": 20}, + ) + ) + + assert json.loads(result)["success"] is True + runner.assert_called_once_with( + relative_script="skills/downloader-operation/scripts/mp-downloader.py", + selector_flag="--client", + selector_value="main", + action="tasks.list", + arguments={"limit": 20}, + ) diff --git a/tests/test_service_operation_skills.py b/tests/test_service_operation_skills.py index a6f2a9e81..ff15e8127 100644 --- a/tests/test_service_operation_skills.py +++ b/tests/test_service_operation_skills.py @@ -186,6 +186,88 @@ def test_downloader_instances_and_capabilities_do_not_expose_credentials( assert any(item["action"] == "tasks.peers" for item in capabilities["actions"]) +def test_downloader_capability_exposes_complete_action_arguments( + downloader_module: ModuleType, +) -> None: + """单 action 能力查询应直接返回类型、必填性、默认值和枚举。""" + result = downloader_module.list_capabilities(None, "tasks.queue.move") + + assert len(result["actions"]) == 1 + action = result["actions"][0] + arguments = {item["name"]: item for item in action["arguments"]} + assert arguments["task_ids"]["type"] == "string[]" + assert arguments["position"] == { + "name": "position", + "type": "string", + "required": True, + "description": "目标队列位置。", + "enum": ["top", "up", "down", "bottom"], + } + assert action["argument_rules"] == ["task_id 与 task_ids 必须提供且只能选择一种。"] + + +def test_downloader_argument_validation_reports_all_errors_before_config_load( + downloader_module: ModuleType, + monkeypatch, +) -> None: + """下载器调用应一次返回全部可检测参数错误,且不连接配置或 provider。""" + load_configs = MagicMock() + monkeypatch.setattr(downloader_module, "_load_configs", load_configs) + + with pytest.raises(ValueError) as error: + downloader_module.call_action( + None, + "tasks.queue.move", + { + "position": "sideways", + "task_id": "", + "task_ids": [], + "unexpected": True, + }, + ) + + message = str(error.value) + assert "未知参数: unexpected" in message + assert "参数 position 仅支持: top, up, down, bottom" in message + assert "task_id 与 task_ids 必须提供且只能选择一种" in message + load_configs.assert_not_called() + + +def test_downloader_ambiguous_instance_error_lists_reusable_names( + downloader_module: ModuleType, + monkeypatch, +) -> None: + """实例歧义应直接返回可重试名称,避免额外 instances 探测。""" + first = _downloader_config() + first.name = "main" + first.default = False + second = _downloader_config("transmission") + second.name = "backup" + second.default = False + monkeypatch.setattr(downloader_module, "_load_configs", lambda: [first, second]) + + with pytest.raises(ValueError, match="main、backup"): + downloader_module._select_config(None) + + +def test_downloader_call_uses_default_instance_without_discovery( + downloader_module: ModuleType, + monkeypatch, +) -> None: + """省略 client 时应在同一次调用中选择默认实例并执行。""" + config = _downloader_config() + client = MagicMock() + client.get_torrents.return_value = ([], False) + monkeypatch.setattr(downloader_module, "_load_configs", lambda: [config]) + monkeypatch.setattr(downloader_module, "_build_client", lambda _config: client) + + result = downloader_module.call_action(None, "tasks.list", {"limit": 10}) + + assert result["success"] is True + assert result["client"] == "main" + client.get_torrents.assert_called_once_with(ids=None, status=None, tags=None) + + def test_downloader_task_list_is_paged_and_normalized( downloader_module: ModuleType, monkeypatch, @@ -315,6 +397,86 @@ def test_mediaserver_capabilities_are_provider_specific( assert "secret" not in str(result) +def test_mediaserver_capability_exposes_nested_refresh_contract( + mediaserver_module: ModuleType, +) -> None: + """媒体服务器能力查询应直接描述嵌套刷新条目字段。""" + result = mediaserver_module.list_capabilities(None, "metadata.refresh") + + action = result["actions"][0] + assert action["required_arguments"] == ["items"] + assert action["arguments"] == [ + { + "name": "items", + "type": "object[]", + "required": True, + "description": ( + "刷新条目;每项支持 title:string、year:string|integer、type:电影|电视剧|音乐、" + "category:string、target_path:string。" + ), + } + ] + + +def test_mediaserver_argument_validation_reports_nested_errors_before_config_load( + mediaserver_module: ModuleType, + monkeypatch, +) -> None: + """媒体服务器调用应一次返回顶层及嵌套参数错误,且不读取实例配置。""" + load_configs = MagicMock() + monkeypatch.setattr(mediaserver_module, "_load_configs", load_configs) + + with pytest.raises(ValueError) as error: + mediaserver_module.call_action( + None, + "metadata.refresh", + { + "items": [{"type": "movie", "target_path": 42, "extra": True}], + "unexpected": True, + }, + ) + + message = str(error.value) + assert "未知参数: unexpected" in message + assert "items[0] 未知字段: extra" in message + assert "items[0].type 仅支持: 电影、电视剧、音乐" in message + assert "items[0].target_path 必须是 string" in message + load_configs.assert_not_called() + + +def test_mediaserver_ambiguous_instance_error_lists_reusable_names( + mediaserver_module: ModuleType, + monkeypatch, +) -> None: + """媒体服务器歧义应直接返回可重试名称,避免额外 instances 探测。""" + first = _mediaserver_config() + first.name = "living-room" + second = _mediaserver_config("plex") + second.name = "study" + monkeypatch.setattr(mediaserver_module, "_load_configs", lambda: [first, second]) + + with pytest.raises(ValueError, match="living-room、study"): + mediaserver_module._select_config(None) + + +def test_mediaserver_call_uses_only_instance_without_discovery( + mediaserver_module: ModuleType, + monkeypatch, +) -> None: + """省略 server 时应在同一次调用中选择唯一实例并执行。""" + config = _mediaserver_config() + client = MagicMock() + client.get_medias_count.return_value = {"movie": 12} + monkeypatch.setattr(mediaserver_module, "_load_configs", lambda: [config]) + monkeypatch.setattr(mediaserver_module, "_build_client", lambda _config: client) + + result = mediaserver_module.call_action(None, "server.statistics", {}) + + assert result["success"] is True + assert result["server"] == "living-room" + client.get_medias_count.assert_called_once_with() + + def test_mediaserver_items_and_scan_use_fixed_public_methods( mediaserver_module: ModuleType, monkeypatch, @@ -406,3 +568,36 @@ def test_skill_docs_forbid_arbitrary_network_and_preserve_high_level_workflows() assert "download.add" in downloader assert "arbitrary SDK methods" in mediaserver assert "library.exists" in mediaserver + assert "Do not routinely call `instances` or `capabilities`" in downloader + assert "Do not routinely call `instances` or `capabilities`" in mediaserver + assert "all detectable argument errors" in downloader + assert "all detectable argument errors" in mediaserver + + +def test_service_operation_skill_docs_cover_every_action_and_argument( + downloader_module: ModuleType, + mediaserver_module: ModuleType, +) -> None: + """Agent 加载 Skill 后应能直接看到每个 action 的功能和全部参数名。""" + pairs = ( + ( + downloader_module.ACTIONS, + PROJECT_ROOT / "skills/downloader-operation/SKILL.md", + ), + ( + mediaserver_module.ACTIONS, + PROJECT_ROOT / "skills/mediaserver-operation/SKILL.md", + ), + ) + + for actions, path in pairs: + content = path.read_text(encoding="utf-8") + assert "## Complete Action Contract" in content + for action_name, spec in actions.items(): + row = next( + (line for line in content.splitlines() if line.startswith(f"| `{action_name}` |")), + "", + ) + assert row, f"{path} 缺少 {action_name} 的独立合同表格行" + for argument in spec.arguments: + assert f"`{argument.name}" in row