diff --git a/app/application/plugin/gateway.py b/app/application/plugin/gateway.py index 1adbb25a0..1b44e4424 100644 --- a/app/application/plugin/gateway.py +++ b/app/application/plugin/gateway.py @@ -4,7 +4,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, replace from datetime import datetime from typing import Protocol @@ -33,6 +33,7 @@ from app.domain.plugin import PLUGIN_SYSTEM_VERSION_FIELD from app.schemas.exception import PluginMutationRejectedError InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]] +MarketRefresh = Callable[[Sequence[str]], Awaitable[None]] IdentityReader = Callable[[str], Awaitable[PluginIdentity | None]] CandidateCompatibility = Callable[[Candidate], tuple[bool, str]] SourcePluginIdResolver = Callable[[str], str] @@ -97,9 +98,12 @@ class PluginInstallGateway: executor: PluginInstallExecutor, clock: Callable[[], datetime], source_plugin_id: SourcePluginIdResolver, + market_refresh: MarketRefresh | None = None, ) -> None: """ 保存候选事实、身份读取、兼容校验、事务执行、时间和源身份归一端口。 + + :param market_refresh: 只重读指定市场索引的端口;未注入时沿用缓存索引 """ self.__source_plugin_id = source_plugin_id self.__inventory = inventory @@ -107,6 +111,7 @@ class PluginInstallGateway: self.__candidate_compatibility = candidate_compatibility self.__executor = executor self.__clock = clock + self.__market_refresh = market_refresh async def install( self, @@ -131,6 +136,10 @@ class PluginInstallGateway: 归一必须早于生命周期占位:``hold`` 按 ID 互斥,用分身 ID 占位不会把同源 分身的并发安装串起来,它们会同时改写同一份源插件载荷。 + + ``force`` 且没有点名 Release 版本时,先把该插件出现过的市场索引重读一次: + Release tag 由索引里的 version 拼出,沿用缓存索引会把同一个旧版本重装一遍 + 还提示更新成功。重读只覆盖这些仓库,其余市场仍走缓存。 """ plugin_id = self.__source_plugin_id(plugin_id) try: @@ -139,6 +148,17 @@ class PluginInstallGateway: inventory = await self.__inventory(False) async with plugin_lifecycle.hold(plugin_id, startup_token): identity = await self.__identity(plugin_id) + markets = _plugin_market_urls(inventory, plugin_id) + if ( + force + and release_version is None + and markets + and self.__market_refresh is not None + ): + await self.__market_refresh(markets) + # 重读一次完整库存:只有限定仓库的缓存被替换,其余仍是缓存命中, + # 且准入必须按配置市场集合判断库存是否完整。 + inventory = await self.__inventory(False) admission = admit_plugin_install( inventory, request=PluginInstallAdmissionRequest( @@ -264,6 +284,25 @@ def reset_plugin_install_service() -> None: _plugin_install_gateway = None +def _plugin_market_urls(inventory: CandidateInventory, plugin_id: str) -> tuple[str, ...]: + """ + 收集索引里出现该插件的市场,供强制更新只重读这些仓库。 + + :param inventory: 本轮已冻结的候选库存 + :param plugin_id: 已归一的物理插件标识 + :return: 去重且保持配置顺序的市场标识;该插件不在任何市场索引里时为空 + """ + normalized = plugin_id.lower() + return tuple(dict.fromkeys( + read.market + for read in inventory.market_reads + if any( + candidate.normalized_plugin_id == normalized + for candidate in read.candidates + ) + )) + + def _generation_order(package_version: str | None) -> tuple[str, ...]: """ 把兼容入口的首选代际转换为来源选择优先序。 diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index b538e49a1..b842e6505 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -5,7 +5,7 @@ import asyncio import threading import uuid -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional, TypeVar, cast @@ -637,6 +637,15 @@ def configure_plugin_services() -> None: force=force, ) + async def refresh_markets(markets: Sequence[str]) -> None: + """ + 只重读指定市场的索引并替换进程内缓存,供强制更新取到当前版本。 + + 强制更新单个插件时不能连带将全部配置市场改为强刷,这里按调用方给出的 + 限定仓库集合重读,其余市场在随后的完整库存读取里仍是缓存命中。 + """ + await inventory_reader.async_load(tuple(markets), force=True) + async def reload_plugin_tree( plugin_id: str, *, restore_enabled: bool | None = None, ) -> PluginRuntimeStatus: @@ -714,6 +723,8 @@ def configure_plugin_services() -> None: clock=lambda: datetime.now(timezone.utc), # 分身的安装包只登记在源插件名下,勘察来源候选前必须先归一 source_plugin_id=plugin_manager.get_plugin_source_id, + # 强制更新要按市场当前版本安装,只重读该插件出现过的仓库 + market_refresh=refresh_markets, ) configure_plugin_install_service(gateway) configure_plugin_installation_recovery( diff --git a/tests/test_plugin_install_gateway.py b/tests/test_plugin_install_gateway.py index 5f79aad81..89753a7c4 100644 --- a/tests/test_plugin_install_gateway.py +++ b/tests/test_plugin_install_gateway.py @@ -30,7 +30,7 @@ NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins" -def _inventory() -> CandidateInventory: +def _inventory(plugin_version: str = "1.0.0") -> CandidateInventory: """ 构造仅含官方候选的完整库存。 """ @@ -44,6 +44,29 @@ def _inventory() -> CandidateInventory: source_type=TrustedPluginSourceType.OFFICIAL, repo_url=REPO_URL, package_generation="v3", + plugin_version=plugin_version, + dto={"v3": True}, + ), + ), + package_generation="v3", + ), + )) + + +def _other_plugin_inventory() -> CandidateInventory: + """ + 构造不含目标插件的库存,用于验证本地专属安装不强刷市场。 + """ + return CandidateInventory(( + MarketRead.present( + REPO_URL, + ( + PluginMarketCandidate( + plugin_id="OtherPlugin", + source_key="github:jxxghp/moviepilot-plugins", + source_type=TrustedPluginSourceType.OFFICIAL, + repo_url=REPO_URL, + package_generation="v3", plugin_version="1.0.0", dto={"v3": True}, ), @@ -53,6 +76,116 @@ def _inventory() -> CandidateInventory: )) +def _gateway( + *, + inventory, + executor, + market_refresh=None, + identity=None, +): + """ + 组装一个只依赖注入桩件的 Gateway。 + """ + return PluginInstallGateway( + inventory=inventory, + identity=identity or AsyncMock(return_value=None), + candidate_compatibility=lambda _candidate: (True, ""), + executor=executor, + clock=lambda: NOW, + source_plugin_id=lambda plugin_id: plugin_id, + market_refresh=market_refresh, + ) + + +def _executor() -> AsyncMock: + """ + 返回带成功结果的执行器桩件。 + """ + executor = AsyncMock() + executor.execute.return_value = type( + "Result", + (), + {"success": True, "message": ""}, + )() + return executor + + +@pytest.mark.asyncio +async def test_forced_update_refreshes_only_the_markets_holding_the_plugin() -> None: + """ + 未指定版本的强制更新只重读该插件出现过的市场,并按新索引准入。 + + Release tag 取自索引里的 version;沿用缓存索引会把同一个旧版本重装一遍 + 还提示更新成功,这正是「关于」里点更新没有反应的原因。 + """ + inventory = AsyncMock(side_effect=[_inventory(), _inventory("1.0.1")]) + market_refresh = AsyncMock() + executor = _executor() + gateway = _gateway( + inventory=inventory, executor=executor, market_refresh=market_refresh + ) + + result = await gateway.install( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + force=True, + explicit_source=True, + ) + + assert result.success is True + market_refresh.assert_awaited_once_with((REPO_URL,)) + # 全市场读取仍然只走缓存,不能被单个插件的更新放大成强制刷新 + assert [call.args[0] for call in inventory.await_args_list] == [False, False] + assert executor.execute.await_args.kwargs["admission"].candidate.plugin_version == "1.0.1" + + +@pytest.mark.asyncio +async def test_selected_release_skips_the_market_refresh() -> None: + """ + 用户已点名 Release 版本时不需要重读索引,tag 直接由所选版本拼出。 + """ + inventory = AsyncMock(return_value=_inventory()) + market_refresh = AsyncMock() + gateway = _gateway( + inventory=inventory, executor=_executor(), market_refresh=market_refresh + ) + + await gateway.install( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + release_version="0.9.0", + force=True, + explicit_source=True, + ) + + market_refresh.assert_not_awaited() + inventory.assert_awaited_once_with(False) + + +@pytest.mark.asyncio +async def test_market_without_the_plugin_is_not_refreshed_on_forced_install() -> None: + """ + 目标插件不在任何市场索引里(本地专属安装)时不发起市场刷新。 + """ + inventory = AsyncMock(return_value=_other_plugin_inventory()) + market_refresh = AsyncMock() + gateway = _gateway( + inventory=inventory, executor=_executor(), market_refresh=market_refresh + ) + + await gateway.install( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?version=v3", + package_version="v3", + force=True, + ) + + market_refresh.assert_not_awaited() + inventory.assert_awaited_once_with(False) + + @pytest.mark.asyncio async def test_gateway_reports_lifecycle_busy_before_identity_or_install( monkeypatch: pytest.MonkeyPatch,