mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
fix(plugin): 按绑定仓库处理插件更新 (#6472)
This commit is contained in:
@@ -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.folders import remove_plugin_from_folders
|
||||
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.runtime import PluginRuntime, get_plugin_manager
|
||||
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 PluginSourceInstallRequest as _SchemaPluginSourceInstallRequest
|
||||
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.token import TokenPayload as _SchemaTokenPayload
|
||||
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])
|
||||
async def all_plugins(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
@@ -310,11 +379,18 @@ async def all_plugins(
|
||||
local_repo_plugins = plugin_manager.get_local_repo_plugins()
|
||||
# 在线插件
|
||||
online_plugins = await plugin_manager.async_get_online_plugins(force)
|
||||
installed_ids = [plugin.id for plugin in installed_plugins if plugin.id]
|
||||
candidate_plugins = (
|
||||
plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, [])
|
||||
if online_plugins or local_repo_plugins
|
||||
else []
|
||||
)
|
||||
candidate_plugins = await _prepare_update_candidates(
|
||||
plugin_manager,
|
||||
online_plugins,
|
||||
candidate_plugins,
|
||||
installed_ids,
|
||||
)
|
||||
if not candidate_plugins:
|
||||
# 没有获取在线插件
|
||||
if state == "market":
|
||||
@@ -328,10 +404,9 @@ async def all_plugins(
|
||||
# 插件市场插件清单
|
||||
market_plugins = []
|
||||
# 已安装插件IDS
|
||||
_installed_ids = [plugin.id for plugin in installed_plugins]
|
||||
# 未安装的线上插件或者有更新的插件
|
||||
for plugin in candidate_plugins:
|
||||
if plugin.id not in _installed_ids:
|
||||
if plugin.id not in installed_ids:
|
||||
market_plugins.append(plugin)
|
||||
elif plugin.has_update:
|
||||
market_plugins.append(plugin)
|
||||
@@ -616,7 +691,7 @@ async def get_plugin_source_identity(
|
||||
"""返回显式换源确认所需的当前可信来源和 revision。"""
|
||||
identity = await get_plugin_persistence().get_identity(plugin_id)
|
||||
if identity is None:
|
||||
return _SchemaResponse(success=False, message="插件来源身份不存在")
|
||||
return _SchemaResponse(success=False, message="未找到该插件的仓库绑定信息")
|
||||
return _SchemaResponse(
|
||||
success=True,
|
||||
data=_plugin_source_identity_schema(identity),
|
||||
|
||||
@@ -120,15 +120,15 @@ def admit_plugin_install(
|
||||
bound_at: datetime | None
|
||||
if request.source_change:
|
||||
if not request.explicit_source or not request.requested_repo_url:
|
||||
raise PluginSourceAdmissionError("显式换源必须指定目标在线来源")
|
||||
raise PluginSourceAdmissionError("请选择要更换到的插件仓库")
|
||||
if request.requested_repo_url.startswith("local://"):
|
||||
raise PluginSourceAdmissionError("显式换源只接受在线插件仓库")
|
||||
raise PluginSourceAdmissionError("只能更换为在线插件仓库")
|
||||
if identity is None or identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
raise PluginSourceAdmissionError("显式换源要求插件已经绑定在线来源")
|
||||
raise PluginSourceAdmissionError("当前插件尚未绑定仓库")
|
||||
if request.expected_revision != identity.revision:
|
||||
raise PluginSourceAdmissionError("显式换源的身份 revision 已失效")
|
||||
raise PluginSourceAdmissionError("插件状态已变化,请重新打开页面后再试")
|
||||
elif request.expected_revision is not None:
|
||||
raise PluginSourceAdmissionError("普通安装不能携带换源 revision")
|
||||
raise PluginSourceAdmissionError("插件操作参数无效,请重新打开页面后再试")
|
||||
|
||||
requested_source_key = None
|
||||
local_candidates = None
|
||||
@@ -142,7 +142,7 @@ def admit_plugin_install(
|
||||
or referenced_plugin_id.lower() != request.plugin_id.lower()
|
||||
):
|
||||
raise PluginSourceAdmissionError(
|
||||
"明确选择的本地来源与目标插件不一致"
|
||||
"所选本地插件与安装目标不一致"
|
||||
)
|
||||
available_local_candidates = inventory.local_candidates_for(
|
||||
request.plugin_id
|
||||
@@ -154,7 +154,7 @@ def admit_plugin_install(
|
||||
)
|
||||
local_candidates = exact_candidates or available_local_candidates
|
||||
if not local_candidates:
|
||||
raise PluginSourceAdmissionError("明确选择的本地来源没有当前插件候选")
|
||||
raise PluginSourceAdmissionError("所选本地仓库中没有该插件")
|
||||
else:
|
||||
requested_source_key, _repo_url = normalize_github_plugin_source(
|
||||
request.requested_repo_url
|
||||
@@ -171,10 +171,10 @@ def admit_plugin_install(
|
||||
allow_source_change=request.source_change,
|
||||
)
|
||||
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
|
||||
if not candidate.plugin_version:
|
||||
raise PluginSourceAdmissionError("插件候选缺少可持久化的版本声明")
|
||||
raise PluginSourceAdmissionError("插件包缺少版本信息,无法安装")
|
||||
|
||||
if isinstance(candidate, PluginLocalCandidate):
|
||||
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_key == candidate.source_key
|
||||
):
|
||||
raise PluginSourceAdmissionError("显式换源的目标必须不同于当前来源")
|
||||
raise PluginSourceAdmissionError("所选仓库与当前绑定仓库相同")
|
||||
basis = PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
|
||||
bound_at = now
|
||||
elif identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN:
|
||||
|
||||
@@ -7,8 +7,12 @@ import concurrent.futures
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.plugin.identity import PluginIdentity
|
||||
from app.schemas.plugin import Plugin
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.schemas.plugin import Plugin, PluginSourceBindingStatus
|
||||
|
||||
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
|
||||
AsyncMarketLoader = Callable[
|
||||
@@ -27,17 +31,28 @@ def apply_declared_metadata_fallback(
|
||||
result: list[Plugin] = []
|
||||
for plugin in plugins:
|
||||
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 (
|
||||
identity is None
|
||||
or identity.declared_metadata is None
|
||||
or identity.declared_version is None
|
||||
):
|
||||
result.append(plugin)
|
||||
result.append(plugin.model_copy(update=updates) if updates else plugin)
|
||||
continue
|
||||
fallback = identity.declared_metadata.display_fallback(
|
||||
installed_version=identity.declared_version
|
||||
)
|
||||
updates: dict[str, str] = {}
|
||||
if not plugin.plugin_version:
|
||||
updates["plugin_version"] = fallback["plugin_version"]
|
||||
if (
|
||||
|
||||
@@ -115,7 +115,7 @@ class PluginInstallGateway:
|
||||
)
|
||||
if not compatible:
|
||||
raise PluginSourceAdmissionError(
|
||||
message or "插件候选与当前 MoviePilot 版本不兼容"
|
||||
message or "插件包与当前 MoviePilot 版本不兼容"
|
||||
)
|
||||
return await self.__executor.execute(
|
||||
admission=admission,
|
||||
|
||||
@@ -524,7 +524,7 @@ def _select_local_candidate(
|
||||
):
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.INCOMPLETE,
|
||||
reason="本地插件仓库读取失败,不能自动选择在线载荷",
|
||||
reason="部分插件仓库暂时无法读取,请稍后重试",
|
||||
)
|
||||
if not local:
|
||||
return None
|
||||
@@ -532,12 +532,12 @@ def _select_local_candidate(
|
||||
if selected_local is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="本地候选没有符合当前运行代际的版本",
|
||||
reason="本地插件不支持当前 MoviePilot 版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_local,
|
||||
reason="优先使用本地载荷",
|
||||
reason="当前使用本地插件",
|
||||
)
|
||||
|
||||
|
||||
@@ -587,7 +587,7 @@ def select_plugin_candidate(
|
||||
if not online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason=f"没有找到插件 {plugin_id} 的在线候选",
|
||||
reason=f"没有找到插件 {plugin_id} 的可用安装包",
|
||||
)
|
||||
|
||||
allowed_source = _allowed_source(identity, normalized_id)
|
||||
@@ -600,7 +600,7 @@ def select_plugin_candidate(
|
||||
if not requested_online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="明确选择的在线来源没有当前插件候选",
|
||||
reason="所选仓库中没有该插件的可用安装包",
|
||||
)
|
||||
if allowed_source is not None:
|
||||
_source_type, allowed_key = allowed_source
|
||||
@@ -608,22 +608,22 @@ def select_plugin_candidate(
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.CONFLICT,
|
||||
conflict_source_keys=(allowed_key, requested_source),
|
||||
reason="普通安装不能改变已绑定的在线来源",
|
||||
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="明确选择的来源没有符合当前运行代际的版本",
|
||||
reason="所选仓库没有适用于当前 MoviePilot 版本的插件包",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_requested,
|
||||
reason=(
|
||||
"按显式换源目标选择在线载荷"
|
||||
"已选择目标仓库中的插件包"
|
||||
if allow_source_change
|
||||
else "按管理员明确选择的来源安装在线载荷"
|
||||
else "已选择指定仓库中的插件包"
|
||||
),
|
||||
)
|
||||
if allowed_source is not None:
|
||||
@@ -636,24 +636,24 @@ def select_plugin_candidate(
|
||||
if not online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="当前来源身份没有可用候选",
|
||||
reason="已绑定仓库中暂无可用插件包",
|
||||
)
|
||||
selected_online = _select_best(online, generation_order)
|
||||
if selected_online is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="在线候选没有符合当前运行代际的版本",
|
||||
reason="已绑定仓库没有适用于当前 MoviePilot 版本的插件包",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_online,
|
||||
reason="按已绑定来源选择在线载荷",
|
||||
reason="已使用绑定仓库中的插件包",
|
||||
)
|
||||
|
||||
if identity is not None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.INCOMPLETE,
|
||||
reason="插件来源身份尚未绑定,不能自动选择在线载荷",
|
||||
reason="当前插件尚未绑定仓库",
|
||||
)
|
||||
|
||||
source_pairs = {(candidate.source_type, candidate.source_key) for candidate in online}
|
||||
@@ -661,25 +661,25 @@ def select_plugin_candidate(
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.CONFLICT,
|
||||
conflict_source_keys=tuple(source_key for _source_type, source_key in source_pairs),
|
||||
reason="未安装插件存在多个在线来源,不能静默选择",
|
||||
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="市场读取不完整,不能建立唯一第三方来源的 TOFU",
|
||||
reason="部分插件仓库暂时无法读取,无法安全确认仓库",
|
||||
)
|
||||
selected_online = _select_best(online, generation_order)
|
||||
if selected_online is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="在线候选没有符合当前运行代际的版本",
|
||||
reason="可用仓库中没有适用于当前 MoviePilot 版本的插件包",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_online,
|
||||
reason="唯一在线来源候选",
|
||||
reason="已找到唯一可用仓库",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -430,7 +430,7 @@ class SiteParserBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def _parse_logged_in(self, html_text):
|
||||
def _parse_logged_in(self, html_text: str) -> bool:
|
||||
"""
|
||||
解析用户是否已经登陆
|
||||
:param html_text:
|
||||
|
||||
@@ -20,7 +20,7 @@ def process_topology_issue(*, workers: int, safe_mode: bool) -> Optional[str]:
|
||||
if workers == 1 or safe_mode:
|
||||
return None
|
||||
return (
|
||||
"MoviePilot V3 全功能模式仅支持 API_WORKERS=1;"
|
||||
"MoviePilot v3 全功能模式仅支持 API_WORKERS=1;"
|
||||
f"当前配置为 {workers},每个 worker 都会重复启动插件、调度器、监控器和工作流。"
|
||||
"请将 API_WORKERS 改为 1 后重启。故障排查可以临时启用 "
|
||||
"MOVIEPILOT_SAFE_MODE=true,但安全模式不是全功能扩容方案。"
|
||||
|
||||
@@ -297,6 +297,7 @@ SCHEMA_EXPORTS = {
|
||||
'PluginSourceIdentity': ('app.schemas.plugin', 'PluginSourceIdentity'),
|
||||
'PluginSourceInstallRequest': ('app.schemas.plugin', 'PluginSourceInstallRequest'),
|
||||
'PluginSourceOptions': ('app.schemas.plugin', 'PluginSourceOptions'),
|
||||
'PluginUpdateCandidate': ('app.schemas.plugin', 'PluginUpdateCandidate'),
|
||||
'PluginTriggeredEventData': ('app.schemas.event', 'PluginTriggeredEventData'),
|
||||
'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'),
|
||||
'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'),
|
||||
|
||||
@@ -17,6 +17,26 @@ class PluginRuntimeStatus(str, _Enum):
|
||||
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):
|
||||
"""持久化一个共享源码插件的独立运行实例。"""
|
||||
|
||||
@@ -73,6 +93,10 @@ class Plugin(BaseModel):
|
||||
has_page: 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
|
||||
# 主系统版本兼容提示
|
||||
|
||||
@@ -325,7 +325,7 @@ def test_install_plugin_reports_source_conflict_before_retry() -> None:
|
||||
"app.agent.tools.impl.install_plugin.inspect_plugin_sources",
|
||||
new=AsyncMock(return_value={
|
||||
"selection_status": "conflict",
|
||||
"selection_reason": "未安装插件存在多个在线来源,不能静默选择",
|
||||
"selection_reason": "该插件存在于多个仓库,请选择仓库",
|
||||
"inventory_complete": True,
|
||||
"candidates": source_candidates,
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from app import schemas
|
||||
from app.api.endpoints import plugin as plugin_endpoint
|
||||
from app.api.endpoints.plugin import (
|
||||
_prepare_update_candidates,
|
||||
plugin_history,
|
||||
plugin_releases,
|
||||
plugin_static_file,
|
||||
@@ -40,21 +41,28 @@ SOURCE_URL = "https://github.com/demo/plugins"
|
||||
def _plugin_identity(
|
||||
*,
|
||||
metadata: PluginDeclaredMetadata | None = None,
|
||||
plugin_id: str = "DemoPlugin",
|
||||
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
|
||||
source_key: str = SOURCE_KEY,
|
||||
) -> PluginIdentity:
|
||||
"""构造绑定到测试仓库的插件身份。"""
|
||||
has_payload = metadata is not None
|
||||
return PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
trusted_source_key=SOURCE_KEY,
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
trusted_source_type=source_type,
|
||||
trusted_source_key=source_key,
|
||||
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
|
||||
payload_source_type=(
|
||||
PluginPayloadSourceType.THIRD_PARTY
|
||||
if has_payload
|
||||
else PluginPayloadSourceType.UNKNOWN
|
||||
PluginPayloadSourceType.UNKNOWN
|
||||
if not has_payload
|
||||
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,
|
||||
package_generation="v3" if has_payload else None,
|
||||
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:
|
||||
"""构造只暴露身份读取合同的异步持久化替身。"""
|
||||
persistence = MagicMock()
|
||||
|
||||
@@ -384,7 +384,7 @@ async def test_http_source_options_return_sanitized_candidates(monkeypatch) -> N
|
||||
identity=identity,
|
||||
selection=SimpleNamespace(
|
||||
status=SimpleNamespace(value="conflict"),
|
||||
reason="未安装插件存在多个在线来源,不能静默选择",
|
||||
reason="该插件存在于多个仓库,请选择仓库",
|
||||
),
|
||||
online_candidates=(
|
||||
SimpleNamespace(
|
||||
|
||||
@@ -184,7 +184,7 @@ def test_first_online_binding_uses_payload_commit_time() -> None:
|
||||
|
||||
def test_force_semantics_cannot_authorize_source_change() -> None:
|
||||
"""普通安装即使替换载荷,也不能选择不同于已绑定来源的仓库。"""
|
||||
with pytest.raises(PluginSourceAdmissionError, match="普通安装不能改变"):
|
||||
with pytest.raises(PluginSourceAdmissionError, match="已绑定其他仓库"):
|
||||
admit_plugin_install(
|
||||
_inventory(
|
||||
_online_candidate(),
|
||||
@@ -207,7 +207,7 @@ def test_force_semantics_cannot_authorize_source_change() -> None:
|
||||
@pytest.mark.parametrize("revision", [None, 2, 4])
|
||||
def test_source_change_requires_exact_identity_revision(revision: int | None) -> None:
|
||||
"""显式换源必须携带当前身份的精确 revision。"""
|
||||
with pytest.raises(PluginSourceAdmissionError, match="revision"):
|
||||
with pytest.raises(PluginSourceAdmissionError, match="状态已变化"):
|
||||
admit_plugin_install(
|
||||
_inventory(
|
||||
_online_candidate(
|
||||
@@ -410,7 +410,7 @@ def test_source_change_rejects_local_payload_reference() -> None:
|
||||
|
||||
with pytest.raises(
|
||||
PluginSourceAdmissionError,
|
||||
match="显式换源只接受在线插件仓库",
|
||||
match="只能更换为在线插件仓库",
|
||||
):
|
||||
admit_plugin_install(
|
||||
_inventory(local_candidates=(local,)),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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.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.modules.filter import FilterModule
|
||||
from app.runtime.events import Event
|
||||
|
||||
Reference in New Issue
Block a user