mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-15 12:50:20 +08:00
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""
|
||
统一插件安装安装网关。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||
from dataclasses import dataclass, replace
|
||
from datetime import datetime
|
||
from typing import Protocol
|
||
|
||
from app.application.plugin.admission import (
|
||
PluginInstallAdmission,
|
||
PluginInstallAdmissionRequest,
|
||
PluginSourceAdmissionError,
|
||
admit_plugin_install,
|
||
)
|
||
from app.application.plugin.identity import PluginIdentity
|
||
from app.application.plugin.install import PluginInstallResult
|
||
from app.application.plugin.inventory import PLUGIN_V3_GENERATIONS
|
||
from app.application.plugin.lifecycle import PluginStartupLease, plugin_lifecycle
|
||
from app.application.plugin.source import (
|
||
Candidate,
|
||
CandidateInventory,
|
||
PluginLocalCandidate,
|
||
PluginMarketCandidate,
|
||
PluginSelection,
|
||
get_effective_local_candidate,
|
||
list_effective_online_candidates,
|
||
select_plugin_candidate,
|
||
)
|
||
from app.domain.plugin import PLUGIN_SYSTEM_VERSION_FIELD
|
||
from app.schemas.exception import PluginMutationRejectedError
|
||
|
||
InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]]
|
||
MarketRefresh = Callable[[Sequence[str]], Awaitable[None]]
|
||
IdentityReader = Callable[[str], Awaitable[PluginIdentity | None]]
|
||
CandidateCompatibility = Callable[[Candidate], tuple[bool, str]]
|
||
SourcePluginIdResolver = Callable[[str], str]
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PluginSourceInspection:
|
||
"""
|
||
前端与 Agent 选择来源所需的只读候选和当前身份快照。
|
||
|
||
:param plugin_id: 物理插件标识,不使用分身实例标识
|
||
:param inventory_complete: 所需市场与代际是否均已得到可靠读取结果
|
||
:param identity: 当前已持久化的来源身份;首次安装前可为 None
|
||
:param selection: 按来源信任策略生成的候选选择结果
|
||
:param online_candidates: 当前可供用户选择的在线候选
|
||
:param local_candidate: 当前有效本地候选;没有可用候选时为 None
|
||
"""
|
||
|
||
plugin_id: str
|
||
inventory_complete: bool
|
||
identity: PluginIdentity | None
|
||
selection: PluginSelection
|
||
online_candidates: tuple[PluginMarketCandidate, ...]
|
||
local_candidate: PluginLocalCandidate | None
|
||
|
||
|
||
class PluginInstallExecutor(Protocol):
|
||
"""
|
||
统一安装网关调用的可恢复安装执行端口。
|
||
"""
|
||
|
||
async def execute(
|
||
self,
|
||
*,
|
||
admission: PluginInstallAdmission,
|
||
release_version: str | None,
|
||
force: bool,
|
||
local_sync: bool = False,
|
||
) -> PluginInstallResult:
|
||
"""
|
||
执行已通过来源准入的插件载荷事务。
|
||
|
||
:param admission: 已确认的唯一载荷候选与目标来源身份决策
|
||
:param release_version: 指定的发布版本;None 使用候选默认版本
|
||
:param force: 是否跳过已安装版本相同等短路检查
|
||
:param local_sync: 是否只把本地载荷同步到运行目录,默认 False
|
||
:return: 安装结果、数据库和运行态提交状态,以及失败补偿详情
|
||
"""
|
||
|
||
|
||
class PluginInstallGateway:
|
||
"""
|
||
让全部插件载荷写入共享同一来源策略和事务执行器。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
inventory: InventoryProvider,
|
||
identity: IdentityReader,
|
||
candidate_compatibility: CandidateCompatibility,
|
||
executor: PluginInstallExecutor,
|
||
clock: Callable[[], datetime],
|
||
source_plugin_id: SourcePluginIdResolver,
|
||
market_refresh: MarketRefresh | None = None,
|
||
) -> None:
|
||
"""
|
||
保存候选事实、身份读取、兼容校验、事务执行、时间和源身份归一端口。
|
||
|
||
:param market_refresh: 只重读指定市场索引的端口;未注入时沿用缓存索引
|
||
"""
|
||
self.__source_plugin_id = source_plugin_id
|
||
self.__inventory = inventory
|
||
self.__identity = identity
|
||
self.__candidate_compatibility = candidate_compatibility
|
||
self.__executor = executor
|
||
self.__clock = clock
|
||
self.__market_refresh = market_refresh
|
||
|
||
async def install(
|
||
self,
|
||
*,
|
||
plugin_id: str,
|
||
repo_url: str | None,
|
||
package_version: str | None = None,
|
||
release_version: str | None = None,
|
||
force: bool = False,
|
||
explicit_source: bool = False,
|
||
source_change: bool = False,
|
||
expected_revision: int | None = None,
|
||
startup_token: PluginStartupLease | None = None,
|
||
local_sync: bool = False,
|
||
) -> PluginInstallResult:
|
||
"""
|
||
读取冻结库存并执行一次不能绕过来源身份的插件写入。
|
||
|
||
先把分身归一到源插件,理由与来源勘察相同:安装包只登记在源插件名下,拿
|
||
分身自身 ID 去查候选必然落空。界面上分身卡片的「版本历史」与「关于」两条
|
||
入口都会把分身自己的 ID 送进来。
|
||
|
||
归一必须早于生命周期占位:``hold`` 按 ID 互斥,用分身 ID 占位不会把同源
|
||
分身的并发安装串起来,它们会同时改写同一份源插件载荷。
|
||
|
||
``force`` 且没有点名 Release 版本时,先把该插件出现过的市场索引重读一次:
|
||
Release tag 由索引里的 version 拼出,沿用缓存索引会把同一个旧版本重装一遍
|
||
还提示更新成功。重读只覆盖这些仓库,其余市场仍走缓存。
|
||
"""
|
||
plugin_id = self.__source_plugin_id(plugin_id)
|
||
try:
|
||
# 安装 ``force`` 只控制载荷覆盖;远程市场索引由市场手动刷新和
|
||
# 定时缓存任务维护,不能让单个插件更新放大全部仓库请求。
|
||
inventory = await self.__inventory(False)
|
||
async with plugin_lifecycle.hold(plugin_id, startup_token):
|
||
identity = await self.__identity(plugin_id)
|
||
markets = _plugin_market_urls(inventory, plugin_id)
|
||
if (
|
||
force
|
||
and release_version is None
|
||
and markets
|
||
and self.__market_refresh is not None
|
||
):
|
||
await self.__market_refresh(markets)
|
||
# 重读一次完整库存:只有限定仓库的缓存被替换,其余仍是缓存命中,
|
||
# 且准入必须按配置市场集合判断库存是否完整。
|
||
inventory = await self.__inventory(False)
|
||
admission = admit_plugin_install(
|
||
inventory,
|
||
request=PluginInstallAdmissionRequest(
|
||
plugin_id=plugin_id,
|
||
generations=_generation_order(package_version),
|
||
requested_repo_url=repo_url,
|
||
explicit_source=explicit_source,
|
||
source_change=source_change,
|
||
expected_revision=expected_revision,
|
||
),
|
||
identity=identity,
|
||
now=self.__clock(),
|
||
)
|
||
compatible, message = self.__candidate_compatibility(
|
||
self._compatibility_candidate(admission.candidate, release_version)
|
||
)
|
||
if not compatible:
|
||
raise PluginSourceAdmissionError(
|
||
message or "插件包与当前 MoviePilot 版本不兼容"
|
||
)
|
||
# 本地同步是最终准入候选的执行属性,不能由调用前的 URL 推断。
|
||
return await self.__executor.execute(
|
||
admission=admission,
|
||
release_version=release_version,
|
||
force=force,
|
||
local_sync=isinstance(
|
||
admission.candidate,
|
||
PluginLocalCandidate,
|
||
),
|
||
)
|
||
except PluginMutationRejectedError as error:
|
||
return PluginInstallResult(success=False, message=str(error), failure_stage="admission")
|
||
except (TypeError, ValueError, PluginSourceAdmissionError) as error:
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=str(error),
|
||
failure_stage="source_admission",
|
||
)
|
||
|
||
@staticmethod
|
||
def _compatibility_candidate(candidate: Candidate, release_version: str | None) -> Candidate:
|
||
"""
|
||
历史远端Release的宿主范围交给实际版本选择,保留市场的来源和运行时要求。
|
||
|
||
:param candidate: 已通过来源与代际准入的冻结候选
|
||
:param release_version: 请求安装的Release版本
|
||
:return: 仅兼容校验使用的候选视图,不修改事务使用的原候选
|
||
"""
|
||
if (not release_version or release_version == candidate.plugin_version
|
||
or isinstance(candidate, PluginLocalCandidate) or not isinstance(candidate.dto, Mapping)):
|
||
return candidate
|
||
metadata = dict(candidate.dto)
|
||
metadata.pop(PLUGIN_SYSTEM_VERSION_FIELD, None)
|
||
return replace(candidate, dto=metadata)
|
||
|
||
async def inspect_source(
|
||
self,
|
||
*,
|
||
plugin_id: str,
|
||
package_version: str | None = None,
|
||
force: bool = False,
|
||
) -> PluginSourceInspection:
|
||
"""
|
||
读取与真实安装相同的库存和身份,返回脱敏来源选择快照。
|
||
|
||
先把分身归一到源插件:分身只是共享同一份源码的运行实例,安装包与来源身份
|
||
都只登记在源插件名下,拿分身自身 ID(源插件 ID 加后缀)去查库存必然落空,
|
||
界面上表现为「没有找到插件 X 的可用安装包」。
|
||
"""
|
||
plugin_id = self.__source_plugin_id(plugin_id)
|
||
inventory = await self.__inventory(force)
|
||
identity = await self.__identity(plugin_id)
|
||
generations = _generation_order(package_version)
|
||
selection = select_plugin_candidate(
|
||
inventory,
|
||
plugin_id=plugin_id,
|
||
generations=generations,
|
||
identity=identity,
|
||
)
|
||
return PluginSourceInspection(
|
||
plugin_id=plugin_id,
|
||
inventory_complete=inventory.complete,
|
||
identity=identity,
|
||
selection=selection,
|
||
online_candidates=list_effective_online_candidates(
|
||
inventory,
|
||
plugin_id=plugin_id,
|
||
generations=generations,
|
||
),
|
||
local_candidate=get_effective_local_candidate(
|
||
inventory,
|
||
plugin_id=plugin_id,
|
||
generations=generations,
|
||
),
|
||
)
|
||
|
||
|
||
_plugin_install_gateway: PluginInstallGateway | None = None
|
||
|
||
|
||
def configure_plugin_install_service(gateway: PluginInstallGateway) -> None:
|
||
"""
|
||
由启动组合根发布当前 lifespan 的唯一插件安装安装网关。
|
||
"""
|
||
global _plugin_install_gateway
|
||
_plugin_install_gateway = gateway
|
||
|
||
|
||
def get_plugin_install_service() -> PluginInstallGateway:
|
||
"""
|
||
返回已装配安装网关;启动未完成时拒绝任何载荷写入。
|
||
"""
|
||
if _plugin_install_gateway is None:
|
||
raise RuntimeError("插件安装服务尚未完成初始化")
|
||
return _plugin_install_gateway
|
||
|
||
|
||
def reset_plugin_install_service() -> None:
|
||
"""
|
||
清除当前 lifespan 的安装网关,供停机和隔离测试使用。
|
||
"""
|
||
global _plugin_install_gateway
|
||
_plugin_install_gateway = None
|
||
|
||
|
||
def _plugin_market_urls(inventory: CandidateInventory, plugin_id: str) -> tuple[str, ...]:
|
||
"""
|
||
收集索引里出现该插件的市场,供强制更新只重读这些仓库。
|
||
|
||
:param inventory: 本轮已冻结的候选库存
|
||
:param plugin_id: 已归一的物理插件标识
|
||
:return: 去重且保持配置顺序的市场标识;该插件不在任何市场索引里时为空
|
||
"""
|
||
normalized = plugin_id.lower()
|
||
return tuple(dict.fromkeys(
|
||
read.market
|
||
for read in inventory.market_reads
|
||
if any(
|
||
candidate.normalized_plugin_id == normalized
|
||
for candidate in read.candidates
|
||
)
|
||
))
|
||
|
||
|
||
def _generation_order(package_version: str | None) -> tuple[str, ...]:
|
||
"""
|
||
把兼容入口的首选代际转换为来源选择优先序。
|
||
"""
|
||
normalized = (package_version or "v3").strip().lower()
|
||
if normalized in {"", "v1"}:
|
||
return ("v1",)
|
||
if normalized == "v2":
|
||
return ("v2", "v1")
|
||
if normalized == "v3":
|
||
return PLUGIN_V3_GENERATIONS
|
||
raise ValueError("插件包代际必须为 v1、v2 或 v3")
|