mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor: expand runtime contracts and debt ratchets
This commit is contained in:
@@ -7,6 +7,10 @@ from fastapi import Depends, Request
|
|||||||
|
|
||||||
from app.application.messaging.chat import AsyncAgentChatRepository, AsyncUnitOfWork
|
from app.application.messaging.chat import AsyncAgentChatRepository, AsyncUnitOfWork
|
||||||
from app.application.outbox import AsyncOutboxTransaction
|
from app.application.outbox import AsyncOutboxTransaction
|
||||||
|
from app.application.configuration import (
|
||||||
|
ApiRuntimeConfig,
|
||||||
|
get_api_runtime_config_snapshot,
|
||||||
|
)
|
||||||
from app.application.subscription.delete import SubscribeDeletionRepository
|
from app.application.subscription.delete import SubscribeDeletionRepository
|
||||||
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
|
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
|
||||||
from app.application.subscription.mutation import (
|
from app.application.subscription.mutation import (
|
||||||
@@ -28,6 +32,20 @@ def get_host_runtime(request: Request) -> HostRuntime:
|
|||||||
return runtime
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_runtime_config(
|
||||||
|
runtime: HostRuntime = Depends(get_host_runtime),
|
||||||
|
) -> ApiRuntimeConfig:
|
||||||
|
"""为当前请求创建稳定的 API 配置快照。"""
|
||||||
|
return runtime.configuration.api()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_api_runtime_config(value: object) -> ApiRuntimeConfig:
|
||||||
|
"""兼容直接调用 endpoint 的旧入口,并统一返回真实配置快照。"""
|
||||||
|
if isinstance(value, ApiRuntimeConfig):
|
||||||
|
return value
|
||||||
|
return get_api_runtime_config_snapshot()
|
||||||
|
|
||||||
|
|
||||||
def get_agent_chat_runtime(
|
def get_agent_chat_runtime(
|
||||||
runtime: HostRuntime = Depends(get_host_runtime),
|
runtime: HostRuntime = Depends(get_host_runtime),
|
||||||
) -> AgentChatRuntime:
|
) -> AgentChatRuntime:
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ from app.schemas.response import Response as _SchemaResponse
|
|||||||
from app.api.response import ResponseAPIRouter
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.dashboard import DashboardChain
|
from app.chain.dashboard import DashboardChain
|
||||||
from app.chain.storage import StorageChain
|
from app.chain.storage import StorageChain
|
||||||
from app.runtime.config import settings
|
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
|
||||||
|
from app.application.configuration import ApiRuntimeConfig
|
||||||
from app.adapters.web.security.access import verify_apitoken
|
from app.adapters.web.security.access import verify_apitoken
|
||||||
from app.api.dependencies.auth import get_current_active_superuser
|
from app.api.dependencies.auth import get_current_active_superuser
|
||||||
from app.api.dependencies.history import get_dashboard_query_service
|
from app.api.dependencies.history import get_dashboard_query_service
|
||||||
@@ -53,7 +54,11 @@ def _build_storage() -> _SchemaStorage:
|
|||||||
return _SchemaStorage(total_storage=total, used_storage=total - available)
|
return _SchemaStorage(total_storage=total, used_storage=total - available)
|
||||||
|
|
||||||
|
|
||||||
def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo:
|
def _build_downloader(
|
||||||
|
name: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
btrfs_fsid_dedup: bool = False,
|
||||||
|
) -> _SchemaDownloaderInfo:
|
||||||
"""
|
"""
|
||||||
构建下载器统计信息。
|
构建下载器统计信息。
|
||||||
"""
|
"""
|
||||||
@@ -61,7 +66,7 @@ def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo:
|
|||||||
download_dirs = DirectoryHelper().get_local_download_dirs()
|
download_dirs = DirectoryHelper().get_local_download_dirs()
|
||||||
_, free_space = SystemUtils.space_usage(
|
_, free_space = SystemUtils.space_usage(
|
||||||
[Path(d.download_path) for d in download_dirs],
|
[Path(d.download_path) for d in download_dirs],
|
||||||
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
|
btrfs_fsid_dedup=btrfs_fsid_dedup,
|
||||||
)
|
)
|
||||||
# 下载器信息
|
# 下载器信息
|
||||||
downloader_info = _SchemaDownloaderInfo()
|
downloader_info = _SchemaDownloaderInfo()
|
||||||
@@ -137,12 +142,18 @@ def system_info(_: Any = Depends(get_current_active_superuser)) -> Any:
|
|||||||
|
|
||||||
@router.get("/downloader", summary="下载器信息", response_model=_SchemaDownloaderInfo)
|
@router.get("/downloader", summary="下载器信息", response_model=_SchemaDownloaderInfo)
|
||||||
def downloader(
|
def downloader(
|
||||||
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
|
name: Optional[str] = None,
|
||||||
|
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
|
||||||
|
_: Any = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
查询下载器信息
|
查询下载器信息
|
||||||
"""
|
"""
|
||||||
return _build_downloader(name)
|
runtime_config = resolve_api_runtime_config(runtime_config)
|
||||||
|
return _build_downloader(
|
||||||
|
name,
|
||||||
|
btrfs_fsid_dedup=runtime_config.btrfs_fsid_dedup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -150,11 +161,17 @@ def downloader(
|
|||||||
summary="下载器信息(API_TOKEN)",
|
summary="下载器信息(API_TOKEN)",
|
||||||
response_model=_SchemaDownloaderInfo,
|
response_model=_SchemaDownloaderInfo,
|
||||||
)
|
)
|
||||||
def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
def downloader2(
|
||||||
|
_: Annotated[str, Depends(verify_apitoken)],
|
||||||
|
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
|
||||||
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
return _build_downloader()
|
runtime_config = resolve_api_runtime_config(runtime_config)
|
||||||
|
return _build_downloader(
|
||||||
|
btrfs_fsid_dedup=runtime_config.btrfs_fsid_dedup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule", summary="后台服务", response_model=List[_SchemaScheduleInfo])
|
@router.get("/schedule", summary="后台服务", response_model=List[_SchemaScheduleInfo])
|
||||||
|
|||||||
@@ -111,6 +111,74 @@ def _build_unrecognized_media_info(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_add_media(
|
||||||
|
torrent_in: _SchemaTorrentInfo,
|
||||||
|
media_source: MediaSource | None,
|
||||||
|
media_id: str | None,
|
||||||
|
music_type: MusicTargetEntityType | None,
|
||||||
|
allow_unrecognized: bool,
|
||||||
|
) -> tuple[MetaBase | None, MediaInfo | MusicInfo | None, _SchemaResponse | None]:
|
||||||
|
"""校验媒体身份并为无媒体信息下载构建识别上下文。"""
|
||||||
|
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
|
||||||
|
if music_type is not None and not normalized_music_type:
|
||||||
|
return None, None, _SchemaResponse(
|
||||||
|
success=False,
|
||||||
|
message="音乐实体类型无效,仅支持 recording 或 album",
|
||||||
|
)
|
||||||
|
if (media_source is None) != (media_id is None):
|
||||||
|
return None, None, _SchemaResponse(
|
||||||
|
success=False,
|
||||||
|
message="媒体来源和媒体 ID 必须同时提供",
|
||||||
|
)
|
||||||
|
is_music = (
|
||||||
|
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
|
||||||
|
or is_music_media_source(media_source)
|
||||||
|
or normalized_music_type is not None
|
||||||
|
)
|
||||||
|
if is_music and media_source and not is_music_media_source(media_source):
|
||||||
|
return None, None, _SchemaResponse(
|
||||||
|
success=False,
|
||||||
|
message="音乐下载只能使用音乐元数据源",
|
||||||
|
)
|
||||||
|
if is_music and not normalized_music_type:
|
||||||
|
normalized_music_type = MUSIC_ENTITY_RECORDING
|
||||||
|
metainfo = (
|
||||||
|
MetaMusic.parse_query(torrent_in.title)
|
||||||
|
if is_music
|
||||||
|
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||||
|
)
|
||||||
|
if media_source and media_id:
|
||||||
|
mediainfo = MediaChain().recognize_media(
|
||||||
|
meta=metainfo,
|
||||||
|
media_source=media_source,
|
||||||
|
media_id=media_id,
|
||||||
|
mtype=MediaType.MUSIC if is_music else None,
|
||||||
|
music_type=normalized_music_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mediainfo = MediaChain().recognize_by_meta(
|
||||||
|
metainfo,
|
||||||
|
media_source=media_source,
|
||||||
|
obtain_images=False,
|
||||||
|
mtype=MediaType.MUSIC if is_music else None,
|
||||||
|
music_type=normalized_music_type,
|
||||||
|
)
|
||||||
|
if mediainfo:
|
||||||
|
return metainfo, mediainfo, None
|
||||||
|
if not allow_unrecognized:
|
||||||
|
return metainfo, None, _SchemaResponse(
|
||||||
|
success=False,
|
||||||
|
message="无法识别媒体信息",
|
||||||
|
data=_SchemaDownloadAddedData(requires_confirmation=True),
|
||||||
|
)
|
||||||
|
return metainfo, _build_unrecognized_media_info(
|
||||||
|
torrent_in,
|
||||||
|
metainfo,
|
||||||
|
is_music=is_music,
|
||||||
|
music_type=normalized_music_type,
|
||||||
|
), None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent])
|
@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent])
|
||||||
def current(
|
def current(
|
||||||
name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
|
name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
|
||||||
@@ -183,66 +251,17 @@ def add(
|
|||||||
"""
|
"""
|
||||||
添加下载任务(不含媒体信息)
|
添加下载任务(不含媒体信息)
|
||||||
"""
|
"""
|
||||||
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
|
metainfo, mediainfo, error = _resolve_add_media(
|
||||||
if music_type is not None and not normalized_music_type:
|
torrent_in,
|
||||||
return _SchemaResponse(
|
media_source,
|
||||||
success=False,
|
media_id,
|
||||||
message="音乐实体类型无效,仅支持 recording 或 album",
|
music_type,
|
||||||
)
|
allow_unrecognized,
|
||||||
if (media_source is None) != (media_id is None):
|
|
||||||
return _SchemaResponse(
|
|
||||||
success=False,
|
|
||||||
message="媒体来源和媒体 ID 必须同时提供",
|
|
||||||
)
|
|
||||||
is_music = (
|
|
||||||
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
|
|
||||||
or is_music_media_source(media_source)
|
|
||||||
or normalized_music_type is not None
|
|
||||||
)
|
)
|
||||||
if is_music and media_source and not is_music_media_source(media_source):
|
if error:
|
||||||
return _SchemaResponse(
|
return error
|
||||||
success=False,
|
if metainfo is None or mediainfo is None:
|
||||||
message="音乐下载只能使用音乐元数据源",
|
return _SchemaResponse(success=False, message="无法识别媒体信息")
|
||||||
)
|
|
||||||
if is_music and not normalized_music_type:
|
|
||||||
normalized_music_type = MUSIC_ENTITY_RECORDING
|
|
||||||
# 元数据
|
|
||||||
metainfo = (
|
|
||||||
MetaMusic.parse_query(torrent_in.title)
|
|
||||||
if is_music
|
|
||||||
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
|
||||||
)
|
|
||||||
# 媒体信息
|
|
||||||
if media_source and media_id:
|
|
||||||
mediainfo = MediaChain().recognize_media(
|
|
||||||
meta=metainfo,
|
|
||||||
media_source=media_source,
|
|
||||||
media_id=media_id,
|
|
||||||
mtype=MediaType.MUSIC if is_music else None,
|
|
||||||
music_type=normalized_music_type,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
mediainfo = MediaChain().recognize_by_meta(
|
|
||||||
metainfo,
|
|
||||||
media_source=media_source,
|
|
||||||
obtain_images=False,
|
|
||||||
mtype=MediaType.MUSIC if is_music else None,
|
|
||||||
music_type=normalized_music_type,
|
|
||||||
)
|
|
||||||
if not mediainfo:
|
|
||||||
if not allow_unrecognized:
|
|
||||||
return _SchemaResponse(
|
|
||||||
success=False,
|
|
||||||
message="无法识别媒体信息",
|
|
||||||
data=_SchemaDownloadAddedData(requires_confirmation=True),
|
|
||||||
)
|
|
||||||
# 用户已确认:影视与音乐统一按种子元信息构造最小上下文继续下载
|
|
||||||
mediainfo = _build_unrecognized_media_info(
|
|
||||||
torrent_in,
|
|
||||||
metainfo,
|
|
||||||
is_music=is_music,
|
|
||||||
music_type=normalized_music_type,
|
|
||||||
)
|
|
||||||
# 种子信息
|
# 种子信息
|
||||||
torrentinfo = TorrentInfo()
|
torrentinfo = TorrentInfo()
|
||||||
torrentinfo.from_dict(torrent_in.model_dump())
|
torrentinfo.from_dict(torrent_in.model_dump())
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from app.agent.prompt.transfer_redo import (
|
|||||||
build_batch_manual_redo_prompt,
|
build_batch_manual_redo_prompt,
|
||||||
build_manual_redo_prompt,
|
build_manual_redo_prompt,
|
||||||
)
|
)
|
||||||
from app.runtime.config import settings, global_vars
|
from app.runtime.config import global_vars
|
||||||
|
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
|
||||||
|
from app.application.configuration import ApiRuntimeConfig
|
||||||
from app.adapters.web.security.access import verify_token
|
from app.adapters.web.security.access import verify_token
|
||||||
from app.api.dependencies.auth import (
|
from app.api.dependencies.auth import (
|
||||||
get_current_active_manage_user,
|
get_current_active_manage_user,
|
||||||
@@ -244,12 +246,14 @@ def delete_transfer_history(
|
|||||||
async def ai_redo_transfer_history(
|
async def ai_redo_transfer_history(
|
||||||
history_id: int,
|
history_id: int,
|
||||||
query: HistoryQueryService = Depends(get_history_query_service),
|
query: HistoryQueryService = Depends(get_history_query_service),
|
||||||
|
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
|
||||||
_: object = Depends(get_current_active_manage_user),
|
_: object = Depends(get_current_active_manage_user),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
手动触发单条历史记录的 AI 重新整理,并返回进度键。
|
手动触发单条历史记录的 AI 重新整理,并返回进度键。
|
||||||
"""
|
"""
|
||||||
if not settings.AI_AGENT_ENABLE:
|
runtime_config = resolve_api_runtime_config(runtime_config)
|
||||||
|
if not runtime_config.ai_agent_enable:
|
||||||
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
|
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
|
||||||
|
|
||||||
history = await query.get_transfer(history_id)
|
history = await query.get_transfer(history_id)
|
||||||
@@ -275,12 +279,14 @@ async def ai_redo_transfer_history(
|
|||||||
async def batch_ai_redo_transfer_history(
|
async def batch_ai_redo_transfer_history(
|
||||||
payload: _SchemaBatchTransferHistoryRedoRequest,
|
payload: _SchemaBatchTransferHistoryRedoRequest,
|
||||||
query: HistoryQueryService = Depends(get_history_query_service),
|
query: HistoryQueryService = Depends(get_history_query_service),
|
||||||
|
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
|
||||||
_: object = Depends(get_current_active_manage_user),
|
_: object = Depends(get_current_active_manage_user),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
|
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
|
||||||
"""
|
"""
|
||||||
if not settings.AI_AGENT_ENABLE:
|
runtime_config = resolve_api_runtime_config(runtime_config)
|
||||||
|
if not runtime_config.ai_agent_enable:
|
||||||
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
|
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
|
||||||
|
|
||||||
history_ids = normalize_history_ids(payload.history_ids)
|
history_ids = normalize_history_ids(payload.history_ids)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
|||||||
from app.chain.user import MfaRequired, UserChain
|
from app.chain.user import MfaRequired, UserChain
|
||||||
from app.adapters.web.security.access import set_or_refresh_resource_token_cookie
|
from app.adapters.web.security.access import set_or_refresh_resource_token_cookie
|
||||||
from app.application.security.token import create_access_token
|
from app.application.security.token import create_access_token
|
||||||
from app.runtime.config import settings
|
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
|
||||||
from app.application.configuration import get_configured_system_config
|
from app.application.configuration import ApiRuntimeConfig, get_configured_system_config
|
||||||
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
|
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
|
||||||
from app.application.image import WallpaperHelper
|
from app.application.image import WallpaperHelper
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
@@ -39,10 +39,12 @@ def login_access_token(
|
|||||||
response: Response,
|
response: Response,
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
otp_password: Annotated[str | None, Form()] = None,
|
otp_password: Annotated[str | None, Form()] = None,
|
||||||
|
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
获取认证Token
|
获取认证Token
|
||||||
"""
|
"""
|
||||||
|
runtime_config = resolve_api_runtime_config(runtime_config)
|
||||||
success, user_or_message = UserChain().user_authenticate(
|
success, user_or_message = UserChain().user_authenticate(
|
||||||
username=form_data.username, password=form_data.password, mfa_code=otp_password
|
username=form_data.username, password=form_data.password, mfa_code=otp_password
|
||||||
)
|
)
|
||||||
@@ -69,13 +71,13 @@ def login_access_token(
|
|||||||
# 是否显示配置向导
|
# 是否显示配置向导
|
||||||
show_wizard = (
|
show_wizard = (
|
||||||
not get_configured_system_config().get(SystemConfigKey.SetupWizardState)
|
not get_configured_system_config().get(SystemConfigKey.SetupWizardState)
|
||||||
and not settings.ADVANCED_MODE
|
and not runtime_config.advanced_mode
|
||||||
)
|
)
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
userid=user_or_message.id,
|
userid=user_or_message.id,
|
||||||
username=user_or_message.name,
|
username=user_or_message.name,
|
||||||
super_user=user_or_message.is_superuser,
|
super_user=user_or_message.is_superuser,
|
||||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=runtime_config.access_token_expire_minutes),
|
||||||
level=level,
|
level=level,
|
||||||
)
|
)
|
||||||
set_or_refresh_resource_token_cookie(
|
set_or_refresh_resource_token_cookie(
|
||||||
|
|||||||
+34
-41
@@ -84,6 +84,39 @@ def create_jsonrpc_error(
|
|||||||
return error
|
return error
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_jsonrpc_method(
|
||||||
|
method: Any,
|
||||||
|
params: Dict[str, Any],
|
||||||
|
request_id: Union[str, int, None],
|
||||||
|
) -> Union[JSONResponse, Response]:
|
||||||
|
"""分派一个已经通过基础格式校验的 MCP JSON-RPC 方法。"""
|
||||||
|
if method == "initialize":
|
||||||
|
result = await handle_initialize(params)
|
||||||
|
return JSONResponse(content=create_jsonrpc_response(request_id, result))
|
||||||
|
if method == "notifications/initialized":
|
||||||
|
if request_id is None:
|
||||||
|
return Response(status_code=204)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=create_jsonrpc_error(
|
||||||
|
request_id, -32600, "initialized must be a notification"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if method == "tools/list":
|
||||||
|
result = await handle_tools_list()
|
||||||
|
return JSONResponse(content=create_jsonrpc_response(request_id, result))
|
||||||
|
if method == "tools/call":
|
||||||
|
result = await handle_tools_call(params)
|
||||||
|
return JSONResponse(content=create_jsonrpc_response(request_id, result))
|
||||||
|
if method == "ping":
|
||||||
|
return JSONResponse(content=create_jsonrpc_response(request_id, {}))
|
||||||
|
return JSONResponse(
|
||||||
|
content=create_jsonrpc_error(
|
||||||
|
request_id, -32601, f"Method not found: {method}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
summary="MCP JSON-RPC 端点",
|
summary="MCP JSON-RPC 端点",
|
||||||
@@ -130,48 +163,8 @@ async def mcp_jsonrpc(
|
|||||||
params = body.get("params", {})
|
params = body.get("params", {})
|
||||||
request_id = body.get("id")
|
request_id = body.get("id")
|
||||||
|
|
||||||
# 如果有 id,则为请求;没有 id 则为通知
|
|
||||||
is_notification = request_id is None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 处理初始化请求
|
return await _dispatch_jsonrpc_method(method, params, request_id)
|
||||||
if method == "initialize":
|
|
||||||
result = await handle_initialize(params)
|
|
||||||
return JSONResponse(content=create_jsonrpc_response(request_id, result))
|
|
||||||
|
|
||||||
# 处理已初始化通知
|
|
||||||
elif method == "notifications/initialized":
|
|
||||||
if is_notification:
|
|
||||||
return Response(status_code=204)
|
|
||||||
else:
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=400,
|
|
||||||
content=create_jsonrpc_error(
|
|
||||||
request_id, -32600, "initialized must be a notification"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 处理工具列表请求
|
|
||||||
if method == "tools/list":
|
|
||||||
result = await handle_tools_list()
|
|
||||||
return JSONResponse(content=create_jsonrpc_response(request_id, result))
|
|
||||||
|
|
||||||
# 处理工具调用请求
|
|
||||||
elif method == "tools/call":
|
|
||||||
result = await handle_tools_call(params)
|
|
||||||
return JSONResponse(content=create_jsonrpc_response(request_id, result))
|
|
||||||
|
|
||||||
# 处理 ping 请求
|
|
||||||
elif method == "ping":
|
|
||||||
return JSONResponse(content=create_jsonrpc_response(request_id, {}))
|
|
||||||
|
|
||||||
# 未知方法
|
|
||||||
else:
|
|
||||||
return JSONResponse(
|
|
||||||
content=create_jsonrpc_error(
|
|
||||||
request_id, -32601, f"Method not found: {method}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"MCP 请求参数错误: {e}")
|
logger.warning(f"MCP 请求参数错误: {e}")
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from app.application.chain.data import ChainDataPorts
|
from app.application.chain.data import ChainDataPorts
|
||||||
from app.application.chain.durable_events import ChainDurableEventWriter
|
from app.application.chain.durable_events import ChainDurableEventWriter
|
||||||
|
from app.application.configuration import ChainRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
MessageQueueFactory = Callable[[Callable[..., Any]], Any]
|
MessageQueueFactory = Callable[[Callable[..., Any]], Any]
|
||||||
@@ -30,6 +31,9 @@ class ChainRuntimeContext:
|
|||||||
module_dispatcher_factory: ModuleDispatcherFactory
|
module_dispatcher_factory: ModuleDispatcherFactory
|
||||||
data_ports: Optional[ChainDataPorts] = None
|
data_ports: Optional[ChainDataPorts] = None
|
||||||
durable_event_writer: Optional[ChainDurableEventWriter] = None
|
durable_event_writer: Optional[ChainDurableEventWriter] = None
|
||||||
|
configuration: ChainRuntimeConfig = field(
|
||||||
|
default_factory=lambda: ChainRuntimeConfig(media_extensions=())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _unconfigured_chain_runtime_context() -> ChainRuntimeContext:
|
def _unconfigured_chain_runtime_context() -> ChainRuntimeContext:
|
||||||
|
|||||||
@@ -41,6 +41,56 @@ class TransferRetryConfig:
|
|||||||
max_failed_retries: Any
|
max_failed_retries: Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApiRuntimeConfig:
|
||||||
|
"""单次 API 请求使用的宿主配置快照。"""
|
||||||
|
|
||||||
|
advanced_mode: bool
|
||||||
|
access_token_expire_minutes: int
|
||||||
|
btrfs_fsid_dedup: bool
|
||||||
|
ai_agent_enable: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SchedulerRuntimeConfig:
|
||||||
|
"""一次 Scheduler 初始化或任务注册使用的稳定配置快照。"""
|
||||||
|
|
||||||
|
dev: bool
|
||||||
|
timezone: str
|
||||||
|
scheduler_workers: int
|
||||||
|
db_backup_enable: bool
|
||||||
|
db_backup_cron: str
|
||||||
|
cookiecloud_interval: Any
|
||||||
|
mediaserver_sync_interval: Any
|
||||||
|
subscribe_search: bool
|
||||||
|
subscribe_search_interval: Any
|
||||||
|
subscribe_mode: str
|
||||||
|
subscribe_rss_interval: int
|
||||||
|
data_cleanup_enable: bool
|
||||||
|
sitedata_refresh_interval: Any
|
||||||
|
memory_gc_interval: Any
|
||||||
|
ai_agent_enable: bool
|
||||||
|
ai_agent_job_interval: Any
|
||||||
|
usage_statistic_share: bool
|
||||||
|
site_link: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ChainRuntimeConfig:
|
||||||
|
"""Chain 在一次宿主生命周期内使用的基础配置快照。"""
|
||||||
|
|
||||||
|
media_extensions: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeConfiguration:
|
||||||
|
"""由启动组合根提供的 API、Scheduler 与 Chain 配置快照工厂。"""
|
||||||
|
|
||||||
|
api: Callable[[], ApiRuntimeConfig]
|
||||||
|
scheduler: Callable[[], SchedulerRuntimeConfig]
|
||||||
|
chain: Callable[[], ChainRuntimeConfig]
|
||||||
|
|
||||||
|
|
||||||
class SystemConfigService:
|
class SystemConfigService:
|
||||||
"""系统配置读写应用服务。"""
|
"""系统配置读写应用服务。"""
|
||||||
|
|
||||||
@@ -82,6 +132,7 @@ class SystemConfigService:
|
|||||||
|
|
||||||
_configured_system_config: SystemConfigService | None = None
|
_configured_system_config: SystemConfigService | None = None
|
||||||
_transfer_retry_config_provider: Callable[[], TransferRetryConfig] | None = None
|
_transfer_retry_config_provider: Callable[[], TransferRetryConfig] | None = None
|
||||||
|
_runtime_configuration: RuntimeConfiguration | None = None
|
||||||
|
|
||||||
|
|
||||||
def configure_system_config(service: SystemConfigService) -> None:
|
def configure_system_config(service: SystemConfigService) -> None:
|
||||||
@@ -110,3 +161,23 @@ def get_transfer_retry_config() -> TransferRetryConfig:
|
|||||||
if _transfer_retry_config_provider is None:
|
if _transfer_retry_config_provider is None:
|
||||||
raise RuntimeError("整理失败重试配置尚未装配")
|
raise RuntimeError("整理失败重试配置尚未装配")
|
||||||
return _transfer_retry_config_provider()
|
return _transfer_retry_config_provider()
|
||||||
|
|
||||||
|
|
||||||
|
def configure_runtime_configuration(configuration: RuntimeConfiguration) -> None:
|
||||||
|
"""由启动组合根登记各运行面使用的类型化配置快照工厂。"""
|
||||||
|
global _runtime_configuration
|
||||||
|
_runtime_configuration = configuration
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler_runtime_config() -> SchedulerRuntimeConfig:
|
||||||
|
"""为一次调度操作创建不可变配置快照。"""
|
||||||
|
if _runtime_configuration is None:
|
||||||
|
raise RuntimeError("运行时配置尚未装配")
|
||||||
|
return _runtime_configuration.scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_runtime_config_snapshot() -> ApiRuntimeConfig:
|
||||||
|
"""为一次 API 调用创建不可变配置快照。"""
|
||||||
|
if _runtime_configuration is None:
|
||||||
|
raise RuntimeError("运行时配置尚未装配")
|
||||||
|
return _runtime_configuration.api()
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
|
|||||||
self.pluginmanager = context.plugin_manager
|
self.pluginmanager = context.plugin_manager
|
||||||
self.filecache = context.file_cache
|
self.filecache = context.file_cache
|
||||||
self.async_filecache = context.async_file_cache
|
self.async_filecache = context.async_file_cache
|
||||||
|
self.runtime_config = context.configuration
|
||||||
self.data_ports = context.data_ports or get_chain_data_ports()
|
self.data_ports = context.data_ports or get_chain_data_ports()
|
||||||
self.durable_event_writer = context.durable_event_writer
|
self.durable_event_writer = context.durable_event_writer
|
||||||
self._module_dispatcher = context.module_dispatcher_factory(
|
self._module_dispatcher = context.module_dispatcher_factory(
|
||||||
|
|||||||
+81
-67
@@ -1038,6 +1038,51 @@ class DownloadChain(ChainBase):
|
|||||||
# 返回 种子文件路径,种子目录名,种子文件清单
|
# 返回 种子文件路径,种子目录名,种子文件清单
|
||||||
return content, download_folder, files
|
return content, download_folder, files
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_resource_download_event(
|
||||||
|
context: Context,
|
||||||
|
episodes: Optional[Set[int]],
|
||||||
|
channel: Optional[NotificationChannel],
|
||||||
|
source: Optional[str],
|
||||||
|
downloader: Optional[str],
|
||||||
|
save_path: Optional[str],
|
||||||
|
userid: Union[str, int, None],
|
||||||
|
username: Optional[str],
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""应用资源下载事件覆盖,并校验事件返回的下载目录。"""
|
||||||
|
event_data = ResourceDownloadEventData(
|
||||||
|
context=context,
|
||||||
|
episodes=episodes or context.meta_info.episode_list,
|
||||||
|
channel=channel,
|
||||||
|
origin=source,
|
||||||
|
downloader=downloader,
|
||||||
|
options={
|
||||||
|
"save_path": save_path,
|
||||||
|
"userid": userid,
|
||||||
|
"username": username,
|
||||||
|
"media_category": context.media_info.category,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
|
||||||
|
if event and event.event_data:
|
||||||
|
event_data = event.event_data
|
||||||
|
if event_data.cancel:
|
||||||
|
logger.debug(
|
||||||
|
"Resource download canceled by event: %s,Reason: %s",
|
||||||
|
event_data.source,
|
||||||
|
event_data.reason,
|
||||||
|
)
|
||||||
|
return save_path, "下载被事件取消"
|
||||||
|
if event_data.options and "save_path" in event_data.options:
|
||||||
|
save_path = event_data.options.get("save_path")
|
||||||
|
if save_path is None:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
return validate_download_save_path(save_path), None
|
||||||
|
except ValueError as err:
|
||||||
|
logger.warn(str(err))
|
||||||
|
return save_path, str(err)
|
||||||
|
|
||||||
def download_single(self, context: Context,
|
def download_single(self, context: Context,
|
||||||
torrent_file: Path = None,
|
torrent_file: Path = None,
|
||||||
torrent_content: Optional[Union[str, bytes]] = None,
|
torrent_content: Optional[Union[str, bytes]] = None,
|
||||||
@@ -1077,40 +1122,12 @@ class DownloadChain(ChainBase):
|
|||||||
_media = MediaChain().supplement_tmdb_info(_media, _meta)
|
_media = MediaChain().supplement_tmdb_info(_media, _meta)
|
||||||
context.media_info = _media
|
context.media_info = _media
|
||||||
|
|
||||||
# 发送资源下载事件,允许外部拦截下载
|
save_path, event_error = self._apply_resource_download_event(
|
||||||
event_data = ResourceDownloadEventData(
|
context, episodes, channel, source, downloader, save_path,
|
||||||
context=context,
|
userid, username,
|
||||||
episodes=episodes or context.meta_info.episode_list,
|
|
||||||
channel=channel,
|
|
||||||
origin=source,
|
|
||||||
downloader=downloader,
|
|
||||||
options={
|
|
||||||
"save_path": save_path,
|
|
||||||
"userid": userid,
|
|
||||||
"username": username,
|
|
||||||
"media_category": _media.category
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
# 触发资源下载事件
|
if event_error:
|
||||||
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
|
return (None, event_error) if return_detail else None
|
||||||
if event and event.event_data:
|
|
||||||
event_data: ResourceDownloadEventData = event.event_data
|
|
||||||
# 如果事件被取消,跳过资源下载
|
|
||||||
if event_data.cancel:
|
|
||||||
logger.debug(
|
|
||||||
f"Resource download canceled by event: {event_data.source},"
|
|
||||||
f"Reason: {event_data.reason}")
|
|
||||||
return (None, "下载被事件取消") if return_detail else None
|
|
||||||
# 如果事件修改了下载路径,使用新路径
|
|
||||||
if event_data.options and "save_path" in event_data.options:
|
|
||||||
save_path = event_data.options.get("save_path")
|
|
||||||
|
|
||||||
if save_path is not None:
|
|
||||||
try:
|
|
||||||
save_path = validate_download_save_path(save_path)
|
|
||||||
except ValueError as err:
|
|
||||||
logger.warn(str(err))
|
|
||||||
return (None, str(err)) if return_detail else None
|
|
||||||
|
|
||||||
# 实际下载的集数
|
# 实际下载的集数
|
||||||
download_episodes = episode_rules.format_ranges(list(episodes)) if episodes else None
|
download_episodes = episode_rules.format_ranges(list(episodes)) if episodes else None
|
||||||
@@ -1913,34 +1930,6 @@ class DownloadChain(ChainBase):
|
|||||||
logger.error("电视剧缺集检查需要有效的 media_source 和 media_id")
|
logger.error("电视剧缺集检查需要有效的 media_source 和 media_id")
|
||||||
return False, no_exists or {}
|
return False, no_exists or {}
|
||||||
|
|
||||||
def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int):
|
|
||||||
"""
|
|
||||||
添加不存在的季集信息
|
|
||||||
{source:id: [
|
|
||||||
"season": int,
|
|
||||||
"episodes": list,
|
|
||||||
"total_episode": int,
|
|
||||||
"start_episode": int
|
|
||||||
]}
|
|
||||||
"""
|
|
||||||
mediakey = build_media_key(media_source, media_id)
|
|
||||||
if not no_exists.get(mediakey):
|
|
||||||
no_exists[mediakey] = {
|
|
||||||
_season: NotExistMediaInfo(
|
|
||||||
season=_season,
|
|
||||||
episodes=_episodes,
|
|
||||||
total_episode=_total,
|
|
||||||
start_episode=_start
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
no_exists[mediakey][_season] = NotExistMediaInfo(
|
|
||||||
season=_season,
|
|
||||||
episodes=_episodes,
|
|
||||||
total_episode=_total,
|
|
||||||
start_episode=_start
|
|
||||||
)
|
|
||||||
|
|
||||||
if not no_exists:
|
if not no_exists:
|
||||||
no_exists = {}
|
no_exists = {}
|
||||||
|
|
||||||
@@ -2006,8 +1995,10 @@ class DownloadChain(ChainBase):
|
|||||||
continue
|
continue
|
||||||
# 总集数
|
# 总集数
|
||||||
total_ep = totals.get(season) or len(episodes)
|
total_ep = totals.get(season) or len(episodes)
|
||||||
__append_no_exists(_season=season, _episodes=[],
|
self._append_no_exists(
|
||||||
_total=total_ep, _start=min(episodes))
|
no_exists, media_source, media_id, season, [],
|
||||||
|
total_ep, min(episodes)
|
||||||
|
)
|
||||||
return False, no_exists
|
return False, no_exists
|
||||||
else:
|
else:
|
||||||
# 存在一些,检查每季缺失的季集
|
# 存在一些,检查每季缺失的季集
|
||||||
@@ -2035,12 +2026,16 @@ class DownloadChain(ChainBase):
|
|||||||
# 全部集存在
|
# 全部集存在
|
||||||
continue
|
continue
|
||||||
# 添加不存在的季集信息
|
# 添加不存在的季集信息
|
||||||
__append_no_exists(_season=season, _episodes=lack_episodes,
|
self._append_no_exists(
|
||||||
_total=season_total, _start=min(lack_episodes))
|
no_exists, media_source, media_id, season,
|
||||||
|
lack_episodes, season_total, min(lack_episodes)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 全季不存在
|
# 全季不存在
|
||||||
__append_no_exists(_season=season, _episodes=[],
|
self._append_no_exists(
|
||||||
_total=season_total, _start=min(episodes))
|
no_exists, media_source, media_id, season, [],
|
||||||
|
season_total, min(episodes)
|
||||||
|
)
|
||||||
# 存在不完整的剧集
|
# 存在不完整的剧集
|
||||||
if no_exists:
|
if no_exists:
|
||||||
logger.debug(f"媒体库中已存在部分剧集,缺失:{no_exists}")
|
logger.debug(f"媒体库中已存在部分剧集,缺失:{no_exists}")
|
||||||
@@ -2048,6 +2043,25 @@ class DownloadChain(ChainBase):
|
|||||||
# 全部存在
|
# 全部存在
|
||||||
return True, no_exists
|
return True, no_exists
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _append_no_exists(
|
||||||
|
no_exists: Dict[str, Dict[int, NotExistMediaInfo]],
|
||||||
|
media_source: Optional[MediaSource],
|
||||||
|
media_id: Optional[str],
|
||||||
|
season: int,
|
||||||
|
episodes: list,
|
||||||
|
total: int,
|
||||||
|
start: int,
|
||||||
|
) -> None:
|
||||||
|
"""把一季缺失信息合并到标准媒体身份对应的结果中。"""
|
||||||
|
media_key = build_media_key(media_source, media_id)
|
||||||
|
no_exists.setdefault(media_key, {})[season] = NotExistMediaInfo(
|
||||||
|
season=season,
|
||||||
|
episodes=episodes,
|
||||||
|
total_episode=total,
|
||||||
|
start_episode=start,
|
||||||
|
)
|
||||||
|
|
||||||
def remote_downloading(self, channel: NotificationChannel, userid: Union[str, int] = None, source: Optional[str] = None):
|
def remote_downloading(self, channel: NotificationChannel, userid: Union[str, int] = None, source: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
查询正在下载的任务,并发送消息
|
查询正在下载的任务,并发送消息
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from typing import Any, Optional, List, Dict
|
|||||||
|
|
||||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.runtime.config import settings
|
|
||||||
from app.application.directory import DirectoryHelper
|
from app.application.directory import DirectoryHelper
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
|
|
||||||
@@ -148,7 +147,7 @@ class StorageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
删除媒体文件,以及不含媒体文件的目录
|
删除媒体文件,以及不含媒体文件的目录
|
||||||
"""
|
"""
|
||||||
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
|
media_exts = self.runtime_config.media_extensions
|
||||||
fileitem_path = Path(fileitem.path) if fileitem.path else Path("")
|
fileitem_path = Path(fileitem.path) if fileitem.path else Path("")
|
||||||
if len(fileitem_path.parts) <= 2:
|
if len(fileitem_path.parts) <= 2:
|
||||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除")
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy import Float, Index, Integer, String, delete, select
|
|||||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base, execute_dml, get_id_column
|
from app.db.base import Base, execute_dml, get_id_column
|
||||||
from app.db.decorators import db_query, db_update
|
from app.db.decorators import db_update
|
||||||
from app.db.models._constraints import media_identity_constraint
|
from app.db.models._constraints import media_identity_constraint
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +62,6 @@ class DownloadFailure(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@db_query
|
|
||||||
def get_active_by_fingerprints(
|
def get_active_by_fingerprints(
|
||||||
cls,
|
cls,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -81,7 +80,6 @@ class DownloadFailure(Base):
|
|||||||
).scalars().all())
|
).scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@db_update
|
|
||||||
def record_failure(
|
def record_failure(
|
||||||
cls,
|
cls,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -63,31 +63,33 @@ _METHOD_CONTRACTS = {
|
|||||||
"recognize_media": ModuleMethodContract(
|
"recognize_media": ModuleMethodContract(
|
||||||
family="media-recognition", input_contract="MediaRecognitionRequest",
|
family="media-recognition", input_contract="MediaRecognitionRequest",
|
||||||
result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
|
result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
|
||||||
|
required_parameters=("meta", "mtype", "media_source", "media_id", "episode_group", "cache"),
|
||||||
),
|
),
|
||||||
"search_medias": ModuleMethodContract(
|
"search_medias": ModuleMethodContract(
|
||||||
family="media-recognition", input_contract="MediaSearchRequest",
|
family="media-recognition", input_contract="MediaSearchRequest",
|
||||||
result_contract="list[MediaInfo]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE,
|
result_contract="list[MediaInfo]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE,
|
||||||
|
required_parameters=("meta", "media_source"),
|
||||||
),
|
),
|
||||||
"obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None"),
|
"obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None", required_parameters=("mediainfo",)),
|
||||||
"media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"),
|
"media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"),
|
||||||
"mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
"mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "library_id", "start_index", "limit")),
|
||||||
"mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
|
||||||
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
|
||||||
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "item_id")),
|
||||||
"download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path")),
|
||||||
"upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path", "new_name")),
|
||||||
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("fileitem", "recursion")),
|
||||||
"get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")),
|
||||||
"get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")),
|
||||||
"get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem",)),
|
||||||
"rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "name")),
|
||||||
"storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="Any", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="Any", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "action")),
|
||||||
"snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path", "last_snapshot_time", "max_depth", "previous_snapshot")),
|
||||||
"send_message": ModuleMethodContract(family="messaging", input_contract="MessageSendRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"send_message": ModuleMethodContract(family="messaging", input_contract="MessageSendRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("response",)),
|
||||||
"register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None"),
|
"register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None", required_parameters=("commands",)),
|
||||||
"scheduler_job": ModuleMethodContract(family="scheduling", input_contract="SchedulerJobRequest", result_contract="None"),
|
"scheduler_job": ModuleMethodContract(family="scheduling", input_contract="SchedulerJobRequest", result_contract="None"),
|
||||||
"webhook_parser": ModuleMethodContract(family="integration", input_contract="WebhookRequest", result_contract="WebhookEventInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
"webhook_parser": ModuleMethodContract(family="integration", input_contract="WebhookRequest", result_contract="WebhookEventInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("body", "form", "args")),
|
||||||
}
|
}
|
||||||
|
|
||||||
_PREFIX_CONTRACTS = (
|
_PREFIX_CONTRACTS = (
|
||||||
|
|||||||
+51
-45
@@ -24,13 +24,17 @@ from app.chain.site import SiteChain
|
|||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
from app.chain.workflow import WorkflowChain
|
from app.chain.workflow import WorkflowChain
|
||||||
from app.runtime.config import settings, global_vars
|
from app.runtime.config import global_vars
|
||||||
from app.runtime.events import Event, eventmanager
|
from app.runtime.events import Event, eventmanager
|
||||||
from app.runtime.extensions.plugin_manager import PluginManager
|
from app.runtime.extensions.plugin_manager import PluginManager
|
||||||
from app.db.oper.agenttask import AgentTaskOper
|
from app.db.oper.agenttask import AgentTaskOper
|
||||||
from app.db.oper.systemconfig import SystemConfigOper
|
|
||||||
from app.application.database import get_database_governance
|
from app.application.database import get_database_governance
|
||||||
from app.application.outbox import dispatch_pending_outbox
|
from app.application.outbox import dispatch_pending_outbox
|
||||||
|
from app.application.configuration import (
|
||||||
|
SchedulerRuntimeConfig,
|
||||||
|
get_configured_system_config,
|
||||||
|
get_scheduler_runtime_config,
|
||||||
|
)
|
||||||
from app.application.image import WallpaperHelper
|
from app.application.image import WallpaperHelper
|
||||||
from app.application.messaging.message import MessageHelper
|
from app.application.messaging.message import MessageHelper
|
||||||
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
|
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
|
||||||
@@ -209,9 +213,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
"""按当前宿主策略创建一次定时数据库备份。"""
|
"""按当前宿主策略创建一次定时数据库备份。"""
|
||||||
return get_database_governance().create_backup()
|
return get_database_governance().create_backup()
|
||||||
|
|
||||||
def _register_database_backup_job(self) -> None:
|
def _register_database_backup_job(
|
||||||
|
self,
|
||||||
|
config: SchedulerRuntimeConfig,
|
||||||
|
) -> None:
|
||||||
"""在共享调度器中按当前配置维护唯一的数据库备份作业。"""
|
"""在共享调度器中按当前配置维护唯一的数据库备份作业。"""
|
||||||
if not settings.DB_BACKUP_ENABLE or not settings.DB_BACKUP_CRON.strip():
|
if not config.db_backup_enable or not config.db_backup_cron.strip():
|
||||||
return
|
return
|
||||||
|
|
||||||
job_id = "database_backup"
|
job_id = "database_backup"
|
||||||
@@ -226,8 +233,8 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
self.start,
|
self.start,
|
||||||
trigger=TimerUtils.build_schedule_trigger(
|
trigger=TimerUtils.build_schedule_trigger(
|
||||||
trigger_type="cron",
|
trigger_type="cron",
|
||||||
trigger_value=settings.DB_BACKUP_CRON,
|
trigger_value=config.db_backup_cron,
|
||||||
timezone_name=settings.TZ,
|
timezone_name=config.timezone,
|
||||||
),
|
),
|
||||||
id=job_id,
|
id=job_id,
|
||||||
name="数据库备份",
|
name="数据库备份",
|
||||||
@@ -240,11 +247,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
初始化定时服务
|
初始化定时服务
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
config = get_scheduler_runtime_config()
|
||||||
# 停止定时服务
|
# 停止定时服务
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
# 调试模式不启动定时服务
|
# 调试模式不启动定时服务
|
||||||
if settings.DEV:
|
if config.dev:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 对账上个进程未收口的 Agent 任务;进程内重复初始化不会重复改写状态。
|
# 对账上个进程未收口的 Agent 任务;进程内重复初始化不会重复改写状态。
|
||||||
@@ -277,11 +285,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
]).runtime_states()
|
]).runtime_states()
|
||||||
|
|
||||||
self._scheduler = BackgroundScheduler(
|
self._scheduler = BackgroundScheduler(
|
||||||
timezone=settings.TZ,
|
timezone=config.timezone,
|
||||||
executors={"default": ThreadPoolExecutor(settings.CONF.scheduler)},
|
executors={"default": ThreadPoolExecutor(config.scheduler_workers)},
|
||||||
)
|
)
|
||||||
|
|
||||||
self._register_database_backup_job()
|
self._register_database_backup_job(config)
|
||||||
self._jobs["outbox_dispatch"] = JobSpec(
|
self._jobs["outbox_dispatch"] = JobSpec(
|
||||||
"outbox_dispatch",
|
"outbox_dispatch",
|
||||||
"恢复待投递副作用",
|
"恢复待投递副作用",
|
||||||
@@ -295,30 +303,30 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
id="outbox_dispatch",
|
id="outbox_dispatch",
|
||||||
name="恢复待投递副作用",
|
name="恢复待投递副作用",
|
||||||
seconds=30,
|
seconds=30,
|
||||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)),
|
next_run_time=datetime.now(pytz.timezone(config.timezone)),
|
||||||
kwargs={"job_id": "outbox_dispatch"},
|
kwargs={"job_id": "outbox_dispatch"},
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CookieCloud定时同步
|
# CookieCloud定时同步
|
||||||
if (
|
if (
|
||||||
settings.COOKIECLOUD_INTERVAL
|
config.cookiecloud_interval
|
||||||
and str(settings.COOKIECLOUD_INTERVAL).isdigit()
|
and str(config.cookiecloud_interval).isdigit()
|
||||||
):
|
):
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
"interval",
|
"interval",
|
||||||
id="cookiecloud",
|
id="cookiecloud",
|
||||||
name="同步CookieCloud站点",
|
name="同步CookieCloud站点",
|
||||||
minutes=int(settings.COOKIECLOUD_INTERVAL),
|
minutes=int(config.cookiecloud_interval),
|
||||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
|
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=5),
|
||||||
kwargs={"job_id": "cookiecloud"},
|
kwargs={"job_id": "cookiecloud"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 按媒体服务器分别注册自动同步任务
|
# 按媒体服务器分别注册自动同步任务
|
||||||
mediaserver_schedules = self._build_mediaserver_sync_schedules(
|
mediaserver_schedules = self._build_mediaserver_sync_schedules(
|
||||||
mediaservers=ServiceConfigHelper.get_mediaserver_configs(),
|
mediaservers=ServiceConfigHelper.get_mediaserver_configs(),
|
||||||
default_interval=settings.MEDIASERVER_SYNC_INTERVAL,
|
default_interval=config.mediaserver_sync_interval,
|
||||||
)
|
)
|
||||||
for mediaserver_schedule in mediaserver_schedules:
|
for mediaserver_schedule in mediaserver_schedules:
|
||||||
job_id = mediaserver_schedule["id"]
|
job_id = mediaserver_schedule["id"]
|
||||||
@@ -335,7 +343,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
id=job_id,
|
id=job_id,
|
||||||
name=mediaserver_schedule["name"],
|
name=mediaserver_schedule["name"],
|
||||||
hours=mediaserver_schedule["interval"],
|
hours=mediaserver_schedule["interval"],
|
||||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=10),
|
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=10),
|
||||||
kwargs={"job_id": job_id},
|
kwargs={"job_id": job_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -360,17 +368,17 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 订阅状态每隔24小时搜索一次
|
# 订阅状态每隔24小时搜索一次
|
||||||
if settings.SUBSCRIBE_SEARCH:
|
if config.subscribe_search:
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
"interval",
|
"interval",
|
||||||
id="subscribe_search",
|
id="subscribe_search",
|
||||||
name="订阅搜索补全",
|
name="订阅搜索补全",
|
||||||
hours=settings.SUBSCRIBE_SEARCH_INTERVAL,
|
hours=config.subscribe_search_interval,
|
||||||
kwargs={"job_id": "subscribe_search"},
|
kwargs={"job_id": "subscribe_search"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.SUBSCRIBE_MODE == "spider":
|
if config.subscribe_mode == "spider":
|
||||||
# 站点首页种子定时刷新模式
|
# 站点首页种子定时刷新模式
|
||||||
triggers = TimerUtils.random_scheduler(num_executions=32)
|
triggers = TimerUtils.random_scheduler(num_executions=32)
|
||||||
for trigger in triggers:
|
for trigger in triggers:
|
||||||
@@ -385,19 +393,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# RSS订阅模式
|
# RSS订阅模式
|
||||||
if (
|
|
||||||
not settings.SUBSCRIBE_RSS_INTERVAL
|
|
||||||
or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit()
|
|
||||||
):
|
|
||||||
settings.SUBSCRIBE_RSS_INTERVAL = 30
|
|
||||||
elif int(settings.SUBSCRIBE_RSS_INTERVAL) < 5:
|
|
||||||
settings.SUBSCRIBE_RSS_INTERVAL = 5
|
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
"interval",
|
"interval",
|
||||||
id="subscribe_refresh",
|
id="subscribe_refresh",
|
||||||
name="RSS订阅刷新",
|
name="RSS订阅刷新",
|
||||||
minutes=int(settings.SUBSCRIBE_RSS_INTERVAL),
|
minutes=config.subscribe_rss_interval,
|
||||||
kwargs={"job_id": "subscribe_refresh"},
|
kwargs={"job_id": "subscribe_refresh"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -428,7 +429,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
id="random_wallpager",
|
id="random_wallpager",
|
||||||
name="壁纸缓存",
|
name="壁纸缓存",
|
||||||
minutes=30,
|
minutes=30,
|
||||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=1),
|
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=1),
|
||||||
kwargs={"job_id": "random_wallpager"},
|
kwargs={"job_id": "random_wallpager"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -443,7 +444,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 数据表清理服务,每天凌晨执行一次
|
# 数据表清理服务,每天凌晨执行一次
|
||||||
if settings.DATA_CLEANUP_ENABLE:
|
if config.data_cleanup_enable:
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
"cron",
|
"cron",
|
||||||
@@ -465,13 +466,13 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 站点数据刷新
|
# 站点数据刷新
|
||||||
if settings.SITEDATA_REFRESH_INTERVAL:
|
if config.sitedata_refresh_interval:
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
"interval",
|
"interval",
|
||||||
id="sitedata_refresh",
|
id="sitedata_refresh",
|
||||||
name="站点数据刷新",
|
name="站点数据刷新",
|
||||||
minutes=settings.SITEDATA_REFRESH_INTERVAL * 60,
|
minutes=config.sitedata_refresh_interval * 60,
|
||||||
kwargs={"job_id": "sitedata_refresh"},
|
kwargs={"job_id": "sitedata_refresh"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -482,7 +483,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
id="recommend_refresh",
|
id="recommend_refresh",
|
||||||
name="推荐缓存",
|
name="推荐缓存",
|
||||||
hours=24,
|
hours=24,
|
||||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=5),
|
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=5),
|
||||||
kwargs={"job_id": "recommend_refresh"},
|
kwargs={"job_id": "recommend_refresh"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -503,34 +504,34 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
id="subscribe_calendar_cache",
|
id="subscribe_calendar_cache",
|
||||||
name="订阅日历缓存",
|
name="订阅日历缓存",
|
||||||
hours=6,
|
hours=6,
|
||||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=2),
|
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=2),
|
||||||
kwargs={"job_id": "subscribe_calendar_cache"},
|
kwargs={"job_id": "subscribe_calendar_cache"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 主动内存回收
|
# 主动内存回收
|
||||||
if settings.MEMORY_GC_INTERVAL:
|
if config.memory_gc_interval:
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
"interval",
|
"interval",
|
||||||
id="full_gc",
|
id="full_gc",
|
||||||
name="主动内存回收",
|
name="主动内存回收",
|
||||||
minutes=settings.MEMORY_GC_INTERVAL,
|
minutes=config.memory_gc_interval,
|
||||||
kwargs={"job_id": "full_gc"},
|
kwargs={"job_id": "full_gc"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 智能体定时任务检查
|
# 智能体定时任务检查
|
||||||
if settings.AI_AGENT_ENABLE and settings.AI_AGENT_JOB_INTERVAL:
|
if config.ai_agent_enable and config.ai_agent_job_interval:
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
"interval",
|
"interval",
|
||||||
id="agent_heartbeat",
|
id="agent_heartbeat",
|
||||||
name="智能体定时任务",
|
name="智能体定时任务",
|
||||||
hours=settings.AI_AGENT_JOB_INTERVAL,
|
hours=config.ai_agent_job_interval,
|
||||||
kwargs={"job_id": "agent_heartbeat"},
|
kwargs={"job_id": "agent_heartbeat"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 安装版本统计上报
|
# 安装版本统计上报
|
||||||
if settings.USAGE_STATISTIC_SHARE:
|
if config.usage_statistic_share:
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
"interval",
|
"interval",
|
||||||
@@ -544,7 +545,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
self.init_workflow_jobs()
|
self.init_workflow_jobs()
|
||||||
|
|
||||||
# 恢复 Agent 自主定时任务
|
# 恢复 Agent 自主定时任务
|
||||||
if settings.AI_AGENT_ENABLE:
|
if config.ai_agent_enable:
|
||||||
self.init_agent_task_jobs()
|
self.init_agent_task_jobs()
|
||||||
|
|
||||||
# 初始化插件服务
|
# 初始化插件服务
|
||||||
@@ -969,10 +970,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
:param task_id: Agent 定时任务 ID
|
:param task_id: Agent 定时任务 ID
|
||||||
:return: 下一次执行时间,不可调度时返回 None
|
:return: 下一次执行时间,不可调度时返回 None
|
||||||
"""
|
"""
|
||||||
|
config = get_scheduler_runtime_config()
|
||||||
self.remove_agent_task_job(task_id)
|
self.remove_agent_task_job(task_id)
|
||||||
task = AgentTaskOper().get(task_id)
|
task = AgentTaskOper().get(task_id)
|
||||||
if (
|
if (
|
||||||
not settings.AI_AGENT_ENABLE
|
not config.ai_agent_enable
|
||||||
or not task
|
or not task
|
||||||
or not task.enabled
|
or not task.enabled
|
||||||
or not self._scheduler
|
or not self._scheduler
|
||||||
@@ -989,7 +991,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
trigger = TimerUtils.build_schedule_trigger(
|
trigger = TimerUtils.build_schedule_trigger(
|
||||||
trigger_type=task.trigger_type,
|
trigger_type=task.trigger_type,
|
||||||
trigger_value=trigger_value,
|
trigger_value=trigger_value,
|
||||||
timezone_name=settings.TZ,
|
timezone_name=config.timezone,
|
||||||
)
|
)
|
||||||
except (TypeError, ValueError) as err:
|
except (TypeError, ValueError) as err:
|
||||||
logger.error(f"Agent 定时任务 {task_id} 的触发配置无效:{str(err)}")
|
logger.error(f"Agent 定时任务 {task_id} 的触发配置无效:{str(err)}")
|
||||||
@@ -1046,6 +1048,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
:param task_id: Agent 定时任务 ID
|
:param task_id: Agent 定时任务 ID
|
||||||
:return: 带时区的 ISO 8601 时间,不再执行时返回 None
|
:return: 带时区的 ISO 8601 时间,不再执行时返回 None
|
||||||
"""
|
"""
|
||||||
|
config = get_scheduler_runtime_config()
|
||||||
job_id = self._get_agent_task_job_id(task_id)
|
job_id = self._get_agent_task_job_id(task_id)
|
||||||
if self._scheduler:
|
if self._scheduler:
|
||||||
job = self._scheduler.get_job(job_id)
|
job = self._scheduler.get_job(job_id)
|
||||||
@@ -1065,7 +1068,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
next_run_time = TimerUtils.get_schedule_next_run_time(
|
next_run_time = TimerUtils.get_schedule_next_run_time(
|
||||||
trigger_type=task.trigger_type,
|
trigger_type=task.trigger_type,
|
||||||
trigger_value=trigger_value,
|
trigger_value=trigger_value,
|
||||||
timezone_name=settings.TZ,
|
timezone_name=config.timezone,
|
||||||
)
|
)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
@@ -1443,6 +1446,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
"""
|
"""
|
||||||
用户认证检查
|
用户认证检查
|
||||||
"""
|
"""
|
||||||
|
config = get_scheduler_runtime_config()
|
||||||
if SitesHelper().auth_level >= 2:
|
if SitesHelper().auth_level >= 2:
|
||||||
return
|
return
|
||||||
# 最大重试次数
|
# 最大重试次数
|
||||||
@@ -1457,7 +1461,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
self._auth_message = True
|
self._auth_message = True
|
||||||
return
|
return
|
||||||
logger.info("用户未认证,正在尝试认证...")
|
logger.info("用户未认证,正在尝试认证...")
|
||||||
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
|
auth_conf = get_configured_system_config().get(
|
||||||
|
SystemConfigKey.UserSiteAuthParams
|
||||||
|
)
|
||||||
if auth_conf:
|
if auth_conf:
|
||||||
status, msg = SitesHelper().check_user(**auth_conf)
|
status, msg = SitesHelper().check_user(**auth_conf)
|
||||||
else:
|
else:
|
||||||
@@ -1470,7 +1476,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
mtype=MessageType.Manual,
|
mtype=MessageType.Manual,
|
||||||
title="MoviePilot用户认证成功",
|
title="MoviePilot用户认证成功",
|
||||||
text=f"使用站点:{msg},如有插件使用异常,请重启MoviePilot。",
|
text=f"使用站点:{msg},如有插件使用异常,请重启MoviePilot。",
|
||||||
link=settings.MP_DOMAIN("#/site"),
|
link=config.site_link,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# 认证通过后重新初始化插件
|
# 认证通过后重新初始化插件
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.application.messaging.chat import (
|
|||||||
AsyncUnitOfWork,
|
AsyncUnitOfWork,
|
||||||
)
|
)
|
||||||
from app.application.outbox import AsyncOutboxTransaction
|
from app.application.outbox import AsyncOutboxTransaction
|
||||||
|
from app.application.configuration import RuntimeConfiguration
|
||||||
from app.application.subscription.delete import SubscribeDeletionRepository
|
from app.application.subscription.delete import SubscribeDeletionRepository
|
||||||
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
|
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
|
||||||
from app.application.subscription.mutation import (
|
from app.application.subscription.mutation import (
|
||||||
@@ -125,4 +126,5 @@ class HostRuntime:
|
|||||||
|
|
||||||
agent_chat: AgentChatRuntime
|
agent_chat: AgentChatRuntime
|
||||||
subscription: SubscriptionRuntime
|
subscription: SubscriptionRuntime
|
||||||
|
configuration: RuntimeConfiguration
|
||||||
compatibility_api_data: CompatibilityApiData
|
compatibility_api_data: CompatibilityApiData
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""下载失败冷却切片的显式会话与事务适配器。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from app.db.oper.downloadfailure import DownloadFailureOper
|
||||||
|
from app.db.uow import SqlAlchemyUnitOfWork
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionalDownloadFailureRepository:
|
||||||
|
"""为 Chain 下载失败读写创建短生命周期会话并显式收口事务。"""
|
||||||
|
|
||||||
|
def __init__(self, session_factory: Callable[[], Any]) -> None:
|
||||||
|
"""保存由启动组合根提供的同步会话工厂。"""
|
||||||
|
self._session_factory = session_factory
|
||||||
|
|
||||||
|
def get_active_by_fingerprints(
|
||||||
|
self,
|
||||||
|
fingerprints: list[str],
|
||||||
|
now_time: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""在独立只读会话中查询仍处于冷却期的失败记录。"""
|
||||||
|
with self._session_factory() as session:
|
||||||
|
return cast(
|
||||||
|
dict[str, Any],
|
||||||
|
DownloadFailureOper(db=session).get_active_by_fingerprints(
|
||||||
|
fingerprints=fingerprints,
|
||||||
|
now_time=now_time,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_failure(
|
||||||
|
self,
|
||||||
|
fingerprint: str,
|
||||||
|
now_time: str,
|
||||||
|
next_retry_at: str,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> Any:
|
||||||
|
"""在一个显式 UoW 中新增或更新下载失败记录。"""
|
||||||
|
with self._session_factory() as session:
|
||||||
|
transaction = SqlAlchemyUnitOfWork(session)
|
||||||
|
try:
|
||||||
|
failure = DownloadFailureOper(db=session).record_failure(
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
now_time=now_time,
|
||||||
|
next_retry_at=next_retry_at,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
transaction.commit()
|
||||||
|
return failure
|
||||||
|
except Exception:
|
||||||
|
transaction.rollback()
|
||||||
|
raise
|
||||||
@@ -36,8 +36,13 @@ from app.application.messaging.message import (
|
|||||||
stop_message,
|
stop_message,
|
||||||
)
|
)
|
||||||
from app.application.configuration import (
|
from app.application.configuration import (
|
||||||
|
ApiRuntimeConfig,
|
||||||
|
ChainRuntimeConfig,
|
||||||
|
RuntimeConfiguration,
|
||||||
|
SchedulerRuntimeConfig,
|
||||||
SystemConfigService,
|
SystemConfigService,
|
||||||
TransferRetryConfig,
|
TransferRetryConfig,
|
||||||
|
configure_runtime_configuration,
|
||||||
configure_system_config,
|
configure_system_config,
|
||||||
configure_transfer_retry_config,
|
configure_transfer_retry_config,
|
||||||
)
|
)
|
||||||
@@ -86,7 +91,6 @@ from app.db.oper.transferhistory import TransferHistoryOper
|
|||||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||||
from app.db.oper.transferpending import TransferPendingOper
|
from app.db.oper.transferpending import TransferPendingOper
|
||||||
from app.db.oper.mediaserver import MediaServerOper
|
from app.db.oper.mediaserver import MediaServerOper
|
||||||
from app.db.oper.downloadfailure import DownloadFailureOper
|
|
||||||
from app.db.oper.site import SiteOper
|
from app.db.oper.site import SiteOper
|
||||||
from app.db.oper.message import MessageOper
|
from app.db.oper.message import MessageOper
|
||||||
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
||||||
@@ -108,6 +112,7 @@ from app.startup.subscription import (
|
|||||||
configure_transactional_subscription_scopes,
|
configure_transactional_subscription_scopes,
|
||||||
)
|
)
|
||||||
from app.startup.chain_events import TransactionalChainDurableEventWriter
|
from app.startup.chain_events import TransactionalChainDurableEventWriter
|
||||||
|
from app.startup.download_failure import TransactionalDownloadFailureRepository
|
||||||
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
|
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
|
||||||
from app.adapters.web.security.access import set_superuser_token_payload_provider
|
from app.adapters.web.security.access import set_superuser_token_payload_provider
|
||||||
from app.application.security.auth import build_superuser_token_payload
|
from app.application.security.auth import build_superuser_token_payload
|
||||||
@@ -151,11 +156,68 @@ def _build_chain_runtime_context() -> ChainRuntimeContext:
|
|||||||
send_callback=callback
|
send_callback=callback
|
||||||
),
|
),
|
||||||
module_dispatcher_factory=ModuleInvocationDispatcher,
|
module_dispatcher_factory=ModuleInvocationDispatcher,
|
||||||
|
configuration=_build_chain_runtime_config(),
|
||||||
data_ports=get_chain_data_ports(),
|
data_ports=get_chain_data_ports(),
|
||||||
durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory),
|
durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_subscribe_rss_interval(value: object) -> int:
|
||||||
|
"""把无效或过小的 RSS 间隔收敛为兼容的安全值。"""
|
||||||
|
try:
|
||||||
|
return max(int(value), 5)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 30
|
||||||
|
|
||||||
|
|
||||||
|
def _build_api_runtime_config() -> ApiRuntimeConfig:
|
||||||
|
"""从可热更新 settings 构建一次 API 请求配置快照。"""
|
||||||
|
return ApiRuntimeConfig(
|
||||||
|
advanced_mode=settings.ADVANCED_MODE,
|
||||||
|
access_token_expire_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
|
||||||
|
ai_agent_enable=settings.AI_AGENT_ENABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scheduler_runtime_config() -> SchedulerRuntimeConfig:
|
||||||
|
"""从可热更新 settings 构建一次 Scheduler 操作配置快照。"""
|
||||||
|
return SchedulerRuntimeConfig(
|
||||||
|
dev=settings.DEV,
|
||||||
|
timezone=settings.TZ,
|
||||||
|
scheduler_workers=settings.CONF.scheduler,
|
||||||
|
db_backup_enable=settings.DB_BACKUP_ENABLE,
|
||||||
|
db_backup_cron=settings.DB_BACKUP_CRON,
|
||||||
|
cookiecloud_interval=settings.COOKIECLOUD_INTERVAL,
|
||||||
|
mediaserver_sync_interval=settings.MEDIASERVER_SYNC_INTERVAL,
|
||||||
|
subscribe_search=settings.SUBSCRIBE_SEARCH,
|
||||||
|
subscribe_search_interval=settings.SUBSCRIBE_SEARCH_INTERVAL,
|
||||||
|
subscribe_mode=settings.SUBSCRIBE_MODE,
|
||||||
|
subscribe_rss_interval=_normalize_subscribe_rss_interval(
|
||||||
|
settings.SUBSCRIBE_RSS_INTERVAL
|
||||||
|
),
|
||||||
|
data_cleanup_enable=settings.DATA_CLEANUP_ENABLE,
|
||||||
|
sitedata_refresh_interval=settings.SITEDATA_REFRESH_INTERVAL,
|
||||||
|
memory_gc_interval=settings.MEMORY_GC_INTERVAL,
|
||||||
|
ai_agent_enable=settings.AI_AGENT_ENABLE,
|
||||||
|
ai_agent_job_interval=settings.AI_AGENT_JOB_INTERVAL,
|
||||||
|
usage_statistic_share=settings.USAGE_STATISTIC_SHARE,
|
||||||
|
site_link=settings.MP_DOMAIN("#/site"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chain_runtime_config() -> ChainRuntimeConfig:
|
||||||
|
"""构建 Chain 通用媒体文件后缀配置快照。"""
|
||||||
|
return ChainRuntimeConfig(
|
||||||
|
media_extensions=tuple(
|
||||||
|
settings.RMT_MEDIAEXT
|
||||||
|
+ settings.DOWNLOAD_TMPEXT
|
||||||
|
+ settings.RMT_SUBEXT
|
||||||
|
+ settings.RMT_AUDIOEXT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configure_runtime_data_providers() -> None:
|
def configure_runtime_data_providers() -> None:
|
||||||
"""在启动组合层装配运行时和外部服务所需的数据库读取能力。"""
|
"""在启动组合层装配运行时和外部服务所需的数据库读取能力。"""
|
||||||
configure_service_config_reader(lambda key: SystemConfigOper().get(key))
|
configure_service_config_reader(lambda key: SystemConfigOper().get(key))
|
||||||
@@ -478,6 +540,11 @@ async def init_modules() -> HostRuntime:
|
|||||||
"sync": SqlAlchemyUnitOfWork,
|
"sync": SqlAlchemyUnitOfWork,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
runtime_configuration = RuntimeConfiguration(
|
||||||
|
api=_build_api_runtime_config,
|
||||||
|
scheduler=_build_scheduler_runtime_config,
|
||||||
|
chain=_build_chain_runtime_config,
|
||||||
|
)
|
||||||
host_runtime = HostRuntime(
|
host_runtime = HostRuntime(
|
||||||
agent_chat=AgentChatRuntime(
|
agent_chat=AgentChatRuntime(
|
||||||
async_session=get_async_db,
|
async_session=get_async_db,
|
||||||
@@ -491,8 +558,10 @@ async def init_modules() -> HostRuntime:
|
|||||||
transaction=SqlAlchemyAsyncUnitOfWork,
|
transaction=SqlAlchemyAsyncUnitOfWork,
|
||||||
outbox=SqlAlchemyAsyncOutboxStager,
|
outbox=SqlAlchemyAsyncOutboxStager,
|
||||||
),
|
),
|
||||||
|
configuration=runtime_configuration,
|
||||||
compatibility_api_data=api_data,
|
compatibility_api_data=api_data,
|
||||||
)
|
)
|
||||||
|
configure_runtime_configuration(host_runtime.configuration)
|
||||||
configure_api_data_runtime(host_runtime.compatibility_api_data)
|
configure_api_data_runtime(host_runtime.compatibility_api_data)
|
||||||
configure_runtime_data_providers()
|
configure_runtime_data_providers()
|
||||||
configure_chain_data_ports(
|
configure_chain_data_ports(
|
||||||
@@ -503,7 +572,9 @@ async def init_modules() -> HostRuntime:
|
|||||||
transfer_history=lambda: TransferHistoryOper(),
|
transfer_history=lambda: TransferHistoryOper(),
|
||||||
transfer_pending=lambda: TransferPendingOper(),
|
transfer_pending=lambda: TransferPendingOper(),
|
||||||
media_server=lambda: MediaServerOper(),
|
media_server=lambda: MediaServerOper(),
|
||||||
download_failure=lambda: DownloadFailureOper(),
|
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||||
|
SessionFactory
|
||||||
|
),
|
||||||
user=lambda: UserOper(),
|
user=lambda: UserOper(),
|
||||||
)
|
)
|
||||||
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
||||||
|
|||||||
@@ -559,6 +559,17 @@ app/api/dependencies/ # 按领域拆分依赖工厂
|
|||||||
- 219 个架构、配置、Agent 安全、整理重试、Module reload 专项测试通过,Pylint 10/10;依赖基线
|
- 219 个架构、配置、Agent 安全、整理重试、Module reload 专项测试通过,Pylint 10/10;依赖基线
|
||||||
仅把 `app.application.history -> app.runtime.config` 替换为窄配置端口边,禁止边不变。
|
仅把 `app.application.history -> app.runtime.config` 替换为窄配置端口边,禁止边不变。
|
||||||
|
|
||||||
|
**扩展实施记录(2026-08-22)**:
|
||||||
|
|
||||||
|
- `HostRuntime.configuration` 现在提供 API、Scheduler、Chain 三类 frozen snapshot 工厂。API 每个请求、
|
||||||
|
Scheduler 每次初始化/任务注册都取得新快照,因此配置 reload 后的新调用可见新值,已经开始执行的调用
|
||||||
|
不会在中途漂移;Chain 基础文件后缀由启动上下文一次注入。
|
||||||
|
- 登录、仪表板和整理历史 API 不再直接导入 `settings`;`Scheduler` 已清除全部直接 `settings` 访问,
|
||||||
|
用户认证配置改走 `SystemConfigService`;`StorageChain` 的媒体后缀改走 Chain snapshot。canonical 配置债务
|
||||||
|
从 169/15 降到 164 个 settings import 文件/14 个 SystemConfigOper 构造点。
|
||||||
|
- 直接调用 endpoint 和显式构造 `ChainRuntimeContext` 的旧测试/兼容入口仍有 fallback;正式 FastAPI 与
|
||||||
|
Startup 路径始终使用 HostRuntime 注入。插件 SDK 的 `app.sdk.config.settings`、动态 API 返回和事件字段未改。
|
||||||
|
|
||||||
### 阶段 4:把动态模块和事件变成可演进契约
|
### 阶段 4:把动态模块和事件变成可演进契约
|
||||||
|
|
||||||
#### ARCH-240:Module Contract V2
|
#### ARCH-240:Module Contract V2
|
||||||
@@ -605,6 +616,8 @@ ModuleMethodSpec(
|
|||||||
方法继续使用 legacy contract。
|
方法继续使用 legacy contract。
|
||||||
- runtime contract baseline 现包含稳定的 `module_method_specs`,后续字段或显式方法变化必须审查;
|
- runtime contract baseline 现包含稳定的 `module_method_specs`,后续字段或显式方法变化必须审查;
|
||||||
`ModuleCapability` Protocol 为宿主和新插件提供静态声明入口,但不替换字符串 dispatcher ABI。
|
`ModuleCapability` Protocol 为宿主和新插件提供静态声明入口,但不替换字符串 dispatcher ABI。
|
||||||
|
- 22 个显式方法进一步登记宿主真实传入的 required parameter 名称,覆盖识别、搜索、媒体服务器、存储、
|
||||||
|
消息收尾、命令注册和 webhook;dispatcher 仍只输出诊断 warning,不阻断缺少参数的旧插件或未知自定义方法。
|
||||||
|
|
||||||
#### ARCH-241:Event Contract Registry
|
#### ARCH-241:Event Contract Registry
|
||||||
|
|
||||||
@@ -753,6 +766,9 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas
|
|||||||
Application 删除命令替代的两个 `Subscribe` Model 级删除事务装饰器;Model decorator 基线从
|
Application 删除命令替代的两个 `Subscribe` Model 级删除事务装饰器;Model decorator 基线从
|
||||||
178 降到 176,Oper 内显式 commit/rollback 仍为 0。strict mypy 门禁新增 Chain durable context、
|
178 降到 176,Oper 内显式 commit/rollback 仍为 0。strict mypy 门禁新增 Chain durable context、
|
||||||
payload 转换和启动适配器。
|
payload 转换和启动适配器。
|
||||||
|
- 下载失败冷却切片继续迁移到 `TransactionalDownloadFailureRepository`:Chain 每次读写使用独立短会话,
|
||||||
|
写成功由显式 `SqlAlchemyUnitOfWork` commit,异常 rollback;`DownloadFailure` 查询和记录方法不再拥有
|
||||||
|
自动会话/提交装饰器。Model decorator 基线继续从 176 降到 174,Oper 内显式 commit/rollback 仍为 0。
|
||||||
|
|
||||||
**禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。
|
**禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。
|
||||||
|
|
||||||
@@ -864,6 +880,9 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op-
|
|||||||
- 配置约束测试会检查 strict、关键合同文件和至少一个 Domain 文件均在清单中,并实际启动锁定版本 mypy;
|
- 配置约束测试会检查 strict、关键合同文件和至少一个 Domain 文件均在清单中,并实际启动锁定版本 mypy;
|
||||||
当前 10 个源文件零错误通过。
|
当前 10 个源文件零错误通过。
|
||||||
|
|
||||||
|
**扩展实施记录(2026-08-22)**:mypy 目标运行时更新到 Python 3.14,严格清单扩大到 20 个源文件;
|
||||||
|
新增纳管配置快照和下载失败事务适配器,仍保持零错误、无全局 ignore。
|
||||||
|
|
||||||
#### ARCH-271:复杂度和端点预算 ratchet
|
#### ARCH-271:复杂度和端点预算 ratchet
|
||||||
|
|
||||||
**目标**:阻止大方法继续增长,并让拆分对应真实阶段,而不是机械 helper 化。
|
**目标**:阻止大方法继续增长,并让拆分对应真实阶段,而不是机械 helper 化。
|
||||||
@@ -887,6 +906,8 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op-
|
|||||||
- 当前债务清单明确包含 `web_agent_stream`、`batch_download`、`SubscribeChain.match`、`do_transfer`;
|
- 当前债务清单明确包含 `web_agent_stream`、`batch_download`、`SubscribeChain.match`、`do_transfer`;
|
||||||
`Scheduler.init` 已在 ARCH-252 通过 JobSpec/catalog 拆分退出超限清单,调度专项测试是该代表性拆分的回归证据。
|
`Scheduler.init` 已在 ARCH-252 通过 JobSpec/catalog 拆分退出超限清单,调度专项测试是该代表性拆分的回归证据。
|
||||||
- 单元测试覆盖删除/缩短放行和增长/新增拒绝,当前仓库 baseline check 通过。
|
- 单元测试覆盖删除/缩短放行和增长/新增拒绝,当前仓库 baseline check 通过。
|
||||||
|
- 2026-08-22 将 MCP JSON-RPC 分派、无媒体信息下载识别、缺集结果合并拆成具有独立输入/输出的私有阶段;
|
||||||
|
对应 `mcp_jsonrpc`、`download.add`、`DownloadChain.get_no_exists_info` 退出超限清单,总债务从 28 降到 25。
|
||||||
|
|
||||||
#### ARCH-272:异步阻塞检测
|
#### ARCH-272:异步阻塞检测
|
||||||
|
|
||||||
@@ -910,6 +931,8 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op-
|
|||||||
ActivityLog 为保证 `O_EXCL` 原子创建使用的一处 `os.open` 精确债务,不泛化豁免整个文件或目录。
|
ActivityLog 为保证 `O_EXCL` 原子创建使用的一处 `os.open` 精确债务,不泛化豁免整个文件或目录。
|
||||||
- pytest 全局启用 `asyncio_debug`,专项测试验证实际 loop debug 状态;AST ratchet 与 46 个 Agent 流式回归
|
- pytest 全局启用 `asyncio_debug`,专项测试验证实际 loop debug 状态;AST ratchet 与 46 个 Agent 流式回归
|
||||||
通过。同步第三方 Module 仍由 dispatcher 的 `app.runtime.execution.run_in_threadpool` 兼容。
|
通过。同步第三方 Module 仍由 dispatcher 的 `app.runtime.execution.run_in_threadpool` 兼容。
|
||||||
|
- 2026-08-22 扫描范围扩大到 `app/chain`、`app/modules`、`app/startup` 与 `app/scheduler.py`;扩大后未发现
|
||||||
|
新存量,仍只保留 ActivityLog 的一处原子 `os.open` 精确债务,并由测试锁定扫描根目录。
|
||||||
|
|
||||||
## 6. 推荐执行队列
|
## 6. 推荐执行队列
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[mypy]
|
[mypy]
|
||||||
python_version = 3.12
|
python_version = 3.14
|
||||||
strict = True
|
strict = True
|
||||||
warn_unused_configs = True
|
warn_unused_configs = True
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
@@ -16,6 +16,7 @@ files =
|
|||||||
app/runtime/event/contracts.py,
|
app/runtime/event/contracts.py,
|
||||||
app/runtime/extensions/module/contracts.py,
|
app/runtime/extensions/module/contracts.py,
|
||||||
app/application/outbox.py,
|
app/application/outbox.py,
|
||||||
|
app/application/configuration.py,
|
||||||
app/application/chain/context.py,
|
app/application/chain/context.py,
|
||||||
app/application/chain/durable_events.py,
|
app/application/chain/durable_events.py,
|
||||||
app/application/subscription/delete.py,
|
app/application/subscription/delete.py,
|
||||||
@@ -23,5 +24,6 @@ files =
|
|||||||
app/application/subscription/mutation.py,
|
app/application/subscription/mutation.py,
|
||||||
app/startup/context.py,
|
app/startup/context.py,
|
||||||
app/startup/chain_events.py,
|
app/startup/chain_events.py,
|
||||||
|
app/startup/download_failure.py,
|
||||||
app/api/context.py,
|
app/api/context.py,
|
||||||
app/api/dependencies/subscription.py
|
app/api/dependencies/subscription.py
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/async-blocking-baseline.json"
|
DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/async-blocking-baseline.json"
|
||||||
SCAN_ROOTS = ("app/api", "app/agent", "app/application")
|
SCAN_ROOTS = (
|
||||||
|
"app/api",
|
||||||
|
"app/agent",
|
||||||
|
"app/application",
|
||||||
|
"app/chain",
|
||||||
|
"app/modules",
|
||||||
|
"app/startup",
|
||||||
|
"app/scheduler.py",
|
||||||
|
)
|
||||||
BLOCKING_EXACT = {
|
BLOCKING_EXACT = {
|
||||||
"open",
|
"open",
|
||||||
"time.sleep",
|
"time.sleep",
|
||||||
@@ -167,7 +175,9 @@ def collect_async_blocking(root: Path = PROJECT_ROOT) -> dict[str, int]:
|
|||||||
"""扫描关键目录并以文件、函数、调用名聚合存量次数。"""
|
"""扫描关键目录并以文件、函数、调用名聚合存量次数。"""
|
||||||
debt: Counter[str] = Counter()
|
debt: Counter[str] = Counter()
|
||||||
for scan_root in SCAN_ROOTS:
|
for scan_root in SCAN_ROOTS:
|
||||||
for path in sorted((root / scan_root).rglob("*.py")):
|
target = root / scan_root
|
||||||
|
paths = [target] if target.is_file() else sorted(target.rglob("*.py"))
|
||||||
|
for path in paths:
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
relative = path.relative_to(root).as_posix()
|
relative = path.relative_to(root).as_posix()
|
||||||
for qualname, function in _async_functions(tree):
|
for qualname, function in _async_functions(tree):
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ def configure_plugin_system_services():
|
|||||||
)
|
)
|
||||||
from app.api.data import configure_api_data_ports
|
from app.api.data import configure_api_data_ports
|
||||||
from app.application.configuration import (
|
from app.application.configuration import (
|
||||||
|
ApiRuntimeConfig,
|
||||||
|
ChainRuntimeConfig,
|
||||||
|
RuntimeConfiguration,
|
||||||
|
SchedulerRuntimeConfig,
|
||||||
SystemConfigService,
|
SystemConfigService,
|
||||||
TransferRetryConfig,
|
TransferRetryConfig,
|
||||||
|
configure_runtime_configuration,
|
||||||
configure_system_config,
|
configure_system_config,
|
||||||
configure_transfer_retry_config,
|
configure_transfer_retry_config,
|
||||||
)
|
)
|
||||||
@@ -46,6 +51,22 @@ def configure_plugin_system_services():
|
|||||||
from app.db.oper.systemconfig import SystemConfigOper
|
from app.db.oper.systemconfig import SystemConfigOper
|
||||||
|
|
||||||
configure_token_codec(create_access_token, decode_access_token)
|
configure_token_codec(create_access_token, decode_access_token)
|
||||||
|
configure_runtime_configuration(
|
||||||
|
RuntimeConfiguration(
|
||||||
|
api=lambda: ApiRuntimeConfig(
|
||||||
|
advanced_mode=settings.ADVANCED_MODE,
|
||||||
|
access_token_expire_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
|
||||||
|
ai_agent_enable=settings.AI_AGENT_ENABLE,
|
||||||
|
),
|
||||||
|
scheduler=lambda: SchedulerRuntimeConfig(
|
||||||
|
False, settings.TZ, 1, False, "", None, None, False, 24,
|
||||||
|
"rss", 30, False, None, None, settings.AI_AGENT_ENABLE,
|
||||||
|
None, False, None,
|
||||||
|
),
|
||||||
|
chain=lambda: ChainRuntimeConfig(media_extensions=()),
|
||||||
|
)
|
||||||
|
)
|
||||||
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
||||||
configure_transfer_retry_config(
|
configure_transfer_retry_config(
|
||||||
lambda: TransferRetryConfig(
|
lambda: TransferRetryConfig(
|
||||||
|
|||||||
+1
-4
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"api_endpoint": {
|
"api_endpoint": {
|
||||||
"app/api/endpoints/agent.py:web_agent_stream": 346,
|
"app/api/endpoints/agent.py:web_agent_stream": 346,
|
||||||
"app/api/endpoints/download.py:add": 92,
|
|
||||||
"app/api/endpoints/mcp.py:mcp_jsonrpc": 83,
|
|
||||||
"app/api/endpoints/media.py:scrape": 105,
|
"app/api/endpoints/media.py:scrape": 105,
|
||||||
"app/api/endpoints/openai.py:chat_completions": 107,
|
"app/api/endpoints/openai.py:chat_completions": 107,
|
||||||
"app/api/endpoints/openai.py:responses": 105,
|
"app/api/endpoints/openai.py:responses": 105,
|
||||||
@@ -20,8 +18,7 @@
|
|||||||
},
|
},
|
||||||
"chain_public": {
|
"chain_public": {
|
||||||
"app/chain/download.py:DownloadChain.batch_download": 572,
|
"app/chain/download.py:DownloadChain.batch_download": 572,
|
||||||
"app/chain/download.py:DownloadChain.download_single": 276,
|
"app/chain/download.py:DownloadChain.download_single": 255,
|
||||||
"app/chain/download.py:DownloadChain.get_no_exists_info": 152,
|
|
||||||
"app/chain/mediaserver.py:MediaServerChain.sync": 292,
|
"app/chain/mediaserver.py:MediaServerChain.sync": 292,
|
||||||
"app/chain/site.py:SiteChain.sync_cookies": 180,
|
"app/chain/site.py:SiteChain.sync_cookies": 180,
|
||||||
"app/chain/subscribe.py:SubscribeChain.add": 183,
|
"app/chain/subscribe.py:SubscribeChain.add": 183,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"root": "app"
|
"root": "app"
|
||||||
},
|
},
|
||||||
"settings_imports": {
|
"settings_imports": {
|
||||||
"count": 169,
|
"count": 164,
|
||||||
"files": [
|
"files": [
|
||||||
"app/adapters/cache/backends.py",
|
"app/adapters/cache/backends.py",
|
||||||
"app/adapters/cache/redis.py",
|
"app/adapters/cache/redis.py",
|
||||||
@@ -49,9 +49,6 @@
|
|||||||
"app/agent/tools/impl/update_system_settings.py",
|
"app/agent/tools/impl/update_system_settings.py",
|
||||||
"app/api/endpoints/agent.py",
|
"app/api/endpoints/agent.py",
|
||||||
"app/api/endpoints/anthropic.py",
|
"app/api/endpoints/anthropic.py",
|
||||||
"app/api/endpoints/dashboard.py",
|
|
||||||
"app/api/endpoints/history.py",
|
|
||||||
"app/api/endpoints/login.py",
|
|
||||||
"app/api/endpoints/media.py",
|
"app/api/endpoints/media.py",
|
||||||
"app/api/endpoints/message.py",
|
"app/api/endpoints/message.py",
|
||||||
"app/api/endpoints/openai.py",
|
"app/api/endpoints/openai.py",
|
||||||
@@ -83,7 +80,6 @@
|
|||||||
"app/chain/scraping.py",
|
"app/chain/scraping.py",
|
||||||
"app/chain/search.py",
|
"app/chain/search.py",
|
||||||
"app/chain/site.py",
|
"app/chain/site.py",
|
||||||
"app/chain/storage.py",
|
|
||||||
"app/chain/subscribe.py",
|
"app/chain/subscribe.py",
|
||||||
"app/chain/system.py",
|
"app/chain/system.py",
|
||||||
"app/chain/torrents.py",
|
"app/chain/torrents.py",
|
||||||
@@ -166,7 +162,6 @@
|
|||||||
"app/runtime/extensions/plugin_manager.py",
|
"app/runtime/extensions/plugin_manager.py",
|
||||||
"app/runtime/state.py",
|
"app/runtime/state.py",
|
||||||
"app/runtime/thread.py",
|
"app/runtime/thread.py",
|
||||||
"app/scheduler.py",
|
|
||||||
"app/startup/agent_initializer.py",
|
"app/startup/agent_initializer.py",
|
||||||
"app/startup/database.py",
|
"app/startup/database.py",
|
||||||
"app/startup/database_initializer.py",
|
"app/startup/database_initializer.py",
|
||||||
@@ -184,10 +179,6 @@
|
|||||||
},
|
},
|
||||||
"system_config_oper_constructions": {
|
"system_config_oper_constructions": {
|
||||||
"calls": [
|
"calls": [
|
||||||
{
|
|
||||||
"file": "app/scheduler.py",
|
|
||||||
"name": "SystemConfigOper"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "app/startup/modules_initializer.py",
|
"file": "app/startup/modules_initializer.py",
|
||||||
"name": "SystemConfigOper"
|
"name": "SystemConfigOper"
|
||||||
@@ -245,6 +236,6 @@
|
|||||||
"name": "SystemConfigOper"
|
"name": "SystemConfigOper"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"count": 15
|
"count": 14
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-10
@@ -13,8 +13,8 @@
|
|||||||
"runtime_to_db": [],
|
"runtime_to_db": [],
|
||||||
"workflow_to_db": []
|
"workflow_to_db": []
|
||||||
},
|
},
|
||||||
"edge_count": 6299,
|
"edge_count": 6306,
|
||||||
"edge_sha256": "f98ab7d05c884a07a881eff1ab34b1ba5023751f1e95b956debcbad54c385810",
|
"edge_sha256": "7ca2da728599965ef604010fdf8e8e03d2939c68f3f45eba5b93aff63c2838a4",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -1479,6 +1479,7 @@
|
|||||||
"app.api.apiv1 -> app.api",
|
"app.api.apiv1 -> app.api",
|
||||||
"app.api.apiv1 -> app.api.router_specs",
|
"app.api.apiv1 -> app.api.router_specs",
|
||||||
"app.api.context -> app.application",
|
"app.api.context -> app.application",
|
||||||
|
"app.api.context -> app.application.configuration",
|
||||||
"app.api.context -> app.application.messaging",
|
"app.api.context -> app.application.messaging",
|
||||||
"app.api.context -> app.application.messaging.chat",
|
"app.api.context -> app.application.messaging.chat",
|
||||||
"app.api.context -> app.application.outbox",
|
"app.api.context -> app.application.outbox",
|
||||||
@@ -1701,19 +1702,19 @@
|
|||||||
"app.api.endpoints.dashboard -> app.adapters.web.security",
|
"app.api.endpoints.dashboard -> app.adapters.web.security",
|
||||||
"app.api.endpoints.dashboard -> app.adapters.web.security.access",
|
"app.api.endpoints.dashboard -> app.adapters.web.security.access",
|
||||||
"app.api.endpoints.dashboard -> app.api",
|
"app.api.endpoints.dashboard -> app.api",
|
||||||
|
"app.api.endpoints.dashboard -> app.api.context",
|
||||||
"app.api.endpoints.dashboard -> app.api.dependencies",
|
"app.api.endpoints.dashboard -> app.api.dependencies",
|
||||||
"app.api.endpoints.dashboard -> app.api.dependencies.auth",
|
"app.api.endpoints.dashboard -> app.api.dependencies.auth",
|
||||||
"app.api.endpoints.dashboard -> app.api.dependencies.history",
|
"app.api.endpoints.dashboard -> app.api.dependencies.history",
|
||||||
"app.api.endpoints.dashboard -> app.api.response",
|
"app.api.endpoints.dashboard -> app.api.response",
|
||||||
"app.api.endpoints.dashboard -> app.application",
|
"app.api.endpoints.dashboard -> app.application",
|
||||||
|
"app.api.endpoints.dashboard -> app.application.configuration",
|
||||||
"app.api.endpoints.dashboard -> app.application.dashboard",
|
"app.api.endpoints.dashboard -> app.application.dashboard",
|
||||||
"app.api.endpoints.dashboard -> app.application.directory",
|
"app.api.endpoints.dashboard -> app.application.directory",
|
||||||
"app.api.endpoints.dashboard -> app.application.scheduling",
|
"app.api.endpoints.dashboard -> app.application.scheduling",
|
||||||
"app.api.endpoints.dashboard -> app.chain",
|
"app.api.endpoints.dashboard -> app.chain",
|
||||||
"app.api.endpoints.dashboard -> app.chain.dashboard",
|
"app.api.endpoints.dashboard -> app.chain.dashboard",
|
||||||
"app.api.endpoints.dashboard -> app.chain.storage",
|
"app.api.endpoints.dashboard -> app.chain.storage",
|
||||||
"app.api.endpoints.dashboard -> app.runtime",
|
|
||||||
"app.api.endpoints.dashboard -> app.runtime.config",
|
|
||||||
"app.api.endpoints.dashboard -> app.schemas",
|
"app.api.endpoints.dashboard -> app.schemas",
|
||||||
"app.api.endpoints.dashboard -> app.schemas.dashboard",
|
"app.api.endpoints.dashboard -> app.schemas.dashboard",
|
||||||
"app.api.endpoints.dashboard -> app.schemas.response",
|
"app.api.endpoints.dashboard -> app.schemas.response",
|
||||||
@@ -1798,11 +1799,13 @@
|
|||||||
"app.api.endpoints.history -> app.agent.prompt.transfer_redo",
|
"app.api.endpoints.history -> app.agent.prompt.transfer_redo",
|
||||||
"app.api.endpoints.history -> app.agent.runtime_loader",
|
"app.api.endpoints.history -> app.agent.runtime_loader",
|
||||||
"app.api.endpoints.history -> app.api",
|
"app.api.endpoints.history -> app.api",
|
||||||
|
"app.api.endpoints.history -> app.api.context",
|
||||||
"app.api.endpoints.history -> app.api.dependencies",
|
"app.api.endpoints.history -> app.api.dependencies",
|
||||||
"app.api.endpoints.history -> app.api.dependencies.auth",
|
"app.api.endpoints.history -> app.api.dependencies.auth",
|
||||||
"app.api.endpoints.history -> app.api.dependencies.history",
|
"app.api.endpoints.history -> app.api.dependencies.history",
|
||||||
"app.api.endpoints.history -> app.api.response",
|
"app.api.endpoints.history -> app.api.response",
|
||||||
"app.api.endpoints.history -> app.application",
|
"app.api.endpoints.history -> app.application",
|
||||||
|
"app.api.endpoints.history -> app.application.configuration",
|
||||||
"app.api.endpoints.history -> app.application.history",
|
"app.api.endpoints.history -> app.application.history",
|
||||||
"app.api.endpoints.history -> app.runtime",
|
"app.api.endpoints.history -> app.runtime",
|
||||||
"app.api.endpoints.history -> app.runtime.config",
|
"app.api.endpoints.history -> app.runtime.config",
|
||||||
@@ -1828,6 +1831,7 @@
|
|||||||
"app.api.endpoints.login -> app.adapters.web.security",
|
"app.api.endpoints.login -> app.adapters.web.security",
|
||||||
"app.api.endpoints.login -> app.adapters.web.security.access",
|
"app.api.endpoints.login -> app.adapters.web.security.access",
|
||||||
"app.api.endpoints.login -> app.api",
|
"app.api.endpoints.login -> app.api",
|
||||||
|
"app.api.endpoints.login -> app.api.context",
|
||||||
"app.api.endpoints.login -> app.api.response",
|
"app.api.endpoints.login -> app.api.response",
|
||||||
"app.api.endpoints.login -> app.application",
|
"app.api.endpoints.login -> app.application",
|
||||||
"app.api.endpoints.login -> app.application.configuration",
|
"app.api.endpoints.login -> app.application.configuration",
|
||||||
@@ -1837,8 +1841,6 @@
|
|||||||
"app.api.endpoints.login -> app.application.site",
|
"app.api.endpoints.login -> app.application.site",
|
||||||
"app.api.endpoints.login -> app.chain",
|
"app.api.endpoints.login -> app.chain",
|
||||||
"app.api.endpoints.login -> app.chain.user",
|
"app.api.endpoints.login -> app.chain.user",
|
||||||
"app.api.endpoints.login -> app.runtime",
|
|
||||||
"app.api.endpoints.login -> app.runtime.config",
|
|
||||||
"app.api.endpoints.login -> app.schemas",
|
"app.api.endpoints.login -> app.schemas",
|
||||||
"app.api.endpoints.login -> app.schemas.response",
|
"app.api.endpoints.login -> app.schemas.response",
|
||||||
"app.api.endpoints.login -> app.schemas.token",
|
"app.api.endpoints.login -> app.schemas.token",
|
||||||
@@ -2439,6 +2441,7 @@
|
|||||||
"app.application.chain.context -> app.application.chain",
|
"app.application.chain.context -> app.application.chain",
|
||||||
"app.application.chain.context -> app.application.chain.data",
|
"app.application.chain.context -> app.application.chain.data",
|
||||||
"app.application.chain.context -> app.application.chain.durable_events",
|
"app.application.chain.context -> app.application.chain.durable_events",
|
||||||
|
"app.application.chain.context -> app.application.configuration",
|
||||||
"app.application.chain.durable_events -> app.application",
|
"app.application.chain.durable_events -> app.application",
|
||||||
"app.application.chain.durable_events -> app.application.history",
|
"app.application.chain.durable_events -> app.application.history",
|
||||||
"app.application.chain.durable_events -> app.domain",
|
"app.application.chain.durable_events -> app.domain",
|
||||||
@@ -3210,7 +3213,6 @@
|
|||||||
"app.chain.storage -> app.application.directory",
|
"app.chain.storage -> app.application.directory",
|
||||||
"app.chain.storage -> app.chain",
|
"app.chain.storage -> app.chain",
|
||||||
"app.chain.storage -> app.runtime",
|
"app.chain.storage -> app.runtime",
|
||||||
"app.chain.storage -> app.runtime.config",
|
|
||||||
"app.chain.storage -> app.runtime.log",
|
"app.chain.storage -> app.runtime.log",
|
||||||
"app.chain.storage -> app.schemas",
|
"app.chain.storage -> app.schemas",
|
||||||
"app.chain.storage -> app.schemas.workflow",
|
"app.chain.storage -> app.schemas.workflow",
|
||||||
@@ -5579,6 +5581,7 @@
|
|||||||
"app.scheduler -> app.agent",
|
"app.scheduler -> app.agent",
|
||||||
"app.scheduler -> app.agent.runtime_loader",
|
"app.scheduler -> app.agent.runtime_loader",
|
||||||
"app.scheduler -> app.application",
|
"app.scheduler -> app.application",
|
||||||
|
"app.scheduler -> app.application.configuration",
|
||||||
"app.scheduler -> app.application.database",
|
"app.scheduler -> app.application.database",
|
||||||
"app.scheduler -> app.application.image",
|
"app.scheduler -> app.application.image",
|
||||||
"app.scheduler -> app.application.messaging",
|
"app.scheduler -> app.application.messaging",
|
||||||
@@ -5596,7 +5599,6 @@
|
|||||||
"app.scheduler -> app.db",
|
"app.scheduler -> app.db",
|
||||||
"app.scheduler -> app.db.oper",
|
"app.scheduler -> app.db.oper",
|
||||||
"app.scheduler -> app.db.oper.agenttask",
|
"app.scheduler -> app.db.oper.agenttask",
|
||||||
"app.scheduler -> app.db.oper.systemconfig",
|
|
||||||
"app.scheduler -> app.foundation",
|
"app.scheduler -> app.foundation",
|
||||||
"app.scheduler -> app.foundation.singleton",
|
"app.scheduler -> app.foundation.singleton",
|
||||||
"app.scheduler -> app.runtime",
|
"app.scheduler -> app.runtime",
|
||||||
@@ -5871,6 +5873,7 @@
|
|||||||
"app.startup.command_initializer -> app.application.commands",
|
"app.startup.command_initializer -> app.application.commands",
|
||||||
"app.startup.command_initializer -> app.command",
|
"app.startup.command_initializer -> app.command",
|
||||||
"app.startup.context -> app.application",
|
"app.startup.context -> app.application",
|
||||||
|
"app.startup.context -> app.application.configuration",
|
||||||
"app.startup.context -> app.application.messaging",
|
"app.startup.context -> app.application.messaging",
|
||||||
"app.startup.context -> app.application.messaging.chat",
|
"app.startup.context -> app.application.messaging.chat",
|
||||||
"app.startup.context -> app.application.outbox",
|
"app.startup.context -> app.application.outbox",
|
||||||
@@ -5918,6 +5921,10 @@
|
|||||||
"app.startup.domain_initializer -> app.domain.metainfo",
|
"app.startup.domain_initializer -> app.domain.metainfo",
|
||||||
"app.startup.domain_initializer -> app.runtime",
|
"app.startup.domain_initializer -> app.runtime",
|
||||||
"app.startup.domain_initializer -> app.runtime.config",
|
"app.startup.domain_initializer -> app.runtime.config",
|
||||||
|
"app.startup.download_failure -> app.db",
|
||||||
|
"app.startup.download_failure -> app.db.oper",
|
||||||
|
"app.startup.download_failure -> app.db.oper.downloadfailure",
|
||||||
|
"app.startup.download_failure -> app.db.uow",
|
||||||
"app.startup.lifecycle -> app.adapters",
|
"app.startup.lifecycle -> app.adapters",
|
||||||
"app.startup.lifecycle -> app.adapters.external",
|
"app.startup.lifecycle -> app.adapters.external",
|
||||||
"app.startup.lifecycle -> app.adapters.external.server",
|
"app.startup.lifecycle -> app.adapters.external.server",
|
||||||
@@ -6017,7 +6024,6 @@
|
|||||||
"app.startup.modules_initializer -> app.db.oper",
|
"app.startup.modules_initializer -> app.db.oper",
|
||||||
"app.startup.modules_initializer -> app.db.oper.agentchat",
|
"app.startup.modules_initializer -> app.db.oper.agentchat",
|
||||||
"app.startup.modules_initializer -> app.db.oper.agenttask",
|
"app.startup.modules_initializer -> app.db.oper.agenttask",
|
||||||
"app.startup.modules_initializer -> app.db.oper.downloadfailure",
|
|
||||||
"app.startup.modules_initializer -> app.db.oper.downloadhistory",
|
"app.startup.modules_initializer -> app.db.oper.downloadhistory",
|
||||||
"app.startup.modules_initializer -> app.db.oper.mediaserver",
|
"app.startup.modules_initializer -> app.db.oper.mediaserver",
|
||||||
"app.startup.modules_initializer -> app.db.oper.message",
|
"app.startup.modules_initializer -> app.db.oper.message",
|
||||||
@@ -6056,6 +6062,7 @@
|
|||||||
"app.startup.modules_initializer -> app.startup.chain_events",
|
"app.startup.modules_initializer -> app.startup.chain_events",
|
||||||
"app.startup.modules_initializer -> app.startup.context",
|
"app.startup.modules_initializer -> app.startup.context",
|
||||||
"app.startup.modules_initializer -> app.startup.database",
|
"app.startup.modules_initializer -> app.startup.database",
|
||||||
|
"app.startup.modules_initializer -> app.startup.download_failure",
|
||||||
"app.startup.modules_initializer -> app.startup.managed_resources_initializer",
|
"app.startup.modules_initializer -> app.startup.managed_resources_initializer",
|
||||||
"app.startup.modules_initializer -> app.startup.outbox",
|
"app.startup.modules_initializer -> app.startup.outbox",
|
||||||
"app.startup.modules_initializer -> app.startup.subscription",
|
"app.startup.modules_initializer -> app.startup.subscription",
|
||||||
@@ -6316,7 +6323,7 @@
|
|||||||
"app.workflow.actions.transfer_file -> app.workflow",
|
"app.workflow.actions.transfer_file -> app.workflow",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 784,
|
"module_count": 785,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -7069,6 +7076,7 @@
|
|||||||
"app.startup.database",
|
"app.startup.database",
|
||||||
"app.startup.database_initializer",
|
"app.startup.database_initializer",
|
||||||
"app.startup.domain_initializer",
|
"app.startup.domain_initializer",
|
||||||
|
"app.startup.download_failure",
|
||||||
"app.startup.lifecycle",
|
"app.startup.lifecycle",
|
||||||
"app.startup.lifecycle.components",
|
"app.startup.lifecycle.components",
|
||||||
"app.startup.managed_resources_initializer",
|
"app.startup.managed_resources_initializer",
|
||||||
|
|||||||
+83
-19
@@ -2343,7 +2343,10 @@
|
|||||||
"input_contract": "StorageDownloadRequest",
|
"input_contract": "StorageDownloadRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"fileitem",
|
||||||
|
"path"
|
||||||
|
],
|
||||||
"result_contract": "FileItem | None",
|
"result_contract": "FileItem | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2358,7 +2361,9 @@
|
|||||||
"input_contract": "MessageFinalizeRequest",
|
"input_contract": "MessageFinalizeRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"response"
|
||||||
|
],
|
||||||
"result_contract": "Message | None",
|
"result_contract": "Message | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2373,7 +2378,10 @@
|
|||||||
"input_contract": "StorageItemRequest",
|
"input_contract": "StorageItemRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"path",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
"result_contract": "FileItem | None",
|
"result_contract": "FileItem | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2388,7 +2396,10 @@
|
|||||||
"input_contract": "StorageFolderRequest",
|
"input_contract": "StorageFolderRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"path",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
"result_contract": "FileItem | None",
|
"result_contract": "FileItem | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2403,7 +2414,9 @@
|
|||||||
"input_contract": "StorageParentRequest",
|
"input_contract": "StorageParentRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"fileitem"
|
||||||
|
],
|
||||||
"result_contract": "FileItem | None",
|
"result_contract": "FileItem | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2418,7 +2431,10 @@
|
|||||||
"input_contract": "StorageListRequest",
|
"input_contract": "StorageListRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"fileitem",
|
||||||
|
"recursion"
|
||||||
|
],
|
||||||
"result_contract": "list[FileItem]",
|
"result_contract": "list[FileItem]",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2448,7 +2464,10 @@
|
|||||||
"input_contract": "MediaServerItemRequest",
|
"input_contract": "MediaServerItemRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"item_id",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
"result_contract": "MediaServerItem | None",
|
"result_contract": "MediaServerItem | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2463,7 +2482,12 @@
|
|||||||
"input_contract": "MediaServerItemsRequest",
|
"input_contract": "MediaServerItemsRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"library_id",
|
||||||
|
"limit",
|
||||||
|
"server",
|
||||||
|
"start_index"
|
||||||
|
],
|
||||||
"result_contract": "list[MediaServerItem]",
|
"result_contract": "list[MediaServerItem]",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2478,7 +2502,10 @@
|
|||||||
"input_contract": "MediaServerPlayRequest",
|
"input_contract": "MediaServerPlayRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"item_id",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
"result_contract": "str | None",
|
"result_contract": "str | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2493,7 +2520,10 @@
|
|||||||
"input_contract": "MediaServerEpisodesRequest",
|
"input_contract": "MediaServerEpisodesRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"item_id",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
"result_contract": "list[MediaServerPlayItem]",
|
"result_contract": "list[MediaServerPlayItem]",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2508,7 +2538,9 @@
|
|||||||
"input_contract": "MediaInfo",
|
"input_contract": "MediaInfo",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"mediainfo"
|
||||||
|
],
|
||||||
"result_contract": "MediaInfo | None",
|
"result_contract": "MediaInfo | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2523,7 +2555,14 @@
|
|||||||
"input_contract": "MediaRecognitionRequest",
|
"input_contract": "MediaRecognitionRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"cache",
|
||||||
|
"episode_group",
|
||||||
|
"media_id",
|
||||||
|
"media_source",
|
||||||
|
"meta",
|
||||||
|
"mtype"
|
||||||
|
],
|
||||||
"result_contract": "MediaInfo | None",
|
"result_contract": "MediaInfo | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2538,7 +2577,9 @@
|
|||||||
"input_contract": "CommandRegistrationRequest",
|
"input_contract": "CommandRegistrationRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"commands"
|
||||||
|
],
|
||||||
"result_contract": "None",
|
"result_contract": "None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2553,7 +2594,10 @@
|
|||||||
"input_contract": "StorageRenameRequest",
|
"input_contract": "StorageRenameRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"fileitem",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
"result_contract": "bool | FileItem",
|
"result_contract": "bool | FileItem",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2583,7 +2627,10 @@
|
|||||||
"input_contract": "MediaSearchRequest",
|
"input_contract": "MediaSearchRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"media_source",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
"result_contract": "list[MediaInfo]",
|
"result_contract": "list[MediaInfo]",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2613,7 +2660,13 @@
|
|||||||
"input_contract": "StorageSnapshotRequest",
|
"input_contract": "StorageSnapshotRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"last_snapshot_time",
|
||||||
|
"max_depth",
|
||||||
|
"path",
|
||||||
|
"previous_snapshot",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
"result_contract": "dict[str, dict] | None",
|
"result_contract": "dict[str, dict] | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2628,7 +2681,10 @@
|
|||||||
"input_contract": "StorageManageRequest",
|
"input_contract": "StorageManageRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"action",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
"result_contract": "Any",
|
"result_contract": "Any",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2643,7 +2699,11 @@
|
|||||||
"input_contract": "StorageUploadRequest",
|
"input_contract": "StorageUploadRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"fileitem",
|
||||||
|
"new_name",
|
||||||
|
"path"
|
||||||
|
],
|
||||||
"result_contract": "FileItem | None",
|
"result_contract": "FileItem | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
@@ -2658,7 +2718,11 @@
|
|||||||
"input_contract": "WebhookRequest",
|
"input_contract": "WebhookRequest",
|
||||||
"plugin_short_circuit": true,
|
"plugin_short_circuit": true,
|
||||||
"public_to_plugins": true,
|
"public_to_plugins": true,
|
||||||
"required_parameters": [],
|
"required_parameters": [
|
||||||
|
"args",
|
||||||
|
"body",
|
||||||
|
"form"
|
||||||
|
],
|
||||||
"result_contract": "WebhookEventInfo | None",
|
"result_contract": "WebhookEventInfo | None",
|
||||||
"supports_async": true,
|
"supports_async": true,
|
||||||
"supports_sync": true,
|
"supports_sync": true,
|
||||||
|
|||||||
+3
-13
@@ -3,10 +3,10 @@
|
|||||||
"by_kind": {
|
"by_kind": {
|
||||||
"async_db_query": 49,
|
"async_db_query": 49,
|
||||||
"async_db_update": 12,
|
"async_db_update": 12,
|
||||||
"db_query": 75,
|
"db_query": 74,
|
||||||
"db_update": 40
|
"db_update": 39
|
||||||
},
|
},
|
||||||
"count": 176,
|
"count": 174,
|
||||||
"methods": [
|
"methods": [
|
||||||
{
|
{
|
||||||
"decorator": "async_db_query",
|
"decorator": "async_db_query",
|
||||||
@@ -83,16 +83,6 @@
|
|||||||
"file": "app/db/models/downloadfailure.py",
|
"file": "app/db/models/downloadfailure.py",
|
||||||
"method": "DownloadFailure.delete_expired"
|
"method": "DownloadFailure.delete_expired"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"decorator": "db_query",
|
|
||||||
"file": "app/db/models/downloadfailure.py",
|
|
||||||
"method": "DownloadFailure.get_active_by_fingerprints"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"decorator": "db_update",
|
|
||||||
"file": "app/db/models/downloadfailure.py",
|
|
||||||
"method": "DownloadFailure.record_failure"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"decorator": "db_update",
|
"decorator": "db_update",
|
||||||
"file": "app/db/models/downloadhistory.py",
|
"file": "app/db/models/downloadhistory.py",
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None:
|
|||||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
assert baseline["schema_version"] == 1
|
assert baseline["schema_version"] == 1
|
||||||
assert baseline["model_decorators"]["count"] == 176
|
assert baseline["model_decorators"]["count"] == 174
|
||||||
assert sum(baseline["model_decorators"]["by_kind"].values()) == 176
|
assert sum(baseline["model_decorators"]["by_kind"].values()) == 174
|
||||||
assert baseline["model_transaction_calls"] == {"count": 0, "calls": []}
|
assert baseline["model_transaction_calls"] == {"count": 0, "calls": []}
|
||||||
assert baseline["model_session_factories"] == {"count": 0, "calls": []}
|
assert baseline["model_session_factories"] == {"count": 0, "calls": []}
|
||||||
assert baseline["oper_transaction_calls"] == {"count": 0, "calls": []}
|
assert baseline["oper_transaction_calls"] == {"count": 0, "calls": []}
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ import asyncio
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from scripts.architecture.async_blocking import compare_async_blocking
|
from scripts.architecture.async_blocking import SCAN_ROOTS, compare_async_blocking
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_blocking_scan_covers_runtime_entrypoints() -> None:
|
||||||
|
"""扫描范围必须覆盖 API、Scheduler、Chain 及其启动组合根。"""
|
||||||
|
assert {
|
||||||
|
"app/api",
|
||||||
|
"app/application",
|
||||||
|
"app/chain",
|
||||||
|
"app/modules",
|
||||||
|
"app/startup",
|
||||||
|
"app/scheduler.py",
|
||||||
|
}.issubset({str(path) for path in SCAN_ROOTS})
|
||||||
|
|
||||||
|
|
||||||
def test_async_blocking_ratchet_allows_removal_and_rejects_growth() -> None:
|
def test_async_blocking_ratchet_allows_removal_and_rejects_growth() -> None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from app.application.chain.context import ChainRuntimeContext
|
from app.application.chain.context import ChainRuntimeContext
|
||||||
|
from app.application.configuration import ChainRuntimeConfig
|
||||||
from app.application.chain import context as chain_context
|
from app.application.chain import context as chain_context
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
|
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
|
||||||
@@ -20,6 +21,7 @@ def _context() -> ChainRuntimeContext:
|
|||||||
async_file_cache=Mock(),
|
async_file_cache=Mock(),
|
||||||
message_queue_factory=Mock(return_value=Mock()),
|
message_queue_factory=Mock(return_value=Mock()),
|
||||||
module_dispatcher_factory=ModuleInvocationDispatcher,
|
module_dispatcher_factory=ModuleInvocationDispatcher,
|
||||||
|
configuration=ChainRuntimeConfig(media_extensions=(".mkv",)),
|
||||||
durable_event_writer=Mock(),
|
durable_event_writer=Mock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import asyncio
|
|||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from app.application.configuration import (
|
from app.application.configuration import (
|
||||||
|
ApiRuntimeConfig,
|
||||||
|
ChainRuntimeConfig,
|
||||||
|
RuntimeConfiguration,
|
||||||
|
SchedulerRuntimeConfig,
|
||||||
SystemConfigService,
|
SystemConfigService,
|
||||||
TransferRetryConfig,
|
TransferRetryConfig,
|
||||||
|
configure_runtime_configuration,
|
||||||
configure_transfer_retry_config,
|
configure_transfer_retry_config,
|
||||||
|
get_api_runtime_config_snapshot,
|
||||||
get_transfer_retry_config,
|
get_transfer_retry_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,3 +51,32 @@ def test_transfer_retry_provider_returns_frozen_snapshot_per_call() -> None:
|
|||||||
|
|
||||||
assert before_reload.max_failed_retries == 2
|
assert before_reload.max_failed_retries == 2
|
||||||
assert after_reload.max_failed_retries == 4
|
assert after_reload.max_failed_retries == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_runtime_provider_returns_frozen_snapshot_per_request() -> None:
|
||||||
|
"""API 每次请求读取新配置,但已取得的快照保持不变。"""
|
||||||
|
state = {"enabled": False}
|
||||||
|
configure_runtime_configuration(
|
||||||
|
RuntimeConfiguration(
|
||||||
|
api=lambda: ApiRuntimeConfig(
|
||||||
|
advanced_mode=False,
|
||||||
|
access_token_expire_minutes=60,
|
||||||
|
btrfs_fsid_dedup=False,
|
||||||
|
ai_agent_enable=state["enabled"],
|
||||||
|
),
|
||||||
|
scheduler=lambda: SchedulerRuntimeConfig(
|
||||||
|
False, "Asia/Shanghai", 1, False, "", None, None, False,
|
||||||
|
24, "rss", 30, False, None, None, False, None, False, None,
|
||||||
|
),
|
||||||
|
chain=lambda: ChainRuntimeConfig(media_extensions=(".mkv",)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
before_reload = get_api_runtime_config_snapshot()
|
||||||
|
state["enabled"] = True
|
||||||
|
after_reload = get_api_runtime_config_snapshot()
|
||||||
|
|
||||||
|
assert before_reload.ai_agent_enable is False
|
||||||
|
assert after_reload.ai_agent_enable is True
|
||||||
|
configure_runtime_configuration,
|
||||||
|
get_api_runtime_config_snapshot,
|
||||||
|
|||||||
@@ -1,28 +1,60 @@
|
|||||||
"""数据库备份与宿主调度器的接入合同。"""
|
"""数据库备份与宿主调度器的接入合同。"""
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from app import scheduler as scheduler_module
|
from app import scheduler as scheduler_module
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
|
from app.application.configuration import SchedulerRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
class _SchedulerStub:
|
class _SchedulerStub:
|
||||||
|
"""记录 Scheduler 注册结果的最小调度器替身。"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
"""初始化空作业表。"""
|
||||||
self.jobs = {}
|
self.jobs = {}
|
||||||
|
|
||||||
def add_job(self, func, *, trigger, id, **kwargs) -> None:
|
def add_job(self, func, *, trigger, id, **kwargs) -> None:
|
||||||
|
"""按作业 ID 保存最近一次注册参数。"""
|
||||||
self.jobs[id] = {"func": func, "trigger": trigger, **kwargs}
|
self.jobs[id] = {"func": func, "trigger": trigger, **kwargs}
|
||||||
|
|
||||||
|
|
||||||
def _scheduler() -> Scheduler:
|
def _scheduler() -> Scheduler:
|
||||||
|
"""构造不启动后台线程的 Scheduler。"""
|
||||||
scheduler = object.__new__(Scheduler)
|
scheduler = object.__new__(Scheduler)
|
||||||
scheduler._scheduler = _SchedulerStub()
|
scheduler._scheduler = _SchedulerStub()
|
||||||
scheduler._jobs = {}
|
scheduler._jobs = {}
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def _config(**changes) -> SchedulerRuntimeConfig:
|
||||||
|
"""构造数据库备份测试所需的最小调度配置快照。"""
|
||||||
|
config = SchedulerRuntimeConfig(
|
||||||
|
dev=False,
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
scheduler_workers=1,
|
||||||
|
db_backup_enable=False,
|
||||||
|
db_backup_cron="",
|
||||||
|
cookiecloud_interval=None,
|
||||||
|
mediaserver_sync_interval=None,
|
||||||
|
subscribe_search=False,
|
||||||
|
subscribe_search_interval=24,
|
||||||
|
subscribe_mode="rss",
|
||||||
|
subscribe_rss_interval=30,
|
||||||
|
data_cleanup_enable=False,
|
||||||
|
sitedata_refresh_interval=None,
|
||||||
|
memory_gc_interval=None,
|
||||||
|
ai_agent_enable=False,
|
||||||
|
ai_agent_job_interval=None,
|
||||||
|
usage_statistic_share=False,
|
||||||
|
site_link=None,
|
||||||
|
)
|
||||||
|
return replace(config, **changes)
|
||||||
|
|
||||||
|
|
||||||
def test_database_backup_schedule_only_watches_job_shape() -> None:
|
def test_database_backup_schedule_only_watches_job_shape() -> None:
|
||||||
assert Scheduler.CONFIG_WATCH.intersection({
|
assert Scheduler.CONFIG_WATCH.intersection({
|
||||||
"DB_BACKUP_ENABLE",
|
"DB_BACKUP_ENABLE",
|
||||||
@@ -34,22 +66,19 @@ def test_database_backup_schedule_only_watches_job_shape() -> None:
|
|||||||
}) == {"DB_BACKUP_ENABLE", "DB_BACKUP_CRON"}
|
}) == {"DB_BACKUP_ENABLE", "DB_BACKUP_CRON"}
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_database_backup_does_not_register_job(monkeypatch) -> None:
|
def test_disabled_database_backup_does_not_register_job() -> None:
|
||||||
|
"""关闭备份时不注册作业。"""
|
||||||
scheduler = _scheduler()
|
scheduler = _scheduler()
|
||||||
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_ENABLE", False)
|
|
||||||
|
|
||||||
scheduler._register_database_backup_job()
|
scheduler._register_database_backup_job(_config())
|
||||||
|
|
||||||
assert scheduler._scheduler.jobs == {}
|
assert scheduler._scheduler.jobs == {}
|
||||||
|
|
||||||
|
|
||||||
def test_enabled_database_backup_without_cron_does_not_register_job(monkeypatch) -> None:
|
def test_enabled_database_backup_without_cron_does_not_register_job() -> None:
|
||||||
"""总开关开启但未配置周期时,不启用定时备份。"""
|
"""总开关开启但未配置周期时,不启用定时备份。"""
|
||||||
scheduler = _scheduler()
|
scheduler = _scheduler()
|
||||||
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_ENABLE", True)
|
scheduler._register_database_backup_job(_config(db_backup_enable=True))
|
||||||
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_CRON", "")
|
|
||||||
|
|
||||||
scheduler._register_database_backup_job()
|
|
||||||
|
|
||||||
assert scheduler._scheduler.jobs == {}
|
assert scheduler._scheduler.jobs == {}
|
||||||
|
|
||||||
@@ -57,12 +86,11 @@ def test_enabled_database_backup_without_cron_does_not_register_job(monkeypatch)
|
|||||||
def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -> None:
|
def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -> None:
|
||||||
scheduler = _scheduler()
|
scheduler = _scheduler()
|
||||||
trigger = object()
|
trigger = object()
|
||||||
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_ENABLE", True)
|
|
||||||
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_CRON", "0 3 * * *")
|
|
||||||
monkeypatch.setattr(scheduler_module.TimerUtils, "build_schedule_trigger", Mock(return_value=trigger))
|
monkeypatch.setattr(scheduler_module.TimerUtils, "build_schedule_trigger", Mock(return_value=trigger))
|
||||||
|
config = _config(db_backup_enable=True, db_backup_cron="0 3 * * *")
|
||||||
|
|
||||||
scheduler._register_database_backup_job()
|
scheduler._register_database_backup_job(config)
|
||||||
scheduler._register_database_backup_job()
|
scheduler._register_database_backup_job(config)
|
||||||
|
|
||||||
assert list(scheduler._scheduler.jobs) == ["database_backup"]
|
assert list(scheduler._scheduler.jobs) == ["database_backup"]
|
||||||
assert scheduler._scheduler.jobs["database_backup"]["replace_existing"] is True
|
assert scheduler._scheduler.jobs["database_backup"]["replace_existing"] is True
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ from app.api.data import (
|
|||||||
)
|
)
|
||||||
from app.startup import lifecycle
|
from app.startup import lifecycle
|
||||||
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
|
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
|
||||||
|
from app.application.configuration import (
|
||||||
|
ApiRuntimeConfig,
|
||||||
|
ChainRuntimeConfig,
|
||||||
|
RuntimeConfiguration,
|
||||||
|
SchedulerRuntimeConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _Repository:
|
class _Repository:
|
||||||
@@ -87,6 +93,14 @@ def _runtime() -> HostRuntime:
|
|||||||
transaction=_UnitOfWork,
|
transaction=_UnitOfWork,
|
||||||
outbox=_Outbox,
|
outbox=_Outbox,
|
||||||
),
|
),
|
||||||
|
configuration=RuntimeConfiguration(
|
||||||
|
api=lambda: ApiRuntimeConfig(False, 60, False, True),
|
||||||
|
scheduler=lambda: SchedulerRuntimeConfig(
|
||||||
|
False, "Asia/Shanghai", 1, False, "", None, None,
|
||||||
|
False, 24, "rss", 30, False, None, None, True, 1, False, None,
|
||||||
|
),
|
||||||
|
chain=lambda: ChainRuntimeConfig(media_extensions=(".mkv",)),
|
||||||
|
),
|
||||||
compatibility_api_data=compatibility,
|
compatibility_api_data=compatibility,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -92,3 +92,23 @@ def test_signature_diagnostics_do_not_reject_legacy_callable() -> None:
|
|||||||
"signature-unavailable",
|
"signature-unavailable",
|
||||||
)
|
)
|
||||||
assert _OpaqueCallable()() == "ok"
|
assert _OpaqueCallable()() == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_diagnostics_report_missing_contract_parameters() -> None:
|
||||||
|
"""显式 Contract 应能指出 provider 遗漏的宿主调用参数。"""
|
||||||
|
def incomplete_storage_provider(fileitem):
|
||||||
|
"""模拟仍未接受 recursion 参数的旧存储 provider。"""
|
||||||
|
return [fileitem]
|
||||||
|
|
||||||
|
assert diagnose_module_callable(
|
||||||
|
"list_files", incomplete_storage_provider
|
||||||
|
) == ("missing-parameter:recursion",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_diagnostics_accept_keyword_compatibility_provider() -> None:
|
||||||
|
"""带 **kwargs 的第三方 provider 继续兼容逐步扩展的输入契约。"""
|
||||||
|
def compatible_provider(**kwargs):
|
||||||
|
"""模拟通过关键字参数保持前向兼容的第三方 provider。"""
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
assert diagnose_module_callable("snapshot_storage", compatible_provider) == ()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from unittest.mock import Mock
|
|||||||
|
|
||||||
from app import scheduler as scheduler_module
|
from app import scheduler as scheduler_module
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
|
from app.application.configuration import SchedulerRuntimeConfig
|
||||||
from app.startup import scheduler_initializer
|
from app.startup import scheduler_initializer
|
||||||
|
|
||||||
|
|
||||||
@@ -75,16 +76,14 @@ def test_clear_cache_is_manual_only(monkeypatch):
|
|||||||
monkeypatch.setattr(Scheduler, "init_workflow_jobs", lambda self: None)
|
monkeypatch.setattr(Scheduler, "init_workflow_jobs", lambda self: None)
|
||||||
monkeypatch.setattr(Scheduler, "init_agent_task_jobs", lambda self: None)
|
monkeypatch.setattr(Scheduler, "init_agent_task_jobs", lambda self: None)
|
||||||
monkeypatch.setattr(Scheduler, "init_plugin_jobs", lambda self: None)
|
monkeypatch.setattr(Scheduler, "init_plugin_jobs", lambda self: None)
|
||||||
monkeypatch.setattr(scheduler_module.settings, "DEV", False)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(scheduler_module.settings, "COOKIECLOUD_INTERVAL", 0)
|
scheduler_module,
|
||||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_SEARCH", False)
|
"get_scheduler_runtime_config",
|
||||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_MODE", "rss")
|
lambda: SchedulerRuntimeConfig(
|
||||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_RSS_INTERVAL", 30)
|
False, "Asia/Shanghai", 1, False, "", 0, None, False, 24,
|
||||||
monkeypatch.setattr(scheduler_module.settings, "SITEDATA_REFRESH_INTERVAL", 0)
|
"rss", 30, False, 0, 0, False, None, False, None,
|
||||||
monkeypatch.setattr(scheduler_module.settings, "MEMORY_GC_INTERVAL", 0)
|
),
|
||||||
monkeypatch.setattr(scheduler_module.settings, "AI_AGENT_ENABLE", False)
|
)
|
||||||
monkeypatch.setattr(scheduler_module.settings, "DATA_CLEANUP_ENABLE", False)
|
|
||||||
monkeypatch.setattr(scheduler_module.settings, "USAGE_STATISTIC_SHARE", False)
|
|
||||||
|
|
||||||
scheduler = object.__new__(Scheduler)
|
scheduler = object.__new__(Scheduler)
|
||||||
scheduler._scheduler = None
|
scheduler._scheduler = None
|
||||||
|
|||||||
@@ -596,12 +596,11 @@ def test_dashboard_downloader_forwards_btrfs_fsid_setting():
|
|||||||
from app.api.endpoints import dashboard as dashboard_module
|
from app.api.endpoints import dashboard as dashboard_module
|
||||||
|
|
||||||
download_dir = MagicMock(download_path="/downloads")
|
download_dir = MagicMock(download_path="/downloads")
|
||||||
with patch.object(dashboard_module.settings, "BTRFS_FSID_DEDUP", True), \
|
with patch.object(dashboard_module.DirectoryHelper, "get_local_download_dirs",
|
||||||
patch.object(dashboard_module.DirectoryHelper, "get_local_download_dirs",
|
|
||||||
return_value=[download_dir]), \
|
return_value=[download_dir]), \
|
||||||
patch.object(SystemUtils, "space_usage", return_value=(4.0, 2.0)) as usage_mock, \
|
patch.object(SystemUtils, "space_usage", return_value=(4.0, 2.0)) as usage_mock, \
|
||||||
patch.object(dashboard_module.DashboardChain, "downloader_info", return_value=[]):
|
patch.object(dashboard_module.DashboardChain, "downloader_info", return_value=[]):
|
||||||
dashboard_module._build_downloader()
|
dashboard_module._build_downloader(btrfs_fsid_dedup=True)
|
||||||
|
|
||||||
usage_mock.assert_called_once_with(
|
usage_mock.assert_called_once_with(
|
||||||
[Path("/downloads")],
|
[Path("/downloads")],
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""下载失败冷却切片显式 UoW 的回归测试。"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.startup.download_failure import TransactionalDownloadFailureRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _session_factory(session: MagicMock):
|
||||||
|
"""返回支持 context manager 的固定会话工厂。"""
|
||||||
|
session.__enter__.return_value = session
|
||||||
|
session.__exit__.return_value = False
|
||||||
|
return MagicMock(return_value=session)
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_failure_commits_explicit_unit_of_work() -> None:
|
||||||
|
"""写入成功后只由适配器显式提交一次事务。"""
|
||||||
|
session = MagicMock()
|
||||||
|
oper = MagicMock()
|
||||||
|
oper.record_failure.return_value = object()
|
||||||
|
repository = TransactionalDownloadFailureRepository(_session_factory(session))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.startup.download_failure.DownloadFailureOper",
|
||||||
|
return_value=oper,
|
||||||
|
):
|
||||||
|
result = repository.record_failure("fp", "now", "next", title="片名")
|
||||||
|
|
||||||
|
assert result is oper.record_failure.return_value
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
session.rollback.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_failure_rolls_back_explicit_unit_of_work() -> None:
|
||||||
|
"""写入异常时回滚并保留原始异常。"""
|
||||||
|
session = MagicMock()
|
||||||
|
oper = MagicMock()
|
||||||
|
oper.record_failure.side_effect = ValueError("duplicate")
|
||||||
|
repository = TransactionalDownloadFailureRepository(_session_factory(session))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.startup.download_failure.DownloadFailureOper",
|
||||||
|
return_value=oper,
|
||||||
|
), pytest.raises(ValueError, match="duplicate"):
|
||||||
|
repository.record_failure("fp", "now", "next")
|
||||||
|
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
|
session.commit.assert_not_called()
|
||||||
@@ -24,7 +24,9 @@ def test_mypy_gate_has_explicit_strict_scope_without_global_ignore() -> None:
|
|||||||
assert "app/runtime/event/contracts.py" in governed_files
|
assert "app/runtime/event/contracts.py" in governed_files
|
||||||
assert "app/runtime/extensions/module/contracts.py" in governed_files
|
assert "app/runtime/extensions/module/contracts.py" in governed_files
|
||||||
assert "app/startup/context.py" in governed_files
|
assert "app/startup/context.py" in governed_files
|
||||||
|
assert "app/startup/download_failure.py" in governed_files
|
||||||
assert "app/api/context.py" in governed_files
|
assert "app/api/context.py" in governed_files
|
||||||
|
assert len(governed_files) >= 20
|
||||||
assert any(path.startswith("app/domain/") for path in governed_files)
|
assert any(path.startswith("app/domain/") for path in governed_files)
|
||||||
assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8")
|
assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user