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