diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 7c1b4608e..b45e91df4 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -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), diff --git a/app/application/plugin/admission.py b/app/application/plugin/admission.py index 8cd4fbd47..8d5658781 100644 --- a/app/application/plugin/admission.py +++ b/app/application/plugin/admission.py @@ -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: diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py index 2c2005093..9ad58a4bb 100644 --- a/app/application/plugin/catalog.py +++ b/app/application/plugin/catalog.py @@ -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 ( diff --git a/app/application/plugin/gateway.py b/app/application/plugin/gateway.py index 18df8ad9c..f34367d9e 100644 --- a/app/application/plugin/gateway.py +++ b/app/application/plugin/gateway.py @@ -115,7 +115,7 @@ class PluginInstallGateway: ) if not compatible: raise PluginSourceAdmissionError( - message or "插件候选与当前 MoviePilot 版本不兼容" + message or "插件包与当前 MoviePilot 版本不兼容" ) return await self.__executor.execute( admission=admission, diff --git a/app/application/plugin/source.py b/app/application/plugin/source.py index f4c0302ef..91c4b81cc 100644 --- a/app/application/plugin/source.py +++ b/app/application/plugin/source.py @@ -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="已找到唯一可用仓库", ) diff --git a/app/modules/indexer/parser/__init__.py b/app/modules/indexer/parser/__init__.py index f163090e0..b9dea832b 100644 --- a/app/modules/indexer/parser/__init__.py +++ b/app/modules/indexer/parser/__init__.py @@ -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: diff --git a/app/runtime/topology.py b/app/runtime/topology.py index f85c4c31e..ce3b2bac6 100644 --- a/app/runtime/topology.py +++ b/app/runtime/topology.py @@ -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,但安全模式不是全功能扩容方案。" diff --git a/app/schemas/exports.py b/app/schemas/exports.py index a42ef9b80..988d01a9e 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -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'), diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index c8d9b6a90..7dce4499c 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -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 # 主系统版本兼容提示 diff --git a/tests/test_agent_plugin_tools.py b/tests/test_agent_plugin_tools.py index 912724a48..fa8f6ed70 100644 --- a/tests/test_agent_plugin_tools.py +++ b/tests/test_agent_plugin_tools.py @@ -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, }), diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 2aafde1e1..9dbb22442 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -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() diff --git a/tests/test_plugin_external_install_boundary.py b/tests/test_plugin_external_install_boundary.py index dd2b98f7c..7df712ec1 100644 --- a/tests/test_plugin_external_install_boundary.py +++ b/tests/test_plugin_external_install_boundary.py @@ -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( diff --git a/tests/test_plugin_install_admission.py b/tests/test_plugin_install_admission.py index 989ea822c..c6bf5a7a3 100644 --- a/tests/test_plugin_install_admission.py +++ b/tests/test_plugin_install_admission.py @@ -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,)), diff --git a/tests/test_rule_group_media.py b/tests/test_rule_group_media.py index fa4465eca..3b3e29bc9 100644 --- a/tests/test_rule_group_media.py +++ b/tests/test_rule_group_media.py @@ -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