Files
MoviePilot/app/application/plugin/migration.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

246 lines
8.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
存量插件来源身份的幂等启动迁移。
"""
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:
"""
读取一个物理插件的当前身份。
:param plugin_id: 目标插件的物理标识
:return: 该物理插件的当前来源身份;尚未建立时为 None
"""
async def migrate_identity(
self,
identity: PluginIdentity,
*,
expected_revision: int | None,
) -> PluginIdentity:
"""
创建尚不存在的存量身份。
:param identity: 拟创建或替换的完整来源身份
:param expected_revision: 预期的当前身份版本;None 表示只允许首次创建
:return: 成功持久化的存量插件来源身份
"""
async def bind_online_identity(
self,
identity: PluginIdentity,
*,
expected_revision: int,
) -> PluginIdentity:
"""
把 legacy_unbound 身份绑定到已确认的在线来源。
:param identity: 拟创建或替换的完整来源身份
:param expected_revision: 当前已存在身份的预期版本,用于拒绝并发更新
:return: 已绑定到可信在线来源的新版本身份
"""
@dataclass(frozen=True, slots=True)
class PluginIdentityMigrationResult:
"""
一次迁移批次创建、绑定和跳过的物理插件数量。
:param created: 本轮首次创建的来源身份数量
:param bound: 本轮成功绑定到在线来源的身份数量
:param unbound: 本轮新建但尚未绑定在线来源的身份数量
:param skipped: 本轮因无需处理、身份竞争或候选不足而跳过的数量
"""
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()