mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-04 06:15:14 +08:00
fix(plugin): 强制更新时重读该插件所在市场的索引 (#7280)
This commit is contained in:
@@ -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, ...]:
|
||||
"""
|
||||
把兼容入口的首选代际转换为来源选择优先序。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user