refactor(api): split management endpoint routers

This commit is contained in:
jxxghp
2026-09-05 16:05:27 +08:00
parent 5c2c62f7b6
commit e656793aa5
21 changed files with 552 additions and 484 deletions
+1 -1
View File
@@ -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],
+74
View File
@@ -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)
+4 -135
View File
@@ -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,
+152
View File
@@ -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)
+2 -2
View File
@@ -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],
+3 -1
View File
@@ -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,
+148
View File
@@ -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)
+57 -236
View File
@@ -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(
+13 -58
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, 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),
+9 -9
View File
@@ -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.")
+1 -1
View File
@@ -26,7 +26,7 @@ class StorageLoginStatusData(BaseModel):
tip: str = Field(description="状态提示")
class StorageOption(BaseModel):
class StorageOption(BaseModel): # type: ignore[misc]
"""前端选择控件可安全消费的存储摘要。"""
name: str = Field(description="存储显示名称")
+2 -2
View File
@@ -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 |
+3 -3
View File
@@ -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 热点文件
+53 -5
View File
@@ -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",
+3 -3
View File
@@ -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,
-3
View File
@@ -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
},
@@ -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,
+17 -8
View File
@@ -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,
+1 -1
View File
@@ -1,5 +1,5 @@
import asyncio
from contextlib import contextmanager, nullcontext
from contextlib import nullcontext
from unittest.mock import AsyncMock, MagicMock
import pytest
+5 -12
View File
@@ -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:
"""暂存删除等价为从内存集合移除历史快照。"""
+1 -1
View File
@@ -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