refactor: expand runtime contracts and debt ratchets

This commit is contained in:
jxxghp
2026-08-22 08:25:36 +08:00
parent 820f7e8850
commit 5d9c6c8b19
37 changed files with 883 additions and 339 deletions
+18
View File
@@ -7,6 +7,10 @@ from fastapi import Depends, Request
from app.application.messaging.chat import AsyncAgentChatRepository, AsyncUnitOfWork
from app.application.outbox import AsyncOutboxTransaction
from app.application.configuration import (
ApiRuntimeConfig,
get_api_runtime_config_snapshot,
)
from app.application.subscription.delete import SubscribeDeletionRepository
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
@@ -28,6 +32,20 @@ def get_host_runtime(request: Request) -> HostRuntime:
return runtime
def get_api_runtime_config(
runtime: HostRuntime = Depends(get_host_runtime),
) -> ApiRuntimeConfig:
"""为当前请求创建稳定的 API 配置快照。"""
return runtime.configuration.api()
def resolve_api_runtime_config(value: object) -> ApiRuntimeConfig:
"""兼容直接调用 endpoint 的旧入口,并统一返回真实配置快照。"""
if isinstance(value, ApiRuntimeConfig):
return value
return get_api_runtime_config_snapshot()
def get_agent_chat_runtime(
runtime: HostRuntime = Depends(get_host_runtime),
) -> AgentChatRuntime:
+24 -7
View File
@@ -16,7 +16,8 @@ from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.chain.dashboard import DashboardChain
from app.chain.storage import StorageChain
from app.runtime.config import settings
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
from app.application.configuration import ApiRuntimeConfig
from app.adapters.web.security.access import verify_apitoken
from app.api.dependencies.auth import get_current_active_superuser
from app.api.dependencies.history import get_dashboard_query_service
@@ -53,7 +54,11 @@ def _build_storage() -> _SchemaStorage:
return _SchemaStorage(total_storage=total, used_storage=total - available)
def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo:
def _build_downloader(
name: Optional[str] = None,
*,
btrfs_fsid_dedup: bool = False,
) -> _SchemaDownloaderInfo:
"""
构建下载器统计信息。
"""
@@ -61,7 +66,7 @@ def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo:
download_dirs = DirectoryHelper().get_local_download_dirs()
_, free_space = SystemUtils.space_usage(
[Path(d.download_path) for d in download_dirs],
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
btrfs_fsid_dedup=btrfs_fsid_dedup,
)
# 下载器信息
downloader_info = _SchemaDownloaderInfo()
@@ -137,12 +142,18 @@ def system_info(_: Any = Depends(get_current_active_superuser)) -> Any:
@router.get("/downloader", summary="下载器信息", response_model=_SchemaDownloaderInfo)
def downloader(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
name: Optional[str] = None,
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
_: Any = Depends(get_current_active_superuser),
) -> Any:
"""
查询下载器信息
"""
return _build_downloader(name)
runtime_config = resolve_api_runtime_config(runtime_config)
return _build_downloader(
name,
btrfs_fsid_dedup=runtime_config.btrfs_fsid_dedup,
)
@router.get(
@@ -150,11 +161,17 @@ def downloader(
summary="下载器信息(API_TOKEN",
response_model=_SchemaDownloaderInfo,
)
def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def downloader2(
_: Annotated[str, Depends(verify_apitoken)],
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
) -> Any:
"""
查询下载器信息 API_TOKEN认证(?token=xxx
"""
return _build_downloader()
runtime_config = resolve_api_runtime_config(runtime_config)
return _build_downloader(
btrfs_fsid_dedup=runtime_config.btrfs_fsid_dedup,
)
@router.get("/schedule", summary="后台服务", response_model=List[_SchemaScheduleInfo])
+78 -59
View File
@@ -111,6 +111,74 @@ def _build_unrecognized_media_info(
)
def _resolve_add_media(
torrent_in: _SchemaTorrentInfo,
media_source: MediaSource | None,
media_id: str | None,
music_type: MusicTargetEntityType | None,
allow_unrecognized: bool,
) -> tuple[MetaBase | None, MediaInfo | MusicInfo | None, _SchemaResponse | None]:
"""校验媒体身份并为无媒体信息下载构建识别上下文。"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
return None, None, _SchemaResponse(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
if (media_source is None) != (media_id is None):
return None, None, _SchemaResponse(
success=False,
message="媒体来源和媒体 ID 必须同时提供",
)
is_music = (
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return None, None, _SchemaResponse(
success=False,
message="音乐下载只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = MUSIC_ENTITY_RECORDING
metainfo = (
MetaMusic.parse_query(torrent_in.title)
if is_music
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
)
if media_source and media_id:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
media_source=media_source,
media_id=media_id,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
media_source=media_source,
obtain_images=False,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if mediainfo:
return metainfo, mediainfo, None
if not allow_unrecognized:
return metainfo, None, _SchemaResponse(
success=False,
message="无法识别媒体信息",
data=_SchemaDownloadAddedData(requires_confirmation=True),
)
return metainfo, _build_unrecognized_media_info(
torrent_in,
metainfo,
is_music=is_music,
music_type=normalized_music_type,
), None
@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent])
def current(
name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
@@ -183,66 +251,17 @@ def add(
"""
添加下载任务(不含媒体信息)
"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
return _SchemaResponse(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
if (media_source is None) != (media_id is None):
return _SchemaResponse(
success=False,
message="媒体来源和媒体 ID 必须同时提供",
)
is_music = (
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
metainfo, mediainfo, error = _resolve_add_media(
torrent_in,
media_source,
media_id,
music_type,
allow_unrecognized,
)
if is_music and media_source and not is_music_media_source(media_source):
return _SchemaResponse(
success=False,
message="音乐下载只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = MUSIC_ENTITY_RECORDING
# 元数据
metainfo = (
MetaMusic.parse_query(torrent_in.title)
if is_music
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
)
# 媒体信息
if media_source and media_id:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
media_source=media_source,
media_id=media_id,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
media_source=media_source,
obtain_images=False,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if not mediainfo:
if not allow_unrecognized:
return _SchemaResponse(
success=False,
message="无法识别媒体信息",
data=_SchemaDownloadAddedData(requires_confirmation=True),
)
# 用户已确认:影视与音乐统一按种子元信息构造最小上下文继续下载
mediainfo = _build_unrecognized_media_info(
torrent_in,
metainfo,
is_music=is_music,
music_type=normalized_music_type,
)
if error:
return error
if metainfo is None or mediainfo is None:
return _SchemaResponse(success=False, message="无法识别媒体信息")
# 种子信息
torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.model_dump())
+9 -3
View File
@@ -19,7 +19,9 @@ from app.agent.prompt.transfer_redo import (
build_batch_manual_redo_prompt,
build_manual_redo_prompt,
)
from app.runtime.config import settings, global_vars
from app.runtime.config import global_vars
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
from app.application.configuration import ApiRuntimeConfig
from app.adapters.web.security.access import verify_token
from app.api.dependencies.auth import (
get_current_active_manage_user,
@@ -244,12 +246,14 @@ def delete_transfer_history(
async def ai_redo_transfer_history(
history_id: int,
query: HistoryQueryService = Depends(get_history_query_service),
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
_: object = Depends(get_current_active_manage_user),
) -> Any:
"""
手动触发单条历史记录的 AI 重新整理,并返回进度键。
"""
if not settings.AI_AGENT_ENABLE:
runtime_config = resolve_api_runtime_config(runtime_config)
if not runtime_config.ai_agent_enable:
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
history = await query.get_transfer(history_id)
@@ -275,12 +279,14 @@ async def ai_redo_transfer_history(
async def batch_ai_redo_transfer_history(
payload: _SchemaBatchTransferHistoryRedoRequest,
query: HistoryQueryService = Depends(get_history_query_service),
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
_: object = Depends(get_current_active_manage_user),
) -> Any:
"""
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
"""
if not settings.AI_AGENT_ENABLE:
runtime_config = resolve_api_runtime_config(runtime_config)
if not runtime_config.ai_agent_enable:
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
history_ids = normalize_history_ids(payload.history_ids)
+6 -4
View File
@@ -13,8 +13,8 @@ from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.chain.user import MfaRequired, UserChain
from app.adapters.web.security.access import set_or_refresh_resource_token_cookie
from app.application.security.token import create_access_token
from app.runtime.config import settings
from app.application.configuration import get_configured_system_config
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
from app.application.configuration import ApiRuntimeConfig, get_configured_system_config
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.application.image import WallpaperHelper
from app.schemas.types import SystemConfigKey
@@ -39,10 +39,12 @@ def login_access_token(
response: Response,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
otp_password: Annotated[str | None, Form()] = None,
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
) -> Any:
"""
获取认证Token
"""
runtime_config = resolve_api_runtime_config(runtime_config)
success, user_or_message = UserChain().user_authenticate(
username=form_data.username, password=form_data.password, mfa_code=otp_password
)
@@ -69,13 +71,13 @@ def login_access_token(
# 是否显示配置向导
show_wizard = (
not get_configured_system_config().get(SystemConfigKey.SetupWizardState)
and not settings.ADVANCED_MODE
and not runtime_config.advanced_mode
)
access_token = create_access_token(
userid=user_or_message.id,
username=user_or_message.name,
super_user=user_or_message.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
expires_delta=timedelta(minutes=runtime_config.access_token_expire_minutes),
level=level,
)
set_or_refresh_resource_token_cookie(
+34 -41
View File
@@ -84,6 +84,39 @@ def create_jsonrpc_error(
return error
async def _dispatch_jsonrpc_method(
method: Any,
params: Dict[str, Any],
request_id: Union[str, int, None],
) -> Union[JSONResponse, Response]:
"""分派一个已经通过基础格式校验的 MCP JSON-RPC 方法。"""
if method == "initialize":
result = await handle_initialize(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
if method == "notifications/initialized":
if request_id is None:
return Response(status_code=204)
return JSONResponse(
status_code=400,
content=create_jsonrpc_error(
request_id, -32600, "initialized must be a notification"
),
)
if method == "tools/list":
result = await handle_tools_list()
return JSONResponse(content=create_jsonrpc_response(request_id, result))
if method == "tools/call":
result = await handle_tools_call(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
if method == "ping":
return JSONResponse(content=create_jsonrpc_response(request_id, {}))
return JSONResponse(
content=create_jsonrpc_error(
request_id, -32601, f"Method not found: {method}"
)
)
@router.post(
"",
summary="MCP JSON-RPC 端点",
@@ -130,48 +163,8 @@ async def mcp_jsonrpc(
params = body.get("params", {})
request_id = body.get("id")
# 如果有 id,则为请求;没有 id 则为通知
is_notification = request_id is None
try:
# 处理初始化请求
if method == "initialize":
result = await handle_initialize(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理已初始化通知
elif method == "notifications/initialized":
if is_notification:
return Response(status_code=204)
else:
return JSONResponse(
status_code=400,
content=create_jsonrpc_error(
request_id, -32600, "initialized must be a notification"
),
)
# 处理工具列表请求
if method == "tools/list":
result = await handle_tools_list()
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理工具调用请求
elif method == "tools/call":
result = await handle_tools_call(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理 ping 请求
elif method == "ping":
return JSONResponse(content=create_jsonrpc_response(request_id, {}))
# 未知方法
else:
return JSONResponse(
content=create_jsonrpc_error(
request_id, -32601, f"Method not found: {method}"
)
)
return await _dispatch_jsonrpc_method(method, params, request_id)
except ValueError as e:
logger.warning(f"MCP 请求参数错误: {e}")
+5 -1
View File
@@ -3,11 +3,12 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Optional
from app.application.chain.data import ChainDataPorts
from app.application.chain.durable_events import ChainDurableEventWriter
from app.application.configuration import ChainRuntimeConfig
MessageQueueFactory = Callable[[Callable[..., Any]], Any]
@@ -30,6 +31,9 @@ class ChainRuntimeContext:
module_dispatcher_factory: ModuleDispatcherFactory
data_ports: Optional[ChainDataPorts] = None
durable_event_writer: Optional[ChainDurableEventWriter] = None
configuration: ChainRuntimeConfig = field(
default_factory=lambda: ChainRuntimeConfig(media_extensions=())
)
def _unconfigured_chain_runtime_context() -> ChainRuntimeContext:
+71
View File
@@ -41,6 +41,56 @@ class TransferRetryConfig:
max_failed_retries: Any
@dataclass(frozen=True, slots=True)
class ApiRuntimeConfig:
"""单次 API 请求使用的宿主配置快照。"""
advanced_mode: bool
access_token_expire_minutes: int
btrfs_fsid_dedup: bool
ai_agent_enable: bool
@dataclass(frozen=True, slots=True)
class SchedulerRuntimeConfig:
"""一次 Scheduler 初始化或任务注册使用的稳定配置快照。"""
dev: bool
timezone: str
scheduler_workers: int
db_backup_enable: bool
db_backup_cron: str
cookiecloud_interval: Any
mediaserver_sync_interval: Any
subscribe_search: bool
subscribe_search_interval: Any
subscribe_mode: str
subscribe_rss_interval: int
data_cleanup_enable: bool
sitedata_refresh_interval: Any
memory_gc_interval: Any
ai_agent_enable: bool
ai_agent_job_interval: Any
usage_statistic_share: bool
site_link: str | None
@dataclass(frozen=True, slots=True)
class ChainRuntimeConfig:
"""Chain 在一次宿主生命周期内使用的基础配置快照。"""
media_extensions: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class RuntimeConfiguration:
"""由启动组合根提供的 API、Scheduler 与 Chain 配置快照工厂。"""
api: Callable[[], ApiRuntimeConfig]
scheduler: Callable[[], SchedulerRuntimeConfig]
chain: Callable[[], ChainRuntimeConfig]
class SystemConfigService:
"""系统配置读写应用服务。"""
@@ -82,6 +132,7 @@ class SystemConfigService:
_configured_system_config: SystemConfigService | None = None
_transfer_retry_config_provider: Callable[[], TransferRetryConfig] | None = None
_runtime_configuration: RuntimeConfiguration | None = None
def configure_system_config(service: SystemConfigService) -> None:
@@ -110,3 +161,23 @@ def get_transfer_retry_config() -> TransferRetryConfig:
if _transfer_retry_config_provider is None:
raise RuntimeError("整理失败重试配置尚未装配")
return _transfer_retry_config_provider()
def configure_runtime_configuration(configuration: RuntimeConfiguration) -> None:
"""由启动组合根登记各运行面使用的类型化配置快照工厂。"""
global _runtime_configuration
_runtime_configuration = configuration
def get_scheduler_runtime_config() -> SchedulerRuntimeConfig:
"""为一次调度操作创建不可变配置快照。"""
if _runtime_configuration is None:
raise RuntimeError("运行时配置尚未装配")
return _runtime_configuration.scheduler()
def get_api_runtime_config_snapshot() -> ApiRuntimeConfig:
"""为一次 API 调用创建不可变配置快照。"""
if _runtime_configuration is None:
raise RuntimeError("运行时配置尚未装配")
return _runtime_configuration.api()
+1
View File
@@ -52,6 +52,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
self.pluginmanager = context.plugin_manager
self.filecache = context.file_cache
self.async_filecache = context.async_file_cache
self.runtime_config = context.configuration
self.data_ports = context.data_ports or get_chain_data_ports()
self.durable_event_writer = context.durable_event_writer
self._module_dispatcher = context.module_dispatcher_factory(
+81 -67
View File
@@ -1038,6 +1038,51 @@ class DownloadChain(ChainBase):
# 返回 种子文件路径,种子目录名,种子文件清单
return content, download_folder, files
@staticmethod
def _apply_resource_download_event(
context: Context,
episodes: Optional[Set[int]],
channel: Optional[NotificationChannel],
source: Optional[str],
downloader: Optional[str],
save_path: Optional[str],
userid: Union[str, int, None],
username: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""应用资源下载事件覆盖,并校验事件返回的下载目录。"""
event_data = ResourceDownloadEventData(
context=context,
episodes=episodes or context.meta_info.episode_list,
channel=channel,
origin=source,
downloader=downloader,
options={
"save_path": save_path,
"userid": userid,
"username": username,
"media_category": context.media_info.category,
},
)
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
if event and event.event_data:
event_data = event.event_data
if event_data.cancel:
logger.debug(
"Resource download canceled by event: %s,Reason: %s",
event_data.source,
event_data.reason,
)
return save_path, "下载被事件取消"
if event_data.options and "save_path" in event_data.options:
save_path = event_data.options.get("save_path")
if save_path is None:
return None, None
try:
return validate_download_save_path(save_path), None
except ValueError as err:
logger.warn(str(err))
return save_path, str(err)
def download_single(self, context: Context,
torrent_file: Path = None,
torrent_content: Optional[Union[str, bytes]] = None,
@@ -1077,40 +1122,12 @@ class DownloadChain(ChainBase):
_media = MediaChain().supplement_tmdb_info(_media, _meta)
context.media_info = _media
# 发送资源下载事件,允许外部拦截下载
event_data = ResourceDownloadEventData(
context=context,
episodes=episodes or context.meta_info.episode_list,
channel=channel,
origin=source,
downloader=downloader,
options={
"save_path": save_path,
"userid": userid,
"username": username,
"media_category": _media.category
}
save_path, event_error = self._apply_resource_download_event(
context, episodes, channel, source, downloader, save_path,
userid, username,
)
# 触发资源下载事件
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
if event and event.event_data:
event_data: ResourceDownloadEventData = event.event_data
# 如果事件被取消,跳过资源下载
if event_data.cancel:
logger.debug(
f"Resource download canceled by event: {event_data.source},"
f"Reason: {event_data.reason}")
return (None, "下载被事件取消") if return_detail else None
# 如果事件修改了下载路径,使用新路径
if event_data.options and "save_path" in event_data.options:
save_path = event_data.options.get("save_path")
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError as err:
logger.warn(str(err))
return (None, str(err)) if return_detail else None
if event_error:
return (None, event_error) if return_detail else None
# 实际下载的集数
download_episodes = episode_rules.format_ranges(list(episodes)) if episodes else None
@@ -1913,34 +1930,6 @@ class DownloadChain(ChainBase):
logger.error("电视剧缺集检查需要有效的 media_source 和 media_id")
return False, no_exists or {}
def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int):
"""
添加不存在的季集信息
{source:id: [
"season": int,
"episodes": list,
"total_episode": int,
"start_episode": int
]}
"""
mediakey = build_media_key(media_source, media_id)
if not no_exists.get(mediakey):
no_exists[mediakey] = {
_season: NotExistMediaInfo(
season=_season,
episodes=_episodes,
total_episode=_total,
start_episode=_start
)
}
else:
no_exists[mediakey][_season] = NotExistMediaInfo(
season=_season,
episodes=_episodes,
total_episode=_total,
start_episode=_start
)
if not no_exists:
no_exists = {}
@@ -2006,8 +1995,10 @@ class DownloadChain(ChainBase):
continue
# 总集数
total_ep = totals.get(season) or len(episodes)
__append_no_exists(_season=season, _episodes=[],
_total=total_ep, _start=min(episodes))
self._append_no_exists(
no_exists, media_source, media_id, season, [],
total_ep, min(episodes)
)
return False, no_exists
else:
# 存在一些,检查每季缺失的季集
@@ -2035,12 +2026,16 @@ class DownloadChain(ChainBase):
# 全部集存在
continue
# 添加不存在的季集信息
__append_no_exists(_season=season, _episodes=lack_episodes,
_total=season_total, _start=min(lack_episodes))
self._append_no_exists(
no_exists, media_source, media_id, season,
lack_episodes, season_total, min(lack_episodes)
)
else:
# 全季不存在
__append_no_exists(_season=season, _episodes=[],
_total=season_total, _start=min(episodes))
self._append_no_exists(
no_exists, media_source, media_id, season, [],
season_total, min(episodes)
)
# 存在不完整的剧集
if no_exists:
logger.debug(f"媒体库中已存在部分剧集,缺失:{no_exists}")
@@ -2048,6 +2043,25 @@ class DownloadChain(ChainBase):
# 全部存在
return True, no_exists
@staticmethod
def _append_no_exists(
no_exists: Dict[str, Dict[int, NotExistMediaInfo]],
media_source: Optional[MediaSource],
media_id: Optional[str],
season: int,
episodes: list,
total: int,
start: int,
) -> None:
"""把一季缺失信息合并到标准媒体身份对应的结果中。"""
media_key = build_media_key(media_source, media_id)
no_exists.setdefault(media_key, {})[season] = NotExistMediaInfo(
season=season,
episodes=episodes,
total_episode=total,
start_episode=start,
)
def remote_downloading(self, channel: NotificationChannel, userid: Union[str, int] = None, source: Optional[str] = None):
"""
查询正在下载的任务,并发送消息
+1 -2
View File
@@ -3,7 +3,6 @@ from typing import Any, Optional, List, Dict
from app.schemas.workflow import FileItem as _SchemaFileItem
from app.chain import ChainBase
from app.runtime.config import settings
from app.application.directory import DirectoryHelper
from app.runtime.log import logger
@@ -148,7 +147,7 @@ class StorageChain(ChainBase):
"""
删除媒体文件以及不含媒体文件的目录
"""
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
media_exts = self.runtime_config.media_extensions
fileitem_path = Path(fileitem.path) if fileitem.path else Path("")
if len(fileitem_path.parts) <= 2:
logger.warn(f"{fileitem.storage}{fileitem.path} 根目录或一级目录不允许删除")
+1 -3
View File
@@ -4,7 +4,7 @@ from sqlalchemy import Float, Index, Integer, String, delete, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import db_query, db_update
from app.db.decorators import db_update
from app.db.models._constraints import media_identity_constraint
@@ -62,7 +62,6 @@ class DownloadFailure(Base):
)
@classmethod
@db_query
def get_active_by_fingerprints(
cls,
db: Session,
@@ -81,7 +80,6 @@ class DownloadFailure(Base):
).scalars().all())
@classmethod
@db_update
def record_failure(
cls,
db: Session,
+19 -17
View File
@@ -63,31 +63,33 @@ _METHOD_CONTRACTS = {
"recognize_media": ModuleMethodContract(
family="media-recognition", input_contract="MediaRecognitionRequest",
result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
required_parameters=("meta", "mtype", "media_source", "media_id", "episode_group", "cache"),
),
"search_medias": ModuleMethodContract(
family="media-recognition", input_contract="MediaSearchRequest",
result_contract="list[MediaInfo]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE,
required_parameters=("meta", "media_source"),
),
"obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None"),
"obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None", required_parameters=("mediainfo",)),
"media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"),
"mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
"mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
"download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
"get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="Any", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "library_id", "start_index", "limit")),
"mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "item_id")),
"download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path")),
"upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path", "new_name")),
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("fileitem", "recursion")),
"get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")),
"get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")),
"get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem",)),
"rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "name")),
"storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="Any", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "action")),
"snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path", "last_snapshot_time", "max_depth", "previous_snapshot")),
"send_message": ModuleMethodContract(family="messaging", input_contract="MessageSendRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None"),
"finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("response",)),
"register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None", required_parameters=("commands",)),
"scheduler_job": ModuleMethodContract(family="scheduling", input_contract="SchedulerJobRequest", result_contract="None"),
"webhook_parser": ModuleMethodContract(family="integration", input_contract="WebhookRequest", result_contract="WebhookEventInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"webhook_parser": ModuleMethodContract(family="integration", input_contract="WebhookRequest", result_contract="WebhookEventInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("body", "form", "args")),
}
_PREFIX_CONTRACTS = (
+51 -45
View File
@@ -24,13 +24,17 @@ from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain
from app.chain.transfer import TransferChain
from app.chain.workflow import WorkflowChain
from app.runtime.config import settings, global_vars
from app.runtime.config import global_vars
from app.runtime.events import Event, eventmanager
from app.runtime.extensions.plugin_manager import PluginManager
from app.db.oper.agenttask import AgentTaskOper
from app.db.oper.systemconfig import SystemConfigOper
from app.application.database import get_database_governance
from app.application.outbox import dispatch_pending_outbox
from app.application.configuration import (
SchedulerRuntimeConfig,
get_configured_system_config,
get_scheduler_runtime_config,
)
from app.application.image import WallpaperHelper
from app.application.messaging.message import MessageHelper
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
@@ -209,9 +213,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""按当前宿主策略创建一次定时数据库备份。"""
return get_database_governance().create_backup()
def _register_database_backup_job(self) -> None:
def _register_database_backup_job(
self,
config: SchedulerRuntimeConfig,
) -> None:
"""在共享调度器中按当前配置维护唯一的数据库备份作业。"""
if not settings.DB_BACKUP_ENABLE or not settings.DB_BACKUP_CRON.strip():
if not config.db_backup_enable or not config.db_backup_cron.strip():
return
job_id = "database_backup"
@@ -226,8 +233,8 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
self.start,
trigger=TimerUtils.build_schedule_trigger(
trigger_type="cron",
trigger_value=settings.DB_BACKUP_CRON,
timezone_name=settings.TZ,
trigger_value=config.db_backup_cron,
timezone_name=config.timezone,
),
id=job_id,
name="数据库备份",
@@ -240,11 +247,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
初始化定时服务
"""
config = get_scheduler_runtime_config()
# 停止定时服务
self.stop()
# 调试模式不启动定时服务
if settings.DEV:
if config.dev:
return
# 对账上个进程未收口的 Agent 任务;进程内重复初始化不会重复改写状态。
@@ -277,11 +285,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
]).runtime_states()
self._scheduler = BackgroundScheduler(
timezone=settings.TZ,
executors={"default": ThreadPoolExecutor(settings.CONF.scheduler)},
timezone=config.timezone,
executors={"default": ThreadPoolExecutor(config.scheduler_workers)},
)
self._register_database_backup_job()
self._register_database_backup_job(config)
self._jobs["outbox_dispatch"] = JobSpec(
"outbox_dispatch",
"恢复待投递副作用",
@@ -295,30 +303,30 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="outbox_dispatch",
name="恢复待投递副作用",
seconds=30,
next_run_time=datetime.now(pytz.timezone(settings.TZ)),
next_run_time=datetime.now(pytz.timezone(config.timezone)),
kwargs={"job_id": "outbox_dispatch"},
replace_existing=True,
)
# CookieCloud定时同步
if (
settings.COOKIECLOUD_INTERVAL
and str(settings.COOKIECLOUD_INTERVAL).isdigit()
config.cookiecloud_interval
and str(config.cookiecloud_interval).isdigit()
):
self._scheduler.add_job(
self.start,
"interval",
id="cookiecloud",
name="同步CookieCloud站点",
minutes=int(settings.COOKIECLOUD_INTERVAL),
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
minutes=int(config.cookiecloud_interval),
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=5),
kwargs={"job_id": "cookiecloud"},
)
# 按媒体服务器分别注册自动同步任务
mediaserver_schedules = self._build_mediaserver_sync_schedules(
mediaservers=ServiceConfigHelper.get_mediaserver_configs(),
default_interval=settings.MEDIASERVER_SYNC_INTERVAL,
default_interval=config.mediaserver_sync_interval,
)
for mediaserver_schedule in mediaserver_schedules:
job_id = mediaserver_schedule["id"]
@@ -335,7 +343,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id=job_id,
name=mediaserver_schedule["name"],
hours=mediaserver_schedule["interval"],
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=10),
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=10),
kwargs={"job_id": job_id},
)
@@ -360,17 +368,17 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
)
# 订阅状态每隔24小时搜索一次
if settings.SUBSCRIBE_SEARCH:
if config.subscribe_search:
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_search",
name="订阅搜索补全",
hours=settings.SUBSCRIBE_SEARCH_INTERVAL,
hours=config.subscribe_search_interval,
kwargs={"job_id": "subscribe_search"},
)
if settings.SUBSCRIBE_MODE == "spider":
if config.subscribe_mode == "spider":
# 站点首页种子定时刷新模式
triggers = TimerUtils.random_scheduler(num_executions=32)
for trigger in triggers:
@@ -385,19 +393,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
)
else:
# RSS订阅模式
if (
not settings.SUBSCRIBE_RSS_INTERVAL
or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit()
):
settings.SUBSCRIBE_RSS_INTERVAL = 30
elif int(settings.SUBSCRIBE_RSS_INTERVAL) < 5:
settings.SUBSCRIBE_RSS_INTERVAL = 5
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_refresh",
name="RSS订阅刷新",
minutes=int(settings.SUBSCRIBE_RSS_INTERVAL),
minutes=config.subscribe_rss_interval,
kwargs={"job_id": "subscribe_refresh"},
)
@@ -428,7 +429,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="random_wallpager",
name="壁纸缓存",
minutes=30,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=1),
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=1),
kwargs={"job_id": "random_wallpager"},
)
@@ -443,7 +444,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
)
# 数据表清理服务,每天凌晨执行一次
if settings.DATA_CLEANUP_ENABLE:
if config.data_cleanup_enable:
self._scheduler.add_job(
self.start,
"cron",
@@ -465,13 +466,13 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
)
# 站点数据刷新
if settings.SITEDATA_REFRESH_INTERVAL:
if config.sitedata_refresh_interval:
self._scheduler.add_job(
self.start,
"interval",
id="sitedata_refresh",
name="站点数据刷新",
minutes=settings.SITEDATA_REFRESH_INTERVAL * 60,
minutes=config.sitedata_refresh_interval * 60,
kwargs={"job_id": "sitedata_refresh"},
)
@@ -482,7 +483,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="recommend_refresh",
name="推荐缓存",
hours=24,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=5),
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=5),
kwargs={"job_id": "recommend_refresh"},
)
@@ -503,34 +504,34 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="subscribe_calendar_cache",
name="订阅日历缓存",
hours=6,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=2),
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=2),
kwargs={"job_id": "subscribe_calendar_cache"},
)
# 主动内存回收
if settings.MEMORY_GC_INTERVAL:
if config.memory_gc_interval:
self._scheduler.add_job(
self.start,
"interval",
id="full_gc",
name="主动内存回收",
minutes=settings.MEMORY_GC_INTERVAL,
minutes=config.memory_gc_interval,
kwargs={"job_id": "full_gc"},
)
# 智能体定时任务检查
if settings.AI_AGENT_ENABLE and settings.AI_AGENT_JOB_INTERVAL:
if config.ai_agent_enable and config.ai_agent_job_interval:
self._scheduler.add_job(
self.start,
"interval",
id="agent_heartbeat",
name="智能体定时任务",
hours=settings.AI_AGENT_JOB_INTERVAL,
hours=config.ai_agent_job_interval,
kwargs={"job_id": "agent_heartbeat"},
)
# 安装版本统计上报
if settings.USAGE_STATISTIC_SHARE:
if config.usage_statistic_share:
self._scheduler.add_job(
self.start,
"interval",
@@ -544,7 +545,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
self.init_workflow_jobs()
# 恢复 Agent 自主定时任务
if settings.AI_AGENT_ENABLE:
if config.ai_agent_enable:
self.init_agent_task_jobs()
# 初始化插件服务
@@ -969,10 +970,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
:param task_id: Agent 定时任务 ID
:return: 下一次执行时间不可调度时返回 None
"""
config = get_scheduler_runtime_config()
self.remove_agent_task_job(task_id)
task = AgentTaskOper().get(task_id)
if (
not settings.AI_AGENT_ENABLE
not config.ai_agent_enable
or not task
or not task.enabled
or not self._scheduler
@@ -989,7 +991,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
trigger = TimerUtils.build_schedule_trigger(
trigger_type=task.trigger_type,
trigger_value=trigger_value,
timezone_name=settings.TZ,
timezone_name=config.timezone,
)
except (TypeError, ValueError) as err:
logger.error(f"Agent 定时任务 {task_id} 的触发配置无效:{str(err)}")
@@ -1046,6 +1048,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
:param task_id: Agent 定时任务 ID
:return: 带时区的 ISO 8601 时间不再执行时返回 None
"""
config = get_scheduler_runtime_config()
job_id = self._get_agent_task_job_id(task_id)
if self._scheduler:
job = self._scheduler.get_job(job_id)
@@ -1065,7 +1068,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
next_run_time = TimerUtils.get_schedule_next_run_time(
trigger_type=task.trigger_type,
trigger_value=trigger_value,
timezone_name=settings.TZ,
timezone_name=config.timezone,
)
except (TypeError, ValueError):
return None
@@ -1443,6 +1446,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""
用户认证检查
"""
config = get_scheduler_runtime_config()
if SitesHelper().auth_level >= 2:
return
# 最大重试次数
@@ -1457,7 +1461,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
self._auth_message = True
return
logger.info("用户未认证,正在尝试认证...")
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
auth_conf = get_configured_system_config().get(
SystemConfigKey.UserSiteAuthParams
)
if auth_conf:
status, msg = SitesHelper().check_user(**auth_conf)
else:
@@ -1470,7 +1476,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
mtype=MessageType.Manual,
title="MoviePilot用户认证成功",
text=f"使用站点:{msg},如有插件使用异常,请重启MoviePilot。",
link=settings.MP_DOMAIN("#/site"),
link=config.site_link,
)
)
# 认证通过后重新初始化插件
+2
View File
@@ -9,6 +9,7 @@ from app.application.messaging.chat import (
AsyncUnitOfWork,
)
from app.application.outbox import AsyncOutboxTransaction
from app.application.configuration import RuntimeConfiguration
from app.application.subscription.delete import SubscribeDeletionRepository
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
@@ -125,4 +126,5 @@ class HostRuntime:
agent_chat: AgentChatRuntime
subscription: SubscriptionRuntime
configuration: RuntimeConfiguration
compatibility_api_data: CompatibilityApiData
+55
View File
@@ -0,0 +1,55 @@
"""下载失败冷却切片的显式会话与事务适配器。"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.uow import SqlAlchemyUnitOfWork
class TransactionalDownloadFailureRepository:
"""为 Chain 下载失败读写创建短生命周期会话并显式收口事务。"""
def __init__(self, session_factory: Callable[[], Any]) -> None:
"""保存由启动组合根提供的同步会话工厂。"""
self._session_factory = session_factory
def get_active_by_fingerprints(
self,
fingerprints: list[str],
now_time: str,
) -> dict[str, Any]:
"""在独立只读会话中查询仍处于冷却期的失败记录。"""
with self._session_factory() as session:
return cast(
dict[str, Any],
DownloadFailureOper(db=session).get_active_by_fingerprints(
fingerprints=fingerprints,
now_time=now_time,
),
)
def record_failure(
self,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> Any:
"""在一个显式 UoW 中新增或更新下载失败记录。"""
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
failure = DownloadFailureOper(db=session).record_failure(
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
**kwargs,
)
transaction.commit()
return failure
except Exception:
transaction.rollback()
raise
+73 -2
View File
@@ -36,8 +36,13 @@ from app.application.messaging.message import (
stop_message,
)
from app.application.configuration import (
ApiRuntimeConfig,
ChainRuntimeConfig,
RuntimeConfiguration,
SchedulerRuntimeConfig,
SystemConfigService,
TransferRetryConfig,
configure_runtime_configuration,
configure_system_config,
configure_transfer_retry_config,
)
@@ -86,7 +91,6 @@ from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.transferpending import TransferPendingOper
from app.db.oper.mediaserver import MediaServerOper
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.oper.site import SiteOper
from app.db.oper.message import MessageOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
@@ -108,6 +112,7 @@ from app.startup.subscription import (
configure_transactional_subscription_scopes,
)
from app.startup.chain_events import TransactionalChainDurableEventWriter
from app.startup.download_failure import TransactionalDownloadFailureRepository
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
from app.adapters.web.security.access import set_superuser_token_payload_provider
from app.application.security.auth import build_superuser_token_payload
@@ -151,11 +156,68 @@ def _build_chain_runtime_context() -> ChainRuntimeContext:
send_callback=callback
),
module_dispatcher_factory=ModuleInvocationDispatcher,
configuration=_build_chain_runtime_config(),
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,
)
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
)
)
def configure_runtime_data_providers() -> None:
"""在启动组合层装配运行时和外部服务所需的数据库读取能力。"""
configure_service_config_reader(lambda key: SystemConfigOper().get(key))
@@ -478,6 +540,11 @@ async def init_modules() -> HostRuntime:
"sync": SqlAlchemyUnitOfWork,
},
)
runtime_configuration = RuntimeConfiguration(
api=_build_api_runtime_config,
scheduler=_build_scheduler_runtime_config,
chain=_build_chain_runtime_config,
)
host_runtime = HostRuntime(
agent_chat=AgentChatRuntime(
async_session=get_async_db,
@@ -491,8 +558,10 @@ async def init_modules() -> HostRuntime:
transaction=SqlAlchemyAsyncUnitOfWork,
outbox=SqlAlchemyAsyncOutboxStager,
),
configuration=runtime_configuration,
compatibility_api_data=api_data,
)
configure_runtime_configuration(host_runtime.configuration)
configure_api_data_runtime(host_runtime.compatibility_api_data)
configure_runtime_data_providers()
configure_chain_data_ports(
@@ -503,7 +572,9 @@ async def init_modules() -> HostRuntime:
transfer_history=lambda: TransferHistoryOper(),
transfer_pending=lambda: TransferPendingOper(),
media_server=lambda: MediaServerOper(),
download_failure=lambda: DownloadFailureOper(),
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
),
user=lambda: UserOper(),
)
configure_system_config(SystemConfigService(repository=SystemConfigOper()))