diff --git a/app/api/context.py b/app/api/context.py index 4c3ec7ba2..8b9686eca 100644 --- a/app/api/context.py +++ b/app/api/context.py @@ -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: diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index f983b308f..f67d3788f 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -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]) diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 32823e9ab..8bb423adf 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -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()) diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index debac796b..4d67dc6cc 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -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) diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index cc5239093..d5ae03024 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -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( diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index 8e9f598e8..827fc8c93 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -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}") diff --git a/app/application/chain/context.py b/app/application/chain/context.py index 8204f8bb6..8b4c54d92 100644 --- a/app/application/chain/context.py +++ b/app/application/chain/context.py @@ -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: diff --git a/app/application/configuration.py b/app/application/configuration.py index 7bd32df1b..0c06d29fc 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -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() diff --git a/app/chain/__init__.py b/app/chain/__init__.py index df1ee0514..1ca9897d9 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -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( diff --git a/app/chain/download.py b/app/chain/download.py index 827eeb3bb..874e7bc61 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -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): """ 查询正在下载的任务,并发送消息 diff --git a/app/chain/storage.py b/app/chain/storage.py index c6be7e7fd..5e596ea15 100644 --- a/app/chain/storage.py +++ b/app/chain/storage.py @@ -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} 根目录或一级目录不允许删除") diff --git a/app/db/models/downloadfailure.py b/app/db/models/downloadfailure.py index 49c27de4f..3ab0d3a63 100644 --- a/app/db/models/downloadfailure.py +++ b/app/db/models/downloadfailure.py @@ -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, diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index 5bcbc518e..e99194f4d 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -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 = ( diff --git a/app/scheduler.py b/app/scheduler.py index a481e9ea4..556c7dd27 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -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, ) ) # 认证通过后重新初始化插件 diff --git a/app/startup/context.py b/app/startup/context.py index 6eabbcde2..178b2a1f7 100644 --- a/app/startup/context.py +++ b/app/startup/context.py @@ -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 diff --git a/app/startup/download_failure.py b/app/startup/download_failure.py new file mode 100644 index 000000000..7eddae833 --- /dev/null +++ b/app/startup/download_failure.py @@ -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 diff --git a/app/startup/modules_initializer.py b/app/startup/modules_initializer.py index ee8ea8364..89ed68bfa 100644 --- a/app/startup/modules_initializer.py +++ b/app/startup/modules_initializer.py @@ -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())) diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 477b2da89..77898b91a 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -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. 推荐执行队列 diff --git a/mypy.ini b/mypy.ini index f776fdb35..7c00f9e1b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/scripts/architecture/async_blocking.py b/scripts/architecture/async_blocking.py index 58a201f2a..2bdae70ae 100644 --- a/scripts/architecture/async_blocking.py +++ b/scripts/architecture/async_blocking.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): diff --git a/tests/conftest.py b/tests/conftest.py index 877e8ecc5..144449dff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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( diff --git a/tests/fixtures/architecture/complexity-baseline.json b/tests/fixtures/architecture/complexity-baseline.json index b357500e6..834625c2e 100644 --- a/tests/fixtures/architecture/complexity-baseline.json +++ b/tests/fixtures/architecture/complexity-baseline.json @@ -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, diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json index 599c933d5..e5ec2c07d 100644 --- a/tests/fixtures/architecture/configuration-debt-baseline.json +++ b/tests/fixtures/architecture/configuration-debt-baseline.json @@ -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 } } diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 8776306ed..e12ee58bd 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 2548c123c..7c1c7e079 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -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, diff --git a/tests/fixtures/architecture/transaction-debt-baseline.json b/tests/fixtures/architecture/transaction-debt-baseline.json index 199d353ad..9182e7bc7 100644 --- a/tests/fixtures/architecture/transaction-debt-baseline.json +++ b/tests/fixtures/architecture/transaction-debt-baseline.json @@ -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", diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index d6c1b9ac7..8746ac28b 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.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": []} diff --git a/tests/test_async_blocking_gate.py b/tests/test_async_blocking_gate.py index 3b00f5c23..6e4f1009c 100644 --- a/tests/test_async_blocking_gate.py +++ b/tests/test_async_blocking_gate.py @@ -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: diff --git a/tests/test_chain_runtime_context.py b/tests/test_chain_runtime_context.py index 09ac53d35..833d97d85 100644 --- a/tests/test_chain_runtime_context.py +++ b/tests/test_chain_runtime_context.py @@ -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(), ) diff --git a/tests/test_configuration_ports.py b/tests/test_configuration_ports.py index 91b0f6731..dffb61ea8 100644 --- a/tests/test_configuration_ports.py +++ b/tests/test_configuration_ports.py @@ -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, diff --git a/tests/test_database_backup_scheduler.py b/tests/test_database_backup_scheduler.py index 8880d4f0d..d876dc756 100644 --- a/tests/test_database_backup_scheduler.py +++ b/tests/test_database_backup_scheduler.py @@ -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 diff --git a/tests/test_host_runtime_context.py b/tests/test_host_runtime_context.py index 8ec582e07..e4db0ecb6 100644 --- a/tests/test_host_runtime_context.py +++ b/tests/test_host_runtime_context.py @@ -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, ) diff --git a/tests/test_module_method_contracts.py b/tests/test_module_method_contracts.py index 0da6de09a..f3edb054a 100644 --- a/tests/test_module_method_contracts.py +++ b/tests/test_module_method_contracts.py @@ -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) == () diff --git a/tests/test_scheduler_cache_expiry.py b/tests/test_scheduler_cache_expiry.py index 28ea46fbb..7220b1621 100644 --- a/tests/test_scheduler_cache_expiry.py +++ b/tests/test_scheduler_cache_expiry.py @@ -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 diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index 726472cb2..374cd87d1 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -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")], diff --git a/tests/test_transactional_download_failure.py b/tests/test_transactional_download_failure.py new file mode 100644 index 000000000..0b1312aac --- /dev/null +++ b/tests/test_transactional_download_failure.py @@ -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() diff --git a/tests/test_type_gate.py b/tests/test_type_gate.py index 9c35963ac..7cf6e3b3f 100644 --- a/tests/test_type_gate.py +++ b/tests/test_type_gate.py @@ -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")