mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
feat(agent): add compatible collection pagination
This commit is contained in:
@@ -35,6 +35,12 @@ class MoviePilotApiExecutor:
|
|||||||
|
|
||||||
_ALLOWED_SOURCES = frozenset({"tmdb", "douban", "bangumi", "anilist"})
|
_ALLOWED_SOURCES = frozenset({"tmdb", "douban", "bangumi", "anilist"})
|
||||||
_ALLOWED_DOWNLOAD_ACTIONS = frozenset({"start", "stop"})
|
_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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -110,6 +116,34 @@ class MoviePilotApiExecutor:
|
|||||||
headers["X-MoviePilot-Agent-Source"] = self._context.source
|
headers["X-MoviePilot-Agent-Source"] = self._context.source
|
||||||
return headers
|
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(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
operation_id: str,
|
operation_id: str,
|
||||||
@@ -150,6 +184,7 @@ class MoviePilotApiExecutor:
|
|||||||
try:
|
try:
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
status_code = response.status_code
|
status_code = response.status_code
|
||||||
|
response_headers = dict(response.headers)
|
||||||
finally:
|
finally:
|
||||||
await response.aclose()
|
await response.aclose()
|
||||||
except ApiExecutionError:
|
except ApiExecutionError:
|
||||||
@@ -162,6 +197,7 @@ class MoviePilotApiExecutor:
|
|||||||
{"success": False, "error": "api_error", "status_code": status_code, "data": payload},
|
{"success": False, "error": "api_error", "status_code": status_code, "data": payload},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
payload = self._attach_collection_metadata(payload, response_headers)
|
||||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1597
-115
File diff suppressed because it is too large
Load Diff
+162
-5
@@ -754,6 +754,12 @@ def _person_credits_operation(openapi: Mapping[str, Any]) -> dict[str, Any]:
|
|||||||
for source_path in source_paths.values():
|
for source_path in source_paths.values():
|
||||||
if source_path not in paths:
|
if source_path not in paths:
|
||||||
raise ValueError(f"OpenAPI 缺少人物作品端点: {source_path}")
|
raise ValueError(f"OpenAPI 缺少人物作品端点: {source_path}")
|
||||||
|
representative = paths[source_paths["douban"]].get("get")
|
||||||
|
responses = (
|
||||||
|
deepcopy(representative.get("responses"))
|
||||||
|
if isinstance(representative, Mapping)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"summary": "Read person credits",
|
"summary": "Read person credits",
|
||||||
"parameters": [
|
"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.",
|
"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
|
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(
|
def build_api_mcp_input_schema(
|
||||||
*,
|
*,
|
||||||
openapi: Mapping[str, Any],
|
openapi: Mapping[str, Any],
|
||||||
@@ -954,25 +1100,36 @@ def build_api_mcp_input_schema(
|
|||||||
required.append("body")
|
required.append("body")
|
||||||
|
|
||||||
spec = spec_by_id[operation_id]
|
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",
|
"type": "object",
|
||||||
"title": operation_id,
|
"title": operation_id,
|
||||||
"description": (
|
"description": (
|
||||||
f"{summary} Method: {route.method}. Path: {route.path}. "
|
f"{summary} Method: {route.method}. Path: {route.path}. "
|
||||||
f"Effect: {spec.effect.value}."
|
f"Effect: {spec.effect.value}.{collection_guidance}"
|
||||||
),
|
),
|
||||||
"properties": properties,
|
"properties": properties,
|
||||||
"required": required,
|
"required": required,
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
}
|
}
|
||||||
)
|
if collection_contract is not None:
|
||||||
|
branch["x-moviepilot-collection"] = collection_contract
|
||||||
|
branches.append(branch)
|
||||||
|
|
||||||
schema: dict[str, Any] = {
|
schema: dict[str, Any] = {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"title": "moviepilot_api",
|
"title": "moviepilot_api",
|
||||||
"type": "object",
|
"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": {
|
"properties": {
|
||||||
"operation_id": {"type": "string", "enum": sorted(routes)},
|
"operation_id": {"type": "string", "enum": sorted(routes)},
|
||||||
"path_params": {"type": "object"},
|
"path_params": {"type": "object"},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import time
|
|||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
from typing import Any, Callable, List, Optional
|
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.adapters.web.security.access import verify_token
|
||||||
from app.agent.contracts import ReplyMode
|
from app.agent.contracts import ReplyMode
|
||||||
@@ -27,7 +27,11 @@ from app.api.dependencies.history import (
|
|||||||
get_transfer_execution_repository,
|
get_transfer_execution_repository,
|
||||||
get_transfer_history_mutation_command,
|
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.agent import get_running_agent_manager
|
||||||
from app.application.configuration import ApiRuntimeConfig
|
from app.application.configuration import ApiRuntimeConfig
|
||||||
from app.application.history import (
|
from app.application.history import (
|
||||||
@@ -343,17 +347,24 @@ def _submit_legacy_batch_ai_redo(
|
|||||||
"/download",
|
"/download",
|
||||||
summary="查询下载历史记录",
|
summary="查询下载历史记录",
|
||||||
response_model=List[_SchemaDownloadHistory],
|
response_model=List[_SchemaDownloadHistory],
|
||||||
|
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||||||
)
|
)
|
||||||
async def download_history(
|
async def download_history(
|
||||||
page: Optional[int] = 1,
|
page: Optional[int] = 1,
|
||||||
count: Optional[int] = 30,
|
count: Optional[int] = 30,
|
||||||
query: HistoryQueryService = Depends(get_history_query_service),
|
query: HistoryQueryService = Depends(get_history_query_service),
|
||||||
_: _SchemaTokenPayload = Depends(verify_token),
|
_: _SchemaTokenPayload = Depends(verify_token),
|
||||||
|
response: Response = None,
|
||||||
) -> Any:
|
) -> 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(
|
@router.delete(
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ from fastapi import Depends, HTTPException, status
|
|||||||
|
|
||||||
from app.adapters.web.security.access import verify_token
|
from app.adapters.web.security.access import verify_token
|
||||||
from app.api.dependencies.history import get_mediaserver_query_service
|
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.configuration import get_configured_system_config
|
||||||
from app.application.mediaserver import (
|
from app.application.mediaserver import (
|
||||||
MediaServerQueryService,
|
MediaServerQueryService,
|
||||||
@@ -149,6 +152,7 @@ def exists(
|
|||||||
"/notexists",
|
"/notexists",
|
||||||
summary="查询媒体库缺失信息(媒体服务器)",
|
summary="查询媒体库缺失信息(媒体服务器)",
|
||||||
response_model=List[_SchemaNotExistMediaInfo],
|
response_model=List[_SchemaNotExistMediaInfo],
|
||||||
|
openapi_extra={COLLECTION_PAGINATION_OPENAPI_KEY: True},
|
||||||
)
|
)
|
||||||
def not_exists(
|
def not_exists(
|
||||||
media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token)
|
media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import aiofiles
|
|||||||
from anyio import Path as AsyncPath
|
from anyio import Path as AsyncPath
|
||||||
from fastapi import Depends, Header, HTTPException, Query, Security
|
from fastapi import Depends, Header, HTTPException, Query, Security
|
||||||
from starlette import status
|
from starlette import status
|
||||||
|
from starlette.responses import Response as StarletteResponse
|
||||||
from starlette.responses import StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from app.adapters.web.security.access import (
|
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.dependencies.plugin import get_plugin_config_command
|
||||||
from app.api.principal import ApiPrincipal
|
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.commands import init_commands
|
||||||
from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config
|
from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config
|
||||||
from app.application.plugin.catalog import get_plugin_catalog_query
|
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)
|
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(
|
async def all_plugins(
|
||||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||||
state: Optional[str] = "all",
|
state: Optional[str] = "all",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
max_results: Annotated[int, Query(ge=1, le=200)] = 50,
|
max_results: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
|
response: StarletteResponse = None,
|
||||||
) -> List[_SchemaPlugin]:
|
) -> List[_SchemaPlugin]:
|
||||||
"""
|
"""
|
||||||
查询插件清单,并支持 Agent 使用关键字和有界结果完成精确选择。
|
查询插件清单,并支持 Agent 使用关键字和有界结果完成精确选择。
|
||||||
@@ -187,6 +198,8 @@ async def all_plugins(
|
|||||||
)
|
)
|
||||||
if query:
|
if query:
|
||||||
plugins = [item["plugin"] for item in search_plugin_candidates(query, plugins)]
|
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]
|
return plugins[:max_results]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ from app.api.dependencies.auth import (
|
|||||||
get_current_active_user,
|
get_current_active_user,
|
||||||
)
|
)
|
||||||
from app.api.principal import ApiPrincipal
|
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.configuration import get_api_runtime_config_snapshot
|
||||||
from app.application.directory import DirectoryHelper
|
from app.application.directory import DirectoryHelper
|
||||||
from app.chain.media import MediaChain
|
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(
|
def list_files(
|
||||||
fileitem: _SchemaFileItem,
|
fileitem: _SchemaFileItem,
|
||||||
sort: Optional[str] = "updated_at",
|
sort: Optional[str] = "updated_at",
|
||||||
@@ -150,6 +158,7 @@ def _list_files(
|
|||||||
"/agent/list",
|
"/agent/list",
|
||||||
summary="查询 Agent 可用目录和文件",
|
summary="查询 Agent 可用目录和文件",
|
||||||
response_model=List[_SchemaFileItem],
|
response_model=List[_SchemaFileItem],
|
||||||
|
openapi_extra={COLLECTION_PAGINATION_OPENAPI_KEY: True},
|
||||||
)
|
)
|
||||||
def list_agent_files(
|
def list_agent_files(
|
||||||
fileitem: _SchemaFileItem,
|
fileitem: _SchemaFileItem,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import Annotated, Any, List, Optional
|
from typing import Annotated, Any, List, Optional
|
||||||
|
|
||||||
import cn2an
|
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.external.server import MoviePilotServerHelper
|
||||||
from app.adapters.web.security.access import (
|
from app.adapters.web.security.access import (
|
||||||
@@ -26,7 +26,11 @@ from app.api.dependencies.subscription import (
|
|||||||
get_subscription_query_service,
|
get_subscription_query_service,
|
||||||
)
|
)
|
||||||
from app.api.principal import ApiPrincipal
|
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 (
|
from app.application.configuration import (
|
||||||
get_api_runtime_config_snapshot,
|
get_api_runtime_config_snapshot,
|
||||||
get_configured_system_config,
|
get_configured_system_config,
|
||||||
@@ -537,7 +541,10 @@ async def seerr_subscribe(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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(
|
async def subscribe_history(
|
||||||
mtype: str,
|
mtype: str,
|
||||||
@@ -545,16 +552,23 @@ async def subscribe_history(
|
|||||||
count: Optional[int] = 30,
|
count: Optional[int] = 30,
|
||||||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||||||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||||||
|
response: Response = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
查询电影、电视剧或音乐订阅历史
|
查询电影、电视剧或音乐订阅历史
|
||||||
"""
|
"""
|
||||||
return await query.list_history(
|
username = None if current_user.is_superuser else current_user.name
|
||||||
|
results = await query.list_history(
|
||||||
mtype,
|
mtype,
|
||||||
page=page,
|
page=page,
|
||||||
count=count,
|
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(
|
@router.delete(
|
||||||
|
|||||||
+247
-2
@@ -1,8 +1,9 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
import json
|
||||||
from functools import wraps
|
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.datastructures import DefaultPlaceholder
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.routing import APIRoute, get_typed_return_annotation
|
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": "服务器内部错误"},
|
500: {"model": Response[None], "description": "服务器内部错误"},
|
||||||
}
|
}
|
||||||
RAW_RESPONSE_OPENAPI_KEY = "x-moviepilot-raw-response"
|
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):
|
class ResponseAPIRoute(APIRoute):
|
||||||
@@ -41,6 +94,12 @@ class ResponseAPIRoute(APIRoute):
|
|||||||
status_code = kwargs.get("status_code")
|
status_code = kwargs.get("status_code")
|
||||||
openapi_extra = kwargs.get("openapi_extra") or {}
|
openapi_extra = kwargs.get("openapi_extra") or {}
|
||||||
force_raw = bool(openapi_extra.get(RAW_RESPONSE_OPENAPI_KEY))
|
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):
|
if isinstance(response_model, DefaultPlaceholder):
|
||||||
inferred_model = get_typed_return_annotation(endpoint)
|
inferred_model = get_typed_return_annotation(endpoint)
|
||||||
@@ -54,6 +113,38 @@ class ResponseAPIRoute(APIRoute):
|
|||||||
response_model = Response[JsonData]
|
response_model = Response[JsonData]
|
||||||
kwargs["response_model"] = response_model
|
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(
|
should_wrap = self._should_wrap_response(
|
||||||
response_model=response_model,
|
response_model=response_model,
|
||||||
response_class=response_class,
|
response_class=response_class,
|
||||||
@@ -70,6 +161,21 @@ class ResponseAPIRoute(APIRoute):
|
|||||||
|
|
||||||
super().__init__(path=path, endpoint=endpoint, **kwargs)
|
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
|
@staticmethod
|
||||||
def _should_wrap_response(
|
def _should_wrap_response(
|
||||||
response_model: Any,
|
response_model: Any,
|
||||||
@@ -110,6 +216,145 @@ class ResponseAPIRoute(APIRoute):
|
|||||||
except TypeError:
|
except TypeError:
|
||||||
return False
|
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
|
@staticmethod
|
||||||
def _merge_error_responses(
|
def _merge_error_responses(
|
||||||
responses: dict[int | str, dict[str, Any]] | None,
|
responses: dict[int | str, dict[str, Any]] | None,
|
||||||
|
|||||||
@@ -612,6 +612,10 @@ class AsyncDownloadHistoryQueryRepository(Protocol):
|
|||||||
"""按下载时间倒序分页读取历史记录。"""
|
"""按下载时间倒序分页读取历史记录。"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
async def async_count(self) -> int:
|
||||||
|
"""返回下载历史记录总数。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ManualTransferHistory:
|
class ManualTransferHistory:
|
||||||
@@ -700,6 +704,10 @@ class HistoryQueryService:
|
|||||||
records = await self._download_repository.async_list_by_page(page, count)
|
records = await self._download_repository.async_list_by_page(page, count)
|
||||||
return [DownloadHistory.model_validate(record) for record in records]
|
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(
|
async def list_transfer(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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):
|
class SubscriptionWritePort(Protocol):
|
||||||
"""独立短事务订阅新增端口。"""
|
"""独立短事务订阅新增端口。"""
|
||||||
|
|||||||
@@ -121,6 +121,22 @@ class SubscriptionQueryService:
|
|||||||
result.append(item)
|
result.append(item)
|
||||||
return result
|
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
|
@staticmethod
|
||||||
def _matches_music_type(
|
def _matches_music_type(
|
||||||
record: SubscriptionSnapshot,
|
record: SubscriptionSnapshot,
|
||||||
|
|||||||
@@ -219,6 +219,11 @@ class TransactionalDownloadHistoryRepository:
|
|||||||
)
|
)
|
||||||
return [_project_history(record) for record in records]
|
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(
|
def add(
|
||||||
self,
|
self,
|
||||||
history: DownloadHistoryWrite,
|
history: DownloadHistoryWrite,
|
||||||
|
|||||||
@@ -471,6 +471,22 @@ class TransactionalSubscriptionHistoryRepository:
|
|||||||
)
|
)
|
||||||
return [_project_history(record) for record in records]
|
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:
|
class SessionSubscriptionRepository:
|
||||||
"""复用调用方 Session,负责订阅查询投影和暂存且不提交。"""
|
"""复用调用方 Session,负责订阅查询投影和暂存且不提交。"""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Any, Optional
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
@@ -153,6 +153,32 @@ class SubscribeHistory(Base):
|
|||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
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
|
@classmethod
|
||||||
def _identity_condition(
|
def _identity_condition(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -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):
|
async def async_delete_history(self, historyid: int):
|
||||||
"""
|
"""
|
||||||
异步删除下载记录。
|
异步删除下载记录。
|
||||||
|
|||||||
@@ -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]:
|
async def async_get(self, history_id: int) -> Optional[SubscribeHistory]:
|
||||||
"""异步按 ID 查询订阅历史。"""
|
"""异步按 ID 查询订阅历史。"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MoviePilot Agent 工具体系重构计划
|
# MoviePilot Agent 工具体系重构计划
|
||||||
|
|
||||||
> 状态:COMPLETE — API/Skill/MCP 全面收口,固定 80% 覆盖率与远端 CI 已通过
|
> 状态:COMPLETE — L10 查询 API 兼容分页与总数合同已验证
|
||||||
>
|
>
|
||||||
> 建立日期:2026-08-31
|
> 建立日期:2026-08-31
|
||||||
>
|
>
|
||||||
@@ -133,7 +133,8 @@ provider 动态返回 namespaced action、参数约束、副作用等级及是
|
|||||||
| L6 原生工具收敛 | VERIFIED | L4,L5 | Agent 任务和人格工具合并为 action 工具;宿主/会话/渠道原生能力边界固定 |
|
| L6 原生工具收敛 | VERIFIED | L4,L5 | Agent 任务和人格工具合并为 action 工具;宿主/会话/渠道原生能力边界固定 |
|
||||||
| L7 第三方服务 Skill 化 | VERIFIED | L5 | 下载器和媒体服务器能力发现、受控脚本、Skill、策略和离线测试完成;重复低层 Agent API operation 已删除 |
|
| L7 第三方服务 Skill 化 | VERIFIED | L5 | 下载器和媒体服务器能力发现、受控脚本、Skill、策略和离线测试完成;重复低层 Agent API operation 已删除 |
|
||||||
| L8 收口与交付 | VERIFIED | L6,L7 | 旧代码、文档与架构基线已收口;静态检查和锁定全量测试已完成 |
|
| 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 网关约束
|
## 5. L2 受控 API 网关约束
|
||||||
|
|
||||||
@@ -330,4 +331,17 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API
|
|||||||
- 全量 Agent API 面审计、Skill/MCP 参数合同、下载器/媒体服务器/数据库真实只读调用、音乐双向浏览能力和固定 80% 覆盖率要求均已完成
|
- 全量 Agent API 面审计、Skill/MCP 参数合同、下载器/媒体服务器/数据库真实只读调用、音乐双向浏览能力和固定 80% 覆盖率要求均已完成
|
||||||
- 本次重构正式完成:旧 Agent 工具代码、旧 MCP 兼容路径和运行时切换开关均不保留;后续新增 API 必须同步注册表、Skill、MCP schema、审计清单和测试
|
- 本次重构正式完成:旧 Agent 工具代码、旧 MCP 兼容路径和运行时切换开关均不保留;后续新增 API 必须同步注册表、Skill、MCP schema、审计清单和测试
|
||||||
|
|
||||||
|
### 2026-09-01:L10 查询 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`,其网络修复与本阶段文件无冲突;快进后的关键回归再次通过
|
||||||
|
|
||||||
本文件作为本次重构的持续记录,保留阶段状态、实际变更、验证结果、提交状态与已知基线边界。
|
本文件作为本次重构的持续记录,保留阶段状态、实际变更、验证结果、提交状态与已知基线边界。
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确
|
|||||||
|
|
||||||
只允许传 `tools/list` 对应 operation 分支中声明的 `path_params`、`query` 和 `body` 字段。不得传 URL、认证头、API Token 或任意 HTTP 方法。
|
只允许传 `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` 调用形状
|
### `downloader_operation` / `mediaserver_operation` 调用形状
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -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`.",
|
"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.",
|
"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):
|
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()}",
|
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"):
|
for bucket in ("path_params", "query", "body"):
|
||||||
bucket_schema = branch["properties"].get(bucket)
|
bucket_schema = branch["properties"].get(bucket)
|
||||||
if not isinstance(bucket_schema, Mapping):
|
if not isinstance(bucket_schema, Mapping):
|
||||||
|
|||||||
@@ -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`.
|
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.
|
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`
|
### `config.identifiers.get`
|
||||||
`GET /api/v1/system/identifiers`; policy effect: `safe_read`.
|
`GET /api/v1/system/identifiers`; policy effect: `safe_read`.
|
||||||
@@ -223,15 +225,17 @@ Purpose: Read current MoviePilot process and host memory utilization.
|
|||||||
### `dashboard.network`
|
### `dashboard.network`
|
||||||
`GET /api/v1/dashboard/network`; policy effect: `safe_read`.
|
`GET /api/v1/dashboard/network`; policy effect: `safe_read`.
|
||||||
Purpose: Read the current host network receive and transmit counters.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `dashboard.processes`
|
### `dashboard.processes`
|
||||||
`GET /api/v1/dashboard/processes`; policy effect: `safe_read`.
|
`GET /api/v1/dashboard/processes`; policy effect: `safe_read`.
|
||||||
Purpose: List host processes visible to the MoviePilot runtime.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `dashboard.storage`
|
### `dashboard.storage`
|
||||||
@@ -251,8 +255,9 @@ Purpose: Read MoviePilot host, runtime, platform, and uptime summary information
|
|||||||
### `dashboard.transfer.statistics`
|
### `dashboard.transfer.statistics`
|
||||||
`GET /api/v1/dashboard/transfer`; policy effect: `safe_read`.
|
`GET /api/v1/dashboard/transfer`; policy effect: `safe_read`.
|
||||||
Purpose: Read aggregate file-transfer counts grouped by time period.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `database.backups.create`
|
### `database.backups.create`
|
||||||
@@ -272,8 +277,9 @@ Purpose: Delete one exact managed database backup artifact.
|
|||||||
### `database.backups.list`
|
### `database.backups.list`
|
||||||
`GET /api/v1/system/database/backups`; policy effect: `safe_read`.
|
`GET /api/v1/system/database/backups`; policy effect: `safe_read`.
|
||||||
Purpose: List managed database backup artifacts without exposing host paths.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `database.backups.verify`
|
### `database.backups.verify`
|
||||||
@@ -293,8 +299,9 @@ Purpose: Submit one torrent to MoviePilot's normal download workflow.
|
|||||||
### `download.clients`
|
### `download.clients`
|
||||||
`GET /api/v1/download/clients`; policy effect: `safe_read`.
|
`GET /api/v1/download/clients`; policy effect: `safe_read`.
|
||||||
Purpose: List enabled downloader instance names and provider types without credentials.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `download.history.delete`
|
### `download.history.delete`
|
||||||
@@ -307,6 +314,7 @@ Purpose: Delete one MoviePilot download-history record.
|
|||||||
### `download.history.list`
|
### `download.history.list`
|
||||||
`GET /api/v1/history/download`; policy effect: `safe_read`.
|
`GET /api/v1/history/download`; policy effect: `safe_read`.
|
||||||
Purpose: Page MoviePilot download-history records in reverse chronological order.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -314,15 +322,17 @@ Purpose: Page MoviePilot download-history records in reverse chronological order
|
|||||||
### `download.paths`
|
### `download.paths`
|
||||||
`GET /api/v1/download/paths`; policy effect: `safe_read`.
|
`GET /api/v1/download/paths`; policy effect: `safe_read`.
|
||||||
Purpose: List configured downloader save-path URIs that may be passed to download.add.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `download.tasks.active`
|
### `download.tasks.active`
|
||||||
`GET /api/v1/download/`; policy effect: `safe_read`.
|
`GET /api/v1/download/`; policy effect: `safe_read`.
|
||||||
Purpose: List currently downloading MoviePilot tasks with their canonical media context.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `filter.builtin`
|
### `filter.builtin`
|
||||||
@@ -405,6 +415,7 @@ Purpose: Check configured media servers for one canonical media identity.
|
|||||||
### `library.latest`
|
### `library.latest`
|
||||||
`GET /api/v1/mediaserver/latest`; policy effect: `safe_read`.
|
`GET /api/v1/mediaserver/latest`; policy effect: `safe_read`.
|
||||||
Purpose: List recently added items from one configured media-server instance for the current user.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -440,27 +451,31 @@ Purpose: Read canonical media details from one selected metadata source.
|
|||||||
### `media.episode_group.seasons`
|
### `media.episode_group.seasons`
|
||||||
`GET /api/v1/media/group/seasons/{episode_group}`; policy effect: `safe_read`.
|
`GET /api/v1/media/group/seasons/{episode_group}`; policy effect: `safe_read`.
|
||||||
Purpose: List seasons defined by one exact TMDB episode-group identity.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `media.episode_groups`
|
### `media.episode_groups`
|
||||||
`GET /api/v1/media/groups/{tmdbid}`; policy effect: `safe_read`.
|
`GET /api/v1/media/groups/{tmdbid}`; policy effect: `safe_read`.
|
||||||
Purpose: List alternate TMDB episode groups available for one TV media identity.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `media.episode_schedule`
|
### `media.episode_schedule`
|
||||||
`GET /api/v1/tmdb/{tmdbid}/{season}`; policy effect: `safe_read`.
|
`GET /api/v1/tmdb/{tmdbid}/{season}`; policy effect: `safe_read`.
|
||||||
Purpose: Read TMDB episode release information for one season.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `media.person.credits`
|
### `media.person.credits`
|
||||||
`GET /api/v1/{source}/person/credits/{person_id}`; policy effect: `safe_read`.
|
`GET /api/v1/{source}/person/credits/{person_id}`; policy effect: `safe_read`.
|
||||||
Purpose: Read one person's credits from the selected metadata source.
|
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.
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -468,6 +483,7 @@ Purpose: Read one person's credits from the selected metadata source.
|
|||||||
### `media.person.search`
|
### `media.person.search`
|
||||||
`GET /api/v1/media/search`; policy effect: `safe_read`.
|
`GET /api/v1/media/search`; policy effect: `safe_read`.
|
||||||
Purpose: Search people across selected metadata sources.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -496,6 +512,7 @@ Purpose: Generate or refresh metadata for one storage item.
|
|||||||
### `media.search`
|
### `media.search`
|
||||||
`GET /api/v1/media/search`; policy effect: `safe_read`.
|
`GET /api/v1/media/search`; policy effect: `safe_read`.
|
||||||
Purpose: Search canonical media across selected metadata sources.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -503,15 +520,17 @@ Purpose: Search canonical media across selected metadata sources.
|
|||||||
### `media.seasons`
|
### `media.seasons`
|
||||||
`GET /api/v1/media/seasons`; policy effect: `safe_read`.
|
`GET /api/v1/media/seasons`; policy effect: `safe_read`.
|
||||||
Purpose: List seasons for one exact media identity or a title-and-year fallback.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `media.sources`
|
### `media.sources`
|
||||||
`GET /api/v1/media/source`; policy effect: `safe_read`.
|
`GET /api/v1/media/source`; policy effect: `safe_read`.
|
||||||
Purpose: List metadata sources currently registered for MoviePilot media operations.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `music.album.get`
|
### `music.album.get`
|
||||||
@@ -524,6 +543,7 @@ Purpose: Read one album's details, tracks, releases, and aligned artist names an
|
|||||||
### `music.album.related`
|
### `music.album.related`
|
||||||
`GET /api/v1/music/album/{album_id}/related`; policy effect: `safe_read`.
|
`GET /api/v1/music/album/{album_id}/related`; policy effect: `safe_read`.
|
||||||
Purpose: Browse albums related to one source-native album identity.
|
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.
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -531,6 +551,7 @@ Purpose: Browse albums related to one source-native album identity.
|
|||||||
### `music.artist.albums`
|
### `music.artist.albums`
|
||||||
`GET /api/v1/music/artist/{artist_id}/albums`; policy effect: `safe_read`.
|
`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.
|
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.
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -545,6 +566,7 @@ Purpose: Read one artist's canonical details from the selected music metadata so
|
|||||||
### `music.artist.related`
|
### `music.artist.related`
|
||||||
`GET /api/v1/music/artist/{artist_id}/related`; policy effect: `safe_read`.
|
`GET /api/v1/music/artist/{artist_id}/related`; policy effect: `safe_read`.
|
||||||
Purpose: Browse artists related to one source-native artist identity.
|
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.
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -573,6 +595,7 @@ Purpose: Inspect the administrator-only MusicBrainz recognition cache and summar
|
|||||||
### `music.explore`
|
### `music.explore`
|
||||||
`GET /api/v1/music/explore`; policy effect: `safe_read`.
|
`GET /api/v1/music/explore`; policy effect: `safe_read`.
|
||||||
Purpose: Browse MusicBrainz charts or fresh releases, or Douban Music tag categories.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -671,6 +694,7 @@ Purpose: Install or update one plugin from an approved source.
|
|||||||
### `plugin.installed`
|
### `plugin.installed`
|
||||||
`GET /api/v1/plugin/`; policy effect: `safe_read`.
|
`GET /api/v1/plugin/`; policy effect: `safe_read`.
|
||||||
Purpose: List installed plugins and their runtime status.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -678,6 +702,7 @@ Purpose: List installed plugins and their runtime status.
|
|||||||
### `plugin.market`
|
### `plugin.market`
|
||||||
`GET /api/v1/plugin/`; policy effect: `safe_read`.
|
`GET /api/v1/plugin/`; policy effect: `safe_read`.
|
||||||
Purpose: List plugins available from configured marketplaces.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -776,6 +801,7 @@ Purpose: Uninstall one plugin and remove it from the installed set.
|
|||||||
### `recommendation.list`
|
### `recommendation.list`
|
||||||
`GET /api/v1/recommend/agent`; policy effect: `safe_read`.
|
`GET /api/v1/recommend/agent`; policy effect: `safe_read`.
|
||||||
Purpose: Read personalized media or music recommendations.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -783,8 +809,9 @@ Purpose: Read personalized media or music recommendations.
|
|||||||
### `scheduler.list`
|
### `scheduler.list`
|
||||||
`GET /api/v1/dashboard/schedule`; policy effect: `safe_read`.
|
`GET /api/v1/dashboard/schedule`; policy effect: `safe_read`.
|
||||||
Purpose: List registered scheduler jobs and their current state.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `scheduler.progress`
|
### `scheduler.progress`
|
||||||
@@ -818,6 +845,7 @@ Purpose: Read the most recent torrent-search context and result set.
|
|||||||
### `search.title`
|
### `search.title`
|
||||||
`GET /api/v1/search/title`; policy effect: `external_side_effect`.
|
`GET /api/v1/search/title`; policy effect: `external_side_effect`.
|
||||||
Purpose: Search torrent sites directly from a free-form title and optional media filters.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -825,8 +853,9 @@ Purpose: Search torrent sites directly from a free-form title and optional media
|
|||||||
### `search.torrents`
|
### `search.torrents`
|
||||||
`GET /api/v1/search/media/{media_id}`; policy effect: `safe_read`.
|
`GET /api/v1/search/media/{media_id}`; policy effect: `safe_read`.
|
||||||
Purpose: Search torrent sites for one canonical media identity.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `site.add`
|
### `site.add`
|
||||||
@@ -853,8 +882,9 @@ Purpose: Authenticate a supported site account and persist the resulting site au
|
|||||||
### `site.category`
|
### `site.category`
|
||||||
`GET /api/v1/site/category/{site_id}`; policy effect: `safe_read`.
|
`GET /api/v1/site/category/{site_id}`; policy effect: `safe_read`.
|
||||||
Purpose: List torrent categories supported by one configured site.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `site.cookie.update`
|
### `site.cookie.update`
|
||||||
@@ -881,8 +911,9 @@ Purpose: Delete one configured site by persistent site ID.
|
|||||||
### `site.list`
|
### `site.list`
|
||||||
`GET /api/v1/site/agent`; policy effect: `safe_read`.
|
`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.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `site.mapping`
|
### `site.mapping`
|
||||||
@@ -909,6 +940,7 @@ Purpose: Delete all configured sites and start a fresh CookieCloud synchronizati
|
|||||||
### `site.resource`
|
### `site.resource`
|
||||||
`GET /api/v1/site/resource/{site_id}`; policy effect: `external_side_effect`.
|
`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.
|
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.
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -916,15 +948,17 @@ Purpose: Browse torrent resources from one configured site with category and key
|
|||||||
### `site.rss`
|
### `site.rss`
|
||||||
`GET /api/v1/site/rss`; policy effect: `safe_read`.
|
`GET /api/v1/site/rss`; policy effect: `safe_read`.
|
||||||
Purpose: List configured sites selected for RSS subscription processing.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `site.searchable`
|
### `site.searchable`
|
||||||
`GET /api/v1/site/media/{media_type}`; policy effect: `safe_read`.
|
`GET /api/v1/site/media/{media_type}`; policy effect: `safe_read`.
|
||||||
Purpose: List active configured sites supporting one exact media type.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `site.statistic`
|
### `site.statistic`
|
||||||
@@ -937,8 +971,9 @@ Purpose: Read account and traffic statistics for one exact configured site domai
|
|||||||
### `site.statistics`
|
### `site.statistics`
|
||||||
`GET /api/v1/site/statistic`; policy effect: `safe_read`.
|
`GET /api/v1/site/statistic`; policy effect: `safe_read`.
|
||||||
Purpose: Read the latest account and traffic statistics for all configured sites.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `site.supporting`
|
### `site.supporting`
|
||||||
@@ -965,15 +1000,17 @@ Purpose: Update one configured site's complete settings.
|
|||||||
### `site.userdata`
|
### `site.userdata`
|
||||||
`GET /api/v1/site/userdata/{site_id}`; policy effect: `safe_read`.
|
`GET /api/v1/site/userdata/{site_id}`; policy effect: `safe_read`.
|
||||||
Purpose: Read the latest account statistics collected from one site.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `site.userdata.latest`
|
### `site.userdata.latest`
|
||||||
`GET /api/v1/site/userdata/latest`; policy effect: `safe_read`.
|
`GET /api/v1/site/userdata/latest`; policy effect: `safe_read`.
|
||||||
Purpose: Read the latest collected account statistics for every configured site.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `site.userdata.refresh`
|
### `site.userdata.refresh`
|
||||||
@@ -986,8 +1023,9 @@ Purpose: Refresh and return account statistics for one configured site.
|
|||||||
### `slash.list`
|
### `slash.list`
|
||||||
`GET /api/v1/message/agent/commands`; policy effect: `safe_read`.
|
`GET /api/v1/message/agent/commands`; policy effect: `safe_read`.
|
||||||
Purpose: List slash commands that the Agent may dispatch.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `slash.run`
|
### `slash.run`
|
||||||
@@ -1007,8 +1045,9 @@ Purpose: Delete one exact file or directory from a configured storage provider.
|
|||||||
### `storage.list`
|
### `storage.list`
|
||||||
`POST /api/v1/storage/agent/list`; policy effect: `safe_read`.
|
`POST /api/v1/storage/agent/list`; policy effect: `safe_read`.
|
||||||
Purpose: List files or directories from one configured storage location.
|
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
|
- `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.
|
- `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`
|
### `storage.manage`
|
||||||
@@ -1035,8 +1074,9 @@ Purpose: Rename one exact storage item, optionally applying media-aware recursiv
|
|||||||
### `storage.settings`
|
### `storage.settings`
|
||||||
`GET /api/v1/storage/directories`; policy effect: `safe_read`.
|
`GET /api/v1/storage/directories`; policy effect: `safe_read`.
|
||||||
Purpose: Read configured directory or storage settings.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `subscription.add`
|
### `subscription.add`
|
||||||
@@ -1091,8 +1131,9 @@ Purpose: Stop following one subscription-sharing user by exact share user ID.
|
|||||||
### `subscription.follow.list`
|
### `subscription.follow.list`
|
||||||
`GET /api/v1/subscribe/follow`; policy effect: `safe_read`.
|
`GET /api/v1/subscribe/follow`; policy effect: `safe_read`.
|
||||||
Purpose: List subscription-sharing user IDs followed by the current user.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `subscription.fork`
|
### `subscription.fork`
|
||||||
@@ -1112,6 +1153,7 @@ Purpose: Read one accessible subscription by persistent subscription ID.
|
|||||||
### `subscription.history`
|
### `subscription.history`
|
||||||
`GET /api/v1/subscribe/history/{mtype}`; policy effect: `safe_read`.
|
`GET /api/v1/subscribe/history/{mtype}`; policy effect: `safe_read`.
|
||||||
Purpose: List completed or archived subscription records.
|
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.
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -1126,8 +1168,9 @@ Purpose: Delete one accessible subscription-history record.
|
|||||||
### `subscription.list`
|
### `subscription.list`
|
||||||
`GET /api/v1/subscribe/`; policy effect: `safe_read`.
|
`GET /api/v1/subscribe/`; policy effect: `safe_read`.
|
||||||
Purpose: List active subscriptions.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `subscription.metadata.refresh`
|
### `subscription.metadata.refresh`
|
||||||
@@ -1140,6 +1183,7 @@ Purpose: Start a system-wide refresh of subscription TMDB metadata.
|
|||||||
### `subscription.popular`
|
### `subscription.popular`
|
||||||
`GET /api/v1/subscribe/popular`; policy effect: `safe_read`.
|
`GET /api/v1/subscribe/popular`; policy effect: `safe_read`.
|
||||||
Purpose: List globally popular subscriptions with filters and pagination.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -1189,13 +1233,15 @@ Purpose: Delete one shared-subscription publication by share ID.
|
|||||||
### `subscription.share.statistics`
|
### `subscription.share.statistics`
|
||||||
`GET /api/v1/subscribe/share/statistics`; policy effect: `safe_read`.
|
`GET /api/v1/subscribe/share/statistics`; policy effect: `safe_read`.
|
||||||
Purpose: Read aggregate contribution and reuse counts for subscription sharers.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `subscription.shares`
|
### `subscription.shares`
|
||||||
`GET /api/v1/subscribe/shares`; policy effect: `safe_read`.
|
`GET /api/v1/subscribe/shares`; policy effect: `safe_read`.
|
||||||
Purpose: List shared subscriptions with filters and pagination.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -1217,20 +1263,23 @@ Purpose: Update one existing movie, TV, or music subscription.
|
|||||||
### `subscription.user.list`
|
### `subscription.user.list`
|
||||||
`GET /api/v1/subscribe/user/{username}`; policy effect: `safe_read`.
|
`GET /api/v1/subscribe/user/{username}`; policy effect: `safe_read`.
|
||||||
Purpose: List public subscriptions owned by one accessible MoviePilot username.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `subtitle.search.media`
|
### `subtitle.search.media`
|
||||||
`GET /api/v1/search/subtitle/media/{media_id}`; policy effect: `external_side_effect`.
|
`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.
|
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.
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `subtitle.search.title`
|
### `subtitle.search.title`
|
||||||
`GET /api/v1/search/subtitle/title`; policy effect: `external_side_effect`.
|
`GET /api/v1/search/subtitle/title`; policy effect: `external_side_effect`.
|
||||||
Purpose: Search subtitle providers from a free-form title and optional media filters.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -1252,8 +1301,9 @@ Purpose: Run the built-in availability test for one loaded MoviePilot module.
|
|||||||
### `system.network.targets`
|
### `system.network.targets`
|
||||||
`GET /api/v1/system/nettest/targets`; policy effect: `safe_read`.
|
`GET /api/v1/system/nettest/targets`; policy effect: `safe_read`.
|
||||||
Purpose: List approved built-in network-test targets without exposing their request URLs.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `system.network.test`
|
### `system.network.test`
|
||||||
@@ -1315,8 +1365,9 @@ Purpose: Read the installation version and runtime usage report available to the
|
|||||||
### `system.versions`
|
### `system.versions`
|
||||||
`GET /api/v1/system/versions`; policy effect: `safe_read`.
|
`GET /api/v1/system/versions`; policy effect: `safe_read`.
|
||||||
Purpose: List available MoviePilot GitHub releases.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `torrent.cache.clear`
|
### `torrent.cache.clear`
|
||||||
@@ -1371,6 +1422,7 @@ Purpose: Run MoviePilot's manual file-transfer and organization workflow.
|
|||||||
### `transfer.history`
|
### `transfer.history`
|
||||||
`GET /api/v1/history/transfer`; policy effect: `safe_read`.
|
`GET /api/v1/history/transfer`; policy effect: `safe_read`.
|
||||||
Purpose: List file-transfer history with filters and pagination.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -1427,6 +1479,7 @@ Purpose: Record the authorized decision for one durable transfer manual-review o
|
|||||||
### `transfer.manual_reviews`
|
### `transfer.manual_reviews`
|
||||||
`GET /api/v1/transfer/tasks/manual-reviews`; policy effect: `safe_read`.
|
`GET /api/v1/transfer/tasks/manual-reviews`; policy effect: `safe_read`.
|
||||||
Purpose: Page durable transfer tasks awaiting manual review or retry recovery.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
@@ -1441,8 +1494,9 @@ Purpose: Preview the organized destination name for one source path and media id
|
|||||||
### `transfer.queue`
|
### `transfer.queue`
|
||||||
`GET /api/v1/transfer/queue`; policy effect: `safe_read`.
|
`GET /api/v1/transfer/queue`; policy effect: `safe_read`.
|
||||||
Purpose: List items waiting in the file-transfer queue.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `transfer.queue.delete`
|
### `transfer.queue.delete`
|
||||||
@@ -1462,8 +1516,9 @@ Purpose: Resolve the configured transfer destination for supplied source storage
|
|||||||
### `workflow.actions`
|
### `workflow.actions`
|
||||||
`GET /api/v1/workflow/actions`; policy effect: `safe_read`.
|
`GET /api/v1/workflow/actions`; policy effect: `safe_read`.
|
||||||
Purpose: List built-in workflow action definitions and their parameter contracts.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `workflow.create`
|
### `workflow.create`
|
||||||
@@ -1483,8 +1538,9 @@ Purpose: Delete one configured workflow by persistent workflow ID.
|
|||||||
### `workflow.event_types`
|
### `workflow.event_types`
|
||||||
`GET /api/v1/workflow/event_types`; policy effect: `safe_read`.
|
`GET /api/v1/workflow/event_types`; policy effect: `safe_read`.
|
||||||
Purpose: List event types that can trigger workflows.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `workflow.fork`
|
### `workflow.fork`
|
||||||
@@ -1504,8 +1560,9 @@ Purpose: Read one complete configured workflow definition.
|
|||||||
### `workflow.list`
|
### `workflow.list`
|
||||||
`GET /api/v1/workflow/agent`; policy effect: `safe_read`.
|
`GET /api/v1/workflow/agent`; policy effect: `safe_read`.
|
||||||
Purpose: List configured workflows and their execution state.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `workflow.pause`
|
### `workflow.pause`
|
||||||
@@ -1518,8 +1575,9 @@ Purpose: Disable automatic execution of one configured workflow.
|
|||||||
### `workflow.plugin.actions`
|
### `workflow.plugin.actions`
|
||||||
`GET /api/v1/workflow/plugin/actions`; policy effect: `safe_read`.
|
`GET /api/v1/workflow/plugin/actions`; policy effect: `safe_read`.
|
||||||
Purpose: List workflow actions contributed by installed plugins, optionally filtered by plugin ID.
|
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
|
- `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
|
- `body`: none
|
||||||
|
|
||||||
### `workflow.reset`
|
### `workflow.reset`
|
||||||
@@ -1553,6 +1611,7 @@ Purpose: Delete one shared-workflow publication by share ID.
|
|||||||
### `workflow.shares`
|
### `workflow.shares`
|
||||||
`GET /api/v1/workflow/shares`; policy effect: `safe_read`.
|
`GET /api/v1/workflow/shares`; policy effect: `safe_read`.
|
||||||
Purpose: List shared workflows with name and pagination filters.
|
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
|
- `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.
|
- `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
|
- `body`: none
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -119,6 +119,53 @@ def test_mcp_tools_list_preserves_all_moviepilot_api_operation_branches() -> Non
|
|||||||
assert operation_ids == set(API_OPERATION_ROUTES)
|
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:
|
def test_filter_read_parameters_are_query_fields_not_get_request_bodies() -> None:
|
||||||
"""规则读取筛选列表必须作为 query 参数公开,避免 Agent 构造无语义 GET body。"""
|
"""规则读取筛选列表必须作为 query 参数公开,避免 Agent 构造无语义 GET body。"""
|
||||||
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
|
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
|
||||||
|
|||||||
+150
-4
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@@ -18,6 +19,8 @@ from starlette.responses import StreamingResponse
|
|||||||
|
|
||||||
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
||||||
from app.api.response import (
|
from app.api.response import (
|
||||||
|
COLLECTION_PAGINATION_OPENAPI_KEY,
|
||||||
|
COLLECTION_TOTAL_OPENAPI_KEY,
|
||||||
RAW_RESPONSE_OPENAPI_KEY,
|
RAW_RESPONSE_OPENAPI_KEY,
|
||||||
ResponseAPIRoute,
|
ResponseAPIRoute,
|
||||||
ResponseAPIRouter,
|
ResponseAPIRouter,
|
||||||
@@ -28,18 +31,17 @@ from app.factory import (
|
|||||||
localized_validation_exception_handler,
|
localized_validation_exception_handler,
|
||||||
persistence_unavailable_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 (
|
from app.schemas.exception import (
|
||||||
AgentChatPersistenceUnavailableError,
|
AgentChatPersistenceUnavailableError,
|
||||||
DatabaseWorkerClosedError,
|
DatabaseWorkerClosedError,
|
||||||
DatabaseWorkerOverloadedError,
|
DatabaseWorkerOverloadedError,
|
||||||
PersistenceUnavailableError,
|
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
|
from app.schemas.response import Response
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.anyio
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +103,18 @@ def api_app() -> FastAPI:
|
|||||||
"""返回需要自动封装的业务数据。"""
|
"""返回需要自动封装的业务数据。"""
|
||||||
return [Item(id=1)]
|
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])
|
@app.get("/wrapped", response_model=Response[Item])
|
||||||
async def get_wrapped_response() -> 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 def test_explicit_none_and_stream_keep_native_protocol(api_app: FastAPI):
|
||||||
"""显式无响应模型和流式响应应保持原生协议。"""
|
"""显式无响应模型和流式响应应保持原生协议。"""
|
||||||
async with make_client(api_app) as client:
|
async with make_client(api_app) as client:
|
||||||
|
|||||||
@@ -109,6 +109,20 @@ def test_history_queries_reuse_explicit_sessions(db, monkeypatch):
|
|||||||
assert await SubscribeHistory.async_list_by_type_and_username(
|
assert await SubscribeHistory.async_list_by_type_and_username(
|
||||||
session, MediaType.TV.value, "alice", page=1, count=10
|
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(
|
assert await SubscribeHistory.async_exists(
|
||||||
session, MediaSource.TMDB, "8501", season=1
|
session, MediaSource.TMDB, "8501", season=1
|
||||||
) is not None
|
) is not None
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import ANY, AsyncMock
|
from unittest.mock import ANY, AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.api.endpoints.history import download_history
|
||||||
from app.application.history import HistoryQueryService
|
from app.application.history import HistoryQueryService
|
||||||
from app.schemas.history import DownloadHistory, TransferHistory
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_list_transfer_uses_explicit_status_without_reinterpreting_title() -> None:
|
async def test_list_transfer_uses_explicit_status_without_reinterpreting_title() -> None:
|
||||||
"""状态筛选必须使用显式参数,中文标题仍保持标题查询语义。"""
|
"""状态筛选必须使用显式参数,中文标题仍保持标题查询语义。"""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.api.endpoints import plugin as plugin_endpoint
|
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",
|
"app.api.endpoints.plugin.get_plugin_catalog_query",
|
||||||
return_value=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 [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 is not None
|
||||||
assert result[0].update_candidate.version == "2.0.0"
|
assert result[0].update_candidate.version == "2.0.0"
|
||||||
assert result[0].update_candidate.is_bound is True
|
assert result[0].update_candidate.is_bound is True
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ def _route_contract(route: APIRoute) -> tuple[Any, ...]:
|
|||||||
(
|
(
|
||||||
dependency.dependency,
|
dependency.dependency,
|
||||||
dependency.use_cache,
|
dependency.use_cache,
|
||||||
tuple(dependency.scopes or ()),
|
tuple(getattr(dependency, "scopes", ()) or ()),
|
||||||
|
getattr(dependency, "scope", None),
|
||||||
)
|
)
|
||||||
for dependency in route.dependencies
|
for dependency in route.dependencies
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
from app.api.endpoints.subscribe import create_subscribe
|
from app.api.endpoints.subscribe import create_subscribe
|
||||||
from app.application.outbox import ClaimedOutboxMessage
|
from app.application.outbox import ClaimedOutboxMessage
|
||||||
@@ -762,18 +763,25 @@ class TestSubscribeEndpoint:
|
|||||||
count=2,
|
count=2,
|
||||||
query=_subscription_query(repository, history_repository),
|
query=_subscription_query(repository, history_repository),
|
||||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||||
|
response=(regular_response := Response()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert [history.id for history in regular_result] == [8]
|
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(
|
history_repository.async_list_by_type_and_username.assert_awaited_once_with(
|
||||||
MediaType.MOVIE.value,
|
MediaType.MOVIE.value,
|
||||||
"alice",
|
"alice",
|
||||||
1,
|
1,
|
||||||
2,
|
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.assert_not_awaited()
|
||||||
|
|
||||||
history_repository.async_list_by_type_and_username.reset_mock()
|
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()
|
history_repository.async_list_by_type.reset_mock()
|
||||||
superuser_result = asyncio.run(
|
superuser_result = asyncio.run(
|
||||||
subscribe_history(
|
subscribe_history(
|
||||||
@@ -782,14 +790,19 @@ class TestSubscribeEndpoint:
|
|||||||
count=3,
|
count=3,
|
||||||
query=_subscription_query(repository, history_repository),
|
query=_subscription_query(repository, history_repository),
|
||||||
current_user=_EndpointUser(name="admin", is_superuser=True),
|
current_user=_EndpointUser(name="admin", is_superuser=True),
|
||||||
|
response=(superuser_response := Response()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert [history.id for history in superuser_result] == [8, 9, 10]
|
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(
|
history_repository.async_list_by_type.assert_awaited_once_with(
|
||||||
MediaType.MOVIE.value,
|
MediaType.MOVIE.value,
|
||||||
1,
|
1,
|
||||||
3,
|
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()
|
history_repository.async_list_by_type_and_username.assert_not_awaited()
|
||||||
|
|
||||||
def test_delete_subscribe_history_rejects_other_user(self):
|
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_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 = 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_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)
|
self.stage_delete = AsyncMock(side_effect=self._stage_delete)
|
||||||
|
|
||||||
async def _async_get(self, history_id: int) -> SubscriptionHistorySnapshot | None:
|
async def _async_get(self, history_id: int) -> SubscriptionHistorySnapshot | None:
|
||||||
@@ -1386,6 +1403,21 @@ class _SubscriptionHistoryRepositoryFake:
|
|||||||
start = (page - 1) * count
|
start = (page - 1) * count
|
||||||
return rows[start : start + 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:
|
async def _stage_delete(self, history_id: int) -> None:
|
||||||
"""暂存删除等价为从内存集合移除历史快照。"""
|
"""暂存删除等价为从内存集合移除历史快照。"""
|
||||||
self.rows.pop(history_id, None)
|
self.rows.pop(history_id, None)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from types import SimpleNamespace
|
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.contract import SubscriptionIdentity
|
||||||
from app.application.subscription.query import SubscriptionQueryService
|
from app.application.subscription.query import SubscriptionQueryService
|
||||||
@@ -8,6 +10,32 @@ from app.domain.context import MediaInfo
|
|||||||
from app.schemas.types import MediaSource, MediaType
|
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:
|
def test_subscription_query_service_builds_complete_exists_identity() -> None:
|
||||||
"""存在性查询必须保留媒体、音乐实体、季和剧集组全部身份维度。"""
|
"""存在性查询必须保留媒体、音乐实体、季和剧集组全部身份维度。"""
|
||||||
repository = Mock()
|
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:
|
def test_transactional_repository_async_query_and_delete(db) -> None:
|
||||||
"""异步分页返回脱离 Session 的快照,删除由独立事务提交。"""
|
"""异步分页返回脱离 Session 的快照,删除由独立事务提交。"""
|
||||||
repository = _repository()
|
repository = _repository()
|
||||||
|
baseline_count = asyncio.run(repository.async_count())
|
||||||
history_id = repository.add(_history_write(download_hash="typed-async-hash"))
|
history_id = repository.add(_history_write(download_hash="typed-async-hash"))
|
||||||
|
|
||||||
async def exercise() -> list[DownloadHistorySnapshot]:
|
async def exercise() -> list[DownloadHistorySnapshot]:
|
||||||
"""在同一事件循环中执行异步分页和删除。"""
|
"""在同一事件循环中执行异步分页和删除。"""
|
||||||
records = await repository.async_list_by_page(count=10)
|
records = await repository.async_list_by_page(count=10)
|
||||||
|
assert await repository.async_count() == baseline_count + 1
|
||||||
await repository.async_delete(history_id)
|
await repository.async_delete(history_id)
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user