mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor: expand runtime contracts and debt ratchets
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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):
|
||||
"""
|
||||
查询正在下载的任务,并发送消息
|
||||
|
||||
@@ -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} 根目录或一级目录不允许删除")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
)
|
||||
# 认证通过后重新初始化插件
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user