mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
fix: 协调本地插件与绑定仓库的版本选择 (#6482)
* fix(plugin): reconcile local and bound payload versions * fix(plugin): preserve bound source on updates * fix(plugin): normalize source reconciliation ids * fix(plugin): defer local payload activation * fix(plugin): centralize startup source admission * fix(plugin): derive sync mode from admitted source
This commit is contained in:
@@ -11,6 +11,7 @@ from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
TrustedPluginSourceType,
|
||||
normalize_physical_plugin_id,
|
||||
)
|
||||
from app.schemas.plugin import Plugin, PluginSourceBindingStatus
|
||||
|
||||
@@ -274,13 +275,16 @@ class PluginCatalogService:
|
||||
"""按代际、来源顺序和版本合并插件目录。"""
|
||||
all_plugins = list(higher_plugins)
|
||||
higher_keys = {
|
||||
f"{plugin.id}{plugin.plugin_version}"
|
||||
(normalize_physical_plugin_id(plugin.id), plugin.plugin_version)
|
||||
for plugin in higher_plugins
|
||||
}
|
||||
all_plugins.extend(
|
||||
plugin
|
||||
for plugin in base_plugins
|
||||
if f"{plugin.id}{plugin.plugin_version}" not in higher_keys
|
||||
if (
|
||||
normalize_physical_plugin_id(plugin.id),
|
||||
plugin.plugin_version,
|
||||
) not in higher_keys
|
||||
)
|
||||
|
||||
def repo_order(plugin: Any) -> int:
|
||||
@@ -293,7 +297,10 @@ class PluginCatalogService:
|
||||
|
||||
deduplicated = {}
|
||||
for plugin in sorted(all_plugins, key=repo_order):
|
||||
key = f"{plugin.id}{plugin.plugin_version}"
|
||||
key = (
|
||||
normalize_physical_plugin_id(plugin.id),
|
||||
plugin.plugin_version,
|
||||
)
|
||||
exists = deduplicated.get(key)
|
||||
if not exists or (
|
||||
self._is_local_repo(exists.repo_url)
|
||||
@@ -303,7 +310,8 @@ class PluginCatalogService:
|
||||
|
||||
result_by_id = {}
|
||||
for plugin in sorted(deduplicated.values(), key=repo_order):
|
||||
exists = result_by_id.get(plugin.id)
|
||||
normalized_id = normalize_physical_plugin_id(plugin.id)
|
||||
exists = result_by_id.get(normalized_id)
|
||||
if not exists \
|
||||
or self._version_compare(
|
||||
plugin.plugin_version,
|
||||
@@ -315,7 +323,7 @@ class PluginCatalogService:
|
||||
and self._is_local_repo(exists.repo_url)
|
||||
and not self._is_local_repo(plugin.repo_url)
|
||||
):
|
||||
result_by_id[plugin.id] = plugin
|
||||
result_by_id[normalized_id] = plugin
|
||||
return list(result_by_id.values())
|
||||
|
||||
def merge_by_source(
|
||||
@@ -326,14 +334,22 @@ class PluginCatalogService:
|
||||
) -> list[Any]:
|
||||
"""每个仓库保留同一插件的最高兼容版本,供来源准入继续决策。"""
|
||||
higher_keys = {
|
||||
(plugin.repo_url, plugin.id, plugin.plugin_version)
|
||||
(
|
||||
plugin.repo_url,
|
||||
normalize_physical_plugin_id(plugin.id),
|
||||
plugin.plugin_version,
|
||||
)
|
||||
for plugin in higher_plugins
|
||||
}
|
||||
all_plugins = list(higher_plugins)
|
||||
all_plugins.extend(
|
||||
plugin
|
||||
for plugin in base_plugins
|
||||
if (plugin.repo_url, plugin.id, plugin.plugin_version) not in higher_keys
|
||||
if (
|
||||
plugin.repo_url,
|
||||
normalize_physical_plugin_id(plugin.id),
|
||||
plugin.plugin_version,
|
||||
) not in higher_keys
|
||||
)
|
||||
|
||||
def repo_order(plugin: Any) -> int:
|
||||
@@ -341,9 +357,9 @@ class PluginCatalogService:
|
||||
return markets.index(plugin.repo_url)
|
||||
return len(markets)
|
||||
|
||||
result_by_source: dict[tuple[Optional[str], Optional[str]], Any] = {}
|
||||
result_by_source: dict[tuple[Optional[str], str], Any] = {}
|
||||
for plugin in sorted(all_plugins, key=repo_order):
|
||||
key = (plugin.repo_url, plugin.id)
|
||||
key = (plugin.repo_url, normalize_physical_plugin_id(plugin.id))
|
||||
exists = result_by_source.get(key)
|
||||
if not exists or self._version_compare(
|
||||
plugin.plugin_version,
|
||||
|
||||
@@ -117,11 +117,15 @@ class PluginInstallGateway:
|
||||
raise PluginSourceAdmissionError(
|
||||
message or "插件包与当前 MoviePilot 版本不兼容"
|
||||
)
|
||||
# 本地同步是最终准入候选的执行属性,不能由调用前的 URL 推断。
|
||||
return await self.__executor.execute(
|
||||
admission=admission,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
local_sync=local_sync,
|
||||
local_sync=isinstance(
|
||||
admission.candidate,
|
||||
PluginLocalCandidate,
|
||||
),
|
||||
)
|
||||
except (TypeError, ValueError, PluginSourceAdmissionError) as error:
|
||||
return PluginInstallResult(
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Protocol
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
|
||||
_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,127}$")
|
||||
_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,127}$")
|
||||
_ONLINE_SOURCE_KEY_PATTERN = re.compile(
|
||||
r"^github:[a-z0-9](?:[a-z0-9-]{0,38})/"
|
||||
r"[a-z0-9._-]{1,100}$"
|
||||
@@ -59,9 +59,9 @@ class PluginIdentityConflictError(RuntimeError):
|
||||
|
||||
|
||||
def normalize_physical_plugin_id(plugin_id: str) -> str:
|
||||
"""校验物理插件 ID,并返回大小写无关的数据库身份键。"""
|
||||
"""校验可安全持久化的物理插件 ID,并返回大小写无关身份键。"""
|
||||
if plugin_id != plugin_id.strip() or not _PLUGIN_ID_PATTERN.fullmatch(plugin_id):
|
||||
raise ValueError("插件 ID 必须以字母开头且只能包含 ASCII 字母或数字")
|
||||
raise ValueError("插件 ID 必须以字母开头且只能包含 ASCII 字母、数字或下划线")
|
||||
return plugin_id.lower()
|
||||
|
||||
|
||||
|
||||
@@ -553,13 +553,13 @@ def select_plugin_candidate(
|
||||
allow_source_change: bool = False,
|
||||
) -> PluginSelection:
|
||||
"""
|
||||
按允许来源、运行代际和同源版本选择一个插件载荷。
|
||||
按在线绑定、本地候选、运行代际和版本选择一个插件载荷。
|
||||
|
||||
:param inventory: 本轮市场读取快照
|
||||
:param plugin_id: 要选择的物理插件 ID
|
||||
:param generations: 调用方按优先级传入的代际顺序
|
||||
:param identity: 已安装插件来源身份;为空表示未安装
|
||||
:param local_candidates: 可选的本地载荷候选,优先于在线候选
|
||||
:param local_candidates: 可选的本地载荷候选;已有在线绑定时参与代际和版本比较
|
||||
:param requested_source_key: 调用方提供的规范在线来源;非显式调用不能绕过本地载荷
|
||||
:param explicit_source: 本次调用是否代表管理员明确选源
|
||||
:param allow_source_change: 是否是带 revision 的显式换源命令
|
||||
@@ -572,6 +572,7 @@ def select_plugin_candidate(
|
||||
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,
|
||||
@@ -580,11 +581,16 @@ def select_plugin_candidate(
|
||||
generation_order=generation_order,
|
||||
local_candidates=local_candidates,
|
||||
)
|
||||
if local_selection is not None:
|
||||
if (
|
||||
local_selection is not None
|
||||
and local_selection.status is PluginSelectionStatus.INCOMPLETE
|
||||
):
|
||||
return local_selection
|
||||
|
||||
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} 的可用安装包",
|
||||
@@ -627,35 +633,25 @@ def select_plugin_candidate(
|
||||
),
|
||||
)
|
||||
if allowed_source is not None:
|
||||
source_type, source_key = allowed_source
|
||||
online = tuple(
|
||||
candidate
|
||||
for candidate in online
|
||||
if candidate.source_type is source_type and candidate.source_key == source_key
|
||||
)
|
||||
if not online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
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="已使用绑定仓库中的插件包",
|
||||
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(
|
||||
@@ -683,6 +679,102 @@ def select_plugin_candidate(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user