refactor: reorganize startup persistence boundaries

This commit is contained in:
jxxghp
2026-08-23 21:24:34 +08:00
parent 7f09927c47
commit e1509c4e0d
96 changed files with 1036 additions and 782 deletions
+1
View File
@@ -0,0 +1 @@
"""按领域组织的宿主初始化与关闭入口。"""
+270
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
from app.adapters.cache.backends import configure_platform_cache
def configure_cache_dependencies() -> None:
"""在导入使用缓存装饰器的业务模块前注册具体缓存适配器。"""
configure_platform_cache()
+28
View File
@@ -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()
+160
View File
@@ -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
+32
View File
@@ -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")
+854
View File
@@ -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
+26
View File
@@ -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)
)
+328
View File
@@ -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
+23
View File
@@ -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")
+41
View File
@@ -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()
+25
View File
@@ -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)
+15
View File
@@ -0,0 +1,15 @@
from app.workflow import WorkFlowManager
def init_workflow():
"""
初始化工作流
"""
WorkFlowManager()
def stop_workflow():
"""
停止工作流
"""
WorkFlowManager().stop()