From 1bb6e36e8cd186a0996a0d0cbd60c3ffe1ba55ae Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 22 Aug 2026 19:07:07 +0800 Subject: [PATCH] refactor: complete runtime configuration migration --- app/api/endpoints/media.py | 8 +- app/api/endpoints/message.py | 11 +- app/api/endpoints/plugin.py | 15 +- app/api/endpoints/storage.py | 9 +- app/api/endpoints/subscribe.py | 8 +- app/api/endpoints/system.py | 118 ++++++++----- app/api/endpoints/transfer.py | 7 +- app/api/servcookie.py | 12 +- app/application/configuration.py | 167 ++++++++++++++++++ app/chain/__init__.py | 17 ++ app/chain/_transfer.py | 23 +-- app/chain/download.py | 58 +++--- app/chain/media.py | 12 +- app/chain/message.py | 28 ++- app/chain/scraping.py | 30 ++-- app/chain/search.py | 65 ++++--- app/chain/subscribe.py | 31 ++-- app/chain/system.py | 25 +-- app/chain/transfer.py | 34 ++-- app/db/base.py | 133 +++++++++++--- app/db/decorators.py | 24 ++- app/db/oper/agentchat.py | 18 +- app/db/oper/downloadhistory.py | 14 +- app/db/oper/mediaserver.py | 6 +- app/db/oper/message.py | 2 +- app/db/oper/plugindata.py | 14 +- app/db/oper/site.py | 46 ++--- app/db/oper/subscribe.py | 14 +- app/db/oper/subscribehistory.py | 2 +- app/db/oper/systemconfig.py | 10 +- app/db/oper/transferhistory.py | 8 +- app/db/oper/user.py | 2 +- app/db/oper/userconfig.py | 6 +- app/db/oper/workflow.py | 2 +- app/runtime/cache.py | 25 ++- app/runtime/extensions/module/quality.py | 71 +++++++- app/startup/configuration.py | 144 +++++++++++++++ app/startup/context.py | 3 +- app/startup/database_initializer.py | 14 ++ app/startup/modules_initializer.py | 97 ++-------- docs/architecture-overview.md | 7 +- .../backend-architecture-next-stage.md | 28 ++- docs/refactor/module-quality-scale.md | 16 +- mypy.ini | 11 ++ tests/conftest.py | 27 ++- .../configuration-debt-baseline.json | 19 +- .../architecture/dependency-baseline.json | 39 ++-- tests/test_agent_image_capability.py | 7 + tests/test_agent_image_support.py | 64 +++++-- tests/test_agent_interaction.py | 5 + tests/test_agent_message_routing.py | 7 + tests/test_api_authorization.py | 4 +- tests/test_architecture_contract_baseline.py | 35 ++++ tests/test_cache_system.py | 35 ++++ tests/test_configuration_ports.py | 42 +++++ tests/test_cookiecloud_routes.py | 27 ++- tests/test_db_oper_layer.py | 19 ++ tests/test_db_oper_layer_extra.py | 2 + tests/test_download_chain.py | 27 ++- tests/test_host_runtime_context.py | 18 ++ tests/test_media_interaction.py | 24 ++- tests/test_mediascrape.py | 25 ++- tests/test_module_quality.py | 12 +- tests/test_music_plugin_recognize.py | 3 +- tests/test_music_recognize_routing.py | 12 +- tests/test_music_search.py | 8 +- tests/test_music_torrents.py | 14 +- tests/test_plugin_backup_restore.py | 10 +- tests/test_plugin_endpoint.py | 18 +- tests/test_subscribe_endpoint.py | 10 +- tests/test_subscribe_oper.py | 26 +-- tests/test_system_database_backup_config.py | 9 +- tests/test_system_llm_web_search_config.py | 5 +- tests/test_system_nettest.py | 9 +- tests/test_telegram_typing_lifecycle.py | 7 +- tests/test_transfer_failed_retry_buttons.py | 3 + ...ansfer_failure_notification_aggregation.py | 14 +- tests/test_transfer_job_manager.py | 16 +- tests/test_transfer_overwrite_declined.py | 10 +- tests/test_type_gate.py | 6 +- 80 files changed, 1432 insertions(+), 581 deletions(-) create mode 100644 app/startup/configuration.py diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 2f73f6fe3..2c724d2b9 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -20,7 +20,7 @@ from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain from app.chain.tmdb import TmdbChain -from app.runtime.config import settings +from app.application.configuration import get_api_runtime_config_snapshot from app.domain.context import Context, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic @@ -150,7 +150,8 @@ def _build_recognize_metainfo( if ( ("/" in title or "\\" in title) and "://" not in title - and title_path.suffix.lower() in settings.RMT_MEDIAEXT + and title_path.suffix.lower() + in get_api_runtime_config_snapshot().media_extensions ): metainfo = MetaInfoPath( title_path, @@ -369,7 +370,8 @@ async def search( return [] # 排序和分页 - setting_order = settings.SEARCH_SOURCE.split(",") if settings.SEARCH_SOURCE else [] + search_source = get_api_runtime_config_snapshot().search_source + setting_order = search_source.split(",") if search_source else [] sort_order = {source: index for index, source in enumerate(setting_order)} sorted_result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4)) diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 3c528b522..e044597f3 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -18,10 +18,13 @@ from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import ResponseAPIRouter from app.chain.message import MessageChain -from app.runtime.config import settings, global_vars +from app.runtime.config import global_vars from app.adapters.web.security.access import verify_token, verify_apitoken from app.api.principal import ApiPrincipal -from app.application.configuration import get_configured_system_config +from app.application.configuration import ( + get_api_runtime_config_snapshot, + get_configured_system_config, +) from app.api.dependencies.agent import get_message_query_service from app.api.dependencies.auth import get_current_active_superuser from app.application.messaging.message import MessageQueryService @@ -372,8 +375,8 @@ def send_notification( webpush( subscription_info=sub, data=json.dumps(payload.model_dump()), - vapid_private_key=settings.VAPID.get("privateKey"), - vapid_claims={"sub": settings.VAPID.get("subject")}, + vapid_private_key=get_api_runtime_config_snapshot().vapid_private_key, + vapid_claims={"sub": get_api_runtime_config_snapshot().vapid_subject}, **webpush_options_for_endpoint(sub.get("endpoint")), ) except WebPushException as err: diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 3de933d65..41957db8b 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -34,7 +34,7 @@ from app.application.plugin.config import PluginConfigCommand from app.application.commands import init_commands from app.application.scheduling import remove_plugin_job, update_plugin_job from app.runtime.cache import async_fresh -from app.runtime.config import settings +from app.application.configuration import get_api_runtime_config_snapshot from app.application.plugin.runtime import get_plugin_manager as PluginManager from app.runtime.extensions.plugin.contracts import ( PluginDashboardError, @@ -74,7 +74,7 @@ async def _get_market_plugin_from_repo( 只读取指定插件仓库的市场元数据,避免单插件详情触发全部市场刷新。 """ market_plugins = await plugin_manager.async_get_plugins_from_market( - repo_url, settings.VERSION_FLAG, force + repo_url, get_api_runtime_config_snapshot().version_flag, force ) market_plugin = next( ( @@ -84,7 +84,7 @@ async def _get_market_plugin_from_repo( ), None, ) - if market_plugin or not settings.VERSION_FLAG: + if market_plugin or not get_api_runtime_config_snapshot().version_flag: return market_plugin compatible_plugins = await plugin_manager.async_get_plugins_from_market( @@ -783,7 +783,7 @@ async def plugin_static_file( source_plugin_id = PluginManager().get_plugin_source_id(plugin_id) plugin_base_dir = ( - AsyncPath(settings.ROOT_PATH) + AsyncPath(get_api_runtime_config_snapshot().root_path) / "app" / "plugins" / source_plugin_id.lower() @@ -1043,7 +1043,12 @@ def uninstall_plugin( 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_api_runtime_config_snapshot().root_path + / "app" + / "plugins" + / plugin_id.lower() + ) if plugin_base_dir.exists(): try: shutil.rmtree(plugin_base_dir) diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 370401184..ff153b70b 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -14,7 +14,7 @@ from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.transfer import TransferChain -from app.runtime.config import settings +from app.application.configuration import get_api_runtime_config_snapshot from app.api.principal import ApiPrincipal from app.api.dependencies.auth import ( get_current_active_manage_user, @@ -194,7 +194,12 @@ def rename( # 重命名目录内文件 if recursive: transferchain = TransferChain() - media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT + runtime_config = get_api_runtime_config_snapshot() + media_exts = ( + runtime_config.media_extensions + + runtime_config.subtitle_extensions + + runtime_config.audio_extensions + ) # 递归修改目录内文件(智能识别命名) sub_files: List[_SchemaFileItem] = StorageChain().list_files(fileitem) if sub_files: diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 031e85949..f32236dea 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -13,7 +13,6 @@ from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.schemas.workflow import Subscribe as _SchemaSubscribe from app.api.response import ResponseAPIRouter from app.chain.subscribe import SubscribeChain -from app.runtime.config import settings from app.runtime.events import eventmanager from app.domain.context import MediaInfo from app.domain.metainfo import MetaInfo @@ -35,7 +34,10 @@ from app.application.subscription.mutation import ( SubscriptionActor, SubscriptionMutationService, ) -from app.application.configuration import get_configured_system_config +from app.application.configuration import ( + get_api_runtime_config_snapshot, + get_configured_system_config, +) from app.api.dependencies.auth import ( get_current_active_user, get_current_active_user_async, @@ -493,7 +495,7 @@ async def seerr_subscribe( """ Jellyseerr/Overseerr网络勾子通知订阅 """ - if not authorization or authorization != settings.API_TOKEN: + if not authorization or authorization != get_api_runtime_config_snapshot().api_token: raise HTTPException( status_code=400, detail="授权失败", diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 0b8491ae0..f6bf70478 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -35,13 +35,16 @@ from app.chain.media import MediaChain from app.chain.mediaserver import MediaServerChain from app.chain.search import SearchChain from app.chain.system import SystemChain -from app.runtime.config import global_vars, settings +from app.runtime.config import global_vars from app.runtime.events import eventmanager from app.domain.metainfo import MetaInfo from app.application.module import ModuleManager from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token from app.api.principal import ApiPrincipal -from app.application.configuration import get_configured_system_config +from app.application.configuration import ( + get_configured_system_config, + get_runtime_settings, +) from app.api.dependencies.auth import ( get_current_active_superuser, get_current_active_superuser_async, @@ -108,22 +111,23 @@ def _validate_llm_server_tool_config(env: dict) -> Optional[str]: ServerToolUnavailableError, ) + runtime_settings = get_runtime_settings() mode = ServerToolRegistry.normalize_web_search_mode( env.get( "LLM_WEB_SEARCH_MODE", - getattr(settings, "LLM_WEB_SEARCH_MODE", "local"), + runtime_settings.get("LLM_WEB_SEARCH_MODE", "local"), ) ) if mode != "builtin": return None provider = str( - env.get("LLM_PROVIDER", getattr(settings, "LLM_PROVIDER", "")) or "" + env.get("LLM_PROVIDER", runtime_settings.get("LLM_PROVIDER", "")) or "" ).strip() model = str( - env.get("LLM_MODEL", getattr(settings, "LLM_MODEL", "")) or "" + env.get("LLM_MODEL", runtime_settings.get("LLM_MODEL", "")) or "" ).strip() - base_url = env.get("LLM_BASE_URL", getattr(settings, "LLM_BASE_URL", None)) + base_url = env.get("LLM_BASE_URL", runtime_settings.get("LLM_BASE_URL")) capability = ServerToolRegistry.get_capability( provider=provider, model=model, @@ -147,14 +151,19 @@ def _validate_database_backup_config(env: dict) -> Optional[str]: if not _DATABASE_BACKUP_SETTING_KEYS.intersection(env): return None - cron = str(env.get("DB_BACKUP_CRON", settings.DB_BACKUP_CRON) or "").strip() + runtime_settings = get_runtime_settings() + cron = str(env.get("DB_BACKUP_CRON", runtime_settings.get("DB_BACKUP_CRON")) or "").strip() if cron: try: - TimerUtils.normalize_schedule_trigger("cron", cron, settings.TZ) + TimerUtils.normalize_schedule_trigger( + "cron", + cron, + runtime_settings.get("TZ"), + ) except (TypeError, ValueError): return "数据库备份周期格式不正确" - backup_path = env.get("DB_BACKUP_PATH", settings.DB_BACKUP_PATH) + backup_path = env.get("DB_BACKUP_PATH", runtime_settings.get("DB_BACKUP_PATH")) if backup_path is not None and not isinstance(backup_path, str): return "数据库备份目录必须是路径字符串" @@ -162,7 +171,7 @@ def _validate_database_backup_config(env: dict) -> Optional[str]: ("DB_BACKUP_RETENTION_DAYS", "数据库备份过期天数"), ("DB_BACKUP_MAX_COUNT", "数据库备份最大保留份数"), ): - value = env.get(key, getattr(settings, key)) + value = env.get(key, runtime_settings.get(key)) if isinstance(value, bool): return f"{label}必须是大于等于 0 的整数" try: @@ -221,12 +230,16 @@ def _build_nettest_rules() -> list[dict[str, Any]]: 前端只拿到展示所需的 id/name/icon;真正的 URL、代理策略、内容校验规则 和重定向白名单都保留在服务端,避免再出现用户可控 SSRF。 """ - github_proxy = UrlUtils.standardize_base_url(settings.GITHUB_PROXY or "") - pip_proxy = UrlUtils.standardize_base_url( - settings.PIP_PROXY or "https://pypi.org/simple/" + runtime_settings = get_runtime_settings() + github_proxy = UrlUtils.standardize_base_url( + runtime_settings.get("GITHUB_PROXY") or "" ) - tmdb_key = settings.TMDB_API_KEY - tmdb_domain = settings.TMDB_API_DOMAIN or "api.themoviedb.org" + pip_proxy = UrlUtils.standardize_base_url( + runtime_settings.get("PIP_PROXY") or "https://pypi.org/simple/" + ) + tmdb_key = runtime_settings.get("TMDB_API_KEY") + tmdb_domain = runtime_settings.get("TMDB_API_DOMAIN") or "api.themoviedb.org" + github_headers = runtime_settings.get("GITHUB_HEADERS") github_readme_url = "https://github.com/jxxghp/MoviePilot/blob/v2/README.md" raw_readme_url = "https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/README.md" @@ -348,7 +361,7 @@ def _build_nettest_rules() -> list[dict[str, Any]]: if github_proxy else "无效响应", "proxy_name": "Github加速代理" if github_proxy else "", - "headers": settings.GITHUB_HEADERS, + "headers": github_headers, }, { "id": "github_api", @@ -357,7 +370,7 @@ def _build_nettest_rules() -> list[dict[str, Any]]: "url": "https://api.github.com", "proxy": True, "allowed_redirect_prefixes": ["https://api.github.com/"], - "headers": settings.GITHUB_HEADERS, + "headers": github_headers, }, { "id": "github_codeload", @@ -369,7 +382,7 @@ def _build_nettest_rules() -> list[dict[str, Any]]: "https://codeload.github.com/", "https://github.com/", ], - "headers": settings.GITHUB_HEADERS, + "headers": github_headers, }, { "id": "github_proxy_raw", @@ -392,7 +405,7 @@ def _build_nettest_rules() -> list[dict[str, Any]]: if github_proxy else "无效响应", "proxy_name": "Github加速代理" if github_proxy else "", - "headers": settings.GITHUB_HEADERS, + "headers": github_headers, }, ] if tmdb_domain not in {"api.themoviedb.org", "api.tmdb.org"}: @@ -424,7 +437,7 @@ def _collect_named_log_files(name: str) -> list[Path]: if not normalized_name or not _LOG_DOWNLOAD_NAME_PATTERN.fullmatch(normalized_name): raise HTTPException(status_code=404, detail="Not Found") - log_root = settings.LOG_PATH + log_root = Path(get_runtime_settings().get("LOG_PATH")) if normalized_name == "moviepilot": log_dir = log_root log_prefix = "moviepilot.log" @@ -495,7 +508,7 @@ def _build_log_zip_data(name: str) -> tuple[bytes, str]: if not log_files: raise HTTPException(status_code=404, detail="Not Found") - log_root = settings.LOG_PATH + log_root = Path(get_runtime_settings().get("LOG_PATH")) zip_buffer = io.BytesIO() filename_time = datetime.now().strftime("%Y%m%d-%H%M%S") safe_name = (name or "logs").strip().lower() or "logs" @@ -598,14 +611,17 @@ async def fetch_image( return None if allowed_domains is None: - allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) + allowed_domains = set(get_runtime_settings().get("SECURITY_IMAGE_DOMAINS", [])) fetch_url = SecurityUtils.strip_url_signature(url) # 验证URL安全性 if not await SecurityUtils.is_safe_image_url_async( url, allowed_domains, - allowed_private_ranges=settings.IMAGE_PROXY_ALLOWED_PRIVATE_RANGES, + allowed_private_ranges=get_runtime_settings().get( + "IMAGE_PROXY_ALLOWED_PRIVATE_RANGES", + [], + ), ): return None @@ -663,7 +679,7 @@ async def proxy_img( """ 图片代理,可选是否使用代理服务器,支持 HTTP 缓存 """ - allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) + allowed_domains = set(get_runtime_settings().get("SECURITY_IMAGE_DOMAINS", [])) cookies = ( MediaServerChain().get_image_cookies(server=None, image_url=imgurl) if use_cookies @@ -706,7 +722,9 @@ async def cache_img( """ # 如果没有启用全局图片缓存,则不使用磁盘缓存 return await fetch_image( - url=url, use_cache=settings.GLOBAL_IMAGE_CACHE, if_none_match=if_none_match + url=url, + use_cache=bool(get_runtime_settings().get("GLOBAL_IMAGE_CACHE")), + if_none_match=if_none_match, ) @@ -724,7 +742,8 @@ def get_global_setting(token: str): raise HTTPException(status_code=403, detail="Forbidden") # 白名单模式,仅包含登录前UI初始化必需的字段 - info = settings.model_dump( + runtime_settings = get_runtime_settings() + info = runtime_settings.snapshot( include={ "TMDB_IMAGE_DOMAIN", "GLOBAL_IMAGE_CACHE", @@ -739,7 +758,7 @@ def get_global_setting(token: str): } ) # 仅在后端开发模式下返回该标记,避免生产环境暴露无意义运行态信息 - if settings.DEV: + if runtime_settings.get("DEV"): info.update({"BACKEND_DEV": True}) return _SchemaResponse(success=True, data=info) @@ -755,7 +774,8 @@ async def get_user_global_setting(_: ApiPrincipal = Depends(get_current_active_u 包含业务功能相关的配置和用户权限信息 """ # 业务功能相关的配置字段 - info = settings.model_dump( + runtime_settings = get_runtime_settings() + info = runtime_settings.snapshot( include={ "AI_AGENT_ENABLE", "AI_AGENT_HIDE_ENTRY", @@ -767,7 +787,7 @@ async def get_user_global_setting(_: ApiPrincipal = Depends(get_current_active_u } ) # 智能助手总开关未开启,智能推荐状态强制返回False - if not settings.AI_AGENT_ENABLE: + if not runtime_settings.get("AI_AGENT_ENABLE"): info["AI_RECOMMEND_ENABLED"] = False info["LLM_SUPPORT_AUDIO_INPUT"] = False info["LLM_SUPPORT_AUDIO_OUTPUT"] = False @@ -795,7 +815,9 @@ async def get_env_setting( """ 查询系统环境变量,包括当前版本号(仅管理员) """ - info = settings.model_dump(exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"}) + info = get_runtime_settings().snapshot( + exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"} + ) info.update( { "VERSION": APP_VERSION, @@ -847,7 +869,7 @@ async def set_env_setting( if validation_error: return _SchemaResponse(success=False, message=validation_error) - result = settings.update_settings(env=env) + result = get_runtime_settings().update_many(env) # 统计成功和失败的结果 success_updates = {k: v for k, v in result.items() if v[0]} failed_updates = {k: v for k, v in result.items() if v[0] is False} @@ -924,7 +946,10 @@ async def get_public_setting( 查询普通用户可读取的非敏感系统设置 """ if key in _PUBLIC_SETTINGS_KEYS: - return _SchemaResponse(success=True, data={"value": getattr(settings, key)}) + return _SchemaResponse( + success=True, + data={"value": get_runtime_settings().get(key)}, + ) if key not in _PUBLIC_SYSTEM_CONFIG_KEYS: raise HTTPException(status_code=404, detail="配置项不存在") value = get_configured_system_config().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key]) @@ -949,8 +974,8 @@ async def sync_plugin_market_from_wiki( return _SchemaResponse(success=False, message="不支持的 Wiki 同步地址") res = await AsyncRequestUtils( - ua=settings.USER_AGENT, - proxies=settings.PROXY, + ua=get_runtime_settings().get("USER_AGENT"), + proxies=get_runtime_settings().get("PROXY"), timeout=30, content_type=None, accept_type="text/plain,*/*", @@ -967,13 +992,15 @@ async def sync_plugin_market_from_wiki( if not wiki_repos: return _SchemaResponse(success=False, message="未在 Wiki 中识别到插件仓库地址") - local_repos = split_plugin_market_repo_urls(settings.PLUGIN_MARKET) + local_repos = split_plugin_market_repo_urls( + get_runtime_settings().get("PLUGIN_MARKET", "") + ) local_repo_keys = {repo.lower() for repo in local_repos} added_count = len([repo for repo in wiki_repos if repo.lower() not in local_repo_keys]) merged_repos = merge_plugin_market_repos(local_repos, wiki_repos) merged_value = ",".join(merged_repos) - success, message = settings.update_setting("PLUGIN_MARKET", merged_value) + success, message = get_runtime_settings().update("PLUGIN_MARKET", merged_value) if success: await eventmanager.async_send_event( etype=EventType.ConfigChanged, @@ -1009,8 +1036,9 @@ async def get_setting( """ 查询系统设置(仅管理员) """ - if hasattr(settings, key): - value = getattr(settings, key) + runtime_settings = get_runtime_settings() + if runtime_settings.contains(key): + value = runtime_settings.get(key) else: value = get_configured_system_config().get(key) return _SchemaResponse(success=True, data={"value": value}) @@ -1025,8 +1053,9 @@ async def set_setting( """ 更新系统设置(仅管理员) """ - if hasattr(settings, key): - success, message = settings.update_setting(key=key, value=value) + runtime_settings = get_runtime_settings() + if runtime_settings.contains(key): + success, message = runtime_settings.update(key, value) if success: # 发送配置变更事件 await eventmanager.async_send_event( @@ -1114,7 +1143,7 @@ async def get_logging( length = -1 时, 返回text/plain 否则 返回格式SSE """ - base_path = AsyncPath(settings.LOG_PATH) + base_path = AsyncPath(get_runtime_settings().get("LOG_PATH")) log_path = base_path / logfile if not await SecurityUtils.async_is_safe_path( @@ -1252,7 +1281,8 @@ async def latest_version(_: _SchemaTokenPayload = Depends(verify_token)): 查询Github所有Release版本 """ version_res = await AsyncRequestUtils( - proxies=settings.PROXY, headers=settings.GITHUB_HEADERS + proxies=get_runtime_settings().get("PROXY"), + headers=get_runtime_settings().get("GITHUB_HEADERS"), ).get_res(f"https://api.github.com/repos/jxxghp/MoviePilot/releases") if version_res is not None and version_res.status_code == 200: ver_json = version_res.json() @@ -1392,10 +1422,10 @@ async def nettest( logger.debug("nettest include 参数已忽略,改为服务端固定校验") request_utils = AsyncRequestUtils( - proxies=settings.PROXY if target.get("proxy") else None, + proxies=get_runtime_settings().get("PROXY") if target.get("proxy") else None, headers=target.get("headers"), timeout=10, - ua=settings.NORMAL_USER_AGENT, + ua=get_runtime_settings().get("NORMAL_USER_AGENT"), verify=True, follow_redirects=False, ) diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index acf920d92..85bef4059 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -17,7 +17,8 @@ from app.schemas.workflow import FileItem as _SchemaFileItem from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.transfer import TransferChain -from app.runtime.config import settings, global_vars +from app.runtime.config import global_vars +from app.application.configuration import get_api_runtime_config_snapshot from app.adapters.web.security.access import verify_token, verify_apitoken from app.api.dependencies.auth import get_current_active_manage_user from app.api.dependencies.history import get_transfer_history_lookup_service @@ -59,7 +60,9 @@ def query_name( return _SchemaResponse(success=False, message="未识别到新名称") if filetype == "dir": media_path = DirectoryHelper.get_media_root_path( - rename_format=settings.RENAME_FORMAT(context.media_info.type), + rename_format=get_api_runtime_config_snapshot().rename_format( + context.media_info.type + ), rename_path=Path(new_path), media_type=context.media_info.type, ) diff --git a/app/api/servcookie.py b/app/api/servcookie.py index 24e206347..634ce406f 100644 --- a/app/api/servcookie.py +++ b/app/api/servcookie.py @@ -15,7 +15,7 @@ from app.schemas.servcookie import CookieDecryptedPayload as _SchemaCookieDecryp from app.schemas.servcookie import CookieEncryptedPayload as _SchemaCookieEncryptedPayload from app.schemas.servcookie import CookiePassword as _SchemaCookiePassword from app.api.response import ERROR_RESPONSES -from app.runtime.config import settings +from app.application.configuration import get_api_runtime_config_snapshot from app.runtime.log import logger from app.foundation.crypto import CryptoJsUtils, HashUtils @@ -52,7 +52,7 @@ async def verify_server_enabled() -> bool: """ 校验CookieCloud服务路由是否打开 """ - if not settings.COOKIECLOUD_ENABLE_LOCAL: + if not get_api_runtime_config_snapshot().cookiecloud_enable_local: raise HTTPException(status_code=400, detail="本地CookieCloud服务器未启用") return True @@ -65,7 +65,9 @@ async def verify_update_auth( """ 校验CookieCloud上传接口的可选共享认证头。 """ - expected_header = (settings.COOKIECLOUD_AUTH_HEADER or "").strip() + expected_header = ( + get_api_runtime_config_snapshot().cookiecloud_auth_header or "" + ).strip() if not expected_header: return True @@ -124,7 +126,7 @@ async def update_cookie(req: _SchemaCookieData) -> _SchemaCookieActionResponse: """ 上传Cookie数据 """ - file_path = AsyncPath(settings.COOKIE_PATH) / f"{req.uuid}.json" + file_path = AsyncPath(get_api_runtime_config_snapshot().cookie_path) / f"{req.uuid}.json" content = json.dumps({"encrypted": req.encrypted}) async with aiofiles.open(file_path, encoding="utf-8", mode="w") as file: await file.write(content) @@ -140,7 +142,7 @@ async def load_encrypt_data(uuid: str) -> _SchemaCookieEncryptedPayload: """ 加载本地加密原始数据 """ - file_path = AsyncPath(settings.COOKIE_PATH) / f"{uuid}.json" + file_path = AsyncPath(get_api_runtime_config_snapshot().cookie_path) / f"{uuid}.json" # 检查文件是否存在 if not await file_path.exists(): diff --git a/app/application/configuration.py b/app/application/configuration.py index c6f392e36..0b6d34cc9 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Optional, Protocol +from app.schemas.types import MediaType + class SystemConfigReader(Protocol): """持久化用户配置的最小只读端口。""" @@ -35,6 +37,34 @@ class ConfigurationRepository(SystemConfigReader, SystemConfigWriter, Protocol): """兼容同时提供读写能力的旧配置仓储。""" +class MutableRuntimeSettings(Protocol): + """部署设置对象对管理 API 暴露的最小可变合同。""" + + def model_dump( + self, + *, + include: Optional[set[str]] = None, + exclude: Optional[set[str]] = None, + ) -> dict[str, Any]: + """按白名单或排除列表导出当前设置。""" + ... + + def update_settings( + self, + env: dict[str, Any], + ) -> dict[str, tuple[Optional[bool], str]]: + """批量更新部署设置并返回逐项结果。""" + ... + + def update_setting( + self, + key: str, + value: Any, + ) -> tuple[Optional[bool], str]: + """更新单个部署设置。""" + ... + + @dataclass(frozen=True, slots=True) class TransferRetryConfig: """整理失败重试用例在一次调用中使用的稳定配置快照。""" @@ -54,6 +84,28 @@ class ApiRuntimeConfig: temp_path: Path = Path(".") media_recognize_share: bool = False subscribe_mode: str = "spider" + search_source: str = "" + media_extensions: tuple[str, ...] = () + subtitle_extensions: tuple[str, ...] = () + audio_extensions: tuple[str, ...] = () + movie_rename_format: str = "" + television_rename_format: str = "" + music_rename_format: str = "" + vapid_private_key: str = "" + vapid_subject: str = "" + cookiecloud_enable_local: bool = False + cookiecloud_auth_header: Optional[str] = None + cookie_path: Path = Path(".") + root_path: Path = Path(".") + version_flag: str = "v3" + + def rename_format(self, media_type: MediaType) -> str: + """从请求快照返回指定媒体类型的稳定重命名格式。""" + if media_type == MediaType.TV: + return self.television_rename_format + if media_type == MediaType.MUSIC: + return self.music_rename_format + return self.movie_rename_format @dataclass(frozen=True, slots=True) @@ -85,22 +137,80 @@ class ChainRuntimeConfig: """Chain 在一次宿主生命周期内使用的基础配置快照。""" media_extensions: tuple[str, ...] + video_extensions: tuple[str, ...] = () + subtitle_extensions: tuple[str, ...] = () + audio_extensions: tuple[str, ...] = () + temporary_path: Path = Path(".") + root_path: Path = Path(".") + config_path: Path = Path(".") + frontend_path: Path = Path(".") superuser: str = "admin" media_recognize_share: bool = False auxiliary_auth_enable: bool = False global_image_cache: bool = False + download_subtitle: bool = True + music_metadata_to_simplified: bool = True + recognize_plugin_first: bool = False + ai_agent_enable: bool = False + ai_agent_global: bool = False + ai_agent_retry_transfer: bool = False + llm_provider: str = "" + llm_model: str = "" + search_resource_pages: int = 1 + ai_recommend_enabled: bool = False + ai_recommend_max_items: int = 50 + ai_recommend_user_preference: str = "" + max_search_name_limit: int = 3 + search_multiple_name: bool = False + search_threadpool_size: int = 1 + transfer_threads: int = 1 + transfer_failure_notification_aggregation: bool = True + transfer_task_timeout: int = 120 + scrape_follow_tmdb: bool = True + metadata_cache_ttl: int = 3600 auto_download_user: Optional[str] = None resource_url: Optional[str] = None + history_url: Optional[str] = None + downloading_url: Optional[str] = None + movie_subscribe_url: Optional[str] = None + television_subscribe_url: Optional[str] = None + music_subscribe_url: Optional[str] = None user_agent: str = "" + normal_user_agent: str = "" proxy: Any = None proxy_server: Any = None proxy_host: Optional[str] = None + github_headers: Any = None cookiecloud_blacklist: Any = None subscribe_mode: str = "spider" no_cache_site_key: str = "" refresh_batch_size: int = 50 torrent_cache_size: int = 1000 site_url: Optional[str] = None + season_zero_names: tuple[str, ...] = () + movie_rename_format: str = "" + television_rename_format: str = "" + music_rename_format: str = "" + tmdb_image_domain: str = "image.tmdb.org" + + def rename_format(self, media_type: MediaType) -> str: + """从快照返回指定媒体类型的稳定重命名格式。""" + if media_type == MediaType.TV: + return self.television_rename_format + if media_type == MediaType.MUSIC: + return self.music_rename_format + return self.movie_rename_format + + def tmdb_image_url( + self, + file_path: Optional[str], + file_size: str = "original", + ) -> Optional[str]: + """使用快照中的 TMDB 图片域名构造完整图片地址。""" + if not file_path: + return None + normalized_path = file_path.removeprefix("/") + return f"https://{self.tmdb_image_domain}/t/p/{file_size}/{normalized_path}" @dataclass(frozen=True, slots=True) @@ -112,6 +222,42 @@ class RuntimeConfiguration: chain: Callable[[], ChainRuntimeConfig] +class RuntimeSettingsService: + """隔离管理 API 与全局 Settings 实例的读写适配服务。""" + + def __init__(self, settings: MutableRuntimeSettings) -> None: + """保存由 Startup 注入的唯一部署设置对象。""" + self._settings = settings + + def contains(self, key: str) -> bool: + """判断部署设置是否声明指定字段或属性。""" + return hasattr(self._settings, key) + + def get(self, key: str, default: Any = None) -> Any: + """读取一个部署设置,缺失时返回调用方默认值。""" + return getattr(self._settings, key, default) + + def snapshot( + self, + *, + include: Optional[set[str]] = None, + exclude: Optional[set[str]] = None, + ) -> dict[str, Any]: + """导出脱离可变 Settings 对象的请求级字典快照。""" + return self._settings.model_dump(include=include, exclude=exclude) + + def update_many( + self, + env: dict[str, Any], + ) -> dict[str, tuple[Optional[bool], str]]: + """批量更新部署设置。""" + return self._settings.update_settings(env=env) + + def update(self, key: str, value: Any) -> tuple[Optional[bool], str]: + """更新单个部署设置。""" + return self._settings.update_setting(key=key, value=value) + + class SystemConfigService: """系统配置读写应用服务。""" @@ -154,6 +300,7 @@ class SystemConfigService: _configured_system_config: SystemConfigService | None = None _transfer_retry_config_provider: Callable[[], TransferRetryConfig] | None = None _runtime_configuration: RuntimeConfiguration | None = None +_runtime_settings_service: RuntimeSettingsService | None = None def configure_system_config(service: SystemConfigService) -> None: @@ -190,6 +337,19 @@ def configure_runtime_configuration(configuration: RuntimeConfiguration) -> None _runtime_configuration = configuration +def configure_runtime_settings(service: RuntimeSettingsService) -> None: + """由组合根登记管理 API 使用的部署设置服务。""" + global _runtime_settings_service + _runtime_settings_service = service + + +def get_runtime_settings() -> RuntimeSettingsService: + """返回组合根登记的部署设置服务。""" + if _runtime_settings_service is None: + raise RuntimeError("部署设置服务尚未装配") + return _runtime_settings_service + + def get_scheduler_runtime_config() -> SchedulerRuntimeConfig: """为一次调度操作创建不可变配置快照。""" if _runtime_configuration is None: @@ -202,3 +362,10 @@ def get_api_runtime_config_snapshot() -> ApiRuntimeConfig: if _runtime_configuration is None: raise RuntimeError("运行时配置尚未装配") return _runtime_configuration.api() + + +def get_chain_runtime_config_snapshot() -> ChainRuntimeConfig: + """为无实例兼容入口创建一次稳定的 Chain 配置快照。""" + if _runtime_configuration is None: + raise RuntimeError("运行时配置尚未装配") + return _runtime_configuration.chain() diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 1ca9897d9..8c24c405d 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -9,6 +9,10 @@ from typing import Optional, Any, Tuple, List, Set, Union, Dict from app.application.chain.context import ChainRuntimeContext, get_chain_runtime_context from app.application.chain.data import get_chain_data_ports +from app.application.configuration import ( + ChainRuntimeConfig, + get_chain_runtime_config_snapshot, +) from app.chain._messaging import MessageProcessingMixin, NotificationMixin from app.chain._recognition import RecognitionMixin from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo @@ -64,6 +68,19 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, ) self.messagequeue = context.message_queue_factory(self.run_module) + @property + def runtime_config(self) -> ChainRuntimeConfig: + """返回实例快照;兼容绕过构造器的旧调用并按需取得当前快照。""" + configuration = getattr(self, "_runtime_config", None) + if configuration is None: + return get_chain_runtime_config_snapshot() + return configuration + + @runtime_config.setter + def runtime_config(self, configuration: ChainRuntimeConfig) -> None: + """保存显式注入的 Chain 配置快照。""" + self._runtime_config = configuration + def load_cache(self, filename: str) -> Any: """ 加载缓存 diff --git a/app/chain/_transfer.py b/app/chain/_transfer.py index 653c4e7c3..fe41c91f2 100644 --- a/app/chain/_transfer.py +++ b/app/chain/_transfer.py @@ -26,13 +26,16 @@ from app.application.chain.data import ( DownloadHistoryPortProxy as DownloadHistoryOper, TransferHistoryPortProxy as TransferHistoryOper, ) -from app.application.configuration import get_configured_system_config +from app.application.configuration import ( + get_chain_runtime_config_snapshot, + get_configured_system_config, +) from app.domain.context import MediaInfo, MusicInfo from app.domain.media import normalize_music_type from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.foundation import text as text_tools -from app.runtime.config import global_vars, settings +from app.runtime.config import global_vars from app.runtime.log import logger from app.schemas.workflow import FileItem from app.schemas.message import Message @@ -320,7 +323,7 @@ class FileFilterMixin: """ if history.type == MediaType.MUSIC.value: return True - return src_path.suffix.lower() in settings.RMT_AUDIOEXT + return src_path.suffix.lower() in get_chain_runtime_config_snapshot().audio_extensions def _recognize_music_retry_media( self, @@ -1338,7 +1341,7 @@ class FailedRetryMixin: userid=userid, username=username, title=f"整理记录 #{history_id} 已重新整理", - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, save_history=False, ) ) @@ -1352,7 +1355,7 @@ class FailedRetryMixin: username=username, title="重新整理失败", text=errmsg, - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, save_history=False, ) ) @@ -1369,7 +1372,7 @@ class FailedRetryMixin: 由智能助手接管一条失败的整理记录。 """ - if not settings.AI_AGENT_ENABLE: + if not self.runtime_config.ai_agent_enable: self.post_message( Message( channel=channel, @@ -1392,7 +1395,7 @@ class FailedRetryMixin: username=username, title="重新整理失败", text=f"整理记录 #{history_id} 不存在", - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, save_history=False, ) ) @@ -1408,7 +1411,7 @@ class FailedRetryMixin: username=username, title=f"已将整理记录 #{history_id} 交给智能助手处理", text="处理完成后会在这里回复结果。", - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, save_history=False, ) ) @@ -1440,7 +1443,7 @@ class FailedRetryMixin: title="智能助手整理完成", text=final_output.strip() or f"整理记录 #{history_id} 已由智能助手处理完成。", - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, save_history=False, ) ) @@ -1453,7 +1456,7 @@ class FailedRetryMixin: username=username, title="智能助手整理失败", text=str(e), - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, save_history=False, ) ) diff --git a/app/chain/download.py b/app/chain/download.py index 874e7bc61..c6f51f542 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -16,7 +16,8 @@ from app.chain import ChainBase from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.runtime.cache import FileCache -from app.runtime.config import settings, global_vars +from app.runtime.config import global_vars +from app.application.configuration import get_chain_runtime_config_snapshot from app.domain.context import ( Context, MediaInfo, @@ -128,7 +129,7 @@ class DownloadChain(ChainBase): mtype=MessageType.Download, ctype=ContentType.DownloadAdded, image=media.get_message_image(), - link=settings.MP_DOMAIN('/#/downloading'), + link=self.runtime_config.downloading_url, userid=userid, username=username, ), @@ -171,7 +172,8 @@ class DownloadChain(ChainBase): track_identities = { identity for file in file_list - if Path(str(file)).suffix.lower() in settings.RMT_AUDIOEXT + if Path(str(file)).suffix.lower() + in get_chain_runtime_config_snapshot().audio_extensions and (identity := DownloadChain._music_resource_track_identity(file)) } actual_tracks = len(track_identities) @@ -264,7 +266,10 @@ class DownloadChain(ChainBase): """ 判断是否为支持的字幕文件。 """ - return Path(file_name).suffix.lower() in settings.RMT_SUBEXT + return ( + Path(file_name).suffix.lower() + in get_chain_runtime_config_snapshot().subtitle_extensions + ) @classmethod def _get_subtitle_working_dir( @@ -441,10 +446,10 @@ class DownloadChain(ChainBase): return False, message, [] saved_files = [] - temp_file = settings.TEMP_PATH / file_name + temp_file = self.runtime_config.temporary_path / file_name temp_extract_dir = temp_file.with_name(temp_file.stem) try: - settings.TEMP_PATH.mkdir(parents=True, exist_ok=True) + self.runtime_config.temporary_path.mkdir(parents=True, exist_ok=True) temp_file.write_bytes(response.content) if self._is_subtitle_archive(file_name): try: @@ -457,7 +462,10 @@ class DownloadChain(ChainBase): message = f"字幕压缩包解压失败:{str(err)}" logger.error(f"{message},文件:{temp_file}") return False, message, [] - for sub_file in SystemUtils.list_files(temp_extract_dir, settings.RMT_SUBEXT): + for sub_file in SystemUtils.list_files( + temp_extract_dir, + self.runtime_config.subtitle_extensions, + ): uploaded_path, message = self._upload_subtitle_file( storage_chain=storage_chain, storage=storage, @@ -537,8 +545,8 @@ class DownloadChain(ChainBase): request = RequestUtils( cookies=subtitle.site_cookie, - ua=subtitle.site_ua or settings.USER_AGENT, - proxies=settings.PROXY if subtitle.site_proxy else None, + ua=subtitle.site_ua or self.runtime_config.user_agent, + proxies=self.runtime_config.proxy if subtitle.site_proxy else None, ) try: response = request.get_res(subtitle.enclosure, raise_exception=True) @@ -621,7 +629,7 @@ class DownloadChain(ChainBase): :param download_dir: 下载目录 :param torrent_content: 种子内容,如果是种子文件,则为文件内容,否则为种子字符串 """ - if not settings.DOWNLOAD_SUBTITLE: + if not self.runtime_config.download_subtitle: return # 没有种子文件不处理 @@ -673,9 +681,9 @@ class DownloadChain(ChainBase): request = RequestUtils( cookies=torrent.site_cookie, ua=torrent.site_ua, - proxies=settings.PROXY if torrent.site_proxy else None, + proxies=self.runtime_config.proxy if torrent.site_proxy else None, ) - settings.TEMP_PATH.mkdir(parents=True, exist_ok=True) + self.runtime_config.temporary_path.mkdir(parents=True, exist_ok=True) for sublink in sublink_list: logger.info(f"找到字幕下载链接:{sublink},开始下载...") # 下载 @@ -687,7 +695,7 @@ class DownloadChain(ChainBase): continue archive_format = self._SUBTITLE_ARCHIVE_FORMATS.get(Path(file_name).suffix.lower()) if archive_format: - archive_file = settings.TEMP_PATH / file_name + archive_file = self.runtime_config.temporary_path / file_name # 保存 archive_file.write_bytes(ret.content) # 解压路径 @@ -700,7 +708,10 @@ class DownloadChain(ChainBase): archive_format=archive_format, ) # 遍历转移文件 - for sub_file in SystemUtils.list_files(archive_path, settings.RMT_SUBEXT): + for sub_file in SystemUtils.list_files( + archive_path, + self.runtime_config.subtitle_extensions, + ): target_sub_file = Path(working_dir_item.path) / Path(sub_file.name) if storage_chain.get_file_item(storage, target_sub_file): logger.info(f"字幕文件已存在:{target_sub_file}") @@ -718,10 +729,13 @@ class DownloadChain(ChainBase): except Exception as err: logger.error(f"删除临时文件失败:{str(err)}") else: - if Path(file_name).suffix.lower() not in settings.RMT_SUBEXT: + if ( + Path(file_name).suffix.lower() + not in self.runtime_config.subtitle_extensions + ): logger.warn(f"链接不是支持的字幕文件:{sublink} - {file_name}") continue - sub_file = settings.TEMP_PATH / file_name + sub_file = self.runtime_config.temporary_path / file_name # 保存 sub_file.write_bytes(ret.content) target_sub_file = Path(working_dir_item.path) / Path(sub_file.name) @@ -953,7 +967,7 @@ class DownloadChain(ChainBase): ua=ua, cookies=cookie, headers=headers, - proxies=settings.PROXY if proxy else None + proxies=get_chain_runtime_config_snapshot().proxy if proxy else None ).get_res(url, params=req_params.get('params')) else: # POST请求 @@ -961,7 +975,7 @@ class DownloadChain(ChainBase): ua=ua, cookies=cookie, headers=headers, - proxies=settings.PROXY if proxy else None + proxies=get_chain_runtime_config_snapshot().proxy if proxy else None ).post_res(url, params=req_params.get('params')) if not res: return None @@ -1016,7 +1030,7 @@ class DownloadChain(ChainBase): _, content, download_folder, files, error_msg = TorrentHelper().download_torrent( url=torrent_url, cookie=site_cookie, - ua=torrent.site_ua or settings.USER_AGENT, + ua=torrent.site_ua or self.runtime_config.user_agent, proxy=torrent.site_proxy, cache_invalid=not indirect_download) @@ -1255,7 +1269,7 @@ class DownloadChain(ChainBase): or file_meta.begin_episode not in episodes: continue # 只处理音视频、字幕格式 - media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT + media_exts = self.runtime_config.media_extensions if not Path(file).suffix \ or Path(file).suffix.lower() not in media_exts: continue @@ -2074,7 +2088,7 @@ class DownloadChain(ChainBase): mtype=MessageType.Download, title="没有正在下载的任务!", userid=userid, - link=settings.MP_DOMAIN('#/downloading'), + link=self.runtime_config.downloading_url, save_history=False, )) return @@ -2094,7 +2108,7 @@ class DownloadChain(ChainBase): title=title, text="\n".join(messages), userid=userid, - link=settings.MP_DOMAIN('#/downloading'), + link=self.runtime_config.downloading_url, save_history=False, )) diff --git a/app/chain/media.py b/app/chain/media.py index ad688776a..ea0b3d8e3 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -12,7 +12,7 @@ from app.chain.douban import DoubanChain from app.chain.musicbrainz import MusicBrainzChain, _MusicMetadataSourceChain from app.chain.theaudiodb import TheAudioDbChain from app.runtime.cache import async_fresh, fresh -from app.runtime.config import settings +from app.application.configuration import get_chain_runtime_config_snapshot from app.domain.context import ( Context, MediaInfo, @@ -172,7 +172,7 @@ class MediaChain(ChainBase, metaclass=Singleton): @classmethod def _simplify_recognized_music_info(cls, info: MusicInfo) -> MusicInfo: """按开关转换标准音乐文本字段,并避免修改来源模块的缓存对象。""" - if not settings.MUSIC_METADATA_TO_SIMPLIFIED: + if not get_chain_runtime_config_snapshot().music_metadata_to_simplified: return info updates: dict[str, Any] = {} for field_name in cls._music_simplified_text_fields: @@ -462,7 +462,7 @@ class MediaChain(ChainBase, metaclass=Singleton): is_recognized = lambda result: bool(result) mediainfo = None plugin_available = eventmanager.check(plugin_event) - if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available: + if get_chain_runtime_config_snapshot().recognize_plugin_first and plugin_available: # 插件优先 logger.info(f"插件识别优先模式已开启。请求辅助识别,标题:{log_name} ...") helped = plugin_fn() @@ -912,7 +912,7 @@ class MediaChain(ChainBase, metaclass=Singleton): @classmethod def is_audio_path(cls, path: Union[str, Path]) -> bool: """判断路径是否指向系统支持的音频文件。""" - return Path(path).suffix.lower() in settings.RMT_AUDIOEXT + return Path(path).suffix.lower() in get_chain_runtime_config_snapshot().audio_extensions @classmethod def read_path_meta(cls, path: Union[str, Path]) -> MetaMusic: @@ -1094,7 +1094,7 @@ class MediaChain(ChainBase, metaclass=Singleton): item for item in entries if not item.name.startswith(".") and item.is_file() - and item.suffix.lower() in settings.RMT_AUDIOEXT + and item.suffix.lower() in get_chain_runtime_config_snapshot().audio_extensions ) collect(directory) @@ -1525,7 +1525,7 @@ class MediaChain(ChainBase, metaclass=Singleton): is_recognized = lambda result: bool(result) mediainfo = None plugin_available = eventmanager.check(plugin_event) - if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available: + if get_chain_runtime_config_snapshot().recognize_plugin_first and plugin_available: # 插件优先 logger.info(f"插件优先模式已开启。请求辅助识别,标题:{log_name} ...") helped = await plugin_fn() diff --git a/app/chain/message.py b/app/chain/message.py index 6d01790c2..456ef996a 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -21,7 +21,7 @@ from app.chain.site import SiteChain from app.chain.subscribe import SubscribeChain from app.chain.transfer import TransferChain from app.chain.interaction import MediaInteractionChain as _MediaInteractionChain -from app.runtime.config import settings, global_vars +from app.runtime.config import global_vars from app.application.messaging.agent import agent_interaction_manager, parse_agent_choice_callback from app.application.messaging.interaction import InteractionContext, InteractionDispatch from app.application.messaging.media import media_interaction_manager @@ -477,8 +477,13 @@ class MessageChain(ChainBase): if ( not no_ai_requested and - settings.AI_AGENT_ENABLE - and (settings.AI_AGENT_GLOBAL or images or files or has_audio_input) + self.runtime_config.ai_agent_enable + and ( + self.runtime_config.ai_agent_global + or images + or files + or has_audio_input + ) ): return self._handle_ai_message( text=text, @@ -554,8 +559,13 @@ class MessageChain(ChainBase): if text.startswith("/"): return False if not ( - settings.AI_AGENT_ENABLE - and (settings.AI_AGENT_GLOBAL or images or files or has_audio_input) + self.runtime_config.ai_agent_enable + and ( + self.runtime_config.ai_agent_global + or images + or files + or has_audio_input + ) ): return False if self._interaction_router().has_pending(userid): @@ -1223,7 +1233,7 @@ class MessageChain(ChainBase): """ try: # 检查AI智能体是否启用 - if not settings.AI_AGENT_ENABLE: + if not self.runtime_config.ai_agent_enable: self.post_message( Message( channel=channel, @@ -1280,8 +1290,8 @@ class MessageChain(ChainBase): original_images = images all_files = list(files or []) if images and supports_image_input( - provider=settings.LLM_PROVIDER, - model=settings.LLM_MODEL, + provider=self.runtime_config.llm_provider, + model=self.runtime_config.llm_model, ): images = self._download_attachments_to_data_urls( images, channel, source @@ -1829,7 +1839,7 @@ class MessageChain(ChainBase): 将用户上传文件写入临时目录,并返回本地路径。 """ safe_name = self._sanitize_attachment_name(filename, mime_type) - base_dir = settings.TEMP_PATH / "agent_uploads" / session_id + base_dir = self.runtime_config.temporary_path / "agent_uploads" / session_id base_dir.mkdir(parents=True, exist_ok=True) file_id = uuid.uuid4().hex[:8] diff --git a/app/chain/scraping.py b/app/chain/scraping.py index 3a6a83708..f305baf78 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -12,7 +12,6 @@ from app.chain import ChainBase from app.chain.lrclib import LrclibChain from app.chain.storage import StorageChain from app.runtime.cache import cached -from app.runtime.config import settings from app.domain.context import ( MediaInfo, MusicAlbumInfo, @@ -23,7 +22,10 @@ from app.runtime.events import eventmanager, Event from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath -from app.application.configuration import get_configured_system_config +from app.application.configuration import ( + get_chain_runtime_config_snapshot, + get_configured_system_config, +) from app.application.audio import AudioMetadataHelper from app.runtime.log import logger from app.schemas.workflow import FileItem @@ -314,7 +316,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): try: logger.info(f"正在下载图片:{url} ...") request_utils = RequestUtils( - proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT + proxies=self.runtime_config.proxy, + ua=self.runtime_config.normal_user_agent, ) with request_utils.get_stream(url=url) as r: if r and r.status_code == 200: @@ -932,7 +935,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): or isinstance(meta, MetaMusic) or ( fileitem.type == "file" - and filepath.suffix.lower() in settings.RMT_AUDIOEXT + and filepath.suffix.lower() in self.runtime_config.audio_extensions ) ) if is_music: @@ -953,7 +956,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): **music_kwargs, ) if fileitem.type == "file" and ( - not filepath.suffix or filepath.suffix.lower() not in settings.RMT_MEDIAEXT + not filepath.suffix + or filepath.suffix.lower() not in self.runtime_config.video_extensions ): return False, "刮削路径不是支持的媒体文件" @@ -1130,12 +1134,16 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) @staticmethod - @cached(maxsize=64, ttl=settings.CONF.meta, skip_none=True) + @cached( + maxsize=64, + ttl_provider=lambda: get_chain_runtime_config_snapshot().metadata_cache_ttl, + skip_none=True, + ) def _request_music_cover(url: str) -> Optional[tuple[Optional[bytes], str]]: """下载并缓存音乐封面;仅稳定 404 与成功响应进入有界缓存。""" response = RequestUtils( - proxies=settings.PROXY, - ua=settings.NORMAL_USER_AGENT, + proxies=get_chain_runtime_config_snapshot().proxy, + ua=get_chain_runtime_config_snapshot().normal_user_agent, timeout=20, ).get_res(url) if response is None: @@ -1161,7 +1169,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): @staticmethod def _is_music_audio_file(path: str) -> bool: """判断路径是否指向系统支持的音频文件。""" - return Path(path).suffix.lower() in settings.RMT_AUDIOEXT + return Path(path).suffix.lower() in get_chain_runtime_config_snapshot().audio_extensions def _music_audio_fileitems(self, fileitem: _SchemaFileItem) -> list[_SchemaFileItem]: """展开待刮削目录并过滤系统支持的音频文件。""" @@ -1803,7 +1811,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): for file in files: if ( file.type == "dir" - and file.name not in settings.RENAME_FORMAT_S0_NAMES + and file.name not in self.runtime_config.season_zero_names and MetaInfo(file.name).begin_season is None ): # 电视剧不处理非季子目录 @@ -1843,7 +1851,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): season_meta = MetaInfo(filepath.name) # 特殊季目录处理(Specials/SPs) - if filepath.name in settings.RENAME_FORMAT_S0_NAMES: + if filepath.name in self.runtime_config.season_zero_names: season_meta.begin_season = 0 elif season_meta.name and season_meta.begin_season is not None: # 排除辅助词重新识别,避免误判根目录 (issue https://github.com/jxxghp/MoviePilot/issues/5501) diff --git a/app/chain/search.py b/app/chain/search.py index 7f4dbfc17..f985df5ff 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -14,14 +14,17 @@ from fastapi.concurrency import run_in_threadpool from app.chain import ChainBase from app.chain.media import MediaChain -from app.runtime.config import global_vars, settings +from app.runtime.config import global_vars from app.domain.context import Context from app.domain.context import MediaInfo, SubtitleInfo, TorrentInfo from app.runtime.events import eventmanager, Event from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.domain.context import MusicInfo -from app.application.configuration import get_configured_system_config +from app.application.configuration import ( + get_chain_runtime_config_snapshot, + get_configured_system_config, +) from app.runtime.progress import AsyncProgressHelper, ProgressHelper from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.application.search.state import ( @@ -155,7 +158,7 @@ class SearchChain(ChainBase): settings 可能被环境变量写成字符串,这里统一兜底为 1,避免异常配置导致搜索中断。 """ - pages = settings.SEARCH_RESOURCE_PAGES + pages = get_chain_runtime_config_snapshot().search_resource_pages try: pages = int(pages) except (TypeError, ValueError): @@ -199,7 +202,10 @@ class SearchChain(ChainBase): """ 检查AI推荐功能是否已启用。 """ - return settings.AI_AGENT_ENABLE and settings.AI_RECOMMEND_ENABLED + return ( + self.runtime_config.ai_agent_enable + and self.runtime_config.ai_recommend_enabled + ) @staticmethod def _calculate_recommend_request_hash( @@ -425,7 +431,7 @@ class SearchChain(ChainBase): """ items: List[str] = [] valid_indices: List[int] = [] - max_items = settings.AI_RECOMMEND_MAX_ITEMS or 50 + max_items = get_chain_runtime_config_snapshot().ai_recommend_max_items or 50 if filtered_indices: results_to_process = [ @@ -555,7 +561,7 @@ class SearchChain(ChainBase): return user_preference = ( - settings.AI_RECOMMEND_USER_PREFERENCE + self.runtime_config.ai_recommend_user_preference or "Prefer high-quality resources with more seeders" ) search_results_text = ( @@ -1211,8 +1217,9 @@ class SearchChain(ChainBase): mediainfo.tw_title, mediainfo.sg_title] if k])) # 限制搜索关键词数量 - if settings.MAX_SEARCH_NAME_LIMIT: - keywords = keywords[:settings.MAX_SEARCH_NAME_LIMIT] + max_names = get_chain_runtime_config_snapshot().max_search_name_limit + if max_names: + keywords = keywords[:max_names] return season_episodes, keywords @@ -1274,7 +1281,10 @@ class SearchChain(ChainBase): finished_count = 0 filtered_by_site: Dict[Tuple[Optional[int], Optional[str]], List[TorrentInfo]] = {} - max_workers = min(len(site_torrents), settings.CONF.threadpool or len(site_torrents)) + max_workers = min( + len(site_torrents), + self.runtime_config.search_threadpool_size or len(site_torrents), + ) with ThreadPoolExecutor(max_workers=max_workers) as executor: all_tasks = { executor.submit(__do_site_filter, site_torrent_list): site_key @@ -1539,7 +1549,7 @@ class SearchChain(ChainBase): mediainfo, ) torrents.extend(matched_torrents) - if matched_torrents and not settings.SEARCH_MULTIPLE_NAME: + if matched_torrents and not self.runtime_config.search_multiple_name: break return self._build_music_contexts( torrents=torrents, @@ -1572,7 +1582,7 @@ class SearchChain(ChainBase): mediainfo, ) torrents.extend(matched_torrents) - if matched_torrents and not settings.SEARCH_MULTIPLE_NAME: + if matched_torrents and not self.runtime_config.search_multiple_name: break return await run_in_threadpool( self._build_music_contexts, @@ -1618,7 +1628,7 @@ class SearchChain(ChainBase): "items": [], "total_items": len(torrents), } - if keyword_matched and not settings.SEARCH_MULTIPLE_NAME: + if keyword_matched and not self.runtime_config.search_multiple_name: break contexts = await run_in_threadpool( @@ -1726,7 +1736,7 @@ class SearchChain(ChainBase): torrents.extend(results) # 有结果则停止 - if not settings.SEARCH_MULTIPLE_NAME and torrents: + if not self.runtime_config.search_multiple_name and torrents: logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索") break @@ -1816,7 +1826,7 @@ class SearchChain(ChainBase): ) search_count += 1 # 未开启多名称搜索时,有结果则停止 - if not settings.SEARCH_MULTIPLE_NAME and torrents: + if not self.runtime_config.search_multiple_name and torrents: logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索") break @@ -1918,7 +1928,7 @@ class SearchChain(ChainBase): } search_count += 1 - if not settings.SEARCH_MULTIPLE_NAME and torrents: + if not self.runtime_config.search_multiple_name and torrents: logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索") break @@ -2156,7 +2166,7 @@ class SearchChain(ChainBase): ) or [] ) search_count += 1 - if not settings.SEARCH_MULTIPLE_NAME and subtitles: + if not self.runtime_config.search_multiple_name and subtitles: logger.info(f"共搜索到 {len(subtitles)} 个字幕,停止搜索") break @@ -2246,7 +2256,7 @@ class SearchChain(ChainBase): } search_count += 1 - if not settings.SEARCH_MULTIPLE_NAME and subtitles: + if not self.runtime_config.search_multiple_name and subtitles: logger.info(f"共搜索到 {len(subtitles)} 个字幕,停止搜索") break @@ -2331,7 +2341,10 @@ class SearchChain(ChainBase): # 结果集 results = [] # 同一站点按页顺序抓取,避免空页后仍继续请求该站点的后续页。 - max_workers = min(len(indexer_sites), settings.CONF.threadpool or len(indexer_sites)) + max_workers = min( + len(indexer_sites), + self.runtime_config.search_threadpool_size or len(indexer_sites), + ) with ThreadPoolExecutor(max_workers=max_workers) as executor: pending_tasks = {} @@ -2444,7 +2457,9 @@ class SearchChain(ChainBase): text=f"开始搜索,共 {len(indexer_sites)} 个站点,{len(search_pages)} 页 ...") # 结果集 results = [] - semaphore = asyncio.Semaphore(settings.CONF.threadpool or total_num) + semaphore = asyncio.Semaphore( + self.runtime_config.search_threadpool_size or total_num + ) async def search_site_page(site: dict, search_page: int) -> List[TorrentInfo]: """ @@ -2579,7 +2594,9 @@ class SearchChain(ChainBase): "total": total_num } - semaphore = asyncio.Semaphore(settings.CONF.threadpool or total_num) + semaphore = asyncio.Semaphore( + self.runtime_config.search_threadpool_size or total_num + ) async def search_site(site: dict, search_page: int) -> List[TorrentInfo]: """ @@ -2701,7 +2718,9 @@ class SearchChain(ChainBase): await progress.update(value=0, text=f"开始搜索字幕,共 {len(indexer_sites)} 个站点,{len(search_pages)} 页 ...") results = [] - semaphore = asyncio.Semaphore(settings.CONF.threadpool or total_num) + semaphore = asyncio.Semaphore( + self.runtime_config.search_threadpool_size or total_num + ) async def search_site_page(site: dict, search_page: int) -> List[SubtitleInfo]: """ @@ -2816,7 +2835,9 @@ class SearchChain(ChainBase): "total": total_num } - semaphore = asyncio.Semaphore(settings.CONF.threadpool or total_num) + semaphore = asyncio.Semaphore( + self.runtime_config.search_threadpool_size or total_num + ) async def search_site(site: dict, search_page: int) -> List[SubtitleInfo]: """ diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 73fc4b1a9..e53d598f4 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -23,7 +23,7 @@ from app.chain.mediaserver import MediaServerChain from app.chain.search import SearchChain from app.chain.tmdb import TmdbChain from app.chain.torrents import TorrentsChain -from app.runtime.config import settings, global_vars +from app.runtime.config import global_vars from app.domain.context import ( Context, MediaInfo, @@ -39,7 +39,10 @@ from app.application.chain.data import ( SitePortProxy as SiteOper, SubscribePortProxy as SubscribeOper, ) -from app.application.configuration import get_configured_system_config +from app.application.configuration import ( + get_chain_runtime_config_snapshot, + get_configured_system_config, +) from app.application.messaging.subscribe import SubscribeInteractionHandler from app.application.mediaserver import MediaServerHelper from app.application.subscription.write import add_subscribe, async_add_subscribe @@ -874,10 +877,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): def __subscribe_added_link(mtype: MediaType) -> str: """返回订阅类型对应的前端详情入口。""" if mtype == MediaType.TV: - return settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') + return get_chain_runtime_config_snapshot().television_subscribe_url if mtype == MediaType.MUSIC: - return settings.MP_DOMAIN('#/subscribe/music?tab=mysub') - return settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') + return get_chain_runtime_config_snapshot().music_subscribe_url + return get_chain_runtime_config_snapshot().movie_subscribe_url @staticmethod def __subscribe_report_payload(context: _SubscribePostCommitContext) -> dict: @@ -2938,11 +2941,11 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): subscribeoper.delete(subscribe.id) # 发送通知 if mediainfo.type == MediaType.TV: - link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') + link = self.runtime_config.television_subscribe_url elif mediainfo.type == MediaType.MUSIC: - link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub') + link = self.runtime_config.music_subscribe_url else: - link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') + link = self.runtime_config.movie_subscribe_url # 完成订阅按规则发送消息 self.post_message( _SchemaMessage( @@ -3195,11 +3198,8 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if not default_subscribe_key: return None - # 默认订阅规则 - if hasattr(settings, default_subscribe_key): - value = getattr(settings, default_subscribe_key) - else: - value = _system_config().get(default_subscribe_key) + # 默认订阅规则属于持久化用户配置,不再从部署 Settings 猜测同名属性。 + value = _system_config().get(default_subscribe_key) if not value: return None @@ -3259,7 +3259,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): info = _SchemaSubscribeEpisodeInfo() info.title = episode.name info.description = episode.overview - info.backdrop = settings.TMDB_IMAGE_URL(episode.still_path, "w500") + info.backdrop = self.runtime_config.tmdb_image_url( + episode.still_path, + "w500", + ) episodes[episode.episode_number] = info elif subscribe.type == MediaType.TV.value: # 根据开始结束集计算集信息 diff --git a/app/chain/system.py b/app/chain/system.py index 3198ddf07..2992b49b7 100644 --- a/app/chain/system.py +++ b/app/chain/system.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Union, Optional from app.chain import ChainBase -from app.runtime.config import settings +from app.application.configuration import get_chain_runtime_config_snapshot from app.runtime.state import SystemHelper from app.runtime.log import logger from app.schemas.message import Message @@ -70,8 +70,9 @@ class SystemChain(ChainBase): try: # 使用绝对路径确保准确性 - plugins_dir = settings.ROOT_PATH / "app" / "plugins" - backup_dir = settings.CONFIG_PATH / "plugins_backup" + config = get_chain_runtime_config_snapshot() + plugins_dir = config.root_path / "app" / "plugins" + backup_dir = config.config_path / "plugins_backup" if not plugins_dir.exists(): logger.info("插件目录不存在,跳过备份") @@ -134,8 +135,9 @@ class SystemChain(ChainBase): return # 使用绝对路径确保准确性 - plugins_dir = settings.ROOT_PATH / "app" / "plugins" - backup_dir = settings.CONFIG_PATH / "plugins_backup" + config = get_chain_runtime_config_snapshot() + plugins_dir = config.root_path / "app" / "plugins" + backup_dir = config.config_path / "plugins_backup" if not backup_dir.exists(): logger.info("插件备份目录不存在,跳过恢复") @@ -367,8 +369,8 @@ class SystemChain(ChainBase): try: # 获取所有发布的版本列表 response = RequestUtils( - proxies=settings.PROXY, - headers=settings.GITHUB_HEADERS + proxies=get_chain_runtime_config_snapshot().proxy, + headers=get_chain_runtime_config_snapshot().github_headers, ).get_res("https://api.github.com/repos/jxxghp/MoviePilot/releases") if response: releases = [release['tag_name'] for release in response.json()] @@ -394,8 +396,8 @@ class SystemChain(ChainBase): try: # 获取所有发布的版本列表 response = RequestUtils( - proxies=settings.PROXY, - headers=settings.GITHUB_HEADERS + proxies=get_chain_runtime_config_snapshot().proxy, + headers=get_chain_runtime_config_snapshot().github_headers, ).get_res("https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases") if response: releases = [release['tag_name'] for release in response.json()] @@ -426,9 +428,10 @@ class SystemChain(ChainBase): 获取前端版本 """ if SystemUtils.is_frozen() and SystemUtils.is_windows(): - version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt" + config = get_chain_runtime_config_snapshot() + version_file = config.config_path.parent / "nginx" / "html" / "version.txt" else: - version_file = Path(settings.FRONTEND_PATH) / "version.txt" + version_file = get_chain_runtime_config_snapshot().frontend_path / "version.txt" if version_file.exists(): try: with open(version_file, 'r', encoding='utf-8', errors='replace') as f: diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 9ab605e45..0c9df6ab7 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -13,7 +13,7 @@ from app.chain import ChainBase from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.tmdb import TmdbChain -from app.runtime.config import settings, global_vars +from app.runtime.config import global_vars from app.domain.context import MediaInfo, MusicInfo from app.runtime.events import eventmanager from app.domain.meta.metabase import MetaBase @@ -112,11 +112,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo """初始化文件整理处理链。""" super().__init__() # 主要媒体文件后缀 - self._media_exts = settings.RMT_MEDIAEXT + self._media_exts = self.runtime_config.video_extensions # 字幕文件后缀 - self._subtitle_exts = settings.RMT_SUBEXT + self._subtitle_exts = self.runtime_config.subtitle_extensions # 音频文件后缀 - self._audio_exts = settings.RMT_AUDIOEXT + self._audio_exts = self.runtime_config.audio_extensions # 可处理的文件后缀(视频文件、字幕、音频文件) self._allowed_exts = self._media_exts + self._audio_exts + self._subtitle_exts # 待整理任务队列 @@ -154,7 +154,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo 启动文件整理线程 """ self._queue_active = True - for i in range(settings.TRANSFER_THREADS): + for i in range(self.runtime_config.transfer_threads): logger.info(f"启动文件整理线程 {i + 1} ...") thread = threading.Thread( target=self.__start_transfer, name=f"transfer-{i}", daemon=True @@ -341,8 +341,8 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo # AI智能体自动重试整理 if ( history - and settings.AI_AGENT_ENABLE - and settings.AI_AGENT_RETRY_TRANSFER + and self.runtime_config.ai_agent_enable + and self.runtime_config.ai_agent_retry_transfer ): try: # 使用 download_hash 或源文件父目录作为分组键, @@ -554,7 +554,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo username=task.username, manual_identity=manual_identity, ) - if not settings.TRANSFER_FAILURE_NOTIFICATION_AGGREGATION: + if not self.runtime_config.transfer_failure_notification_aggregation: self._send_transfer_failure_notifications([notification]) return try: @@ -616,7 +616,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo text = "\n".join(text_parts) buttons = [[{ "text": "批量处理", - "url": settings.MP_DOMAIN("#/history"), + "url": self.runtime_config.history_url, }]] title = f"{first.media_title} 入库失败({len(notifications)} 个文件)" self.post_message( @@ -626,7 +626,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo text=text, image=first.image, username=first.username, - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, buttons=buttons, ) ) @@ -855,7 +855,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo def __expire_stale_transfer_tasks(self): """清理外部接管后失去状态心跳的运行中整理任务。""" - timeout_minutes = max(int(settings.TRANSFER_TASK_TIMEOUT), 0) + timeout_minutes = max(int(self.runtime_config.transfer_task_timeout), 0) expire_tasks = getattr(self.jobview, "expire_stale_running_tasks", None) expired_tasks = ( expire_tasks(timeout_seconds=timeout_minutes * 60) @@ -1110,8 +1110,8 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo # AI智能体自动重试整理 if ( his - and settings.AI_AGENT_ENABLE - and settings.AI_AGENT_RETRY_TRANSFER + and self.runtime_config.ai_agent_enable + and self.runtime_config.ai_agent_retry_transfer ): try: # 使用 download_hash 或源文件父目录作为分组键 @@ -1136,7 +1136,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo # 只有 TMDB 主源沿用历史 TMDB 标题,避免辅助 ID 改写其它识别源标题。 if ( - not settings.SCRAP_FOLLOW_TMDB + not self.runtime_config.scrape_follow_tmdb and mediainfo.media_source == MediaSource.TMDB ): transfer_history = transferhis.get_by_media_identity( @@ -2533,7 +2533,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo source=source, text=errmsg, userid=userid, - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, save_history=False, ) ) @@ -2570,7 +2570,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo source=source, text=errmsg, userid=userid, - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, save_history=False, ) ) @@ -2734,7 +2734,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo ctype=ContentType.OrganizeSuccess, image=mediainfo.get_message_image(), username=username, - link=settings.MP_DOMAIN("#/history"), + link=self.runtime_config.history_url, ), meta=meta, mediainfo=mediainfo, diff --git a/app/db/base.py b/app/db/base.py index 2952f64ea..358f324c8 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -21,7 +21,7 @@ T = TypeVar("T") def execute_dml(db: Session, statement: Executable, - execution_options: Optional[dict] = None) -> int: + execution_options: Optional[dict[str, Any]] = None) -> int: """ 执行 DML 语句并返回影响行数。 @@ -37,7 +37,7 @@ def execute_dml(db: Session, statement: Executable, result = db.execute(statement) else: result = db.execute(statement, execution_options=execution_options) - return cast(CursorResult[Any], result).rowcount + return int(cast(CursorResult[Any], result).rowcount) def get_id_column() -> Mapped[int]: @@ -52,7 +52,7 @@ def get_id_column() -> Mapped[int]: return mapped_column(Integer, Sequence('id'), primary_key=True) -class Base(DeclarativeBase): +class Base(DeclarativeBase): # type: ignore[misc] # SQLAlchemy 无 py.typed 基类 """ 声明式基类。 @@ -70,11 +70,11 @@ class Base(DeclarativeBase): id: Mapped[int] @db_update - def create(self, db: Session): + def create(self, db: Session) -> None: db.add(self) @async_db_update - async def async_create(self, db: AsyncSession): + async def async_create(self, db: AsyncSession) -> Self: db.add(self) await db.flush() return self @@ -82,23 +82,30 @@ class Base(DeclarativeBase): @classmethod @db_query def get(cls, db: Session, rid: int) -> Optional[Self]: - return db.execute(select(cls).where(and_(cls.id == rid))).scalars().first() + return cast( + Optional[Self], + db.execute(select(cls).where(and_(cls.id == rid))).scalars().first(), + ) @classmethod @async_db_query async def async_get(cls, db: AsyncSession, rid: int) -> Optional[Self]: result = await db.execute(select(cls).where(and_(cls.id == rid))) - return result.scalars().first() + return cast(Optional[Self], result.scalars().first()) @db_update - def update(self, db: Session, payload: dict): + def update(self, db: Session, payload: dict[str, Any]) -> None: for key, value in payload.items(): setattr(self, key, value) if inspect(self).detached: db.add(self) @async_db_update - async def async_update(self, db: AsyncSession, payload: dict): + async def async_update( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> None: for key, value in payload.items(): setattr(self, key, value) if inspect(self).detached: @@ -106,12 +113,12 @@ class Base(DeclarativeBase): @classmethod @db_update - def delete(cls, db: Session, rid): + def delete(cls, db: Session, rid: Any) -> None: db.execute(delete(cls).where(and_(cls.id == rid))) @classmethod @async_db_update - async def async_delete(cls, db: AsyncSession, rid): + async def async_delete(cls, db: AsyncSession, rid: Any) -> None: result = await db.execute(select(cls).where(and_(cls.id == rid))) user = result.scalars().first() if user: @@ -119,12 +126,12 @@ class Base(DeclarativeBase): @classmethod @db_update - def truncate(cls, db: Session): + def truncate(cls, db: Session) -> None: db.execute(delete(cls)) @classmethod @async_db_update - async def async_truncate(cls, db: AsyncSession): + async def async_truncate(cls, db: AsyncSession) -> None: await db.execute(delete(cls)) @classmethod @@ -138,12 +145,15 @@ class Base(DeclarativeBase): result = await db.execute(select(cls)) return list(result.scalars().all()) - def to_dict(self): + def to_dict(self) -> dict[str, Any]: return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa - @declared_attr.directive + @declared_attr.directive # type: ignore[misc] # SQLAlchemy decorator 缺少类型信息 def __tablename__(cls) -> str: # noqa: N805 declared_attr 的第一个参数即类本身 - return cls.__name__.lower() + return str(cls.__name__).lower() + + +TModel = TypeVar("TModel", bound=Base) class DbOper: @@ -157,10 +167,10 @@ class DbOper: def _execute_sync_write(self, operation: Callable[[Session], T]) -> T: """在当前同步会话暂存,或委托组合根创建兼容事务。""" - if self._db is None: + if self._db is None or isinstance(self._db, AsyncSession): + # 旧调用可能在同一 Oper 上混用同步/异步方法;跨会话类型时使用匹配的 + # 兼容事务,不能把 AsyncSession 交给同步 SQLAlchemy API。 return run_sync_transaction(operation) - if not isinstance(self._db, Session): - raise TypeError("同步写操作不能使用 AsyncSession") return operation(self._db) async def _execute_async_write( @@ -168,8 +178,87 @@ class DbOper: operation: Callable[[AsyncSession], Awaitable[T]], ) -> T: """在当前异步会话暂存,或委托组合根创建兼容事务。""" - if self._db is None: + if self._db is None or isinstance(self._db, Session): + # 与查询装饰器的历史行为一致:同步会话不会被错误传入异步模型写入, + # 而是由组合根另开匹配的异步事务。 return await run_async_transaction(operation) - if not isinstance(self._db, AsyncSession): - raise TypeError("异步写操作不能使用同步 Session") return await operation(self._db) + + def _stage_create(self, model: TModel) -> TModel: + """在显式同步事务中暂存新模型,不触发 Base 的兼容提交装饰器。""" + def stage(session: Session) -> TModel: + """把模型加入当前同步会话。""" + session.add(model) + return model + + return self._execute_sync_write(stage) + + async def _stage_async_create(self, model: TModel) -> TModel: + """在显式异步事务中暂存新模型并刷新主键。""" + async def stage(session: AsyncSession) -> TModel: + """把模型加入当前异步会话并刷新。""" + session.add(model) + await session.flush() + return model + + return await self._execute_async_write(stage) + + def _stage_update(self, model: TModel, payload: dict[str, Any]) -> TModel: + """在显式同步事务中更新模型字段,必要时重新附加游离对象。""" + def stage(session: Session) -> TModel: + """应用字段并把游离模型重新加入会话。""" + for key, value in payload.items(): + setattr(model, key, value) + model_state = inspect(model, raiseerr=False) + if model_state is not None and model_state.detached: + session.add(model) + return model + + return self._execute_sync_write(stage) + + async def _stage_async_update( + self, + model: TModel, + payload: dict[str, Any], + ) -> TModel: + """在显式异步事务中更新模型字段,必要时重新附加游离对象。""" + async def stage(session: AsyncSession) -> TModel: + """应用字段并把游离模型重新加入会话。""" + for key, value in payload.items(): + setattr(model, key, value) + model_state = inspect(model, raiseerr=False) + if model_state is not None and model_state.detached: + session.add(model) + return model + + return await self._execute_async_write(stage) + + def _stage_delete(self, model_type: type[Base], rid: Any) -> None: + """在显式同步事务中按主键删除模型。""" + self._execute_sync_write( + lambda session: session.execute( + delete(model_type).where(model_type.id == rid) + ) + ) + + async def _stage_async_delete(self, model_type: type[Base], rid: Any) -> None: + """在显式异步事务中按主键删除模型。""" + async def stage(session: AsyncSession) -> None: + """执行当前异步事务内的按主键删除。""" + await session.execute(delete(model_type).where(model_type.id == rid)) + + await self._execute_async_write(stage) + + def _stage_truncate(self, model_type: type[Base]) -> None: + """在显式同步事务中删除模型表的全部记录。""" + self._execute_sync_write( + lambda session: session.execute(delete(model_type)) + ) + + async def _stage_async_truncate(self, model_type: type[Base]) -> None: + """在显式异步事务中删除模型表的全部记录。""" + async def stage(session: AsyncSession) -> None: + """执行当前异步事务内的全表删除。""" + await session.execute(delete(model_type)) + + await self._execute_async_write(stage) diff --git a/app/db/decorators.py b/app/db/decorators.py index f4acb5ceb..83b8a5941 100644 --- a/app/db/decorators.py +++ b/app/db/decorators.py @@ -16,7 +16,7 @@ SQLAlchemy 归还连接时已在池层吞掉异常并 invalidate 坏连接,再把释放故障升级成调用方 的异常,只会让一次已经落库的写入看起来像失败,诱发重复提交。 """ -from typing import Any, Awaitable, Callable, Optional, Tuple, TypeVar +from typing import Any, Awaitable, Callable, Optional, TypeVar from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -31,7 +31,10 @@ _R = TypeVar("_R") # 接管、返回值原样透传」。否则调用方传 None 或传异步会话都会被判成类型不符,而这恰恰是 # 装饰器存在的理由(各 Oper 的 self._db 常态就是 None)。 -def _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]: +def _get_args_db( + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> Optional[Session]: """ 从参数中获取数据库Session对象 """ @@ -49,7 +52,10 @@ def _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]: return db -def _get_args_async_db(args: tuple, kwargs: dict) -> Optional[AsyncSession]: +def _get_args_async_db( + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> Optional[AsyncSession]: """ 从参数中获取异步数据库AsyncSession对象 """ @@ -67,7 +73,11 @@ def _get_args_async_db(args: tuple, kwargs: dict) -> Optional[AsyncSession]: return db -def _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]: +def _update_args_db( + args: tuple[Any, ...], + kwargs: dict[str, Any], + db: Session, +) -> tuple[tuple[Any, ...], dict[str, Any]]: """ 更新参数中的数据库Session对象,关键字传参时更新db的值,否则更新第1或第2个参数 """ @@ -81,7 +91,11 @@ def _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict return args, kwargs -def _update_args_async_db(args: tuple, kwargs: dict, db: AsyncSession) -> Tuple[tuple, dict]: +def _update_args_async_db( + args: tuple[Any, ...], + kwargs: dict[str, Any], + db: AsyncSession, +) -> tuple[tuple[Any, ...], dict[str, Any]]: """ 更新参数中的异步数据库AsyncSession对象,关键字传参时更新db的值,否则更新第1或第2个参数 """ diff --git a/app/db/oper/agentchat.py b/app/db/oper/agentchat.py index 5b02427a7..acc3bccce 100644 --- a/app/db/oper/agentchat.py +++ b/app/db/oper/agentchat.py @@ -115,7 +115,7 @@ class AgentChatOper(DbOper): } payload = {key: value for key, value in payload.items() if value is not None} if chat: - chat.update(self._db, payload) + self._stage_update(chat, payload) return self.get(session_id=session_id, user_id=user_id) or self.get(session_id=session_id) chat = AgentChat( @@ -134,7 +134,7 @@ class AgentChatOper(DbOper): created_at=now, updated_at=now, ) - chat.create(self._db) + self._stage_create(chat) return self.get(session_id=session_id, user_id=user_id) or self.get(session_id=session_id) def save_agent_messages( @@ -153,8 +153,8 @@ class AgentChatOper(DbOper): chat = self.ensure_session(session_id=session_id, user_id=user_id) if not chat: return - chat.update( - self._db, + self._stage_update( + chat, { "agent_messages": messages or [], "updated_at": self._now(), @@ -192,8 +192,8 @@ class AgentChatOper(DbOper): return if self.has_custom_title(chat.title): return - chat.update( - self._db, + self._stage_update( + chat, { "title": normalized_title, "updated_at": self._now(), @@ -232,8 +232,8 @@ class AgentChatOper(DbOper): if self.has_custom_title(chat.title) else self._normalize_title(title, normalized_messages) ) - chat.update( - self._db, + self._stage_update( + chat, { "title": normalized_title, "preview": self._normalize_preview(normalized_messages), @@ -311,7 +311,7 @@ class AgentChatOper(DbOper): chat = await self.async_get(session_id=session_id, user_id=user_id) if not chat: return False - await AgentChat.async_delete(self._db, chat.id) + await self._stage_async_delete(AgentChat, chat.id) return True async def async_stage_delete( diff --git a/app/db/oper/downloadhistory.py b/app/db/oper/downloadhistory.py index 3213438c7..d9be14c85 100644 --- a/app/db/oper/downloadhistory.py +++ b/app/db/oper/downloadhistory.py @@ -59,7 +59,7 @@ class DownloadHistoryOper(DbOper): """ 新增下载历史 """ - DownloadHistory(**kwargs).create(self._db) + self._stage_create(DownloadHistory(**kwargs)) def stage_add(self, payload: dict) -> DownloadHistory: """在调用方同步 Session 中暂存下载历史并返回已分配 ID 的记录。""" @@ -76,7 +76,7 @@ class DownloadHistoryOper(DbOper): """ for file_item in file_items: downloadfile = DownloadFiles(**file_item) - downloadfile.create(self._db) + self._stage_create(downloadfile) def stage_add_files(self, file_items: List[dict]) -> None: """在调用方事务内批量暂存下载文件,不逐条提交。""" @@ -89,7 +89,7 @@ class DownloadHistoryOper(DbOper): """ 清空下载历史文件记录 """ - DownloadFiles.truncate(self._db) + self._stage_truncate(DownloadFiles) def get_files_by_hash(self, download_hash: str, state: Optional[int] = None) -> List[DownloadFiles]: """ @@ -171,13 +171,13 @@ class DownloadHistoryOper(DbOper): """ 异步删除下载记录。 """ - await DownloadHistory.async_delete(self._db, historyid) + await self._stage_async_delete(DownloadHistory, historyid) def truncate(self): """ 清空下载记录 """ - DownloadHistory.truncate(self._db) + self._stage_truncate(DownloadHistory) def get_last_by(self, mtype=None, title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, @@ -230,7 +230,7 @@ class DownloadHistoryOper(DbOper): """ 删除下载记录 """ - DownloadHistory.delete(self._db, historyid) + self._stage_delete(DownloadHistory, historyid) def stage_delete_history(self, historyid: int) -> None: """暂存下载记录删除,不由模型装饰器提交事务。""" @@ -244,4 +244,4 @@ class DownloadHistoryOper(DbOper): """ 删除下载文件记录 """ - DownloadFiles.delete(self._db, downloadfileid) + self._stage_delete(DownloadFiles, downloadfileid) diff --git a/app/db/oper/mediaserver.py b/app/db/oper/mediaserver.py index 64913ee29..a33bfe6d4 100644 --- a/app/db/oper/mediaserver.py +++ b/app/db/oper/mediaserver.py @@ -35,7 +35,7 @@ class MediaServerOper(DbOper): return False item = MediaServerItem(**kwargs) if not item.get_by_server_itemid(self._db, server, item_id): - item.create(self._db) + self._stage_create(item) return True return False @@ -51,10 +51,10 @@ class MediaServerOper(DbOper): item = MediaServerItem.get_by_server_itemid(self._db, server, item_id) if item: - item.update(self._db, kwargs) + self._stage_update(item, kwargs) return False - MediaServerItem(**kwargs).create(self._db) + self._stage_create(MediaServerItem(**kwargs)) return True def empty(self, server: Optional[str] = None): diff --git a/app/db/oper/message.py b/app/db/oper/message.py index b298f11f4..ebfd448d0 100644 --- a/app/db/oper/message.py +++ b/app/db/oper/message.py @@ -99,7 +99,7 @@ class MessageOper(DbOper): if k not in Message.__table__.columns.keys(): # noqa kwargs.pop(k) - return await Message(**kwargs).async_create(self._db) + return await self._stage_async_create(Message(**kwargs)) def list_by_page(self, page: int = 1, count: int = 30) -> list[Message]: """ diff --git a/app/db/oper/plugindata.py b/app/db/oper/plugindata.py index 9dd76425b..a86525b53 100644 --- a/app/db/oper/plugindata.py +++ b/app/db/oper/plugindata.py @@ -21,11 +21,11 @@ class PluginDataOper(DbOper): """ plugin = PluginData.get_plugin_data_by_key(self._db, plugin_id, key) if plugin: - plugin.update(self._db, { + self._stage_update(plugin, { "value": value }) else: - PluginData(plugin_id=plugin_id, key=key, value=value).create(self._db) + self._stage_create(PluginData(plugin_id=plugin_id, key=key, value=value)) async def async_save(self, plugin_id: str, key: str, value: Any) -> None: """ @@ -39,11 +39,11 @@ class PluginDataOper(DbOper): self._db, plugin_id, key ) if plugin: - await plugin.async_update(self._db, {"value": value}) + await self._stage_async_update(plugin, {"value": value}) else: - await PluginData( - plugin_id=plugin_id, key=key, value=value - ).async_create(self._db) + await self._stage_async_create( + PluginData(plugin_id=plugin_id, key=key, value=value) + ) def get_data(self, plugin_id: str, key: Optional[str] = None) -> Any: """ @@ -102,7 +102,7 @@ class PluginDataOper(DbOper): """ 清空插件数据 """ - PluginData.truncate(self._db) + self._stage_truncate(PluginData) def get_data_all(self, plugin_id: str) -> Any: """ diff --git a/app/db/oper/site.py b/app/db/oper/site.py index 6c8a7f152..559df23d4 100644 --- a/app/db/oper/site.py +++ b/app/db/oper/site.py @@ -21,7 +21,7 @@ class SiteOper(DbOper): """ site = Site(**kwargs) if not site.get_by_domain(self._db, kwargs.get("domain")): - site.create(self._db) + self._stage_create(site) return True, "新增站点成功" return False, "站点已存在" @@ -113,7 +113,7 @@ class SiteOper(DbOper): """ 删除站点 """ - Site.delete(self._db, sid) + self._stage_delete(Site, sid) def reset(self) -> None: """清空站点表;兼容入口的事务由组合根统一持有。""" @@ -130,7 +130,7 @@ class SiteOper(DbOper): site = Site.get(self._db, sid) if not site: return None - site.update(self._db, payload) + self._stage_update(site, payload) return site async def async_update(self, sid: int, payload: dict) -> Optional[Site]: @@ -139,7 +139,7 @@ class SiteOper(DbOper): """ site = await self.async_get(sid) if site: - await site.async_update(self._db, payload) + await self._stage_async_update(site, payload) return site def get_by_domain(self, domain: str) -> Optional[Site]: @@ -179,7 +179,7 @@ class SiteOper(DbOper): site = Site.get_by_domain(self._db, domain) if not site: return False, "站点不存在" - site.update(self._db, { + self._stage_update(site, { "cookie": cookies }) return True, "更新站点Cookie成功" @@ -191,7 +191,7 @@ class SiteOper(DbOper): site = Site.get_by_domain(self._db, domain) if not site: return False, "站点不存在" - site.update(self._db, { + self._stage_update(site, { "rss": rss }) return True, "更新站点RSS地址成功" @@ -215,10 +215,10 @@ class SiteOper(DbOper): if siteuserdatas: # 存在则更新 if not payload.get("err_msg"): - siteuserdatas[0].update(self._db, payload) + self._stage_update(siteuserdatas[0], payload) else: # 不存在则插入 - SiteUserData(**payload).create(self._db) + self._stage_create(SiteUserData(**payload)) return True, "更新站点用户数据成功" def get_userdata(self) -> List[SiteUserData]: @@ -287,9 +287,11 @@ class SiteOper(DbOper): icon_base64 = f"data:image/ico;base64,{icon_base64}" if icon_base64 else "" siteicon = self.get_icon_by_domain(domain) if not siteicon: - SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db) + self._stage_create( + SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64) + ) elif icon_base64: - siteicon.update(self._db, { + self._stage_update(siteicon, { "url": icon_url, "base64": icon_base64 }) @@ -313,7 +315,7 @@ class SiteOper(DbOper): note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10]) avg_seconds = sum([v for v in note.values()]) // avg_times - sta.update(self._db, { + self._stage_update(sta, { "success": sta.success + 1, "seconds": avg_seconds or sta.seconds, "lst_state": 0, @@ -326,7 +328,7 @@ class SiteOper(DbOper): note = { lst_date: seconds or 1 } - SiteStatistic( + self._stage_create(SiteStatistic( domain=domain, success=1, fail=0, @@ -334,7 +336,7 @@ class SiteOper(DbOper): lst_state=0, lst_mod_date=lst_date, note=note - ).create(self._db) + )) def fail(self, domain: str): """ @@ -343,19 +345,19 @@ class SiteOper(DbOper): lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") sta = SiteStatistic.get_by_domain(self._db, domain) if sta: - sta.update(self._db, { + self._stage_update(sta, { "fail": sta.fail + 1, "lst_state": 1, "lst_mod_date": lst_date }) else: - SiteStatistic( + self._stage_create(SiteStatistic( domain=domain, success=0, fail=1, lst_state=1, lst_mod_date=lst_date - ).create(self._db) + )) async def async_success(self, domain: str, seconds: Optional[int] = None): """ @@ -375,7 +377,7 @@ class SiteOper(DbOper): note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10]) avg_seconds = sum([v for v in note.values()]) // avg_times - await sta.async_update(self._db, { + await self._stage_async_update(sta, { "success": sta.success + 1, "seconds": avg_seconds or sta.seconds, "lst_state": 0, @@ -388,7 +390,7 @@ class SiteOper(DbOper): note = { lst_date: seconds or 1 } - await SiteStatistic( + await self._stage_async_create(SiteStatistic( domain=domain, success=1, fail=0, @@ -396,7 +398,7 @@ class SiteOper(DbOper): lst_state=0, lst_mod_date=lst_date, note=note - ).async_create(self._db) + )) async def async_fail(self, domain: str): """ @@ -405,16 +407,16 @@ class SiteOper(DbOper): lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") sta = await SiteStatistic.async_get_by_domain(self._db, domain) if sta: - await sta.async_update(self._db, { + await self._stage_async_update(sta, { "fail": sta.fail + 1, "lst_state": 1, "lst_mod_date": lst_date }) else: - await SiteStatistic( + await self._stage_async_create(SiteStatistic( domain=domain, success=0, fail=1, lst_state=1, lst_mod_date=lst_date - ).async_create(self._db) + )) diff --git a/app/db/oper/subscribe.py b/app/db/oper/subscribe.py index 02860a784..a15ae098d 100644 --- a/app/db/oper/subscribe.py +++ b/app/db/oper/subscribe.py @@ -227,7 +227,7 @@ class SubscribeOper(DbOper): if after_commit: after_commit(subscribe.id) return subscribe.id, "订阅已存在" - Subscribe(**_persistable(payload)).create(self._db) + self._stage_create(Subscribe(**_persistable(payload))) subscribe = self._exists(identity, username) if not subscribe: return 0, "新增订阅失败" @@ -251,7 +251,7 @@ class SubscribeOper(DbOper): if after_commit: await after_commit(subscribe.id) return subscribe.id, "订阅已存在" - await Subscribe(**_persistable(payload)).async_create(self._db) + await self._stage_async_create(Subscribe(**_persistable(payload))) subscribe = await self._async_exists(identity, username) if not subscribe: return 0, "新增订阅失败" @@ -472,13 +472,13 @@ class SubscribeOper(DbOper): """ 删除订阅 """ - Subscribe.delete(self._db, rid=sid) + self._stage_delete(Subscribe, sid) async def async_delete(self, sid: int): """ 异步删除订阅。 """ - await Subscribe.async_delete(self._db, rid=sid) + await self._stage_async_delete(Subscribe, sid) async def stage_delete(self, sid: int) -> None: """登记订阅删除但不提交,由 Application UnitOfWork 控制事务边界。""" @@ -493,7 +493,7 @@ class SubscribeOper(DbOper): subscribe = await self.async_get(sid) if subscribe: payload = _normalize_integer_flags(payload) - await subscribe.async_update(self._db, payload) + await self._stage_async_update(subscribe, payload) return subscribe async def async_stage_update( @@ -527,7 +527,7 @@ class SubscribeOper(DbOper): subscribe = self.get(sid) if subscribe: payload = _normalize_integer_flags(payload) - subscribe.update(self._db, payload) + self._stage_update(subscribe, payload) return subscribe def list_by_username(self, username: str, state: Optional[str] = None, @@ -556,7 +556,7 @@ class SubscribeOper(DbOper): if "id" in kwargs: kwargs.pop("id") subscribe = SubscribeHistory(**kwargs) - subscribe.create(self._db) + self._stage_create(subscribe) def exist_history( self, media_source: MediaSource, media_id: str, diff --git a/app/db/oper/subscribehistory.py b/app/db/oper/subscribehistory.py index 63d6ea8cb..a30aeb995 100644 --- a/app/db/oper/subscribehistory.py +++ b/app/db/oper/subscribehistory.py @@ -47,4 +47,4 @@ class SubscribeHistoryOper(DbOper): async def async_delete(self, history_id: int) -> None: """异步删除订阅历史。""" - await SubscribeHistory.async_delete(self._db, history_id) + await self._stage_async_delete(SubscribeHistory, history_id) diff --git a/app/db/oper/systemconfig.py b/app/db/oper/systemconfig.py index c6896db9a..df0212bac 100644 --- a/app/db/oper/systemconfig.py +++ b/app/db/oper/systemconfig.py @@ -43,12 +43,12 @@ class SystemConfigOper(DbOper, metaclass=Singleton): if old_value != value: # 假值(False/0/None/空容器)同样落库而不是删除记录: # 读取端以「无记录」表示未配置并回落默认值,删除会使布尔开关的关闭态无法持久化 - conf.update(self._db, {"value": value}) + self._stage_update(conf, {"value": value}) return True return None else: conf = SystemConfig(key=key, value=value) - conf.create(self._db) + self._stage_create(conf) return True async def async_set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]: @@ -78,10 +78,10 @@ class SystemConfigOper(DbOper, metaclass=Singleton): if conf: # 假值(False/0/None/空容器)同样落库而不是删除记录: # 读取端以「无记录」表示未配置并回落默认值,删除会使布尔开关的关闭态无法持久化 - await conf.async_update(self._db, {"value": value}) + await self._stage_async_update(conf, {"value": value}) else: conf = SystemConfig(key=key, value=value) - await conf.async_create(self._db) + await self._stage_async_create(conf) # 数据库更新成功后,再更新缓存 with self._rlock: self.__SYSTEMCONF[key] = copy.deepcopy(value) @@ -132,5 +132,5 @@ class SystemConfigOper(DbOper, metaclass=Singleton): # 写入数据库 conf = SystemConfig.get_by_key(self._db, key) if conf: - conf.delete(self._db, conf.id) + self._stage_delete(SystemConfig, conf.id) return True diff --git a/app/db/oper/transferhistory.py b/app/db/oper/transferhistory.py index aeeffadc9..e559e9bc0 100644 --- a/app/db/oper/transferhistory.py +++ b/app/db/oper/transferhistory.py @@ -177,7 +177,7 @@ class TransferHistoryOper(DbOper): kwargs.update({ "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) }) - TransferHistory(**kwargs).create(self._db) + self._stage_create(TransferHistory(**kwargs)) def statistic(self, days: int = 7) -> List[Any]: """ @@ -226,7 +226,7 @@ class TransferHistoryOper(DbOper): """ 删除转移记录 """ - TransferHistory.delete(self._db, historyid) + self._stage_delete(TransferHistory, historyid) def stage_delete(self, historyid: int) -> None: """暂存整理记录删除,不由模型装饰器提交事务。""" @@ -244,13 +244,13 @@ class TransferHistoryOper(DbOper): """ 异步删除转移记录。 """ - await TransferHistory.async_delete(self._db, historyid) + await self._stage_async_delete(TransferHistory, historyid) def truncate(self): """ 清空转移记录 """ - TransferHistory.truncate(self._db) + self._stage_truncate(TransferHistory) def add_force(self, **kwargs) -> Optional[TransferHistory]: """ diff --git a/app/db/oper/user.py b/app/db/oper/user.py index 3e226f295..42ba38f19 100644 --- a/app/db/oper/user.py +++ b/app/db/oper/user.py @@ -33,7 +33,7 @@ class UserOper(DbOper): 新增用户 """ user = User(**kwargs) - user.create(self._db) + self._stage_create(user) def get_by_name(self, name: str) -> Optional[User]: """ diff --git a/app/db/oper/userconfig.py b/app/db/oper/userconfig.py index 6d5b46a41..5edfc3b97 100644 --- a/app/db/oper/userconfig.py +++ b/app/db/oper/userconfig.py @@ -31,12 +31,12 @@ class UserConfigOper(DbOper, metaclass=Singleton): conf = UserConfig.get_by_key(db=self._db, username=username, key=key) if conf: if value: - conf.update(self._db, {"value": value}) + self._stage_update(conf, {"value": value}) else: - conf.delete(self._db, conf.id) + self._stage_delete(UserConfig, conf.id) else: conf = UserConfig(username=username, key=key, value=value) - conf.create(self._db) + self._stage_create(conf) def get(self, username: str, key: Optional[Union[str, UserConfigKey]] = None) -> Any: """ diff --git a/app/db/oper/workflow.py b/app/db/oper/workflow.py index 53ef649c4..e3724efaf 100644 --- a/app/db/oper/workflow.py +++ b/app/db/oper/workflow.py @@ -67,7 +67,7 @@ class WorkflowOper(DbOper): """ wf = Workflow(**kwargs) if not wf.get_by_name(self._db, kwargs.get("name")): - wf.create(self._db) + self._stage_create(wf) return True, "新增工作流成功" return False, "工作流已存在" diff --git a/app/runtime/cache.py b/app/runtime/cache.py index b4fee9163..17f5f4964 100644 --- a/app/runtime/cache.py +++ b/app/runtime/cache.py @@ -756,6 +756,7 @@ def AsyncCache(cache_type: Literal['ttl', 'lru'] = 'ttl', def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Optional[int] = None, + ttl_provider: Optional[Callable[[], Optional[int]]] = None, skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False, shared_key: Optional[str] = None, skip_if: Optional[Callable[[Any], bool]] = None, empty_ttl: Optional[int] = None, empty_if: Optional[Callable[[Any], bool]] = None): @@ -765,6 +766,8 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt :param region: 缓存区域的标识符,默认根据模块名、函数名等自动生成标识 :param maxsize: 缓存区内的最大条目数 :param ttl: 缓存的存活时间,单位秒;未传入时使用 LRU 缓存 + :param ttl_provider: 每次写入时解析 TTL 的配置快照工厂;用于可热更新配置, + 与固定 ttl 互斥 :param skip_none: 跳过 None 缓存,默认为 True :param skip_empty: 跳过空值缓存(如 None, [], {}, "", set()),默认为 False :param shared_key: 同步/异步函数共享缓存的键,默认使用函数名(异步函数名会标准化为同步格式,如移除 `async_` 前缀) @@ -806,6 +809,9 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt return False return True + if ttl is not None and ttl_provider is not None: + raise ValueError("cached 的 ttl 与 ttl_provider 不能同时设置") + def get_cache_ttl(value: Any) -> Optional[int]: """ 返回写入该返回值时应使用的 TTL,空结果改用独立的短 TTL(empty_ttl) @@ -813,13 +819,14 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt :param value: 待写入缓存的返回值 :return: 实际使用的 TTL,单位秒 """ + configured_ttl = ttl_provider() if ttl_provider is not None else ttl if empty_ttl is None: - return ttl + return configured_ttl if value is None: return empty_ttl if empty_if is not None: - return empty_ttl if empty_if(value) else ttl - return empty_ttl if not value else ttl + return empty_ttl if empty_if(value) else configured_ttl + return empty_ttl if not value else configured_ttl def is_valid_cache_value(_cache_key: str, _cached_value: Any, _cache_region: str) -> bool: """ @@ -897,7 +904,11 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt if is_async: # 异步函数使用异步缓存后端 - cache_backend = AsyncCache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl) + cache_backend = AsyncCache( + cache_type="ttl" if ttl is not None or ttl_provider is not None else "lru", + maxsize=maxsize, + ttl=ttl if ttl is not None else 1, + ) # 异步函数的缓存装饰器 @wraps(func) async def async_wrapper(*args, **kwargs): @@ -950,7 +961,11 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt return async_wrapper else: # 同步函数使用同步缓存后端 - cache_backend = Cache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl) + cache_backend = Cache( + cache_type="ttl" if ttl is not None or ttl_provider is not None else "lru", + maxsize=maxsize, + ttl=ttl if ttl is not None else 1, + ) # 同步函数的缓存装饰器 @wraps(func) def wrapper(*args, **kwargs): diff --git a/app/runtime/extensions/module/quality.py b/app/runtime/extensions/module/quality.py index c52012857..324685ecc 100644 --- a/app/runtime/extensions/module/quality.py +++ b/app/runtime/extensions/module/quality.py @@ -39,7 +39,76 @@ QUALITY_RULES = frozenset( } ) +COMMON_ASSESSED_RULES = frozenset( + { + "zero-real-network-tests", + "no-blocking-io-in-event-loop", + "module-contract-v2", + "owner-declared", + } +) + +# 这些模块均已纳入全局真实网络守卫、async 阻塞扫描和 V2 方法契约门禁。 +# 模块专属的鉴权、限流、并发和生命周期规则仍按 profile 中的精确豁免逐项补强, +# 但不再使用无法区分“未审查”和“已审查有缺口”的 legacy 状态。 +BASELINE_ASSESSED_MODULES = frozenset( + { + "acoustid", + "anilist", + "discord", + "douban", + "emby", + "fanart", + "feishu", + "filemanager", + "filter", + "imdb", + "indexer", + "jellyfin", + "listenbrainz", + "lrclib", + "musicbrainz", + "navidrome", + "plex", + "postgresql", + "qbittorrent", + "qqbot", + "redis", + "rtorrent", + "slack", + "subtitle", + "synologychat", + "telegram", + "theaudiodb", + "themoviedb", + "thetvdb", + "transmission", + "trimemedia", + "ugreen", + "vocechat", + "webpush", + "wechat", + "wechatclawbot", + "zspace", + } +) + MODULE_QUALITY_PROFILES = { + module: ModuleQualityProfile( + module=module, + level=ModuleQualityLevel.ASSESSED, + owner="MoviePilot core", + verified_rules=COMMON_ASSESSED_RULES, + exemption_reason=( + "已完成宿主通用网络、异步阻塞、V2 方法契约与维护责任门禁;" + "模块专属鉴权、限流、并发、敏感日志及 reload/stop 语义只在有对应能力时适用," + "继续由各模块专项测试证明" + ), + ) + for module in BASELINE_ASSESSED_MODULES +} + +MODULE_QUALITY_PROFILES.update({ "bangumi": ModuleQualityProfile( module="bangumi", level=ModuleQualityLevel.ASSESSED, @@ -78,7 +147,7 @@ MODULE_QUALITY_PROFILES = { "钉钉自定义机器人仅提供同步出站 Webhook,不包含长连接、轮询或入站回调" ), ), -} +}) def get_module_quality_profile(module: str) -> ModuleQualityProfile: diff --git a/app/startup/configuration.py b/app/startup/configuration.py new file mode 100644 index 000000000..59ac0f08f --- /dev/null +++ b/app/startup/configuration.py @@ -0,0 +1,144 @@ +"""把可变部署设置转换成宿主各领域使用的类型化配置快照。""" + +from pathlib import Path + +from app.application.configuration import ( + ApiRuntimeConfig, + ChainRuntimeConfig, + SchedulerRuntimeConfig, +) +from app.runtime.config import Settings +from app.schemas.types import MediaType + + +def normalize_subscribe_rss_interval(value: object) -> int: + """把无效或过小的 RSS 间隔收敛为兼容的安全值。""" + try: + if not isinstance(value, (str, bytes, bytearray, int, float)): + return 30 + return max(int(value), 5) + except (TypeError, ValueError): + return 30 + + +def build_api_runtime_config(settings: Settings) -> ApiRuntimeConfig: + """从可热更新的部署设置构建一次 API 请求配置快照。""" + return ApiRuntimeConfig( + advanced_mode=settings.ADVANCED_MODE, + access_token_expire_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES, + btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP, + ai_agent_enable=settings.AI_AGENT_ENABLE, + api_token=settings.API_TOKEN, + temp_path=settings.TEMP_PATH, + media_recognize_share=settings.MEDIA_RECOGNIZE_SHARE, + subscribe_mode=settings.SUBSCRIBE_MODE, + search_source=settings.SEARCH_SOURCE, + media_extensions=tuple(settings.RMT_MEDIAEXT), + subtitle_extensions=tuple(settings.RMT_SUBEXT), + audio_extensions=tuple(settings.RMT_AUDIOEXT), + movie_rename_format=settings.RENAME_FORMAT(MediaType.MOVIE), + television_rename_format=settings.RENAME_FORMAT(MediaType.TV), + music_rename_format=settings.RENAME_FORMAT(MediaType.MUSIC), + vapid_private_key=settings.VAPID.get("privateKey", ""), + vapid_subject=settings.VAPID.get("subject", ""), + cookiecloud_enable_local=bool(settings.COOKIECLOUD_ENABLE_LOCAL), + cookiecloud_auth_header=settings.COOKIECLOUD_AUTH_HEADER, + cookie_path=settings.COOKIE_PATH, + root_path=settings.ROOT_PATH, + version_flag=settings.VERSION_FLAG, + ) + + +def build_scheduler_runtime_config(settings: Settings) -> SchedulerRuntimeConfig: + """从可热更新的部署设置构建一次 Scheduler 配置快照。""" + return SchedulerRuntimeConfig( + dev=settings.DEV, + timezone=settings.TZ, + scheduler_workers=settings.CONF.scheduler, + db_backup_enable=settings.DB_BACKUP_ENABLE, + db_backup_cron=settings.DB_BACKUP_CRON, + cookiecloud_interval=settings.COOKIECLOUD_INTERVAL, + mediaserver_sync_interval=settings.MEDIASERVER_SYNC_INTERVAL, + subscribe_search=settings.SUBSCRIBE_SEARCH, + subscribe_search_interval=settings.SUBSCRIBE_SEARCH_INTERVAL, + subscribe_mode=settings.SUBSCRIBE_MODE, + subscribe_rss_interval=normalize_subscribe_rss_interval( + settings.SUBSCRIBE_RSS_INTERVAL + ), + data_cleanup_enable=settings.DATA_CLEANUP_ENABLE, + sitedata_refresh_interval=settings.SITEDATA_REFRESH_INTERVAL, + memory_gc_interval=settings.MEMORY_GC_INTERVAL, + ai_agent_enable=settings.AI_AGENT_ENABLE, + ai_agent_job_interval=settings.AI_AGENT_JOB_INTERVAL, + usage_statistic_share=settings.USAGE_STATISTIC_SHARE, + site_link=settings.MP_DOMAIN("#/site"), + ) + + +def build_chain_runtime_config(settings: Settings) -> ChainRuntimeConfig: + """从部署设置构建 Chain 在本次实例生命周期使用的配置快照。""" + return ChainRuntimeConfig( + media_extensions=tuple( + settings.RMT_MEDIAEXT + + settings.DOWNLOAD_TMPEXT + + settings.RMT_SUBEXT + + settings.RMT_AUDIOEXT + ), + video_extensions=tuple(settings.RMT_MEDIAEXT), + subtitle_extensions=tuple(settings.RMT_SUBEXT), + audio_extensions=tuple(settings.RMT_AUDIOEXT), + temporary_path=settings.TEMP_PATH, + root_path=settings.ROOT_PATH, + config_path=settings.CONFIG_PATH, + frontend_path=Path(settings.FRONTEND_PATH), + superuser=settings.SUPERUSER, + media_recognize_share=settings.MEDIA_RECOGNIZE_SHARE, + auxiliary_auth_enable=settings.AUXILIARY_AUTH_ENABLE, + global_image_cache=settings.GLOBAL_IMAGE_CACHE, + download_subtitle=settings.DOWNLOAD_SUBTITLE, + music_metadata_to_simplified=settings.MUSIC_METADATA_TO_SIMPLIFIED, + recognize_plugin_first=settings.RECOGNIZE_PLUGIN_FIRST, + ai_agent_enable=settings.AI_AGENT_ENABLE, + ai_agent_global=settings.AI_AGENT_GLOBAL, + ai_agent_retry_transfer=settings.AI_AGENT_RETRY_TRANSFER, + llm_provider=settings.LLM_PROVIDER, + llm_model=settings.LLM_MODEL, + search_resource_pages=settings.SEARCH_RESOURCE_PAGES, + ai_recommend_enabled=settings.AI_RECOMMEND_ENABLED, + ai_recommend_max_items=settings.AI_RECOMMEND_MAX_ITEMS, + ai_recommend_user_preference=settings.AI_RECOMMEND_USER_PREFERENCE, + max_search_name_limit=settings.MAX_SEARCH_NAME_LIMIT, + search_multiple_name=settings.SEARCH_MULTIPLE_NAME, + search_threadpool_size=settings.CONF.threadpool, + transfer_threads=settings.TRANSFER_THREADS, + transfer_failure_notification_aggregation=( + settings.TRANSFER_FAILURE_NOTIFICATION_AGGREGATION + ), + transfer_task_timeout=settings.TRANSFER_TASK_TIMEOUT, + scrape_follow_tmdb=settings.SCRAP_FOLLOW_TMDB, + metadata_cache_ttl=settings.CONF.meta, + auto_download_user=settings.AUTO_DOWNLOAD_USER, + resource_url=settings.MP_DOMAIN("#/resource"), + history_url=settings.MP_DOMAIN("#/history"), + downloading_url=settings.MP_DOMAIN("#/downloading"), + movie_subscribe_url=settings.MP_DOMAIN("#/subscribe/movie?tab=mysub"), + television_subscribe_url=settings.MP_DOMAIN("#/subscribe/tv?tab=mysub"), + music_subscribe_url=settings.MP_DOMAIN("#/subscribe/music?tab=mysub"), + user_agent=settings.USER_AGENT, + normal_user_agent=settings.NORMAL_USER_AGENT, + proxy=settings.PROXY, + proxy_server=settings.PROXY_SERVER, + proxy_host=settings.PROXY_HOST, + github_headers=settings.GITHUB_HEADERS, + cookiecloud_blacklist=settings.COOKIECLOUD_BLACKLIST, + subscribe_mode=settings.SUBSCRIBE_MODE, + no_cache_site_key=settings.NO_CACHE_SITE_KEY, + refresh_batch_size=settings.CONF.refresh, + torrent_cache_size=settings.CONF.torrents, + site_url=settings.MP_DOMAIN("#/site"), + season_zero_names=tuple(settings.RENAME_FORMAT_S0_NAMES), + movie_rename_format=settings.RENAME_FORMAT(MediaType.MOVIE), + television_rename_format=settings.RENAME_FORMAT(MediaType.TV), + music_rename_format=settings.RENAME_FORMAT(MediaType.MUSIC), + tmdb_image_domain=settings.TMDB_IMAGE_DOMAIN, + ) diff --git a/app/startup/context.py b/app/startup/context.py index c9e8311ad..a51d7ded0 100644 --- a/app/startup/context.py +++ b/app/startup/context.py @@ -9,7 +9,7 @@ from app.application.messaging.chat import ( AsyncUnitOfWork, ) from app.application.outbox import AsyncOutboxTransaction -from app.application.configuration import RuntimeConfiguration +from app.application.configuration import RuntimeConfiguration, RuntimeSettingsService from app.application.subscription.delete import SubscribeDeletionRepository from app.application.subscription.identity import SubscribeIdentityDeletionRepository from app.application.subscription.mutation import ( @@ -189,3 +189,4 @@ class HostRuntime: subscription: SubscriptionRuntime workflow: WorkflowRuntime configuration: RuntimeConfiguration + settings: RuntimeSettingsService diff --git a/app/startup/database_initializer.py b/app/startup/database_initializer.py index 5146c925d..0249578c7 100644 --- a/app/startup/database_initializer.py +++ b/app/startup/database_initializer.py @@ -14,8 +14,20 @@ from app.runtime.config import settings from app.db.base import Base from app.db.engine import get_engine from app.db.models import load_all_models +from app.db.session import SessionFactory, async_session_scope +from app.db.uow import configure_transaction_runners from app.runtime.log import logger from app.startup.database import build_database_governance +from app.startup.transaction import TransactionalWriteRunner + + +def _configure_migration_transaction_runner() -> None: + """在 Alembic 调用旧无会话 Oper 前装配可独立提交的兼容事务。""" + runner = TransactionalWriteRunner( + sync_session=SessionFactory, + async_session=async_session_scope, + ) + configure_transaction_runners(sync=runner.sync, async_=runner.async_) def _build_alembic_config(engine: Engine | None = None) -> Config: @@ -149,6 +161,8 @@ def update_db(alembic_cfg: Config | None = None): 更新数据库 """ try: + # 早期迁移脚本会调用 SystemConfigOper(),此时 modules_initializer 尚未执行。 + _configure_migration_transaction_runner() alembic_cfg = alembic_cfg or _build_alembic_config() upgrade(alembic_cfg, 'head') except Exception as error: diff --git a/app/startup/modules_initializer.py b/app/startup/modules_initializer.py index 74b3b7daf..475d70d6b 100644 --- a/app/startup/modules_initializer.py +++ b/app/startup/modules_initializer.py @@ -37,16 +37,20 @@ from app.application.messaging.message import ( stop_message, ) from app.application.configuration import ( - ApiRuntimeConfig, - ChainRuntimeConfig, RuntimeConfiguration, - SchedulerRuntimeConfig, + RuntimeSettingsService, SystemConfigService, TransferRetryConfig, configure_runtime_configuration, + configure_runtime_settings, configure_system_config, configure_transfer_retry_config, ) +from app.startup.configuration import ( + build_api_runtime_config, + build_chain_runtime_config, + build_scheduler_runtime_config, +) from app.application.database import configure_database_governance from app.application.service import configure_service_directory from app.application.plugin.runtime import configure_plugin_runtime @@ -173,88 +177,12 @@ def _build_chain_runtime_context() -> ChainRuntimeContext: send_callback=callback ), module_dispatcher_factory=ModuleInvocationDispatcher, - configuration=_build_chain_runtime_config(), + configuration=build_chain_runtime_config(settings), data_ports=get_chain_data_ports(), durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory), ) -def _normalize_subscribe_rss_interval(value: object) -> int: - """把无效或过小的 RSS 间隔收敛为兼容的安全值。""" - try: - return max(int(value), 5) - except (TypeError, ValueError): - return 30 - - -def _build_api_runtime_config() -> ApiRuntimeConfig: - """从可热更新 settings 构建一次 API 请求配置快照。""" - return ApiRuntimeConfig( - advanced_mode=settings.ADVANCED_MODE, - access_token_expire_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES, - btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP, - ai_agent_enable=settings.AI_AGENT_ENABLE, - api_token=settings.API_TOKEN, - temp_path=settings.TEMP_PATH, - media_recognize_share=settings.MEDIA_RECOGNIZE_SHARE, - subscribe_mode=settings.SUBSCRIBE_MODE, - ) - - -def _build_scheduler_runtime_config() -> SchedulerRuntimeConfig: - """从可热更新 settings 构建一次 Scheduler 操作配置快照。""" - return SchedulerRuntimeConfig( - dev=settings.DEV, - timezone=settings.TZ, - scheduler_workers=settings.CONF.scheduler, - db_backup_enable=settings.DB_BACKUP_ENABLE, - db_backup_cron=settings.DB_BACKUP_CRON, - cookiecloud_interval=settings.COOKIECLOUD_INTERVAL, - mediaserver_sync_interval=settings.MEDIASERVER_SYNC_INTERVAL, - subscribe_search=settings.SUBSCRIBE_SEARCH, - subscribe_search_interval=settings.SUBSCRIBE_SEARCH_INTERVAL, - subscribe_mode=settings.SUBSCRIBE_MODE, - subscribe_rss_interval=_normalize_subscribe_rss_interval( - settings.SUBSCRIBE_RSS_INTERVAL - ), - data_cleanup_enable=settings.DATA_CLEANUP_ENABLE, - sitedata_refresh_interval=settings.SITEDATA_REFRESH_INTERVAL, - memory_gc_interval=settings.MEMORY_GC_INTERVAL, - ai_agent_enable=settings.AI_AGENT_ENABLE, - ai_agent_job_interval=settings.AI_AGENT_JOB_INTERVAL, - usage_statistic_share=settings.USAGE_STATISTIC_SHARE, - site_link=settings.MP_DOMAIN("#/site"), - ) - - -def _build_chain_runtime_config() -> ChainRuntimeConfig: - """构建 Chain 在本次实例生命周期内使用的部署配置快照。""" - return ChainRuntimeConfig( - media_extensions=tuple( - settings.RMT_MEDIAEXT - + settings.DOWNLOAD_TMPEXT - + settings.RMT_SUBEXT - + settings.RMT_AUDIOEXT - ), - superuser=settings.SUPERUSER, - media_recognize_share=settings.MEDIA_RECOGNIZE_SHARE, - auxiliary_auth_enable=settings.AUXILIARY_AUTH_ENABLE, - global_image_cache=settings.GLOBAL_IMAGE_CACHE, - auto_download_user=settings.AUTO_DOWNLOAD_USER, - resource_url=settings.MP_DOMAIN("#/resource"), - user_agent=settings.USER_AGENT, - proxy=settings.PROXY, - proxy_server=settings.PROXY_SERVER, - proxy_host=settings.PROXY_HOST, - cookiecloud_blacklist=settings.COOKIECLOUD_BLACKLIST, - subscribe_mode=settings.SUBSCRIBE_MODE, - no_cache_site_key=settings.NO_CACHE_SITE_KEY, - refresh_batch_size=settings.CONF.refresh, - torrent_cache_size=settings.CONF.torrents, - site_url=settings.MP_DOMAIN("#/site"), - ) - - def configure_runtime_data_providers() -> None: """在启动组合层装配运行时和外部服务所需的数据库读取能力。""" configure_service_config_reader(lambda key: SystemConfigOper().get(key)) @@ -591,10 +519,11 @@ async def init_modules() -> HostRuntime: }, ) runtime_configuration = RuntimeConfiguration( - api=_build_api_runtime_config, - scheduler=_build_scheduler_runtime_config, - chain=_build_chain_runtime_config, + api=lambda: build_api_runtime_config(settings), + scheduler=lambda: build_scheduler_runtime_config(settings), + chain=lambda: build_chain_runtime_config(settings), ) + runtime_settings = RuntimeSettingsService(settings) host_runtime = HostRuntime( agent_chat=AgentChatRuntime( async_session=get_async_db, @@ -632,8 +561,10 @@ async def init_modules() -> HostRuntime: system_config=SystemConfigOper, ), configuration=runtime_configuration, + settings=runtime_settings, ) configure_runtime_configuration(host_runtime.configuration) + configure_runtime_settings(host_runtime.settings) # 旧 app.api.data 导入只保留 ABI 转发,正式 API 依赖全部读取 HostRuntime。 configure_api_data_runtime(api_data) configure_runtime_data_providers() diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index d6d241b18..7e72cb5e2 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -248,7 +248,9 @@ sequenceDiagram - **类型化请求装配**:`startup/context.py` 的 frozen slots `HostRuntime` 是 lifespan 内唯一宿主 上下文,`api/context.py` 从 `app.state` 收窄到具体领域能力。认证、消息、历史、媒体服务器、站点、 订阅、工作流和请求事务均使用命名 runtime 字段,不再通过字符串仓储键定位;API、Scheduler、Chain - 从 `HostRuntime.configuration` 获取 frozen 配置快照。`ApiDataPorts` 仅保留旧导入 ABI,不参与正式请求链路。 + 从 `HostRuntime.configuration` 获取 frozen 配置快照。系统设置管理 API 通过 + `HostRuntime.settings` 的窄服务读写可变部署设置,业务域不接触 Settings 实例;生产与测试组合根统一 + 复用 `startup/configuration.py` 的映射。`ApiDataPorts` 仅保留旧导入 ABI,不参与正式请求链路。 - **安全模式**:`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。 - **进程拓扑**:全功能 V3 强制 `API_WORKERS=1`,避免每个 worker 重复启动插件和后台控制面;安全模式可临时使用多 worker 诊断,但不是正式扩容方案。 - **健康语义**:`/health/live` 只确认进程和事件循环可响应;`/health/ready` 仅在数据库 @@ -377,7 +379,8 @@ flowchart LR `application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()` 只查重、`add` 和 `flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。 `transaction-debt-baseline.json` 当前冻结 123 个只读查询装饰器;原有 45 个同步/异步写装饰器 - 已全部移除,`db_update` 与 `async_db_update` 必须持续保持为 0。 + 已全部移除,`db_update` 与 `async_db_update` 必须持续保持为 0。宿主 Oper 也不得调用 Base 保留的 + `create/update/delete/truncate` 兼容包装器;AST 门禁保证显式 Session 的提交权不会被底层抢走。 - 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application Command/Service 持有 UoW,Oper 的 `stage_*` 方法只修改当前会话。插件数据重置从 `startup/plugins_initializer.py` 创建独占会话,插件直接使用 `PluginDataOper` 的旧 ABI 仅作兼容。 diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index ee17870ed..c6f3750e5 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -6,7 +6,7 @@ > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` -> 实施进度:阶段 0(ARCH-201~203)、阶段 1(ARCH-210~212)、阶段 2(ARCH-220~222)与阶段 3(ARCH-230~232)已完成,后续任务按 ID 独立提交和回滚 +> 实施进度:阶段 0~6 的宿主架构能力已完成收口;按既定范围暂不处理插件仓适配、Outbox 外围扩展和 25 个存量超长方法拆分 ## 1. 结论先行 @@ -585,6 +585,13 @@ app/api/dependencies/ # 按领域拆分依赖工厂 测试显式注入快照,不再依赖 endpoint 模块中的全局配置别名。 - 直接调用 endpoint 和显式构造 `ChainRuntimeContext` 的旧测试/兼容入口仍有 fallback;正式 FastAPI 与 Startup 路径始终使用 HostRuntime 注入。插件 SDK 的 `app.sdk.config.settings`、动态 API 返回和事件字段未改。 +- 收尾批次把 API 与 Chain 余下直接配置读取全部迁入类型化 snapshot;Scheduler 继续保持为零。 + `HostRuntime` 新增可变部署设置服务,只供系统设置管理 API 使用,业务 API/Chain 只接收 frozen 字段。 + snapshot 构造集中到 `app/startup/configuration.py`,生产启动与测试组合根复用同一映射,避免测试默认值 + 漂移。canonical `settings` 直接导入低水位从 154 降到 137,`SystemConfigOper()` 保持 14 个。 +- `ApiRuntimeConfig` 已覆盖搜索来源、媒体/字幕/音频后缀、重命名格式、WebPush、CookieCloud、根目录和 + 版本标识;`ChainRuntimeConfig` 覆盖搜索、下载、整理、刮削、AI、代理、缓存、链接、路径和 TMDB 图片域。 + 元数据缓存 TTL 使用动态 provider,在保留热更新语义的同时不再让 Chain 导入全局 settings。 ### 阶段 4:把动态模块和事件变成可演进契约 @@ -706,6 +713,11 @@ ModuleMethodSpec( - 详细规则和验收证据见 `docs/refactor/module-quality-scale.md`;自动测试阻止 profile 使用未登记规则, 并要求今后修改模块时将对应 profile 纳入同一提交。 +**收口记录(2026-08-22)**:39 个宿主 Module 已全部显式进入 assessed,不再以通用 fallback 把 +37 个模块标成“尚未审查”。所有模块共同由零真实网络、async 阻塞扫描、Module Contract V2 和 owner +四项机器门禁覆盖;能力专属的鉴权、限流、并发、敏感日志与 reload/stop 仍按 profile 精确豁免, +不会把 assessed 误读为十项满分。未知第三方模块继续使用 legacy 兼容视图,Module ABI 未变。 + ### 阶段 5:定义后台可靠性,不先引入分布式队列 #### ARCH-250:后台动作可靠性分类 ADR @@ -800,6 +812,9 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas 的旧 Oper ABI 委托 Startup 注入的短事务执行器。当前 Model 装饰器仅剩 123 个查询装饰器, `db_update` 与 `async_db_update` 均为 0,Oper 自建 Session/直接提交仍为 0。 - 数据清理按批次显式提交 UoW,单表失败先回滚会话再继续汇总后续表;不再依赖删除 Model 的隐式提交。 +- 收尾批次进一步移除宿主 Oper 对 `Base.create/update/delete/truncate` 八个兼容包装器的调用:显式 + Session 只 stage,由 Application UoW 提交;无 Session 的旧 Oper 入口才委托 Startup 的短事务执行器。 + Base 包装器继续保留给插件/旧模型 ABI,新增 AST 门禁禁止宿主 Oper 回退到隐式提交。 **禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。 @@ -926,6 +941,9 @@ Workflow 执行状态 UoW 切片将 `app/application/workflow.py` 与 `app/start 异步安全与契约收口继续纳管 scheduling facade、Event error policy、Module dispatcher 和 async blocking scanner,strict 清单扩大到 26 个源文件;已登记范围保持零错误,未使用全文件 ignore 或 `cast(Any, ...)`。 +收尾批次继续纳管 Startup 配置快照、Module quality、Compat manifest/diagnostics、插件运行时窄端口、 +Outbox adapter、DB 装饰器、Base 与 UoW,strict 清单扩大到 37 个源文件并保持零错误。 + #### ARCH-271:复杂度和端点预算 ratchet **目标**:阻止大方法继续增长,并让拆分对应真实阶段,而不是机械 helper 化。 @@ -1133,10 +1151,10 @@ rollback: | 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope | | 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 | | 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 | -| Model 事务装饰器 | 178 | 新增为 0;每迁移一个切片净减少,baseline 不增 | -| 新写用例事务 | 部分 UoW | 100% 由入口/Application 边界拥有 Session/UoW | -| 高频 Module 契约 | 96 个 legacy 默认 | 首批 20 个高频方法有完整参数、结果、错误、timeout 描述 | -| Event payload | 53 类型 / 20 专用 model | 宿主 producer 使用的 EventType 100% 登记 payload 与可靠性 | +| Model 事务装饰器 | 当前 123 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 | +| 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW | +| 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 | +| Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict | | 超长新端点/用例 | 无增量门禁 | 新代码不越预算;旧 baseline 只降不增 | | Request 关联 | 无统一 ID | HTTP → Application → Module/Event/外部请求可关联 | | 关键后台副作用 | commit 后存在崩溃窗口 | 选定 pilot 可恢复、幂等、可查询失败和重试次数 | diff --git a/docs/refactor/module-quality-scale.md b/docs/refactor/module-quality-scale.md index 0f7cbec60..478a4f5db 100644 --- a/docs/refactor/module-quality-scale.md +++ b/docs/refactor/module-quality-scale.md @@ -1,12 +1,12 @@ # Module / Integration 渐进质量清单 本清单对应 ARCH-242。机器可检查定义位于 -`app/runtime/extensions/module/quality.py`;它不改变 Module ABI,也不要求一次修完全部历史模块。 +`app/runtime/extensions/module/quality.py`;它不改变 Module ABI,也不把“已评估”误写成“所有规则满分”。 ## 使用规则 -- 未在本阶段修改的模块解析为 `legacy`,必须携带统一豁免原因和 owner。 -- 新模块或本阶段修改的模块必须新增显式 `ModuleQualityProfile`,只可使用登记规则。 +- 当前 39 个宿主模块都必须显式登记 `ModuleQualityProfile`;未知第三方扩展才解析为 `legacy`。 +- 新模块必须在同一提交新增 profile,只可使用登记规则;宿主目录与 profile 集合不一致时测试失败。 - `assessed` 表示已明确检查的规则集合,不等于所有规则满分;未覆盖项必须写精确原因。 - 测试不得访问真实网络。外部错误、限流和超时通过 fake client、fixture 或 adapter stub 验证。 - profile 不能替代 Module Contract V2;对外能力仍须在 contract registry 单独登记。 @@ -26,8 +26,12 @@ | `sensitive-log-redaction` | token/cookie/password 不进入日志 | | `owner-declared` | profile 有维护 owner | -## 当前 assessed 切片 +## 当前 assessed 范围 -`bangumi`:本轮配置快照改造已验证 fake client、零真实网络、同步/异步边界、reload/stop、 -Contract V2、敏感日志和 owner。限流/并发仍复用通用 HTTP adapter,未在本切片重复实现。 +全部 39 个宿主模块已经完成显式 assessed 登记。所有模块共同具备四项机器证据:全测试真实网络 +守卫、覆盖 `app/modules` 的 async 阻塞扫描、宿主已观察能力的 Module Contract V2、明确的 +`MoviePilot core` owner。鉴权、限流、并发、敏感日志和 reload/stop 等能力相关规则不做虚假 +“全通过”声明,仍由对应模块专项测试证明,并在 profile 中保留豁免边界。 +`bangumi` 与 `dingtalk` 已登记更细的专项证据;其他模块先完成“已审查、通用门禁已覆盖、专属规则 +按能力适用”的收口。测试会阻止宿主模块退回无法区分是否审查过的 `legacy` 状态。 diff --git a/mypy.ini b/mypy.ini index 37d1d6a14..7c9dab870 100644 --- a/mypy.ini +++ b/mypy.ini @@ -17,6 +17,9 @@ files = app/runtime/event/errors.py, app/runtime/extensions/module/contracts.py, app/runtime/extensions/module/dispatcher.py, + app/runtime/extensions/module/quality.py, + app/runtime/compat/diagnostics.py, + app/runtime/compat/manifest.py, app/application/outbox.py, app/application/configuration.py, app/application/scheduling.py, @@ -26,7 +29,15 @@ files = app/application/subscription/delete.py, app/application/subscription/identity.py, app/application/subscription/mutation.py, + app/application/plugin/folders.py, + app/application/plugin/routes.py, + app/application/plugin/runtime.py, + app/db/decorators.py, + app/db/base.py, + app/db/uow.py, app/startup/context.py, + app/startup/configuration.py, + app/startup/outbox.py, app/startup/chain_events.py, app/startup/download_failure.py, app/startup/workflow.py, diff --git a/tests/conftest.py b/tests/conftest.py index d95871dbb..aa7efdaef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,17 +30,21 @@ def configure_plugin_system_services(): ) from app.api.data import configure_api_data_ports from app.application.configuration import ( - ApiRuntimeConfig, - ChainRuntimeConfig, RuntimeConfiguration, - SchedulerRuntimeConfig, + RuntimeSettingsService, SystemConfigService, TransferRetryConfig, configure_runtime_configuration, + configure_runtime_settings, configure_system_config, configure_transfer_retry_config, ) from app.runtime.config import settings + from app.startup.configuration import ( + build_api_runtime_config, + build_chain_runtime_config, + build_scheduler_runtime_config, + ) from app.application.service import configure_service_directory from app.db.session import ( SessionFactory, @@ -58,20 +62,12 @@ def configure_plugin_system_services(): configure_token_codec(create_access_token, decode_access_token) configure_runtime_configuration( RuntimeConfiguration( - api=lambda: ApiRuntimeConfig( - advanced_mode=settings.ADVANCED_MODE, - access_token_expire_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES, - btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP, - ai_agent_enable=settings.AI_AGENT_ENABLE, - ), - scheduler=lambda: SchedulerRuntimeConfig( - False, settings.TZ, 1, False, "", None, None, False, 24, - "rss", 30, False, None, None, settings.AI_AGENT_ENABLE, - None, False, None, - ), - chain=lambda: ChainRuntimeConfig(media_extensions=()), + api=lambda: build_api_runtime_config(settings), + scheduler=lambda: build_scheduler_runtime_config(settings), + chain=lambda: build_chain_runtime_config(settings), ) ) + configure_runtime_settings(RuntimeSettingsService(settings)) configure_system_config(SystemConfigService(repository=SystemConfigOper())) configure_transfer_retry_config( lambda: TransferRetryConfig( @@ -194,6 +190,7 @@ def configure_plugin_system_services(): send_callback=callback ), module_dispatcher_factory=ModuleInvocationDispatcher, + configuration=build_chain_runtime_config(settings), )) configure_site_query_service(SiteQueryService(repository=SiteOper())) configure_site_health_service(SiteHealthService(repository=SiteOper())) diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json index 0409e0db6..f0062b28b 100644 --- a/tests/fixtures/architecture/configuration-debt-baseline.json +++ b/tests/fixtures/architecture/configuration-debt-baseline.json @@ -9,7 +9,7 @@ "root": "app" }, "settings_imports": { - "count": 154, + "count": 137, "files": [ "app/adapters/cache/backends.py", "app/adapters/cache/redis.py", @@ -47,14 +47,6 @@ "app/agent/tools/impl/send_voice_message.py", "app/agent/tools/impl/update_agent_task.py", "app/agent/tools/impl/update_system_settings.py", - "app/api/endpoints/media.py", - "app/api/endpoints/message.py", - "app/api/endpoints/plugin.py", - "app/api/endpoints/storage.py", - "app/api/endpoints/subscribe.py", - "app/api/endpoints/system.py", - "app/api/endpoints/transfer.py", - "app/api/servcookie.py", "app/application/formatting.py", "app/application/image.py", "app/application/maintenance.py", @@ -64,15 +56,6 @@ "app/application/security/token.py", "app/application/security/url.py", "app/application/torrent.py", - "app/chain/_transfer.py", - "app/chain/download.py", - "app/chain/media.py", - "app/chain/message.py", - "app/chain/scraping.py", - "app/chain/search.py", - "app/chain/subscribe.py", - "app/chain/system.py", - "app/chain/transfer.py", "app/cli.py", "app/db/base.py", "app/db/engine.py", diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index b5f23e0e7..c654fe435 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": 6379, - "edge_sha256": "1c619d72157004590838a85c497330b8ca462d3bc1eb490fd5e7bac97e6190b8", + "edge_count": 6393, + "edge_sha256": "fe5804c22c536d640583046cb78ba29a7ba4de577a3205cc6eacaf030491c3d3", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -1882,6 +1882,7 @@ "app.api.endpoints.media -> app.api.dependencies.auth", "app.api.endpoints.media -> app.api.response", "app.api.endpoints.media -> app.application", + "app.api.endpoints.media -> app.application.configuration", "app.api.endpoints.media -> app.application.plugin", "app.api.endpoints.media -> app.application.plugin.runtime", "app.api.endpoints.media -> app.chain", @@ -1895,8 +1896,6 @@ "app.api.endpoints.media -> app.domain.meta.metabase", "app.api.endpoints.media -> app.domain.meta.metamusic", "app.api.endpoints.media -> app.domain.metainfo", - "app.api.endpoints.media -> app.runtime", - "app.api.endpoints.media -> app.runtime.config", "app.api.endpoints.media -> app.schemas", "app.api.endpoints.media -> app.schemas.category", "app.api.endpoints.media -> app.schemas.context", @@ -2058,7 +2057,6 @@ "app.api.endpoints.plugin -> app.application.scheduling", "app.api.endpoints.plugin -> app.runtime", "app.api.endpoints.plugin -> app.runtime.cache", - "app.api.endpoints.plugin -> app.runtime.config", "app.api.endpoints.plugin -> app.runtime.extensions", "app.api.endpoints.plugin -> app.runtime.extensions.plugin", "app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts", @@ -2151,6 +2149,8 @@ "app.api.endpoints.storage -> app.api.dependencies.auth", "app.api.endpoints.storage -> app.api.principal", "app.api.endpoints.storage -> app.api.response", + "app.api.endpoints.storage -> app.application", + "app.api.endpoints.storage -> app.application.configuration", "app.api.endpoints.storage -> app.chain", "app.api.endpoints.storage -> app.chain.media", "app.api.endpoints.storage -> app.chain.storage", @@ -2158,7 +2158,6 @@ "app.api.endpoints.storage -> app.foundation", "app.api.endpoints.storage -> app.foundation.text", "app.api.endpoints.storage -> app.runtime", - "app.api.endpoints.storage -> app.runtime.config", "app.api.endpoints.storage -> app.runtime.progress", "app.api.endpoints.storage -> app.schemas", "app.api.endpoints.storage -> app.schemas.common", @@ -2192,7 +2191,6 @@ "app.api.endpoints.subscribe -> app.domain.context", "app.api.endpoints.subscribe -> app.domain.metainfo", "app.api.endpoints.subscribe -> app.runtime", - "app.api.endpoints.subscribe -> app.runtime.config", "app.api.endpoints.subscribe -> app.runtime.events", "app.api.endpoints.subscribe -> app.schemas", "app.api.endpoints.subscribe -> app.schemas.common", @@ -2309,6 +2307,7 @@ "app.api.endpoints.transfer -> app.api.dependencies.history", "app.api.endpoints.transfer -> app.api.response", "app.api.endpoints.transfer -> app.application", + "app.api.endpoints.transfer -> app.application.configuration", "app.api.endpoints.transfer -> app.application.directory", "app.api.endpoints.transfer -> app.application.history", "app.api.endpoints.transfer -> app.chain", @@ -2428,10 +2427,11 @@ "app.api.servarr -> app.schemas.types", "app.api.servcookie -> app.api", "app.api.servcookie -> app.api.response", + "app.api.servcookie -> app.application", + "app.api.servcookie -> app.application.configuration", "app.api.servcookie -> app.foundation", "app.api.servcookie -> app.foundation.crypto", "app.api.servcookie -> app.runtime", - "app.api.servcookie -> app.runtime.config", "app.api.servcookie -> app.runtime.log", "app.api.servcookie -> app.schemas", "app.api.servcookie -> app.schemas.servcookie", @@ -2466,6 +2466,8 @@ "app.application.chain.durable_events -> app.schemas.file", "app.application.chain.durable_events -> app.schemas.transfer", "app.application.chain.durable_events -> app.schemas.types", + "app.application.configuration -> app.schemas", + "app.application.configuration -> app.schemas.types", "app.application.dashboard -> app.schemas", "app.application.dashboard -> app.schemas.dashboard", "app.application.database -> app.application", @@ -2817,6 +2819,7 @@ "app.chain -> app.application.chain", "app.chain -> app.application.chain.context", "app.chain -> app.application.chain.data", + "app.chain -> app.application.configuration", "app.chain -> app.chain._messaging", "app.chain -> app.chain._recognition", "app.chain -> app.domain", @@ -2960,6 +2963,7 @@ "app.chain.download -> app.application", "app.chain.download -> app.application.chain", "app.chain.download -> app.application.chain.data", + "app.chain.download -> app.application.configuration", "app.chain.download -> app.application.directory", "app.chain.download -> app.application.download", "app.chain.download -> app.application.download.tasks", @@ -3036,6 +3040,7 @@ "app.chain.lrclib -> app.domain.meta.metamusic", "app.chain.media -> app.application", "app.chain.media -> app.application.audio", + "app.chain.media -> app.application.configuration", "app.chain.media -> app.application.music", "app.chain.media -> app.application.music.catalog", "app.chain.media -> app.chain", @@ -3056,7 +3061,6 @@ "app.chain.media -> app.foundation.text", "app.chain.media -> app.runtime", "app.chain.media -> app.runtime.cache", - "app.chain.media -> app.runtime.config", "app.chain.media -> app.runtime.events", "app.chain.media -> app.runtime.log", "app.chain.media -> app.schemas", @@ -3151,7 +3155,6 @@ "app.chain.scraping -> app.foundation.singleton", "app.chain.scraping -> app.runtime", "app.chain.scraping -> app.runtime.cache", - "app.chain.scraping -> app.runtime.config", "app.chain.scraping -> app.runtime.events", "app.chain.scraping -> app.runtime.log", "app.chain.scraping -> app.runtime.reload", @@ -3274,9 +3277,10 @@ "app.chain.system -> app.adapters.network.http", "app.chain.system -> app.adapters.system", "app.chain.system -> app.adapters.system.host", + "app.chain.system -> app.application", + "app.chain.system -> app.application.configuration", "app.chain.system -> app.chain", "app.chain.system -> app.runtime", - "app.chain.system -> app.runtime.config", "app.chain.system -> app.runtime.log", "app.chain.system -> app.runtime.state", "app.chain.system -> app.schemas", @@ -5934,6 +5938,12 @@ "app.startup.command_initializer -> app.application", "app.startup.command_initializer -> app.application.commands", "app.startup.command_initializer -> app.command", + "app.startup.configuration -> app.application", + "app.startup.configuration -> app.application.configuration", + "app.startup.configuration -> app.runtime", + "app.startup.configuration -> app.runtime.config", + "app.startup.configuration -> app.schemas", + "app.startup.configuration -> app.schemas.types", "app.startup.context -> app.application", "app.startup.context -> app.application.configuration", "app.startup.context -> app.application.messaging", @@ -5962,11 +5972,14 @@ "app.startup.database_initializer -> app.db.base", "app.startup.database_initializer -> app.db.engine", "app.startup.database_initializer -> app.db.models", + "app.startup.database_initializer -> app.db.session", + "app.startup.database_initializer -> app.db.uow", "app.startup.database_initializer -> app.runtime", "app.startup.database_initializer -> app.runtime.config", "app.startup.database_initializer -> app.runtime.log", "app.startup.database_initializer -> app.startup", "app.startup.database_initializer -> app.startup.database", + "app.startup.database_initializer -> app.startup.transaction", "app.startup.domain_initializer -> app.adapters", "app.startup.domain_initializer -> app.adapters.system", "app.startup.domain_initializer -> app.adapters.system.rust", @@ -6123,6 +6136,7 @@ "app.startup.modules_initializer -> app.startup", "app.startup.modules_initializer -> app.startup.agent_initializer", "app.startup.modules_initializer -> app.startup.chain_events", + "app.startup.modules_initializer -> app.startup.configuration", "app.startup.modules_initializer -> app.startup.context", "app.startup.modules_initializer -> app.startup.database", "app.startup.modules_initializer -> app.startup.download_failure", @@ -6396,7 +6410,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 791, + "module_count": 792, "modules": [ "app", "app.adapters", @@ -7149,6 +7163,7 @@ "app.startup.cache_initializer", "app.startup.chain_events", "app.startup.command_initializer", + "app.startup.configuration", "app.startup.context", "app.startup.database", "app.startup.database_initializer", diff --git a/tests/test_agent_image_capability.py b/tests/test_agent_image_capability.py index 3a028b518..bf6124f7c 100644 --- a/tests/test_agent_image_capability.py +++ b/tests/test_agent_image_capability.py @@ -1,3 +1,4 @@ +from dataclasses import replace from unittest.mock import AsyncMock, patch import pytest @@ -78,6 +79,12 @@ def test_handle_ai_message_routes_text_only_model_images_to_files( ): """纯文本模型收到图片消息时,应降级为文件附件而非 image_url 内容块。""" chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + llm_provider="minimax", + llm_model="MiniMax-M2.7", + ) monkeypatch.setattr(settings, "AI_AGENT_ENABLE", True) monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True) monkeypatch.setattr(settings, "LLM_PROVIDER", "minimax") diff --git a/tests/test_agent_image_support.py b/tests/test_agent_image_support.py index 497c5ef23..f4828d326 100644 --- a/tests/test_agent_image_support.py +++ b/tests/test_agent_image_support.py @@ -3,6 +3,7 @@ import base64 import json import tempfile import unittest +from dataclasses import replace from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -199,6 +200,11 @@ class AgentImageSupportTest(unittest.TestCase): def test_image_message_routes_to_agent_even_when_global_agent_is_disabled(self): chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ai_agent_global=False, + ) with patch.object(chain, "load_cache", return_value={}), patch.object( chain.messagehelper, "put" @@ -222,6 +228,11 @@ class AgentImageSupportTest(unittest.TestCase): def test_audio_message_routes_to_agent_without_forcing_voice_reply(self): chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ai_agent_global=False, + ) with patch.object(chain, "load_cache", return_value={}), patch.object( chain, "_transcribe_audio_refs", return_value="帮我推荐一部电影" @@ -247,6 +258,11 @@ class AgentImageSupportTest(unittest.TestCase): def test_file_message_routes_to_agent_even_when_global_agent_is_disabled(self): chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ai_agent_global=False, + ) with patch.object(chain, "load_cache", return_value={}), patch.object( chain.messagehelper, "put" @@ -433,6 +449,10 @@ class AgentImageSupportTest(unittest.TestCase): def test_handle_ai_message_routes_images_to_files_when_image_input_disabled(self): chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ) with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( settings, "LLM_SUPPORT_IMAGE_INPUT", False @@ -483,6 +503,10 @@ class AgentImageSupportTest(unittest.TestCase): def test_handle_ai_message_forwards_voice_input_to_agent_manager(self): """AI消息入队时应保留语音输入标记。""" chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ) with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( chain, "_get_or_create_session_id", return_value="session-1" @@ -1330,29 +1354,31 @@ class AgentImageSupportTest(unittest.TestCase): def test_prepare_agent_files_saves_local_file(self): chain = MessageChain() - with tempfile.TemporaryDirectory() as tempdir, patch( - "app.chain.message.settings", - SimpleNamespace(TEMP_PATH=Path(tempdir)), - ), patch.object( + with tempfile.TemporaryDirectory() as tempdir: + chain.runtime_config = replace( + chain.runtime_config, + temporary_path=Path(tempdir), + ) + with patch.object( chain, "_download_message_file_bytes", return_value="你好,MoviePilot".encode("utf-8"), - ): - prepared = chain._prepare_agent_files( - session_id="session-1", - files=[ - IncomingMessage.MessageAttachment( - ref="tg://document_file_id/doc-1", - name="note.txt", - mime_type="text/plain", - ) - ], - channel=NotificationChannel.Telegram, - source="telegram-test", - ) + ): + prepared = chain._prepare_agent_files( + session_id="session-1", + files=[ + IncomingMessage.MessageAttachment( + ref="tg://document_file_id/doc-1", + name="note.txt", + mime_type="text/plain", + ) + ], + channel=NotificationChannel.Telegram, + source="telegram-test", + ) - self.assertEqual(prepared[0]["status"], "ready") - self.assertTrue(Path(prepared[0]["local_path"]).exists()) + self.assertEqual(prepared[0]["status"], "ready") + self.assertTrue(Path(prepared[0]["local_path"]).exists()) def test_telegram_post_message_passes_file_to_client(self): module = TelegramModule() diff --git a/tests/test_agent_interaction.py b/tests/test_agent_interaction.py index 82e2bb64c..9f404df23 100644 --- a/tests/test_agent_interaction.py +++ b/tests/test_agent_interaction.py @@ -1,5 +1,6 @@ import asyncio import unittest +from dataclasses import replace from datetime import datetime from unittest.mock import AsyncMock, Mock, patch @@ -174,6 +175,10 @@ class TestAgentInteraction(unittest.TestCase): def test_agent_interaction_callback_routes_selected_value_back_to_agent(self): chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ) request = agent_interaction_manager.create_request( session_id="session-choice", user_id="10001", diff --git a/tests/test_agent_message_routing.py b/tests/test_agent_message_routing.py index a57a165bf..b28637acb 100644 --- a/tests/test_agent_message_routing.py +++ b/tests/test_agent_message_routing.py @@ -1,5 +1,6 @@ import asyncio from concurrent.futures import Future +from dataclasses import replace from unittest.mock import AsyncMock, Mock, patch from app.agent import MoviePilotAgent @@ -65,6 +66,7 @@ def test_explicit_ai_message_bypasses_pending_media_interaction(): def test_explicit_ai_message_is_not_recorded_to_message_history(): """显式 /ai 消息不登记到数据库或实时消息队列。""" chain = MessageChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True) manager = Mock(process_message=AsyncMock()) with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( @@ -90,6 +92,7 @@ def test_explicit_ai_message_is_not_recorded_to_message_history(): def test_agent_queue_full_is_reported_to_the_originating_channel(): """消息队列满时应消费 Future 异常并向原渠道返回可重试提示。""" chain = MessageChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True) manager = Mock(process_message=AsyncMock()) failed = Future() failed.set_exception(AgentManagerQueueFullError("session-1", 8)) @@ -121,6 +124,7 @@ def test_agent_queue_full_is_reported_to_the_originating_channel(): def test_message_chain_passes_stable_channel_admin_principal_to_agent(): """消息链应将渠道适配器生成的管理员事实传给 Agent。""" chain = MessageChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True) manager = Mock(process_message=AsyncMock()) with patch.object(settings, "AI_AGENT_ENABLE", True), patch( @@ -144,6 +148,7 @@ def test_message_chain_passes_stable_channel_admin_principal_to_agent(): def test_message_chain_does_not_trust_channel_display_username(): """消息链应保留适配器给出的明确非管理员结论。""" chain = MessageChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True) manager = Mock(process_message=AsyncMock()) with patch.object(settings, "AI_AGENT_ENABLE", True), patch( @@ -167,6 +172,7 @@ def test_message_chain_does_not_trust_channel_display_username(): def test_message_chain_uses_same_admin_contract_for_slack(): """管理员事实透传应复用于其他消息渠道,而不是 Telegram 特判。""" chain = MessageChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True) manager = Mock(process_message=AsyncMock()) with patch.object(settings, "AI_AGENT_ENABLE", True), patch( @@ -272,6 +278,7 @@ def test_send_message_tool_disables_notification_history(): def test_agent_choice_callback_is_not_recorded_to_message_history(): """Agent 按钮选择回传不登记到数据库或实时消息队列。""" chain = MessageChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True) request = agent_interaction_manager.create_request( session_id="session-choice", user_id="10001", diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index 2029bf43d..9686ae815 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -148,7 +148,9 @@ def test_system_public_setting_allows_only_non_sensitive_keys(monkeypatch): response = asyncio.run(system_endpoint.get_public_setting("PLUGIN_MARKET")) assert response.success is True - assert response.data == {"value": system_endpoint.settings.PLUGIN_MARKET} + assert response.data == { + "value": system_endpoint.get_runtime_settings().get("PLUGIN_MARKET") + } assert calls == [SystemConfigKey.Directories] with pytest.raises(HTTPException) as exc_info: diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index af484d1b9..b692d5a65 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -1,3 +1,4 @@ +import ast import json import os import subprocess @@ -135,6 +136,40 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None: assert baseline["oper_session_factories"] == {"count": 0, "calls": []} +def test_host_oper_does_not_call_base_implicit_write_wrappers() -> None: + """宿主 Oper 不得重新借 Base 兼容写方法隐式提交调用方事务。""" + implicit_methods = { + "create", + "async_create", + "update", + "async_update", + "delete", + "async_delete", + "truncate", + "async_truncate", + } + violations = [] + for path in (PROJECT_ROOT / "app" / "db" / "oper").glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not isinstance(node.func, ast.Attribute): + continue + if node.func.attr not in implicit_methods or not node.args: + continue + first_argument = node.args[0] + if ( + isinstance(first_argument, ast.Attribute) + and isinstance(first_argument.value, ast.Name) + and first_argument.value.id == "self" + and first_argument.attr == "_db" + ): + violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}") + + assert violations == [] + + def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None: """配置债务基线必须排除插件兼容面,并冻结两个可下降的直接访问集合。""" baseline_path = BASELINE_ROOT / "configuration-debt-baseline.json" diff --git a/tests/test_cache_system.py b/tests/test_cache_system.py index 0ae0ff649..95016906a 100644 --- a/tests/test_cache_system.py +++ b/tests/test_cache_system.py @@ -5,6 +5,8 @@ import time from types import SimpleNamespace from unittest.mock import AsyncMock +import pytest + from app.adapters.cache.backends import ( AsyncFileBackend, AsyncRedisBackend, @@ -305,6 +307,39 @@ def test_cached_zero_ttl_does_not_cache_async_result(): assert asyncio.run(run_test()) == (1, 2) +def test_cached_ttl_provider_resolves_current_value_for_each_write(): + """动态 TTL 工厂应在每次写入时读取新快照,而不是在导入期固化。""" + state = {"ttl": 10} + calls = 0 + + @cached(region="sync_dynamic_ttl", ttl_provider=lambda: state["ttl"]) + def load_value(): + nonlocal calls + calls += 1 + return calls + + assert load_value() == 1 + region_cache = MemoryBackend._region_caches[ + MemoryBackend.get_region("sync_dynamic_ttl") + ] + started_at = region_cache.timer() + region_cache.expire(time=started_at + 11) + + state["ttl"] = 30 + assert load_value() == 2 + region_cache.expire(time=started_at + 20) + assert load_value() == 2 + + +def test_cached_rejects_fixed_and_dynamic_ttl_together(): + """固定 TTL 与动态 TTL 同时存在时应在装饰阶段明确拒绝。""" + with pytest.raises(ValueError, match="不能同时设置"): + + @cached(ttl=10, ttl_provider=lambda: 20) + def load_value(): + return 1 + + def test_cached_empty_ttl_expires_empty_result_sooner_sync(): """ 同步 cached 的空结果应按 empty_ttl 独立过期,不受默认 ttl 影响。 diff --git a/tests/test_configuration_ports.py b/tests/test_configuration_ports.py index 66662ff95..ac0f1b46e 100644 --- a/tests/test_configuration_ports.py +++ b/tests/test_configuration_ports.py @@ -10,6 +10,7 @@ from app.application.configuration import ( ApiRuntimeConfig, ChainRuntimeConfig, RuntimeConfiguration, + RuntimeSettingsService, SchedulerRuntimeConfig, SystemConfigService, TransferRetryConfig, @@ -20,6 +21,47 @@ from app.application.configuration import ( ) +class _MutableSettings: + """记录管理设置服务的读取和更新操作。""" + + def __init__(self) -> None: + """初始化一组可变测试设置。""" + self.VALUE = "before" + + def model_dump(self, *, include=None, exclude=None): + """按白名单返回设置字典。""" + values = {"VALUE": self.VALUE, "SECRET": "hidden"} + if include is not None: + values = {key: value for key, value in values.items() if key in include} + if exclude is not None: + values = {key: value for key, value in values.items() if key not in exclude} + return values + + def update_settings(self, env): + """批量更新并返回逐项结果。""" + for key, value in env.items(): + setattr(self, key, value) + return {key: (True, "") for key in env} + + def update_setting(self, key, value): + """更新单个设置。""" + setattr(self, key, value) + return True, "" + + +def test_runtime_settings_service_hides_mutable_settings_implementation() -> None: + """管理 API 通过窄服务读取和修改设置,不依赖全局 Settings 类型。""" + settings = _MutableSettings() + service = RuntimeSettingsService(settings) + + assert service.contains("VALUE") + assert service.get("VALUE") == "before" + assert service.snapshot(include={"VALUE"}) == {"VALUE": "before"} + assert service.update("VALUE", "after") == (True, "") + assert service.update_many({"VALUE": "final"}) == {"VALUE": (True, "")} + assert service.get("VALUE") == "final" + + def test_system_config_service_supports_separate_reader_and_writer() -> None: """应用服务可以分别注入只读与写入适配器。""" reader = MagicMock() diff --git a/tests/test_cookiecloud_routes.py b/tests/test_cookiecloud_routes.py index daaa30b0e..00d29a019 100644 --- a/tests/test_cookiecloud_routes.py +++ b/tests/test_cookiecloud_routes.py @@ -1,11 +1,11 @@ import json -from types import SimpleNamespace import httpx import pytest from fastapi import FastAPI from app.api import servcookie +from app.runtime.config import settings pytestmark = pytest.mark.anyio @@ -17,12 +17,11 @@ def anyio_backend(): @pytest.fixture() def cookiecloud_app(tmp_path, monkeypatch): - settings = SimpleNamespace( - COOKIE_PATH=tmp_path, - COOKIECLOUD_ENABLE_LOCAL=True, - COOKIECLOUD_AUTH_HEADER=None, - ) - monkeypatch.setattr(servcookie, "settings", settings) + """用启动组合根读取的同一 Settings 实例配置 CookieCloud 测试。""" + monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "COOKIECLOUD_ENABLE_LOCAL", True) + monkeypatch.setattr(settings, "COOKIECLOUD_AUTH_HEADER", None) + settings.COOKIE_PATH.mkdir(parents=True, exist_ok=True) app = FastAPI() app.include_router(servcookie.cookie_router, prefix="/cookiecloud") @@ -37,8 +36,8 @@ def make_client(app): async def test_update_rejects_when_local_cookiecloud_disabled(cookiecloud_app): - servcookie.settings.COOKIECLOUD_ENABLE_LOCAL = False - servcookie.settings.COOKIECLOUD_AUTH_HEADER = "secret" + settings.COOKIECLOUD_ENABLE_LOCAL = False + settings.COOKIECLOUD_AUTH_HEADER = "secret" async with make_client(cookiecloud_app) as client: response = await client.post( @@ -54,7 +53,7 @@ async def test_update_rejects_when_local_cookiecloud_disabled(cookiecloud_app): async def test_update_allows_legacy_clients_when_auth_header_unconfigured( cookiecloud_app, auth_header ): - servcookie.settings.COOKIECLOUD_AUTH_HEADER = auth_header + settings.COOKIECLOUD_AUTH_HEADER = auth_header async with make_client(cookiecloud_app) as client: response = await client.post( @@ -64,13 +63,13 @@ async def test_update_allows_legacy_clients_when_auth_header_unconfigured( assert response.status_code == 200 assert response.json() == {"action": "done"} - assert json.loads((servcookie.settings.COOKIE_PATH / "abcde.json").read_text()) == { + assert json.loads((settings.COOKIE_PATH / "abcde.json").read_text()) == { "encrypted": "payload" } async def test_update_allows_matching_auth_header(cookiecloud_app): - servcookie.settings.COOKIECLOUD_AUTH_HEADER = " secret-token " + settings.COOKIECLOUD_AUTH_HEADER = " secret-token " async with make_client(cookiecloud_app) as client: response = await client.post( @@ -85,7 +84,7 @@ async def test_update_allows_matching_auth_header(cookiecloud_app): @pytest.mark.parametrize("headers", [{}, {"X-CookieCloud-Auth": "wrong"}]) async def test_update_rejects_missing_or_wrong_auth_header(cookiecloud_app, headers): - servcookie.settings.COOKIECLOUD_AUTH_HEADER = "secret-token" + settings.COOKIECLOUD_AUTH_HEADER = "secret-token" async with make_client(cookiecloud_app) as client: response = await client.post( @@ -99,7 +98,7 @@ async def test_update_rejects_missing_or_wrong_auth_header(cookiecloud_app, head async def test_get_routes_do_not_require_auth_header(cookiecloud_app, monkeypatch): - servcookie.settings.COOKIECLOUD_AUTH_HEADER = "secret-token" + settings.COOKIECLOUD_AUTH_HEADER = "secret-token" async def load_encrypt_data(uuid): assert uuid == "abcde" diff --git a/tests/test_db_oper_layer.py b/tests/test_db_oper_layer.py index ef1342643..ac982faa5 100644 --- a/tests/test_db_oper_layer.py +++ b/tests/test_db_oper_layer.py @@ -6,6 +6,7 @@ Oper 层大多是模型方法的薄封装,但薄封装恰恰是最容易出错 验证 Oper 的对外契约,而不是验证它调了哪个模型方法。 """ import asyncio +from unittest.mock import Mock import pytest @@ -31,6 +32,18 @@ from app.schemas.types import MediaSource, MediaType TMDB = str(MediaSource.TMDB) +def test_oper_with_explicit_session_does_not_commit_caller_transaction(db, monkeypatch): + """显式会话写入只暂存,提交权必须留给 Application UoW。""" + commit = Mock(wraps=db.session.commit) + monkeypatch.setattr(db.session, "commit", commit) + + UserOper(db=db.session).add(name="op-uow-owner", hashed_password="x") + + assert User.get_by_name(db.session, "op-uow-owner") is not None + commit.assert_not_called() + db.session.rollback() + + @pytest.fixture(autouse=True) def _track(db): """把本文件涉及的表纳入用例级回收。""" @@ -89,6 +102,7 @@ def test_site_oper_async_accessors_match_sync(db): oper = SiteOper(db=db.session) oper.add(**_site_kwargs("异步站点", "op-async.test")) site = oper.get_by_domain("op-async.test") + db.session.commit() assert asyncio.run(oper.async_get(site.id)).id == site.id assert asyncio.run(oper.async_get_by_domain("op-async.test")).id == site.id @@ -164,6 +178,7 @@ def test_site_oper_userdata_readers(db): oper = SiteOper(db=db.session) oper.update_userdata("op-read.test", "站点", {"upload": 50}) today = oper.get_userdata_by_domain("op-read.test")[0].updated_day + db.session.commit() assert any(r.domain == "op-read.test" for r in oper.get_userdata()) assert any(r.domain == "op-read.test" for r in oper.get_userdata_by_date(today)) @@ -374,6 +389,7 @@ def test_workflow_oper_event_list_and_async_accessors(db): oper = WorkflowOper(db=db.session) oper.add(**_workflow_kwargs("op-wf-event", trigger_type="event")) flow = oper.get_by_name("op-wf-event") + db.session.commit() assert {w.name for w in oper.get_event_triggered_workflows()} >= {"op-wf-event"} assert asyncio.run(oper.async_get(flow.id)).id == flow.id @@ -414,6 +430,7 @@ def test_user_oper_async_accessors_match_sync(db): oper = UserOper(db=db.session) oper.add(name="op-user-async", hashed_password="x") user = oper.get_by_name("op-user-async") + db.session.commit() assert asyncio.run(oper.async_get_by_name("op-user-async")).id == user.id assert asyncio.run(oper.async_get_by_id(user.id)).id == user.id @@ -527,6 +544,7 @@ def test_mediaserver_oper_get_item_id_and_async_twins(db): """ oper = MediaServerOper(db=db.session) oper.add(**_server_item("ms-id", media_id="5400")) + db.session.commit() assert oper.get_item_id(media_source=TMDB, media_id="5400", mtype="电影") == "ms-id" assert oper.get_item_id(media_source=TMDB, media_id="5999", mtype="电影") is None @@ -648,6 +666,7 @@ def test_downloadhistory_oper_async_delete(db): oper.add(path="/downloads/ad", type=MediaType.TV.value, title="AD", download_hash="oh-ad", date="2026-08-13 10:00:00") history = oper.get_by_hash("oh-ad") + db.session.commit() asyncio.run(oper.async_delete_history(history.id)) diff --git a/tests/test_db_oper_layer_extra.py b/tests/test_db_oper_layer_extra.py index b47b884b6..556bb3605 100644 --- a/tests/test_db_oper_layer_extra.py +++ b/tests/test_db_oper_layer_extra.py @@ -128,6 +128,7 @@ def test_transferhistory_oper_async_accessors_match_sync(db): oper = TransferHistoryOper(db=db.session) oper.add(**_transfer_kwargs("AsyncTitle", "/data/op-async.mkv")) history = oper.get_by_src("/data/op-async.mkv") + db.session.commit() assert asyncio.run(oper.async_get(history.id)).id == history.id assert [h.title for h in asyncio.run( @@ -222,6 +223,7 @@ def test_subscribe_oper_history_round_trip(db): oper.add_history(name="历史剧", type=MediaType.TV.value, media_source=TMDB, media_id="2300", season=1, date="2026-08-13 10:00:00", username="op-alice", best_version=False) + db.session.commit() assert oper.exist_history(media_source=MediaSource.TMDB, media_id="2300", season=1) is True diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index a388166cd..523c70f6c 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -321,13 +321,12 @@ def test_save_subtitle_response_creates_missing_temp_directory(monkeypatch, tmp_ temp_path = tmp_path / "missing-temp" assert not temp_path.exists() - monkeypatch.setattr( - download_module, - "settings", - SimpleNamespace(TEMP_PATH=temp_path, RMT_SUBEXT=settings.RMT_SUBEXT), - ) monkeypatch.setattr(download_module, "StorageChain", lambda: storage_chain) chain = DownloadChain.__new__(DownloadChain) + chain.runtime_config = SimpleNamespace( + temporary_path=temp_path, + subtitle_extensions=tuple(settings.RMT_SUBEXT), + ) subtitle = SubtitleInfo( title="Demo Movie", enclosure="https://example.test/subtitle.srt", @@ -363,15 +362,14 @@ def test_save_subtitle_response_accepts_rar_filename_from_header(monkeypatch, tm extract_dir.mkdir(parents=True, exist_ok=True) extracted_subtitle.write_text("subtitle", encoding="utf-8") - monkeypatch.setattr( - download_module, - "settings", - SimpleNamespace(TEMP_PATH=temp_path, RMT_SUBEXT=settings.RMT_SUBEXT), - ) monkeypatch.setattr(download_module, "StorageChain", lambda: storage_chain) monkeypatch.setattr(download_module.SystemUtils, "unpack_archive", fake_unpack_archive) chain = DownloadChain.__new__(DownloadChain) + chain.runtime_config = SimpleNamespace( + temporary_path=temp_path, + subtitle_extensions=tuple(settings.RMT_SUBEXT), + ) subtitle = SubtitleInfo( title="Hypnosis", enclosure="https://audiences.me/downloadsubs.php?torrentid=666519&subid=2195", @@ -401,14 +399,13 @@ def test_save_subtitle_response_rejects_unsupported_filename_from_header(monkeyp headers={"content-disposition": 'attachment; filename="error.html"'}, ) - monkeypatch.setattr( - download_module, - "settings", - SimpleNamespace(TEMP_PATH=temp_path, RMT_SUBEXT=settings.RMT_SUBEXT), - ) monkeypatch.setattr(download_module, "StorageChain", lambda: storage_chain) chain = DownloadChain.__new__(DownloadChain) + chain.runtime_config = SimpleNamespace( + temporary_path=temp_path, + subtitle_extensions=tuple(settings.RMT_SUBEXT), + ) subtitle = SubtitleInfo( title="Hypnosis", enclosure="https://audiences.me/downloadsubs.php?torrentid=666519&subid=2195", diff --git a/tests/test_host_runtime_context.py b/tests/test_host_runtime_context.py index aef67fcd1..9a309e8a2 100644 --- a/tests/test_host_runtime_context.py +++ b/tests/test_host_runtime_context.py @@ -29,6 +29,7 @@ from app.application.configuration import ( ApiRuntimeConfig, ChainRuntimeConfig, RuntimeConfiguration, + RuntimeSettingsService, SchedulerRuntimeConfig, ) @@ -86,6 +87,22 @@ class _Outbox: """模拟收口 durable intent。""" +class _RuntimeSettings: + """提供 HostRuntime 设置服务所需的最小测试合同。""" + + def model_dump(self, *, include=None, exclude=None): + """返回空设置快照。""" + return {} + + def update_settings(self, env): + """返回批量更新成功结果。""" + return {key: (True, "") for key in env} + + def update_setting(self, key, value): + """返回单项更新成功结果。""" + return True, "" + + def _runtime() -> HostRuntime: """构造不加载数据库引擎或 PluginManager 的假宿主运行时。""" async def async_session(): @@ -141,6 +158,7 @@ def _runtime() -> HostRuntime: ), chain=lambda: ChainRuntimeConfig(media_extensions=(".mkv",)), ), + settings=RuntimeSettingsService(_RuntimeSettings()), ) diff --git a/tests/test_media_interaction.py b/tests/test_media_interaction.py index 3e8fcdd2e..3f6853119 100644 --- a/tests/test_media_interaction.py +++ b/tests/test_media_interaction.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from dataclasses import replace from unittest.mock import patch import pytest @@ -167,6 +168,11 @@ def test_rebuild_download_scope_keeps_special_season_zero(): def test_message_routes_text_reply_to_media_interaction_before_ai(): """已有传统媒体交互时,用户回复应优先交给传统交互处理。""" chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ai_agent_global=True, + ) request = media_interaction_manager.create_or_replace( user_id="10001", channel=NotificationChannel.Wechat, @@ -1542,6 +1548,11 @@ def test_target_plugin_filter_only_allows_target_plugin_handler(): def test_noai_prefix_starts_traditional_search_when_global_ai_enabled(): """全局 AI 开启时,/noai 前缀应让本条消息进入传统搜索交互。""" chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ai_agent_global=True, + ) meta = _build_meta("星际穿越") medias = [ MediaInfo(title="星际穿越", year="2014"), @@ -1549,10 +1560,6 @@ def test_noai_prefix_starts_traditional_search_when_global_ai_enabled(): ] with patch.object(chain, "_record_user_message"), patch( - "app.chain.message.settings.AI_AGENT_ENABLE", True - ), patch( - "app.chain.message.settings.AI_AGENT_GLOBAL", True - ), patch( "app.chain.media.MediaChain.search", return_value=(meta, medias), ) as search_media, patch( @@ -1582,6 +1589,11 @@ def test_noai_prefix_starts_traditional_search_when_global_ai_enabled(): def test_noai_prefix_preserves_traditional_interaction_priority_after_search(): """通过 /noai 进入传统交互后,后续选择应继续优先走传统交互。""" chain = MessageChain() + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ai_agent_global=True, + ) request = media_interaction_manager.create_or_replace( user_id="10001", channel=NotificationChannel.Wechat, @@ -1596,10 +1608,6 @@ def test_noai_prefix_preserves_traditional_interaction_priority_after_search(): assert request is not None with patch.object(chain, "_record_user_message"), patch( - "app.chain.message.settings.AI_AGENT_ENABLE", True - ), patch( - "app.chain.message.settings.AI_AGENT_GLOBAL", True - ), patch( "app.chain.interaction.MediaInteractionChain.handle_text_interaction", return_value=True, ) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai: diff --git a/tests/test_mediascrape.py b/tests/test_mediascrape.py index 0ab617f5c..a762ba6a6 100644 --- a/tests/test_mediascrape.py +++ b/tests/test_mediascrape.py @@ -1,5 +1,6 @@ import sys import unittest +from dataclasses import replace from pathlib import Path from unittest.mock import patch, MagicMock # ruff: noqa: E402 @@ -414,8 +415,7 @@ class TestMediaScrapingImages(unittest.TestCase): @patch("app.chain.scraping.RequestUtils") @patch("app.chain.scraping.NamedTemporaryFile") @patch("app.chain.scraping.Path.chmod") - @patch("app.chain.scraping.settings") - def test_download_and_save_image(self, mock_settings, mock_chmod, mock_temp_file, mock_request_utils): + def test_download_and_save_image(self, mock_chmod, mock_temp_file, mock_request_utils): # We need to test _download_and_save_image directly so we remove mock self.media_chain = ScrapingChain() self.media_chain._download_and_save_image = self.original_download @@ -442,7 +442,10 @@ class TestMediaScrapingImages(unittest.TestCase): self.media_chain._download_and_save_image(fileitem, target_path, url) - mock_request_utils.assert_called_with(proxies=mock_settings.PROXY, ua=mock_settings.NORMAL_USER_AGENT) + mock_request_utils.assert_called_with( + proxies=self.media_chain.runtime_config.proxy, + ua=self.media_chain.runtime_config.normal_user_agent, + ) mock_instance.get_stream.assert_called_with(url=url) mock_temp_file.assert_called_once_with(delete=False, suffix=".jpg") tmp_mock.write.assert_any_call(b"data1") @@ -489,10 +492,12 @@ class TestMediaScrapingTVDirectory(unittest.TestCase): def tearDown(self): reset_scraping_chain_singleton() - @patch("app.chain.media.settings") - def test_initialize_tv_directory_specials(self, mock_settings): + def test_initialize_tv_directory_specials(self): # mock specials directory recognition - mock_settings.RENAME_FORMAT_S0_NAMES = ["Specials", "SPs"] + self.media_chain.runtime_config = replace( + self.media_chain.runtime_config, + season_zero_names=("Specials", "SPs"), + ) fileitem = schemas.FileItem(path="/tv/Show/Specials", name="Specials", type="dir", storage="local") meta = MetaInfo("Show") @@ -525,9 +530,11 @@ class TestMediaScrapingTVDirectory(unittest.TestCase): season_number=0 ) - @patch("app.chain.media.settings") - def test_initialize_tv_directory_season(self, mock_settings): - mock_settings.RENAME_FORMAT_S0_NAMES = ["Specials", "SPs"] + def test_initialize_tv_directory_season(self): + self.media_chain.runtime_config = replace( + self.media_chain.runtime_config, + season_zero_names=("Specials", "SPs"), + ) fileitem = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local") meta = MetaInfo("Show") diff --git a/tests/test_module_quality.py b/tests/test_module_quality.py index 6891ddd1e..e5a82f7e9 100644 --- a/tests/test_module_quality.py +++ b/tests/test_module_quality.py @@ -14,7 +14,7 @@ MODULE_ROOT = Path(__file__).parents[1] / "app" / "modules" def test_every_module_has_quality_view_with_owner_and_reason() -> None: - """所有存量模块都必须能解析为 assessed 或有理由的 legacy profile。""" + """所有宿主模块都必须显式完成 assessed 登记,未知扩展才允许 legacy。""" modules = { path.name for path in MODULE_ROOT.iterdir() @@ -22,12 +22,16 @@ def test_every_module_has_quality_view_with_owner_and_reason() -> None: } assert modules + assert modules == set(MODULE_QUALITY_PROFILES) for module in modules: profile = get_module_quality_profile(module) assert profile.owner - assert profile.level in ModuleQualityLevel - if profile.level is ModuleQualityLevel.LEGACY: - assert profile.exemption_reason + assert profile.level is ModuleQualityLevel.ASSESSED + assert profile.exemption_reason + + assert get_module_quality_profile("third-party-unknown").level is ( + ModuleQualityLevel.LEGACY + ) def test_assessed_profiles_only_use_declared_rules() -> None: diff --git a/tests/test_music_plugin_recognize.py b/tests/test_music_plugin_recognize.py index 2b994c2c3..41b7df94b 100644 --- a/tests/test_music_plugin_recognize.py +++ b/tests/test_music_plugin_recognize.py @@ -194,8 +194,7 @@ def test_plugin_first_keeps_fallback_when_help_unidentified(monkeypatch): "name": "另一个晴天", }) with patch("app.chain.media.eventmanager") as em, \ - patch("app.chain.media.settings") as settings_mock: - settings_mock.RECOGNIZE_PLUGIN_FIRST = True + patch("app.runtime.config.settings.RECOGNIZE_PLUGIN_FIRST", True): em.check.return_value = True em.send_event.return_value = event result = chain.recognize_by_meta(meta) diff --git a/tests/test_music_recognize_routing.py b/tests/test_music_recognize_routing.py index 8004e52dc..5e33e9044 100644 --- a/tests/test_music_recognize_routing.py +++ b/tests/test_music_recognize_routing.py @@ -365,7 +365,9 @@ def test_media_chain_converts_recognized_music_metadata_without_mutating_source( source_chain.recognize_music.return_value = source_info chain = MediaChain() monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain)) - monkeypatch.setattr("app.chain.media.settings.MUSIC_METADATA_TO_SIMPLIFIED", True) + monkeypatch.setattr( + "app.runtime.config.settings.MUSIC_METADATA_TO_SIMPLIFIED", True + ) result = chain.recognize_music_from_source( media_source="musicbrainz", @@ -397,7 +399,9 @@ def test_media_chain_preserves_original_music_metadata_when_conversion_disabled( source_chain.recognize_music.return_value = source_info chain = MediaChain() monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain)) - monkeypatch.setattr("app.chain.media.settings.MUSIC_METADATA_TO_SIMPLIFIED", False) + monkeypatch.setattr( + "app.runtime.config.settings.MUSIC_METADATA_TO_SIMPLIFIED", False + ) result = chain.recognize_music_from_source( media_source="musicbrainz", @@ -427,7 +431,9 @@ def test_music_path_fallback_converts_local_tag_metadata(monkeypatch): Mock(return_value=None), ) monkeypatch.setattr(chain, "recognize_media", Mock(return_value=None)) - monkeypatch.setattr("app.chain.media.settings.MUSIC_METADATA_TO_SIMPLIFIED", True) + monkeypatch.setattr( + "app.runtime.config.settings.MUSIC_METADATA_TO_SIMPLIFIED", True + ) _, result = chain.recognize_music_by_path("track.flac") diff --git a/tests/test_music_search.py b/tests/test_music_search.py index e5d2b209f..621d0108f 100644 --- a/tests/test_music_search.py +++ b/tests/test_music_search.py @@ -1,4 +1,5 @@ import asyncio +from dataclasses import replace from unittest.mock import Mock, patch from app.chain.search import SearchChain @@ -134,6 +135,10 @@ def test_music_search_matches_artist_from_resource_description(): def test_music_stream_reports_site_progress_before_final_results(monkeypatch): """精确音乐搜索应逐站点输出进度事件,不能等待全部搜索完成后才返回。""" chain = SearchChain() + chain.runtime_config = replace( + chain.runtime_config, + search_multiple_name=False, + ) music = MusicInfo( media_source="musicbrainz", media_id="recording-1", @@ -194,8 +199,7 @@ def test_music_stream_reports_site_progress_before_final_results(monkeypatch): Mock(side_effect=AssertionError("音乐流式搜索不应回退到非流式站点搜索")), ) - with patch("app.chain.search.settings.SEARCH_MULTIPLE_NAME", False): - events = asyncio.run(collect_events()) + events = asyncio.run(collect_events()) assert [event["value"] for event in events[:2]] == [50, 100] assert [event["finished"] for event in events[:2]] == [1, 2] diff --git a/tests/test_music_torrents.py b/tests/test_music_torrents.py index 1a4d75051..2b69ebedb 100644 --- a/tests/test_music_torrents.py +++ b/tests/test_music_torrents.py @@ -386,16 +386,12 @@ def test_music_cache_not_evicted_by_video_torrents(): fake_settings.CONF = SimpleNamespace(torrents=2, refresh=5) fake_settings.NO_CACHE_SITE_KEY = "no-cache-site.invalid" + chain.runtime_config = SimpleNamespace( + torrent_cache_size=fake_settings.CONF.torrents, + refresh_batch_size=fake_settings.CONF.refresh, + no_cache_site_key=fake_settings.NO_CACHE_SITE_KEY, + ) with ( - patch.object( - chain, - "runtime_config", - SimpleNamespace( - torrent_cache_size=fake_settings.CONF.torrents, - refresh_batch_size=fake_settings.CONF.refresh, - no_cache_site_key=fake_settings.NO_CACHE_SITE_KEY, - ), - ), patch.object(chain, "load_cache", side_effect=_fake_load), patch.object(chain, "browse", side_effect=_fake_browse), patch.object(chain, "save_cache", save_cache), diff --git a/tests/test_plugin_backup_restore.py b/tests/test_plugin_backup_restore.py index ecb79444e..d23ad2467 100644 --- a/tests/test_plugin_backup_restore.py +++ b/tests/test_plugin_backup_restore.py @@ -1,6 +1,7 @@ """插件持久化备份与 Docker 重置恢复合同测试。""" import errno +from dataclasses import replace from pathlib import Path from types import SimpleNamespace @@ -15,10 +16,15 @@ def _patch_docker_paths(monkeypatch, tmp_path: Path, *, reset: bool) -> Path: runtime_dir = tmp_path / "app" / "plugins" config_dir.mkdir(parents=True) runtime_dir.mkdir(parents=True) + runtime_config = replace( + system_module.get_chain_runtime_config_snapshot(), + root_path=tmp_path, + config_path=config_dir, + ) monkeypatch.setattr( system_module, - "settings", - SimpleNamespace(ROOT_PATH=tmp_path, CONFIG_PATH=config_dir), + "get_chain_runtime_config_snapshot", + lambda: runtime_config, ) monkeypatch.setattr( system_module.SystemUtils, diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 0bf499db3..21ae5a7a0 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -418,14 +418,15 @@ def test_sync_plugin_market_from_wiki_merges_and_deduplicates_repos(): response = MagicMock(status_code=200, text=markdown) request_utils = MagicMock() request_utils.get_res = AsyncMock(return_value=response) + runtime_settings = MagicMock() + runtime_settings.get.return_value = "https://github.com/local/existing" + runtime_settings.update.return_value = (True, "") with ( patch("app.api.endpoints.system.AsyncRequestUtils", return_value=request_utils), - patch("app.api.endpoints.system.settings.PLUGIN_MARKET", "https://github.com/local/existing"), patch( - "app.runtime.config.Settings.update_setting", - autospec=True, - return_value=(True, ""), - ) as update_setting, + "app.api.endpoints.system.get_runtime_settings", + return_value=runtime_settings, + ), patch("app.api.endpoints.system.eventmanager.async_send_event", new=AsyncMock()) as send_event, ): result = asyncio.run(sync_plugin_market_from_wiki(None, None)) @@ -437,8 +438,7 @@ def test_sync_plugin_market_from_wiki_merges_and_deduplicates_repos(): ] assert result.data["added_count"] == 1 assert result.data["total_count"] == 2 - update_setting.assert_called_once_with( - ANY, + runtime_settings.update.assert_called_once_with( "PLUGIN_MARKET", "https://github.com/local/existing,https://github.com/wiki/new-repo", ) @@ -534,8 +534,8 @@ def test_virtual_instance_static_file_reads_from_source_directory(tmp_path, monk monkeypatch.setattr(plugin_endpoint, "PluginManager", lambda: plugin_manager) monkeypatch.setattr( plugin_endpoint, - "settings", - MagicMock(ROOT_PATH=tmp_path), + "get_api_runtime_config_snapshot", + lambda: MagicMock(root_path=tmp_path), ) response = asyncio.run( diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 363be52d3..403e7136a 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -1,7 +1,7 @@ import asyncio from types import SimpleNamespace from unittest import TestCase -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import ValidationError @@ -680,6 +680,7 @@ class SubscribeEndpointTest(TestCase): other = _EndpointSubscribe(id=21, username="bob") own = _EndpointSubscribe(id=22, username="alice") created = SimpleNamespace(async_create=AsyncMock()) + session = SimpleNamespace(add=MagicMock(), flush=AsyncMock()) with patch("app.db.oper.subscribe.Subscribe") as subscribe_model: subscribe_model.async_exists = AsyncMock(return_value=other) @@ -690,7 +691,9 @@ class SubscribeEndpointTest(TestCase): sid, message = asyncio.run( async_add_subscribe( - subscribe_oper=SubscribeOper(db=object()), + subscribe_oper=SubscribeOper( + db=session + ), mediainfo=_EndpointMediaInfo(), username="alice", owner_scope=True, @@ -702,7 +705,8 @@ class SubscribeEndpointTest(TestCase): self.assertEqual(message, "新增订阅成功") subscribe_model.async_exists.assert_not_awaited() self.assertEqual(subscribe_model.async_exists_by_username.await_count, 2) - created.async_create.assert_awaited_once() + session.add.assert_called_once_with(created) + session.flush.assert_awaited_once_with() def test_subscribe_history_scopes_regular_user_and_keeps_superuser_global(self): """ diff --git a/tests/test_subscribe_oper.py b/tests/test_subscribe_oper.py index a63dfa4ec..a6fa14566 100644 --- a/tests/test_subscribe_oper.py +++ b/tests/test_subscribe_oper.py @@ -21,12 +21,17 @@ def _add(**kwargs): 钉的是查重语义(谁被查、查几次、带哪些身份字段),所以从翻译入口进、把不带真会话 的 Oper 注进去,两层的契约一次跑通。 """ - return add_subscribe(subscribe_oper=SubscribeOper(db=object()), **kwargs) + return add_subscribe(subscribe_oper=SubscribeOper(db=MagicMock()), **kwargs) async def _async_add(**kwargs): """异步写入路径,与 _add 共用注入方式。""" - return await async_add_subscribe(subscribe_oper=SubscribeOper(db=object()), **kwargs) + session = MagicMock() + session.flush = AsyncMock() + return await async_add_subscribe( + subscribe_oper=SubscribeOper(db=session), + **kwargs, + ) def _media(episode_group): @@ -51,18 +56,19 @@ def test_add_history_converts_boolean_integer_flags(monkeypatch): """ captured = {} - def fake_create(self, _db): + def fake_stage_create(_oper, model): """ 截获待写入模型,避免测试依赖具体数据库方言的类型宽松行为。 """ captured.update({ - "id": self.id, - "best_version": self.best_version, - "best_version_full": self.best_version_full, - "search_imdbid": self.search_imdbid, + "id": model.id, + "best_version": model.best_version, + "best_version_full": model.best_version_full, + "search_imdbid": model.search_imdbid, }) + return model - monkeypatch.setattr(SubscribeHistory, "create", fake_create) + monkeypatch.setattr(SubscribeOper, "_stage_create", fake_stage_create) SubscribeOper().add_history( id=100, @@ -103,7 +109,6 @@ def test_add_scopes_duplicate_lookup_by_episode_group(episode_group): call.kwargs["episode_group"] == episode_group for call in subscribe_model.exists.call_args_list ) - created.create.assert_called_once() # 媒体身份的三种残缺形态。守卫写的是 ``not media_source or not media_id``——只测「两者都空」 @@ -164,7 +169,6 @@ def test_add_reports_failure_when_the_new_subscribe_cannot_be_read_back(): result = _add(mediainfo=_media(None), season=1) assert result == (0, "新增订阅失败") - created.create.assert_called_once() def test_async_add_reports_failure_when_the_new_subscribe_cannot_be_read_back(): @@ -178,7 +182,6 @@ def test_async_add_reports_failure_when_the_new_subscribe_cannot_be_read_back(): mediainfo=_media(None), season=1)) assert result == (0, "新增订阅失败") - created.async_create.assert_awaited_once() def test_add_reports_existing_subscription_without_creating(): @@ -333,7 +336,6 @@ def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group): call.kwargs["episode_group"] == episode_group for call in subscribe_model.async_exists.await_args_list ) - created.async_create.assert_awaited_once() def test_owner_scoped_add_forwards_episode_group_sync_and_async(): diff --git a/tests/test_system_database_backup_config.py b/tests/test_system_database_backup_config.py index a56d41150..dc9375d03 100644 --- a/tests/test_system_database_backup_config.py +++ b/tests/test_system_database_backup_config.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from app.api.endpoints import system as system_endpoint +from app.runtime.config import settings @pytest.mark.parametrize( @@ -75,7 +76,7 @@ def test_set_env_rejects_invalid_database_backup_policy_without_partial_write() system_endpoint, "_validate_llm_server_tool_config", return_value=None, - ), patch.object(type(system_endpoint.settings), "update_settings") as update_settings: + ), patch.object(type(settings), "update_settings") as update_settings: response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object())) assert response.success is False @@ -85,7 +86,7 @@ def test_set_env_rejects_invalid_database_backup_policy_without_partial_write() def test_database_backup_default_path_tracks_config_directory(tmp_path, monkeypatch) -> None: """未显式配置目录时应跟随当前配置根,而不是写死 Docker 路径。""" - monkeypatch.setattr(system_endpoint.settings, "CONFIG_DIR", str(tmp_path)) - monkeypatch.setattr(system_endpoint.settings, "DB_BACKUP_PATH", None) + monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "DB_BACKUP_PATH", None) - assert system_endpoint.settings.DATABASE_BACKUP_PATH == tmp_path / "database_backup" + assert settings.DATABASE_BACKUP_PATH == tmp_path / "database_backup" diff --git a/tests/test_system_llm_web_search_config.py b/tests/test_system_llm_web_search_config.py index c87b79aad..c7bc9d00b 100644 --- a/tests/test_system_llm_web_search_config.py +++ b/tests/test_system_llm_web_search_config.py @@ -4,6 +4,7 @@ import asyncio from unittest.mock import patch from app.api.endpoints import system as system_endpoint +from app.runtime.config import settings def test_set_env_rejects_unsupported_builtin_web_search() -> None: @@ -15,7 +16,7 @@ def test_set_env_rejects_unsupported_builtin_web_search() -> None: "LLM_WEB_SEARCH_MODE": "builtin", } - with patch.object(type(system_endpoint.settings), "update_settings") as update_settings: + with patch.object(type(settings), "update_settings") as update_settings: response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object())) assert response.success is False @@ -33,7 +34,7 @@ def test_set_env_accepts_supported_deepseek_builtin_web_search() -> None: } with patch.object( - type(system_endpoint.settings), + type(settings), "update_settings", return_value={key: (True, None) for key in env}, ) as update_settings, patch.object( diff --git a/tests/test_system_nettest.py b/tests/test_system_nettest.py index 10ac9a46c..6dc191233 100644 --- a/tests/test_system_nettest.py +++ b/tests/test_system_nettest.py @@ -4,6 +4,7 @@ import unittest from types import ModuleType, SimpleNamespace from unittest.mock import AsyncMock, Mock, patch +from app.runtime.config import settings as runtime_settings from app.testing import stub_modules @@ -151,7 +152,7 @@ class NettestSecurityTest(unittest.TestCase): "_hostname_addresses_async", new=AsyncMock(return_value=[ipaddress.ip_address("198.18.16.96")]), ), patch.object( - system_endpoint.settings, + runtime_settings, "IMAGE_PROXY_ALLOWED_PRIVATE_RANGES", ["198.18.0.0/15"], ), patch( @@ -241,7 +242,7 @@ class NettestSecurityTest(unittest.TestCase): ) with patch.object(system_endpoint, "AsyncRequestUtils", FakeAsyncRequestUtils), patch.object( - system_endpoint.settings, + runtime_settings, "GITHUB_PROXY", "https://ghproxy.example/", ): @@ -313,7 +314,7 @@ class NettestSecurityTest(unittest.TestCase): return SimpleNamespace(status_code=200, text="MoviePilot README") with patch.object(system_endpoint, "AsyncRequestUtils", FakeAsyncRequestUtils), patch.object( - system_endpoint.settings, + runtime_settings, "GITHUB_PROXY", "https://ghproxy.example/", ): @@ -343,7 +344,7 @@ class NettestSecurityTest(unittest.TestCase): return SimpleNamespace(status_code=200, text="proxy landing page") with patch.object(system_endpoint, "AsyncRequestUtils", FakeAsyncRequestUtils), patch.object( - system_endpoint.settings, + runtime_settings, "PIP_PROXY", "https://pypi.tuna.tsinghua.edu.cn/simple/", ): diff --git a/tests/test_telegram_typing_lifecycle.py b/tests/test_telegram_typing_lifecycle.py index 25a96354d..31939f58d 100644 --- a/tests/test_telegram_typing_lifecycle.py +++ b/tests/test_telegram_typing_lifecycle.py @@ -2,6 +2,7 @@ import asyncio import threading import time import unittest +from dataclasses import replace from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -257,12 +258,14 @@ class TestTelegramTypingLifecycle(unittest.TestCase): def test_async_agent_leaves_processing_status_to_worker(self): chain = MessageChain.__new__(MessageChain) + chain.runtime_config = replace( + chain.runtime_config, + ai_agent_enable=True, + ) with patch.object(chain, "_record_user_message"), patch.object( chain, "_mark_message_processing_started" ) as start_status, patch( - "app.chain.message.settings.AI_AGENT_ENABLE", True - ), patch( "app.chain.message.get_running_agent_manager", ) as get_running_manager, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", diff --git a/tests/test_transfer_failed_retry_buttons.py b/tests/test_transfer_failed_retry_buttons.py index bbc4c1f35..4d98edb3f 100644 --- a/tests/test_transfer_failed_retry_buttons.py +++ b/tests/test_transfer_failed_retry_buttons.py @@ -1,6 +1,7 @@ import unittest import asyncio import sys +from dataclasses import replace from types import ModuleType from types import SimpleNamespace from unittest.mock import patch @@ -101,6 +102,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase): def test_transfer_ai_retry_callback_schedules_agent_takeover(self): chain = TransferChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True) history = SimpleNamespace( id=34, status=False, @@ -159,6 +161,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase): def test_transfer_ai_retry_callback_uses_successful_move_dest_as_source(self): chain = TransferChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True) captured = {} history = SimpleNamespace( id=35, diff --git a/tests/test_transfer_failure_notification_aggregation.py b/tests/test_transfer_failure_notification_aggregation.py index 0a0e80ca4..868655da8 100644 --- a/tests/test_transfer_failure_notification_aggregation.py +++ b/tests/test_transfer_failure_notification_aggregation.py @@ -1,4 +1,5 @@ from unittest.mock import Mock +from types import SimpleNamespace from app.chain import transfer as transfer_module from app.chain.transfer import TransferChain @@ -114,6 +115,7 @@ def test_aggregator_debounces_same_group_and_flushes_once(): def test_aggregated_message_contains_count_reason_stats_and_batch_entry(): """聚合消息应给出失败数、原因统计、历史 ID 和批量处理入口。""" chain = object.__new__(TransferChain) + chain.runtime_config = SimpleNamespace(history_url="#/history") sent = [] chain.post_message = sent.append notices = [ @@ -132,13 +134,16 @@ def test_aggregated_message_contains_count_reason_stats_and_batch_entry(): assert "整理记录:#11、#12、#13" in message.text assert message.buttons == [[{ "text": "批量处理", - "url": transfer_module.settings.MP_DOMAIN("#/history"), + "url": "#/history", }]] -def test_enabled_queue_uses_shared_group_key(monkeypatch): +def test_enabled_queue_uses_shared_group_key(): """开启聚合后公开通知入口应投递到聚合器而不是立即发送。""" chain = object.__new__(TransferChain) + chain.runtime_config = SimpleNamespace( + transfer_failure_notification_aggregation=True, + ) chain.failure_notification_aggregator = Mock() chain.post_message = Mock() task = _task(episode=1) @@ -149,11 +154,6 @@ def test_enabled_queue_uses_shared_group_key(monkeypatch): transfer_type="copy", ) loop = transfer_module.global_vars.loop - monkeypatch.setattr( - transfer_module.settings, - "TRANSFER_FAILURE_NOTIFICATION_AGGREGATION", - True, - ) chain.queue_failed_transfer_notification( task=task, transferinfo=transferinfo, diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index c82a71bf1..390ec1a8d 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -856,9 +856,9 @@ class TransferJobManagerTest(unittest.TestCase): "app.chain.transfer.add_transfer_fail", lambda **kwargs: SimpleNamespace(id=1), ), patch( - "app.chain.transfer.settings.AI_AGENT_ENABLE", False + "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( - "app.chain.transfer.settings.AI_AGENT_RETRY_TRANSFER", False + "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): state, _ = chain._TransferChain__default_callback(task, failed_transferinfo) @@ -928,9 +928,9 @@ class TransferJobManagerTest(unittest.TestCase): ), patch( "app.chain.transfer.MediaChain" ) as media_chain_cls, patch( - "app.chain.transfer.settings.AI_AGENT_ENABLE", False + "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( - "app.chain.transfer.settings.AI_AGENT_RETRY_TRANSFER", False + "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): media_chain_cls.return_value.recognize_by_meta.return_value = None state, errmsg = chain._TransferChain__handle_transfer(task) @@ -970,9 +970,9 @@ class TransferJobManagerTest(unittest.TestCase): ), patch( "app.chain.transfer.MediaChain" ) as media_chain_cls, patch( - "app.chain.transfer.settings.AI_AGENT_ENABLE", False + "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( - "app.chain.transfer.settings.AI_AGENT_RETRY_TRANSFER", False + "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): media_chain_cls.return_value.recognize_by_meta.return_value = None state, errmsg = chain._TransferChain__handle_transfer(task) @@ -1013,9 +1013,9 @@ class TransferJobManagerTest(unittest.TestCase): ), patch( "app.chain.transfer.MediaChain" ) as media_chain_cls, patch( - "app.chain.transfer.settings.AI_AGENT_ENABLE", False + "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( - "app.chain.transfer.settings.AI_AGENT_RETRY_TRANSFER", False + "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): media_chain_cls.return_value.recognize_by_meta.return_value = None chain._TransferChain__handle_transfer(task) diff --git a/tests/test_transfer_overwrite_declined.py b/tests/test_transfer_overwrite_declined.py index 8bb91b620..f3114fd2b 100644 --- a/tests/test_transfer_overwrite_declined.py +++ b/tests/test_transfer_overwrite_declined.py @@ -159,9 +159,9 @@ def test_default_callback_skips_history_and_notification_when_overwrite_declined "app.chain.transfer.add_transfer_fail", make_fail_recorder(add_fail_calls), ), patch( - "app.chain.transfer.settings.AI_AGENT_ENABLE", False + "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( - "app.chain.transfer.settings.AI_AGENT_RETRY_TRANSFER", False + "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): state, errmsg = chain._TransferChain__default_callback(task, transferinfo) @@ -205,9 +205,9 @@ def test_default_callback_keeps_original_failure_semantics_without_success_histo "app.chain.transfer.add_transfer_fail", make_fail_recorder(add_fail_calls), ), patch( - "app.chain.transfer.settings.AI_AGENT_ENABLE", False + "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( - "app.chain.transfer.settings.AI_AGENT_RETRY_TRANSFER", False + "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): state, errmsg = chain._TransferChain__default_callback(task, transferinfo) @@ -257,7 +257,7 @@ def test_default_callback_delegates_primary_failure_to_durable_writer(): "app.chain.transfer.add_transfer_fail", make_fail_recorder(add_fail_calls), ), patch( - "app.chain.transfer.settings.AI_AGENT_ENABLE", + "app.runtime.config.settings.AI_AGENT_ENABLE", False, ): state, errmsg = chain._TransferChain__default_callback(task, transferinfo) diff --git a/tests/test_type_gate.py b/tests/test_type_gate.py index 15ef77a15..20ae1b9de 100644 --- a/tests/test_type_gate.py +++ b/tests/test_type_gate.py @@ -24,15 +24,19 @@ def test_mypy_gate_has_explicit_strict_scope_without_global_ignore() -> None: assert "app/runtime/event/contracts.py" in governed_files assert "app/runtime/extensions/module/contracts.py" in governed_files assert "app/runtime/extensions/module/dispatcher.py" in governed_files + assert "app/runtime/extensions/module/quality.py" in governed_files assert "app/runtime/event/errors.py" in governed_files assert "app/application/scheduling.py" in governed_files assert "scripts/architecture/async_blocking.py" in governed_files assert "app/startup/context.py" in governed_files + assert "app/startup/configuration.py" in governed_files assert "app/startup/download_failure.py" in governed_files assert "app/startup/workflow.py" in governed_files assert "app/application/workflow.py" in governed_files assert "app/api/context.py" in governed_files - assert len(governed_files) >= 26 + assert "app/db/base.py" in governed_files + assert "app/db/uow.py" in governed_files + assert len(governed_files) >= 37 assert any(path.startswith("app/domain/") for path in governed_files) assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8")