feat(api): expose frontend-safe management capabilities

This commit is contained in:
jxxghp
2026-09-05 15:11:02 +08:00
parent d07564e3cb
commit f00853de2a
40 changed files with 2477 additions and 465 deletions
+23 -11
View File
@@ -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}",
),
}
+6 -1
View File
@@ -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.",
+264 -11
View File
@@ -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,18 +8273,101 @@
"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": {
"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.",
"anyOf": [
{
"items": {
"type": "string"
},
"title": "Plugin Ids",
"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.",
"title": "Plugin Update"
},
"operation_id": {
"const": "plugin.folder.plugins.update",
"description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
@@ -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",
+35 -34
View File
@@ -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)
+168 -58
View File
@@ -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
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")
]
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 {}
return _SchemaResponse(
success=True,
data=_SchemaPluginRuntimeCapabilities(
commands=commands,
actions=action_groups,
services=services,
),
}
for service in services
]
return _SchemaResponse(success=True, data=data)
)
@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:
"""
+48
View File
@@ -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="更新过滤规则组",
+24 -2
View File
@@ -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),
+21 -1
View File
@@ -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:
"""
+42 -5
View File
@@ -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,
+17 -5
View File
@@ -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,8 +729,9 @@ 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]
"""完整替换自定义识别词,并可拒绝基于过期快照的覆盖"""
identifiers = list(payload.identifiers)
try:
data = await SystemSettingsService(
get_runtime_settings(),
get_configured_system_config(),
@@ -734,7 +739,14 @@ async def update_custom_identifiers(
).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)
+31 -1
View File
@@ -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:
+88
View File
@@ -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}顺序已被其他请求修改,请重新读取后再试")
+75
View File
@@ -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,
}
+160 -26
View File
@@ -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()
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}' 已存在")
return PluginFolderResult(False, f"文件夹 '{folder_name}' 已存在"), folders
folders[folder_name] = []
await self._write(folders)
return PluginFolderResult(True, f"文件夹 '{folder_name}' 创建成功")
except PluginMutationRejectedError as error:
return PluginFolderResult(False, str(error))
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()
def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]:
"""只从最新快照移除目标文件夹。"""
if folder_name not in folders:
return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在")
return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders
del folders[folder_name]
await self._write(folders)
return PluginFolderResult(True, f"文件夹 '{folder_name}' 删除成功")
except PluginMutationRejectedError as error:
return PluginFolderResult(False, str(error))
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)
+62 -3
View File
@@ -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,13 +621,19 @@ 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):
message = ""
if spec.source == "settings":
previous_value = self._load(spec)
next_value = self._prepare_next_value(
spec,
@@ -625,14 +644,54 @@ class SystemSettingsService:
match_field,
match_value,
)
message = ""
event_value = next_value
if spec.source == "settings":
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(
+13 -1
View File
@@ -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'],
+121 -21
View File
@@ -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):
"""插件仪表板入口摘要。"""
+14
View File
@@ -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
+7
View File
@@ -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="存储类型标识")
+10 -1
View File
@@ -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"]
+118 -28
View File
@@ -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"
],
+22 -15
View File
@@ -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 | 查询订阅分享统计 |
+6 -7
View File
@@ -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
+4 -4
View File
@@ -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.
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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",
+220 -18
View File
@@ -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")
+94
View File
@@ -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 = (
+1 -1
View File
@@ -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"]
+56 -14
View File
@@ -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:
@@ -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:
"""记录头像查询和更新的用户服务桩。"""
+31
View File
@@ -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()
+40
View File
@@ -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 == "已清空旧整理记录,失败任务记录已保留"
+19 -18
View File
@@ -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,19 +132,30 @@ 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(
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,13 +173,11 @@ 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(
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"],
@@ -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]
+234 -80
View File
@@ -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: [
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(".")
),
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=[
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
]
)
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=[
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
]
)
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=[
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
]
)
release_service = _release_service(plugin_manager, plugin_helper=plugin_helper)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
@@ -973,7 +1056,8 @@ def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data():
def publish_reset(plugin_id):
"""记录重置前事件,验证应用用例保留补偿时序。"""
calls.append((
calls.append(
(
"event",
ChainEventType.PluginDataReset,
PluginDataResetEventData(
@@ -981,7 +1065,8 @@ def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data():
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():
"""
重置入口会先停止插件插件数据删除不能依赖运行态注册仍存在
+88
View File
@@ -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()
+14
View File
@@ -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"}
+32 -5
View File
@@ -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:
+17
View File
@@ -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"}
+100
View File
@@ -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 == "配置已被其他会话更新"