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:
InfinityPacer
2026-08-27 20:03:30 +08:00
committed by GitHub
parent 2b1f590c4d
commit 011ebfbe5f
16 changed files with 1030 additions and 77 deletions
+1 -1
View File
@@ -669,7 +669,7 @@ async def install(
"""
result = await get_plugin_install_service().install(
plugin_id=plugin_id,
repo_url=None,
repo_url=repo_url or None,
release_version=release_version,
force=bool(force),
explicit_source=False,
+25 -9
View File
@@ -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,
+5 -1
View File
@@ -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(
+3 -3
View File
@@ -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()
+116 -24
View File
@@ -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,
*,
+1 -1
View File
@@ -207,7 +207,7 @@ class PluginChangeMonitor:
else None
)
if runtime_plugin_id:
last_sync_time = self._recent_sync.get(runtime_plugin_id)
last_sync_time = self._recent_sync.get(runtime_plugin_id.lower())
if last_sync_time and time.time() - last_sync_time < 2:
continue
plugins_to_reload.add(runtime_plugin_id)
+31 -9
View File
@@ -44,10 +44,17 @@ class PluginSyncService:
if self._frozen():
return []
installed = self._installed_plugins()
installed = {
plugin_id.lower()
for plugin_id in self._installed_plugins()
}
online = self._online_plugins()
local = self._local_plugins()
local_plugin_ids = {plugin.id.lower() for plugin in local}
local_plugin_ids = {
plugin.id.lower()
for plugin in local
}
deferred_plugin_ids = installed & local_plugin_ids
restore_plugin_ids = {
plugin_id.lower()
for plugin_id in (online_restore_plugins or set())
@@ -56,9 +63,10 @@ class PluginSyncService:
targets = [
plugin
for plugin in candidates
if plugin.id in installed
if plugin.id.lower() in installed
and (
plugin.id.lower() in restore_plugin_ids
plugin.id.lower() in deferred_plugin_ids
or plugin.id.lower() in restore_plugin_ids
or (
plugin.system_version_compatible is not False
and not self._plugin_exists(plugin.id, plugin.plugin_version)
@@ -71,6 +79,7 @@ class PluginSyncService:
self._logger.info("开始安装第三方插件...")
synced: list[str] = []
failed: list[str] = []
failed_deferred: list[str] = []
def install_one(plugin: Any) -> None:
"""安装一个插件并记录结果。"""
@@ -84,13 +93,14 @@ class PluginSyncService:
elapsed = time.time() - started
if state:
self._logger.info(
f"插件 {plugin.plugin_name} 安装成功,版本{plugin.plugin_version}"
f"耗时:{elapsed:.2f}"
f"插件 {plugin.plugin_name} 同步成功,耗时{elapsed:.2f}"
)
synced.append(plugin.id)
else:
if plugin.id.lower() in deferred_plugin_ids:
failed_deferred.append(plugin.id)
self._logger.error(
f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:"
f"插件 {plugin.plugin_name} 同步失败:"
f"{message},耗时:{elapsed:.2f}"
)
failed.append(plugin.id)
@@ -102,6 +112,8 @@ class PluginSyncService:
try:
future.result()
except Exception as error: # noqa: BLE001
if plugin.id.lower() in deferred_plugin_ids:
failed_deferred.append(plugin.id)
self._logger.error(
f"插件 {plugin.plugin_name} 安装过程中出现异常: {error}"
)
@@ -109,6 +121,11 @@ class PluginSyncService:
self._logger.info(
f"第三方插件安装完成,成功:{len(synced)} 个,失败:{len(failed)}"
)
if failed_deferred:
raise RuntimeError(
"延后激活的插件同步未完成:"
f"{', '.join(sorted(set(failed_deferred)))}"
)
return synced
@@ -133,7 +150,12 @@ class LocalPluginSyncService:
def sync(self, plugin_id: str, candidate: Optional[dict] = None) -> bool:
"""同步已安装且兼容的本地插件,成功后记录短时事件抑制标记。"""
if plugin_id not in self._installed_plugins():
normalized_plugin_id = plugin_id.lower()
installed = {
installed_id.lower()
for installed_id in self._installed_plugins()
}
if normalized_plugin_id not in installed:
self._logger.info(f"本地插件 {plugin_id} 尚未安装,跳过自动同步和热重载")
return False
candidate = candidate or self._candidate(plugin_id)
@@ -160,7 +182,7 @@ class LocalPluginSyncService:
if not state:
self._logger.error(f"同步本地插件 {plugin_id} 失败:{message}")
return False
self._recent_sync[plugin_id] = time.time()
self._recent_sync[normalized_plugin_id] = time.time()
self._logger.info(f"已同步本地插件 {plugin_id}")
return True
except Exception as error:
+48 -6
View File
@@ -453,6 +453,8 @@ async def _sync_plugins_admitted(
),
"插件同步到本地",
)
if sync_result is None:
return False
dependency_result = await (
plugin_manager.async_install_plugin_missing_dependencies_with_status()
)
@@ -471,7 +473,7 @@ async def _sync_plugins_admitted(
lambda: _activate_ready_plugins(
plugin_manager,
classification.ready,
sync_result or [],
sync_result,
previous_statuses,
),
"插件运行态激活",
@@ -502,14 +504,18 @@ def _activate_ready_plugins(
) -> list[str]:
"""在线程池中完成插件导入和初始化,避免阻塞 Web 事件循环。"""
running_ids = set(plugin_manager.running_plugins)
synced = set(synced_ids)
synced = {
_plugin_source_id(plugin_manager, plugin_id)
for plugin_id in synced_ids
}
changed_ids: list[str] = []
for plugin_id in ready_ids:
source_id = _plugin_source_id(plugin_manager, plugin_id)
dependency_recovered = (
previous_statuses.get(plugin_id)
is PluginRuntimeStatus.DEPENDENCY_PENDING
)
if plugin_id in running_ids and (plugin_id in synced or dependency_recovered):
if plugin_id in running_ids and (source_id in synced or dependency_recovered):
plugin_manager.reload_plugin(plugin_id)
changed_ids.append(plugin_id)
continue
@@ -519,6 +525,35 @@ def _activate_ready_plugins(
return changed_ids
def _plugin_source_id(plugin_manager: PluginManager, plugin_id: str) -> str:
"""把物理插件和虚拟实例归一到同一个源码身份。"""
source_id = plugin_manager.get_plugin_source_id(plugin_id)
try:
return normalize_physical_plugin_id(source_id)
except ValueError:
return source_id.lower()
def _local_plugin_sources(plugin_manager: PluginManager) -> set[str]:
"""返回安装清单中存在本地仓候选的物理插件身份。"""
installed = {
normalize_physical_plugin_id(plugin_id)
for plugin_id in (
get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins)
or []
)
}
candidates: set[str] = set()
for plugin in plugin_manager.get_local_repo_plugins():
try:
source_id = normalize_physical_plugin_id(plugin.id)
except ValueError:
continue
if source_id in installed:
candidates.add(source_id)
return candidates
async def quiesce_plugins(timeout: float = 240.0) -> bool:
"""封口插件变更并停用 handler,保留超时 Future 的运行所有权。"""
plugin_manager = PluginManager.get_existing_instance()
@@ -579,13 +614,20 @@ def init_plugins():
classification = plugin_manager.classify_plugins()
plugin_manager.apply_plugin_dependency_classification(classification)
plugin_manager.set_plugin_settling(True)
for plugin_id in classification.ready:
deferred_sources = _local_plugin_sources(plugin_manager)
immediate_ready = [
plugin_id
for plugin_id in classification.ready
if _plugin_source_id(plugin_manager, plugin_id) not in deferred_sources
]
for plugin_id in immediate_ready:
plugin_manager.start(plugin_id)
register_plugin_api()
plugin_manager.start_monitor(reopen=True)
logger.info(
"插件启动分类:立即加载=%s,等待依赖=%s,等待源码=%s",
len(classification.ready),
"插件启动分类:立即加载=%s,等待本地同步=%s,等待依赖=%s,等待源码=%s",
len(immediate_ready),
len(classification.ready) - len(immediate_ready),
len(classification.missing_dependencies),
len(classification.missing_source),
)