refactor: complete runtime configuration migration

This commit is contained in:
jxxghp
2026-08-22 19:07:07 +08:00
parent d131f8d571
commit 1bb6e36e8c
80 changed files with 1432 additions and 581 deletions
+5 -3
View File
@@ -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))
+7 -4
View File
@@ -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:
+10 -5
View File
@@ -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)
+7 -2
View File
@@ -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:
+5 -3
View File
@@ -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="授权失败",
+74 -44
View File
@@ -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,
)
+5 -2
View File
@@ -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,
)
+7 -5
View File
@@ -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():
+167
View File
@@ -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()
+17
View File
@@ -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:
"""
加载缓存
+13 -10
View File
@@ -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,
)
)
+36 -22
View File
@@ -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,
))
+6 -6
View File
@@ -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()
+19 -9
View File
@@ -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]
+19 -11
View File
@@ -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)
+43 -22
View File
@@ -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]:
"""
+17 -14
View File
@@ -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:
# 根据开始结束集计算集信息
+14 -11
View File
@@ -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:
+17 -17
View File
@@ -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,
+111 -22
View File
@@ -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)
+19 -5
View File
@@ -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个参数
"""
+9 -9
View File
@@ -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(
+7 -7
View File
@@ -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)
+3 -3
View File
@@ -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):
+1 -1
View File
@@ -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]:
"""
+7 -7
View File
@@ -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:
"""
+24 -22
View File
@@ -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)
))
+7 -7
View File
@@ -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,
+1 -1
View File
@@ -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)
+5 -5
View File
@@ -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
+4 -4
View File
@@ -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]:
"""
+1 -1
View File
@@ -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]:
"""
+3 -3
View File
@@ -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:
"""
+1 -1
View File
@@ -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, "工作流已存在"
+20 -5
View File
@@ -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空结果改用独立的短 TTLempty_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):
+70 -1
View File
@@ -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:
+144
View File
@@ -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,
)
+2 -1
View File
@@ -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
+14
View File
@@ -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:
+14 -83
View File
@@ -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()
+5 -2
View File
@@ -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 持有 UoWOper 的 `stage_*` 方法只修改当前会话。插件数据重置从
`startup/plugins_initializer.py` 创建独占会话,插件直接使用 `PluginDataOper` 的旧 ABI 仅作兼容。
@@ -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`
> 实施进度:阶段 0ARCH-201203)、阶段 1ARCH-210212)、阶段 2ARCH-220222)与阶段 3ARCH-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` 均为 0Oper 自建 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
scannerstrict 清单扩大到 26 个源文件;已登记范围保持零错误,未使用全文件 ignore 或 `cast(Any, ...)`
收尾批次继续纳管 Startup 配置快照、Module quality、Compat manifest/diagnostics、插件运行时窄端口、
Outbox adapter、DB 装饰器、Base 与 UoWstrict 清单扩大到 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 可恢复、幂等、可查询失败和重试次数 |
+10 -6
View File
@@ -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` 状态。
+11
View File
@@ -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,
+12 -15
View File
@@ -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()))
@@ -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",
+27 -12
View File
@@ -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",
+7
View File
@@ -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")
+45 -19
View File
@@ -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()
+5
View File
@@ -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",
+7
View File
@@ -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",
+3 -1
View File
@@ -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:
@@ -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"
+35
View File
@@ -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 影响
+42
View File
@@ -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()
+13 -14
View File
@@ -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"
+19
View File
@@ -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))
+2
View File
@@ -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
+12 -15
View File
@@ -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",
+18
View File
@@ -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()),
)
+16 -8
View File
@@ -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:
+16 -9
View File
@@ -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")
+8 -4
View File
@@ -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:
+1 -2
View File
@@ -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)
+9 -3
View File
@@ -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")
+6 -2
View File
@@ -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]
+5 -9
View File
@@ -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),
+8 -2
View File
@@ -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,
+9 -9
View File
@@ -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(
+7 -3
View File
@@ -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):
"""
+14 -12
View File
@@ -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():
+5 -4
View File
@@ -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"
+3 -2
View File
@@ -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(
+5 -4
View File
@@ -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/",
):
+5 -2
View File
@@ -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",
@@ -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,
@@ -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,
+8 -8
View File
@@ -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)
+5 -5
View File
@@ -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)
+5 -1
View File
@@ -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")