mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
1099 lines
40 KiB
Python
1099 lines
40 KiB
Python
"""
|
||
插件市场候选事实与来源选择策略。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Iterable, Sequence
|
||
from dataclasses import dataclass, field
|
||
from enum import StrEnum
|
||
from typing import Any, TypeAlias
|
||
from urllib.parse import quote
|
||
|
||
from app.application.plugin.identity import (
|
||
PluginIdentity,
|
||
PluginPayloadSourceType,
|
||
TrustedPluginSourceType,
|
||
normalize_physical_plugin_id,
|
||
validate_online_source_key,
|
||
)
|
||
from app.application.plugin.identity import (
|
||
PluginSourceCandidate as IdentitySourceCandidate,
|
||
)
|
||
from app.foundation.version import compare_version
|
||
|
||
PLUGIN_GENERATIONS = ("v1", "v2", "v3")
|
||
LOCAL_PLUGIN_SOURCE_PREFIX = "local://"
|
||
|
||
|
||
def _build_public_local_source(plugin_id: str, package_generation: str) -> str:
|
||
"""
|
||
生成不携带宿主路径的本地候选标识。
|
||
"""
|
||
source = f"{LOCAL_PLUGIN_SOURCE_PREFIX}{quote(plugin_id, safe='')}"
|
||
if package_generation != "v1":
|
||
source += f"?version={quote(package_generation, safe='')}"
|
||
return source
|
||
|
||
|
||
class MarketReadStatus(StrEnum):
|
||
"""
|
||
一次市场索引读取的最终状态。
|
||
"""
|
||
|
||
# 索引读取成功并存在,可返回空候选集合
|
||
PRESENT = "present"
|
||
# 已确认该市场没有请求代际的索引
|
||
ABSENT = "absent"
|
||
# 索引读取失败,不能当作索引不存在
|
||
FAILED = "failed"
|
||
|
||
|
||
class PluginSelectionStatus(StrEnum):
|
||
"""
|
||
插件候选选择的可观察结果。
|
||
"""
|
||
|
||
# 已选定唯一符合来源策略的候选
|
||
SELECTED = "selected"
|
||
# 没有符合条件的可用候选
|
||
UNAVAILABLE = "unavailable"
|
||
# 多个来源存在歧义或违反既有来源绑定
|
||
CONFLICT = "conflict"
|
||
# 市场或本地读取不完整,无法安全选择
|
||
INCOMPLETE = "incomplete"
|
||
|
||
|
||
class PluginSourceSelectionError(RuntimeError):
|
||
"""
|
||
插件来源选择的策略错误。
|
||
"""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PluginMarketCandidate:
|
||
"""
|
||
一个在线市场条目的原始候选事实。
|
||
|
||
:param plugin_id: 物理插件标识,不使用分身实例标识
|
||
:param source_key: 经规范化的在线仓库身份键,格式为 github:所有者/仓库
|
||
:param source_type: 可信在线来源类型,区分官方与第三方
|
||
:param repo_url: 插件候选所在仓库的访问地址
|
||
:param package_generation: 插件包代际目录,例如 v1、v2 或 v3
|
||
:param plugin_version: 插件声明的业务版本;未提供时为 None
|
||
:param dto: 市场或本地扫描得到的原始元数据;未提供时为 None
|
||
:ivar normalized_plugin_id: 按小写规范化的物理插件身份键,由初始化自动计算,不接受构造参数
|
||
"""
|
||
|
||
plugin_id: str
|
||
source_key: str
|
||
source_type: TrustedPluginSourceType
|
||
repo_url: str
|
||
package_generation: str
|
||
plugin_version: str | None
|
||
dto: Any = None
|
||
normalized_plugin_id: str = field(init=False)
|
||
|
||
def __post_init__(self) -> None:
|
||
"""
|
||
校验候选身份,并把外部来源键归一为持久化合同使用的形式。
|
||
"""
|
||
normalized_id = normalize_physical_plugin_id(self.plugin_id)
|
||
source_type = _coerce_online_source_type(self.source_type)
|
||
source_key = validate_online_source_key(self.source_key)
|
||
# IdentitySourceCandidate 复用官方仓库与来源类型的双向约束。
|
||
IdentitySourceCandidate(source_type=source_type, source_key=source_key)
|
||
repo_url = _normalize_repo_url(self.repo_url)
|
||
package_generation = normalize_package_generation(self.package_generation)
|
||
plugin_version = _normalize_plugin_version(self.plugin_version)
|
||
object.__setattr__(self, "normalized_plugin_id", normalized_id)
|
||
object.__setattr__(self, "source_type", source_type)
|
||
object.__setattr__(self, "source_key", source_key)
|
||
object.__setattr__(self, "repo_url", repo_url)
|
||
object.__setattr__(self, "package_generation", package_generation)
|
||
object.__setattr__(self, "plugin_version", plugin_version)
|
||
|
||
@property
|
||
def payload_source_type(self) -> PluginPayloadSourceType:
|
||
"""
|
||
返回用于载荷审计的在线来源类型。
|
||
"""
|
||
return PluginPayloadSourceType(self.source_type.value)
|
||
|
||
def public_dict(self) -> dict[str, Any]:
|
||
"""
|
||
生成不携带原始元数据的公共候选投影。
|
||
"""
|
||
return {
|
||
"plugin_id": self.plugin_id,
|
||
"source_key": self.source_key,
|
||
"source_type": self.source_type.value,
|
||
"repo_url": self.repo_url,
|
||
"package_generation": self.package_generation,
|
||
"plugin_version": self.plugin_version,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PluginLocalCandidate:
|
||
"""
|
||
一个本地插件载荷候选,与在线来源身份保持独立。
|
||
|
||
:param plugin_id: 物理插件标识,不使用分身实例标识
|
||
:param repo_url: 本地载荷仓库引用,仅在宿主内部使用,不直接对外公开
|
||
:param package_generation: 插件包代际目录,例如 v1、v2 或 v3
|
||
:param plugin_version: 插件声明的业务版本;未提供时为 None
|
||
:param dto: 市场或本地扫描得到的原始元数据;未提供时为 None
|
||
:ivar normalized_plugin_id: 按小写规范化的物理插件身份键,由初始化自动计算,不接受构造参数
|
||
"""
|
||
|
||
plugin_id: str
|
||
repo_url: str
|
||
package_generation: str
|
||
plugin_version: str | None
|
||
dto: Any = None
|
||
normalized_plugin_id: str = field(init=False)
|
||
|
||
def __post_init__(self) -> None:
|
||
"""
|
||
校验本地载荷的插件与版本事实。
|
||
"""
|
||
normalized_id = normalize_physical_plugin_id(self.plugin_id)
|
||
repo_url = _normalize_repo_url(self.repo_url)
|
||
package_generation = normalize_package_generation(self.package_generation)
|
||
plugin_version = _normalize_plugin_version(self.plugin_version)
|
||
object.__setattr__(self, "normalized_plugin_id", normalized_id)
|
||
object.__setattr__(self, "repo_url", repo_url)
|
||
object.__setattr__(self, "package_generation", package_generation)
|
||
object.__setattr__(self, "plugin_version", plugin_version)
|
||
|
||
@property
|
||
def payload_source_type(self) -> PluginPayloadSourceType:
|
||
"""
|
||
返回本地载荷类型,不把本地路径伪装成在线来源。
|
||
"""
|
||
return PluginPayloadSourceType.LOCAL
|
||
|
||
@property
|
||
def source_type(self) -> PluginPayloadSourceType:
|
||
"""
|
||
返回独立的本地载荷类型,不伪造在线可信来源。
|
||
"""
|
||
return PluginPayloadSourceType.LOCAL
|
||
|
||
@property
|
||
def source_key(self) -> None:
|
||
"""
|
||
本地载荷没有可绑定的在线来源键。
|
||
"""
|
||
return None
|
||
|
||
def public_dict(self) -> dict[str, Any]:
|
||
"""
|
||
生成不包含本地仓库路径的公共候选投影。
|
||
"""
|
||
public_repo_url = _build_public_local_source(
|
||
self.plugin_id,
|
||
self.package_generation,
|
||
)
|
||
return {
|
||
"plugin_id": self.plugin_id,
|
||
"source_type": PluginPayloadSourceType.LOCAL.value,
|
||
"repo_url": public_repo_url,
|
||
"package_generation": self.package_generation,
|
||
"plugin_version": self.plugin_version,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class MarketRead:
|
||
"""
|
||
记录一个配置市场的读取状态及其全部在线候选。
|
||
|
||
:param market: 本次读取的配置市场地址
|
||
:param status: 当前市场与包代际索引的读取状态,区分存在、不存在和失败
|
||
:param candidates: 本次读取获得的插件候选集合
|
||
:param error: 读取失败的可读原因;无错误时为 None
|
||
:param package_generation: 插件包代际目录,例如 v1、v2 或 v3
|
||
"""
|
||
|
||
market: str
|
||
status: MarketReadStatus
|
||
candidates: tuple[PluginMarketCandidate, ...] = ()
|
||
error: str | None = None
|
||
package_generation: str = "v1"
|
||
|
||
def __post_init__(self) -> None:
|
||
"""
|
||
拒绝把失败读取伪装成空成功结果。
|
||
"""
|
||
market = _normalize_market(self.market)
|
||
status = MarketReadStatus(self.status)
|
||
candidates = tuple(self.candidates)
|
||
package_generation = normalize_package_generation(self.package_generation)
|
||
if status is MarketReadStatus.FAILED:
|
||
if candidates:
|
||
raise ValueError("失败的插件市场读取不能携带候选")
|
||
if not self.error or not self.error.strip():
|
||
raise ValueError("失败的插件市场读取必须保留错误说明")
|
||
else:
|
||
if self.error:
|
||
raise ValueError("已判定的插件市场读取不能携带错误说明")
|
||
if status is MarketReadStatus.ABSENT and candidates:
|
||
raise ValueError("不存在的插件市场索引不能携带候选")
|
||
if any(not isinstance(candidate, PluginMarketCandidate) for candidate in candidates):
|
||
raise TypeError("市场读取候选必须是在线插件候选")
|
||
object.__setattr__(self, "market", market)
|
||
object.__setattr__(self, "status", status)
|
||
object.__setattr__(self, "candidates", candidates)
|
||
object.__setattr__(self, "error", self.error.strip() if self.error else None)
|
||
object.__setattr__(self, "package_generation", package_generation)
|
||
|
||
@classmethod
|
||
def present(
|
||
cls,
|
||
market: str,
|
||
candidates: Iterable[PluginMarketCandidate] = (),
|
||
*,
|
||
package_generation: str = "v1",
|
||
) -> "MarketRead":
|
||
"""
|
||
构造存在的索引读取;真实空索引可以没有候选。
|
||
"""
|
||
return cls(
|
||
market=market,
|
||
status=MarketReadStatus.PRESENT,
|
||
candidates=tuple(candidates),
|
||
package_generation=package_generation,
|
||
)
|
||
|
||
@classmethod
|
||
def absent(
|
||
cls,
|
||
market: str,
|
||
*,
|
||
package_generation: str = "v1",
|
||
) -> "MarketRead":
|
||
"""
|
||
构造已确认不存在的代际索引。
|
||
"""
|
||
return cls(
|
||
market=market,
|
||
status=MarketReadStatus.ABSENT,
|
||
package_generation=package_generation,
|
||
)
|
||
|
||
@classmethod
|
||
def failure(
|
||
cls,
|
||
market: str,
|
||
error: str,
|
||
*,
|
||
package_generation: str = "v1",
|
||
) -> "MarketRead":
|
||
"""
|
||
构造失败读取,并保留可诊断但不用于选择的错误说明。
|
||
"""
|
||
return cls(
|
||
market=market,
|
||
status=MarketReadStatus.FAILED,
|
||
error=error,
|
||
package_generation=package_generation,
|
||
)
|
||
|
||
@property
|
||
def succeeded(self) -> bool:
|
||
"""
|
||
判断该索引是否得到存在或不存在的确定结论。
|
||
"""
|
||
return self.status is not MarketReadStatus.FAILED
|
||
|
||
@property
|
||
def present_index(self) -> bool:
|
||
"""
|
||
判断该代际索引是否真实存在。
|
||
"""
|
||
return self.status is MarketReadStatus.PRESENT
|
||
|
||
def public_dict(self) -> dict[str, Any]:
|
||
"""
|
||
生成市场读取的脱敏投影,保留状态、代际和候选事实。
|
||
"""
|
||
return {
|
||
"market": self.market,
|
||
"package_generation": self.package_generation,
|
||
"status": self.status.value,
|
||
"error": self.error,
|
||
"candidates": [candidate.public_dict() for candidate in self.candidates],
|
||
}
|
||
|
||
|
||
class LocalCandidateReadStatus(StrEnum):
|
||
"""
|
||
一次本地插件仓库扫描的可观察终态。
|
||
"""
|
||
|
||
# 本地扫描成功并存在仓库,可返回空候选集合
|
||
PRESENT = "present"
|
||
# 已确认不存在本地插件仓库
|
||
ABSENT = "absent"
|
||
# 本地扫描失败,候选完整性无法保证
|
||
FAILED = "failed"
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class LocalCandidateRead:
|
||
"""
|
||
记录本地候选扫描状态,避免扫描失败伪装成空仓库。
|
||
|
||
:param status: 本地仓库扫描结果,区分存在、不存在和失败
|
||
:param candidates: 本次读取获得的插件候选集合
|
||
:param error: 读取失败的可读原因;无错误时为 None
|
||
"""
|
||
|
||
status: LocalCandidateReadStatus
|
||
candidates: tuple[PluginLocalCandidate, ...] = ()
|
||
error: str | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""
|
||
保证本地扫描状态、候选和错误说明相互一致。
|
||
"""
|
||
status = LocalCandidateReadStatus(self.status)
|
||
candidates = tuple(self.candidates)
|
||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in candidates):
|
||
raise TypeError("本地扫描候选必须是 PluginLocalCandidate")
|
||
if status is LocalCandidateReadStatus.FAILED:
|
||
if candidates:
|
||
raise ValueError("失败的本地扫描不能携带候选")
|
||
if not self.error or not self.error.strip():
|
||
raise ValueError("失败的本地扫描必须保留错误说明")
|
||
else:
|
||
if self.error:
|
||
raise ValueError("已判定的本地扫描不能携带错误说明")
|
||
if status is LocalCandidateReadStatus.ABSENT and candidates:
|
||
raise ValueError("不存在的本地扫描不能携带候选")
|
||
object.__setattr__(self, "status", status)
|
||
object.__setattr__(self, "candidates", candidates)
|
||
object.__setattr__(self, "error", self.error.strip() if self.error else None)
|
||
|
||
@classmethod
|
||
def present(
|
||
cls,
|
||
candidates: Iterable[PluginLocalCandidate] = (),
|
||
) -> "LocalCandidateRead":
|
||
"""
|
||
构造扫描成功的本地候选快照,空结果仍表示扫描成功。
|
||
"""
|
||
return cls(
|
||
status=LocalCandidateReadStatus.PRESENT,
|
||
candidates=tuple(candidates),
|
||
)
|
||
|
||
@classmethod
|
||
def absent(cls) -> "LocalCandidateRead":
|
||
"""
|
||
构造没有配置本地仓库的结果。
|
||
"""
|
||
return cls(status=LocalCandidateReadStatus.ABSENT)
|
||
|
||
@classmethod
|
||
def failure(cls, error: str) -> "LocalCandidateRead":
|
||
"""
|
||
构造无法完成本地扫描的结果。
|
||
"""
|
||
return cls(status=LocalCandidateReadStatus.FAILED, error=error)
|
||
|
||
def public_dict(self) -> dict[str, Any]:
|
||
"""
|
||
生成不泄漏本地路径的扫描投影。
|
||
"""
|
||
return {
|
||
"status": self.status.value,
|
||
"error": self.error,
|
||
"candidates": [candidate.public_dict() for candidate in self.candidates],
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class CandidateInventory:
|
||
"""
|
||
一次短生命周期市场快照,保留配置市场状态和全部候选。
|
||
|
||
:param market_reads: 各配置市场与包代际的读取事实
|
||
:param local_candidates: 兼容入口提供的本地候选集合
|
||
:param expected_markets: 必须完成读取的市场集合;None 从读取事实推导
|
||
:param expected_generations: 必须完成读取的包代际集合;与 expected_markets 同时为 None 时只要求已有读取全部成功
|
||
:param local_read: 本地扫描的明确结果;None 兼容使用 local_candidates
|
||
"""
|
||
|
||
market_reads: tuple[MarketRead, ...]
|
||
local_candidates: tuple[PluginLocalCandidate, ...] = ()
|
||
expected_markets: tuple[str, ...] | None = None
|
||
expected_generations: tuple[str, ...] | None = None
|
||
local_read: LocalCandidateRead | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""
|
||
冻结快照输入,避免后续市场刷新改变选择依据。
|
||
"""
|
||
market_reads = tuple(self.market_reads)
|
||
local_candidates = tuple(self.local_candidates)
|
||
expected_markets = (
|
||
tuple(_normalize_market(market) for market in self.expected_markets)
|
||
if self.expected_markets is not None
|
||
else None
|
||
)
|
||
expected_generations = (
|
||
tuple(normalize_package_generation(generation) for generation in self.expected_generations)
|
||
if self.expected_generations is not None
|
||
else None
|
||
)
|
||
local_read = self.local_read
|
||
if local_read is None:
|
||
local_read = (
|
||
LocalCandidateRead.present(local_candidates)
|
||
if local_candidates
|
||
else LocalCandidateRead.absent()
|
||
)
|
||
if not isinstance(local_read, LocalCandidateRead):
|
||
raise TypeError("候选清单的本地读取必须由 LocalCandidateRead 组成")
|
||
if local_read.candidates != local_candidates:
|
||
raise ValueError("候选清单的本地读取与本地候选必须一致")
|
||
if any(not isinstance(read, MarketRead) for read in market_reads):
|
||
raise TypeError("候选清单必须由 MarketRead 组成")
|
||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in local_candidates):
|
||
raise TypeError("本地候选清单必须由 PluginLocalCandidate 组成")
|
||
read_keys = [(read.market, read.package_generation) for read in market_reads]
|
||
if len(read_keys) != len(set(read_keys)):
|
||
raise ValueError("候选清单不能重复记录同一个市场代际")
|
||
if expected_markets is not None and len(expected_markets) != len(set(expected_markets)):
|
||
raise ValueError("候选清单的预期市场不能重复")
|
||
if expected_generations is not None:
|
||
if not expected_generations or len(expected_generations) != len(set(expected_generations)):
|
||
raise ValueError("候选清单的预期代际必须唯一且非空")
|
||
object.__setattr__(self, "market_reads", market_reads)
|
||
object.__setattr__(self, "local_candidates", local_candidates)
|
||
object.__setattr__(self, "expected_markets", expected_markets)
|
||
object.__setattr__(self, "expected_generations", expected_generations)
|
||
object.__setattr__(self, "local_read", local_read)
|
||
|
||
@property
|
||
def configured_markets(self) -> tuple[str, ...]:
|
||
"""
|
||
按配置顺序返回本轮预期读取的市场。
|
||
"""
|
||
if self.expected_markets is not None:
|
||
return self.expected_markets
|
||
return tuple(dict.fromkeys(read.market for read in self.market_reads))
|
||
|
||
def reads_for(self, market: str) -> tuple[MarketRead, ...]:
|
||
"""
|
||
返回一个市场的全部代际读取事实。
|
||
"""
|
||
normalized_market = _normalize_market(market)
|
||
return tuple(read for read in self.market_reads if read.market == normalized_market)
|
||
|
||
def read_for(self, market: str, package_generation: str) -> MarketRead | None:
|
||
"""
|
||
返回一个市场和代际的读取事实。
|
||
"""
|
||
normalized_generation = normalize_package_generation(package_generation)
|
||
return next(
|
||
(
|
||
read
|
||
for read in self.reads_for(market)
|
||
if read.package_generation == normalized_generation
|
||
),
|
||
None,
|
||
)
|
||
|
||
@property
|
||
def complete(self) -> bool:
|
||
"""
|
||
只有预期市场与代际均可证明已成功读取时才算完整。
|
||
"""
|
||
if not self.market_reads or not all(read.succeeded for read in self.market_reads):
|
||
return False
|
||
expected_markets = self.expected_markets
|
||
expected_generations = self.expected_generations
|
||
if (expected_markets is None) != (expected_generations is None):
|
||
return False
|
||
if expected_markets is None or expected_generations is None:
|
||
return True
|
||
expected = {
|
||
(market, generation)
|
||
for market in expected_markets
|
||
for generation in expected_generations
|
||
}
|
||
actual = {(read.market, read.package_generation) for read in self.market_reads}
|
||
return expected <= actual
|
||
|
||
@property
|
||
def can_use_for_tofu(self) -> bool:
|
||
"""
|
||
判断快照是否足以证明唯一第三方来源。
|
||
"""
|
||
local_read = self.local_read
|
||
return (
|
||
self.complete
|
||
and local_read is not None
|
||
and local_read.status is not LocalCandidateReadStatus.FAILED
|
||
)
|
||
|
||
@property
|
||
def online_candidates(self) -> tuple[PluginMarketCandidate, ...]:
|
||
"""
|
||
按配置市场顺序返回全部在线候选,不按 ID 或版本去重。
|
||
"""
|
||
return tuple(
|
||
candidate
|
||
for read in self.market_reads
|
||
if read.present_index
|
||
for candidate in read.candidates
|
||
)
|
||
|
||
def candidates_for(self, plugin_id: str) -> tuple[PluginMarketCandidate, ...]:
|
||
"""
|
||
读取一个插件 ID 的全部在线候选。
|
||
"""
|
||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||
return tuple(
|
||
candidate
|
||
for candidate in self.online_candidates
|
||
if candidate.normalized_plugin_id == normalized_id
|
||
)
|
||
|
||
def local_candidates_for(self, plugin_id: str) -> tuple[PluginLocalCandidate, ...]:
|
||
"""
|
||
读取一个插件 ID 的全部本地候选。
|
||
"""
|
||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||
return tuple(
|
||
candidate
|
||
for candidate in self.local_candidates
|
||
if candidate.normalized_plugin_id == normalized_id
|
||
)
|
||
|
||
def public_dict(self) -> dict[str, Any]:
|
||
"""
|
||
生成完整库存的脱敏投影,不泄漏本地路径或原始 DTO。
|
||
"""
|
||
local_read = self.local_read
|
||
if local_read is None:
|
||
raise RuntimeError("候选清单缺少本地读取终态")
|
||
return {
|
||
"markets": [read.public_dict() for read in self.market_reads],
|
||
"local_candidates": [candidate.public_dict() for candidate in self.local_candidates],
|
||
"local_read": local_read.public_dict(),
|
||
"complete": self.complete,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PluginSelection:
|
||
"""
|
||
候选选择结果,冲突和不完整状态均不降级为静默空值。
|
||
|
||
:param status: 候选选择状态,区分选定、不可用、冲突和信息不完整
|
||
:param candidate: 唯一被选定的候选;未选定时为 None
|
||
:param conflict_source_keys: 产生同名来源冲突的仓库键
|
||
:param reason: 未能选定候选的可读原因,默认空字符串
|
||
"""
|
||
|
||
status: PluginSelectionStatus
|
||
candidate: PluginMarketCandidate | PluginLocalCandidate | None = None
|
||
conflict_source_keys: tuple[str, ...] = ()
|
||
reason: str = ""
|
||
|
||
def __post_init__(self) -> None:
|
||
"""
|
||
保证选择状态与载荷及冲突信息相互一致。
|
||
"""
|
||
status = PluginSelectionStatus(self.status)
|
||
conflict_source_keys = tuple(sorted(set(self.conflict_source_keys)))
|
||
if status is PluginSelectionStatus.SELECTED and self.candidate is None:
|
||
raise ValueError("selected 结果必须携带候选")
|
||
if status is not PluginSelectionStatus.SELECTED and self.candidate is not None:
|
||
raise ValueError("未选中结果不能携带候选")
|
||
if status is not PluginSelectionStatus.CONFLICT and conflict_source_keys:
|
||
raise ValueError("只有 conflict 结果能携带冲突来源")
|
||
object.__setattr__(self, "status", status)
|
||
object.__setattr__(self, "conflict_source_keys", conflict_source_keys)
|
||
|
||
@property
|
||
def selected(self) -> bool:
|
||
"""
|
||
判断是否已经选择出一个载荷候选。
|
||
"""
|
||
return self.status is PluginSelectionStatus.SELECTED
|
||
|
||
def public_dict(self) -> dict[str, Any]:
|
||
"""
|
||
生成安全的选择结果投影,不透传本地路径或原始 DTO。
|
||
"""
|
||
result: dict[str, Any] = {
|
||
"status": self.status.value,
|
||
"reason": self.reason,
|
||
}
|
||
if self.conflict_source_keys:
|
||
result["conflict_source_keys"] = list(self.conflict_source_keys)
|
||
if self.candidate is not None:
|
||
result["candidate"] = self.candidate.public_dict()
|
||
return result
|
||
|
||
|
||
Candidate: TypeAlias = PluginMarketCandidate | PluginLocalCandidate
|
||
|
||
|
||
def normalize_package_generation(package_generation: str) -> str:
|
||
"""
|
||
校验并归一插件包代际。
|
||
"""
|
||
value = str(package_generation).strip().lower()
|
||
if value not in PLUGIN_GENERATIONS:
|
||
raise ValueError("插件包代际必须为 v1、v2 或 v3")
|
||
return value
|
||
|
||
|
||
def _select_local_candidate(
|
||
inventory: CandidateInventory,
|
||
*,
|
||
plugin_id: str,
|
||
normalized_id: str,
|
||
generation_order: tuple[str, ...],
|
||
local_candidates: Iterable[PluginLocalCandidate] | None,
|
||
) -> PluginSelection | None:
|
||
"""
|
||
优先选择本地载荷;读取失败时阻止自动降级到在线来源。
|
||
"""
|
||
local = (
|
||
tuple(local_candidates)
|
||
if local_candidates is not None
|
||
else inventory.local_candidates_for(plugin_id)
|
||
)
|
||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in local):
|
||
raise TypeError("本地候选必须是 PluginLocalCandidate")
|
||
if any(candidate.normalized_plugin_id != normalized_id for candidate in local):
|
||
raise ValueError("本地候选的插件 ID 必须与选择目标一致")
|
||
local_read = inventory.local_read
|
||
if (
|
||
not local
|
||
and local_read is not None
|
||
and local_read.status is LocalCandidateReadStatus.FAILED
|
||
):
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.INCOMPLETE,
|
||
reason="部分插件仓库暂时无法读取,请稍后重试",
|
||
)
|
||
if not local:
|
||
return None
|
||
selected_local = _select_best(local, generation_order)
|
||
if selected_local is None:
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.UNAVAILABLE,
|
||
reason="本地插件不支持当前 MoviePilot 版本",
|
||
)
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.SELECTED,
|
||
candidate=selected_local,
|
||
reason="当前使用本地插件",
|
||
)
|
||
|
||
|
||
def select_plugin_candidate(
|
||
inventory: CandidateInventory,
|
||
*,
|
||
plugin_id: str,
|
||
generations: Sequence[str],
|
||
identity: PluginIdentity | None = None,
|
||
local_candidates: Iterable[PluginLocalCandidate] | None = None,
|
||
requested_source_key: str | None = None,
|
||
explicit_source: bool = False,
|
||
allow_source_change: bool = False,
|
||
) -> PluginSelection:
|
||
"""
|
||
按在线绑定、本地候选、运行代际和版本选择一个插件载荷。
|
||
|
||
:param inventory: 本轮市场读取快照
|
||
:param plugin_id: 要选择的物理插件 ID
|
||
:param generations: 调用方按优先级传入的代际顺序
|
||
:param identity: 已安装插件来源身份;为空表示未安装
|
||
:param local_candidates: 可选的本地载荷候选;已有在线绑定时参与代际和版本比较
|
||
:param requested_source_key: 调用方提供的规范在线来源;非显式调用不能绕过本地载荷
|
||
:param explicit_source: 本次调用是否代表管理员明确选源
|
||
:param allow_source_change: 是否是带版本号的显式换源命令
|
||
:return: 带明确冲突或不完整状态的选择结果
|
||
"""
|
||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||
generation_order = _normalize_generation_order(generations)
|
||
requested_source = (
|
||
validate_online_source_key(requested_source_key)
|
||
if requested_source_key is not None
|
||
else None
|
||
)
|
||
local_selection: PluginSelection | None = None
|
||
if requested_source is None or not (explicit_source or allow_source_change):
|
||
local_selection = _select_local_candidate(
|
||
inventory,
|
||
plugin_id=plugin_id,
|
||
normalized_id=normalized_id,
|
||
generation_order=generation_order,
|
||
local_candidates=local_candidates,
|
||
)
|
||
if (
|
||
local_selection is not None
|
||
and local_selection.status is PluginSelectionStatus.INCOMPLETE
|
||
):
|
||
return local_selection
|
||
# 精确本地来源用于开发同步;未指定来源的启动与更新才参与在线版本协调。
|
||
if explicit_source and local_candidates is not None:
|
||
return local_selection or PluginSelection(
|
||
status=PluginSelectionStatus.UNAVAILABLE,
|
||
reason="所选本地仓库中没有该插件",
|
||
)
|
||
|
||
online = inventory.candidates_for(plugin_id)
|
||
if not online:
|
||
if local_selection is not None:
|
||
return local_selection
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.UNAVAILABLE,
|
||
reason=f"没有找到插件 {plugin_id} 的可用安装包",
|
||
)
|
||
|
||
allowed_source = _allowed_source(identity, normalized_id)
|
||
if requested_source is not None:
|
||
requested_online = tuple(
|
||
candidate
|
||
for candidate in online
|
||
if candidate.source_key == requested_source
|
||
)
|
||
if not requested_online:
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.UNAVAILABLE,
|
||
reason="所选仓库中没有该插件的可用安装包",
|
||
)
|
||
if allowed_source is not None:
|
||
_source_type, allowed_key = allowed_source
|
||
if requested_source != allowed_key and not allow_source_change:
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.CONFLICT,
|
||
conflict_source_keys=(allowed_key, requested_source),
|
||
reason="该插件已绑定其他仓库,请先确认更换",
|
||
)
|
||
if explicit_source or allow_source_change:
|
||
selected_requested = _select_best(requested_online, generation_order)
|
||
if selected_requested is None:
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.UNAVAILABLE,
|
||
reason="所选仓库没有适用于当前 MoviePilot 版本的插件包",
|
||
)
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.SELECTED,
|
||
candidate=selected_requested,
|
||
reason=(
|
||
"已选择目标仓库中的插件包"
|
||
if allow_source_change
|
||
else "已选择指定仓库中的插件包"
|
||
),
|
||
)
|
||
if allowed_source is not None:
|
||
return _select_bound_source_candidate(
|
||
online=online,
|
||
allowed_source=allowed_source,
|
||
local_selection=local_selection,
|
||
identity=identity,
|
||
generation_order=generation_order,
|
||
)
|
||
|
||
if identity is not None:
|
||
if local_selection is not None:
|
||
return local_selection
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.INCOMPLETE,
|
||
reason="当前插件尚未绑定仓库",
|
||
)
|
||
|
||
if local_selection is not None:
|
||
return local_selection
|
||
|
||
source_pairs = {(candidate.source_type, candidate.source_key) for candidate in online}
|
||
if len(source_pairs) > 1:
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.CONFLICT,
|
||
conflict_source_keys=tuple(source_key for _source_type, source_key in source_pairs),
|
||
reason="该插件存在于多个仓库,请选择仓库",
|
||
)
|
||
|
||
source_type = next(iter(source_pairs))[0]
|
||
if source_type is TrustedPluginSourceType.THIRD_PARTY and not inventory.can_use_for_tofu:
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.INCOMPLETE,
|
||
reason="部分插件仓库暂时无法读取,无法安全确认仓库",
|
||
)
|
||
selected_online = _select_best(online, generation_order)
|
||
if selected_online is None:
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.UNAVAILABLE,
|
||
reason="可用仓库中没有适用于当前 MoviePilot 版本的插件包",
|
||
)
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.SELECTED,
|
||
candidate=selected_online,
|
||
reason="已找到唯一可用仓库",
|
||
)
|
||
|
||
|
||
def _select_bound_source_candidate(
|
||
*,
|
||
online: tuple[PluginMarketCandidate, ...],
|
||
allowed_source: tuple[TrustedPluginSourceType, str],
|
||
local_selection: PluginSelection | None,
|
||
identity: PluginIdentity | None,
|
||
generation_order: tuple[str, ...],
|
||
) -> PluginSelection:
|
||
"""
|
||
只在已绑定仓库范围内选取在线候选,并与可用本地载荷协调。
|
||
"""
|
||
if identity is None:
|
||
raise PluginSourceSelectionError("已绑定来源选择缺少插件身份")
|
||
source_type, source_key = allowed_source
|
||
allowed_online = tuple(
|
||
candidate
|
||
for candidate in online
|
||
if candidate.source_type is source_type and candidate.source_key == source_key
|
||
)
|
||
if not allowed_online:
|
||
if (
|
||
local_selection is not None
|
||
and local_selection.status is PluginSelectionStatus.SELECTED
|
||
):
|
||
return local_selection
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.UNAVAILABLE,
|
||
reason="已绑定仓库中暂无可用插件包",
|
||
)
|
||
selected_online = _select_best(allowed_online, generation_order)
|
||
if selected_online is None:
|
||
if (
|
||
local_selection is not None
|
||
and local_selection.status is PluginSelectionStatus.SELECTED
|
||
):
|
||
return local_selection
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.UNAVAILABLE,
|
||
reason="已绑定仓库没有适用于当前 MoviePilot 版本的插件包",
|
||
)
|
||
return _select_bound_or_local_candidate(
|
||
local_selection=local_selection,
|
||
online_candidate=selected_online,
|
||
identity=identity,
|
||
generation_order=generation_order,
|
||
)
|
||
|
||
|
||
def _select_bound_or_local_candidate(
|
||
*,
|
||
local_selection: PluginSelection | None,
|
||
online_candidate: Candidate,
|
||
identity: PluginIdentity,
|
||
generation_order: tuple[str, ...],
|
||
) -> PluginSelection:
|
||
"""
|
||
在已绑定在线候选和本地候选之间选择代际、版本更高的载荷。
|
||
"""
|
||
if (
|
||
local_selection is None
|
||
or local_selection.status is not PluginSelectionStatus.SELECTED
|
||
or local_selection.candidate is None
|
||
):
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.SELECTED,
|
||
candidate=online_candidate,
|
||
reason="已使用绑定仓库中的插件包",
|
||
)
|
||
|
||
local_candidate = local_selection.candidate
|
||
local_generation = generation_order.index(local_candidate.package_generation)
|
||
online_generation = generation_order.index(online_candidate.package_generation)
|
||
if local_generation < online_generation:
|
||
return local_selection
|
||
if online_generation < local_generation:
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.SELECTED,
|
||
candidate=online_candidate,
|
||
reason="绑定仓库提供了更高代际的插件包",
|
||
)
|
||
|
||
local_version = local_candidate.plugin_version or "0"
|
||
online_version = online_candidate.plugin_version or "0"
|
||
if compare_version(local_version, ">", online_version):
|
||
return local_selection
|
||
if compare_version(online_version, ">", local_version):
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.SELECTED,
|
||
candidate=online_candidate,
|
||
reason="绑定仓库提供了更高版本的插件包",
|
||
)
|
||
if identity.payload_source_type is PluginPayloadSourceType.LOCAL:
|
||
return local_selection
|
||
return PluginSelection(
|
||
status=PluginSelectionStatus.SELECTED,
|
||
candidate=online_candidate,
|
||
reason="当前继续使用绑定仓库中的插件包",
|
||
)
|
||
|
||
|
||
def list_effective_online_candidates(
|
||
inventory: CandidateInventory,
|
||
*,
|
||
plugin_id: str,
|
||
generations: Sequence[str],
|
||
) -> tuple[PluginMarketCandidate, ...]:
|
||
"""
|
||
按来源列出当前运行代际实际可安装的最高版本候选,官方来源始终置顶。
|
||
"""
|
||
generation_order = _normalize_generation_order(generations)
|
||
grouped: dict[
|
||
tuple[TrustedPluginSourceType, str],
|
||
list[PluginMarketCandidate],
|
||
] = {}
|
||
for candidate in inventory.candidates_for(plugin_id):
|
||
grouped.setdefault(
|
||
(candidate.source_type, candidate.source_key),
|
||
[],
|
||
).append(candidate)
|
||
|
||
selected: list[PluginMarketCandidate] = []
|
||
for candidates in grouped.values():
|
||
selected_candidate = _select_best(candidates, generation_order)
|
||
if isinstance(selected_candidate, PluginMarketCandidate):
|
||
selected.append(selected_candidate)
|
||
selected.sort(
|
||
key=lambda candidate: candidate.source_type
|
||
is not TrustedPluginSourceType.OFFICIAL
|
||
)
|
||
return tuple(selected)
|
||
|
||
|
||
def get_effective_local_candidate(
|
||
inventory: CandidateInventory,
|
||
*,
|
||
plugin_id: str,
|
||
generations: Sequence[str],
|
||
) -> PluginLocalCandidate | None:
|
||
"""
|
||
返回本地插件目录中当前运行代际优先级最高的安全候选。
|
||
"""
|
||
candidate = _select_best(
|
||
inventory.local_candidates_for(plugin_id),
|
||
_normalize_generation_order(generations),
|
||
)
|
||
return candidate if isinstance(candidate, PluginLocalCandidate) else None
|
||
|
||
|
||
def _coerce_online_source_type(source_type: TrustedPluginSourceType) -> TrustedPluginSourceType:
|
||
"""
|
||
把外部字符串来源类型转换为可信在线来源枚举。
|
||
"""
|
||
value = TrustedPluginSourceType(source_type)
|
||
if value is TrustedPluginSourceType.UNKNOWN:
|
||
raise ValueError("未知来源不能作为在线市场候选")
|
||
return value
|
||
|
||
|
||
def _normalize_market(market: str) -> str:
|
||
"""
|
||
校验市场标识,保留其作为本轮快照的显示值。
|
||
"""
|
||
value = str(market).strip()
|
||
if not value:
|
||
raise ValueError("插件市场标识不能为空")
|
||
return value
|
||
|
||
|
||
def _normalize_repo_url(repo_url: str) -> str:
|
||
"""
|
||
保留仓库地址作为安装事实,但移除无意义的外围空白。
|
||
"""
|
||
value = str(repo_url).strip()
|
||
if not value:
|
||
raise ValueError("插件仓库地址不能为空")
|
||
return value
|
||
|
||
|
||
def _normalize_plugin_version(plugin_version: str | None) -> str | None:
|
||
"""
|
||
标准化可选插件声明版本,并保持缺失版本可观察。
|
||
"""
|
||
if plugin_version is None:
|
||
return None
|
||
value = str(plugin_version).strip()
|
||
if not value:
|
||
raise ValueError("插件声明版本不能为空字符串")
|
||
if len(value) > 64:
|
||
raise ValueError("插件声明版本长度不能超过 64")
|
||
return value
|
||
|
||
|
||
def _normalize_generation_order(generations: Sequence[str]) -> tuple[str, ...]:
|
||
"""
|
||
校验调用方提供的代际优先序,并拒绝重复项。
|
||
"""
|
||
normalized = tuple(normalize_package_generation(generation) for generation in generations)
|
||
if not normalized:
|
||
raise ValueError("至少需要一个当前运行代际")
|
||
if len(normalized) != len(set(normalized)):
|
||
raise ValueError("当前运行代际优先序不能重复")
|
||
return normalized
|
||
|
||
|
||
def _allowed_source(
|
||
identity: PluginIdentity | None,
|
||
normalized_plugin_id: str,
|
||
) -> tuple[TrustedPluginSourceType, str] | None:
|
||
"""
|
||
从已安装身份读取不可变的允许在线来源。
|
||
"""
|
||
if identity is None:
|
||
return None
|
||
if identity.normalized_plugin_id != normalized_plugin_id:
|
||
raise ValueError("来源身份的插件 ID 与选择目标不一致")
|
||
if identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||
return None
|
||
if not identity.trusted_source_key:
|
||
raise PluginSourceSelectionError("已绑定来源身份缺少规范来源键")
|
||
return identity.trusted_source_type, identity.trusted_source_key
|
||
|
||
|
||
def _select_best(
|
||
candidates: Sequence[Candidate],
|
||
generation_order: Sequence[str],
|
||
) -> Candidate | None:
|
||
"""
|
||
在已完成来源过滤后按代际和同源版本选择最高候选。
|
||
"""
|
||
for generation in generation_order:
|
||
generation_candidates = tuple(
|
||
candidate
|
||
for candidate in candidates
|
||
if candidate.package_generation == generation
|
||
)
|
||
if generation_candidates:
|
||
return _select_highest_version(generation_candidates)
|
||
return None
|
||
|
||
|
||
def _select_highest_version(candidates: Sequence[Candidate]) -> Candidate:
|
||
"""
|
||
使用宿主既有版本比较语义选择同源最高版本,平级保留先读候选。
|
||
"""
|
||
selected = candidates[0]
|
||
for candidate in candidates[1:]:
|
||
selected_version = selected.plugin_version or "0"
|
||
candidate_version = candidate.plugin_version or "0"
|
||
if compare_version(candidate_version, ">", selected_version):
|
||
selected = candidate
|
||
return selected
|