refactor: expand runtime contracts and debt ratchets

This commit is contained in:
jxxghp
2026-08-22 08:25:36 +08:00
parent 820f7e8850
commit 5d9c6c8b19
37 changed files with 883 additions and 339 deletions
+18
View File
@@ -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:
+24 -7
View File
@@ -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])
+78 -59
View File
@@ -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())
+9 -3
View File
@@ -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)
+6 -4
View File
@@ -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
View File
@@ -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}")
+5 -1
View File
@@ -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:
+71
View File
@@ -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()
+1
View File
@@ -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
View File
@@ -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):
"""
查询正在下载的任务,并发送消息
+1 -2
View File
@@ -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} 根目录或一级目录不允许删除")
+1 -3
View File
@@ -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,
+19 -17
View File
@@ -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
View File
@@ -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,
)
)
# 认证通过后重新初始化插件
+2
View File
@@ -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
+55
View File
@@ -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
+73 -2
View File
@@ -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-240Module Contract V2
@@ -605,6 +616,8 @@ ModuleMethodSpec(
方法继续使用 legacy contract。
- runtime contract baseline 现包含稳定的 `module_method_specs`,后续字段或显式方法变化必须审查;
`ModuleCapability` Protocol 为宿主和新插件提供静态声明入口,但不替换字符串 dispatcher ABI。
- 22 个显式方法进一步登记宿主真实传入的 required parameter 名称,覆盖识别、搜索、媒体服务器、存储、
消息收尾、命令注册和 webhookdispatcher 仍只输出诊断 warning,不阻断缺少参数的旧插件或未知自定义方法。
#### ARCH-241Event Contract Registry
@@ -753,6 +766,9 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas
Application 删除命令替代的两个 `Subscribe` Model 级删除事务装饰器;Model decorator 基线从
178 降到 176Oper 内显式 commit/rollback 仍为 0。strict mypy 门禁新增 Chain durable context、
payload 转换和启动适配器。
- 下载失败冷却切片继续迁移到 `TransactionalDownloadFailureRepository`:Chain 每次读写使用独立短会话,
写成功由显式 `SqlAlchemyUnitOfWork` commit,异常 rollback`DownloadFailure` 查询和记录方法不再拥有
自动会话/提交装饰器。Model decorator 基线继续从 176 降到 174Oper 内显式 commit/rollback 仍为 0。
**禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。
@@ -864,6 +880,9 @@ OTel 初始化只能位于 Startup/AdapterDomain/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/AdapterDomain/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/AdapterDomain/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. 推荐执行队列
+3 -1
View File
@@ -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
+12 -2
View File
@@ -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):
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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": []}
+13 -1
View File
@@ -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:
+2
View File
@@ -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(),
)
+35
View File
@@ -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,
+40 -12
View File
@@ -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
+14
View File
@@ -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,
)
+20
View File
@@ -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) == ()
+9 -10
View File
@@ -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
+2 -3
View File
@@ -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()
+2
View File
@@ -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")