diff --git a/app/adapters/cache/backends.py b/app/adapters/cache/backends.py index bcd7049a2..92781b402 100644 --- a/app/adapters/cache/backends.py +++ b/app/adapters/cache/backends.py @@ -9,19 +9,14 @@ from anyio import Path as AsyncPath from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper from app.runtime.cache import ( + DEFAULT_CACHE_REGION, AsyncCacheBackend, CacheBackend, - DEFAULT_CACHE_REGION, configure_cache_factories, ) -from app.runtime import config as _runtime_config from app.runtime.settings import get_runtime_setting -# 兼容旧插件对模块级 Settings 的覆盖,工厂实际读取统一经过 runtime 端口。 -settings = _runtime_config.settings - - class RedisBackend(CacheBackend): """通过同步 Redis 客户端实现缓存后端。""" @@ -335,14 +330,14 @@ class AsyncFileBackend(AsyncCacheBackend): def configure_platform_cache() -> None: """把配置感知的 Redis 与文件适配器注册到平台缓存工厂。""" configure_cache_factories( - backend_type_provider=lambda: get_runtime_setting("CACHE_BACKEND_TYPE"), + backend_type_provider=lambda: get_runtime_setting('CACHE_BACKEND_TYPE'), redis_factory=lambda ttl: RedisBackend(ttl=ttl), async_redis_factory=lambda ttl: AsyncRedisBackend(ttl=ttl), file_factory=lambda base: FileBackend( - base=base or get_runtime_setting("TEMP_PATH") + base=base or get_runtime_setting('TEMP_PATH') ), async_file_factory=lambda base: AsyncFileBackend( - base=base or get_runtime_setting("TEMP_PATH") + base=base or get_runtime_setting('TEMP_PATH') ), - file_ttl_provider=lambda: get_runtime_setting("TEMP_FILE_DAYS") * 24 * 3600, + file_ttl_provider=lambda: get_runtime_setting('TEMP_FILE_DAYS') * 24 * 3600, ) diff --git a/app/adapters/cache/redis.py b/app/adapters/cache/redis.py index 26b1af63c..0ec257ddf 100644 --- a/app/adapters/cache/redis.py +++ b/app/adapters/cache/redis.py @@ -2,22 +2,17 @@ import asyncio import json import pickle import threading -from typing import Any, Optional, Generator, Tuple, AsyncGenerator, Union +from typing import Any, AsyncGenerator, Generator, Optional, Tuple, Union from urllib.parse import quote, unquote import redis from redis.asyncio import BlockingConnectionPool as AsyncBlockingConnectionPool from redis.asyncio import Redis -from app.runtime import config as _runtime_config +from app.foundation.singleton import Singleton from app.runtime.log import logger from app.runtime.reload import ConfigReloadMixin from app.runtime.settings import get_runtime_setting -from app.foundation.singleton import Singleton - - -# 兼容旧插件和测试对模块级 Settings 的覆盖,Redis 连接逻辑统一读取 runtime 端口。 -settings = _runtime_config.settings # 类型缓存集合,针对非容器简单类型 _complex_serializable_types = set() @@ -102,7 +97,7 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): """ 初始化Redis助手实例 """ - self.redis_url = get_runtime_setting("CACHE_BACKEND_URL") + self.redis_url = get_runtime_setting('CACHE_BACKEND_URL') self.client = None self._connect_lock = threading.RLock() @@ -117,15 +112,15 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): with self._connect_lock: if self.client is not None: return - self.redis_url = get_runtime_setting("CACHE_BACKEND_URL") + self.redis_url = get_runtime_setting('CACHE_BACKEND_URL') connection_pool = redis.BlockingConnectionPool.from_url( self.redis_url, decode_responses=False, socket_timeout=_socket_timeout, socket_connect_timeout=_socket_connect_timeout, health_check_interval=_health_check_interval, - max_connections=get_runtime_setting("CACHE_REDIS_MAX_CONNECTIONS"), - timeout=get_runtime_setting("CACHE_REDIS_POOL_TIMEOUT"), + max_connections=get_runtime_setting('CACHE_REDIS_MAX_CONNECTIONS'), + timeout=get_runtime_setting('CACHE_REDIS_POOL_TIMEOUT'), ) client = redis.Redis(connection_pool=connection_pool) # 测试连接,确保Redis可用 @@ -143,7 +138,7 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): def on_config_changed(self): """缓存配置变化后重建同步 Redis 连接。""" with self._connect_lock: - self.redis_url = get_runtime_setting("CACHE_BACKEND_URL") + self.redis_url = get_runtime_setting('CACHE_BACKEND_URL') self.close() self._connect() @@ -159,8 +154,8 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): """ try: # 如果有显式值,则直接使用,为0时说明不限制,如果未配置,开启BIG_MEMORY_MODE时为"1024mb",未开启时为"256mb" - maxmemory = get_runtime_setting("CACHE_REDIS_MAXMEMORY") or ( - "1024mb" if get_runtime_setting("BIG_MEMORY_MODE") else "256mb" + maxmemory = get_runtime_setting('CACHE_REDIS_MAXMEMORY') or ( + "1024mb" if get_runtime_setting('BIG_MEMORY_MODE') else "256mb" ) self.client.config_set("maxmemory", maxmemory) self.client.config_set("maxmemory-policy", policy) @@ -371,7 +366,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): """ 初始化异步Redis助手实例 """ - self.redis_url = get_runtime_setting("CACHE_BACKEND_URL") + self.redis_url = get_runtime_setting('CACHE_BACKEND_URL') self.client: Optional[Redis] = None self._loop: Optional[asyncio.AbstractEventLoop] = None self._connect_lock: Optional[asyncio.Lock] = None @@ -401,15 +396,15 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): await self._close_client() if self.client is not None: return - self.redis_url = get_runtime_setting("CACHE_BACKEND_URL") + self.redis_url = get_runtime_setting('CACHE_BACKEND_URL') connection_pool = AsyncBlockingConnectionPool.from_url( self.redis_url, decode_responses=False, socket_timeout=_socket_timeout, socket_connect_timeout=_socket_connect_timeout, health_check_interval=_health_check_interval, - max_connections=get_runtime_setting("CACHE_REDIS_MAX_CONNECTIONS"), - timeout=get_runtime_setting("CACHE_REDIS_POOL_TIMEOUT"), + max_connections=get_runtime_setting('CACHE_REDIS_MAX_CONNECTIONS'), + timeout=get_runtime_setting('CACHE_REDIS_POOL_TIMEOUT'), ) client = Redis(connection_pool=connection_pool) self._loop = current_loop @@ -440,7 +435,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): async def on_config_changed(self): """缓存配置变化后异步重建 Redis 连接。""" - self.redis_url = get_runtime_setting("CACHE_BACKEND_URL") + self.redis_url = get_runtime_setting('CACHE_BACKEND_URL') await self._close_client() await self._connect() @@ -456,8 +451,8 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): """ try: # 如果有显式值,则直接使用,为0时说明不限制,如果未配置,开启BIG_MEMORY_MODE时为"1024mb",未开启时为"256mb" - maxmemory = get_runtime_setting("CACHE_REDIS_MAXMEMORY") or ( - "1024mb" if get_runtime_setting("BIG_MEMORY_MODE") else "256mb" + maxmemory = get_runtime_setting('CACHE_REDIS_MAXMEMORY') or ( + "1024mb" if get_runtime_setting('BIG_MEMORY_MODE') else "256mb" ) await self.client.config_set("maxmemory", maxmemory) await self.client.config_set("maxmemory-policy", policy) diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index a4e69f988..2c8e6f90c 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -37,7 +37,7 @@ from app.runtime.dependencies import ( iter_runtime_requirement_strings, runtime_excluded_dependency_pairs, ) -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.adapters.system.package import ( PackageInstallRequest, build_package_install_strategies, @@ -64,9 +64,8 @@ from app.adapters.system.host import SystemUtils from app.foundation.url import UrlUtils from app.runtime.version import get_app_version -# 保留模块级可替换入口,代理默认读取组合根的最新 runtime 配置。 -settings = RuntimeSettingsCompat() -PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins" +# 插件市场只通过 runtime 读取端口消费组合根的最新配置。 +PLUGIN_DIR = Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" LOCAL_REPO_PREFIX = "local://" PLUGIN_SYSTEM_VERSION_FIELD = "system_version" PLUGIN_MARKET_WIKI_START = "" @@ -317,7 +316,7 @@ class PluginHelper(metaclass=WeakSingleton): return None path = Path(values[0]).expanduser() if not path.is_absolute(): - path = settings.ROOT_PATH / path + path = get_runtime_setting('ROOT_PATH') / path return path.resolve() except Exception: return None @@ -355,9 +354,9 @@ class PluginHelper(metaclass=WeakSingleton): 未启用 VERSION_FLAG(v1)时返回空列表,表示仅使用 package.json 基础索引。 """ flags: List[str] = [] - if settings.VERSION_FLAG: - flags.append(settings.VERSION_FLAG) - flags.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])) + if get_runtime_setting('VERSION_FLAG'): + flags.append(get_runtime_setting('VERSION_FLAG')) + flags.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), [])) return flags @classmethod @@ -370,9 +369,9 @@ class PluginHelper(metaclass=WeakSingleton): """ if not isinstance(plugin_info, dict): return False - if not settings.VERSION_FLAG: + if not get_runtime_setting('VERSION_FLAG'): return True - current_flag = settings.VERSION_FLAG + current_flag = get_runtime_setting('VERSION_FLAG') if plugin_info.get(current_flag) is False: return False if plugin_info.get(current_flag) is True: @@ -398,7 +397,7 @@ class PluginHelper(metaclass=WeakSingleton): """ if not isinstance(plugin_info, dict): return False - current_flag = settings.VERSION_FLAG + current_flag = get_runtime_setting('VERSION_FLAG') if not current_flag: return not package_version if package_version == current_flag: @@ -416,7 +415,7 @@ class PluginHelper(metaclass=WeakSingleton): package_version: Optional[str], ) -> Tuple[str, ...]: """返回插件安装唯一的代际候选顺序,并去除重复的基础索引。""" - preferred_version = package_version or settings.VERSION_FLAG + preferred_version = package_version or get_runtime_setting('VERSION_FLAG') candidates = [preferred_version] candidates.extend( VERSION_BACKWARD_COMPATIBLE_FLAGS.get(preferred_version, []) @@ -491,16 +490,16 @@ class PluginHelper(metaclass=WeakSingleton): """ 获取本地插件仓库目录列表 """ - if not settings.PLUGIN_LOCAL_REPO_PATHS: + if not get_runtime_setting('PLUGIN_LOCAL_REPO_PATHS'): return [] paths = [] - for item in settings.PLUGIN_LOCAL_REPO_PATHS.split(","): + for item in get_runtime_setting('PLUGIN_LOCAL_REPO_PATHS').split(","): local_repo_path = item.strip() if not local_repo_path: continue path = Path(local_repo_path).expanduser() if not path.is_absolute(): - path = settings.ROOT_PATH / path + path = get_runtime_setting('ROOT_PATH') / path paths.append(path.resolve()) return paths @@ -542,11 +541,11 @@ class PluginHelper(metaclass=WeakSingleton): continue package_candidates = [] - if settings.VERSION_FLAG: - package_candidates.append((settings.VERSION_FLAG, self.__get_local_package(repo_path, - settings.VERSION_FLAG))) + if get_runtime_setting('VERSION_FLAG'): + package_candidates.append((get_runtime_setting('VERSION_FLAG'), self.__get_local_package(repo_path, + get_runtime_setting('VERSION_FLAG')))) # 向后兼容:补充扫描更低版本的 package 文件,便于本地仓库复用历史版本插件。 - for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []): + for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), []): package_candidates.append((backward_flag, self.__get_local_package(repo_path, backward_flag))) package_candidates.append(("", self.__get_local_package(repo_path))) @@ -611,9 +610,9 @@ class PluginHelper(metaclass=WeakSingleton): repo_paths = [repo_path.resolve()] if repo_path else self.get_local_repo_paths() package_versions = [package_version] if package_version is not None else [] if package_version is None: - if settings.VERSION_FLAG: - package_versions.append(settings.VERSION_FLAG) - package_versions.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])) + if get_runtime_setting('VERSION_FLAG'): + package_versions.append(get_runtime_setting('VERSION_FLAG')) + package_versions.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), [])) package_versions.append("") selected_candidate = None for repo_order, local_repo_path in enumerate(self.get_local_repo_paths()): @@ -650,7 +649,7 @@ class PluginHelper(metaclass=WeakSingleton): if not is_compatible: candidate["compatible"] = False candidate["skip_reason"] = ( - f"插件索引条目不兼容 {settings.VERSION_FLAG}" + f"插件索引条目不兼容 {get_runtime_setting('VERSION_FLAG')}" ) self.annotate_plugin_system_version(candidate) if strict_system_version and candidate.get("system_version_compatible") is False: @@ -729,7 +728,7 @@ class PluginHelper(metaclass=WeakSingleton): else "package.json" ) package_url = cls.__append_cache_buster(f"{raw_url}{package_file}") - headers = settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}") + headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=f"{user}/{repo}") return package_url, headers @classmethod @@ -829,7 +828,7 @@ class PluginHelper(metaclass=WeakSingleton): return user_repo = f"{user}/{repo}" - headers = settings.REPO_GITHUB_HEADERS(repo=user_repo) + headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo) for page in range(1, 11): release_api = ( f"https://api.github.com/repos/{user_repo}/releases" @@ -998,7 +997,7 @@ class PluginHelper(metaclass=WeakSingleton): package_version: Optional[str] = None) -> Optional[str]: """ 检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测 - 1. 如果未指定版本,则使用系统配置的默认版本(通过 settings.VERSION_FLAG 设置) + 1. 如果未指定版本,则使用系统配置的默认版本(通过 get_runtime_setting('VERSION_FLAG') 设置) 2. 优先检查指定版本的插件(如 `package.v2.json`) 3. 检查更低版本的 package 文件,并应用版本兼容标志 4. 检查 `package.json` 文件,并应用共享实现兼容标志 @@ -1084,7 +1083,7 @@ class PluginHelper(metaclass=WeakSingleton): user_repo = f"{user}/{repo}" if not package_version: - package_version = settings.VERSION_FLAG + package_version = get_runtime_setting('VERSION_FLAG') # 1. 优先检查指定版本的插件 package_version = self.get_plugin_package_version(pid, repo_url, package_version) @@ -1232,7 +1231,7 @@ class PluginHelper(metaclass=WeakSingleton): file_api += f"/{pid.lower()}" res = self.__request_with_fallback(file_api, - headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), + headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo), is_api=True, timeout=30) if res is None: @@ -1273,7 +1272,7 @@ class PluginHelper(metaclass=WeakSingleton): if item.get("download_url"): logger.debug(f"正在下载文件:{item.get('path')}") res = self.__request_with_fallback(item.get('download_url'), - headers=settings.REPO_GITHUB_HEADERS(repo=user_repo)) + headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo)) if not res: return False, f"文件 {item.get('path')} 下载失败!" elif res.status_code != 200: @@ -1285,7 +1284,7 @@ class PluginHelper(metaclass=WeakSingleton): relative_path = relative_path.replace(f"plugins.{package_version}", "plugins", 1) # 创建插件文件夹并写入文件 - file_path = Path(settings.ROOT_PATH) / "app" / relative_path + file_path = Path(get_runtime_setting('ROOT_PATH')) / "app" / relative_path file_path.parent.mkdir(parents=True, exist_ok=True) with open(file_path, "w", encoding="utf-8") as f: f.write(res.text) @@ -1327,7 +1326,7 @@ class PluginHelper(metaclass=WeakSingleton): :return: 备份目录路径 """ plugin_dir = PLUGIN_DIR / pid.lower() - backup_dir = Path(settings.TEMP_PATH) / "plugin_backup" / pid.lower() + backup_dir = Path(get_runtime_setting('TEMP_PATH')) / "plugin_backup" / pid.lower() if plugin_dir.exists(): # 备份时清理已有的备份目录,防止残留文件影响 @@ -1381,7 +1380,7 @@ class PluginHelper(metaclass=WeakSingleton): logger.warn(f"{pid} 插件目录不存在,跳过刷新插件备份") return False - backup_root = settings.CONFIG_PATH / "plugins_backup" + backup_root = get_runtime_setting('CONFIG_PATH') / "plugins_backup" backup_dir = backup_root / pid.lower() staging_dir = backup_root / f".{pid.lower()}.tmp-{uuid.uuid4().hex}" previous_dir = backup_root / f".{pid.lower()}.old-{uuid.uuid4().hex}" @@ -1546,7 +1545,7 @@ class PluginHelper(metaclass=WeakSingleton): if package_name in cls._protected_runtime_packages } - project_file = settings.ROOT_PATH / "pyproject.toml" + project_file = get_runtime_setting('ROOT_PATH') / "pyproject.toml" root_requirements = cls.__parse_project_requirement_roots(project_file) if not root_requirements: return protected_packages @@ -1595,7 +1594,7 @@ class PluginHelper(metaclass=WeakSingleton): def __get_strict_runtime_packages(cls) -> Set[str]: """返回核心包及当前 ABI profile 中不得被插件改写的根包。""" packages = set(cls._protected_runtime_packages) - project_file = settings.ROOT_PATH / "pyproject.toml" + project_file = get_runtime_setting('ROOT_PATH') / "pyproject.toml" try: for raw_requirement in iter_runtime_profile_requirement_strings(project_file): requirement = Requirement(raw_requirement) @@ -1717,7 +1716,7 @@ class PluginHelper(metaclass=WeakSingleton): """ 以主程序依赖的当前已安装版本生成临时约束文件,确保插件安装不会改写主程序依赖。 """ - temp_dir = Path(settings.TEMP_PATH) / "plugin_dependencies" + temp_dir = Path(get_runtime_setting('TEMP_PATH')) / "plugin_dependencies" temp_dir.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( mode="w", @@ -1794,10 +1793,10 @@ class PluginHelper(metaclass=WeakSingleton): python_bin=Path(sys.executable), find_links_dirs=find_links_dirs or [], constraints_file=constraints_file, - config_dir=settings.CONFIG_PATH, - package_cache_root=settings.PACKAGE_CACHE_PATH, - package_index_url=settings.PIP_PROXY or None, - proxy_url=settings.PROXY_HOST or None, + config_dir=get_runtime_setting('CONFIG_PATH'), + package_cache_root=get_runtime_setting('PACKAGE_CACHE_PATH'), + package_index_url=get_runtime_setting('PIP_PROXY') or None, + proxy_url=get_runtime_setting('PROXY_HOST') or None, purpose=purpose, ) @@ -1868,7 +1867,7 @@ class PluginHelper(metaclass=WeakSingleton): return lines excluded_pairs = runtime_excluded_dependency_pairs( - Path(settings.ROOT_PATH) / "pyproject.toml" + Path(get_runtime_setting('ROOT_PATH')) / "pyproject.toml" ) package_errors = set() for match in matches: @@ -1930,12 +1929,12 @@ class PluginHelper(metaclass=WeakSingleton): if repair_target and not repair_target.exists(): repair_target = None if repair_target is None: - repair_target = settings.ROOT_PATH / "pyproject.toml" + repair_target = get_runtime_setting('ROOT_PATH') / "pyproject.toml" repair_desc = "主程序 uv.lock" if not repair_target.exists(): return False, f"恢复依赖文件不存在:{repair_target}" - if snapshot_file is None and not (settings.ROOT_PATH / "uv.lock").exists(): - return False, f"恢复依赖文件不存在:{settings.ROOT_PATH / 'uv.lock'}" + if snapshot_file is None and not (get_runtime_setting('ROOT_PATH') / "uv.lock").exists(): + return False, f"恢复依赖文件不存在:{get_runtime_setting('ROOT_PATH') / 'uv.lock'}" last_error = "" request = cls.__build_package_install_request(repair_target, purpose="runtime-repair") @@ -2127,21 +2126,21 @@ class PluginHelper(metaclass=WeakSingleton): ) -> List[Tuple[str, str, dict]]: """构造同步与异步 GitHub 请求共用的镜像、代理和直连顺序。""" strategies: List[Tuple[str, str, dict]] = [] - if not is_api and settings.GITHUB_PROXY: + if not is_api and get_runtime_setting('GITHUB_PROXY'): proxy_url = ( - f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}" + f"{UrlUtils.standardize_base_url(get_runtime_setting('GITHUB_PROXY'))}{url}" ) strategies.append( ("镜像站", proxy_url, {"headers": headers, "timeout": timeout}) ) - if settings.PROXY_HOST: + if get_runtime_setting('PROXY_HOST'): strategies.append( ( "代理", url, { "headers": headers, - "proxies": settings.PROXY, + "proxies": get_runtime_setting('PROXY'), "timeout": timeout, }, ) @@ -2218,7 +2217,7 @@ class PluginHelper(metaclass=WeakSingleton): compatible, message = self.check_plugin_system_version(candidate) return None if compatible else message - package_version = self.get_plugin_package_version(pid, repo_url, settings.VERSION_FLAG) + package_version = self.get_plugin_package_version(pid, repo_url, get_runtime_setting('VERSION_FLAG')) if package_version is None: return None meta = self.__get_plugin_meta(pid, repo_url, package_version) @@ -2235,7 +2234,7 @@ class PluginHelper(metaclass=WeakSingleton): if self.is_local_repo_url(repo_url): return await asyncio.to_thread(self.get_plugin_system_version_check_message, pid, repo_url) - package_version = await self.async_get_plugin_package_version(pid, repo_url, settings.VERSION_FLAG) + package_version = await self.async_get_plugin_package_version(pid, repo_url, get_runtime_setting('VERSION_FLAG')) if package_version is None: return None meta = await self.__async_get_plugin_meta(pid, repo_url, package_version) @@ -2382,7 +2381,7 @@ class PluginHelper(metaclass=WeakSingleton): release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}" rel_res = self.__request_with_fallback( release_api, - headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), + headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo), timeout=30, is_api=True, ) @@ -2405,7 +2404,7 @@ class PluginHelper(metaclass=WeakSingleton): return False, f"解析 Release 信息失败:{e}" # 使用资产的API端点下载,需要设置Accept头为application/octet-stream - headers = settings.REPO_GITHUB_HEADERS(repo=user_repo).copy() + headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo).copy() headers["Accept"] = "application/octet-stream" res = self.__request_with_fallback(download_url, headers=headers, is_api=True) if res is None or res.status_code != 200: @@ -2416,7 +2415,7 @@ class PluginHelper(metaclass=WeakSingleton): infos = zf.infolist() if not infos: return False, "压缩包内容为空" - dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower() + dest_base = Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" / pid.lower() targets = self.__iter_release_zip_targets(zf, dest_base) wrote_any = False for info, dest_path, is_dir in targets: @@ -2721,7 +2720,7 @@ class PluginHelper(metaclass=WeakSingleton): file_api += f"/{pid.lower()}" res = await self.__async_request_with_fallback(file_api, - headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), + headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo), is_api=True, timeout=30) if res is None: @@ -2762,7 +2761,7 @@ class PluginHelper(metaclass=WeakSingleton): if item.get("download_url"): logger.debug(f"正在下载文件:{item.get('path')}") res = await self.__async_request_with_fallback(item.get('download_url'), - headers=settings.REPO_GITHUB_HEADERS(repo=user_repo)) + headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo)) if not res: return False, f"文件 {item.get('path')} 下载失败!" elif res.status_code != 200: @@ -2774,7 +2773,7 @@ class PluginHelper(metaclass=WeakSingleton): relative_path = relative_path.replace(f"plugins.{package_version}", "plugins", 1) # 创建插件文件夹并写入文件 - file_path = AsyncPath(settings.ROOT_PATH) / "app" / relative_path + file_path = AsyncPath(get_runtime_setting('ROOT_PATH')) / "app" / relative_path await file_path.parent.mkdir(parents=True, exist_ok=True) async with aiofiles.open(file_path, "w", encoding="utf-8") as f: await f.write(res.text) @@ -2825,13 +2824,13 @@ class PluginHelper(metaclass=WeakSingleton): if repair_target and not await _await_thread_operation(repair_target.exists): repair_target = None if repair_target is None: - repair_target = settings.ROOT_PATH / "pyproject.toml" + repair_target = get_runtime_setting('ROOT_PATH') / "pyproject.toml" repair_desc = "主程序 uv.lock" if not await _await_thread_operation(repair_target.exists): return False, f"恢复依赖文件不存在:{repair_target}" - lock_file = settings.ROOT_PATH / "uv.lock" + lock_file = get_runtime_setting('ROOT_PATH') / "uv.lock" if snapshot_file is None and not await _await_thread_operation(lock_file.exists): - return False, f"恢复依赖文件不存在:{settings.ROOT_PATH / 'uv.lock'}" + return False, f"恢复依赖文件不存在:{get_runtime_setting('ROOT_PATH') / 'uv.lock'}" request = cls.__build_package_install_request( repair_target, @@ -3076,7 +3075,7 @@ class PluginHelper(metaclass=WeakSingleton): :return: 备份目录路径 """ plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower() - backup_dir = AsyncPath(settings.TEMP_PATH) / "plugin_backup" / pid.lower() + backup_dir = AsyncPath(get_runtime_setting('TEMP_PATH')) / "plugin_backup" / pid.lower() if await plugin_dir.exists(): try: @@ -3234,7 +3233,7 @@ class PluginHelper(metaclass=WeakSingleton): user_repo = f"{user}/{repo}" if not package_version: - package_version = settings.VERSION_FLAG + package_version = get_runtime_setting('VERSION_FLAG') # 1. 优先检查指定版本的插件 package_version = await self.async_get_plugin_package_version(pid, repo_url, package_version) @@ -3396,7 +3395,7 @@ class PluginHelper(metaclass=WeakSingleton): release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}" rel_res = await self.__async_request_with_fallback( release_api, - headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), + headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo), timeout=30, is_api=True, ) @@ -3419,7 +3418,7 @@ class PluginHelper(metaclass=WeakSingleton): return False, f"解析 Release 信息失败:{e}" # 使用资产的API端点下载,需要设置Accept头为application/octet-stream - headers = settings.REPO_GITHUB_HEADERS(repo=user_repo).copy() + headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo).copy() headers["Accept"] = "application/octet-stream" res = await self.__async_request_with_fallback(download_url, headers=headers, @@ -3432,7 +3431,7 @@ class PluginHelper(metaclass=WeakSingleton): infos = zf.infolist() if not infos: return False, "压缩包内容为空" - dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower() + dest_base = Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" / pid.lower() targets = self.__iter_release_zip_targets(zf, dest_base) wrote_any = False for info, dest_path, is_dir in targets: diff --git a/app/adapters/external/ocr.py b/app/adapters/external/ocr.py index 7c639ee1e..366c145fe 100644 --- a/app/adapters/external/ocr.py +++ b/app/adapters/external/ocr.py @@ -13,7 +13,7 @@ class OcrHelper: def __init__(self, ocr_base_url: Optional[str] = None) -> None: """初始化 OCR 服务地址,优先使用组合根设置快照。""" if ocr_base_url is None: - ocr_base_url = get_runtime_setting("OCR_HOST") + ocr_base_url = get_runtime_setting('OCR_HOST') self._ocr_b64_url = f"{str(ocr_base_url).rstrip('/')}/captcha/base64" def get_captcha_text( diff --git a/app/adapters/external/server.py b/app/adapters/external/server.py index 49d170f82..3bf2e156f 100644 --- a/app/adapters/external/server.py +++ b/app/adapters/external/server.py @@ -7,7 +7,7 @@ from urllib.parse import parse_qs, quote, urlparse, urlsplit from app.runtime.cache import cached from app.runtime.config import global_vars -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.runtime.tasks import get_task_registry from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase @@ -26,8 +26,7 @@ from app.adapters.system.host import SystemUtils from app.runtime.version import get_app_version, get_frontend_version -# 保留旧插件可覆盖的模块级入口,默认通过 runtime 代理动态读取配置。 -settings = RuntimeSettingsCompat() +# 中心服务适配器只通过 runtime 读取端口消费组合根的最新配置。 _server_report_service: Any = None @@ -102,7 +101,7 @@ class MoviePilotServerHelper: """ 判断请求地址是否指向配置中的 MoviePilot 服务端。 """ - server_host = (settings.MP_SERVER_HOST or "").strip().rstrip("/") + server_host = (get_runtime_setting('MP_SERVER_HOST') or "").strip().rstrip("/") if not server_host or not url: return False @@ -144,7 +143,7 @@ class MoviePilotServerHelper: user_uid = cls.get_user_uid() if user_uid: request_headers[cls.USER_UID_HEADER] = user_uid - request_headers["User-Agent"] = settings.USER_AGENT + request_headers["User-Agent"] = get_runtime_setting('USER_AGENT') return request_headers @classmethod @@ -162,10 +161,10 @@ class MoviePilotServerHelper: """ 获取当前 GitHub 用户名。 """ - if cls._github_user is None and settings.GITHUB_HEADERS: + if cls._github_user is None and get_runtime_setting('GITHUB_HEADERS'): res = RequestUtils( - headers=settings.GITHUB_HEADERS, - proxies=settings.PROXY, + headers=get_runtime_setting('GITHUB_HEADERS'), + proxies=get_runtime_setting('PROXY'), timeout=15, ).get_res("https://api.github.com/user") if res: @@ -178,10 +177,10 @@ class MoviePilotServerHelper: """ 异步获取当前 GitHub 用户名。 """ - if cls._github_user is None and settings.GITHUB_HEADERS: + if cls._github_user is None and get_runtime_setting('GITHUB_HEADERS'): res = await AsyncRequestUtils( - headers=settings.GITHUB_HEADERS, - proxies=settings.PROXY, + headers=get_runtime_setting('GITHUB_HEADERS'), + proxies=get_runtime_setting('PROXY'), timeout=15, ).get_res("https://api.github.com/user") if res: @@ -278,7 +277,7 @@ class MoviePilotServerHelper: "user_uid": cls.get_user_uid(), "backend_version": get_app_version(), "frontend_version": get_frontend_version(), - "version_flag": settings.VERSION_FLAG, + "version_flag": get_runtime_setting('VERSION_FLAG'), "platform": f"{platform.system()} {platform.release()}".strip(), "arch": SystemUtils.cpu_arch(), } @@ -288,7 +287,7 @@ class MoviePilotServerHelper: """ 上报当前安装实例的版本统计。 """ - if not settings.USAGE_STATISTIC_SHARE: + if not get_runtime_setting('USAGE_STATISTIC_SHARE'): return False payload = cls.build_usage_payload() if not payload.get("user_uid"): @@ -305,7 +304,7 @@ class MoviePilotServerHelper: """ 异步上报当前安装实例的版本统计。 """ - if not settings.USAGE_STATISTIC_SHARE: + if not get_runtime_setting('USAGE_STATISTIC_SHARE'): return False payload = cls.build_usage_payload() if not payload.get("user_uid"): @@ -322,7 +321,7 @@ class MoviePilotServerHelper: """ 异步获取安装版本统计报表。 """ - if not settings.USAGE_STATISTIC_SHARE: + if not get_runtime_setting('USAGE_STATISTIC_SHARE'): return {} try: res = await cls.async_usage_statistic() @@ -338,7 +337,7 @@ class MoviePilotServerHelper: 初始化订阅统计上报状态。 """ cls._report_service().init_report( - enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'), state_key=SystemConfigKey.SubscribeReport, reporter=cls.sub_report, ) @@ -347,7 +346,7 @@ class MoviePilotServerHelper: async def async_init_subscribe_report(cls) -> None: """异步初始化订阅统计标记。""" await cls._report_service().async_init_report( - enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'), state_key=SystemConfigKey.SubscribeReport, reporter=cls.async_sub_report, ) @@ -358,7 +357,7 @@ class MoviePilotServerHelper: 初始化插件安装统计上报状态。 """ cls._report_service().init_report( - enabled=settings.PLUGIN_STATISTIC_SHARE, + enabled=get_runtime_setting('PLUGIN_STATISTIC_SHARE'), state_key=SystemConfigKey.PluginInstallReport, reporter=cls.install_plugin_report, ) @@ -367,7 +366,7 @@ class MoviePilotServerHelper: async def async_init_plugin_report(cls) -> None: """异步初始化插件统计标记。""" await cls._report_service().async_init_report( - enabled=settings.PLUGIN_STATISTIC_SHARE, + enabled=get_runtime_setting('PLUGIN_STATISTIC_SHARE'), state_key=SystemConfigKey.PluginInstallReport, reporter=cls.async_install_plugin_report, ) @@ -514,7 +513,7 @@ class MoviePilotServerHelper: """ 获取插件安装统计。 """ - if not settings.PLUGIN_STATISTIC_SHARE: + if not get_runtime_setting('PLUGIN_STATISTIC_SHARE'): return {} res = cls.plugin_statistic() if res is not None and res.status_code == 200: @@ -526,7 +525,7 @@ class MoviePilotServerHelper: """ 异步获取插件安装统计。 """ - if not settings.PLUGIN_STATISTIC_SHARE: + if not get_runtime_setting('PLUGIN_STATISTIC_SHARE'): return {} res = await cls.async_plugin_statistic() if res is not None and res.status_code == 200: @@ -590,7 +589,7 @@ class MoviePilotServerHelper: """ 上报单个插件安装统计。 """ - if not settings.PLUGIN_STATISTIC_SHARE: + if not get_runtime_setting('PLUGIN_STATISTIC_SHARE'): return False if not plugin_id: return False @@ -605,7 +604,7 @@ class MoviePilotServerHelper: """ 异步上报单个插件安装统计。 """ - if not settings.PLUGIN_STATISTIC_SHARE: + if not get_runtime_setting('PLUGIN_STATISTIC_SHARE'): return False if not plugin_id: return False @@ -621,7 +620,7 @@ class MoviePilotServerHelper: 批量上报存量插件安装统计。 """ return cls._report_service().report_plugins( - enabled=settings.PLUGIN_STATISTIC_SHARE, + enabled=get_runtime_setting('PLUGIN_STATISTIC_SHARE'), items=items, ) @@ -631,7 +630,7 @@ class MoviePilotServerHelper: 异步批量上报存量插件安装统计。 """ return await cls._report_service().async_report_plugins( - enabled=settings.PLUGIN_STATISTIC_SHARE, + enabled=get_runtime_setting('PLUGIN_STATISTIC_SHARE'), items=items, ) @@ -820,7 +819,7 @@ class MoviePilotServerHelper: """ 获取订阅统计数据。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return [] params = cls._build_subscribe_query_params( page=page, @@ -848,7 +847,7 @@ class MoviePilotServerHelper: """ 异步获取订阅统计数据。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return [] params = cls._build_subscribe_query_params( page=page, @@ -866,7 +865,7 @@ class MoviePilotServerHelper: """ 新增订阅统计。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return False payload = cls._build_subscribe_statistic_payload(sub) if not payload: @@ -879,7 +878,7 @@ class MoviePilotServerHelper: """ 异步新增订阅统计。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return False payload = cls._build_subscribe_statistic_payload(sub) if not payload: @@ -890,14 +889,14 @@ class MoviePilotServerHelper: @classmethod def sub_reg_durable(cls, sub: dict) -> bool: """同步上报新增统计;明确禁用时视为无需投递。""" - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return True return cls.sub_reg(sub) @classmethod async def async_sub_reg_durable(cls, sub: dict) -> bool: """异步上报新增统计;明确禁用时视为无需投递。""" - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return True return await cls.async_sub_reg(sub) @@ -906,7 +905,7 @@ class MoviePilotServerHelper: """ 完成订阅统计。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return False payload = cls._build_subscribe_statistic_payload(sub) if not payload: @@ -917,7 +916,7 @@ class MoviePilotServerHelper: @classmethod async def async_sub_done(cls, sub: dict) -> bool: """异步完成订阅统计,并仅在服务端确认成功时返回 True。""" - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return False payload = cls._build_subscribe_statistic_payload(sub) if not payload: @@ -928,14 +927,14 @@ class MoviePilotServerHelper: @classmethod def sub_done_durable(cls, sub: dict) -> bool: """同步上报完成统计;明确禁用时视为无需投递。""" - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return True return cls.sub_done(sub) @classmethod async def async_sub_done_durable(cls, sub: dict) -> bool: """异步上报完成统计;明确禁用时视为无需投递。""" - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return True return await cls.async_sub_done(sub) @@ -989,14 +988,14 @@ class MoviePilotServerHelper: 上报存量订阅统计。 """ return cls._report_service().report_subscribes( - enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'), ) @classmethod async def async_sub_report(cls) -> bool: """异步上报存量订阅统计。""" return await cls._report_service().async_report_subscribes( - enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'), ) @classmethod @@ -1011,7 +1010,7 @@ class MoviePilotServerHelper: 分享订阅。 """ return cls._sharing_service().share_subscribe( - enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'), subscribe_id=subscribe_id, share_title=share_title, share_comment=share_comment, @@ -1030,7 +1029,7 @@ class MoviePilotServerHelper: 异步分享订阅。 """ return await cls._sharing_service().async_share_subscribe( - enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'), subscribe_id=subscribe_id, share_title=share_title, share_comment=share_comment, @@ -1056,7 +1055,7 @@ class MoviePilotServerHelper: """ 删除订阅分享。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return False, "当前没有开启订阅数据共享功能" return cls._handle_response( cls.subscribe_share_delete(share_id, cls.get_user_uuid()), @@ -1068,7 +1067,7 @@ class MoviePilotServerHelper: """ 异步删除订阅分享。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return False, "当前没有开启订阅数据共享功能" return cls._handle_response( await cls.async_subscribe_share_delete(share_id, cls.get_user_uuid()), @@ -1080,7 +1079,7 @@ class MoviePilotServerHelper: """ 复用订阅分享。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return False, "当前没有开启订阅数据共享功能" return cls._handle_response(cls.subscribe_fork(share_id)) @@ -1089,7 +1088,7 @@ class MoviePilotServerHelper: """ 异步复用订阅分享。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return False, "当前没有开启订阅数据共享功能" return cls._handle_response(await cls.async_subscribe_fork(share_id)) @@ -1108,7 +1107,7 @@ class MoviePilotServerHelper: """ 获取订阅分享数据。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return [] params = cls._build_subscribe_query_params( page=page, @@ -1136,7 +1135,7 @@ class MoviePilotServerHelper: """ 异步获取订阅分享数据。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return [] params = cls._build_subscribe_query_params( page=page, @@ -1155,7 +1154,7 @@ class MoviePilotServerHelper: """ 获取订阅分享统计数据。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return [] return cls._handle_list_response(cls.subscribe_share_statistics()) @@ -1165,7 +1164,7 @@ class MoviePilotServerHelper: """ 异步获取订阅分享统计数据。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: + if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'): return [] return cls._handle_list_response(await cls.async_subscribe_share_statistics()) @@ -1252,7 +1251,7 @@ class MoviePilotServerHelper: 分享工作流。 """ return cls._sharing_service().share_workflow( - enabled=settings.WORKFLOW_STATISTIC_SHARE, + enabled=get_runtime_setting('WORKFLOW_STATISTIC_SHARE'), workflow_id=workflow_id, share_title=share_title, share_comment=share_comment, @@ -1271,7 +1270,7 @@ class MoviePilotServerHelper: 异步分享工作流。 """ return await cls._sharing_service().async_share_workflow( - enabled=settings.WORKFLOW_STATISTIC_SHARE, + enabled=get_runtime_setting('WORKFLOW_STATISTIC_SHARE'), workflow_id=workflow_id, share_title=share_title, share_comment=share_comment, @@ -1283,7 +1282,7 @@ class MoviePilotServerHelper: """ 删除工作流分享。 """ - if not settings.WORKFLOW_STATISTIC_SHARE: + if not get_runtime_setting('WORKFLOW_STATISTIC_SHARE'): return False, "当前没有开启工作流数据共享功能" return cls._handle_response( cls.workflow_share_delete(share_id, cls.get_user_uuid()), @@ -1295,7 +1294,7 @@ class MoviePilotServerHelper: """ 异步删除工作流分享。 """ - if not settings.WORKFLOW_STATISTIC_SHARE: + if not get_runtime_setting('WORKFLOW_STATISTIC_SHARE'): return False, "当前没有开启工作流数据共享功能" return cls._handle_response( await cls.async_workflow_share_delete(share_id, cls.get_user_uuid()), @@ -1307,7 +1306,7 @@ class MoviePilotServerHelper: """ 复用工作流分享。 """ - if not settings.WORKFLOW_STATISTIC_SHARE: + if not get_runtime_setting('WORKFLOW_STATISTIC_SHARE'): return False, "当前没有开启工作流数据共享功能" return cls._handle_response(cls.workflow_fork(share_id)) @@ -1316,7 +1315,7 @@ class MoviePilotServerHelper: """ 异步复用工作流分享。 """ - if not settings.WORKFLOW_STATISTIC_SHARE: + if not get_runtime_setting('WORKFLOW_STATISTIC_SHARE'): return False, "当前没有开启工作流数据共享功能" return cls._handle_response(await cls.async_workflow_fork(share_id)) @@ -1331,7 +1330,7 @@ class MoviePilotServerHelper: """ 获取工作流分享数据。 """ - if not settings.WORKFLOW_STATISTIC_SHARE: + if not get_runtime_setting('WORKFLOW_STATISTIC_SHARE'): return [] return cls._handle_list_response(cls.workflow_shares({ "name": name, @@ -1350,7 +1349,7 @@ class MoviePilotServerHelper: """ 异步获取工作流分享数据。 """ - if not settings.WORKFLOW_STATISTIC_SHARE: + if not get_runtime_setting('WORKFLOW_STATISTIC_SHARE'): return [] return cls._handle_list_response(await cls.async_workflow_shares({ "name": name, @@ -1370,10 +1369,10 @@ class MoviePilotServerHelper: """ 获取共享识别服务端地址。 """ - custom_api = (settings.MEDIA_RECOGNIZE_SHARE_API or "").strip() + custom_api = (get_runtime_setting('MEDIA_RECOGNIZE_SHARE_API') or "").strip() if custom_api: return custom_api.rstrip("/") - server_host = (settings.MP_SERVER_HOST or "").strip().rstrip("/") + server_host = (get_runtime_setting('MP_SERVER_HOST') or "").strip().rstrip("/") if not server_host: return None return f"{server_host}{cls._RECOGNIZE_SHARE_PATH}" @@ -1429,7 +1428,7 @@ class MoviePilotServerHelper: """ 查询共享识别结果。 """ - if not settings.MEDIA_RECOGNIZE_SHARE: + if not get_runtime_setting('MEDIA_RECOGNIZE_SHARE'): return None params = cls._build_recognize_query_params( meta=meta, @@ -1453,7 +1452,7 @@ class MoviePilotServerHelper: """ 异步查询共享识别结果。 """ - if not settings.MEDIA_RECOGNIZE_SHARE: + if not get_runtime_setting('MEDIA_RECOGNIZE_SHARE'): return None params = cls._build_recognize_query_params( meta=meta, @@ -1476,7 +1475,7 @@ class MoviePilotServerHelper: """ 上报共享识别结果,电影、电视剧、音乐共用。 """ - if not settings.MEDIA_RECOGNIZE_SHARE: + if not get_runtime_setting('MEDIA_RECOGNIZE_SHARE'): return False payload = cls._build_recognize_report_payload( meta=meta, @@ -1498,7 +1497,7 @@ class MoviePilotServerHelper: """ 异步上报共享识别结果,电影、电视剧、音乐共用。 """ - if not settings.MEDIA_RECOGNIZE_SHARE: + if not get_runtime_setting('MEDIA_RECOGNIZE_SHARE'): return False payload = cls._build_recognize_report_payload( meta=meta, @@ -1855,7 +1854,7 @@ class MoviePilotServerHelper: """ 根据服务端基础地址和路径生成完整 URL。 """ - return f"{settings.MP_SERVER_HOST.rstrip('/')}{path}" + return f"{get_runtime_setting('MP_SERVER_HOST').rstrip('/')}{path}" @classmethod def _get( @@ -1869,7 +1868,7 @@ class MoviePilotServerHelper: 发送服务端 GET 请求,默认携带安装用户 ID。 """ return RequestUtils( - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=timeout, headers=cls.build_headers(url) if include_user_uid else {}, ).get_res(url, params=params) @@ -1886,7 +1885,7 @@ class MoviePilotServerHelper: 异步发送服务端 GET 请求,默认携带安装用户 ID。 """ return await AsyncRequestUtils( - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=timeout, headers=cls.build_headers(url) if include_user_uid else {}, ).get_res(url, params=params) @@ -1897,7 +1896,7 @@ class MoviePilotServerHelper: 发送携带安装用户 ID 的服务端 JSON POST 请求。 """ return RequestUtils( - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=timeout, headers=cls.build_headers(url, content_type="application/json"), ).post(url, json=payload) @@ -1908,7 +1907,7 @@ class MoviePilotServerHelper: 异步发送携带安装用户 ID 的服务端 JSON POST 请求。 """ return await AsyncRequestUtils( - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=timeout, headers=cls.build_headers(url, content_type="application/json"), ).post(url, json=payload) @@ -1919,7 +1918,7 @@ class MoviePilotServerHelper: 发送携带安装用户 ID 的服务端 DELETE 请求。 """ return RequestUtils( - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=timeout, headers=cls.build_headers(url), ).delete_res(url, params=params) @@ -1930,7 +1929,7 @@ class MoviePilotServerHelper: 异步发送携带安装用户 ID 的服务端 DELETE 请求。 """ return await AsyncRequestUtils( - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=timeout, headers=cls.build_headers(url), ).delete_res(url, params=params) diff --git a/app/adapters/network/browser.py b/app/adapters/network/browser.py index 8f9c5488c..ba0b9752c 100644 --- a/app/adapters/network/browser.py +++ b/app/adapters/network/browser.py @@ -9,7 +9,7 @@ from typing import Any, Callable, Optional, Protocol from urllib.parse import urlparse from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.runtime.managed_resources import ( acquire_managed_resource, acquire_managed_resource_async, @@ -17,10 +17,6 @@ from app.runtime.managed_resources import ( from app.adapters.network.http import RequestUtils, cookie_parse -# 保留旧插件可覆盖的模块级入口,默认通过 runtime 代理动态读取浏览器配置。 -settings = RuntimeSettingsCompat() - - class BrowserElement(Protocol): """ 页面元素的最小接口,避免为了类型标注直接导入 Playwright。 @@ -721,8 +717,8 @@ class BrowserSessionHelper: ) -> BrowserContext: """按宿主反检测配置创建 CloakBrowser 上下文。""" context_kwargs = { - "humanize": settings.CLOAKBROWSER_HUMANIZE, - "human_preset": settings.CLOAKBROWSER_HUMAN_PRESET, + "humanize": get_runtime_setting('CLOAKBROWSER_HUMANIZE'), + "human_preset": get_runtime_setting('CLOAKBROWSER_HUMAN_PRESET'), } if user_agent: context_kwargs["user_agent"] = user_agent @@ -922,14 +918,18 @@ class PlaywrightHelper: """ 兼容旧的 PlaywrightHelper(browser_type=...) 构造方式。 """ - self.browser_type = browser_type or settings.PLAYWRIGHT_BROWSER_TYPE + self.browser_type = browser_type or get_runtime_setting( + "PLAYWRIGHT_BROWSER_TYPE" + ) @staticmethod def __browser_emulation() -> str: """ 当前浏览器仿真类型。 """ - return (settings.BROWSER_EMULATION or "cloakbrowser").lower() + return ( + get_runtime_setting('BROWSER_EMULATION') or "cloakbrowser" + ).lower() @staticmethod def __launch_cloakbrowser_context(headless: bool, @@ -941,8 +941,8 @@ class PlaywrightHelper: return launch_browser_context(headless=headless, proxy=proxies, user_agent=user_agent, - humanize=settings.CLOAKBROWSER_HUMANIZE, - human_preset=settings.CLOAKBROWSER_HUMAN_PRESET) + humanize=get_runtime_setting('CLOAKBROWSER_HUMANIZE'), + human_preset=get_runtime_setting('CLOAKBROWSER_HUMAN_PRESET')) @staticmethod def __fs_cookie_str(cookies: list) -> str: @@ -960,11 +960,12 @@ class PlaywrightHelper: 调用 FlareSolverr 解决 Cloudflare 并返回 solution 结果 参考: https://github.com/FlareSolverr/FlareSolverr """ - if not settings.FLARESOLVERR_URL: + flaresolverr_url = get_runtime_setting('FLARESOLVERR_URL') + if not flaresolverr_url: logger.warn("未配置 FLARESOLVERR_URL,无法使用 FlareSolverr") return None - fs_api = settings.FLARESOLVERR_URL.rstrip("/") + "/v1" + fs_api = flaresolverr_url.rstrip("/") + "/v1" session_id = None try: diff --git a/app/adapters/system/fsproxy.py b/app/adapters/system/fsproxy.py index 061cc292f..4c1a00a82 100644 --- a/app/adapters/system/fsproxy.py +++ b/app/adapters/system/fsproxy.py @@ -27,10 +27,8 @@ import threading from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Union -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting # worker 脚本路径。用绝对路径直接执行,而不是 -m 或 import: # 直接执行文件不会触发 app/__init__.py 的导入链,代理启动才是毫秒级的 @@ -258,7 +256,7 @@ class FileSystemProxy: """ if self._timeout_override is not None: return self._timeout_override - return float(getattr(settings, "FS_PROXY_TIMEOUT", DEFAULT_TIMEOUT)) + return float(get_runtime_setting("FS_PROXY_TIMEOUT", DEFAULT_TIMEOUT)) @property def _stall_timeout(self) -> float: @@ -267,14 +265,16 @@ class FileSystemProxy: """ if self._stall_timeout_override is not None: return self._stall_timeout_override - return float(getattr(settings, "FS_PROXY_STALL_TIMEOUT", DEFAULT_STALL_TIMEOUT)) + return float( + get_runtime_setting("FS_PROXY_STALL_TIMEOUT", DEFAULT_STALL_TIMEOUT) + ) @staticmethod def _enabled() -> bool: """ 代理是否启用。关闭时退回直接调用,行为与引入代理之前完全一致。 """ - return bool(getattr(settings, "FS_PROXY_ENABLED", True)) + return bool(get_runtime_setting("FS_PROXY_ENABLED", True)) @staticmethod def _direct(op: str, payload: Dict[str, Any]) -> Any: diff --git a/app/adapters/system/plugin/dependency.py b/app/adapters/system/plugin/dependency.py index 80afb2db6..5be56151e 100644 --- a/app/adapters/system/plugin/dependency.py +++ b/app/adapters/system/plugin/dependency.py @@ -52,7 +52,7 @@ class PluginDependencyInstaller: self._helper = helper self._installed_plugins_provider = installed_plugins_provider or (lambda: []) self._plugin_dir = plugin_dir or ( - Path(get_runtime_setting("ROOT_PATH")) / "app" / "plugins" + Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" ) @staticmethod diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py index edcaf130f..96308f179 100644 --- a/app/adapters/system/plugin/package.py +++ b/app/adapters/system/plugin/package.py @@ -16,10 +16,7 @@ from app.runtime.execution import ( run_in_threadpool_to_completion as _await_thread_operation, ) from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat - -# 保留旧模块级入口,插件本地同步测试和旧扩展仍可能覆盖这些设置。 -settings = RuntimeSettingsCompat() +from app.runtime.settings import get_runtime_setting @dataclass(frozen=True, slots=True) class PluginPackageCheckpoint: @@ -58,7 +55,7 @@ class PluginPackageManager: def __plugin_dir(plugin_id: str) -> Path: """解析插件运行目录并拒绝越出宿主插件根目录的标识。""" plugins_root = ( - Path(settings.ROOT_PATH) / "app" / "plugins" + Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" ).resolve() plugin_dir = (plugins_root / plugin_id.lower()).resolve() if plugin_dir == plugins_root or not plugin_dir.is_relative_to(plugins_root): @@ -74,7 +71,9 @@ class PluginPackageManager: plugin_dir = self.__plugin_dir(plugin_id) durable = transaction_id is not None persistent_backup_dir = ( - Path(settings.CONFIG_PATH) / "plugins_backup" / plugin_id.lower() + Path(get_runtime_setting('CONFIG_PATH')) + / "plugins_backup" + / plugin_id.lower() ).resolve() backup_staging_dir = ( persistent_backup_dir.parent @@ -89,9 +88,9 @@ class PluginPackageManager: else None ) transaction_root = ( - Path(settings.CONFIG_PATH) + Path(get_runtime_setting('CONFIG_PATH')) if durable - else Path(settings.TEMP_PATH) + else Path(get_runtime_setting('TEMP_PATH')) ) transaction_dir = ( transaction_root @@ -129,7 +128,9 @@ class PluginPackageManager: """按受控根目录和事务 ID 重建崩溃回放所需的文件引用。""" plugin_dir = self.__plugin_dir(plugin_id) persistent_backup_dir = ( - Path(settings.CONFIG_PATH) / "plugins_backup" / plugin_id.lower() + Path(get_runtime_setting('CONFIG_PATH')) + / "plugins_backup" + / plugin_id.lower() ).resolve() durable_backup = SystemUtils.is_docker() return PluginPackageCheckpoint( @@ -149,7 +150,7 @@ class PluginPackageManager: else None ), transaction_dir=( - Path(settings.CONFIG_PATH) + Path(get_runtime_setting('CONFIG_PATH')) / "plugin_transactions" / transaction_id ), diff --git a/app/adapters/system/resource.py b/app/adapters/system/resource.py index c6c06a15b..a9c2a7f1d 100644 --- a/app/adapters/system/resource.py +++ b/app/adapters/system/resource.py @@ -5,18 +5,12 @@ import sysconfig from pathlib import Path from typing import Callable -from app.runtime import config as _runtime_config -from app.runtime.log import logger from app.adapters.network.http import RequestUtils -from app.foundation.version import compare_version from app.adapters.system.host import SystemUtils +from app.foundation.version import compare_version +from app.runtime.log import logger from app.runtime.settings import get_runtime_setting - -# 保留模块级旧 Settings 入口,旧插件和测试可能仍会对其做运行时覆盖;实现读取统一走 runtime 端口。 -settings = _runtime_config.settings - - ResourceVersionProvider = Callable[[], tuple[str, str]] @@ -39,9 +33,9 @@ class ResourceHelper: 检测和更新资源包 """ - _base_dir: Path = get_runtime_setting("ROOT_PATH") + _base_dir: Path = get_runtime_setting('ROOT_PATH') _resource_target = Path("app/application/site") - _version_flag = get_runtime_setting("RESOURCE_VERSION_FLAG") + _version_flag = get_runtime_setting('RESOURCE_VERSION_FLAG') _repo = ( f"{get_runtime_setting('GITHUB_PROXY')}https://raw.githubusercontent.com/" f"jxxghp/MoviePilot-Resources/main/package.{_version_flag}.json" @@ -56,8 +50,8 @@ class ResourceHelper: """返回访问 GitHub 资源时应使用的代理配置。""" return ( None - if get_runtime_setting("GITHUB_PROXY") - else get_runtime_setting("PROXY") + if get_runtime_setting('GITHUB_PROXY') + else get_runtime_setting('PROXY') ) @staticmethod @@ -97,7 +91,7 @@ class ResourceHelper: """读取 V3 资源清单。""" response = RequestUtils( proxies=self.proxies, - headers=get_runtime_setting("GITHUB_HEADERS"), + headers=get_runtime_setting('GITHUB_HEADERS'), timeout=10, ).get_res(self._repo) return response if response and response.status_code == 200 else None @@ -115,7 +109,7 @@ class ResourceHelper: :param indexer_version: 当前已加载的站点索引资源版本;省略时使用组合根注入值 :return: 是否成功安装了需要由上层处理重启的新资源 """ - if not get_runtime_setting("AUTO_UPDATE_RESOURCE"): + if not get_runtime_setting('AUTO_UPDATE_RESOURCE'): return False if SystemUtils.is_frozen(): return False @@ -170,8 +164,8 @@ class ResourceHelper: if need_updates: # 下载文件信息列表 r = RequestUtils( - proxies=get_runtime_setting("PROXY"), - headers=get_runtime_setting("GITHUB_HEADERS"), + proxies=get_runtime_setting('PROXY'), + headers=get_runtime_setting('GITHUB_HEADERS'), timeout=30, ).get_res(self._files_api) if r and not r.ok: @@ -201,7 +195,7 @@ class ResourceHelper: ) res = RequestUtils( proxies=self.proxies, - headers=get_runtime_setting("GITHUB_HEADERS"), + headers=get_runtime_setting('GITHUB_HEADERS'), timeout=180, ).get_res(download_url) if not res: diff --git a/app/adapters/system/rust.py b/app/adapters/system/rust.py index 3f37a64c0..fb7c856e9 100644 --- a/app/adapters/system/rust.py +++ b/app/adapters/system/rust.py @@ -17,7 +17,7 @@ else: def _rust_accel_enabled() -> bool: """读取 Rust 开关快照,组合根未装配时回退旧 Settings。""" - return bool(get_runtime_setting("RUST_ACCEL")) + return bool(get_runtime_setting('RUST_ACCEL')) def is_required() -> bool: diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 49351fe4c..e02700863 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -43,7 +43,7 @@ class SystemUpdateManager(metaclass=SingletonClass): @property def _root(self) -> Path: - return Path(get_runtime_setting("TEMP_PATH")) / "moviepilot-update" + return Path(get_runtime_setting('TEMP_PATH')) / "moviepilot-update" @property def _state_file(self) -> Path: @@ -268,8 +268,8 @@ class SystemUpdateManager(metaclass=SingletonClass): def _request(self) -> RequestUtils: return RequestUtils( - proxies=get_runtime_setting("PROXY"), - headers=get_runtime_setting("GITHUB_HEADERS"), + proxies=get_runtime_setting('PROXY'), + headers=get_runtime_setting('GITHUB_HEADERS'), timeout=60, ) @@ -474,7 +474,7 @@ class SystemUpdateManager(metaclass=SingletonClass): @staticmethod def _proxied(url: str) -> str: - proxy = str(get_runtime_setting("GITHUB_PROXY") or "").strip() + proxy = str(get_runtime_setting('GITHUB_PROXY') or "").strip() return f"{proxy}{url}" if proxy else url diff --git a/app/adapters/web/security/access.py b/app/adapters/web/security/access.py index 81c81fcb6..65dc3627e 100644 --- a/app/adapters/web/security/access.py +++ b/app/adapters/web/security/access.py @@ -16,7 +16,7 @@ from fastapi.security import ( from app.runtime.cache import cached from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.schemas.token import TokenPayload SuperuserTokenPayloadProvider = Callable[[], TokenPayload] @@ -28,15 +28,12 @@ _token_decoder: Optional[TokenDecoder] = None JWT_ALGORITHM = "HS256" -# 兼容旧鉴权插件覆盖模块级设置;令牌实际策略仍由已注入 codec 和 runtime 配置提供。 -settings = RuntimeSettingsCompat() - oauth2_scheme_manual_error = OAuth2PasswordBearer( auto_error=False, - tokenUrl=f"{settings.API_V1_STR}/login/access-token", + tokenUrl=f"{get_runtime_setting('API_V1_STR')}/login/access-token", ) resource_token_cookie = APIKeyCookie( - name=settings.PROJECT_NAME, + name=get_runtime_setting('PROJECT_NAME'), auto_error=False, scheme_name="resource_token_cookie", ) @@ -133,12 +130,13 @@ def set_or_refresh_resource_token_cookie( payload: TokenPayload, ) -> None: """复用匹配的资源令牌,或为当前身份写入新的安全 Cookie。""" - resource_token = request.cookies.get(settings.PROJECT_NAME) + project_name = get_runtime_setting('PROJECT_NAME') + resource_token = request.cookies.get(project_name) if resource_token: try: decoded = jwt.decode( resource_token, - settings.RESOURCE_SECRET_KEY, + get_runtime_setting('RESOURCE_SECRET_KEY'), algorithms=[JWT_ALGORITHM], ) exp = decoded.get("exp") @@ -148,7 +146,12 @@ def set_or_refresh_resource_token_cookie( tz=datetime.UTC, ) - datetime.datetime.now(datetime.UTC) if remaining_time < timedelta( - seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3 + seconds=( + get_runtime_setting( + "RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS" + ) + / 3 + ) ): raise jwt.ExpiredSignatureError expected_claims = { @@ -177,7 +180,7 @@ def set_or_refresh_resource_token_cookie( username=payload.username or "", super_user=payload.super_user, expires_delta=timedelta( - seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS + seconds=get_runtime_setting('RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS') ), level=payload.level, purpose="resource", @@ -187,7 +190,7 @@ def set_or_refresh_resource_token_cookie( or request.headers.get("x-forwarded-proto", "").lower() == "https" ) response.set_cookie( - key=settings.PROJECT_NAME, + key=project_name, value=resource_token, httponly=True, secure=is_https, @@ -261,11 +264,11 @@ def verify_apitoken( token: Annotated[str | None, Security(_get_api_token)], ) -> str: """校验 URL 查询参数中的兼容 API Token。""" - return _verify_key(token, settings.API_TOKEN, "token") + return _verify_key(token, get_runtime_setting('API_TOKEN'), "token") def verify_apikey( apikey: Annotated[str | None, Security(_get_api_key)], ) -> str: """校验请求头或查询参数中的兼容 API Key。""" - return _verify_key(apikey, settings.API_TOKEN, "apikey") + return _verify_key(apikey, get_runtime_setting('API_TOKEN'), "apikey") diff --git a/app/agent/capabilities/adapter.py b/app/agent/capabilities/adapter.py index 9edaa8499..c984d57a9 100644 --- a/app/agent/capabilities/adapter.py +++ b/app/agent/capabilities/adapter.py @@ -17,10 +17,7 @@ from app.runtime.capabilities.model import ( SelectorSchema, ) from app.runtime.capabilities.registry import CapabilityRegistry -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() - +from app.runtime.settings import get_runtime_setting, has_runtime_setting _DEFAULT_CAPABILITY_ROOT = Path(__file__).resolve().parent _SETTING_SELECTOR = "setting_truthy" @@ -29,7 +26,7 @@ _SETTING_SELECTOR = "setting_truthy" def _validate_setting_selector(config: Mapping[str, Any]) -> None: """限制 selector 只能读取已声明的应用设置。""" key = config["key"] - if not isinstance(key, str) or not key or not hasattr(settings, key): + if not isinstance(key, str) or not key or not has_runtime_setting(key): raise ValueError(f"未知应用设置:{key!r}") @@ -221,4 +218,4 @@ def should_run_agent_service(spec: CapabilitySpec) -> bool: or selector.kind != _SETTING_SELECTOR ): raise ValueError(f"{spec.source}: 不是可协调的 Agent Service 声明") - return bool(getattr(settings, selector.config["key"])) + return bool(get_runtime_setting(selector.config["key"])) diff --git a/app/agent/llm/capability.py b/app/agent/llm/capability.py index a75f6a500..c417b37d9 100644 --- a/app/agent/llm/capability.py +++ b/app/agent/llm/capability.py @@ -13,9 +13,8 @@ from typing import Any, Dict, Optional from uuid import uuid4 from app.application.notification import get_notification_configs -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.adapters.network.http import RequestUtils @@ -61,11 +60,11 @@ class OpenAIAudioProvider(AudioCapabilityProvider): @staticmethod def _input_credentials() -> tuple[Optional[str], Optional[str]]: - return settings.AUDIO_INPUT_API_KEY, settings.AUDIO_INPUT_BASE_URL + return get_runtime_setting('AUDIO_INPUT_API_KEY'), get_runtime_setting('AUDIO_INPUT_BASE_URL') @staticmethod def _output_credentials() -> tuple[Optional[str], Optional[str]]: - return settings.AUDIO_OUTPUT_API_KEY, settings.AUDIO_OUTPUT_BASE_URL + return get_runtime_setting('AUDIO_OUTPUT_API_KEY'), get_runtime_setting('AUDIO_OUTPUT_BASE_URL') def is_available_for_audio_input(self) -> bool: api_key, _ = self._input_credentials() @@ -89,9 +88,9 @@ class OpenAIAudioProvider(AudioCapabilityProvider): audio_file = BytesIO(content) audio_file.name = filename response = client.audio.transcriptions.create( - model=settings.AUDIO_INPUT_MODEL, + model=get_runtime_setting('AUDIO_INPUT_MODEL'), file=audio_file, - language=settings.AUDIO_INPUT_LANGUAGE or "zh", + language=get_runtime_setting('AUDIO_INPUT_LANGUAGE') or "zh", response_format="verbose_json", ) text = getattr(response, "text", None) @@ -109,12 +108,12 @@ class OpenAIAudioProvider(AudioCapabilityProvider): if not api_key: raise ValueError("音频输出 provider 未配置 API Key") client = self._build_client(api_key=api_key, base_url=base_url) - voice_dir = settings.TEMP_PATH / "voice" + voice_dir = get_runtime_setting('TEMP_PATH') / "voice" voice_dir.mkdir(parents=True, exist_ok=True) output_path = voice_dir / f"{uuid4().hex}.opus" response = client.audio.speech.create( - model=settings.AUDIO_OUTPUT_MODEL, - voice=settings.AUDIO_OUTPUT_VOICE, + model=get_runtime_setting('AUDIO_OUTPUT_MODEL'), + voice=get_runtime_setting('AUDIO_OUTPUT_VOICE'), input=text, response_format="opus", ) @@ -163,22 +162,22 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): @staticmethod def _input_credentials() -> tuple[Optional[str], Optional[str]]: - return settings.AUDIO_INPUT_API_KEY, settings.AUDIO_INPUT_BASE_URL + return get_runtime_setting('AUDIO_INPUT_API_KEY'), get_runtime_setting('AUDIO_INPUT_BASE_URL') @staticmethod def _output_credentials() -> tuple[Optional[str], Optional[str]]: - return settings.AUDIO_OUTPUT_API_KEY, settings.AUDIO_OUTPUT_BASE_URL + return get_runtime_setting('AUDIO_OUTPUT_API_KEY'), get_runtime_setting('AUDIO_OUTPUT_BASE_URL') def _normalize_stt_model(self) -> str: return self._normalize_model( - model=settings.AUDIO_INPUT_MODEL, + model=get_runtime_setting('AUDIO_INPUT_MODEL'), supported_models=self.SUPPORTED_STT_MODELS, default_model=self.DEFAULT_STT_MODEL, ) def _normalize_tts_model(self) -> str: return self._normalize_model( - model=settings.AUDIO_OUTPUT_MODEL, + model=get_runtime_setting('AUDIO_OUTPUT_MODEL'), supported_models=self.SUPPORTED_TTS_MODELS, default_model=self.DEFAULT_TTS_MODEL, ) @@ -268,7 +267,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): return None suffix = Path(filename or "").suffix.lower() or ".audio" - voice_dir = settings.TEMP_PATH / "voice" + voice_dir = get_runtime_setting('TEMP_PATH') / "voice" voice_dir.mkdir(parents=True, exist_ok=True) input_path = voice_dir / f"{uuid4().hex}{suffix}" output_path = input_path.with_suffix(self.TRANSCODED_STT_SUFFIX) @@ -391,7 +390,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): if not normalized_audio: return None content, filename = normalized_audio - language = (settings.AUDIO_INPUT_LANGUAGE or "").strip() + language = (get_runtime_setting('AUDIO_INPUT_LANGUAGE') or "").strip() prompt = "请将这段音频完整转写为文字,只输出转写结果,不要添加解释。" if language: prompt += f"音频主要语言是 {language}。" @@ -426,7 +425,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): logger.error( "%s TTS 当前不支持该模型或模型未配置: %s", self.DISPLAY_NAME, - settings.AUDIO_OUTPUT_MODEL, + get_runtime_setting('AUDIO_OUTPUT_MODEL'), ) return None @@ -435,7 +434,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): if not api_key: raise ValueError("音频输出 provider 未配置 API Key") client = self._build_client(api_key=api_key, base_url=base_url) - voice_dir = settings.TEMP_PATH / "voice" + voice_dir = get_runtime_setting('TEMP_PATH') / "voice" voice_dir.mkdir(parents=True, exist_ok=True) wav_path = voice_dir / f"{uuid4().hex}.wav" request = { @@ -448,7 +447,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): ], "audio": { "format": self.AUDIO_RESPONSE_FORMAT, - "voice": settings.AUDIO_OUTPUT_VOICE or self.DEFAULT_VOICE, + "voice": get_runtime_setting('AUDIO_OUTPUT_VOICE') or self.DEFAULT_VOICE, }, } if self.INCLUDE_AUDIO_MODALITIES: @@ -487,7 +486,7 @@ class MiMoAudioProvider(OpenAIChatAudioProvider): ) def _normalize_tts_model(self) -> str: - model = (settings.AUDIO_OUTPUT_MODEL or "").strip().lower() + model = (get_runtime_setting('AUDIO_OUTPUT_MODEL') or "").strip().lower() if not model or not model.startswith("mimo-"): return self.DEFAULT_TTS_MODEL return model @@ -546,21 +545,21 @@ class MiniMaxAudioProvider(OpenAIChatAudioProvider): def _normalize_stt_model(self) -> str: """将非 MiniMax 的默认转写模型名兜底为 MiniMax 对话模型。""" - model = (settings.AUDIO_INPUT_MODEL or "").strip() + model = (get_runtime_setting('AUDIO_INPUT_MODEL') or "").strip() if not model or model.lower().startswith(("gpt-", "mimo-")): return self.DEFAULT_STT_MODEL return model def _normalize_tts_model(self) -> str: """将非 MiniMax 语音模型兜底为官方 T2A 模型。""" - model = (settings.AUDIO_OUTPUT_MODEL or "").strip().lower() + model = (get_runtime_setting('AUDIO_OUTPUT_MODEL') or "").strip().lower() if model in self.SUPPORTED_TTS_MODELS: return model return self.DEFAULT_TTS_MODEL def _normalize_voice_id(self) -> str: """将其他 provider 的默认音色兜底为 MiniMax 中文系统音色。""" - voice_id = (settings.AUDIO_OUTPUT_VOICE or "").strip() + voice_id = (get_runtime_setting('AUDIO_OUTPUT_VOICE') or "").strip() if not voice_id or voice_id in {"alloy", "mimo_default"}: return self.DEFAULT_VOICE return voice_id @@ -599,7 +598,7 @@ class MiniMaxAudioProvider(OpenAIChatAudioProvider): "Content-Type": "application/json", "Accept": "application/json", }, - proxies=settings.PROXY or {}, + proxies=get_runtime_setting('PROXY') or {}, timeout=60, ).post_res( url=self._build_t2a_url(base_url), @@ -637,7 +636,7 @@ class MiniMaxAudioProvider(OpenAIChatAudioProvider): if not audio_data: raise ValueError("MiniMax T2A 响应中没有音频数据") - voice_dir = settings.TEMP_PATH / "voice" + voice_dir = get_runtime_setting('TEMP_PATH') / "voice" voice_dir.mkdir(parents=True, exist_ok=True) output_path = voice_dir / f"{uuid4().hex}.opus" output_path.write_bytes(self._decode_audio_payload(audio_data)) @@ -681,9 +680,9 @@ class AgentCapabilityManager: @classmethod def get_audio_provider(cls, mode: str) -> Optional[AudioCapabilityProvider]: provider_name = cls._normalize_provider_name( - settings.AUDIO_INPUT_PROVIDER + get_runtime_setting('AUDIO_INPUT_PROVIDER') if (mode or "").lower() == "input" - else settings.AUDIO_OUTPUT_PROVIDER + else get_runtime_setting('AUDIO_OUTPUT_PROVIDER') ) provider = cls._audio_providers.get(provider_name) if provider: @@ -701,12 +700,12 @@ class AgentCapabilityManager: @staticmethod def supports_audio_input() -> bool: """当前 Agent 是否启用音频输入能力。""" - return bool(settings.LLM_SUPPORT_AUDIO_INPUT) + return bool(get_runtime_setting('LLM_SUPPORT_AUDIO_INPUT')) @staticmethod def supports_audio_output() -> bool: """当前 Agent 是否启用音频输出能力。""" - return bool(settings.LLM_SUPPORT_AUDIO_OUTPUT) + return bool(get_runtime_setting('LLM_SUPPORT_AUDIO_OUTPUT')) @classmethod def is_audio_input_available(cls) -> bool: diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index 6325f757b..04c12bedc 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -11,10 +11,8 @@ from urllib.parse import urlsplit from langchain_core.messages import AIMessage, AIMessageChunk from app.agent.llm.gateway import resolve_llm_provider_runtime -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting if TYPE_CHECKING: from app.agent.llm.server_tools import ServerToolResolution @@ -148,8 +146,8 @@ def _resolve_llm_proxy(use_proxy: bool | None = None) -> str | None: """ 解析本次 LLM 调用应使用的系统代理地址。 """ - should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy - return settings.PROXY_HOST if should_use_proxy and settings.PROXY_HOST else None + should_use_proxy = get_runtime_setting('LLM_USE_PROXY') if use_proxy is None else use_proxy + return get_runtime_setting('PROXY_HOST') if should_use_proxy and get_runtime_setting('PROXY_HOST') else None def _build_httpx_proxy_kwargs(proxy_url: str | None) -> dict[str, str]: @@ -539,7 +537,7 @@ class LLMHelper: record_input = cls._source_input_limit(model_record) metadata_input = cls._source_input_limit(metadata_source) profile_input = cls._positive_token_limit(profile.get("max_input_tokens")) - configured_k = cls._positive_token_limit(settings.LLM_MAX_CONTEXT_TOKENS) + configured_k = cls._positive_token_limit(get_runtime_setting('LLM_MAX_CONTEXT_TOKENS')) configured_input = configured_k * 1000 if configured_k else None endpoint_matched = runtime.get("model_profile_endpoint_matched") is True @@ -790,8 +788,8 @@ class LLMHelper: base_url_preset: Optional[str] = None, ) -> Optional[bool]: """复用 provider 目录缓存解析当前模型是否支持图片输入。""" - provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).strip() - model_name = str(model if model is not None else settings.LLM_MODEL).strip() + provider_name = str(provider if provider is not None else get_runtime_setting('LLM_PROVIDER')).strip() + model_name = str(model if model is not None else get_runtime_setting('LLM_MODEL')).strip() if not provider_name or not model_name: return None @@ -799,11 +797,11 @@ class LLMHelper: metadata = resolve_llm_provider_runtime().resolve_cached_model_metadata( provider_id=provider_name, model_id=model_name, - base_url=base_url if base_url is not None else settings.LLM_BASE_URL, + base_url=base_url if base_url is not None else get_runtime_setting('LLM_BASE_URL'), base_url_preset_id=( base_url_preset if base_url_preset is not None - else settings.LLM_BASE_URL_PRESET + else get_runtime_setting('LLM_BASE_URL_PRESET') ), ) except Exception as err: @@ -828,7 +826,7 @@ class LLMHelper: 被兼容端点以 400 拒绝。无参调用保持旧版“只读总开关”语义, 未知自定义模型也保持原有开关语义。 """ - if not settings.LLM_SUPPORT_IMAGE_INPUT: + if not get_runtime_setting('LLM_SUPPORT_IMAGE_INPUT'): return False if provider is None and model is None: return True @@ -857,8 +855,8 @@ class LLMHelper: 这主要用于单测 stub 环境以及极端的最小运行环境,正常生产路径仍优先 走 `LLMProviderManager.resolve_runtime()`。 """ - api_key_value = api_key if api_key is not None else settings.LLM_API_KEY - base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL + api_key_value = api_key if api_key is not None else get_runtime_setting('LLM_API_KEY') + base_url_value = base_url if base_url is not None else get_runtime_setting('LLM_BASE_URL') if not api_key_value: raise ValueError("未配置LLM API Key") @@ -1037,7 +1035,7 @@ class LLMHelper: """ 规范化 API 协议配置,未知值统一回退为 ``auto`` 以保持兼容。 """ - normalized = str(api_protocol or settings.LLM_API_PROTOCOL or "").strip().lower() + normalized = str(api_protocol or get_runtime_setting('LLM_API_PROTOCOL') or "").strip().lower() if normalized in {"auto", "chat_completions", "responses"}: return normalized if normalized: @@ -1185,15 +1183,15 @@ class LLMHelper: :param prompt_cache_key: 同一 Agent 会话内稳定且脱敏的提示词缓存路由键。 :return: LLM实例 """ - provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower() - model_name = model if model is not None else settings.LLM_MODEL - api_key_value = api_key if api_key is not None else settings.LLM_API_KEY - base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL + provider_name = str(provider if provider is not None else get_runtime_setting('LLM_PROVIDER')).lower() + model_name = model if model is not None else get_runtime_setting('LLM_MODEL') + api_key_value = api_key if api_key is not None else get_runtime_setting('LLM_API_KEY') + base_url_value = base_url if base_url is not None else get_runtime_setting('LLM_BASE_URL') base_url_preset_value = ( - base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET + base_url_preset if base_url_preset is not None else get_runtime_setting('LLM_BASE_URL_PRESET') ) - user_agent_value = user_agent if user_agent is not None else settings.LLM_USER_AGENT - temperature_value = temperature if temperature is not None else settings.LLM_TEMPERATURE + user_agent_value = user_agent if user_agent is not None else get_runtime_setting('LLM_USER_AGENT') + temperature_value = temperature if temperature is not None else get_runtime_setting('LLM_TEMPERATURE') normalized_thinking_level = cls._resolve_thinking_level( thinking_level=thinking_level, ) @@ -1228,12 +1226,12 @@ class LLMHelper: mode=( web_search_mode if web_search_mode is not None - else getattr(settings, "LLM_WEB_SEARCH_MODE", "local") + else get_runtime_setting("LLM_WEB_SEARCH_MODE", "local") ), api_protocol=( api_protocol if api_protocol is not None - else settings.LLM_API_PROTOCOL + else get_runtime_setting('LLM_API_PROTOCOL') ), base_url=runtime.get("base_url"), ) @@ -1345,7 +1343,7 @@ class LLMHelper: credentials=aws_auth, base_url=runtime.get("base_url"), use_proxy=use_proxy, - read_timeout=settings.LLM_TOOL_TIMEOUT, + read_timeout=get_runtime_setting('LLM_TOOL_TIMEOUT'), ) model = bedrock_model_cls( model_id=model_name, @@ -1492,8 +1490,8 @@ class LLMHelper: :param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。 :param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。 """ - provider_name = provider if provider is not None else settings.LLM_PROVIDER - model_name = model if model is not None else settings.LLM_MODEL + provider_name = provider if provider is not None else get_runtime_setting('LLM_PROVIDER') + model_name = model if model is not None else get_runtime_setting('LLM_MODEL') start = time.perf_counter() llm_kwargs = { "streaming": False, diff --git a/app/agent/llm/provider.py b/app/agent/llm/provider.py index b60d3640a..282332ca9 100644 --- a/app/agent/llm/provider.py +++ b/app/agent/llm/provider.py @@ -20,9 +20,8 @@ import aiofiles import httpx import jwt -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.types import LlmProviderAction, SystemConfigKey @@ -268,7 +267,7 @@ class LLMProviderManager(metaclass=Singleton): self._models_dev_data: dict[str, Any] | None = None self._models_dev_loaded_at: float = 0 self._models_dev_cache_path = ( - Path(settings.TEMP_PATH) / "llm_provider_models_dev_cache.json" + Path(get_runtime_setting('TEMP_PATH')) / "llm_provider_models_dev_cache.json" ) def _cleanup_auth_sessions_locked(self, now: Optional[float] = None) -> None: @@ -1498,13 +1497,13 @@ class LLMProviderManager(metaclass=Singleton): def _build_httpx_kwargs(self, use_proxy: Optional[bool] = None) -> dict[str, Any]: """构造用于 httpx 客户端的参数,如代理等。""" - should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy + should_use_proxy = get_runtime_setting('LLM_USE_PROXY') if use_proxy is None else use_proxy kwargs: dict[str, Any] = { "timeout": self._DEFAULT_TIMEOUT, "trust_env": False, } - if should_use_proxy and settings.PROXY_HOST: - kwargs[self._httpx_proxy_key()] = settings.PROXY_HOST + if should_use_proxy and get_runtime_setting('PROXY_HOST'): + kwargs[self._httpx_proxy_key()] = get_runtime_setting('PROXY_HOST') return kwargs @staticmethod @@ -1616,7 +1615,7 @@ class LLMProviderManager(metaclass=Singleton): async def _fetch_models_dev(self, use_proxy: Optional[bool] = None) -> dict[str, Any]: """通过网络请求获取最新 models.dev 数据。""" - headers = {"User-Agent": settings.USER_AGENT} + headers = {"User-Agent": get_runtime_setting('USER_AGENT')} async with httpx.AsyncClient(**self._build_httpx_kwargs(use_proxy)) as client: response = await client.get(self._MODELS_DEV_URL, headers=headers) response.raise_for_status() @@ -2043,10 +2042,10 @@ class LLMProviderManager(metaclass=Singleton): from google import genai from google.genai.types import HttpOptions - should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy + should_use_proxy = get_runtime_setting('LLM_USE_PROXY') if use_proxy is None else use_proxy client_args: dict[str, Any] = {"trust_env": False} - if should_use_proxy and settings.PROXY_HOST: - client_args[self._httpx_proxy_key()] = settings.PROXY_HOST + if should_use_proxy and get_runtime_setting('PROXY_HOST'): + client_args[self._httpx_proxy_key()] = get_runtime_setting('PROXY_HOST') http_options = HttpOptions( client_args=client_args, async_client_args=client_args, @@ -2160,10 +2159,10 @@ class LLMProviderManager(metaclass=Singleton): """ from botocore.config import Config - should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy + should_use_proxy = get_runtime_setting('LLM_USE_PROXY') if use_proxy is None else use_proxy proxies = None - if should_use_proxy and settings.PROXY_HOST: - proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST} + if should_use_proxy and get_runtime_setting('PROXY_HOST'): + proxies = {"http": get_runtime_setting('PROXY_HOST'), "https": get_runtime_setting('PROXY_HOST')} return Config( connect_timeout=10, read_timeout=60, @@ -2388,7 +2387,7 @@ class LLMProviderManager(metaclass=Singleton): 仅补充 Copilot 必需的意图头,避免重复覆盖。 """ headers = { - "User-Agent": settings.USER_AGENT, + "User-Agent": get_runtime_setting('USER_AGENT'), "Openai-Intent": "conversation-edits", "x-initiator": "user", } @@ -2769,7 +2768,7 @@ class LLMProviderManager(metaclass=Singleton): f"{self._CHATGPT_ISSUER}/api/accounts/deviceauth/usercode", headers={ "Content-Type": "application/json", - "User-Agent": settings.USER_AGENT, + "User-Agent": get_runtime_setting('USER_AGENT'), }, json={"client_id": self._CHATGPT_CLIENT_ID}, ) @@ -2806,7 +2805,7 @@ class LLMProviderManager(metaclass=Singleton): headers={ "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": settings.USER_AGENT, + "User-Agent": get_runtime_setting('USER_AGENT'), }, json={ "client_id": self._COPILOT_CLIENT_ID, @@ -3036,11 +3035,11 @@ class LLMProviderManager(metaclass=Singleton): """管理动作:使用传入配置或当前已保存配置执行一次最小 LLM 调用。""" from app.agent.llm.helper import LLMHelper, LLMTestTimeout - provider_name = provider or settings.LLM_PROVIDER - model = params.get("model") if params.get("model") is not None else settings.LLM_MODEL + provider_name = provider or get_runtime_setting('LLM_PROVIDER') + model = params.get("model") if params.get("model") is not None else get_runtime_setting('LLM_MODEL') enabled = params.get("enabled") - enabled = bool(enabled) if enabled is not None else bool(settings.AI_AGENT_ENABLE) - api_key = params.get("api_key") if params.get("api_key") is not None else settings.LLM_API_KEY + enabled = bool(enabled) if enabled is not None else bool(get_runtime_setting('AI_AGENT_ENABLE')) + api_key = params.get("api_key") if params.get("api_key") is not None else get_runtime_setting('LLM_API_KEY') data = {"provider": provider_name, "model": model} if not provider_name: @@ -3151,7 +3150,7 @@ class LLMProviderManager(metaclass=Singleton): f"{self._CHATGPT_ISSUER}/api/accounts/deviceauth/token", headers={ "Content-Type": "application/json", - "User-Agent": settings.USER_AGENT, + "User-Agent": get_runtime_setting('USER_AGENT'), }, json={ "device_auth_id": session.context["device_auth_id"], @@ -3196,7 +3195,7 @@ class LLMProviderManager(metaclass=Singleton): headers={ "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": settings.USER_AGENT, + "User-Agent": get_runtime_setting('USER_AGENT'), }, json={ "client_id": self._COPILOT_CLIENT_ID, diff --git a/app/agent/memory/__init__.py b/app/agent/memory/__init__.py index f72dfc425..1ce19dde3 100644 --- a/app/agent/memory/__init__.py +++ b/app/agent/memory/__init__.py @@ -6,15 +6,13 @@ from typing import Dict, List, Optional from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.application.agentdata import get_agent_chat_port from app.application.messaging.chat import ( get_configured_agent_chat_persistence, get_configured_agent_chat_service, ) from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting from app.schemas.agent import ConversationMemory @@ -228,7 +226,7 @@ class MemoryManager: for cache_key, memory in self.memory_cache.items(): if ( current_time - memory.updated_at - ).days > settings.LLM_MEMORY_RETENTION_DAYS: + ).days > get_runtime_setting('LLM_MEMORY_RETENTION_DAYS'): expired_sessions.append(cache_key) # 只清理内存缓存,不删除Redis中的键(Redis会自动过期) diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index fff264b9c..b2b1ef0b1 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -68,9 +68,8 @@ from app.agent.tools.impl.mcp import ( ) from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool from app.chain.agent import AgentChain -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.events import eventmanager from app.runtime.observability import record_metric from app.application.plugin.runtime import get_plugin_manager @@ -622,7 +621,7 @@ class MoviePilotAgent: def _get_recursion_limit() -> int: """读取 LangGraph 递归上限,防止模型持续循环调用工具。""" try: - limit = int(settings.LLM_MAX_ITERATIONS or 0) + limit = int(get_runtime_setting('LLM_MAX_ITERATIONS') or 0) except (TypeError, ValueError): limit = 0 return limit if limit > 0 else 128 @@ -747,7 +746,7 @@ class MoviePilotAgent: self._session_usage.cache_usage_available |= cache_usage_available provider_type = _agent_provider_metric_type( (self._llm_provider_selection or {}).get("provider") - or settings.LLM_PROVIDER + or get_runtime_setting('LLM_PROVIDER') ) if input_tokens: record_metric( @@ -872,14 +871,14 @@ class MoviePilotAgent: not self._session_usage.model and self._session_usage.last_request_sequence == 0 ): - self._session_usage.model = settings.LLM_MODEL + self._session_usage.model = get_runtime_setting('LLM_MODEL') if ( not self._session_usage.context_window_tokens and self._session_usage.last_request_sequence == 0 ): self._session_usage.context_window_tokens = ( - settings.LLM_MAX_CONTEXT_TOKENS * 1000 - if settings.LLM_MAX_CONTEXT_TOKENS + get_runtime_setting('LLM_MAX_CONTEXT_TOKENS') * 1000 + if get_runtime_setting('LLM_MAX_CONTEXT_TOKENS') else None ) return self._session_usage.to_dict(self.session_id) @@ -899,9 +898,9 @@ class MoviePilotAgent: session_id=self.session_id, selected_provider_id=selection.get("selected_provider_id"), selected_provider_name=selection.get("selected_provider_name"), - provider=selection.get("provider") or settings.LLM_PROVIDER, - base_url=selection.get("base_url") or settings.LLM_BASE_URL, - model=self._session_usage.model or selection.get("model") or settings.LLM_MODEL, + provider=selection.get("provider") or get_runtime_setting('LLM_PROVIDER'), + base_url=selection.get("base_url") or get_runtime_setting('LLM_BASE_URL'), + model=self._session_usage.model or selection.get("model") or get_runtime_setting('LLM_MODEL'), input_tokens=self._session_usage.total_input_tokens, output_tokens=self._session_usage.total_output_tokens, total_tokens=self._session_usage.total_tokens, @@ -1272,7 +1271,7 @@ class MoviePilotAgent: if self.is_background: return False # 啰嗦模式下始终需要流式输出来捕获工具调用前的 Agent 文字 - if settings.AI_AGENT_VERBOSE: + if get_runtime_setting('AI_AGENT_VERBOSE'): return True try: channel_enum = NotificationChannel(self.channel) @@ -1321,16 +1320,16 @@ class MoviePilotAgent: return self._llm_runtime_config event_data = AgentLLMProviderEventData( - provider=settings.LLM_PROVIDER, - model=settings.LLM_MODEL, - api_key=settings.LLM_API_KEY, - base_url=settings.LLM_BASE_URL, - base_url_preset=settings.LLM_BASE_URL_PRESET, - user_agent=settings.LLM_USER_AGENT, - use_proxy=settings.LLM_USE_PROXY, - thinking_level=settings.LLM_THINKING_LEVEL, - api_protocol=settings.LLM_API_PROTOCOL, - web_search_mode=settings.LLM_WEB_SEARCH_MODE, + provider=get_runtime_setting('LLM_PROVIDER'), + model=get_runtime_setting('LLM_MODEL'), + api_key=get_runtime_setting('LLM_API_KEY'), + base_url=get_runtime_setting('LLM_BASE_URL'), + base_url_preset=get_runtime_setting('LLM_BASE_URL_PRESET'), + user_agent=get_runtime_setting('LLM_USER_AGENT'), + use_proxy=get_runtime_setting('LLM_USE_PROXY'), + thinking_level=get_runtime_setting('LLM_THINKING_LEVEL'), + api_protocol=get_runtime_setting('LLM_API_PROTOCOL'), + web_search_mode=get_runtime_setting('LLM_WEB_SEARCH_MODE'), ) selected_event = await eventmanager.async_send_event( ChainEventType.AgentLLMProvider, @@ -1340,43 +1339,43 @@ class MoviePilotAgent: provider = ( self._clean_optional_text(self._get_event_value(resolved_data, "provider")) - or settings.LLM_PROVIDER + or get_runtime_setting('LLM_PROVIDER') ) model = ( self._clean_optional_text(self._get_event_value(resolved_data, "model")) - or settings.LLM_MODEL + or get_runtime_setting('LLM_MODEL') ) api_key = ( self._clean_optional_text(self._get_event_value(resolved_data, "api_key")) - or settings.LLM_API_KEY + or get_runtime_setting('LLM_API_KEY') ) base_url = ( self._clean_optional_text(self._get_event_value(resolved_data, "base_url")) - or settings.LLM_BASE_URL + or get_runtime_setting('LLM_BASE_URL') ) base_url_preset = ( self._clean_optional_text(self._get_event_value(resolved_data, "base_url_preset")) - or settings.LLM_BASE_URL_PRESET + or get_runtime_setting('LLM_BASE_URL_PRESET') ) user_agent = ( self._clean_optional_text(self._get_event_value(resolved_data, "user_agent")) - or settings.LLM_USER_AGENT + or get_runtime_setting('LLM_USER_AGENT') ) use_proxy = self._get_event_value(resolved_data, "use_proxy") if use_proxy is None: - use_proxy = settings.LLM_USE_PROXY + use_proxy = get_runtime_setting('LLM_USE_PROXY') thinking_level = ( self._clean_optional_text( self._get_event_value(resolved_data, "thinking_level") ) - or settings.LLM_THINKING_LEVEL + or get_runtime_setting('LLM_THINKING_LEVEL') ) api_protocol = self._clean_optional_text( self._get_event_value(resolved_data, "api_protocol") - ) or settings.LLM_API_PROTOCOL + ) or get_runtime_setting('LLM_API_PROTOCOL') web_search_mode = self._clean_optional_text( self._get_event_value(resolved_data, "web_search_mode") - ) or settings.LLM_WEB_SEARCH_MODE + ) or get_runtime_setting('LLM_WEB_SEARCH_MODE') selected_provider_id = self._clean_optional_text( self._get_event_value(resolved_data, "selected_provider_id") ) @@ -1519,8 +1518,8 @@ class MoviePilotAgent: 清理执行错误中的密钥和尾部长说明,避免把敏感字段或 SDK 调参文档直接发给用户。 """ sanitized = re.sub(r"\s+", " ", str(message or "")).strip() - if settings.LLM_API_KEY: - sanitized = sanitized.replace(settings.LLM_API_KEY, "***") + if get_runtime_setting('LLM_API_KEY'): + sanitized = sanitized.replace(get_runtime_setting('LLM_API_KEY'), "***") sanitized = re.sub( r"(?i)(api[_-]?key\s*[:=]\s*)([^\s,;]+)", r"\1***", @@ -1713,11 +1712,11 @@ class MoviePilotAgent: bool(self._tool_context.get("is_admin")), self.has_message_context, self.is_background, - settings.AI_AGENT_VERBOSE, - settings.LLM_TEMPERATURE, - settings.LLM_MAX_CONTEXT_TOKENS, - settings.LLM_MAX_TOOLS, - settings.LLM_MAX_ITERATIONS, + get_runtime_setting('AI_AGENT_VERBOSE'), + get_runtime_setting('LLM_TEMPERATURE'), + get_runtime_setting('LLM_MAX_CONTEXT_TOKENS'), + get_runtime_setting('LLM_MAX_TOOLS'), + get_runtime_setting('LLM_MAX_ITERATIONS'), self._public_runtime_config_signature(runtime_config), agent_runtime_manager.current_signature(), agent_mcp_manager.config_signature(), @@ -2004,7 +2003,7 @@ class MoviePilotAgent: ) skills_middleware = SkillsMiddleware( sources=[str(agent_runtime_manager.skills_dir)], - bundled_skills_dir=str(settings.ROOT_PATH / "skills"), + bundled_skills_dir=str(get_runtime_setting('ROOT_PATH') / "skills"), stream_handler=self.stream_handler, ) skill_tools = list(getattr(skills_middleware, "tools", []) or []) @@ -2060,7 +2059,7 @@ class MoviePilotAgent: temporary_subagent_middlewares = () logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}") return cached_agent - max_tools = settings.LLM_MAX_TOOLS + max_tools = get_runtime_setting('LLM_MAX_TOOLS') from app.agent.runtime_loader import get_tool_factory always_include_tools = ( @@ -2558,7 +2557,7 @@ class MoviePilotAgent: "agent.provider.duration", time.perf_counter() - metric_started_at, provider_type=_agent_provider_metric_type( - selection.get("provider") or settings.LLM_PROVIDER + selection.get("provider") or get_runtime_setting('LLM_PROVIDER') ), outcome="success" if execution_success else "error", ) @@ -2588,7 +2587,7 @@ class MoviePilotAgent: source=None if broadcast else self.source, mtype=MessageType.Agent, userid=None if broadcast else self.user_id, - username=self.username or (settings.SUPERUSER if broadcast else None), + username=self.username or (get_runtime_setting('SUPERUSER') if broadcast else None), original_message_id=None if broadcast else self.original_message_id, original_chat_id=None if broadcast else self.original_chat_id, title=title, @@ -2704,10 +2703,10 @@ class AgentManager: status = agent.get_session_status() else: status = _SessionUsageSnapshot( - model=settings.LLM_MODEL, + model=get_runtime_setting('LLM_MODEL'), context_window_tokens=( - settings.LLM_MAX_CONTEXT_TOKENS * 1000 - if settings.LLM_MAX_CONTEXT_TOKENS + get_runtime_setting('LLM_MAX_CONTEXT_TOKENS') * 1000 + if get_runtime_setting('LLM_MAX_CONTEXT_TOKENS') else None ), ).to_dict(session_id) @@ -3515,7 +3514,7 @@ class AgentManager: message=message, channel=None, source=None, - username=settings.SUPERUSER, + username=get_runtime_setting('SUPERUSER'), reply_mode=reply_mode, output_callback=output_callback, allow_message_tools=allow_message_tools, @@ -3538,7 +3537,7 @@ class AgentManager: :param trigger_source: 触发入口,scheduled-自动调度,manual-显式立即执行 :return: 执行是否成功及结果摘要 """ - if not settings.AI_AGENT_ENABLE: + if not get_runtime_setting('AI_AGENT_ENABLE'): return False, "AI Agent 未启用" accepting_before_claim = self._accepting_tasks task_service = get_agent_task_execution_service() @@ -3564,7 +3563,7 @@ class AgentManager: ) success = True result = "" - notification_username = run.username or settings.SUPERUSER + notification_username = run.username or get_runtime_setting('SUPERUSER') try: result = await self.process_message( session_id=run.session_id, @@ -3650,7 +3649,7 @@ class AgentManager: message=heartbeat_message, channel=None, source=None, - username=settings.SUPERUSER, + username=get_runtime_setting('SUPERUSER'), reply_mode=ReplyMode.CAPTURE_ONLY, allow_message_tools=True, ) diff --git a/app/agent/prompt/__init__.py b/app/agent/prompt/__init__.py index aeff7460e..4064301b1 100644 --- a/app/agent/prompt/__init__.py +++ b/app/agent/prompt/__init__.py @@ -10,9 +10,8 @@ from typing import Any, Dict, Optional import yaml from app.agent.llm.capability import AgentCapabilityManager -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.notification import ChannelCapability from app.schemas.notification import ChannelCapabilities @@ -301,9 +300,9 @@ class PromptManager: def _get_runtime_path_lines() -> list[str]: """返回基础系统提示词需要常驻注入的全局运行路径。""" paths = { - "项目根目录": settings.ROOT_PATH, - "配置目录": settings.CONFIG_PATH, - "临时目录": settings.TEMP_PATH, + "项目根目录": get_runtime_setting('ROOT_PATH'), + "配置目录": get_runtime_setting('CONFIG_PATH'), + "临时目录": get_runtime_setting('TEMP_PATH'), } return [f" - {label}: `{path}`" for label, path in paths.items()] diff --git a/app/agent/skills/registry.py b/app/agent/skills/registry.py index 08dd4aba0..3aa8f30b7 100644 --- a/app/agent/skills/registry.py +++ b/app/agent/skills/registry.py @@ -9,15 +9,15 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple from urllib.parse import urlencode, urljoin, urlparse -from app.agent.skills.metadata import parse_skill_metadata -from app.runtime.cache import cached, fresh -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() -from app.runtime.log import logger from app.adapters.network.http import RequestUtils +from app.agent.skills.metadata import parse_skill_metadata +from app.application.configuration import get_runtime_settings from app.foundation.singleton import WeakSingleton from app.foundation.url import UrlUtils +from app.runtime.cache import cached, fresh +from app.runtime.config import Settings +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting _SOURCE_META_FILENAME = ".moviepilot-skill-source.json" _DEFAULT_BRANCHES = ("main", "master") @@ -77,30 +77,30 @@ class SkillHelper(metaclass=WeakSingleton): """ 返回用户技能目录,所有市场安装的技能都落在这里。 """ - return settings.CONFIG_PATH / "agent" / "skills" + return get_runtime_setting('CONFIG_PATH') / "agent" / "skills" @staticmethod def get_bundled_skills_dir() -> Path: """ 返回仓库内置技能目录。 """ - return settings.ROOT_PATH / "skills" + return get_runtime_setting('ROOT_PATH') / "skills" @staticmethod def get_market_sources() -> List[str]: """ 解析配置中的技能市场列表。 """ - if not settings.SKILL_MARKET: + if not get_runtime_setting('SKILL_MARKET'): return [] - return [item.strip() for item in settings.SKILL_MARKET.split(",") if item.strip()] + return [item.strip() for item in get_runtime_setting('SKILL_MARKET').split(",") if item.strip()] @staticmethod def get_default_market_sources() -> List[str]: """ 返回系统默认的技能市场列表,用于区分内置源和用户追加源。 """ - skill_market_field = type(settings).model_fields.get("SKILL_MARKET") + skill_market_field = Settings.model_fields.get("SKILL_MARKET") default_value = skill_market_field.default if skill_market_field else None if not default_value: return [] @@ -199,10 +199,10 @@ class SkillHelper(metaclass=WeakSingleton): @staticmethod def _persist_market_sources(sources: List[str]) -> Tuple[bool, str]: """ - 将技能源列表写回配置文件,并同步更新内存中的 settings。 + 将技能源列表写回配置服务,并让后续读取立即看到新值。 """ filtered_sources = [item.strip() for item in sources if item and item.strip()] - success, message = settings.update_setting( + success, message = get_runtime_settings().update( key="SKILL_MARKET", value=",".join(filtered_sources), ) @@ -1093,8 +1093,8 @@ class SkillHelper(metaclass=WeakSingleton): } strategies = [] - if settings.PROXY_HOST: - strategies.append({"proxies": settings.PROXY, "timeout": timeout}) + if get_runtime_setting('PROXY_HOST'): + strategies.append({"proxies": get_runtime_setting('PROXY'), "timeout": timeout}) strategies.append({"timeout": timeout}) for kwargs in strategies: @@ -1124,8 +1124,8 @@ class SkillHelper(metaclass=WeakSingleton): 请求注册表 API,兼容代理和直连场景。 """ strategies = [] - if settings.PROXY_HOST: - strategies.append(({"proxies": settings.PROXY, "timeout": timeout}, url)) + if get_runtime_setting('PROXY_HOST'): + strategies.append(({"proxies": get_runtime_setting('PROXY'), "timeout": timeout}, url)) strategies.append(({"timeout": timeout}, url)) for kwargs, target_url in strategies: @@ -1152,17 +1152,17 @@ class SkillHelper(metaclass=WeakSingleton): 按代理优先级顺序请求 GitHub 资源,兼容代理和直连场景。 """ strategies = [] - headers = settings.REPO_GITHUB_HEADERS(repo=repo_name) - if not is_api and settings.GITHUB_PROXY: - proxy_url = f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}" + headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=repo_name) + if not is_api and get_runtime_setting('GITHUB_PROXY'): + proxy_url = f"{UrlUtils.standardize_base_url(get_runtime_setting('GITHUB_PROXY'))}{url}" strategies.append((proxy_url, {"headers": headers, "timeout": timeout})) - if settings.PROXY_HOST: + if get_runtime_setting('PROXY_HOST'): strategies.append( ( url, { "headers": headers, - "proxies": settings.PROXY, + "proxies": get_runtime_setting('PROXY'), "timeout": timeout, }, ) diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index 9a59988d2..542b18a30 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -19,9 +19,8 @@ from app.agent.policy.sanitizer import ( ) from app.agent.tools.tags import ToolTag from app.chain import ChainBase -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.messaging.agent import matches_channel_admin from app.application.notification import get_notification_configs from app.runtime.log import logger @@ -301,7 +300,7 @@ class ToolExecutionTimeoutError(TimeoutError): def _get_tool_timeout_seconds() -> Optional[float]: """读取工具执行超时时间,配置为 0 或负数时表示不限制。""" try: - timeout = float(settings.LLM_TOOL_TIMEOUT or 0) + timeout = float(get_runtime_setting('LLM_TOOL_TIMEOUT') or 0) except (TypeError, ValueError): timeout = 0 return timeout if timeout > 0 else None @@ -414,7 +413,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): # 发送工具执行过程消息(流式传输且非最后终结工具时) if self._stream_handler and self._stream_handler.is_streaming and not self.return_direct: - if settings.AI_AGENT_VERBOSE: + if get_runtime_setting('AI_AGENT_VERBOSE'): if self._stream_handler.is_auto_flushing: # 渠道支持编辑:工具消息追加到 buffer,由定时刷新推送 if tool_message: @@ -614,7 +613,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): :return: 普通用户允许读写的本地目录列表 """ roots = [ - settings.CONFIG_PATH / "agent", + get_runtime_setting('CONFIG_PATH') / "agent", ] resolved_roots = [] for root in roots: @@ -758,7 +757,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): "userid": None, "username": message.username or self._username - or settings.SUPERUSER, + or get_runtime_setting('SUPERUSER'), "original_message_id": None, "original_chat_id": None, } diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index 4807890fa..5a358d424 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -9,11 +9,10 @@ from app.adapters.external.market import PluginHelper from app.application.configuration import get_configured_system_config from app.application.plugin.gateway import get_plugin_install_service from app.application.plugin.runtime import get_plugin_manager -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.schemas.plugin import PluginRuntimeStatus from app.schemas.types import SystemConfigKey -settings = RuntimeSettingsCompat() # 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。 DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000 @@ -395,7 +394,7 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: elif was_clone: plugin_manager.delete_plugin_config(plugin_id) plugin_manager.delete_plugin_data(plugin_id) - plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower() + plugin_base_dir = get_runtime_setting('ROOT_PATH') / "app" / "plugins" / plugin_id.lower() try: clone_files_removed = await run_agent_blocking( "plugin", diff --git a/app/agent/tools/impl/_terminal_session.py b/app/agent/tools/impl/_terminal_session.py index 8126af25f..eb3caf9fe 100644 --- a/app/agent/tools/impl/_terminal_session.py +++ b/app/agent/tools/impl/_terminal_session.py @@ -14,10 +14,8 @@ from pathlib import Path from typing import Any, Optional from app.agent.tools.impl._command_safety import validate_command_safety -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting if os.name == "posix": import fcntl as _fcntl @@ -151,10 +149,10 @@ class _TerminalSessionManager: def _normalize_cwd(cwd: Optional[str]) -> str: """解析工作目录,未传入时默认使用 MoviePilot 项目根目录。""" if not cwd: - return str(settings.ROOT_PATH) + return str(get_runtime_setting('ROOT_PATH')) path = Path(cwd).expanduser() if not path.is_absolute(): - path = (settings.ROOT_PATH / path).resolve() + path = (get_runtime_setting('ROOT_PATH') / path).resolve() else: path = path.resolve() if not path.exists(): diff --git a/app/agent/tools/impl/add_download_tasks.py b/app/agent/tools/impl/add_download_tasks.py index 2d1e6d74a..cd4f8ba6b 100644 --- a/app/agent/tools/impl/add_download_tasks.py +++ b/app/agent/tools/impl/add_download_tasks.py @@ -12,9 +12,8 @@ from app.agent.tools.tags import ToolTag from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search import SearchChain -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import Context from app.domain.metainfo import MetaInfo from app.application.agentdata import get_agent_site_port @@ -142,7 +141,7 @@ class AddDownloadTasksTool(MoviePilotTool): @staticmethod def _merge_labels_with_system_tag(labels: Optional[str]) -> Optional[str]: """合并用户标签与系统默认标签,确保任务可被系统管理""" - system_tag = (settings.TORRENT_TAG or "").strip() + system_tag = (get_runtime_setting('TORRENT_TAG') or "").strip() user_labels = [item.strip() for item in (labels or "").split(",") if item.strip()] if system_tag and system_tag not in user_labels: diff --git a/app/agent/tools/impl/create_agent_task.py b/app/agent/tools/impl/create_agent_task.py index 6ffedb077..f60356fa5 100644 --- a/app/agent/tools/impl/create_agent_task.py +++ b/app/agent/tools/impl/create_agent_task.py @@ -7,9 +7,8 @@ from pydantic import BaseModel, Field, model_validator from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.agentdata import get_agent_chat_port from app.application.agentdata import get_agent_task_port from app.runtime.scheduling import TimerUtils @@ -73,7 +72,7 @@ class CreateAgentTaskInput(BaseModel): self.trigger_type, self.trigger = TimerUtils.normalize_schedule_trigger( trigger_type=self.trigger_type, trigger_value=self.trigger, - timezone_name=settings.TZ, + timezone_name=get_runtime_setting('TZ'), require_future=True, ) return self @@ -105,14 +104,14 @@ class CreateAgentTaskTool(MoviePilotTool): trigger_value = payload.trigger if payload.trigger_type == "date" and payload.delay_minutes is not None: - timezone = pytz.timezone(settings.TZ) + timezone = pytz.timezone(get_runtime_setting('TZ')) trigger_value = ( datetime.now(timezone) + timedelta(minutes=payload.delay_minutes) ).isoformat(timespec="seconds") _, trigger_value = TimerUtils.normalize_schedule_trigger( trigger_type=payload.trigger_type, trigger_value=trigger_value, - timezone_name=settings.TZ, + timezone_name=get_runtime_setting('TZ'), require_future=True, ) chat = get_agent_chat_port().get( @@ -136,7 +135,7 @@ class CreateAgentTaskTool(MoviePilotTool): return get_agent_task_port().to_dict( task, next_run_at=next_run_at, - timezone=settings.TZ, + timezone=get_runtime_setting('TZ'), ) async def run( @@ -149,7 +148,7 @@ class CreateAgentTaskTool(MoviePilotTool): **kwargs: object, ) -> str: """创建 Agent 自主定时任务。""" - if not settings.AI_AGENT_ENABLE: + if not get_runtime_setting('AI_AGENT_ENABLE'): return "AI Agent 未启用,无法创建自主定时任务" payload = CreateAgentTaskInput( name=name, diff --git a/app/agent/tools/impl/query_agent_tasks.py b/app/agent/tools/impl/query_agent_tasks.py index 517f93e78..fc2911012 100644 --- a/app/agent/tools/impl/query_agent_tasks.py +++ b/app/agent/tools/impl/query_agent_tasks.py @@ -5,10 +5,8 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.application.agentdata import get_agent_task_port +from app.runtime.settings import get_runtime_setting class QueryAgentTasksInput(BaseModel): @@ -63,7 +61,7 @@ class QueryAgentTasksTool(MoviePilotTool): data = oper.to_dict( task, next_run_at=get_agent_task_next_run(task.id), - timezone=settings.TZ, + timezone=get_runtime_setting('TZ'), ) if task_id: data["recent_runs"] = [ diff --git a/app/agent/tools/impl/recognize_media.py b/app/agent/tools/impl/recognize_media.py index d37a2c7ae..ebc7af300 100644 --- a/app/agent/tools/impl/recognize_media.py +++ b/app/agent/tools/impl/recognize_media.py @@ -9,9 +9,8 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.media import MediaChain -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import Context from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo @@ -104,7 +103,7 @@ class RecognizeMediaTool(MoviePilotTool): }, ensure_ascii=False) is_audio_path = bool( - path and Path(path).suffix.lower() in settings.RMT_AUDIOEXT + path and Path(path).suffix.lower() in get_runtime_setting('RMT_AUDIOEXT') ) recognize_music = media_type_enum == MediaType.MUSIC or ( media_type_enum is None and is_audio_path diff --git a/app/agent/tools/impl/scrape_metadata.py b/app/agent/tools/impl/scrape_metadata.py index 643ceb345..8a611dfbe 100644 --- a/app/agent/tools/impl/scrape_metadata.py +++ b/app/agent/tools/impl/scrape_metadata.py @@ -10,9 +10,8 @@ from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.workflow import FileItem from app.schemas.types import ( @@ -184,7 +183,7 @@ class ScrapeMetadataTool(MoviePilotTool): scraping_chain = ScrapingChain() is_audio_file = ( fileitem.type == "file" - and Path(path).suffix.lower() in settings.RMT_AUDIOEXT + and Path(path).suffix.lower() in get_runtime_setting('RMT_AUDIOEXT') ) scrape_music = media_type_enum == MediaType.MUSIC or ( media_type_enum is None and is_audio_file diff --git a/app/agent/tools/impl/search_web.py b/app/agent/tools/impl/search_web.py index 2a30813fa..14781877f 100644 --- a/app/agent/tools/impl/search_web.py +++ b/app/agent/tools/impl/search_web.py @@ -9,10 +9,8 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting # 搜索超时时间(秒) SEARCH_TIMEOUT = 20 @@ -412,7 +410,7 @@ class SearchWebTool(MoviePilotTool): """在线程中执行同步搜索""" results = [] ddgs_kwargs = {"timeout": SEARCH_TIMEOUT} - proxy_url = self._get_proxy_url(settings.PROXY) + proxy_url = self._get_proxy_url(get_runtime_setting('PROXY')) if proxy_url: ddgs_kwargs["proxy"] = proxy_url diff --git a/app/agent/tools/impl/send_voice_message.py b/app/agent/tools/impl/send_voice_message.py index 982336ac5..ad6a36f7e 100644 --- a/app/agent/tools/impl/send_voice_message.py +++ b/app/agent/tools/impl/send_voice_message.py @@ -6,9 +6,8 @@ from pydantic import BaseModel, Field from app.agent.llm.capability import AgentCapabilityManager from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.message import Message from app.schemas.message import MessageType @@ -96,7 +95,7 @@ class SendVoiceMessageTool(MoviePilotTool): voice_path=voice_path, voice_caption=( message - if voice_path and settings.AUDIO_OUTPUT_INCLUDE_TEXT + if voice_path and get_runtime_setting('AUDIO_OUTPUT_INCLUDE_TEXT') else None ), save_history=False, diff --git a/app/agent/tools/impl/update_agent_task.py b/app/agent/tools/impl/update_agent_task.py index b287f071b..c5be3a81f 100644 --- a/app/agent/tools/impl/update_agent_task.py +++ b/app/agent/tools/impl/update_agent_task.py @@ -7,11 +7,9 @@ from pydantic import BaseModel, Field, model_validator from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.application.agentdata import get_agent_task_port from app.runtime.scheduling import TimerUtils +from app.runtime.settings import get_runtime_setting class UpdateAgentTaskInput(BaseModel): @@ -115,7 +113,7 @@ class UpdateAgentTaskTool(MoviePilotTool): trigger_type = payload.trigger_type or task.trigger_type trigger_value = payload.trigger if trigger_type == "date" and payload.delay_minutes is not None: - timezone = pytz.timezone(settings.TZ) + timezone = pytz.timezone(get_runtime_setting('TZ')) trigger_value = ( datetime.now(timezone) + timedelta(minutes=payload.delay_minutes) ).isoformat(timespec="seconds") @@ -134,7 +132,7 @@ class UpdateAgentTaskTool(MoviePilotTool): normalized_type, normalized_trigger = TimerUtils.normalize_schedule_trigger( trigger_type=trigger_type, trigger_value=trigger_value, - timezone_name=settings.TZ, + timezone_name=get_runtime_setting('TZ'), require_future=bool( trigger_type == "date" and ( @@ -181,7 +179,7 @@ class UpdateAgentTaskTool(MoviePilotTool): return oper.to_dict( updated_task, next_run_at=next_run_at, - timezone=settings.TZ, + timezone=get_runtime_setting('TZ'), ) async def run( diff --git a/app/application/configuration.py b/app/application/configuration.py index a682a3021..fb7dc62b7 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -396,9 +396,6 @@ def configure_runtime_settings(service: RuntimeSettingsService) -> None: """由组合根登记管理 API 使用的部署设置服务。""" global _runtime_settings_service _runtime_settings_service = service - from app.runtime.settings import configure_runtime_settings_compat - - configure_runtime_settings_compat(service) def get_runtime_settings() -> RuntimeSettingsService: diff --git a/app/application/messaging/ingress.py b/app/application/messaging/ingress.py index 7aa145958..aa820887b 100644 --- a/app/application/messaging/ingress.py +++ b/app/application/messaging/ingress.py @@ -6,19 +6,20 @@ from urllib.parse import urlencode from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() +from app.runtime.settings import get_runtime_setting BackgroundSubmitter = Callable[..., object] def build_message_ingress_url(source: str | None) -> str: """按当前运行配置构造安全编码的本地消息入口 URL。""" - query = {"token": settings.API_TOKEN} + query = {"token": get_runtime_setting('API_TOKEN')} if source: query["source"] = source - return f"http://127.0.0.1:{settings.PORT}/api/v1/message?{urlencode(query)}" + return ( + f"http://127.0.0.1:{get_runtime_setting('PORT')}/api/v1/message?" + f"{urlencode(query)}" + ) def forward_message_to_host( diff --git a/app/cli.py b/app/cli.py index 3f1ab593f..f3873d29a 100644 --- a/app/cli.py +++ b/app/cli.py @@ -16,21 +16,20 @@ from urllib.request import Request, urlopen import click import psutil -from app.runtime.config import Settings -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() -from app.runtime.state import SystemHelper from app.application.backup import BackupArtifact -from app.startup.composition.database import build_database_governance +from app.application.configuration import get_runtime_settings +from app.runtime.config import Settings +from app.runtime.settings import get_runtime_setting +from app.runtime.state import SystemHelper from app.runtime.version import get_app_version, get_frontend_version +from app.startup.composition.database import build_database_governance -BACKEND_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.runtime.json" -BACKEND_STDIO_LOG_FILE = settings.LOG_PATH / "moviepilot.stdout.log" -BACKEND_APP_LOG_FILE = settings.LOG_PATH / "moviepilot.log" -FRONTEND_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.frontend.runtime.json" -FRONTEND_STDIO_LOG_FILE = settings.LOG_PATH / "moviepilot.frontend.stdout.log" -FRONTEND_DIR = settings.ROOT_PATH / "public" +BACKEND_RUNTIME_FILE = get_runtime_setting('TEMP_PATH') / "moviepilot.runtime.json" +BACKEND_STDIO_LOG_FILE = get_runtime_setting('LOG_PATH') / "moviepilot.stdout.log" +BACKEND_APP_LOG_FILE = get_runtime_setting('LOG_PATH') / "moviepilot.log" +FRONTEND_RUNTIME_FILE = get_runtime_setting('TEMP_PATH') / "moviepilot.frontend.runtime.json" +FRONTEND_STDIO_LOG_FILE = get_runtime_setting('LOG_PATH') / "moviepilot.frontend.stdout.log" +FRONTEND_DIR = get_runtime_setting('ROOT_PATH') / "public" FRONTEND_SERVICE_FILE = FRONTEND_DIR / "service.js" FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt" HEALTH_PATH = "/api/v1/system/global" @@ -47,13 +46,13 @@ MASKED_FIELDS = { } MASKED_SUFFIXES = ("_TOKEN", "_PASSWORD", "_SECRET", "_API_KEY") CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} -PREPARED_UPDATE_ROOT = settings.TEMP_PATH / "moviepilot-update" +PREPARED_UPDATE_ROOT = get_runtime_setting('TEMP_PATH') / "moviepilot-update" PREPARED_UPDATE_MANIFEST = PREPARED_UPDATE_ROOT / "install.json" PREPARED_UPDATE_STATE = PREPARED_UPDATE_ROOT / "state.json" def _repo_root() -> Path: - return settings.ROOT_PATH + return get_runtime_setting('ROOT_PATH') def _read_json_file(path: Path) -> Optional[Dict[str, Any]]: @@ -115,21 +114,21 @@ def _frontend_runtime() -> Optional[Dict[str, Any]]: def _backend_base_url(runtime: Optional[Dict[str, Any]] = None) -> str: runtime = runtime or _backend_runtime() or {} - host = runtime.get("host") or settings.HOST - port = runtime.get("port") or settings.PORT + host = runtime.get("host") or get_runtime_setting('HOST') + port = runtime.get("port") or get_runtime_setting('PORT') return f"http://{_client_host(host)}:{port}" def _frontend_base_url(runtime: Optional[Dict[str, Any]] = None) -> str: runtime = runtime or _frontend_runtime() or {} - host = runtime.get("host") or settings.HOST - port = runtime.get("port") or settings.NGINX_PORT + host = runtime.get("host") or get_runtime_setting('HOST') + port = runtime.get("port") or get_runtime_setting('NGINX_PORT') return f"http://{_client_host(host)}:{port}" def _runtime_api_token(runtime: Optional[Dict[str, Any]] = None) -> str: runtime = runtime or _backend_runtime() or {} - return runtime.get("api_token") or settings.API_TOKEN + return runtime.get("api_token") or get_runtime_setting('API_TOKEN') def _http_request( @@ -238,7 +237,7 @@ def _git_current_branch() -> Optional[str]: def _auto_update_mode() -> str: if SystemHelper.consume_one_shot_dev_update(): return "dev" - return str(settings.MOVIEPILOT_AUTO_UPDATE or "").strip().lower() + return str(get_runtime_setting('MOVIEPILOT_AUTO_UPDATE') or "").strip().lower() def _file_sha256(path: Path) -> str: @@ -267,18 +266,18 @@ def _local_update_env() -> dict[str, str]: """构造本地更新子进程使用的包缓存、代理和认证环境。""" update_env = os.environ.copy() package_cache_root = Path( - update_env.get("PACKAGE_CACHE_ROOT", "").strip() or settings.PACKAGE_CACHE_PATH + update_env.get("PACKAGE_CACHE_ROOT", "").strip() or get_runtime_setting('PACKAGE_CACHE_PATH') ) update_env.setdefault("PACKAGE_CACHE_ROOT", str(package_cache_root)) update_env.setdefault("UV_CACHE_DIR", str(package_cache_root / "uv")) - if settings.PIP_PROXY: - update_env["PIP_PROXY"] = settings.PIP_PROXY - if settings.PROXY_HOST: - update_env["PROXY_HOST"] = settings.PROXY_HOST + if get_runtime_setting('PIP_PROXY'): + update_env["PIP_PROXY"] = get_runtime_setting('PIP_PROXY') + if get_runtime_setting('PROXY_HOST'): + update_env["PROXY_HOST"] = get_runtime_setting('PROXY_HOST') for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"): - update_env[key] = settings.PROXY_HOST - if settings.GITHUB_TOKEN: - update_env.setdefault("GITHUB_TOKEN", settings.GITHUB_TOKEN) + update_env[key] = get_runtime_setting('PROXY_HOST') + if get_runtime_setting('GITHUB_TOKEN'): + update_env.setdefault("GITHUB_TOKEN", get_runtime_setting('GITHUB_TOKEN')) return update_env @@ -322,7 +321,7 @@ def _apply_prepared_release_update() -> bool: "--venv", str(_repo_root() / "venv"), "--config-dir", - str(settings.CONFIG_PATH), + str(get_runtime_setting('CONFIG_PATH')), ] click.echo(f"安装已下载并校验的 MoviePilot {version} 更新包") result = subprocess.run( @@ -391,7 +390,7 @@ def _best_effort_auto_update() -> None: "--venv", str(_repo_root() / "venv"), "--config-dir", - str(settings.CONFIG_PATH), + str(get_runtime_setting('CONFIG_PATH')), ] click.echo(f"检测到 MOVIEPILOT_AUTO_UPDATE={mode},启动前执行本地自动更新") @@ -647,10 +646,12 @@ def _parse_key_value_pairs(items: Iterable[str]) -> Dict[str, str]: def _ensure_local_api_token() -> bool: - if settings.API_TOKEN and len(str(settings.API_TOKEN).strip()) >= 16: + if get_runtime_setting('API_TOKEN') and len(str(get_runtime_setting('API_TOKEN')).strip()) >= 16: return False - result, message = settings.update_setting("API_TOKEN", settings.API_TOKEN or "") + result, message = get_runtime_settings().update( + "API_TOKEN", get_runtime_setting('API_TOKEN') or "" + ) if result is False: raise click.ClickException(message or "初始化 API_TOKEN 失败") return result is True @@ -691,10 +692,10 @@ def _spawn_backend_process(*, safe: bool = False) -> subprocess.Popen: "MOVIEPILOT_DISABLE_CONSOLE_LOG": "1", "MOVIEPILOT_STDIO_LOG_FILE": str(BACKEND_STDIO_LOG_FILE), "MOVIEPILOT_STDIO_LOG_MAX_BYTES": str( - max(int(settings.LOG_MAX_FILE_SIZE or 0), 1) * 1024 * 1024 + max(int(get_runtime_setting('LOG_MAX_FILE_SIZE') or 0), 1) * 1024 * 1024 ), "MOVIEPILOT_STDIO_LOG_BACKUP_COUNT": str( - max(int(settings.LOG_BACKUP_COUNT or 0), 0) + max(int(get_runtime_setting('LOG_BACKUP_COUNT') or 0), 0) ), } if safe: @@ -741,7 +742,7 @@ def _spawn_frontend_process(backend_port: int) -> subprocess.Popen: env={ **os.environ, "PORT": str(backend_port), - "NGINX_PORT": str(settings.NGINX_PORT), + "NGINX_PORT": str(get_runtime_setting('NGINX_PORT')), }, ) @@ -796,9 +797,9 @@ def _start_backend_service(timeout: int, safe: bool = False) -> Dict[str, Any]: runtime = { "pid": process.pid, "create_time": ps_process.create_time(), - "host": settings.HOST, - "port": settings.PORT, - "api_token": settings.API_TOKEN, + "host": get_runtime_setting('HOST'), + "port": get_runtime_setting('PORT'), + "api_token": get_runtime_setting('API_TOKEN'), "started_at": int(time.time()), "python": sys.executable, "stdio_log": str(BACKEND_STDIO_LOG_FILE), @@ -822,8 +823,8 @@ def _start_frontend_service(timeout: int, backend_port: int) -> Dict[str, Any]: runtime = { "pid": process.pid, "create_time": ps_process.create_time(), - "host": settings.HOST, - "port": settings.NGINX_PORT, + "host": get_runtime_setting('HOST'), + "port": get_runtime_setting('NGINX_PORT'), "backend_port": backend_port, "started_at": int(time.time()), "node": str(_frontend_node_binary()), @@ -1139,8 +1140,9 @@ def config() -> None: @config.command("path", context_settings=CONTEXT_SETTINGS) def config_path() -> None: """显示配置路径""" - click.echo(f"Config Dir: {settings.CONFIG_PATH}") - click.echo(f"Env File: {settings.CONFIG_PATH / 'app.env'}") + config_path = get_runtime_setting('CONFIG_PATH') + click.echo(f"Config Dir: {config_path}") + click.echo(f"Env File: {config_path / 'app.env'}") click.echo(f"Frontend Dir: {FRONTEND_DIR}") @@ -1148,7 +1150,7 @@ def config_path() -> None: @click.option("--show-secrets", is_flag=True, help="显示敏感配置原文") def config_list(show_secrets: bool) -> None: """列出当前配置""" - values = settings.model_dump() + values = get_runtime_settings().snapshot() for key in sorted(values): click.echo(f"{key}={_format_value(_mask_value(key, values[key], show_secrets))}") @@ -1158,9 +1160,9 @@ def config_list(show_secrets: bool) -> None: def config_get(key: str) -> None: """读取单个配置项""" setting_fields = Settings.model_fields.keys() - if key not in setting_fields and not hasattr(settings, key): + if key not in setting_fields and not get_runtime_settings().contains(key): raise click.ClickException(f"配置项不存在:{key}") - click.echo(_format_value(getattr(settings, key))) + click.echo(_format_value(get_runtime_settings().get(key))) @config.command("set", context_settings=CONTEXT_SETTINGS) @@ -1168,7 +1170,7 @@ def config_get(key: str) -> None: @click.argument("value") def config_set(key: str, value: str) -> None: """写入单个配置项""" - result, message = settings.update_setting(key, value) + result, message = get_runtime_settings().update(key, value) if result is False: raise click.ClickException(message or f"配置项更新失败:{key}") if result is None: @@ -1196,7 +1198,7 @@ def config_keys(pattern: Optional[str], show_current: bool, show_secrets: bool) if pattern and pattern.lower() not in key.lower(): continue default_value = _field_default(field) - current_value = getattr(settings, key, default_value) + current_value = get_runtime_settings().get(key, default_value) rows.append( ( key, @@ -1228,12 +1230,12 @@ def config_describe(key: str, show_secrets: bool) -> None: raise click.ClickException(f"配置项不存在:{key}") default_value = _field_default(field) - current_value = getattr(settings, key, default_value) + current_value = get_runtime_settings().get(key, default_value) click.echo(f"Key: {key}") click.echo(f"Type: {_annotation_name(field.annotation)}") click.echo(f"Default: {_format_value(_mask_value(key, default_value, show_secrets))}") click.echo(f"Current: {_format_value(_mask_value(key, current_value, show_secrets))}") - click.echo(f"Env File: {settings.CONFIG_PATH / 'app.env'}") + click.echo(f"Env File: {get_runtime_setting('CONFIG_PATH') / 'app.env'}") @cli.group(context_settings=CONTEXT_SETTINGS) diff --git a/app/db/adapters/site.py b/app/db/adapters/site.py index f695d2425..ca51b4f28 100644 --- a/app/db/adapters/site.py +++ b/app/db/adapters/site.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from contextlib import AbstractAsyncContextManager -from typing import Any, TypeVar +from typing import Any, List, TypeVar from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -12,7 +12,6 @@ from sqlalchemy.orm import Session from app.db.oper.site import SiteOper from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork - T = TypeVar("T") @@ -80,19 +79,19 @@ class TransactionalSiteRepository: """按域名查询站点。""" return self._read(lambda repository: repository.get_by_domain(domain)) - def get_domains_by_ids(self, ids: list[int]) -> list[str | None]: + def get_domains_by_ids(self, ids: List[int]) -> List[str | None]: """查询一组站点 ID 对应的域名。""" return self._read(lambda repository: repository.get_domains_by_ids(ids)) - def list(self) -> list[Any]: + def list(self) -> List[Any]: """查询全部站点。""" return self._read(lambda repository: repository.list()) - def list_order_by_pri(self) -> list[Any]: + def list_order_by_pri(self) -> List[Any]: """同步按优先级查询站点。""" return self._read(lambda repository: repository.list_order_by_pri()) - def get_userdata_latest(self) -> list[Any]: + def get_userdata_latest(self) -> List[Any]: """同步查询各站点最新用户数据。""" return self._read(lambda repository: repository.get_userdata_latest()) @@ -112,11 +111,11 @@ class TransactionalSiteRepository: lambda repository: repository.async_get_by_name(name) ) - async def async_list(self) -> list[Any]: + async def async_list(self) -> List[Any]: """异步查询全部站点。""" return await self._async_read(lambda repository: repository.async_list()) - async def async_list_order_by_pri(self) -> list[Any]: + async def async_list_order_by_pri(self) -> List[Any]: """异步按优先级查询站点。""" return await self._async_read( lambda repository: repository.async_list_order_by_pri() @@ -132,13 +131,13 @@ class TransactionalSiteRepository: self, domain: str, workdate: str | None = None, - ) -> list[Any]: + ) -> List[Any]: """异步查询站点用户数据。""" return await self._async_read( lambda repository: repository.async_get_userdata_by_domain(domain, workdate) ) - async def async_get_userdata_latest(self) -> list[Any]: + async def async_get_userdata_latest(self) -> List[Any]: """异步查询各站点最新用户数据。""" return await self._async_read( lambda repository: repository.async_get_userdata_latest() @@ -156,7 +155,7 @@ class TransactionalSiteRepository: lambda repository: repository.async_get_statistic_by_domain(domain) ) - async def async_list_statistics(self) -> list[Any]: + async def async_list_statistics(self) -> List[Any]: """异步查询全部站点统计。""" return await self._async_read( lambda repository: repository.async_list_statistics() diff --git a/app/db/adapters/subscription.py b/app/db/adapters/subscription.py index 062f3bbda..d4a387141 100644 --- a/app/db/adapters/subscription.py +++ b/app/db/adapters/subscription.py @@ -3,6 +3,7 @@ from collections.abc import Callable from contextlib import AbstractAsyncContextManager from datetime import datetime, timezone +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -41,8 +42,8 @@ class TransactionalSubscribeWriter: def add( self, - identity: dict, - payload: dict, + identity: dict[str, Any], + payload: dict[str, Any], username: str | None = None, after_commit: AfterCommitEffect | None = None, notification: dict[str, object] | None = None, @@ -88,8 +89,8 @@ class TransactionalSubscribeWriter: async def async_add( self, - identity: dict, - payload: dict, + identity: dict[str, Any], + payload: dict[str, Any], username: str | None = None, after_commit: AsyncAfterCommitEffect | None = None, notification: dict[str, object] | None = None, diff --git a/app/db/base.py b/app/db/base.py index 388d16703..947f0c4b9 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import DeclarativeBase, Mapped, Session, declared_attr, mapped_column from app.db.uow import run_async_transaction, run_sync_transaction -from app.runtime.config import settings +from app.runtime.settings import get_runtime_setting T = TypeVar("T") @@ -43,7 +43,7 @@ def get_id_column() -> Mapped[int]: """ 根据数据库类型返回合适的ID列定义 """ - if settings.DB_TYPE.lower() == "postgresql": + if get_runtime_setting('DB_TYPE').lower() == "postgresql": # PostgreSQL使用SERIAL类型,让数据库自动处理序列 return mapped_column(Integer, Identity(start=1, cycle=True), primary_key=True) else: diff --git a/app/db/engine.py b/app/db/engine.py index 23da8416a..15ff13779 100644 --- a/app/db/engine.py +++ b/app/db/engine.py @@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine, create_async_en from sqlalchemy.pool import Pool from app.foundation.environment import is_free_threaded_runtime -from app.runtime.config import settings +from app.runtime.settings import get_runtime_setting from app.db.diagnostics import _register_database_error_logging from app.db.worker import DATABASE_WORKER_MAX_WORKERS from app.runtime.log import logger @@ -22,7 +22,7 @@ from app.runtime.observability import record_metric def _database_backend_label() -> str: """把数据库类型收敛为有限的观测标签。""" - return "postgresql" if settings.DB_TYPE.lower() == "postgresql" else "sqlite" + return "postgresql" if get_runtime_setting('DB_TYPE').lower() == "postgresql" else "sqlite" def _sync_postgresql_driver() -> Optional[str]: @@ -61,9 +61,9 @@ def _async_pool_kwargs(pooled: bool) -> dict: if not pooled: return {"poolclass": NullPool} return { - "pool_size": settings.DB_ASYNC_POOL_SIZE, - "max_overflow": settings.DB_ASYNC_MAX_OVERFLOW, - "pool_timeout": settings.DB_POOL_TIMEOUT, + "pool_size": get_runtime_setting('DB_ASYNC_POOL_SIZE'), + "max_overflow": get_runtime_setting('DB_ASYNC_MAX_OVERFLOW'), + "pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'), } @@ -75,7 +75,7 @@ def _get_database_engine(is_async: bool = False, pooled: bool = False): :return: 返回对应的数据库引擎 """ # 根据数据库类型选择连接方式 - if settings.DB_TYPE.lower() == "postgresql": + if get_runtime_setting('DB_TYPE').lower() == "postgresql": return _get_postgresql_engine(is_async, pooled=pooled) else: return _get_sqlite_engine(is_async, pooled=pooled) @@ -87,35 +87,35 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False): """ # 连接参数 _connect_args = { - "timeout": settings.DB_TIMEOUT, + "timeout": get_runtime_setting('DB_TIMEOUT'), } # 允许部署侧注入驱动级参数(如 PgBouncer 事务模式下的 statement_cache_size) - _connect_args.update(settings.DB_CONNECT_ARGS or {}) + _connect_args.update(get_runtime_setting('DB_CONNECT_ARGS') or {}) # 启用 WAL 模式时的额外配置 - if settings.DB_WAL_ENABLE: + if get_runtime_setting('DB_WAL_ENABLE'): _connect_args["check_same_thread"] = False # 创建同步引擎 if not is_async: # 根据池类型设置 poolclass 和相关参数 - _pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool + _pool_class = NullPool if get_runtime_setting('DB_POOL_TYPE') == "NullPool" else QueuePool # 数据库参数 _db_kwargs = { - "url": settings.DB_SQLITE_URL(), - "pool_pre_ping": settings.DB_POOL_PRE_PING, - "echo": settings.DB_ECHO, + "url": get_runtime_setting('DB_SQLITE_URL')(), + "pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'), + "echo": get_runtime_setting('DB_ECHO'), "poolclass": _pool_class, - "pool_recycle": settings.DB_POOL_RECYCLE, + "pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'), "connect_args": _connect_args } # 当使用 QueuePool 时,添加 QueuePool 特有的参数 if _pool_class == QueuePool: _db_kwargs.update({ - "pool_size": settings.DB_SQLITE_POOL_SIZE, - "pool_timeout": settings.DB_POOL_TIMEOUT, - "max_overflow": settings.DB_SQLITE_MAX_OVERFLOW + "pool_size": get_runtime_setting('DB_SQLITE_POOL_SIZE'), + "pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'), + "max_overflow": get_runtime_setting('DB_SQLITE_MAX_OVERFLOW') }) # 创建数据库引擎 @@ -129,7 +129,7 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False): # 设置一次,而同步引擎的首次创建由 lifespan 数据库准备组件中的 init_db() 完成, # 不存在一群线程 # 等在锁上的场面;即便退化到运行期首次访问,阻塞的也只是本地 SQLite 的一次 PRAGMA。 - _journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE" + _journal_mode = "WAL" if get_runtime_setting('DB_WAL_ENABLE') else "DELETE" with engine.connect() as connection: current_mode = connection.execute(text(f"PRAGMA journal_mode={_journal_mode};")).scalar() print(f"SQLite database journal mode set to: {current_mode}") @@ -138,10 +138,10 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False): else: # 数据库参数,只能使用 NullPool _db_kwargs = { - "url": settings.DB_SQLITE_URL("aiosqlite"), - "pool_pre_ping": settings.DB_POOL_PRE_PING, - "echo": settings.DB_ECHO, - "pool_recycle": settings.DB_POOL_RECYCLE, + "url": get_runtime_setting('DB_SQLITE_URL')("aiosqlite"), + "pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'), + "echo": get_runtime_setting('DB_ECHO'), + "pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'), "connect_args": _connect_args, **_async_pool_kwargs(pooled), } @@ -162,51 +162,51 @@ def _get_postgresql_engine(is_async: bool = False, pooled: bool = False): """ 获取PostgreSQL数据库引擎 """ - db_url = settings.DB_POSTGRESQL_URL(_sync_postgresql_driver()) + db_url = get_runtime_setting('DB_POSTGRESQL_URL')(_sync_postgresql_driver()) # PostgreSQL连接参数。允许部署侧注入驱动级参数, # 例如经 PgBouncer 事务模式接入时 asyncpg 需要 statement_cache_size=0 - _connect_args = dict(settings.DB_CONNECT_ARGS or {}) + _connect_args = dict(get_runtime_setting('DB_CONNECT_ARGS') or {}) # 创建同步引擎 if not is_async: # 根据池类型设置 poolclass 和相关参数 - _pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool + _pool_class = NullPool if get_runtime_setting('DB_POOL_TYPE') == "NullPool" else QueuePool # 数据库参数 _db_kwargs = { "url": db_url, - "pool_pre_ping": settings.DB_POOL_PRE_PING, - "echo": settings.DB_ECHO, + "pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'), + "echo": get_runtime_setting('DB_ECHO'), "poolclass": _pool_class, - "pool_recycle": settings.DB_POOL_RECYCLE, + "pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'), "connect_args": _connect_args } # 当使用 QueuePool 时,添加 QueuePool 特有的参数 if _pool_class == QueuePool: _db_kwargs.update({ - "pool_size": settings.DB_POSTGRESQL_POOL_SIZE, - "pool_timeout": settings.DB_POOL_TIMEOUT, - "max_overflow": settings.DB_POSTGRESQL_MAX_OVERFLOW + "pool_size": get_runtime_setting('DB_POSTGRESQL_POOL_SIZE'), + "pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'), + "max_overflow": get_runtime_setting('DB_POSTGRESQL_MAX_OVERFLOW') }) # 创建数据库引擎 engine = create_engine(**_db_kwargs) _register_database_error_logging(engine) _register_database_pool_metrics(engine) - print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}") + print(f"PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}") return engine else: - async_db_url = settings.DB_POSTGRESQL_URL("asyncpg") + async_db_url = get_runtime_setting('DB_POSTGRESQL_URL')("asyncpg") # 数据库参数,只能使用 NullPool _db_kwargs = { "url": async_db_url, - "pool_pre_ping": settings.DB_POOL_PRE_PING, - "echo": settings.DB_ECHO, - "pool_recycle": settings.DB_POOL_RECYCLE, + "pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'), + "echo": get_runtime_setting('DB_ECHO'), + "pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'), "connect_args": _connect_args, **_async_pool_kwargs(pooled), } @@ -214,7 +214,7 @@ def _get_postgresql_engine(is_async: bool = False, pooled: bool = False): async_engine = create_async_engine(**_db_kwargs) _register_database_error_logging(async_engine.sync_engine) _register_database_pool_metrics(async_engine.sync_engine) - print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}") + print(f"Async PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}") return async_engine @@ -291,7 +291,7 @@ def _async_pool_enabled() -> bool: """ 是否启用异步连接池。设为 NullPool 可回退到池化前的行为。 """ - return str(settings.DB_ASYNC_POOL_TYPE or "").strip().lower() != "nullpool" + return str(get_runtime_setting('DB_ASYNC_POOL_TYPE') or "").strip().lower() != "nullpool" def connection_budget() -> Dict[str, int]: @@ -306,19 +306,19 @@ def connection_budget() -> Dict[str, int]: 就顶穿了 max_connections。 :return: 单进程各项上限、worker 数与合计 """ - if settings.DB_TYPE.lower() == "postgresql": - sync_max = settings.DB_POSTGRESQL_POOL_SIZE + settings.DB_POSTGRESQL_MAX_OVERFLOW + if get_runtime_setting('DB_TYPE').lower() == "postgresql": + sync_max = get_runtime_setting('DB_POSTGRESQL_POOL_SIZE') + get_runtime_setting('DB_POSTGRESQL_MAX_OVERFLOW') else: - sync_max = settings.DB_SQLITE_POOL_SIZE + settings.DB_SQLITE_MAX_OVERFLOW - if settings.DB_POOL_TYPE == "NullPool": + sync_max = get_runtime_setting('DB_SQLITE_POOL_SIZE') + get_runtime_setting('DB_SQLITE_MAX_OVERFLOW') + if get_runtime_setting('DB_POOL_TYPE') == "NullPool": # 未池化连接由通用线程池和专属数据库 worker 共同创建,二者都要计入上限估计。 - sync_max = settings.CONF.threadpool + DATABASE_WORKER_MAX_WORKERS - async_max = (settings.DB_ASYNC_POOL_SIZE + settings.DB_ASYNC_MAX_OVERFLOW + sync_max = get_runtime_setting('CONF').threadpool + DATABASE_WORKER_MAX_WORKERS + async_max = (get_runtime_setting('DB_ASYNC_POOL_SIZE') + get_runtime_setting('DB_ASYNC_MAX_OVERFLOW') if _async_pool_enabled() else 0) - fallback = settings.DB_ASYNC_FALLBACK_LIMIT if _async_pool_enabled() else settings.CONF.scheduler + fallback = get_runtime_setting('DB_ASYNC_FALLBACK_LIMIT') if _async_pool_enabled() else get_runtime_setting('CONF').scheduler per_worker = sync_max + async_max + fallback # worker 数非法时按 1 计:退化成 0 会让合计归零、反而误判「额度充足」 - workers = getattr(settings, "API_WORKERS", 1) or 1 + workers = get_runtime_setting("API_WORKERS", 1) or 1 workers = workers if isinstance(workers, int) and workers > 0 else 1 return { "sync": sync_max, @@ -339,7 +339,7 @@ def check_connection_budget() -> bool: :return: 是否在额度之内 """ budget = connection_budget() - if settings.DB_TYPE.lower() != "postgresql": + if get_runtime_setting('DB_TYPE').lower() != "postgresql": logger.info(f"数据库连接理论峰值: {budget['total']} " f"(单进程 {budget['per_worker']} = 同步 {budget['sync']} + 异步池 " f"{budget['async_pooled']} + 回退 {budget['async_fallback']}" diff --git a/app/db/oper/agenttask.py b/app/db/oper/agenttask.py index 475e21dab..24b2c97f0 100644 --- a/app/db/oper/agenttask.py +++ b/app/db/oper/agenttask.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime -from typing import Optional +from typing import Any, List, Optional, cast from uuid import uuid4 from sqlalchemy import select @@ -65,13 +65,16 @@ class AgentTaskOper(DbOper): """ def query(session: Session) -> Optional[AgentTask]: """在调用方会话中读取单个任务。""" - return session.execute( - _get_for_user_statement( - AgentTask, - task_id=task_id, - user_id=user_id, - ) - ).scalars().first() + return cast( + Optional[AgentTask], + session.execute( + _get_for_user_statement( + AgentTask, + task_id=task_id, + user_id=user_id, + ) + ).scalars().first(), + ) return self._execute_sync_query(query) @@ -90,7 +93,7 @@ class AgentTaskOper(DbOper): user_id=user_id, ) ) - return result.scalars().first() + return cast(Optional[AgentTask], result.scalars().first()) return await self._execute_async_query(query) @@ -98,11 +101,11 @@ class AgentTaskOper(DbOper): self, user_id: Optional[str] = None, enabled: Optional[bool] = None, - ) -> list[AgentTask]: + ) -> List[AgentTask]: """ 查询 Agent 定时任务列表。 """ - def query(session: Session) -> list[AgentTask]: + def query(session: Session) -> List[AgentTask]: """在调用方会话中读取任务列表。""" return list(session.execute( _list_for_user_statement( @@ -117,7 +120,7 @@ class AgentTaskOper(DbOper): def update( self, task_id: int, - payload: dict, + payload: dict[str, Any], user_id: Optional[str] = None, ) -> bool: """ @@ -217,7 +220,7 @@ class AgentTaskOper(DbOper): task_id: int, user_id: Optional[str] = None, limit: int = 10, - ) -> list[AgentTaskRun]: + ) -> List[AgentTaskRun]: """查询任务最近的有界运行历史。""" return self._execute_sync_query( lambda session: AgentTaskRun.list_for_task( @@ -330,7 +333,7 @@ class AgentTaskOper(DbOper): task: AgentTask, next_run_at: Optional[str] = None, timezone: Optional[str] = None, - ) -> dict: + ) -> dict[str, Any]: """ 将 Agent 定时任务转换为工具可返回的结构。 """ @@ -354,7 +357,7 @@ class AgentTaskOper(DbOper): } @staticmethod - def run_to_dict(run: AgentTaskRun) -> dict: + def run_to_dict(run: AgentTaskRun) -> dict[str, Any]: """将一次 Agent 任务运行转换为工具返回结构。""" return { "run_id": run.run_id, diff --git a/app/db/session.py b/app/db/session.py index e94911468..725a39d56 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -23,7 +23,8 @@ import app.db.engine as engine_module from app.db.engine import (_async_pool_enabled, _get_database_engine, _database_backend_label, get_engine, get_global_async_engine) -from app.runtime.config import global_vars, settings +from app.runtime.config import global_vars +from app.runtime.settings import get_runtime_setting from app.runtime.log import logger from app.runtime.observability import record_metric @@ -134,7 +135,7 @@ _pooled_async_engines: Dict[int, Any] = {} _pooled_async_lock = threading.Lock() # 回退路径(未池化的临时循环)共享的全局连接配额。用 threading 信号量而非 # asyncio.Semaphore:后者绑定单个事件循环,无法跨循环生效 -_fallback_slots = threading.BoundedSemaphore(max(1, settings.DB_ASYNC_FALLBACK_LIMIT)) +_fallback_slots = threading.BoundedSemaphore(max(1, get_runtime_setting('DB_ASYNC_FALLBACK_LIMIT'))) def _pooled_loop() -> Optional[Any]: @@ -179,8 +180,8 @@ def _resolve_async_engine() -> Tuple[SaAsyncEngine, bool]: if engine is None: engine = cast(SaAsyncEngine, _get_database_engine(is_async=True, pooled=True)) _pooled_async_engines[key] = engine - logger.info(f"异步数据库连接池已启用: pool_size={settings.DB_ASYNC_POOL_SIZE}, " - f"max_overflow={settings.DB_ASYNC_MAX_OVERFLOW}") + logger.info(f"异步数据库连接池已启用: pool_size={get_runtime_setting('DB_ASYNC_POOL_SIZE')}, " + f"max_overflow={get_runtime_setting('DB_ASYNC_MAX_OVERFLOW')}") return engine, True @@ -201,7 +202,7 @@ async def _acquire_fallback_slot(): 因此用非阻塞获取 + 异步让出。 """ started_at = time.monotonic() - deadline = started_at + settings.DB_POOL_TIMEOUT + deadline = started_at + get_runtime_setting('DB_POOL_TIMEOUT') outcome = "success" try: while not _fallback_slots.acquire(blocking=False): @@ -212,8 +213,8 @@ async def _acquire_fallback_slot(): backend=_database_backend_label(), ) raise TimeoutError( - f"异步数据库连接配额已耗尽(上限 {settings.DB_ASYNC_FALLBACK_LIMIT})," - f"等待超过 {settings.DB_POOL_TIMEOUT} 秒" + f"异步数据库连接配额已耗尽(上限 {get_runtime_setting('DB_ASYNC_FALLBACK_LIMIT')})," + f"等待超过 {get_runtime_setting('DB_POOL_TIMEOUT')} 秒" ) await asyncio.sleep(0.01) finally: diff --git a/app/doctor/checks.py b/app/doctor/checks.py index c3414d84e..396cad2da 100644 --- a/app/doctor/checks.py +++ b/app/doctor/checks.py @@ -20,13 +20,10 @@ import psutil from app.adapters.system.backup.database import verify_database_backup from app.adapters.system.backup.files import BackupFiles -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() -from app.runtime.topology import process_topology_issue -from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorReport, DoctorSeverity from app.adapters.system.host import SystemUtils - +from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorReport, DoctorSeverity +from app.runtime.settings import get_runtime_setting, update_runtime_setting +from app.runtime.topology import process_topology_issue CheckFunc = Callable[["DoctorRunnerProtocol"], None] @@ -72,23 +69,23 @@ SENSITIVE_PATTERNS = ( def _backend_runtime_file() -> Path: - return settings.TEMP_PATH / "moviepilot.runtime.json" + return get_runtime_setting('TEMP_PATH') / "moviepilot.runtime.json" def _frontend_runtime_file() -> Path: - return settings.TEMP_PATH / "moviepilot.frontend.runtime.json" + return get_runtime_setting('TEMP_PATH') / "moviepilot.frontend.runtime.json" def _backend_stdio_log_file() -> Path: - return settings.LOG_PATH / "moviepilot.stdout.log" + return get_runtime_setting('LOG_PATH') / "moviepilot.stdout.log" def _backend_app_log_file() -> Path: - return settings.LOG_PATH / "moviepilot.log" + return get_runtime_setting('LOG_PATH') / "moviepilot.log" def _frontend_stdio_log_file() -> Path: - return settings.LOG_PATH / "moviepilot.frontend.stdout.log" + return get_runtime_setting('LOG_PATH') / "moviepilot.frontend.stdout.log" class DoctorRunnerProtocol: @@ -163,12 +160,12 @@ def _mask_text(text: str) -> str: def _check_process_topology(runner: DoctorRunnerProtocol) -> None: """诊断 API worker 配置是否会复制全功能控制面。""" issue = process_topology_issue( - workers=settings.API_WORKERS, - safe_mode=settings.MOVIEPILOT_SAFE_MODE, + workers=get_runtime_setting('API_WORKERS'), + safe_mode=get_runtime_setting('MOVIEPILOT_SAFE_MODE'), ) context = { - "api_workers": settings.API_WORKERS, - "safe_mode": settings.MOVIEPILOT_SAFE_MODE, + "api_workers": get_runtime_setting('API_WORKERS'), + "safe_mode": get_runtime_setting('MOVIEPILOT_SAFE_MODE'), } if issue: runner.add( @@ -181,7 +178,7 @@ def _check_process_topology(runner: DoctorRunnerProtocol) -> None: context=context, ) return - if settings.API_WORKERS != 1: + if get_runtime_setting('API_WORKERS') != 1: runner.add( finding_id="startup.process_topology", severity=DoctorSeverity.Warn, @@ -319,7 +316,7 @@ def _backend_health_payload(port: int, timeout: float = BACKEND_HEALTH_TIMEOUT) 读取本机后端健康接口响应,用于识别非 CLI 管理的 MoviePilot 进程。 """ query = urlencode({"token": BACKEND_HEALTH_TOKEN}) - url = f"http://{_client_host(settings.HOST)}:{port}{BACKEND_HEALTH_PATH}?{query}" + url = f"http://{_client_host(get_runtime_setting('HOST'))}:{port}{BACKEND_HEALTH_PATH}?{query}" request = Request(url=url, headers={"Accept": "application/json"}, method="GET") try: with urlopen(request, timeout=timeout) as response: @@ -465,13 +462,13 @@ def _partition_error_lines( def _frontend_dir() -> Path: - root_public = settings.ROOT_PATH / "public" - configured = Path(settings.FRONTEND_PATH) + root_public = get_runtime_setting('ROOT_PATH') / "public" + configured = Path(get_runtime_setting('FRONTEND_PATH')) if root_public.exists(): return root_public if configured.is_absolute(): return configured - return settings.ROOT_PATH / configured + return get_runtime_setting('ROOT_PATH') / configured def _unlink_if_requested(runner: DoctorRunnerProtocol, path: Path) -> bool: @@ -491,26 +488,26 @@ def _check_runtime_paths(runner: DoctorRunnerProtocol) -> None: status=DoctorFindingStatus.Ok, title="运行路径已识别", detail=( - f"程序目录:{settings.ROOT_PATH};配置目录:{settings.CONFIG_PATH};" - f"日志目录:{settings.LOG_PATH};Python:{sys.executable}" + f"程序目录:{get_runtime_setting('ROOT_PATH')};配置目录:{get_runtime_setting('CONFIG_PATH')};" + f"日志目录:{get_runtime_setting('LOG_PATH')};Python:{sys.executable}" ), recommendation="如需切换配置目录,请使用 CONFIG_DIR 或本地 CLI 的 --config-dir 参数。", context={ - "root_path": str(settings.ROOT_PATH), - "config_path": str(settings.CONFIG_PATH), - "log_path": str(settings.LOG_PATH), + "root_path": str(get_runtime_setting('ROOT_PATH')), + "config_path": str(get_runtime_setting('CONFIG_PATH')), + "log_path": str(get_runtime_setting('LOG_PATH')), "python": sys.executable, }, ) def _check_config(runner: DoctorRunnerProtocol) -> None: - token = (settings.API_TOKEN or "").strip() + token = (get_runtime_setting('API_TOKEN') or "").strip() if len(token) < 16: fixed = False detail = "API_TOKEN 未设置或长度小于 16 个字符,后端鉴权和本地工具调用可能不可用。" if runner.fix and "API_TOKEN" not in os.environ: - result, message = settings.update_setting("API_TOKEN", token) + result, message = update_runtime_setting("API_TOKEN", token) fixed = result is True if message: detail = f"{detail} {message}" @@ -537,13 +534,13 @@ def _check_config(runner: DoctorRunnerProtocol) -> None: recommendation="无需处理。", ) - if settings.PORT == settings.NGINX_PORT: + if get_runtime_setting('PORT') == get_runtime_setting('NGINX_PORT'): runner.add( finding_id="config.port_same", severity=DoctorSeverity.Error, status=DoctorFindingStatus.Failed, title="前后端端口冲突", - detail=f"PORT 与 NGINX_PORT 都设置为 {settings.PORT}。", + detail=f"PORT 与 NGINX_PORT 都设置为 {get_runtime_setting('PORT')}。", recommendation="将 PORT 或 NGINX_PORT 调整为不同端口后重启服务。", ) else: @@ -552,11 +549,11 @@ def _check_config(runner: DoctorRunnerProtocol) -> None: severity=DoctorSeverity.Info, status=DoctorFindingStatus.Ok, title="前后端端口配置不同", - detail=f"后端端口 PORT={settings.PORT};前端端口 NGINX_PORT={settings.NGINX_PORT}。", + detail=f"后端端口 PORT={get_runtime_setting('PORT')};前端端口 NGINX_PORT={get_runtime_setting('NGINX_PORT')}。", recommendation="无需处理。", ) - proxy_host = (settings.PROXY_HOST or "").strip() + proxy_host = (get_runtime_setting('PROXY_HOST') or "").strip() if proxy_host and not re.match(r"^(https?|socks5h?)://", proxy_host, re.IGNORECASE): runner.add( finding_id="config.proxy_format", @@ -683,16 +680,16 @@ def _check_processes_and_ports(runner: DoctorRunnerProtocol) -> None: runner, name="backend", path=_backend_runtime_file(), - port=int(settings.PORT), + port=int(get_runtime_setting('PORT')), ) frontend_process = _check_runtime_file( runner, name="frontend", path=_frontend_runtime_file(), - port=int(settings.NGINX_PORT), + port=int(get_runtime_setting('NGINX_PORT')), ) - _check_port(runner, name="backend", port=int(settings.PORT), managed_process=backend_process) - _check_port(runner, name="frontend", port=int(settings.NGINX_PORT), managed_process=frontend_process) + _check_port(runner, name="backend", port=int(get_runtime_setting('PORT')), managed_process=backend_process) + _check_port(runner, name="frontend", port=int(get_runtime_setting('NGINX_PORT')), managed_process=frontend_process) def _check_dependencies(runner: DoctorRunnerProtocol) -> None: @@ -719,7 +716,7 @@ def _check_dependencies(runner: DoctorRunnerProtocol) -> None: def _check_sqlite_database(runner: DoctorRunnerProtocol) -> None: - db_file = settings.CONFIG_PATH / "user.db" + db_file = get_runtime_setting('CONFIG_PATH') / "user.db" if not db_file.exists(): runner.add( finding_id="database.sqlite_missing", @@ -775,7 +772,7 @@ def _check_sqlite_database(runner: DoctorRunnerProtocol) -> None: def _check_postgresql_database(runner: DoctorRunnerProtocol) -> None: missing = [] for key in ("DB_POSTGRESQL_HOST", "DB_POSTGRESQL_DATABASE", "DB_POSTGRESQL_USERNAME"): - if not str(getattr(settings, key, "") or "").strip(): + if not str(get_runtime_setting(key, "") or "").strip(): missing.append(key) if missing: runner.add( @@ -800,9 +797,9 @@ def _check_postgresql_database(runner: DoctorRunnerProtocol) -> None: ) return - host = settings.DB_POSTGRESQL_HOST - port = settings.DB_POSTGRESQL_PORT - if settings.DB_POSTGRESQL_SOCKET_MODE or not port: + host = get_runtime_setting('DB_POSTGRESQL_HOST') + port = get_runtime_setting('DB_POSTGRESQL_PORT') + if get_runtime_setting('DB_POSTGRESQL_SOCKET_MODE') or not port: runner.add( finding_id="database.postgresql_deep_skipped", severity=DoctorSeverity.Info, @@ -818,13 +815,13 @@ def _check_postgresql_database(runner: DoctorRunnerProtocol) -> None: severity=DoctorSeverity.Info if ok else DoctorSeverity.Error, status=DoctorFindingStatus.Ok if ok else DoctorFindingStatus.Failed, title="PostgreSQL TCP 端口可连接" if ok else "PostgreSQL TCP 端口不可连接", - detail=f"{settings.DB_POSTGRESQL_TARGET} {detail}".strip(), + detail=f"{get_runtime_setting('DB_POSTGRESQL_TARGET')} {detail}".strip(), recommendation="不可连接时请检查数据库服务、容器网络、端口映射和防火墙。", ) def _check_database(runner: DoctorRunnerProtocol) -> None: - if settings.DB_TYPE.lower() == "postgresql": + if get_runtime_setting('DB_TYPE').lower() == "postgresql": _check_postgresql_database(runner) else: _check_sqlite_database(runner) @@ -833,9 +830,9 @@ def _check_database(runner: DoctorRunnerProtocol) -> None: def _check_database_backups(runner: DoctorRunnerProtocol) -> None: """列举并离线校验与当前数据库类型匹配的受管备份。""" - db_type = "postgresql" if settings.DB_TYPE.lower() == "postgresql" else "sqlite" + db_type = "postgresql" if get_runtime_setting('DB_TYPE').lower() == "postgresql" else "sqlite" try: - paths = BackupFiles(settings.DATABASE_BACKUP_PATH).list() + paths = BackupFiles(get_runtime_setting('DATABASE_BACKUP_PATH')).list() except OSError as error: runner.add( finding_id="database.backup_recovery", @@ -985,7 +982,7 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None: _backend_stdio_log_file(), _frontend_stdio_log_file(), ] - plugin_log_dir = settings.LOG_PATH / "plugins" + plugin_log_dir = get_runtime_setting('LOG_PATH') / "plugins" plugin_logger_names: set[str] = set() if plugin_log_dir.exists(): plugin_log_files = sorted(plugin_log_dir.rglob("*.log")) @@ -1021,7 +1018,7 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None: severity=DoctorSeverity.Warn, status=DoctorFindingStatus.Degraded, title="未找到运行日志", - detail=f"{settings.LOG_PATH} 下没有可读取的 MoviePilot 日志。", + detail=f"{get_runtime_setting('LOG_PATH')} 下没有可读取的 MoviePilot 日志。", recommendation="如果服务尚未启动过可忽略;否则请确认 CONFIG_DIR 和日志目录权限。", ) return @@ -1067,7 +1064,7 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None: status=DoctorFindingStatus.Ok, title="最近日志未发现明显错误关键词", detail=( - f"已扫描 {settings.LOG_PATH} 下最近 {LOG_LOOKBACK_HOURS} 小时的主日志、" + f"已扫描 {get_runtime_setting('LOG_PATH')} 下最近 {LOG_LOOKBACK_HOURS} 小时的主日志、" "启动日志和插件日志;插件扩展告警不参与核心健康状态。" ), recommendation="如果问题仍存在,请结合具体操作时间扩大日志范围排查。", @@ -1113,7 +1110,7 @@ def _check_docker(runner: DoctorRunnerProtocol) -> None: status=DoctorFindingStatus.Ok, title="Docker 诊断入口可用", detail=( - f"CONFIG_DIR={settings.CONFIG_PATH};VENV_PATH={os.getenv('VENV_PATH', '/opt/venv')};" + f"CONFIG_DIR={get_runtime_setting('CONFIG_PATH')};VENV_PATH={os.getenv('VENV_PATH', '/opt/venv')};" f"MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE={os.getenv('MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE', 'true')}" ), recommendation="主进程异常退出后容器会保活,仍可通过 `docker exec moviepilot doctor` 诊断。", @@ -1121,7 +1118,7 @@ def _check_docker(runner: DoctorRunnerProtocol) -> None: def _check_safe_mode(runner: DoctorRunnerProtocol) -> None: - if settings.MOVIEPILOT_SAFE_MODE: + if get_runtime_setting('MOVIEPILOT_SAFE_MODE'): runner.add( finding_id="startup.safe_mode", severity=DoctorSeverity.Warn, diff --git a/app/doctor/runner.py b/app/doctor/runner.py index b73bc4ab9..59a53b67f 100644 --- a/app/doctor/runner.py +++ b/app/doctor/runner.py @@ -6,9 +6,8 @@ import sys from datetime import datetime from typing import Any, Optional -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.doctor.checks import default_checks from app.doctor.models import ( DoctorFinding, @@ -110,12 +109,12 @@ class DoctorRunner: "platform": platform.platform(), "python": sys.executable, "python_version": platform.python_version(), - "root_path": str(settings.ROOT_PATH), - "config_path": str(settings.CONFIG_PATH), - "log_path": str(settings.LOG_PATH), - "temp_path": str(settings.TEMP_PATH), + "root_path": str(get_runtime_setting('ROOT_PATH')), + "config_path": str(get_runtime_setting('CONFIG_PATH')), + "log_path": str(get_runtime_setting('LOG_PATH')), + "temp_path": str(get_runtime_setting('TEMP_PATH')), "is_docker": SystemUtils.is_docker(), - "safe_mode": settings.MOVIEPILOT_SAFE_MODE, + "safe_mode": get_runtime_setting('MOVIEPILOT_SAFE_MODE'), "pid": os.getpid(), } diff --git a/app/factory.py b/app/factory.py index 4938fa6e6..8442f7e10 100644 --- a/app/factory.py +++ b/app/factory.py @@ -7,41 +7,40 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException -from app.api.response import ResponseAPIRoute -from app.adapters.web.correlation import CorrelationIdMiddleware -from app.adapters.web.metrics import HttpMetricsMiddleware from app.adapters.observability.otel import build_observation_port -from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry +from app.adapters.web.correlation import CorrelationIdMiddleware from app.adapters.web.health import install_health_routes -from app.application.plugin.routes import configure_plugin_routes -from app.application.plugin.runtime import get_plugin_manager -from app.schemas.exception import ( - PersistenceUnavailableError, -) +from app.adapters.web.metrics import HttpMetricsMiddleware +from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry from app.adapters.web.security.access import ( configure_token_codec, verify_apikey, verify_token, ) +from app.api.response import ResponseAPIRoute +from app.application.plugin.routes import configure_plugin_routes +from app.application.plugin.runtime import get_plugin_manager from app.application.security.token import create_access_token, decode_access_token from app.runtime.config import global_vars -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.runtime.correlation import get_correlation_id from app.runtime.localization import LocaleHelper from app.runtime.log import configure_correlation_id_provider, logger from app.runtime.observability import configure_observation +from app.runtime.settings import get_runtime_setting +from app.runtime.version import get_app_version +from app.schemas.exception import ( + PersistenceUnavailableError, +) +from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail from app.schemas.openai import ( AnthropicErrorDetail, AnthropicErrorResponse, OpenAIErrorDetail, OpenAIErrorResponse, ) -from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail -from app.schemas.response import Response as ApiResponse, ValidationIssue +from app.schemas.response import Response as ApiResponse +from app.schemas.response import ValidationIssue from app.startup.lifecycle import lifespan -from app.runtime.version import get_app_version def _get_http_exception_message(detail: Any) -> str: @@ -67,15 +66,15 @@ def _localize_exception_message(request: Request, message: str) -> str: def _is_mcp_jsonrpc_request(request: Request) -> bool: """判断请求是否指向保持原生响应的 MCP JSON-RPC 根端点。""" request_path = getattr(getattr(request, "url", None), "path", "") - return request_path.rstrip("/") == f"{settings.API_V1_STR}/mcp" + return request_path.rstrip("/") == f"{get_runtime_setting('API_V1_STR')}/mcp" def _get_native_ai_protocol(request: Request) -> str | None: """识别需要保持原生错误体的 OpenAI 或 Anthropic 兼容请求。""" request_path = getattr(getattr(request, "url", None), "path", "") - if request_path.startswith(f"{settings.API_V1_STR}/openai/v1/"): + if request_path.startswith(f"{get_runtime_setting('API_V1_STR')}/openai/v1/"): return "openai" - if request_path.startswith(f"{settings.API_V1_STR}/anthropic/v1/"): + if request_path.startswith(f"{get_runtime_setting('API_V1_STR')}/anthropic/v1/"): return "anthropic" return None @@ -327,9 +326,9 @@ def create_app() -> FastAPI: configure_correlation_id_provider(get_correlation_id) configure_observation(build_observation_port()) _app = FastAPI( - title=settings.PROJECT_NAME, + title=get_runtime_setting('PROJECT_NAME'), version=get_app_version(), - openapi_url=f"{settings.API_V1_STR}/openapi.json", + openapi_url=f"{get_runtime_setting('API_V1_STR')}/openapi.json", lifespan=lifespan ) @@ -351,7 +350,7 @@ def create_app() -> FastAPI: # 配置 CORS 中间件 _app.add_middleware( CORSMiddleware, # noqa - allow_origins=settings.ALLOWED_HOSTS, + allow_origins=get_runtime_setting('ALLOWED_HOSTS'), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -386,9 +385,9 @@ def create_app() -> FastAPI: plugin_apis=lambda plugin_id: get_plugin_manager().get_plugin_apis(plugin_id), verify_token=verify_token, verify_apikey=verify_apikey, - prefix=f"{settings.API_V1_STR}/plugin", + prefix=f"{get_runtime_setting('API_V1_STR')}/plugin", protected_routes={ - f"{settings.API_V1_STR}/openapi.json", + f"{get_runtime_setting('API_V1_STR')}/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc", diff --git a/app/main.py b/app/main.py index 4b0990f82..b87e88454 100644 --- a/app/main.py +++ b/app/main.py @@ -55,17 +55,16 @@ elif SystemUtils.is_frozen(): sys.stderr = open(os.devnull, 'w') from app.factory import app -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.runtime.config import global_vars from app.runtime.stop import runtime_stop_state -settings = RuntimeSettingsCompat() from app.runtime.topology import ( UnsupportedProcessTopologyError, validate_process_topology, ) -setproctitle.setproctitle(settings.PROJECT_NAME) +setproctitle.setproctitle(get_runtime_setting('PROJECT_NAME')) class MoviePilotServer(uvicorn.Server): @@ -85,8 +84,8 @@ def create_server() -> MoviePilotServer: server = MoviePilotServer( Config( app, - host=settings.HOST, - port=settings.PORT, + host=get_runtime_setting('HOST'), + port=get_runtime_setting('PORT'), reload=False, workers=1, timeout_graceful_shutdown=60, @@ -101,9 +100,9 @@ def create_server() -> MoviePilotServer: def run_api_server() -> None: """按开发 reload、安全模式多进程或生产单进程选择 Uvicorn 入口。""" global Server - supervised = settings.DEV or settings.API_WORKERS > 1 + supervised = get_runtime_setting('DEV') or get_runtime_setting('API_WORKERS') > 1 if supervised: - if settings.DEV and settings.API_WORKERS > 1: + if get_runtime_setting('DEV') and get_runtime_setting('API_WORKERS') > 1: raise UnsupportedProcessTopologyError( "Uvicorn reload 与多 worker 不能同时启用;" "开发模式请设置 API_WORKERS=1。" @@ -112,10 +111,10 @@ def run_api_server() -> None: uvicorn.run( APP_FACTORY, factory=True, - host=settings.HOST, - port=settings.PORT, - reload=settings.DEV, - workers=settings.API_WORKERS, + host=get_runtime_setting('HOST'), + port=get_runtime_setting('PORT'), + reload=get_runtime_setting('DEV'), + workers=get_runtime_setting('API_WORKERS'), timeout_graceful_shutdown=60, ) return @@ -146,7 +145,7 @@ def start_tray(): 调用浏览器打开前端页面 """ import webbrowser - webbrowser.open(f"http://localhost:{settings.NGINX_PORT}") + webbrowser.open(f"http://localhost:{get_runtime_setting('NGINX_PORT')}") def quit_app(): """ @@ -158,8 +157,8 @@ def start_tray(): import pystray TrayIcon = pystray.Icon( - settings.PROJECT_NAME, - icon=Image.open(settings.ROOT_PATH / 'app.ico'), + get_runtime_setting('PROJECT_NAME'), + icon=Image.open(get_runtime_setting('ROOT_PATH') / 'app.ico'), menu=pystray.Menu( pystray.MenuItem( '打开', @@ -185,8 +184,8 @@ def signal_handler(signum, frame): def run_application() -> None: """初始化进程并启动 API 服务""" validate_process_topology( - workers=settings.API_WORKERS, - safe_mode=settings.MOVIEPILOT_SAFE_MODE, + workers=get_runtime_setting('API_WORKERS'), + safe_mode=get_runtime_setting('MOVIEPILOT_SAFE_MODE'), ) signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) diff --git a/app/modules/acoustid/__init__.py b/app/modules/acoustid/__init__.py index 8dd031c83..607b44985 100644 --- a/app/modules/acoustid/__init__.py +++ b/app/modules/acoustid/__init__.py @@ -10,9 +10,8 @@ from typing import Any, Optional, Tuple, Union from uuid import UUID from app.runtime.execution import run_in_threadpool -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.modules import _ModuleBase from app.schemas.types import ModuleType, OtherModulesType @@ -71,19 +70,19 @@ class AcoustIdModule(_ModuleBase): 模块初始化早于 fpcalc 安装,或运行期依赖被移除,测试也能如实反映本地 依赖状态,而不是只校验网络连通性。 """ - if not str(settings.ACOUSTID_API_KEY or "").strip(): + if not str(get_runtime_setting('ACOUSTID_API_KEY') or "").strip(): return False, "AcoustID API Key 未配置" fpcalc_path = self._resolve_fpcalc() if not fpcalc_path: return False, "未找到 fpcalc,请先安装 Chromaprint" self._fpcalc_path = fpcalc_path response = RequestUtils( - ua=settings.USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('USER_AGENT'), + proxies=get_runtime_setting('PROXY'), timeout=15, ).get_res( url=self._base_url, - params={"client": settings.ACOUSTID_API_KEY, "format": "json"}, + params={"client": get_runtime_setting('ACOUSTID_API_KEY'), "format": "json"}, ) if response is None: return False, "AcoustID 网络连接失败" @@ -298,13 +297,13 @@ class AcoustIdModule(_ModuleBase): fingerprint: str, ) -> Optional[str]: """查询 AcoustID 指纹库并提取 MusicBrainz Recording ID。""" - api_key = str(settings.ACOUSTID_API_KEY or "").strip() + api_key = str(get_runtime_setting('ACOUSTID_API_KEY') or "").strip() if not api_key: return None self._wait_for_rate_limit() response = RequestUtils( - ua=settings.USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('USER_AGENT'), + proxies=get_runtime_setting('PROXY'), timeout=30, ).post_res( url=self._base_url, @@ -337,13 +336,13 @@ class AcoustIdModule(_ModuleBase): fingerprint: str, ) -> Optional[str]: """异步查询 AcoustID 指纹库并提取 MusicBrainz Recording ID。""" - api_key = str(settings.ACOUSTID_API_KEY or "").strip() + api_key = str(get_runtime_setting('ACOUSTID_API_KEY') or "").strip() if not api_key: return None await self._async_wait_for_rate_limit() response = await AsyncRequestUtils( - ua=settings.USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('USER_AGENT'), + proxies=get_runtime_setting('PROXY'), timeout=30, ).post_res( url=self._base_url, diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py index 4258dce2e..c281eab7d 100644 --- a/app/modules/anilist/__init__.py +++ b/app/modules/anilist/__init__.py @@ -77,7 +77,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param media_source: 请求级识别数据源 :return: 是否启用 AniList 识别 """ - return (media_source or get_runtime_setting("RECOGNIZE_SOURCE")) == MediaSource.AniList + return (media_source or get_runtime_setting('RECOGNIZE_SOURCE')) == MediaSource.AniList @staticmethod def _media_type(info: dict) -> MediaType: @@ -570,7 +570,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param episode: 集号 :return: NFO XML 文本 """ - scrape_source = mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE") + scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE') if scrape_source != "anilist": return None return self.scraper.get_metadata_nfo(mediainfo, season=season, episode=episode) @@ -589,7 +589,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param episode: 集号 :return: 图片文件名与下载地址映射 """ - scrape_source = mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE") + scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE') if scrape_source != "anilist": return None return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode) diff --git a/app/modules/anilist/anilist.py b/app/modules/anilist/anilist.py index f4d2195c8..947ecfe96 100644 --- a/app/modules/anilist/anilist.py +++ b/app/modules/anilist/anilist.py @@ -2,9 +2,8 @@ from datetime import date from typing import Optional from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.adapters.network.http import AsyncRequestUtils, RequestUtils @@ -103,16 +102,16 @@ class AniListApi: def __init__(self) -> None: """初始化同步与异步请求客户端""" headers = { - "User-Agent": settings.NORMAL_USER_AGENT, + "User-Agent": get_runtime_setting('NORMAL_USER_AGENT'), "Accept": "application/json", "Content-Type": "application/json", } self._request = RequestUtils( - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), headers=headers, ) self._async_request = AsyncRequestUtils( - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), headers=headers, ) self._proxy_available = True @@ -363,8 +362,8 @@ class AniListApi: return seasons[(current.month - 1) // 3], current.year @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="detail", ) @@ -380,8 +379,8 @@ class AniListApi: return result.get("Media") if result else None @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="detail", ) @@ -397,8 +396,8 @@ class AniListApi: return result.get("Media") if result else None @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="search", ) @@ -421,8 +420,8 @@ class AniListApi: return self._page_medias(result) @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="search", ) @@ -445,8 +444,8 @@ class AniListApi: return self._page_medias(result) @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="discover", ) @@ -483,8 +482,8 @@ class AniListApi: return self._page_medias(self._invoke(self._page_query, variables)) @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="discover", ) @@ -576,8 +575,8 @@ class AniListApi: ) @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="credits", ) @@ -606,8 +605,8 @@ class AniListApi: return result.get("Media", {}).get("characters", {}).get("edges") or [] if result else [] @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="credits", ) @@ -636,8 +635,8 @@ class AniListApi: return result.get("Media", {}).get("characters", {}).get("edges") or [] if result else [] @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="recommendations", ) @@ -662,8 +661,8 @@ class AniListApi: return self._medias_by_ids(media_ids) @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="recommendations", ) @@ -688,8 +687,8 @@ class AniListApi: return await self._async_medias_by_ids(media_ids) @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="person_detail", ) @@ -713,8 +712,8 @@ class AniListApi: return result.get("Staff") if result else None @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="person_detail", ) @@ -738,8 +737,8 @@ class AniListApi: return result.get("Staff") if result else None @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="person_credits", ) @@ -763,8 +762,8 @@ class AniListApi: return self._medias_by_ids([node.get("id") for node in nodes]) @cached( - maxsize=settings.CONF.anilist, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').anilist, + ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key="person_credits", ) diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index 2d35eb1dd..9c0570758 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -44,7 +44,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ 初始化Bangumi客户端 """ - self._config = BangumiConfigSnapshot(proxy=get_runtime_setting("PROXY")) + self._config = BangumiConfigSnapshot(proxy=get_runtime_setting('PROXY')) self.bangumiapi = BangumiApi() self.scraper = MediaScraperHelper() @@ -126,7 +126,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None bangumiid = int(media_id) if media_id is not None else None if not bangumiid and ( - not meta or (media_source or get_runtime_setting("RECOGNIZE_SOURCE")) != MediaSource.Bangumi + not meta or (media_source or get_runtime_setting('RECOGNIZE_SOURCE')) != MediaSource.Bangumi ): return None @@ -175,7 +175,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None bangumiid = int(media_id) if media_id is not None else None if not bangumiid and ( - not meta or (media_source or get_runtime_setting("RECOGNIZE_SOURCE")) != MediaSource.Bangumi + not meta or (media_source or get_runtime_setting('RECOGNIZE_SOURCE')) != MediaSource.Bangumi ): return None @@ -316,7 +316,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param episode: 集号 :return: NFO XML文本 """ - scrape_source = mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE") + scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE') if scrape_source != "bangumi": return None return self.scraper.get_metadata_nfo(mediainfo, season=season, episode=episode) @@ -335,7 +335,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param episode: 集号 :return: 图片文件名与下载地址映射 """ - scrape_source = mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE") + scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE') if scrape_source != "bangumi": return None return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode) diff --git a/app/modules/bangumi/bangumi.py b/app/modules/bangumi/bangumi.py index 8e2511f9a..3651511e1 100644 --- a/app/modules/bangumi/bangumi.py +++ b/app/modules/bangumi/bangumi.py @@ -4,9 +4,8 @@ from typing import Optional import requests from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.adapters.network.http import RequestUtils, AsyncRequestUtils @@ -33,16 +32,16 @@ class BangumiApi(object): def __init__(self): self._session = requests.Session() self._req = RequestUtils( - ua=settings.NORMAL_USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('NORMAL_USER_AGENT'), + proxies=get_runtime_setting('PROXY'), session=self._session, ) self._async_req = AsyncRequestUtils( - ua=settings.NORMAL_USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('NORMAL_USER_AGENT'), + proxies=get_runtime_setting('PROXY'), ) - @cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta, shared_key="get") + @cached(maxsize=get_runtime_setting('CONF').bangumi, ttl=get_runtime_setting('CONF').meta, shared_key="get") def __invoke(self, url, key: Optional[str] = None, **kwargs): req_url = self._base_url + url params = {} @@ -58,7 +57,7 @@ class BangumiApi(object): print(e) return None - @cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta, shared_key="get") + @cached(maxsize=get_runtime_setting('CONF').bangumi, ttl=get_runtime_setting('CONF').meta, shared_key="get") async def __async_invoke(self, url, key: Optional[str] = None, **kwargs): req_url = self._base_url + url params = {} diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index 47f4190c2..8222b2fdd 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -8,9 +8,8 @@ import discord from discord import app_commands from app.runtime.execution import run_in_threadpool -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.messaging.ingress import async_forward_message_to_host from app.domain.context import MediaInfo, Context from app.domain.metainfo import MetaInfo @@ -72,7 +71,7 @@ class Discord: intents.guilds = True self._client: Optional[discord.Client] = discord.Client( - intents=intents, proxy=settings.PROXY_HOST + intents=intents, proxy=get_runtime_setting('PROXY_HOST') ) self._tree: Optional[app_commands.CommandTree] = app_commands.CommandTree(self._client) self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 0243dcd3c..a62db8e66 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -706,7 +706,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if ( meta and not doubanid - and (kwargs.get("media_source") or get_runtime_setting("RECOGNIZE_SOURCE")) != "douban" + and (kwargs.get("media_source") or get_runtime_setting('RECOGNIZE_SOURCE')) != "douban" ): return None @@ -777,7 +777,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if ( meta and not doubanid - and (kwargs.get("media_source") or get_runtime_setting("RECOGNIZE_SOURCE")) != "douban" + and (kwargs.get("media_source") or get_runtime_setting('RECOGNIZE_SOURCE')) != "douban" ): return None @@ -1684,7 +1684,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param mediainfo: 媒体信息 :param season: 季号 """ - if (mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE")) != "douban": + if (mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')) != "douban": return None return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season) @@ -1695,7 +1695,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param season: 季号 :param episode: 集号 """ - if (mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE")) != "douban": + if (mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')) != "douban": return None return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode) @@ -1706,7 +1706,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param mediainfo: 媒体信息 :return: None 表示不处理,MediaInfo 表示继续处理 """ - if mediainfo.media_source != MediaSource.Douban and get_runtime_setting("RECOGNIZE_SOURCE") != "douban": + if mediainfo.media_source != MediaSource.Douban and get_runtime_setting('RECOGNIZE_SOURCE') != "douban": return None if not mediainfo.douban_id: return None diff --git a/app/modules/douban/apiv2.py b/app/modules/douban/apiv2.py index 36c8a64c8..b9cb2c9c2 100644 --- a/app/modules/douban/apiv2.py +++ b/app/modules/douban/apiv2.py @@ -13,9 +13,8 @@ import requests from bs4 import BeautifulSoup from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.foundation.singleton import WeakSingleton @@ -233,7 +232,7 @@ class DoubanApi(metaclass=WeakSingleton): """ return resp.json() if resp is not None else None - @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key="get") + @cached(maxsize=get_runtime_setting('CONF').douban, ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="get") def __invoke(self, url: str, **kwargs) -> dict: """ GET请求 @@ -245,7 +244,7 @@ class DoubanApi(metaclass=WeakSingleton): ).get_res(url=req_url, params=params) return self._handle_response(resp) - @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key="get") + @cached(maxsize=get_runtime_setting('CONF').douban, ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="get") async def __async_invoke(self, url: str, **kwargs) -> dict: """ GET请求(异步版本) @@ -268,7 +267,7 @@ class DoubanApi(metaclass=WeakSingleton): params.pop('_ts') return req_url, params - @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key="post") + @cached(maxsize=get_runtime_setting('CONF').douban, ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="post") def __post(self, url: str, **kwargs) -> dict: """ POST请求 @@ -285,19 +284,19 @@ class DoubanApi(metaclass=WeakSingleton): """ req_url, params = self._prepare_post_request(url, **kwargs) resp = RequestUtils( - ua=settings.NORMAL_USER_AGENT, + ua=get_runtime_setting('NORMAL_USER_AGENT'), session=self._session, ).post_res(url=req_url, data=params) return self._handle_response(resp) - @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key="post") + @cached(maxsize=get_runtime_setting('CONF').douban, ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="post") async def __async_post(self, url: str, **kwargs) -> dict: """ POST请求(异步版本) """ req_url, params = self._prepare_post_request(url, **kwargs) resp = await AsyncRequestUtils( - ua=settings.NORMAL_USER_AGENT + ua=get_runtime_setting('NORMAL_USER_AGENT') ).post_res(url=req_url, data=params) return self._handle_response(resp) @@ -644,7 +643,7 @@ class DoubanApi(metaclass=WeakSingleton): self._urls["music_single"], start=start, count=count ) - @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True) + @cached(maxsize=get_runtime_setting('CONF').douban, ttl=get_runtime_setting('CONF').meta, skip_none=True) def music_tag( self, tag: str, @@ -665,8 +664,8 @@ class DoubanApi(metaclass=WeakSingleton): while len(items) < required: url = f"{self._music_web_url}/tag/{parse.quote(normalized_tag, safe='')}" response = RequestUtils( - ua=settings.NORMAL_USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('NORMAL_USER_AGENT'), + proxies=get_runtime_setting('PROXY'), timeout=20, accept_type="text/html,application/xhtml+xml", ).get_res(url=url, params={"start": page * page_size, "type": sort}) @@ -679,12 +678,12 @@ class DoubanApi(metaclass=WeakSingleton): page += 1 return {"items": items[first_offset:first_offset + max(count, 1)]} - @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True) + @cached(maxsize=get_runtime_setting('CONF').douban, ttl=get_runtime_setting('CONF').meta, skip_none=True) def music_chart(self) -> dict: """从豆瓣音乐官方榜单页读取新碟榜,并补充专辑详情供卡片展示。""" response = RequestUtils( - ua=settings.NORMAL_USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('NORMAL_USER_AGENT'), + proxies=get_runtime_setting('PROXY'), timeout=20, accept_type="text/html,application/xhtml+xml", ).get_res(url=f"{self._music_web_url}/chart") diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index 2e7ff7bcc..4b8a3ab34 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -12,9 +12,8 @@ from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibr from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem from app.schemas.mediaserver import RefreshMediaItem as _SchemaRefreshMediaItem from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.mediaserver import MediaServerIdentityHelper, format_emby_family_item from app.runtime.log import logger from app.schemas.mediaserver import MediaServerItem @@ -44,7 +43,7 @@ class Emby: self._playhost = UrlUtils.standardize_base_url(self._playhost) self._apikey = apikey self._username = username - self.user = self.get_user(username or settings.SUPERUSER) + self.user = self.get_user(username or get_runtime_setting('SUPERUSER')) self.folders = self.get_emby_folders() self.serverid = self.get_server_id() self._sync_libraries = sync_libraries or [] diff --git a/app/modules/fanart/__init__.py b/app/modules/fanart/__init__.py index 7b507c10c..e540cd828 100644 --- a/app/modules/fanart/__init__.py +++ b/app/modules/fanart/__init__.py @@ -4,9 +4,8 @@ from typing import Optional, Tuple, Union from app.runtime.cache import cached from app.domain.context import MediaInfo -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.runtime.tasks import get_task_registry from app.modules import _ModuleBase @@ -309,14 +308,14 @@ class FanartModule(_ModuleBase): """ # 代理 - _proxies: dict = settings.PROXY + _proxies: dict = get_runtime_setting('PROXY') # Fanart Api _movie_url: str = ( - f"https://webservice.fanart.tv/v3/movies/%s?api_key={settings.FANART_API_KEY}" + f"https://webservice.fanart.tv/v3/movies/%s?api_key={get_runtime_setting('FANART_API_KEY')}" ) _tv_url: str = ( - f"https://webservice.fanart.tv/v3/tv/%s?api_key={settings.FANART_API_KEY}" + f"https://webservice.fanart.tv/v3/tv/%s?api_key={get_runtime_setting('FANART_API_KEY')}" ) def init_module(self) -> None: @@ -451,7 +450,7 @@ class FanartModule(_ModuleBase): """ 获取 Fanart 查询参数 """ - if not settings.FANART_ENABLE: + if not get_runtime_setting('FANART_ENABLE'): return None if not mediainfo.tmdb_id and not mediainfo.tvdb_id: return None @@ -532,7 +531,7 @@ class FanartModule(_ModuleBase): """ 其他图片,优先环境变量指定语言,再like最多 """ - lang_env = settings.FANART_LANG + lang_env = get_runtime_setting('FANART_LANG') if lang_env: langs = [lang.strip() for lang in lang_env.split(",") if lang.strip()] for lang in langs: @@ -582,7 +581,7 @@ class FanartModule(_ModuleBase): return cls._FANART_NAME_MAP.get(fanart_name.lower(), fanart_name) @classmethod - @cached(maxsize=settings.CONF.fanart, ttl=settings.CONF.meta, shared_key="get") + @cached(maxsize=get_runtime_setting('CONF').fanart, ttl=get_runtime_setting('CONF').meta, shared_key="get") def __request_fanart( cls, media_type: MediaType, queryid: Union[str, int] ) -> Optional[dict]: @@ -601,7 +600,7 @@ class FanartModule(_ModuleBase): return None @classmethod - @cached(maxsize=settings.CONF.fanart, ttl=settings.CONF.meta, shared_key="get") + @cached(maxsize=get_runtime_setting('CONF').fanart, ttl=get_runtime_setting('CONF').meta, shared_key="get") async def __async_request_fanart( cls, media_type: MediaType, queryid: Union[str, int] ) -> Optional[dict]: diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index 4046f1b2b..9ff226d6c 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -50,9 +50,8 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import ( P2CardActionTriggerResponse, ) -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.messaging.ingress import submit_message_to_host from app.domain.context import Context, MediaInfo from app.application.security.user import get_configured_user_channel_lookup @@ -1065,7 +1064,7 @@ class Feishu: response = None temp_path = None try: - response = RequestUtils(timeout=30, ua=settings.USER_AGENT).get_res(image_url) + response = RequestUtils(timeout=30, ua=get_runtime_setting('USER_AGENT')).get_res(image_url) if not response or not getattr(response, "content", None): logger.warning(f"飞书图片下载失败:{image_url}") return None diff --git a/app/modules/filemanager/module.py b/app/modules/filemanager/module.py index 6d6113216..153697d9d 100644 --- a/app/modules/filemanager/module.py +++ b/app/modules/filemanager/module.py @@ -1,9 +1,8 @@ from pathlib import Path from typing import Any, Optional, List, Tuple, Union, Dict, Callable -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic @@ -191,7 +190,7 @@ class FileManagerModule(_ModuleBase): """ handler = TransHandler() # 重命名格式 - rename_format = settings.RENAME_FORMAT(mediainfo.type) + rename_format = get_runtime_setting('RENAME_FORMAT')(mediainfo.type) # 获取重命名后的名称 path = handler.get_rename_path( template_string=rename_format, @@ -631,7 +630,7 @@ class FileManagerModule(_ModuleBase): # 媒体分类路径 dir_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=dest_dir) # 重命名格式 - rename_format = settings.RENAME_FORMAT(mediainfo.type) + rename_format = get_runtime_setting('RENAME_FORMAT')(mediainfo.type) # 元数据补上常用属性,尽可能确保重命名后的路径不出现空白 meta = self._build_library_lookup_meta(mediainfo) # 获取路径(重命名路径) @@ -665,9 +664,9 @@ class FileManagerModule(_ModuleBase): continue if media_files: media_extensions = ( - settings.RMT_AUDIOEXT + get_runtime_setting('RMT_AUDIOEXT') if mediainfo.type == MediaType.MUSIC - else settings.RMT_MEDIAEXT + else get_runtime_setting('RMT_MEDIAEXT') ) for media_file in media_files: if ( @@ -692,7 +691,7 @@ class FileManagerModule(_ModuleBase): if kwargs.get("server"): return None - if not settings.LOCAL_EXISTS_SEARCH: + if not get_runtime_setting('LOCAL_EXISTS_SEARCH'): return None logger.debug(f"正在本地媒体库中查找 {mediainfo.title_year}...") diff --git a/app/modules/filemanager/storages/alipan.py b/app/modules/filemanager/storages/alipan.py index 751316279..ed734978e 100644 --- a/app/modules/filemanager/storages/alipan.py +++ b/app/modules/filemanager/storages/alipan.py @@ -8,19 +8,17 @@ from typing import List, Optional, Tuple, Union import requests -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.stop import runtime_stop_state -from app.schemas.file import StorageUsage as _SchemaStorageUsage -from app.schemas.workflow import FileItem as _SchemaFileItem - -settings = RuntimeSettingsCompat() from app.adapters.network.http import RequestUtils from app.foundation import temporal as time_tools from app.foundation.singleton import WeakSingleton from app.modules.filemanager.storages import StorageBase, transfer_process from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from app.runtime.stop import runtime_stop_state from app.schemas.exception import StorageQueryError +from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.types import StorageSchema +from app.schemas.workflow import FileItem as _SchemaFileItem lock = threading.Lock() @@ -48,7 +46,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): base_url = "https://openapi.alipan.com" # 阿里云盘目录时间不随子文件变更而更新,默认关闭目录修改时间检查 - snapshot_check_folder_modtime = settings.ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME + snapshot_check_folder_modtime = get_runtime_setting('ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME') # 文件块大小,默认10MB chunk_size = 10 * 1024 * 1024 @@ -117,7 +115,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): resp = self.session.post( f"{self.base_url}/oauth/authorize/qrcode", json={ - "client_id": settings.ALIPAN_APP_ID, + "client_id": get_runtime_setting('ALIPAN_APP_ID'), "scopes": [ "user:base", "file:all:read", @@ -181,7 +179,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): resp = self.session.post( f"{self.base_url}/oauth/access_token", json={ - "client_id": settings.ALIPAN_APP_ID, + "client_id": get_runtime_setting('ALIPAN_APP_ID'), "grant_type": "authorization_code", "code": self._auth_state["authCode"], "code_verifier": self._auth_state["code_verifier"], @@ -205,7 +203,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): resp = self.session.post( f"{self.base_url}/oauth/access_token", json={ - "client_id": settings.ALIPAN_APP_ID, + "client_id": get_runtime_setting('ALIPAN_APP_ID'), "grant_type": "refresh_token", "refresh_token": refresh_token, }, @@ -745,7 +743,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): logger.error(f"【阿里云盘】下载链接为空: {fileitem.name}") return None - local_path = self._build_download_path(fileitem, path or settings.TEMP_PATH) + local_path = self._build_download_path(fileitem, path or get_runtime_setting('TEMP_PATH')) if not local_path: return None @@ -759,7 +757,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): try: # 构建请求头,包含必要的认证信息 headers = { - "User-Agent": settings.NORMAL_USER_AGENT, + "User-Agent": get_runtime_setting('NORMAL_USER_AGENT'), "Referer": "https://www.aliyundrive.com/", "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", diff --git a/app/modules/filemanager/storages/alist.py b/app/modules/filemanager/storages/alist.py index 5a988292f..195eb0332 100644 --- a/app/modules/filemanager/storages/alist.py +++ b/app/modules/filemanager/storages/alist.py @@ -5,20 +5,18 @@ from datetime import datetime from pathlib import Path from typing import List, Optional -from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.stop import runtime_stop_state -from app.schemas.file import StorageUsage as _SchemaStorageUsage -from app.schemas.workflow import FileItem as _SchemaFileItem - -settings = RuntimeSettingsCompat() from app.adapters.network.http import RequestUtils from app.foundation.singleton import WeakSingleton from app.foundation.url import UrlUtils from app.modules.filemanager.storages import StorageBase, transfer_process +from app.runtime.cache import cached from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from app.runtime.stop import runtime_stop_state from app.schemas.exception import OperationInterrupted, StorageQueryError +from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.types import StorageSchema +from app.schemas.workflow import FileItem as _SchemaFileItem # OpenList/AList 在 per_page<=0 时会退回后端默认 200,显式指定最大页大小避免大目录被截断。 OPENLIST_MAX_LIST_PAGE_SIZE = 500 @@ -41,7 +39,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): } # 快照检查目录修改时间 - snapshot_check_folder_modtime = settings.OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME + snapshot_check_folder_modtime = get_runtime_setting('OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME') def __init__(self): super().__init__() @@ -692,7 +690,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): download_url = download_url + "?sign=" + result["data"]["sign"] if not path: - local_path = settings.TEMP_PATH / fileitem.name + local_path = get_runtime_setting('TEMP_PATH') / fileitem.name else: local_path = path / fileitem.name diff --git a/app/modules/filemanager/storages/local.py b/app/modules/filemanager/storages/local.py index 8347d101e..8e45c533a 100644 --- a/app/modules/filemanager/storages/local.py +++ b/app/modules/filemanager/storages/local.py @@ -4,19 +4,17 @@ import time from pathlib import Path from typing import List, Optional -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.stop import runtime_stop_state -from app.schemas.file import StorageUsage as _SchemaStorageUsage -from app.schemas.workflow import FileItem as _SchemaFileItem - -settings = RuntimeSettingsCompat() from app.adapters.system.fsproxy import fsproxy from app.adapters.system.host import SystemUtils from app.application.directory import DirectoryHelper from app.modules.filemanager.storages import StorageBase, transfer_process from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from app.runtime.stop import runtime_stop_state from app.schemas.exception import StorageQueryError +from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.types import StorageSchema +from app.schemas.workflow import FileItem as _SchemaFileItem class LocalStorage(StorageBase): @@ -477,7 +475,7 @@ class LocalStorage(StorageBase): total_storage, free_storage = SystemUtils.space_usage( [Path(d.download_path) for d in directory_helper.get_local_download_dirs() if d.download_path] + [Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path], - btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP, + btrfs_fsid_dedup=get_runtime_setting('BTRFS_FSID_DEDUP'), ) return _SchemaStorageUsage( total=total_storage, diff --git a/app/modules/filemanager/storages/rclone.py b/app/modules/filemanager/storages/rclone.py index 429083dad..ef7a18687 100644 --- a/app/modules/filemanager/storages/rclone.py +++ b/app/modules/filemanager/storages/rclone.py @@ -6,17 +6,15 @@ from collections import OrderedDict from pathlib import Path from typing import List, Optional, Union -from app.runtime.settings import RuntimeSettingsCompat -from app.schemas.file import StorageUsage as _SchemaStorageUsage -from app.schemas.workflow import FileItem as _SchemaFileItem - -settings = RuntimeSettingsCompat() from app.adapters.system.host import SystemUtils from app.foundation import temporal as time_tools from app.modules.filemanager.storages import StorageBase, transfer_process from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting from app.schemas.exception import StorageQueryError +from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.types import StorageSchema +from app.schemas.workflow import FileItem as _SchemaFileItem _MAX_FOLDER_LOCKS = 4096 _folder_locks: OrderedDict[str, threading.Lock] = OrderedDict() @@ -50,7 +48,7 @@ class Rclone(StorageBase): "copy": "复制" } - snapshot_check_folder_modtime = settings.RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME + snapshot_check_folder_modtime = get_runtime_setting('RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME') def init_storage(self): """ @@ -376,7 +374,7 @@ class Rclone(StorageBase): """ 带实时进度显示的下载 """ - local_path = self._build_download_path(fileitem, path or settings.TEMP_PATH) + local_path = self._build_download_path(fileitem, path or get_runtime_setting('TEMP_PATH')) if not local_path: return None diff --git a/app/modules/filemanager/storages/smb.py b/app/modules/filemanager/storages/smb.py index 8b17b0446..8b7ddceda 100644 --- a/app/modules/filemanager/storages/smb.py +++ b/app/modules/filemanager/storages/smb.py @@ -12,17 +12,15 @@ from smbprotocol.exceptions import ( SMBResponseException, ) -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.stop import runtime_stop_state -from app.schemas.file import StorageUsage as _SchemaStorageUsage -from app.schemas.workflow import FileItem as _SchemaFileItem - -settings = RuntimeSettingsCompat() from app.foundation.singleton import WeakSingleton from app.modules.filemanager.storages import StorageBase, transfer_process from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from app.runtime.stop import runtime_stop_state from app.schemas.exception import StorageQueryError +from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.types import StorageSchema +from app.schemas.workflow import FileItem as _SchemaFileItem lock = threading.Lock() @@ -550,7 +548,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): """ 带实时进度显示的下载 """ - local_path = self._build_download_path(fileitem, path or settings.TEMP_PATH) + local_path = self._build_download_path(fileitem, path or get_runtime_setting('TEMP_PATH')) if not local_path: return None smb_path = self._normalize_path(fileitem.path) diff --git a/app/modules/filemanager/storages/u115.py b/app/modules/filemanager/storages/u115.py index f6735fbd5..5e265e6ab 100644 --- a/app/modules/filemanager/storages/u115.py +++ b/app/modules/filemanager/storages/u115.py @@ -12,19 +12,17 @@ from cryptography.hazmat.primitives import hashes from oss2 import SizedFileAdapter, determine_part_size from oss2.models import PartInfo -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.stop import runtime_stop_state -from app.schemas.file import StorageUsage as _SchemaStorageUsage -from app.schemas.workflow import FileItem as _SchemaFileItem - -settings = RuntimeSettingsCompat() from app.foundation import size as size_tools from app.foundation.singleton import WeakSingleton from app.modules.filemanager.storages import StorageBase, transfer_process from app.runtime.log import logger from app.runtime.rate import QpsRateLimiter, RateStats +from app.runtime.settings import get_runtime_setting +from app.runtime.stop import runtime_stop_state from app.schemas.exception import StorageQueryError +from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.types import StorageSchema +from app.schemas.workflow import FileItem as _SchemaFileItem lock = Lock() @@ -130,7 +128,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): 生成 OAuth2 授权 URL """ try: - resp = self.session.get(f"{settings.U115_AUTH_SERVER}/u115/auth_url") + resp = self.session.get(f"{get_runtime_setting('U115_AUTH_SERVER')}/u115/auth_url") if resp is None: return {}, "无法连接到授权服务器" @@ -165,7 +163,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): resp = self.session.post( "https://passportapi.115.com/open/authDeviceCode", data={ - "client_id": settings.U115_APP_ID, + "client_id": get_runtime_setting('U115_APP_ID'), "code_challenge": code_challenge, "code_challenge_method": "sha256", }, @@ -229,7 +227,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): try: resp = self.session.get( - f"{settings.U115_AUTH_SERVER}/u115/token", params={"state": state} + f"{get_runtime_setting('U115_AUTH_SERVER')}/u115/token", params={"state": state} ) if resp is None: return {}, "无法连接到授权服务器" @@ -910,7 +908,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): logger.error(f"【115】下载链接为空: {fileitem.name}") return None - local_path = self._build_download_path(fileitem, path or settings.TEMP_PATH) + local_path = self._build_download_path(fileitem, path or get_runtime_setting('TEMP_PATH')) if not local_path: return None diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index f58a2d5f8..ed8b1c2ce 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -4,9 +4,8 @@ from typing import Optional, List, Tuple from jinja2 import Template -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import MediaInfo, MusicInfo from app.runtime.events import eventmanager from app.domain.meta.metabase import MetaBase @@ -204,7 +203,7 @@ class TransHandler: """ if not _fileitem.extension: return False - if f".{_fileitem.extension.lower()}" in settings.RMT_SUBEXT: + if f".{_fileitem.extension.lower()}" in get_runtime_setting('RMT_SUBEXT'): return True return False @@ -224,11 +223,11 @@ class TransHandler: if not _fileitem.extension: return False extension = f".{_fileitem.extension.lower()}" - if extension in settings.RMT_SUBEXT: + if extension in get_runtime_setting('RMT_SUBEXT'): return True if __is_music_lyrics_file(_fileitem): return True - if mediainfo.type != MediaType.MUSIC and extension in settings.RMT_AUDIOEXT: + if mediainfo.type != MediaType.MUSIC and extension in get_runtime_setting('RMT_AUDIOEXT'): return True return False @@ -252,7 +251,7 @@ class TransHandler: try: # 重命名格式 - rename_format = settings.RENAME_FORMAT(mediainfo.type) + rename_format = get_runtime_setting('RENAME_FORMAT')(mediainfo.type) # 判断是否为文件夹 if fileitem.type == "dir": @@ -953,10 +952,10 @@ class TransHandler: # 添加默认字幕标识 if ( - (settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn") - or (settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw") - or (settings.DEFAULT_SUB == "ja" and new_file_type == ".ja") - or (settings.DEFAULT_SUB == "eng" and new_file_type == ".eng") + (get_runtime_setting('DEFAULT_SUB') == "zh-cn" and new_file_type == ".chi.zh-cn") + or (get_runtime_setting('DEFAULT_SUB') == "zh-tw" and new_file_type == ".zh-tw") + or (get_runtime_setting('DEFAULT_SUB') == "ja" and new_file_type == ".ja") + or (get_runtime_setting('DEFAULT_SUB') == "eng" and new_file_type == ".eng") ): new_sub_tag = ".default" + new_file_type else: @@ -1283,7 +1282,7 @@ class TransHandler: if media_file.type != "file": continue # 当前只有视频文件需要保留最新版本,其余格式无需处理,以避免误删 (issue 5449) - if f".{media_file.extension.lower()}" not in settings.RMT_MEDIAEXT: + if f".{media_file.extension.lower()}" not in get_runtime_setting('RMT_MEDIAEXT'): continue # 识别文件中的季集信息 filemeta = MetaInfoPath(media_path) diff --git a/app/modules/imdb/__init__.py b/app/modules/imdb/__init__.py index e332ad97f..e2400abd9 100644 --- a/app/modules/imdb/__init__.py +++ b/app/modules/imdb/__init__.py @@ -12,10 +12,8 @@ from app.domain.scraper import MediaScraperHelper from app.foundation.text import convert as zhconv_convert from app.modules import _ModuleBase from app.modules._base.media_auxiliary import MediaAuxiliaryProviderMixin -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting from app.schemas.context import MediaCredit, MediaImageSet from app.schemas.media import normalize_media_source from app.schemas.types import ( @@ -59,7 +57,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): def init_module(self) -> None: """按当前代理配置初始化 IMDb 客户端和通用刮削器。""" - self._config = ImdbConfigSnapshot(proxy=settings.PROXY) + self._config = ImdbConfigSnapshot(proxy=get_runtime_setting('PROXY')) self.imdb_api = ImdbApi(proxies=self._config.proxy) self.scraper = MediaScraperHelper() @@ -517,7 +515,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): if requested_source not in {None, MediaSource.IMDb}: return None selected_source = requested_source or normalize_media_source( - settings.RECOGNIZE_SOURCE + get_runtime_setting('RECOGNIZE_SOURCE') ) if selected_source != MediaSource.IMDb or not meta or not meta.name: return None @@ -560,7 +558,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): if requested_source not in {None, MediaSource.IMDb}: return None selected_source = requested_source or normalize_media_source( - settings.RECOGNIZE_SOURCE + get_runtime_setting('RECOGNIZE_SOURCE') ) if selected_source != MediaSource.IMDb or not meta or not meta.name: return None @@ -657,7 +655,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): ) -> Optional[str]: """生成 IMDb 来源的 NFO 元数据文本。""" del kwargs - if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != MediaSource.IMDb.value: + if (mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')) != MediaSource.IMDb.value: return None if not self.scraper: return None @@ -672,7 +670,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): episode: Optional[int] = None, ) -> Optional[dict]: """生成 IMDb 来源的图片文件名与下载地址映射。""" - if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != MediaSource.IMDb.value: + if (mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')) != MediaSource.IMDb.value: return None if not self.scraper: return None diff --git a/app/modules/imdb/api.py b/app/modules/imdb/api.py index 428dbd313..fe75f37ea 100644 --- a/app/modules/imdb/api.py +++ b/app/modules/imdb/api.py @@ -9,11 +9,9 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.tasks import get_task_registry - -settings = RuntimeSettingsCompat() from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from app.runtime.tasks import get_task_registry TModel = TypeVar("TModel", bound=BaseModel) @@ -227,7 +225,7 @@ class ImdbApi: def __init__(self, proxies: Optional[dict] = None) -> None: """按一次模块配置快照创建网络请求适配器。""" headers = { - "User-Agent": settings.NORMAL_USER_AGENT, + "User-Agent": get_runtime_setting('NORMAL_USER_AGENT'), "Accept": "application/graphql+json, application/json", "Content-Type": "application/json", "x-imdb-client-name": "imdb-web-next-localized", @@ -262,8 +260,8 @@ class ImdbApi: return cls._freeze_value(params or {}) @cached( - maxsize=settings.CONF.imdb, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').imdb, + ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="imdb_get", ) @@ -274,8 +272,8 @@ class ImdbApi: return self._request.get_json(url, params=dict(params_key)) @cached( - maxsize=settings.CONF.imdb, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').imdb, + ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="imdb_get", ) @@ -286,8 +284,8 @@ class ImdbApi: return await self._async_request.get_json(url, params=dict(params_key)) @cached( - maxsize=settings.CONF.imdb, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').imdb, + ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="imdb_graphql", skip_if=_is_graphql_error, @@ -302,8 +300,8 @@ class ImdbApi: ) @cached( - maxsize=settings.CONF.imdb, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').imdb, + ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="imdb_graphql", skip_if=_is_graphql_error, diff --git a/app/modules/indexer/parser/__init__.py b/app/modules/indexer/parser/__init__.py index 8247160aa..f163090e0 100644 --- a/app/modules/indexer/parser/__init__.py +++ b/app/modules/indexer/parser/__init__.py @@ -8,9 +8,8 @@ from urllib.parse import urljoin, urlsplit from requests import Session -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.adapters.network.cloudflare import under_challenge from app.runtime.log import logger from app.adapters.network.http import RequestUtils @@ -228,7 +227,7 @@ class SiteParserBase(metaclass=ABCMeta): ) ) # 解析用户未读消息 - if settings.SITE_MESSAGE: + if get_runtime_setting('SITE_MESSAGE'): self._pase_unread_msgs() # 解析用户上传、下载、分享率等信息 if self._user_traffic_page: @@ -346,7 +345,7 @@ class SiteParserBase(metaclass=ABCMeta): :return: """ req_headers = None - proxies = settings.PROXY if self._proxy else None + proxies = get_runtime_setting('PROXY') if self._proxy else None if self._ua or headers or self._addition_headers: if self.request_mode == "apikey": @@ -408,8 +407,8 @@ class SiteParserBase(metaclass=ABCMeta): f"{self._site_name} 检测到Cloudflare,请更新Cookie和UA") return "" return RequestUtils.get_decoded_html_content(res, - settings.ENCODING_DETECTION_PERFORMANCE_MODE, - settings.ENCODING_DETECTION_MIN_CONFIDENCE) + get_runtime_setting('ENCODING_DETECTION_PERFORMANCE_MODE'), + get_runtime_setting('ENCODING_DETECTION_MIN_CONFIDENCE')) return "" diff --git a/app/modules/indexer/parser/rousi.py b/app/modules/indexer/parser/rousi.py index 28978b186..4cd4daa6f 100644 --- a/app/modules/indexer/parser/rousi.py +++ b/app/modules/indexer/parser/rousi.py @@ -4,9 +4,8 @@ from urllib.parse import urljoin from typing import Optional, Tuple from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.adapters.network.http import RequestUtils from app.domain import site as site_rules from app.foundation import temporal as time_tools @@ -195,7 +194,7 @@ class RousiSiteUserInfo(SiteParserBase): res = RequestUtils( headers=headers, timeout=60, - proxies=settings.PROXY if self._proxy else None + proxies=get_runtime_setting('PROXY') if self._proxy else None ).get_res( url=urljoin(self._base_url, "api/messages"), params=params @@ -231,7 +230,7 @@ class RousiSiteUserInfo(SiteParserBase): RequestUtils( headers=headers, timeout=60, - proxies=settings.PROXY if self._proxy else None + proxies=get_runtime_setting('PROXY') if self._proxy else None ).post_res( url=urljoin(self._base_url, "api/messages/read-all") ) diff --git a/app/modules/indexer/spider/__init__.py b/app/modules/indexer/spider/__init__.py index de737eac4..5006cfc2f 100644 --- a/app/modules/indexer/spider/__init__.py +++ b/app/modules/indexer/spider/__init__.py @@ -9,9 +9,8 @@ from jinja2 import Template from pyquery import PyQuery from app.runtime.execution import run_in_threadpool -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.types import MediaType from app.adapters.system import rust as rust_accel @@ -136,9 +135,9 @@ class SiteSpider: self.page = page if self.domain and not str(self.domain).endswith("/"): self.domain = self.domain + "/" - self.ua = indexer.get('ua') or settings.USER_AGENT - self.proxies = settings.PROXY if indexer.get('proxy') else None - self.proxy_server = settings.PROXY_SERVER if indexer.get('proxy') else None + self.ua = indexer.get('ua') or get_runtime_setting('USER_AGENT') + self.proxies = get_runtime_setting('PROXY') if indexer.get('proxy') else None + self.proxy_server = get_runtime_setting('PROXY_SERVER') if indexer.get('proxy') else None self.cookie = indexer.get('cookie') self.referer = referer # 初始化属性 @@ -362,8 +361,8 @@ class SiteSpider: return self.parse( RequestUtils.get_decoded_html_content( ret, - performance_mode=settings.ENCODING_DETECTION_PERFORMANCE_MODE, - confidence_threshold=settings.ENCODING_DETECTION_MIN_CONFIDENCE + performance_mode=get_runtime_setting('ENCODING_DETECTION_PERFORMANCE_MODE'), + confidence_threshold=get_runtime_setting('ENCODING_DETECTION_MIN_CONFIDENCE') ) ) @@ -394,8 +393,8 @@ class SiteSpider: self.parse, RequestUtils.get_decoded_html_content( ret, - performance_mode=settings.ENCODING_DETECTION_PERFORMANCE_MODE, - confidence_threshold=settings.ENCODING_DETECTION_MIN_CONFIDENCE + performance_mode=get_runtime_setting('ENCODING_DETECTION_PERFORMANCE_MODE'), + confidence_threshold=get_runtime_setting('ENCODING_DETECTION_MIN_CONFIDENCE') ) ) diff --git a/app/modules/indexer/spider/haidan.py b/app/modules/indexer/spider/haidan.py index a585b0cfd..f0dae9bca 100644 --- a/app/modules/indexer/spider/haidan.py +++ b/app/modules/indexer/spider/haidan.py @@ -1,9 +1,8 @@ import urllib.parse from typing import Tuple, List -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.types import MediaType @@ -70,7 +69,7 @@ class HaiDanSpider: self._searchurl = self._searchurl % self._url self._name = indexer.get('name') if indexer.get('proxy'): - self._proxy = settings.PROXY + self._proxy = get_runtime_setting('PROXY') self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._timeout = indexer.get('timeout') or 15 diff --git a/app/modules/indexer/spider/hddolby.py b/app/modules/indexer/spider/hddolby.py index a333dea44..d7b06d0ed 100644 --- a/app/modules/indexer/spider/hddolby.py +++ b/app/modules/indexer/spider/hddolby.py @@ -1,8 +1,7 @@ from typing import Tuple, List, Optional -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.types import MediaType @@ -76,7 +75,7 @@ class HddolbySpider: self._domain_host = site_rules.extract_domain(self._domain) self._name = indexer.get('name') if indexer.get('proxy'): - self._proxy = settings.PROXY + self._proxy = get_runtime_setting('PROXY') self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._apikey = indexer.get('apikey') diff --git a/app/modules/indexer/spider/mtorrent.py b/app/modules/indexer/spider/mtorrent.py index 578eedc6b..a4122dd98 100644 --- a/app/modules/indexer/spider/mtorrent.py +++ b/app/modules/indexer/spider/mtorrent.py @@ -4,9 +4,8 @@ import re from typing import Tuple, List, Optional from urllib.parse import urlparse -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.types import MediaType @@ -75,7 +74,7 @@ class MTorrentSpider: self._searchurl = self._searchurl % self._domain self._name = indexer.get('name') if indexer.get('proxy'): - self._proxy = settings.PROXY + self._proxy = get_runtime_setting('PROXY') self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._apikey = indexer.get('apikey') diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index 982a247df..53fce5ae5 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -2,9 +2,8 @@ import base64 import json from typing import List, Optional, Tuple -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.types import MediaType @@ -60,7 +59,7 @@ class RousiSpider: self._downloadurl = self._downloadurl % (self._domain, "%s") self._name = indexer.get('name') if indexer.get('proxy'): - self._proxy = settings.PROXY + self._proxy = get_runtime_setting('PROXY') self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._apikey = indexer.get('apikey') diff --git a/app/modules/indexer/spider/sunnypt.py b/app/modules/indexer/spider/sunnypt.py index ea4d702a0..694efc50f 100644 --- a/app/modules/indexer/spider/sunnypt.py +++ b/app/modules/indexer/spider/sunnypt.py @@ -3,9 +3,8 @@ import json import time from typing import List, Optional, Tuple -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.types import MediaType from app.adapters.network.http import AsyncRequestUtils, RequestUtils @@ -34,9 +33,9 @@ class SunnyPTSpider: self._api_url = str( indexer.get("api_url") or "https://api.sunnypt.top/api/v1/mp" ).rstrip("/") - self._proxy = settings.PROXY if indexer.get("proxy") else None + self._proxy = get_runtime_setting('PROXY') if indexer.get("proxy") else None self._use_proxy = bool(indexer.get("proxy")) - self._user_agent = indexer.get("ua") or settings.USER_AGENT + self._user_agent = indexer.get("ua") or get_runtime_setting('USER_AGENT') self._api_key = indexer.get("apikey") self._timeout = indexer.get("timeout") or 15 self._configured_categories = self._parse_configured_categories( diff --git a/app/modules/indexer/spider/tnode.py b/app/modules/indexer/spider/tnode.py index 0773c5cd4..64cb3e20a 100644 --- a/app/modules/indexer/spider/tnode.py +++ b/app/modules/indexer/spider/tnode.py @@ -2,9 +2,8 @@ import re from typing import Tuple, List, Optional from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.foundation.singleton import SingletonClass @@ -33,7 +32,7 @@ class TNodeSpider(metaclass=SingletonClass): self._searchurl = self._baseurl % self._domain self._name = indexer.get('name') if indexer.get('proxy'): - self._proxy = settings.PROXY + self._proxy = get_runtime_setting('PROXY') self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._timeout = indexer.get('timeout') or 15 diff --git a/app/modules/indexer/spider/torrentleech.py b/app/modules/indexer/spider/torrentleech.py index 54f98e772..248f11f67 100644 --- a/app/modules/indexer/spider/torrentleech.py +++ b/app/modules/indexer/spider/torrentleech.py @@ -1,9 +1,8 @@ from typing import List, Tuple, Optional from urllib.parse import quote -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils @@ -34,7 +33,7 @@ class TorrentLeech: """初始化站点认证信息和媒体分类配置。""" self._indexer = indexer if indexer.get('proxy'): - self._proxy = settings.PROXY + self._proxy = get_runtime_setting('PROXY') self._timeout = indexer.get('timeout') or 15 def __category_ids(self, mtype: MediaType = None) -> List[str]: diff --git a/app/modules/indexer/spider/yema.py b/app/modules/indexer/spider/yema.py index 4814cba36..b44fec625 100644 --- a/app/modules/indexer/spider/yema.py +++ b/app/modules/indexer/spider/yema.py @@ -2,9 +2,8 @@ import base64 import json from typing import List, Optional, Tuple -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.types import MediaType from app.adapters.network.http import AsyncRequestUtils, RequestUtils @@ -46,9 +45,9 @@ class YemaSpider: indexer = indexer or {} self._name = indexer.get("name") or "YemaPT" self._site_url = str(indexer.get("domain") or "https://www.yemapt.org/").rstrip("/") - self._proxy = settings.PROXY if indexer.get("proxy") else None + self._proxy = get_runtime_setting('PROXY') if indexer.get("proxy") else None self._use_proxy = bool(indexer.get("proxy")) - self._user_agent = indexer.get("ua") or settings.USER_AGENT + self._user_agent = indexer.get("ua") or get_runtime_setting('USER_AGENT') self._api_key = indexer.get("apikey") self._timeout = indexer.get("timeout") or 15 self._search_url = f"{self._site_url}/openApi/torrent/fetchOpenTorrentList.json" diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index f3e2645c0..787d5093d 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -9,9 +9,8 @@ from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.mediaserver import MediaServerIdentityHelper, format_emby_family_item from app.runtime.log import logger from app.schemas.types import MediaType @@ -40,7 +39,7 @@ class Jellyfin: if self._playhost: self._playhost = UrlUtils.standardize_base_url(self._playhost) self._apikey = apikey - self.user = self.get_user(settings.SUPERUSER) + self.user = self.get_user(get_runtime_setting('SUPERUSER')) self.serverid = self.get_server_id() self._sync_libraries = sync_libraries or [] @@ -253,9 +252,9 @@ class Jellyfin: for user in users: if user.get("Name") == user_name: return user.get("Id") - if user_name == settings.SUPERUSER: + if user_name == get_runtime_setting('SUPERUSER'): logger.warning( - "MoviePilot 当前配置的超级管理员用户名为 {},请确保Jellyfin中存在同名管理员账号,否则可能无法正常使用部分功能!".format(settings.SUPERUSER) + "MoviePilot 当前配置的超级管理员用户名为 {},请确保Jellyfin中存在同名管理员账号,否则可能无法正常使用部分功能!".format(get_runtime_setting('SUPERUSER')) ) # 查询管理员,优先选择同时具备全库访问能力的账号,再回退到普通管理员。 # 获取总媒体库数量 diff --git a/app/modules/listenbrainz/__init__.py b/app/modules/listenbrainz/__init__.py index 30f282265..e71ee86cc 100644 --- a/app/modules/listenbrainz/__init__.py +++ b/app/modules/listenbrainz/__init__.py @@ -1,9 +1,8 @@ from typing import Any, Optional, Tuple, Union from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import MusicInfo from app.runtime.log import logger from app.modules import _ModuleBase @@ -155,7 +154,7 @@ class ListenBrainzModule(_ModuleBase): ) @classmethod - @cached(maxsize=settings.CONF.listenbrainz, ttl=settings.CONF.meta, skip_none=True) + @cached(maxsize=get_runtime_setting('CONF').listenbrainz, ttl=get_runtime_setting('CONF').meta, skip_none=True) def _request_json( cls, path: str, @@ -164,10 +163,10 @@ class ListenBrainzModule(_ModuleBase): """请求 ListenBrainz JSON 接口并统一处理网络和响应错误。""" response = RequestUtils( headers={ - "User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)", + "User-Agent": f"{get_runtime_setting('USER_AGENT')} (https://github.com/jxxghp/MoviePilot)", "Accept": "application/json", }, - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=20, ).get_res(f"{cls._base_url}{path}", params=params) if response is None: @@ -281,7 +280,7 @@ class ListenBrainzModule(_ModuleBase): if not release_mbid: return None # 支持配置音乐封面代理地址,解决 coverartarchive.org 无法访问的问题 - base = (settings.MUSIC_COVER_PROXY or "https://coverartarchive.org").rstrip("/") + base = (get_runtime_setting('MUSIC_COVER_PROXY') or "https://coverartarchive.org").rstrip("/") return f"{base}/release/{release_mbid}/front-500" @classmethod @@ -290,7 +289,7 @@ class ListenBrainzModule(_ModuleBase): if not release_group_id: return None # 支持配置音乐封面代理地址,解决 coverartarchive.org 无法访问的问题 - base = (settings.MUSIC_COVER_PROXY or "https://coverartarchive.org").rstrip("/") + base = (get_runtime_setting('MUSIC_COVER_PROXY') or "https://coverartarchive.org").rstrip("/") return f"{base}/release-group/{release_group_id}/front-500" @staticmethod diff --git a/app/modules/lrclib/__init__.py b/app/modules/lrclib/__init__.py index 2eb1ded58..9fb57f99a 100644 --- a/app/modules/lrclib/__init__.py +++ b/app/modules/lrclib/__init__.py @@ -9,10 +9,9 @@ from app.domain.meta.metamusic import MetaMusic from app.modules import _ModuleBase from app.runtime.cache import cached from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.schemas.types import ModuleType, OtherModulesType -settings = RuntimeSettingsCompat() class LrclibModule(_ModuleBase): @@ -201,13 +200,13 @@ class LrclibModule(_ModuleBase): time.sleep(delay) response = RequestUtils( headers={ - "User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)", + "User-Agent": f"{get_runtime_setting('USER_AGENT')} (https://github.com/jxxghp/MoviePilot)", "Accept": "application/json", }, - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=20, ).get_res( - f"{(base_url or str(settings.LRCLIB_BASE_URL)).rstrip('/')}{path}", + f"{(base_url or str(get_runtime_setting('LRCLIB_BASE_URL'))).rstrip('/')}{path}", params=params, ) cls._last_request_at = time.monotonic() @@ -231,7 +230,7 @@ class LrclibModule(_ModuleBase): if response.status_code in (429, 503): retry_after = cls._retry_after_seconds(response.headers.get("Retry-After")) response.close() - max_wait = max(int(settings.LYRICS_PROVIDER_RETRY_MAX_WAIT), 0) + max_wait = max(int(get_runtime_setting('LYRICS_PROVIDER_RETRY_MAX_WAIT')), 0) if retry_after > max_wait: cls._cooldown_until = time.monotonic() + retry_after logger.warning(f"LRCLIB 进入冷却 {retry_after:g} 秒,跳过当前批次后续请求") diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index 324068f4a..de93328f7 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -8,9 +8,8 @@ from typing import Any, Iterable, Optional, Tuple, Union from requests import Session from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import ( MusicAlbumInfo, MusicArtistInfo, @@ -1761,7 +1760,7 @@ class MusicBrainzModule(_ModuleBase): if not release_group_id: return None # 支持配置音乐封面代理地址,解决 coverartarchive.org 无法访问的问题 - base = (settings.MUSIC_COVER_PROXY or "https://coverartarchive.org").rstrip("/") + base = (get_runtime_setting('MUSIC_COVER_PROXY') or "https://coverartarchive.org").rstrip("/") return f"{base}/release-group/{release_group_id}/front-500" @classmethod @@ -1795,7 +1794,7 @@ class MusicBrainzModule(_ModuleBase): await asyncio.sleep(delay) @classmethod - @cached(maxsize=settings.CONF.musicbrainz, ttl=settings.CONF.meta, skip_none=True) + @cached(maxsize=get_runtime_setting('CONF').musicbrainz, ttl=get_runtime_setting('CONF').meta, skip_none=True) def _request_json( cls, path: str, @@ -1811,10 +1810,10 @@ class MusicBrainzModule(_ModuleBase): cls._wait_for_rate_limit() response = RequestUtils( headers={ - "User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)", + "User-Agent": f"{get_runtime_setting('USER_AGENT')} (https://github.com/jxxghp/MoviePilot)", "Accept": "application/json", }, - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), session=cls._get_session(), timeout=20, ).get_res(f"{cls._base_url}{path}", params=params) @@ -1850,8 +1849,8 @@ class MusicBrainzModule(_ModuleBase): @classmethod @cached( - maxsize=settings.CONF.musicbrainz, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').musicbrainz, + ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="_request_json", ) @@ -1866,10 +1865,10 @@ class MusicBrainzModule(_ModuleBase): await cls._async_wait_for_rate_limit() response = await AsyncRequestUtils( headers={ - "User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)", + "User-Agent": f"{get_runtime_setting('USER_AGENT')} (https://github.com/jxxghp/MoviePilot)", "Accept": "application/json", }, - proxies=settings.PROXY, + proxies=get_runtime_setting('PROXY'), timeout=20, ).get_res(f"{cls._base_url}{path}", params=params) if response is None: diff --git a/app/modules/musicbrainz/music_cache.py b/app/modules/musicbrainz/music_cache.py index 8b771df13..2c8022f56 100644 --- a/app/modules/musicbrainz/music_cache.py +++ b/app/modules/musicbrainz/music_cache.py @@ -6,9 +6,8 @@ from time import time from typing import Optional from app.runtime.cache import FileCache, TTLCache -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import MusicInfo from app.domain.meta.metamusic import MetaMusic from app.runtime.log import logger @@ -37,15 +36,15 @@ class MusicBrainzCache(metaclass=WeakSingleton): def __init__(self): """初始化音乐识别缓存并恢复未过期的持久化数据。""" - self.maxsize = settings.CONF.musicbrainz - self.ttl = settings.CONF.meta + self.maxsize = get_runtime_setting('CONF').musicbrainz + self.ttl = get_runtime_setting('CONF').meta self.region = "__musicbrainz_cache__" self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl) self._expires_at: dict[str, float] = {} self._dirty = False self._file_cache = None if not self._cache.is_redis(): - self._file_cache = FileCache(base=settings.CACHE_PATH, ttl=self.ttl) + self._file_cache = FileCache(base=get_runtime_setting('CACHE_PATH'), ttl=self.ttl) self._restore() def _restore(self) -> None: diff --git a/app/modules/musixmatch/__init__.py b/app/modules/musixmatch/__init__.py index ca5d2bca5..49fa929c0 100644 --- a/app/modules/musixmatch/__init__.py +++ b/app/modules/musixmatch/__init__.py @@ -6,11 +6,9 @@ from app.domain.context import MusicInfo, MusicLyrics from app.domain.meta.metamusic import MetaMusic from app.modules import _ModuleBase from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.schemas.types import ModuleType, OtherModulesType -settings = RuntimeSettingsCompat() - class MusixmatchModule(_ModuleBase): """使用用户授权的 Musixmatch 官方 API 获取同步或纯文本歌词。""" @@ -30,7 +28,7 @@ class MusixmatchModule(_ModuleBase): def test(self) -> Tuple[bool, str]: """验证 API Key 和官方接口连通性。""" - if not str(settings.MUSIXMATCH_API_KEY or "").strip(): + if not str(get_runtime_setting('MUSIXMATCH_API_KEY') or "").strip(): return False, "Musixmatch API Key 未配置" payload = self._request("matcher.lyrics.get", {"q_track": "test", "q_artist": "test"}) return (True, "") if payload is not None else (False, "Musixmatch API 连接或授权失败") @@ -106,15 +104,15 @@ class MusixmatchModule(_ModuleBase): def _request(self, method: str, params: dict[str, Any]) -> Optional[dict[str, Any]]: """请求官方 API,并对限流或服务过载设置进程内冷却。""" - api_key = str(settings.MUSIXMATCH_API_KEY or "").strip() + api_key = str(get_runtime_setting('MUSIXMATCH_API_KEY') or "").strip() if not api_key or time.monotonic() < self._cooldown_until: return None response = RequestUtils( - ua=settings.USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('USER_AGENT'), + proxies=get_runtime_setting('PROXY'), timeout=20, ).get_res( - f"{str(settings.MUSIXMATCH_BASE_URL).rstrip('/')}/{method}", + f"{str(get_runtime_setting('MUSIXMATCH_BASE_URL')).rstrip('/')}/{method}", params={**params, "apikey": api_key}, ) if response is None: diff --git a/app/modules/postgresql/__init__.py b/app/modules/postgresql/__init__.py index 806d07017..07e746df8 100644 --- a/app/modules/postgresql/__init__.py +++ b/app/modules/postgresql/__init__.py @@ -2,11 +2,9 @@ from typing import Tuple, Union from app.application.database import get_database_governance from app.modules import _ModuleBase -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.schemas.types import ModuleType, OtherModulesType -settings = RuntimeSettingsCompat() - class PostgreSQLModule(_ModuleBase): """ @@ -55,7 +53,7 @@ class PostgreSQLModule(_ModuleBase): """ 测试模块连接性 """ - if settings.DB_TYPE != "postgresql": + if get_runtime_setting('DB_TYPE') != "postgresql": return None error = get_database_governance().test() if error: diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index c713ed7f7..7bab9dc54 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -4,7 +4,7 @@ from typing import Set, Tuple, Optional, Union, List, Dict from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo from app.domain.metainfo import MetaInfo from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.modules._base import _DownloaderModuleBase from app.modules.qbittorrent.qbittorrent import Qbittorrent from app.schemas.transfer import DownloaderFile, DownloaderTorrent @@ -19,7 +19,6 @@ from app.foundation import size as size_tools from app.foundation import temporal as time_tools from app.foundation import text as text_tools -settings = RuntimeSettingsCompat() _QBITTORRENT_DOWNLOADING_STATES = { "allocating", @@ -128,8 +127,8 @@ class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]): tag = text_tools.random_string(10) if label: tags = label.split(',') + [tag] - elif settings.TORRENT_TAG: - tags = [tag, settings.TORRENT_TAG] + elif get_runtime_setting('TORRENT_TAG'): + tags = [tag, get_runtime_setting('TORRENT_TAG')] else: tags = [tag] # 如果要选择文件则先暂停 @@ -163,9 +162,9 @@ class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]): # 给种子打上标签 if "已整理" in torrent_tags: server.remove_torrents_tag(ids=torrent_hash, tag=['已整理']) - if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags: - logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}") - server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG]) + if get_runtime_setting('TORRENT_TAG') and get_runtime_setting('TORRENT_TAG') not in torrent_tags: + logger.info(f"给种子 {torrent_hash} 打上标签:{get_runtime_setting('TORRENT_TAG')}") + server.set_torrents_tag(ids=torrent_hash, tags=[get_runtime_setting('TORRENT_TAG')]) # 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹` torrent_layout = server.get_content_layout() return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在" @@ -250,7 +249,7 @@ class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]): servers: Dict[str, Qbittorrent] = self.get_instances() ret_torrents = [] query_status = self._normalize_query_status(status) - query_tags = None if include_all_tags else settings.TORRENT_TAG + query_tags = None if include_all_tags else get_runtime_setting('TORRENT_TAG') def __get_torrent_path(torrent_data: dict) -> Path: """ diff --git a/app/modules/qqbot/qqbot.py b/app/modules/qqbot/qqbot.py index dc201ce14..b142830f4 100644 --- a/app/modules/qqbot/qqbot.py +++ b/app/modules/qqbot/qqbot.py @@ -7,19 +7,15 @@ import hashlib import io import pickle import threading -from typing import Optional, List, Tuple +from typing import List, Optional, Tuple from PIL import Image -from app.runtime.cache import FileCache -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() +from app.adapters.network.http import RequestUtils from app.application.messaging.ingress import submit_message_to_host -from app.domain.context import MediaInfo, Context +from app.domain.context import Context, MediaInfo from app.domain.metainfo import MetaInfo -from app.runtime.log import logger -from app.runtime.thread import ThreadHelper +from app.foundation import size as size_tools from app.modules.qqbot.api import ( get_access_token, get_gateway_url, @@ -27,8 +23,9 @@ from app.modules.qqbot.api import ( send_proactive_group_message, ) from app.modules.qqbot.gateway import run_gateway -from app.adapters.network.http import RequestUtils -from app.foundation import size as size_tools +from app.runtime.cache import FileCache +from app.runtime.log import logger +from app.runtime.thread import ThreadHelper # QQ Markdown 图片展示尺寸限制,避免竖版海报被客户端拉伸变形 _DEFAULT_IMAGE_SIZE: Tuple[int, int] = (208, 320) diff --git a/app/modules/redis/__init__.py b/app/modules/redis/__init__.py index dac3b312d..97c8fcc03 100644 --- a/app/modules/redis/__init__.py +++ b/app/modules/redis/__init__.py @@ -2,11 +2,9 @@ from typing import Tuple, Union from app.adapters.cache.redis import RedisHelper from app.modules import _ModuleBase -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.schemas.types import ModuleType, OtherModulesType -settings = RuntimeSettingsCompat() - class RedisModule(_ModuleBase): """ @@ -55,7 +53,7 @@ class RedisModule(_ModuleBase): """ 测试模块连接性 """ - if settings.CACHE_BACKEND_TYPE != "redis": + if get_runtime_setting('CACHE_BACKEND_TYPE') != "redis": return None if RedisHelper().test(): return True, "" diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index 9f0330ec1..e9364d606 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -4,7 +4,7 @@ from typing import Set, Tuple, Optional, Union, List, Dict from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo from app.domain.metainfo import MetaInfo from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.modules._base import _DownloaderModuleBase from app.modules.rtorrent.rtorrent import Rtorrent from app.schemas.transfer import DownloaderFile, DownloaderTorrent @@ -19,7 +19,6 @@ from app.foundation import size as size_tools from app.foundation import temporal as time_tools from app.foundation import text as text_tools -settings = RuntimeSettingsCompat() class RtorrentModule(_DownloaderModuleBase[Rtorrent]): @@ -113,8 +112,8 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): tag = text_tools.random_string(10) if label: tags = label.split(",") + [tag] - elif settings.TORRENT_TAG: - tags = [tag, settings.TORRENT_TAG] + elif get_runtime_setting('TORRENT_TAG'): + tags = [tag, get_runtime_setting('TORRENT_TAG')] else: tags = [tag] # 如果要选择文件则先暂停 @@ -160,14 +159,14 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): ids=torrent_hash, tag=["已整理"] ) if ( - settings.TORRENT_TAG - and settings.TORRENT_TAG not in torrent_tags + get_runtime_setting('TORRENT_TAG') + and get_runtime_setting('TORRENT_TAG') not in torrent_tags ): logger.info( - f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}" + f"给种子 {torrent_hash} 打上标签:{get_runtime_setting('TORRENT_TAG')}" ) server.set_torrents_tag( - ids=torrent_hash, tags=[settings.TORRENT_TAG] + ids=torrent_hash, tags=[get_runtime_setting('TORRENT_TAG')] ) return ( downloader or self.get_default_config_name(), @@ -266,7 +265,7 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): servers: Dict[str, Rtorrent] = self.get_instances() ret_torrents = [] query_status = self._normalize_query_status(status) - query_tags = None if include_all_tags else settings.TORRENT_TAG + query_tags = None if include_all_tags else get_runtime_setting('TORRENT_TAG') def __get_torrent_path(torrent_data: dict) -> Path: """ diff --git a/app/modules/slack/slack.py b/app/modules/slack/slack.py index 15b4e1db1..7fe51f351 100644 --- a/app/modules/slack/slack.py +++ b/app/modules/slack/slack.py @@ -8,9 +8,8 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.messaging.ingress import forward_message_to_host from app.domain.context import MediaInfo, Context from app.domain.metainfo import MetaInfo @@ -266,7 +265,7 @@ class Slack: try: headers = { "Authorization": f"Bearer {self._oauth_token}", - "User-Agent": settings.USER_AGENT, + "User-Agent": get_runtime_setting('USER_AGENT'), "Accept": "*/*", } resp = RequestUtils(headers=headers, timeout=30).get_res(file_url) diff --git a/app/modules/subtitle/__init__.py b/app/modules/subtitle/__init__.py index d0ddfc65b..cefbf4989 100644 --- a/app/modules/subtitle/__init__.py +++ b/app/modules/subtitle/__init__.py @@ -4,9 +4,8 @@ from urllib.parse import urljoin, urlparse from lxml import etree -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import Context from app.application.site.query import get_configured_site_query_service from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module @@ -146,7 +145,7 @@ class SubtitleModule(_ModuleBase): request = RequestUtils( cookies=torrent.site_cookie, ua=torrent.site_ua, - proxies=settings.PROXY if torrent.site_proxy else None, + proxies=get_runtime_setting('PROXY') if torrent.site_proxy else None, ) res = request.get_res(torrent.page_url) if res and res.status_code == 200: diff --git a/app/modules/telegram/telegram.py b/app/modules/telegram/telegram.py index 4b24d8376..4cdaa87bf 100644 --- a/app/modules/telegram/telegram.py +++ b/app/modules/telegram/telegram.py @@ -36,9 +36,8 @@ try: except ImportError: from telegramify_markdown.type import ContentTypes, File, Photo, Text # noqa: E402 -from app.runtime.settings import RuntimeSettingsCompat # noqa: E402 +from app.runtime.settings import get_runtime_setting # noqa: E402 -settings = RuntimeSettingsCompat() from app.domain.context import MediaInfo, Context # noqa: E402 from app.domain.metainfo import MetaInfo # noqa: E402 from app.application.image import ImageHelper # noqa: E402 @@ -124,7 +123,7 @@ class Telegram: apihelper.API_URL = "https://api.telegram.org/bot{0}/{1}" apihelper.FILE_URL = "https://api.telegram.org/file/bot{0}/{1}" # 设置代理 - apihelper.proxy = settings.PROXY + apihelper.proxy = get_runtime_setting('PROXY') # bot _bot = TeleBot(self._telegram_token, parse_mode=TELEGRAM_PARSE_MODE_MARKDOWN) # 记录句柄 diff --git a/app/modules/theaudiodb/__init__.py b/app/modules/theaudiodb/__init__.py index 6757363f9..a297693d0 100644 --- a/app/modules/theaudiodb/__init__.py +++ b/app/modules/theaudiodb/__init__.py @@ -1,9 +1,8 @@ from typing import Any, Optional, Tuple, Union from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import ( MusicAlbumInfo, MusicArtistInfo, @@ -553,20 +552,20 @@ class TheAudioDbModule(_ModuleBase): return results @classmethod - @cached(maxsize=settings.CONF.theaudiodb, ttl=settings.CONF.meta, skip_none=True) + @cached(maxsize=get_runtime_setting('CONF').theaudiodb, ttl=get_runtime_setting('CONF').meta, skip_none=True) def _request_json( cls, endpoint: str, params: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: """请求 TheAudioDB V1 JSON 接口并统一处理错误响应。""" - api_key = str(settings.THEAUDIODB_API_KEY or "").strip() + api_key = str(get_runtime_setting('THEAUDIODB_API_KEY') or "").strip() if not api_key: logger.warning("TheAudioDB API Key 未配置,跳过请求") return None response = RequestUtils( - ua=settings.USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('USER_AGENT'), + proxies=get_runtime_setting('PROXY'), timeout=30, ).get_res( url=f"{cls._base_url}/{api_key}/{endpoint}", @@ -594,8 +593,8 @@ class TheAudioDbModule(_ModuleBase): @classmethod @cached( - maxsize=settings.CONF.theaudiodb, - ttl=settings.CONF.meta, + maxsize=get_runtime_setting('CONF').theaudiodb, + ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="_request_json", ) @@ -605,13 +604,13 @@ class TheAudioDbModule(_ModuleBase): params: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: """异步请求 TheAudioDB V1 JSON 接口并统一处理错误响应。""" - api_key = str(settings.THEAUDIODB_API_KEY or "").strip() + api_key = str(get_runtime_setting('THEAUDIODB_API_KEY') or "").strip() if not api_key: logger.warning("TheAudioDB API Key 未配置,跳过请求") return None response = await AsyncRequestUtils( - ua=settings.USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_setting('USER_AGENT'), + proxies=get_runtime_setting('PROXY'), timeout=30, ).get_res( url=f"{cls._base_url}/{api_key}/{endpoint}", diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 7d416dd0f..56d987493 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -94,13 +94,13 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ 测试模块连接性 """ - ret = RequestUtils(ua=get_runtime_setting("NORMAL_USER_AGENT"), proxies=get_runtime_setting("PROXY")).get_res( - f"https://{get_runtime_setting("TMDB_API_DOMAIN")}/3/movie/550?api_key={get_runtime_setting("TMDB_API_KEY")}") + ret = RequestUtils(ua=get_runtime_setting('NORMAL_USER_AGENT'), proxies=get_runtime_setting('PROXY')).get_res( + f"https://{get_runtime_setting('TMDB_API_DOMAIN')}/3/movie/550?api_key={get_runtime_setting('TMDB_API_KEY')}") if ret and ret.status_code == 200: return True, "" elif ret: - return False, f"无法连接 {get_runtime_setting("TMDB_API_DOMAIN")},错误码:{ret.status_code}" - return False, f"{get_runtime_setting("TMDB_API_DOMAIN")} 网络连接失败" + return False, f"无法连接 {get_runtime_setting('TMDB_API_DOMAIN')},错误码:{ret.status_code}" + return False, f"{get_runtime_setting('TMDB_API_DOMAIN')} 网络连接失败" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass @@ -122,7 +122,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not tmdbid and not meta: return False - selected_source = normalize_media_source(media_source or get_runtime_setting("RECOGNIZE_SOURCE")) + selected_source = normalize_media_source(media_source or get_runtime_setting('RECOGNIZE_SOURCE')) if meta and not tmdbid and selected_source != MediaSource.TMDB: return False @@ -973,7 +973,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param season: 季号 :param episode: 集号 """ - if (mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE")) != "themoviedb": + if (mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')) != "themoviedb": return None return self.scraper.get_metadata_nfo(meta=meta, mediainfo=mediainfo, season=season, episode=episode) @@ -985,7 +985,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param season: 季号 :param episode: 集号 """ - if (mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE")) != "themoviedb": + if (mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')) != "themoviedb": return None return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode) @@ -1106,7 +1106,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param mediainfo: 媒体信息 :return: None 表示不处理,MediaInfo 表示继续处理 """ - if mediainfo.media_source != "themoviedb" and get_runtime_setting("RECOGNIZE_SOURCE") != "themoviedb": + if mediainfo.media_source != "themoviedb" and get_runtime_setting('RECOGNIZE_SOURCE') != "themoviedb": return None if not mediainfo.tmdb_id: return mediainfo @@ -1147,15 +1147,15 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): # 背景图 if not mediainfo.backdrop_path: if image_path := cls._pick_best_tmdb_image(images.get("backdrops")): - mediainfo.backdrop_path = get_runtime_setting("TMDB_IMAGE_URL")(image_path) + mediainfo.backdrop_path = get_runtime_setting('TMDB_IMAGE_URL')(image_path) # 标志 if not mediainfo.logo_path: if image_path := cls._pick_best_tmdb_image(images.get("logos")): - mediainfo.logo_path = get_runtime_setting("TMDB_IMAGE_URL")(image_path) + mediainfo.logo_path = get_runtime_setting('TMDB_IMAGE_URL')(image_path) # 海报 if not mediainfo.poster_path: if image_path := cls._pick_best_tmdb_image(images.get("posters")): - mediainfo.poster_path = get_runtime_setting("TMDB_IMAGE_URL")(image_path) + mediainfo.poster_path = get_runtime_setting('TMDB_IMAGE_URL')(image_path) return mediainfo def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: @@ -1245,7 +1245,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): image_path = seasoninfo.get(image_type.value) if image_path: - return get_runtime_setting("TMDB_IMAGE_URL")(image_path, image_prefix) + return get_runtime_setting('TMDB_IMAGE_URL')(image_path, image_prefix) return None def tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]: diff --git a/app/modules/themoviedb/category.py b/app/modules/themoviedb/category.py index a22fdf92c..6404f2740 100644 --- a/app/modules/themoviedb/category.py +++ b/app/modules/themoviedb/category.py @@ -5,9 +5,8 @@ from typing import Union import ruamel.yaml from ruamel.yaml import CommentedMap -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.category import CategoryConfig from app.foundation.singleton import WeakSingleton @@ -33,7 +32,7 @@ class CategoryHelper(metaclass=WeakSingleton): """ def __init__(self): - self._category_path: Path = settings.CONFIG_PATH / "category.yaml" + self._category_path: Path = get_runtime_setting('CONFIG_PATH') / "category.yaml" self._categorys = {} self._movie_categorys = {} self._tv_categorys = {} @@ -45,7 +44,7 @@ class CategoryHelper(metaclass=WeakSingleton): """ try: if not self._category_path.exists(): - shutil.copy(settings.INNER_CONFIG_PATH / "category.yaml", self._category_path) + shutil.copy(get_runtime_setting('INNER_CONFIG_PATH') / "category.yaml", self._category_path) with open(self._category_path, mode='r', encoding='utf-8', errors='replace') as f: try: yaml_loader = ruamel.yaml.YAML() diff --git a/app/modules/themoviedb/scraper.py b/app/modules/themoviedb/scraper.py index eb57265bd..ff6768bdb 100644 --- a/app/modules/themoviedb/scraper.py +++ b/app/modules/themoviedb/scraper.py @@ -2,9 +2,8 @@ from pathlib import Path from typing import Optional, Tuple from xml.dom import minidom -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.context import MediaInfo from app.domain.meta.metabase import MetaBase from app.schemas.types import MediaType @@ -22,14 +21,14 @@ class TmdbScraper: 获取元数据TMDB Api """ if not self._meta_tmdb: - self._meta_tmdb = TmdbApi(language=settings.TMDB_LOCALE) + self._meta_tmdb = TmdbApi(language=get_runtime_setting('TMDB_LOCALE')) return self._meta_tmdb def original_tmdb(self, mediainfo: Optional[MediaInfo] = None): """ 获取图片TMDB Api """ - if settings.TMDB_SCRAP_ORIGINAL_IMAGE and mediainfo: + if get_runtime_setting('TMDB_SCRAP_ORIGINAL_IMAGE') and mediainfo: return TmdbApi(language=mediainfo.original_language) return self.default_tmdb @@ -116,7 +115,7 @@ class TmdbScraper: # TMDB集still图片 ext = Path(still_path).suffix still_name = f"episode-thumb{ext}" - still_url = settings.TMDB_IMAGE_URL(still_path) + still_url = get_runtime_setting('TMDB_IMAGE_URL')(still_path) images[still_name] = still_url else: # 季的图片 @@ -144,14 +143,14 @@ class TmdbScraper: images[image_name] = attr_value # 替换原语言Poster - if settings.TMDB_SCRAP_ORIGINAL_IMAGE: + if get_runtime_setting('TMDB_SCRAP_ORIGINAL_IMAGE'): _mediainfo = self.original_tmdb(mediainfo).get_info( mediainfo.type, mediainfo.tmdb_id ) if _mediainfo: for attr_name, attr_value in _mediainfo.items(): if attr_name.endswith("_path") and attr_value is not None: - image_url = settings.TMDB_IMAGE_URL(attr_value) + image_url = get_runtime_setting('TMDB_IMAGE_URL')(attr_value) image_name = ( attr_name.replace("_path", "") + Path(image_url).suffix ) @@ -181,11 +180,11 @@ class TmdbScraper: if not mediainfo.poster_path: poster_path = self.__pick_best_image_path(image_info.get("posters")) if poster_path: - mediainfo.poster_path = settings.TMDB_IMAGE_URL(poster_path) + mediainfo.poster_path = get_runtime_setting('TMDB_IMAGE_URL')(poster_path) if not mediainfo.backdrop_path: backdrop_path = self.__pick_best_image_path(image_info.get("backdrops")) if backdrop_path: - mediainfo.backdrop_path = settings.TMDB_IMAGE_URL(backdrop_path) + mediainfo.backdrop_path = get_runtime_setting('TMDB_IMAGE_URL')(backdrop_path) @staticmethod def __pick_best_image_path(images: list) -> Optional[str]: @@ -215,7 +214,7 @@ class TmdbScraper: # 后缀 ext = Path(poster_path).suffix # URL - url = settings.TMDB_IMAGE_URL(poster_path) + url = get_runtime_setting('TMDB_IMAGE_URL')(poster_path) # S0海报格式不同 if season == 0: image_name = f"season-specials-poster{ext}" @@ -286,7 +285,7 @@ class TmdbScraper: DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "") if profile_path := actor.get("profile_path"): DomUtils.add_node( - doc, xactor, "thumb", settings.TMDB_IMAGE_URL(profile_path) + doc, xactor, "thumb", get_runtime_setting('TMDB_IMAGE_URL')(profile_path) ) DomUtils.add_node( doc, @@ -453,7 +452,7 @@ class TmdbScraper: DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "") if profile_path := actor.get("profile_path"): DomUtils.add_node( - doc, xactor, "thumb", settings.TMDB_IMAGE_URL(profile_path) + doc, xactor, "thumb", get_runtime_setting('TMDB_IMAGE_URL')(profile_path) ) DomUtils.add_node( doc, diff --git a/app/modules/themoviedb/tmdb_cache.py b/app/modules/themoviedb/tmdb_cache.py index 8b354f9a3..5c71e4dc4 100644 --- a/app/modules/themoviedb/tmdb_cache.py +++ b/app/modules/themoviedb/tmdb_cache.py @@ -6,9 +6,8 @@ from time import time from typing import Any, Optional from app.runtime.cache import FileCache, TTLCache -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.domain.meta.metabase import MetaBase from app.runtime.log import logger from app.schemas.types import MediaSource, MediaType @@ -32,8 +31,8 @@ class TmdbCache(metaclass=WeakSingleton): """ def __init__(self): """初始化 TMDB 识别缓存并恢复未过期的持久化数据。""" - self.maxsize = settings.CONF.tmdb - self.ttl = settings.CONF.meta + self.maxsize = get_runtime_setting('CONF').tmdb + self.ttl = get_runtime_setting('CONF').meta self.region = "__tmdb_cache__" self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl) self._expires_at: dict[str, float] = {} @@ -42,8 +41,8 @@ class TmdbCache(metaclass=WeakSingleton): self._legacy_file_cache = None self._legacy_cache_found = False if not self._cache.is_redis(): - self._file_cache = FileCache(base=settings.CACHE_PATH, ttl=self.ttl) - self._legacy_file_cache = FileCache(base=settings.TEMP_PATH.parent, ttl=self.ttl) + self._file_cache = FileCache(base=get_runtime_setting('CACHE_PATH'), ttl=self.ttl) + self._legacy_file_cache = FileCache(base=get_runtime_setting('TEMP_PATH').parent, ttl=self.ttl) self._restore() def _restore(self) -> None: @@ -53,7 +52,7 @@ class TmdbCache(metaclass=WeakSingleton): if not content: content = self._legacy_file_cache.get( self.region, - region=settings.TEMP_PATH.name, + region=get_runtime_setting('TEMP_PATH').name, ) if content: self._legacy_cache_found = True @@ -146,7 +145,7 @@ class TmdbCache(metaclass=WeakSingleton): 获取缓存KEY """ media_id = meta.media_id if meta.media_source == MediaSource.TMDB else None - return f"[{meta.type.value if meta.type else '未知'}][{settings.TMDB_LOCALE}]{media_id or meta.name}-{meta.year}-{meta.begin_season}" + return f"[{meta.type.value if meta.type else '未知'}][{get_runtime_setting('TMDB_LOCALE')}]{media_id or meta.name}-{meta.year}-{meta.begin_season}" @staticmethod def __is_type_conflicted(meta: MetaBase, media_type: Any, tmdb_id: Any) -> bool: @@ -263,7 +262,7 @@ class TmdbCache(metaclass=WeakSingleton): # 负识别缓存使用独立的短 TTL:故障期间「合法 JSON 但结果为空」会被 # 记为未识别,若按完整有效期固化,故障自愈后同名仍会被判无法识别; # 短过期让恢复后可重新识别,真不存在的条目过期后重新确认一次即可 - self._set(key, {"id": 0}, ttl=settings.EMPTY_RESULT_CACHE_TTL) + self._set(key, {"id": 0}, ttl=get_runtime_setting('EMPTY_RESULT_CACHE_TTL')) def save(self, force: bool = False) -> None: """ @@ -314,7 +313,7 @@ class TmdbCache(metaclass=WeakSingleton): if self._legacy_cache_found: self._legacy_file_cache.delete( self.region, - region=settings.TEMP_PATH.name, + region=get_runtime_setting('TEMP_PATH').name, ) self._legacy_cache_found = False self._dirty = False diff --git a/app/modules/themoviedb/tmdbapi.py b/app/modules/themoviedb/tmdbapi.py index badb5aeb8..6e0f413a9 100644 --- a/app/modules/themoviedb/tmdbapi.py +++ b/app/modules/themoviedb/tmdbapi.py @@ -2,9 +2,8 @@ import re import traceback from typing import Optional, List -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.schemas.types import MediaType from app.foundation import text as text_tools @@ -1279,7 +1278,7 @@ class TmdbApi: """ languages = [] for language in ( - settings.TMDB_LOCALE, + get_runtime_setting('TMDB_LOCALE'), "en", None, original_language, diff --git a/app/modules/themoviedb/tmdbv3api/objs/discover.py b/app/modules/themoviedb/tmdbv3api/objs/discover.py index 42eaaa2d7..ffddd53f3 100644 --- a/app/modules/themoviedb/tmdbv3api/objs/discover.py +++ b/app/modules/themoviedb/tmdbv3api/objs/discover.py @@ -1,7 +1,6 @@ from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from ..tmdb import TMDb try: @@ -16,7 +15,7 @@ class Discover(TMDb): "tv": "/discover/tv" } - @cached(maxsize=1, ttl=43200, empty_ttl=settings.EMPTY_RESULT_CACHE_TTL) + @cached(maxsize=1, ttl=43200, empty_ttl=get_runtime_setting('EMPTY_RESULT_CACHE_TTL')) def discover_movies(self, params_tuple): """ Discover movies by different types of data like average rating, number of votes, genres and certifications. @@ -26,7 +25,7 @@ class Discover(TMDb): params = dict(params_tuple) return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False) - @cached(maxsize=1, ttl=43200, empty_ttl=settings.EMPTY_RESULT_CACHE_TTL) + @cached(maxsize=1, ttl=43200, empty_ttl=get_runtime_setting('EMPTY_RESULT_CACHE_TTL')) def discover_tv_shows(self, params_tuple): """ Discover TV shows by different types of data like average rating, number of votes, genres, @@ -36,7 +35,7 @@ class Discover(TMDb): """ return self._request_obj(self._urls["tv"], urlencode(params_tuple), key="results", call_cached=False) - @cached(maxsize=1, ttl=43200, empty_ttl=settings.EMPTY_RESULT_CACHE_TTL) + @cached(maxsize=1, ttl=43200, empty_ttl=get_runtime_setting('EMPTY_RESULT_CACHE_TTL')) async def async_discover_movies(self, params_tuple): """ Discover movies by different types of data like average rating, number of votes, genres and certifications.(异步版本) @@ -46,7 +45,7 @@ class Discover(TMDb): params = dict(params_tuple) return await self._async_request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False) - @cached(maxsize=1, ttl=43200, empty_ttl=settings.EMPTY_RESULT_CACHE_TTL) + @cached(maxsize=1, ttl=43200, empty_ttl=get_runtime_setting('EMPTY_RESULT_CACHE_TTL')) async def async_discover_tv_shows(self, params_tuple): """ Discover TV shows by different types of data like average rating, number of votes, genres, diff --git a/app/modules/themoviedb/tmdbv3api/tmdb.py b/app/modules/themoviedb/tmdbv3api/tmdb.py index f72f1bf60..aa4fc0777 100644 --- a/app/modules/themoviedb/tmdbv3api/tmdb.py +++ b/app/modules/themoviedb/tmdbv3api/tmdb.py @@ -10,9 +10,8 @@ import requests import requests.exceptions from app.runtime.cache import cached, fresh, async_fresh -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.adapters.network.http import RequestUtils, AsyncRequestUtils from .exceptions import TMDbException, TMDbConnectionError @@ -44,7 +43,7 @@ def _is_empty_result_snapshot(snapshot) -> bool: 判断响应快照是否为空结果(列表/搜索类接口的 results 为空列表)。 这类快照结构合法但无业务内容,常由代理瞬时故障产生;不能靠 skip_none/skip_empty - 识别(快照本身是非空字典),需单独谓词判定后按 settings.EMPTY_RESULT_CACHE_TTL + 识别(快照本身是非空字典),需单独谓词判定后按 get_runtime_setting('EMPTY_RESULT_CACHE_TTL') 短 TTL 缓存。详情类接口无 results 字段,不属于空结果。 """ if not isinstance(snapshot, dict): @@ -60,13 +59,13 @@ class TMDb(object): _RESPONSE_SNAPSHOT_MARKER = "__mp_tmdb_response_snapshot__" def __init__(self, session=None, language=None): - self._api_key = settings.TMDB_API_KEY - self._language = language or settings.TMDB_LOCALE or "en-US" + self._api_key = get_runtime_setting('TMDB_API_KEY') + self._language = language or get_runtime_setting('TMDB_LOCALE') or "en-US" self._session_id = None self._session = session self._wait_on_rate_limit = True - self._proxies = settings.PROXY - self._domain = settings.TMDB_API_DOMAIN + self._proxies = get_runtime_setting('PROXY') + self._domain = get_runtime_setting('TMDB_API_DOMAIN') self._page = None self._total_results = None self._total_pages = None @@ -76,7 +75,7 @@ class TMDb(object): # TMDB 在部分代理和运营商链路下的 HTTP/2 长连接偶发卡死,识别路径优先保证稳定性。 self._async_req = AsyncRequestUtils( - ua=settings.NORMAL_USER_AGENT, + ua=get_runtime_setting('NORMAL_USER_AGENT'), proxies=self.proxies, http2=False, ) @@ -91,7 +90,7 @@ class TMDb(object): """ self._session = session or requests.Session() self._req = RequestUtils( - ua=settings.NORMAL_USER_AGENT, + ua=get_runtime_setting('NORMAL_USER_AGENT'), session=self._session, proxies=self.proxies, ) @@ -175,9 +174,9 @@ class TMDb(object): def wait_on_rate_limit(self, wait_on_rate_limit): self._wait_on_rate_limit = bool(wait_on_rate_limit) - @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True, + @cached(maxsize=get_runtime_setting('CONF').tmdb, ttl=get_runtime_setting('CONF').meta, skip_none=True, skip_if=_is_business_failure_snapshot, - empty_ttl=settings.EMPTY_RESULT_CACHE_TTL, empty_if=_is_empty_result_snapshot) + empty_ttl=get_runtime_setting('EMPTY_RESULT_CACHE_TTL'), empty_if=_is_empty_result_snapshot) def request(self, method, url, data, json, **kwargs): req = self._request_once(method, url, data, json) if req is None and method == "GET" and self._owns_session: @@ -201,9 +200,9 @@ class TMDb(object): return self._req.get_res(url, params=data, json=json) return self._req.post_res(url, data=data, json=json) - @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True, + @cached(maxsize=get_runtime_setting('CONF').tmdb, ttl=get_runtime_setting('CONF').meta, skip_none=True, skip_if=_is_business_failure_snapshot, - empty_ttl=settings.EMPTY_RESULT_CACHE_TTL, empty_if=_is_empty_result_snapshot) + empty_ttl=get_runtime_setting('EMPTY_RESULT_CACHE_TTL'), empty_if=_is_empty_result_snapshot) async def async_request(self, method, url, data, json, **kwargs): req = await self._async_request_once(method, url, data, json) if req is None: diff --git a/app/modules/thetvdb/__init__.py b/app/modules/thetvdb/__init__.py index fc69b75f7..95c932351 100644 --- a/app/modules/thetvdb/__init__.py +++ b/app/modules/thetvdb/__init__.py @@ -38,11 +38,11 @@ class TheTvDbModule(_ModuleBase): action = "刷新" if is_retry else "创建" logger.info(f"开始{action}TVDB登录会话...") try: - if not get_runtime_setting("TVDB_V4_API_KEY"): + if not get_runtime_setting('TVDB_V4_API_KEY'): raise ConnectionError("TVDB API Key 未配置,无法初始化会话。") - self.tvdb = tvdb_v4_official.TVDB(apikey=get_runtime_setting("TVDB_V4_API_KEY"), - pin=get_runtime_setting("TVDB_V4_API_PIN"), - proxy=get_runtime_setting("PROXY"), + self.tvdb = tvdb_v4_official.TVDB(apikey=get_runtime_setting('TVDB_V4_API_KEY'), + pin=get_runtime_setting('TVDB_V4_API_PIN'), + proxy=get_runtime_setting('PROXY'), timeout=self.__timeout) if self.tvdb: logger.info(f"TVDB登录会话{action}成功。") diff --git a/app/modules/thetvdb/tvdb_v4_official.py b/app/modules/thetvdb/tvdb_v4_official.py index a862a794a..2f8851c38 100644 --- a/app/modules/thetvdb/tvdb_v4_official.py +++ b/app/modules/thetvdb/tvdb_v4_official.py @@ -7,11 +7,9 @@ import json import urllib.parse from http import HTTPStatus -from app.runtime.cache import cached -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.adapters.network.http import RequestUtils +from app.runtime.cache import cached +from app.runtime.settings import get_runtime_setting class Auth: @@ -69,7 +67,7 @@ class Request: self.proxy = proxy self.timeout = timeout - @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True) + @cached(maxsize=get_runtime_setting('CONF').tmdb, ttl=get_runtime_setting('CONF').meta, skip_none=True) def make_request(self, url: str, if_modified_since: bool = None): """ 向指定的 URL 发起请求并返回数据 diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index 0ccf054ad..fadb0e395 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -4,7 +4,7 @@ from typing import Set, Tuple, Optional, Union, List, Dict from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo from app.domain.metainfo import MetaInfo from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.modules._base import _DownloaderModuleBase from app.modules.transmission.transmission import Transmission from app.schemas.transfer import DownloaderFile, DownloaderTorrent @@ -18,7 +18,6 @@ from app.schemas.types import ( from app.foundation import size as size_tools from app.foundation import temporal as time_tools -settings = RuntimeSettingsCompat() _TRANSMISSION_DOWNLOADING_STATES = { "download_pending", @@ -111,8 +110,8 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): # 标签 if label: labels = label.split(',') - elif settings.TORRENT_TAG: - labels = settings.TORRENT_TAG.split(',') + elif get_runtime_setting('TORRENT_TAG'): + labels = get_runtime_setting('TORRENT_TAG').split(',') else: labels = None # 添加任务 @@ -139,16 +138,16 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): torrent_hash = torrent.hashString logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.name}") # 给种子打上标签 - if settings.TORRENT_TAG: - logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}") + if get_runtime_setting('TORRENT_TAG'): + logger.info(f"给种子 {torrent_hash} 打上标签:{get_runtime_setting('TORRENT_TAG')}") # 种子标签 labels = [str(tag).strip() for tag in torrent.labels] if hasattr(torrent, "labels") else [] if "已整理" in labels: labels.remove("已整理") server.set_torrent_tag(ids=torrent_hash, tags=labels) - if settings.TORRENT_TAG and settings.TORRENT_TAG not in labels: - labels.append(settings.TORRENT_TAG) + if get_runtime_setting('TORRENT_TAG') and get_runtime_setting('TORRENT_TAG') not in labels: + labels.append(get_runtime_setting('TORRENT_TAG')) server.set_torrent_tag(ids=torrent_hash, tags=labels) return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在" finally: @@ -213,7 +212,7 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): servers: Dict[str, Transmission] = self.get_instances() ret_torrents = [] query_status = self._normalize_query_status(status) - query_tags = None if include_all_tags else settings.TORRENT_TAG + query_tags = None if include_all_tags else get_runtime_setting('TORRENT_TAG') def __get_torrent_attr(torrent_data, *attr_names): """ diff --git a/app/modules/trimemedia/api.py b/app/modules/trimemedia/api.py index 4a6372eb8..f1f18acb7 100644 --- a/app/modules/trimemedia/api.py +++ b/app/modules/trimemedia/api.py @@ -7,9 +7,8 @@ from enum import Enum from typing import List, Optional, Union from urllib.parse import quote -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.adapters.network.http import RequestUtils, requests @@ -583,7 +582,7 @@ class Api: else: queries_unquoted = None headers = { - "User-Agent": settings.USER_AGENT, + "User-Agent": get_runtime_setting('USER_AGENT'), "Accept": "application/json", "Referer": self._host, "Authorization": self._token, diff --git a/app/modules/webpush/__init__.py b/app/modules/webpush/__init__.py index 9f472b2c8..ee21129c6 100644 --- a/app/modules/webpush/__init__.py +++ b/app/modules/webpush/__init__.py @@ -4,9 +4,8 @@ from typing import Union, Tuple from pywebpush import webpush, WebPushException from app.runtime.config import global_vars -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.runtime.log import logger from app.modules import _ModuleBase, _MessageBase from app.schemas.message import Message @@ -103,9 +102,9 @@ class WebPushModule(_ModuleBase, _MessageBase): "body": content, "url": message.link or "/?shotcut=message" }), - vapid_private_key=settings.VAPID.get("privateKey"), + vapid_private_key=get_runtime_setting('VAPID').get("privateKey"), vapid_claims={ - "sub": settings.VAPID.get("subject") + "sub": get_runtime_setting('VAPID').get("subject") }, **webpush_options, ) diff --git a/app/modules/wechat/wechatbot.py b/app/modules/wechat/wechatbot.py index e749772a7..42fbe14fc 100644 --- a/app/modules/wechat/wechatbot.py +++ b/app/modules/wechat/wechatbot.py @@ -1,3 +1,4 @@ +import base64 import hashlib import json import pickle @@ -5,26 +6,22 @@ import re import threading import time import uuid -import base64 -from typing import Optional, List, Dict, Tuple, Set +from typing import Dict, List, Optional, Set, Tuple import websocket from Crypto.Cipher import AES -from app.runtime.cache import FileCache -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() -from app.application.messaging.ingress import submit_message_to_host -from app.domain.context import MediaInfo, Context -from app.domain.metainfo import MetaInfo +from app.adapters.network.http import RequestUtils from app.application.messaging.agent import matches_channel_admin +from app.application.messaging.ingress import submit_message_to_host +from app.domain.context import Context, MediaInfo +from app.domain.metainfo import MetaInfo +from app.foundation import size as size_tools +from app.runtime.cache import FileCache from app.runtime.log import logger from app.runtime.thread import ThreadHelper from app.schemas.message import IncomingMessage from app.schemas.types import NotificationChannel -from app.adapters.network.http import RequestUtils -from app.foundation import size as size_tools class WeChatBot: diff --git a/app/modules/wechatclawbot/wechatclawbot.py b/app/modules/wechatclawbot/wechatclawbot.py index 531d07149..e4e81c8de 100644 --- a/app/modules/wechatclawbot/wechatclawbot.py +++ b/app/modules/wechatclawbot/wechatclawbot.py @@ -17,9 +17,8 @@ from Crypto.Cipher import AES from Crypto.Util.Padding import pad from app.runtime.cache import FileCache -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting -settings = RuntimeSettingsCompat() from app.application.messaging.ingress import forward_message_to_host from app.domain.context import Context, MediaInfo from app.domain.metainfo import MetaInfo @@ -1686,14 +1685,14 @@ class WechatClawBot: except Exception: return None if image_url.startswith("/"): - image_url = settings.MP_DOMAIN(image_url) + image_url = get_runtime_setting('MP_DOMAIN')(image_url) if not image_url.lower().startswith("http"): return None try: resp = RequestUtils( timeout=20, - proxies=settings.PROXY, - ua=settings.USER_AGENT, + proxies=get_runtime_setting('PROXY'), + ua=get_runtime_setting('USER_AGENT'), ).get_res(image_url) if resp and resp.status_code == 200 and resp.content: content_type = (resp.headers.get("Content-Type") or "").lower() diff --git a/app/monitor/dispatcher.py b/app/monitor/dispatcher.py index b34837476..dd7a2e1a0 100644 --- a/app/monitor/dispatcher.py +++ b/app/monitor/dispatcher.py @@ -39,9 +39,9 @@ class TransferDispatcher: :param cache: 去重缓存,默认使用 10 秒 TTL 缓存 """ self.all_exts = all_exts if all_exts is not None else ( - get_runtime_setting("RMT_MEDIAEXT") - + get_runtime_setting("RMT_SUBEXT") - + get_runtime_setting("RMT_AUDIOEXT") + get_runtime_setting('RMT_MEDIAEXT') + + get_runtime_setting('RMT_SUBEXT') + + get_runtime_setting('RMT_AUDIOEXT') ) self._cache = cache if cache is not None else TTLCache(region="monitor", maxsize=1024, ttl=10) self._lock = Lock() @@ -79,7 +79,7 @@ class TransferDispatcher: """ 判断监控事件路径是否需要进入整理链。 """ - if self._has_suffix_in(file_path, get_runtime_setting("DOWNLOAD_TMPEXT")): + if self._has_suffix_in(file_path, get_runtime_setting('DOWNLOAD_TMPEXT')): return False return self._has_suffix_in(file_path, self.all_exts) diff --git a/app/monitor/monitor.py b/app/monitor/monitor.py index 526ba32b2..34560eb13 100644 --- a/app/monitor/monitor.py +++ b/app/monitor/monitor.py @@ -235,7 +235,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): # 启动定时服务进程 if not self.__accepting_work(): return False - self._scheduler = BackgroundScheduler(timezone=get_runtime_setting("TZ")) + self._scheduler = BackgroundScheduler(timezone=get_runtime_setting('TZ')) mon_storages: Dict[str, List[Path]] = {} # 本地监控启动结果计数,用于输出真实的启动总结 @@ -400,7 +400,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): # 网络/FUSE 挂载轮询降频,减少监控自身对挂载后端的持续 stat 压力 poll_delay_ms = None if use_polling and SystemUtils.is_network_filesystem(mon_path): - poll_delay_ms = (get_runtime_setting("MONITOR_POLL_DELAY_NETWORK") + poll_delay_ms = (get_runtime_setting('MONITOR_POLL_DELAY_NETWORK') or LocalDirectoryWatcher.POLL_DELAY_NETWORK_MS) logger.info(f"检测到网络文件系统,轮询扫描间隔调整为 {poll_delay_ms}ms: {mon_path}") diff --git a/app/monitor/snapshot.py b/app/monitor/snapshot.py index eae1a9fd9..7ca5dc350 100644 --- a/app/monitor/snapshot.py +++ b/app/monitor/snapshot.py @@ -19,7 +19,7 @@ class SnapshotStore: :param cache: 快照文件缓存,默认使用 CACHE_PATH/snapshots """ self._cache = cache if cache is not None else FileCache( - base=get_runtime_setting("CACHE_PATH") / "snapshots" + base=get_runtime_setting('CACHE_PATH') / "snapshots" ) def save(self, storage: str, snapshot: Dict, file_count: int = 0, diff --git a/app/monitor/syslimits.py b/app/monitor/syslimits.py index 3cca9717b..0a3009369 100644 --- a/app/monitor/syslimits.py +++ b/app/monitor/syslimits.py @@ -123,7 +123,7 @@ def decide_monitor_mode(directory: Path, # 检查网络文件系统 if SystemUtils.is_network_filesystem(directory): - if not get_runtime_setting("MONITOR_NETWORK_FAST_MODE"): + if not get_runtime_setting('MONITOR_NETWORK_FAST_MODE'): return True, "检测到网络文件系统,建议使用兼容模式", None, None # 用户已确认该挂载支持 inotify,继续走快速模式的系统限制检查 logger.info(f"检测到网络文件系统,但已配置允许快速模式: {directory}") diff --git a/app/monitor/watcher.py b/app/monitor/watcher.py index 17fcc4de0..8912cdc80 100644 --- a/app/monitor/watcher.py +++ b/app/monitor/watcher.py @@ -286,7 +286,7 @@ class LocalDirectoryWatcher: :return: 重扫轮次延迟秒数元组 """ return self._parse_rescan_delays( - get_runtime_setting("MONITOR_RESCAN_DELAYS") + get_runtime_setting('MONITOR_RESCAN_DELAYS') ) @classmethod diff --git a/app/runtime/deprecation/policy.py b/app/runtime/deprecation/policy.py index 754ff1fb5..6a0205d56 100644 --- a/app/runtime/deprecation/policy.py +++ b/app/runtime/deprecation/policy.py @@ -62,7 +62,7 @@ def _enabled_keys() -> FrozenSet[str]: :return: 标识集合 """ - configured = get_runtime_setting("DEPRECATION_ENABLED") or "" + configured = get_runtime_setting('DEPRECATION_ENABLED') or "" return frozenset(item.strip() for item in str(configured).split(",") if item.strip()) diff --git a/app/runtime/extensions/host_module_adapter.py b/app/runtime/extensions/host_module_adapter.py index feeb694bf..67daaf09c 100644 --- a/app/runtime/extensions/host_module_adapter.py +++ b/app/runtime/extensions/host_module_adapter.py @@ -13,9 +13,7 @@ from app.runtime.capabilities.model import ( SelectorSchema, ) from app.runtime.capabilities.registry import CapabilityRegistry -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() +from app.runtime.settings import get_runtime_setting, has_runtime_setting from app.runtime.extensions.service_config import ServiceConfigHelper from app.schemas.types import ( DownloaderType, @@ -63,7 +61,7 @@ class HostModuleConfigSnapshot: def _validate_setting_selector(config: Mapping[str, Any]) -> None: """限制 setting selector 只能读取已声明的应用设置。""" key = config["key"] - if not isinstance(key, str) or not key or not hasattr(settings, key): + if not isinstance(key, str) or not key or not has_runtime_setting(key): raise ValueError(f"未知应用设置:{key!r}") @@ -176,7 +174,7 @@ def capture_host_module_config( service_keys.add(key) setting_values = { - key: getattr(settings, key) + key: get_runtime_setting(key) for key in sorted(setting_keys) } service_values = { diff --git a/app/runtime/extensions/module_manager.py b/app/runtime/extensions/module_manager.py index e4ca5da32..4b852f525 100644 --- a/app/runtime/extensions/module_manager.py +++ b/app/runtime/extensions/module_manager.py @@ -12,9 +12,7 @@ from app.runtime.capabilities.model import ( CapabilitySpec, ) from app.runtime.capabilities.runtime import CapabilityRuntime -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() +from app.runtime.settings import get_runtime_setting from app.runtime.events import Event, EventHandlerBinding, eventmanager from app.runtime.extensions.host_module_adapter import ( HOST_MODULE_KIND, @@ -259,7 +257,7 @@ class ModuleManager(metaclass=Singleton): if not setting: return True switch, value = setting - option = getattr(settings, switch) + option = get_runtime_setting(switch) if not option: return False if value is True: diff --git a/app/runtime/extensions/plugin/catalog.py b/app/runtime/extensions/plugin/catalog.py index 60032a900..19fa400db 100644 --- a/app/runtime/extensions/plugin/catalog.py +++ b/app/runtime/extensions/plugin/catalog.py @@ -7,12 +7,10 @@ from collections.abc import Callable, Mapping from typing import Any, Optional from app.foundation.version import compare_version -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.runtime.extensions.plugin.contracts import supports_plugin_hook from app.runtime.extensions.plugin.storage import PluginStorage from app.runtime.extensions.plugin.system import PluginSystemServices +from app.runtime.settings import get_runtime_setting from app.schemas.plugin import Plugin, PluginInstance, PluginRuntimeStatus from app.schemas.types import SystemConfigKey @@ -56,12 +54,15 @@ class PluginCatalogFacade: def online(self, force: bool = False) -> list[Plugin]: """读取所有兼容代际的在线插件目录。""" - if not settings.PLUGIN_MARKET: + plugin_market = get_runtime_setting('PLUGIN_MARKET') + if not plugin_market: return [] - markets = [item for item in settings.PLUGIN_MARKET.split(",") if item] + markets = [item for item in plugin_market.split(",") if item] result = self._market_catalog().collect( markets=markets, - compatible_flags=self._system().compatible_flags(settings.VERSION_FLAG), + compatible_flags=self._system().compatible_flags( + get_runtime_setting('VERSION_FLAG') + ), force=force, loader=self._market_loader, ) @@ -224,14 +225,17 @@ class PluginCatalogFacade: progress_callback: Optional[Callable[..., None]] = None, ) -> list[Plugin]: """异步读取所有兼容代际的在线插件目录。""" - if not settings.PLUGIN_MARKET: + plugin_market = get_runtime_setting('PLUGIN_MARKET') + if not plugin_market: if progress_callback: progress_callback(value=100, text="未配置插件市场,跳过刷新") return [] - markets = [item for item in settings.PLUGIN_MARKET.split(",") if item] + markets = [item for item in plugin_market.split(",") if item] result = await self._market_catalog().async_collect( markets=markets, - compatible_flags=self._system().compatible_flags(settings.VERSION_FLAG), + compatible_flags=self._system().compatible_flags( + get_runtime_setting('VERSION_FLAG') + ), force=force, loader=self._async_market_loader, progress_callback=progress_callback, @@ -254,7 +258,8 @@ class PluginCatalogFacade: def merge(self, higher: list[Plugin], base: list[Plugin]) -> list[Plugin]: """合并不同代际插件目录并保留市场优先级。""" - markets = [item for item in settings.PLUGIN_MARKET.split(",") if item] + plugin_market = get_runtime_setting('PLUGIN_MARKET') + markets = [item for item in plugin_market.split(",") if item] return self._market_catalog().merge(higher, base, markets) def _safe_state(self, plugin_id: str, plugin: Any) -> bool: diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index 9c36091a3..e37f9eb9b 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -29,10 +29,9 @@ from app.foundation.version import compare_version from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.log import logger from app.runtime.observability import observe_compat_facade -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from app.runtime.thread import ThreadHelper -settings = RuntimeSettingsCompat() from app.runtime.events import EventHandlerBinding, eventmanager from app.runtime.reload import ConfigReloadMixin from app.runtime.extensions.plugin.loader import PluginLoader @@ -215,10 +214,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._monitor_suppression_lock = threading.Lock() self._suppressed_monitor_plugins: Dict[str, int] = {} self._plugin_paths = PluginPathResolver( - runtime_root=settings.ROOT_PATH / "app" / "plugins", + runtime_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", running=lambda: self._running_plugins, system=get_plugin_system, - strict_system_version=lambda: not settings.DEV, + strict_system_version=lambda: not get_runtime_setting('DEV'), log=logger, ) self._local_plugin_sync = LocalPluginSyncService( @@ -248,7 +247,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): log=logger, ) self._plugin_loader = PluginLoader( - plugins_root=settings.ROOT_PATH / "app" / "plugins", + plugins_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), log=logger, @@ -384,7 +383,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): with self.mutation("启动插件"): with self._plugin_quiesce_lock: _legacy_diagnostics_configurator( - enabled=settings.DEBUG, + enabled=get_runtime_setting('DEBUG'), emitter=logger.warning, ) gil_enabled_before = is_gil_enabled() @@ -564,7 +563,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :return: 插件类列表 """ return PluginLoader( - plugins_root=settings.ROOT_PATH / "app" / "plugins", + plugins_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), log=logger, @@ -624,7 +623,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return if ( not self.is_plugin_settling() - and (settings.DEV or settings.PLUGIN_AUTO_RELOAD) + and ( + get_runtime_setting('DEV') + or get_runtime_setting('PLUGIN_AUTO_RELOAD') + ) ): self._plugin_monitor.start() @@ -635,7 +637,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._plugin_monitor.reload( enabled=( not self.is_plugin_settling() - and (settings.DEV or settings.PLUGIN_AUTO_RELOAD) + and ( + get_runtime_setting('DEV') + or get_runtime_setting('PLUGIN_AUTO_RELOAD') + ) ) ) @@ -652,7 +657,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): 运行 watchfiles 监视器的主循环。 """ PluginChangeMonitor( - runtime_root=settings.ROOT_PATH / "app" / "plugins", + runtime_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", local_roots=get_plugin_system().local_repo_paths, stop_event=self._plugin_monitor.stop_event, recent_sync=self._recent_local_sync, @@ -812,7 +817,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ return PluginLoader( - plugins_root=settings.ROOT_PATH / "app" / "plugins", + plugins_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), log=logger, diff --git a/app/runtime/settings.py b/app/runtime/settings.py index e795170cf..0c50fde85 100644 --- a/app/runtime/settings.py +++ b/app/runtime/settings.py @@ -6,66 +6,19 @@ import importlib from collections.abc import Callable from typing import Any - RuntimeSettingProvider = Callable[[str], Any] +RuntimeSettingUpdater = Callable[[str, Any], tuple[Any, str]] _provider: RuntimeSettingProvider | None = None -_runtime_settings_service: Any | None = None +_updater: RuntimeSettingUpdater | None = None +_MISSING = object() # 测试和插件可能临时替换某个模块上的 importlib.import_module;保存原始函数, -# 让兼容代理的 legacy Settings 解析不受这类局部替身影响。 +# 让启动前的 legacy Settings 回退不受这类局部替身影响。 _import_module = importlib.import_module -class RuntimeSettingsCompat: - """为旧模块级 Settings 访问提供动态 runtime 配置代理。""" - - @staticmethod - def _legacy_settings() -> Any: - """返回旧 Settings 实例,供 runtime 尚未装配时的兼容回退使用。""" - return _import_module("app.runtime.config").settings - - def __getattr__(self, key: str) -> Any: - """读取当前组合根配置;未装配时沿用旧 Settings 回退。""" - return get_runtime_setting(key) - - def __setattr__(self, key: str, value: Any) -> None: - """把旧模块级覆盖同步到 legacy Settings,保持测试和插件注入语义。""" - setattr(self._legacy_settings(), key, value) - - def __delattr__(self, key: str) -> None: - """删除旧模块级覆盖,使配置对象恢复其原有属性解析。""" - delattr(self._legacy_settings(), key) - - def model_dump( - self, - *, - include: set[str] | None = None, - exclude: set[str] | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - """导出当前配置快照,保留旧 Settings 的序列化入口。""" - if _runtime_settings_service is not None: - return _runtime_settings_service.snapshot(include=include, exclude=exclude) - return self._legacy_settings().model_dump( - include=include, exclude=exclude, **kwargs - ) - - def update_setting(self, key: str, value: Any) -> tuple[Any, str]: - """更新单项配置,兼容插件对模块级 Settings 的公开调用。""" - if _runtime_settings_service is not None: - return _runtime_settings_service.update(key, value) - return self._legacy_settings().update_setting(key, value) - - def update_settings(self, env: dict[str, Any]) -> dict[str, tuple[Any, str]]: - """批量更新配置,兼容旧 Settings 的管理接口。""" - if _runtime_settings_service is not None: - return _runtime_settings_service.update_many(env) - return self._legacy_settings().update_settings(env=env) - - -def configure_runtime_settings_compat(service: Any) -> None: - """由应用组合根注入可变配置服务,避免低层代理反向导入应用层。""" - global _runtime_settings_service - _runtime_settings_service = service +def _legacy_settings() -> Any: + """返回启动前读取配置用的旧 Settings 实例。""" + return _import_module("app.runtime.config").settings def configure_runtime_setting_provider(provider: RuntimeSettingProvider) -> None: @@ -74,8 +27,37 @@ def configure_runtime_setting_provider(provider: RuntimeSettingProvider) -> None _provider = provider -def get_runtime_setting(key: str) -> Any: - """读取单项运行配置;启动早期未装配时回退旧 Settings ABI。""" +def configure_runtime_setting_updater(updater: RuntimeSettingUpdater) -> None: + """由启动组合根登记配置写入器,避免低层调用方依赖 Application。""" + global _updater + _updater = updater + + +def get_runtime_setting(key: str, default: Any = _MISSING) -> Any: + """读取单项运行配置;可选默认值保留旧 `getattr` 容错语义。""" + try: + if _provider is not None: + return _provider(key) + return getattr(_legacy_settings(), key) + except AttributeError: + if default is _MISSING: + raise + return default + + +def update_runtime_setting(key: str, value: Any) -> tuple[Any, str]: + """更新单项运行配置;启动早期沿用旧 Settings 的兼容写入。""" + if _updater is not None: + return _updater(key, value) + return _legacy_settings().update_setting(key, value) + + +def has_runtime_setting(key: str) -> bool: + """判断运行配置是否声明指定键,供低层 manifest 校验使用。""" if _provider is not None: - return _provider(key) - return getattr(RuntimeSettingsCompat._legacy_settings(), key) + try: + _provider(key) + except AttributeError: + return False + return True + return hasattr(_legacy_settings(), key) diff --git a/app/runtime/state.py b/app/runtime/state.py index 337e1346f..f7cfa4608 100644 --- a/app/runtime/state.py +++ b/app/runtime/state.py @@ -11,9 +11,7 @@ from typing import Optional, Tuple import docker import psutil -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() +from app.runtime.settings import get_runtime_setting from app.runtime.log import logger from app.runtime.reload import ConfigReloadMixin from app.foundation.environment import is_docker @@ -33,10 +31,18 @@ class SystemHelper(ConfigReloadMixin): } __system_flag_file = "/var/log/nginx/__moviepilot__" - __local_backend_runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json" - __local_restart_log_file = settings.LOG_PATH / "moviepilot.restart.stdout.log" - __one_shot_dev_update_flag_file = settings.TEMP_PATH / "moviepilot.pending_dev_update" - __docker_restart_intent_file = settings.TEMP_PATH / "moviepilot.intentional_restart" + __local_backend_runtime_file = ( + get_runtime_setting('TEMP_PATH') / "moviepilot.runtime.json" + ) + __local_restart_log_file = ( + get_runtime_setting('LOG_PATH') / "moviepilot.restart.stdout.log" + ) + __one_shot_dev_update_flag_file = ( + get_runtime_setting('TEMP_PATH') / "moviepilot.pending_dev_update" + ) + __docker_restart_intent_file = ( + get_runtime_setting('TEMP_PATH') / "moviepilot.intentional_restart" + ) __graceful_shutdown_monitor_lock = threading.Lock() __graceful_shutdown_monitor: Optional[threading.Thread] = None @@ -142,13 +148,14 @@ class SystemHelper(ConfigReloadMixin): "subprocess.run(cmd, cwd=os.environ.get('MOVIEPILOT_ROOT'), env=os.environ.copy(), check=False)" ) env = os.environ.copy() - env["MOVIEPILOT_ROOT"] = str(settings.ROOT_PATH) + root_path = get_runtime_setting('ROOT_PATH') + env["MOVIEPILOT_ROOT"] = str(root_path) env["PYTHONUNBUFFERED"] = "1" SystemHelper.__local_restart_log_file.parent.mkdir(parents=True, exist_ok=True) with SystemHelper.__local_restart_log_file.open("a", encoding="utf-8") as log_handle: kwargs = { - "cwd": str(settings.ROOT_PATH), + "cwd": str(root_path), "stdout": log_handle, "stderr": subprocess.STDOUT, "stdin": subprocess.DEVNULL, @@ -200,7 +207,9 @@ class SystemHelper(ConfigReloadMixin): return False # 创建 Docker 客户端 - client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API) + client = docker.DockerClient( + base_url=get_runtime_setting('DOCKER_CLIENT_API') + ) # 获取容器信息 container = client.containers.get(container_id) restart_policy = container.attrs.get('HostConfig', {}).get('RestartPolicy', {}) @@ -280,7 +289,9 @@ class SystemHelper(ConfigReloadMixin): @staticmethod def upgrade_dev() -> Tuple[bool, str]: """保留原 Dev 模式:重启后跟踪当前 v3 开发分支。""" - configured_mode = str(settings.MOVIEPILOT_AUTO_UPDATE or "").strip().lower() + configured_mode = str( + get_runtime_setting('MOVIEPILOT_AUTO_UPDATE') or "" + ).strip().lower() if configured_mode != "dev": queued, message = SystemHelper.queue_one_shot_dev_update() if not queued: @@ -340,7 +351,9 @@ class SystemHelper(ConfigReloadMixin): """ try: # 创建 Docker 客户端 - client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API) + client = docker.DockerClient( + base_url=get_runtime_setting('DOCKER_CLIENT_API') + ) container_id = SystemHelper._get_container_id() if not container_id: return False, "获取容器ID失败!" diff --git a/app/runtime/thread.py b/app/runtime/thread.py index 7c9af59b6..9bb1faeae 100644 --- a/app/runtime/thread.py +++ b/app/runtime/thread.py @@ -4,9 +4,7 @@ from typing import Any, Callable, TypeVar, cast from app.foundation.singleton import Singleton from app.runtime.execution import OwnedThreadPoolExecutor -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() +from app.runtime.settings import get_runtime_setting _Result = TypeVar("_Result") _THREAD_POOL_STOP_TIMEOUT_SECONDS = 10.0 @@ -23,7 +21,9 @@ class ThreadHelper(metaclass=Singleton): # type: ignore[metaclass] def __init__(self) -> None: """按系统配置创建共享后台线程池。""" - self.pool = OwnedThreadPoolExecutor(max_workers=settings.CONF.threadpool) + self.pool = OwnedThreadPoolExecutor( + max_workers=get_runtime_setting('CONF').threadpool + ) def submit( self, diff --git a/app/runtime/version.py b/app/runtime/version.py index a4c9be3df..f689a72ce 100644 --- a/app/runtime/version.py +++ b/app/runtime/version.py @@ -29,13 +29,13 @@ def get_frontend_version(*, fallback_to_declared: bool = True) -> str | None: """返回当前部署的前端资源版本,并可关闭发布声明回退。""" if is_frozen() and is_windows(): version_file = ( - Path(get_runtime_setting("CONFIG_PATH")).parent + Path(get_runtime_setting('CONFIG_PATH')).parent / "nginx" / "html" / "version.txt" ) else: - version_file = Path(get_runtime_setting("FRONTEND_PATH")) / "version.txt" + version_file = Path(get_runtime_setting('FRONTEND_PATH')) / "version.txt" installed_version = _read_version_file(version_file) if installed_version or not fallback_to_declared: return installed_version diff --git a/app/sdk/_legacy/subscribe.py b/app/sdk/_legacy/subscribe.py index 1f34aad26..56fb68e29 100644 --- a/app/sdk/_legacy/subscribe.py +++ b/app/sdk/_legacy/subscribe.py @@ -1,8 +1,12 @@ """把旧订阅 Oper 写入调用转交给新的应用服务。""" -from typing import Any, Optional +from typing import Any, Optional, cast -from app.application.subscription.write import add_subscribe, async_add_subscribe +from app.application.subscription.write import ( + SubscribeWriter, + add_subscribe, + async_add_subscribe, +) from app.db.oper.subscribe import SubscribeOper as CanonicalSubscribeOper from app.domain.context import MediaInfo, MusicInfo @@ -10,11 +14,12 @@ from app.domain.context import MediaInfo, MusicInfo class SubscribeOper(CanonicalSubscribeOper): """保留旧 ``mediainfo`` 写入签名,同时继承新的查询接口。""" - def add( + # 旧插件 ABI 以 mediainfo 为首参,故签名有意宽于 canonical Oper。 + def add( # type: ignore[override] self, mediainfo: Optional[MediaInfo | MusicInfo] = None, **kwargs: Any, - ): + ) -> tuple[int, str]: """ 兼容旧订阅写入;应用服务回调的新字典签名直接交给 canonical Oper。 @@ -35,16 +40,17 @@ class SubscribeOper(CanonicalSubscribeOper): username=username, ) return add_subscribe( - mediainfo=mediainfo, - subscribe_oper=self, + mediainfo=cast(MediaInfo | MusicInfo, mediainfo), + subscribe_oper=cast(SubscribeWriter, self), **kwargs, ) - async def async_add( + # 异步入口保留相同的旧插件双签名合同。 + async def async_add( # type: ignore[override] self, mediainfo: Optional[MediaInfo | MusicInfo] = None, **kwargs: Any, - ): + ) -> tuple[int, str]: """ 异步兼容旧订阅写入;新字典签名直接交给 canonical Oper。 @@ -65,8 +71,8 @@ class SubscribeOper(CanonicalSubscribeOper): username=username, ) return await async_add_subscribe( - mediainfo=mediainfo, - subscribe_oper=self, + mediainfo=cast(MediaInfo | MusicInfo, mediainfo), + subscribe_oper=cast(SubscribeWriter, self), **kwargs, ) diff --git a/app/startup/composition/database.py b/app/startup/composition/database.py index 7e86d5b7b..5833406a8 100644 --- a/app/startup/composition/database.py +++ b/app/startup/composition/database.py @@ -20,9 +20,7 @@ from app.db.engine import get_engine from app.db.health import probe_database from app.db.maintenance import DatabaseCleanupRepository from app.db.session import SessionFactory -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() +from app.runtime.settings import get_runtime_setting def build_database_governance() -> DatabaseGovernance: @@ -57,7 +55,7 @@ def configure_database() -> None: def read_backup_policy() -> BackupPolicy: """读取一次可热更新的数据库备份目录与保留策略。""" return BackupPolicy( - root=settings.DATABASE_BACKUP_PATH, - retention_days=settings.DB_BACKUP_RETENTION_DAYS, - max_count=settings.DB_BACKUP_MAX_COUNT, + root=get_runtime_setting('DATABASE_BACKUP_PATH'), + retention_days=get_runtime_setting('DB_BACKUP_RETENTION_DAYS'), + max_count=get_runtime_setting('DB_BACKUP_MAX_COUNT'), ) diff --git a/app/startup/initializers/agent.py b/app/startup/initializers/agent.py index 32dfc131b..08c240194 100644 --- a/app/startup/initializers/agent.py +++ b/app/startup/initializers/agent.py @@ -16,11 +16,9 @@ from app.agent.runtime_loader import ( ) from app.application.agent import register_agent_service_providers from app.application.messaging.skill import register_skill_catalog_provider -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.runtime.events import Event, eventmanager from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting from app.schemas.types import EventType AGENT_BLOCKING_EXECUTOR_SHUTDOWN_TIMEOUT_SECONDS = 10.0 @@ -127,7 +125,7 @@ class AgentInitializer: self._shutdown_started = False self._shutdown_complete = False if agent_manager is not None: - if not settings.AI_AGENT_ENABLE: + if not get_runtime_setting('AI_AGENT_ENABLE'): logger.info("AI智能体功能未启用") return True self._manager = agent_manager diff --git a/app/startup/initializers/database.py b/app/startup/initializers/database.py index ac8ce4ea0..7bdbaf516 100644 --- a/app/startup/initializers/database.py +++ b/app/startup/initializers/database.py @@ -10,13 +10,11 @@ from alembic.util import CommandError from sqlalchemy import inspect from sqlalchemy.engine import Engine -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() from app.db.base import Base from app.db.engine import get_engine from app.db.models import load_all_models from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting from app.startup.composition.database import build_database_governance @@ -27,7 +25,7 @@ def _build_alembic_config(engine: Engine | None = None) -> Config: alembic_cfg.file_config = _ConfigParser(interpolation=None) alembic_cfg.set_main_option( 'script_location', - str(settings.ROOT_PATH / 'database'), + str(get_runtime_setting('ROOT_PATH') / 'database'), ) alembic_cfg.set_main_option( 'sqlalchemy.url', @@ -105,8 +103,8 @@ def prepare_database(*, before_alembic: Callable[[], None] | None = None) -> Non ) if ( requires_migration - and settings.DB_BACKUP_ENABLE - and settings.DB_BACKUP_ON_UPGRADE + and get_runtime_setting('DB_BACKUP_ENABLE') + and get_runtime_setting('DB_BACKUP_ON_UPGRADE') ): current_version = current_heads[0] if current_heads else "未标记" target_version = target_heads[0] diff --git a/app/startup/initializers/domain.py b/app/startup/initializers/domain.py index cea0a5440..48d709fda 100644 --- a/app/startup/initializers/domain.py +++ b/app/startup/initializers/domain.py @@ -1,4 +1,5 @@ from app.adapters.system import rust as rust_accelerator +from app.application.recognition import RecognitionRuleService from app.domain.context import configure_tmdb_image_url_builder from app.domain.media import configure_search_source_provider from app.domain.meta.customization import configure_customization_provider @@ -6,10 +7,7 @@ from app.domain.meta.releasegroup import configure_release_groups_provider from app.domain.meta.runtime import configure_recognition_runtime from app.domain.meta.words import configure_custom_words_provider from app.domain.metainfo import clear_rust_parse_options_cache -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() -from app.application.recognition import RecognitionRuleService +from app.runtime.settings import get_runtime_setting def configure_domain_dependencies() -> None: @@ -18,15 +16,15 @@ def configure_domain_dependencies() -> None: configure_customization_provider(rule_service.get_customization) configure_release_groups_provider(rule_service.get_release_groups) configure_custom_words_provider(rule_service.get_custom_words) - configure_search_source_provider(lambda: settings.SEARCH_SOURCE) - configure_tmdb_image_url_builder(settings.TMDB_IMAGE_URL) + configure_search_source_provider(lambda: get_runtime_setting('SEARCH_SOURCE')) + configure_tmdb_image_url_builder(get_runtime_setting('TMDB_IMAGE_URL')) configure_recognition_runtime( media_extensions_provider=lambda: ( - *settings.RMT_MEDIAEXT, - *settings.RMT_SUBEXT, - *settings.RMT_AUDIOEXT, + *get_runtime_setting('RMT_MEDIAEXT'), + *get_runtime_setting('RMT_SUBEXT'), + *get_runtime_setting('RMT_AUDIOEXT'), ), - audio_extensions_provider=lambda: settings.RMT_AUDIOEXT, + audio_extensions_provider=lambda: get_runtime_setting('RMT_AUDIOEXT'), accelerator=rust_accelerator, ) clear_rust_parse_options_cache() diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 0e45ef3c0..b4cb5f87b 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -22,18 +22,12 @@ except ImportError as e: print(error_message, file=sys.stderr) sys.exit(1) -from app.adapters.system.host import SystemUtils -from app.runtime.config import settings as legacy_settings -from app.runtime.log import logger -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.stop import runtime_stop_state - -settings = RuntimeSettingsCompat() from app.adapters.external.server import ( MoviePilotServerHelper, configure_server_application_services, ) from app.adapters.network.doh import DohHelper +from app.adapters.system.host import SystemUtils from app.adapters.system.resource import ( ResourceHelper, configure_resource_version_provider, @@ -147,6 +141,7 @@ from app.db.uow import ( ) from app.db.worker import DatabaseWorker from app.runtime.cache import AsyncFileCache, FileCache +from app.runtime.config import settings as legacy_settings from app.runtime.events import EventHandlerBinding, EventManager from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher @@ -156,9 +151,15 @@ from app.runtime.extensions.service_config import ( ServiceConfigHelper, configure_service_config_reader, ) +from app.runtime.log import logger from app.runtime.observability import record_metric -from app.runtime.settings import configure_runtime_setting_provider +from app.runtime.settings import ( + configure_runtime_setting_provider, + configure_runtime_setting_updater, + get_runtime_setting, +) from app.runtime.state import SystemHelper +from app.runtime.stop import runtime_stop_state from app.runtime.tasks import get_task_registry from app.runtime.thread import ThreadHelper from app.schemas.message import Message, MessageType @@ -226,7 +227,7 @@ async def _initialize_configuration_services( def _build_runtime_settings_service() -> RuntimeSettingsService: - """将可变部署配置实现注入管理服务,避免把兼容代理再次包装。""" + """将唯一可变部署配置实现注入管理服务。""" return RuntimeSettingsService(legacy_settings) @@ -254,7 +255,7 @@ def _build_chain_runtime_context() -> ChainRuntimeContext: send_callback=callback ), module_dispatcher_factory=ModuleInvocationDispatcher, - configuration=build_chain_runtime_config(settings), + configuration=build_chain_runtime_config(legacy_settings), data_ports=get_chain_data_ports(), durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory), stop_state=runtime_stop_state, @@ -512,11 +513,11 @@ def start_frontend(): or not SystemUtils.is_windows(): return # 临时Nginx目录 - nginx_path = settings.ROOT_PATH / 'nginx' + nginx_path = get_runtime_setting("ROOT_PATH") / 'nginx' if not nginx_path.exists(): return # 配置目录下的Nginx目录 - run_nginx_dir = settings.CONFIG_PATH.with_name('nginx') + run_nginx_dir = get_runtime_setting("CONFIG_PATH").with_name('nginx') if not run_nginx_dir.exists(): # 移动到配置目录 SystemUtils.move(nginx_path, run_nginx_dir) @@ -543,9 +544,15 @@ def clear_temp(): 清理临时文件和图片缓存 """ # 清理临时目录中3天前的文件 - SystemUtils.clear(settings.TEMP_PATH, days=settings.TEMP_FILE_DAYS) + SystemUtils.clear( + get_runtime_setting("TEMP_PATH"), + days=get_runtime_setting("TEMP_FILE_DAYS"), + ) # 清理图片缓存目录中7天前的文件 - SystemUtils.clear(settings.CACHE_PATH / "images", days=settings.GLOBAL_IMAGE_CACHE_DAYS) + SystemUtils.clear( + get_runtime_setting("CACHE_PATH") / "images", + days=get_runtime_setting("GLOBAL_IMAGE_CACHE_DAYS"), + ) # 清理 pip/uv 包下载缓存,不接管整个 .cache 目录。 clear_package_tool_cache() @@ -554,10 +561,10 @@ def clear_package_tool_cache(): """ 清理 pip/uv 包下载缓存,只处理 MoviePilot 管理的工具子目录。 """ - days = settings.PACKAGE_CACHE_DAYS + days = get_runtime_setting("PACKAGE_CACHE_DAYS") if days <= 0: return - tool_cache_root = settings.PACKAGE_CACHE_PATH + tool_cache_root = get_runtime_setting("PACKAGE_CACHE_PATH") for child in ("pip", "uv"): cache_path = tool_cache_root / child try: @@ -593,7 +600,7 @@ def check_auth(): mtype=MessageType.Manual, title="MoviePilot用户认证", text=err_msg, - link=settings.MP_DOMAIN('#/site') + link=get_runtime_setting("MP_DOMAIN")('#/site') ) ) @@ -776,9 +783,9 @@ async def init_modules() -> HostRuntime: }, ) runtime_configuration = RuntimeConfiguration( - api=lambda: build_api_runtime_config(settings), - scheduler=lambda: build_scheduler_runtime_config(settings), - chain=lambda: build_chain_runtime_config(settings), + api=lambda: build_api_runtime_config(legacy_settings), + scheduler=lambda: build_scheduler_runtime_config(legacy_settings), + chain=lambda: build_chain_runtime_config(legacy_settings), ) runtime_settings = _build_runtime_settings_service() agent_chat_persistence = AgentChatPersistenceService( @@ -831,7 +838,8 @@ async def init_modules() -> HostRuntime: configure_runtime_configuration(host_runtime.configuration) configure_runtime_settings(host_runtime.settings) configure_runtime_setting_provider(lambda key: getattr(legacy_settings, key)) - configure_token_runtime_config(lambda: build_token_runtime_config(settings)) + configure_runtime_setting_updater(host_runtime.settings.update) + configure_token_runtime_config(lambda: build_token_runtime_config(legacy_settings)) # 旧 app.api.data 导入只保留 ABI 转发,正式 API 依赖全部读取 HostRuntime。 configure_api_data_runtime(api_data) configure_runtime_data_providers() @@ -856,7 +864,7 @@ async def init_modules() -> HostRuntime: configure_outbox_dispatcher(_build_outbox_dispatcher) configure_transfer_retry_config( lambda: TransferRetryConfig( - max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES, + max_failed_retries=get_runtime_setting("TRANSFER_MAX_FAILED_RETRIES"), ) ) configure_database_governance(build_database_governance()) diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index e8e750d27..870626714 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -3,7 +3,24 @@ import uuid from datetime import datetime, timezone from pathlib import Path +from app.adapters.external.market import ( + LOCAL_REPO_PREFIX, + VERSION_BACKWARD_COMPATIBLE_FLAGS, + PluginHelper, + configure_installed_plugins_provider, + configure_plugin_install_gateway, + split_plugin_market_repo_urls, +) +from app.adapters.external.plugin.client import PluginMarketClient +from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.system.host import SystemUtils +from app.adapters.system.plugin.dependency import PluginDependencyInstaller +from app.adapters.system.plugin.manifest import dependency_manifest_status +from app.adapters.system.plugin.package import PluginPackageManager from app.application.commands import init_commands +from app.application.configuration import get_configured_system_config +from app.application.plugin.catalog import PluginCatalogService +from app.application.plugin.data import DeletePluginDataCommand from app.application.plugin.gateway import ( PluginInstallGateway, configure_plugin_install_service, @@ -32,37 +49,17 @@ from app.application.plugin.transaction import ( get_plugin_persistence, ) from app.application.scheduling import update_plugin_job +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module +from app.db.oper.plugindata import PluginDataOper +from app.db.session import SessionFactory +from app.db.uow import SqlAlchemyUnitOfWork +from app.foundation.version import compare_version from app.runtime.compat.diagnostics import ( configure_legacy_import_diagnostics, scan_plugin_legacy_imports, ) from app.runtime.compat.resource_imports import scan_plugin_resource_imports from app.runtime.config import global_vars -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() -from app.adapters.external.market import ( - LOCAL_REPO_PREFIX, - VERSION_BACKWARD_COMPATIBLE_FLAGS, - PluginHelper, - configure_installed_plugins_provider, - configure_plugin_install_gateway, - split_plugin_market_repo_urls, -) -from app.adapters.external.plugin.client import PluginMarketClient -from app.adapters.external.server import MoviePilotServerHelper -from app.adapters.system.host import SystemUtils -from app.adapters.system.plugin.dependency import PluginDependencyInstaller -from app.adapters.system.plugin.manifest import dependency_manifest_status -from app.adapters.system.plugin.package import PluginPackageManager -from app.application.configuration import get_configured_system_config -from app.application.plugin.catalog import PluginCatalogService -from app.application.plugin.data import DeletePluginDataCommand -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module -from app.db.oper.plugindata import PluginDataOper -from app.db.session import SessionFactory -from app.db.uow import SqlAlchemyUnitOfWork -from app.foundation.version import compare_version from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult from app.runtime.extensions.plugin.storage import ( @@ -83,6 +80,7 @@ from app.runtime.extensions.plugin_manager import ( ) from app.runtime.log import logger from app.runtime.managed_resources import acquire_managed_resource +from app.runtime.settings import get_runtime_setting from app.schemas.exception import PluginMutationRejectedError from app.schemas.plugin import PluginRuntimeStatus from app.schemas.types import SystemConfigKey @@ -130,7 +128,7 @@ def configure_plugin_services() -> None: async def load_inventory(force: bool): """读取本轮配置市场和本地仓库的完整候选事实。""" return await inventory_reader.async_load( - split_plugin_market_repo_urls(settings.PLUGIN_MARKET), + split_plugin_market_repo_urls(get_runtime_setting('PLUGIN_MARKET')), force=force, ) @@ -263,7 +261,7 @@ def configure_plugin_services() -> None: installed_plugins_provider=lambda: get_configured_system_config().get( SystemConfigKey.UserInstalledPlugins ) or [], - plugin_dir=Path(settings.ROOT_PATH) / "app" / "plugins", + plugin_dir=Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins", ), dependency_manifest_status=dependency_manifest_status, compatible_flags=lambda flag: ( diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 0920117c4..56b01b17b 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -26,25 +26,23 @@ try: except Exception: pass -from app.application.plugin.lifecycle import plugin_lifecycle -from app.application.plugin.runtime import get_plugin_manager -from app.chain.system import SystemChain -from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled -from app.runtime.config import global_vars -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.stop import runtime_stop_state - -settings = RuntimeSettingsCompat() from app.adapters.external.server import MoviePilotServerHelper from app.adapters.network.http import ( aclose_shared_async_transports, configure_default_user_agent, ) +from app.application.plugin.lifecycle import plugin_lifecycle +from app.application.plugin.runtime import get_plugin_manager +from app.chain.system import SystemChain from app.db.engine import check_connection_budget, get_engine, get_global_async_engine +from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled +from app.runtime.config import global_vars from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.health import get_application_health from app.runtime.log import LoggerManager, logger +from app.runtime.settings import get_runtime_setting from app.runtime.state import SystemHelper +from app.runtime.stop import runtime_stop_state from app.runtime.tasks import TaskRegistry, configure_task_registry from app.runtime.topology import validate_process_topology from app.startup.initializers.agent import stop_agent @@ -90,7 +88,7 @@ async def init_extra(): """ 同步插件及重启相关依赖服务 """ - if settings.MOVIEPILOT_SAFE_MODE: + if get_runtime_setting('MOVIEPILOT_SAFE_MODE'): SystemHelper().set_system_modified() SystemChain().restart_finish() _log_runtime_gil_status() @@ -355,7 +353,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: ), LifecycleComponent( name="HTTP 基础能力", - start=lambda: configure_default_user_agent(settings.USER_AGENT), + start=lambda: configure_default_user_agent(get_runtime_setting('USER_AGENT')), stop=aclose_shared_async_transports, start_order=20, stop_order=80, @@ -386,7 +384,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: LifecycleComponent( name="路由", dependencies=("数据库连接预算",), - start=lambda: init_routers(app, settings.API_V1_STR), + start=lambda: init_routers(app, get_runtime_setting('API_V1_STR')), start_order=60, start_timeout_seconds=30, ), @@ -584,8 +582,8 @@ async def lifespan(app: FastAPI): active_start_component: LifecycleComponent | None = None try: validate_process_topology( - workers=settings.API_WORKERS, - safe_mode=settings.MOVIEPILOT_SAFE_MODE, + workers=get_runtime_setting('API_WORKERS'), + safe_mode=get_runtime_setting('MOVIEPILOT_SAFE_MODE'), ) print("Starting up...") main_loop_owner = global_vars.set_loop(main_loop) @@ -593,7 +591,7 @@ async def lifespan(app: FastAPI): enabled_components = tuple( component for component in components - if component.enabled(settings.MOVIEPILOT_SAFE_MODE) + if component.enabled(get_runtime_setting('MOVIEPILOT_SAFE_MODE')) ) logger.info( "启用生命周期组件:%s", @@ -611,7 +609,7 @@ async def lifespan(app: FastAPI): ) started_component_names.add(component.name) active_start_component = None - if settings.MOVIEPILOT_SAFE_MODE: + if get_runtime_setting('MOVIEPILOT_SAFE_MODE'): print("MoviePilot safe mode enabled: skip plugins, scheduler, monitor, commands and workflow.") health.mark_ready() except BaseException: diff --git a/docs/refactor/backend-architecture-review.md b/docs/refactor/backend-architecture-review.md index 864a9d7e5..e47249880 100644 --- a/docs/refactor/backend-architecture-review.md +++ b/docs/refactor/backend-architecture-review.md @@ -113,9 +113,11 @@ chain 层零 `app.db` / `app.modules` 内部直连,domain 与 chain 层配置 |---|---|---| | `eventmanager` 单例 | 36 个文件直连 import;其中 chain 层 8 个文件绕过已注入的 `context.event_manager`(search/download/media/subscribe/transfer/site/scraping/workflow) | chain 层直连改为使用注入上下文,改动机械、风险低 | | `global_vars` 容器 | 47 个文件 147 处引用,workflow + chain 占近半,被当"停止信号总线"广泛直读 | 停止信号演进为 `runtime/state.py` 的显式契约 | -| `RuntimeSettingsCompat` | 119 个文件 import(modules 占 60),形式上是端口、用法上仍是每模块全局对象 | modules 层逐步改为注入快照 | +| 运行时 Settings 读取 | 宿主已统一通过 `app.runtime.settings.get_runtime_setting()` 读取;可变部署配置只经 `RuntimeSettingsService` 管理,旧 `settings` 对象仅由插件兼容入口保留 | 新代码使用只读端口或类型化快照,禁止恢复模块级代理 | | Singleton 元类 | 41 处 class 使用,与 getter 门面双轨并存 | 维持双轨兼容,新增能力一律走 getter 门面 | +> 处理进展(2026-08-26):Agent、Module、Adapter、Doctor、Startup、CLI 及入口层已完成 Settings 读取迁移,宿主源码不再导入或实例化旧兼容代理;插件兼容入口继续提供旧 `settings` ABI。后续新增宿主代码必须依赖读取端口、配置服务或不可变快照。 +> > 处理进展(2026-08-24):chain 层 17 处实例方法调用已改用注入的 `self.eventmanager` > (`transfer.py`、`media.py` 已完全脱离全局导入,其余文件因 `@eventmanager.register` > 装饰器注册与 staticmethod 调用点按设计保留全局引用);`media.py` 的 diff --git a/scripts/benchmark_metainfo_rust.py b/scripts/benchmark_metainfo_rust.py index c48fdab58..c509e20ba 100644 --- a/scripts/benchmark_metainfo_rust.py +++ b/scripts/benchmark_metainfo_rust.py @@ -20,7 +20,7 @@ from app.domain.meta.runtime import ( ) from app.domain.metainfo import MetaInfo, MetaInfoPath from app.adapters.system import rust as rust_accel -from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.settings import get_runtime_setting from tests.cases.meta import meta_cases @@ -64,14 +64,13 @@ _MUSIC_CASES: tuple[BenchmarkInput, ...] = ( def configure_benchmark_runtime() -> None: """注入独立基准所需的文件类型和 Rust 适配器,避免静默测量 Python 回退。""" - settings = RuntimeSettingsCompat() configure_recognition_runtime( media_extensions_provider=lambda: ( - *settings.RMT_MEDIAEXT, - *settings.RMT_SUBEXT, - *settings.RMT_AUDIOEXT, + *get_runtime_setting("RMT_MEDIAEXT"), + *get_runtime_setting("RMT_SUBEXT"), + *get_runtime_setting("RMT_AUDIOEXT"), ), - audio_extensions_provider=lambda: settings.RMT_AUDIOEXT, + audio_extensions_provider=lambda: get_runtime_setting("RMT_AUDIOEXT"), accelerator=rust_accel, ) diff --git a/scripts/startup/performance.py b/scripts/startup/performance.py index 2921649b4..9f72334a6 100644 --- a/scripts/startup/performance.py +++ b/scripts/startup/performance.py @@ -104,6 +104,7 @@ from app.testing.bootstrap import ensure_sites_stub ensure_sites_stub() from app.startup import lifecycle +from app.runtime.config import settings as legacy_settings def _noop(): @@ -140,7 +141,7 @@ def _isolated_stop(component): async def _probe(): '''执行一次隔离生命周期并输出资源与耗时样本。''' - lifecycle.settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r} + legacy_settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r} lifecycle.init_extra = _async_noop lifecycle.global_vars.set_loop = lambda loop: None lifecycle.global_vars.stop_system = lambda: None diff --git a/tests/conftest.py b/tests/conftest.py index 61f95f151..b12474988 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,71 @@ class _TestDatabaseExecutor: return await asyncio.to_thread(operation) +class _TestRuntimeSettingsProxy: + """为仍需覆盖旧配置字段的测试提供局部桩,不回到宿主模块级代理。""" + + def __init__(self) -> None: + self._originals: dict[str, tuple[bool, object]] = {} + + def __getattr__(self, key: str): + from app.runtime.config import settings + + return getattr(settings, key) + + def __setattr__(self, key: str, value): + if key == "_originals": + object.__setattr__(self, key, value) + return + from app.runtime.config import settings + + if key not in self._originals: + self._originals[key] = (hasattr(settings, key), getattr(settings, key, None)) + setattr(settings, key, value) + + def __delattr__(self, key: str) -> None: + if key in self._originals: + from app.runtime.config import settings + + had_value, original = self._originals.pop(key) + if had_value: + setattr(settings, key, original) + elif hasattr(settings, key): + delattr(settings, key) + return + raise AttributeError(key) + + +@pytest.fixture(autouse=True) +def install_runtime_settings_test_proxies(monkeypatch): + """给历史测试 patch 点注入测试专用对象,生产代码不保留 settings 属性。""" + proxy = _TestRuntimeSettingsProxy() + _install_runtime_settings_test_proxies(proxy, monkeypatch) + yield + + +def _install_runtime_settings_test_proxies(proxy, monkeypatch=None) -> None: + """把测试专用 patch 点补到当前已导入的 Agent/模块。""" + for module_name, module in tuple(sys.modules.items()): + if not ( + module_name.startswith("app.modules.") + or module_name.startswith("app.agent.") + or module_name.startswith("app.startup.") + or module_name == "app.main" + or module_name.startswith("app.adapters.") + ): + continue + if hasattr(module, "get_runtime_setting") and "settings" not in vars(module): + if monkeypatch is None: + setattr(module, "settings", proxy) + else: + monkeypatch.setattr(module, "settings", proxy, raising=False) + + +def pytest_runtest_call(item): + """显式 fixture 期间才导入的模块也要拥有同一个测试 patch 点。""" + _install_runtime_settings_test_proxies(_TestRuntimeSettingsProxy()) + + @pytest.fixture(autouse=True) def configure_plugin_system_services(): """为绕过完整启动流程的单元测试装配真实插件系统适配器。""" diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json index f871c11ac..e4c30f464 100644 --- a/tests/fixtures/architecture/configuration-debt-baseline.json +++ b/tests/fixtures/architecture/configuration-debt-baseline.json @@ -10,21 +10,8 @@ ] }, "foundational_settings_boundaries": { - "count": 3, - "entries": [ - { - "file": "app/db/base.py", - "reason": "模型声明阶段必须在运行时配置服务装配前确定数据库主键类型" - }, - { - "file": "app/db/engine.py", - "reason": "数据库引擎是运行时配置服务的底层依赖,不能通过兼容代理自递归" - }, - { - "file": "app/db/session.py", - "reason": "数据库会话与连接配额必须在应用组合根装配前可用" - } - ] + "count": 0, + "entries": [] }, "schema_version": 2, "scope": { diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index acc28bd07..bc572dd47 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": 6809, - "edge_sha256": "0e5ab6c1e8d428de4edbc0ee162f17130143946c483967aaf49bca8c7c75b105", + "edge_count": 6807, + "edge_sha256": "93c39ca5828e23fcb5e4b33ffea101cb1ebc9a4ef6fac1317e2ebd482592ecdd", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -24,12 +24,10 @@ "app.adapters.cache.backends -> app.adapters.cache.redis", "app.adapters.cache.backends -> app.runtime", "app.adapters.cache.backends -> app.runtime.cache", - "app.adapters.cache.backends -> app.runtime.config", "app.adapters.cache.backends -> app.runtime.settings", "app.adapters.cache.redis -> app.foundation", "app.adapters.cache.redis -> app.foundation.singleton", "app.adapters.cache.redis -> app.runtime", - "app.adapters.cache.redis -> app.runtime.config", "app.adapters.cache.redis -> app.runtime.log", "app.adapters.cache.redis -> app.runtime.reload", "app.adapters.cache.redis -> app.runtime.settings", @@ -171,7 +169,6 @@ "app.adapters.system.resource -> app.foundation", "app.adapters.system.resource -> app.foundation.version", "app.adapters.system.resource -> app.runtime", - "app.adapters.system.resource -> app.runtime.config", "app.adapters.system.resource -> app.runtime.log", "app.adapters.system.resource -> app.runtime.settings", "app.adapters.system.rust -> app.foundation", @@ -447,11 +444,14 @@ "app.agent.skills.registry -> app.agent", "app.agent.skills.registry -> app.agent.skills", "app.agent.skills.registry -> app.agent.skills.metadata", + "app.agent.skills.registry -> app.application", + "app.agent.skills.registry -> app.application.configuration", "app.agent.skills.registry -> app.foundation", "app.agent.skills.registry -> app.foundation.singleton", "app.agent.skills.registry -> app.foundation.url", "app.agent.skills.registry -> app.runtime", "app.agent.skills.registry -> app.runtime.cache", + "app.agent.skills.registry -> app.runtime.config", "app.agent.skills.registry -> app.runtime.log", "app.agent.skills.registry -> app.runtime.settings", "app.agent.tools.base -> app.agent", @@ -2556,8 +2556,6 @@ "app.application.chain.durable_events -> app.schemas.types", "app.application.configuration -> app.application", "app.application.configuration -> app.application.database", - "app.application.configuration -> app.runtime", - "app.application.configuration -> app.runtime.settings", "app.application.configuration -> app.schemas", "app.application.configuration -> app.schemas.types", "app.application.dashboard -> app.schemas", @@ -3616,6 +3614,7 @@ "app.chain.workflow -> app.schemas.workflow", "app.cli -> app.application", "app.cli -> app.application.backup", + "app.cli -> app.application.configuration", "app.cli -> app.doctor", "app.cli -> app.doctor.formatters", "app.cli -> app.runtime", @@ -3717,7 +3716,7 @@ "app.db.base -> app.db", "app.db.base -> app.db.uow", "app.db.base -> app.runtime", - "app.db.base -> app.runtime.config", + "app.db.base -> app.runtime.settings", "app.db.decorators -> app.db", "app.db.decorators -> app.db.session", "app.db.decorators -> app.runtime", @@ -3730,9 +3729,9 @@ "app.db.engine -> app.foundation", "app.db.engine -> app.foundation.environment", "app.db.engine -> app.runtime", - "app.db.engine -> app.runtime.config", "app.db.engine -> app.runtime.log", "app.db.engine -> app.runtime.observability", + "app.db.engine -> app.runtime.settings", "app.db.health -> app.db", "app.db.health -> app.db.session", "app.db.maintenance -> app.db", @@ -3932,6 +3931,7 @@ "app.db.session -> app.runtime.config", "app.db.session -> app.runtime.log", "app.db.session -> app.runtime.observability", + "app.db.session -> app.runtime.settings", "app.db.worker -> app.runtime", "app.db.worker -> app.runtime.observability", "app.db.worker -> app.schemas", @@ -5102,7 +5102,6 @@ "app.modules.qqbot.qqbot -> app.runtime", "app.modules.qqbot.qqbot -> app.runtime.cache", "app.modules.qqbot.qqbot -> app.runtime.log", - "app.modules.qqbot.qqbot -> app.runtime.settings", "app.modules.qqbot.qqbot -> app.runtime.thread", "app.modules.redis -> app.adapters", "app.modules.redis -> app.adapters.cache", @@ -5643,7 +5642,6 @@ "app.modules.wechat.wechatbot -> app.runtime", "app.modules.wechat.wechatbot -> app.runtime.cache", "app.modules.wechat.wechatbot -> app.runtime.log", - "app.modules.wechat.wechatbot -> app.runtime.settings", "app.modules.wechat.wechatbot -> app.runtime.thread", "app.modules.wechat.wechatbot -> app.schemas", "app.modules.wechat.wechatbot -> app.schemas.message", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 52c8f6b14..722255925 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 378, + "loaded_app_module_count": 379, "max_ms": 909.62, "median_ms": 908.975, "min_ms": 904.929, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 390, + "loaded_app_module_count": 391, "max_ms": 952.709, "median_ms": 934.785, "min_ms": 916.888, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 392, + "loaded_app_module_count": 393, "max_ms": 947.928, "median_ms": 938.251, "min_ms": 932.597, diff --git a/tests/test_agent_lifecycle.py b/tests/test_agent_lifecycle.py index bf2d5cdef..16b7eb94a 100644 --- a/tests/test_agent_lifecycle.py +++ b/tests/test_agent_lifecycle.py @@ -81,7 +81,7 @@ async def test_agent_entrypoint_initializes_on_calling_loop(monkeypatch) -> None initialized_loops.append(asyncio.get_running_loop()) manager.initialize.side_effect = initialize - monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True) + _patch_agent_settings(monkeypatch, True) monkeypatch.setattr(agent_initializer, "agent_manager", manager) monkeypatch.setattr( agent_initializer, @@ -134,7 +134,7 @@ async def test_agent_entrypoint_reuses_tasks_and_closes_idempotently( memory_manager = MemoryManager() initializer = agent_initializer.AgentInitializer() monkeypatch.setattr(agent_module, "memory_manager", memory_manager) - monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True) + _patch_agent_settings(monkeypatch, True) monkeypatch.setattr(agent_initializer, "agent_manager", manager) monkeypatch.setattr(agent_initializer, "agent_initializer", initializer) @@ -162,7 +162,7 @@ async def test_agent_initialization_failure_does_not_stop_module_startup( """Agent 初始化异常只关闭该能力,基础模块仍继续完成启动。""" manager = AsyncMock() manager.initialize.side_effect = RuntimeError("agent init failed") - monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True) + _patch_agent_settings(monkeypatch, True) monkeypatch.setattr(agent_initializer, "agent_manager", manager) monkeypatch.setattr( agent_initializer, @@ -214,7 +214,7 @@ async def test_agent_initialization_failure_does_not_stop_module_startup( async def test_disabled_agent_does_not_create_background_tasks(monkeypatch) -> None: """Agent 未启用时启动入口不得创建运行时任务。""" manager = AsyncMock() - monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", False) + _patch_agent_settings(monkeypatch, False) monkeypatch.setattr(agent_initializer, "agent_manager", manager) monkeypatch.setattr( agent_initializer, @@ -903,3 +903,10 @@ async def test_session_worker_restarts_after_idle_timeout_races_with_full_enqueu if manager._accepting_tasks: await manager.clear_session(session_id, "1") await manager.close() +def _patch_agent_settings(monkeypatch, enabled: bool) -> None: + """注入 Agent 启动测试所需的只读配置。""" + monkeypatch.setattr( + agent_initializer, + "get_runtime_setting", + lambda key: enabled if key == "AI_AGENT_ENABLE" else None, + ) diff --git a/tests/test_agent_llm_capability.py b/tests/test_agent_llm_capability.py index a6c19ccee..457eaad64 100644 --- a/tests/test_agent_llm_capability.py +++ b/tests/test_agent_llm_capability.py @@ -82,13 +82,17 @@ class AgentCapabilityManagerTest(unittest.TestCase): provider, "_build_client", return_value=fake_client ), patch.object( capability_module, - "settings", - SimpleNamespace( + "get_runtime_setting", + side_effect=lambda key, default=None: getattr( + SimpleNamespace( TEMP_PATH=Path(temp_dir), AUDIO_OUTPUT_MODEL="gpt-4o-audio-preview", AUDIO_OUTPUT_VOICE="alloy", AUDIO_OUTPUT_API_KEY="sk-test", AUDIO_OUTPUT_BASE_URL="https://example.com/v1", + ), + key, + default, ), ), patch.object(provider, "_convert_wav_to_opus", return_value=None): output_path = provider.synthesize_speech("你好") @@ -249,13 +253,17 @@ class AgentCapabilityManagerTest(unittest.TestCase): provider, "_build_client", return_value=fake_client ), patch.object( capability_module, - "settings", - SimpleNamespace( + "get_runtime_setting", + side_effect=lambda key, default=None: getattr( + SimpleNamespace( TEMP_PATH=Path(temp_dir), AUDIO_OUTPUT_MODEL="mimo-v2.5-tts", AUDIO_OUTPUT_VOICE="冰糖", AUDIO_OUTPUT_API_KEY="sk-test", AUDIO_OUTPUT_BASE_URL="https://api.xiaomimimo.com/v1", + ), + key, + default, ), ), patch.object(provider, "_convert_wav_to_opus", return_value=None): output_path = provider.synthesize_speech("你好") @@ -370,14 +378,18 @@ class AgentCapabilityManagerTest(unittest.TestCase): capability_module, "RequestUtils", return_value=request_utils ) as request_utils_cls, patch.object( capability_module, - "settings", - SimpleNamespace( + "get_runtime_setting", + side_effect=lambda key, default=None: getattr( + SimpleNamespace( TEMP_PATH=Path(temp_dir), PROXY={}, AUDIO_OUTPUT_MODEL="gpt-4o-mini-tts", AUDIO_OUTPUT_VOICE="alloy", AUDIO_OUTPUT_API_KEY="sk-test", AUDIO_OUTPUT_BASE_URL="https://api.minimaxi.com/anthropic/v1", + ), + key, + default, ), ): output_path = provider.synthesize_speech("你好") diff --git a/tests/test_agent_task_runs.py b/tests/test_agent_task_runs.py index caf4e9e8c..388c19312 100644 --- a/tests/test_agent_task_runs.py +++ b/tests/test_agent_task_runs.py @@ -365,7 +365,15 @@ async def test_query_task_returns_owner_scoped_ten_recent_runs(monkeypatch) -> N @pytest.mark.anyio async def test_agent_manager_records_manual_trigger_source(monkeypatch) -> None: """真实执行入口应把手动触发来源写入对应 run。""" - monkeypatch.setattr("app.agent.orchestrator.settings.AI_AGENT_ENABLE", True) + from app.agent import orchestrator + from app.runtime.config import settings + + monkeypatch.setattr(settings, "AI_AGENT_ENABLE", True) + monkeypatch.setattr( + orchestrator, + "get_runtime_setting", + lambda key, default=None: getattr(settings, key, default), + ) task = _add_task("run-manager") manager = AgentManager() captured = {} diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index 890ae210d..89acbfb0a 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -195,11 +195,10 @@ def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None: "count": 0, "calls": [], } - assert { - entry["file"] - for entry in baseline["foundational_settings_boundaries"]["entries"] - } == {"app/db/base.py", "app/db/engine.py", "app/db/session.py"} - assert baseline["foundational_settings_boundaries"]["count"] == 3 + assert baseline["foundational_settings_boundaries"] == { + "count": 0, + "entries": [], + } assert baseline["composition_root_oper_boundaries"]["count"] == 1 assert baseline["composition_root_oper_boundaries"]["entries"][0]["file"] == ( "app/startup/initializers/modules.py" diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 79182d1df..11d49bb8b 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -1310,6 +1310,89 @@ def test_modules_read_deployment_settings_through_runtime_port(): assert violations == [] +def test_runtime_implementation_does_not_use_legacy_settings_proxy(): + """runtime 实现只能使用只读配置端口,不得重新引入迁移期代理对象。""" + violations: list[str] = [] + for path in (APP_ROOT / "runtime").rglob("*.py"): + if path == APP_ROOT / "runtime" / "settings.py": + continue + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + if node.module != "app.runtime.settings": + continue + if any(alias.name.lower().endswith("compat") for alias in node.names): + violations.append(path.relative_to(PROJECT_ROOT).as_posix()) + break + + assert violations == [] + + +def test_deprecated_settings_proxy_imports_are_zero(): + """宿主代码不得导入已删除的 Settings 兼容代理。""" + limits = { + "adapters": 0, + "agent": 0, + "application": 0, + "cli.py": 0, + "doctor": 0, + "factory.py": 0, + "main.py": 0, + "modules": 0, + "startup": 0, + } + counts: dict[str, int] = {} + for path in APP_ROOT.rglob("*.py"): + if path == APP_ROOT / "runtime" / "settings.py": + continue + if path.is_relative_to(APP_ROOT / "plugins"): + continue + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + imports_compat = any( + isinstance(node, ast.ImportFrom) + and node.module == "app.runtime.settings" + and any(alias.name.lower().endswith("compat") for alias in node.names) + for node in ast.walk(tree) + ) + if not imports_compat: + continue + relative = path.relative_to(APP_ROOT) + group = relative.parts[0] if len(relative.parts) > 1 else relative.as_posix() + counts[group] = counts.get(group, 0) + 1 + + unexpected = set(counts) - set(limits) + exceeded = { + group: count + for group, count in counts.items() + if group in limits and count > limits[group] + } + assert unexpected == set() + assert exceeded == {} + + +def test_global_settings_imports_stay_within_compatibility_baseline(): + """真实 Settings 对象只能保留在已知迁移点和插件 SDK,不得产生新宿主调用。""" + allowed = { + "app/sdk/config.py", + "app/startup/initializers/modules.py", + } + imports: set[str] = set() + for path in APP_ROOT.rglob("*.py"): + if path.is_relative_to(APP_ROOT / "plugins"): + continue + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + if any( + isinstance(node, ast.ImportFrom) + and node.module == "app.runtime.config" + and any(alias.name == "settings" for alias in node.names) + for node in ast.walk(tree) + ): + imports.add(path.relative_to(PROJECT_ROOT).as_posix()) + + assert imports <= allowed + + def test_api_does_not_import_factory(): """装配器(factory)只允许 app.main 使用,HTTP 端点不得回引。""" violations: dict[str, set[str]] = {} diff --git a/tests/test_browser_helper.py b/tests/test_browser_helper.py index e9179fc4e..f01988471 100644 --- a/tests/test_browser_helper.py +++ b/tests/test_browser_helper.py @@ -177,7 +177,10 @@ def test_default_emulation_uses_cloakbrowser_context(): page = _FakePage() context = _FakeContext([page]) - with patch("app.adapters.network.browser.settings.BROWSER_EMULATION", "cloakbrowser"), patch.object( + with patch( + "app.adapters.network.browser.get_runtime_setting", + return_value="cloakbrowser", + ), patch.object( PlaywrightHelper, "_PlaywrightHelper__launch_cloakbrowser_context", return_value=context, @@ -206,7 +209,10 @@ def test_legacy_playwright_emulation_uses_cloakbrowser_context(): page = _FakePage() context = _FakeContext([page]) - with patch("app.adapters.network.browser.settings.BROWSER_EMULATION", "Playwright"), patch.object( + with patch( + "app.adapters.network.browser.get_runtime_setting", + return_value="Playwright", + ), patch.object( PlaywrightHelper, "_PlaywrightHelper__launch_cloakbrowser_context", return_value=context, diff --git a/tests/test_cli_auto_update.py b/tests/test_cli_auto_update.py index 7c02b863d..1e74d9a36 100644 --- a/tests/test_cli_auto_update.py +++ b/tests/test_cli_auto_update.py @@ -79,6 +79,12 @@ def load_cli_module(): with patch.dict(sys.modules, stub_modules): spec.loader.exec_module(module) + # CLI 生产代码只依赖读取端口;这个动态加载器仍提供旧字段 patch 点, + # 让历史更新流程测试可以独立于全局测试配置运行。 + module.settings = settings + module.get_runtime_setting = lambda key, default=None: getattr( + settings, key, default + ) return module diff --git a/tests/test_configuration_initializer.py b/tests/test_configuration_initializer.py index 5ab7cbb6d..60b22b21b 100644 --- a/tests/test_configuration_initializer.py +++ b/tests/test_configuration_initializer.py @@ -7,7 +7,6 @@ import pytest from app.startup.initializers import modules as modules_initializer from app.startup.lifecycle import initialize_modules_component from app.application.configuration import configure_runtime_settings -from app.runtime.settings import RuntimeSettingsCompat class _InlineWorker: @@ -46,18 +45,17 @@ class _MutableSettings: return True, "" -def test_runtime_settings_compat_uses_legacy_settings_from_startup_root(monkeypatch) -> None: - """组合根装配的兼容代理应读写原始部署配置而不是自身。""" +def test_runtime_settings_service_uses_legacy_settings_from_startup_root(monkeypatch) -> None: + """组合根装配的设置服务应直接读写唯一部署配置对象。""" legacy_settings = _MutableSettings() monkeypatch.setattr(modules_initializer, "legacy_settings", legacy_settings) service = modules_initializer._build_runtime_settings_service() configure_runtime_settings(service) - compat = RuntimeSettingsCompat() - assert compat.model_dump(include={"VALUE"}) == {"VALUE": "before"} - assert compat.update_setting("VALUE", "after") == (True, "") - assert compat.model_dump(include={"VALUE"}) == {"VALUE": "after"} + assert service.snapshot(include={"VALUE"}) == {"VALUE": "before"} + assert service.update("VALUE", "after") == (True, "") + assert service.snapshot(include={"VALUE"}) == {"VALUE": "after"} @pytest.mark.asyncio diff --git a/tests/test_configuration_ports.py b/tests/test_configuration_ports.py index c79fa355e..8f399b801 100644 --- a/tests/test_configuration_ports.py +++ b/tests/test_configuration_ports.py @@ -20,7 +20,6 @@ from app.application.configuration import ( get_transfer_retry_config, ) from app.application.security.userconfig import UserConfigurationService -from app.runtime.settings import RuntimeSettingsCompat, configure_runtime_settings_compat class _InlineDatabaseExecutor: @@ -72,14 +71,11 @@ def test_runtime_settings_service_hides_mutable_settings_implementation() -> Non assert service.get("VALUE") == "final" -def test_runtime_settings_compat_delegates_to_concrete_service_backend() -> None: - """兼容 Settings 代理委托到真实设置对象时不会在 model_dump 中递归。""" +def test_runtime_settings_service_is_the_only_mutable_settings_port() -> None: + """可变部署设置只通过应用服务暴露,不再提供宿主级兼容代理。""" service = RuntimeSettingsService(_MutableSettings()) - configure_runtime_settings_compat(service) - assert RuntimeSettingsCompat().model_dump(include={"VALUE"}) == { - "VALUE": "before" - } + assert service.snapshot(include={"VALUE"}) == {"VALUE": "before"} def test_system_config_service_supports_separate_reader_and_writer() -> None: diff --git a/tests/test_downloader_path_mapping.py b/tests/test_downloader_path_mapping.py index acc513c7f..45a4ad069 100644 --- a/tests/test_downloader_path_mapping.py +++ b/tests/test_downloader_path_mapping.py @@ -272,13 +272,6 @@ def _load_transmission_module(): def get(self, *_args, **_kwargs): return None - class _RuntimeSettingsCompat: - """隔离测试用动态配置代理,保持生产模块的兼容读取语义。""" - - def __getattr__(self, key): - """从测试提供的旧 Settings 桩读取配置项。""" - return getattr(config_module.settings, key) - transmission_client_module.Transmission = object cache_module.FileCache = _FileCache schema_transfer_module.TransferTorrent = _TransferTorrent @@ -294,7 +287,9 @@ def _load_transmission_module(): "DownloaderType", {"Transmission": "Transmission"} ) config_module.settings = SimpleNamespace(TORRENT_TAG="moviepilot-tag") - runtime_settings_module.RuntimeSettingsCompat = _RuntimeSettingsCompat + runtime_settings_module.get_runtime_setting = lambda key, default=None: getattr( + config_module.settings, key, default + ) metainfo_module.MetaInfo = _MetaInfo log_module.logger = _Logger() modules_module._ModuleBase = _ModuleBase diff --git a/tests/test_fs_proxy_copy.py b/tests/test_fs_proxy_copy.py index d0bbdb066..1d9fab2be 100644 --- a/tests/test_fs_proxy_copy.py +++ b/tests/test_fs_proxy_copy.py @@ -150,7 +150,11 @@ def test_copy_falls_back_to_direct_when_disabled(tmp_path, monkeypatch): """ import app.adapters.system.fsproxy as fsproxy_module - monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False) + monkeypatch.setattr( + fsproxy_module, + "get_runtime_setting", + lambda key, default=None: False if key == "FS_PROXY_ENABLED" else default, + ) src = tmp_path / "a.mkv" src.write_bytes(b"direct" * 100) dst = tmp_path / "b.mkv" @@ -171,7 +175,11 @@ def test_direct_copy_honours_cancel(tmp_path, monkeypatch): """ import app.adapters.system.fsproxy as fsproxy_module - monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False) + monkeypatch.setattr( + fsproxy_module, + "get_runtime_setting", + lambda key, default=None: False if key == "FS_PROXY_ENABLED" else default, + ) src = tmp_path / "a.mkv" src.write_bytes(b"x" * 8192) dst = tmp_path / "b.mkv" diff --git a/tests/test_host_runtime_context.py b/tests/test_host_runtime_context.py index 72df803c7..14a9183ca 100644 --- a/tests/test_host_runtime_context.py +++ b/tests/test_host_runtime_context.py @@ -211,6 +211,8 @@ def test_string_api_data_locator_is_confined_to_compatibility_boundary() -> None """字符串数据注册表只能由 startup 注入并经旧 Facade 转发。""" importers = set() for path in (PROJECT_ROOT / "app").rglob("*.py"): + if path.is_relative_to(PROJECT_ROOT / "app" / "plugins"): + continue tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) imported_modules = { node.module diff --git a/tests/test_legacy_plugin_resource_imports.py b/tests/test_legacy_plugin_resource_imports.py index dc1c040d3..141941c2d 100644 --- a/tests/test_legacy_plugin_resource_imports.py +++ b/tests/test_legacy_plugin_resource_imports.py @@ -5,21 +5,20 @@ from __future__ import annotations import importlib import os from pathlib import Path -from types import ModuleType, SimpleNamespace +from types import ModuleType import pytest from app.runtime.compat import resource_imports from app.runtime.compat.resource_imports import ( - PluginResourceImportScanError, RESOURCE_IMPORT_RULES, + PluginResourceImportScanError, scan_plugin_resource_imports, ) from app.runtime.extensions import plugin_manager as plugin_manager_module from app.runtime.extensions.plugin_manager import PluginManager from app.startup.initializers import plugins as plugins_initializer - _HEADED_CLOAKBROWSER_ENTRYPOINTS = ( "launch", "launch_async", @@ -317,8 +316,12 @@ def test_plugin_preparer_runs_before_import_in_non_debug_and_isolates_failures( monkeypatch.setattr( plugin_manager_module, - "settings", - SimpleNamespace(ROOT_PATH=tmp_path, DEBUG=False), + "get_runtime_setting", + lambda key, default=None: { + "ROOT_PATH": tmp_path, + "DEBUG": False, + "DEV": False, + }.get(key, default), ) monkeypatch.setattr( plugin_manager_module, diff --git a/tests/test_llm_helper_testcall.py b/tests/test_llm_helper_testcall.py index a31072e89..7b0af0a2c 100644 --- a/tests/test_llm_helper_testcall.py +++ b/tests/test_llm_helper_testcall.py @@ -146,6 +146,10 @@ with stub_modules({"app.runtime.config": _config_stub, "app.runtime.log": _log_s llm_module = importlib.util.module_from_spec(spec) assert spec and spec.loader spec.loader.exec_module(llm_module) +llm_module.settings = _config_stub.settings +llm_module.get_runtime_setting = lambda key, default=None: getattr( + _config_stub.settings, key, default +) class _OfflineProviderManager: diff --git a/tests/test_message_ingress.py b/tests/test_message_ingress.py index c4eed2aac..8c9504797 100644 --- a/tests/test_message_ingress.py +++ b/tests/test_message_ingress.py @@ -20,6 +20,16 @@ from app.modules.wechatclawbot import wechatclawbot as clawbot_module PROJECT_ROOT = Path(__file__).resolve().parents[1] +def _patch_ingress_settings(monkeypatch, **values): + """通过只读运行配置端口注入回环入口测试设置。""" + settings = SimpleNamespace(**values) + monkeypatch.setattr( + ingress, + "get_runtime_setting", + lambda key: getattr(settings, key), + ) + + def test_forward_message_to_host_encodes_source_and_closes_response(monkeypatch): """统一入口必须安全编码查询参数并释放本地 HTTP 响应。""" response = SimpleNamespace(status_code=200, close=MagicMock()) @@ -27,11 +37,7 @@ def test_forward_message_to_host_encodes_source_and_closes_response(monkeypatch) request = MagicMock() request.post_res = post_res request_factory = MagicMock(return_value=request) - monkeypatch.setattr( - ingress, - "settings", - SimpleNamespace(PORT=3000, API_TOKEN="token value"), - ) + _patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value") monkeypatch.setattr(ingress, "RequestUtils", request_factory) assert ingress.forward_message_to_host( @@ -60,11 +66,7 @@ def test_forward_message_to_host_rejects_unconfirmed_response( response = SimpleNamespace(status_code=status_code, close=MagicMock()) request = MagicMock() request.post_res.return_value = response - monkeypatch.setattr( - ingress, - "settings", - SimpleNamespace(PORT=3000, API_TOKEN="token"), - ) + _patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token") monkeypatch.setattr(ingress, "RequestUtils", MagicMock(return_value=request)) assert ingress.forward_message_to_host({}, "channel") is False @@ -78,11 +80,7 @@ async def test_async_forward_message_to_host_uses_same_contract(monkeypatch): request = MagicMock() request.post_res = AsyncMock(return_value=response) request_factory = MagicMock(return_value=request) - monkeypatch.setattr( - ingress, - "settings", - SimpleNamespace(PORT=3000, API_TOKEN="token value"), - ) + _patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value") monkeypatch.setattr(ingress, "AsyncRequestUtils", request_factory) assert await ingress.async_forward_message_to_host( diff --git a/tests/test_plugin_backup_restore.py b/tests/test_plugin_backup_restore.py index d23ad2467..0b1bcfea0 100644 --- a/tests/test_plugin_backup_restore.py +++ b/tests/test_plugin_backup_restore.py @@ -46,10 +46,11 @@ def _patch_market_paths(monkeypatch, tmp_path: Path) -> tuple[Path, Path]: plugin_root.mkdir(parents=True) config_dir.mkdir(parents=True) monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root) + runtime_settings = SimpleNamespace(CONFIG_PATH=config_dir) monkeypatch.setattr( market_module, - "settings", - SimpleNamespace(CONFIG_PATH=config_dir), + "get_runtime_setting", + lambda key, default=None: getattr(runtime_settings, key, default), ) monkeypatch.setattr( market_module.SystemUtils, diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 508dd493e..02a2c68d8 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -12,22 +12,62 @@ from types import ModuleType, SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import pytest - from packaging.requirements import Requirement from packaging.version import Version - PLUGIN_ID = "DemoPlugin" REPO_URL = "https://github.com/demo/MoviePilot-Plugins" +def _patch_catalog_settings(monkeypatch, **values) -> None: + """通过只读端口注入插件目录测试需要的部署配置。""" + from app.runtime.extensions.plugin import catalog as catalog_module + + settings = SimpleNamespace(**values) + monkeypatch.setattr( + catalog_module, + "get_runtime_setting", + lambda key: getattr(settings, key), + ) + + @pytest.fixture(autouse=True) def _configure_plugin_catalog_factory(monkeypatch): """为直接构造 PluginManager 的测试注入真实目录用例和假持久化接缝。""" + from app.adapters.external import market as market_module from app.adapters.external.plugin.client import PluginMarketClient from app.application.plugin.catalog import PluginCatalogService from app.foundation.version import compare_version from app.runtime.extensions import plugin_manager as manager_module + original_runtime_setting = market_module.get_runtime_setting + + class _SettingsStub(SimpleNamespace): + """允许存量用例覆盖尚未显式声明的配置键。""" + + def __getattr__(self, _key): + return None + + market_settings = _SettingsStub( + VERSION_FLAG="v3", + ROOT_PATH=original_runtime_setting("ROOT_PATH"), + TEMP_PATH=original_runtime_setting("TEMP_PATH"), + CONFIG_PATH=original_runtime_setting("CONFIG_PATH"), + PACKAGE_CACHE_PATH=original_runtime_setting("PACKAGE_CACHE_PATH"), + PIP_PROXY=original_runtime_setting("PIP_PROXY"), + PROXY_HOST=original_runtime_setting("PROXY_HOST"), + REPO_GITHUB_HEADERS=original_runtime_setting("REPO_GITHUB_HEADERS"), + PLUGIN_LOCAL_REPO_PATHS="", + ) + monkeypatch.setattr(market_module, "settings", market_settings, raising=False) + monkeypatch.setattr( + market_module, + "get_runtime_setting", + lambda key, default=None: ( + getattr(market_module.settings, key) + if hasattr(market_module.settings, key) + else original_runtime_setting(key, default) + ), + ) def build_catalog(manager): """按生产组合方式连接目录服务,但保留测试可替换的依赖。""" @@ -231,8 +271,8 @@ class TestPluginHelper: 插件库强制刷新时远端索引 URL 也要变化,避免命中镜像或代理缓存。 """ try: - from app.runtime.cache import fresh from app.adapters.external.market import PluginHelper + from app.runtime.cache import fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -342,8 +382,8 @@ class TestPluginHelper: 插件市场强制刷新时 Release 列表请求也要绕过 GitHub 镜像或代理缓存。 """ try: - from app.runtime.cache import fresh from app.adapters.external.market import PluginHelper + from app.runtime.cache import fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -442,8 +482,8 @@ class TestPluginHelper: 同一仓库的并发强制刷新共享一个请求任务,避免缓存失效瞬间放大 GitHub 请求。 """ try: - from app.runtime.cache import async_fresh from app.adapters.external.market import PluginHelper + from app.runtime.cache import async_fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -484,8 +524,8 @@ class TestPluginHelper: def test_async_forced_release_refresh_does_not_reuse_normal_read_task(self, monkeypatch): """强刷等待在途普通读取后再请求,最终缓存必须保留强刷结果。""" try: - from app.runtime.cache import async_fresh from app.adapters.external.market import PluginHelper + from app.runtime.cache import async_fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -594,8 +634,8 @@ class TestPluginHelper: def test_async_normal_release_read_does_not_wait_for_pending_force_refresh(self, monkeypatch): """普通读取遇到后台强刷时仍优先返回已有缓存,避免页面响应被强刷阻塞。""" try: - from app.runtime.cache import async_fresh from app.adapters.external.market import PluginHelper + from app.runtime.cache import async_fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -697,8 +737,8 @@ class TestPluginHelper: def test_failed_forced_release_refresh_preserves_cached_repository_payload(self, monkeypatch): """GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。""" try: - from app.runtime.cache import fresh from app.adapters.external.market import PluginHelper + from app.runtime.cache import fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -730,8 +770,8 @@ class TestPluginHelper: 插件市场 labels 为列表时应转换为字符串,避免响应模型序列化异常。 """ try: - from app.runtime.extensions.plugin_manager import PluginManager from app.adapters.external.market import PluginHelper + from app.runtime.extensions.plugin_manager import PluginManager except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -747,7 +787,7 @@ class TestPluginHelper: plugin_manager = PluginManager() monkeypatch.setattr(plugin_manager, "_plugins", {}) monkeypatch.setattr(plugin_manager, "_running_plugins", {}) - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", SimpleNamespace(VERSION_FLAG="v2")) + _patch_catalog_settings(monkeypatch, VERSION_FLAG="v2") monkeypatch.setattr( "app.runtime.extensions.plugin_manager.get_plugin_storage", lambda: SimpleNamespace(read=lambda _key: []), @@ -770,8 +810,8 @@ class TestPluginHelper: package.v2.json 中的 v2 原生插件,并过滤掉未声明任何版本兼容的 v1 插件。 """ try: - from app.runtime.extensions.plugin_manager import PluginManager from app.adapters.external.market import PluginHelper + from app.runtime.extensions.plugin_manager import PluginManager except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -807,9 +847,10 @@ class TestPluginHelper: plugin_manager = PluginManager() monkeypatch.setattr(plugin_manager, "_plugins", {}) monkeypatch.setattr(plugin_manager, "_running_plugins", {}) - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace(VERSION_FLAG="v3", PLUGIN_MARKET=REPO_URL), + _patch_catalog_settings( + monkeypatch, + VERSION_FLAG="v3", + PLUGIN_MARKET=REPO_URL, ) monkeypatch.setattr("app.adapters.external.market.settings", SimpleNamespace(VERSION_FLAG="v3")) monkeypatch.setattr( @@ -968,13 +1009,17 @@ class TestPluginHelper: 全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。 """ try: - from app.runtime.extensions.plugin_manager import PluginManager from app.adapters.external.market import PluginHelper + from app.runtime.extensions.plugin_manager import PluginManager except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") clear_calls = [] - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings.PLUGIN_MARKET", "https://github.com/demo/plugins") + _patch_catalog_settings( + monkeypatch, + PLUGIN_MARKET="https://github.com/demo/plugins", + VERSION_FLAG="v3", + ) monkeypatch.setattr(PluginManager, "get_plugins_from_market", lambda *_args, **_kwargs: []) PluginManager().get_online_plugins(force=True) @@ -996,7 +1041,11 @@ class TestPluginHelper: async def fake_market(*_args, **_kwargs): return [] - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings.PLUGIN_MARKET", "https://github.com/demo/plugins") + _patch_catalog_settings( + monkeypatch, + PLUGIN_MARKET="https://github.com/demo/plugins", + VERSION_FLAG="v3", + ) monkeypatch.setattr(PluginManager, "async_get_plugins_from_market", fake_market) asyncio.run(PluginManager().async_get_online_plugins(force=True)) @@ -1788,8 +1837,8 @@ demo = { index = "private" } with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \ patch.dict(os.environ, {}, clear=True), \ - patch("app.adapters.external.market.settings.CONFIG_DIR", str(root / "config")), \ - patch("app.adapters.external.market.settings.PACKAGE_CACHE_ROOT", str(root / "custom-package-cache")), \ + patch("app.adapters.external.market.settings.CONFIG_PATH", root / "config"), \ + patch("app.adapters.external.market.settings.PACKAGE_CACHE_PATH", root / "custom-package-cache"), \ patch("app.adapters.external.market.settings.PIP_PROXY", "https://user:pass@mirror.example/simple"), \ patch("app.adapters.external.market.settings.PROXY_HOST", "http://proxy.example:7890"), \ patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): @@ -3425,8 +3474,8 @@ demo = { index = "private" } 异步 release zip 带顶层插件目录时剥离该层后写入运行目录。 """ try: - from app.runtime.config import settings from app.adapters.external.market import PluginHelper + from app.runtime.config import settings except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index 83ea85e95..482e9aa45 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -21,6 +21,30 @@ from app.schemas.types import EventType, SystemConfigKey def plugin_manager(monkeypatch) -> Iterator[PluginManager]: """构造隔离的插件管理器实例,避免单例状态污染其它用例。""" system = get_plugin_system() + from app.adapters.external import market as market_module + original_runtime_setting = market_module.get_runtime_setting + + class _SettingsStub(SimpleNamespace): + """允许存量用例覆盖尚未显式声明的配置键。""" + + def __getattr__(self, _key): + return None + + market_settings = _SettingsStub( + VERSION_FLAG="v2", + REPO_GITHUB_HEADERS=original_runtime_setting("REPO_GITHUB_HEADERS"), + PLUGIN_LOCAL_REPO_PATHS="", + ) + monkeypatch.setattr(market_module, "settings", market_settings, raising=False) + monkeypatch.setattr( + market_module, + "get_runtime_setting", + lambda key, default=None: ( + getattr(market_module.settings, key) + if hasattr(market_module.settings, key) + else original_runtime_setting(key, default) + ), + ) def install_local(**kwargs) -> tuple[bool, str]: """用测试包适配器模拟已通过来源准入的本地 Gateway。""" @@ -73,6 +97,22 @@ def _build_local_plugin_repo(tmp_path: Path) -> tuple[Path, Path]: return repo_path, source_file +def _patch_plugin_runtime_settings(monkeypatch, settings) -> None: + """以只读键值端口注入插件运行配置。""" + monkeypatch.setattr( + "app.runtime.extensions.plugin_manager.get_runtime_setting", + lambda key: getattr(settings, key), + ) + + +def _patch_package_runtime_settings(monkeypatch, settings) -> None: + """为插件包文件适配器注入隔离路径配置。""" + monkeypatch.setattr( + "app.adapters.system.plugin.package.get_runtime_setting", + lambda key: getattr(settings, key), + ) + + def _configure_local_watcher( monkeypatch, tmp_path: Path, @@ -91,9 +131,9 @@ def _configure_local_watcher( CONFIG_PATH=tmp_path / "config", VERSION_FLAG="v2", ) - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) + _patch_plugin_runtime_settings(monkeypatch, settings_stub) monkeypatch.setattr("app.adapters.external.market.settings", settings_stub) - monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub) + _patch_package_runtime_settings(monkeypatch, settings_stub) monkeypatch.setattr("app.runtime.extensions.plugin_manager.watch", lambda *_args, **_kwargs: iter([changes])) @@ -169,8 +209,8 @@ def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_l TEMP_PATH=tmp_path / "temp", CONFIG_PATH=tmp_path / "config", ) - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) - monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub) + _patch_plugin_runtime_settings(monkeypatch, settings_stub) + _patch_package_runtime_settings(monkeypatch, settings_stub) monkeypatch.setattr("app.adapters.external.market.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path)) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) _set_installed_plugins(monkeypatch, ["DemoPlugin"]) @@ -193,7 +233,10 @@ def test_local_plugin_candidate_keeps_system_version_gate_outside_dev( """非 DEV 本地候选继续受主系统版本门禁保护,避免自动热加载绕过安装约束。""" repo_path, source_file = _build_local_plugin_repo(tmp_path) - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", SimpleNamespace(DEV=False, ROOT_PATH=tmp_path)) + _patch_plugin_runtime_settings( + monkeypatch, + SimpleNamespace(DEV=False, ROOT_PATH=tmp_path), + ) monkeypatch.setattr("app.adapters.external.market.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path)) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) @@ -219,7 +262,7 @@ def test_local_plugin_sync_without_candidate_respects_system_version_gate( PLUGIN_LOCAL_REPO_PATHS=str(repo_path), ) - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) + _patch_plugin_runtime_settings(monkeypatch, settings_stub) monkeypatch.setattr("app.adapters.external.market.settings", settings_stub) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) _set_installed_plugins(monkeypatch, ["DemoPlugin"]) @@ -374,7 +417,7 @@ def test_local_federated_asset_reads_running_render_mode_for_each_batch( ROOT_PATH=tmp_path, VERSION_FLAG="v2", ) - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) + _patch_plugin_runtime_settings(monkeypatch, settings_stub) monkeypatch.setattr("app.adapters.external.market.settings", settings_stub) monkeypatch.setattr( "app.runtime.extensions.plugin_manager.watch", diff --git a/tests/test_plugin_market_request_policy.py b/tests/test_plugin_market_request_policy.py index 81ea94e7c..86ec3a3bb 100644 --- a/tests/test_plugin_market_request_policy.py +++ b/tests/test_plugin_market_request_policy.py @@ -14,14 +14,15 @@ async def test_sync_and_async_github_requests_share_fallback_policy( ) -> None: """同步与异步请求必须使用相同镜像、代理、直连顺序和参数。""" proxy = {"all": "http://proxy.example:7890"} - monkeypatch.setattr( - market, - "settings", - SimpleNamespace( + runtime_settings = SimpleNamespace( GITHUB_PROXY="https://mirror.example", PROXY_HOST="http://proxy.example:7890", PROXY=proxy, - ), + ) + monkeypatch.setattr( + market, + "get_runtime_setting", + lambda key, default=None: getattr(runtime_settings, key, default), ) sync_requests: list[tuple[dict, str]] = [] async_requests: list[tuple[dict, str]] = [] @@ -88,14 +89,15 @@ async def test_sync_and_async_github_requests_share_fallback_policy( def test_github_api_request_policy_skips_content_mirror(monkeypatch) -> None: """GitHub API 请求必须跳过只用于 raw 内容的镜像站。""" - monkeypatch.setattr( - market, - "settings", - SimpleNamespace( + runtime_settings = SimpleNamespace( GITHUB_PROXY="https://mirror.example", PROXY_HOST=None, PROXY=None, - ), + ) + monkeypatch.setattr( + market, + "get_runtime_setting", + lambda key, default=None: getattr(runtime_settings, key, default), ) strategies = PluginHelper._build_github_request_strategies( diff --git a/tests/test_plugin_monitor_lifecycle.py b/tests/test_plugin_monitor_lifecycle.py index 3b3b6564e..7f84de2f2 100644 --- a/tests/test_plugin_monitor_lifecycle.py +++ b/tests/test_plugin_monitor_lifecycle.py @@ -30,6 +30,16 @@ def _reset_plugin_manager() -> None: Singleton._instances.pop((PluginManager, (), frozenset()), None) +def _patch_runtime_settings(monkeypatch, **values) -> None: + """按键注入插件运行时配置,避免测试恢复模块级 Settings 代理。""" + settings = SimpleNamespace(**values) + monkeypatch.setattr( + plugin_manager_module, + "get_runtime_setting", + lambda key: getattr(settings, key), + ) + + @pytest.mark.parametrize( ("dev", "auto_reload"), ((True, False), (False, True)), @@ -43,13 +53,11 @@ def test_plugin_manager_constructor_does_not_start_monitor_before_runtime( _reset_plugin_manager() reset_plugin_system() start = MagicMock() - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace( - DEV=dev, - PLUGIN_AUTO_RELOAD=auto_reload, - ROOT_PATH=MagicMock(), - ), + _patch_runtime_settings( + monkeypatch, + DEV=dev, + PLUGIN_AUTO_RELOAD=auto_reload, + ROOT_PATH=MagicMock(), ) monkeypatch.setattr(PluginMonitorController, "start", start) @@ -422,13 +430,11 @@ def test_start_monitor_respects_runtime_configuration( """首次启动只在开发模式或插件自动重载启用时创建监控线程。""" _reset_plugin_manager() reset_plugin_system() - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace( - DEV=dev, - PLUGIN_AUTO_RELOAD=auto_reload, - ROOT_PATH=MagicMock(), - ), + _patch_runtime_settings( + monkeypatch, + DEV=dev, + PLUGIN_AUTO_RELOAD=auto_reload, + ROOT_PATH=MagicMock(), ) manager = PluginManager() start = MagicMock() @@ -444,13 +450,11 @@ def test_plugin_monitor_waits_until_dependency_settlement(monkeypatch) -> None: """后台依赖收敛期间不启动文件监控,避免源码写入触发重复重载。""" _reset_plugin_manager() reset_plugin_system() - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace( - DEV=True, - PLUGIN_AUTO_RELOAD=False, - ROOT_PATH=MagicMock(), - ), + _patch_runtime_settings( + monkeypatch, + DEV=True, + PLUGIN_AUTO_RELOAD=False, + ROOT_PATH=MagicMock(), ) manager = PluginManager() start = MagicMock() @@ -589,13 +593,11 @@ def test_plugin_manager_start_monitor_can_reopen_new_lifespan(monkeypatch) -> No """新应用生命周期可显式解除封口,再按运行配置启动监控。""" _reset_plugin_manager() reset_plugin_system() - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace( - DEV=True, - PLUGIN_AUTO_RELOAD=False, - ROOT_PATH=MagicMock(), - ), + _patch_runtime_settings( + monkeypatch, + DEV=True, + PLUGIN_AUTO_RELOAD=False, + ROOT_PATH=MagicMock(), ) manager = PluginManager() reopen = MagicMock(return_value=True) @@ -648,13 +650,11 @@ async def test_quiesce_timeout_retains_future_owner_until_worker_finishes( """同步插件 hook 超时后必须保留 Future,且未结束前拒绝卸载实例。""" _reset_plugin_manager() reset_plugin_system() - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace( - DEV=False, - PLUGIN_AUTO_RELOAD=False, - ROOT_PATH=MagicMock(), - ), + _patch_runtime_settings( + monkeypatch, + DEV=False, + PLUGIN_AUTO_RELOAD=False, + ROOT_PATH=MagicMock(), ) manager = PluginManager() started = threading.Event() @@ -703,14 +703,12 @@ async def test_quiesce_seals_runtime_until_new_lifespan_reopens(monkeypatch) -> """屏障前封口后 start/reload/config 不能重开 producer,新 lifespan 可显式恢复。""" _reset_plugin_manager() reset_plugin_system() - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace( - DEBUG=False, - DEV=False, - PLUGIN_AUTO_RELOAD=False, - ROOT_PATH=MagicMock(), - ), + _patch_runtime_settings( + monkeypatch, + DEBUG=False, + DEV=False, + PLUGIN_AUTO_RELOAD=False, + ROOT_PATH=MagicMock(), ) manager = PluginManager() manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True) @@ -831,13 +829,11 @@ def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None: """同一插件的重叠写入必须等最后一个事务退出后才解除监控抑制。""" _reset_plugin_manager() reset_plugin_system() - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace( - DEV=False, - PLUGIN_AUTO_RELOAD=False, - ROOT_PATH=MagicMock(), - ), + _patch_runtime_settings( + monkeypatch, + DEV=False, + PLUGIN_AUTO_RELOAD=False, + ROOT_PATH=MagicMock(), ) manager = PluginManager() @@ -855,13 +851,11 @@ def test_config_change_reloads_monitor(monkeypatch) -> None: """配置热更新继续使用重建语义,不复用首次启动入口。""" _reset_plugin_manager() reset_plugin_system() - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.settings", - SimpleNamespace( - DEV=False, - PLUGIN_AUTO_RELOAD=False, - ROOT_PATH=MagicMock(), - ), + _patch_runtime_settings( + monkeypatch, + DEV=False, + PLUGIN_AUTO_RELOAD=False, + ROOT_PATH=MagicMock(), ) manager = PluginManager() reload_monitor = MagicMock() diff --git a/tests/test_plugin_package_manager.py b/tests/test_plugin_package_manager.py index 848971190..3417a8162 100644 --- a/tests/test_plugin_package_manager.py +++ b/tests/test_plugin_package_manager.py @@ -10,13 +10,14 @@ from app.adapters.system.plugin.package import PluginPackageManager def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager: """构造使用隔离运行目录和事务目录的插件包管理器。""" + settings = SimpleNamespace( + ROOT_PATH=tmp_path, + TEMP_PATH=tmp_path / "temp", + CONFIG_PATH=tmp_path / "config", + ) monkeypatch.setattr( - "app.adapters.system.plugin.package.settings", - SimpleNamespace( - ROOT_PATH=tmp_path, - TEMP_PATH=tmp_path / "temp", - CONFIG_PATH=tmp_path / "config", - ), + "app.adapters.system.plugin.package.get_runtime_setting", + lambda key: getattr(settings, key), ) return PluginPackageManager(helper=Mock()) diff --git a/tests/test_plugin_package_version_resolution.py b/tests/test_plugin_package_version_resolution.py index 5eda57dad..75e6f087a 100644 --- a/tests/test_plugin_package_version_resolution.py +++ b/tests/test_plugin_package_version_resolution.py @@ -23,10 +23,11 @@ def test_package_version_candidates_have_one_canonical_order( expected: tuple[str, ...], ) -> None: """显式版本、向后兼容版本和基础索引必须由一个有序事实源产生。""" + runtime_settings = SimpleNamespace(VERSION_FLAG=configured_version) monkeypatch.setattr( market, - "settings", - SimpleNamespace(VERSION_FLAG=configured_version), + "get_runtime_setting", + lambda key, default=None: getattr(runtime_settings, key, default), ) assert PluginHelper._package_version_candidates(requested_version) == expected @@ -37,10 +38,11 @@ async def test_sync_and_async_package_resolution_visit_same_candidates( monkeypatch, ) -> None: """同步与异步安装必须按相同顺序停止在首个兼容插件索引。""" + runtime_settings = SimpleNamespace(VERSION_FLAG="v3") monkeypatch.setattr( market, - "settings", - SimpleNamespace(VERSION_FLAG="v3"), + "get_runtime_setting", + lambda key, default=None: getattr(runtime_settings, key, default), ) helper = PluginHelper.__new__(PluginHelper) indexes = { diff --git a/tests/test_plugin_rating.py b/tests/test_plugin_rating.py index 6b60538cd..1969580b9 100644 --- a/tests/test_plugin_rating.py +++ b/tests/test_plugin_rating.py @@ -6,6 +6,7 @@ from fastapi import HTTPException from app import schemas from app.api.endpoints.plugin import plugin_rating, plugin_ratings, rate_plugin +from app.adapters.external import server as server_module from app.adapters.external.server import MoviePilotServerHelper @@ -13,8 +14,13 @@ def test_server_helper_uses_plugin_rating_endpoints() -> None: """评分辅助方法应使用独立中心端路径并传递评分载荷。""" async def run_scenario() -> None: + runtime_setting = server_module.get_runtime_setting with ( - patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"), + patch.object( + server_module, + "get_runtime_setting", + side_effect=lambda key: "https://movie-pilot.org" if key == "MP_SERVER_HOST" else runtime_setting(key), + ), patch.object( MoviePilotServerHelper, "_async_get", @@ -30,16 +36,12 @@ def test_server_helper_uses_plugin_rating_endpoints() -> None: await MoviePilotServerHelper.async_plugin_rating("Demo Plugin") await MoviePilotServerHelper.async_rate_plugin("Demo Plugin", 4.5) - assert get_request.await_args_list[0].args == ( - "https://movie-pilot.org/plugin/rating", - ) + assert get_request.await_args_list[0].args == ("https://movie-pilot.org/plugin/rating",) assert get_request.await_args_list[0].kwargs == { "params": {"plugin_ids": "DemoPlugin,OtherPlugin"}, "timeout": 10, } - assert get_request.await_args_list[1].args == ( - "https://movie-pilot.org/plugin/rating/Demo%20Plugin", - ) + assert get_request.await_args_list[1].args == ("https://movie-pilot.org/plugin/rating/Demo%20Plugin",) assert post_request.await_args.args == ( "https://movie-pilot.org/plugin/rating/Demo%20Plugin", {"rating": 4.5}, diff --git a/tests/test_process_topology.py b/tests/test_process_topology.py index 70d6f0437..088cf41cd 100644 --- a/tests/test_process_topology.py +++ b/tests/test_process_topology.py @@ -52,8 +52,16 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch): """主入口应在注册信号、迁移数据库和启动服务器前拒绝错误拓扑。""" from app import main - monkeypatch.setattr(main.settings, "API_WORKERS", 2) - monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", False) + original_setting = main.get_runtime_setting + topology_settings = { + "API_WORKERS": 2, + "MOVIEPILOT_SAFE_MODE": False, + } + monkeypatch.setattr( + main, + "get_runtime_setting", + lambda key: topology_settings[key] if key in topology_settings else original_setting(key), + ) signal_handler = MagicMock() start_tray = MagicMock() server_run = MagicMock() diff --git a/tests/test_qbittorrent_compat.py b/tests/test_qbittorrent_compat.py index e2eff82c0..888f1db83 100644 --- a/tests/test_qbittorrent_compat.py +++ b/tests/test_qbittorrent_compat.py @@ -84,13 +84,6 @@ def _load_qbittorrent_modules(): def get(self, *_args, **_kwargs): return None - class _RuntimeSettingsCompat: - """隔离测试用动态配置代理,保持生产模块的兼容读取语义。""" - - def __getattr__(self, key): - """从测试提供的旧 Settings 桩读取配置项。""" - return getattr(config_module.settings, key) - class _MetaInfo: def __init__(self, name): self.name = name @@ -195,7 +188,9 @@ def _load_qbittorrent_modules(): log_module.logger = _Logger() cache_module.FileCache = _FileCache config_module.settings = types.SimpleNamespace(TORRENT_TAG="moviepilot-tag") - runtime_settings_module.RuntimeSettingsCompat = _RuntimeSettingsCompat + runtime_settings_module.get_runtime_setting = lambda key, default=None: getattr( + config_module.settings, key, default + ) metainfo_module.MetaInfo = _MetaInfo schema_dashboard_module.DownloaderInfo = object schema_transfer_module.TransferTorrent = object diff --git a/tests/test_server_helper.py b/tests/test_server_helper.py index 85150b4da..b53bbf7ed 100644 --- a/tests/test_server_helper.py +++ b/tests/test_server_helper.py @@ -1,195 +1,237 @@ from __future__ import annotations -import unittest +from contextlib import contextmanager from unittest.mock import AsyncMock, Mock, patch +import pytest + +from app.adapters.external import server as server_module from app.adapters.external.server import ( MoviePilotServerHelper, configure_server_application_services, ) from app.application.server.report import ServerReportService from app.application.server.share import ServerSharingService -from app.runtime.config import settings from app.schemas.types import MediaSource -class MoviePilotServerHelperTests(unittest.TestCase): +@contextmanager +def _runtime_settings(**values): + """按键覆盖中心服务测试配置,其余读取继续委托真实只读端口。""" + original = server_module.get_runtime_setting + with patch.object( + server_module, + "get_runtime_setting", + side_effect=lambda key: values[key] if key in values else original(key), + ): + yield + + +@pytest.fixture(autouse=True) +def _configure_server_services() -> None: """ - MoviePilot 服务端请求辅助工具测试。 + 清理安装用户 ID 缓存,避免不同用例之间互相影响。 """ + MoviePilotServerHelper._user_uid = None + configure_server_application_services( + report_service=ServerReportService( + config_reader=Mock(return_value=None), + config_writer=Mock(), + installed_plugins_provider=Mock(return_value=[]), + subscribes_provider=Mock(return_value=[]), + async_subscribes_provider=AsyncMock(return_value=[]), + plugin_report_sender=Mock(), + async_plugin_report_sender=AsyncMock(), + subscribe_report_sender=Mock(), + async_subscribe_report_sender=AsyncMock(), + async_config_writer=AsyncMock(), + repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url, + ), + sharing_service=ServerSharingService( + subscribe_provider=Mock(return_value=None), + async_subscribe_provider=AsyncMock(return_value=None), + workflow_provider=Mock(return_value=None), + async_workflow_provider=AsyncMock(return_value=None), + user_uuid_provider=Mock(return_value="user-1"), + subscribe_sender=Mock(), + async_subscribe_sender=AsyncMock(), + workflow_sender=Mock(), + async_workflow_sender=AsyncMock(), + response_handler=Mock(return_value=(True, "")), + subscribe_cache_clearer=Mock(), + workflow_cache_clearer=Mock(), + ), + ) - def setUp(self) -> None: - """ - 清理安装用户 ID 缓存,避免不同用例之间互相影响。 - """ - MoviePilotServerHelper._user_uid = None - configure_server_application_services( - report_service=ServerReportService( - config_reader=Mock(return_value=None), - config_writer=Mock(), - installed_plugins_provider=Mock(return_value=[]), - subscribes_provider=Mock(return_value=[]), - async_subscribes_provider=AsyncMock(return_value=[]), - plugin_report_sender=Mock(), - async_plugin_report_sender=AsyncMock(), - subscribe_report_sender=Mock(), - async_subscribe_report_sender=AsyncMock(), - async_config_writer=AsyncMock(), - repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url, - ), - sharing_service=ServerSharingService( - subscribe_provider=Mock(return_value=None), - async_subscribe_provider=AsyncMock(return_value=None), - workflow_provider=Mock(return_value=None), - async_workflow_provider=AsyncMock(return_value=None), - user_uuid_provider=Mock(return_value="user-1"), - subscribe_sender=Mock(), - async_subscribe_sender=AsyncMock(), - workflow_sender=Mock(), - async_workflow_sender=AsyncMock(), - response_handler=Mock(return_value=(True, "")), - subscribe_cache_clearer=Mock(), - workflow_cache_clearer=Mock(), - ), + +def test_server_request_adds_user_uid_header(): + """ + 发往 MoviePilot 服务端的请求会自动携带安装用户 ID。 + """ + with ( + patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), + _runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"), + ): + headers = MoviePilotServerHelper.build_headers( + "https://movie-pilot.org/plugin/install", + headers={"Content-Type": "application/json"}, ) - def test_server_request_adds_user_uid_header(self): - """ - 发往 MoviePilot 服务端的请求会自动携带安装用户 ID。 - """ - with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \ - patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"): - headers = MoviePilotServerHelper.build_headers( - "https://movie-pilot.org/plugin/install", - headers={"Content-Type": "application/json"}, - ) + assert headers["X-MoviePilot-User-Uid"] == "uid-1" + assert headers["Content-Type"] == "application/json" - self.assertEqual(headers["X-MoviePilot-User-Uid"], "uid-1") - self.assertEqual(headers["Content-Type"], "application/json") - def test_non_server_request_does_not_add_user_uid_header(self): - """ - 发往其他域名的请求不会携带安装用户 ID。 - """ - with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \ - patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"): - headers = MoviePilotServerHelper.build_headers( - "https://example.com/plugin/install", - headers={"Content-Type": "application/json"}, - ) - - self.assertNotIn("X-MoviePilot-User-Uid", headers) - - def test_existing_user_uid_header_is_preserved(self): - """ - 调用方显式传入的安装用户 ID 请求头不被覆盖。 - """ - with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \ - patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"): - headers = MoviePilotServerHelper.build_headers( - "https://movie-pilot.org/plugin/install", - headers={ - "Content-Type": "application/json", - "X-MoviePilot-User-Uid": "custom-uid", - }, - ) - - self.assertEqual(headers["X-MoviePilot-User-Uid"], "custom-uid") - - def test_existing_user_uid_header_is_detected_case_insensitively(self): - """ - 调用方使用不同大小写的安装用户 ID 请求头时不会重复注入。 - """ - with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \ - patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"): - headers = MoviePilotServerHelper.build_headers( - "https://movie-pilot.org/plugin/install", - headers={ - "Content-Type": "application/json", - "x-moviepilot-user-uid": "custom-uid", - }, - ) - - self.assertNotIn("X-MoviePilot-User-Uid", headers) - self.assertEqual(headers["x-moviepilot-user-uid"], "custom-uid") - - def test_content_type_can_be_added(self): - """ - 构建 JSON 请求头时会补充 Content-Type。 - """ - with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \ - patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"): - headers = MoviePilotServerHelper.build_headers( - "https://movie-pilot.org/plugin/install", - content_type="application/json", - ) - - self.assertEqual(headers["Content-Type"], "application/json") - - def test_subscribe_fork_uses_fork_endpoint(self): - """ - 订阅复用请求使用服务端 fork 接口。 - """ - with patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"), \ - patch.object(MoviePilotServerHelper, "_get", return_value=None) as request: - MoviePilotServerHelper.subscribe_fork(9) - - request.assert_called_once_with( - "https://movie-pilot.org/subscribe/fork/9", - timeout=5, +def test_non_server_request_does_not_add_user_uid_header(): + """ + 发往其他域名的请求不会携带安装用户 ID。 + """ + with ( + patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), + _runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"), + ): + headers = MoviePilotServerHelper.build_headers( + "https://example.com/plugin/install", + headers={"Content-Type": "application/json"}, ) - def test_workflow_fork_uses_fork_endpoint(self): - """ - 工作流复用请求使用服务端 fork 接口。 - """ - with patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"), \ - patch.object(MoviePilotServerHelper, "_get", return_value=None) as request: - MoviePilotServerHelper.workflow_fork(9) + assert "X-MoviePilot-User-Uid" not in headers - request.assert_called_once_with( - "https://movie-pilot.org/workflow/fork/9", - timeout=5, + +def test_existing_user_uid_header_is_preserved(): + """ + 调用方显式传入的安装用户 ID 请求头不被覆盖。 + """ + with ( + patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), + _runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"), + ): + headers = MoviePilotServerHelper.build_headers( + "https://movie-pilot.org/plugin/install", + headers={ + "Content-Type": "application/json", + "X-MoviePilot-User-Uid": "custom-uid", + }, ) - def test_user_permissions_uses_server_endpoint(self): - """ - 用户权限请求使用服务端权限接口。 - """ - with patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"), \ - patch.object(MoviePilotServerHelper, "_get", return_value=None) as request: - MoviePilotServerHelper.user_permissions("jxxghp") + assert headers["X-MoviePilot-User-Uid"] == "custom-uid" - request.assert_called_once_with( - "https://movie-pilot.org/user/permissions", - params={"github_user": "jxxghp"}, - include_user_uid=False, - timeout=5, + +def test_existing_user_uid_header_is_detected_case_insensitively(): + """ + 调用方使用不同大小写的安装用户 ID 请求头时不会重复注入。 + """ + with ( + patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), + _runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"), + ): + headers = MoviePilotServerHelper.build_headers( + "https://movie-pilot.org/plugin/install", + headers={ + "Content-Type": "application/json", + "x-moviepilot-user-uid": "custom-uid", + }, ) - def test_is_admin_user_uses_server_permissions(self): - """ - 共享管理权限由服务端权限结果决定。 - """ - response = Mock(status_code=200) - response.json.return_value = {"is_admin": True} - with patch.object(MoviePilotServerHelper, "get_github_user", return_value="jxxghp"), \ - patch.object(MoviePilotServerHelper, "user_permissions", return_value=response): - self.assertTrue(MoviePilotServerHelper.is_admin_user()) + assert "X-MoviePilot-User-Uid" not in headers + assert headers["x-moviepilot-user-uid"] == "custom-uid" - def test_is_admin_user_returns_false_without_server_permission(self): - """ - 服务端未返回管理权限时不授予共享管理权限。 - """ - response = Mock(status_code=200) - response.json.return_value = {"is_admin": False} - with patch.object(MoviePilotServerHelper, "get_github_user", return_value="user"), \ - patch.object(MoviePilotServerHelper, "user_permissions", return_value=response): - self.assertFalse(MoviePilotServerHelper.is_admin_user()) - def test_subscribe_statistic_payload_only_keeps_server_contract(self): - """订阅统计载荷应删除本地运行列和所有旧专用媒体 ID。""" - payload = MoviePilotServerHelper._build_subscribe_statistic_payload({ +def test_content_type_can_be_added(): + """ + 构建 JSON 请求头时会补充 Content-Type。 + """ + with ( + patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), + _runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"), + ): + headers = MoviePilotServerHelper.build_headers( + "https://movie-pilot.org/plugin/install", + content_type="application/json", + ) + + assert headers["Content-Type"] == "application/json" + + +def test_subscribe_fork_uses_fork_endpoint(): + """ + 订阅复用请求使用服务端 fork 接口。 + """ + with ( + _runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"), + patch.object(MoviePilotServerHelper, "_get", return_value=None) as request, + ): + MoviePilotServerHelper.subscribe_fork(9) + + request.assert_called_once_with( + "https://movie-pilot.org/subscribe/fork/9", + timeout=5, + ) + + +def test_workflow_fork_uses_fork_endpoint(): + """ + 工作流复用请求使用服务端 fork 接口。 + """ + with ( + _runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"), + patch.object(MoviePilotServerHelper, "_get", return_value=None) as request, + ): + MoviePilotServerHelper.workflow_fork(9) + + request.assert_called_once_with( + "https://movie-pilot.org/workflow/fork/9", + timeout=5, + ) + + +def test_user_permissions_uses_server_endpoint(): + """ + 用户权限请求使用服务端权限接口。 + """ + with ( + _runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"), + patch.object(MoviePilotServerHelper, "_get", return_value=None) as request, + ): + MoviePilotServerHelper.user_permissions("jxxghp") + + request.assert_called_once_with( + "https://movie-pilot.org/user/permissions", + params={"github_user": "jxxghp"}, + include_user_uid=False, + timeout=5, + ) + + +def test_is_admin_user_uses_server_permissions(): + """ + 共享管理权限由服务端权限结果决定。 + """ + response = Mock(status_code=200) + response.json.return_value = {"is_admin": True} + with ( + patch.object(MoviePilotServerHelper, "get_github_user", return_value="jxxghp"), + patch.object(MoviePilotServerHelper, "user_permissions", return_value=response), + ): + assert MoviePilotServerHelper.is_admin_user() + + +def test_is_admin_user_returns_false_without_server_permission(): + """ + 服务端未返回管理权限时不授予共享管理权限。 + """ + response = Mock(status_code=200) + response.json.return_value = {"is_admin": False} + with ( + patch.object(MoviePilotServerHelper, "get_github_user", return_value="user"), + patch.object(MoviePilotServerHelper, "user_permissions", return_value=response), + ): + assert not MoviePilotServerHelper.is_admin_user() + + +def test_subscribe_statistic_payload_only_keeps_server_contract(): + """订阅统计载荷应删除本地运行列和所有旧专用媒体 ID。""" + payload = MoviePilotServerHelper._build_subscribe_statistic_payload( + { "id": 1, "name": "Test", "type": "电影", @@ -198,18 +240,21 @@ class MoviePilotServerHelperTests(unittest.TestCase): "tmdbid": 99, "state": "N", "username": "tester", - }) + } + ) - self.assertEqual(payload, { - "name": "Test", - "type": "电影", - "media_source": MediaSource.Douban.value, - "media_id": "42", - }) + assert payload == { + "name": "Test", + "type": "电影", + "media_source": MediaSource.Douban.value, + "media_id": "42", + } - def test_subscribe_share_payload_only_keeps_server_contract(self): - """订阅分享载荷应保留分享配置并剔除本地下载状态。""" - payload = MoviePilotServerHelper._build_subscribe_share_payload({ + +def test_subscribe_share_payload_only_keeps_server_contract(): + """订阅分享载荷应保留分享配置并剔除本地下载状态。""" + payload = MoviePilotServerHelper._build_subscribe_share_payload( + { "share_title": "Share", "share_user": "tester", "name": "Test", @@ -220,40 +265,52 @@ class MoviePilotServerHelperTests(unittest.TestCase): "audio_quality": "lossless", "downloader": "default", "bangumiid": 7, - }) + } + ) - self.assertEqual(payload, { - "share_title": "Share", - "share_user": "tester", - "name": "Test", - "type": "电视剧", - "media_source": MediaSource.Bangumi.value, - "media_id": "7", - "include": "WEB-DL", - }) + assert payload == { + "share_title": "Share", + "share_user": "tester", + "name": "Test", + "type": "电视剧", + "media_source": MediaSource.Bangumi.value, + "media_id": "7", + "include": "WEB-DL", + } - def test_subscribe_payload_rejects_incomplete_unified_identity(self): - """中心服务载荷不得再从旧专用 ID 推导主身份。""" - self.assertIsNone( - MoviePilotServerHelper._build_subscribe_statistic_payload({ + +def test_subscribe_payload_rejects_incomplete_unified_identity(): + """中心服务载荷不得再从旧专用 ID 推导主身份。""" + assert ( + MoviePilotServerHelper._build_subscribe_statistic_payload( + { "name": "Legacy", "type": "电影", "tmdbid": 99, - }) + } ) + is None + ) - def test_durable_subscribe_report_treats_disabled_sharing_as_success(self): - """用户关闭统计分享时,durable intent 应视为无需远端投递。""" - with patch.object(settings, "SUBSCRIBE_STATISTIC_SHARE", False), patch.object( + +def test_durable_subscribe_report_treats_disabled_sharing_as_success(): + """用户关闭统计分享时,durable intent 应视为无需远端投递。""" + with ( + _runtime_settings(SUBSCRIBE_STATISTIC_SHARE=False), + patch.object( MoviePilotServerHelper, "sub_reg", - ) as reporter: - self.assertTrue(MoviePilotServerHelper.sub_reg_durable({"media_id": "1"})) - reporter.assert_not_called() + ) as reporter, + ): + assert MoviePilotServerHelper.sub_reg_durable({"media_id": "1"}) + reporter.assert_not_called() - with patch.object(settings, "SUBSCRIBE_STATISTIC_SHARE", False), patch.object( + with ( + _runtime_settings(SUBSCRIBE_STATISTIC_SHARE=False), + patch.object( MoviePilotServerHelper, "sub_done", - ) as reporter: - self.assertTrue(MoviePilotServerHelper.sub_done_durable({"media_id": "1"})) - reporter.assert_not_called() + ) as reporter, + ): + assert MoviePilotServerHelper.sub_done_durable({"media_id": "1"}) + reporter.assert_not_called() diff --git a/tests/test_skills_command.py b/tests/test_skills_command.py index 375757cc0..6b448be9b 100644 --- a/tests/test_skills_command.py +++ b/tests/test_skills_command.py @@ -14,6 +14,7 @@ ensure_optional_stub("aioshutil") ensure_optional_stub("pyquery", PyQuery=object) from app.chain.message import MessageChain +from app.application.configuration import get_runtime_settings # noqa: E402 - optional stubs must be installed first from app.application.messaging.interaction import InteractionContext from app.application.messaging.skill import SkillInteractionHandler from app.application.messaging.skill import skill_interaction_manager @@ -21,7 +22,6 @@ from app.agent.skills.registry import ( SkillHelper, SkillInfo, SkillMarketSource, - settings as skill_settings, ) from app.schemas.types import NotificationChannel @@ -358,8 +358,8 @@ class TestSkillsCommand(unittest.TestCase): "get_market_sources", return_value=["https://github.com/openai/skills"], ), patch.object( - type(skill_settings), - "update_setting", + get_runtime_settings(), + "update", return_value=(True, ""), ) as update_setting: success, message = helper.add_custom_market_source("acme/custom-skills")