mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
refactor: complete runtime configuration migration
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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个参数
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
@@ -756,6 +756,7 @@ def AsyncCache(cache_type: Literal['ttl', 'lru'] = 'ttl',
|
||||
|
||||
|
||||
def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Optional[int] = None,
|
||||
ttl_provider: Optional[Callable[[], Optional[int]]] = None,
|
||||
skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False, shared_key: Optional[str] = None,
|
||||
skip_if: Optional[Callable[[Any], bool]] = None,
|
||||
empty_ttl: Optional[int] = None, empty_if: Optional[Callable[[Any], bool]] = None):
|
||||
@@ -765,6 +766,8 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
||||
:param region: 缓存区域的标识符,默认根据模块名、函数名等自动生成标识
|
||||
:param maxsize: 缓存区内的最大条目数
|
||||
:param ttl: 缓存的存活时间,单位秒;未传入时使用 LRU 缓存
|
||||
:param ttl_provider: 每次写入时解析 TTL 的配置快照工厂;用于可热更新配置,
|
||||
与固定 ttl 互斥
|
||||
:param skip_none: 跳过 None 缓存,默认为 True
|
||||
:param skip_empty: 跳过空值缓存(如 None, [], {}, "", set()),默认为 False
|
||||
:param shared_key: 同步/异步函数共享缓存的键,默认使用函数名(异步函数名会标准化为同步格式,如移除 `async_` 前缀)
|
||||
@@ -806,6 +809,9 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
||||
return False
|
||||
return True
|
||||
|
||||
if ttl is not None and ttl_provider is not None:
|
||||
raise ValueError("cached 的 ttl 与 ttl_provider 不能同时设置")
|
||||
|
||||
def get_cache_ttl(value: Any) -> Optional[int]:
|
||||
"""
|
||||
返回写入该返回值时应使用的 TTL,空结果改用独立的短 TTL(empty_ttl)
|
||||
@@ -813,13 +819,14 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
||||
:param value: 待写入缓存的返回值
|
||||
:return: 实际使用的 TTL,单位秒
|
||||
"""
|
||||
configured_ttl = ttl_provider() if ttl_provider is not None else ttl
|
||||
if empty_ttl is None:
|
||||
return ttl
|
||||
return configured_ttl
|
||||
if value is None:
|
||||
return empty_ttl
|
||||
if empty_if is not None:
|
||||
return empty_ttl if empty_if(value) else ttl
|
||||
return empty_ttl if not value else ttl
|
||||
return empty_ttl if empty_if(value) else configured_ttl
|
||||
return empty_ttl if not value else configured_ttl
|
||||
|
||||
def is_valid_cache_value(_cache_key: str, _cached_value: Any, _cache_region: str) -> bool:
|
||||
"""
|
||||
@@ -897,7 +904,11 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
||||
|
||||
if is_async:
|
||||
# 异步函数使用异步缓存后端
|
||||
cache_backend = AsyncCache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl)
|
||||
cache_backend = AsyncCache(
|
||||
cache_type="ttl" if ttl is not None or ttl_provider is not None else "lru",
|
||||
maxsize=maxsize,
|
||||
ttl=ttl if ttl is not None else 1,
|
||||
)
|
||||
# 异步函数的缓存装饰器
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
@@ -950,7 +961,11 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
||||
return async_wrapper
|
||||
else:
|
||||
# 同步函数使用同步缓存后端
|
||||
cache_backend = Cache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl)
|
||||
cache_backend = Cache(
|
||||
cache_type="ttl" if ttl is not None or ttl_provider is not None else "lru",
|
||||
maxsize=maxsize,
|
||||
ttl=ttl if ttl is not None else 1,
|
||||
)
|
||||
# 同步函数的缓存装饰器
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -248,7 +248,9 @@ sequenceDiagram
|
||||
- **类型化请求装配**:`startup/context.py` 的 frozen slots `HostRuntime` 是 lifespan 内唯一宿主
|
||||
上下文,`api/context.py` 从 `app.state` 收窄到具体领域能力。认证、消息、历史、媒体服务器、站点、
|
||||
订阅、工作流和请求事务均使用命名 runtime 字段,不再通过字符串仓储键定位;API、Scheduler、Chain
|
||||
从 `HostRuntime.configuration` 获取 frozen 配置快照。`ApiDataPorts` 仅保留旧导入 ABI,不参与正式请求链路。
|
||||
从 `HostRuntime.configuration` 获取 frozen 配置快照。系统设置管理 API 通过
|
||||
`HostRuntime.settings` 的窄服务读写可变部署设置,业务域不接触 Settings 实例;生产与测试组合根统一
|
||||
复用 `startup/configuration.py` 的映射。`ApiDataPorts` 仅保留旧导入 ABI,不参与正式请求链路。
|
||||
- **安全模式**:`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。
|
||||
- **进程拓扑**:全功能 V3 强制 `API_WORKERS=1`,避免每个 worker 重复启动插件和后台控制面;安全模式可临时使用多 worker 诊断,但不是正式扩容方案。
|
||||
- **健康语义**:`/health/live` 只确认进程和事件循环可响应;`/health/ready` 仅在数据库
|
||||
@@ -377,7 +379,8 @@ flowchart LR
|
||||
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
|
||||
只查重、`add` 和 `flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
|
||||
`transaction-debt-baseline.json` 当前冻结 123 个只读查询装饰器;原有 45 个同步/异步写装饰器
|
||||
已全部移除,`db_update` 与 `async_db_update` 必须持续保持为 0。
|
||||
已全部移除,`db_update` 与 `async_db_update` 必须持续保持为 0。宿主 Oper 也不得调用 Base 保留的
|
||||
`create/update/delete/truncate` 兼容包装器;AST 门禁保证显式 Session 的提交权不会被底层抢走。
|
||||
- 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application
|
||||
Command/Service 持有 UoW,Oper 的 `stage_*` 方法只修改当前会话。插件数据重置从
|
||||
`startup/plugins_initializer.py` 创建独占会话,插件直接使用 `PluginDataOper` 的旧 ABI 仅作兼容。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
|
||||
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
|
||||
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
|
||||
> 实施进度:阶段 0(ARCH-201~203)、阶段 1(ARCH-210~212)、阶段 2(ARCH-220~222)与阶段 3(ARCH-230~232)已完成,后续任务按 ID 独立提交和回滚
|
||||
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;按既定范围暂不处理插件仓适配、Outbox 外围扩展和 25 个存量超长方法拆分
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
@@ -585,6 +585,13 @@ app/api/dependencies/ # 按领域拆分依赖工厂
|
||||
测试显式注入快照,不再依赖 endpoint 模块中的全局配置别名。
|
||||
- 直接调用 endpoint 和显式构造 `ChainRuntimeContext` 的旧测试/兼容入口仍有 fallback;正式 FastAPI 与
|
||||
Startup 路径始终使用 HostRuntime 注入。插件 SDK 的 `app.sdk.config.settings`、动态 API 返回和事件字段未改。
|
||||
- 收尾批次把 API 与 Chain 余下直接配置读取全部迁入类型化 snapshot;Scheduler 继续保持为零。
|
||||
`HostRuntime` 新增可变部署设置服务,只供系统设置管理 API 使用,业务 API/Chain 只接收 frozen 字段。
|
||||
snapshot 构造集中到 `app/startup/configuration.py`,生产启动与测试组合根复用同一映射,避免测试默认值
|
||||
漂移。canonical `settings` 直接导入低水位从 154 降到 137,`SystemConfigOper()` 保持 14 个。
|
||||
- `ApiRuntimeConfig` 已覆盖搜索来源、媒体/字幕/音频后缀、重命名格式、WebPush、CookieCloud、根目录和
|
||||
版本标识;`ChainRuntimeConfig` 覆盖搜索、下载、整理、刮削、AI、代理、缓存、链接、路径和 TMDB 图片域。
|
||||
元数据缓存 TTL 使用动态 provider,在保留热更新语义的同时不再让 Chain 导入全局 settings。
|
||||
|
||||
### 阶段 4:把动态模块和事件变成可演进契约
|
||||
|
||||
@@ -706,6 +713,11 @@ ModuleMethodSpec(
|
||||
- 详细规则和验收证据见 `docs/refactor/module-quality-scale.md`;自动测试阻止 profile 使用未登记规则,
|
||||
并要求今后修改模块时将对应 profile 纳入同一提交。
|
||||
|
||||
**收口记录(2026-08-22)**:39 个宿主 Module 已全部显式进入 assessed,不再以通用 fallback 把
|
||||
37 个模块标成“尚未审查”。所有模块共同由零真实网络、async 阻塞扫描、Module Contract V2 和 owner
|
||||
四项机器门禁覆盖;能力专属的鉴权、限流、并发、敏感日志与 reload/stop 仍按 profile 精确豁免,
|
||||
不会把 assessed 误读为十项满分。未知第三方模块继续使用 legacy 兼容视图,Module ABI 未变。
|
||||
|
||||
### 阶段 5:定义后台可靠性,不先引入分布式队列
|
||||
|
||||
#### ARCH-250:后台动作可靠性分类 ADR
|
||||
@@ -800,6 +812,9 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas
|
||||
的旧 Oper ABI 委托 Startup 注入的短事务执行器。当前 Model 装饰器仅剩 123 个查询装饰器,
|
||||
`db_update` 与 `async_db_update` 均为 0,Oper 自建 Session/直接提交仍为 0。
|
||||
- 数据清理按批次显式提交 UoW,单表失败先回滚会话再继续汇总后续表;不再依赖删除 Model 的隐式提交。
|
||||
- 收尾批次进一步移除宿主 Oper 对 `Base.create/update/delete/truncate` 八个兼容包装器的调用:显式
|
||||
Session 只 stage,由 Application UoW 提交;无 Session 的旧 Oper 入口才委托 Startup 的短事务执行器。
|
||||
Base 包装器继续保留给插件/旧模型 ABI,新增 AST 门禁禁止宿主 Oper 回退到隐式提交。
|
||||
|
||||
**禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。
|
||||
|
||||
@@ -926,6 +941,9 @@ Workflow 执行状态 UoW 切片将 `app/application/workflow.py` 与 `app/start
|
||||
异步安全与契约收口继续纳管 scheduling facade、Event error policy、Module dispatcher 和 async blocking
|
||||
scanner,strict 清单扩大到 26 个源文件;已登记范围保持零错误,未使用全文件 ignore 或 `cast(Any, ...)`。
|
||||
|
||||
收尾批次继续纳管 Startup 配置快照、Module quality、Compat manifest/diagnostics、插件运行时窄端口、
|
||||
Outbox adapter、DB 装饰器、Base 与 UoW,strict 清单扩大到 37 个源文件并保持零错误。
|
||||
|
||||
#### ARCH-271:复杂度和端点预算 ratchet
|
||||
|
||||
**目标**:阻止大方法继续增长,并让拆分对应真实阶段,而不是机械 helper 化。
|
||||
@@ -1133,10 +1151,10 @@ rollback:
|
||||
| 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope |
|
||||
| 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 |
|
||||
| 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 |
|
||||
| Model 事务装饰器 | 178 | 新增为 0;每迁移一个切片净减少,baseline 不增 |
|
||||
| 新写用例事务 | 部分 UoW | 100% 由入口/Application 边界拥有 Session/UoW |
|
||||
| 高频 Module 契约 | 96 个 legacy 默认 | 首批 20 个高频方法有完整参数、结果、错误、timeout 描述 |
|
||||
| Event payload | 53 类型 / 20 专用 model | 宿主 producer 使用的 EventType 100% 登记 payload 与可靠性 |
|
||||
| Model 事务装饰器 | 当前 123 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 |
|
||||
| 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW |
|
||||
| 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 |
|
||||
| Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict |
|
||||
| 超长新端点/用例 | 无增量门禁 | 新代码不越预算;旧 baseline 只降不增 |
|
||||
| Request 关联 | 无统一 ID | HTTP → Application → Module/Event/外部请求可关联 |
|
||||
| 关键后台副作用 | commit 后存在崩溃窗口 | 选定 pilot 可恢复、幂等、可查询失败和重试次数 |
|
||||
|
||||
@@ -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` 状态。
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 影响。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/",
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user