mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: reorganize startup persistence boundaries
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""按领域组织的宿主初始化与关闭入口。"""
|
||||
@@ -0,0 +1,270 @@
|
||||
from typing import Any
|
||||
|
||||
from app.agent.runtime_loader import (
|
||||
activate_agent_service,
|
||||
begin_agent_shutdown,
|
||||
close_materialized_terminal_sessions,
|
||||
get_agent_manager as get_runtime_agent_manager,
|
||||
get_running_agent_manager as get_runtime_running_agent_manager,
|
||||
is_tool_factory_materialized,
|
||||
reconcile_agent_service,
|
||||
)
|
||||
from app.agent.llm.gateway import register_llm_provider_runtime
|
||||
from app.application.agent import register_agent_service_providers
|
||||
from app.application.messaging.skill import register_skill_catalog_provider
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.runtime.events import Event, eventmanager
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
AGENT_BLOCKING_EXECUTOR_SHUTDOWN_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
def _get_skill_catalog() -> Any:
|
||||
"""按需返回 Agent 技能目录实现,供消息应用层消费端口。"""
|
||||
from app.agent.skills.registry import SkillHelper
|
||||
|
||||
return SkillHelper()
|
||||
|
||||
|
||||
def _get_llm_provider_runtime() -> Any:
|
||||
"""按需返回 LLM provider 运行时,实现只在真实调用边界加载。"""
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
return LLMProviderManager()
|
||||
|
||||
|
||||
# 嵌入式启动器可显式注入 manager;常规进程使用 Capability Runtime。
|
||||
agent_manager: Any = None
|
||||
|
||||
|
||||
def _event_changed_keys(event: Event | None) -> set[str]:
|
||||
"""兼容对象和 dict 两种配置事件载荷。"""
|
||||
if event is None:
|
||||
return set()
|
||||
event_data = event.event_data
|
||||
if isinstance(event_data, dict):
|
||||
keys = event_data.get("key", set())
|
||||
else:
|
||||
keys = getattr(event_data, "key", set())
|
||||
if isinstance(keys, str):
|
||||
return {keys}
|
||||
return {str(key) for key in (keys or set())}
|
||||
|
||||
|
||||
def _get_agent_manager() -> Any:
|
||||
"""兼容显式注入对象,否则按需解析 canonical manager。"""
|
||||
return agent_manager if agent_manager is not None else get_runtime_agent_manager()
|
||||
|
||||
|
||||
def _get_running_agent_manager() -> Any | None:
|
||||
"""只返回已运行实例,状态探测不得触发 Agent 物化。"""
|
||||
if agent_initializer._compat_injected:
|
||||
return agent_initializer._manager
|
||||
return get_runtime_running_agent_manager()
|
||||
|
||||
|
||||
def _get_prompt_manager() -> Any:
|
||||
"""首个提示词调用才导入模板管理器。"""
|
||||
from app.agent.prompt import prompt_manager
|
||||
|
||||
return prompt_manager
|
||||
|
||||
|
||||
def _get_capability_manager() -> Any:
|
||||
"""首个多模态调用才导入 Agent 能力管理器。"""
|
||||
from app.agent.llm.capability import AgentCapabilityManager
|
||||
|
||||
return AgentCapabilityManager
|
||||
|
||||
|
||||
def _get_llm_helper() -> Any:
|
||||
"""首个模型能力查询才导入 LLM helper。"""
|
||||
from app.agent.llm.helper import LLMHelper
|
||||
|
||||
return LLMHelper
|
||||
|
||||
|
||||
def _get_manual_redo_prompt_builder() -> Any:
|
||||
"""首个整理接管请求才导入对应提示词构建器。"""
|
||||
from app.agent.prompt.transfer_redo import build_manual_redo_prompt
|
||||
|
||||
return build_manual_redo_prompt
|
||||
|
||||
|
||||
async def _handle_agent_config_changed(event: Event) -> None:
|
||||
"""把配置事件交给当前全局 initializer,避免监听器持有过期实例。"""
|
||||
await agent_initializer.handle_config_changed(event)
|
||||
|
||||
|
||||
class AgentInitializer:
|
||||
"""
|
||||
AI智能体初始化器
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._initialized = False
|
||||
self._manager: Any = None
|
||||
self._compat_injected = False
|
||||
self._shutdown_started = False
|
||||
self._shutdown_complete = False
|
||||
eventmanager.add_event_listener(
|
||||
EventType.ConfigChanged,
|
||||
_handle_agent_config_changed,
|
||||
)
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""
|
||||
初始化AI智能体管理器
|
||||
"""
|
||||
try:
|
||||
self._shutdown_started = False
|
||||
self._shutdown_complete = False
|
||||
if agent_manager is not None:
|
||||
if not settings.AI_AGENT_ENABLE:
|
||||
logger.info("AI智能体功能未启用")
|
||||
return True
|
||||
self._manager = agent_manager
|
||||
self._compat_injected = True
|
||||
await agent_manager.initialize()
|
||||
else:
|
||||
self._manager = await activate_agent_service()
|
||||
self._compat_injected = False
|
||||
if self._manager is None:
|
||||
logger.info("AI智能体功能未启用")
|
||||
return True
|
||||
self._initialized = True
|
||||
logger.info("AI智能体管理器初始化成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI智能体管理器初始化失败: {e}")
|
||||
return False
|
||||
|
||||
async def handle_config_changed(self, event: Event) -> None:
|
||||
"""仅在 manifest watch 命中时协调 service,关闭态保持 fail closed。"""
|
||||
changed_keys = _event_changed_keys(event)
|
||||
if (
|
||||
not changed_keys
|
||||
or self._compat_injected
|
||||
or self._shutdown_started
|
||||
or self._shutdown_complete
|
||||
):
|
||||
return
|
||||
try:
|
||||
self._manager = await reconcile_agent_service(
|
||||
reason="agent_service_config_changed",
|
||||
changed_keys=changed_keys,
|
||||
retry=True,
|
||||
)
|
||||
self._initialized = self._manager is not None
|
||||
except Exception as error:
|
||||
self._manager = None
|
||||
self._initialized = False
|
||||
logger.debug(f"配置变更协调AI智能体失败: {error}")
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
"""清理 initializer 引用;未收敛的显式注入对象继续由本实例持有。"""
|
||||
try:
|
||||
manager = self._manager
|
||||
compat_injected = self._compat_injected
|
||||
if manager is None:
|
||||
return True
|
||||
if compat_injected and await manager.close() is False:
|
||||
logger.error("AI智能体管理器仍有会话 owner 未收敛")
|
||||
return False
|
||||
logger.info("AI智能体管理器已关闭")
|
||||
self._initialized = False
|
||||
self._manager = None
|
||||
self._compat_injected = False
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"关闭AI智能体管理器时发生错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 全局AI智能体初始化器实例
|
||||
agent_initializer = AgentInitializer()
|
||||
|
||||
# application 门面仅保存 provider;下列注册不会导入 Agent 实现。
|
||||
register_agent_service_providers(
|
||||
agent_manager_provider=_get_agent_manager,
|
||||
running_agent_manager_provider=_get_running_agent_manager,
|
||||
prompt_manager_provider=_get_prompt_manager,
|
||||
capability_manager_provider=_get_capability_manager,
|
||||
llm_helper_provider=_get_llm_helper,
|
||||
manual_redo_prompt_builder_provider=_get_manual_redo_prompt_builder,
|
||||
)
|
||||
register_skill_catalog_provider(_get_skill_catalog)
|
||||
register_llm_provider_runtime(_get_llm_provider_runtime)
|
||||
|
||||
|
||||
async def init_agent() -> bool:
|
||||
"""
|
||||
在应用事件循环中初始化AI智能体。
|
||||
"""
|
||||
try:
|
||||
return await agent_initializer.initialize()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"初始化AI智能体时发生错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def stop_agent() -> bool:
|
||||
"""
|
||||
停止AI智能体,并在全部会话和工具资源释放后返回 True。
|
||||
"""
|
||||
converged = True
|
||||
close_blocking_executors = None
|
||||
agent_initializer._shutdown_started = True
|
||||
try:
|
||||
if is_tool_factory_materialized():
|
||||
from app.agent.tools.base import (
|
||||
begin_blocking_executor_shutdown,
|
||||
close_blocking_executors as close_executors,
|
||||
)
|
||||
|
||||
# 必须在任何 manager await 之前封口,避免旧会话趁收尾窗口提交新同步调用。
|
||||
begin_blocking_executor_shutdown(cancel_futures=True)
|
||||
close_blocking_executors = close_executors
|
||||
except Exception as e:
|
||||
logger.error(f"封住AI智能体阻塞工具提交时发生错误: {e}")
|
||||
converged = False
|
||||
|
||||
try:
|
||||
if not agent_initializer._shutdown_complete:
|
||||
if agent_initializer._compat_injected:
|
||||
service_converged = await agent_initializer.cleanup()
|
||||
else:
|
||||
service_converged = await begin_agent_shutdown()
|
||||
if service_converged is not False:
|
||||
service_converged = await agent_initializer.cleanup()
|
||||
converged = converged and service_converged is not False
|
||||
except Exception as e:
|
||||
logger.error(f"停止AI智能体时发生错误: {e}")
|
||||
converged = False
|
||||
|
||||
if close_blocking_executors is not None:
|
||||
try:
|
||||
blocking_converged = await close_blocking_executors(
|
||||
timeout_seconds=AGENT_BLOCKING_EXECUTOR_SHUTDOWN_TIMEOUT_SECONDS,
|
||||
cancel_futures=True,
|
||||
)
|
||||
converged = converged and blocking_converged
|
||||
except Exception as e:
|
||||
logger.error(f"关闭AI智能体阻塞工具线程池时发生错误: {e}")
|
||||
converged = False
|
||||
|
||||
if converged:
|
||||
try:
|
||||
await close_materialized_terminal_sessions()
|
||||
except Exception as e:
|
||||
logger.error(f"关闭AI智能体终端会话时发生错误: {e}")
|
||||
converged = False
|
||||
agent_initializer._shutdown_complete = converged
|
||||
return converged
|
||||
@@ -0,0 +1,6 @@
|
||||
from app.adapters.cache.backends import configure_platform_cache
|
||||
|
||||
|
||||
def configure_cache_dependencies() -> None:
|
||||
"""在导入使用缓存装饰器的业务模块前注册具体缓存适配器。"""
|
||||
configure_platform_cache()
|
||||
@@ -0,0 +1,28 @@
|
||||
from concurrent.futures import Future
|
||||
|
||||
from app.application.commands import register_command_class
|
||||
from app.command import Command
|
||||
|
||||
# 导入期即向 application 门面注册命令类,保证工具调用时不依赖静态边。
|
||||
register_command_class(Command)
|
||||
|
||||
|
||||
def init_command():
|
||||
"""
|
||||
初始化命令
|
||||
"""
|
||||
Command()
|
||||
|
||||
|
||||
def stop_command():
|
||||
"""
|
||||
停止命令
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def restart_command() -> Future:
|
||||
"""
|
||||
重建命令并返回完成信号。
|
||||
"""
|
||||
return Command().init_commands()
|
||||
@@ -0,0 +1,160 @@
|
||||
from collections.abc import Callable
|
||||
from configparser import ConfigParser as _ConfigParser
|
||||
import traceback
|
||||
|
||||
from alembic.command import upgrade
|
||||
from alembic.config import Config
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from alembic.util import CommandError
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.db.base import Base
|
||||
from app.db.engine import get_engine
|
||||
from app.db.models import load_all_models
|
||||
from app.runtime.log import logger
|
||||
from app.startup.composition.database import build_database_governance
|
||||
|
||||
|
||||
def _build_alembic_config(engine: Engine | None = None) -> Config:
|
||||
"""构造与应用活动数据库一致的 Alembic 配置。"""
|
||||
engine = engine or get_engine()
|
||||
alembic_cfg = Config()
|
||||
alembic_cfg.file_config = _ConfigParser(interpolation=None)
|
||||
alembic_cfg.set_main_option(
|
||||
'script_location',
|
||||
str(settings.ROOT_PATH / 'database'),
|
||||
)
|
||||
alembic_cfg.set_main_option(
|
||||
'sqlalchemy.url',
|
||||
engine.url.render_as_string(hide_password=False),
|
||||
)
|
||||
return alembic_cfg
|
||||
|
||||
|
||||
def _migration_state(
|
||||
engine: Engine,
|
||||
alembic_cfg: Config,
|
||||
) -> tuple[bool, tuple[str, ...], tuple[str, ...]]:
|
||||
"""读取数据库迁移状态,并在结构写入前校验版本链。"""
|
||||
script = ScriptDirectory.from_config(alembic_cfg)
|
||||
target_heads = tuple(script.get_heads())
|
||||
with engine.connect() as connection:
|
||||
table_names = set(inspect(connection).get_table_names())
|
||||
current_heads = tuple(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
has_existing_database = bool(table_names - {'alembic_version'})
|
||||
_validate_migration_lineage(script, current_heads, target_heads)
|
||||
return has_existing_database, current_heads, target_heads
|
||||
|
||||
|
||||
def _validate_migration_lineage(
|
||||
script: ScriptDirectory,
|
||||
current_heads: tuple[str, ...],
|
||||
target_heads: tuple[str, ...],
|
||||
) -> None:
|
||||
"""拒绝无法沿当前迁移链安全升级的数据库版本。"""
|
||||
if len(target_heads) != 1:
|
||||
raise RuntimeError(
|
||||
f"数据库迁移脚本必须只有一个 head,当前为 {target_heads}"
|
||||
)
|
||||
if len(current_heads) > 1:
|
||||
raise RuntimeError(
|
||||
f"数据库存在多个 current revision,无法自动迁移:{current_heads}"
|
||||
)
|
||||
if not current_heads:
|
||||
return
|
||||
|
||||
current = current_heads[0]
|
||||
target = target_heads[0]
|
||||
try:
|
||||
script.get_revision(current)
|
||||
except CommandError as error:
|
||||
raise RuntimeError(
|
||||
f"当前 MoviePilot 无法识别数据库 revision:{current}"
|
||||
) from error
|
||||
if current == target:
|
||||
return
|
||||
|
||||
ancestors = {
|
||||
revision.revision
|
||||
for revision in script.walk_revisions(base='base', head=target)
|
||||
}
|
||||
if current not in ancestors:
|
||||
raise RuntimeError(
|
||||
f"数据库 revision {current} 不是当前 head {target} 的可升级祖先"
|
||||
)
|
||||
|
||||
|
||||
def prepare_database(*, before_alembic: Callable[[], None] | None = None) -> None:
|
||||
"""在建表或迁移前完成版本校验及可选备份。"""
|
||||
engine = get_engine()
|
||||
alembic_cfg = _build_alembic_config(engine)
|
||||
has_existing_database, current_heads, target_heads = _migration_state(
|
||||
engine,
|
||||
alembic_cfg,
|
||||
)
|
||||
requires_migration = (
|
||||
has_existing_database
|
||||
and set(current_heads) != set(target_heads)
|
||||
)
|
||||
if (
|
||||
requires_migration
|
||||
and settings.DB_BACKUP_ENABLE
|
||||
and settings.DB_BACKUP_ON_UPGRADE
|
||||
):
|
||||
current_version = current_heads[0] if current_heads else "未标记"
|
||||
target_version = target_heads[0]
|
||||
logger.info(
|
||||
f"数据库需要从版本 {current_version} 升级到 {target_version},"
|
||||
"正在创建迁移前备份"
|
||||
)
|
||||
build_database_governance().create_backup()
|
||||
|
||||
init_db()
|
||||
if before_alembic:
|
||||
# 首次初始化需要先建立用户表,再把管理员密码交给 Alembic 基础迁移消费。
|
||||
before_alembic()
|
||||
update_db(alembic_cfg)
|
||||
|
||||
|
||||
def verify_database_revision() -> None:
|
||||
"""确认活动数据库已位于当前唯一 Alembic head,否则阻止 readiness。"""
|
||||
engine = get_engine()
|
||||
alembic_cfg = _build_alembic_config(engine)
|
||||
_, current_heads, target_heads = _migration_state(engine, alembic_cfg)
|
||||
if set(current_heads) != set(target_heads):
|
||||
raise RuntimeError(
|
||||
"数据库迁移完成后 revision 仍未到达当前 head:"
|
||||
f"current={current_heads}, target={target_heads}"
|
||||
)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""
|
||||
初始化数据库
|
||||
"""
|
||||
# 确保所有模型都已注册到 Base.metadata 中
|
||||
load_all_models()
|
||||
|
||||
# 全量建表
|
||||
Base.metadata.create_all(bind=get_engine())
|
||||
|
||||
|
||||
def update_db(alembic_cfg: Config | None = None):
|
||||
"""
|
||||
更新数据库
|
||||
"""
|
||||
try:
|
||||
alembic_cfg = alembic_cfg or _build_alembic_config()
|
||||
upgrade(alembic_cfg, 'head')
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"数据库更新失败:{error}\n{traceback.format_exc()}"
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,32 @@
|
||||
from app.domain.context import configure_tmdb_image_url_builder
|
||||
from app.domain.media import configure_search_source_provider
|
||||
from app.domain.meta.customization import configure_customization_provider
|
||||
from app.domain.meta.releasegroup import configure_release_groups_provider
|
||||
from app.domain.meta.runtime import configure_recognition_runtime
|
||||
from app.domain.meta.words import configure_custom_words_provider
|
||||
from app.domain.metainfo import clear_rust_parse_options_cache
|
||||
from app.adapters.system import rust as rust_accelerator
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.application.recognition import RecognitionRuleService
|
||||
|
||||
|
||||
def configure_domain_dependencies() -> None:
|
||||
"""在组合根集中注入领域模型需要的配置、持久化规则和加速适配器。"""
|
||||
rule_service = RecognitionRuleService()
|
||||
configure_customization_provider(rule_service.get_customization)
|
||||
configure_release_groups_provider(rule_service.get_release_groups)
|
||||
configure_custom_words_provider(rule_service.get_custom_words)
|
||||
configure_search_source_provider(lambda: settings.SEARCH_SOURCE)
|
||||
configure_tmdb_image_url_builder(settings.TMDB_IMAGE_URL)
|
||||
configure_recognition_runtime(
|
||||
media_extensions_provider=lambda: (
|
||||
*settings.RMT_MEDIAEXT,
|
||||
*settings.RMT_SUBEXT,
|
||||
*settings.RMT_AUDIOEXT,
|
||||
),
|
||||
audio_extensions_provider=lambda: settings.RMT_AUDIOEXT,
|
||||
accelerator=rust_accelerator,
|
||||
)
|
||||
clear_rust_parse_options_cache()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Managed Resource 的启动组合与进程关闭入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from app.runtime.capabilities.runtime import CapabilityRuntime
|
||||
from app.runtime.extensions.managed_resource_adapter import (
|
||||
AsyncManagedResourceAdapter,
|
||||
SyncManagedResourceAdapter,
|
||||
build_managed_resource_registry,
|
||||
)
|
||||
from app.runtime.managed_resources import (
|
||||
MANAGED_RESOURCE_ASYNC_KIND,
|
||||
MANAGED_RESOURCE_SYNC_KIND,
|
||||
configure_managed_resource_runtime,
|
||||
)
|
||||
|
||||
|
||||
_runtime_lock = threading.RLock()
|
||||
_managed_resource_runtime: Optional[CapabilityRuntime] = None
|
||||
|
||||
|
||||
def init_managed_resources() -> CapabilityRuntime:
|
||||
"""构建并注入资源 Runtime;只发现声明,不物化或启动任何资源。"""
|
||||
global _managed_resource_runtime
|
||||
with _runtime_lock:
|
||||
if _managed_resource_runtime is None:
|
||||
_managed_resource_runtime = CapabilityRuntime(
|
||||
build_managed_resource_registry(),
|
||||
adapters={
|
||||
MANAGED_RESOURCE_SYNC_KIND: SyncManagedResourceAdapter(),
|
||||
MANAGED_RESOURCE_ASYNC_KIND: AsyncManagedResourceAdapter(),
|
||||
},
|
||||
)
|
||||
configure_managed_resource_runtime(_managed_resource_runtime)
|
||||
return _managed_resource_runtime
|
||||
|
||||
|
||||
async def stop_managed_resources() -> None:
|
||||
"""关闭已经初始化的资源 Runtime;未初始化时不执行发现或激活。"""
|
||||
with _runtime_lock:
|
||||
runtime = _managed_resource_runtime
|
||||
if runtime is None:
|
||||
return
|
||||
await runtime.shutdown_async(reason="application_shutdown")
|
||||
@@ -0,0 +1,854 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
from app.adapters.cache.redis import RedisHelper, AsyncRedisHelper
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
|
||||
# SitesHelper涉及资源包拉取,提前引入并容错提示
|
||||
try:
|
||||
from app.application.site.sites import SitesHelper # noqa # pylint: disable=import-error,no-name-in-module
|
||||
except ImportError as e:
|
||||
SitesHelper = None
|
||||
error_message = f"错误: {str(e)}\n站点认证及索引相关资源导入失败,请尝试重建容器或手动拉取资源"
|
||||
print(error_message, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
from app.runtime.config import settings as legacy_settings
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.runtime.cache import AsyncFileCache, FileCache
|
||||
from app.runtime.extensions.module_manager import ModuleManager
|
||||
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.runtime.events import EventHandlerBinding, EventManager
|
||||
from app.runtime.observability import record_metric
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.runtime.settings import configure_runtime_setting_provider
|
||||
from app.runtime.thread import ThreadHelper
|
||||
from app.adapters.network.doh import DohHelper
|
||||
from app.adapters.system.resource import (
|
||||
ResourceHelper,
|
||||
configure_resource_version_provider,
|
||||
)
|
||||
from app.application.messaging.message import (
|
||||
MessageHelper,
|
||||
MessageQueueManager,
|
||||
stop_message,
|
||||
)
|
||||
from app.application.configuration import (
|
||||
RuntimeConfiguration,
|
||||
RuntimeSettingsService,
|
||||
SystemConfigService,
|
||||
get_configured_system_config,
|
||||
TransferRetryConfig,
|
||||
configure_token_runtime_config,
|
||||
configure_runtime_configuration,
|
||||
configure_runtime_settings,
|
||||
configure_system_config,
|
||||
configure_transfer_retry_config,
|
||||
)
|
||||
from app.startup.composition.configuration import (
|
||||
build_api_runtime_config,
|
||||
build_chain_runtime_config,
|
||||
build_scheduler_runtime_config,
|
||||
build_token_runtime_config,
|
||||
)
|
||||
from app.application.database import configure_database_governance
|
||||
from app.application.service import configure_service_directory
|
||||
from app.application.plugin.runtime import configure_plugin_runtime
|
||||
from app.application.module import configure_module_runtime
|
||||
from app.application.messaging.chat import (
|
||||
AgentChatPersistenceService,
|
||||
AgentChatService,
|
||||
configure_agent_chat_persistence,
|
||||
configure_agent_chat_service,
|
||||
get_configured_agent_chat_persistence,
|
||||
)
|
||||
from app.application.messaging.agent import (
|
||||
shutdown_web_agent_background_tasks,
|
||||
wait_web_agent_background_tasks,
|
||||
)
|
||||
from app.application.security.user import configure_user_lookups
|
||||
from app.application.security.auth import AuthService, configure_auth_service
|
||||
from app.application.security.passkeys import PasskeyService, configure_passkey_service
|
||||
from app.application.security.userconfig import (
|
||||
UserConfigurationService,
|
||||
configure_user_configuration,
|
||||
)
|
||||
from app.application.history import configure_transfer_history_provider
|
||||
from app.application.outbox import OutboxDispatcher, configure_outbox_dispatcher
|
||||
from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
|
||||
from app.application.site.query import SiteQueryService, configure_site_query_service
|
||||
from app.application.site.health import SiteHealthService, configure_site_health_service
|
||||
from app.application.workflow import WorkflowQueryService, configure_workflow_query
|
||||
from app.application.agentdata import configure_agent_data_ports
|
||||
from app.api.data import ApiDataPorts, configure_api_data_runtime
|
||||
from app.application.subscription.write import configure_subscribe_writer
|
||||
from app.adapters.external.server import (
|
||||
MoviePilotServerHelper,
|
||||
configure_server_application_services,
|
||||
)
|
||||
from app.application.server.report import ServerReportService
|
||||
from app.application.server.share import ServerSharingService
|
||||
from app.db.session import (
|
||||
SessionFactory,
|
||||
async_session_scope,
|
||||
close_database,
|
||||
get_async_db,
|
||||
get_db,
|
||||
)
|
||||
from app.db.worker import DatabaseWorker
|
||||
from app.db.uow import (
|
||||
SqlAlchemyAsyncUnitOfWork,
|
||||
SqlAlchemyUnitOfWork,
|
||||
configure_transaction_runners,
|
||||
)
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.db.oper.agentchat import AgentChatOper
|
||||
from app.db.oper.agenttask import AgentTaskOper
|
||||
from app.db.oper.user import UserOper
|
||||
from app.db.oper.passkey import PassKeyOper
|
||||
from app.db.oper.userconfig import UserConfigOper
|
||||
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.site import SiteOper
|
||||
from app.db.oper.message import MessageOper
|
||||
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
||||
from app.db.oper.plugindata import PluginDataOper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer
|
||||
from app.command import CommandChain
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.message import MessageType
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
from app.startup.initializers.agent import init_agent
|
||||
from app.startup.composition.database import build_database_governance
|
||||
from app.startup.initializers.managed_resources import (
|
||||
init_managed_resources,
|
||||
stop_managed_resources,
|
||||
)
|
||||
from app.db.adapters.subscription import TransactionalSubscribeWriter
|
||||
from app.startup.composition.subscription import (
|
||||
configure_transactional_subscription_scopes,
|
||||
)
|
||||
from app.db.adapters.chain import TransactionalChainDurableEventWriter
|
||||
from app.db.adapters.download import TransactionalDownloadFailureRepository
|
||||
from app.db.adapters.site import TransactionalSiteRepository
|
||||
from app.db.adapters.workflow import TransactionalWorkflowExecutionService
|
||||
from app.db.adapters.transaction import TransactionalWriteRunner
|
||||
from app.startup.composition.context import (
|
||||
AgentChatRuntime,
|
||||
AuthenticationRuntime,
|
||||
HistoryRuntime,
|
||||
HostRuntime,
|
||||
MessagingRuntime,
|
||||
PersistenceRuntime,
|
||||
SiteRuntime,
|
||||
SubscriptionRuntime,
|
||||
WorkflowRuntime,
|
||||
)
|
||||
from app.adapters.web.security.access import set_superuser_token_payload_provider
|
||||
from app.application.security.auth import build_superuser_token_payload
|
||||
from app.application.image import configure_wallpaper_providers
|
||||
from app.application.chain.context import (
|
||||
ChainRuntimeContext,
|
||||
configure_chain_runtime_context_provider,
|
||||
)
|
||||
from app.application.chain.durable_events import (
|
||||
restore_download_added,
|
||||
restore_transfer_result,
|
||||
)
|
||||
from app.application.chain.data import configure_chain_data_ports, get_chain_data_ports
|
||||
from app.runtime.extensions.service_config import (
|
||||
ServiceConfigHelper,
|
||||
configure_service_config_reader,
|
||||
)
|
||||
from app.runtime.tasks import get_task_registry
|
||||
|
||||
|
||||
_database_worker: DatabaseWorker | None = None
|
||||
|
||||
|
||||
async def stop_database_worker() -> None:
|
||||
"""停止当前进程的数据库短事务 worker。"""
|
||||
global _database_worker
|
||||
worker = _database_worker
|
||||
if worker is not None:
|
||||
await worker.shutdown()
|
||||
_database_worker = None
|
||||
|
||||
|
||||
async def _initialize_configuration_services(
|
||||
database_worker: DatabaseWorker,
|
||||
) -> None:
|
||||
"""加载完整配置快照后发布系统与用户配置服务。"""
|
||||
system_config = SystemConfigOper()
|
||||
user_config = UserConfigOper()
|
||||
await database_worker.run(system_config.load_snapshot)
|
||||
await database_worker.run(user_config.load_snapshot)
|
||||
configure_system_config(
|
||||
SystemConfigService(
|
||||
repository=system_config,
|
||||
async_executor=database_worker,
|
||||
)
|
||||
)
|
||||
configure_user_configuration(
|
||||
UserConfigurationService(
|
||||
repository=user_config,
|
||||
async_executor=database_worker,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_runtime_settings_service() -> RuntimeSettingsService:
|
||||
"""将可变部署配置实现注入管理服务,避免把兼容代理再次包装。"""
|
||||
return RuntimeSettingsService(legacy_settings)
|
||||
|
||||
|
||||
async def _async_get_subscribe(subscribe_id: int):
|
||||
"""通过数据库操作器异步读取订阅,供服务端共享用例使用。"""
|
||||
return await SubscribeOper().async_get(subscribe_id)
|
||||
|
||||
|
||||
async def _async_get_workflow(workflow_id: int):
|
||||
"""通过数据库操作器异步读取工作流,供服务端共享用例使用。"""
|
||||
return await WorkflowOper().async_get(workflow_id)
|
||||
|
||||
|
||||
def _build_chain_runtime_context() -> ChainRuntimeContext:
|
||||
"""在启动组合根创建 Chain 所需的运行时对象和数据端口。"""
|
||||
return ChainRuntimeContext(
|
||||
module_manager=ModuleManager(),
|
||||
plugin_manager=PluginManager(),
|
||||
event_manager=EventManager(),
|
||||
message_oper=MessageOper(),
|
||||
message_helper=MessageHelper(),
|
||||
file_cache=FileCache(),
|
||||
async_file_cache=AsyncFileCache(),
|
||||
message_queue_factory=lambda callback: MessageQueueManager(
|
||||
send_callback=callback
|
||||
),
|
||||
module_dispatcher_factory=ModuleInvocationDispatcher,
|
||||
configuration=build_chain_runtime_config(settings),
|
||||
data_ports=get_chain_data_ports(),
|
||||
durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory),
|
||||
)
|
||||
|
||||
|
||||
def configure_runtime_data_providers() -> None:
|
||||
"""在启动组合层装配运行时和外部服务所需的数据库读取能力。"""
|
||||
configure_service_config_reader(lambda key: get_configured_system_config().get(key))
|
||||
configure_module_runtime(lambda: ModuleManager())
|
||||
configure_plugin_runtime(lambda: PluginManager())
|
||||
configure_service_directory(
|
||||
configs=ServiceConfigHelper.get_configs,
|
||||
modules=lambda module_type: ModuleManager().get_running_type_modules(
|
||||
module_type
|
||||
),
|
||||
)
|
||||
configure_server_application_services(
|
||||
report_service=ServerReportService(
|
||||
config_reader=lambda key: get_configured_system_config().get(key),
|
||||
config_writer=lambda key, value: get_configured_system_config().set(key, value),
|
||||
async_config_writer=lambda key, value: get_configured_system_config().async_set(
|
||||
key, value
|
||||
),
|
||||
installed_plugins_provider=lambda: get_configured_system_config().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
subscribes_provider=lambda: SubscribeOper().list(),
|
||||
async_subscribes_provider=lambda: SubscribeOper().async_list(),
|
||||
plugin_report_sender=MoviePilotServerHelper.plugin_install_report,
|
||||
async_plugin_report_sender=(
|
||||
MoviePilotServerHelper.async_plugin_install_report
|
||||
),
|
||||
subscribe_report_sender=MoviePilotServerHelper.subscribe_report,
|
||||
async_subscribe_report_sender=MoviePilotServerHelper.async_subscribe_report,
|
||||
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
|
||||
),
|
||||
sharing_service=ServerSharingService(
|
||||
subscribe_provider=lambda subscribe_id: SubscribeOper().get(
|
||||
subscribe_id
|
||||
),
|
||||
async_subscribe_provider=_async_get_subscribe,
|
||||
workflow_provider=lambda workflow_id: WorkflowOper().get(workflow_id),
|
||||
async_workflow_provider=_async_get_workflow,
|
||||
user_uuid_provider=MoviePilotServerHelper.get_user_uuid,
|
||||
subscribe_sender=MoviePilotServerHelper.subscribe_share,
|
||||
async_subscribe_sender=MoviePilotServerHelper.async_subscribe_share,
|
||||
workflow_sender=MoviePilotServerHelper.workflow_share,
|
||||
async_workflow_sender=MoviePilotServerHelper.async_workflow_share,
|
||||
response_handler=MoviePilotServerHelper._handle_response,
|
||||
subscribe_cache_clearer=(
|
||||
MoviePilotServerHelper._clear_subscribe_share_cache
|
||||
),
|
||||
workflow_cache_clearer=(
|
||||
MoviePilotServerHelper._clear_workflow_share_cache
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_outbox_dispatcher() -> OutboxDispatcher:
|
||||
"""创建一次恢复批次独占的 Session、Repository 和事件 handler。"""
|
||||
def dispatch_subscribe_deleted_report(message) -> None:
|
||||
"""重放订阅删除统计;未确认时抛错以进入有限重试。"""
|
||||
if not MoviePilotServerHelper.sub_done_durable(
|
||||
message.payload.get("subscribe_info") or {}
|
||||
):
|
||||
raise RuntimeError("订阅删除统计上报未确认")
|
||||
|
||||
def dispatch_subscribe_added_report(message) -> None:
|
||||
"""重放订阅新增统计;未确认时抛错以进入有限重试。"""
|
||||
if not MoviePilotServerHelper.sub_reg_durable(
|
||||
message.payload.get("subscribe_info") or {}
|
||||
):
|
||||
raise RuntimeError("订阅新增统计上报未确认")
|
||||
|
||||
def dispatch_subscribe_complete_report(message) -> None:
|
||||
"""重放订阅完成统计;未确认时抛错以进入有限重试。"""
|
||||
if not MoviePilotServerHelper.sub_done_durable(
|
||||
message.payload.get("subscribe_info") or {}
|
||||
):
|
||||
raise RuntimeError("订阅完成统计上报未确认")
|
||||
|
||||
def dispatch_subscribe_notification(message) -> None:
|
||||
"""恢复订阅完成通知;消息快照无需重建领域对象。"""
|
||||
snapshot = message.payload.get("message") or {}
|
||||
if not isinstance(snapshot, dict):
|
||||
raise RuntimeError("订阅完成通知快照格式无效")
|
||||
CommandChain().post_message(Message.model_validate(snapshot))
|
||||
|
||||
def dispatch_subscribe_added_notification(message) -> None:
|
||||
"""恢复订阅新增通知;恢复使用提交前冻结的渲染消息快照。"""
|
||||
snapshot = message.payload.get("message") or {}
|
||||
if not isinstance(snapshot, dict):
|
||||
raise RuntimeError("订阅新增通知快照格式无效")
|
||||
CommandChain().post_message(Message.model_validate(snapshot))
|
||||
|
||||
session = SessionFactory()
|
||||
return OutboxDispatcher(
|
||||
repository=SqlAlchemyOutboxRepository(session),
|
||||
handlers={
|
||||
"subscribe.added": lambda message: EventManager().send_event(
|
||||
EventType.SubscribeAdded,
|
||||
message.payload,
|
||||
),
|
||||
"subscribe.added.report": dispatch_subscribe_added_report,
|
||||
"subscribe.added.notification": dispatch_subscribe_added_notification,
|
||||
"subscribe.modified": lambda message: EventManager().send_event(
|
||||
EventType.SubscribeModified,
|
||||
message.payload,
|
||||
),
|
||||
"subscribe.deleted": lambda message: EventManager().send_event(
|
||||
EventType.SubscribeDeleted,
|
||||
message.payload,
|
||||
),
|
||||
"subscribe.deleted.report": dispatch_subscribe_deleted_report,
|
||||
"subscribe.complete": lambda message: EventManager().send_event(
|
||||
EventType.SubscribeComplete,
|
||||
message.payload,
|
||||
),
|
||||
"subscribe.complete.report": dispatch_subscribe_complete_report,
|
||||
"subscribe.complete.notification": dispatch_subscribe_notification,
|
||||
"download.added": lambda message: EventManager().send_event(
|
||||
EventType.DownloadAdded,
|
||||
restore_download_added(message.payload),
|
||||
),
|
||||
"transfer.completed": lambda message: EventManager().send_event(
|
||||
EventType.TransferComplete,
|
||||
restore_transfer_result(message.payload),
|
||||
),
|
||||
"transfer.failed": lambda message: EventManager().send_event(
|
||||
EventType.TransferFailed,
|
||||
restore_transfer_result(message.payload),
|
||||
),
|
||||
},
|
||||
close=session.close,
|
||||
failure_observer=lambda dead: record_metric(
|
||||
"scheduler.job.dead_letter" if dead else "scheduler.job.retry",
|
||||
owner="outbox",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def configure_wallpaper_services() -> None:
|
||||
"""把需要 Chain 编排的壁纸来源注入图片服务。"""
|
||||
configure_wallpaper_providers(
|
||||
tmdb_wallpaper=lambda: TmdbChain().get_random_wallpager(),
|
||||
tmdb_wallpapers=lambda count: TmdbChain().get_trending_wallpapers(count),
|
||||
mediaserver_wallpaper=lambda: MediaServerChain().get_latest_wallpaper(),
|
||||
mediaserver_wallpapers=lambda count: MediaServerChain().get_latest_wallpapers(
|
||||
count=count
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def notify_event_error(title: str, message: str) -> None:
|
||||
"""将事件总线错误转发到系统消息通道。"""
|
||||
MessageHelper().put(
|
||||
title=title,
|
||||
message=message,
|
||||
role="system",
|
||||
)
|
||||
|
||||
|
||||
def get_host_event_handler_factories() -> dict[type, Callable[[], object]]:
|
||||
"""返回所有使用事件装饰器的宿主类及其明确实例工厂。"""
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.scraping import ScrapingChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.command import Command
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
return {
|
||||
Command: Command,
|
||||
DownloadChain: DownloadChain,
|
||||
Scheduler: Scheduler,
|
||||
ScrapingChain: ScrapingChain,
|
||||
SearchChain: SearchChain,
|
||||
SiteChain: SiteChain,
|
||||
SubscribeChain: SubscribeChain,
|
||||
WorkflowChain: WorkflowChain,
|
||||
}
|
||||
|
||||
|
||||
def configure_host_event_handler_resolver() -> None:
|
||||
"""显式登记宿主内置类处理器,禁止事件总线按类名临时构造未知对象。"""
|
||||
factories = get_host_event_handler_factories()
|
||||
|
||||
def resolve(owner_class: type) -> EventHandlerBinding | None:
|
||||
"""按明确白名单复用单例或构造与旧路径等价的 Chain 实例。"""
|
||||
factory = factories.get(owner_class)
|
||||
if factory is None:
|
||||
return None
|
||||
get_existing = getattr(owner_class, "get_existing_instance", None)
|
||||
instance = get_existing() if callable(get_existing) else None
|
||||
if instance is None:
|
||||
instance = factory()
|
||||
return EventHandlerBinding(
|
||||
instance=instance,
|
||||
owner_name=owner_class.__name__,
|
||||
)
|
||||
|
||||
EventManager().register_handler_instance_resolver("host", resolve)
|
||||
|
||||
|
||||
def start_frontend():
|
||||
"""
|
||||
启动前端服务
|
||||
"""
|
||||
# 仅Windows可执行文件支持内嵌nginx
|
||||
if not SystemUtils.is_frozen() \
|
||||
or not SystemUtils.is_windows():
|
||||
return
|
||||
# 临时Nginx目录
|
||||
nginx_path = settings.ROOT_PATH / 'nginx'
|
||||
if not nginx_path.exists():
|
||||
return
|
||||
# 配置目录下的Nginx目录
|
||||
run_nginx_dir = settings.CONFIG_PATH.with_name('nginx')
|
||||
if not run_nginx_dir.exists():
|
||||
# 移动到配置目录
|
||||
SystemUtils.move(nginx_path, run_nginx_dir)
|
||||
# 启动Nginx
|
||||
import subprocess
|
||||
subprocess.Popen("start nginx.exe",
|
||||
cwd=run_nginx_dir,
|
||||
shell=True)
|
||||
|
||||
|
||||
def stop_frontend():
|
||||
"""
|
||||
停止前端服务
|
||||
"""
|
||||
if not SystemUtils.is_frozen() \
|
||||
or not SystemUtils.is_windows():
|
||||
return
|
||||
import subprocess
|
||||
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)
|
||||
|
||||
|
||||
def clear_temp():
|
||||
"""
|
||||
清理临时文件和图片缓存
|
||||
"""
|
||||
# 清理临时目录中3天前的文件
|
||||
SystemUtils.clear(settings.TEMP_PATH, days=settings.TEMP_FILE_DAYS)
|
||||
# 清理图片缓存目录中7天前的文件
|
||||
SystemUtils.clear(settings.CACHE_PATH / "images", days=settings.GLOBAL_IMAGE_CACHE_DAYS)
|
||||
# 清理 pip/uv 包下载缓存,不接管整个 .cache 目录。
|
||||
clear_package_tool_cache()
|
||||
|
||||
|
||||
def clear_package_tool_cache():
|
||||
"""
|
||||
清理 pip/uv 包下载缓存,只处理 MoviePilot 管理的工具子目录。
|
||||
"""
|
||||
days = settings.PACKAGE_CACHE_DAYS
|
||||
if days <= 0:
|
||||
return
|
||||
tool_cache_root = settings.PACKAGE_CACHE_PATH
|
||||
for child in ("pip", "uv"):
|
||||
cache_path = tool_cache_root / child
|
||||
try:
|
||||
SystemUtils.clear(cache_path, days=days)
|
||||
except Exception as err:
|
||||
logger.warning("清理包下载缓存失败:%s - %s", cache_path, err)
|
||||
|
||||
|
||||
def user_auth():
|
||||
"""
|
||||
用户认证检查
|
||||
"""
|
||||
sites_helper = SitesHelper()
|
||||
if sites_helper.auth_level >= 2:
|
||||
return
|
||||
auth_conf = get_configured_system_config().get(SystemConfigKey.UserSiteAuthParams)
|
||||
status, msg = sites_helper.check_user(**auth_conf) if auth_conf else sites_helper.check_user()
|
||||
if status:
|
||||
logger.info(f"{msg} 用户认证成功")
|
||||
else:
|
||||
logger.info(f"用户认证失败,{msg}")
|
||||
|
||||
|
||||
def check_auth():
|
||||
"""
|
||||
检查认证状态
|
||||
"""
|
||||
if SitesHelper().auth_level < 2:
|
||||
err_msg = "用户认证失败,站点相关功能将无法使用!"
|
||||
MessageHelper().put(f"注意:{err_msg}", title="用户认证", role="system")
|
||||
CommandChain().post_message(
|
||||
Message(
|
||||
mtype=MessageType.Manual,
|
||||
title="MoviePilot用户认证",
|
||||
text=err_msg,
|
||||
link=settings.MP_DOMAIN('#/site')
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_resources() -> None:
|
||||
"""安装可用资源更新,并由组合根统一决定是否重启进程。"""
|
||||
sites_helper = SitesHelper()
|
||||
configure_resource_version_provider(
|
||||
lambda: (sites_helper.auth_version, sites_helper.indexer_version)
|
||||
)
|
||||
if ResourceHelper().check() is not True:
|
||||
return
|
||||
restarted, message = SystemHelper.restart()
|
||||
if not restarted:
|
||||
logger.error(f"资源更新完成但自动重启失败:{message}")
|
||||
|
||||
|
||||
def close_browser_sessions() -> None:
|
||||
"""在托管资源关闭前释放所有浏览器上下文及其工作线程。"""
|
||||
from app.adapters.network.browser import BrowserSessionHelper
|
||||
|
||||
BrowserSessionHelper.close_all_sessions()
|
||||
|
||||
|
||||
async def drain_events() -> bool:
|
||||
"""在插件卸载前等待已接收事件及其同步、异步处理器完成。"""
|
||||
event_manager = EventManager.get_existing_instance()
|
||||
if event_manager is None:
|
||||
return True
|
||||
return await event_manager.drain_async(seal=True)
|
||||
|
||||
|
||||
async def settle_events() -> bool:
|
||||
"""在插件 handler 停用后结算在途事件,但保留停机 hook 的尾事件入口。"""
|
||||
event_manager = EventManager.get_existing_instance()
|
||||
if event_manager is None:
|
||||
return True
|
||||
return await event_manager.drain_async(seal=False)
|
||||
|
||||
|
||||
async def stop_modules():
|
||||
"""
|
||||
服务关闭
|
||||
"""
|
||||
async def run_step(name: str, callback: Callable[[], object]) -> bool:
|
||||
"""单个模块资源关闭失败时继续执行后续阶段"""
|
||||
try:
|
||||
result = callback()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
return True
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("关闭%s时收到取消请求,继续执行资源收口", name)
|
||||
return False
|
||||
except Exception as err:
|
||||
logger.error(f"关闭{name}失败:{err}")
|
||||
return True
|
||||
|
||||
await run_step("模块", lambda: ModuleManager().shutdown())
|
||||
await run_step("事件消费", lambda: EventManager().stop_async())
|
||||
await run_step("浏览器会话", close_browser_sessions)
|
||||
await run_step("托管资源", stop_managed_resources)
|
||||
await run_step("DoH服务", lambda: DohHelper().shutdown())
|
||||
await run_step("线程池", lambda: ThreadHelper().shutdown())
|
||||
await run_step("消息服务", stop_message)
|
||||
await run_step("Redis缓存连接", lambda: RedisHelper().close())
|
||||
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
|
||||
# Web Agent 的取消 finally 可能还要写入最终展示快照,必须先完成任务收尾,再关闭写入准入。
|
||||
web_agent_drained = await run_step(
|
||||
"Web Agent后台任务", shutdown_web_agent_background_tasks
|
||||
)
|
||||
if not web_agent_drained:
|
||||
web_agent_drained = await run_step(
|
||||
"Web Agent后台任务收尾", wait_web_agent_background_tasks
|
||||
)
|
||||
if web_agent_drained:
|
||||
await run_step(
|
||||
"Agent会话持久化准入",
|
||||
lambda: get_configured_agent_chat_persistence().begin_shutdown(),
|
||||
)
|
||||
persistence_drained = await run_step(
|
||||
"Agent会话持久化",
|
||||
lambda: get_configured_agent_chat_persistence().shutdown(),
|
||||
)
|
||||
else:
|
||||
persistence_drained = False
|
||||
logger.error("Web Agent任务未完成收尾,跳过持久化和数据库关闭以保护活动事务")
|
||||
if persistence_drained:
|
||||
await run_step("数据库任务", stop_database_worker)
|
||||
if _database_worker is None:
|
||||
await run_step("数据库连接", close_database)
|
||||
else:
|
||||
logger.error("数据库任务未收敛,跳过数据库连接关闭以避免运行中事务使用已释放连接")
|
||||
await run_step("前端服务", stop_frontend)
|
||||
await run_step("临时文件", clear_temp)
|
||||
|
||||
|
||||
async def init_modules() -> HostRuntime:
|
||||
"""
|
||||
启动模块并返回本次 lifespan 唯一的类型化 HostRuntime。
|
||||
"""
|
||||
global _database_worker
|
||||
# 兼容 Oper 的无 Session 写入口仍由组合根持有事务,避免模型恢复自动提交。
|
||||
transaction_runner = TransactionalWriteRunner(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
)
|
||||
configure_transaction_runners(
|
||||
sync=transaction_runner.sync,
|
||||
async_=transaction_runner.async_,
|
||||
)
|
||||
database_worker = DatabaseWorker()
|
||||
await database_worker.start()
|
||||
_database_worker = database_worker
|
||||
try:
|
||||
await _initialize_configuration_services(database_worker)
|
||||
except BaseException:
|
||||
try:
|
||||
await stop_database_worker()
|
||||
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
|
||||
logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}")
|
||||
raise
|
||||
# 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。
|
||||
api_data = ApiDataPorts(
|
||||
sync_session=get_db,
|
||||
async_session=get_async_db,
|
||||
repositories={
|
||||
"download_history": DownloadHistoryOper,
|
||||
"media_server": MediaServerOper,
|
||||
"message": MessageOper,
|
||||
"passkey": PassKeyOper,
|
||||
"site": SiteOper,
|
||||
"subscribe": SubscribeOper,
|
||||
"subscribe_history": SubscribeHistoryOper,
|
||||
"transfer_history": TransferHistoryOper,
|
||||
"user": UserOper,
|
||||
"workflow": WorkflowOper,
|
||||
},
|
||||
standalone={
|
||||
"passkey": PassKeyOper,
|
||||
"system_config": SystemConfigOper,
|
||||
"user": UserOper,
|
||||
},
|
||||
unit_of_work={
|
||||
"async": SqlAlchemyAsyncUnitOfWork,
|
||||
"sync": SqlAlchemyUnitOfWork,
|
||||
},
|
||||
)
|
||||
runtime_configuration = RuntimeConfiguration(
|
||||
api=lambda: build_api_runtime_config(settings),
|
||||
scheduler=lambda: build_scheduler_runtime_config(settings),
|
||||
chain=lambda: build_chain_runtime_config(settings),
|
||||
)
|
||||
runtime_settings = _build_runtime_settings_service()
|
||||
agent_chat_persistence = AgentChatPersistenceService(
|
||||
repository=lambda session: AgentChatOper(session),
|
||||
async_executor=database_worker,
|
||||
sync_transaction=transaction_runner.sync,
|
||||
capacity=database_worker.snapshot().capacity,
|
||||
)
|
||||
host_runtime = HostRuntime(
|
||||
agent_chat=AgentChatRuntime(
|
||||
async_session=get_async_db,
|
||||
repository=AgentChatOper,
|
||||
transaction=SqlAlchemyAsyncUnitOfWork,
|
||||
persistence=agent_chat_persistence,
|
||||
),
|
||||
persistence=PersistenceRuntime(
|
||||
sync_session=get_db,
|
||||
async_session=get_async_db,
|
||||
sync_transaction=SqlAlchemyUnitOfWork,
|
||||
async_transaction=SqlAlchemyAsyncUnitOfWork,
|
||||
),
|
||||
authentication=AuthenticationRuntime(
|
||||
user_repository=UserOper,
|
||||
standalone_user=UserOper,
|
||||
system_config=SystemConfigOper,
|
||||
passkey=PassKeyOper,
|
||||
),
|
||||
messaging=MessagingRuntime(repository=MessageOper),
|
||||
history=HistoryRuntime(
|
||||
download_repository=DownloadHistoryOper,
|
||||
transfer_repository=TransferHistoryOper,
|
||||
media_server_repository=MediaServerOper,
|
||||
),
|
||||
site=SiteRuntime(repository=SiteOper),
|
||||
subscription=SubscriptionRuntime(
|
||||
async_session=get_async_db,
|
||||
repository=SubscribeOper,
|
||||
history_repository=SubscribeHistoryOper,
|
||||
transaction=SqlAlchemyAsyncUnitOfWork,
|
||||
outbox=SqlAlchemyAsyncOutboxStager,
|
||||
),
|
||||
workflow=WorkflowRuntime(
|
||||
repository=WorkflowOper,
|
||||
system_config=get_configured_system_config,
|
||||
),
|
||||
configuration=runtime_configuration,
|
||||
settings=runtime_settings,
|
||||
tasks=get_task_registry(),
|
||||
)
|
||||
configure_runtime_configuration(host_runtime.configuration)
|
||||
configure_runtime_settings(host_runtime.settings)
|
||||
configure_runtime_setting_provider(lambda key: getattr(legacy_settings, key))
|
||||
configure_token_runtime_config(lambda: build_token_runtime_config(settings))
|
||||
# 旧 app.api.data 导入只保留 ABI 转发,正式 API 依赖全部读取 HostRuntime。
|
||||
configure_api_data_runtime(api_data)
|
||||
configure_runtime_data_providers()
|
||||
workflow_execution = TransactionalWorkflowExecutionService(SessionFactory)
|
||||
configure_workflow_legacy_writer(workflow_execution)
|
||||
configure_chain_data_ports(
|
||||
site=lambda: TransactionalSiteRepository(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
),
|
||||
subscribe=lambda: SubscribeOper(),
|
||||
workflow=lambda: WorkflowOper(),
|
||||
download_history=lambda: DownloadHistoryOper(),
|
||||
transfer_history=lambda: TransferHistoryOper(),
|
||||
transfer_pending=lambda: TransferPendingOper(),
|
||||
media_server=lambda: MediaServerOper(),
|
||||
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||
SessionFactory
|
||||
),
|
||||
user=lambda: UserOper(),
|
||||
)
|
||||
configure_outbox_dispatcher(_build_outbox_dispatcher)
|
||||
configure_transfer_retry_config(
|
||||
lambda: TransferRetryConfig(
|
||||
max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES,
|
||||
)
|
||||
)
|
||||
configure_database_governance(build_database_governance())
|
||||
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
|
||||
configure_agent_chat_persistence(agent_chat_persistence)
|
||||
configure_user_lookups(
|
||||
by_id=lambda user_id: UserOper().get_by_id(user_id),
|
||||
by_name=lambda username: UserOper().get_by_name(username),
|
||||
by_channel=lambda **bindings: UserOper().get_name(**bindings),
|
||||
)
|
||||
configure_auth_service(
|
||||
AuthService(
|
||||
users=UserOper(),
|
||||
config=get_configured_system_config(),
|
||||
passkeys=PassKeyOper(),
|
||||
)
|
||||
)
|
||||
configure_passkey_service(PasskeyService(repository=PassKeyOper()))
|
||||
configure_transfer_history_provider(lambda: TransferHistoryOper())
|
||||
configure_site_query_service(SiteQueryService(repository=TransactionalSiteRepository(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
)))
|
||||
configure_site_health_service(SiteHealthService(repository=TransactionalSiteRepository(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
)))
|
||||
configure_workflow_query(WorkflowQueryService(repository=WorkflowOper()))
|
||||
configure_agent_data_ports(
|
||||
agent_chat=lambda: AgentChatOper(),
|
||||
agent_task=lambda: AgentTaskOper(),
|
||||
user=lambda: UserOper(),
|
||||
site=lambda: TransactionalSiteRepository(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
),
|
||||
subscribe=lambda: SubscribeOper(),
|
||||
subscribe_history=lambda: SubscribeHistoryOper(),
|
||||
transfer_history=lambda: TransferHistoryOper(),
|
||||
download_history=lambda: DownloadHistoryOper(),
|
||||
workflow=lambda: WorkflowOper(),
|
||||
plugin_data=lambda: PluginDataOper(),
|
||||
)
|
||||
configure_subscribe_writer(
|
||||
lambda: TransactionalSubscribeWriter(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
)
|
||||
)
|
||||
configure_transactional_subscription_scopes()
|
||||
# 托管资源只在这里装配声明与 adapter,具体资源仍由首个消费者显式激活。
|
||||
init_managed_resources()
|
||||
# 应用服务不反向依赖 Chain,由启动组合层注入壁纸来源。
|
||||
configure_wallpaper_services()
|
||||
# Chain 无参兼容入口由组合根明确提供依赖上下文;测试和新代码可直接注入替代上下文。
|
||||
configure_chain_runtime_context_provider(_build_chain_runtime_context)
|
||||
# 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。
|
||||
set_superuser_token_payload_provider(build_superuser_token_payload)
|
||||
# DoH
|
||||
DohHelper()
|
||||
# 站点管理
|
||||
SitesHelper()
|
||||
# 资源适配器只负责下载安装,是否重启由启动组合层决定。
|
||||
update_resources()
|
||||
# 用户认证
|
||||
user_auth()
|
||||
# 事件错误通知由启动组合层接入消息服务。
|
||||
EventManager().set_error_notifier(notify_event_error)
|
||||
# 宿主类处理器在启动层显式登记,事件总线不再兜底 owner_class()。
|
||||
configure_host_event_handler_resolver()
|
||||
# 加载模块
|
||||
ModuleManager()
|
||||
# 启动事件消费
|
||||
EventManager().start()
|
||||
# 初始化共享服务端状态
|
||||
await MoviePilotServerHelper.async_init_plugin_report()
|
||||
await MoviePilotServerHelper.async_init_subscribe_report()
|
||||
MoviePilotServerHelper.get_user_uuid()
|
||||
MoviePilotServerHelper.get_github_user()
|
||||
# 初始化AI智能体
|
||||
await init_agent()
|
||||
# 启动前端服务
|
||||
start_frontend()
|
||||
# 检查认证状态
|
||||
check_auth()
|
||||
return host_runtime
|
||||
@@ -0,0 +1,26 @@
|
||||
from app.monitor import Monitor
|
||||
from app.runtime.execution import run_in_threadpool_to_completion
|
||||
|
||||
|
||||
def init_monitor() -> None:
|
||||
"""初始化监控器;复用单例时必须显式开启新的应用 lifespan。"""
|
||||
monitor = Monitor.get_existing_instance()
|
||||
if monitor is None:
|
||||
Monitor()
|
||||
return
|
||||
if not monitor.lifecycle_closed:
|
||||
return
|
||||
if not monitor.reopen(timeout=Monitor.RELOAD_STOP_TIMEOUT):
|
||||
raise RuntimeError("旧目录监控 owner 未收敛,无法开启新生命周期")
|
||||
if not monitor.init(timeout=Monitor.RELOAD_STOP_TIMEOUT):
|
||||
raise RuntimeError("目录监控初始化失败")
|
||||
|
||||
|
||||
async def stop_monitor(timeout: float = Monitor.LIFECYCLE_CLOSE_TIMEOUT) -> bool:
|
||||
"""在线程池里永久关闭监控器,取消后仍等待同步 owner 收敛到终态。"""
|
||||
monitor = Monitor.get_existing_instance()
|
||||
if monitor is None:
|
||||
return True
|
||||
return bool(
|
||||
await run_in_threadpool_to_completion(monitor.close, timeout=timeout)
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.runtime.compat.diagnostics import (
|
||||
configure_legacy_import_diagnostics,
|
||||
scan_plugin_legacy_imports,
|
||||
)
|
||||
from app.runtime.compat.resource_imports import scan_plugin_resource_imports
|
||||
from app.application.plugin.routes import register_plugin_api
|
||||
from app.runtime.config import global_vars
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.runtime.extensions.plugin_manager import (
|
||||
PluginManager,
|
||||
configure_plugin_catalog_factory,
|
||||
configure_plugin_install_reporter,
|
||||
configure_plugin_legacy_import_services,
|
||||
configure_plugin_resource_import_preparer,
|
||||
configure_site_auth_level_provider,
|
||||
)
|
||||
from app.runtime.execution import run_in_threadpool_to_completion
|
||||
from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult
|
||||
from app.application.plugin.catalog import PluginCatalogService
|
||||
from app.application.plugin.data import DeletePluginDataCommand
|
||||
from app.adapters.external.plugin.client import PluginMarketClient
|
||||
from app.runtime.extensions.plugin.storage import (
|
||||
PluginStorage,
|
||||
configure_plugin_storage,
|
||||
)
|
||||
from app.runtime.extensions.plugin.system import (
|
||||
PluginSystemServices,
|
||||
configure_plugin_system,
|
||||
)
|
||||
from app.runtime.managed_resources import acquire_managed_resource
|
||||
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.adapters.external.market import (
|
||||
PluginHelper,
|
||||
VERSION_BACKWARD_COMPATIBLE_FLAGS,
|
||||
configure_installed_plugins_provider,
|
||||
)
|
||||
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
|
||||
from app.adapters.system.plugin.manifest import dependency_manifest_status
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.db.oper.plugindata import PluginDataOper
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.db.session import SessionFactory
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
from app.runtime.log import logger
|
||||
from app.foundation.version import compare_version
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
from app.schemas.exception import PluginMutationRejectedError
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
async def _async_write_plugin_config(key, value):
|
||||
"""通过数据库操作器异步保存插件运行时配置。"""
|
||||
return await get_configured_system_config().async_set(key, value)
|
||||
|
||||
|
||||
def _delete_plugin_data(plugin_id: str) -> None:
|
||||
"""用独占同步会话执行插件重置的数据删除事务。"""
|
||||
session = SessionFactory()
|
||||
try:
|
||||
DeletePluginDataCommand(
|
||||
repository=PluginDataOper(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
).execute(plugin_id)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None:
|
||||
"""在执行旧插件顶层代码前准备其静态导入所需的宿主资源。"""
|
||||
for capability_id in scan_plugin_resource_imports(plugin_id, plugin_dir):
|
||||
acquire_managed_resource(
|
||||
capability_id,
|
||||
reason="legacy_plugin_import",
|
||||
)
|
||||
|
||||
|
||||
def configure_plugin_services() -> None:
|
||||
"""把兼容诊断、远程上报和站点认证等级装配到插件管理器。"""
|
||||
plugin_helper = PluginHelper()
|
||||
market_client = PluginMarketClient(plugin_helper)
|
||||
configure_plugin_legacy_import_services(
|
||||
diagnostics_configurator=configure_legacy_import_diagnostics,
|
||||
import_scanner=scan_plugin_legacy_imports,
|
||||
)
|
||||
configure_plugin_resource_import_preparer(_prepare_legacy_plugin_import)
|
||||
configure_plugin_install_reporter(MoviePilotServerHelper.install_plugin_reg)
|
||||
configure_site_auth_level_provider(lambda: SitesHelper().auth_level)
|
||||
configure_installed_plugins_provider(
|
||||
lambda: get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
)
|
||||
configure_plugin_catalog_factory(_build_plugin_catalog)
|
||||
configure_plugin_system(PluginSystemServices(
|
||||
market=market_client,
|
||||
package=PluginPackageManager(plugin_helper),
|
||||
dependency=PluginDependencyInstaller(
|
||||
plugin_helper,
|
||||
installed_plugins_provider=lambda: get_configured_system_config().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
plugin_dir=Path(settings.ROOT_PATH) / "app" / "plugins",
|
||||
),
|
||||
dependency_manifest_status=dependency_manifest_status,
|
||||
compatible_flags=lambda flag: (
|
||||
[flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, [])
|
||||
if flag else []
|
||||
),
|
||||
frozen=SystemUtils.is_frozen,
|
||||
))
|
||||
configure_plugin_storage(PluginStorage(
|
||||
read=lambda key: get_configured_system_config().get(key),
|
||||
write=lambda key, value: get_configured_system_config().set(key, value),
|
||||
async_write=_async_write_plugin_config,
|
||||
delete=lambda key: get_configured_system_config().delete(key),
|
||||
delete_data=_delete_plugin_data,
|
||||
))
|
||||
|
||||
|
||||
def _build_plugin_catalog(manager: PluginManager) -> PluginCatalogService:
|
||||
"""在组合根连接目录用例、市场客户端、持久化读取和插件 DTO 映射。"""
|
||||
client = PluginMarketClient()
|
||||
return PluginCatalogService(
|
||||
market_loader=client.get_plugins,
|
||||
async_market_loader=client.async_get_plugins,
|
||||
installed_plugins_provider=lambda: get_configured_system_config().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
plugin_mapper=manager._process_plugin_info,
|
||||
is_local_repo=PluginMarketClient.is_local_repo_url,
|
||||
version_compare=compare_version,
|
||||
warning=logger.warning,
|
||||
error=logger.error,
|
||||
)
|
||||
|
||||
|
||||
async def sync_plugins() -> bool:
|
||||
"""
|
||||
初始化安装插件,并动态注册后台任务及API
|
||||
"""
|
||||
plugin_manager = None
|
||||
try:
|
||||
loop = global_vars.loop
|
||||
plugin_manager = PluginManager()
|
||||
with plugin_manager.mutation("启动后同步插件"):
|
||||
configure_plugin_services()
|
||||
plugin_manager.set_plugin_settling(True)
|
||||
return await _sync_plugins_admitted(plugin_manager, loop)
|
||||
except PluginMutationRejectedError as error:
|
||||
logger.warning(str(error))
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"插件初始化过程中出现异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _sync_plugins_admitted(plugin_manager: PluginManager, loop) -> bool:
|
||||
"""在一个 admission lease 内完成包、依赖、实例和动态路由同步。"""
|
||||
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
|
||||
dependency_result = await (
|
||||
plugin_manager.async_install_plugin_missing_dependencies_with_status()
|
||||
)
|
||||
if dependency_result is None:
|
||||
return False
|
||||
if not isinstance(dependency_result, PluginDependencyInstallResult):
|
||||
logger.error("缺失依赖项安装返回了无效结果,跳过插件重新初始化")
|
||||
return False
|
||||
previous_statuses = plugin_manager.get_plugin_runtime_statuses()
|
||||
classification = plugin_manager.classify_plugins()
|
||||
plugin_manager.apply_plugin_dependency_classification(classification)
|
||||
if not dependency_result.success:
|
||||
logger.error("缺失依赖项安装未完成,将继续激活当前已就绪插件")
|
||||
changed_ids = await execute_task(
|
||||
loop,
|
||||
lambda: _activate_ready_plugins(
|
||||
plugin_manager,
|
||||
classification.ready,
|
||||
sync_result or [],
|
||||
previous_statuses,
|
||||
),
|
||||
"插件运行态激活",
|
||||
)
|
||||
if changed_ids is None:
|
||||
return False
|
||||
|
||||
if not changed_ids:
|
||||
logger.debug("没有新的插件进入可运行状态")
|
||||
return False
|
||||
|
||||
for plugin_id in changed_ids:
|
||||
register_plugin_api(plugin_id)
|
||||
if dependency_result.success:
|
||||
logger.info(f"后台插件加载完成,共处理 {len(changed_ids)} 个插件")
|
||||
else:
|
||||
logger.warning(
|
||||
f"缺失依赖项仍未全部恢复,已激活 {len(changed_ids)} 个就绪插件"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _activate_ready_plugins(
|
||||
plugin_manager: PluginManager,
|
||||
ready_ids: tuple[str, ...],
|
||||
synced_ids: list[str],
|
||||
previous_statuses: dict[str, PluginRuntimeStatus],
|
||||
) -> list[str]:
|
||||
"""在线程池中完成插件导入和初始化,避免阻塞 Web 事件循环。"""
|
||||
running_ids = set(plugin_manager.running_plugins)
|
||||
synced = set(synced_ids)
|
||||
changed_ids: list[str] = []
|
||||
for plugin_id in ready_ids:
|
||||
dependency_recovered = (
|
||||
previous_statuses.get(plugin_id)
|
||||
is PluginRuntimeStatus.DEPENDENCY_PENDING
|
||||
)
|
||||
if plugin_id in running_ids and (plugin_id in synced or dependency_recovered):
|
||||
plugin_manager.reload_plugin(plugin_id)
|
||||
changed_ids.append(plugin_id)
|
||||
continue
|
||||
if plugin_id not in running_ids:
|
||||
plugin_manager.start(plugin_id)
|
||||
changed_ids.append(plugin_id)
|
||||
return changed_ids
|
||||
|
||||
|
||||
async def quiesce_plugins(timeout: float = 240.0) -> bool:
|
||||
"""封口插件变更并停用 handler,保留超时 Future 的运行所有权。"""
|
||||
plugin_manager = PluginManager.get_existing_instance()
|
||||
if plugin_manager is None:
|
||||
return True
|
||||
return await plugin_manager.quiesce_plugins(timeout=timeout)
|
||||
|
||||
|
||||
async def quiesce_plugin_services(timeout: float = 240.0) -> bool:
|
||||
"""在事件结算后有界执行旧插件 close、stop_service hook。"""
|
||||
plugin_manager = PluginManager.get_existing_instance()
|
||||
if plugin_manager is None:
|
||||
return True
|
||||
return await plugin_manager.quiesce_plugin_services(timeout=timeout)
|
||||
|
||||
|
||||
def finalize_plugins() -> bool:
|
||||
"""在事件屏障封口后卸载已停用 handler 的插件实例。"""
|
||||
plugin_manager = PluginManager.get_existing_instance()
|
||||
if plugin_manager is None:
|
||||
return True
|
||||
return bool(plugin_manager.finalize_plugins())
|
||||
|
||||
|
||||
async def execute_task(loop, task_func, task_name):
|
||||
"""
|
||||
执行后台任务;取消调用方时仍持有同步线程直到真实完成。
|
||||
"""
|
||||
try:
|
||||
# loop 参数属于既有调用 ABI;同步执行改由 completion-aware 适配器持有,
|
||||
# 避免外层 Task 被取消后把仍在修改插件源码/依赖的线程伪装成已结束。
|
||||
del loop
|
||||
result = await run_in_threadpool_to_completion(task_func)
|
||||
if isinstance(result, PluginDependencyInstallResult):
|
||||
processed_count = len(result.missing)
|
||||
elif isinstance(result, list):
|
||||
processed_count = len(result)
|
||||
else:
|
||||
processed_count = 0
|
||||
if processed_count:
|
||||
logger.debug(f"{task_name} 已完成,共处理 {processed_count} 个项目")
|
||||
else:
|
||||
logger.debug(f"没有新的 {task_name} 需要处理")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"{task_name} 时发生错误:{e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def init_plugins():
|
||||
"""
|
||||
初始化插件
|
||||
"""
|
||||
configure_plugin_services()
|
||||
plugin_manager = PluginManager()
|
||||
if not plugin_manager.reopen_plugins():
|
||||
raise RuntimeError("上一应用生命周期的插件后台服务仍未收敛")
|
||||
classification = plugin_manager.classify_plugins()
|
||||
plugin_manager.apply_plugin_dependency_classification(classification)
|
||||
plugin_manager.set_plugin_settling(True)
|
||||
for plugin_id in classification.ready:
|
||||
plugin_manager.start(plugin_id)
|
||||
register_plugin_api()
|
||||
plugin_manager.start_monitor(reopen=True)
|
||||
logger.info(
|
||||
"插件启动分类:立即加载=%s,等待依赖=%s,等待源码=%s",
|
||||
len(classification.ready),
|
||||
len(classification.missing_dependencies),
|
||||
len(classification.missing_source),
|
||||
)
|
||||
|
||||
|
||||
def stop_plugin_monitor(timeout: float = 5.0) -> bool:
|
||||
"""封口已创建管理器的文件监控线程,并返回是否完成收口。"""
|
||||
plugin_manager = PluginManager.get_existing_instance()
|
||||
if plugin_manager is None:
|
||||
return True
|
||||
try:
|
||||
return bool(plugin_manager.close_monitor(timeout=timeout))
|
||||
except Exception as e:
|
||||
logger.error(f"停止插件文件监控时发生错误:{e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def stop_plugins() -> bool:
|
||||
"""停止已创建的插件监控和运行实例,不在停机阶段反向物化管理器。"""
|
||||
try:
|
||||
plugin_manager = PluginManager.get_existing_instance()
|
||||
if plugin_manager is None:
|
||||
return True
|
||||
monitor_stopped = True
|
||||
try:
|
||||
monitor_stopped = plugin_manager.stop_monitor()
|
||||
finally:
|
||||
plugin_manager.stop()
|
||||
return bool(monitor_stopped)
|
||||
except Exception as e:
|
||||
logger.error(f"停止插件时发生错误:{e}", exc_info=True)
|
||||
return False
|
||||
@@ -0,0 +1,23 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
def init_routers(app: FastAPI, api_prefix: str = "/api/v1"):
|
||||
"""
|
||||
初始化路由
|
||||
|
||||
:param app: 需要挂载路由的 FastAPI 应用
|
||||
:param api_prefix: v1 API 根路径,由启动组合根传入
|
||||
"""
|
||||
from app.api.router_specs import API_V1_ROUTER_SPECS
|
||||
from app.api.servarr import arr_router
|
||||
from app.api.servcookie import cookie_router
|
||||
# 直接聚合端点路由,避免先构建兼容路由器再克隆到最终应用。
|
||||
for spec in API_V1_ROUTER_SPECS:
|
||||
app.include_router(
|
||||
spec.router,
|
||||
prefix=f"{api_prefix}{spec.prefix}",
|
||||
tags=list(spec.tags),
|
||||
)
|
||||
# Radarr、Sonarr路由
|
||||
app.include_router(arr_router, prefix="/api/v3")
|
||||
# CookieCloud路由
|
||||
app.include_router(cookie_router, prefix="/cookiecloud")
|
||||
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
|
||||
from app.application.scheduling import register_scheduler_class
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
# 导入期即向 application 门面注册调度器类,保证工具调用时不依赖静态边。
|
||||
register_scheduler_class(Scheduler)
|
||||
|
||||
|
||||
def init_scheduler():
|
||||
"""
|
||||
初始化定时器
|
||||
"""
|
||||
Scheduler().init()
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""
|
||||
停止定时器;生命周期事件循环中返回可等待的收口协程。
|
||||
"""
|
||||
scheduler = Scheduler()
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
scheduler.stop()
|
||||
return None
|
||||
return scheduler.stop_async()
|
||||
|
||||
|
||||
def restart_scheduler():
|
||||
"""
|
||||
重启定时器
|
||||
"""
|
||||
Scheduler().init()
|
||||
|
||||
|
||||
def init_plugin_scheduler():
|
||||
"""
|
||||
初始化插件定时器
|
||||
"""
|
||||
Scheduler().init_plugin_jobs()
|
||||
@@ -0,0 +1,25 @@
|
||||
from app.chain.transfer import TransferChain
|
||||
|
||||
|
||||
def replay_pending_transfers():
|
||||
"""
|
||||
回放上次进程退出时仍未整理完的文件。
|
||||
|
||||
整理队列是纯内存的,挂载挂死后的人工重启、版本升级、OOM、宿主重启都会让
|
||||
队列连同「这些文件还没整理」这个事实一起蒸发;而已稳定落地的文件不会再产生
|
||||
任何监控事件,也不会有新的补偿扫描起点,结果就是永久漏件。
|
||||
回放本身在后台线程执行,不阻塞启动流程。
|
||||
"""
|
||||
TransferChain().replay_pending()
|
||||
|
||||
|
||||
async def stop_transfer_runtime(timeout_seconds: float = 30.0) -> bool:
|
||||
"""关闭已存在的整理后台 owner,且不在关停阶段创建新的整理链实例。
|
||||
|
||||
:param timeout_seconds: worker 与 pending 回放共享的最大等待秒数
|
||||
:return: 没有已创建实例或所有整理后台 owner 均已收敛时返回 True
|
||||
"""
|
||||
transfer_chain = TransferChain.get_existing_instance()
|
||||
if transfer_chain is None:
|
||||
return True
|
||||
return await transfer_chain.close(timeout_seconds=timeout_seconds)
|
||||
@@ -0,0 +1,15 @@
|
||||
from app.workflow import WorkFlowManager
|
||||
|
||||
|
||||
def init_workflow():
|
||||
"""
|
||||
初始化工作流
|
||||
"""
|
||||
WorkFlowManager()
|
||||
|
||||
|
||||
def stop_workflow():
|
||||
"""
|
||||
停止工作流
|
||||
"""
|
||||
WorkFlowManager().stop()
|
||||
Reference in New Issue
Block a user