feat(agent): add compatible collection pagination

This commit is contained in:
jxxghp
2026-09-01 07:43:55 +08:00
parent c4d8185d8d
commit a73714e5e7
31 changed files with 2754 additions and 181 deletions
+36
View File
@@ -35,6 +35,12 @@ class MoviePilotApiExecutor:
_ALLOWED_SOURCES = frozenset({"tmdb", "douban", "bangumi", "anilist"})
_ALLOWED_DOWNLOAD_ACTIONS = frozenset({"start", "stop"})
_COLLECTION_HEADERS = {
"x-result-count": "result_count",
"x-total-count": "total_count",
"x-page": "page",
"x-page-size": "count",
}
def __init__(
self,
@@ -110,6 +116,34 @@ class MoviePilotApiExecutor:
headers["X-MoviePilot-Agent-Source"] = self._context.source
return headers
@classmethod
def _attach_collection_metadata(
cls,
payload: Any,
headers: Mapping[str, Any],
) -> Any:
"""把 REST 数量响应头投影为 Agent 可直接读取的附加集合元数据。"""
if not isinstance(payload, Mapping):
return payload
normalized_headers = {
str(name).lower(): value
for name, value in headers.items()
}
collection = {}
for header_name, field_name in cls._COLLECTION_HEADERS.items():
raw_value = normalized_headers.get(header_name)
if raw_value is None:
continue
try:
collection[field_name] = int(raw_value)
except (TypeError, ValueError):
continue
if not collection:
return payload
result = dict(payload)
result["collection"] = collection
return result
async def execute(
self,
operation_id: str,
@@ -150,6 +184,7 @@ class MoviePilotApiExecutor:
try:
payload = response.json()
status_code = response.status_code
response_headers = dict(response.headers)
finally:
await response.aclose()
except ApiExecutionError:
@@ -162,6 +197,7 @@ class MoviePilotApiExecutor:
{"success": False, "error": "api_error", "status_code": status_code, "data": payload},
ensure_ascii=False,
)
payload = self._attach_collection_metadata(payload, response_headers)
return json.dumps(payload, ensure_ascii=False, default=str)
File diff suppressed because it is too large Load Diff
+162 -5
View File
@@ -754,6 +754,12 @@ def _person_credits_operation(openapi: Mapping[str, Any]) -> dict[str, Any]:
for source_path in source_paths.values():
if source_path not in paths:
raise ValueError(f"OpenAPI 缺少人物作品端点: {source_path}")
representative = paths[source_paths["douban"]].get("get")
responses = (
deepcopy(representative.get("responses"))
if isinstance(representative, Mapping)
else {}
)
return {
"summary": "Read person credits",
"parameters": [
@@ -785,6 +791,7 @@ def _person_credits_operation(openapi: Mapping[str, Any]) -> dict[str, Any]:
"description": "Page size used by Bangumi and AniList; other sources ignore it.",
},
],
"responses": responses,
}
@@ -874,6 +881,145 @@ def _apply_operation_overrides(
return body_schema, None
def _resolve_openapi_schema(
schema: Mapping[str, Any],
components: Mapping[str, Any],
) -> Mapping[str, Any]:
"""解析响应中使用的本地 OpenAPI 引用与可空联合。"""
reference = schema.get("$ref")
if isinstance(reference, str) and reference.startswith("#/components/schemas/"):
resolved = components.get(reference.rsplit("/", 1)[-1])
if isinstance(resolved, Mapping):
return _resolve_openapi_schema(resolved, components)
alternatives = schema.get("anyOf")
if isinstance(alternatives, list):
for alternative in alternatives:
if not isinstance(alternative, Mapping):
continue
resolved = _resolve_openapi_schema(alternative, components)
if resolved.get("type") != "null":
return resolved
return schema
def _response_data_schema(
operation: Mapping[str, Any],
components: Mapping[str, Any],
) -> Mapping[str, Any]:
"""读取统一响应的 data schema,供 MCP 标注集合输出合同。"""
responses = operation.get("responses")
if not isinstance(responses, Mapping):
return {}
success = responses.get("200") or responses.get(200)
if not isinstance(success, Mapping):
return {}
content = success.get("content")
if not isinstance(content, Mapping):
return {}
media = content.get("application/json")
if not isinstance(media, Mapping):
return {}
raw_schema = media.get("schema")
if not isinstance(raw_schema, Mapping):
return {}
response_schema = _resolve_openapi_schema(raw_schema, components)
properties = response_schema.get("properties")
if not isinstance(properties, Mapping):
return response_schema
data_schema = properties.get("data")
if not isinstance(data_schema, Mapping):
return response_schema
return _resolve_openapi_schema(data_schema, components)
def _collection_response_contract(
operation: Mapping[str, Any],
components: Mapping[str, Any],
) -> dict[str, Any] | None:
"""从 OpenAPI 响应声明提取列表或结构化分页结果的机器可读合同。"""
responses = operation.get("responses")
success = responses.get("200") if isinstance(responses, Mapping) else None
if not isinstance(success, Mapping) and isinstance(responses, Mapping):
success = responses.get(200)
headers = success.get("headers") if isinstance(success, Mapping) else None
header_names = {
str(name).lower()
for name in headers
} if isinstance(headers, Mapping) else set()
data_schema = _response_data_schema(operation, components)
if data_schema.get("type") == "array":
has_total = "x-total-count" in header_names
parameters = operation.get("parameters")
query_parameters = {
str(parameter.get("name")): parameter
for parameter in parameters
if isinstance(parameter, Mapping)
and parameter.get("in") == "query"
} if isinstance(parameters, list) else {}
compatibility_parameters = [
query_parameters.get("page"),
query_parameters.get("count"),
]
defaults_to_unpaginated = all(
isinstance(parameter, Mapping)
and not parameter.get("required", False)
and isinstance(parameter.get("schema"), Mapping)
and "default" not in parameter["schema"]
for parameter in compatibility_parameters
)
return {
"body_shape": "list",
"result_count_field": "collection.result_count",
"total_count_field": "collection.total_count" if has_total else None,
"default_pagination": (
"unpaginated"
if defaults_to_unpaginated
else "endpoint-defined"
),
}
data_properties = data_schema.get("properties")
if not isinstance(data_properties, Mapping) or "total" not in data_properties:
return None
items_field = next(
(name for name in ("items", "list") if name in data_properties),
None,
)
if items_field is None:
return None
return {
"body_shape": "page_object",
"items_field": f"data.{items_field}",
"total_count_field": "data.total",
"default_pagination": "endpoint-defined",
}
def _collection_response_guidance(contract: Mapping[str, Any]) -> str:
"""把集合输出合同转换为 oneOf 分支中的英文自描述说明。"""
if contract.get("body_shape") == "page_object":
return (
f" Collection response: items stay in {contract['items_field']} and the exact total "
f"stays in {contract['total_count_field']}."
)
if contract.get("total_count_field"):
if contract.get("default_pagination") != "unpaginated":
return (
" Collection response: data remains a list and the endpoint's documented "
"pagination or limit defaults remain in effect. Successful gateway output adds "
"collection.result_count and the exact collection.total_count."
)
return (
" Collection response: data remains a list; omit both page and count to preserve the "
"legacy complete result. Successful gateway output adds collection.result_count and "
"the exact collection.total_count."
)
return (
" Collection response: data remains a list and successful gateway output adds "
"collection.result_count. collection.total_count is omitted when the endpoint or its "
"upstream source does not expose a total."
)
def build_api_mcp_input_schema(
*,
openapi: Mapping[str, Any],
@@ -954,25 +1100,36 @@ def build_api_mcp_input_schema(
required.append("body")
spec = spec_by_id[operation_id]
branches.append(
{
collection_contract = _collection_response_contract(operation, components)
collection_guidance = (
_collection_response_guidance(collection_contract)
if collection_contract is not None
else ""
)
branch = {
"type": "object",
"title": operation_id,
"description": (
f"{summary} Method: {route.method}. Path: {route.path}. "
f"Effect: {spec.effect.value}."
f"Effect: {spec.effect.value}.{collection_guidance}"
),
"properties": properties,
"required": required,
"additionalProperties": False,
}
)
if collection_contract is not None:
branch["x-moviepilot-collection"] = collection_contract
branches.append(branch)
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.",
"description": (
"Select the oneOf branch matching operation_id and send exactly its documented fields. "
"Collection branches also describe their additive output metadata in "
"x-moviepilot-collection."
),
"properties": {
"operation_id": {"type": "string", "enum": sorted(routes)},
"path_params": {"type": "object"},
+14 -3
View File
@@ -3,7 +3,7 @@ import time
from collections.abc import Coroutine
from typing import Any, Callable, List, Optional
from fastapi import Depends
from fastapi import Depends, Response
from app.adapters.web.security.access import verify_token
from app.agent.contracts import ReplyMode
@@ -27,7 +27,11 @@ from app.api.dependencies.history import (
get_transfer_execution_repository,
get_transfer_history_mutation_command,
)
from app.api.response import ResponseAPIRouter
from app.api.response import (
COLLECTION_TOTAL_HEADER,
COLLECTION_TOTAL_OPENAPI_KEY,
ResponseAPIRouter,
)
from app.application.agent import get_running_agent_manager
from app.application.configuration import ApiRuntimeConfig
from app.application.history import (
@@ -343,17 +347,24 @@ def _submit_legacy_batch_ai_redo(
"/download",
summary="查询下载历史记录",
response_model=List[_SchemaDownloadHistory],
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
)
async def download_history(
page: Optional[int] = 1,
count: Optional[int] = 30,
query: HistoryQueryService = Depends(get_history_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
response: Response = None,
) -> Any:
"""
按下载时间倒序查询下载历史记录
"""
return await query.list_download(page=page, count=count)
results = await query.list_download(page=page, count=count)
if response is not None:
response.headers[COLLECTION_TOTAL_HEADER] = str(
await query.count_download()
)
return results
@router.delete(
+5 -1
View File
@@ -4,7 +4,10 @@ from fastapi import Depends, HTTPException, status
from app.adapters.web.security.access import verify_token
from app.api.dependencies.history import get_mediaserver_query_service
from app.api.response import ResponseAPIRouter
from app.api.response import (
COLLECTION_PAGINATION_OPENAPI_KEY,
ResponseAPIRouter,
)
from app.application.configuration import get_configured_system_config
from app.application.mediaserver import (
MediaServerQueryService,
@@ -149,6 +152,7 @@ def exists(
"/notexists",
summary="查询媒体库缺失信息(媒体服务器)",
response_model=List[_SchemaNotExistMediaInfo],
openapi_extra={COLLECTION_PAGINATION_OPENAPI_KEY: True},
)
def not_exists(
media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token)
+15 -2
View File
@@ -6,6 +6,7 @@ import aiofiles
from anyio import Path as AsyncPath
from fastapi import Depends, Header, HTTPException, Query, Security
from starlette import status
from starlette.responses import Response as StarletteResponse
from starlette.responses import StreamingResponse
from app.adapters.web.security.access import (
@@ -24,7 +25,11 @@ from app.api.dependencies.auth import (
)
from app.api.dependencies.plugin import get_plugin_config_command
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.api.response import (
COLLECTION_TOTAL_HEADER,
COLLECTION_TOTAL_OPENAPI_KEY,
ResponseAPIRouter,
)
from app.application.commands import init_commands
from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config
from app.application.plugin.catalog import get_plugin_catalog_query
@@ -170,13 +175,19 @@ def _verify_plugin_static_file_access(
verify_resource_token(resource_token)
@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin])
@router.get(
"/",
summary="所有插件",
response_model=List[_SchemaPlugin],
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
)
async def all_plugins(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
state: Optional[str] = "all",
force: bool = False,
query: Optional[str] = None,
max_results: Annotated[int, Query(ge=1, le=200)] = 50,
response: StarletteResponse = None,
) -> List[_SchemaPlugin]:
"""
查询插件清单,并支持 Agent 使用关键字和有界结果完成精确选择。
@@ -187,6 +198,8 @@ async def all_plugins(
)
if query:
plugins = [item["plugin"] for item in search_plugin_candidates(query, plugins)]
if response is not None:
response.headers[COLLECTION_TOTAL_HEADER] = str(len(plugins))
return plugins[:max_results]
+11 -2
View File
@@ -13,7 +13,10 @@ from app.api.dependencies.auth import (
get_current_active_user,
)
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.api.response import (
COLLECTION_PAGINATION_OPENAPI_KEY,
ResponseAPIRouter,
)
from app.application.configuration import get_api_runtime_config_snapshot
from app.application.directory import DirectoryHelper
from app.chain.media import MediaChain
@@ -109,7 +112,12 @@ def manage(request: _SchemaManageRequest, _: ApiPrincipal = Depends(get_current_
)
@router.post("/list", summary="所有目录和文件", response_model=List[_SchemaFileItem])
@router.post(
"/list",
summary="所有目录和文件",
response_model=List[_SchemaFileItem],
openapi_extra={COLLECTION_PAGINATION_OPENAPI_KEY: True},
)
def list_files(
fileitem: _SchemaFileItem,
sort: Optional[str] = "updated_at",
@@ -150,6 +158,7 @@ def _list_files(
"/agent/list",
summary="查询 Agent 可用目录和文件",
response_model=List[_SchemaFileItem],
openapi_extra={COLLECTION_PAGINATION_OPENAPI_KEY: True},
)
def list_agent_files(
fileitem: _SchemaFileItem,
+19 -5
View File
@@ -1,7 +1,7 @@
from typing import Annotated, Any, List, Optional
import cn2an
from fastapi import Depends, Header, HTTPException, Request
from fastapi import Depends, Header, HTTPException, Request, Response
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.web.security.access import (
@@ -26,7 +26,11 @@ from app.api.dependencies.subscription import (
get_subscription_query_service,
)
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.api.response import (
COLLECTION_TOTAL_HEADER,
COLLECTION_TOTAL_OPENAPI_KEY,
ResponseAPIRouter,
)
from app.application.configuration import (
get_api_runtime_config_snapshot,
get_configured_system_config,
@@ -537,7 +541,10 @@ async def seerr_subscribe(
@router.get(
"/history/{mtype}", summary="查询订阅历史", response_model=List[_SchemaSubscribe]
"/history/{mtype}",
summary="查询订阅历史",
response_model=List[_SchemaSubscribe],
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
)
async def subscribe_history(
mtype: str,
@@ -545,16 +552,23 @@ async def subscribe_history(
count: Optional[int] = 30,
query: SubscriptionQueryService = Depends(get_subscription_query_service),
current_user: ApiPrincipal = Depends(get_current_active_user_async),
response: Response = None,
) -> Any:
"""
查询电影、电视剧或音乐订阅历史
"""
return await query.list_history(
username = None if current_user.is_superuser else current_user.name
results = await query.list_history(
mtype,
page=page,
count=count,
username=None if current_user.is_superuser else current_user.name,
username=username,
)
if response is not None:
response.headers[COLLECTION_TOTAL_HEADER] = str(
await query.count_history(mtype, username=username)
)
return results
@router.delete(
+247 -2
View File
@@ -1,8 +1,9 @@
import inspect
import json
from functools import wraps
from typing import Any, Callable
from typing import Annotated, Any, Awaitable, Callable, Optional, get_args, get_origin
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Query, Request
from fastapi.datastructures import DefaultPlaceholder
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute, get_typed_return_annotation
@@ -24,6 +25,58 @@ ERROR_RESPONSES: dict[int, dict[str, Any]] = {
500: {"model": Response[None], "description": "服务器内部错误"},
}
RAW_RESPONSE_OPENAPI_KEY = "x-moviepilot-raw-response"
COLLECTION_PAGINATION_OPENAPI_KEY = "x-moviepilot-compatible-pagination"
COLLECTION_TOTAL_OPENAPI_KEY = "x-moviepilot-exact-total"
COLLECTION_TOTAL_HEADER = "X-Total-Count"
COLLECTION_RESULT_HEADER = "X-Result-Count"
COLLECTION_PAGE_HEADER = "X-Page"
COLLECTION_PAGE_SIZE_HEADER = "X-Page-Size"
COLLECTION_DEFAULT_PAGE_SIZE = 50
COLLECTION_MAX_PAGE_SIZE = 200
_COLLECTION_WINDOW_PARAMETERS = frozenset(
{"page", "count", "limit", "offset", "page_size", "max_results"}
)
_COLLECTION_RESPONSE_HEADERS = {
COLLECTION_RESULT_HEADER: {
"description": "Number of collection items serialized in this response body.",
"schema": {"type": "integer", "minimum": 0},
},
COLLECTION_PAGE_HEADER: {
"description": "Effective one-based page when request pagination is active.",
"schema": {"type": "integer", "minimum": 1},
},
COLLECTION_PAGE_SIZE_HEADER: {
"description": "Effective page size when request pagination is active.",
"schema": {"type": "integer", "minimum": 1},
},
}
def _optional_collection_pagination(
page: Annotated[
Optional[int],
Query(
ge=1,
description=(
"Optional one-based page for a legacy full-list endpoint. Omit both page and "
"count to keep the original unpaginated full result."
),
),
] = None,
count: Annotated[
Optional[int],
Query(
ge=1,
le=COLLECTION_MAX_PAGE_SIZE,
description=(
"Optional page size for a legacy full-list endpoint. Supplying page or count "
f"activates pagination; an omitted count then uses {COLLECTION_DEFAULT_PAGE_SIZE}."
),
),
] = None,
) -> None:
"""校验兼容分页参数;实际切片由统一响应路由在序列化后执行。"""
del page, count
class ResponseAPIRoute(APIRoute):
@@ -41,6 +94,12 @@ class ResponseAPIRoute(APIRoute):
status_code = kwargs.get("status_code")
openapi_extra = kwargs.get("openapi_extra") or {}
force_raw = bool(openapi_extra.get(RAW_RESPONSE_OPENAPI_KEY))
force_collection_pagination = bool(
openapi_extra.get(COLLECTION_PAGINATION_OPENAPI_KEY)
)
endpoint_reports_collection_total = bool(
openapi_extra.get(COLLECTION_TOTAL_OPENAPI_KEY)
)
if isinstance(response_model, DefaultPlaceholder):
inferred_model = get_typed_return_annotation(endpoint)
@@ -54,6 +113,38 @@ class ResponseAPIRoute(APIRoute):
response_model = Response[JsonData]
kwargs["response_model"] = response_model
methods = {
str(method).upper()
for method in (kwargs.get("methods") or [])
}
endpoint_parameters = set(inspect.signature(endpoint).parameters)
collection_response = self._is_collection_response_model(response_model)
collection_window_parameters = endpoint_parameters & _COLLECTION_WINDOW_PARAMETERS
optional_collection_pagination = bool(
collection_response
and ("GET" in methods or force_collection_pagination)
and not collection_window_parameters
)
if optional_collection_pagination:
dependencies = list(kwargs.get("dependencies") or [])
dependencies.append(Depends(_optional_collection_pagination))
kwargs["dependencies"] = dependencies
if collection_response:
kwargs["responses"] = self._merge_collection_response_headers(
kwargs.get("responses"),
include_total=(
optional_collection_pagination
or endpoint_reports_collection_total
),
)
self._collection_response = collection_response
self._optional_collection_pagination = optional_collection_pagination
self._collection_parameter_defaults = self._parameter_defaults(
endpoint,
collection_window_parameters,
)
should_wrap = self._should_wrap_response(
response_model=response_model,
response_class=response_class,
@@ -70,6 +161,21 @@ class ResponseAPIRoute(APIRoute):
super().__init__(path=path, endpoint=endpoint, **kwargs)
def get_route_handler(
self,
) -> Callable[[Request], Awaitable[StarletteResponse]]:
"""在标准端点序列化后附加兼容列表分页与数量元数据。"""
original_handler = super().get_route_handler()
if not self._collection_response:
return original_handler
async def collection_handler(request: Request) -> StarletteResponse:
"""保留列表响应体形状,并在显式请求时执行兼容切片。"""
response = await original_handler(request)
return self._apply_collection_contract(request, response)
return collection_handler
@staticmethod
def _should_wrap_response(
response_model: Any,
@@ -110,6 +216,145 @@ class ResponseAPIRoute(APIRoute):
except TypeError:
return False
@staticmethod
def _is_collection_response_model(response_model: Any) -> bool:
"""判断响应模型的业务数据是否为列表。"""
if response_model is None:
return False
generic_metadata = getattr(
response_model,
"__pydantic_generic_metadata__",
None,
)
if isinstance(generic_metadata, dict):
arguments = generic_metadata.get("args") or ()
if arguments:
response_model = arguments[0]
if get_origin(response_model) is list:
return True
if isinstance(response_model, type):
model_fields = getattr(response_model, "model_fields", None)
root_field = model_fields.get("root") if isinstance(model_fields, dict) else None
if root_field is not None and get_origin(root_field.annotation) is list:
return True
return get_origin(response_model) in {list, tuple} and bool(get_args(response_model))
@staticmethod
def _parameter_defaults(
endpoint: Callable[..., Any],
parameter_names: set[str],
) -> dict[str, Any]:
"""读取原生分页或限量参数的端点默认值,供响应元数据复用。"""
signature = inspect.signature(endpoint)
defaults: dict[str, Any] = {}
for name in parameter_names:
parameter = signature.parameters.get(name)
if parameter is None or parameter.default is inspect.Parameter.empty:
continue
defaults[name] = parameter.default
return defaults
@staticmethod
def _merge_collection_response_headers(
responses: dict[int | str, dict[str, Any]] | None,
*,
include_total: bool,
) -> dict[int | str, dict[str, Any]]:
"""为列表响应声明兼容数量头,并保留端点既有成功响应定义。"""
merged = dict(responses or {})
success_key: int | str = 200 if "200" not in merged else "200"
success_response = dict(merged.get(success_key) or {})
headers = dict(success_response.get("headers") or {})
headers.update(_COLLECTION_RESPONSE_HEADERS)
if include_total:
headers[COLLECTION_TOTAL_HEADER] = {
"description": (
"Exact collection size before optional compatibility pagination. This header "
"is omitted when an upstream-native page or limit does not expose a total."
),
"schema": {"type": "integer", "minimum": 0},
}
success_response["headers"] = headers
merged[success_key] = success_response
return merged
def _apply_collection_contract(
self,
request: Request,
response: StarletteResponse,
) -> StarletteResponse:
"""对已序列化列表应用显式分页,同时通过响应头报告数量元数据。"""
content_type = response.headers.get("content-type", "").lower()
body = getattr(response, "body", None)
if (
response.status_code >= 400
or body is None
or not ("application/json" in content_type or "+json" in content_type)
):
return response
try:
payload = json.loads(body)
except (TypeError, ValueError, json.JSONDecodeError):
return response
items = payload.get("data") if isinstance(payload, dict) else payload
if not isinstance(items, list):
return response
total_count = len(items)
page: Optional[int] = None
page_size: Optional[int] = None
if self._optional_collection_pagination and (
"page" in request.query_params or "count" in request.query_params
):
page = int(request.query_params.get("page", "1"))
page_size = int(
request.query_params.get(
"count",
str(COLLECTION_DEFAULT_PAGE_SIZE),
)
)
start = (page - 1) * page_size
paged_items = items[start : start + page_size]
if isinstance(payload, dict):
payload["data"] = paged_items
else:
payload = paged_items
items = paged_items
response.body = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
response.headers["content-length"] = str(len(response.body))
elif not self._optional_collection_pagination:
page = self._request_int_value(request, "page")
page_size = self._request_int_value(request, "count")
if self._optional_collection_pagination:
response.headers[COLLECTION_TOTAL_HEADER] = str(total_count)
response.headers[COLLECTION_RESULT_HEADER] = str(len(items))
if page is not None:
response.headers[COLLECTION_PAGE_HEADER] = str(page)
if page_size is not None:
response.headers[COLLECTION_PAGE_SIZE_HEADER] = str(page_size)
return response
def _request_int_value(
self,
request: Request,
name: str,
) -> Optional[int]:
"""读取请求显式值或端点默认整数值,无法解释时不写分页头。"""
raw_value: Any = request.query_params.get(name)
if raw_value is None:
raw_value = self._collection_parameter_defaults.get(name)
if raw_value is None or isinstance(raw_value, bool):
return None
try:
return int(raw_value)
except (TypeError, ValueError):
return None
@staticmethod
def _merge_error_responses(
responses: dict[int | str, dict[str, Any]] | None,
+8
View File
@@ -612,6 +612,10 @@ class AsyncDownloadHistoryQueryRepository(Protocol):
"""按下载时间倒序分页读取历史记录。"""
...
async def async_count(self) -> int:
"""返回下载历史记录总数。"""
...
@dataclass(frozen=True, slots=True)
class ManualTransferHistory:
@@ -700,6 +704,10 @@ class HistoryQueryService:
records = await self._download_repository.async_list_by_page(page, count)
return [DownloadHistory.model_validate(record) for record in records]
async def count_download(self) -> int:
"""返回下载历史精确总数,供分页 API 通过附加元数据报告。"""
return await self._download_repository.async_count()
async def list_transfer(
self,
*,
+12
View File
@@ -444,6 +444,18 @@ class SubscriptionHistoryQueryPort(Protocol):
"""异步按类型和用户分页读取订阅历史快照。"""
...
async def async_count_by_type(self, mtype: str) -> int:
"""异步统计指定媒体类型的订阅历史。"""
...
async def async_count_by_type_and_username(
self,
mtype: str,
username: str,
) -> int:
"""异步统计指定媒体类型和用户的订阅历史。"""
...
class SubscriptionWritePort(Protocol):
"""独立短事务订阅新增端口。"""
+16
View File
@@ -121,6 +121,22 @@ class SubscriptionQueryService:
result.append(item)
return result
async def count_history(
self,
mtype: str,
*,
username: Optional[str] = None,
) -> int:
"""按与历史列表相同的 owner 范围返回精确总数。"""
if self._history_repository is None:
raise RuntimeError("订阅历史查询端口未注册")
if username:
return await self._history_repository.async_count_by_type_and_username(
mtype,
username,
)
return await self._history_repository.async_count_by_type(mtype)
@staticmethod
def _matches_music_type(
record: SubscriptionSnapshot,
+5
View File
@@ -219,6 +219,11 @@ class TransactionalDownloadHistoryRepository:
)
return [_project_history(record) for record in records]
async def async_count(self) -> int:
"""在独立异步 Session 内统计下载历史总数。"""
async with self._async_session() as session:
return await DownloadHistoryOper(session).async_count()
def add(
self,
history: DownloadHistoryWrite,
+16
View File
@@ -471,6 +471,22 @@ class TransactionalSubscriptionHistoryRepository:
)
return [_project_history(record) for record in records]
async def async_count_by_type(self, mtype: str) -> int:
"""在短 Session 内统计指定媒体类型的历史数量。"""
async with self._async_session() as session:
return await SubscribeHistoryOper(session).async_count_by_type(mtype)
async def async_count_by_type_and_username(
self,
mtype: str,
username: str,
) -> int:
"""在短 Session 内统计指定媒体类型和 owner 的历史数量。"""
async with self._async_session() as session:
return await SubscribeHistoryOper(
session
).async_count_by_type_and_username(mtype, username)
class SessionSubscriptionRepository:
"""复用调用方 Session,负责订阅查询投影和暂存且不提交。"""
+27 -1
View File
@@ -1,6 +1,6 @@
from typing import Any, Optional
from sqlalchemy import JSON, Float, Index, Integer, String, or_, select
from sqlalchemy import JSON, Float, Index, Integer, String, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
@@ -153,6 +153,32 @@ class SubscribeHistory(Base):
)
return list(result.scalars().all())
@classmethod
async def async_count_by_type(cls, db: AsyncSession, mtype: str) -> int:
"""统计指定媒体类型的订阅历史。"""
result = await db.execute(
select(func.count(cls.id)).where(cls.type == mtype)
)
return int(result.scalar() or 0)
@classmethod
async def async_count_by_type_and_username(
cls,
db: AsyncSession,
mtype: str,
username: str,
) -> int:
"""统计指定媒体类型和 owner 的订阅历史。"""
if not username:
return 0
result = await db.execute(
select(func.count(cls.id)).where(
cls.type == mtype,
cls.username == username,
)
)
return int(result.scalar() or 0)
@classmethod
def _identity_condition(
cls,
+5
View File
@@ -311,6 +311,11 @@ class DownloadHistoryOper(DbOper):
)
)
async def async_count(self) -> int:
"""异步统计全部下载历史记录。"""
count = await self._execute_async_query(DownloadHistory.async_count)
return int(count or 0)
async def async_delete_history(self, historyid: int):
"""
异步删除下载记录
+20
View File
@@ -181,6 +181,26 @@ class SubscribeHistoryOper(DbOper):
)
)
async def async_count_by_type(self, mtype: str) -> int:
"""异步统计指定媒体类型的订阅历史。"""
return await self._execute_async_query(
lambda session: SubscribeHistory.async_count_by_type(session, mtype)
)
async def async_count_by_type_and_username(
self,
mtype: str,
username: str,
) -> int:
"""异步统计指定媒体类型和 owner 的订阅历史。"""
return await self._execute_async_query(
lambda session: SubscribeHistory.async_count_by_type_and_username(
session,
mtype,
username,
)
)
async def async_get(self, history_id: int) -> Optional[SubscribeHistory]:
"""异步按 ID 查询订阅历史。"""
+16 -2
View File
@@ -1,6 +1,6 @@
# MoviePilot Agent 工具体系重构计划
> 状态:COMPLETE — API/Skill/MCP 全面收口,固定 80% 覆盖率与远端 CI 已通过
> 状态:COMPLETE — L10 查询 API 兼容分页与总数合同已验证
>
> 建立日期:2026-08-31
>
@@ -133,7 +133,8 @@ provider 动态返回 namespaced action、参数约束、副作用等级及是
| L6 原生工具收敛 | VERIFIED | L4,L5 | Agent 任务和人格工具合并为 action 工具;宿主/会话/渠道原生能力边界固定 |
| L7 第三方服务 Skill 化 | VERIFIED | L5 | 下载器和媒体服务器能力发现、受控脚本、Skill、策略和离线测试完成;重复低层 Agent API operation 已删除 |
| L8 收口与交付 | VERIFIED | L6,L7 | 旧代码、文档与架构基线已收口;静态检查和锁定全量测试已完成 |
| L9 全 API 面审计与最终交付 | ACTIVE | L8 | 375 个 OpenAPI 操作逐路由归属、203 个网关合同与 72 个退出工具映射均由测试锁定;完成全量测试、80% 覆盖率门禁、提交推送和远端 CI 终态 |
| L9 全 API 面审计与最终交付 | VERIFIED | L8 | 375 个 OpenAPI 操作逐路由归属、203 个网关合同与 72 个退出工具映射均由测试锁定;全量测试、80% 覆盖率门禁、提交推送和远端 CI 终态均已完成 |
| L10 查询 API 兼容分页与总数合同 | VERIFIED | L9 | 列表查询均可报告当前返回数量;原完整列表新增可选分页,省略新参数时继续返回全部原始结果并报告精确总数;响应 `data` 列表结构不变;外部原生分页未提供总数时不强制输出;OpenAPI、MCP、Skill、审计和测试同步完成 |
## 5. L2 受控 API 网关约束
@@ -330,4 +331,17 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API
- 全量 Agent API 面审计、Skill/MCP 参数合同、下载器/媒体服务器/数据库真实只读调用、音乐双向浏览能力和固定 80% 覆盖率要求均已完成
- 本次重构正式完成:旧 Agent 工具代码、旧 MCP 兼容路径和运行时切换开关均不保留;后续新增 API 必须同步注册表、Skill、MCP schema、审计清单和测试
### 2026-09-01L10 查询 API 兼容分页与总数合同
- 重新开启父目标并进入 L10;当前 `v3``origin/v3` 对齐,工作区同时存在维护者的 Transfer 领域未提交修改,本阶段避开这些文件并只提交 Agent/API 合同相关改动
- 查询 API 按结果语义分为完整列表、原生分页或限量列表、结构化分页对象、统计或聚合对象;只有列表结果进入统一数量合同,统计、映射和时序对象不为形式统一而错误分页
- 原完整列表新增的 `page` / `count` 必须都是可选参数;两者都省略时不切片,继续返回端点原先的完整列表。显式传入任一参数才启用分页,缺失的 `page` 按 1、缺失的 `count` 按 50 解释
- REST 响应继续保持 `Response.data` 为原列表,禁止改成 `{items,total}` 等对象;总数和分页信息使用响应头及 Agent 网关附加元数据表达,避免破坏外部插件和既有客户端
- 对完整列表可报告切片前精确总数;对已经由第三方来源原生分页或限量、且上游没有提供总数的结果,只报告当前返回数量,不强制增加总数,也禁止把当前页数量伪装成全局总数
- 统一响应路由已为原完整列表注入可选 Query 参数:`page >= 1``1 <= count <= 200`;两者均省略时不分页,显式提供任一参数后,缺失的 `page` 使用 1、缺失的 `count` 使用 50。已有 `page/count``limit/offset``max_results` 的端点继续使用自己的原始参数和默认值
- REST 保持 `data` 原列表;`X-Result-Count` 报告本次返回数量,精确可知时增加 `X-Total-Count`,Agent 网关把这些响应头映射到附加 `collection` 对象。下载历史、订阅历史和插件目录已增加本地精确计数;外部媒体、音乐、推荐和搜索来源未提供总数时不输出 `total_count`
- OpenAPI 与 `api_mcp_schema.json` 已同步完整参数、默认值、范围和集合输出合同,英文 `skills/moviepilot-api/SKILL.md` 已重新生成;结构化分页端点继续使用既有 `data.total``data.items` / `data.list`
- 验证完成:定向回归 226 项通过;全量四分片合计 7634 项通过、9 项跳过;固定 80% 覆盖率门禁通过(Application 81.87%Domain 81.01%);Ruff、Mypy、架构基线与差异检查均通过
- 验证期间安全快进到最新 `v3` 提交 `b2e3056ca`,其网络修复与本阶段文件无冲突;快进后的关键回归再次通过
本文件作为本次重构的持续记录,保留阶段状态、实际变更、验证结果、提交状态与已知基线边界。
+7
View File
@@ -93,6 +93,13 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确
只允许传 `tools/list` 对应 operation 分支中声明的 `path_params``query``body` 字段。不得传 URL、认证头、API Token 或任意 HTTP 方法。
查询结果的兼容分页合同如下:
- 原先返回完整列表、没有分页参数的接口会在 OpenAPI、Skill 和 MCP `oneOf` 中新增可选 `page` / `count``page` 必须不小于 1`count` 范围为 1 到 200。两者都省略时仍返回原来的完整列表,不启用分页;显式传入任一参数时才切片,缺失的 `page` 按 1、缺失的 `count` 按 50 处理。
- REST 响应的 `data` 保持原列表结构,不改成 `{items,total}``X-Result-Count` 报告本次实际返回数量;仅当 MoviePilot 已经取得完整筛选结果时,才增加精确的 `X-Total-Count`。原有结构化分页接口继续在既有 `data.total``data.items` / `data.list` 中返回总数。
- `moviepilot_api` 把这些响应头投影为响应中的附加 `collection` 对象:`result_count` 为本次返回数量,`total_count` 仅在精确可知时出现,`page` / `count` 在可用时出现。`collection` 是附加元数据,不替换或改写 `data`
- 已经由第三方接口原生分页或限量、但上游没有返回总数的查询不会伪造 `total_count`Agent 应以 `result_count` 判断当前页是否为空,并按原接口的分页参数继续读取。
### `downloader_operation` / `mediaserver_operation` 调用形状
```json
+29
View File
@@ -265,6 +265,8 @@ def _render_api_docs() -> str:
"",
"The operations, HTTP methods, routes, and path/query/body fields below exactly match external MCP `tools/list`.",
"A field name ending in `*` is required. Omit an empty bucket or send `{}`. Referenced body models are expanded below.",
"For collection operations, `data` keeps its existing list or page-object shape. The gateway may add a sibling `collection` object with `result_count`, optional exact `total_count`, `page`, and `count`; it never replaces the list body with a new wrapper.",
"If an endpoint or external source does not expose a total, `collection.total_count` is omitted instead of being guessed from the current page.",
"",
]
for operation_id in sorted(API_OPERATION_ROUTES):
@@ -283,6 +285,33 @@ def _render_api_docs() -> str:
f"Purpose: {description.split(' Method:', 1)[0].strip()}",
]
)
collection_contract = branch.get("x-moviepilot-collection")
if isinstance(collection_contract, Mapping):
if collection_contract.get("body_shape") == "page_object":
lines.append(
"- `response`: structured page object; items stay in "
f"`{collection_contract['items_field']}` and the exact total stays in "
f"`{collection_contract['total_count_field']}`."
)
elif collection_contract.get("total_count_field"):
if collection_contract.get("default_pagination") == "unpaginated":
lines.append(
"- `response`: `data` remains a list; omitting both `page` and `count` "
"keeps the complete legacy result. `collection.result_count` reports the "
"returned items and `collection.total_count` reports the exact pre-pagination total."
)
else:
lines.append(
"- `response`: `data` remains a list and the endpoint's documented pagination "
"or limit defaults remain in effect. `collection.result_count` reports the "
"returned items and `collection.total_count` reports the exact total."
)
else:
lines.append(
"- `response`: `data` remains a list and `collection.result_count` reports "
"the returned items. `collection.total_count` is omitted because this endpoint "
"or its upstream source does not expose a total."
)
for bucket in ("path_params", "query", "body"):
bucket_schema = branch["properties"].get(bucket)
if not isinstance(bucket_schema, Mapping):
+95 -36
View File
@@ -149,6 +149,8 @@ Call the gateway with this shape:
The operations, HTTP methods, routes, and path/query/body fields below exactly match external MCP `tools/list`.
A field name ending in `*` is required. Omit an empty bucket or send `{}`. Referenced body models are expanded below.
For collection operations, `data` keeps its existing list or page-object shape. The gateway may add a sibling `collection` object with `result_count`, optional exact `total_count`, `page`, and `count`; it never replaces the list body with a new wrapper.
If an endpoint or external source does not expose a total, `collection.total_count` is omitted instead of being guessed from the current page.
### `config.identifiers.get`
`GET /api/v1/system/identifiers`; policy effect: `safe_read`.
@@ -223,15 +225,17 @@ Purpose: Read current MoviePilot process and host memory utilization.
### `dashboard.network`
`GET /api/v1/dashboard/network`; policy effect: `safe_read`.
Purpose: Read the current host network receive and transmit counters.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `dashboard.processes`
`GET /api/v1/dashboard/processes`; policy effect: `safe_read`.
Purpose: List host processes visible to the MoviePilot runtime.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `dashboard.storage`
@@ -251,8 +255,9 @@ Purpose: Read MoviePilot host, runtime, platform, and uptime summary information
### `dashboard.transfer.statistics`
`GET /api/v1/dashboard/transfer`; policy effect: `safe_read`.
Purpose: Read aggregate file-transfer counts grouped by time period.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: `days` (integer|null; default `7`): Recommendation time window in days.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `days` (integer|null; default `7`): Recommendation time window in days.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `database.backups.create`
@@ -272,8 +277,9 @@ Purpose: Delete one exact managed database backup artifact.
### `database.backups.list`
`GET /api/v1/system/database/backups`; policy effect: `safe_read`.
Purpose: List managed database backup artifacts without exposing host paths.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `database.backups.verify`
@@ -293,8 +299,9 @@ Purpose: Submit one torrent to MoviePilot's normal download workflow.
### `download.clients`
`GET /api/v1/download/clients`; policy effect: `safe_read`.
Purpose: List enabled downloader instance names and provider types without credentials.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `download.history.delete`
@@ -307,6 +314,7 @@ Purpose: Delete one MoviePilot download-history record.
### `download.history.list`
`GET /api/v1/history/download`; policy effect: `safe_read`.
Purpose: Page MoviePilot download-history records in reverse chronological order.
- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total.
- `path_params`: none
- `query`: `count` (integer|null; default `30`): Maximum number of records to return on the requested page.; `page` (integer|null; default `1`): One-based result page number.
- `body`: none
@@ -314,15 +322,17 @@ Purpose: Page MoviePilot download-history records in reverse chronological order
### `download.paths`
`GET /api/v1/download/paths`; policy effect: `safe_read`.
Purpose: List configured downloader save-path URIs that may be passed to download.add.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `download.tasks.active`
`GET /api/v1/download/`; policy effect: `safe_read`.
Purpose: List currently downloading MoviePilot tasks with their canonical media context.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `filter.builtin`
@@ -405,6 +415,7 @@ Purpose: Check configured media servers for one canonical media identity.
### `library.latest`
`GET /api/v1/mediaserver/latest`; policy effect: `safe_read`.
Purpose: List recently added items from one configured media-server instance for the current user.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `count` (integer|null; default `20`): Maximum number of records to return on the requested page.; `server*` (string): Exact configured media-server instance name returned by the media-server instance list.
- `body`: none
@@ -440,27 +451,31 @@ Purpose: Read canonical media details from one selected metadata source.
### `media.episode_group.seasons`
`GET /api/v1/media/group/seasons/{episode_group}`; policy effect: `safe_read`.
Purpose: List seasons defined by one exact TMDB episode-group identity.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `episode_group*` (string): TMDB episode-group identifier used for alternate episode ordering.
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `media.episode_groups`
`GET /api/v1/media/groups/{tmdbid}`; policy effect: `safe_read`.
Purpose: List alternate TMDB episode groups available for one TV media identity.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `tmdbid*` (integer): TMDB media ID returned by media search or detail.
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `media.episode_schedule`
`GET /api/v1/tmdb/{tmdbid}/{season}`; policy effect: `safe_read`.
Purpose: Read TMDB episode release information for one season.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `season*` (integer): Season number used by the media, search, subscription, or transfer operation.; `tmdbid*` (integer): TMDB media ID returned by media search or detail.
- `query`: `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `media.person.credits`
`GET /api/v1/{source}/person/credits/{person_id}`; policy effect: `safe_read`.
Purpose: Read one person's credits from the selected metadata source.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: `person_id*` (integer): Source-native person ID.; `source*` (string(douban,tmdb,bangumi,anilist)): Metadata source that owns the person ID.
- `query`: `count` (integer; default `20`; minimum `1`; maximum `50`): Page size used by Bangumi and AniList; other sources ignore it.; `page` (integer; default `1`; minimum `1`): One-based result page number.
- `body`: none
@@ -468,6 +483,7 @@ Purpose: Read one person's credits from the selected metadata source.
### `media.person.search`
`GET /api/v1/media/search`; policy effect: `safe_read`.
Purpose: Search people across selected metadata sources.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `count` (integer; default `8`): Maximum number of records to return on the requested page.; `media_source` (array<MediaSource>; default `[]`): Metadata source identifier. Preserve the exact value returned with media_id.; `page` (integer; default `1`): One-based result page number.; `title*` (string): Media, torrent, subscription, or history title used by the operation.; `type*` (string=person): Literal person, selecting person search instead of media search.
- `body`: none
@@ -496,6 +512,7 @@ Purpose: Generate or refresh metadata for one storage item.
### `media.search`
`GET /api/v1/media/search`; policy effect: `safe_read`.
Purpose: Search canonical media across selected metadata sources.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `count` (integer; default `8`): Maximum number of records to return on the requested page.; `media_source` (array<MediaSource>; default `[]`): Metadata source identifier. Preserve the exact value returned with media_id.; `page` (integer; default `1`): One-based result page number.; `title*` (string): Media, torrent, subscription, or history title used by the operation.; `type` (string|null; default `media`): MoviePilot media or storage item type required by the selected operation.
- `body`: none
@@ -503,15 +520,17 @@ Purpose: Search canonical media across selected metadata sources.
### `media.seasons`
`GET /api/v1/media/seasons`; policy effect: `safe_read`.
Purpose: List seasons for one exact media identity or a title-and-year fallback.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `season` (integer): Season number used by the media, search, subscription, or transfer operation.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `year` (string): Release or premiere year used to disambiguate the media title.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (integer): Season number used by the media, search, subscription, or transfer operation.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `year` (string): Release or premiere year used to disambiguate the media title.
- `body`: none
### `media.sources`
`GET /api/v1/media/source`; policy effect: `safe_read`.
Purpose: List metadata sources currently registered for MoviePilot media operations.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `music.album.get`
@@ -524,6 +543,7 @@ Purpose: Read one album's details, tracks, releases, and aligned artist names an
### `music.album.related`
`GET /api/v1/music/album/{album_id}/related`; policy effect: `safe_read`.
Purpose: Browse albums related to one source-native album identity.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: `album_id*` (string): Source-native album ID returned by music search, exploration, or artist-album browsing.
- `query`: `count` (integer; default `24`; minimum `1`; maximum `100`): Maximum number of records to return on the requested page.; `media_source` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.
- `body`: none
@@ -531,6 +551,7 @@ Purpose: Browse albums related to one source-native album identity.
### `music.artist.albums`
`GET /api/v1/music/artist/{artist_id}/albums`; policy effect: `safe_read`.
Purpose: Browse one artist's albums, singles, EPs, or another exact release-group type.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: `artist_id*` (string): Source-native artist ID returned by music search or an album detail response.
- `query`: `album_type` (string|null): MusicBrainz release-group type filter: album, single, ep, broadcast, other, compilation, soundtrack, live, or remix.; `count` (integer; default `30`; minimum `1`; maximum `100`): Maximum number of records to return on the requested page.; `media_source` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `page` (integer; default `1`; minimum `1`): One-based result page number.
- `body`: none
@@ -545,6 +566,7 @@ Purpose: Read one artist's canonical details from the selected music metadata so
### `music.artist.related`
`GET /api/v1/music/artist/{artist_id}/related`; policy effect: `safe_read`.
Purpose: Browse artists related to one source-native artist identity.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: `artist_id*` (string): Source-native artist ID returned by music search or an album detail response.
- `query`: `count` (integer; default `24`; minimum `1`; maximum `100`): Maximum number of records to return on the requested page.; `media_source` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.
- `body`: none
@@ -573,6 +595,7 @@ Purpose: Inspect the administrator-only MusicBrainz recognition cache and summar
### `music.explore`
`GET /api/v1/music/explore`; policy effect: `safe_read`.
Purpose: Browse MusicBrainz charts or fresh releases, or Douban Music tag categories.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `count` (integer; default `30`; minimum `1`; maximum `100`): Maximum number of records to return on the requested page.; `days` (integer; default `14`; minimum `1`; maximum `90`): Fresh-release lookback/lookahead window, from 1 through the endpoint maximum.; `douban_sort` (string; default `U`): Douban Music order: U comprehensive, S rating, R newest, or O hottest.; `entity` (string; default `recording`): Chart entity: recording for tracks or album for release groups. Fresh results are albums.; `future` (boolean; default `True`): Include releases after today in fresh mode.; `media_source` (MediaSource): Music exploration source. Use musicbrainz for chart/fresh modes or doubanmusic for tag browsing.; `min_listen_count` (integer; default `0`; minimum `0`): Minimum ListenBrainz listen count in chart mode.; `mode` (string; default `chart`): MusicBrainz mode: chart reads listening charts; fresh reads new album releases.; `page` (integer; default `1`; minimum `1`): One-based result page number.; `past` (boolean; default `True`): Include releases before today in fresh mode.; `range_name` (string; default `this_month`): ListenBrainz chart range: this_week, this_month, this_year, week, month, or year.; `sort` (string; default `release_date`): Fresh-release order accepted by the current ListenBrainz implementation.; `sort_by` (string; default `listen_count.desc`): ListenBrainz chart order: listen_count.desc or listen_count.asc.; `tags` (string; default ``): Comma-separated Douban Music tags used only when media_source is doubanmusic.; `with_cover` (boolean; default `False`): Keep only results with cover artwork when true.
- `body`: none
@@ -671,6 +694,7 @@ Purpose: Install or update one plugin from an approved source.
### `plugin.installed`
`GET /api/v1/plugin/`; policy effect: `safe_read`.
Purpose: List installed plugins and their runtime status.
- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total.
- `path_params`: none
- `query`: `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries.
- `body`: none
@@ -678,6 +702,7 @@ Purpose: List installed plugins and their runtime status.
### `plugin.market`
`GET /api/v1/plugin/`; policy effect: `safe_read`.
Purpose: List plugins available from configured marketplaces.
- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total.
- `path_params`: none
- `query`: `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=market): Literal market, selecting only market plugin catalog entries.
- `body`: none
@@ -776,6 +801,7 @@ Purpose: Uninstall one plugin and remove it from the installed set.
### `recommendation.list`
`GET /api/v1/recommend/agent`; policy effect: `safe_read`.
Purpose: Read personalized media or music recommendations.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `days` (integer; default `14`): Recommendation time window in days.; `fresh_sort` (string; default `release_date`): Freshness ordering used by the recommendation source.; `future` (boolean; default `True`): Include future recommendation periods when supported.; `media_type` (string; default `all`): MoviePilot media type used to filter recommendations or rule groups.; `min_listen_count` (integer; default `0`): Minimum listen count required for a music recommendation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer; default `1`): One-based result page number.; `past` (boolean; default `True`): Include past recommendation periods when supported.; `range_name` (string; default `this_month`): Named recommendation time range.; `sort_by` (string; default `listen_count.desc`): Recommendation field used for ordering results.; `source` (string; default `tmdb_trending`): Exact metadata or recommendation source selected by the operation.; `with_cover` (boolean; default `False`): Require recommendation results to include cover artwork.
- `body`: none
@@ -783,8 +809,9 @@ Purpose: Read personalized media or music recommendations.
### `scheduler.list`
`GET /api/v1/dashboard/schedule`; policy effect: `safe_read`.
Purpose: List registered scheduler jobs and their current state.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `scheduler.progress`
@@ -818,6 +845,7 @@ Purpose: Read the most recent torrent-search context and result set.
### `search.title`
`GET /api/v1/search/title`; policy effect: `external_side_effect`.
Purpose: Search torrent sites directly from a free-form title and optional media filters.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `page` (integer|null; default `0`): One-based result page number.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
- `body`: none
@@ -825,8 +853,9 @@ Purpose: Search torrent sites directly from a free-form title and optional media
### `search.torrents`
`GET /api/v1/search/media/{media_id}`; policy effect: `safe_read`.
Purpose: Search torrent sites for one canonical media identity.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search.
- `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
- `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
- `body`: none
### `site.add`
@@ -853,8 +882,9 @@ Purpose: Authenticate a supported site account and persist the resulting site au
### `site.category`
`GET /api/v1/site/category/{site_id}`; policy effect: `safe_read`.
Purpose: List torrent categories supported by one configured site.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `site_id*` (integer): Persistent site ID returned by site.list.
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `site.cookie.update`
@@ -881,8 +911,9 @@ Purpose: Delete one configured site by persistent site ID.
### `site.list`
`GET /api/v1/site/agent`; policy effect: `safe_read`.
Purpose: List configured sites with status/name filters; authentication fields are returned only to a superuser.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `status` (string(active,inactive,all); default `all`): Transfer success status used to filter history or describe a record.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `status` (string(active,inactive,all); default `all`): Transfer success status used to filter history or describe a record.
- `body`: none
### `site.mapping`
@@ -909,6 +940,7 @@ Purpose: Delete all configured sites and start a fresh CookieCloud synchronizati
### `site.resource`
`GET /api/v1/site/resource/{site_id}`; policy effect: `external_side_effect`.
Purpose: Browse torrent resources from one configured site with category and keyword filters.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: `site_id*` (integer): Persistent site ID returned by site.list.
- `query`: `cat` (string|null): Exact site category identifier returned by site.category.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `page` (integer|null; default `0`): One-based result page number.
- `body`: none
@@ -916,15 +948,17 @@ Purpose: Browse torrent resources from one configured site with category and key
### `site.rss`
`GET /api/v1/site/rss`; policy effect: `safe_read`.
Purpose: List configured sites selected for RSS subscription processing.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `site.searchable`
`GET /api/v1/site/media/{media_type}`; policy effect: `safe_read`.
Purpose: List active configured sites supporting one exact media type.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `media_type*` (string): MoviePilot media type used to filter recommendations or rule groups.
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `site.statistic`
@@ -937,8 +971,9 @@ Purpose: Read account and traffic statistics for one exact configured site domai
### `site.statistics`
`GET /api/v1/site/statistic`; policy effect: `safe_read`.
Purpose: Read the latest account and traffic statistics for all configured sites.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `site.supporting`
@@ -965,15 +1000,17 @@ Purpose: Update one configured site's complete settings.
### `site.userdata`
`GET /api/v1/site/userdata/{site_id}`; policy effect: `safe_read`.
Purpose: Read the latest account statistics collected from one site.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `site_id*` (integer): Persistent site ID returned by site.list.
- `query`: `workdate` (string|null): Date used when retrieving one site's historical user statistics.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `workdate` (string|null): Date used when retrieving one site's historical user statistics.
- `body`: none
### `site.userdata.latest`
`GET /api/v1/site/userdata/latest`; policy effect: `safe_read`.
Purpose: Read the latest collected account statistics for every configured site.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `site.userdata.refresh`
@@ -986,8 +1023,9 @@ Purpose: Refresh and return account statistics for one configured site.
### `slash.list`
`GET /api/v1/message/agent/commands`; policy effect: `safe_read`.
Purpose: List slash commands that the Agent may dispatch.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `slash.run`
@@ -1007,8 +1045,9 @@ Purpose: Delete one exact file or directory from a configured storage provider.
### `storage.list`
`POST /api/v1/storage/agent/list`; policy effect: `safe_read`.
Purpose: List files or directories from one configured storage location.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `sort` (string|null; default `updated_at`): Storage-list sort field or ordering expression.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `sort` (string|null; default `updated_at`): Storage-list sort field or ordering expression.
- `body`: `basename` (string|null): Base filename without its parent path.; `children` (array<FileItem-Input>|null): Child storage items nested below this item.; `drive_id` (string|null): Provider-native storage drive identifier.; `extension` (string|null): Filename extension, including or excluding the leading dot as returned by storage.; `fileid` (string|null): Provider-native storage item identifier.; `modify_time` (number|null): Storage item modification timestamp.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `parent_fileid` (string|null): Provider-native identifier of the parent storage directory.; `path` (string|null; default `/`): Storage or history path represented by this record.; `pickcode` (string|null): 115 storage pickcode associated with the item.; `size` (integer|null): File or torrent size in bytes.; `storage` (string|null; default `local`): Configured storage name or storage type used by the operation.; `thumbnail` (string|null): Thumbnail URL returned by the storage provider.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `url` (string|null): Site, storage, or torrent URL represented by this field.
### `storage.manage`
@@ -1035,8 +1074,9 @@ Purpose: Rename one exact storage item, optionally applying media-aware recursiv
### `storage.settings`
`GET /api/v1/storage/directories`; policy effect: `safe_read`.
Purpose: Read configured directory or storage settings.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: `directory_type` (string; default `all`): Directory configuration subtype to return.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `storage_type` (string; default `all`): Configured storage provider type to return.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `directory_type` (string; default `all`): Directory configuration subtype to return.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `storage_type` (string; default `all`): Configured storage provider type to return.
- `body`: none
### `subscription.add`
@@ -1091,8 +1131,9 @@ Purpose: Stop following one subscription-sharing user by exact share user ID.
### `subscription.follow.list`
`GET /api/v1/subscribe/follow`; policy effect: `safe_read`.
Purpose: List subscription-sharing user IDs followed by the current user.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `subscription.fork`
@@ -1112,6 +1153,7 @@ Purpose: Read one accessible subscription by persistent subscription ID.
### `subscription.history`
`GET /api/v1/subscribe/history/{mtype}`; policy effect: `safe_read`.
Purpose: List completed or archived subscription records.
- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total.
- `path_params`: `mtype*` (string): MoviePilot media type or subscription-history category required by the operation.
- `query`: `count` (integer|null; default `30`): Maximum number of records to return on the requested page.; `page` (integer|null; default `1`): One-based result page number.
- `body`: none
@@ -1126,8 +1168,9 @@ Purpose: Delete one accessible subscription-history record.
### `subscription.list`
`GET /api/v1/subscribe/`; policy effect: `safe_read`.
Purpose: List active subscriptions.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `subscription.metadata.refresh`
@@ -1140,6 +1183,7 @@ Purpose: Start a system-wide refresh of subscription TMDB metadata.
### `subscription.popular`
`GET /api/v1/subscribe/popular`; policy effect: `safe_read`.
Purpose: List globally popular subscriptions with filters and pagination.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `count` (integer|null; default `30`): Maximum number of records to return on the requested page.; `genre_id` (integer|null): Genre identifier used to filter shared or popular subscriptions.; `max_rating` (number|null): Maximum rating used to filter shared or popular subscriptions.; `min_rating` (number|null): Minimum rating used to filter shared or popular subscriptions.; `min_sub` (integer|null): Minimum subscriber count used to filter popular subscriptions.; `page` (integer|null; default `1`): One-based result page number.; `sort_type` (string|null): Ascending or descending order used by shared or popular subscriptions.; `stype*` (string): Popular-subscription category requested by the endpoint.
- `body`: none
@@ -1189,13 +1233,15 @@ Purpose: Delete one shared-subscription publication by share ID.
### `subscription.share.statistics`
`GET /api/v1/subscribe/share/statistics`; policy effect: `safe_read`.
Purpose: Read aggregate contribution and reuse counts for subscription sharers.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `subscription.shares`
`GET /api/v1/subscribe/shares`; policy effect: `safe_read`.
Purpose: List shared subscriptions with filters and pagination.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `count` (integer|null; default `30`): Maximum number of records to return on the requested page.; `genre_id` (integer|null): Genre identifier used to filter shared or popular subscriptions.; `max_rating` (number|null): Maximum rating used to filter shared or popular subscriptions.; `min_rating` (number|null): Minimum rating used to filter shared or popular subscriptions.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `page` (integer|null; default `1`): One-based result page number.; `sort_type` (string|null): Ascending or descending order used by shared or popular subscriptions.
- `body`: none
@@ -1217,20 +1263,23 @@ Purpose: Update one existing movie, TV, or music subscription.
### `subscription.user.list`
`GET /api/v1/subscribe/user/{username}`; policy effect: `safe_read`.
Purpose: List public subscriptions owned by one accessible MoviePilot username.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `username*` (string): MoviePilot or site username required by the selected operation.
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `subtitle.search.media`
`GET /api/v1/search/subtitle/media/{media_id}`; policy effect: `external_side_effect`.
Purpose: Search subtitle providers for one canonical media identity and optional season or episode.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search.
- `query`: `episode` (string|null): Episode number used to narrow a subtitle or media search.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `episode` (string|null): Episode number used to narrow a subtitle or media search.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
- `body`: none
### `subtitle.search.title`
`GET /api/v1/search/subtitle/title`; policy effect: `external_side_effect`.
Purpose: Search subtitle providers from a free-form title and optional media filters.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `page` (integer|null; default `0`): One-based result page number.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
- `body`: none
@@ -1252,8 +1301,9 @@ Purpose: Run the built-in availability test for one loaded MoviePilot module.
### `system.network.targets`
`GET /api/v1/system/nettest/targets`; policy effect: `safe_read`.
Purpose: List approved built-in network-test targets without exposing their request URLs.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `system.network.test`
@@ -1315,8 +1365,9 @@ Purpose: Read the installation version and runtime usage report available to the
### `system.versions`
`GET /api/v1/system/versions`; policy effect: `safe_read`.
Purpose: List available MoviePilot GitHub releases.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `torrent.cache.clear`
@@ -1371,6 +1422,7 @@ Purpose: Run MoviePilot's manual file-transfer and organization workflow.
### `transfer.history`
`GET /api/v1/history/transfer`; policy effect: `safe_read`.
Purpose: List file-transfer history with filters and pagination.
- `response`: structured page object; items stay in `data.list` and the exact total stays in `data.total`.
- `path_params`: none
- `query`: `count` (integer|null; default `30`): Maximum number of records to return on the requested page.; `page` (integer|null; default `1`): One-based result page number.; `status` (boolean|null): Transfer success status used to filter history or describe a record.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.
- `body`: none
@@ -1427,6 +1479,7 @@ Purpose: Record the authorized decision for one durable transfer manual-review o
### `transfer.manual_reviews`
`GET /api/v1/transfer/tasks/manual-reviews`; policy effect: `safe_read`.
Purpose: Page durable transfer tasks awaiting manual review or retry recovery.
- `response`: structured page object; items stay in `data.items` and the exact total stays in `data.total`.
- `path_params`: none
- `query`: `page` (integer; default `1`; minimum `1`): One-based result page number.; `page_size` (integer; default `30`; minimum `1`; maximum `100`): Maximum records returned on one page.; `state` (string(manual_review,retry_wait); default `manual_review`): Current site, subscription, marketplace, or transfer state filter.
- `body`: none
@@ -1441,8 +1494,9 @@ Purpose: Preview the organized destination name for one source path and media id
### `transfer.queue`
`GET /api/v1/transfer/queue`; policy effect: `safe_read`.
Purpose: List items waiting in the file-transfer queue.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `transfer.queue.delete`
@@ -1462,8 +1516,9 @@ Purpose: Resolve the configured transfer destination for supplied source storage
### `workflow.actions`
`GET /api/v1/workflow/actions`; policy effect: `safe_read`.
Purpose: List built-in workflow action definitions and their parameter contracts.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `workflow.create`
@@ -1483,8 +1538,9 @@ Purpose: Delete one configured workflow by persistent workflow ID.
### `workflow.event_types`
`GET /api/v1/workflow/event_types`; policy effect: `safe_read`.
Purpose: List event types that can trigger workflows.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `workflow.fork`
@@ -1504,8 +1560,9 @@ Purpose: Read one complete configured workflow definition.
### `workflow.list`
`GET /api/v1/workflow/agent`; policy effect: `safe_read`.
Purpose: List configured workflows and their execution state.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `state` (string(W,R,P,S,F,all); default `all`): Current site, subscription, marketplace, or transfer state filter.; `trigger_type` (string(timer,event,manual,all); default `all`): Workflow trigger filter: timer, event, manual, or all.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `state` (string(W,R,P,S,F,all); default `all`): Current site, subscription, marketplace, or transfer state filter.; `trigger_type` (string(timer,event,manual,all); default `all`): Workflow trigger filter: timer, event, manual, or all.
- `body`: none
### `workflow.pause`
@@ -1518,8 +1575,9 @@ Purpose: Disable automatic execution of one configured workflow.
### `workflow.plugin.actions`
`GET /api/v1/workflow/plugin/actions`; policy effect: `safe_read`.
Purpose: List workflow actions contributed by installed plugins, optionally filtered by plugin ID.
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total.
- `path_params`: none
- `query`: `plugin_id` (string): Exact installed or marketplace plugin ID.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `plugin_id` (string): Exact installed or marketplace plugin ID.
- `body`: none
### `workflow.reset`
@@ -1553,6 +1611,7 @@ Purpose: Delete one shared-workflow publication by share ID.
### `workflow.shares`
`GET /api/v1/workflow/shares`; policy effect: `safe_read`.
Purpose: List shared workflows with name and pagination filters.
- `response`: `data` remains a list and `collection.result_count` reports the returned items. `collection.total_count` is omitted because this endpoint or its upstream source does not expose a total.
- `path_params`: none
- `query`: `count` (integer|null; default `30`): Maximum number of records to return on the requested page.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `page` (integer|null; default `1`): One-based result page number.
- `body`: none
+79
View File
@@ -0,0 +1,79 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor
def _execute_with_headers(headers: dict[str, str]) -> tuple[dict, AsyncMock]:
"""用内存 HTTP 响应执行一次固定 API operation。"""
response = SimpleNamespace(
status_code=200,
headers=headers,
json=lambda: {"success": True, "message": "", "data": [{"id": 1}]},
aclose=AsyncMock(),
)
request = AsyncMock(return_value=response)
request_factory = MagicMock(return_value=SimpleNamespace(request=request))
executor = MoviePilotApiExecutor(
context=ApiExecutionContext(
user_id="1",
username="admin",
is_admin=True,
),
request_factory=request_factory,
)
with patch("app.agent.api.executor.create_access_token", return_value="token"):
result = asyncio.run(executor.execute("subscription.list"))
return json.loads(result), response.aclose
def test_executor_exposes_exact_collection_total_when_api_reports_it() -> None:
"""Agent 结果应把精确数量响应头变成可直接读取的集合元数据。"""
result, close = _execute_with_headers(
{
"X-Result-Count": "20",
"X-Total-Count": "57",
"X-Page": "2",
"X-Page-Size": "20",
}
)
assert result["data"] == [{"id": 1}]
assert result["collection"] == {
"result_count": 20,
"total_count": 57,
"page": 2,
"count": 20,
}
close.assert_awaited_once()
def test_executor_does_not_invent_total_for_upstream_window() -> None:
"""外部接口未报告总数时,Agent 元数据只能包含当前窗口数量。"""
result, close = _execute_with_headers(
{
"X-Result-Count": "20",
"X-Page": "3",
"X-Page-Size": "20",
}
)
assert result["collection"] == {
"result_count": 20,
"page": 3,
"count": 20,
}
assert "total_count" not in result["collection"]
close.assert_awaited_once()
def test_executor_keeps_non_collection_payload_unchanged_without_headers() -> None:
"""非列表 API 未返回数量头时必须保持既有输出形状。"""
result, close = _execute_with_headers({})
assert result == {"success": True, "message": "", "data": [{"id": 1}]}
close.assert_awaited_once()
+47
View File
@@ -119,6 +119,53 @@ def test_mcp_tools_list_preserves_all_moviepilot_api_operation_branches() -> Non
assert operation_ids == set(API_OPERATION_ROUTES)
def test_mcp_collection_contract_distinguishes_exact_and_unavailable_totals() -> None:
"""MCP 必须说明缺省全量、精确总数和外部无总数三种集合语义。"""
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
branches = {
item["properties"]["operation_id"]["const"]: item
for item in schema["oneOf"]
}
subscription = branches["subscription.list"]
subscription_query = subscription["properties"]["query"]["properties"]
assert "default" not in subscription_query["page"]
assert "default" not in subscription_query["count"]
assert subscription["x-moviepilot-collection"] == {
"body_shape": "list",
"result_count_field": "collection.result_count",
"total_count_field": "collection.total_count",
"default_pagination": "unpaginated",
}
storage = branches["storage.list"]
assert {"page", "count"}.issubset(storage["properties"]["query"]["properties"])
assert storage["x-moviepilot-collection"]["total_count_field"] == (
"collection.total_count"
)
for operation_id in (
"subscription.history",
"download.history.list",
"plugin.installed",
"plugin.market",
):
local_page = branches[operation_id]["x-moviepilot-collection"]
assert local_page["total_count_field"] == "collection.total_count"
assert local_page["default_pagination"] == "endpoint-defined"
assert "defaults remain in effect" in branches[operation_id]["description"]
media_search = branches["media.search"]["x-moviepilot-collection"]
assert media_search["result_count_field"] == "collection.result_count"
assert media_search["total_count_field"] is None
assert "does not expose a total" in branches["media.search"]["description"]
transfer = branches["transfer.history"]["x-moviepilot-collection"]
assert transfer["body_shape"] == "page_object"
assert transfer["items_field"] == "data.list"
assert transfer["total_count_field"] == "data.total"
def test_filter_read_parameters_are_query_fields_not_get_request_bodies() -> None:
"""规则读取筛选列表必须作为 query 参数公开,避免 Agent 构造无语义 GET body。"""
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
+150 -4
View File
@@ -1,4 +1,5 @@
import asyncio
import inspect
import threading
import time
from concurrent.futures import ThreadPoolExecutor
@@ -18,6 +19,8 @@ from starlette.responses import StreamingResponse
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
from app.api.response import (
COLLECTION_PAGINATION_OPENAPI_KEY,
COLLECTION_TOTAL_OPENAPI_KEY,
RAW_RESPONSE_OPENAPI_KEY,
ResponseAPIRoute,
ResponseAPIRouter,
@@ -28,18 +31,17 @@ from app.factory import (
localized_validation_exception_handler,
persistence_unavailable_handler,
)
from app.runtime.config import settings
from app.runtime.localization import LocaleHelper
from app.schemas.common import JsonData
from app.schemas.exception import (
AgentChatPersistenceUnavailableError,
DatabaseWorkerClosedError,
DatabaseWorkerOverloadedError,
PersistenceUnavailableError,
)
from app.runtime.localization import LocaleHelper
from app.runtime.config import settings
from app.schemas.common import JsonData
from app.schemas.response import Response
pytestmark = pytest.mark.anyio
@@ -101,6 +103,18 @@ def api_app() -> FastAPI:
"""返回需要自动封装的业务数据。"""
return [Item(id=1)]
@app.get("/many-items", response_model=list[Item])
async def get_many_items() -> list[Item]:
"""返回用于验证兼容分页行为的完整业务列表。"""
return [Item(id=index) for index in range(1, 6)]
@app.get("/native-page", response_model=list[Item])
async def get_native_page(page: int = 1, count: int = 2) -> list[Item]:
"""模拟已经由端点自身执行分页的列表查询。"""
items = [Item(id=index) for index in range(1, 6)]
start = (page - 1) * count
return items[start : start + count]
@app.get("/wrapped", response_model=Response[Item])
async def get_wrapped_response() -> Response[Item]:
"""返回已经封装的响应。"""
@@ -189,6 +203,138 @@ async def test_route_wraps_data_and_keeps_existing_response(api_app: FastAPI):
}
async def test_legacy_collection_defaults_to_complete_unpaginated_result(api_app: FastAPI):
"""新增分页参数省略时必须保留原完整列表和精确总数。"""
async with make_client(api_app) as client:
response = await client.get("/many-items")
assert response.json()["data"] == [
{"id": 1},
{"id": 2},
{"id": 3},
{"id": 4},
{"id": 5},
]
assert response.headers["X-Total-Count"] == "5"
assert response.headers["X-Result-Count"] == "5"
assert "X-Page" not in response.headers
assert "X-Page-Size" not in response.headers
async def test_legacy_collection_paginates_only_when_explicitly_requested(api_app: FastAPI):
"""显式传入任一兼容分页参数后才切片,并保持 data 仍为列表。"""
async with make_client(api_app) as client:
page_response = await client.get("/many-items", params={"page": 2, "count": 2})
count_response = await client.get("/many-items", params={"count": 3})
assert page_response.json()["data"] == [{"id": 3}, {"id": 4}]
assert page_response.headers["X-Total-Count"] == "5"
assert page_response.headers["X-Result-Count"] == "2"
assert page_response.headers["X-Page"] == "2"
assert page_response.headers["X-Page-Size"] == "2"
assert count_response.json()["data"] == [{"id": 1}, {"id": 2}, {"id": 3}]
assert count_response.headers["X-Page"] == "1"
assert count_response.headers["X-Page-Size"] == "3"
async def test_native_pagination_is_not_double_sliced_or_given_a_fake_total(api_app: FastAPI):
"""端点已有分页时只报告当前窗口,不再次切片或伪造全局总数。"""
async with make_client(api_app) as client:
response = await client.get("/native-page", params={"page": 2, "count": 2})
assert response.json()["data"] == [{"id": 3}, {"id": 4}]
assert "X-Total-Count" not in response.headers
assert response.headers["X-Result-Count"] == "2"
assert response.headers["X-Page"] == "2"
assert response.headers["X-Page-Size"] == "2"
async def test_legacy_collection_pagination_validation_uses_standard_error_shape(api_app: FastAPI):
"""兼容分页非法值必须复用标准 422 错误响应而不是静默修正。"""
async with make_client(api_app) as client:
response = await client.get("/many-items", params={"page": 0})
assert response.status_code == 422
assert response.json()["success"] is False
def test_collection_openapi_declares_optional_compatibility_parameters_and_headers(
api_app: FastAPI,
):
"""OpenAPI 必须区分缺省全量的新增参数与原生分页默认值。"""
openapi = api_app.openapi()
legacy = openapi["paths"]["/many-items"]["get"]
native = openapi["paths"]["/native-page"]["get"]
legacy_query = {
parameter["name"]: parameter["schema"]
for parameter in legacy["parameters"]
if parameter["in"] == "query"
}
native_query = {
parameter["name"]: parameter["schema"]
for parameter in native["parameters"]
if parameter["in"] == "query"
}
assert "default" not in legacy_query["page"]
assert "default" not in legacy_query["count"]
assert native_query["page"]["default"] == 1
assert native_query["count"]["default"] == 2
assert set(legacy["responses"]["200"]["headers"]) == {
"X-Total-Count",
"X-Result-Count",
"X-Page",
"X-Page-Size",
}
assert set(native["responses"]["200"]["headers"]) == {
"X-Result-Count",
"X-Page",
"X-Page-Size",
}
def test_every_host_collection_route_declares_compatible_count_contract():
"""所有宿主列表接口必须报告当前数量,并区分缺省全量与原生分页。"""
app = FastAPI()
from app.api.apiv1 import api_router
app.include_router(api_router, prefix="/api/v1")
openapi = app.openapi()
window_parameters = {"page", "count", "limit", "offset", "page_size", "max_results"}
for prefix, route in _v1_compat_routes():
if not ResponseAPIRoute._is_collection_response_model(route.response_model):
continue
method = sorted(route.methods)[0].lower()
operation = openapi["paths"][f"/api/v1{prefix}"][method]
headers = operation["responses"]["200"].get("headers", {})
assert "X-Result-Count" in headers, prefix
if (route.openapi_extra or {}).get(COLLECTION_TOTAL_OPENAPI_KEY):
assert "X-Total-Count" in headers, prefix
endpoint_parameters = set(inspect.signature(route.endpoint).parameters)
has_native_window = bool(endpoint_parameters & window_parameters)
compatible_method = (
"GET" in route.methods
or bool(
(route.openapi_extra or {}).get(
COLLECTION_PAGINATION_OPENAPI_KEY
)
)
)
if not compatible_method or has_native_window:
continue
query_parameters = {
parameter["name"]: parameter["schema"]
for parameter in operation.get("parameters", [])
if parameter.get("in") == "query"
}
assert {"page", "count"}.issubset(query_parameters), prefix
assert "default" not in query_parameters["page"], prefix
assert "default" not in query_parameters["count"], prefix
assert "X-Total-Count" in headers, prefix
async def test_explicit_none_and_stream_keep_native_protocol(api_app: FastAPI):
"""显式无响应模型和流式响应应保持原生协议。"""
async with make_client(api_app) as client:
+14
View File
@@ -109,6 +109,20 @@ def test_history_queries_reuse_explicit_sessions(db, monkeypatch):
assert await SubscribeHistory.async_list_by_type_and_username(
session, MediaType.TV.value, "alice", page=1, count=10
)
assert await SubscribeHistory.async_count_by_type(
session,
MediaType.TV.value,
) == 1
assert await SubscribeHistory.async_count_by_type_and_username(
session,
MediaType.TV.value,
"alice",
) == 1
assert await SubscribeHistory.async_count_by_type_and_username(
session,
MediaType.TV.value,
"bob",
) == 0
assert await SubscribeHistory.async_exists(
session, MediaSource.TMDB, "8501", season=1
) is not None
+25
View File
@@ -4,7 +4,9 @@ from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock
import pytest
from starlette.responses import Response
from app.api.endpoints.history import download_history
from app.application.history import HistoryQueryService
from app.schemas.history import DownloadHistory, TransferHistory
@@ -41,6 +43,29 @@ async def test_list_download_returns_schema_dtos() -> None:
download_repository.async_list_by_page.assert_awaited_once_with(2, 10)
@pytest.mark.asyncio
async def test_download_history_reports_exact_total_without_changing_list() -> None:
"""下载历史 API 应通过响应头报告精确总数并保持原列表返回。"""
service, download_repository, _ = _make_service()
download_repository.async_list_by_page.return_value = [
SimpleNamespace(id=7, title="Movie")
]
download_repository.async_count.return_value = 12
response = Response()
records = await download_history(
page=2,
count=10,
query=service,
_=SimpleNamespace(),
response=response,
)
assert records == [DownloadHistory(id=7, title="Movie")]
assert response.headers["X-Total-Count"] == "12"
download_repository.async_count.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_list_transfer_uses_explicit_status_without_reinterpreting_title() -> None:
"""状态筛选必须使用显式参数,中文标题仍保持标题查询语义。"""
+12 -1
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from starlette.responses import Response
from app import schemas
from app.api.endpoints import plugin as plugin_endpoint
@@ -240,9 +241,19 @@ def test_market_endpoint_reads_source_preserving_candidates_for_bound_update():
"app.api.endpoints.plugin.get_plugin_catalog_query",
return_value=query,
):
result = asyncio.run(plugin_endpoint.all_plugins(None, "market", False))
response = Response()
result = asyncio.run(
plugin_endpoint.all_plugins(
None,
"market",
False,
max_results=1,
response=response,
)
)
assert [plugin.id for plugin in result] == ["DemoPlugin"]
assert response.headers["X-Total-Count"] == "1"
assert result[0].update_candidate is not None
assert result[0].update_candidate.version == "2.0.0"
assert result[0].update_candidate.is_bound is True
+2 -1
View File
@@ -42,7 +42,8 @@ def _route_contract(route: APIRoute) -> tuple[Any, ...]:
(
dependency.dependency,
dependency.use_cache,
tuple(dependency.scopes or ()),
tuple(getattr(dependency, "scopes", ()) or ()),
getattr(dependency, "scope", None),
)
for dependency in route.dependencies
),
+32
View File
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from starlette.responses import Response
from app.api.endpoints.subscribe import create_subscribe
from app.application.outbox import ClaimedOutboxMessage
@@ -762,18 +763,25 @@ class TestSubscribeEndpoint:
count=2,
query=_subscription_query(repository, history_repository),
current_user=_EndpointUser(name="alice", is_superuser=False),
response=(regular_response := Response()),
)
)
assert [history.id for history in regular_result] == [8]
assert regular_response.headers["X-Total-Count"] == "1"
history_repository.async_list_by_type_and_username.assert_awaited_once_with(
MediaType.MOVIE.value,
"alice",
1,
2,
)
history_repository.async_count_by_type_and_username.assert_awaited_once_with(
MediaType.MOVIE.value,
"alice",
)
history_repository.async_list_by_type.assert_not_awaited()
history_repository.async_list_by_type_and_username.reset_mock()
history_repository.async_count_by_type_and_username.reset_mock()
history_repository.async_list_by_type.reset_mock()
superuser_result = asyncio.run(
subscribe_history(
@@ -782,14 +790,19 @@ class TestSubscribeEndpoint:
count=3,
query=_subscription_query(repository, history_repository),
current_user=_EndpointUser(name="admin", is_superuser=True),
response=(superuser_response := Response()),
)
)
assert [history.id for history in superuser_result] == [8, 9, 10]
assert superuser_response.headers["X-Total-Count"] == "3"
history_repository.async_list_by_type.assert_awaited_once_with(
MediaType.MOVIE.value,
1,
3,
)
history_repository.async_count_by_type.assert_awaited_once_with(
MediaType.MOVIE.value
)
history_repository.async_list_by_type_and_username.assert_not_awaited()
def test_delete_subscribe_history_rejects_other_user(self):
@@ -1357,6 +1370,10 @@ class _SubscriptionHistoryRepositoryFake:
self.async_get = AsyncMock(side_effect=self._async_get)
self.async_list_by_type = AsyncMock(side_effect=self._async_list_by_type)
self.async_list_by_type_and_username = AsyncMock(side_effect=self._async_list_by_type_and_username)
self.async_count_by_type = AsyncMock(side_effect=self._async_count_by_type)
self.async_count_by_type_and_username = AsyncMock(
side_effect=self._async_count_by_type_and_username
)
self.stage_delete = AsyncMock(side_effect=self._stage_delete)
async def _async_get(self, history_id: int) -> SubscriptionHistorySnapshot | None:
@@ -1386,6 +1403,21 @@ class _SubscriptionHistoryRepositoryFake:
start = (page - 1) * count
return rows[start : start + count]
async def _async_count_by_type(self, mtype: str) -> int:
"""异步统计指定类型的历史快照。"""
return sum(row.type == mtype for row in self.rows.values())
async def _async_count_by_type_and_username(
self,
mtype: str,
username: str,
) -> int:
"""异步统计指定类型和 owner 的历史快照。"""
return sum(
row.type == mtype and row.username == username
for row in self.rows.values()
)
async def _stage_delete(self, history_id: int) -> None:
"""暂存删除等价为从内存集合移除历史快照。"""
self.rows.pop(history_id, None)
+29 -1
View File
@@ -1,5 +1,7 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.application.subscription.contract import SubscriptionIdentity
from app.application.subscription.query import SubscriptionQueryService
@@ -8,6 +10,32 @@ from app.domain.context import MediaInfo
from app.schemas.types import MediaSource, MediaType
@pytest.mark.asyncio
async def test_subscription_history_count_preserves_owner_scope() -> None:
"""订阅历史总数必须复用列表的媒体类型和 owner 范围。"""
repository = Mock()
history_repository = AsyncMock()
history_repository.async_count_by_type.return_value = 8
history_repository.async_count_by_type_and_username.return_value = 3
service = SubscriptionQueryService(
repository,
history_repository=history_repository,
)
assert await service.count_history(MediaType.MOVIE.value) == 8
assert await service.count_history(
MediaType.MOVIE.value,
username="alice",
) == 3
history_repository.async_count_by_type.assert_awaited_once_with(
MediaType.MOVIE.value
)
history_repository.async_count_by_type_and_username.assert_awaited_once_with(
MediaType.MOVIE.value,
"alice",
)
def test_subscription_query_service_builds_complete_exists_identity() -> None:
"""存在性查询必须保留媒体、音乐实体、季和剧集组全部身份维度。"""
repository = Mock()
@@ -157,11 +157,13 @@ def test_transactional_repository_rolls_back_history_and_files_on_commit_failure
def test_transactional_repository_async_query_and_delete(db) -> None:
"""异步分页返回脱离 Session 的快照,删除由独立事务提交。"""
repository = _repository()
baseline_count = asyncio.run(repository.async_count())
history_id = repository.add(_history_write(download_hash="typed-async-hash"))
async def exercise() -> list[DownloadHistorySnapshot]:
"""在同一事件循环中执行异步分页和删除。"""
records = await repository.async_list_by_page(count=10)
assert await repository.async_count() == baseline_count + 1
await repository.async_delete(history_id)
return records