diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index 2802aeb9d..01e2827fe 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -112,7 +112,7 @@ API_FIRST_BATCH_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = ( _spec("media.detail"), _write("subscription.add"), _write("subscription.update"), - _spec("subscription.search"), + _user_write("subscription.search", effect=ActionEffect.EXTERNAL_SIDE_EFFECT), _spec("subscription.list"), _spec("subscription.shares"), _spec("subscription.popular"), @@ -367,7 +367,7 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = ( ), _user_write("subscription.status.update"), _user_write("subscription.reset"), - _spec("subscription.search_all", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT), + _user_write("subscription.search_all", effect=ActionEffect.EXTERNAL_SIDE_EFFECT), _spec( "subscription.refresh", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, @@ -536,8 +536,11 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = ( _admin_read("plugin.folders.get", sensitivity=ResultSensitivity.PRIVATE), _write("plugin.folders.update", recovery=RecoveryMode.TRANSACTION), _write("plugin.folder.create", recovery=RecoveryMode.TRANSACTION), + _write("plugin.folder.update", recovery=RecoveryMode.TRANSACTION), _write("plugin.folder.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE), _write("plugin.folder.plugins.update", recovery=RecoveryMode.TRANSACTION), + _write("plugin.folder.plugin.assign", recovery=RecoveryMode.TRANSACTION), + _write("plugin.folder.plugin.remove", recovery=RecoveryMode.TRANSACTION), ) @@ -572,7 +575,7 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "music.cache.clear": ApiOperationRoute("DELETE", "/api/v1/music/cache"), "subscription.add": ApiOperationRoute("POST", "/api/v1/subscribe/"), "subscription.update": ApiOperationRoute("PUT", "/api/v1/subscribe/"), - "subscription.search": ApiOperationRoute("GET", "/api/v1/subscribe/search/{subscribe_id}"), + "subscription.search": ApiOperationRoute("POST", "/api/v1/subscribe/search/{subscribe_id}"), "subscription.list": ApiOperationRoute("GET", "/api/v1/subscribe/"), "subscription.shares": ApiOperationRoute("GET", "/api/v1/subscribe/shares"), "subscription.popular": ApiOperationRoute("GET", "/api/v1/subscribe/popular"), @@ -609,7 +612,7 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "plugin.source.options": ApiOperationRoute("GET", "/api/v1/plugin/source/{plugin_id}"), "plugin.source.install": ApiOperationRoute("POST", "/api/v1/plugin/source/{plugin_id}/install"), "plugin.source.change": ApiOperationRoute("POST", "/api/v1/plugin/source/{plugin_id}"), - "plugin.reload": ApiOperationRoute("GET", "/api/v1/plugin/reload/{plugin_id}"), + "plugin.reload": ApiOperationRoute("POST", "/api/v1/plugin/reload/{plugin_id}"), "plugin.install": ApiOperationRoute("GET", "/api/v1/plugin/install/{plugin_id}"), "plugin.uninstall": ApiOperationRoute("DELETE", "/api/v1/plugin/{plugin_id}"), "slash.list": ApiOperationRoute("GET", "/api/v1/message/agent/commands"), @@ -662,8 +665,8 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "site.delete": ApiOperationRoute("DELETE", "/api/v1/site/{site_id}"), "site.auth.options": ApiOperationRoute("GET", "/api/v1/site/auth"), "site.authenticate": ApiOperationRoute("POST", "/api/v1/site/auth"), - "site.cookiecloud.sync": ApiOperationRoute("GET", "/api/v1/site/cookiecloud"), - "site.reset": ApiOperationRoute("GET", "/api/v1/site/reset"), + "site.cookiecloud.sync": ApiOperationRoute("POST", "/api/v1/site/cookiecloud"), + "site.reset": ApiOperationRoute("POST", "/api/v1/site/reset"), "site.priorities.update": ApiOperationRoute("POST", "/api/v1/site/priorities"), "site.userdata.refresh": ApiOperationRoute("POST", "/api/v1/site/userdata/{site_id}"), "site.userdata.latest": ApiOperationRoute("GET", "/api/v1/site/userdata/latest"), @@ -679,10 +682,10 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "subscription.find": ApiOperationRoute("GET", "/api/v1/subscribe/media/{media_id}"), "subscription.delete_by_media": ApiOperationRoute("DELETE", "/api/v1/subscribe/media/{media_id}"), "subscription.status.update": ApiOperationRoute("PUT", "/api/v1/subscribe/status/{subid}"), - "subscription.reset": ApiOperationRoute("GET", "/api/v1/subscribe/reset/{subid}"), - "subscription.search_all": ApiOperationRoute("GET", "/api/v1/subscribe/search"), - "subscription.refresh": ApiOperationRoute("GET", "/api/v1/subscribe/refresh"), - "subscription.metadata.refresh": ApiOperationRoute("GET", "/api/v1/subscribe/check"), + "subscription.reset": ApiOperationRoute("POST", "/api/v1/subscribe/reset/{subid}"), + "subscription.search_all": ApiOperationRoute("POST", "/api/v1/subscribe/search"), + "subscription.refresh": ApiOperationRoute("POST", "/api/v1/subscribe/refresh"), + "subscription.metadata.refresh": ApiOperationRoute("POST", "/api/v1/subscribe/check"), "subscription.history.delete": ApiOperationRoute("DELETE", "/api/v1/subscribe/history/{history_id}"), "subscription.user.list": ApiOperationRoute("GET", "/api/v1/subscribe/user/{username}"), "subscription.files": ApiOperationRoute("GET", "/api/v1/subscribe/files/{subscribe_id}"), @@ -708,7 +711,7 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "transfer.manual_review.resolve": ApiOperationRoute("POST", "/api/v1/transfer/tasks/{task_id}/manual-review"), "transfer.history.redo": ApiOperationRoute("POST", "/api/v1/history/transfer/{history_id}/ai-redo"), "transfer.history.redo_batch": ApiOperationRoute("POST", "/api/v1/history/transfer/ai-redo"), - "transfer.history.clear": ApiOperationRoute("GET", "/api/v1/history/empty/transfer"), + "transfer.history.clear": ApiOperationRoute("DELETE", "/api/v1/history/transfer/all"), "workflow.create": ApiOperationRoute("POST", "/api/v1/workflow/"), "workflow.get": ApiOperationRoute("GET", "/api/v1/workflow/{workflow_id}"), "workflow.update": ApiOperationRoute("PUT", "/api/v1/workflow/{workflow_id}"), @@ -753,8 +756,17 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "plugin.folders.get": ApiOperationRoute("GET", "/api/v1/plugin/folders"), "plugin.folders.update": ApiOperationRoute("POST", "/api/v1/plugin/folders"), "plugin.folder.create": ApiOperationRoute("POST", "/api/v1/plugin/folders/{folder_name}"), + "plugin.folder.update": ApiOperationRoute("PATCH", "/api/v1/plugin/folders/{folder_name}"), "plugin.folder.delete": ApiOperationRoute("DELETE", "/api/v1/plugin/folders/{folder_name}"), "plugin.folder.plugins.update": ApiOperationRoute("PUT", "/api/v1/plugin/folders/{folder_name}/plugins"), + "plugin.folder.plugin.assign": ApiOperationRoute( + "PUT", + "/api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}", + ), + "plugin.folder.plugin.remove": ApiOperationRoute( + "DELETE", + "/api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}", + ), } diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 765c481ed..62283ca1d 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -176,7 +176,7 @@ OPERATION_DESCRIPTIONS = { "torrent.cache.refresh": "Refresh torrent caches from configured RSS or spider sources.", "torrent.cache.reidentify": "Replace or recompute the media identity for one cached torrent context.", "transfer.episode_format.recommend": "Recommend an episode-number extraction template from supplied file samples.", - "transfer.history.clear": "Delete every transfer-history record while leaving transferred files untouched.", + "transfer.history.clear": "Delete legacy transfer-history records while leaving files and durable failed-task records untouched.", "transfer.history.redo": "Start AI-assisted reorganization for one transfer-history record.", "transfer.history.redo_batch": "Start AI-assisted reorganization for an explicit list of transfer-history records.", "transfer.manual_history": "Check whether supplied storage items already have successful transfer history.", @@ -207,8 +207,11 @@ OPERATION_DESCRIPTIONS = { "plugin.folders.get": "Read the complete administrator plugin-folder grouping configuration.", "plugin.folders.update": "Replace the complete administrator plugin-folder grouping configuration.", "plugin.folder.create": "Create one named plugin folder.", + "plugin.folder.update": "Incrementally rename one plugin folder or update its presentation settings.", "plugin.folder.delete": "Delete one named plugin folder without uninstalling its plugins.", "plugin.folder.plugins.update": "Replace the ordered plugin IDs assigned to one named plugin folder.", + "plugin.folder.plugin.assign": "Move one installed plugin into one named folder and remove its other folder assignments.", + "plugin.folder.plugin.remove": "Remove one installed plugin from one named folder without uninstalling it.", } @@ -587,6 +590,8 @@ MODEL_DESCRIPTIONS.update( "PluginRatingRequest": "Current user's numeric plugin-rating submission.", "PluginFoldersData": "Complete mapping from plugin folder names to ordered plugin IDs or display configuration.", "PluginFolderConfigData": "One plugin folder's ordered members and optional presentation settings.", + "PluginFolderUpdateRequest": "Incremental plugin-folder rename or presentation-settings update request.", + "PluginFolderPluginsUpdateRequest": "Conditional replacement of one plugin folder's ordered members.", "Body_recommend_search_results_api_v1_search_recommend_post": "Torrent search results and recommendation controls supplied to the configured model.", "SiteAuth": "Supported site-account authentication provider and its exact parameter values.", "SitePriorityUpdate": "One configured site ID and its replacement search priority.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 9ffef7614..bd48e9f8e 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -658,6 +658,21 @@ "CustomIdentifiersUpdateRequest": { "description": "Complete custom recognition-identifier replacement request.", "properties": { + "expected_identifiers": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Previously read complete ordered list. When supplied, reject the replacement if the stored list has changed.", + "title": "Expected Identifiers" + }, "identifiers": { "description": "Complete ordered list of custom recognition identifier rules.", "items": { @@ -1891,6 +1906,121 @@ "title": "PluginFolderConfigData", "type": "object" }, + "PluginFolderPluginsUpdateRequest": { + "description": "Conditional replacement of one plugin folder's ordered members.", + "properties": { + "expected_plugins": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Last observed ordered plugin IDs used to reject stale replacements.", + "title": "Expected Plugins" + }, + "plugins": { + "description": "Ordered installed plugin IDs assigned to this folder.", + "items": { + "type": "string" + }, + "title": "Plugins", + "type": "array" + } + }, + "required": [ + "plugins" + ], + "title": "PluginFolderPluginsUpdateRequest", + "type": "object" + }, + "PluginFolderUpdateRequest": { + "additionalProperties": false, + "description": "Incremental plugin-folder rename or presentation-settings update request.", + "properties": { + "background": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional folder background color or style.", + "title": "Background" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional folder foreground color.", + "title": "Color" + }, + "gradient": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional folder gradient definition.", + "title": "Gradient" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional folder icon name.", + "title": "Icon" + }, + "new_name": { + "anyOf": [ + { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional replacement folder name.", + "title": "New Name" + }, + "showIcon": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether the frontend should display the folder icon.", + "title": "Showicon" + } + }, + "title": "PluginFolderUpdateRequest", + "type": "object" + }, "PluginFoldersData": { "additionalProperties": { "anyOf": [ @@ -8143,17 +8273,100 @@ "title": "plugin.folder.delete", "type": "object" }, + { + "additionalProperties": false, + "description": "Move one installed plugin into one named folder and remove its other folder assignments. Method: PUT. Path: /api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}. Effect: reversible_write.", + "properties": { + "operation_id": { + "const": "plugin.folder.plugin.assign", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.folder.plugin.assign. Move one installed plugin into one named folder and remove its other folder assignments. Use only the named fields below.", + "properties": { + "folder_name": { + "description": "Exact plugin folder name returned by plugin.folders.get.", + "title": "Folder Name", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "folder_name", + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.folder.plugin.assign", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Remove one installed plugin from one named folder without uninstalling it. Method: DELETE. Path: /api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}. Effect: reversible_write.", + "properties": { + "operation_id": { + "const": "plugin.folder.plugin.remove", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.folder.plugin.remove. Remove one installed plugin from one named folder without uninstalling it. Use only the named fields below.", + "properties": { + "folder_name": { + "description": "Exact plugin folder name returned by plugin.folders.get.", + "title": "Folder Name", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "folder_name", + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.folder.plugin.remove", + "type": "object" + }, { "additionalProperties": false, "description": "Replace the ordered plugin IDs assigned to one named plugin folder. Method: PUT. Path: /api/v1/plugin/folders/{folder_name}/plugins. Effect: reversible_write.", "properties": { "body": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "$ref": "#/$defs/PluginFolderPluginsUpdateRequest" + } + ], "description": "Request value for plugin.folder.plugins.update. Replace the ordered plugin IDs assigned to one named plugin folder. Use the exact type and fields below.", - "items": { - "type": "string" - }, - "title": "Plugin Ids", - "type": "array" + "title": "Plugin Update" }, "operation_id": { "const": "plugin.folder.plugins.update", @@ -8184,6 +8397,43 @@ "title": "plugin.folder.plugins.update", "type": "object" }, + { + "additionalProperties": false, + "description": "Incrementally rename one plugin folder or update its presentation settings. Method: PATCH. Path: /api/v1/plugin/folders/{folder_name}. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/PluginFolderUpdateRequest", + "description": "Request value for plugin.folder.update. Incrementally rename one plugin folder or update its presentation settings. Use the exact type and fields below." + }, + "operation_id": { + "const": "plugin.folder.update", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.folder.update. Incrementally rename one plugin folder or update its presentation settings. Use only the named fields below.", + "properties": { + "folder_name": { + "description": "Exact plugin folder name returned by plugin.folders.get.", + "title": "Folder Name", + "type": "string" + } + }, + "required": [ + "folder_name" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "plugin.folder.update", + "type": "object" + }, { "additionalProperties": false, "description": "Read the complete administrator plugin-folder grouping configuration. Method: GET. Path: /api/v1/plugin/folders. Effect: safe_read.", @@ -8729,7 +8979,7 @@ }, { "additionalProperties": false, - "description": "Reload one installed plugin into the running process. Method: GET. Path: /api/v1/plugin/reload/{plugin_id}. Effect: external_side_effect.", + "description": "Reload one installed plugin into the running process. Method: POST. Path: /api/v1/plugin/reload/{plugin_id}. Effect: external_side_effect.", "properties": { "operation_id": { "const": "plugin.reload", @@ -9612,7 +9862,7 @@ }, { "additionalProperties": false, - "description": "Start a CookieCloud synchronization of configured sites. Method: GET. Path: /api/v1/site/cookiecloud. Effect: external_side_effect.", + "description": "Start a CookieCloud synchronization of configured sites. Method: POST. Path: /api/v1/site/cookiecloud. Effect: external_side_effect.", "properties": { "operation_id": { "const": "site.cookiecloud.sync", @@ -9780,7 +10030,7 @@ }, { "additionalProperties": false, - "description": "Delete all configured sites and start a fresh CookieCloud synchronization. Method: GET. Path: /api/v1/site/reset. Effect: destructive_write.", + "description": "Delete all configured sites and start a fresh CookieCloud synchronization. Method: POST. Path: /api/v1/site/reset. Effect: destructive_write.", "properties": { "operation_id": { "const": "site.reset", @@ -11296,7 +11546,7 @@ }, { "additionalProperties": false, - "description": "Start a system-wide refresh of subscription TMDB metadata. Method: GET. Path: /api/v1/subscribe/check. Effect: external_side_effect.", + "description": "Start a system-wide refresh of subscription TMDB metadata. Method: POST. Path: /api/v1/subscribe/check. Effect: external_side_effect.", "properties": { "operation_id": { "const": "subscription.metadata.refresh", @@ -11436,7 +11686,7 @@ }, { "additionalProperties": false, - "description": "Start the configured system-wide subscription refresh job. Method: GET. Path: /api/v1/subscribe/refresh. Effect: external_side_effect.", + "description": "Start the configured system-wide subscription refresh job. Method: POST. Path: /api/v1/subscribe/refresh. Effect: external_side_effect.", "properties": { "operation_id": { "const": "subscription.refresh", @@ -11452,7 +11702,7 @@ }, { "additionalProperties": false, - "description": "Reset one accessible subscription so it can be processed again. Method: GET. Path: /api/v1/subscribe/reset/{subid}. Effect: reversible_write.", + "description": "Reset one accessible subscription so it can be processed again. Method: POST. Path: /api/v1/subscribe/reset/{subid}. Effect: reversible_write.", "properties": { "operation_id": { "const": "subscription.reset", @@ -11484,7 +11734,7 @@ }, { "additionalProperties": false, - "description": "Run an immediate search for one existing subscription. Method: GET. Path: /api/v1/subscribe/search/{subscribe_id}. Effect: safe_read.", + "description": "Run an immediate search for one existing subscription. Method: POST. Path: /api/v1/subscribe/search/{subscribe_id}. Effect: external_side_effect.", "properties": { "operation_id": { "const": "subscription.search", @@ -11516,7 +11766,7 @@ }, { "additionalProperties": false, - "description": "Start immediate searches for all subscriptions accessible to the current user. Method: GET. Path: /api/v1/subscribe/search. Effect: external_side_effect.", + "description": "Start immediate searches for all subscriptions accessible to the current user. Method: POST. Path: /api/v1/subscribe/search. Effect: external_side_effect.", "properties": { "operation_id": { "const": "subscription.search_all", @@ -12764,7 +13014,7 @@ }, { "additionalProperties": false, - "description": "Delete every transfer-history record while leaving transferred files untouched. Method: GET. Path: /api/v1/history/empty/transfer. Effect: destructive_write.", + "description": "Delete legacy transfer-history records while leaving files and durable failed-task records untouched. Method: DELETE. Path: /api/v1/history/transfer/all. Effect: destructive_write.", "properties": { "operation_id": { "const": "transfer.history.clear", @@ -13919,7 +14169,10 @@ "plugin.data", "plugin.folder.create", "plugin.folder.delete", + "plugin.folder.plugin.assign", + "plugin.folder.plugin.remove", "plugin.folder.plugins.update", + "plugin.folder.update", "plugin.folders.get", "plugin.folders.update", "plugin.history", diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index 139df7fee..9a4e82830 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -334,9 +334,7 @@ def _submit_legacy_batch_ai_redo( progress_key=progress_key, task_registry=task_registry, ) - message = ";".join( - [*messages, f"已提交 {len(histories)} 条旧历史给智能助手处理"] - ) + message = ";".join([*messages, f"已提交 {len(histories)} 条旧历史给智能助手处理"]) return _SchemaResponse( success=True, message=message, @@ -365,9 +363,7 @@ async def download_history( """ results = await query.list_download(page=page, count=count) if response is not None: - response.headers[COLLECTION_TOTAL_HEADER] = str( - await query.count_download() - ) + response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_download()) return results @@ -378,9 +374,7 @@ async def download_history( ) def delete_download_history( history_in: _SchemaDownloadHistory, - command: DownloadHistoryMutationCommand = Depends( - get_download_history_mutation_command - ), + command: DownloadHistoryMutationCommand = Depends(get_download_history_mutation_command), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ @@ -415,6 +409,27 @@ async def transfer_history( return _SchemaResponse(success=True, data=result) +def _clear_transfer_history( + command: TransferHistoryMutationCommand, +) -> _SchemaResponse[None]: + """执行旧整理历史清理,并统一构造兼容响应。""" + result = command.truncate() + return _SchemaResponse(success=result.success, message=result.message) + + +@router.delete( + "/transfer/all", + summary="清空旧整理记录", + response_model=_SchemaResponse[None], +) +def clear_transfer_history( + command: TransferHistoryMutationCommand = Depends(get_transfer_history_mutation_command), + _: object = Depends(get_current_active_superuser), +) -> Any: + """清空没有持久任务回执的旧整理记录,不删除任何文件。""" + return _clear_transfer_history(command) + + @router.delete( "/transfer", summary="删除整理记录", @@ -424,9 +439,7 @@ def delete_transfer_history( history_in: _SchemaTransferHistory, deletesrc: Optional[bool] = False, deletedest: Optional[bool] = False, - command: TransferHistoryMutationCommand = Depends( - get_transfer_history_mutation_command - ), + command: TransferHistoryMutationCommand = Depends(get_transfer_history_mutation_command), _: object = Depends(get_current_active_manage_user), ) -> Any: """ @@ -455,9 +468,7 @@ async def ai_redo_transfer_history( runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config), _: object = Depends(get_current_active_manage_user), task_registry: TaskRegistry = Depends(get_background_task_registry), - execution_repository: TransferExecutionRepository = Depends( - get_transfer_execution_repository - ), + execution_repository: TransferExecutionRepository = Depends(get_transfer_execution_repository), ) -> Any: """ 手动触发单条历史记录的 AI 重新整理,并返回进度键。 @@ -519,9 +530,7 @@ async def batch_ai_redo_transfer_history( runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config), _: object = Depends(get_current_active_manage_user), task_registry: TaskRegistry = Depends(get_background_task_registry), - execution_repository: TransferExecutionRepository = Depends( - get_transfer_execution_repository - ), + execution_repository: TransferExecutionRepository = Depends(get_transfer_execution_repository), ) -> Any: """ 手动触发多条历史记录的 AI 批量重新整理,并返回进度键。 @@ -536,8 +545,7 @@ async def batch_ai_redo_transfer_history( if missing_ids: return _SchemaResponse( success=False, - message="整理记录不存在: " - + ", ".join(str(history_id) for history_id in missing_ids), + message="整理记录不存在: " + ", ".join(str(history_id) for history_id in missing_ids), ) durable_histories, legacy_histories = _partition_durable_histories(histories) @@ -558,18 +566,14 @@ async def batch_ai_redo_transfer_history( ) if rejections: - response_message_parts.append( - f"{len(legacy_histories)} 条旧历史未提交:批量请求包含被拒绝的持久任务" - ) + response_message_parts.append(f"{len(legacy_histories)} 条旧历史未提交:批量请求包含被拒绝的持久任务") return _SchemaResponse( success=False, message=";".join(response_message_parts), ) if not runtime_config.ai_agent_enable: - response_message_parts.append( - f"{len(legacy_histories)} 条旧历史未处理:MoviePilot智能助手未启用" - ) + response_message_parts.append(f"{len(legacy_histories)} 条旧历史未处理:MoviePilot智能助手未启用") return _SchemaResponse( success=False, message=";".join(response_message_parts), @@ -587,15 +591,12 @@ async def batch_ai_redo_transfer_history( "/empty/transfer", summary="清空整理记录", response_model=_SchemaResponse[None], + include_in_schema=False, + deprecated=True, ) def empty_transfer_history( - command: TransferHistoryMutationCommand = Depends( - get_transfer_history_mutation_command - ), + command: TransferHistoryMutationCommand = Depends(get_transfer_history_mutation_command), _: object = Depends(get_current_active_superuser), ) -> Any: - """ - 清空整理记录 - """ - result = command.truncate() - return _SchemaResponse(success=result.success, message=result.message) + """兼容旧客户端的清空入口;新调用应使用 DELETE /transfer/all。""" + return _clear_transfer_history(command) diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index a266090c5..df1a26257 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -1,6 +1,6 @@ import asyncio import mimetypes -from typing import Annotated, Any, Dict, List, Optional +from typing import Annotated, Any, Dict, List, Optional, Union import aiofiles from anyio import Path as AsyncPath @@ -36,14 +36,18 @@ 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 from app.application.plugin.config import PluginConfigCommand -from app.application.plugin.data import PluginDataQueryService +from app.application.plugin.data import PluginDataQueryService, PluginDataSummaryService from app.application.plugin.folders import ( add_clone_to_plugin_folder, get_plugin_folder_service, remove_plugin_from_folders, ) from app.application.plugin.gateway import get_plugin_install_service -from app.application.plugin.management import get_plugin_snapshot, search_plugin_candidates +from app.application.plugin.management import ( + get_plugin_snapshot, + reload_plugin_runtime, + search_plugin_candidates, +) from app.application.plugin.rating import PluginNotInstalledError, get_plugin_rating_service from app.application.plugin.release import get_plugin_release_service from app.application.plugin.routes import register_plugin_api, remove_plugin_api @@ -59,13 +63,21 @@ from app.schemas.plugin import Plugin as _SchemaPlugin from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem +from app.schemas.plugin import PluginDataSummary as _SchemaPluginDataSummary +from app.schemas.plugin import PluginFolderPluginsUpdateRequest as _SchemaPluginFolderPluginsUpdateRequest from app.schemas.plugin import PluginFoldersData as _SchemaPluginFoldersData +from app.schemas.plugin import PluginFolderUpdateRequest as _SchemaPluginFolderUpdateRequest from app.schemas.plugin import PluginInstallOutcome as _SchemaPluginInstallOutcome from app.schemas.plugin import PluginRating as _SchemaPluginRating from app.schemas.plugin import PluginRatingMap as _SchemaPluginRatingMap from app.schemas.plugin import PluginRatingRequest as _SchemaPluginRatingRequest from app.schemas.plugin import PluginReleaseData as _SchemaPluginReleaseData from app.schemas.plugin import PluginRemoteInfo as _SchemaPluginRemoteInfo +from app.schemas.plugin import PluginRuntimeActionCapability as _SchemaPluginRuntimeActionCapability +from app.schemas.plugin import PluginRuntimeActionGroup as _SchemaPluginRuntimeActionGroup +from app.schemas.plugin import PluginRuntimeCapabilities as _SchemaPluginRuntimeCapabilities +from app.schemas.plugin import PluginRuntimeCommandCapability as _SchemaPluginRuntimeCommandCapability +from app.schemas.plugin import PluginRuntimeServiceCapability as _SchemaPluginRuntimeServiceCapability from app.schemas.plugin import PluginRuntimeStatus as _SchemaPluginRuntimeStatus from app.schemas.plugin import PluginRuntimeSummary as _SchemaPluginRuntimeSummary from app.schemas.plugin import PluginSidebarNavItem as _SchemaPluginSidebarNavItem @@ -177,14 +189,17 @@ def _verify_plugin_static_file_access( verify_resource_token(resource_token) -@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}) +@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[Optional[int], Query(ge=1, le=200)] = None, - page: CompatiblePageParam = None, count: CompatibleCountParam = None, + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, response: Response = None, ) -> List[_SchemaPlugin]: """查询插件清单;未指定分页或限量时返回完整清单。""" @@ -201,7 +216,11 @@ async def all_plugins( @router.get("/installed", summary="已安装插件", response_model=List[str]) -async def installed(_: ApiPrincipal = Depends(get_current_active_superuser_async), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: +async def installed( + _: ApiPrincipal = Depends(get_current_active_superuser_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, +) -> Any: """ 查询用户已安装插件清单 """ @@ -370,18 +389,11 @@ async def rate_plugin( return _SchemaResponse(success=True, data=rating) -@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=_SchemaResponse[None]) +@router.post("/reload/{plugin_id}", summary="重新加载插件", response_model=_SchemaResponse[None]) def reload_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any: - """ - 重新加载插件 - """ - plugin_manager = get_plugin_manager() + """重新加载插件并刷新其命令、定时任务和动态 API 注册。""" try: - with plugin_manager.mutation(f"重载插件 {plugin_id}"): - # 重新加载插件 - runtime_status = plugin_manager.reload_plugin(plugin_id) - # 注册插件服务 - register_plugin(plugin_id) + runtime_status = reload_plugin_runtime(plugin_id) except PluginMutationRejectedError as error: return _SchemaResponse(success=False, message=str(error)) if runtime_status is _SchemaPluginRuntimeStatus.ACTIVE: @@ -553,7 +565,9 @@ async def remotes(token: str, page: CompatiblePageParam = None, count: Compatibl @router.get("/sidebar_nav", summary="获取插件侧栏导航项", response_model=List[_SchemaPluginSidebarNavItem]) -def plugin_sidebar_nav(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: +def plugin_sidebar_nav( + _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None +) -> Any: """ 聚合已启用 Vue 插件声明的侧栏入口(get_sidebar_nav),供前端主界面侧栏展示。 """ @@ -801,6 +815,30 @@ async def delete_plugin_folder(folder_name: str, _: ApiPrincipal = Depends(get_c return _SchemaResponse(success=result.success, message=result.message) +@router.patch( + "/folders/{folder_name}", + summary="更新插件文件夹", + response_model=_SchemaResponse[None], +) +async def update_plugin_folder( + folder_name: str, + folder: _SchemaPluginFolderUpdateRequest, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """增量更新插件文件夹名称或展示配置。""" + changes = folder.model_dump( + by_alias=True, + exclude={"new_name"}, + exclude_unset=True, + ) + result = await get_plugin_folder_service().update_folder( + folder_name, + new_name=folder.new_name, + changes=changes, + ) + return _SchemaResponse(success=result.success, message=result.message) + + @router.put( "/folders/{folder_name}/plugins", summary="更新文件夹中的插件", @@ -808,13 +846,54 @@ async def delete_plugin_folder(folder_name: str, _: ApiPrincipal = Depends(get_c ) async def update_folder_plugins( folder_name: str, - plugin_ids: List[str], + plugin_update: Union[List[str], _SchemaPluginFolderPluginsUpdateRequest], _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> Any: - """ - 更新指定文件夹中的插件列表 - """ - result = await get_plugin_folder_service().update_plugins(folder_name, plugin_ids) + """条件替换指定文件夹中的插件列表,并兼容旧数组请求。""" + if isinstance(plugin_update, list): + plugin_ids = plugin_update + expected_plugin_ids = None + else: + plugin_ids = plugin_update.plugins + expected_plugin_ids = plugin_update.expected_plugins + result = await get_plugin_folder_service().update_plugins( + folder_name, + plugin_ids, + expected_plugin_ids, + ) + return _SchemaResponse(success=result.success, message=result.message) + + +@router.put( + "/folders/{folder_name}/plugins/{plugin_id}", + summary="移动插件到文件夹", + response_model=_SchemaResponse[None], +) +async def assign_plugin_to_folder( + folder_name: str, + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """把一个插件原子迁移到目标文件夹。""" + result = await get_plugin_folder_service().assign_plugin(folder_name, plugin_id) + return _SchemaResponse(success=result.success, message=result.message) + + +@router.delete( + "/folders/{folder_name}/plugins/{plugin_id}", + summary="从文件夹移除插件", + response_model=_SchemaResponse[None], +) +async def remove_plugin_from_folder( + folder_name: str, + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """只从指定文件夹移除一个插件。""" + result = await get_plugin_folder_service().remove_plugin_from_folder( + folder_name, + plugin_id, + ) return _SchemaResponse(success=result.success, message=result.message) @@ -854,52 +933,60 @@ def clone_plugin( @router.get( # type: ignore[misc] "/runtime/capabilities", summary="查询插件运行能力", - response_model=_SchemaResponse[_SchemaJsonObject], + response_model=_SchemaResponse[_SchemaPluginRuntimeCapabilities], ) async def plugin_capabilities( plugin_id: Optional[str] = None, _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> _SchemaResponse[Any]: - """查询运行中插件注册的命令、动作和定时服务。""" + """查询运行中插件注册的安全命令、动作和定时服务元数据。""" manager = get_plugin_manager() - data: dict[str, Any] = {} - commands = manager.get_plugin_commands(pid=plugin_id) or [] - if commands: - data["commands"] = [ - { - "cmd": command.get("cmd"), - "desc": command.get("desc"), - "plugin_id": command.get("pid"), - **({"data": command.get("data")} if command.get("data") else {}), - } - for command in commands + commands = [ + _SchemaPluginRuntimeCommandCapability( + cmd=str(command["cmd"]), + desc=str(command["desc"]) if command.get("desc") else None, + plugin_id=str(command["pid"]) if command.get("pid") else None, + ) + for command in (manager.get_plugin_commands(pid=plugin_id) or []) + if isinstance(command, dict) and command.get("cmd") + ] + action_groups = [] + for group in manager.get_plugin_actions(pid=plugin_id) or []: + if not isinstance(group, dict): + continue + actions = [ + _SchemaPluginRuntimeActionCapability( + id=str(item["id"]), + name=str(item["name"]) if item.get("name") else None, + ) + for item in (group.get("actions") or []) + if isinstance(item, dict) and item.get("id") ] - actions = manager.get_plugin_actions(pid=plugin_id) or [] - if actions: - data["actions"] = [ - { - "plugin_id": group.get("plugin_id"), - "plugin_name": group.get("plugin_name"), - "actions": [{"id": item.get("id"), "name": item.get("name")} for item in group.get("actions", [])], - } - for group in actions - ] - services = manager.get_plugin_services(pid=plugin_id) or [] - if services: - data["services"] = [ - { - "id": service.get("id"), - "name": service.get("name"), - **({"trigger": str(service.get("trigger"))} if service.get("trigger") else {}), - **( - {"trigger_kwargs": {key: str(value) for key, value in service.get("kwargs", {}).items()}} - if service.get("kwargs") - else {} - ), - } - for service in services - ] - return _SchemaResponse(success=True, data=data) + if actions: + action_groups.append( + _SchemaPluginRuntimeActionGroup( + plugin_id=str(group["plugin_id"]) if group.get("plugin_id") else None, + plugin_name=str(group["plugin_name"]) if group.get("plugin_name") else None, + actions=actions, + ) + ) + services = [ + _SchemaPluginRuntimeServiceCapability( + id=str(service["id"]), + name=str(service["name"]) if service.get("name") else None, + trigger=str(service["trigger"]) if service.get("trigger") else None, + ) + for service in (manager.get_plugin_services(pid=plugin_id) or []) + if isinstance(service, dict) and service.get("id") + ] + return _SchemaResponse( + success=True, + data=_SchemaPluginRuntimeCapabilities( + commands=commands, + actions=action_groups, + services=services, + ), + ) @router.get( # type: ignore[misc] @@ -925,6 +1012,27 @@ async def plugin_data( return _SchemaResponse(success=True, data=data) +@router.get( + "/runtime/{plugin_id}/data/summary", + summary="查询插件持久化数据摘要", + response_model=_SchemaResponse[_SchemaPluginDataSummary], +) +async def plugin_data_summary( + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), + runtime: HostRuntime = Depends(get_host_runtime), +) -> _SchemaResponse[Any]: + """读取不包含插件持久化原值的键、类型和大小摘要。""" + try: + data = await PluginDataSummaryService( + runtime.agent.plugin_data, + get_plugin_snapshot, + ).summarize(plugin_id) + except ValueError as error: + return _SchemaResponse(success=False, message=str(error)) + return _SchemaResponse(success=True, data=_SchemaPluginDataSummary.model_validate(data)) + + @router.get( "/{plugin_id}", summary="获取插件配置", @@ -949,6 +1057,8 @@ def set_plugin_config( """ result = command.update(plugin_id, conf) return _SchemaResponse(success=result.success, message=result.message) + + @router.delete("/{plugin_id}", summary="卸载插件", response_model=_SchemaResponse[None]) def uninstall_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any: """ diff --git a/app/api/endpoints/rule.py b/app/api/endpoints/rule.py index ed4549c6a..6c0ad62d0 100644 --- a/app/api/endpoints/rule.py +++ b/app/api/endpoints/rule.py @@ -17,12 +17,18 @@ from app.schemas.response import Response as _SchemaResponse from app.schemas.rule import ( CustomFilterRuleCreateRequest as _SchemaCustomFilterRuleCreateRequest, ) +from app.schemas.rule import ( + CustomFilterRuleReorderRequest as _SchemaCustomFilterRuleReorderRequest, +) from app.schemas.rule import ( CustomFilterRuleUpdateRequest as _SchemaCustomFilterRuleUpdateRequest, ) from app.schemas.rule import ( FilterRuleGroupCreateRequest as _SchemaFilterRuleGroupCreateRequest, ) +from app.schemas.rule import ( + FilterRuleGroupReorderRequest as _SchemaFilterRuleGroupReorderRequest, +) from app.schemas.rule import ( FilterRuleGroupUpdateRequest as _SchemaFilterRuleGroupUpdateRequest, ) @@ -139,6 +145,27 @@ async def add_custom_rule( return _SchemaResponse(success=True, message=data.get("message"), data=data) +@router.put( # type: ignore[misc] + "/custom/reorder", + summary="调整自定义过滤规则顺序", + response_model=_SchemaResponse[_SchemaJsonObject], +) +async def reorder_custom_rules( + payload: _SchemaCustomFilterRuleReorderRequest, + _: ApiPrincipal = Depends(get_current_active_superuser_async), + runtime: HostRuntime = Depends(get_host_runtime), +) -> _SchemaResponse[Any]: + """只调整现有自定义规则顺序并拒绝过期集合覆盖。""" + try: + data = await _service(runtime).reorder_custom( + payload.rule_ids, + expected_rule_ids=payload.expected_rule_ids, + ) + except ValueError as error: + return _SchemaResponse(success=False, message=str(error)) + return _SchemaResponse(success=True, message=data.get("message"), data=data) + + @router.put( # type: ignore[misc] "/custom/{rule_id}", summary="更新自定义过滤规则", @@ -197,6 +224,27 @@ async def add_rule_group( return _SchemaResponse(success=True, message=data.get("message"), data=data) +@router.put( # type: ignore[misc] + "/groups/reorder", + summary="调整过滤规则组顺序", + response_model=_SchemaResponse[_SchemaJsonObject], +) +async def reorder_rule_groups( + payload: _SchemaFilterRuleGroupReorderRequest, + _: ApiPrincipal = Depends(get_current_active_superuser_async), + runtime: HostRuntime = Depends(get_host_runtime), +) -> _SchemaResponse[Any]: + """只调整现有规则组顺序并拒绝过期集合覆盖。""" + try: + data = await _service(runtime).reorder_groups( + payload.group_names, + expected_group_names=payload.expected_group_names, + ) + except ValueError as error: + return _SchemaResponse(success=False, message=str(error)) + return _SchemaResponse(success=True, message=data.get("message"), data=data) + + @router.put( # type: ignore[misc] "/groups/{name}", summary="更新过滤规则组", diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 1cc43418c..21dfb19d3 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -293,7 +293,18 @@ async def update_site( return _SchemaResponse(success=result.success, message=result.message) -@router.get("/cookiecloud", summary="CookieCloud同步", response_model=_SchemaResponse[None]) +@router.get( + "/cookiecloud", + summary="CookieCloud同步(兼容入口)", + response_model=_SchemaResponse[None], + include_in_schema=False, + deprecated=True, +) +@router.post( + "/cookiecloud", + summary="CookieCloud同步", + response_model=_SchemaResponse[None], +) async def cookie_cloud_sync( task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)], _: ApiPrincipal = Depends(get_current_active_superuser_async), @@ -307,7 +318,18 @@ async def cookie_cloud_sync( return _SchemaResponse(success=True, message="CookieCloud同步任务已启动!") -@router.get("/reset", summary="重置站点", response_model=_SchemaResponse[None]) +@router.get( + "/reset", + summary="重置站点(兼容入口)", + response_model=_SchemaResponse[None], + include_in_schema=False, + deprecated=True, +) +@router.post( + "/reset", + summary="重置站点", + response_model=_SchemaResponse[None], +) async def reset( task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)], command: SiteMutationCommand = Depends(get_site_mutation_command), diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 8dcd92f91..4d01dd94a 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -21,6 +21,7 @@ from app.api.response import ( ) from app.application.configuration import get_api_runtime_config_snapshot from app.application.directory import DirectoryHelper +from app.application.storage import StorageHelper from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.transfer.facade import TransferChain @@ -28,6 +29,7 @@ from app.foundation import text as text_tools from app.runtime.progress import ProgressHelper from app.schemas.common import ManageRequest as _SchemaManageRequest from app.schemas.response import Response as _SchemaResponse +from app.schemas.storage import StorageOption as _SchemaStorageOption from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf from app.schemas.types import ProgressKey from app.schemas.workflow import FileItem as _SchemaFileItem @@ -44,7 +46,7 @@ def directory_settings( directory_type: str = "all", storage_type: str = "all", name: Optional[str] = None, - _: ApiPrincipal = Depends(get_current_active_superuser), + _: ApiPrincipal = Depends(get_current_active_manage_user), page: CompatiblePageParam = None, count: CompatibleCountParam = None, ) -> _SchemaResponse[Any]: @@ -85,6 +87,8 @@ def directory_settings( "media_type": directory.media_type, "media_category": directory.media_category, "media_category_id": directory.media_category_id, + "download_type_folder": directory.download_type_folder, + "download_category_folder": directory.download_category_folder, "monitor_type": directory.monitor_type, "monitor_mode": directory.monitor_mode, "transfer_type": directory.transfer_type, @@ -92,11 +96,27 @@ def directory_settings( "renaming": directory.renaming, "scraping": directory.scraping, "notify": directory.notify, + "library_type_folder": directory.library_type_folder, + "library_category_folder": directory.library_category_folder, } ) return _SchemaResponse(success=True, data=results) +@router.get("/options", summary="查询可用存储选项", response_model=List[_SchemaStorageOption]) +def storage_options( + _: ApiPrincipal = Depends(get_current_active_user), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, +) -> List[_SchemaStorageOption]: + """返回不包含连接配置和凭据的存储名称与类型。""" + return [ + _SchemaStorageOption(name=storage.name or storage.type or "", type=storage.type or "") + for storage in StorageHelper.get_storagies() + if storage.type + ] + + @router.post("/manage", summary="网盘存储统一管理", response_model=_SchemaResponse[Dict[str, Any]]) # type: ignore[misc] def manage(request: _SchemaManageRequest, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any: """ diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 3d254509f..2be77409e 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -426,7 +426,14 @@ async def subscribe_media_identity( return result if result else _SchemaSubscribe() -@router.get("/refresh", summary="刷新订阅", response_model=_SchemaResponse[None]) +@router.get( + "/refresh", + summary="刷新订阅(兼容入口)", + response_model=_SchemaResponse[None], + include_in_schema=False, + deprecated=True, +) +@router.post("/refresh", summary="刷新订阅", response_model=_SchemaResponse[None]) def refresh_subscribes( current_user: ApiPrincipal = Depends(get_current_active_user), ) -> Any: @@ -439,7 +446,14 @@ def refresh_subscribes( return _SchemaResponse(success=True) -@router.get("/reset/{subid}", summary="重置订阅", response_model=_SchemaResponse[None]) +@router.get( + "/reset/{subid}", + summary="重置订阅(兼容入口)", + response_model=_SchemaResponse[None], + include_in_schema=False, + deprecated=True, +) +@router.post("/reset/{subid}", summary="重置订阅", response_model=_SchemaResponse[None]) async def reset_subscribes( subid: int, mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service), @@ -458,7 +472,14 @@ async def reset_subscribes( return _SchemaResponse(success=False, message="订阅不存在") -@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=_SchemaResponse[None]) +@router.get( + "/check", + summary="刷新订阅 TMDB 信息(兼容入口)", + response_model=_SchemaResponse[None], + include_in_schema=False, + deprecated=True, +) +@router.post("/check", summary="刷新订阅 TMDB 信息", response_model=_SchemaResponse[None]) def check_subscribes( current_user: ApiPrincipal = Depends(get_current_active_user), ) -> Any: @@ -471,7 +492,14 @@ def check_subscribes( return _SchemaResponse(success=True) -@router.get("/search", summary="搜索所有订阅", response_model=_SchemaResponse[None]) +@router.get( + "/search", + summary="搜索所有订阅(兼容入口)", + response_model=_SchemaResponse[None], + include_in_schema=False, + deprecated=True, +) +@router.post("/search", summary="搜索所有订阅", response_model=_SchemaResponse[None]) async def search_subscribes( command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command), current_user: ApiPrincipal = Depends(get_current_active_user_async), @@ -489,7 +517,16 @@ async def search_subscribes( @router.get( - "/search/{subscribe_id}", summary="搜索订阅", response_model=_SchemaResponse[None] + "/search/{subscribe_id}", + summary="搜索订阅(兼容入口)", + response_model=_SchemaResponse[None], + include_in_schema=False, + deprecated=True, +) +@router.post( + "/search/{subscribe_id}", + summary="搜索订阅", + response_model=_SchemaResponse[None], ) async def search_subscribe( subscribe_id: int, diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index f7f625518..87ed3a723 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -7,7 +7,7 @@ from typing import Annotated, Any, Optional, Union import anyio import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用 -from fastapi import Body, Depends, Header, HTTPException, Query, Request, Response +from fastapi import Body, Depends, Header, HTTPException, Query, Request, Response, status from fastapi.responses import StreamingResponse from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token @@ -32,7 +32,7 @@ from app.application.network import get_configured_network_test_service from app.application.rules import RuleHelper from app.application.scheduling import get_scheduler from app.application.security.url import SecurityUtils -from app.application.settings import SystemSettingsService +from app.application.settings import SystemSettingConflictError, SystemSettingsService from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.application.system import LogFileData, LogNotFoundError from app.chain.media import MediaChain @@ -708,7 +708,11 @@ async def query_custom_identifiers( _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> _SchemaResponse[Any]: """返回完整的自定义识别词列表。""" - identifiers = get_configured_system_config().get(SystemConfigKey.CustomIdentifiers) or [] + identifiers = [ + item + for item in (get_configured_system_config().get(SystemConfigKey.CustomIdentifiers) or []) + if isinstance(item, str) + ] return _SchemaResponse( success=True, data={"count": len(identifiers), "identifiers": identifiers}, @@ -725,16 +729,24 @@ async def update_custom_identifiers( _: ApiPrincipal = Depends(get_current_active_superuser_async), runtime: HostRuntime = Depends(get_host_runtime), ) -> _SchemaResponse[Any]: - """完整替换自定义识别词。""" - identifiers = [item for item in payload.identifiers if item is not None] - data = await SystemSettingsService( - get_runtime_settings(), - get_configured_system_config(), - runtime.system.publish_config_changed, - ).update( - setting_key=SystemConfigKey.CustomIdentifiers.value, - value=identifiers or None, - ) + """完整替换自定义识别词,并可拒绝基于过期快照的覆盖。""" + identifiers = list(payload.identifiers) + try: + data = await SystemSettingsService( + get_runtime_settings(), + get_configured_system_config(), + runtime.system.publish_config_changed, + ).update( + setting_key=SystemConfigKey.CustomIdentifiers.value, + value=identifiers or None, + expected_value=payload.expected_identifiers, + enforce_expected_value=payload.expected_identifiers is not None, + ) + except SystemSettingConflictError as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(error), + ) from error data.update({"count": len(identifiers), "identifiers": identifiers}) return _SchemaResponse(success=True, message=data.get("message"), data=data) diff --git a/app/application/configuration.py b/app/application/configuration.py index 11b9807b4..c88c85e4b 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from functools import partial from pathlib import Path -from typing import Any, Optional, Protocol, cast +from typing import Any, Optional, Protocol, TypeVar, cast from app.application.database import AsyncDatabaseExecutor from app.schemas.common import JsonData @@ -15,6 +15,8 @@ from app.schemas.types import MediaType, SystemConfigKey SystemConfigValueNormalizer = Callable[[Any, Any], Any] """系统配置值在进入持久化端口前使用的规范化函数。""" +T = TypeVar("T") + class SystemConfigReader(Protocol): """持久化用户配置的最小只读端口。""" @@ -35,6 +37,13 @@ class SystemConfigWriter(Protocol): def increment(self, key: SystemConfigKey, step: int = 1) -> int: """原子递增整数配置并返回递增后的值。""" + def update_atomically( + self, + key: Any, + mutation: Callable[[Any, Any], tuple[T, Any]], + ) -> T: + """在持久化写锁内读取旧值、提交新值并返回业务结果。""" + class ConfigurationRepository(SystemConfigReader, SystemConfigWriter, Protocol): """兼容同时提供读写能力的旧配置仓储。""" @@ -394,6 +403,27 @@ class SystemConfigService: normalized_value=normalized_value, ) + async def async_update_atomically( + self, + key: Any, + mutation: Callable[[Any], tuple[T, Any]], + ) -> T: + """在线程化短事务内原子读取旧值、规范化并写入新值。""" + if self._async_executor is None: + raise RuntimeError("系统配置异步数据库执行端口尚未配置") + + def apply(_session: Any, current: Any) -> tuple[T, Any]: + """把不暴露数据库会话的应用层 mutation 适配到底层原子仓储。""" + result, value = mutation(current) + return result, self.normalize_value(key, value) + + return cast( + T, + await self._async_executor.run( + partial(self._writer.update_atomically, key, apply) + ), + ) + async def async_delete(self, key: Any) -> Any: """异步删除配置,并等待数据库提交或回滚完成。""" if self._async_executor is None: diff --git a/app/application/filtering.py b/app/application/filtering.py index 9f6d90a9d..83dd3a9e4 100644 --- a/app/application/filtering.py +++ b/app/application/filtering.py @@ -507,6 +507,43 @@ class FilterRuleService: "count": len(rules), } + async def reorder_custom( + self, + rule_ids: list[str], + *, + expected_rule_ids: Optional[list[str]] = None, + ) -> dict[str, Any]: + """按完整 ID 列表重排规则,并用事务快照拒绝并发覆盖。""" + rules = get_custom_rules() + current_ids = [str(rule.id) for rule in rules if rule.id] + if len(current_ids) != len(rules): + raise ValueError("自定义规则存在缺少 ID 的损坏项,不能调整顺序") + self._validate_reorder( + "自定义规则", + current_ids, + rule_ids, + expected_rule_ids, + ) + rules_by_id = {str(rule.id): rule for rule in rules if rule.id} + ordered_rules = [rules_by_id[rule_id] for rule_id in rule_ids] + expected_rules = [rule.model_dump(exclude_none=True) for rule in rules] + rule_definitions = [rule.model_dump(exclude_none=True) for rule in ordered_rules] + groups = get_rule_groups() + group_definitions = [group.model_dump(exclude_none=True) for group in groups] + async with self._mutation_scope() as mutation: + await mutation.apply( + group_definitions, + expected_rule_groups=group_definitions, + custom_rules=rule_definitions, + expected_custom_rules=expected_rules, + ) + await self._publish_config_changed(SystemConfigKey.CustomFilterRules, rule_definitions) + return { + "message": "已调整自定义过滤规则顺序", + "count": len(rule_ids), + "rule_ids": rule_ids, + } + async def update_custom( self, *, @@ -634,6 +671,36 @@ class FilterRuleService: "count": len(definitions), } + async def reorder_groups( + self, + group_names: list[str], + *, + expected_group_names: Optional[list[str]] = None, + ) -> dict[str, Any]: + """按完整名称列表重排规则组,并复用原子修改作用域。""" + groups = get_rule_groups() + current_names = [str(group.name) for group in groups if group.name] + if len(current_names) != len(groups): + raise ValueError("规则组存在缺少名称的损坏项,不能调整顺序") + self._validate_reorder( + "规则组", + current_names, + group_names, + expected_group_names, + ) + groups_by_name = {str(group.name): group for group in groups if group.name} + ordered_groups = [groups_by_name[name] for name in group_names] + expected = [group.model_dump(exclude_none=True) for group in groups] + definitions = [group.model_dump(exclude_none=True) for group in ordered_groups] + async with self._mutation_scope() as mutation: + await mutation.apply(definitions, expected_rule_groups=expected) + await self._publish_config_changed(SystemConfigKey.UserFilterRuleGroups, definitions) + return { + "message": "已调整过滤规则组顺序", + "count": len(group_names), + "group_names": group_names, + } + async def update_group( self, *, @@ -702,3 +769,24 @@ class FilterRuleService: "count": len(remaining), "reference_updates": result.to_dict(), } + + @staticmethod + def _validate_reorder( + item_label: str, + current_names: list[str], + requested_names: list[str], + expected_names: Optional[list[str]], + ) -> None: + """验证重排列表完整、唯一且基于未过期的名称集合。""" + if any(not name.strip() for name in requested_names): + raise ValueError(f"{item_label}顺序不能包含空名称") + if len(set(requested_names)) != len(requested_names): + raise ValueError(f"{item_label}顺序不能包含重复名称") + if set(requested_names) != set(current_names): + raise ValueError(f"{item_label}集合已变化,请重新读取后再试") + if ( + expected_names is not None + and current_names != expected_names + and current_names != requested_names + ): + raise ValueError(f"{item_label}顺序已被其他请求修改,请重新读取后再试") diff --git a/app/application/plugin/data.py b/app/application/plugin/data.py index 9f40ffa08..651349e7f 100644 --- a/app/application/plugin/data.py +++ b/app/application/plugin/data.py @@ -4,6 +4,7 @@ import json from collections.abc import Callable from typing import Any, Optional, Protocol +from app.application.security.secrets import is_secret_setting_key from app.schemas.common import JsonData @@ -145,3 +146,77 @@ class PluginDataQueryService: } ) return result + + +def plugin_data_value_type(value: JsonData) -> str: + """把插件 JSON 值映射为不包含内容的稳定类型名称。""" + if value is None: + return "null" + if type(value) is bool: + return "boolean" + if type(value) in (int, float): + return "number" + if type(value) is str: + return "string" + if type(value) is list: + return "array" + if type(value) is dict: + return "object" + return "unknown" + + +def plugin_data_serialized_chars(value: JsonData) -> Optional[int]: + """计算合法 JSON 值的紧凑字符数,异常对象不执行自定义字符串化。""" + try: + return len(json.dumps(value, ensure_ascii=False, separators=(",", ":"))) + except TypeError, ValueError: + return None + + +class PluginDataSummaryService: + """构建不包含插件持久化原值的有界诊断摘要。""" + + def __init__( + self, + repository: PluginDataQueryRepository, + snapshot: Callable[[str], Optional[dict[str, Any]]], + ) -> None: + """注入插件数据仓储和安装态快照查询函数。""" + self._repository = repository + self._snapshot = snapshot + + async def summarize(self, plugin_id: str) -> dict[str, Any]: + """返回键名、类型、大小和敏感标记,不返回或字符串化数据值。""" + plugin = self._snapshot(plugin_id) + if plugin is None: + raise ValueError(f"插件 {plugin_id} 不存在") + + data = await self._repository.list(plugin_id) + items = [] + total_chars = 0 + for key, value in list(data.items())[:PLUGIN_DATA_KEY_PREVIEW_LIMIT]: + serialized_chars = plugin_data_serialized_chars(value) + if serialized_chars is not None: + total_chars += serialized_chars + items.append( + { + "key": str(key), + "value_type": plugin_data_value_type(value), + "serialized_chars": serialized_chars, + "sensitive": is_secret_setting_key(key), + } + ) + + if len(data) > PLUGIN_DATA_KEY_PREVIEW_LIMIT: + for value in list(data.values())[PLUGIN_DATA_KEY_PREVIEW_LIMIT:]: + serialized_chars = plugin_data_serialized_chars(value) + if serialized_chars is not None: + total_chars += serialized_chars + + return { + **plugin, + "count": len(data), + "total_chars": total_chars, + "keys": items, + "keys_truncated": len(data) > PLUGIN_DATA_KEY_PREVIEW_LIMIT, + } diff --git a/app/application/plugin/folders.py b/app/application/plugin/folders.py index 7b8b63258..e0a0e9f20 100644 --- a/app/application/plugin/folders.py +++ b/app/application/plugin/folders.py @@ -32,6 +32,10 @@ class PluginFolderResult: message: str = "" +FolderChange = Callable[[PluginFolders], tuple[PluginFolderResult, PluginFolders]] +FolderAtomicWriter = Callable[[FolderChange], Awaitable[PluginFolderResult]] + + class PluginFolderService: """集中管理插件文件夹快照和带准入的持久化变更。""" @@ -42,12 +46,14 @@ class PluginFolderService: write: FolderWriter, write_sync: FolderSyncWriter, mutation: FolderMutation, + update: FolderAtomicWriter | None = None, ) -> None: """保存配置读写和插件运行态变更准入端口。""" self._read = read self._write = write self._write_sync = write_sync self._mutation = mutation + self._update = update def get(self) -> PluginFolders: """返回与配置存储隔离的当前文件夹快照。""" @@ -77,45 +83,162 @@ class PluginFolderService: async def create(self, folder_name: str) -> PluginFolderResult: """创建不存在的文件夹,保留旧列表格式的兼容形态。""" - try: - with self._mutation(f"创建插件文件夹 {folder_name}"): - folders = self.get() - if folder_name in folders: - return PluginFolderResult(False, f"文件夹 '{folder_name}' 已存在") - folders[folder_name] = [] - await self._write(folders) - return PluginFolderResult(True, f"文件夹 '{folder_name}' 创建成功") - except PluginMutationRejectedError as error: - return PluginFolderResult(False, str(error)) + folder_name = folder_name.strip() + if not folder_name: + return PluginFolderResult(False, "文件夹名称不能为空") + + def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]: + """只在名称尚未占用时向最新快照追加空文件夹。""" + if folder_name in folders: + return PluginFolderResult(False, f"文件夹 '{folder_name}' 已存在"), folders + folders[folder_name] = [] + return PluginFolderResult(True, f"文件夹 '{folder_name}' 创建成功"), folders + + return await self._change(f"创建插件文件夹 {folder_name}", change) async def delete(self, folder_name: str) -> PluginFolderResult: """删除存在的文件夹并返回稳定业务结果。""" - try: - with self._mutation(f"删除插件文件夹 {folder_name}"): - folders = self.get() - if folder_name not in folders: - return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在") - del folders[folder_name] - await self._write(folders) - return PluginFolderResult(True, f"文件夹 '{folder_name}' 删除成功") - except PluginMutationRejectedError as error: - return PluginFolderResult(False, str(error)) + def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]: + """只从最新快照移除目标文件夹。""" + if folder_name not in folders: + return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders + del folders[folder_name] + return PluginFolderResult(True, f"文件夹 '{folder_name}' 删除成功"), folders + + return await self._change(f"删除插件文件夹 {folder_name}", change) + + async def update_folder( + self, + folder_name: str, + *, + new_name: str | None = None, + changes: dict[str, Any] | None = None, + ) -> PluginFolderResult: + """增量更新文件夹名称或展示配置,同时保留成员与未修改字段。""" + normalized_name = new_name.strip() if new_name is not None else folder_name + if not normalized_name: + return PluginFolderResult(False, "文件夹名称不能为空") + folder_changes = deepcopy(changes or {}) + + def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]: + """在最新快照中合并展示字段,并保持重命名前的字典位置。""" + if folder_name not in folders: + return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders + if normalized_name != folder_name and normalized_name in folders: + return PluginFolderResult(False, f"文件夹 '{normalized_name}' 已存在"), folders + + current = folders[folder_name] + if folder_changes: + current = ( + {"plugins": list(current)} + if isinstance(current, list) + else deepcopy(current) if isinstance(current, dict) else {"plugins": []} + ) + current.update(folder_changes) + + if normalized_name == folder_name: + folders[folder_name] = current + else: + folders = { + (normalized_name if name == folder_name else name): (current if name == folder_name else value) + for name, value in folders.items() + } + return PluginFolderResult(True, f"文件夹 '{normalized_name}' 已更新"), folders + + return await self._change(f"更新插件文件夹 {folder_name}", change) async def update_plugins( - self, folder_name: str, plugin_ids: list[str] + self, + folder_name: str, + plugin_ids: list[str], + expected_plugin_ids: list[str] | None = None, ) -> PluginFolderResult: - """更新指定文件夹的插件顺序和成员。""" - try: - with self._mutation(f"更新插件文件夹 {folder_name}"): - folders = self.get() - folders[folder_name] = list(plugin_ids) - await self._write(folders) - return PluginFolderResult( - True, - f"文件夹 '{folder_name}' 中的插件已更新", + """条件更新指定文件夹的插件顺序和成员,并保留展示配置。""" + next_plugin_ids = list(plugin_ids) + + def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]: + """基于最新成员列表检查预期快照并替换目标列表。""" + if folder_name not in folders: + return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders + folder_data = folders[folder_name] + current_plugin_ids = _folder_plugins(folder_data) or [] + if expected_plugin_ids is not None and current_plugin_ids != expected_plugin_ids: + return PluginFolderResult(False, "插件文件夹已被其他请求修改,请重新读取后再试"), folders + folders[folder_name] = _with_folder_plugins(folder_data, next_plugin_ids) + return PluginFolderResult(True, f"文件夹 '{folder_name}' 中的插件已更新"), folders + + return await self._change(f"更新插件文件夹 {folder_name}", change) + + async def assign_plugin(self, folder_name: str, plugin_id: str) -> PluginFolderResult: + """把一个插件原子迁移到目标文件夹,并从其他文件夹移除。""" + def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]: + """在同一最新快照内完成跨文件夹成员迁移。""" + if folder_name not in folders: + return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders + + for name, folder_data in folders.items(): + plugins = _folder_plugins(folder_data) + if plugins is None: + if name == folder_name: + folders[name] = _with_folder_plugins(folder_data, []) + continue + folders[name] = _with_folder_plugins( + folder_data, + [item for item in plugins if item != plugin_id], + ) + + target = folders[folder_name] + target_plugins = list(_folder_plugins(target) or []) + target_plugins.append(plugin_id) + folders[folder_name] = _with_folder_plugins(target, target_plugins) + return PluginFolderResult(True, f"插件已移动到文件夹 '{folder_name}'"), folders + + return await self._change(f"移动插件到文件夹 {folder_name}", change) + + async def remove_plugin_from_folder( + self, + folder_name: str, + plugin_id: str, + ) -> PluginFolderResult: + """只从指定文件夹移除一个插件,不影响其他文件夹。""" + def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]: + """在最新快照内删除目标文件夹中的指定成员。""" + if folder_name not in folders: + return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders + folder_data = folders[folder_name] + plugins = _folder_plugins(folder_data) or [] + if plugin_id not in plugins: + return PluginFolderResult(False, f"插件不在文件夹 '{folder_name}' 中"), folders + folders[folder_name] = _with_folder_plugins( + folder_data, + [item for item in plugins if item != plugin_id], ) + return PluginFolderResult(True, f"插件已从文件夹 '{folder_name}' 移除"), folders + + return await self._change(f"从文件夹 {folder_name} 移除插件", change) + + async def _change( + self, + operation: str, + change: FolderChange, + ) -> PluginFolderResult: + """统一执行带运行时准入的增量文件夹写入并映射稳定失败结果。""" + try: + with self._mutation(operation): + if self._update is not None: + return await self._update(change) + folders = self.get() + result, changed_folders = change(folders) + if result.success: + await self._write(changed_folders) + return result + except PersistenceUnavailableError: + raise except PluginMutationRejectedError as error: return PluginFolderResult(False, str(error)) + except Exception as error: # noqa: BLE001 - HTTP 兼容入口以业务结果表达失败 + logger.error(f"[文件夹API] {operation}失败: {error}") + return PluginFolderResult(False, str(error)) def remove_plugin(self, plugin_id: str) -> None: """从当前和旧版文件夹形态中移除插件且不阻断卸载。""" @@ -175,6 +298,10 @@ def get_plugin_folder_service() -> PluginFolderService: SystemConfigKey.PluginFolders, folders ), mutation=lambda operation: get_plugin_manager().mutation(operation), + update=lambda change: get_configured_system_config().async_update_atomically( + SystemConfigKey.PluginFolders, + lambda current: change(deepcopy(current) if isinstance(current, dict) else {}), + ), ) @@ -194,3 +321,10 @@ def _folder_plugins(folder_data: Any) -> list[str] | None: plugins = folder_data.get("plugins") return plugins if isinstance(plugins, list) else None return folder_data if isinstance(folder_data, list) else None + + +def _with_folder_plugins(folder_data: Any, plugin_ids: list[str]) -> Any: + """替换成员列表,同时保留对象格式中的展示配置。""" + if isinstance(folder_data, dict): + return {**folder_data, "plugins": list(plugin_ids)} + return list(plugin_ids) diff --git a/app/application/settings.py b/app/application/settings.py index 374be88ec..eb4b6c447 100644 --- a/app/application/settings.py +++ b/app/application/settings.py @@ -29,6 +29,10 @@ class SettingSpec: systemconfig_key: Optional[SystemConfigKey] = None +class SystemSettingConflictError(ValueError): + """表示条件更新所依据的系统配置快照已经过期。""" + + SYSTEMCONFIG_SETTING_METADATA = { SystemConfigKey.Downloaders.value: { "group": "downloaders", @@ -527,6 +531,15 @@ class SystemSettingsService: return filtered or None return value + @classmethod + def _normalize_comparison_value(cls, spec: SettingSpec, value: Any) -> Any: + """按专用 API 的公开投影规范化条件更新比较值。""" + normalized = cls._normalize_systemconfig_value(value) + if spec.systemconfig_key == SystemConfigKey.CustomIdentifiers and isinstance(normalized, list): + identifiers = [item for item in normalized if isinstance(item, str)] + return identifiers or None + return normalized + @staticmethod def _resolve_list_match( spec: SettingSpec, @@ -608,31 +621,77 @@ class SystemSettingsService: remove_keys: Optional[list[str]] = None, match_field: Optional[str] = None, match_value: Any = None, + expected_value: Any = None, + enforce_expected_value: bool = False, ) -> dict[str, Any]: - """更新登记设置并发布统一配置变更事件。""" + """更新登记设置,可选校验旧值,并发布统一配置变更事件。""" spec = resolve_setting_spec(setting_key) if spec is None: raise ValueError(f"系统设置项 '{setting_key}' 不存在") + if enforce_expected_value and spec.source != "systemconfig": + raise ValueError("条件更新仅支持数据库系统配置") mutation_key = spec.systemconfig_key if spec.source == "systemconfig" else None with plugin_system_config_mutation(mutation_key): - previous_value = self._load(spec) - next_value = self._prepare_next_value( - spec, - previous_value, - value, - operation, - remove_keys, - match_field, - match_value, - ) message = "" - event_value = next_value if spec.source == "settings": + previous_value = self._load(spec) + next_value = self._prepare_next_value( + spec, + previous_value, + value, + operation, + remove_keys, + match_field, + match_value, + ) + event_value = next_value success, message = self._runtime_settings.update(spec.key, next_value) if success is False: raise ValueError(message or f"更新设置 {spec.key} 失败") changed = success is True + elif enforce_expected_value: + normalized_expected = self._normalize_comparison_value(spec, expected_value) + + def mutate(current_value: Any) -> tuple[tuple[Any, Any, bool], Any]: + """在配置写锁内校验旧值并构造本次替换结果。""" + normalized_current = self._normalize_comparison_value(spec, current_value) + if normalized_current != normalized_expected: + raise SystemSettingConflictError( + f"系统设置 {spec.key} 已被其他会话更新,请重新加载后再保存" + ) + next_value = self._prepare_next_value( + spec, + current_value, + value, + operation, + remove_keys, + match_field, + match_value, + ) + normalized_next = self._normalize_systemconfig_value(next_value) + return ( + current_value, + normalized_next, + normalized_current != normalized_next, + ), normalized_next + + previous_value, event_value, changed = ( + await self._system_config.async_update_atomically( + spec.systemconfig_key, + mutate, + ) + ) else: + previous_value = self._load(spec) + next_value = self._prepare_next_value( + spec, + previous_value, + value, + operation, + remove_keys, + match_field, + match_value, + ) event_value = self._normalize_systemconfig_value(next_value) write_result = ( await self._system_config.async_set_with_normalized_value( diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 9813a95c0..42eface1a 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -133,6 +133,7 @@ SCHEMA_EXPORTS = { 'CookiePassword': ('app.schemas.servcookie', 'CookiePassword'), 'CurrentUserUpdate': ('app.schemas.user', 'CurrentUserUpdate'), 'CustomFilterRuleCreateRequest': ('app.schemas.rule', 'CustomFilterRuleCreateRequest'), + 'CustomFilterRuleReorderRequest': ('app.schemas.rule', 'CustomFilterRuleReorderRequest'), 'CustomFilterRuleUpdateRequest': ('app.schemas.rule', 'CustomFilterRuleUpdateRequest'), 'CustomIdentifiersUpdateRequest': ('app.schemas.system', 'CustomIdentifiersUpdateRequest'), 'CustomRule': ('app.schemas.rule', 'CustomRule'), @@ -181,6 +182,7 @@ SCHEMA_EXPORTS = { 'FileURI': ('app.schemas.file', 'FileURI'), 'FilterRuleGroup': ('app.schemas.system', 'FilterRuleGroup'), 'FilterRuleGroupCreateRequest': ('app.schemas.rule', 'FilterRuleGroupCreateRequest'), + 'FilterRuleGroupReorderRequest': ('app.schemas.rule', 'FilterRuleGroupReorderRequest'), 'FilterRuleGroupUpdateRequest': ('app.schemas.rule', 'FilterRuleGroupUpdateRequest'), 'Generic': ('app.schemas.response', 'Generic'), 'HistoryDeletedEventData': ('app.schemas.event', 'HistoryDeletedEventData'), @@ -335,8 +337,12 @@ SCHEMA_EXPORTS = { 'PluginCloneRequest': ('app.schemas.plugin', 'PluginCloneRequest'), 'PluginDashboard': ('app.schemas.plugin', 'PluginDashboard'), 'PluginDashboardMetaItem': ('app.schemas.plugin', 'PluginDashboardMetaItem'), + 'PluginDataKeySummary': ('app.schemas.plugin', 'PluginDataKeySummary'), 'PluginDataResetEventData': ('app.schemas.event', 'PluginDataResetEventData'), + 'PluginDataSummary': ('app.schemas.plugin', 'PluginDataSummary'), 'PluginFolderConfigData': ('app.schemas.plugin', 'PluginFolderConfigData'), + 'PluginFolderPluginsUpdateRequest': ('app.schemas.plugin', 'PluginFolderPluginsUpdateRequest'), + 'PluginFolderUpdateRequest': ('app.schemas.plugin', 'PluginFolderUpdateRequest'), 'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'), 'PluginInstallOutcome': ('app.schemas.plugin', 'PluginInstallOutcome'), 'PluginInstance': ('app.schemas.plugin', 'PluginInstance'), @@ -351,6 +357,11 @@ SCHEMA_EXPORTS = { 'PluginReleaseItem': ('app.schemas.plugin', 'PluginReleaseItem'), 'PluginReloadEventData': ('app.schemas.event', 'PluginReloadEventData'), 'PluginRemoteInfo': ('app.schemas.plugin', 'PluginRemoteInfo'), + 'PluginRuntimeActionCapability': ('app.schemas.plugin', 'PluginRuntimeActionCapability'), + 'PluginRuntimeActionGroup': ('app.schemas.plugin', 'PluginRuntimeActionGroup'), + 'PluginRuntimeCapabilities': ('app.schemas.plugin', 'PluginRuntimeCapabilities'), + 'PluginRuntimeCommandCapability': ('app.schemas.plugin', 'PluginRuntimeCommandCapability'), + 'PluginRuntimeServiceCapability': ('app.schemas.plugin', 'PluginRuntimeServiceCapability'), 'PluginRuntimeStatus': ('app.schemas.plugin', 'PluginRuntimeStatus'), 'PluginRuntimeSummary': ('app.schemas.plugin', 'PluginRuntimeSummary'), 'PluginSidebarNavItem': ('app.schemas.plugin', 'PluginSidebarNavItem'), @@ -424,6 +435,7 @@ SCHEMA_EXPORTS = { 'StorageConf': ('app.schemas.system', 'StorageConf'), 'StorageLoginStatusData': ('app.schemas.storage', 'StorageLoginStatusData'), 'StorageOperSelectionEventData': ('app.schemas.event', 'StorageOperSelectionEventData'), + 'StorageOption': ('app.schemas.storage', 'StorageOption'), 'StorageQrCodeData': ('app.schemas.storage', 'StorageQrCodeData'), 'StorageQueryError': ('app.schemas.exception', 'StorageQueryError'), 'StorageSchema': ('app.schemas.file', 'StorageSchema'), @@ -547,7 +559,7 @@ SCHEMA_CONFLICTS = { 'ClassificationFactValue': ['app.schemas.category', 'app.schemas.context', 'app.schemas.music'], 'ClassificationFieldDefinition': ['app.schemas.category', 'app.schemas.event'], 'ClassificationResult': ['app.schemas.category', 'app.schemas.context', 'app.schemas.music'], - 'ConfigDict': ['app.schemas.agent', 'app.schemas.category', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.response', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow'], + 'ConfigDict': ['app.schemas.agent', 'app.schemas.category', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.plugin', 'app.schemas.response', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow'], 'Context': ['app.schemas.context', 'app.schemas.workflow'], 'Dict': ['app.schemas.category', 'app.schemas.common', 'app.schemas.context', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.notification', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.mcp'], 'DownloadTask': ['app.schemas.download', 'app.schemas.workflow'], diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 62a5c9285..72f532860 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -3,7 +3,7 @@ from typing import Annotated as _Annotated from typing import Dict, List, Literal, Optional, Union from pydantic import AfterValidator as _AfterValidator -from pydantic import BaseModel, Field, RootModel, field_validator +from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator from pydantic import PrivateAttr as _PrivateAttr from app.schemas.common import JsonData @@ -31,9 +31,7 @@ class PluginSourceBindingStatus(str, _Enum): class PluginUpdateCandidate(BaseModel): # type: ignore[misc] """插件市场为已安装插件选择的当前更新候选。""" - source_type: Literal["official", "third_party"] = Field( - description="候选仓库是官方来源还是第三方来源" - ) + source_type: Literal["official", "third_party"] = Field(description="候选仓库是官方来源还是第三方来源") source_key: str = Field(description="候选仓库的规范来源键") repo_url: str = Field(description="候选仓库的公开 GitHub 地址") version: str = Field(description="候选仓库当前可安装版本") @@ -55,9 +53,7 @@ _PluginId = _Annotated[str, _AfterValidator(_validate_plugin_id)] class PluginInstance(BaseModel): """持久化一个共享源码插件的独立运行实例。""" - instance_id: _PluginId = Field( - description="运行实例 ID,也是配置、数据和路由命名空间" - ) + instance_id: _PluginId = Field(description="运行实例 ID,也是配置、数据和路由命名空间") source_plugin_id: _PluginId = Field(description="提供代码与前端资源的源插件 ID") plugin_name: Optional[str] = Field(default=None, description="实例展示名称") plugin_desc: Optional[str] = Field(default=None, description="实例展示描述") @@ -69,6 +65,7 @@ class Plugin(BaseModel): """ 插件信息 """ + _package_version: Optional[str] = _PrivateAttr(default=None) id: str = None @@ -157,12 +154,77 @@ class PluginRuntimeSummary(BaseModel): ) +class PluginRuntimeCommandCapability(BaseModel): + """插件运行时注册命令的安全只读投影。""" + + cmd: str = Field(description="命令标识") + desc: Optional[str] = Field(default=None, description="命令说明") + plugin_id: Optional[str] = Field(default=None, description="注册命令的插件 ID") + + +class PluginRuntimeActionCapability(BaseModel): + """插件运行时注册动作的安全只读投影。""" + + id: str = Field(description="动作标识") + name: Optional[str] = Field(default=None, description="动作名称") + + +class PluginRuntimeActionGroup(BaseModel): + """按插件归组的运行时动作投影。""" + + plugin_id: Optional[str] = Field(default=None, description="注册动作的插件 ID") + plugin_name: Optional[str] = Field(default=None, description="插件名称") + actions: List[PluginRuntimeActionCapability] = Field(default_factory=list) + + +class PluginRuntimeServiceCapability(BaseModel): + """插件定时服务的安全只读投影。""" + + id: str = Field(description="服务标识") + name: Optional[str] = Field(default=None, description="服务名称") + trigger: Optional[str] = Field(default=None, description="定时触发器说明") + + +class PluginRuntimeCapabilities(BaseModel): + """插件命令、动作和定时服务的公共安全能力快照。""" + + commands: List[PluginRuntimeCommandCapability] = Field(default_factory=list) + actions: List[PluginRuntimeActionGroup] = Field(default_factory=list) + services: List[PluginRuntimeServiceCapability] = Field(default_factory=list) + + +class PluginDataKeySummary(BaseModel): + """单个插件持久化键的不含值诊断摘要。""" + + key: str = Field(description="持久化数据键") + value_type: Literal["null", "boolean", "number", "string", "array", "object", "unknown"] = Field( + description="值的 JSON 类型" + ) + serialized_chars: Optional[int] = Field( + default=None, + ge=0, + description="JSON 紧凑序列化字符数;异常值为空", + ) + sensitive: bool = Field(description="键名是否符合凭据字段规则") + + +class PluginDataSummary(BaseModel): + """插件持久化数据的不含原值诊断摘要。""" + + plugin_id: str = Field(description="插件 ID") + plugin_name: Optional[str] = Field(default=None, description="插件名称") + plugin_version: Optional[str] = Field(default=None, description="插件版本") + state: Optional[bool] = Field(default=None, description="插件是否启用") + count: int = Field(ge=0, description="持久化数据项总数") + total_chars: int = Field(ge=0, description="所有可序列化值的字符数总和") + keys: List[PluginDataKeySummary] = Field(default_factory=list, description="有界键摘要") + keys_truncated: bool = Field(description="是否还有未返回的键摘要") + + class PluginInstallOutcome(BaseModel): """插件载荷写入成功后的前端反馈依据。""" - restart_required: bool = Field( - description="本次依赖更新是否需要重启 MoviePilot 才能完成" - ) + restart_required: bool = Field(description="本次依赖更新是否需要重启 MoviePilot 才能完成") class PluginCloneRequest(BaseModel): @@ -204,9 +266,7 @@ class PluginSourceIdentity(BaseModel): # type: ignore[misc] class PluginSourceCandidate(BaseModel): # type: ignore[misc] """一个可供管理员识别的脱敏插件来源候选。""" - source_type: Literal["official", "third_party", "local"] = Field( - description="来源类型;本地候选不公开路径" - ) + source_type: Literal["official", "third_party", "local"] = Field(description="来源类型;本地候选不公开路径") source_key: Optional[str] = Field( default=None, description="规范化在线来源键;本地候选为空", @@ -215,9 +275,7 @@ class PluginSourceCandidate(BaseModel): # type: ignore[misc] default=None, description="可明确选择的在线仓库地址;本地候选为空", ) - package_generation: Literal["v1", "v2", "v3"] = Field( - description="当前运行时会采用的插件包代际" - ) + package_generation: Literal["v1", "v2", "v3"] = Field(description="当前运行时会采用的插件包代际") plugin_version: Optional[str] = Field( default=None, description="该来源当前可安装的插件版本", @@ -228,12 +286,10 @@ class PluginSourceOptions(BaseModel): # type: ignore[misc] """来源选择界面所需的当前身份、候选和准入状态。""" plugin_id: str = Field(description="物理插件 ID") - inventory_complete: bool = Field( - description="本轮配置市场是否全部得到确定读取结果" + inventory_complete: bool = Field(description="本轮配置市场是否全部得到确定读取结果") + selection_status: Literal["selected", "unavailable", "conflict", "incomplete"] = Field( + description="未指定新来源时的当前准入状态" ) - selection_status: Literal[ - "selected", "unavailable", "conflict", "incomplete" - ] = Field(description="未指定新来源时的当前准入状态") selection_reason: str = Field(description="当前准入状态的人类可读原因") identity: Optional[PluginSourceIdentity] = Field( default=None, @@ -299,6 +355,7 @@ class PluginDashboard(Plugin): """ 插件仪表盘 """ + id: Optional[str] = None # 名称 name: Optional[str] = None @@ -318,6 +375,7 @@ class PluginSidebarNavItem(BaseModel): """ 插件侧栏导航项(前端全页路由) """ + plugin_id: str = Field(description="插件 ID") nav_key: str = Field(description="导航键,对应 URL 段") title: str = Field(description="侧栏标题") @@ -358,6 +416,7 @@ class PluginRatingMap(RootModel[Dict[str, PluginRating]]): class PluginMemoryInfo(BaseModel): """插件内存信息""" + plugin_id: str = Field(description="插件ID") plugin_name: str = Field(description="插件名称") plugin_version: str = Field(description="插件版本") @@ -431,6 +490,47 @@ class PluginFoldersData(RootModel[Dict[str, Union[List[str], PluginFolderConfigD """插件文件夹与插件配置映射,兼容旧版数组格式与新版对象格式。""" +class PluginFolderUpdateRequest(BaseModel): + """插件文件夹名称和展示字段的增量更新请求。""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + new_name: Optional[str] = Field( + default=None, + min_length=1, + max_length=128, + description="Optional replacement folder name.", + ) + icon: Optional[str] = Field(default=None, description="Optional folder icon name.") + color: Optional[str] = Field( + default=None, + description="Optional folder foreground color.", + ) + gradient: Optional[str] = Field( + default=None, + description="Optional folder gradient definition.", + ) + background: Optional[str] = Field( + default=None, + description="Optional folder background color or style.", + ) + show_icon: Optional[bool] = Field( + default=None, + alias="showIcon", + description="Whether the frontend should display the folder icon.", + ) + + +class PluginFolderPluginsUpdateRequest(BaseModel): + """插件文件夹成员顺序的条件替换请求。""" + + plugins: List[str] = Field(description="Ordered installed plugin IDs assigned to this folder.") + expected_plugins: Optional[List[str]] = Field( + default=None, + description="Last observed ordered plugin IDs used to reject stale replacements.", + ) + + class PluginDashboardMetaItem(BaseModel): """插件仪表板入口摘要。""" diff --git a/app/schemas/rule.py b/app/schemas/rule.py index 8f1a689be..bd1a7e496 100644 --- a/app/schemas/rule.py +++ b/app/schemas/rule.py @@ -63,6 +63,13 @@ class CustomFilterRuleUpdateRequest(BaseModel): # type: ignore[misc] publish_time: Optional[str] = None +class CustomFilterRuleReorderRequest(BaseModel): # type: ignore[misc] + """调整自定义过滤规则顺序请求。""" + + rule_ids: list[str] + expected_rule_ids: Optional[list[str]] = None + + class FilterRuleGroupCreateRequest(BaseModel): # type: ignore[misc] """新增过滤规则组请求。""" @@ -79,3 +86,10 @@ class FilterRuleGroupUpdateRequest(BaseModel): # type: ignore[misc] rule_string: Optional[str] = None media_type: Optional[str] = None category: Optional[str] = None + + +class FilterRuleGroupReorderRequest(BaseModel): # type: ignore[misc] + """调整过滤规则组顺序请求。""" + + group_names: list[str] + expected_group_names: Optional[list[str]] = None diff --git a/app/schemas/storage.py b/app/schemas/storage.py index 8d7b1b3b9..226b9a10c 100644 --- a/app/schemas/storage.py +++ b/app/schemas/storage.py @@ -24,3 +24,10 @@ class StorageLoginStatusData(BaseModel): status: int | str = Field(description="授权状态") tip: str = Field(description="状态提示") + + +class StorageOption(BaseModel): + """前端选择控件可安全消费的存储摘要。""" + + name: str = Field(description="存储显示名称") + type: str = Field(description="存储类型标识") diff --git a/app/schemas/system.py b/app/schemas/system.py index 8dd1e2577..b052fd3f5 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -195,7 +195,16 @@ class SystemSettingsUpdateRequest(BaseModel): # type: ignore[misc] class CustomIdentifiersUpdateRequest(BaseModel): # type: ignore[misc] """完整替换自定义识别词的请求。""" - identifiers: list[str] = Field(default_factory=list) + identifiers: list[str] = Field( + default_factory=list, + description="Complete ordered list of custom recognition identifier rules.", + ) + expected_identifiers: Optional[list[str]] = Field( + default=None, + description=( + "Previously read complete ordered list. When supplied, reject the replacement if the stored list has changed." + ), + ) SystemUpdateType = Literal["application", "resources"] diff --git a/docs/architecture/agent-api-surface-audit.json b/docs/architecture/agent-api-surface-audit.json index 7610d4baa..4f834c3bb 100644 --- a/docs/architecture/agent-api-surface-audit.json +++ b/docs/architecture/agent-api-surface-audit.json @@ -2,11 +2,11 @@ "disposition_counts": { "alternate-auth-duplicate": 11, "consolidated": 72, - "gateway": 199, + "gateway": 202, "provider-skill": 11, "stream_or_binary": 10, "transport_or_identity": 66, - "ui_presentation": 16 + "ui_presentation": 20 }, "dynamic_gateway_routes": [ { @@ -18,10 +18,10 @@ "reason": "The executor validates and expands this bounded source placeholder to one of tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route." } ], - "gateway_http_route_count": 200, - "gateway_operation_count": 202, - "matched_gateway_http_route_count": 199, - "openapi_operation_count": 385, + "gateway_http_route_count": 203, + "gateway_operation_count": 205, + "matched_gateway_http_route_count": 202, + "openapi_operation_count": 392, "operations": [ { "disposition": "consolidated", @@ -755,20 +755,6 @@ "history" ] }, - { - "disposition": "gateway", - "method": "GET", - "operation_ids": [ - "transfer.history.clear" - ], - "owner": "moviepilot-api", - "path": "/api/v1/history/empty/transfer", - "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", - "summary": "清空整理记录", - "tags": [ - "history" - ] - }, { "disposition": "gateway", "method": "DELETE", @@ -811,6 +797,20 @@ "history" ] }, + { + "disposition": "gateway", + "method": "DELETE", + "operation_ids": [ + "transfer.history.clear" + ], + "owner": "moviepilot-api", + "path": "/api/v1/history/transfer/all", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "清空旧整理记录", + "tags": [ + "history" + ] + }, { "disposition": "gateway", "method": "POST", @@ -2051,6 +2051,20 @@ "plugin" ] }, + { + "disposition": "gateway", + "method": "PATCH", + "operation_ids": [ + "plugin.folder.update" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/folders/{folder_name}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "更新插件文件夹", + "tags": [ + "plugin" + ] + }, { "disposition": "gateway", "method": "POST", @@ -2079,6 +2093,34 @@ "plugin" ] }, + { + "disposition": "gateway", + "method": "DELETE", + "operation_ids": [ + "plugin.folder.plugin.remove" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "从文件夹移除插件", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "PUT", + "operation_ids": [ + "plugin.folder.plugin.assign" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "移动插件到文件夹", + "tags": [ + "plugin" + ] + }, { "disposition": "gateway", "method": "GET", @@ -2205,7 +2247,7 @@ }, { "disposition": "gateway", - "method": "GET", + "method": "POST", "operation_ids": [ "plugin.reload" ], @@ -2285,6 +2327,18 @@ "plugin" ] }, + { + "disposition": "ui_presentation", + "method": "GET", + "operation_ids": [], + "owner": "host-ui", + "path": "/api/v1/plugin/runtime/{plugin_id}/data/summary", + "reason": "Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.", + "summary": "查询插件持久化数据摘要", + "tags": [ + "plugin" + ] + }, { "disposition": "ui_presentation", "method": "GET", @@ -2657,6 +2711,18 @@ "rule" ] }, + { + "disposition": "ui_presentation", + "method": "PUT", + "operation_ids": [], + "owner": "host-ui", + "path": "/api/v1/rule/custom/reorder", + "reason": "Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.", + "summary": "调整自定义过滤规则顺序", + "tags": [ + "rule" + ] + }, { "disposition": "gateway", "method": "DELETE", @@ -2713,6 +2779,18 @@ "rule" ] }, + { + "disposition": "ui_presentation", + "method": "PUT", + "operation_ids": [], + "owner": "host-ui", + "path": "/api/v1/rule/groups/reorder", + "reason": "Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.", + "summary": "调整过滤规则组顺序", + "tags": [ + "rule" + ] + }, { "disposition": "gateway", "method": "DELETE", @@ -3023,7 +3101,7 @@ }, { "disposition": "gateway", - "method": "GET", + "method": "POST", "operation_ids": [ "site.cookiecloud.sync" ], @@ -3105,7 +3183,7 @@ }, { "disposition": "gateway", - "method": "GET", + "method": "POST", "operation_ids": [ "site.reset" ], @@ -3379,6 +3457,18 @@ "storage" ] }, + { + "disposition": "ui_presentation", + "method": "GET", + "operation_ids": [], + "owner": "host-ui", + "path": "/api/v1/storage/options", + "reason": "Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.", + "summary": "查询可用存储选项", + "tags": [ + "storage" + ] + }, { "disposition": "gateway", "method": "POST", @@ -3437,7 +3527,7 @@ }, { "disposition": "gateway", - "method": "GET", + "method": "POST", "operation_ids": [ "subscription.metadata.refresh" ], @@ -3641,7 +3731,7 @@ }, { "disposition": "gateway", - "method": "GET", + "method": "POST", "operation_ids": [ "subscription.refresh" ], @@ -3655,7 +3745,7 @@ }, { "disposition": "gateway", - "method": "GET", + "method": "POST", "operation_ids": [ "subscription.reset" ], @@ -3669,7 +3759,7 @@ }, { "disposition": "gateway", - "method": "GET", + "method": "POST", "operation_ids": [ "subscription.search_all" ], @@ -3683,7 +3773,7 @@ }, { "disposition": "gateway", - "method": "GET", + "method": "POST", "operation_ids": [ "subscription.search" ], diff --git a/docs/architecture/agent-api-surface-audit.md b/docs/architecture/agent-api-surface-audit.md index d0a49f10b..33af87440 100644 --- a/docs/architecture/agent-api-surface-audit.md +++ b/docs/architecture/agent-api-surface-audit.md @@ -5,10 +5,10 @@ ## Result -- OpenAPI HTTP operations: **385** -- Stable `moviepilot_api` operations: **202** -- Exact HTTP routes used by the gateway: **200** -- OpenAPI routes matched directly by the gateway: **199** +- OpenAPI HTTP operations: **392** +- Stable `moviepilot_api` operations: **205** +- Exact HTTP routes used by the gateway: **203** +- OpenAPI routes matched directly by the gateway: **202** - Bounded dynamic gateway routes: **1** - Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`. - Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input. @@ -19,11 +19,11 @@ | :--- | ---: | :--- | | `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. | | `consolidated` | 72 | Source/UI route represented by a stable aggregate Agent operation. | -| `gateway` | 199 | Approved structured MoviePilot Agent operation. | +| `gateway` | 202 | Approved structured MoviePilot Agent operation. | | `provider-skill` | 11 | Low-level downloader or media-server capability owned by a provider Skill. | | `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. | | `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. | -| `ui_presentation` | 16 | Frontend or plugin-rendered presentation contract. | +| `ui_presentation` | 20 | Frontend or plugin-rendered presentation contract. | ## Bounded Dynamic Routes @@ -93,10 +93,10 @@ | `PATCH` | `/api/v1/download/{hashString}` | download | `provider-skill` | downloader-operation | 高级更新下载任务 | | `DELETE` | `/api/v1/history/download` | history | `gateway` | download.history.delete | 删除下载历史记录 | | `GET` | `/api/v1/history/download` | history | `gateway` | download.history.list | 查询下载历史记录 | -| `GET` | `/api/v1/history/empty/transfer` | history | `gateway` | transfer.history.clear | 清空整理记录 | | `DELETE` | `/api/v1/history/transfer` | history | `gateway` | transfer.history.delete | 删除整理记录 | | `GET` | `/api/v1/history/transfer` | history | `gateway` | transfer.history | 查询整理记录 | | `POST` | `/api/v1/history/transfer/ai-redo` | history | `gateway` | transfer.history.redo_batch | 智能助手批量重新整理 | +| `DELETE` | `/api/v1/history/transfer/all` | history | `gateway` | transfer.history.clear | 清空旧整理记录 | | `POST` | `/api/v1/history/transfer/{history_id}/ai-redo` | history | `gateway` | transfer.history.redo | 智能助手重新整理 | | `POST` | `/api/v1/llm/manage` | llm | `transport_or_identity` | host-runtime | LLM提供商统一管理 | | `GET` | `/api/v1/llm/provider-auth/callback/{provider_id}` | llm | `transport_or_identity` | host-runtime | LLM提供商OAuth回调 | @@ -195,8 +195,11 @@ | `GET` | `/api/v1/plugin/folders` | plugin | `gateway` | plugin.folders.get | 获取插件文件夹配置 | | `POST` | `/api/v1/plugin/folders` | plugin | `gateway` | plugin.folders.update | 保存插件文件夹配置 | | `DELETE` | `/api/v1/plugin/folders/{folder_name}` | plugin | `gateway` | plugin.folder.delete | 删除插件文件夹 | +| `PATCH` | `/api/v1/plugin/folders/{folder_name}` | plugin | `gateway` | plugin.folder.update | 更新插件文件夹 | | `POST` | `/api/v1/plugin/folders/{folder_name}` | plugin | `gateway` | plugin.folder.create | 创建插件文件夹 | | `PUT` | `/api/v1/plugin/folders/{folder_name}/plugins` | plugin | `gateway` | plugin.folder.plugins.update | 更新文件夹中的插件 | +| `DELETE` | `/api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}` | plugin | `gateway` | plugin.folder.plugin.remove | 从文件夹移除插件 | +| `PUT` | `/api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}` | plugin | `gateway` | plugin.folder.plugin.assign | 移动插件到文件夹 | | `GET` | `/api/v1/plugin/form/{plugin_id}` | plugin | `gateway` | plugin.config.get | 获取插件表单页面 | | `GET` | `/api/v1/plugin/history/{plugin_id}` | plugin | `gateway` | plugin.history | 获取插件更新说明 | | `GET` | `/api/v1/plugin/install/{plugin_id}` | plugin | `gateway` | plugin.install | 安装插件 | @@ -206,12 +209,13 @@ | `GET` | `/api/v1/plugin/rating/{plugin_id}` | plugin | `gateway` | plugin.rating | 查询插件评分 | | `POST` | `/api/v1/plugin/rating/{plugin_id}` | plugin | `gateway` | plugin.rating.submit | 提交插件评分 | | `GET` | `/api/v1/plugin/releases/{plugin_id}` | plugin | `gateway` | plugin.releases | 获取插件Release版本 | -| `GET` | `/api/v1/plugin/reload/{plugin_id}` | plugin | `gateway` | plugin.reload | 重新加载插件 | +| `POST` | `/api/v1/plugin/reload/{plugin_id}` | plugin | `gateway` | plugin.reload | 重新加载插件 | | `GET` | `/api/v1/plugin/remotes` | plugin | `transport_or_identity` | host-runtime | 获取插件联邦组件列表 | | `GET` | `/api/v1/plugin/reset/{plugin_id}` | plugin | `gateway` | plugin.reset | 重置插件配置及数据 | | `GET` | `/api/v1/plugin/runtime` | plugin | `gateway` | plugin.runtime.status | 插件运行时收敛状态 | | `GET` | `/api/v1/plugin/runtime/capabilities` | plugin | `gateway` | plugin.capabilities | 查询插件运行能力 | | `GET` | `/api/v1/plugin/runtime/{plugin_id}/data` | plugin | `gateway` | plugin.data | 查询插件持久化数据 | +| `GET` | `/api/v1/plugin/runtime/{plugin_id}/data/summary` | plugin | `ui_presentation` | host-ui | 查询插件持久化数据摘要 | | `GET` | `/api/v1/plugin/sidebar_nav` | plugin | `ui_presentation` | host-ui | 获取插件侧栏导航项 | | `GET` | `/api/v1/plugin/source/{plugin_id}` | plugin | `gateway` | plugin.source.options | 获取插件来源身份 | | `POST` | `/api/v1/plugin/source/{plugin_id}` | plugin | `gateway` | plugin.source.change | 切换插件来源 | @@ -241,10 +245,12 @@ | `GET` | `/api/v1/rule/builtin` | rule | `gateway` | filter.builtin | 查询内置过滤规则 | | `GET` | `/api/v1/rule/custom` | rule | `gateway` | filter.custom | 查询自定义过滤规则 | | `POST` | `/api/v1/rule/custom` | rule | `gateway` | filter.custom.add | 新增自定义过滤规则 | +| `PUT` | `/api/v1/rule/custom/reorder` | rule | `ui_presentation` | host-ui | 调整自定义过滤规则顺序 | | `DELETE` | `/api/v1/rule/custom/{rule_id}` | rule | `gateway` | filter.custom.delete | 删除自定义过滤规则 | | `PUT` | `/api/v1/rule/custom/{rule_id}` | rule | `gateway` | filter.custom.update | 更新自定义过滤规则 | | `GET` | `/api/v1/rule/groups` | rule | `gateway` | filter.groups | 查询过滤规则组 | | `POST` | `/api/v1/rule/groups` | rule | `gateway` | filter.group.add | 新增过滤规则组 | +| `PUT` | `/api/v1/rule/groups/reorder` | rule | `ui_presentation` | host-ui | 调整过滤规则组顺序 | | `DELETE` | `/api/v1/rule/groups/{name}` | rule | `gateway` | filter.group.delete | 删除过滤规则组 | | `PUT` | `/api/v1/rule/groups/{name}` | rule | `gateway` | filter.group.update | 更新过滤规则组 | | `GET` | `/api/v1/search/last` | search | `consolidated` | search.results | 查询搜索结果 | @@ -267,13 +273,13 @@ | `GET` | `/api/v1/site/category/{site_id}` | site | `gateway` | site.category | 站点分类 | | `GET` | `/api/v1/site/cookie/{site_id}` | site | `consolidated` | site.cookie.update | 更新站点Cookie&UA | | `POST` | `/api/v1/site/cookie/{site_id}` | site | `gateway` | site.cookie.update | 更新站点Cookie&UA | -| `GET` | `/api/v1/site/cookiecloud` | site | `gateway` | site.cookiecloud.sync | CookieCloud同步 | +| `POST` | `/api/v1/site/cookiecloud` | site | `gateway` | site.cookiecloud.sync | CookieCloud同步 | | `GET` | `/api/v1/site/domain/{site_url}` | site | `consolidated` | site.list | 站点详情 | | `GET` | `/api/v1/site/icon/{site_id}` | site | `stream_or_binary` | host-transport | 站点图标 | | `GET` | `/api/v1/site/mapping` | site | `gateway` | site.mapping | 获取站点域名到名称的映射 | | `GET` | `/api/v1/site/media/{media_type}` | site | `gateway` | site.searchable | 按媒体类型获取可搜索站点 | | `POST` | `/api/v1/site/priorities` | site | `gateway` | site.priorities.update | 批量更新站点优先级 | -| `GET` | `/api/v1/site/reset` | site | `gateway` | site.reset | 重置站点 | +| `POST` | `/api/v1/site/reset` | site | `gateway` | site.reset | 重置站点 | | `GET` | `/api/v1/site/resource/{site_id}` | site | `gateway` | site.resource | 站点资源 | | `GET` | `/api/v1/site/rss` | site | `gateway` | site.rss | 所有订阅站点 | | `GET` | `/api/v1/site/statistic` | site | `gateway` | site.statistics | 所有站点统计信息 | @@ -293,11 +299,12 @@ | `POST` | `/api/v1/storage/list` | storage | `consolidated` | storage.list | 所有目录和文件 | | `POST` | `/api/v1/storage/manage` | storage | `gateway` | storage.manage | 网盘存储统一管理 | | `POST` | `/api/v1/storage/mkdir` | storage | `gateway` | storage.mkdir | 创建目录 | +| `GET` | `/api/v1/storage/options` | storage | `ui_presentation` | host-ui | 查询可用存储选项 | | `POST` | `/api/v1/storage/rename` | storage | `gateway` | storage.rename | 重命名文件或目录 | | `GET` | `/api/v1/subscribe/` | subscribe | `gateway` | subscription.list | 查询所有订阅 | | `POST` | `/api/v1/subscribe/` | subscribe | `gateway` | subscription.add | 新增订阅 | | `PUT` | `/api/v1/subscribe/` | subscribe | `gateway` | subscription.update | 更新订阅 | -| `GET` | `/api/v1/subscribe/check` | subscribe | `gateway` | subscription.metadata.refresh | 刷新订阅 TMDB 信息 | +| `POST` | `/api/v1/subscribe/check` | subscribe | `gateway` | subscription.metadata.refresh | 刷新订阅 TMDB 信息 | | `GET` | `/api/v1/subscribe/execution/batches` | subscribe | `ui_presentation` | host-ui | 查询订阅搜索批次状态 | | `GET` | `/api/v1/subscribe/execution/batches/{batch_id}` | subscribe | `ui_presentation` | host-ui | 查询订阅搜索批次 | | `PUT` | `/api/v1/subscribe/execution/batches/{batch_id}/cancel` | subscribe | `ui_presentation` | host-ui | 取消订阅搜索批次 | @@ -312,10 +319,10 @@ | `DELETE` | `/api/v1/subscribe/media/{media_id}` | subscribe | `gateway` | subscription.delete_by_media | 删除订阅 | | `GET` | `/api/v1/subscribe/media/{media_id}` | subscribe | `gateway` | subscription.find | 查询订阅 | | `GET` | `/api/v1/subscribe/popular` | subscribe | `gateway` | subscription.popular | 热门订阅(基于用户共享数据) | -| `GET` | `/api/v1/subscribe/refresh` | subscribe | `gateway` | subscription.refresh | 刷新订阅 | -| `GET` | `/api/v1/subscribe/reset/{subid}` | subscribe | `gateway` | subscription.reset | 重置订阅 | -| `GET` | `/api/v1/subscribe/search` | subscribe | `gateway` | subscription.search_all | 搜索所有订阅 | -| `GET` | `/api/v1/subscribe/search/{subscribe_id}` | subscribe | `gateway` | subscription.search | 搜索订阅 | +| `POST` | `/api/v1/subscribe/refresh` | subscribe | `gateway` | subscription.refresh | 刷新订阅 | +| `POST` | `/api/v1/subscribe/reset/{subid}` | subscribe | `gateway` | subscription.reset | 重置订阅 | +| `POST` | `/api/v1/subscribe/search` | subscribe | `gateway` | subscription.search_all | 搜索所有订阅 | +| `POST` | `/api/v1/subscribe/search/{subscribe_id}` | subscribe | `gateway` | subscription.search | 搜索订阅 | | `POST` | `/api/v1/subscribe/seerr` | subscribe | `transport_or_identity` | host-runtime | OverSeerr/JellySeerr通知订阅 | | `POST` | `/api/v1/subscribe/share` | subscribe | `gateway` | subscription.share | 分享订阅 | | `GET` | `/api/v1/subscribe/share/statistics` | subscribe | `gateway` | subscription.share.statistics | 查询订阅分享统计 | diff --git a/scripts/generate_agent_api_surface_audit.py b/scripts/generate_agent_api_surface_audit.py index c828b49f2..577fd3d74 100644 --- a/scripts/generate_agent_api_surface_audit.py +++ b/scripts/generate_agent_api_surface_audit.py @@ -96,7 +96,11 @@ UI_PRESENTATION_PATHS = frozenset( "/api/v1/plugin/dashboard/{plugin_id}", "/api/v1/plugin/dashboard/{plugin_id}/{key}", "/api/v1/plugin/page/{plugin_id}", + "/api/v1/plugin/runtime/{plugin_id}/data/summary", "/api/v1/plugin/sidebar_nav", + "/api/v1/rule/custom/reorder", + "/api/v1/rule/groups/reorder", + "/api/v1/storage/options", } ) EXPLICIT_TRANSPORT_PATHS = frozenset( @@ -242,9 +246,7 @@ def generate_audit() -> dict[str, Any]: ) counts = Counter(entry["disposition"] for entry in entries) matched_gateway_routes = { - (entry["method"], entry["path"]) - for entry in entries - if entry["disposition"] == "gateway" + (entry["method"], entry["path"]) for entry in entries if entry["disposition"] == "gateway" } dynamic_gateway_routes = [ { @@ -354,10 +356,7 @@ def main() -> int: encoding="utf-8", ) MARKDOWN_OUTPUT.write_text(render_markdown(audit), encoding="utf-8") - print( - "generated " - f"{JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}" - ) + print(f"generated {JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}") return 0 diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md index 7412f9236..b84a3e904 100644 --- a/skills/database-operation/SKILL.md +++ b/skills/database-operation/SKILL.md @@ -156,7 +156,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Purpose: Stores media identity, torrent, downloader, user, and recognition context for submitted downloads. - Useful queries: Reviewing download history or tracing a media identity or hash back to its source. - Write boundary: Written by the download use case; delete or correct records through the download-history API. -- Columns: `id`, `path`, `type`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `image`, `poster`, `downloader`, `download_hash`, `torrent_name`, `torrent_description`, `torrent_site`, `userid`, `username`, `channel`, `date`, `note`, `media_category`, `episode_group`, `custom_words` +- Columns: `id`, `path`, `type`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `image`, `poster`, `downloader`, `download_hash`, `torrent_name`, `torrent_description`, `torrent_site`, `userid`, `username`, `channel`, `date`, `note`, `media_category_id`, `media_category`, `classification_rule_id`, `classification_policy_revision`, `classification_source`, `episode_group`, `custom_words` ### `mediaserveritem` - Purpose: Stores the local index and canonical media identity projected from media-server libraries. @@ -228,13 +228,13 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Purpose: Stores active movie, TV, or music subscriptions, filters, progress, and download targets. - Useful queries: Inspecting state, missing episodes/tracks, quality rules, site scope, and match progress. - Write boundary: Create, update, search, or delete through the subscription API to preserve state-machine consistency. -- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `lack_episode`, `note`, `state`, `last_update`, `date`, `username`, `sites`, `downloader`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `manual_total_episode`, `custom_words`, `media_category`, `filter_groups`, `episode_group` +- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `lack_episode`, `note`, `state`, `last_update`, `date`, `username`, `sites`, `downloader`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `manual_total_episode`, `custom_words`, `media_category_id`, `media_category`, `filter_groups`, `episode_group` ### `subscribehistory` - Purpose: Stores snapshots of completed or archived subscriptions and their final filter state. - Useful queries: Auditing historical subscriptions, media identity, completion criteria, and filter configuration. - Write boundary: Generated by subscription completion and archival; restore or delete through its business API. -- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `date`, `username`, `sites`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `custom_words`, `media_category`, `filter_groups`, `episode_group` +- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `date`, `username`, `sites`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `custom_words`, `media_category_id`, `media_category`, `classification_rule_id`, `classification_policy_revision`, `classification_source`, `filter_groups`, `episode_group` ### `subscriptionsearchbatch` - Purpose: Stores durable subscription search batches, source, aggregate state, counts, and cancellation requests. @@ -270,7 +270,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Purpose: Stores transfer source, destination, mode, media identity, download linkage, and outcome. - Useful queries: Reviewing success/failure history, destination paths, media classification, and download linkage. - Write boundary: Written by transfer settlement; delete or retry through transfer-history business APIs. -- Columns: `id`, `transfer_task_id`, `transfer_settlement_revision`, `src`, `src_storage`, `src_fileitem`, `dest`, `dest_storage`, `dest_fileitem`, `mode`, `type`, `category`, `title`, `year`, `media_source`, `media_id`, `music_type`, `total_tracks`, `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, `bitrate`, `seasons`, `episodes`, `image`, `downloader`, `download_hash`, `status`, `errmsg`, `date`, `files`, `episode_group` +- Columns: `id`, `transfer_task_id`, `transfer_settlement_revision`, `src`, `src_storage`, `src_fileitem`, `dest`, `dest_storage`, `dest_fileitem`, `mode`, `type`, `media_category_id`, `category`, `classification_rule_id`, `classification_policy_revision`, `classification_source`, `title`, `year`, `media_source`, `media_id`, `music_type`, `total_tracks`, `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, `bitrate`, `seasons`, `episodes`, `image`, `downloader`, `download_hash`, `status`, `errmsg`, `date`, `files`, `episode_group` ### `transferpending` - Purpose: Durably stores pending transfer input, plans, checkpoints, leases, retries, and manual review state. diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 7f5d624e0..016fc2dab 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -29,10 +29,10 @@ allowed-api-operations: >- system.update.install system.upgrade.dev dashboard.media.statistics dashboard.storage dashboard.processes dashboard.system dashboard.downloader scheduler.progress dashboard.transfer.statistics dashboard.cpu dashboard.memory dashboard.network media.sources - media.recognize_file media.category.config.get media.category.config.update media.categories - media.episode_groups media.episode_group.seasons media.seasons search.title search.recommend - subtitle.search.title subtitle.search.media site.add site.delete site.auth.options - site.authenticate site.cookiecloud.sync site.reset site.priorities.update site.userdata.refresh + media.recognize_file media.category.config.get media.categories media.episode_groups + media.episode_group.seasons media.seasons search.title search.recommend subtitle.search.title + subtitle.search.media site.add site.delete site.auth.options site.authenticate + site.cookiecloud.sync site.reset site.priorities.update site.userdata.refresh site.userdata.latest site.category site.resource site.searchable site.rss site.statistics site.statistic site.mapping site.supporting subscription.get subscription.find subscription.delete_by_media subscription.status.update subscription.reset @@ -53,7 +53,8 @@ allowed-api-operations: >- plugin.market.sync_wiki plugin.runtime.status plugin.history plugin.releases plugin.ratings plugin.rating plugin.rating.submit plugin.statistics plugin.reset plugin.clone config.user.get config.public.get system.usage.statistics plugin.folders.get plugin.folders.update - plugin.folder.create plugin.folder.delete plugin.folder.plugins.update + plugin.folder.create plugin.folder.update plugin.folder.delete plugin.folder.plugins.update + plugin.folder.plugin.assign plugin.folder.plugin.remove --- # MoviePilot API @@ -183,7 +184,7 @@ Purpose: Read the complete custom media-recognition identifier list. Purpose: Replace the complete custom media-recognition identifier list. - `path_params`: none - `query`: none -- `body`: `identifiers` (array): Complete ordered list of custom recognition identifier rules. +- `body`: `expected_identifiers` (array|null): Previously read complete ordered list. When supplied, reject the replacement if the stored list has changed.; `identifiers` (array): Complete ordered list of custom recognition identifier rules. ### `config.public.get` `GET /api/v1/system/setting/public/{key}`; policy effect: `safe_read`. @@ -328,7 +329,7 @@ Purpose: List enabled downloader instance names and provider types without crede Purpose: Delete one MoviePilot download-history record. - `path_params`: none - `query`: none -- `body`: `channel` (string|null): Message channel that originally submitted the download.; `date` (string|null): Record creation or completion timestamp used by the history item.; `download_hash` (string|null): Provider-native torrent hash associated with the record.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episodes` (string|null): Episode-number expression recorded in history, such as E01-E03.; `id*` (integer): Persistent database identifier of the supplied record.; `image` (string|null): Image URL stored with the history record.; `media_category` (string|null): MoviePilot library category assigned to the media.; `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.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `note` (JsonData-Input|null): Structured auxiliary metadata stored with the record.; `path` (string|null): Storage or history path represented by this record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `seasons` (string|null): Season-number expression recorded in history.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `torrent_description` (string|null): Torrent release description recorded in download history.; `torrent_name` (string|null): Torrent release name recorded in download history.; `torrent_site` (string|null): Source site name recorded in download history.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `userid` (string|null): Message-channel user ID recorded with download history.; `username` (string|null): MoviePilot or site username required by the selected operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `channel` (string|null): Message channel that originally submitted the download.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `date` (string|null): Record creation or completion timestamp used by the history item.; `download_hash` (string|null): Provider-native torrent hash associated with the record.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episodes` (string|null): Episode-number expression recorded in history, such as E01-E03.; `id*` (integer): Persistent database identifier of the supplied record.; `image` (string|null): Image URL stored with the history record.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `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.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `note` (JsonData-Input|null): Structured auxiliary metadata stored with the record.; `path` (string|null): Storage or history path represented by this record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `seasons` (string|null): Season-number expression recorded in history.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `torrent_description` (string|null): Torrent release description recorded in download history.; `torrent_name` (string|null): Torrent release name recorded in download history.; `torrent_site` (string|null): Source site name recorded in download history.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `userid` (string|null): Message-channel user ID recorded with download history.; `username` (string|null): MoviePilot or site username required by the selected operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `download.history.list` `GET /api/v1/history/download`; policy effect: `safe_read`. @@ -453,13 +454,6 @@ Purpose: Read the complete automatic media-category strategy configuration. - `query`: none - `body`: none -### `media.category.config.update` -`POST /api/v1/media/category/config`; policy effect: `reversible_write`. -Purpose: Replace the complete automatic media-category strategy configuration. -- `path_params`: none -- `query`: none -- `body`: `movie` (object|null; default `{}`): Automatic movie-category rules evaluated in order.; `tv` (object|null; default `{}`): Automatic TV-category rules evaluated in order. - ### `media.detail` `GET /api/v1/media/{media_id}`; policy effect: `safe_read`. Purpose: Read canonical media details from one selected metadata source. @@ -675,12 +669,33 @@ Purpose: Delete one named plugin folder without uninstalling its plugins. - `query`: none - `body`: none +### `plugin.folder.plugin.assign` +`PUT /api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}`; policy effect: `reversible_write`. +Purpose: Move one installed plugin into one named folder and remove its other folder assignments. +- `path_params`: `folder_name*` (string): Exact plugin folder name returned by plugin.folders.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + +### `plugin.folder.plugin.remove` +`DELETE /api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}`; policy effect: `reversible_write`. +Purpose: Remove one installed plugin from one named folder without uninstalling it. +- `path_params`: `folder_name*` (string): Exact plugin folder name returned by plugin.folders.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + ### `plugin.folder.plugins.update` `PUT /api/v1/plugin/folders/{folder_name}/plugins`; policy effect: `reversible_write`. Purpose: Replace the ordered plugin IDs assigned to one named plugin folder. - `path_params`: `folder_name*` (string): Exact plugin folder name returned by plugin.folders.get. - `query`: none -- `body*` (array): Request value for plugin.folder.plugins.update. Replace the ordered plugin IDs assigned to one named plugin folder. Use the exact type and fields below. +- `body*` (array|PluginFolderPluginsUpdateRequest): Request value for plugin.folder.plugins.update. Replace the ordered plugin IDs assigned to one named plugin folder. Use the exact type and fields below. + +### `plugin.folder.update` +`PATCH /api/v1/plugin/folders/{folder_name}`; policy effect: `reversible_write`. +Purpose: Incrementally rename one plugin folder or update its presentation settings. +- `path_params`: `folder_name*` (string): Exact plugin folder name returned by plugin.folders.get. +- `query`: none +- `body`: `background` (string|null): Optional folder background color or style.; `color` (string|null): Optional folder foreground color.; `gradient` (string|null): Optional folder gradient definition.; `icon` (string|null): Optional folder icon name.; `new_name` (string|null): Optional replacement folder name.; `showIcon` (boolean|null): Whether the frontend should display the folder icon. ### `plugin.folders.get` `GET /api/v1/plugin/folders`; policy effect: `safe_read`. @@ -762,7 +777,7 @@ Purpose: List available release versions for one plugin source. - `body`: none ### `plugin.reload` -`GET /api/v1/plugin/reload/{plugin_id}`; policy effect: `external_side_effect`. +`POST /api/v1/plugin/reload/{plugin_id}`; policy effect: `external_side_effect`. Purpose: Reload one installed plugin into the running process. - `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID. - `query`: none @@ -914,7 +929,7 @@ Purpose: Log in to one site and refresh its stored authentication cookie. - `body`: `code` (string|null): Two-factor verification code or site-specific authentication secret.; `password*` (string): Site login password. Treat this value as a secret.; `username*` (string): MoviePilot or site username required by the selected operation. ### `site.cookiecloud.sync` -`GET /api/v1/site/cookiecloud`; policy effect: `external_side_effect`. +`POST /api/v1/site/cookiecloud`; policy effect: `external_side_effect`. Purpose: Start a CookieCloud synchronization of configured sites. - `path_params`: none - `query`: none @@ -950,7 +965,7 @@ Purpose: Replace priorities for the supplied configured site IDs. - `body*` (array): Request value for site.priorities.update. Replace priorities for the supplied configured site IDs. Use the exact type and fields below. ### `site.reset` -`GET /api/v1/site/reset`; policy effect: `destructive_write`. +`POST /api/v1/site/reset`; policy effect: `destructive_write`. Purpose: Delete all configured sites and start a fresh CookieCloud synchronization. - `path_params`: none - `query`: none @@ -1074,7 +1089,7 @@ Purpose: List files or directories from one configured storage location. Purpose: Run one provider-defined management action against an exact configured storage target. - `path_params`: none - `query`: none -- `body`: `action*` (string): Exact provider or workflow action identifier required by the selected operation.; `params` (object): Provider-defined JSON parameters for the selected authentication or storage action.; `target*` (string): Exact configured storage target name accepted by storage.manage. +- `body`: `action*` (string): Exact provider or workflow action identifier required by the selected operation.; `params` (object): Provider-defined JSON parameters for the selected authentication or storage action.; `target*` (string): Exact target identifier selected by the operation. ### `storage.mkdir` `POST /api/v1/storage/mkdir`; policy effect: `reversible_write`. @@ -1103,7 +1118,7 @@ Purpose: Read configured directory or storage settings. Purpose: Create one movie, TV, or music subscription. - `path_params`: none - `query`: none -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `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.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `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.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `subscription.delete` `DELETE /api/v1/subscribe/{subscribe_id}`; policy effect: `destructive_write`. @@ -1160,7 +1175,7 @@ Purpose: List subscription-sharing user IDs followed by the current user. Purpose: Create a local subscription from one shared subscription definition. - `path_params`: none - `query`: none -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `count` (integer|null; default `0`): Maximum number of records to return on the requested page.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `media_category` (string|null): MoviePilot library category assigned to the media.; `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.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `share_comment` (string|null): Optional explanatory comment published with a shared item.; `share_title` (string|null): Public title used when publishing a subscription or workflow.; `share_uid` (string|null): Exact MoviePilot Server sharing-user ID to follow or unfollow.; `share_user` (string|null): Public contributor name used when publishing a subscription or workflow.; `subscribe_id` (integer|null): Persistent subscription ID returned by subscription.list.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `count` (integer|null; default `0`): Maximum number of records to return on the requested page.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `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.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `share_comment` (string|null): Optional explanatory comment published with a shared item.; `share_title` (string|null): Public title used when publishing a subscription or workflow.; `share_uid` (string|null): Exact MoviePilot Server sharing-user ID to follow or unfollow.; `share_user` (string|null): Public contributor name used when publishing a subscription or workflow.; `subscribe_id` (integer|null): Persistent subscription ID returned by subscription.list.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `subscription.get` `GET /api/v1/subscribe/{subscribe_id}`; policy effect: `safe_read`. @@ -1193,7 +1208,7 @@ Purpose: List active subscriptions. - `body`: none ### `subscription.metadata.refresh` -`GET /api/v1/subscribe/check`; policy effect: `external_side_effect`. +`POST /api/v1/subscribe/check`; policy effect: `external_side_effect`. Purpose: Start a system-wide refresh of subscription TMDB metadata. - `path_params`: none - `query`: none @@ -1208,28 +1223,28 @@ Purpose: List globally popular subscriptions with filters and pagination. - `body`: none ### `subscription.refresh` -`GET /api/v1/subscribe/refresh`; policy effect: `external_side_effect`. +`POST /api/v1/subscribe/refresh`; policy effect: `external_side_effect`. Purpose: Start the configured system-wide subscription refresh job. - `path_params`: none - `query`: none - `body`: none ### `subscription.reset` -`GET /api/v1/subscribe/reset/{subid}`; policy effect: `reversible_write`. +`POST /api/v1/subscribe/reset/{subid}`; policy effect: `reversible_write`. Purpose: Reset one accessible subscription so it can be processed again. - `path_params`: `subid*` (integer): Persistent subscription ID whose status or processing state will change. - `query`: none - `body`: none ### `subscription.search` -`GET /api/v1/subscribe/search/{subscribe_id}`; policy effect: `safe_read`. +`POST /api/v1/subscribe/search/{subscribe_id}`; policy effect: `external_side_effect`. Purpose: Run an immediate search for one existing subscription. - `path_params`: `subscribe_id*` (integer): Persistent subscription ID returned by subscription.list. - `query`: none - `body`: none ### `subscription.search_all` -`GET /api/v1/subscribe/search`; policy effect: `external_side_effect`. +`POST /api/v1/subscribe/search`; policy effect: `external_side_effect`. Purpose: Start immediate searches for all subscriptions accessible to the current user. - `path_params`: none - `query`: none @@ -1240,7 +1255,7 @@ Purpose: Start immediate searches for all subscriptions accessible to the curren Purpose: Publish one accessible subscription to the MoviePilot sharing service. - `path_params`: none - `query`: none -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `count` (integer|null; default `0`): Maximum number of records to return on the requested page.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `media_category` (string|null): MoviePilot library category assigned to the media.; `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.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `share_comment` (string|null): Optional explanatory comment published with a shared item.; `share_title` (string|null): Public title used when publishing a subscription or workflow.; `share_uid` (string|null): Exact MoviePilot Server sharing-user ID to follow or unfollow.; `share_user` (string|null): Public contributor name used when publishing a subscription or workflow.; `subscribe_id` (integer|null): Persistent subscription ID returned by subscription.list.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `count` (integer|null; default `0`): Maximum number of records to return on the requested page.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `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.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `share_comment` (string|null): Optional explanatory comment published with a shared item.; `share_title` (string|null): Public title used when publishing a subscription or workflow.; `share_uid` (string|null): Exact MoviePilot Server sharing-user ID to follow or unfollow.; `share_user` (string|null): Public contributor name used when publishing a subscription or workflow.; `subscribe_id` (integer|null): Persistent subscription ID returned by subscription.list.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `subscription.share.delete` `DELETE /api/v1/subscribe/share/{share_id}`; policy effect: `external_side_effect`. @@ -1277,7 +1292,7 @@ Purpose: Set one accessible subscription to running, paused, or stopped state. Purpose: Update one existing movie, TV, or music subscription. - `path_params`: none - `query`: none -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `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.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `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.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `subscription.user.list` `GET /api/v1/subscribe/user/{username}`; policy effect: `safe_read`. @@ -1341,28 +1356,28 @@ Purpose: Restart the running MoviePilot process. ### `system.update.check` `POST /api/v1/system/update/check`; policy effect: `external_side_effect`. -Purpose: Check GitHub for the latest stable MoviePilot v3 release. +Purpose: Check for the latest stable MoviePilot v3 application release and current-platform site resources. - `path_params`: none - `query`: none - `body`: none ### `system.update.download` `POST /api/v1/system/update/download`; policy effect: `external_side_effect`. -Purpose: Start downloading and verifying the available stable release in the background. +Purpose: Start downloading and verifying one selected application or site-resource update in the background. - `path_params`: none - `query`: none -- `body`: none +- `body` (SystemUpdateRequest|null): Request value for system.update.download. Start downloading and verifying one selected application or site-resource update in the background. Use the exact type and fields below. ### `system.update.install` `POST /api/v1/system/update/install`; policy effect: `external_side_effect`. -Purpose: Install the already downloaded and verified stable release, then restart MoviePilot. +Purpose: Install one selected already downloaded and verified application or site-resource update, then restart MoviePilot. - `path_params`: none - `query`: none -- `body`: none +- `body` (SystemUpdateRequest|null): Request value for system.update.install. Install one selected already downloaded and verified application or site-resource update, then restart MoviePilot. Use the exact type and fields below. ### `system.update.status` `GET /api/v1/system/update/status`; policy effect: `safe_read`. -Purpose: Read the current stable-release check, download, verification, or install state. +Purpose: Read application and site-resource update checks, downloads, verification, or install state. - `path_params`: none - `query`: none - `body`: none @@ -1447,8 +1462,8 @@ Purpose: List file-transfer history with filters and pagination. - `body`: none ### `transfer.history.clear` -`GET /api/v1/history/empty/transfer`; policy effect: `destructive_write`. -Purpose: Delete every transfer-history record while leaving transferred files untouched. +`DELETE /api/v1/history/transfer/all`; policy effect: `destructive_write`. +Purpose: Delete legacy transfer-history records while leaving files and durable failed-task records untouched. - `path_params`: none - `query`: none - `body`: none @@ -1458,7 +1473,7 @@ Purpose: Delete every transfer-history record while leaving transferred files un Purpose: Delete one transfer-history record and optionally remove files. - `path_params`: none - `query`: `deletedest` (boolean|null; default `False`): Also delete the organized destination files when deleting transfer history.; `deletesrc` (boolean|null; default `False`): Also delete the recorded source files when deleting transfer history. -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_lossless` (boolean|null): Whether the recorded audio result is lossless.; `bit_depth` (integer|null): Recorded audio bit depth in bits.; `bitrate` (integer|null): Recorded audio bitrate in bits per second.; `category` (string|null): MoviePilot media category or filter-group category, depending on the operation.; `date` (string|null): Record creation or completion timestamp used by the history item.; `dest` (string|null): Organized destination path recorded in transfer history.; `dest_fileitem` (JsonData-Input|null): Serialized destination storage item recorded by the transfer.; `dest_storage` (string|null): Configured storage name containing the organized destination.; `download_hash` (string|null): Provider-native torrent hash associated with the record.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episodes` (string|null): Episode-number expression recorded in history, such as E01-E03.; `errmsg` (string|null): Error message recorded for a failed transfer.; `files` (JsonData-Input|null): Serialized list of files recorded by the history item.; `id*` (integer): Persistent database identifier of the supplied record.; `image` (string|null): Image URL stored with the history record.; `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.; `mode` (string|null): Operation mode; music.explore accepts chart or fresh, while transfer history records move, copy, link, or softlink.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `sample_rate` (integer|null): Recorded audio sample rate in hertz.; `seasons` (string|null): Season-number expression recorded in history.; `src` (string|null): Source path recorded in transfer history.; `src_fileitem` (JsonData-Input|null): Serialized source storage item recorded by the transfer.; `src_storage` (string|null): Configured storage name containing the transfer source.; `status` (boolean; default `True`): Transfer success status used to filter history or describe a record.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `transfer_task_id` (string|null): Stable durable transfer-task ID associated with the history record.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_lossless` (boolean|null): Whether the recorded audio result is lossless.; `bit_depth` (integer|null): Recorded audio bit depth in bits.; `bitrate` (integer|null): Recorded audio bitrate in bits per second.; `category` (string|null): MoviePilot media category or filter-group category, depending on the operation.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `date` (string|null): Record creation or completion timestamp used by the history item.; `dest` (string|null): Organized destination path recorded in transfer history.; `dest_fileitem` (JsonData-Input|null): Serialized destination storage item recorded by the transfer.; `dest_storage` (string|null): Configured storage name containing the organized destination.; `download_hash` (string|null): Provider-native torrent hash associated with the record.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episodes` (string|null): Episode-number expression recorded in history, such as E01-E03.; `errmsg` (string|null): Error message recorded for a failed transfer.; `files` (JsonData-Input|null): Serialized list of files recorded by the history item.; `id*` (integer): Persistent database identifier of the supplied record.; `image` (string|null): Image URL stored with the history record.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `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.; `mode` (string|null): Operation mode; music.explore accepts chart or fresh, while transfer history records move, copy, link, or softlink.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `sample_rate` (integer|null): Recorded audio sample rate in hertz.; `seasons` (string|null): Season-number expression recorded in history.; `src` (string|null): Source path recorded in transfer history.; `src_fileitem` (JsonData-Input|null): Serialized source storage item recorded by the transfer.; `src_storage` (string|null): Configured storage name containing the transfer source.; `status` (boolean; default `True`): Transfer success status used to filter history or describe a record.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `transfer_task_id` (string|null): Stable durable transfer-task ID associated with the history record.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `transfer.history.redo` `POST /api/v1/history/transfer/{history_id}/ai-redo`; policy effect: `external_side_effect`. diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 8f94a734b..41c56a8b1 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -33,8 +33,8 @@ def test_api_operation_registry_matches_migration_batches() -> None: assert len(API_PARITY_OPERATION_SPECS) == 15 assert len(API_MUSIC_OPERATION_SPECS) == 10 assert len(API_SYSTEM_OPERATION_SPECS) == 7 - assert len(API_EXTENDED_OPERATION_SPECS) == 118 - assert len(API_OPERATION_SPECS) == 202 + assert len(API_EXTENDED_OPERATION_SPECS) == 121 + assert len(API_OPERATION_SPECS) == 205 assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES) assert { "download.list", diff --git a/tests/test_agent_application_services.py b/tests/test_agent_application_services.py index 8bb5650ad..2f111636a 100644 --- a/tests/test_agent_application_services.py +++ b/tests/test_agent_application_services.py @@ -16,8 +16,11 @@ from app.application.music.projection import simplify_music_album, simplify_musi from app.application.plugin.data import ( DeletePluginDataCommand, PluginDataQueryService, + PluginDataSummaryService, build_preview_payload, clamp_preview_chars, + plugin_data_serialized_chars, + plugin_data_value_type, ) from app.application.security.secrets import is_secret_setting_key from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease @@ -29,6 +32,7 @@ from app.schemas.types import EventType, MediaSource, SystemConfigKey def run_async(awaitable): """在普通 pytest 函数中执行一个短异步断言。""" import asyncio + return asyncio.run(awaitable) @@ -112,12 +116,25 @@ def test_filtering_normalizers_and_usage_collection(monkeypatch): async def async_list(self): """返回带规则组引用的订阅快照。""" - return [SimpleNamespace(id=1, name="Sub", season=1, type="电影", username="u", best_version=True, filter_groups=["search", "custom"]), SimpleNamespace(filter_groups=None)] + return [ + SimpleNamespace( + id=1, + name="Sub", + season=1, + type="电影", + username="u", + best_version=True, + filter_groups=["search", "custom"], + ), + SimpleNamespace(filter_groups=None), + ] usage = run_async(filtering.collect_rule_group_usages(SubscriptionPort(), ["search", "custom"])) assert usage["search"]["used_in_global_search"] is True assert usage["search"]["subscribes"][0]["subscribe_id"] == 1 - refs = filtering.collect_custom_rule_group_refs([FilterRuleGroup(name="main", rule_string="CUSTOM & 4K"), FilterRuleGroup(name="none")], ["CUSTOM"]) + refs = filtering.collect_custom_rule_group_refs( + [FilterRuleGroup(name="main", rule_string="CUSTOM & 4K"), FilterRuleGroup(name="none")], ["CUSTOM"] + ) assert refs["CUSTOM"] == ["main"] @@ -152,7 +169,9 @@ async def test_filter_rule_service_queries_and_mutations(monkeypatch): assert filtering.FilterRuleService.query_custom(["OLD"])["count"] == 1 assert (await service.query_groups(include_usage=False))["count"] == 1 assert (await service.query_groups(["missing"], include_usage=False))["count"] == 0 - added = await service.add_custom(rule_id="NEW", name="New", include="new", exclude=None, size_range=None, seeders=None, publish_time=None) + added = await service.add_custom( + rule_id="NEW", name="New", include="new", exclude=None, size_range=None, seeders=None, publish_time=None + ) assert added["custom_rule"]["id"] == "NEW" updated = await service.update_custom(current_rule_id="OLD", new_rule_id="RENAMED") assert updated["rule_groups_updated_for_rule_id_rename"] == ["group"] @@ -177,7 +196,9 @@ async def test_save_system_config_and_settings_service(monkeypatch): runtime.get.side_effect = lambda key: {"LLM_MODEL": "model", "PLUGIN_MARKET": "a"}.get(key) runtime.update.return_value = (True, "updated") system = MagicMock() - system.get.side_effect = lambda key: [{"name": "qb", "token": "secret"}] if key == SystemConfigKey.Downloaders else {"a": 1} + system.get.side_effect = lambda key: ( + [{"name": "qb", "token": "secret"}] if key == SystemConfigKey.Downloaders else {"a": 1} + ) system.normalize_value.side_effect = lambda _key, value: value system.async_set = AsyncMock(return_value=True) system.async_set_with_normalized_value = AsyncMock( @@ -216,13 +237,22 @@ async def test_save_system_config_and_settings_service(monkeypatch): assert runtime_definition["persistence"] == "app.env" spec = settings_module.resolve_setting_spec(SystemConfigKey.Downloaders.value) assert spec - assert service._prepare_next_value(spec, {"name": "old", "x": 1}, {"name": "old", "y": 2}, "merge_dict", ["x"], None, None) == {"name": "old", "y": 2} - assert service._prepare_next_value(spec, [{"name": "old"}], {"name": "old", "x": 2}, "upsert_list_item", None, None, None) == [{"name": "old", "x": 2}] - assert service._prepare_next_value(spec, [{"name": "old"}], {"name": "old"}, "remove_list_item", None, None, None) == [] + assert service._prepare_next_value( + spec, {"name": "old", "x": 1}, {"name": "old", "y": 2}, "merge_dict", ["x"], None, None + ) == {"name": "old", "y": 2} + assert service._prepare_next_value( + spec, [{"name": "old"}], {"name": "old", "x": 2}, "upsert_list_item", None, None, None + ) == [{"name": "old", "x": 2}] + assert ( + service._prepare_next_value(spec, [{"name": "old"}], {"name": "old"}, "remove_list_item", None, None, None) + == [] + ) with pytest.raises(ValueError, match="不支持"): service._prepare_next_value(spec, None, None, "bad", None, None, None) system.get.side_effect = [[], [{"name": "new"}]] - result = await service.update(setting_key=SystemConfigKey.Downloaders.value, value={"name": "new"}, operation="upsert_list_item") + result = await service.update( + setting_key=SystemConfigKey.Downloaders.value, value={"name": "new"}, operation="upsert_list_item" + ) assert result["changed"] is True runtime.get.side_effect = lambda key: "old" assert (await service.update(setting_key="PLUGIN_MARKET", value="new"))["changed"] is True @@ -259,6 +289,63 @@ async def test_settings_service_publishes_normalized_directory_value(monkeypatch publish.assert_awaited_once_with(SystemConfigKey.Directories.value, normalized) +@pytest.mark.asyncio +async def test_settings_service_conditionally_replaces_system_config_atomically(monkeypatch): + """条件替换应拒绝过期快照,并只为成功提交发布配置事件。""" + runtime = MagicMock() + system = MagicMock() + state = {"value": ["old"]} + + def get_value(_key): + """返回原子测试维护的最新配置快照。""" + return list(state["value"]) + + async def update_atomically(_key, mutation): + """在测试内同步执行条件 mutation 并发布最终值。""" + result, value = mutation(list(state["value"])) + state["value"] = list(value or []) + return result + + system.get.side_effect = get_value + system.async_update_atomically = AsyncMock(side_effect=update_atomically) + publish = AsyncMock() + monkeypatch.setattr(settings_module, "plugin_system_config_mutation", lambda _key: nullcontext()) + service = settings_module.SystemSettingsService(runtime, system, publish) + + result = await service.update( + setting_key=SystemConfigKey.CustomIdentifiers.value, + value=["new"], + expected_value=["old"], + enforce_expected_value=True, + ) + + assert result["changed"] is True + assert result["previous_value"] == ["old"] + assert result["saved_value"] == ["new"] + publish.assert_awaited_once_with(SystemConfigKey.CustomIdentifiers.value, ["new"]) + + state["value"] = ["new", 7] + repaired = await service.update( + setting_key=SystemConfigKey.CustomIdentifiers.value, + value=["fixed"], + expected_value=["new"], + enforce_expected_value=True, + ) + + assert repaired["saved_value"] == ["fixed"] + + with pytest.raises(settings_module.SystemSettingConflictError, match="其他会话"): + await service.update( + setting_key=SystemConfigKey.CustomIdentifiers.value, + value=["mine"], + expected_value=["stale"], + enforce_expected_value=True, + ) + + assert state["value"] == ["fixed"] + assert publish.await_count == 2 + + def test_settings_catalog_redaction_and_projection(): """设置目录应支持分类别名、匹配字段和递归敏感值脱敏。""" assert settings_module.normalize_group("基础配置") == "settings" @@ -280,7 +367,19 @@ def test_settings_catalog_redaction_and_projection(): @pytest.mark.asyncio async def test_plugin_management_and_data_services(monkeypatch): """插件管理、来源补齐和数据预览应覆盖成功与安全失败路径。""" - plugin = SimpleNamespace(id="Demo", plugin_name="Demo Plugin", plugin_desc="desc", plugin_version="1", plugin_author="author", installed=True, has_update=True, state=True, repo_url=None, add_time=1) + plugin = SimpleNamespace( + id="Demo", + plugin_name="Demo Plugin", + plugin_desc="desc", + plugin_version="1", + plugin_author="author", + installed=True, + has_update=True, + state=True, + repo_url=None, + add_time=1, + ) + class SourceCandidate: """提供插件来源检查所需的公开投影。""" @@ -308,14 +407,24 @@ async def test_plugin_management_and_data_services(monkeypatch): assert plugin_management.summarize_plugin(plugin)["source"] == "market" assert plugin_management.is_exact_plugin_match(plugin, "demo plugin") assert plugin_management.search_plugin_candidates("demo", [plugin])[0]["exact"] is True - assert plugin_management.summarize_candidates(plugin_management.search_plugin_candidates("demo", [plugin]), 1)[0]["id"] == "Demo" + assert ( + plugin_management.summarize_candidates(plugin_management.search_plugin_candidates("demo", [plugin]), 1)[0]["id"] + == "Demo" + ) assert await plugin_management.enrich_installed_plugin_sources([plugin]) == [plugin] assert plugin.repo_url == source.repo_url assert await plugin_management.load_market_plugins() == [source] assert plugin_management.list_installed_plugins() == [plugin] install_service = MagicMock() install_service.install = AsyncMock(return_value=SimpleNamespace(success=True, message="ok", refreshed_only=False)) - install_service.inspect_source = AsyncMock(return_value=SimpleNamespace(online_candidates=[source], local_candidate=None, selection=SimpleNamespace(status=SimpleNamespace(value="selected"), reason="exact"), inventory_complete=True)) + install_service.inspect_source = AsyncMock( + return_value=SimpleNamespace( + online_candidates=[source], + local_candidate=None, + selection=SimpleNamespace(status=SimpleNamespace(value="selected"), reason="exact"), + inventory_complete=True, + ) + ) monkeypatch.setattr(plugin_management, "get_plugin_install_service", lambda: install_service) assert await plugin_management.install_plugin_runtime("Demo", source.repo_url) == (True, "ok", False) assert (await plugin_management.inspect_plugin_sources("Demo"))["selection_status"] == "selected" @@ -338,6 +447,63 @@ async def test_plugin_management_and_data_services(monkeypatch): await PluginDataQueryService(query_repo, lambda _id: None).query("Demo") +@pytest.mark.asyncio +async def test_plugin_data_summary_excludes_values_and_flags_sensitive_keys(): + """插件数据摘要只返回类型与大小,并沿用统一敏感键规则。""" + query_repo = MagicMock() + query_repo.list = AsyncMock( + return_value={ + "accessToken": "secret-value", + "items": [{"id": 1}], + "enabled": True, + } + ) + service = PluginDataSummaryService( + query_repo, + lambda _id: { + "plugin_id": "Demo", + "plugin_name": "Demo Plugin", + "plugin_version": "1.0.0", + "state": True, + }, + ) + + result = await service.summarize("Demo") + + assert result == { + "plugin_id": "Demo", + "plugin_name": "Demo Plugin", + "plugin_version": "1.0.0", + "state": True, + "count": 3, + "total_chars": len('"secret-value"') + len('[{"id":1}]') + len("true"), + "keys": [ + { + "key": "accessToken", + "value_type": "string", + "serialized_chars": len('"secret-value"'), + "sensitive": True, + }, + { + "key": "items", + "value_type": "array", + "serialized_chars": len('[{"id":1}]'), + "sensitive": False, + }, + { + "key": "enabled", + "value_type": "boolean", + "serialized_chars": len("true"), + "sensitive": False, + }, + ], + "keys_truncated": False, + } + assert "secret-value" not in repr(result["keys"]) + assert plugin_data_value_type(None) == "null" + assert plugin_data_serialized_chars(object()) is None + + def test_remaining_application_guard_paths(monkeypatch): """应用服务的空结果、回滚和匹配参数保护应保持可观测。""" helper = MagicMock() @@ -419,13 +585,26 @@ def test_command_application_facade_and_dispatch(monkeypatch): def test_music_projection_and_domain_projection(): """音乐和豆瓣投影应保留稳定身份并裁剪大字段。""" - track = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="track", title="Track", artists=["Artist"], year=2024) + track = MusicInfo( + media_source=MediaSource.MusicBrainz, media_id="track", title="Track", artists=["Artist"], year=2024 + ) assert simplify_music_info(track)["title"] == "Track" - album = MusicAlbumInfo(media_source=MediaSource.MusicBrainz, media_id="album", title="Album", artists=["Artist"], tracks=[track] * 3, releases=[MusicRelease(media_id="release", title="Release")]) + album = MusicAlbumInfo( + media_source=MediaSource.MusicBrainz, + media_id="album", + title="Album", + artists=["Artist"], + tracks=[track] * 3, + releases=[MusicRelease(media_id="release", title="Release")], + ) assert simplify_music_album(album, track_limit=2)["tracks_truncated"] is True - artist = MusicArtistInfo(media_source=MediaSource.MusicBrainz, media_id="artist", name="Artist", raw_data={"secret": 1}) + artist = MusicArtistInfo( + media_source=MediaSource.MusicBrainz, media_id="artist", name="Artist", raw_data={"secret": 1} + ) assert simplify_music_artist(artist)["subscribable"] is False - projected = project_douban({}, {"id": "1", "title": "Movie", "subtype": "movie", "rating": {"value": 8.5}, "pic": {"large": "poster"}}) + projected = project_douban( + {}, {"id": "1", "title": "Movie", "subtype": "movie", "rating": {"value": 8.5}, "pic": {"large": "poster"}} + ) assert projected["poster_path"] == "poster" assert projected["douban_id"] == "1" assert project_douban({}, {}) == {} @@ -434,14 +613,37 @@ def test_music_projection_and_domain_projection(): def test_download_task_services_validate_and_delegate(monkeypatch): """下载任务服务应补齐历史媒体并严格校验高级修改。""" torrent = SimpleNamespace(hash="a" * 40, downloader="qb") - history = SimpleNamespace(media_source=MediaSource.TMDB, media_id="1", type="电影", title="Movie", seasons="1", episodes="2", poster="p", image="b", torrent_site="site", userid="u", username="name") + history = SimpleNamespace( + media_source=MediaSource.TMDB, + media_id="1", + type="电影", + title="Movie", + seasons="1", + episodes="2", + poster="p", + image="b", + torrent_site="site", + userid="u", + username="name", + ) list_torrents = MagicMock(return_value=[torrent]) - service = DownloadTaskService(list_torrents, lambda _hashes: {torrent.hash: history}, MagicMock(return_value=True), MagicMock(return_value=True), MagicMock(return_value=True)) + service = DownloadTaskService( + list_torrents, + lambda _hashes: {torrent.hash: history}, + MagicMock(return_value=True), + MagicMock(return_value=True), + MagicMock(return_value=True), + ) assert service.downloading()[0].media.title == "Movie" assert service.set_downloading(torrent.hash, "start") is True assert service.set_downloading(torrent.hash, "bad") is False assert service.remove_downloading(torrent.hash) is True - mutation = DownloadTaskMutationService(list_torrents=lambda **_kwargs: [torrent], set_tags=MagicMock(return_value=True), set_downloading=MagicMock(return_value=True), update_torrent=MagicMock(return_value={"limits": True, "trackers": False})) + mutation = DownloadTaskMutationService( + list_torrents=lambda **_kwargs: [torrent], + set_tags=MagicMock(return_value=True), + set_downloading=MagicMock(return_value=True), + update_torrent=MagicMock(return_value={"limits": True, "trackers": False}), + ) assert mutation.update(hash_value=torrent.hash, action="start", tags=["tag"], download_limit=1)["results"] with pytest.raises(ValueError, match="hash"): mutation.update(hash_value="bad", action="start") diff --git a/tests/test_agent_data_ports.py b/tests/test_agent_data_ports.py index 66a37e17b..51b682fdf 100644 --- a/tests/test_agent_data_ports.py +++ b/tests/test_agent_data_ports.py @@ -24,6 +24,7 @@ from app.db.session import SessionFactory from app.scheduler import Scheduler from app.schemas.rule import CustomRule from app.schemas.system import FilterRuleGroup +from app.schemas.types import SystemConfigKey from app.startup.initializers import agent as agent_initializer @@ -134,6 +135,99 @@ async def test_custom_rule_rename_commits_rule_and_group_definitions_together( assert publish.await_count == 2 +@pytest.mark.asyncio +async def test_custom_rule_reorder_preserves_latest_definitions_and_checks_expected_order( + monkeypatch, +) -> None: + """自定义规则重排只能改变顺序,并拒绝过期顺序覆盖当前列表。""" + mutation = MagicMock() + mutation.apply = AsyncMock(return_value=SimpleNamespace()) + + @asynccontextmanager + async def mutation_scope(): + """提供可观测的异步组合事务作用域。""" + yield mutation + + rules = [ + CustomRule(id="A", name="A", include="latest-a"), + CustomRule(id="B", name="B", exclude="latest-b"), + ] + groups = [FilterRuleGroup(name="group", rule_string="A > B")] + monkeypatch.setattr("app.application.filtering.get_custom_rules", lambda: rules) + monkeypatch.setattr("app.application.filtering.get_rule_groups", lambda: groups) + publish = AsyncMock() + service = FilterRuleService(cast(object, MagicMock()), mutation_scope, publish) + + result = await service.reorder_custom(["B", "A"], expected_rule_ids=["A", "B"]) + + assert result["rule_ids"] == ["B", "A"] + mutation.apply.assert_awaited_once_with( + [{"name": "group", "rule_string": "A > B"}], + expected_rule_groups=[{"name": "group", "rule_string": "A > B"}], + custom_rules=[ + {"id": "B", "name": "B", "exclude": "latest-b"}, + {"id": "A", "name": "A", "include": "latest-a"}, + ], + expected_custom_rules=[ + {"id": "A", "name": "A", "include": "latest-a"}, + {"id": "B", "name": "B", "exclude": "latest-b"}, + ], + ) + publish.assert_awaited_once_with( + SystemConfigKey.CustomFilterRules, + [ + {"id": "B", "name": "B", "exclude": "latest-b"}, + {"id": "A", "name": "A", "include": "latest-a"}, + ], + ) + + with pytest.raises(ValueError, match="顺序已被其他请求修改"): + await service.reorder_custom(["B", "A"], expected_rule_ids=["B", "A"]) + + +@pytest.mark.asyncio +async def test_rule_group_reorder_uses_atomic_scope_and_rejects_changed_collection( + monkeypatch, +) -> None: + """规则组重排必须走原子作用域,并拒绝缺项或新增项的列表。""" + mutation = MagicMock() + mutation.apply = AsyncMock(return_value=SimpleNamespace()) + + @asynccontextmanager + async def mutation_scope(): + """提供可观测的规则组异步事务作用域。""" + yield mutation + + groups = [ + FilterRuleGroup(name="first", rule_string="4K"), + FilterRuleGroup(name="second", rule_string="1080P"), + ] + monkeypatch.setattr("app.application.filtering.get_rule_groups", lambda: groups) + publish = AsyncMock() + service = FilterRuleService(cast(object, MagicMock()), mutation_scope, publish) + + result = await service.reorder_groups( + ["second", "first"], + expected_group_names=["first", "second"], + ) + + assert result["group_names"] == ["second", "first"] + mutation.apply.assert_awaited_once_with( + [ + {"name": "second", "rule_string": "1080P"}, + {"name": "first", "rule_string": "4K"}, + ], + expected_rule_groups=[ + {"name": "first", "rule_string": "4K"}, + {"name": "second", "rule_string": "1080P"}, + ], + ) + publish.assert_awaited_once() + + with pytest.raises(ValueError, match="集合已变化"): + await service.reorder_groups(["first"]) + + def test_agent_service_facade_resolves_registered_dependencies(monkeypatch) -> None: """Agent 服务门面应稳定处理未装配状态并转发组合根注入能力。""" provider_names = ( diff --git a/tests/test_agent_skills_middleware.py b/tests/test_agent_skills_middleware.py index 769a53618..69ecc8c60 100644 --- a/tests/test_agent_skills_middleware.py +++ b/tests/test_agent_skills_middleware.py @@ -138,7 +138,7 @@ async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None: assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES assert payload["truncated"] is False assert payload["truncation_message"] is None - assert len(payload["skill"]["allowed_api_operations"]) == 203 + assert len(payload["skill"]["allowed_api_operations"]) == 205 assert "### `workflow.update`" in payload["content"] diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index 2cdad8147..bff2d17c1 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -1,6 +1,6 @@ import asyncio -import io import inspect +import io from types import SimpleNamespace import pytest @@ -8,23 +8,25 @@ from fastapi import HTTPException from starlette.requests import Request from starlette.responses import Response +from app.api.deps import ( + get_current_active_manage_user, + get_current_active_manage_user_async, + get_current_active_superuser, + get_current_active_superuser_async, + get_current_active_user, + get_current_active_user_async, +) from app.api.endpoints import dashboard as dashboard_endpoint from app.api.endpoints import history as history_endpoint from app.api.endpoints import login as login_endpoint from app.api.endpoints import plugin as plugin_endpoint +from app.api.endpoints import rule as rule_endpoint from app.api.endpoints import site as site_endpoint from app.api.endpoints import storage as storage_endpoint from app.api.endpoints import system as system_endpoint from app.api.endpoints import transfer as transfer_endpoint from app.api.endpoints import user as user_endpoint from app.application.security.token import decode_access_token -from app.api.deps import ( - get_current_active_manage_user, - get_current_active_manage_user_async, - get_current_active_superuser, - get_current_active_superuser_async, - get_current_active_user_async, -) from app.schemas.types import SystemConfigKey @@ -52,6 +54,8 @@ def test_system_sensitive_read_endpoints_require_superuser(): """系统敏感读取接口必须只允许管理员访问。""" assert _dependency_of(system_endpoint.get_env_setting, "_") is get_current_active_superuser_async assert _dependency_of(system_endpoint.get_setting, "_") is get_current_active_superuser_async + assert _dependency_of(system_endpoint.query_settings, "_") is get_current_active_superuser_async + assert _dependency_of(system_endpoint.update_settings, "_") is get_current_active_superuser_async assert _dependency_of(system_endpoint.list_database_backups, "_") is get_current_active_superuser_async assert _dependency_of(system_endpoint.create_database_backup, "_") is get_current_active_superuser_async assert _dependency_of(system_endpoint.verify_database_backup, "_") is get_current_active_superuser_async @@ -62,6 +66,31 @@ def test_system_public_read_endpoints_require_active_user(): """公开读取接口只要求登录且启用的用户。""" assert _dependency_of(system_endpoint.ping, "_") is get_current_active_user_async assert _dependency_of(system_endpoint.get_public_setting, "_") is get_current_active_user_async + assert _dependency_of(storage_endpoint.storage_options, "_") is get_current_active_user + + +def test_rule_query_and_mutation_endpoints_keep_separate_permissions(): + """规则查询允许活动用户,规则定义修改仍只允许管理员。""" + read_endpoints = [ + rule_endpoint.query_builtin_rules, + rule_endpoint.query_custom_rules, + rule_endpoint.query_rule_groups, + ] + mutation_endpoints = [ + rule_endpoint.add_custom_rule, + rule_endpoint.reorder_custom_rules, + rule_endpoint.update_custom_rule, + rule_endpoint.delete_custom_rule, + rule_endpoint.add_rule_group, + rule_endpoint.reorder_rule_groups, + rule_endpoint.update_rule_group, + rule_endpoint.delete_rule_group, + ] + + for endpoint in read_endpoints: + assert _dependency_of(endpoint, "_") is get_current_active_user_async + for endpoint in mutation_endpoints: + assert _dependency_of(endpoint, "_") is get_current_active_superuser_async def test_dashboard_endpoints_require_superuser(): @@ -83,11 +112,27 @@ def test_plugin_dashboard_endpoints_require_superuser(): assert _dependency_of(plugin_endpoint.plugin_dashboard_meta, "_") is get_current_active_superuser assert _dependency_of(plugin_endpoint.plugin_dashboard_by_key, "_") is get_current_active_superuser assert _dependency_of(plugin_endpoint.plugin_dashboard, "_") is get_current_active_superuser + assert _dependency_of(plugin_endpoint.plugin_capabilities, "_") is get_current_active_superuser_async + assert _dependency_of(plugin_endpoint.plugin_data_summary, "_") is get_current_active_superuser_async + assert _dependency_of(plugin_endpoint.reload_plugin, "_") is get_current_active_superuser + + +def test_site_destructive_commands_require_superuser(): + """CookieCloud 同步和站点重置必须保持超级管理员边界。""" + assert _dependency_of(site_endpoint.cookie_cloud_sync, "_") is get_current_active_superuser_async + assert _dependency_of(site_endpoint.reset, "_") is get_current_active_superuser_async + + +def test_transfer_history_clear_requires_superuser(): + """清空全部旧整理历史必须保持超级管理员边界。""" + assert _dependency_of(history_endpoint.clear_transfer_history, "_") is get_current_active_superuser + assert _dependency_of(history_endpoint.empty_transfer_history, "_") is get_current_active_superuser def test_manage_page_endpoints_accept_manage_permission(): """管理页面接口允许具备 manage 权限的普通用户访问。""" sync_endpoints = [ + storage_endpoint.directory_settings, storage_endpoint.list_files, storage_endpoint.mkdir, storage_endpoint.delete, @@ -141,9 +186,7 @@ def test_system_public_setting_allows_only_non_sensitive_keys(monkeypatch): lambda: FakeSystemConfigOper(), ) - response = asyncio.run( - system_endpoint.get_public_setting(SystemConfigKey.Directories.value) - ) + response = asyncio.run(system_endpoint.get_public_setting(SystemConfigKey.Directories.value)) assert response.success is True assert response.data == {"value": [{"path": "/downloads"}]} @@ -152,9 +195,7 @@ def test_system_public_setting_allows_only_non_sensitive_keys(monkeypatch): response = asyncio.run(system_endpoint.get_public_setting("PLUGIN_MARKET")) assert response.success is True - assert response.data == { - "value": system_endpoint.get_runtime_settings().get("PLUGIN_MARKET") - } + assert response.data == {"value": system_endpoint.get_runtime_settings().get("PLUGIN_MARKET")} assert calls == [SystemConfigKey.Directories] with pytest.raises(HTTPException) as exc_info: @@ -276,9 +317,9 @@ def test_upload_avatar_rejects_other_user_for_non_superuser(): with pytest.raises(HTTPException) as exc_info: asyncio.run( - user_endpoint.upload_avatar( - user_id=2, - service=SimpleNamespace(), + user_endpoint.upload_avatar( + user_id=2, + service=SimpleNamespace(), file=upload_file, current_user=current_user, ) @@ -294,6 +335,7 @@ def test_upload_avatar_returns_filename_in_data(monkeypatch): fake_user = SimpleNamespace() current_user = SimpleNamespace(id=1, is_superuser=False) upload_file = SimpleNamespace(file=io.BytesIO(b"avatar"), filename="avatar.png") + class FakeService: """记录头像查询和更新的用户服务桩。""" diff --git a/tests/test_configuration_ports.py b/tests/test_configuration_ports.py index 8ac2dd84c..53b39104c 100644 --- a/tests/test_configuration_ports.py +++ b/tests/test_configuration_ports.py @@ -121,6 +121,37 @@ def test_system_config_service_forwards_atomic_increment() -> None: writer.increment.assert_called_once_with(SystemConfigKey.MediaRecognizeShareCount, 1) +def test_system_config_service_runs_atomic_mutation_through_database_executor() -> None: + """条件写入应在持久化原子回调内读取旧值并规范化最终值。""" + reader = MagicMock() + writer = MagicMock() + committed_values = [] + + def update_atomically(key, mutation): + """模拟仓储在写锁内向应用 mutation 提供当前值。""" + result, value = mutation(object(), ["old"]) + committed_values.append((key, value)) + return result + + writer.update_atomically.side_effect = update_atomically + service = SystemConfigService( + reader=reader, + writer=writer, + async_executor=_InlineDatabaseExecutor(), + value_normalizer=lambda _key, value: [*value, "normalized"], + ) + + result = asyncio.run( + service.async_update_atomically( + "demo", + lambda current: ("updated", [*current, "new"]), + ) + ) + + assert result == "updated" + assert committed_values == [("demo", ["old", "new", "normalized"])] + + def test_system_config_service_normalizes_sync_and_async_writes() -> None: """同步和异步配置写入必须共用组合根注入的值规范化边界。""" reader = MagicMock() diff --git a/tests/test_history_clear_endpoint.py b/tests/test_history_clear_endpoint.py new file mode 100644 index 000000000..310e22d17 --- /dev/null +++ b/tests/test_history_clear_endpoint.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from fastapi.routing import APIRoute + +from app.api.endpoints import history as history_endpoint + + +def _route(path: str) -> APIRoute: + """按路径返回整理历史 API 路由。""" + return next(route for route in history_endpoint.router.routes if isinstance(route, APIRoute) and route.path == path) + + +def test_transfer_history_clear_uses_delete_and_hides_legacy_get() -> None: + """新清空入口必须使用 DELETE,旧 GET 只保留为隐藏兼容。""" + current = _route("/transfer/all") + legacy = _route("/empty/transfer") + + assert current.methods == {"DELETE"} + assert current.include_in_schema is True + assert legacy.methods == {"GET"} + assert legacy.include_in_schema is False + + +def test_transfer_history_clear_delegates_to_transaction_command() -> None: + """清空入口只委托事务命令,不直接操作文件或数据库。""" + command = Mock() + command.truncate.return_value = SimpleNamespace( + success=True, + message="已清空旧整理记录,失败任务记录已保留", + ) + + response = history_endpoint.clear_transfer_history( + command=command, + _=object(), + ) + + command.truncate.assert_called_once_with() + assert response.success is True + assert response.message == "已清空旧整理记录,失败任务记录已保留" diff --git a/tests/test_history_mutation_command.py b/tests/test_history_mutation_command.py index 73383e8ab..be8fe6682 100644 --- a/tests/test_history_mutation_command.py +++ b/tests/test_history_mutation_command.py @@ -88,9 +88,7 @@ def test_transfer_delete_commits_before_event_and_retry_cleanup(): calls = [] command, dependencies = _transfer_command(history=_history()) dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit") - dependencies["publish_download_file_deleted"].side_effect = ( - lambda _payload: calls.append("event") - ) + dependencies["publish_download_file_deleted"].side_effect = lambda _payload: calls.append("event") dependencies["clear_failures"].side_effect = lambda *_args: calls.append("clear") result = command.delete( @@ -102,9 +100,7 @@ def test_transfer_delete_commits_before_event_and_retry_cleanup(): assert result.success is True assert result.source.status == "deleted" assert result.destination.status == "deleted" - dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with( - "/downloads/demo.mkv" - ) + dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with("/downloads/demo.mkv") dependencies["repository"].stage_delete.assert_called_once_with(7) assert calls == ["commit", "event", "clear"] @@ -136,18 +132,29 @@ def test_transfer_truncate_uses_single_transaction(): dependencies["unit_of_work"].commit.assert_called_once_with() +def test_transfer_truncate_rolls_back_commit_failure(): + """清空旧整理历史提交失败时必须回滚请求级事务。""" + command, dependencies = _transfer_command( + commit_error=RuntimeError("commit failed"), + ) + + with pytest.raises(RuntimeError, match="commit failed"): + command.truncate() + + dependencies["repository"].stage_truncate.assert_called_once_with() + dependencies["unit_of_work"].rollback.assert_called_once_with() + + def test_transfer_delete_rejects_nonfailed_durable_receipt_before_file_side_effects(): """非 FAILED durable 回执不能被历史 API 连同文件一起删除。""" history = _history() history.transfer_task_id = "task-durable" history.transfer_settlement_revision = 2 command, dependencies = _transfer_command(history=history) - dependencies["transfer_execution_repository"].discard_failed.return_value = ( - TransferFailureDiscardResult( - discarded=False, - state=TransferExecutionState.MANUAL_REVIEW, - message="这条整理任务需要先完成人工确认,再重试", - ) + dependencies["transfer_execution_repository"].discard_failed.return_value = TransferFailureDiscardResult( + discarded=False, + state=TransferExecutionState.MANUAL_REVIEW, + message="这条整理任务需要先完成人工确认,再重试", ) result = command.delete(7, delete_source=True, delete_destination=True) @@ -166,12 +173,10 @@ def test_transfer_delete_discards_failed_durable_receipt_before_cleanup(): history.transfer_task_id = "task-durable" history.transfer_settlement_revision = 3 command, dependencies = _transfer_command(history=history) - dependencies["transfer_execution_repository"].discard_failed.return_value = ( - TransferFailureDiscardResult( - discarded=True, - state=TransferExecutionState.FAILED, - message="已放弃这条失败的整理任务", - ) + dependencies["transfer_execution_repository"].discard_failed.return_value = TransferFailureDiscardResult( + discarded=True, + state=TransferExecutionState.FAILED, + message="已放弃这条失败的整理任务", ) result = command.delete(7, delete_destination=True) @@ -228,9 +233,7 @@ def test_transfer_delete_commits_completed_source_when_destination_fails(): assert result.source.status == "deleted" assert result.history == "retained" repository.stage_delete.assert_not_called() - dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with( - "/downloads/demo.mkv" - ) + dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with("/downloads/demo.mkv") dependencies["unit_of_work"].commit.assert_called_once_with() dependencies["publish_download_file_deleted"].assert_called_once() dependencies["clear_failures"].assert_not_called() @@ -244,9 +247,7 @@ def test_transfer_delete_treats_missing_requested_file_as_completed(): command = TransferHistoryMutationCommand( repository=dependencies["repository"], download_repository=dependencies["download_repository"], - transfer_execution_repository=dependencies[ - "transfer_execution_repository" - ], + transfer_execution_repository=dependencies["transfer_execution_repository"], unit_of_work=dependencies["unit_of_work"], file_item_factory=dependencies["file_item_factory"], file_exists=dependencies["file_exists"], diff --git a/tests/test_mediaserver_clients_endpoint.py b/tests/test_mediaserver_clients_endpoint.py new file mode 100644 index 000000000..59fdea0e1 --- /dev/null +++ b/tests/test_mediaserver_clients_endpoint.py @@ -0,0 +1,43 @@ +"""媒体服务器客户端安全投影接口测试。""" + +import asyncio + +from app.api.endpoints import mediaserver as mediaserver_endpoint +from app.schemas.types import SystemConfigKey + + +def test_media_server_clients_only_projects_enabled_names_and_types(monkeypatch) -> None: + """客户端列表不得返回地址、令牌或其他完整连接配置。""" + calls = [] + + class FakeSystemConfig: + """返回包含敏感字段的媒体服务器配置。""" + + def get(self, key): + """记录读取键并返回测试配置。""" + calls.append(key) + return [ + { + "name": "家庭 Emby", + "type": "emby", + "enabled": True, + "config": {"host": "https://example.invalid", "token": "secret"}, + }, + { + "name": "停用 Plex", + "type": "plex", + "enabled": False, + "config": {"token": "disabled-secret"}, + }, + ] + + monkeypatch.setattr( + mediaserver_endpoint, + "get_configured_system_config", + lambda: FakeSystemConfig(), + ) + + result = asyncio.run(mediaserver_endpoint.clients(_=object())) + + assert result == [{"name": "家庭 Emby", "type": "emby"}] + assert calls == [SystemConfigKey.MediaServers] diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 6414223e6..5bdd6f1c5 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -10,6 +10,8 @@ from starlette.responses import Response from app import schemas from app.api.endpoints import plugin as plugin_endpoint from app.api.endpoints.plugin import ( + plugin_capabilities, + plugin_data_summary, plugin_history, plugin_releases, plugin_static_file, @@ -81,9 +83,7 @@ def _plugin_identity( ) -def _catalog_query( - plugin_manager: MagicMock, persistence: MagicMock -) -> PluginCatalogQuery: +def _catalog_query(plugin_manager: MagicMock, persistence: MagicMock) -> PluginCatalogQuery: """用运行态和身份端口替身构造插件目录查询。""" return PluginCatalogQuery( installed_plugins=plugin_manager.get_installed_plugins, @@ -220,19 +220,13 @@ def test_market_endpoint_reads_source_preserving_candidates_for_bound_update(): plugin_manager.get_installed_plugins.return_value = [installed] plugin_manager.get_local_plugins.return_value = [] plugin_manager.get_local_repo_plugins.return_value = [] - plugin_manager.async_get_online_plugin_candidates = AsyncMock( - return_value=[bound_update, alternative_update] - ) - plugin_manager.process_plugins_list.side_effect = ( - lambda higher, base: [ - max( - higher + base, - key=lambda plugin: tuple( - int(part) for part in plugin.plugin_version.split(".") - ), - ) - ] - ) + plugin_manager.async_get_online_plugin_candidates = AsyncMock(return_value=[bound_update, alternative_update]) + plugin_manager.process_plugins_list.side_effect = lambda higher, base: [ + max( + higher + base, + key=lambda plugin: tuple(int(part) for part in plugin.plugin_version.split(".")), + ) + ] persistence = MagicMock() persistence.list_identities = AsyncMock(return_value=[_plugin_identity()]) @@ -264,10 +258,7 @@ def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None: """插件列表显式 page/count 应分页,并优先于显式 max_results 限量。""" catalog = MagicMock() catalog.query = AsyncMock( - return_value=[ - schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0") - for index in range(1, 4) - ] + return_value=[schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0") for index in range(1, 4)] ) with patch( @@ -295,10 +286,7 @@ def test_all_plugins_without_pagination_or_limit_returns_complete_catalog() -> N """插件列表省略分页和限量参数时应返回完整目录。""" catalog = MagicMock() catalog.query = AsyncMock( - return_value=[ - schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0") - for index in range(1, 52) - ] + return_value=[schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0") for index in range(1, 52)] ) with patch( @@ -404,9 +392,7 @@ def test_plugin_history_merges_remote_metadata(): assert result.history == {"v1.1.0": "- 新增更新说明"} assert result.system_version == ">=2.0.0" assert result.has_update - plugin_manager.async_get_plugins_from_market.assert_awaited_once_with( - SOURCE_URL, settings.VERSION_FLAG, True - ) + plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(SOURCE_URL, settings.VERSION_FLAG, True) def test_plugin_history_falls_back_to_backward_compatible_package(): @@ -426,9 +412,7 @@ def test_plugin_history_falls_back_to_backward_compatible_package(): plugin_manager = MagicMock() plugin_manager.get_installed_plugins.return_value = [installed_plugin] plugin_manager.get_local_repo_plugins.return_value = [] - plugin_manager.async_get_plugins_from_market = AsyncMock( - side_effect=[[], [market_plugin]] - ) + plugin_manager.async_get_plugins_from_market = AsyncMock(side_effect=[[], [market_plugin]]) persistence = _persistence(_plugin_identity()) release_service = _release_service(plugin_manager, persistence=persistence) @@ -480,18 +464,135 @@ def test_runtime_status_reports_pending_and_terminal_counts(): def test_reload_endpoint_reports_load_failure(monkeypatch): - """插件重载失败时接口返回失败,同时仍刷新旧注册投影。""" - plugin_manager = MagicMock() - plugin_manager.reload_plugin.return_value = PluginRuntimeStatus.LOAD_FAILED - register = MagicMock() - monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager) - monkeypatch.setattr(plugin_endpoint, "register_plugin", register) + """插件重载失败时接口返回失败,并委托应用服务维护注册一致性。""" + reload_runtime = MagicMock(return_value=PluginRuntimeStatus.LOAD_FAILED) + monkeypatch.setattr(plugin_endpoint, "reload_plugin_runtime", reload_runtime) result = reload_plugin("DemoPlugin", None) assert result.success is False assert result.message == "插件加载失败,请查看插件日志" - register.assert_called_once_with("DemoPlugin") + reload_runtime.assert_called_once_with("DemoPlugin") + + +def test_reload_endpoint_uses_post_for_process_side_effect(): + """插件进程内重载只允许通过 POST 暴露。""" + reload_routes = [ + route for route in plugin_endpoint.router.routes if getattr(route, "path", None) == "/reload/{plugin_id}" + ] + + assert len(reload_routes) == 1 + assert reload_routes[0].methods == {"POST"} + + +def test_plugin_capabilities_return_only_safe_runtime_metadata(monkeypatch): + """插件能力接口不得暴露插件自由定义的数据、函数或定时参数。""" + plugin_manager = MagicMock() + plugin_manager.get_plugin_commands.return_value = [ + { + "cmd": "/demo", + "desc": "执行演示命令", + "pid": "DemoPlugin", + "data": {"token": "secret"}, + "event": object(), + }, + {"desc": "缺少命令标识"}, + ] + plugin_manager.get_plugin_actions.return_value = [ + { + "plugin_id": "DemoPlugin", + "plugin_name": "演示插件", + "actions": [ + { + "id": "refresh", + "name": "刷新数据", + "kwargs": {"token": "secret"}, + "func": object(), + }, + {"name": "缺少动作标识"}, + ], + } + ] + plugin_manager.get_plugin_services.return_value = [ + { + "id": "demo-service", + "name": "演示任务", + "trigger": "cron[hour='1']", + "kwargs": {"token": "secret"}, + "func_kwargs": {"path": "/private"}, + "func": object(), + }, + {"name": "缺少服务标识"}, + ] + monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager) + + result = asyncio.run(plugin_capabilities("DemoPlugin", None)) + + assert result.success is True + assert result.data is not None + assert result.data.model_dump() == { + "commands": [ + { + "cmd": "/demo", + "desc": "执行演示命令", + "plugin_id": "DemoPlugin", + } + ], + "actions": [ + { + "plugin_id": "DemoPlugin", + "plugin_name": "演示插件", + "actions": [{"id": "refresh", "name": "刷新数据"}], + } + ], + "services": [ + { + "id": "demo-service", + "name": "演示任务", + "trigger": "cron[hour='1']", + } + ], + } + plugin_manager.get_plugin_commands.assert_called_once_with(pid="DemoPlugin") + plugin_manager.get_plugin_actions.assert_called_once_with(pid="DemoPlugin") + plugin_manager.get_plugin_services.assert_called_once_with(pid="DemoPlugin") + + +def test_plugin_data_summary_endpoint_never_returns_persisted_values(monkeypatch): + """插件数据摘要接口只返回键级诊断元数据。""" + repository = MagicMock() + repository.list = AsyncMock( + return_value={ + "api_token": "secret-token", + "history": [{"id": 1}], + } + ) + runtime = SimpleNamespace(agent=SimpleNamespace(plugin_data=repository)) + monkeypatch.setattr( + plugin_endpoint, + "get_plugin_snapshot", + lambda _plugin_id: { + "plugin_id": "DemoPlugin", + "plugin_name": "演示插件", + "plugin_version": "1.0.0", + "state": True, + }, + ) + + result = asyncio.run(plugin_data_summary("DemoPlugin", None, runtime)) + + assert result.success is True + assert result.data is not None + payload = result.data.model_dump() + assert payload["count"] == 2 + assert payload["keys"][0] == { + "key": "api_token", + "value_type": "string", + "serialized_chars": len('"secret-token"'), + "sensitive": True, + } + assert "secret-token" not in repr(payload) + repository.list.assert_awaited_once_with("DemoPlugin") def test_plugin_history_returns_installed_plugin_when_remote_missing(): @@ -552,9 +653,7 @@ def test_plugin_history_uses_bound_repo_without_refreshing_all_markets(): result = asyncio.run(plugin_history("DemoPlugin", None, True)) assert result.history == {"v1.1.0": "- 新增更新说明"} - plugin_manager.async_get_plugins_from_market.assert_awaited_once_with( - SOURCE_URL, settings.VERSION_FLAG, True - ) + plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(SOURCE_URL, settings.VERSION_FLAG, True) plugin_manager.async_get_online_plugins.assert_not_awaited() @@ -611,13 +710,13 @@ def test_plugin_releases_returns_supported_versions_with_latest_and_current(monk plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) plugin_manager.get_local_plugin_version.return_value = "1.2.0" plugin_helper = MagicMock() - plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[ - {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, - {"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0", "asset_name": "demoplugin_v1.2.0.zip"}, - ]) - release_service = _release_service( - plugin_manager, plugin_helper=plugin_helper + plugin_helper.async_get_plugin_release_versions = AsyncMock( + return_value=[ + {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, + {"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0", "asset_name": "demoplugin_v1.2.0.zip"}, + ] ) + release_service = _release_service(plugin_manager, plugin_helper=plugin_helper) with patch( "app.api.endpoints.plugin.get_plugin_release_service", @@ -658,9 +757,7 @@ def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch): plugin_helper = MagicMock() plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=release_items) - release_service = _release_service( - plugin_manager, plugin_helper=plugin_helper - ) + release_service = _release_service(plugin_manager, plugin_helper=plugin_helper) with patch( "app.api.endpoints.plugin.get_plugin_release_service", @@ -684,24 +781,18 @@ def test_plugin_releases_falls_back_to_compatible_base_package(monkeypatch): release=True, ) plugin_manager = MagicMock() - plugin_manager.async_get_plugins_from_market = AsyncMock( - side_effect=[[], [], [market_plugin]] - ) + plugin_manager.async_get_plugins_from_market = AsyncMock(side_effect=[[], [], [market_plugin]]) plugin_manager.get_local_plugin_version.return_value = None plugin_helper = MagicMock() plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[]) - release_service = _release_service( - plugin_manager, plugin_helper=plugin_helper - ) + release_service = _release_service(plugin_manager, plugin_helper=plugin_helper) with patch( "app.api.endpoints.plugin.get_plugin_release_service", return_value=release_service, ): - result = asyncio.run( - plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False) - ) + result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False)) assert result["latest_version"] == "1.2.3" assert plugin_manager.async_get_plugins_from_market.await_args_list == [ @@ -727,9 +818,7 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch): plugin_helper = MagicMock() plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[]) - release_service = _release_service( - plugin_manager, plugin_helper=plugin_helper - ) + release_service = _release_service(plugin_manager, plugin_helper=plugin_helper) with patch( "app.api.endpoints.plugin.get_plugin_release_service", @@ -777,9 +866,7 @@ def test_plugin_releases_force_uses_cached_release_response_and_schedules_refres ] plugin_helper.async_get_plugin_release_versions = fake_releases - release_service = _release_service( - plugin_manager, plugin_helper=plugin_helper - ) + release_service = _release_service(plugin_manager, plugin_helper=plugin_helper) scheduled = [] def fake_schedule(plugin_id, repo_url, task_registry): @@ -802,9 +889,7 @@ def test_plugin_releases_force_uses_cached_release_response_and_schedules_refres "https://github.com/demo/plugins", ) assert isinstance(scheduled[0][2], TaskRegistry) - plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with( - "https://github.com/demo/plugins" - ) + plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with("https://github.com/demo/plugins") plugin_manager.async_get_plugins_from_market.assert_awaited_once_with( "https://github.com/demo/plugins", settings.VERSION_FLAG, True ) @@ -825,16 +910,16 @@ def test_plugin_releases_force_skips_background_refresh_without_release_cache(mo plugin_manager.get_local_plugin_version.return_value = None plugin_helper = MagicMock() plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) - plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[ - { - "version": "1.2.3", - "tag_name": "DemoPlugin_v1.2.3", - "asset_name": "demoplugin_v1.2.3.zip", - } - ]) - release_service = _release_service( - plugin_manager, plugin_helper=plugin_helper + plugin_helper.async_get_plugin_release_versions = AsyncMock( + return_value=[ + { + "version": "1.2.3", + "tag_name": "DemoPlugin_v1.2.3", + "asset_name": "demoplugin_v1.2.3.zip", + } + ] ) + release_service = _release_service(plugin_manager, plugin_helper=plugin_helper) scheduled = [] def fake_schedule(plugin_id, repo_url): @@ -851,9 +936,7 @@ def test_plugin_releases_force_skips_background_refresh_without_release_cache(mo assert result["release_supported"] is True assert scheduled == [] - plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with( - "https://github.com/demo/plugins" - ) + plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with("https://github.com/demo/plugins") def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch): @@ -871,12 +954,12 @@ def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release( plugin_manager.get_local_plugin_version.return_value = None plugin_helper = MagicMock() plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) - plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[ - {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, - ]) - release_service = _release_service( - plugin_manager, plugin_helper=plugin_helper + plugin_helper.async_get_plugin_release_versions = AsyncMock( + return_value=[ + {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, + ] ) + release_service = _release_service(plugin_manager, plugin_helper=plugin_helper) with patch( "app.api.endpoints.plugin.get_plugin_release_service", @@ -973,15 +1056,17 @@ def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data(): def publish_reset(plugin_id): """记录重置前事件,验证应用用例保留补偿时序。""" - calls.append(( - "event", - ChainEventType.PluginDataReset, - PluginDataResetEventData( - plugin_id=plugin_id, - reset_config=True, - reset_data=True, - ), - )) + calls.append( + ( + "event", + ChainEventType.PluginDataReset, + PluginDataResetEventData( + plugin_id=plugin_id, + reset_config=True, + reset_data=True, + ), + ) + ) command = PluginConfigCommand( save_config=plugin_manager.save_plugin_config, @@ -1042,9 +1127,7 @@ def test_virtual_instance_static_file_reads_from_source_directory(tmp_path, monk lambda: MagicMock(root_path=tmp_path), ) - response = asyncio.run( - plugin_static_file("DemoPluginwork", "dist/remoteEntry.js", None) - ) + response = asyncio.run(plugin_static_file("DemoPluginwork", "dist/remoteEntry.js", None)) async def read_body() -> bytes: """读取流式响应的全部测试内容。""" @@ -1182,15 +1265,86 @@ def test_sealed_http_folder_update_rejects_before_config_access(monkeypatch): config_provider, ) - result = asyncio.run( - plugin_endpoint.update_folder_plugins("常用", ["DemoPlugin"], None) - ) + result = asyncio.run(plugin_endpoint.update_folder_plugins("常用", ["DemoPlugin"], None)) assert result.success is False assert "停机阶段" in result.message config_provider.assert_not_called() +def test_plugin_folder_incremental_endpoints_delegate_structured_payloads(monkeypatch): + """插件文件夹增量 HTTP 入口应保留字段存在性并委托单目标用例。""" + service = MagicMock() + service.update_folder = AsyncMock(return_value=SimpleNamespace(success=True, message="")) + service.update_plugins = AsyncMock(return_value=SimpleNamespace(success=True, message="")) + service.assign_plugin = AsyncMock(return_value=SimpleNamespace(success=True, message="")) + service.remove_plugin_from_folder = AsyncMock(return_value=SimpleNamespace(success=True, message="")) + monkeypatch.setattr(plugin_endpoint, "get_plugin_folder_service", lambda: service) + + patch_result = asyncio.run( + plugin_endpoint.update_plugin_folder( + "常用", + schemas.PluginFolderUpdateRequest( + new_name="工具", + color="#ff0000", + showIcon=False, + ), + None, + ) + ) + members_result = asyncio.run( + plugin_endpoint.update_folder_plugins( + "工具", + schemas.PluginFolderPluginsUpdateRequest( + plugins=["DemoPlugin"], + expected_plugins=[], + ), + None, + ) + ) + assign_result = asyncio.run(plugin_endpoint.assign_plugin_to_folder("工具", "DemoPlugin", None)) + remove_result = asyncio.run(plugin_endpoint.remove_plugin_from_folder("工具", "DemoPlugin", None)) + + assert all(result.success for result in (patch_result, members_result, assign_result, remove_result)) + service.update_folder.assert_awaited_once_with( + "常用", + new_name="工具", + changes={"color": "#ff0000", "showIcon": False}, + ) + service.update_plugins.assert_awaited_once_with( + "工具", + ["DemoPlugin"], + [], + ) + service.assign_plugin.assert_awaited_once_with("工具", "DemoPlugin") + service.remove_plugin_from_folder.assert_awaited_once_with( + "工具", + "DemoPlugin", + ) + + +def test_plugin_folder_member_endpoint_keeps_legacy_array_request(monkeypatch): + """旧客户端发送裸插件数组时仍应按无条件替换语义工作。""" + service = MagicMock() + service.update_plugins = AsyncMock(return_value=SimpleNamespace(success=True, message="")) + monkeypatch.setattr(plugin_endpoint, "get_plugin_folder_service", lambda: service) + + result = asyncio.run( + plugin_endpoint.update_folder_plugins( + "常用", + ["DemoPlugin"], + None, + ) + ) + + assert result.success is True + service.update_plugins.assert_awaited_once_with( + "常用", + ["DemoPlugin"], + None, + ) + + def test_delete_plugin_data_can_force_delete_after_plugin_is_stopped(): """ 重置入口会先停止插件;插件数据删除不能依赖运行态注册仍存在。 diff --git a/tests/test_plugin_folders.py b/tests/test_plugin_folders.py index 8adc88f5a..cafc968d1 100644 --- a/tests/test_plugin_folders.py +++ b/tests/test_plugin_folders.py @@ -150,6 +150,94 @@ def test_folder_mutations_report_duplicate_and_missing_names(): write.assert_not_awaited() +def test_incremental_folder_updates_preserve_metadata_and_use_latest_snapshot(): + """增量更新应在原子端口提供的最新快照上保留展示字段和其他文件夹。""" + state = { + "folders": { + "常用": {"plugins": ["DemoPlugin"], "color": "#00ff00", "order": 2}, + "稍后": ["OtherPlugin"], + } + } + + async def update(change): + """模拟配置原子端口发布 mutation 返回的新快照。""" + result, value = change(state["folders"]) + state["folders"] = value + return result + + service = folders.PluginFolderService( + read=lambda: state["folders"], + write=AsyncMock(), + write_sync=MagicMock(), + mutation=lambda _operation: nullcontext(), + update=update, + ) + + appearance = asyncio.run( + service.update_folder("常用", changes={"icon": "mdi-folder-star"}) + ) + members = asyncio.run( + service.update_plugins( + "常用", + ["DemoPlugin", "ThirdPlugin"], + ["DemoPlugin"], + ) + ) + moved = asyncio.run(service.assign_plugin("稍后", "DemoPlugin")) + removed = asyncio.run(service.remove_plugin_from_folder("稍后", "OtherPlugin")) + renamed = asyncio.run(service.update_folder("常用", new_name="工具")) + + assert all(result.success for result in (appearance, members, moved, removed, renamed)) + assert list(state["folders"]) == ["工具", "稍后"] + assert state["folders"]["工具"] == { + "plugins": ["ThirdPlugin"], + "color": "#00ff00", + "icon": "mdi-folder-star", + "order": 2, + } + assert state["folders"]["稍后"] == ["DemoPlugin"] + + +def test_folder_member_replacement_rejects_stale_snapshot_without_losing_config(): + """成员顺序条件不匹配时应保留当前成员和文件夹展示配置。""" + state = { + "folders": { + "常用": {"plugins": ["CurrentPlugin"], "color": "#00ff00"}, + } + } + + async def update(change): + """模拟即使业务拒绝也发布同值的底层原子配置端口。""" + result, value = change(state["folders"]) + state["folders"] = value + return result + + service = folders.PluginFolderService( + read=lambda: state["folders"], + write=AsyncMock(), + write_sync=MagicMock(), + mutation=lambda _operation: nullcontext(), + update=update, + ) + + result = asyncio.run( + service.update_plugins( + "常用", + ["ReplacementPlugin"], + ["StalePlugin"], + ) + ) + + assert result == folders.PluginFolderResult( + False, + "插件文件夹已被其他请求修改,请重新读取后再试", + ) + assert state["folders"]["常用"] == { + "plugins": ["CurrentPlugin"], + "color": "#00ff00", + } + + def test_folder_mutation_rejection_is_returned_without_accessing_storage(): """运行时封口时应直接返回拒绝结果,不得继续访问配置存储。""" read = MagicMock() diff --git a/tests/test_site_command_routes.py b/tests/test_site_command_routes.py new file mode 100644 index 000000000..375ae7d44 --- /dev/null +++ b/tests/test_site_command_routes.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI + +from app.api.endpoints import site + + +def test_site_commands_publish_post_and_hide_legacy_get() -> None: + """站点副作用命令只在 OpenAPI 中发布 POST,旧 GET 仅保留运行时兼容。""" + app = FastAPI() + app.include_router(site.router, prefix="/api/v1/site") + + paths = app.openapi()["paths"] + + assert set(paths["/api/v1/site/cookiecloud"]) == {"post"} + assert set(paths["/api/v1/site/reset"]) == {"post"} diff --git a/tests/test_storage_directories_endpoint.py b/tests/test_storage_directories_endpoint.py index d296880f9..6151b8c8c 100644 --- a/tests/test_storage_directories_endpoint.py +++ b/tests/test_storage_directories_endpoint.py @@ -1,17 +1,21 @@ -"""存储目录查询接口的稳定分类引用投影测试。""" +"""存储目录与存储选项查询接口测试。""" from unittest.mock import patch -from app.api.endpoints.storage import directory_settings -from app.schemas.system import TransferDirectoryConf +from app.api.endpoints.storage import directory_settings, storage_options +from app.schemas.system import StorageConf, TransferDirectoryConf -def test_directory_settings_projects_category_id_and_path_snapshot() -> None: - """目录设置查询应同时返回稳定分类 ID 和兼容路径快照。""" +def test_directory_settings_projects_complete_selection_contract() -> None: + """目录设置查询应返回分类引用和所有路径分层开关。""" directory = TransferDirectoryConf( name="日番库", + download_type_folder=True, + download_category_folder=True, library_path="/library/anime", library_storage="local", + library_type_folder=True, + library_category_folder=True, media_type="电视剧", media_category_id="tv.anime.jp", media_category="动漫/日番", @@ -26,6 +30,29 @@ def test_directory_settings_projects_category_id_and_path_snapshot() -> None: assert response.success is True assert response.data[0]["media_category_id"] == "tv.anime.jp" assert response.data[0]["media_category"] == "动漫/日番" + assert response.data[0]["download_type_folder"] is True + assert response.data[0]["download_category_folder"] is True + assert response.data[0]["library_type_folder"] is True + assert response.data[0]["library_category_folder"] is True + + +def test_storage_options_excludes_connection_configuration() -> None: + """存储选项只能投影显示名称和类型,不得返回连接配置。""" + storages = [ + StorageConf(type="local", name="本地", config={"path": "/secret"}), + StorageConf(type="rclone", name="网盘", config={"password": "secret"}), + ] + + with patch( + "app.api.endpoints.storage.StorageHelper.get_storagies", + return_value=storages, + ): + response = storage_options(_=object()) + + assert [item.model_dump() for item in response] == [ + {"name": "本地", "type": "local"}, + {"name": "网盘", "type": "rclone"}, + ] def test_transfer_directory_round_trip_keeps_stable_reference_snapshot() -> None: diff --git a/tests/test_subscribe_command_routes.py b/tests/test_subscribe_command_routes.py new file mode 100644 index 000000000..6c5bc5fb8 --- /dev/null +++ b/tests/test_subscribe_command_routes.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI + +from app.api.endpoints import subscribe + + +def test_subscription_commands_publish_post_and_hide_legacy_get() -> None: + """订阅副作用命令只在 OpenAPI 中发布 POST,旧 GET 仅保留运行时兼容。""" + app = FastAPI() + app.include_router(subscribe.router, prefix="/api/v1/subscribe") + + paths = app.openapi()["paths"] + + assert set(paths["/api/v1/subscribe/refresh"]) == {"post"} + assert set(paths["/api/v1/subscribe/reset/{subid}"]) == {"post"} + assert set(paths["/api/v1/subscribe/check"]) == {"post"} + assert set(paths["/api/v1/subscribe/search"]) == {"post"} + assert set(paths["/api/v1/subscribe/search/{subscribe_id}"]) == {"post"} diff --git a/tests/test_system_identifiers_api.py b/tests/test_system_identifiers_api.py new file mode 100644 index 000000000..ee7e13e8f --- /dev/null +++ b/tests/test_system_identifiers_api.py @@ -0,0 +1,100 @@ +"""自定义识别词专用 API 合同测试。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +import app.api.endpoints.system as system_endpoint +from app.application.settings import SystemSettingConflictError +from app.schemas.system import CustomIdentifiersUpdateRequest +from app.schemas.types import SystemConfigKey + + +class _RecordingSettingsService: + """记录专用接口传入设置服务的条件替换参数。""" + + calls: list[dict] = [] + error: Exception | None = None + + def __init__(self, *_args) -> None: + """兼容真实设置服务的构造参数。""" + + async def update(self, **kwargs): + """记录调用,并按测试需要返回或抛出结果。""" + self.calls.append(kwargs) + if self.error is not None: + raise self.error + return {"message": "updated", "changed": True} + + +@pytest.fixture(autouse=True) +def reset_recording_service() -> None: + """隔离每个接口测试的记录和异常状态。""" + _RecordingSettingsService.calls = [] + _RecordingSettingsService.error = None + + +@pytest.mark.asyncio +async def test_query_identifiers_returns_only_public_string_rules(monkeypatch) -> None: + """查询接口应过滤历史坏项,使返回值与字符串请求合同一致。""" + config = MagicMock() + config.get.return_value = ["A", None, 7, "B"] + monkeypatch.setattr(system_endpoint, "get_configured_system_config", lambda: config) + + response = await system_endpoint.query_custom_identifiers(_=object()) + + assert response.data == {"count": 2, "identifiers": ["A", "B"]} + + +@pytest.mark.asyncio +async def test_update_identifiers_forwards_expected_snapshot(monkeypatch) -> None: + """专用写接口应把前端基线作为原子条件传给设置服务。""" + monkeypatch.setattr(system_endpoint, "SystemSettingsService", _RecordingSettingsService) + monkeypatch.setattr(system_endpoint, "get_runtime_settings", MagicMock()) + monkeypatch.setattr(system_endpoint, "get_configured_system_config", MagicMock()) + runtime = SimpleNamespace(system=SimpleNamespace(publish_config_changed=AsyncMock())) + + response = await system_endpoint.update_custom_identifiers( + payload=CustomIdentifiersUpdateRequest( + identifiers=["A", "B"], + expected_identifiers=["A"], + ), + _=object(), + runtime=runtime, + ) + + assert response.success is True + assert response.data["identifiers"] == ["A", "B"] + assert _RecordingSettingsService.calls == [ + { + "setting_key": SystemConfigKey.CustomIdentifiers.value, + "value": ["A", "B"], + "expected_value": ["A"], + "enforce_expected_value": True, + } + ] + + +@pytest.mark.asyncio +async def test_update_identifiers_maps_stale_snapshot_to_http_409(monkeypatch) -> None: + """过期识别词基线必须返回冲突,不能伪装为保存成功。""" + _RecordingSettingsService.error = SystemSettingConflictError("配置已被其他会话更新") + monkeypatch.setattr(system_endpoint, "SystemSettingsService", _RecordingSettingsService) + monkeypatch.setattr(system_endpoint, "get_runtime_settings", MagicMock()) + monkeypatch.setattr(system_endpoint, "get_configured_system_config", MagicMock()) + runtime = SimpleNamespace(system=SimpleNamespace(publish_config_changed=AsyncMock())) + + with pytest.raises(HTTPException) as error: + await system_endpoint.update_custom_identifiers( + payload=CustomIdentifiersUpdateRequest( + identifiers=["mine"], + expected_identifiers=["stale"], + ), + _=object(), + runtime=runtime, + ) + + assert error.value.status_code == 409 + assert error.value.detail == "配置已被其他会话更新"