Files
MoviePilot/app/application/plugin/identity_migration.py
T

206 lines
7.0 KiB
Python

"""存量插件来源身份的一次性启动迁移。"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, replace
from datetime import datetime
from typing import Protocol
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginIdentityConflictError,
PluginMarketAvailability,
PluginSourceCandidate,
TrustedPluginSourceType,
normalize_physical_plugin_id,
plan_legacy_plugin_identity,
)
from app.application.plugin.source import CandidateInventory
from app.runtime.log import logger
InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]]
InstalledPluginsReader = Callable[[], list[str]]
VirtualInstancePredicate = Callable[[str], bool]
class PluginIdentityMigrationPersistence(Protocol):
"""存量来源迁移所需的最小异步持久化端口。"""
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
"""读取一个物理插件的当前身份。"""
async def migrate_identity(
self,
identity: PluginIdentity,
*,
expected_revision: int | None,
) -> PluginIdentity:
"""创建尚不存在的存量身份。"""
async def bind_online_identity(
self,
identity: PluginIdentity,
*,
expected_revision: int,
) -> PluginIdentity:
"""把 legacy_unbound 身份绑定到已确认的在线来源。"""
@dataclass(frozen=True, slots=True)
class PluginIdentityMigrationResult:
"""一次迁移批次创建、绑定和跳过的物理插件数量。"""
created: int = 0
bound: int = 0
unbound: int = 0
skipped: int = 0
class PluginIdentityMigrationService:
"""在任何自动更新前为已安装物理插件建立最小来源身份。"""
def __init__(
self,
*,
persistence: PluginIdentityMigrationPersistence,
inventory: InventoryProvider,
installed_plugins: InstalledPluginsReader,
is_virtual_instance: VirtualInstancePredicate,
clock: Callable[[], datetime],
) -> None:
"""保存库存、安装清单、虚拟实例判定和 CAS 端口。"""
self.__persistence = persistence
self.__inventory = inventory
self.__installed_plugins = installed_plugins
self.__is_virtual_instance = is_virtual_instance
self.__clock = clock
async def migrate(self) -> PluginIdentityMigrationResult:
"""幂等迁移全部存量身份;数据库异常会阻止后续自动更新。"""
inventory = await self.__inventory(False)
created = 0
bound = 0
unbound = 0
skipped = 0
seen: set[str] = set()
for plugin_id in self.__installed_plugins() or []:
try:
normalized_id = normalize_physical_plugin_id(plugin_id)
except ValueError as error:
logger.warning("跳过插件 %s 的存量来源迁移:%s", plugin_id, error)
skipped += 1
continue
if normalized_id in seen or self.__is_virtual_instance(plugin_id):
skipped += 1
continue
seen.add(normalized_id)
candidates = inventory.candidates_for(plugin_id)
planned = plan_legacy_plugin_identity(
plugin_id=plugin_id,
market_availability=(
PluginMarketAvailability.AVAILABLE
if inventory.can_use_for_tofu
else PluginMarketAvailability.UNAVAILABLE
),
online_candidates=tuple(
PluginSourceCandidate(
source_type=candidate.source_type,
source_key=candidate.source_key,
)
for candidate in candidates
),
is_virtual_instance=False,
now=self.__clock(),
)
existing = await self.__persistence.get_identity(normalized_id)
if planned is None:
skipped += 1
continue
if existing is None:
try:
migrated = await self.__persistence.migrate_identity(
planned,
expected_revision=None,
)
except PluginIdentityConflictError:
if await self.__persistence.get_identity(plugin_id) is None:
raise
skipped += 1
continue
created += 1
if migrated.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
unbound += 1
else:
bound += 1
continue
if (
existing.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND
or planned.trusted_source_type is TrustedPluginSourceType.UNKNOWN
):
skipped += 1
continue
target = replace(
planned,
plugin_id=existing.plugin_id,
normalized_plugin_id=existing.normalized_plugin_id,
revision=existing.revision + 1,
created_at=existing.created_at,
updated_at=self.__clock(),
)
try:
await self.__persistence.bind_online_identity(
target,
expected_revision=existing.revision,
)
except PluginIdentityConflictError:
current = await self.__persistence.get_identity(plugin_id)
if current is None or current.revision == existing.revision:
raise
skipped += 1
continue
bound += 1
result = PluginIdentityMigrationResult(
created=created,
bound=bound,
unbound=unbound,
skipped=skipped,
)
logger.info(
"插件来源身份迁移完成:创建=%s,已绑定=%s,未绑定=%s,跳过=%s",
result.created,
result.bound,
result.unbound,
result.skipped,
)
return result
_IDENTITY_MIGRATION_SERVICE: list[PluginIdentityMigrationService] = []
def configure_plugin_identity_migration(
service: PluginIdentityMigrationService,
) -> None:
"""由组合根登记当前 lifespan 的存量身份迁移服务。"""
_IDENTITY_MIGRATION_SERVICE.clear()
_IDENTITY_MIGRATION_SERVICE.append(service)
def get_plugin_identity_migration() -> PluginIdentityMigrationService:
"""返回已装配迁移服务;缺失时拒绝绕过来源迁移。"""
if not _IDENTITY_MIGRATION_SERVICE:
raise RuntimeError("插件来源身份迁移服务尚未完成初始化")
return _IDENTITY_MIGRATION_SERVICE[0]
def reset_plugin_identity_migration() -> None:
"""清除当前 lifespan 的迁移服务,供测试和停机复位。"""
_IDENTITY_MIGRATION_SERVICE.clear()