fix(api): restore full plugin catalog by default

This commit is contained in:
jxxghp
2026-09-04 10:47:02 +08:00
parent 7fff20cf72
commit adae5bd878
9 changed files with 97 additions and 37 deletions
+15 -3
View File
@@ -327,7 +327,7 @@ FIELD_DESCRIPTIONS = {
"match_field": "Object field used to match one list item during an upsert or removal.",
"match_value": "Exact value compared against match_field during a list-item update.",
"max_chars": "Maximum number of serialized plugin-data characters to return.",
"max_results": "Maximum number of plugin catalog results to return, from 1 to 200.",
"max_results": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.",
"max_rating": "Maximum rating used to filter shared or popular subscriptions.",
"media_category": "MoviePilot library category assigned to the media.",
"media_category_id": "Stable classification category ID; preserve it separately from the current category path snapshot.",
@@ -963,7 +963,19 @@ def _collection_response_contract(
query_parameters.get("page"),
query_parameters.get("count"),
]
has_existing_window = bool({"limit", "offset", "page_size", "max_results"} & query_parameters.keys())
native_window_names = {"limit", "offset", "page_size", "max_results"}
has_native_window_default = False
for name in native_window_names:
parameter = query_parameters.get(name)
if not isinstance(parameter, Mapping):
continue
if parameter.get("required", False):
has_native_window_default = True
break
schema = parameter.get("schema")
if isinstance(schema, Mapping) and schema.get("default") is not None:
has_native_window_default = True
break
defaults_to_unpaginated = (
all(
isinstance(parameter, Mapping)
@@ -972,7 +984,7 @@ def _collection_response_contract(
and "default" not in parameter["schema"]
for parameter in compatibility_parameters
)
and not has_existing_window
and not has_native_window_default
)
return {
"body_shape": "list",
+28 -16
View File
@@ -8345,7 +8345,7 @@
},
{
"additionalProperties": false,
"description": "List installed plugins and their runtime status. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. 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. For a count-only request, use the smallest valid page and read collection.total_count; do not query the database merely because item data is truncated.",
"description": "List installed plugins and their runtime status. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. 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. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.",
"properties": {
"operation_id": {
"const": "plugin.installed",
@@ -8377,12 +8377,18 @@
"type": "boolean"
},
"max_results": {
"default": 50,
"description": "Maximum number of plugin catalog results to return, from 1 to 200.",
"maximum": 200,
"minimum": 1,
"title": "Max Results",
"type": "integer"
"anyOf": [
{
"maximum": 200,
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"description": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.",
"title": "Max Results"
},
"page": {
"anyOf": [
@@ -8429,14 +8435,14 @@
"type": "object",
"x-moviepilot-collection": {
"body_shape": "list",
"default_pagination": "endpoint-defined",
"default_pagination": "unpaginated",
"result_count_field": "collection.result_count",
"total_count_field": "collection.total_count"
}
},
{
"additionalProperties": false,
"description": "List plugins available from configured marketplaces. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. 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. For a count-only request, use the smallest valid page and read collection.total_count; do not query the database merely because item data is truncated.",
"description": "List plugins available from configured marketplaces. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. 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. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.",
"properties": {
"operation_id": {
"const": "plugin.market",
@@ -8468,12 +8474,18 @@
"type": "boolean"
},
"max_results": {
"default": 50,
"description": "Maximum number of plugin catalog results to return, from 1 to 200.",
"maximum": 200,
"minimum": 1,
"title": "Max Results",
"type": "integer"
"anyOf": [
{
"maximum": 200,
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"description": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.",
"title": "Max Results"
},
"page": {
"anyOf": [
@@ -8520,7 +8532,7 @@
"type": "object",
"x-moviepilot-collection": {
"body_shape": "list",
"default_pagination": "endpoint-defined",
"default_pagination": "unpaginated",
"result_count_field": "collection.result_count",
"total_count_field": "collection.total_count"
}
+4 -2
View File
@@ -183,11 +183,11 @@ async def all_plugins(
state: Optional[str] = "all",
force: bool = False,
query: Optional[str] = None,
max_results: Annotated[int, Query(ge=1, le=200)] = 50,
max_results: Annotated[Optional[int], Query(ge=1, le=200)] = None,
page: CompatiblePageParam = None, count: CompatibleCountParam = None,
response: Response = None,
) -> List[_SchemaPlugin]:
"""查询插件清单,显式分页优先于兼容的 ``max_results`` 限量"""
"""查询插件清单;未指定分页或限量时返回完整清单"""
plugins = await get_plugin_catalog_query().query(state=state or "all", force=force)
if query:
plugins = [item["plugin"] for item in search_plugin_candidates(query, plugins)]
@@ -197,6 +197,8 @@ async def all_plugins(
page, count = resolve_compatible_pagination(page, count)
assert page is not None and count is not None
return plugins[(page - 1) * count : page * count]
if max_results is None:
return plugins
return plugins[:max_results]
@@ -339,7 +339,7 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API
- REST 响应继续保持 `Response.data` 为原列表,禁止改成 `{items,total}` 等对象;总数和分页信息使用响应头及 Agent 网关附加元数据表达,避免破坏外部插件和既有客户端
- 对完整列表可报告切片前精确总数;对已经由第三方来源原生分页或限量、且上游没有提供总数的结果,只报告当前返回数量,不强制增加总数,也禁止把当前页数量伪装成全局总数
- 第一版由统一响应路由隐式注入分页参数并在序列化后切片;复核后确认这种实现虽然能生成查询参数,但端点签名不自描述,而且数据库列表仍会全量读取,因此不作为最终方案
- 当前正式方案由每个列表端点显式声明 `page` / `count`;框架 `Response` 仅用于写响应头,不会出现在 OpenAPI、MCP 或 Skill 输入中。已有第三方原生 `page``count``limit``max_results` 的接口保留其既有参数与默认语义
- 当前正式方案由每个列表端点显式声明 `page` / `count`;框架 `Response` 仅用于写响应头,不会出现在 OpenAPI、MCP 或 Skill 输入中。已有第三方原生 `page``count``limit``max_results` 的接口保留其显式参数语义;当原生限量参数默认值为 `None` 时,省略所有窗口参数继续返回完整列表
- 用户、PassKey、活动订阅、Workflow、Site、站点用户数据和站点统计的筛选、稳定排序、`LIMIT/OFFSET` 与精确 `COUNT` 已下推到异步 SQLAlchemy 查询;状态、用户名、名称、触发类型、站点启用状态、站点 ID/域名和日期筛选均在分页前执行,避免空页和错误总数
- 纯内存、配置、缓存、文件系统或运行时目录仍可在响应边界按显式 `page/count` 切片;第三方原生分页且不返回总数的接口只报告当前页数量,不伪造总数
- REST 保持 `data` 原列表;`X-Result-Count` 报告本次返回数量,精确可知时增加 `X-Total-Count`,Agent 网关把这些响应头映射到附加 `collection` 对象。下载历史、订阅历史和插件目录已增加本地精确计数;外部媒体、音乐、推荐和搜索来源未提供总数时不输出 `total_count`
+1 -1
View File
@@ -96,7 +96,7 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确
查询结果的兼容分页合同如下:
- 原先返回完整列表、没有分页参数的接口会在端点签名、OpenAPI、Skill 和 MCP `oneOf` 中显式声明可选 `page` / `count``page` 必须不小于 1`count` 范围为 1 到 200。两者都省略时仍返回原来的完整列表,不启用分页;显式传入任一参数时才分页,缺失的 `page` 按 1、缺失的 `count` 按 50 处理。FastAPI 的 `response` 注入对象不是业务输入,不会出现在 REST、Skill 或 MCP 参数中。
- 数据库列表在查询层先应用授权范围和业务筛选,再执行稳定排序、`LIMIT/OFFSET` 和同条件精确 `COUNT`;不得先全表加载、响应后切片。纯内存、配置、缓存、文件系统或运行时列表可以在序列化边界切片。已有 `max_results` 等原生限量参数的接口继续保留其旧默认值,显式 `page/count` 的优先级由端点合同说明。
- 数据库列表在查询层先应用授权范围和业务筛选,再执行稳定排序、`LIMIT/OFFSET` 和同条件精确 `COUNT`;不得先全表加载、响应后切片。纯内存、配置、缓存、文件系统或运行时列表可以在序列化边界切片。已有 `max_results` 等原生限量参数的接口继续支持显式限量;原生限量参数默认值为 `None` 时,省略所有分页和限量参数仍返回完整列表,显式 `page/count` 的优先级由端点合同说明。
- 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`
- Agent 仅查询数量或摘要时,应对支持精确总数的列表发送最小窗口;兼容分页接口使用 `page=1,count=1`,然后直接读取 `collection.total_count`。即使列表内容触发 64KB 工具预览截断,网关也会把 `collection` 放在 `data` 前面,确保总数仍可见;不得因为条目被截断就回退到数据库统计。
+5 -5
View File
@@ -1,6 +1,6 @@
---
name: moviepilot-api
version: 24
version: 25
description: >-
Use this skill for MoviePilot product operations such as media search, torrent
search, downloads, subscriptions, library checks, sites, storage, workflows,
@@ -713,17 +713,17 @@ Purpose: Install or update one plugin from an approved source.
### `plugin.installed`
`GET /api/v1/plugin/`; policy effect: `safe_read`.
Purpose: List installed plugins and their runtime status.
- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total. For a count-only request, use the smallest valid page and read that metadata instead of querying the database after item truncation.
- `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. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
- `path_params`: 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.; `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.; `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.; `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`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `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.; `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
### `plugin.market`
`GET /api/v1/plugin/`; policy effect: `safe_read`.
Purpose: List plugins available from configured marketplaces.
- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total. For a count-only request, use the smallest valid page and read that metadata instead of querying the database after item truncation.
- `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. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
- `path_params`: 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.; `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.; `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.; `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`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `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.; `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
### `plugin.market.sync_wiki`
+11 -7
View File
@@ -172,17 +172,19 @@ def test_mcp_collection_contract_distinguishes_exact_and_unavailable_totals() ->
"collection.total_count"
)
for operation_id in (
"subscription.history",
"download.history.list",
"plugin.installed",
"plugin.market",
):
for operation_id in ("subscription.history", "download.history.list"):
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"]
for operation_id in ("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"] == "unpaginated"
assert "omit both page and count" in branches[operation_id]["description"]
assert "default" not in branches[operation_id]["properties"]["query"]["properties"]["max_results"]
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
@@ -221,7 +223,9 @@ def test_plugin_operations_expose_discovery_before_precise_writes() -> None:
installed_query = branches["plugin.installed"]["properties"]["query"]
assert installed_query["properties"]["state"]["const"] == "installed"
assert "query" in installed_query["properties"]
assert installed_query["properties"]["max_results"]["maximum"] == 200
max_results = installed_query["properties"]["max_results"]
integer_variant = next(item for item in max_results["anyOf"] if item.get("type") == "integer")
assert integer_variant["maximum"] == 200
config_get_path = API_OPERATION_ROUTES["plugin.config.get"].path
assert config_get_path == "/api/v1/plugin/form/{plugin_id}"
+1 -1
View File
@@ -110,7 +110,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"command-dispatch": "2",
"database-operation": "6",
"feedback-issue": "9",
"moviepilot-api": "24",
"moviepilot-api": "25",
"moviepilot-update": "5",
"organize-files": "5",
"transfer-failed-retry": "5",
+31 -1
View File
@@ -261,7 +261,7 @@ def test_market_endpoint_reads_source_preserving_candidates_for_bound_update():
def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None:
"""插件列表显式 page/count 应分页,省略时仍保留旧 max_results 行为"""
"""插件列表显式 page/count 应分页,并优先于显式 max_results 限量"""
catalog = MagicMock()
catalog.query = AsyncMock(
return_value=[
@@ -291,6 +291,36 @@ def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None:
assert response.headers["X-Total-Count"] == "3"
def test_all_plugins_without_pagination_or_limit_returns_complete_catalog() -> None:
"""插件列表省略分页和限量参数时应返回完整目录。"""
catalog = MagicMock()
catalog.query = AsyncMock(
return_value=[
schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0")
for index in range(1, 52)
]
)
with patch(
"app.api.endpoints.plugin.get_plugin_catalog_query",
return_value=catalog,
):
response = Response()
result = asyncio.run(
plugin_endpoint.all_plugins(
None,
"all",
False,
response=response,
)
)
assert len(result) == 51
assert result[0].id == "Plugin1"
assert result[-1].id == "Plugin51"
assert response.headers["X-Total-Count"] == "51"
def _persistence(identity: PluginIdentity) -> MagicMock:
"""构造只暴露身份读取合同的异步持久化替身。"""
persistence = MagicMock()