Files
MoviePilot/app/agent/api/executor.py

167 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通过固定宿主 API 路由执行 Agent 业务操作。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Mapping
from urllib.parse import quote
from app.adapters.network.http import AsyncRequestUtils
from app.agent.policy.api import ApiOperationRoute, resolve_api_route
from app.application.security.token import create_access_token
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
class ApiExecutionError(RuntimeError):
"""固定 API 路由无法构造或请求失败。"""
@dataclass(frozen=True)
class ApiExecutionContext:
"""API 请求使用的 Agent 身份和会话上下文。"""
user_id: str
username: str | None
is_admin: bool
session_id: str | None = None
channel: str | None = None
source: str | None = None
class MoviePilotApiExecutor:
"""把 operation ID 转换为受控的 MoviePilot REST API 请求。"""
_ALLOWED_SOURCES = frozenset({"tmdb", "douban", "bangumi", "anilist"})
_ALLOWED_DOWNLOAD_ACTIONS = frozenset({"start", "stop"})
def __init__(
self,
*,
context: ApiExecutionContext,
request_factory: type[AsyncRequestUtils] = AsyncRequestUtils,
) -> None:
"""绑定调用身份和 HTTP 传输工厂。"""
self._context = context
self._request_factory = request_factory
@staticmethod
def _resolve_base_url() -> str:
"""解析本机 API 基址,避免把任意用户输入当成请求目标。"""
configured_domain = str(get_runtime_setting("APP_DOMAIN", "") or "").strip()
if configured_domain.startswith(("http://", "https://")):
return configured_domain.rstrip("/")
host = str(get_runtime_setting("HOST", "127.0.0.1") or "127.0.0.1")
if host in {"0.0.0.0", "::", "[::]"}:
host = "127.0.0.1"
port = int(get_runtime_setting("PORT", 3001))
return f"http://{host}:{port}"
@classmethod
def _render_path(
cls,
route: ApiOperationRoute,
path_params: Mapping[str, Any],
) -> str:
"""替换固定路由占位符并校验具有枚举约束的路径参数。"""
rendered = route.path
required = []
cursor = 0
while True:
start = rendered.find("{", cursor)
if start < 0:
break
end = rendered.find("}", start)
if end < 0:
raise ApiExecutionError("API 路由占位符格式无效")
required.append(rendered[start + 1 : end])
cursor = end + 1
for name in required:
value = path_params.get(name)
if value in (None, ""):
raise ApiExecutionError(f"缺少 API 路径参数: {name}")
if name == "source" and str(value).lower() not in cls._ALLOWED_SOURCES:
raise ApiExecutionError("媒体来源不在 API 白名单内")
if name == "action" and str(value).lower() not in cls._ALLOWED_DOWNLOAD_ACTIONS:
raise ApiExecutionError("下载动作不在 API 白名单内")
rendered = rendered.replace("{" + name + "}", quote(str(value), safe=""))
return rendered
def _build_headers(self) -> dict[str, str]:
"""构造仅包含宿主生成令牌的认证头。"""
user_id = str(self._context.user_id or "")
if not user_id.isdigit():
raise ApiExecutionError("当前 Agent 身份没有可用于 API 鉴权的用户 ID")
token = create_access_token(
userid=int(user_id),
username=self._context.username or user_id,
super_user=self._context.is_admin,
)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
if self._context.session_id:
headers["X-MoviePilot-Agent-Session"] = self._context.session_id
if self._context.channel:
headers["X-MoviePilot-Agent-Channel"] = self._context.channel
if self._context.source:
headers["X-MoviePilot-Agent-Source"] = self._context.source
return headers
async def execute(
self,
operation_id: str,
*,
path_params: Mapping[str, Any] | None = None,
query: Mapping[str, Any] | None = None,
body: Mapping[str, Any] | None = None,
) -> str:
"""执行白名单 operation,并把响应转换为稳定 JSON 文本。"""
route = resolve_api_route(operation_id)
if route is None:
raise ApiExecutionError(f"未注册 API 路由: {operation_id}")
path = self._render_path(route, path_params or {})
url = f"{self._resolve_base_url()}{path}"
query_data = dict(query or {})
body_data = dict(body or {})
if route.method == "GET" and body_data:
query_data.update(body_data)
body_data = {}
request = self._request_factory(
headers=self._build_headers(),
timeout=30,
verify=False,
trust_env=False,
)
try:
response = await request.request(
method=route.method,
url=url,
params=query_data or None,
json=body_data or None,
raise_exception=True,
)
if response is None:
raise ApiExecutionError("MoviePilot API 没有返回响应")
try:
payload = response.json()
status_code = response.status_code
finally:
await response.aclose()
except ApiExecutionError:
raise
except Exception as error:
logger.warning(f"Agent API 请求失败: operation={operation_id} error={error}")
raise ApiExecutionError(f"MoviePilot API 请求失败: {operation_id}") from error
if status_code >= 400:
return json.dumps(
{"success": False, "error": "api_error", "status_code": status_code, "data": payload},
ensure_ascii=False,
)
return json.dumps(payload, ensure_ascii=False, default=str)
__all__ = ["ApiExecutionContext", "ApiExecutionError", "MoviePilotApiExecutor"]