mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix(api): restore full plugin catalog by default
This commit is contained in:
+15
-3
@@ -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",
|
||||
|
||||
@@ -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,13 +8377,19 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_results": {
|
||||
"default": 50,
|
||||
"description": "Maximum number of plugin catalog results to return, from 1 to 200.",
|
||||
"anyOf": [
|
||||
{
|
||||
"maximum": 200,
|
||||
"minimum": 1,
|
||||
"title": "Max Results",
|
||||
"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,13 +8474,19 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_results": {
|
||||
"default": 50,
|
||||
"description": "Maximum number of plugin catalog results to return, from 1 to 200.",
|
||||
"anyOf": [
|
||||
{
|
||||
"maximum": 200,
|
||||
"minimum": 1,
|
||||
"title": "Max Results",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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` 前面,确保总数仍可见;不得因为条目被截断就回退到数据库统计。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user