fix(plugin): 按绑定仓库处理插件更新 (#6472)

This commit is contained in:
InfinityPacer
2026-08-26 21:35:16 +08:00
committed by GitHub
parent 0a5b6a637d
commit d24c52ea97
14 changed files with 287 additions and 52 deletions
+78 -3
View File
@@ -32,6 +32,11 @@ from app.application.plugin.catalog import apply_declared_metadata_fallback
from app.application.plugin.config import PluginConfigCommand from app.application.plugin.config import PluginConfigCommand
from app.application.plugin.folders import remove_plugin_from_folders from app.application.plugin.folders import remove_plugin_from_folders
from app.application.plugin.gateway import get_plugin_install_service from app.application.plugin.gateway import get_plugin_install_service
from app.application.plugin.identity import (
OFFICIAL_PLUGIN_SOURCE_KEY,
TrustedPluginSourceType,
)
from app.application.plugin.inventory import normalize_github_plugin_source
from app.application.plugin.routes import register_plugin_api, remove_plugin_api from app.application.plugin.routes import register_plugin_api, remove_plugin_api
from app.application.plugin.runtime import PluginRuntime, get_plugin_manager from app.application.plugin.runtime import PluginRuntime, get_plugin_manager
from app.application.plugin.transaction import get_plugin_persistence from app.application.plugin.transaction import get_plugin_persistence
@@ -66,6 +71,7 @@ from app.schemas.plugin import PluginSourceChangeRequest as _SchemaPluginSourceC
from app.schemas.plugin import PluginSourceIdentity as _SchemaPluginSourceIdentity from app.schemas.plugin import PluginSourceIdentity as _SchemaPluginSourceIdentity
from app.schemas.plugin import PluginSourceInstallRequest as _SchemaPluginSourceInstallRequest from app.schemas.plugin import PluginSourceInstallRequest as _SchemaPluginSourceInstallRequest
from app.schemas.plugin import PluginSourceOptions as _SchemaPluginSourceOptions from app.schemas.plugin import PluginSourceOptions as _SchemaPluginSourceOptions
from app.schemas.plugin import PluginUpdateCandidate as _SchemaPluginUpdateCandidate
from app.schemas.response import Response as _SchemaResponse from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.types import SystemConfigKey from app.schemas.types import SystemConfigKey
@@ -288,6 +294,69 @@ async def _installed_plugins_with_declared_metadata(
) )
async def _prepare_update_candidates(
plugin_manager: PluginRuntime,
online_plugins: list[_SchemaPlugin],
plugins: list[_SchemaPlugin],
installed_ids: list[str],
) -> list[_SchemaPlugin]:
"""优先选择绑定仓库的可用更新,再投影候选来源信息。"""
identities = await get_plugin_persistence().list_identities(installed_ids)
identity_by_id = {identity.normalized_plugin_id: identity for identity in identities}
installed_keys = {plugin_id.lower() for plugin_id in installed_ids}
result: list[_SchemaPlugin] = []
for plugin in plugins:
if not plugin.id or plugin.id.lower() not in installed_keys or not plugin.has_update:
result.append(plugin)
continue
identity = identity_by_id.get(plugin.id.lower())
if identity and identity.trusted_source_key:
bound_updates = []
for online_plugin in online_plugins:
if (
not online_plugin.id
or online_plugin.id.lower() != plugin.id.lower()
or not online_plugin.has_update
or not online_plugin.repo_url
):
continue
try:
source_key, _ = normalize_github_plugin_source(
online_plugin.repo_url
)
except ValueError:
continue
if source_key == identity.trusted_source_key:
bound_updates.append(online_plugin)
if bound_updates:
preferred = plugin_manager.process_plugins_list(bound_updates, [])
if preferred:
plugin = preferred[0]
if not plugin.repo_url or not plugin.plugin_version:
result.append(plugin)
continue
try:
source_key, repo_url = normalize_github_plugin_source(plugin.repo_url)
except ValueError:
result.append(plugin)
continue
source_type = (
TrustedPluginSourceType.OFFICIAL
if source_key == OFFICIAL_PLUGIN_SOURCE_KEY
else TrustedPluginSourceType.THIRD_PARTY
)
plugin.update_candidate = _SchemaPluginUpdateCandidate(
source_type=source_type.value,
source_key=source_key,
repo_url=repo_url,
version=plugin.plugin_version,
is_bound=bool(identity and identity.trusted_source_key == source_key),
)
result.append(plugin)
return result
@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin]) @router.get("/", summary="所有插件", response_model=List[_SchemaPlugin])
async def all_plugins( async def all_plugins(
_: ApiPrincipal = Depends(get_current_active_superuser_async), _: ApiPrincipal = Depends(get_current_active_superuser_async),
@@ -310,11 +379,18 @@ async def all_plugins(
local_repo_plugins = plugin_manager.get_local_repo_plugins() local_repo_plugins = plugin_manager.get_local_repo_plugins()
# 在线插件 # 在线插件
online_plugins = await plugin_manager.async_get_online_plugins(force) online_plugins = await plugin_manager.async_get_online_plugins(force)
installed_ids = [plugin.id for plugin in installed_plugins if plugin.id]
candidate_plugins = ( candidate_plugins = (
plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, []) plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, [])
if online_plugins or local_repo_plugins if online_plugins or local_repo_plugins
else [] else []
) )
candidate_plugins = await _prepare_update_candidates(
plugin_manager,
online_plugins,
candidate_plugins,
installed_ids,
)
if not candidate_plugins: if not candidate_plugins:
# 没有获取在线插件 # 没有获取在线插件
if state == "market": if state == "market":
@@ -328,10 +404,9 @@ async def all_plugins(
# 插件市场插件清单 # 插件市场插件清单
market_plugins = [] market_plugins = []
# 已安装插件IDS # 已安装插件IDS
_installed_ids = [plugin.id for plugin in installed_plugins]
# 未安装的线上插件或者有更新的插件 # 未安装的线上插件或者有更新的插件
for plugin in candidate_plugins: for plugin in candidate_plugins:
if plugin.id not in _installed_ids: if plugin.id not in installed_ids:
market_plugins.append(plugin) market_plugins.append(plugin)
elif plugin.has_update: elif plugin.has_update:
market_plugins.append(plugin) market_plugins.append(plugin)
@@ -616,7 +691,7 @@ async def get_plugin_source_identity(
"""返回显式换源确认所需的当前可信来源和 revision。""" """返回显式换源确认所需的当前可信来源和 revision。"""
identity = await get_plugin_persistence().get_identity(plugin_id) identity = await get_plugin_persistence().get_identity(plugin_id)
if identity is None: if identity is None:
return _SchemaResponse(success=False, message="插件来源身份不存在") return _SchemaResponse(success=False, message="未找到该插件的仓库绑定信息")
return _SchemaResponse( return _SchemaResponse(
success=True, success=True,
data=_plugin_source_identity_schema(identity), data=_plugin_source_identity_schema(identity),
+10 -10
View File
@@ -120,15 +120,15 @@ def admit_plugin_install(
bound_at: datetime | None bound_at: datetime | None
if request.source_change: if request.source_change:
if not request.explicit_source or not request.requested_repo_url: if not request.explicit_source or not request.requested_repo_url:
raise PluginSourceAdmissionError("显式换源必须指定目标在线来源") raise PluginSourceAdmissionError("请选择要更换到的插件仓库")
if request.requested_repo_url.startswith("local://"): if request.requested_repo_url.startswith("local://"):
raise PluginSourceAdmissionError("显式换源只接受在线插件仓库") raise PluginSourceAdmissionError("只能更换为在线插件仓库")
if identity is None or identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN: if identity is None or identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
raise PluginSourceAdmissionError("显式换源要求插件已经绑定在线来源") raise PluginSourceAdmissionError("当前插件尚未绑定仓库")
if request.expected_revision != identity.revision: if request.expected_revision != identity.revision:
raise PluginSourceAdmissionError("显式换源的身份 revision 已失效") raise PluginSourceAdmissionError("插件状态已变化,请重新打开页面后再试")
elif request.expected_revision is not None: elif request.expected_revision is not None:
raise PluginSourceAdmissionError("普通安装不能携带换源 revision") raise PluginSourceAdmissionError("插件操作参数无效,请重新打开页面后再试")
requested_source_key = None requested_source_key = None
local_candidates = None local_candidates = None
@@ -142,7 +142,7 @@ def admit_plugin_install(
or referenced_plugin_id.lower() != request.plugin_id.lower() or referenced_plugin_id.lower() != request.plugin_id.lower()
): ):
raise PluginSourceAdmissionError( raise PluginSourceAdmissionError(
"明确选择的本地来源与目标插件不一致" "所选本地插件与安装目标不一致"
) )
available_local_candidates = inventory.local_candidates_for( available_local_candidates = inventory.local_candidates_for(
request.plugin_id request.plugin_id
@@ -154,7 +154,7 @@ def admit_plugin_install(
) )
local_candidates = exact_candidates or available_local_candidates local_candidates = exact_candidates or available_local_candidates
if not local_candidates: if not local_candidates:
raise PluginSourceAdmissionError("明确选择的本地来源没有当前插件候选") raise PluginSourceAdmissionError("所选本地仓库中没有该插件")
else: else:
requested_source_key, _repo_url = normalize_github_plugin_source( requested_source_key, _repo_url = normalize_github_plugin_source(
request.requested_repo_url request.requested_repo_url
@@ -171,10 +171,10 @@ def admit_plugin_install(
allow_source_change=request.source_change, allow_source_change=request.source_change,
) )
if selection.status is not PluginSelectionStatus.SELECTED or selection.candidate is None: if selection.status is not PluginSelectionStatus.SELECTED or selection.candidate is None:
raise PluginSourceAdmissionError(selection.reason or "插件来源准入失败") raise PluginSourceAdmissionError(selection.reason or "当前无法确认插件仓库")
candidate = selection.candidate candidate = selection.candidate
if not candidate.plugin_version: if not candidate.plugin_version:
raise PluginSourceAdmissionError("插件候选缺少可持久化的版本声明") raise PluginSourceAdmissionError("插件包缺少版本信息,无法安装")
if isinstance(candidate, PluginLocalCandidate): if isinstance(candidate, PluginLocalCandidate):
if identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN: if identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN:
@@ -201,7 +201,7 @@ def admit_plugin_install(
and identity.trusted_source_type is candidate.source_type and identity.trusted_source_type is candidate.source_type
and identity.trusted_source_key == candidate.source_key and identity.trusted_source_key == candidate.source_key
): ):
raise PluginSourceAdmissionError("显式换源的目标必须不同于当前来源") raise PluginSourceAdmissionError("所选仓库与当前绑定仓库相同")
basis = PluginBindingBasis.EXPLICIT_SOURCE_CHANGE basis = PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
bound_at = now bound_at = now
elif identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN: elif identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN:
+19 -4
View File
@@ -7,8 +7,12 @@ import concurrent.futures
from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, Optional from typing import Any, Optional
from app.application.plugin.identity import PluginIdentity from app.application.plugin.identity import (
from app.schemas.plugin import Plugin PluginBindingBasis,
PluginIdentity,
TrustedPluginSourceType,
)
from app.schemas.plugin import Plugin, PluginSourceBindingStatus
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]] MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
AsyncMarketLoader = Callable[ AsyncMarketLoader = Callable[
@@ -27,17 +31,28 @@ def apply_declared_metadata_fallback(
result: list[Plugin] = [] result: list[Plugin] = []
for plugin in plugins: for plugin in plugins:
identity = identities.get((plugin.id or "").lower()) identity = identities.get((plugin.id or "").lower())
updates: dict[str, object] = {}
if plugin.installed and not plugin.is_instance:
if identity is None:
updates["source_binding_status"] = PluginSourceBindingStatus.BINDING_REQUIRED
elif identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
updates["source_binding_status"] = (
PluginSourceBindingStatus.LOCAL_ONLY
if identity.binding_basis is PluginBindingBasis.LOCAL_ONLY
else PluginSourceBindingStatus.BINDING_REQUIRED
)
else:
updates["source_binding_status"] = PluginSourceBindingStatus.BOUND
if ( if (
identity is None identity is None
or identity.declared_metadata is None or identity.declared_metadata is None
or identity.declared_version is None or identity.declared_version is None
): ):
result.append(plugin) result.append(plugin.model_copy(update=updates) if updates else plugin)
continue continue
fallback = identity.declared_metadata.display_fallback( fallback = identity.declared_metadata.display_fallback(
installed_version=identity.declared_version installed_version=identity.declared_version
) )
updates: dict[str, str] = {}
if not plugin.plugin_version: if not plugin.plugin_version:
updates["plugin_version"] = fallback["plugin_version"] updates["plugin_version"] = fallback["plugin_version"]
if ( if (
+1 -1
View File
@@ -115,7 +115,7 @@ class PluginInstallGateway:
) )
if not compatible: if not compatible:
raise PluginSourceAdmissionError( raise PluginSourceAdmissionError(
message or "插件候选与当前 MoviePilot 版本不兼容" message or "插件与当前 MoviePilot 版本不兼容"
) )
return await self.__executor.execute( return await self.__executor.execute(
admission=admission, admission=admission,
+17 -17
View File
@@ -524,7 +524,7 @@ def _select_local_candidate(
): ):
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.INCOMPLETE, status=PluginSelectionStatus.INCOMPLETE,
reason="本地插件仓库读取失败,不能自动选择在线载荷", reason="部分插件仓库暂时无法读取,请稍后重试",
) )
if not local: if not local:
return None return None
@@ -532,12 +532,12 @@ def _select_local_candidate(
if selected_local is None: if selected_local is None:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.UNAVAILABLE, status=PluginSelectionStatus.UNAVAILABLE,
reason="本地候选没有符合当前运行代际的版本", reason="本地插件不支持当前 MoviePilot 版本",
) )
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.SELECTED, status=PluginSelectionStatus.SELECTED,
candidate=selected_local, candidate=selected_local,
reason="优先使用本地载荷", reason="当前使用本地插件",
) )
@@ -587,7 +587,7 @@ def select_plugin_candidate(
if not online: if not online:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.UNAVAILABLE, status=PluginSelectionStatus.UNAVAILABLE,
reason=f"没有找到插件 {plugin_id}在线候选", reason=f"没有找到插件 {plugin_id}可用安装包",
) )
allowed_source = _allowed_source(identity, normalized_id) allowed_source = _allowed_source(identity, normalized_id)
@@ -600,7 +600,7 @@ def select_plugin_candidate(
if not requested_online: if not requested_online:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.UNAVAILABLE, status=PluginSelectionStatus.UNAVAILABLE,
reason="明确选择的在线来源没有当前插件候选", reason="所选仓库中没有该插件的可用安装包",
) )
if allowed_source is not None: if allowed_source is not None:
_source_type, allowed_key = allowed_source _source_type, allowed_key = allowed_source
@@ -608,22 +608,22 @@ def select_plugin_candidate(
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.CONFLICT, status=PluginSelectionStatus.CONFLICT,
conflict_source_keys=(allowed_key, requested_source), conflict_source_keys=(allowed_key, requested_source),
reason="普通安装不能改变已绑定的在线来源", reason="该插件已绑定其他仓库,请先确认更换",
) )
if explicit_source or allow_source_change: if explicit_source or allow_source_change:
selected_requested = _select_best(requested_online, generation_order) selected_requested = _select_best(requested_online, generation_order)
if selected_requested is None: if selected_requested is None:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.UNAVAILABLE, status=PluginSelectionStatus.UNAVAILABLE,
reason="明确选择的来源没有符合当前运行代际的版本", reason="所选仓库没有适用于当前 MoviePilot 版本的插件包",
) )
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.SELECTED, status=PluginSelectionStatus.SELECTED,
candidate=selected_requested, candidate=selected_requested,
reason=( reason=(
"按显式换源目标选择在线载荷" "已选择目标仓库中的插件包"
if allow_source_change if allow_source_change
else "按管理员明确选择的来源安装在线载荷" else "已选择指定仓库中的插件包"
), ),
) )
if allowed_source is not None: if allowed_source is not None:
@@ -636,24 +636,24 @@ def select_plugin_candidate(
if not online: if not online:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.UNAVAILABLE, status=PluginSelectionStatus.UNAVAILABLE,
reason="当前来源身份没有可用候选", reason="已绑定仓库中暂无可用插件包",
) )
selected_online = _select_best(online, generation_order) selected_online = _select_best(online, generation_order)
if selected_online is None: if selected_online is None:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.UNAVAILABLE, status=PluginSelectionStatus.UNAVAILABLE,
reason="在线候选没有符合当前运行代际的版本", reason="已绑定仓库没有适用于当前 MoviePilot 版本的插件包",
) )
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.SELECTED, status=PluginSelectionStatus.SELECTED,
candidate=selected_online, candidate=selected_online,
reason="按已绑定来源选择在线载荷", reason="已使用绑定仓库中的插件包",
) )
if identity is not None: if identity is not None:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.INCOMPLETE, status=PluginSelectionStatus.INCOMPLETE,
reason="插件来源身份尚未绑定,不能自动选择在线载荷", reason="当前插件尚未绑定仓库",
) )
source_pairs = {(candidate.source_type, candidate.source_key) for candidate in online} source_pairs = {(candidate.source_type, candidate.source_key) for candidate in online}
@@ -661,25 +661,25 @@ def select_plugin_candidate(
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.CONFLICT, status=PluginSelectionStatus.CONFLICT,
conflict_source_keys=tuple(source_key for _source_type, source_key in source_pairs), conflict_source_keys=tuple(source_key for _source_type, source_key in source_pairs),
reason="未安装插件存在多个在线来源,不能静默选择", reason="插件存在多个仓库,请选择仓库",
) )
source_type = next(iter(source_pairs))[0] source_type = next(iter(source_pairs))[0]
if source_type is TrustedPluginSourceType.THIRD_PARTY and not inventory.can_use_for_tofu: if source_type is TrustedPluginSourceType.THIRD_PARTY and not inventory.can_use_for_tofu:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.INCOMPLETE, status=PluginSelectionStatus.INCOMPLETE,
reason="市场读取不完整,不能建立唯一第三方来源的 TOFU", reason="部分插件仓库暂时无法读取,无法安全确认仓库",
) )
selected_online = _select_best(online, generation_order) selected_online = _select_best(online, generation_order)
if selected_online is None: if selected_online is None:
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.UNAVAILABLE, status=PluginSelectionStatus.UNAVAILABLE,
reason="在线候选没有符合当前运行代际的版本", reason="可用仓库中没有适用于当前 MoviePilot 版本的插件包",
) )
return PluginSelection( return PluginSelection(
status=PluginSelectionStatus.SELECTED, status=PluginSelectionStatus.SELECTED,
candidate=selected_online, candidate=selected_online,
reason="唯一在线来源候选", reason="已找到唯一可用仓库",
) )
+1 -1
View File
@@ -430,7 +430,7 @@ class SiteParserBase(metaclass=ABCMeta):
""" """
pass pass
def _parse_logged_in(self, html_text): def _parse_logged_in(self, html_text: str) -> bool:
""" """
解析用户是否已经登陆 解析用户是否已经登陆
:param html_text: :param html_text:
+1 -1
View File
@@ -20,7 +20,7 @@ def process_topology_issue(*, workers: int, safe_mode: bool) -> Optional[str]:
if workers == 1 or safe_mode: if workers == 1 or safe_mode:
return None return None
return ( return (
"MoviePilot V3 全功能模式仅支持 API_WORKERS=1" "MoviePilot v3 全功能模式仅支持 API_WORKERS=1"
f"当前配置为 {workers},每个 worker 都会重复启动插件、调度器、监控器和工作流。" f"当前配置为 {workers},每个 worker 都会重复启动插件、调度器、监控器和工作流。"
"请将 API_WORKERS 改为 1 后重启。故障排查可以临时启用 " "请将 API_WORKERS 改为 1 后重启。故障排查可以临时启用 "
"MOVIEPILOT_SAFE_MODE=true,但安全模式不是全功能扩容方案。" "MOVIEPILOT_SAFE_MODE=true,但安全模式不是全功能扩容方案。"
+1
View File
@@ -297,6 +297,7 @@ SCHEMA_EXPORTS = {
'PluginSourceIdentity': ('app.schemas.plugin', 'PluginSourceIdentity'), 'PluginSourceIdentity': ('app.schemas.plugin', 'PluginSourceIdentity'),
'PluginSourceInstallRequest': ('app.schemas.plugin', 'PluginSourceInstallRequest'), 'PluginSourceInstallRequest': ('app.schemas.plugin', 'PluginSourceInstallRequest'),
'PluginSourceOptions': ('app.schemas.plugin', 'PluginSourceOptions'), 'PluginSourceOptions': ('app.schemas.plugin', 'PluginSourceOptions'),
'PluginUpdateCandidate': ('app.schemas.plugin', 'PluginUpdateCandidate'),
'PluginTriggeredEventData': ('app.schemas.event', 'PluginTriggeredEventData'), 'PluginTriggeredEventData': ('app.schemas.event', 'PluginTriggeredEventData'),
'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'), 'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'),
'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'), 'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'),
+24
View File
@@ -17,6 +17,26 @@ class PluginRuntimeStatus(str, _Enum):
LOAD_FAILED = "load_failed" LOAD_FAILED = "load_failed"
class PluginSourceBindingStatus(str, _Enum):
"""已安装插件的在线更新仓库绑定状态。"""
BOUND = "bound"
BINDING_REQUIRED = "binding_required"
LOCAL_ONLY = "local_only"
class PluginUpdateCandidate(BaseModel): # type: ignore[misc]
"""插件市场为已安装插件发现的当前最高在线更新候选。"""
source_type: Literal["official", "third_party"] = Field(
description="候选仓库是官方来源还是第三方来源"
)
source_key: str = Field(description="候选仓库的规范来源键")
repo_url: str = Field(description="候选仓库的公开 GitHub 地址")
version: str = Field(description="候选仓库当前可安装版本")
is_bound: bool = Field(description="候选仓库是否为插件当前已绑定仓库")
class PluginInstance(BaseModel): class PluginInstance(BaseModel):
"""持久化一个共享源码插件的独立运行实例。""" """持久化一个共享源码插件的独立运行实例。"""
@@ -73,6 +93,10 @@ class Plugin(BaseModel):
has_page: Optional[bool] = False has_page: Optional[bool] = False
# 是否有新版本 # 是否有新版本
has_update: Optional[bool] = False has_update: Optional[bool] = False
# 当前市场选择的最高在线更新候选;没有确定候选时为空
update_candidate: Optional[PluginUpdateCandidate] = None
# 插件仓库绑定状态;仅已安装物理插件由后端投影真实身份
source_binding_status: PluginSourceBindingStatus = PluginSourceBindingStatus.BOUND
# 主系统版本是否兼容 # 主系统版本是否兼容
system_version_compatible: Optional[bool] = True system_version_compatible: Optional[bool] = True
# 主系统版本兼容提示 # 主系统版本兼容提示
+1 -1
View File
@@ -325,7 +325,7 @@ def test_install_plugin_reports_source_conflict_before_retry() -> None:
"app.agent.tools.impl.install_plugin.inspect_plugin_sources", "app.agent.tools.impl.install_plugin.inspect_plugin_sources",
new=AsyncMock(return_value={ new=AsyncMock(return_value={
"selection_status": "conflict", "selection_status": "conflict",
"selection_reason": "未安装插件存在多个在线来源,不能静默选择", "selection_reason": "插件存在多个仓库,请选择仓库",
"inventory_complete": True, "inventory_complete": True,
"candidates": source_candidates, "candidates": source_candidates,
}), }),
+128 -8
View File
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app import schemas from app import schemas
from app.api.endpoints import plugin as plugin_endpoint from app.api.endpoints import plugin as plugin_endpoint
from app.api.endpoints.plugin import ( from app.api.endpoints.plugin import (
_prepare_update_candidates,
plugin_history, plugin_history,
plugin_releases, plugin_releases,
plugin_static_file, plugin_static_file,
@@ -40,21 +41,28 @@ SOURCE_URL = "https://github.com/demo/plugins"
def _plugin_identity( def _plugin_identity(
*, *,
metadata: PluginDeclaredMetadata | None = None, metadata: PluginDeclaredMetadata | None = None,
plugin_id: str = "DemoPlugin",
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
source_key: str = SOURCE_KEY,
) -> PluginIdentity: ) -> PluginIdentity:
"""构造绑定到测试仓库的插件身份。""" """构造绑定到测试仓库的插件身份。"""
has_payload = metadata is not None has_payload = metadata is not None
return PluginIdentity( return PluginIdentity(
plugin_id="DemoPlugin", plugin_id=plugin_id,
normalized_plugin_id="demoplugin", normalized_plugin_id=plugin_id.lower(),
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY, trusted_source_type=source_type,
trusted_source_key=SOURCE_KEY, trusted_source_key=source_key,
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL, binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
payload_source_type=( payload_source_type=(
PluginPayloadSourceType.THIRD_PARTY PluginPayloadSourceType.UNKNOWN
if has_payload if not has_payload
else PluginPayloadSourceType.UNKNOWN else (
PluginPayloadSourceType.OFFICIAL
if source_type is TrustedPluginSourceType.OFFICIAL
else PluginPayloadSourceType.THIRD_PARTY
)
), ),
payload_source_key=SOURCE_KEY if has_payload else None, payload_source_key=source_key if has_payload else None,
declared_version="1.0.0" if has_payload else None, declared_version="1.0.0" if has_payload else None,
package_generation="v3" if has_payload else None, package_generation="v3" if has_payload else None,
declared_metadata=metadata, declared_metadata=metadata,
@@ -67,6 +75,118 @@ def _plugin_identity(
) )
def test_update_candidates_report_source_and_binding_relationship():
"""市场更新候选应标明仓库类型,并仅把当前绑定仓库视为可直接更新。"""
plugins = [
schemas.Plugin(
id="OfficialBound",
plugin_version="2.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
),
schemas.Plugin(
id="ThirdPartyBound",
plugin_version="2.0.0",
repo_url="https://github.com/example/plugins",
has_update=True,
),
schemas.Plugin(
id="AlternativeOfficial",
plugin_version="2.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
),
schemas.Plugin(
id="LocalOnly",
plugin_version="2.0.0",
repo_url="local:///plugins",
has_update=True,
),
]
persistence = MagicMock()
persistence.list_identities = AsyncMock(
return_value=[
_plugin_identity(
plugin_id="OfficialBound",
source_type=TrustedPluginSourceType.OFFICIAL,
source_key="github:jxxghp/moviepilot-plugins",
),
_plugin_identity(
plugin_id="ThirdPartyBound",
source_key="github:example/plugins",
),
_plugin_identity(
plugin_id="AlternativeOfficial",
source_key="github:example/plugins",
),
]
)
plugin_manager = MagicMock()
plugin_manager.process_plugins_list.side_effect = lambda higher, _base: higher
with patch(
"app.api.endpoints.plugin.get_plugin_persistence",
return_value=persistence,
):
result = asyncio.run(
_prepare_update_candidates(
plugin_manager,
plugins,
plugins,
[plugin.id for plugin in plugins],
)
)
assert result[0].update_candidate is not None
assert result[0].update_candidate.source_type == "official"
assert result[0].update_candidate.is_bound is True
assert result[1].update_candidate is not None
assert result[1].update_candidate.source_type == "third_party"
assert result[1].update_candidate.is_bound is True
assert result[2].update_candidate is not None
assert result[2].update_candidate.source_type == "official"
assert result[2].update_candidate.is_bound is False
assert result[3].update_candidate is None
def test_bound_repository_update_precedes_a_higher_alternative():
"""绑定仓库仍有更新时先完成可信更新,下一轮再提示其他仓库版本。"""
bound_update = schemas.Plugin(
id="DemoPlugin",
plugin_version="2.0.0",
repo_url=SOURCE_URL,
has_update=True,
)
alternative_update = schemas.Plugin(
id="DemoPlugin",
plugin_version="3.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
)
persistence = MagicMock()
persistence.list_identities = AsyncMock(return_value=[_plugin_identity()])
plugin_manager = MagicMock()
plugin_manager.process_plugins_list.return_value = [bound_update]
with patch(
"app.api.endpoints.plugin.get_plugin_persistence",
return_value=persistence,
):
result = asyncio.run(
_prepare_update_candidates(
plugin_manager,
[bound_update, alternative_update],
[alternative_update],
["DemoPlugin"],
)
)
assert result == [bound_update]
assert result[0].update_candidate is not None
assert result[0].update_candidate.version == "2.0.0"
assert result[0].update_candidate.is_bound is True
def _persistence(identity: PluginIdentity) -> MagicMock: def _persistence(identity: PluginIdentity) -> MagicMock:
"""构造只暴露身份读取合同的异步持久化替身。""" """构造只暴露身份读取合同的异步持久化替身。"""
persistence = MagicMock() persistence = MagicMock()
@@ -384,7 +384,7 @@ async def test_http_source_options_return_sanitized_candidates(monkeypatch) -> N
identity=identity, identity=identity,
selection=SimpleNamespace( selection=SimpleNamespace(
status=SimpleNamespace(value="conflict"), status=SimpleNamespace(value="conflict"),
reason="未安装插件存在多个在线来源,不能静默选择", reason="插件存在多个仓库,请选择仓库",
), ),
online_candidates=( online_candidates=(
SimpleNamespace( SimpleNamespace(
+3 -3
View File
@@ -184,7 +184,7 @@ def test_first_online_binding_uses_payload_commit_time() -> None:
def test_force_semantics_cannot_authorize_source_change() -> None: def test_force_semantics_cannot_authorize_source_change() -> None:
"""普通安装即使替换载荷,也不能选择不同于已绑定来源的仓库。""" """普通安装即使替换载荷,也不能选择不同于已绑定来源的仓库。"""
with pytest.raises(PluginSourceAdmissionError, match="普通安装不能改变"): with pytest.raises(PluginSourceAdmissionError, match="已绑定其他仓库"):
admit_plugin_install( admit_plugin_install(
_inventory( _inventory(
_online_candidate(), _online_candidate(),
@@ -207,7 +207,7 @@ def test_force_semantics_cannot_authorize_source_change() -> None:
@pytest.mark.parametrize("revision", [None, 2, 4]) @pytest.mark.parametrize("revision", [None, 2, 4])
def test_source_change_requires_exact_identity_revision(revision: int | None) -> None: def test_source_change_requires_exact_identity_revision(revision: int | None) -> None:
"""显式换源必须携带当前身份的精确 revision。""" """显式换源必须携带当前身份的精确 revision。"""
with pytest.raises(PluginSourceAdmissionError, match="revision"): with pytest.raises(PluginSourceAdmissionError, match="状态已变化"):
admit_plugin_install( admit_plugin_install(
_inventory( _inventory(
_online_candidate( _online_candidate(
@@ -410,7 +410,7 @@ def test_source_change_rejects_local_payload_reference() -> None:
with pytest.raises( with pytest.raises(
PluginSourceAdmissionError, PluginSourceAdmissionError,
match="显式换源只接受在线插件仓库", match="只能更换为在线插件仓库",
): ):
admit_plugin_install( admit_plugin_install(
_inventory(local_candidates=(local,)), _inventory(local_candidates=(local,)),
+2 -2
View File
@@ -1,9 +1,9 @@
from types import SimpleNamespace from types import SimpleNamespace
from app.chain import subscribe as subscribe_module
from app.chain.subscribe import SubscribeChain
from app.agent.tools.impl._filter_rule_utils import normalize_media_type from app.agent.tools.impl._filter_rule_utils import normalize_media_type
from app.application.rules import RuleHelper from app.application.rules import RuleHelper
from app.chain import subscribe as subscribe_module
from app.chain.subscribe import SubscribeChain
from app.domain.context import MediaInfo, MusicInfo, TorrentInfo from app.domain.context import MediaInfo, MusicInfo, TorrentInfo
from app.modules.filter import FilterModule from app.modules.filter import FilterModule
from app.runtime.events import Event from app.runtime.events import Event