fix(plugin): 强制更新时重读该插件所在市场的索引 (#7280)

This commit is contained in:
JontyLee
2026-11-03 07:07:04 +08:00
committed by GitHub
parent 5de950fb7c
commit 91029be058
3 changed files with 186 additions and 3 deletions

View File

@@ -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, ...]:
"""
把兼容入口的首选代际转换为来源选择优先序。

View File

@@ -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(

View File

@@ -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,