mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +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.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.identity import SubscribeIdentityDeletionRepository
|
||||
from app.application.subscription.mutation import (
|
||||
@@ -28,6 +32,20 @@ def get_host_runtime(request: Request) -> HostRuntime:
|
||||
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(
|
||||
runtime: HostRuntime = Depends(get_host_runtime),
|
||||
) -> AgentChatRuntime:
|
||||
|
||||
@@ -16,7 +16,8 @@ from app.schemas.response import Response as _SchemaResponse
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.dashboard import DashboardChain
|
||||
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.api.dependencies.auth import get_current_active_superuser
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
_, free_space = SystemUtils.space_usage(
|
||||
[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()
|
||||
@@ -137,12 +142,18 @@ def system_info(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
|
||||
@router.get("/downloader", summary="下载器信息", response_model=_SchemaDownloaderInfo)
|
||||
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:
|
||||
"""
|
||||
查询下载器信息
|
||||
"""
|
||||
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(
|
||||
@@ -150,11 +161,17 @@ def downloader(
|
||||
summary="下载器信息(API_TOKEN)",
|
||||
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)
|
||||
"""
|
||||
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])
|
||||
|
||||
@@ -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])
|
||||
def current(
|
||||
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)
|
||||
if music_type is not None and not normalized_music_type:
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message="音乐实体类型无效,仅支持 recording 或 album",
|
||||
)
|
||||
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
|
||||
metainfo, mediainfo, error = _resolve_add_media(
|
||||
torrent_in,
|
||||
media_source,
|
||||
media_id,
|
||||
music_type,
|
||||
allow_unrecognized,
|
||||
)
|
||||
if is_music and media_source and not is_music_media_source(media_source):
|
||||
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,
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
if metainfo is None or mediainfo is None:
|
||||
return _SchemaResponse(success=False, message="无法识别媒体信息")
|
||||
# 种子信息
|
||||
torrentinfo = TorrentInfo()
|
||||
torrentinfo.from_dict(torrent_in.model_dump())
|
||||
|
||||
@@ -19,7 +19,9 @@ from app.agent.prompt.transfer_redo import (
|
||||
build_batch_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.api.dependencies.auth import (
|
||||
get_current_active_manage_user,
|
||||
@@ -244,12 +246,14 @@ def delete_transfer_history(
|
||||
async def ai_redo_transfer_history(
|
||||
history_id: int,
|
||||
query: HistoryQueryService = Depends(get_history_query_service),
|
||||
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
|
||||
_: object = Depends(get_current_active_manage_user),
|
||||
) -> Any:
|
||||
"""
|
||||
手动触发单条历史记录的 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智能助手未启用")
|
||||
|
||||
history = await query.get_transfer(history_id)
|
||||
@@ -275,12 +279,14 @@ async def ai_redo_transfer_history(
|
||||
async def batch_ai_redo_transfer_history(
|
||||
payload: _SchemaBatchTransferHistoryRedoRequest,
|
||||
query: HistoryQueryService = Depends(get_history_query_service),
|
||||
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
|
||||
_: object = Depends(get_current_active_manage_user),
|
||||
) -> Any:
|
||||
"""
|
||||
手动触发多条历史记录的 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智能助手未启用")
|
||||
|
||||
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.adapters.web.security.access import set_or_refresh_resource_token_cookie
|
||||
from app.application.security.token import create_access_token
|
||||
from app.runtime.config import settings
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.api.context import get_api_runtime_config, resolve_api_runtime_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.image import WallpaperHelper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
@@ -39,10 +39,12 @@ def login_access_token(
|
||||
response: Response,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
otp_password: Annotated[str | None, Form()] = None,
|
||||
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
|
||||
) -> Any:
|
||||
"""
|
||||
获取认证Token
|
||||
"""
|
||||
runtime_config = resolve_api_runtime_config(runtime_config)
|
||||
success, user_or_message = UserChain().user_authenticate(
|
||||
username=form_data.username, password=form_data.password, mfa_code=otp_password
|
||||
)
|
||||
@@ -69,13 +71,13 @@ def login_access_token(
|
||||
# 是否显示配置向导
|
||||
show_wizard = (
|
||||
not get_configured_system_config().get(SystemConfigKey.SetupWizardState)
|
||||
and not settings.ADVANCED_MODE
|
||||
and not runtime_config.advanced_mode
|
||||
)
|
||||
access_token = create_access_token(
|
||||
userid=user_or_message.id,
|
||||
username=user_or_message.name,
|
||||
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,
|
||||
)
|
||||
set_or_refresh_resource_token_cookie(
|
||||
|
||||
+34
-41
@@ -84,6 +84,39 @@ def create_jsonrpc_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(
|
||||
"",
|
||||
summary="MCP JSON-RPC 端点",
|
||||
@@ -130,48 +163,8 @@ async def mcp_jsonrpc(
|
||||
params = body.get("params", {})
|
||||
request_id = body.get("id")
|
||||
|
||||
# 如果有 id,则为请求;没有 id 则为通知
|
||||
is_notification = request_id is None
|
||||
|
||||
try:
|
||||
# 处理初始化请求
|
||||
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}"
|
||||
)
|
||||
)
|
||||
return await _dispatch_jsonrpc_method(method, params, request_id)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"MCP 请求参数错误: {e}")
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.chain.data import ChainDataPorts
|
||||
from app.application.chain.durable_events import ChainDurableEventWriter
|
||||
from app.application.configuration import ChainRuntimeConfig
|
||||
|
||||
|
||||
MessageQueueFactory = Callable[[Callable[..., Any]], Any]
|
||||
@@ -30,6 +31,9 @@ class ChainRuntimeContext:
|
||||
module_dispatcher_factory: ModuleDispatcherFactory
|
||||
data_ports: Optional[ChainDataPorts] = None
|
||||
durable_event_writer: Optional[ChainDurableEventWriter] = None
|
||||
configuration: ChainRuntimeConfig = field(
|
||||
default_factory=lambda: ChainRuntimeConfig(media_extensions=())
|
||||
)
|
||||
|
||||
|
||||
def _unconfigured_chain_runtime_context() -> ChainRuntimeContext:
|
||||
|
||||
@@ -41,6 +41,56 @@ class TransferRetryConfig:
|
||||
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:
|
||||
"""系统配置读写应用服务。"""
|
||||
|
||||
@@ -82,6 +132,7 @@ class SystemConfigService:
|
||||
|
||||
_configured_system_config: SystemConfigService | None = None
|
||||
_transfer_retry_config_provider: Callable[[], TransferRetryConfig] | None = None
|
||||
_runtime_configuration: RuntimeConfiguration | None = 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:
|
||||
raise RuntimeError("整理失败重试配置尚未装配")
|
||||
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.filecache = context.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.durable_event_writer = context.durable_event_writer
|
||||
self._module_dispatcher = context.module_dispatcher_factory(
|
||||
|
||||
+81
-67
@@ -1038,6 +1038,51 @@ class DownloadChain(ChainBase):
|
||||
# 返回 种子文件路径,种子目录名,种子文件清单
|
||||
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,
|
||||
torrent_file: Path = None,
|
||||
torrent_content: Optional[Union[str, bytes]] = None,
|
||||
@@ -1077,40 +1122,12 @@ class DownloadChain(ChainBase):
|
||||
_media = MediaChain().supplement_tmdb_info(_media, _meta)
|
||||
context.media_info = _media
|
||||
|
||||
# 发送资源下载事件,允许外部拦截下载
|
||||
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": _media.category
|
||||
}
|
||||
save_path, event_error = self._apply_resource_download_event(
|
||||
context, episodes, channel, source, downloader, save_path,
|
||||
userid, username,
|
||||
)
|
||||
# 触发资源下载事件
|
||||
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
|
||||
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
|
||||
if event_error:
|
||||
return (None, event_error) if return_detail 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")
|
||||
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:
|
||||
no_exists = {}
|
||||
|
||||
@@ -2006,8 +1995,10 @@ class DownloadChain(ChainBase):
|
||||
continue
|
||||
# 总集数
|
||||
total_ep = totals.get(season) or len(episodes)
|
||||
__append_no_exists(_season=season, _episodes=[],
|
||||
_total=total_ep, _start=min(episodes))
|
||||
self._append_no_exists(
|
||||
no_exists, media_source, media_id, season, [],
|
||||
total_ep, min(episodes)
|
||||
)
|
||||
return False, no_exists
|
||||
else:
|
||||
# 存在一些,检查每季缺失的季集
|
||||
@@ -2035,12 +2026,16 @@ class DownloadChain(ChainBase):
|
||||
# 全部集存在
|
||||
continue
|
||||
# 添加不存在的季集信息
|
||||
__append_no_exists(_season=season, _episodes=lack_episodes,
|
||||
_total=season_total, _start=min(lack_episodes))
|
||||
self._append_no_exists(
|
||||
no_exists, media_source, media_id, season,
|
||||
lack_episodes, season_total, min(lack_episodes)
|
||||
)
|
||||
else:
|
||||
# 全季不存在
|
||||
__append_no_exists(_season=season, _episodes=[],
|
||||
_total=season_total, _start=min(episodes))
|
||||
self._append_no_exists(
|
||||
no_exists, media_source, media_id, season, [],
|
||||
season_total, min(episodes)
|
||||
)
|
||||
# 存在不完整的剧集
|
||||
if no_exists:
|
||||
logger.debug(f"媒体库中已存在部分剧集,缺失:{no_exists}")
|
||||
@@ -2048,6 +2043,25 @@ class DownloadChain(ChainBase):
|
||||
# 全部存在
|
||||
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):
|
||||
"""
|
||||
查询正在下载的任务,并发送消息
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Any, Optional, List, Dict
|
||||
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
from app.chain import ChainBase
|
||||
from app.runtime.config import settings
|
||||
from app.application.directory import DirectoryHelper
|
||||
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("")
|
||||
if len(fileitem_path.parts) <= 2:
|
||||
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 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
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ class DownloadFailure(Base):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_active_by_fingerprints(
|
||||
cls,
|
||||
db: Session,
|
||||
@@ -81,7 +80,6 @@ class DownloadFailure(Base):
|
||||
).scalars().all())
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def record_failure(
|
||||
cls,
|
||||
db: Session,
|
||||
|
||||
@@ -63,31 +63,33 @@ _METHOD_CONTRACTS = {
|
||||
"recognize_media": ModuleMethodContract(
|
||||
family="media-recognition", input_contract="MediaRecognitionRequest",
|
||||
result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
|
||||
required_parameters=("meta", "mtype", "media_source", "media_id", "episode_group", "cache"),
|
||||
),
|
||||
"search_medias": ModuleMethodContract(
|
||||
family="media-recognition", input_contract="MediaSearchRequest",
|
||||
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"),
|
||||
"mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
||||
"mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | 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),
|
||||
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
||||
"download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", 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),
|
||||
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
||||
"get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", 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),
|
||||
"get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||
"rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||
"storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="Any", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||
"snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||
"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, required_parameters=("server", "item_id")),
|
||||
"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, required_parameters=("server", "item_id")),
|
||||
"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, required_parameters=("fileitem", "path", "new_name")),
|
||||
"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, required_parameters=("storage", "path")),
|
||||
"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, required_parameters=("fileitem",)),
|
||||
"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, required_parameters=("storage", "action")),
|
||||
"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),
|
||||
"finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||
"register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None"),
|
||||
"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", required_parameters=("commands",)),
|
||||
"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 = (
|
||||
|
||||
+51
-45
@@ -24,13 +24,17 @@ from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.transfer import TransferChain
|
||||
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.extensions.plugin_manager import PluginManager
|
||||
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.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.messaging.message import MessageHelper
|
||||
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
|
||||
@@ -209,9 +213,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
"""按当前宿主策略创建一次定时数据库备份。"""
|
||||
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
|
||||
|
||||
job_id = "database_backup"
|
||||
@@ -226,8 +233,8 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
self.start,
|
||||
trigger=TimerUtils.build_schedule_trigger(
|
||||
trigger_type="cron",
|
||||
trigger_value=settings.DB_BACKUP_CRON,
|
||||
timezone_name=settings.TZ,
|
||||
trigger_value=config.db_backup_cron,
|
||||
timezone_name=config.timezone,
|
||||
),
|
||||
id=job_id,
|
||||
name="数据库备份",
|
||||
@@ -240,11 +247,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
初始化定时服务
|
||||
"""
|
||||
|
||||
config = get_scheduler_runtime_config()
|
||||
# 停止定时服务
|
||||
self.stop()
|
||||
|
||||
# 调试模式不启动定时服务
|
||||
if settings.DEV:
|
||||
if config.dev:
|
||||
return
|
||||
|
||||
# 对账上个进程未收口的 Agent 任务;进程内重复初始化不会重复改写状态。
|
||||
@@ -277,11 +285,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
]).runtime_states()
|
||||
|
||||
self._scheduler = BackgroundScheduler(
|
||||
timezone=settings.TZ,
|
||||
executors={"default": ThreadPoolExecutor(settings.CONF.scheduler)},
|
||||
timezone=config.timezone,
|
||||
executors={"default": ThreadPoolExecutor(config.scheduler_workers)},
|
||||
)
|
||||
|
||||
self._register_database_backup_job()
|
||||
self._register_database_backup_job(config)
|
||||
self._jobs["outbox_dispatch"] = JobSpec(
|
||||
"outbox_dispatch",
|
||||
"恢复待投递副作用",
|
||||
@@ -295,30 +303,30 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
id="outbox_dispatch",
|
||||
name="恢复待投递副作用",
|
||||
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"},
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# CookieCloud定时同步
|
||||
if (
|
||||
settings.COOKIECLOUD_INTERVAL
|
||||
and str(settings.COOKIECLOUD_INTERVAL).isdigit()
|
||||
config.cookiecloud_interval
|
||||
and str(config.cookiecloud_interval).isdigit()
|
||||
):
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="cookiecloud",
|
||||
name="同步CookieCloud站点",
|
||||
minutes=int(settings.COOKIECLOUD_INTERVAL),
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
|
||||
minutes=int(config.cookiecloud_interval),
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=5),
|
||||
kwargs={"job_id": "cookiecloud"},
|
||||
)
|
||||
|
||||
# 按媒体服务器分别注册自动同步任务
|
||||
mediaserver_schedules = self._build_mediaserver_sync_schedules(
|
||||
mediaservers=ServiceConfigHelper.get_mediaserver_configs(),
|
||||
default_interval=settings.MEDIASERVER_SYNC_INTERVAL,
|
||||
default_interval=config.mediaserver_sync_interval,
|
||||
)
|
||||
for mediaserver_schedule in mediaserver_schedules:
|
||||
job_id = mediaserver_schedule["id"]
|
||||
@@ -335,7 +343,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
id=job_id,
|
||||
name=mediaserver_schedule["name"],
|
||||
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},
|
||||
)
|
||||
|
||||
@@ -360,17 +368,17 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
)
|
||||
|
||||
# 订阅状态每隔24小时搜索一次
|
||||
if settings.SUBSCRIBE_SEARCH:
|
||||
if config.subscribe_search:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_search",
|
||||
name="订阅搜索补全",
|
||||
hours=settings.SUBSCRIBE_SEARCH_INTERVAL,
|
||||
hours=config.subscribe_search_interval,
|
||||
kwargs={"job_id": "subscribe_search"},
|
||||
)
|
||||
|
||||
if settings.SUBSCRIBE_MODE == "spider":
|
||||
if config.subscribe_mode == "spider":
|
||||
# 站点首页种子定时刷新模式
|
||||
triggers = TimerUtils.random_scheduler(num_executions=32)
|
||||
for trigger in triggers:
|
||||
@@ -385,19 +393,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
)
|
||||
else:
|
||||
# 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.start,
|
||||
"interval",
|
||||
id="subscribe_refresh",
|
||||
name="RSS订阅刷新",
|
||||
minutes=int(settings.SUBSCRIBE_RSS_INTERVAL),
|
||||
minutes=config.subscribe_rss_interval,
|
||||
kwargs={"job_id": "subscribe_refresh"},
|
||||
)
|
||||
|
||||
@@ -428,7 +429,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
id="random_wallpager",
|
||||
name="壁纸缓存",
|
||||
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"},
|
||||
)
|
||||
|
||||
@@ -443,7 +444,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
)
|
||||
|
||||
# 数据表清理服务,每天凌晨执行一次
|
||||
if settings.DATA_CLEANUP_ENABLE:
|
||||
if config.data_cleanup_enable:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"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.start,
|
||||
"interval",
|
||||
id="sitedata_refresh",
|
||||
name="站点数据刷新",
|
||||
minutes=settings.SITEDATA_REFRESH_INTERVAL * 60,
|
||||
minutes=config.sitedata_refresh_interval * 60,
|
||||
kwargs={"job_id": "sitedata_refresh"},
|
||||
)
|
||||
|
||||
@@ -482,7 +483,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
id="recommend_refresh",
|
||||
name="推荐缓存",
|
||||
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"},
|
||||
)
|
||||
|
||||
@@ -503,34 +504,34 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
id="subscribe_calendar_cache",
|
||||
name="订阅日历缓存",
|
||||
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"},
|
||||
)
|
||||
|
||||
# 主动内存回收
|
||||
if settings.MEMORY_GC_INTERVAL:
|
||||
if config.memory_gc_interval:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="full_gc",
|
||||
name="主动内存回收",
|
||||
minutes=settings.MEMORY_GC_INTERVAL,
|
||||
minutes=config.memory_gc_interval,
|
||||
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.start,
|
||||
"interval",
|
||||
id="agent_heartbeat",
|
||||
name="智能体定时任务",
|
||||
hours=settings.AI_AGENT_JOB_INTERVAL,
|
||||
hours=config.ai_agent_job_interval,
|
||||
kwargs={"job_id": "agent_heartbeat"},
|
||||
)
|
||||
|
||||
# 安装版本统计上报
|
||||
if settings.USAGE_STATISTIC_SHARE:
|
||||
if config.usage_statistic_share:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
@@ -544,7 +545,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
self.init_workflow_jobs()
|
||||
|
||||
# 恢复 Agent 自主定时任务
|
||||
if settings.AI_AGENT_ENABLE:
|
||||
if config.ai_agent_enable:
|
||||
self.init_agent_task_jobs()
|
||||
|
||||
# 初始化插件服务
|
||||
@@ -969,10 +970,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
:param task_id: Agent 定时任务 ID
|
||||
:return: 下一次执行时间,不可调度时返回 None
|
||||
"""
|
||||
config = get_scheduler_runtime_config()
|
||||
self.remove_agent_task_job(task_id)
|
||||
task = AgentTaskOper().get(task_id)
|
||||
if (
|
||||
not settings.AI_AGENT_ENABLE
|
||||
not config.ai_agent_enable
|
||||
or not task
|
||||
or not task.enabled
|
||||
or not self._scheduler
|
||||
@@ -989,7 +991,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
trigger = TimerUtils.build_schedule_trigger(
|
||||
trigger_type=task.trigger_type,
|
||||
trigger_value=trigger_value,
|
||||
timezone_name=settings.TZ,
|
||||
timezone_name=config.timezone,
|
||||
)
|
||||
except (TypeError, ValueError) as err:
|
||||
logger.error(f"Agent 定时任务 {task_id} 的触发配置无效:{str(err)}")
|
||||
@@ -1046,6 +1048,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
:param task_id: Agent 定时任务 ID
|
||||
:return: 带时区的 ISO 8601 时间,不再执行时返回 None
|
||||
"""
|
||||
config = get_scheduler_runtime_config()
|
||||
job_id = self._get_agent_task_job_id(task_id)
|
||||
if self._scheduler:
|
||||
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(
|
||||
trigger_type=task.trigger_type,
|
||||
trigger_value=trigger_value,
|
||||
timezone_name=settings.TZ,
|
||||
timezone_name=config.timezone,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -1443,6 +1446,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
"""
|
||||
用户认证检查
|
||||
"""
|
||||
config = get_scheduler_runtime_config()
|
||||
if SitesHelper().auth_level >= 2:
|
||||
return
|
||||
# 最大重试次数
|
||||
@@ -1457,7 +1461,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
self._auth_message = True
|
||||
return
|
||||
logger.info("用户未认证,正在尝试认证...")
|
||||
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
|
||||
auth_conf = get_configured_system_config().get(
|
||||
SystemConfigKey.UserSiteAuthParams
|
||||
)
|
||||
if auth_conf:
|
||||
status, msg = SitesHelper().check_user(**auth_conf)
|
||||
else:
|
||||
@@ -1470,7 +1476,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
mtype=MessageType.Manual,
|
||||
title="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,
|
||||
)
|
||||
from app.application.outbox import AsyncOutboxTransaction
|
||||
from app.application.configuration import RuntimeConfiguration
|
||||
from app.application.subscription.delete import SubscribeDeletionRepository
|
||||
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
|
||||
from app.application.subscription.mutation import (
|
||||
@@ -125,4 +126,5 @@ class HostRuntime:
|
||||
|
||||
agent_chat: AgentChatRuntime
|
||||
subscription: SubscriptionRuntime
|
||||
configuration: RuntimeConfiguration
|
||||
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,
|
||||
)
|
||||
from app.application.configuration import (
|
||||
ApiRuntimeConfig,
|
||||
ChainRuntimeConfig,
|
||||
RuntimeConfiguration,
|
||||
SchedulerRuntimeConfig,
|
||||
SystemConfigService,
|
||||
TransferRetryConfig,
|
||||
configure_runtime_configuration,
|
||||
configure_system_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.transferpending import TransferPendingOper
|
||||
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.message import MessageOper
|
||||
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
||||
@@ -108,6 +112,7 @@ from app.startup.subscription import (
|
||||
configure_transactional_subscription_scopes,
|
||||
)
|
||||
from app.startup.chain_events import TransactionalChainDurableEventWriter
|
||||
from app.startup.download_failure import TransactionalDownloadFailureRepository
|
||||
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
|
||||
from app.adapters.web.security.access import set_superuser_token_payload_provider
|
||||
from app.application.security.auth import build_superuser_token_payload
|
||||
@@ -151,11 +156,68 @@ def _build_chain_runtime_context() -> ChainRuntimeContext:
|
||||
send_callback=callback
|
||||
),
|
||||
module_dispatcher_factory=ModuleInvocationDispatcher,
|
||||
configuration=_build_chain_runtime_config(),
|
||||
data_ports=get_chain_data_ports(),
|
||||
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:
|
||||
"""在启动组合层装配运行时和外部服务所需的数据库读取能力。"""
|
||||
configure_service_config_reader(lambda key: SystemConfigOper().get(key))
|
||||
@@ -478,6 +540,11 @@ async def init_modules() -> HostRuntime:
|
||||
"sync": SqlAlchemyUnitOfWork,
|
||||
},
|
||||
)
|
||||
runtime_configuration = RuntimeConfiguration(
|
||||
api=_build_api_runtime_config,
|
||||
scheduler=_build_scheduler_runtime_config,
|
||||
chain=_build_chain_runtime_config,
|
||||
)
|
||||
host_runtime = HostRuntime(
|
||||
agent_chat=AgentChatRuntime(
|
||||
async_session=get_async_db,
|
||||
@@ -491,8 +558,10 @@ async def init_modules() -> HostRuntime:
|
||||
transaction=SqlAlchemyAsyncUnitOfWork,
|
||||
outbox=SqlAlchemyAsyncOutboxStager,
|
||||
),
|
||||
configuration=runtime_configuration,
|
||||
compatibility_api_data=api_data,
|
||||
)
|
||||
configure_runtime_configuration(host_runtime.configuration)
|
||||
configure_api_data_runtime(host_runtime.compatibility_api_data)
|
||||
configure_runtime_data_providers()
|
||||
configure_chain_data_ports(
|
||||
@@ -503,7 +572,9 @@ async def init_modules() -> HostRuntime:
|
||||
transfer_history=lambda: TransferHistoryOper(),
|
||||
transfer_pending=lambda: TransferPendingOper(),
|
||||
media_server=lambda: MediaServerOper(),
|
||||
download_failure=lambda: DownloadFailureOper(),
|
||||
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||
SessionFactory
|
||||
),
|
||||
user=lambda: UserOper(),
|
||||
)
|
||||
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
||||
|
||||
@@ -559,6 +559,17 @@ app/api/dependencies/ # 按领域拆分依赖工厂
|
||||
- 219 个架构、配置、Agent 安全、整理重试、Module reload 专项测试通过,Pylint 10/10;依赖基线
|
||||
仅把 `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:把动态模块和事件变成可演进契约
|
||||
|
||||
#### ARCH-240:Module Contract V2
|
||||
@@ -605,6 +616,8 @@ ModuleMethodSpec(
|
||||
方法继续使用 legacy contract。
|
||||
- runtime contract baseline 现包含稳定的 `module_method_specs`,后续字段或显式方法变化必须审查;
|
||||
`ModuleCapability` Protocol 为宿主和新插件提供静态声明入口,但不替换字符串 dispatcher ABI。
|
||||
- 22 个显式方法进一步登记宿主真实传入的 required parameter 名称,覆盖识别、搜索、媒体服务器、存储、
|
||||
消息收尾、命令注册和 webhook;dispatcher 仍只输出诊断 warning,不阻断缺少参数的旧插件或未知自定义方法。
|
||||
|
||||
#### ARCH-241:Event Contract Registry
|
||||
|
||||
@@ -753,6 +766,9 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas
|
||||
Application 删除命令替代的两个 `Subscribe` Model 级删除事务装饰器;Model decorator 基线从
|
||||
178 降到 176,Oper 内显式 commit/rollback 仍为 0。strict mypy 门禁新增 Chain durable context、
|
||||
payload 转换和启动适配器。
|
||||
- 下载失败冷却切片继续迁移到 `TransactionalDownloadFailureRepository`:Chain 每次读写使用独立短会话,
|
||||
写成功由显式 `SqlAlchemyUnitOfWork` commit,异常 rollback;`DownloadFailure` 查询和记录方法不再拥有
|
||||
自动会话/提交装饰器。Model decorator 基线继续从 176 降到 174,Oper 内显式 commit/rollback 仍为 0。
|
||||
|
||||
**禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。
|
||||
|
||||
@@ -864,6 +880,9 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op-
|
||||
- 配置约束测试会检查 strict、关键合同文件和至少一个 Domain 文件均在清单中,并实际启动锁定版本 mypy;
|
||||
当前 10 个源文件零错误通过。
|
||||
|
||||
**扩展实施记录(2026-08-22)**:mypy 目标运行时更新到 Python 3.14,严格清单扩大到 20 个源文件;
|
||||
新增纳管配置快照和下载失败事务适配器,仍保持零错误、无全局 ignore。
|
||||
|
||||
#### ARCH-271:复杂度和端点预算 ratchet
|
||||
|
||||
**目标**:阻止大方法继续增长,并让拆分对应真实阶段,而不是机械 helper 化。
|
||||
@@ -887,6 +906,8 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op-
|
||||
- 当前债务清单明确包含 `web_agent_stream`、`batch_download`、`SubscribeChain.match`、`do_transfer`;
|
||||
`Scheduler.init` 已在 ARCH-252 通过 JobSpec/catalog 拆分退出超限清单,调度专项测试是该代表性拆分的回归证据。
|
||||
- 单元测试覆盖删除/缩短放行和增长/新增拒绝,当前仓库 baseline check 通过。
|
||||
- 2026-08-22 将 MCP JSON-RPC 分派、无媒体信息下载识别、缺集结果合并拆成具有独立输入/输出的私有阶段;
|
||||
对应 `mcp_jsonrpc`、`download.add`、`DownloadChain.get_no_exists_info` 退出超限清单,总债务从 28 降到 25。
|
||||
|
||||
#### ARCH-272:异步阻塞检测
|
||||
|
||||
@@ -910,6 +931,8 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op-
|
||||
ActivityLog 为保证 `O_EXCL` 原子创建使用的一处 `os.open` 精确债务,不泛化豁免整个文件或目录。
|
||||
- pytest 全局启用 `asyncio_debug`,专项测试验证实际 loop debug 状态;AST ratchet 与 46 个 Agent 流式回归
|
||||
通过。同步第三方 Module 仍由 dispatcher 的 `app.runtime.execution.run_in_threadpool` 兼容。
|
||||
- 2026-08-22 扫描范围扩大到 `app/chain`、`app/modules`、`app/startup` 与 `app/scheduler.py`;扩大后未发现
|
||||
新存量,仍只保留 ActivityLog 的一处原子 `os.open` 精确债务,并由测试锁定扫描根目录。
|
||||
|
||||
## 6. 推荐执行队列
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[mypy]
|
||||
python_version = 3.12
|
||||
python_version = 3.14
|
||||
strict = True
|
||||
warn_unused_configs = True
|
||||
ignore_missing_imports = True
|
||||
@@ -16,6 +16,7 @@ files =
|
||||
app/runtime/event/contracts.py,
|
||||
app/runtime/extensions/module/contracts.py,
|
||||
app/application/outbox.py,
|
||||
app/application/configuration.py,
|
||||
app/application/chain/context.py,
|
||||
app/application/chain/durable_events.py,
|
||||
app/application/subscription/delete.py,
|
||||
@@ -23,5 +24,6 @@ files =
|
||||
app/application/subscription/mutation.py,
|
||||
app/startup/context.py,
|
||||
app/startup/chain_events.py,
|
||||
app/startup/download_failure.py,
|
||||
app/api/context.py,
|
||||
app/api/dependencies/subscription.py
|
||||
|
||||
@@ -10,7 +10,15 @@ from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
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 = {
|
||||
"open",
|
||||
"time.sleep",
|
||||
@@ -167,7 +175,9 @@ def collect_async_blocking(root: Path = PROJECT_ROOT) -> dict[str, int]:
|
||||
"""扫描关键目录并以文件、函数、调用名聚合存量次数。"""
|
||||
debt: Counter[str] = Counter()
|
||||
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))
|
||||
relative = path.relative_to(root).as_posix()
|
||||
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.application.configuration import (
|
||||
ApiRuntimeConfig,
|
||||
ChainRuntimeConfig,
|
||||
RuntimeConfiguration,
|
||||
SchedulerRuntimeConfig,
|
||||
SystemConfigService,
|
||||
TransferRetryConfig,
|
||||
configure_runtime_configuration,
|
||||
configure_system_config,
|
||||
configure_transfer_retry_config,
|
||||
)
|
||||
@@ -46,6 +51,22 @@ def configure_plugin_system_services():
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
|
||||
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_transfer_retry_config(
|
||||
lambda: TransferRetryConfig(
|
||||
|
||||
+1
-4
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"api_endpoint": {
|
||||
"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/openai.py:chat_completions": 107,
|
||||
"app/api/endpoints/openai.py:responses": 105,
|
||||
@@ -20,8 +18,7 @@
|
||||
},
|
||||
"chain_public": {
|
||||
"app/chain/download.py:DownloadChain.batch_download": 572,
|
||||
"app/chain/download.py:DownloadChain.download_single": 276,
|
||||
"app/chain/download.py:DownloadChain.get_no_exists_info": 152,
|
||||
"app/chain/download.py:DownloadChain.download_single": 255,
|
||||
"app/chain/mediaserver.py:MediaServerChain.sync": 292,
|
||||
"app/chain/site.py:SiteChain.sync_cookies": 180,
|
||||
"app/chain/subscribe.py:SubscribeChain.add": 183,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"root": "app"
|
||||
},
|
||||
"settings_imports": {
|
||||
"count": 169,
|
||||
"count": 164,
|
||||
"files": [
|
||||
"app/adapters/cache/backends.py",
|
||||
"app/adapters/cache/redis.py",
|
||||
@@ -49,9 +49,6 @@
|
||||
"app/agent/tools/impl/update_system_settings.py",
|
||||
"app/api/endpoints/agent.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/message.py",
|
||||
"app/api/endpoints/openai.py",
|
||||
@@ -83,7 +80,6 @@
|
||||
"app/chain/scraping.py",
|
||||
"app/chain/search.py",
|
||||
"app/chain/site.py",
|
||||
"app/chain/storage.py",
|
||||
"app/chain/subscribe.py",
|
||||
"app/chain/system.py",
|
||||
"app/chain/torrents.py",
|
||||
@@ -166,7 +162,6 @@
|
||||
"app/runtime/extensions/plugin_manager.py",
|
||||
"app/runtime/state.py",
|
||||
"app/runtime/thread.py",
|
||||
"app/scheduler.py",
|
||||
"app/startup/agent_initializer.py",
|
||||
"app/startup/database.py",
|
||||
"app/startup/database_initializer.py",
|
||||
@@ -184,10 +179,6 @@
|
||||
},
|
||||
"system_config_oper_constructions": {
|
||||
"calls": [
|
||||
{
|
||||
"file": "app/scheduler.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/modules_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
@@ -245,6 +236,6 @@
|
||||
"name": "SystemConfigOper"
|
||||
}
|
||||
],
|
||||
"count": 15
|
||||
"count": 14
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6299,
|
||||
"edge_sha256": "f98ab7d05c884a07a881eff1ab34b1ba5023751f1e95b956debcbad54c385810",
|
||||
"edge_count": 6306,
|
||||
"edge_sha256": "7ca2da728599965ef604010fdf8e8e03d2939c68f3f45eba5b93aff63c2838a4",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -1479,6 +1479,7 @@
|
||||
"app.api.apiv1 -> app.api",
|
||||
"app.api.apiv1 -> app.api.router_specs",
|
||||
"app.api.context -> app.application",
|
||||
"app.api.context -> app.application.configuration",
|
||||
"app.api.context -> app.application.messaging",
|
||||
"app.api.context -> app.application.messaging.chat",
|
||||
"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.access",
|
||||
"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.auth",
|
||||
"app.api.endpoints.dashboard -> app.api.dependencies.history",
|
||||
"app.api.endpoints.dashboard -> app.api.response",
|
||||
"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.directory",
|
||||
"app.api.endpoints.dashboard -> app.application.scheduling",
|
||||
"app.api.endpoints.dashboard -> app.chain",
|
||||
"app.api.endpoints.dashboard -> app.chain.dashboard",
|
||||
"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.dashboard",
|
||||
"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.runtime_loader",
|
||||
"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.auth",
|
||||
"app.api.endpoints.history -> app.api.dependencies.history",
|
||||
"app.api.endpoints.history -> app.api.response",
|
||||
"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.runtime",
|
||||
"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.access",
|
||||
"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.application",
|
||||
"app.api.endpoints.login -> app.application.configuration",
|
||||
@@ -1837,8 +1841,6 @@
|
||||
"app.api.endpoints.login -> app.application.site",
|
||||
"app.api.endpoints.login -> app.chain",
|
||||
"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.response",
|
||||
"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.data",
|
||||
"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.history",
|
||||
"app.application.chain.durable_events -> app.domain",
|
||||
@@ -3210,7 +3213,6 @@
|
||||
"app.chain.storage -> app.application.directory",
|
||||
"app.chain.storage -> app.chain",
|
||||
"app.chain.storage -> app.runtime",
|
||||
"app.chain.storage -> app.runtime.config",
|
||||
"app.chain.storage -> app.runtime.log",
|
||||
"app.chain.storage -> app.schemas",
|
||||
"app.chain.storage -> app.schemas.workflow",
|
||||
@@ -5579,6 +5581,7 @@
|
||||
"app.scheduler -> app.agent",
|
||||
"app.scheduler -> app.agent.runtime_loader",
|
||||
"app.scheduler -> app.application",
|
||||
"app.scheduler -> app.application.configuration",
|
||||
"app.scheduler -> app.application.database",
|
||||
"app.scheduler -> app.application.image",
|
||||
"app.scheduler -> app.application.messaging",
|
||||
@@ -5596,7 +5599,6 @@
|
||||
"app.scheduler -> app.db",
|
||||
"app.scheduler -> app.db.oper",
|
||||
"app.scheduler -> app.db.oper.agenttask",
|
||||
"app.scheduler -> app.db.oper.systemconfig",
|
||||
"app.scheduler -> app.foundation",
|
||||
"app.scheduler -> app.foundation.singleton",
|
||||
"app.scheduler -> app.runtime",
|
||||
@@ -5871,6 +5873,7 @@
|
||||
"app.startup.command_initializer -> app.application.commands",
|
||||
"app.startup.command_initializer -> app.command",
|
||||
"app.startup.context -> app.application",
|
||||
"app.startup.context -> app.application.configuration",
|
||||
"app.startup.context -> app.application.messaging",
|
||||
"app.startup.context -> app.application.messaging.chat",
|
||||
"app.startup.context -> app.application.outbox",
|
||||
@@ -5918,6 +5921,10 @@
|
||||
"app.startup.domain_initializer -> app.domain.metainfo",
|
||||
"app.startup.domain_initializer -> app.runtime",
|
||||
"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.external",
|
||||
"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.agentchat",
|
||||
"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.mediaserver",
|
||||
"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.context",
|
||||
"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.outbox",
|
||||
"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.actions"
|
||||
],
|
||||
"module_count": 784,
|
||||
"module_count": 785,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -7069,6 +7076,7 @@
|
||||
"app.startup.database",
|
||||
"app.startup.database_initializer",
|
||||
"app.startup.domain_initializer",
|
||||
"app.startup.download_failure",
|
||||
"app.startup.lifecycle",
|
||||
"app.startup.lifecycle.components",
|
||||
"app.startup.managed_resources_initializer",
|
||||
|
||||
+83
-19
@@ -2343,7 +2343,10 @@
|
||||
"input_contract": "StorageDownloadRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"fileitem",
|
||||
"path"
|
||||
],
|
||||
"result_contract": "FileItem | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2358,7 +2361,9 @@
|
||||
"input_contract": "MessageFinalizeRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"response"
|
||||
],
|
||||
"result_contract": "Message | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2373,7 +2378,10 @@
|
||||
"input_contract": "StorageItemRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"path",
|
||||
"storage"
|
||||
],
|
||||
"result_contract": "FileItem | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2388,7 +2396,10 @@
|
||||
"input_contract": "StorageFolderRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"path",
|
||||
"storage"
|
||||
],
|
||||
"result_contract": "FileItem | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2403,7 +2414,9 @@
|
||||
"input_contract": "StorageParentRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"fileitem"
|
||||
],
|
||||
"result_contract": "FileItem | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2418,7 +2431,10 @@
|
||||
"input_contract": "StorageListRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"fileitem",
|
||||
"recursion"
|
||||
],
|
||||
"result_contract": "list[FileItem]",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2448,7 +2464,10 @@
|
||||
"input_contract": "MediaServerItemRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"item_id",
|
||||
"server"
|
||||
],
|
||||
"result_contract": "MediaServerItem | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2463,7 +2482,12 @@
|
||||
"input_contract": "MediaServerItemsRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"library_id",
|
||||
"limit",
|
||||
"server",
|
||||
"start_index"
|
||||
],
|
||||
"result_contract": "list[MediaServerItem]",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2478,7 +2502,10 @@
|
||||
"input_contract": "MediaServerPlayRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"item_id",
|
||||
"server"
|
||||
],
|
||||
"result_contract": "str | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2493,7 +2520,10 @@
|
||||
"input_contract": "MediaServerEpisodesRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"item_id",
|
||||
"server"
|
||||
],
|
||||
"result_contract": "list[MediaServerPlayItem]",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2508,7 +2538,9 @@
|
||||
"input_contract": "MediaInfo",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"mediainfo"
|
||||
],
|
||||
"result_contract": "MediaInfo | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2523,7 +2555,14 @@
|
||||
"input_contract": "MediaRecognitionRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"cache",
|
||||
"episode_group",
|
||||
"media_id",
|
||||
"media_source",
|
||||
"meta",
|
||||
"mtype"
|
||||
],
|
||||
"result_contract": "MediaInfo | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2538,7 +2577,9 @@
|
||||
"input_contract": "CommandRegistrationRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"commands"
|
||||
],
|
||||
"result_contract": "None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2553,7 +2594,10 @@
|
||||
"input_contract": "StorageRenameRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"fileitem",
|
||||
"name"
|
||||
],
|
||||
"result_contract": "bool | FileItem",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2583,7 +2627,10 @@
|
||||
"input_contract": "MediaSearchRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"media_source",
|
||||
"meta"
|
||||
],
|
||||
"result_contract": "list[MediaInfo]",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2613,7 +2660,13 @@
|
||||
"input_contract": "StorageSnapshotRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"last_snapshot_time",
|
||||
"max_depth",
|
||||
"path",
|
||||
"previous_snapshot",
|
||||
"storage"
|
||||
],
|
||||
"result_contract": "dict[str, dict] | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2628,7 +2681,10 @@
|
||||
"input_contract": "StorageManageRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"action",
|
||||
"storage"
|
||||
],
|
||||
"result_contract": "Any",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2643,7 +2699,11 @@
|
||||
"input_contract": "StorageUploadRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"fileitem",
|
||||
"new_name",
|
||||
"path"
|
||||
],
|
||||
"result_contract": "FileItem | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
@@ -2658,7 +2718,11 @@
|
||||
"input_contract": "WebhookRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"required_parameters": [
|
||||
"args",
|
||||
"body",
|
||||
"form"
|
||||
],
|
||||
"result_contract": "WebhookEventInfo | None",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
|
||||
+3
-13
@@ -3,10 +3,10 @@
|
||||
"by_kind": {
|
||||
"async_db_query": 49,
|
||||
"async_db_update": 12,
|
||||
"db_query": 75,
|
||||
"db_update": 40
|
||||
"db_query": 74,
|
||||
"db_update": 39
|
||||
},
|
||||
"count": 176,
|
||||
"count": 174,
|
||||
"methods": [
|
||||
{
|
||||
"decorator": "async_db_query",
|
||||
@@ -83,16 +83,6 @@
|
||||
"file": "app/db/models/downloadfailure.py",
|
||||
"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",
|
||||
"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"))
|
||||
|
||||
assert baseline["schema_version"] == 1
|
||||
assert baseline["model_decorators"]["count"] == 176
|
||||
assert sum(baseline["model_decorators"]["by_kind"].values()) == 176
|
||||
assert baseline["model_decorators"]["count"] == 174
|
||||
assert sum(baseline["model_decorators"]["by_kind"].values()) == 174
|
||||
assert baseline["model_transaction_calls"] == {"count": 0, "calls": []}
|
||||
assert baseline["model_session_factories"] == {"count": 0, "calls": []}
|
||||
assert baseline["oper_transaction_calls"] == {"count": 0, "calls": []}
|
||||
|
||||
@@ -4,7 +4,19 @@ import asyncio
|
||||
|
||||
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:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.application.chain.context import ChainRuntimeContext
|
||||
from app.application.configuration import ChainRuntimeConfig
|
||||
from app.application.chain import context as chain_context
|
||||
from app.chain import ChainBase
|
||||
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
|
||||
@@ -20,6 +21,7 @@ def _context() -> ChainRuntimeContext:
|
||||
async_file_cache=Mock(),
|
||||
message_queue_factory=Mock(return_value=Mock()),
|
||||
module_dispatcher_factory=ModuleInvocationDispatcher,
|
||||
configuration=ChainRuntimeConfig(media_extensions=(".mkv",)),
|
||||
durable_event_writer=Mock(),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,9 +4,15 @@ import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.application.configuration import (
|
||||
ApiRuntimeConfig,
|
||||
ChainRuntimeConfig,
|
||||
RuntimeConfiguration,
|
||||
SchedulerRuntimeConfig,
|
||||
SystemConfigService,
|
||||
TransferRetryConfig,
|
||||
configure_runtime_configuration,
|
||||
configure_transfer_retry_config,
|
||||
get_api_runtime_config_snapshot,
|
||||
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 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
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app import scheduler as scheduler_module
|
||||
from app.scheduler import Scheduler
|
||||
from app.application.configuration import SchedulerRuntimeConfig
|
||||
|
||||
|
||||
class _SchedulerStub:
|
||||
"""记录 Scheduler 注册结果的最小调度器替身。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化空作业表。"""
|
||||
self.jobs = {}
|
||||
|
||||
def add_job(self, func, *, trigger, id, **kwargs) -> None:
|
||||
"""按作业 ID 保存最近一次注册参数。"""
|
||||
self.jobs[id] = {"func": func, "trigger": trigger, **kwargs}
|
||||
|
||||
|
||||
def _scheduler() -> Scheduler:
|
||||
"""构造不启动后台线程的 Scheduler。"""
|
||||
scheduler = object.__new__(Scheduler)
|
||||
scheduler._scheduler = _SchedulerStub()
|
||||
scheduler._jobs = {}
|
||||
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:
|
||||
assert Scheduler.CONFIG_WATCH.intersection({
|
||||
"DB_BACKUP_ENABLE",
|
||||
@@ -34,22 +66,19 @@ def test_database_backup_schedule_only_watches_job_shape() -> None:
|
||||
}) == {"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()
|
||||
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_ENABLE", False)
|
||||
|
||||
scheduler._register_database_backup_job()
|
||||
scheduler._register_database_backup_job(_config())
|
||||
|
||||
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()
|
||||
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_ENABLE", True)
|
||||
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_CRON", "")
|
||||
|
||||
scheduler._register_database_backup_job()
|
||||
scheduler._register_database_backup_job(_config(db_backup_enable=True))
|
||||
|
||||
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:
|
||||
scheduler = _scheduler()
|
||||
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))
|
||||
config = _config(db_backup_enable=True, db_backup_cron="0 3 * * *")
|
||||
|
||||
scheduler._register_database_backup_job()
|
||||
scheduler._register_database_backup_job()
|
||||
scheduler._register_database_backup_job(config)
|
||||
scheduler._register_database_backup_job(config)
|
||||
|
||||
assert list(scheduler._scheduler.jobs) == ["database_backup"]
|
||||
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.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
|
||||
from app.application.configuration import (
|
||||
ApiRuntimeConfig,
|
||||
ChainRuntimeConfig,
|
||||
RuntimeConfiguration,
|
||||
SchedulerRuntimeConfig,
|
||||
)
|
||||
|
||||
|
||||
class _Repository:
|
||||
@@ -87,6 +93,14 @@ def _runtime() -> HostRuntime:
|
||||
transaction=_UnitOfWork,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -92,3 +92,23 @@ def test_signature_diagnostics_do_not_reject_legacy_callable() -> None:
|
||||
"signature-unavailable",
|
||||
)
|
||||
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.scheduler import Scheduler
|
||||
from app.application.configuration import SchedulerRuntimeConfig
|
||||
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_agent_task_jobs", lambda self: None)
|
||||
monkeypatch.setattr(Scheduler, "init_plugin_jobs", lambda self: None)
|
||||
monkeypatch.setattr(scheduler_module.settings, "DEV", False)
|
||||
monkeypatch.setattr(scheduler_module.settings, "COOKIECLOUD_INTERVAL", 0)
|
||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_SEARCH", False)
|
||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_MODE", "rss")
|
||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_RSS_INTERVAL", 30)
|
||||
monkeypatch.setattr(scheduler_module.settings, "SITEDATA_REFRESH_INTERVAL", 0)
|
||||
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)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
"get_scheduler_runtime_config",
|
||||
lambda: SchedulerRuntimeConfig(
|
||||
False, "Asia/Shanghai", 1, False, "", 0, None, False, 24,
|
||||
"rss", 30, False, 0, 0, False, None, False, None,
|
||||
),
|
||||
)
|
||||
|
||||
scheduler = object.__new__(Scheduler)
|
||||
scheduler._scheduler = None
|
||||
|
||||
@@ -596,12 +596,11 @@ def test_dashboard_downloader_forwards_btrfs_fsid_setting():
|
||||
from app.api.endpoints import dashboard as dashboard_module
|
||||
|
||||
download_dir = MagicMock(download_path="/downloads")
|
||||
with patch.object(dashboard_module.settings, "BTRFS_FSID_DEDUP", True), \
|
||||
patch.object(dashboard_module.DirectoryHelper, "get_local_download_dirs",
|
||||
with patch.object(dashboard_module.DirectoryHelper, "get_local_download_dirs",
|
||||
return_value=[download_dir]), \
|
||||
patch.object(SystemUtils, "space_usage", return_value=(4.0, 2.0)) as usage_mock, \
|
||||
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(
|
||||
[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/extensions/module/contracts.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 len(governed_files) >= 20
|
||||
assert any(path.startswith("app/domain/") for path in governed_files)
|
||||
assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user