diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index 9a4e82830..e96bb5645 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -417,7 +417,7 @@ def _clear_transfer_history( return _SchemaResponse(success=result.success, message=result.message) -@router.delete( +@router.delete( # type: ignore[misc] "/transfer/all", summary="清空旧整理记录", response_model=_SchemaResponse[None], diff --git a/app/api/endpoints/identifier.py b/app/api/endpoints/identifier.py new file mode 100644 index 000000000..32cb1ac1b --- /dev/null +++ b/app/api/endpoints/identifier.py @@ -0,0 +1,74 @@ +"""自定义识别词结构化读写端点。""" + +from typing import Any + +from fastapi import Depends, HTTPException, status + +from app.api.context import get_host_runtime +from app.api.dependencies.auth import get_current_active_superuser_async +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.configuration import ( + get_configured_system_config, + get_runtime_settings, +) +from app.application.settings import SystemSettingConflictError, SystemSettingsService +from app.schemas.common import JsonObject +from app.schemas.response import Response +from app.schemas.system import CustomIdentifiersUpdateRequest +from app.schemas.types import SystemConfigKey +from app.startup.composition.context import HostRuntime + +router = ResponseAPIRouter() + + +@router.get( # type: ignore[misc] + "/identifiers", + summary="查询自定义识别词", + response_model=Response[JsonObject], +) +async def query_custom_identifiers( + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Response[Any]: + """返回完整的自定义识别词列表。""" + identifiers = [ + item + for item in (get_configured_system_config().get(SystemConfigKey.CustomIdentifiers) or []) + if isinstance(item, str) + ] + return Response( + success=True, + data={"count": len(identifiers), "identifiers": identifiers}, + ) + + +@router.post( # type: ignore[misc] + "/identifiers", + summary="更新自定义识别词", + response_model=Response[JsonObject], +) +async def update_custom_identifiers( + payload: CustomIdentifiersUpdateRequest, + _: ApiPrincipal = Depends(get_current_active_superuser_async), + runtime: HostRuntime = Depends(get_host_runtime), +) -> Response[Any]: + """完整替换自定义识别词,并拒绝基于过期快照的覆盖。""" + identifiers = list(payload.identifiers) + try: + data = await SystemSettingsService( + get_runtime_settings(), + get_configured_system_config(), + runtime.system.publish_config_changed, + ).update( + setting_key=SystemConfigKey.CustomIdentifiers.value, + value=identifiers or None, + expected_value=payload.expected_identifiers, + enforce_expected_value=payload.expected_identifiers is not None, + ) + except SystemSettingConflictError as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(error), + ) from error + data.update({"count": len(identifiers), "identifiers": identifiers}) + return Response(success=True, message=data.get("message"), data=data) diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index df1a26257..2af1d9f90 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -1,6 +1,6 @@ import asyncio import mimetypes -from typing import Annotated, Any, Dict, List, Optional, Union +from typing import Annotated, Any, Dict, List, Optional import aiofiles from anyio import Path as AsyncPath @@ -23,6 +23,7 @@ from app.api.dependencies.auth import ( get_current_active_superuser_async, ) from app.api.dependencies.plugin import get_plugin_config_command +from app.api.endpoints.pluginfolder import router as plugin_folders_router from app.api.principal import ApiPrincipal from app.api.response import ( COLLECTION_TOTAL_HEADER, @@ -37,11 +38,7 @@ from app.application.configuration import get_api_runtime_config_snapshot, get_c from app.application.plugin.catalog import get_plugin_catalog_query from app.application.plugin.config import PluginConfigCommand 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.folders import add_clone_to_plugin_folder, remove_plugin_from_folders from app.application.plugin.gateway import get_plugin_install_service from app.application.plugin.management import ( get_plugin_snapshot, @@ -64,9 +61,6 @@ 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 @@ -92,6 +86,7 @@ from app.schemas.types import SystemConfigKey from app.startup.composition.context import HostRuntime router = ResponseAPIRouter() +router.include_router(plugin_folders_router) _plugin_release_refresh_tasks: set[asyncio.Task] = set() @@ -771,132 +766,6 @@ async def plugin_static_file( raise HTTPException(status_code=500, detail="Internal Server Error") -@router.get( - "/folders", - summary="获取插件文件夹配置", - response_model=_SchemaPluginFoldersData, -) -async def get_plugin_folders( - _: ApiPrincipal = Depends(get_current_active_superuser_async), -) -> dict: - """ - 获取插件文件夹分组配置 - """ - return get_plugin_folder_service().get_or_empty() - - -@router.post("/folders", summary="保存插件文件夹配置", response_model=_SchemaResponse[None]) -async def save_plugin_folders( - folders: _SchemaPluginFoldersData, - _: ApiPrincipal = Depends(get_current_active_superuser_async), -) -> Any: - """ - 保存插件文件夹分组配置 - """ - result = await get_plugin_folder_service().save(folders.root) - return _SchemaResponse(success=result.success, message=result.message) - - -@router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=_SchemaResponse[None]) -async def create_plugin_folder(folder_name: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> Any: - """ - 创建新的插件文件夹 - """ - result = await get_plugin_folder_service().create(folder_name) - return _SchemaResponse(success=result.success, message=result.message) - - -@router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=_SchemaResponse[None]) -async def delete_plugin_folder(folder_name: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> Any: - """ - 删除插件文件夹 - """ - result = await get_plugin_folder_service().delete(folder_name) - 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="更新文件夹中的插件", - response_model=_SchemaResponse[None], -) -async def update_folder_plugins( - folder_name: str, - plugin_update: Union[List[str], _SchemaPluginFolderPluginsUpdateRequest], - _: ApiPrincipal = Depends(get_current_active_superuser_async), -) -> Any: - """条件替换指定文件夹中的插件列表,并兼容旧数组请求。""" - 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) - - @router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None]) def clone_plugin( plugin_id: str, diff --git a/app/api/endpoints/pluginfolder.py b/app/api/endpoints/pluginfolder.py new file mode 100644 index 000000000..0e66e4fb3 --- /dev/null +++ b/app/api/endpoints/pluginfolder.py @@ -0,0 +1,152 @@ +"""插件文件夹增量管理端点。""" + +from typing import Any + +from fastapi import Depends + +from app.api.dependencies.auth import get_current_active_superuser_async +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.plugin.folders import get_plugin_folder_service +from app.schemas.plugin import ( + PluginFolderPluginsUpdateRequest, + PluginFoldersData, + PluginFolderUpdateRequest, +) +from app.schemas.response import Response + +router = ResponseAPIRouter() + + +@router.get( # type: ignore[misc] + "/folders", + summary="获取插件文件夹配置", + response_model=PluginFoldersData, +) +async def get_plugin_folders( + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> dict[str, Any]: + """获取插件文件夹分组配置。""" + return get_plugin_folder_service().get_or_empty() + + +@router.post( # type: ignore[misc] + "/folders", summary="保存插件文件夹配置", response_model=Response[None] +) +async def save_plugin_folders( + folders: PluginFoldersData, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """保存插件文件夹分组配置。""" + result = await get_plugin_folder_service().save(folders.root) + return Response(success=result.success, message=result.message) + + +@router.post( # type: ignore[misc] + "/folders/{folder_name}", + summary="创建插件文件夹", + response_model=Response[None], +) +async def create_plugin_folder( + folder_name: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """创建新的插件文件夹。""" + result = await get_plugin_folder_service().create(folder_name) + return Response(success=result.success, message=result.message) + + +@router.delete( # type: ignore[misc] + "/folders/{folder_name}", + summary="删除插件文件夹", + response_model=Response[None], +) +async def delete_plugin_folder( + folder_name: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """删除插件文件夹。""" + result = await get_plugin_folder_service().delete(folder_name) + return Response(success=result.success, message=result.message) + + +@router.patch( # type: ignore[misc] + "/folders/{folder_name}", + summary="更新插件文件夹", + response_model=Response[None], +) +async def update_plugin_folder( + folder_name: str, + folder: PluginFolderUpdateRequest, + _: 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 Response(success=result.success, message=result.message) + + +@router.put( # type: ignore[misc] + "/folders/{folder_name}/plugins", + summary="更新文件夹中的插件", + response_model=Response[None], +) +async def update_folder_plugins( + folder_name: str, + plugin_update: list[str] | PluginFolderPluginsUpdateRequest, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """条件替换指定文件夹中的插件列表,并兼容旧数组请求。""" + 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 Response(success=result.success, message=result.message) + + +@router.put( # type: ignore[misc] + "/folders/{folder_name}/plugins/{plugin_id}", + summary="移动插件到文件夹", + response_model=Response[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 Response(success=result.success, message=result.message) + + +@router.delete( # type: ignore[misc] + "/folders/{folder_name}/plugins/{plugin_id}", + summary="从文件夹移除插件", + response_model=Response[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 Response(success=result.success, message=result.message) diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 21dfb19d3..04f99c598 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -300,7 +300,7 @@ async def update_site( include_in_schema=False, deprecated=True, ) -@router.post( +@router.post( # type: ignore[misc] "/cookiecloud", summary="CookieCloud同步", response_model=_SchemaResponse[None], @@ -325,7 +325,7 @@ async def cookie_cloud_sync( include_in_schema=False, deprecated=True, ) -@router.post( +@router.post( # type: ignore[misc] "/reset", summary="重置站点", response_model=_SchemaResponse[None], diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 4d01dd94a..52c1a0e04 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -103,7 +103,9 @@ def directory_settings( return _SchemaResponse(success=True, data=results) -@router.get("/options", summary="查询可用存储选项", response_model=List[_SchemaStorageOption]) +@router.get( # type: ignore[misc] + "/options", summary="查询可用存储选项", response_model=List[_SchemaStorageOption] +) def storage_options( _: ApiPrincipal = Depends(get_current_active_user), page: CompatiblePageParam = None, diff --git a/app/api/endpoints/submaintenance.py b/app/api/endpoints/submaintenance.py new file mode 100644 index 000000000..b7906797a --- /dev/null +++ b/app/api/endpoints/submaintenance.py @@ -0,0 +1,148 @@ +"""订阅维护命令端点。""" + +from typing import Any + +from fastapi import Depends + +from app.api.dependencies.auth import ( + get_current_active_user, + get_current_active_user_async, +) +from app.api.dependencies.subscription import ( + get_search_subscriptions_command, + get_subscription_mutation_service, +) +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.scheduling import get_scheduler +from app.application.subscription.mutation import ( + SubscriptionActor, + SubscriptionMutationService, +) +from app.application.subscription.search import ( + SearchSubscriptionsCommand, + SubscribeSearchActor, +) +from app.schemas.response import Response + +router = ResponseAPIRouter() + + +@router.get( # type: ignore[misc] + "/refresh", + summary="刷新订阅(兼容入口)", + response_model=Response[None], + include_in_schema=False, + deprecated=True, +) +@router.post( # type: ignore[misc] + "/refresh", summary="刷新订阅", response_model=Response[None] +) +def refresh_subscribes( + current_user: ApiPrincipal = Depends(get_current_active_user), +) -> Any: + """刷新所有订阅。""" + if not current_user.is_superuser: + return Response(success=False, message="订阅不存在") + get_scheduler().start("subscribe_refresh") + return Response(success=True) + + +@router.get( # type: ignore[misc] + "/reset/{subid}", + summary="重置订阅(兼容入口)", + response_model=Response[None], + include_in_schema=False, + deprecated=True, +) +@router.post( # type: ignore[misc] + "/reset/{subid}", summary="重置订阅", response_model=Response[None] +) +async def reset_subscribes( + subid: int, + mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), +) -> Any: + """重置一个订阅。""" + actor = SubscriptionActor( + name=current_user.name, + is_superuser=current_user.is_superuser, + ) + change = await mutation.reset(subid, actor) + if change: + return Response(success=True) + return Response(success=False, message="订阅不存在") + + +@router.get( # type: ignore[misc] + "/check", + summary="刷新订阅 TMDB 信息(兼容入口)", + response_model=Response[None], + include_in_schema=False, + deprecated=True, +) +@router.post( # type: ignore[misc] + "/check", summary="刷新订阅 TMDB 信息", response_model=Response[None] +) +def check_subscribes( + current_user: ApiPrincipal = Depends(get_current_active_user), +) -> Any: + """刷新订阅 TMDB 信息。""" + if not current_user.is_superuser: + return Response(success=False, message="订阅不存在") + get_scheduler().start("subscribe_tmdb") + return Response(success=True) + + +@router.get( # type: ignore[misc] + "/search", + summary="搜索所有订阅(兼容入口)", + response_model=Response[None], + include_in_schema=False, + deprecated=True, +) +@router.post( # type: ignore[misc] + "/search", summary="搜索所有订阅", response_model=Response[None] +) +async def search_subscribes( + command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command), + current_user: ApiPrincipal = Depends(get_current_active_user_async), +) -> Any: + """搜索当前用户可管理的全部订阅。""" + await command.execute( + SubscribeSearchActor( + username=current_user.name, + is_superuser=current_user.is_superuser, + ) + ) + return Response(success=True) + + +@router.get( # type: ignore[misc] + "/search/{subscribe_id}", + summary="搜索订阅(兼容入口)", + response_model=Response[None], + include_in_schema=False, + deprecated=True, +) +@router.post( # type: ignore[misc] + "/search/{subscribe_id}", + summary="搜索订阅", + response_model=Response[None], +) +async def search_subscribe( + subscribe_id: int, + command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command), + current_user: ApiPrincipal = Depends(get_current_active_user_async), +) -> Any: + """根据订阅编号搜索一个订阅。""" + found = await command.execute( + SubscribeSearchActor( + username=current_user.name, + is_superuser=current_user.is_superuser, + ), + subscribe_id=subscribe_id, + ) + if not found: + return Response(success=False, message="订阅不存在") + return Response(success=True) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 2be77409e..f259065ca 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -21,11 +21,20 @@ from app.api.dependencies.auth import ( from app.api.dependencies.subscription import ( get_delete_subscribe_command, get_delete_subscriptions_by_identity_command, - get_search_subscriptions_command, get_subscription_execution_status_service, get_subscription_mutation_service, get_subscription_query_service, ) +from app.api.endpoints.submaintenance import ( + check_subscribes, + refresh_subscribes, + reset_subscribes, + search_subscribe, + search_subscribes, +) +from app.api.endpoints.submaintenance import ( + router as subscribe_maintenance_router, +) from app.api.principal import ApiPrincipal from app.api.response import ( COLLECTION_TOTAL_HEADER, @@ -39,7 +48,6 @@ from app.application.configuration import ( get_api_runtime_config_snapshot, get_configured_system_config, ) -from app.application.scheduling import get_scheduler from app.application.subscription.contract import SubscriptionQueryPort from app.application.subscription.delete import ( DeleteSubscribeCommand, @@ -53,10 +61,6 @@ from app.application.subscription.mutation import ( SubscriptionMutationService, ) from app.application.subscription.query import SubscriptionQueryService -from app.application.subscription.search import ( - SearchSubscriptionsCommand, - SubscribeSearchActor, -) from app.application.subscription.status import SubscriptionExecutionStatusService from app.chain.subscribe.facade import SubscribeChain from app.domain.context import MediaInfo @@ -84,6 +88,15 @@ from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.schemas.workflow import Subscribe as _SchemaSubscribe router = ResponseAPIRouter() +router.include_router(subscribe_maintenance_router) + +__all__ = [ + "check_subscribes", + "refresh_subscribes", + "reset_subscribes", + "search_subscribe", + "search_subscribes", +] def _public_subscription_message(message: Optional[object]) -> str: @@ -101,9 +114,7 @@ async def _attach_execution_status( loader = getattr(status_service, "for_subscriptions", None) if not callable(loader): return subscribes - statuses = await loader( - tuple(item.id for item in subscribes if item.id is not None) - ) + statuses = await loader(tuple(item.id for item in subscribes if item.id is not None)) for subscribe in subscribes: if subscribe.id is not None and (status := statuses.get(subscribe.id)) is not None: subscribe.execution_status = _SchemaSubscriptionExecutionStatus.model_validate(status) @@ -140,9 +151,7 @@ def build_subscribe_event_payload(subscribe: Any) -> dict: return subscribe.to_dict() -def can_access_subscribe( - subscribe: Any, current_user: ApiPrincipal -) -> bool: +def can_access_subscribe(subscribe: Any, current_user: ApiPrincipal) -> bool: """ 判断当前用户是否可访问订阅及其历史记录。 @@ -157,9 +166,7 @@ def can_access_subscribe( return bool(username) and username == current_user.name -def select_accessible_subscribe( - subscribes: List[Any], current_user: ApiPrincipal -) -> Any: +def select_accessible_subscribe(subscribes: List[Any], current_user: ApiPrincipal) -> Any: """ 从候选订阅中选择当前用户可访问的第一条记录。 """ @@ -170,15 +177,14 @@ def select_accessible_subscribe( def matches_subscribe_music_type( - subscribe: Any, - music_type: Optional[str], + subscribe: Any, + music_type: Optional[str], ) -> bool: """匹配订阅音乐实体,并把迁移前未标注类型的历史记录兼容为单曲。""" if not music_type: return True subscribe_music_type = getattr(subscribe, "music_type", None) - return subscribe_music_type == music_type \ - or (music_type == MUSIC_ENTITY_RECORDING and subscribe_music_type is None) + return subscribe_music_type == music_type or (music_type == MUSIC_ENTITY_RECORDING and subscribe_music_type is None) @router.get( @@ -190,9 +196,7 @@ def matches_subscribe_music_type( async def read_subscribes( response: Response = None, query: SubscriptionQueryService = Depends(get_subscription_query_service), - status_service: SubscriptionExecutionStatusService = Depends( - get_subscription_execution_status_service - ), + status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), page: CompatiblePageParam = None, count: CompatibleCountParam = None, @@ -203,9 +207,7 @@ async def read_subscribes( username = None if current_user.is_superuser else current_user.name page, count = resolve_compatible_pagination(page, count) if response is not None: - response.headers[COLLECTION_TOTAL_HEADER] = str( - await query.count_public(username) - ) + response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_public(username)) subscribes = await query.list_public(username, page=page, count=count) return await _attach_execution_status(subscribes, status_service) @@ -219,9 +221,7 @@ async def read_subscribes( async def list_subscribes( response: Response = None, query: SubscriptionQueryService = Depends(get_subscription_query_service), - status_service: SubscriptionExecutionStatusService = Depends( - get_subscription_execution_status_service - ), + status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service), _: Annotated[str, Depends(verify_apitoken)] = None, page: CompatiblePageParam = None, count: CompatibleCountParam = None, @@ -255,11 +255,7 @@ async def create_subscribe( else: mtype = None # 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。 - if ( - mtype != MediaType.MUSIC - and normalize_media_source(subscribe_in.media_source) - not in (None, MediaSource.TMDB) - ): + if mtype != MediaType.MUSIC and normalize_media_source(subscribe_in.media_source) not in (None, MediaSource.TMDB): meta = MetaInfo(subscribe_in.name) subscribe_in.name = meta.name if subscribe_in.season is None: @@ -267,9 +263,7 @@ async def create_subscribe( # 空标题由订阅识别链按显式媒体身份补全,但调用契约始终使用字符串。 title = subscribe_in.name or "" subscribe_dict = subscribe_in.to_public_write_payload() - identity_fields = {"media_source", "media_id"}.intersection( - subscribe_in.model_fields_set - ) + identity_fields = {"media_source", "media_id"}.intersection(subscribe_in.model_fields_set) if identity_fields: media_source, media_id = resolve_media_identity( media_source=subscribe_in.media_source, @@ -297,11 +291,7 @@ async def create_subscribe( ) return _SchemaResponse( success=bool(sid), - message=( - _public_subscription_message(message) - if message - else "" - ), + message=(_public_subscription_message(message) if message else ""), data={"id": sid}, ) @@ -324,9 +314,7 @@ async def update_subscribe( if not subscribe: return _SchemaResponse(success=False, message="订阅不存在") subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True) - identity_fields = {"media_source", "media_id"}.intersection( - subscribe_in.model_fields_set - ) + identity_fields = {"media_source", "media_id"}.intersection(subscribe_in.model_fields_set) if identity_fields: media_source, media_id = resolve_media_identity( media_source=subscribe_in.media_source, @@ -349,13 +337,12 @@ async def update_subscribe( # 音乐实体与曲目总数来自识别链,编辑接口不得把专辑改成单曲而提前完成订阅。 subscribe_dict["type"] = subscribe.type subscribe_dict["music_type"] = subscribe.music_type - subscribe_dict["total_tracks"] = subscribe.total_tracks \ - if subscribe.music_type == MUSIC_ENTITY_ALBUM else None + subscribe_dict["total_tracks"] = subscribe.total_tracks if subscribe.music_type == MUSIC_ENTITY_ALBUM else None total_episode_updated = "total_episode" in subscribe_in.model_fields_set if ( - total_episode_updated - and subscribe_in.total_episode - and subscribe_in.total_episode > (subscribe.total_episode or 0) + total_episode_updated + and subscribe_in.total_episode + and subscribe_in.total_episode > (subscribe.total_episode or 0) ): # 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。 subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + ( @@ -426,137 +413,13 @@ async def subscribe_media_identity( return result if result else _SchemaSubscribe() -@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: - """ - 刷新所有订阅 - """ - if not current_user.is_superuser: - return _SchemaResponse(success=False, message="订阅不存在") - get_scheduler().start("subscribe_refresh") - return _SchemaResponse(success=True) - - -@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), - current_user: ApiPrincipal = Depends(get_current_active_user_async), -) -> Any: - """ - 重置订阅 - """ - actor = SubscriptionActor( - name=current_user.name, - is_superuser=current_user.is_superuser, - ) - change = await mutation.reset(subid, actor) - if change: - return _SchemaResponse(success=True) - return _SchemaResponse(success=False, message="订阅不存在") - - -@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: - """ - 刷新订阅 TMDB 信息 - """ - if not current_user.is_superuser: - return _SchemaResponse(success=False, message="订阅不存在") - get_scheduler().start("subscribe_tmdb") - return _SchemaResponse(success=True) - - -@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), -) -> Any: - """ - 搜索所有订阅 - """ - await command.execute( - SubscribeSearchActor( - username=current_user.name, - is_superuser=current_user.is_superuser, - ) - ) - return _SchemaResponse(success=True) - - -@router.get( - "/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, - command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command), - current_user: ApiPrincipal = Depends(get_current_active_user_async), -) -> Any: - """ - 根据订阅编号搜索订阅 - """ - found = await command.execute( - SubscribeSearchActor( - username=current_user.name, - is_superuser=current_user.is_superuser, - ), - subscribe_id=subscribe_id, - ) - if not found: - return _SchemaResponse(success=False, message="订阅不存在") - return _SchemaResponse(success=True) - - @router.delete("/media/{media_id}", summary="删除订阅", response_model=_SchemaResponse[None]) async def delete_subscribe_by_media_identity( media_id: str, media_source: MediaSource, season: Optional[int] = None, music_type: Optional[str] = None, - command: DeleteSubscriptionsByIdentityCommand = Depends( - get_delete_subscriptions_by_identity_command - ), + command: DeleteSubscriptionsByIdentityCommand = Depends(get_delete_subscriptions_by_identity_command), current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ @@ -575,9 +438,7 @@ async def delete_subscribe_by_media_identity( return _SchemaResponse(success=True) -@router.post( - "/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=_SchemaResponse[None] -) +@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=_SchemaResponse[None]) async def seerr_subscribe( request: Request, task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)], @@ -603,11 +464,7 @@ async def seerr_subscribe( if notification_type not in ["MEDIA_APPROVED", "MEDIA_AUTO_APPROVED"]: return _SchemaResponse(success=False, message="不支持的通知类型") subject = req_json.get("subject") - media_type = ( - MediaType.MOVIE - if req_json.get("media", {}).get("media_type") == "movie" - else MediaType.TV - ) + media_type = MediaType.MOVIE if req_json.get("media", {}).get("media_type") == "movie" else MediaType.TV tmdbId = req_json.get("media", {}).get("tmdbId") if not media_type or not tmdbId or not subject: return _SchemaResponse(success=False, message="请求参数不正确") @@ -630,11 +487,7 @@ async def seerr_subscribe( seasons = [] for extra in req_json.get("extra", []): if extra.get("name") == "Requested Seasons": - seasons = [ - int(str(sea).strip()) - for sea in extra.get("value").split(", ") - if str(sea).isdigit() - ] + seasons = [int(str(sea).strip()) for sea in extra.get("value").split(", ") if str(sea).isdigit()] break for season in seasons: resolve_background_task_registry(task_registry).create_sync( @@ -677,9 +530,7 @@ async def subscribe_history( username=username, ) if response is not None: - response.headers[COLLECTION_TOTAL_HEADER] = str( - await query.count_history(mtype, username=username) - ) + response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_history(mtype, username=username)) return results @@ -780,9 +631,7 @@ async def user_subscribes( username: str, response: Response = None, query: SubscriptionQueryService = Depends(get_subscription_query_service), - status_service: SubscriptionExecutionStatusService = Depends( - get_subscription_execution_status_service - ), + status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), page: CompatiblePageParam = None, count: CompatibleCountParam = None, @@ -794,9 +643,7 @@ async def user_subscribes( return [] page, count = resolve_compatible_pagination(page, count) if response is not None: - response.headers[COLLECTION_TOTAL_HEADER] = str( - await query.count_public(username) - ) + response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_public(username)) subscribes = await query.list_public(username, page=page, count=count) return await _attach_execution_status(subscribes, status_service) @@ -848,29 +695,19 @@ async def subscribe_share( ) return _SchemaResponse( success=state, - message=( - _public_subscription_message(errmsg) - if errmsg - else "" - ), + message=(_public_subscription_message(errmsg) if errmsg else ""), ) @router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None]) -async def subscribe_share_delete( - share_id: int, _: _SchemaTokenPayload = Depends(verify_token) -) -> Any: +async def subscribe_share_delete(share_id: int, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 删除分享 """ state, errmsg = await MoviePilotServerHelper.async_share_delete(share_id=share_id) return _SchemaResponse( success=state, - message=( - _public_subscription_message(errmsg) - if errmsg - else "" - ), + message=(_public_subscription_message(errmsg) if errmsg else ""), ) @@ -887,16 +724,16 @@ async def subscribe_fork( for key in list(sub_dict.keys()): if not hasattr(_SchemaSubscribe(), key): sub_dict.pop(key) - result = await create_subscribe( - subscribe_in=_SchemaSubscribe(**sub_dict), current_user=current_user - ) + result = await create_subscribe(subscribe_in=_SchemaSubscribe(**sub_dict), current_user=current_user) if result.success: await MoviePilotServerHelper.async_sub_fork(share_id=sub.id) return result @router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str]) -async def followed_subscribers(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: +async def followed_subscribers( + _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None +) -> Any: """ 查询已Follow的订阅分享人 """ @@ -904,42 +741,30 @@ async def followed_subscribers(_: _SchemaTokenPayload = Depends(verify_token), p @router.post("/follow", summary="Follow订阅分享人", response_model=_SchemaResponse[None]) -async def follow_subscriber( - share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token) -) -> Any: +async def follow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ Follow订阅分享人 """ subscribers = get_configured_system_config().get(SystemConfigKey.FollowSubscribers) or [] if share_uid and share_uid not in subscribers: subscribers.append(share_uid) - await get_configured_system_config().async_set( - SystemConfigKey.FollowSubscribers, subscribers - ) + await get_configured_system_config().async_set(SystemConfigKey.FollowSubscribers, subscribers) return _SchemaResponse(success=True) -@router.delete( - "/follow", summary="取消Follow订阅分享人", response_model=_SchemaResponse[None] -) -async def unfollow_subscriber( - share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token) -) -> Any: +@router.delete("/follow", summary="取消Follow订阅分享人", response_model=_SchemaResponse[None]) +async def unfollow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 取消Follow订阅分享人 """ subscribers = get_configured_system_config().get(SystemConfigKey.FollowSubscribers) or [] if share_uid and share_uid in subscribers: subscribers.remove(share_uid) - await get_configured_system_config().async_set( - SystemConfigKey.FollowSubscribers, subscribers - ) + await get_configured_system_config().async_set(SystemConfigKey.FollowSubscribers, subscribers) return _SchemaResponse(success=True) -@router.get( - "/shares", summary="查询分享的订阅", response_model=List[_SchemaSubscribeShare] -) +@router.get("/shares", summary="查询分享的订阅", response_model=List[_SchemaSubscribeShare]) async def subscribe_shares( name: Optional[str] = None, page: Optional[int] = 1, @@ -993,11 +818,7 @@ async def read_subscribe( if not subscribe_id: return _SchemaSubscribe() subscribe = await query.get_public(subscribe_id) - return ( - subscribe - if subscribe and can_access_subscribe(subscribe, current_user) - else _SchemaSubscribe() - ) + return subscribe if subscribe and can_access_subscribe(subscribe, current_user) else _SchemaSubscribe() @router.delete( diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 87ed3a723..3bbed30df 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -7,7 +7,7 @@ from typing import Annotated, Any, Optional, Union import anyio import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用 -from fastapi import Body, Depends, Header, HTTPException, Query, Request, Response, status +from fastapi import Body, Depends, Header, HTTPException, Query, Request, Response from fastapi.responses import StreamingResponse from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token @@ -17,6 +17,7 @@ from app.api.dependencies.auth import ( get_current_active_superuser_async, get_current_active_user_async, ) +from app.api.endpoints.identifier import router as system_identifiers_router from app.api.principal import ApiPrincipal from app.api.response import CompatibleCountParam, CompatiblePageParam, ResponseAPIRouter from app.application.backup import DatabaseBackupInProgressError @@ -32,7 +33,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 SystemSettingConflictError, SystemSettingsService +from app.application.settings import 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 @@ -53,7 +54,6 @@ from app.schemas.common import JsonObjectList as _SchemaJsonObjectList from app.schemas.common import TimeData as _SchemaTimeData from app.schemas.common import ValueData as _SchemaValueData from app.schemas.response import Response as _SchemaResponse -from app.schemas.system import CustomIdentifiersUpdateRequest as _SchemaCustomIdentifiersUpdateRequest from app.schemas.system import DatabaseBackupArtifactData as _SchemaDatabaseBackupArtifactData from app.schemas.system import DatabaseBackupVerificationData as _SchemaDatabaseBackupVerificationData from app.schemas.system import NetTestTarget as _SchemaNetTestTarget @@ -70,6 +70,7 @@ from app.schemas.types import SystemConfigKey from app.startup.composition.context import HostRuntime router = ResponseAPIRouter() +router.include_router(system_identifiers_router) _PUBLIC_SYSTEM_CONFIG_KEYS = { item.value: item @@ -699,58 +700,6 @@ async def update_settings( return _SchemaResponse(success=True, message=data.get("message"), data=data) -@router.get( # type: ignore[misc] - "/identifiers", - summary="查询自定义识别词", - response_model=_SchemaResponse[_SchemaJsonObject], -) -async def query_custom_identifiers( - _: ApiPrincipal = Depends(get_current_active_superuser_async), -) -> _SchemaResponse[Any]: - """返回完整的自定义识别词列表。""" - 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}, - ) - - -@router.post( # type: ignore[misc] - "/identifiers", - summary="更新自定义识别词", - response_model=_SchemaResponse[_SchemaJsonObject], -) -async def update_custom_identifiers( - payload: _SchemaCustomIdentifiersUpdateRequest, - _: ApiPrincipal = Depends(get_current_active_superuser_async), - runtime: HostRuntime = Depends(get_host_runtime), -) -> _SchemaResponse[Any]: - """完整替换自定义识别词,并可拒绝基于过期快照的覆盖。""" - identifiers = list(payload.identifiers) - try: - data = await SystemSettingsService( - get_runtime_settings(), - get_configured_system_config(), - runtime.system.publish_config_changed, - ).update( - setting_key=SystemConfigKey.CustomIdentifiers.value, - value=identifiers or None, - expected_value=payload.expected_identifiers, - enforce_expected_value=payload.expected_identifiers is not None, - ) - except SystemSettingConflictError as error: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(error), - ) from error - data.update({"count": len(identifiers), "identifiers": identifiers}) - return _SchemaResponse(success=True, message=data.get("message"), data=data) - - @router.get( "/message", summary="实时消息", @@ -976,7 +925,9 @@ def ruletest( summary="获取网络测试目标", response_model=_SchemaResponse[list[_SchemaNetTestTarget]], ) -async def nettest_targets(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None): +async def nettest_targets( + _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None +): """ 获取网络测试目标。 @@ -1106,7 +1057,9 @@ def check_system_update( @router.post( - "/update/download", summary="后台下载系统更新", response_model=_SchemaResponse[_SchemaSystemUpdateStatus], + "/update/download", + summary="后台下载系统更新", + response_model=_SchemaResponse[_SchemaSystemUpdateStatus], ) def download_system_update( _: ApiPrincipal = Depends(get_current_active_superuser), @@ -1119,7 +1072,9 @@ def download_system_update( @router.post( - "/update/install", summary="确认重启安装系统更新", response_model=_SchemaResponse[None], + "/update/install", + summary="确认重启安装系统更新", + response_model=_SchemaResponse[None], ) def install_system_update( _: ApiPrincipal = Depends(get_current_active_superuser), diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 72f532860..4eb05058d 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -154,7 +154,7 @@ class PluginRuntimeSummary(BaseModel): ) -class PluginRuntimeCommandCapability(BaseModel): +class PluginRuntimeCommandCapability(BaseModel): # type: ignore[misc] """插件运行时注册命令的安全只读投影。""" cmd: str = Field(description="命令标识") @@ -162,14 +162,14 @@ class PluginRuntimeCommandCapability(BaseModel): plugin_id: Optional[str] = Field(default=None, description="注册命令的插件 ID") -class PluginRuntimeActionCapability(BaseModel): +class PluginRuntimeActionCapability(BaseModel): # type: ignore[misc] """插件运行时注册动作的安全只读投影。""" id: str = Field(description="动作标识") name: Optional[str] = Field(default=None, description="动作名称") -class PluginRuntimeActionGroup(BaseModel): +class PluginRuntimeActionGroup(BaseModel): # type: ignore[misc] """按插件归组的运行时动作投影。""" plugin_id: Optional[str] = Field(default=None, description="注册动作的插件 ID") @@ -177,7 +177,7 @@ class PluginRuntimeActionGroup(BaseModel): actions: List[PluginRuntimeActionCapability] = Field(default_factory=list) -class PluginRuntimeServiceCapability(BaseModel): +class PluginRuntimeServiceCapability(BaseModel): # type: ignore[misc] """插件定时服务的安全只读投影。""" id: str = Field(description="服务标识") @@ -185,7 +185,7 @@ class PluginRuntimeServiceCapability(BaseModel): trigger: Optional[str] = Field(default=None, description="定时触发器说明") -class PluginRuntimeCapabilities(BaseModel): +class PluginRuntimeCapabilities(BaseModel): # type: ignore[misc] """插件命令、动作和定时服务的公共安全能力快照。""" commands: List[PluginRuntimeCommandCapability] = Field(default_factory=list) @@ -193,7 +193,7 @@ class PluginRuntimeCapabilities(BaseModel): services: List[PluginRuntimeServiceCapability] = Field(default_factory=list) -class PluginDataKeySummary(BaseModel): +class PluginDataKeySummary(BaseModel): # type: ignore[misc] """单个插件持久化键的不含值诊断摘要。""" key: str = Field(description="持久化数据键") @@ -208,7 +208,7 @@ class PluginDataKeySummary(BaseModel): sensitive: bool = Field(description="键名是否符合凭据字段规则") -class PluginDataSummary(BaseModel): +class PluginDataSummary(BaseModel): # type: ignore[misc] """插件持久化数据的不含原值诊断摘要。""" plugin_id: str = Field(description="插件 ID") @@ -490,7 +490,7 @@ class PluginFoldersData(RootModel[Dict[str, Union[List[str], PluginFolderConfigD """插件文件夹与插件配置映射,兼容旧版数组格式与新版对象格式。""" -class PluginFolderUpdateRequest(BaseModel): +class PluginFolderUpdateRequest(BaseModel): # type: ignore[misc] """插件文件夹名称和展示字段的增量更新请求。""" model_config = ConfigDict(extra="forbid", populate_by_name=True) @@ -521,7 +521,7 @@ class PluginFolderUpdateRequest(BaseModel): ) -class PluginFolderPluginsUpdateRequest(BaseModel): +class PluginFolderPluginsUpdateRequest(BaseModel): # type: ignore[misc] """插件文件夹成员顺序的条件替换请求。""" plugins: List[str] = Field(description="Ordered installed plugin IDs assigned to this folder.") diff --git a/app/schemas/storage.py b/app/schemas/storage.py index 226b9a10c..2ffcde18e 100644 --- a/app/schemas/storage.py +++ b/app/schemas/storage.py @@ -26,7 +26,7 @@ class StorageLoginStatusData(BaseModel): tip: str = Field(description="状态提示") -class StorageOption(BaseModel): +class StorageOption(BaseModel): # type: ignore[misc] """前端选择控件可安全消费的存储摘要。""" name: str = Field(description="存储显示名称") diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index f94cf38e1..cea93ac77 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -754,8 +754,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 968 | -| 内部导入边 | 8,161 | +| Python 模块 | 972 | +| 内部导入边 | 8,206 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index edd668ced..8944a8b81 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 969 / 8,161 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 972 / 8,206 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -102,8 +102,8 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,538 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | -| Ruff 历史诊断 | 548 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 全量 mypy 历史债务 | 9,528 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| Ruff 历史诊断 | 547 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 35b9f0628..f777140fa 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8161, - "edge_sha256": "25ffe69a9b335cc3eeb13ed5e6d4b55eda256eae747df6d0612c6ab5145786b3", + "edge_count": 8206, + "edge_sha256": "c1bac8193a102557080e2ff07d49b8c7f4e34ad09831ffee1ac2d0cce5b89463", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2285,6 +2285,23 @@ "app.api.endpoints.history -> app.schemas.history", "app.api.endpoints.history -> app.schemas.response", "app.api.endpoints.history -> app.schemas.token", + "app.api.endpoints.identifier -> app.api", + "app.api.endpoints.identifier -> app.api.context", + "app.api.endpoints.identifier -> app.api.dependencies", + "app.api.endpoints.identifier -> app.api.dependencies.auth", + "app.api.endpoints.identifier -> app.api.principal", + "app.api.endpoints.identifier -> app.api.response", + "app.api.endpoints.identifier -> app.application", + "app.api.endpoints.identifier -> app.application.configuration", + "app.api.endpoints.identifier -> app.application.settings", + "app.api.endpoints.identifier -> app.schemas", + "app.api.endpoints.identifier -> app.schemas.common", + "app.api.endpoints.identifier -> app.schemas.response", + "app.api.endpoints.identifier -> app.schemas.system", + "app.api.endpoints.identifier -> app.schemas.types", + "app.api.endpoints.identifier -> app.startup", + "app.api.endpoints.identifier -> app.startup.composition", + "app.api.endpoints.identifier -> app.startup.composition.context", "app.api.endpoints.llm -> app.agent", "app.api.endpoints.llm -> app.agent.llm", "app.api.endpoints.llm -> app.agent.llm.auth", @@ -2510,6 +2527,8 @@ "app.api.endpoints.plugin -> app.api.dependencies", "app.api.endpoints.plugin -> app.api.dependencies.auth", "app.api.endpoints.plugin -> app.api.dependencies.plugin", + "app.api.endpoints.plugin -> app.api.endpoints", + "app.api.endpoints.plugin -> app.api.endpoints.pluginfolder", "app.api.endpoints.plugin -> app.api.principal", "app.api.endpoints.plugin -> app.api.response", "app.api.endpoints.plugin -> app.application", @@ -2544,6 +2563,17 @@ "app.api.endpoints.plugin -> app.startup", "app.api.endpoints.plugin -> app.startup.composition", "app.api.endpoints.plugin -> app.startup.composition.context", + "app.api.endpoints.pluginfolder -> app.api", + "app.api.endpoints.pluginfolder -> app.api.dependencies", + "app.api.endpoints.pluginfolder -> app.api.dependencies.auth", + "app.api.endpoints.pluginfolder -> app.api.principal", + "app.api.endpoints.pluginfolder -> app.api.response", + "app.api.endpoints.pluginfolder -> app.application", + "app.api.endpoints.pluginfolder -> app.application.plugin", + "app.api.endpoints.pluginfolder -> app.application.plugin.folders", + "app.api.endpoints.pluginfolder -> app.schemas", + "app.api.endpoints.pluginfolder -> app.schemas.plugin", + "app.api.endpoints.pluginfolder -> app.schemas.response", "app.api.endpoints.recommend -> app.adapters", "app.api.endpoints.recommend -> app.adapters.web", "app.api.endpoints.recommend -> app.adapters.web.security", @@ -2685,6 +2715,19 @@ "app.api.endpoints.subexecution -> app.schemas", "app.api.endpoints.subexecution -> app.schemas.response", "app.api.endpoints.subexecution -> app.schemas.subscribe", + "app.api.endpoints.submaintenance -> app.api", + "app.api.endpoints.submaintenance -> app.api.dependencies", + "app.api.endpoints.submaintenance -> app.api.dependencies.auth", + "app.api.endpoints.submaintenance -> app.api.dependencies.subscription", + "app.api.endpoints.submaintenance -> app.api.principal", + "app.api.endpoints.submaintenance -> app.api.response", + "app.api.endpoints.submaintenance -> app.application", + "app.api.endpoints.submaintenance -> app.application.scheduling", + "app.api.endpoints.submaintenance -> app.application.subscription", + "app.api.endpoints.submaintenance -> app.application.subscription.mutation", + "app.api.endpoints.submaintenance -> app.application.subscription.search", + "app.api.endpoints.submaintenance -> app.schemas", + "app.api.endpoints.submaintenance -> app.schemas.response", "app.api.endpoints.subscribe -> app.adapters", "app.api.endpoints.subscribe -> app.adapters.external", "app.api.endpoints.subscribe -> app.adapters.external.server", @@ -2696,18 +2739,18 @@ "app.api.endpoints.subscribe -> app.api.dependencies", "app.api.endpoints.subscribe -> app.api.dependencies.auth", "app.api.endpoints.subscribe -> app.api.dependencies.subscription", + "app.api.endpoints.subscribe -> app.api.endpoints", + "app.api.endpoints.subscribe -> app.api.endpoints.submaintenance", "app.api.endpoints.subscribe -> app.api.principal", "app.api.endpoints.subscribe -> app.api.response", "app.api.endpoints.subscribe -> app.application", "app.api.endpoints.subscribe -> app.application.configuration", - "app.api.endpoints.subscribe -> app.application.scheduling", "app.api.endpoints.subscribe -> app.application.subscription", "app.api.endpoints.subscribe -> app.application.subscription.contract", "app.api.endpoints.subscribe -> app.application.subscription.delete", "app.api.endpoints.subscribe -> app.application.subscription.identity", "app.api.endpoints.subscribe -> app.application.subscription.mutation", "app.api.endpoints.subscribe -> app.application.subscription.query", - "app.api.endpoints.subscribe -> app.application.subscription.search", "app.api.endpoints.subscribe -> app.application.subscription.status", "app.api.endpoints.subscribe -> app.chain", "app.api.endpoints.subscribe -> app.chain.subscribe", @@ -2736,6 +2779,8 @@ "app.api.endpoints.system -> app.api.context", "app.api.endpoints.system -> app.api.dependencies", "app.api.endpoints.system -> app.api.dependencies.auth", + "app.api.endpoints.system -> app.api.endpoints", + "app.api.endpoints.system -> app.api.endpoints.identifier", "app.api.endpoints.system -> app.api.principal", "app.api.endpoints.system -> app.api.response", "app.api.endpoints.system -> app.application", @@ -9254,7 +9299,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 969, + "module_count": 972, "modules": [ "app", "app.adapters", @@ -9414,6 +9459,7 @@ "app.api.endpoints.douban", "app.api.endpoints.download", "app.api.endpoints.history", + "app.api.endpoints.identifier", "app.api.endpoints.llm", "app.api.endpoints.login", "app.api.endpoints.mcp", @@ -9425,12 +9471,14 @@ "app.api.endpoints.notification", "app.api.endpoints.openai", "app.api.endpoints.plugin", + "app.api.endpoints.pluginfolder", "app.api.endpoints.recommend", "app.api.endpoints.rule", "app.api.endpoints.search", "app.api.endpoints.site", "app.api.endpoints.storage", "app.api.endpoints.subexecution", + "app.api.endpoints.submaintenance", "app.api.endpoints.subscribe", "app.api.endpoints.system", "app.api.endpoints.tmdb", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 57da490c8..bb88ebc2e 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -511,11 +511,11 @@ "app/api/endpoints/plugin.py": { "assignment": 1, "import-untyped": 1, - "misc": 33, + "misc": 29, "no-any-return": 5, "no-untyped-call": 1, "no-untyped-def": 2, - "type-arg": 10 + "type-arg": 9 }, "app/api/endpoints/recommend.py": { "attr-defined": 2, @@ -549,7 +549,7 @@ "arg-type": 11, "assignment": 3, "attr-defined": 2, - "misc": 28, + "misc": 23, "no-any-return": 1, "no-untyped-call": 1, "no-untyped-def": 1, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index e09ec5682..8ecaadab4 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -635,9 +635,6 @@ "tests/test_agent_tool_timeouts.py": { "I001": 1 }, - "tests/test_api_authorization.py": { - "I001": 1 - }, "tests/test_async_db_pooling.py": { "I001": 1 }, diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index b88e24580..51d537611 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 535, + "loaded_app_module_count": 536, "max_ms": 1293.338, "median_ms": 1156.239, "min_ms": 1102.806, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 547, + "loaded_app_module_count": 548, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 549, + "loaded_app_module_count": 550, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 5bdd6f1c5..8c3e76e4d 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -9,6 +9,7 @@ from starlette.responses import Response from app import schemas from app.api.endpoints import plugin as plugin_endpoint +from app.api.endpoints import pluginfolder as plugin_folders_endpoint from app.api.endpoints.plugin import ( plugin_capabilities, plugin_data_summary, @@ -1265,7 +1266,7 @@ 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_folders_endpoint.update_folder_plugins("常用", ["DemoPlugin"], None)) assert result.success is False assert "停机阶段" in result.message @@ -1279,10 +1280,14 @@ def test_plugin_folder_incremental_endpoints_delegate_structured_payloads(monkey 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) + monkeypatch.setattr( + plugin_folders_endpoint, + "get_plugin_folder_service", + lambda: service, + ) patch_result = asyncio.run( - plugin_endpoint.update_plugin_folder( + plugin_folders_endpoint.update_plugin_folder( "常用", schemas.PluginFolderUpdateRequest( new_name="工具", @@ -1293,7 +1298,7 @@ def test_plugin_folder_incremental_endpoints_delegate_structured_payloads(monkey ) ) members_result = asyncio.run( - plugin_endpoint.update_folder_plugins( + plugin_folders_endpoint.update_folder_plugins( "工具", schemas.PluginFolderPluginsUpdateRequest( plugins=["DemoPlugin"], @@ -1302,8 +1307,8 @@ def test_plugin_folder_incremental_endpoints_delegate_structured_payloads(monkey 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)) + assign_result = asyncio.run(plugin_folders_endpoint.assign_plugin_to_folder("工具", "DemoPlugin", None)) + remove_result = asyncio.run(plugin_folders_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( @@ -1327,10 +1332,14 @@ 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) + monkeypatch.setattr( + plugin_folders_endpoint, + "get_plugin_folder_service", + lambda: service, + ) result = asyncio.run( - plugin_endpoint.update_folder_plugins( + plugin_folders_endpoint.update_folder_plugins( "常用", ["DemoPlugin"], None, diff --git a/tests/test_plugin_folders.py b/tests/test_plugin_folders.py index 731e82249..bf5b72e4f 100644 --- a/tests/test_plugin_folders.py +++ b/tests/test_plugin_folders.py @@ -1,5 +1,5 @@ import asyncio -from contextlib import contextmanager, nullcontext +from contextlib import nullcontext from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 88899ba0f..17c7a1a17 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -819,9 +819,7 @@ class TestSubscribeEndpoint: 1, 3, ) - history_repository.async_count_by_type.assert_awaited_once_with( - MediaType.MOVIE.value - ) + history_repository.async_count_by_type.assert_awaited_once_with(MediaType.MOVIE.value) history_repository.async_list_by_type_and_username.assert_not_awaited() def test_delete_subscribe_history_rejects_other_user(self): @@ -859,7 +857,7 @@ class TestSubscribeEndpoint: superuser = _EndpointUser(name="admin", is_superuser=True) for endpoint in [refresh_subscribes, check_subscribes]: - with patch("app.api.endpoints.subscribe.get_scheduler") as scheduler: + with patch("app.api.endpoints.submaintenance.get_scheduler") as scheduler: response = endpoint(current_user=regular_user) assert not response.success @@ -870,7 +868,7 @@ class TestSubscribeEndpoint: (refresh_subscribes, "subscribe_refresh"), (check_subscribes, "subscribe_tmdb"), ]: - with patch("app.api.endpoints.subscribe.get_scheduler") as scheduler: + with patch("app.api.endpoints.submaintenance.get_scheduler") as scheduler: response = endpoint(current_user=superuser) assert response.success @@ -1390,9 +1388,7 @@ class _SubscriptionHistoryRepositoryFake: self.async_list_by_type = AsyncMock(side_effect=self._async_list_by_type) self.async_list_by_type_and_username = AsyncMock(side_effect=self._async_list_by_type_and_username) self.async_count_by_type = AsyncMock(side_effect=self._async_count_by_type) - self.async_count_by_type_and_username = AsyncMock( - side_effect=self._async_count_by_type_and_username - ) + self.async_count_by_type_and_username = AsyncMock(side_effect=self._async_count_by_type_and_username) self.stage_delete = AsyncMock(side_effect=self._stage_delete) async def _async_get(self, history_id: int) -> SubscriptionHistorySnapshot | None: @@ -1432,10 +1428,7 @@ class _SubscriptionHistoryRepositoryFake: username: str, ) -> int: """异步统计指定类型和 owner 的历史快照。""" - return sum( - row.type == mtype and row.username == username - for row in self.rows.values() - ) + return sum(row.type == mtype and row.username == username for row in self.rows.values()) async def _stage_delete(self, history_id: int) -> None: """暂存删除等价为从内存集合移除历史快照。""" diff --git a/tests/test_system_identifiers_api.py b/tests/test_system_identifiers_api.py index ee7e13e8f..357da1183 100644 --- a/tests/test_system_identifiers_api.py +++ b/tests/test_system_identifiers_api.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException -import app.api.endpoints.system as system_endpoint +import app.api.endpoints.identifier as system_endpoint from app.application.settings import SystemSettingConflictError from app.schemas.system import CustomIdentifiersUpdateRequest from app.schemas.types import SystemConfigKey