mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-01 21:01:47 +08:00
fix(plugin): optimize release cache loading (#5966)
This commit is contained in:
@@ -6,6 +6,7 @@ from app.api.endpoints.plugin import plugin_history
|
||||
from app.api.endpoints.plugin import plugin_releases
|
||||
from app.api.endpoints.plugin import reset_plugin
|
||||
from app.api.endpoints.system import sync_plugin_market_from_wiki
|
||||
from app.core.config import settings
|
||||
from app.schemas.event import PluginDataResetEventData
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
@@ -75,14 +76,10 @@ def test_plugin_releases_returns_supported_versions_with_latest_and_current(monk
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
release=True,
|
||||
)
|
||||
installed_plugin = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_version="1.2.0",
|
||||
installed=True,
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.get_local_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.get_local_plugin_version.return_value = "1.2.0"
|
||||
plugin_helper = MagicMock()
|
||||
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[
|
||||
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
|
||||
@@ -102,7 +99,11 @@ def test_plugin_releases_returns_supported_versions_with_latest_and_current(monk
|
||||
assert result["items"][0]["is_current"] is False
|
||||
assert result["items"][1]["is_latest"] is False
|
||||
assert result["items"][1]["is_current"] is True
|
||||
plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=False)
|
||||
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
|
||||
"https://github.com/demo/plugins", settings.VERSION_FLAG, False
|
||||
)
|
||||
plugin_manager.async_get_online_plugins.assert_not_awaited()
|
||||
plugin_manager.get_local_plugins.assert_not_called()
|
||||
|
||||
|
||||
def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch):
|
||||
@@ -115,17 +116,12 @@ def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch):
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
release=True,
|
||||
)
|
||||
installed_plugin = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_version="1.2.0",
|
||||
installed=True,
|
||||
)
|
||||
release_items = [
|
||||
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
|
||||
]
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.get_local_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.get_local_plugin_version.return_value = "1.2.0"
|
||||
plugin_helper = MagicMock()
|
||||
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=release_items)
|
||||
|
||||
@@ -140,6 +136,39 @@ def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch):
|
||||
assert "is_current" not in release_items[0]
|
||||
|
||||
|
||||
def test_plugin_releases_falls_back_to_compatible_base_package(monkeypatch):
|
||||
"""
|
||||
当前版本 package 未包含插件时,再读取基础 package 兼容项,不扫描其他市场。
|
||||
"""
|
||||
market_plugin = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_version="1.2.3",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
release=True,
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(
|
||||
side_effect=[[], [market_plugin]]
|
||||
)
|
||||
plugin_manager.get_local_plugin_version.return_value = None
|
||||
plugin_helper = MagicMock()
|
||||
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
|
||||
patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper),
|
||||
):
|
||||
result = asyncio.run(
|
||||
plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False)
|
||||
)
|
||||
|
||||
assert result["latest_version"] == "1.2.3"
|
||||
assert plugin_manager.async_get_plugins_from_market.await_args_list == [
|
||||
(("https://github.com/demo/plugins", settings.VERSION_FLAG, False), {}),
|
||||
(("https://github.com/demo/plugins", None, False), {}),
|
||||
]
|
||||
|
||||
|
||||
def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
|
||||
"""
|
||||
release 列表接口沿用插件市场的 force 语义,供前端手动刷新时绕过缓存。
|
||||
@@ -151,8 +180,8 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
|
||||
release=True,
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.get_local_plugins.return_value = []
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.get_local_plugin_version.return_value = None
|
||||
plugin_helper = MagicMock()
|
||||
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
|
||||
|
||||
@@ -163,8 +192,13 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
|
||||
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True))
|
||||
|
||||
assert result["release_supported"] is False
|
||||
plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=True)
|
||||
assert plugin_helper.async_get_plugin_release_versions.await_args.args == ("DemoPlugin", "https://github.com/demo/plugins")
|
||||
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
|
||||
"https://github.com/demo/plugins", settings.VERSION_FLAG, True
|
||||
)
|
||||
assert plugin_helper.async_get_plugin_release_versions.await_args.args == (
|
||||
"DemoPlugin",
|
||||
"https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch):
|
||||
@@ -178,8 +212,8 @@ def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(
|
||||
release=False,
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.get_local_plugins.return_value = []
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.get_local_plugin_version.return_value = None
|
||||
plugin_helper = MagicMock()
|
||||
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[
|
||||
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
|
||||
|
||||
@@ -245,7 +245,11 @@ class TestPluginHelper:
|
||||
},
|
||||
]
|
||||
helper = PluginHelper()
|
||||
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: _FakeTextResponse(200, payload))
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__request_with_fallback",
|
||||
lambda *_args, **_kwargs: _FakeTextResponse(200, payload),
|
||||
)
|
||||
|
||||
releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL)
|
||||
|
||||
@@ -323,9 +327,177 @@ class TestPluginHelper:
|
||||
assert requested_pages == ["1", "2"]
|
||||
assert [item["version"] for item in releases] == ["1.2.0"]
|
||||
|
||||
def test_get_online_plugins_force_clears_release_cache(self, monkeypatch):
|
||||
def test_get_plugin_release_versions_reuses_repository_pages_across_plugins(self, monkeypatch):
|
||||
"""
|
||||
插件市场缓存刷新会一并清理 Release 列表缓存,覆盖定时刷新服务入口。
|
||||
同一仓库的不同插件共享 GitHub Release 分页结果,避免按插件 ID 重复请求。
|
||||
"""
|
||||
try:
|
||||
from app.helper.plugin import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
payload = [
|
||||
{
|
||||
"tag_name": "DemoPlugin_v1.2.3",
|
||||
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}],
|
||||
},
|
||||
{
|
||||
"tag_name": "OtherPlugin_v2.0.0",
|
||||
"assets": [{"name": "otherplugin_v2.0.0.zip", "id": 2}],
|
||||
},
|
||||
]
|
||||
request_count = 0
|
||||
|
||||
def fake_request(*_args, **_kwargs):
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
return _FakeTextResponse(200, payload)
|
||||
|
||||
helper = PluginHelper()
|
||||
helper.get_plugin_release_versions.cache_clear()
|
||||
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request)
|
||||
|
||||
demo_releases = helper.get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||
other_releases = helper.get_plugin_release_versions("OtherPlugin", REPO_URL)
|
||||
|
||||
assert request_count == 1
|
||||
assert [item["version"] for item in demo_releases] == ["1.2.3"]
|
||||
assert [item["version"] for item in other_releases] == ["2.0.0"]
|
||||
|
||||
def test_async_get_plugin_release_versions_coalesces_forced_repository_requests(self, monkeypatch):
|
||||
"""
|
||||
同一仓库的并发强制刷新共享一个请求任务,避免缓存失效瞬间放大 GitHub 请求。
|
||||
"""
|
||||
try:
|
||||
from app.core.cache import async_fresh
|
||||
from app.helper.plugin import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
payload = [
|
||||
{
|
||||
"tag_name": "DemoPlugin_v1.2.3",
|
||||
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}],
|
||||
},
|
||||
{
|
||||
"tag_name": "OtherPlugin_v2.0.0",
|
||||
"assets": [{"name": "otherplugin_v2.0.0.zip", "id": 2}],
|
||||
},
|
||||
]
|
||||
request_count = 0
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
await asyncio.sleep(0.01)
|
||||
return _FakeTextResponse(200, payload)
|
||||
|
||||
async def run_test():
|
||||
helper = PluginHelper()
|
||||
await helper.async_get_plugin_release_versions.cache_clear()
|
||||
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
|
||||
async with async_fresh(True):
|
||||
return await asyncio.gather(
|
||||
helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL),
|
||||
helper.async_get_plugin_release_versions("OtherPlugin", REPO_URL),
|
||||
)
|
||||
|
||||
demo_releases, other_releases = asyncio.run(run_test())
|
||||
|
||||
assert request_count == 1
|
||||
assert [item["version"] for item in demo_releases] == ["1.2.3"]
|
||||
assert [item["version"] for item in other_releases] == ["2.0.0"]
|
||||
|
||||
def test_async_forced_release_refresh_does_not_reuse_normal_read_task(self, monkeypatch):
|
||||
"""强刷等待在途普通读取后再请求,最终缓存必须保留强刷结果。"""
|
||||
try:
|
||||
from app.core.cache import async_fresh
|
||||
from app.helper.plugin import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
old_payload = [{
|
||||
"tag_name": "DemoPlugin_v1.2.2",
|
||||
"assets": [{"name": "demoplugin_v1.2.2.zip", "id": 1}],
|
||||
}]
|
||||
fresh_payload = [{
|
||||
"tag_name": "DemoPlugin_v1.2.3",
|
||||
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 2}],
|
||||
}]
|
||||
first_request_started = asyncio.Event()
|
||||
release_first_request = asyncio.Event()
|
||||
request_count = 0
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
if request_count == 1:
|
||||
first_request_started.set()
|
||||
await release_first_request.wait()
|
||||
return _FakeTextResponse(200, old_payload)
|
||||
return _FakeTextResponse(200, fresh_payload)
|
||||
|
||||
async def run_test():
|
||||
helper = PluginHelper()
|
||||
await helper.async_get_plugin_release_versions.cache_clear()
|
||||
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
|
||||
normal_task = asyncio.create_task(
|
||||
helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||
)
|
||||
await first_request_started.wait()
|
||||
async with async_fresh(True):
|
||||
force_task = asyncio.create_task(
|
||||
helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
request_count_before_normal_finished = request_count
|
||||
release_first_request.set()
|
||||
normal_result, force_result = await asyncio.gather(normal_task, force_task)
|
||||
cached_result = await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||
return request_count_before_normal_finished, normal_result, force_result, cached_result
|
||||
|
||||
request_count_before_normal_finished, normal_result, force_result, cached_result = asyncio.run(run_test())
|
||||
|
||||
assert request_count_before_normal_finished == 1
|
||||
assert [item["version"] for item in normal_result] == ["1.2.2"]
|
||||
assert [item["version"] for item in force_result] == ["1.2.3"]
|
||||
assert [item["version"] for item in cached_result] == ["1.2.3"]
|
||||
assert request_count == 2
|
||||
|
||||
def test_failed_forced_release_refresh_preserves_cached_repository_payload(self, monkeypatch):
|
||||
"""GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。"""
|
||||
try:
|
||||
from app.core.cache import fresh
|
||||
from app.helper.plugin import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
payload = [{
|
||||
"tag_name": "DemoPlugin_v1.2.3",
|
||||
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}],
|
||||
}]
|
||||
responses = [_FakeTextResponse(200, payload), None]
|
||||
|
||||
def fake_request(*_args, **_kwargs):
|
||||
return responses.pop(0)
|
||||
|
||||
helper = PluginHelper()
|
||||
helper.get_plugin_release_versions.cache_clear()
|
||||
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request)
|
||||
|
||||
initial = helper.get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||
with fresh(True):
|
||||
failed_refresh = helper.get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||
cached = helper.get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||
|
||||
assert [item["version"] for item in initial] == ["1.2.3"]
|
||||
assert failed_refresh == []
|
||||
assert [item["version"] for item in cached] == ["1.2.3"]
|
||||
assert responses == []
|
||||
|
||||
def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
|
||||
"""
|
||||
全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。
|
||||
"""
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
@@ -342,7 +514,56 @@ class TestPluginHelper:
|
||||
|
||||
PluginManager().get_online_plugins(force=True)
|
||||
|
||||
assert clear_calls == ["clear"]
|
||||
assert clear_calls == []
|
||||
|
||||
def test_async_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
|
||||
"""异步全市场刷新同样不得清理其他仓库的 Release 缓存。"""
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
clear_calls = []
|
||||
|
||||
async def fake_clear():
|
||||
clear_calls.append("clear")
|
||||
|
||||
fake_release_method = SimpleNamespace(cache_clear=fake_clear)
|
||||
fake_helper = SimpleNamespace(async_get_plugin_release_versions=fake_release_method)
|
||||
|
||||
async def fake_market(*_args, **_kwargs):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("app.core.plugin.settings.PLUGIN_MARKET", "https://github.com/demo/plugins")
|
||||
monkeypatch.setattr("app.core.plugin.PluginHelper", lambda: fake_helper)
|
||||
monkeypatch.setattr(PluginManager, "async_get_plugins_from_market", fake_market)
|
||||
|
||||
asyncio.run(PluginManager().async_get_online_plugins(force=True))
|
||||
|
||||
assert clear_calls == []
|
||||
|
||||
def test_get_local_plugin_version_reads_only_requested_installed_plugin(self, monkeypatch):
|
||||
"""单插件版本查询不构建全部本地插件信息。"""
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
class DemoPlugin:
|
||||
plugin_version = "1.2.0"
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
monkeypatch.setattr(plugin_manager, "_plugins", {"DemoPlugin": DemoPlugin})
|
||||
monkeypatch.setattr(
|
||||
SystemConfigOper,
|
||||
"get",
|
||||
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
|
||||
)
|
||||
|
||||
assert plugin_manager.get_local_plugin_version("DemoPlugin") == "1.2.0"
|
||||
assert plugin_manager.get_local_plugin_version("OtherPlugin") is None
|
||||
|
||||
def test_annotate_plugin_system_version_marks_incompatible(self):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user