From 996a1a0705e4acf0324f0e40b3297c615e1208f3 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 22 Aug 2026 13:18:05 +0800 Subject: [PATCH] refactor: expand api and chain config snapshots --- app/api/endpoints/agent.py | 9 ++- app/api/endpoints/anthropic.py | 6 +- app/api/endpoints/openai.py | 8 +- app/api/endpoints/tmdb.py | 4 +- app/api/endpoints/torrent.py | 4 +- app/application/configuration.py | 15 ++++ app/chain/site.py | 74 +++++++++---------- app/chain/torrents.py | 36 ++++----- app/startup/modules_initializer.py | 14 ++++ .../backend-architecture-next-stage.md | 4 + .../configuration-debt-baseline.json | 9 +-- .../architecture/dependency-baseline.json | 19 +++-- tests/test_agent_api_lazy_imports.py | 16 ++++ tests/test_music_torrents.py | 10 ++- tests/test_sunnypt_indexer.py | 4 +- tests/test_tmdb_cache_management.py | 7 +- tests/test_web_agent_stream.py | 65 ++++++++++++---- 17 files changed, 197 insertions(+), 107 deletions(-) diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index a9b63f8b0..64ca42ebb 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -44,7 +44,7 @@ from app.agent.runtime_loader import ( ) from app.chain.message import MessageChain from app.command import Command -from app.runtime.config import global_vars, settings +from app.runtime.config import global_vars from app.runtime.events import Event, EventManager from app.api.principal import ApiPrincipal from app.api.dependencies.agent import get_agent_chat_service @@ -55,6 +55,7 @@ from app.application.messaging.chat import ( get_configured_agent_chat_service, ) from app.application.security.user import get_configured_user_id_lookup +from app.application.configuration import get_api_runtime_config_snapshot from app.application.messaging.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue from app.application.messaging.agent import agent_interaction_manager from app.application.messaging.agent import ( @@ -744,7 +745,7 @@ def _get_web_agent_upload_dir(user: ApiPrincipal, session_id: Optional[str]) -> """ server_session_id = _build_web_agent_session_id(user, session_id) safe_session_id = server_session_id.replace(":", "_") - upload_dir = settings.TEMP_PATH / "agent_uploads" / safe_session_id + upload_dir = get_api_runtime_config_snapshot().temp_path / "agent_uploads" / safe_session_id upload_dir.mkdir(parents=True, exist_ok=True) return upload_dir @@ -972,7 +973,7 @@ def _prepare_web_agent_audio_attachment_path(voice_path: str) -> Path: logger.warning("WebAgent 语音转 WAV 跳过:ffmpeg 不可用,path=%s", source_path) return source_path - voice_dir = settings.TEMP_PATH / "voice" + voice_dir = get_api_runtime_config_snapshot().temp_path / "voice" voice_dir.mkdir(parents=True, exist_ok=True) output_path = voice_dir / f"{source_path.stem}_web_{uuid.uuid4().hex[:8]}.wav" cmd = [ @@ -2113,7 +2114,7 @@ async def web_agent_stream( return build_sse_response(traditional_event_generator()) - if not settings.AI_AGENT_ENABLE: + if not get_api_runtime_config_snapshot().ai_agent_enable: return _build_web_agent_error_response( "智能助手未启用,请先在系统设置中开启。", locale=locale, diff --git a/app/api/endpoints/anthropic.py b/app/api/endpoints/anthropic.py index 3897bcc82..4335d2421 100644 --- a/app/api/endpoints/anthropic.py +++ b/app/api/endpoints/anthropic.py @@ -23,7 +23,7 @@ from app.api.openai_utils import ( ) from app.api.presentation.sse import build_sse_response, encode_named_event from app.agent.runtime_loader import get_running_agent_manager -from app.runtime.config import settings +from app.application.configuration import get_api_runtime_config_snapshot from app.adapters.web.security.access import anthropic_api_key_header ANTHROPIC_ERROR_RESPONSES = { @@ -56,7 +56,7 @@ def _check_auth(api_key: Optional[str]) -> Optional[JSONResponse]: """ Anthropic 兼容接口以 API_TOKEN 认证受信客户端,认证通过即按管理员级 Agent 集成处理。 """ - if not api_key or api_key != settings.API_TOKEN: + if not api_key or api_key != get_api_runtime_config_snapshot().api_token: return _anthropic_error_response( "invalid x-api-key", 401, @@ -212,7 +212,7 @@ async def messages( if auth_error: return auth_error - if not settings.AI_AGENT_ENABLE: + if not get_api_runtime_config_snapshot().ai_agent_enable: return _anthropic_error_response( "MoviePilot AI agent is disabled.", 503, diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py index cc30ab2e9..1a6413ba0 100644 --- a/app/api/endpoints/openai.py +++ b/app/api/endpoints/openai.py @@ -31,7 +31,7 @@ from app.agent.runtime_loader import ( get_running_agent_manager, ) from app.agent.contracts import ReplyMode -from app.runtime.config import settings +from app.application.configuration import get_api_runtime_config_snapshot from app.adapters.web.security.access import openai_bearer_scheme from app.schemas.types import NotificationChannel @@ -453,7 +453,7 @@ def _check_auth( error_type="authentication_error", code="invalid_api_key", ) - if credentials.credentials != settings.API_TOKEN: + if credentials.credentials != get_api_runtime_config_snapshot().api_token: return _error_response( "Invalid bearer token.", 401, @@ -506,7 +506,7 @@ async def chat_completions( if auth_error: return auth_error - if not settings.AI_AGENT_ENABLE: + if not get_api_runtime_config_snapshot().ai_agent_enable: return _error_response( "MoviePilot AI agent is disabled.", 503, @@ -607,7 +607,7 @@ async def responses( if auth_error: return auth_error - if not settings.AI_AGENT_ENABLE: + if not get_api_runtime_config_snapshot().ai_agent_enable: return _error_response( "MoviePilot AI agent is disabled.", 503, diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index 257bb5a48..649c705ef 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -11,7 +11,7 @@ from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.tmdb import TmdbChain -from app.runtime.config import settings +from app.application.configuration import get_api_runtime_config_snapshot from app.adapters.web.security.access import verify_token from app.application.configuration import get_configured_system_config from app.api.dependencies.auth import get_current_active_superuser_async @@ -40,7 +40,7 @@ async def tmdb_recognition_cache( "shared_recognized": get_configured_system_config().get( SystemConfigKey.MediaRecognizeShareCount ) or 0, - "shared_recognize_enabled": settings.MEDIA_RECOGNIZE_SHARE, + "shared_recognize_enabled": get_api_runtime_config_snapshot().media_recognize_share, "data": cache_items, }, ) diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index 577cdb19a..b7782a77b 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -8,7 +8,7 @@ from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.torrents import TorrentsChain -from app.runtime.config import settings +from app.application.configuration import get_api_runtime_config_snapshot from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo @@ -41,7 +41,7 @@ async def torrents_cache(_: object = Depends(get_current_active_superuser_async) torrents_chain = TorrentsChain() # 获取spider和rss两种缓存 - if settings.SUBSCRIBE_MODE == "rss": + if get_api_runtime_config_snapshot().subscribe_mode == "rss": cache_info = await torrents_chain.async_get_torrents("rss") else: cache_info = await torrents_chain.async_get_torrents("spider") diff --git a/app/application/configuration.py b/app/application/configuration.py index 06de79225..c6f392e36 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from typing import Any, Optional, Protocol @@ -49,6 +50,10 @@ class ApiRuntimeConfig: access_token_expire_minutes: int btrfs_fsid_dedup: bool ai_agent_enable: bool + api_token: str | None = None + temp_path: Path = Path(".") + media_recognize_share: bool = False + subscribe_mode: str = "spider" @dataclass(frozen=True, slots=True) @@ -86,6 +91,16 @@ class ChainRuntimeConfig: global_image_cache: bool = False auto_download_user: Optional[str] = None resource_url: Optional[str] = None + user_agent: str = "" + proxy: Any = None + proxy_server: Any = None + proxy_host: Optional[str] = None + cookiecloud_blacklist: Any = None + subscribe_mode: str = "spider" + no_cache_site_key: str = "" + refresh_batch_size: int = 50 + torrent_cache_size: int = 1000 + site_url: Optional[str] = None @dataclass(frozen=True, slots=True) diff --git a/app/chain/site.py b/app/chain/site.py index 2fae524fc..c44b2b255 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -9,7 +9,7 @@ from lxml import etree from app.chain import ChainBase from app.chain._interaction import InteractionChainMixin -from app.runtime.config import global_vars, settings +from app.runtime.config import global_vars from app.runtime.events import Event, eventmanager from app.application.chain.data import SitePortProxy as SiteOper from app.application.configuration import get_configured_system_config @@ -175,18 +175,17 @@ class SiteChain(InteractionChainMixin, ChainBase): """ return domain in self.special_site_test - @staticmethod - def __zhuque_test(site: Site) -> Tuple[bool, str]: + def __zhuque_test(self, site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:zhuique """ # 获取token token = None - user_agent = site.ua or settings.USER_AGENT + user_agent = site.ua or self.runtime_config.user_agent res = RequestUtils( ua=user_agent, cookies=site.cookie, - proxies=settings.PROXY if site.proxy else None, + proxies=self.runtime_config.proxy if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=site.url) if res is None: @@ -207,7 +206,7 @@ class SiteChain(InteractionChainMixin, ChainBase): "User-Agent": f"{user_agent}" }, cookies=site.cookie, - proxies=settings.PROXY if site.proxy else None, + proxies=self.runtime_config.proxy if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=f"{site.url}api/user/getInfo") if user_res is None: @@ -220,12 +219,11 @@ class SiteChain(InteractionChainMixin, ChainBase): else: return False, f"错误:{user_res.status_code} {user_res.reason}" - @staticmethod - def __mteam_test(site: Site) -> Tuple[bool, str]: + def __mteam_test(self, site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:m-team """ - user_agent = site.ua or settings.USER_AGENT + user_agent = site.ua or self.runtime_config.user_agent domain = site_rules.extract_domain(site.url) url = f"https://api.{domain}/api/member/profile" headers = { @@ -235,7 +233,7 @@ class SiteChain(InteractionChainMixin, ChainBase): } res = RequestUtils( headers=headers, - proxies=settings.PROXY if site.proxy else None, + proxies=self.runtime_config.proxy if site.proxy else None, timeout=site.timeout or 15 ).post_res(url=url) if res is None: @@ -248,8 +246,7 @@ class SiteChain(InteractionChainMixin, ChainBase): else: return False, f"错误:{res.status_code} {res.reason}" - @staticmethod - def __sunnypt_test(site: Site) -> Tuple[bool, str]: + def __sunnypt_test(self, site: Site) -> Tuple[bool, str]: """ 通过 profile 接口测试 SunnyPT API Key 和下载权限 @@ -263,10 +260,10 @@ class SiteChain(InteractionChainMixin, ChainBase): res = RequestUtils( headers={ "Accept": "application/json", - "User-Agent": site.ua or settings.USER_AGENT, + "User-Agent": site.ua or self.runtime_config.user_agent, "X-API-Key": site.apikey, }, - proxies=settings.PROXY if site.proxy else None, + proxies=self.runtime_config.proxy if site.proxy else None, timeout=site.timeout or 15, ).get_res(url=f"{api_url}/profile") if res is None: @@ -283,12 +280,11 @@ class SiteChain(InteractionChainMixin, ChainBase): return False, "当前账号没有下载权限" return True, "连接成功" - @staticmethod - def __yema_test(site: Site) -> Tuple[bool, str]: + def __yema_test(self, site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:yemapt """ - user_agent = site.ua or settings.USER_AGENT + user_agent = site.ua or self.runtime_config.user_agent url = f"{site.url}api/consumer/fetchSelfDetail" headers = { "User-Agent": user_agent, @@ -298,7 +294,7 @@ class SiteChain(InteractionChainMixin, ChainBase): res = RequestUtils( headers=headers, cookies=site.cookie, - proxies=settings.PROXY if site.proxy else None, + proxies=self.runtime_config.proxy if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=url) if res is None: @@ -318,8 +314,7 @@ class SiteChain(InteractionChainMixin, ChainBase): site.url = f"{site.url}index.php" return self.__test(site) - @staticmethod - def __hddolby_test(site: Site) -> Tuple[bool, str]: + def __hddolby_test(self, site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:hddolby """ @@ -331,7 +326,7 @@ class SiteChain(InteractionChainMixin, ChainBase): } res = RequestUtils( headers=headers, - proxies=settings.PROXY if site.proxy else None, + proxies=self.runtime_config.proxy if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=url) if res is None: @@ -344,8 +339,7 @@ class SiteChain(InteractionChainMixin, ChainBase): else: return False, f"错误:{res.status_code} {res.reason}" - @staticmethod - def __rousi_test(site: Site) -> Tuple[bool, str]: + def __rousi_test(self, site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:rousi """ @@ -357,7 +351,7 @@ class SiteChain(InteractionChainMixin, ChainBase): } res = RequestUtils( headers=headers, - proxies=settings.PROXY if site.proxy else None, + proxies=self.runtime_config.proxy if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=url) if res is None: @@ -477,7 +471,7 @@ class SiteChain(InteractionChainMixin, ChainBase): rss_url, errmsg = rsshelper.get_rss_link( url=site_info.url, cookie=cookie, - ua=site_info.ua or settings.USER_AGENT, + ua=site_info.ua or self.runtime_config.user_agent, proxy=True if site_info.proxy else False, timeout=site_info.timeout or 15 ) @@ -492,16 +486,16 @@ class SiteChain(InteractionChainMixin, ChainBase): siteoper.update_cookie(domain=domain, cookies=cookie) _update_count += 1 elif indexer: - if settings.COOKIECLOUD_BLACKLIST and any( + if self.runtime_config.cookiecloud_blacklist and any( site_rules.extract_domain(domain) == site_rules.extract_domain(black_domain) for black_domain - in str(settings.COOKIECLOUD_BLACKLIST).split(",")): + in str(self.runtime_config.cookiecloud_blacklist).split(",")): logger.warn(f"站点 {domain} 已在黑名单中,不添加站点") continue # 新增站点 domain_url = __indexer_domain(inx=indexer, sub_domain=domain) proxy = False res = RequestUtils(cookies=cookie, - ua=settings.USER_AGENT + ua=self.runtime_config.user_agent ).get_res(url=domain_url) if res and res.status_code in [200, 500, 403]: content = res.text @@ -518,7 +512,7 @@ class SiteChain(InteractionChainMixin, ChainBase): logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点") continue else: - if not settings.PROXY_HOST: + if not self.runtime_config.proxy_host: _fail_count += 1 logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点") continue @@ -527,8 +521,8 @@ class SiteChain(InteractionChainMixin, ChainBase): logger.info(f"站点 {indexer.get('name')} 初次连接失败,尝试通过代理重试...") proxy = True res = RequestUtils(cookies=cookie, - ua=settings.USER_AGENT, - proxies=settings.PROXY + ua=self.runtime_config.user_agent, + proxies=self.runtime_config.proxy ).get_res(url=domain_url) if res and res.status_code in [200, 500, 403]: if not indexer.get("public") and not SiteUtils.is_logged_in(res.text): @@ -547,7 +541,7 @@ class SiteChain(InteractionChainMixin, ChainBase): # 自动生成rss地址 rss_url, errmsg = rsshelper.get_rss_link(url=domain_url, cookie=cookie, - ua=settings.USER_AGENT, + ua=self.runtime_config.user_agent, proxy=proxy) if errmsg: logger.warn(errmsg) @@ -616,7 +610,7 @@ class SiteChain(InteractionChainMixin, ChainBase): logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...") icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"), cookie=cookie, - ua=settings.USER_AGENT) + ua=self.runtime_config.user_agent) if icon_url: siteoper.update_icon(name=indexer.get("name"), domain=domain, @@ -702,18 +696,17 @@ class SiteChain(InteractionChainMixin, ChainBase): except Exception as e: return False, f"{str(e)}!" - @staticmethod - def __test(site_info: Site) -> Tuple[bool, str]: + def __test(self, site_info: Site) -> Tuple[bool, str]: """ 通用站点测试 """ site_url = site_info.url site_cookie = site_info.cookie - ua = site_info.ua or settings.USER_AGENT + ua = site_info.ua or self.runtime_config.user_agent render = site_info.render public = site_info.public - proxies = settings.PROXY if site_info.proxy else None - proxy_server = settings.PROXY_SERVER if site_info.proxy else None + proxies = self.runtime_config.proxy if site_info.proxy else None + proxy_server = self.runtime_config.proxy_server if site_info.proxy else None timeout = site_info.timeout or 60 # 访问链接 @@ -815,8 +808,7 @@ class SiteChain(InteractionChainMixin, ChainBase): # 重新发送消息 self.remote_list(channel=channel, userid=userid, source=source) - @staticmethod - def update_cookie(site_info: Site, + def update_cookie(self, site_info: Site, username: str, password: str, two_step_code: Optional[str] = None) -> Tuple[bool, str]: """ 根据用户名密码更新站点Cookie @@ -832,7 +824,7 @@ class SiteChain(InteractionChainMixin, ChainBase): username=username, password=password, two_step_code=two_step_code, - proxies=settings.PROXY_SERVER if site_info.proxy else None, + proxies=self.runtime_config.proxy_server if site_info.proxy else None, timeout=site_info.timeout or 60 ) if result: diff --git a/app/chain/torrents.py b/app/chain/torrents.py index f099c6f13..f581cc6c3 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -7,7 +7,7 @@ from app.application.site.sites import SitesHelper # pylint: disable=import-err from app.chain import ChainBase from app.chain.media import MediaChain -from app.runtime.config import settings, global_vars +from app.runtime.config import global_vars from app.domain.context import TorrentInfo, Context, MediaInfo from app.domain.context import MusicInfo from app.domain.meta.metamusic import MetaMusic @@ -40,7 +40,7 @@ class TorrentsChain(ChainBase): """ 返回缓存文件列表 """ - if settings.SUBSCRIBE_MODE == 'spider': + if self.runtime_config.subscribe_mode == 'spider': return self._spider_file return self._rss_file @@ -67,7 +67,7 @@ class TorrentsChain(ChainBase): """ if not stype: - stype = settings.SUBSCRIBE_MODE + stype = self.runtime_config.subscribe_mode # 读取缓存 if stype == 'spider': @@ -92,7 +92,7 @@ class TorrentsChain(ChainBase): :param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存 """ if not stype: - stype = settings.SUBSCRIBE_MODE + stype = self.runtime_config.subscribe_mode music_file = self._music_spider_file if stype == 'spider' else self._music_rss_file music_cache = self.load_cache(music_file) or {} # 兼容性处理:为旧版本的Context对象补齐新增候选识别字段 @@ -105,7 +105,7 @@ class TorrentsChain(ChainBase): :param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存 """ if not stype: - stype = settings.SUBSCRIBE_MODE + stype = self.runtime_config.subscribe_mode if stype == 'spider': return self._spider_file, self._music_spider_file return self._rss_file, self._music_rss_file @@ -135,7 +135,7 @@ class TorrentsChain(ChainBase): """ if not stype: - stype = settings.SUBSCRIBE_MODE + stype = self.runtime_config.subscribe_mode # 异步读取缓存 if stype == 'spider': @@ -456,7 +456,7 @@ class TorrentsChain(ChainBase): site=site.get("id"), site_name=site.get("name"), site_cookie=site.get("cookie"), - site_ua=site.get("ua") or settings.USER_AGENT, + site_ua=site.get("ua") or self.runtime_config.user_agent, site_proxy=site.get("proxy"), site_order=site.get("pri"), site_downloader=site.get("downloader"), @@ -545,14 +545,14 @@ class TorrentsChain(ChainBase): """ 判断站点是否不需要缓存 """ - for url_key in settings.NO_CACHE_SITE_KEY.split(','): + for url_key in self.runtime_config.no_cache_site_key.split(','): if url_key in _domain: return True return False # 刷新类型 if not stype: - stype = settings.SUBSCRIBE_MODE + stype = self.runtime_config.subscribe_mode # 刷新站点 if not sites: @@ -636,10 +636,10 @@ class TorrentsChain(ChainBase): # 音乐与影视按同一公共参数独立计算刷新配额,并分别写入各自缓存,音乐不会被影视资源挤出 music_torrents = [ t for t in torrents if t.category == MediaType.MUSIC.value - ][:settings.CONF.refresh] + ][:self.runtime_config.refresh_batch_size] torrents = [ t for t in torrents if t.category != MediaType.MUSIC.value - ][:settings.CONF.refresh] + ][:self.runtime_config.refresh_batch_size] if torrents or music_torrents: if __is_no_cache_site(domain): # 不需要缓存的站点,直接处理 @@ -724,8 +724,10 @@ class TorrentsChain(ChainBase): else: target_cache[domain].append(context) # 如果超过了限制条数则移除掉前面的,音乐与影视各自独立计算配额 - if len(target_cache[domain]) > settings.CONF.torrents: - target_cache[domain] = target_cache[domain][-settings.CONF.torrents:] + if len(target_cache[domain]) > self.runtime_config.torrent_cache_size: + target_cache[domain] = target_cache[domain][ + -self.runtime_config.torrent_cache_size: + ] finally: torrents.clear() music_torrents.clear() @@ -814,7 +816,7 @@ class TorrentsChain(ChainBase): rss_url, errmsg = RssHelper().get_rss_link( url=site.get("url"), cookie=site.get("cookie"), - ua=site.get("ua") or settings.USER_AGENT, + ua=site.get("ua") or self.runtime_config.user_agent, proxy=True if site.get("proxy") else False, timeout=site.get("timeout"), ) @@ -831,13 +833,13 @@ class TorrentsChain(ChainBase): # 发送消息 self.post_message( Message(mtype=MessageType.SiteMessage, title=f"站点 {domain} RSS链接已过期", - link=settings.MP_DOMAIN('#/site')) + link=self.runtime_config.site_url) ) else: self.post_message( Message(mtype=MessageType.SiteMessage, title=f"站点 {domain} RSS链接已过期", - link=settings.MP_DOMAIN('#/site'))) + link=self.runtime_config.site_url)) except Exception as e: logger.error(f"站点 {domain} RSS链接自动获取失败:{str(e)} - {traceback.format_exc()}") self.post_message(Message(mtype=MessageType.SiteMessage, title=f"站点 {domain} RSS链接已过期", - link=settings.MP_DOMAIN('#/site'))) + link=self.runtime_config.site_url)) diff --git a/app/startup/modules_initializer.py b/app/startup/modules_initializer.py index b7ab312ae..bc80f6593 100644 --- a/app/startup/modules_initializer.py +++ b/app/startup/modules_initializer.py @@ -178,6 +178,10 @@ def _build_api_runtime_config() -> ApiRuntimeConfig: access_token_expire_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES, btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP, ai_agent_enable=settings.AI_AGENT_ENABLE, + api_token=settings.API_TOKEN, + temp_path=settings.TEMP_PATH, + media_recognize_share=settings.MEDIA_RECOGNIZE_SHARE, + subscribe_mode=settings.SUBSCRIBE_MODE, ) @@ -222,6 +226,16 @@ def _build_chain_runtime_config() -> ChainRuntimeConfig: global_image_cache=settings.GLOBAL_IMAGE_CACHE, auto_download_user=settings.AUTO_DOWNLOAD_USER, resource_url=settings.MP_DOMAIN("#/resource"), + user_agent=settings.USER_AGENT, + proxy=settings.PROXY, + proxy_server=settings.PROXY_SERVER, + proxy_host=settings.PROXY_HOST, + cookiecloud_blacklist=settings.COOKIECLOUD_BLACKLIST, + subscribe_mode=settings.SUBSCRIBE_MODE, + no_cache_site_key=settings.NO_CACHE_SITE_KEY, + refresh_batch_size=settings.CONF.refresh, + torrent_cache_size=settings.CONF.torrents, + site_url=settings.MP_DOMAIN("#/site"), ) diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 6b51930f1..82a4bba03 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -570,6 +570,10 @@ app/api/dependencies/ # 按领域拆分依赖工厂 - Chain snapshot 继续覆盖超级用户、共享识别、辅助认证、全局图片缓存、自动下载用户和资源页链接; 消息、识别、交互、推荐和用户链的 5 个直接 `settings` 导入被移除,当前低水位进一步降到 161 个 settings import 文件,插件 SDK 与兼容入口未改。 +- API snapshot 继续覆盖 API token、临时目录、识别共享与订阅模式;Chain snapshot 覆盖站点请求、 + 代理、CookieCloud 黑名单和种子缓存配额。Agent/OpenAI/Anthropic/TMDB/种子缓存 API 以及 Site、 + Torrents Chain 共 7 个直接 `settings` 导入被移除,canonical 低水位降到 154 个文件;独立协议 + 测试显式注入快照,不再依赖 endpoint 模块中的全局配置别名。 - 直接调用 endpoint 和显式构造 `ChainRuntimeContext` 的旧测试/兼容入口仍有 fallback;正式 FastAPI 与 Startup 路径始终使用 HostRuntime 注入。插件 SDK 的 `app.sdk.config.settings`、动态 API 返回和事件字段未改。 diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json index 22bbc0671..0409e0db6 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": 161, + "count": 154, "files": [ "app/adapters/cache/backends.py", "app/adapters/cache/redis.py", @@ -47,17 +47,12 @@ "app/agent/tools/impl/send_voice_message.py", "app/agent/tools/impl/update_agent_task.py", "app/agent/tools/impl/update_system_settings.py", - "app/api/endpoints/agent.py", - "app/api/endpoints/anthropic.py", "app/api/endpoints/media.py", "app/api/endpoints/message.py", - "app/api/endpoints/openai.py", "app/api/endpoints/plugin.py", "app/api/endpoints/storage.py", "app/api/endpoints/subscribe.py", "app/api/endpoints/system.py", - "app/api/endpoints/tmdb.py", - "app/api/endpoints/torrent.py", "app/api/endpoints/transfer.py", "app/api/servcookie.py", "app/application/formatting.py", @@ -75,10 +70,8 @@ "app/chain/message.py", "app/chain/scraping.py", "app/chain/search.py", - "app/chain/site.py", "app/chain/subscribe.py", "app/chain/system.py", - "app/chain/torrents.py", "app/chain/transfer.py", "app/cli.py", "app/db/base.py", diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index aeb144b97..baf10fe57 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": 6369, - "edge_sha256": "62638f6e334e7deb05902ca1e50cdcd03245a85c207dd2b4efaa03306cd28187", + "edge_count": 6368, + "edge_sha256": "03022352d218ecfb93295438f8264140fa7f6be2f7e55734e094989e454062ae", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -1632,6 +1632,7 @@ "app.api.endpoints.agent -> app.api.principal", "app.api.endpoints.agent -> app.api.response", "app.api.endpoints.agent -> app.application", + "app.api.endpoints.agent -> app.application.configuration", "app.api.endpoints.agent -> app.application.messaging", "app.api.endpoints.agent -> app.application.messaging.agent", "app.api.endpoints.agent -> app.application.messaging.chat", @@ -1677,8 +1678,8 @@ "app.api.endpoints.anthropic -> app.api.openai_utils", "app.api.endpoints.anthropic -> app.api.presentation", "app.api.endpoints.anthropic -> app.api.presentation.sse", - "app.api.endpoints.anthropic -> app.runtime", - "app.api.endpoints.anthropic -> app.runtime.config", + "app.api.endpoints.anthropic -> app.application", + "app.api.endpoints.anthropic -> app.application.configuration", "app.api.endpoints.anthropic -> app.schemas", "app.api.endpoints.anthropic -> app.schemas.openai", "app.api.endpoints.auth -> app.api", @@ -2023,8 +2024,8 @@ "app.api.endpoints.openai -> app.api.openai_utils", "app.api.endpoints.openai -> app.api.presentation", "app.api.endpoints.openai -> app.api.presentation.sse", - "app.api.endpoints.openai -> app.runtime", - "app.api.endpoints.openai -> app.runtime.config", + "app.api.endpoints.openai -> app.application", + "app.api.endpoints.openai -> app.application.configuration", "app.api.endpoints.openai -> app.schemas", "app.api.endpoints.openai -> app.schemas.openai", "app.api.endpoints.openai -> app.schemas.types", @@ -2268,8 +2269,6 @@ "app.api.endpoints.tmdb -> app.application.configuration", "app.api.endpoints.tmdb -> app.chain", "app.api.endpoints.tmdb -> app.chain.tmdb", - "app.api.endpoints.tmdb -> app.runtime", - "app.api.endpoints.tmdb -> app.runtime.config", "app.api.endpoints.tmdb -> app.schemas", "app.api.endpoints.tmdb -> app.schemas.context", "app.api.endpoints.tmdb -> app.schemas.response", @@ -2281,6 +2280,8 @@ "app.api.endpoints.torrent -> app.api.dependencies", "app.api.endpoints.torrent -> app.api.dependencies.auth", "app.api.endpoints.torrent -> app.api.response", + "app.api.endpoints.torrent -> app.application", + "app.api.endpoints.torrent -> app.application.configuration", "app.api.endpoints.torrent -> app.chain", "app.api.endpoints.torrent -> app.chain.media", "app.api.endpoints.torrent -> app.chain.torrents", @@ -2292,8 +2293,6 @@ "app.api.endpoints.torrent -> app.domain.metainfo", "app.api.endpoints.torrent -> app.foundation", "app.api.endpoints.torrent -> app.foundation.crypto", - "app.api.endpoints.torrent -> app.runtime", - "app.api.endpoints.torrent -> app.runtime.config", "app.api.endpoints.torrent -> app.schemas", "app.api.endpoints.torrent -> app.schemas.cache", "app.api.endpoints.torrent -> app.schemas.media", diff --git a/tests/test_agent_api_lazy_imports.py b/tests/test_agent_api_lazy_imports.py index c3f5eb23e..d1061fffb 100644 --- a/tests/test_agent_api_lazy_imports.py +++ b/tests/test_agent_api_lazy_imports.py @@ -132,10 +132,19 @@ sys.modules["app.application.site.sites"] = sites from fastapi.security import HTTPAuthorizationCredentials from app import schemas +from app.api.endpoints import anthropic, openai from app.api.endpoints.anthropic import messages as anthropic_messages from app.api.endpoints.openai import chat_completions, responses +from app.application.configuration import ApiRuntimeConfig from app.runtime.config import settings +runtime_config = ApiRuntimeConfig( + False, 60, False, settings.AI_AGENT_ENABLE, + api_token=settings.API_TOKEN, +) +anthropic.get_api_runtime_config_snapshot = lambda: runtime_config +openai.get_api_runtime_config_snapshot = lambda: runtime_config + credentials = HTTPAuthorizationCredentials( scheme="Bearer", credentials=settings.API_TOKEN, @@ -367,9 +376,16 @@ sys.modules["app.application.site.sites"] = sites from fastapi.security import HTTPAuthorizationCredentials from app import schemas from app.api.endpoints import anthropic, openai +from app.application.configuration import ApiRuntimeConfig from app.runtime.config import settings settings.AI_AGENT_ENABLE = True +runtime_config = ApiRuntimeConfig( + False, 60, False, True, + api_token=settings.API_TOKEN, +) +anthropic.get_api_runtime_config_snapshot = lambda: runtime_config +openai.get_api_runtime_config_snapshot = lambda: runtime_config credentials = HTTPAuthorizationCredentials( scheme="Bearer", credentials=settings.API_TOKEN, diff --git a/tests/test_music_torrents.py b/tests/test_music_torrents.py index 879a53867..1a4d75051 100644 --- a/tests/test_music_torrents.py +++ b/tests/test_music_torrents.py @@ -387,7 +387,15 @@ def test_music_cache_not_evicted_by_video_torrents(): fake_settings.NO_CACHE_SITE_KEY = "no-cache-site.invalid" with ( - patch("app.chain.torrents.settings", fake_settings), + patch.object( + chain, + "runtime_config", + SimpleNamespace( + torrent_cache_size=fake_settings.CONF.torrents, + refresh_batch_size=fake_settings.CONF.refresh, + no_cache_site_key=fake_settings.NO_CACHE_SITE_KEY, + ), + ), patch.object(chain, "load_cache", side_effect=_fake_load), patch.object(chain, "browse", side_effect=_fake_browse), patch.object(chain, "save_cache", save_cache), diff --git a/tests/test_sunnypt_indexer.py b/tests/test_sunnypt_indexer.py index 0f22cdeef..ae1b04bec 100644 --- a/tests/test_sunnypt_indexer.py +++ b/tests/test_sunnypt_indexer.py @@ -426,7 +426,9 @@ def test_sunnypt_site_test_uses_profile_api(monkeypatch): timeout=15, ) - state, message = SiteChain._SiteChain__sunnypt_test(site) + chain = object.__new__(SiteChain) + chain.runtime_config = SimpleNamespace(proxy=None, user_agent="MoviePilot-Test") + state, message = chain._SiteChain__sunnypt_test(site) assert state assert message == "连接成功" diff --git a/tests/test_tmdb_cache_management.py b/tests/test_tmdb_cache_management.py index 16abe0741..80f2c012f 100644 --- a/tests/test_tmdb_cache_management.py +++ b/tests/test_tmdb_cache_management.py @@ -1,6 +1,7 @@ import asyncio import inspect import pickle +from types import SimpleNamespace from unittest.mock import Mock from app.api.endpoints import tmdb as tmdb_endpoint @@ -282,7 +283,11 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch): "get_configured_system_config", lambda: type("SystemConfigStub", (), {"get": get_system_config})(), ) - monkeypatch.setattr(tmdb_endpoint.settings, "MEDIA_RECOGNIZE_SHARE", True) + monkeypatch.setattr( + tmdb_endpoint, + "get_api_runtime_config_snapshot", + lambda: SimpleNamespace(media_recognize_share=True), + ) response = asyncio.run(tmdb_endpoint.tmdb_recognition_cache(None)) diff --git a/tests/test_web_agent_stream.py b/tests/test_web_agent_stream.py index d1dc51dad..ea0faeed0 100644 --- a/tests/test_web_agent_stream.py +++ b/tests/test_web_agent_stream.py @@ -633,7 +633,10 @@ def test_prepare_web_agent_audio_attachment_converts_unsupported_audio(tmp_path) return SimpleNamespace(returncode=0, stderr="") run.side_effect = write_converted_file - with patch("app.api.endpoints.agent.settings", SimpleNamespace(TEMP_PATH=tmp_path)): + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(temp_path=tmp_path), + ): output_path = _prepare_web_agent_audio_attachment_path(str(source_path)) assert output_path == converted_path @@ -684,7 +687,10 @@ def test_web_agent_stream_returns_error_when_voice_transcription_fails(): request = SimpleNamespace() user = SimpleNamespace(id=1, name="admin") - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch( "app.api.endpoints.agent._transcribe_web_agent_audio_files", return_value=None, ) as transcribe_audio: @@ -722,7 +728,10 @@ def test_web_agent_stream_does_not_block_event_loop_during_transcription(): return await stream_task try: - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch( "app.api.endpoints.agent._resolve_web_agent_audio_refs", return_value=[Mock()], ), patch( @@ -788,7 +797,10 @@ def test_web_agent_stream_binds_session_to_agent_manager(): return "".join(await _collect_streaming_response(response)) try: - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch( "app.api.endpoints.agent._get_web_agent_type", return_value=FakeWebAgent, ): @@ -884,7 +896,10 @@ def test_web_agent_stream_emits_secret_result_only_as_protected_event(): return "".join(await _collect_streaming_response(response)) try: - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch( "app.api.endpoints.agent._get_web_agent_type", return_value=FakeProtectedAgent, ), patch( @@ -942,7 +957,10 @@ def test_web_agent_cancel_keeps_existing_display_history(): return "".join(await _collect_streaming_response(response)) try: - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch.object( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch.object( agent_manager, "matches_secret_confirmation", return_value=True, @@ -982,7 +1000,10 @@ def test_web_agent_stream_rejects_confirmation_without_protected_capability(): response = await web_agent_stream(payload, request, user) return "".join(await _collect_streaming_response(response)) - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch.object( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch.object( agent_manager, "matches_secret_confirmation", return_value=True, @@ -1008,7 +1029,10 @@ def test_web_agent_stream_keeps_confirmation_without_pending_on_normal_path(): body = "".join(await _collect_streaming_response(response)) return response, body - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch.object( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch.object( agent_manager, "process_message", new=AsyncMock(return_value="普通回复"), @@ -1076,7 +1100,10 @@ def test_web_agent_stream_drops_secret_result_after_disconnect(): return body try: - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch.object( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch.object( agent_manager, "matches_secret_confirmation", return_value=True, @@ -1116,7 +1143,10 @@ def test_web_agent_stream_emits_heartbeat_during_idle_tool_wait(): response = await web_agent_stream(payload, request, user) return "".join(await _collect_streaming_response(response)) - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch( "app.api.endpoints.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS", 0.01, ), patch( @@ -1188,7 +1218,10 @@ def test_web_agent_stop_finishes_stream_without_error(): return "".join(received) try: - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch( "app.api.endpoints.agent._is_web_agent_traditional_message", return_value=False, ), patch( @@ -1230,7 +1263,10 @@ def test_web_agent_stream_rechecks_running_service_before_enqueue(): response = await web_agent_stream(payload, request, user) return "".join(await _collect_streaming_response(response)) - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch( "app.api.endpoints.agent._is_web_agent_traditional_message", return_value=False, ), patch( @@ -1366,7 +1402,10 @@ def test_web_agent_stream_sends_done_before_snapshot_persistence_finishes(): return "".join(received) try: - with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + with patch( + "app.api.endpoints.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), patch( "app.api.endpoints.agent._is_web_agent_traditional_message", return_value=False, ), patch(