mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 20:54:32 +08:00
fix: 保留插件开发热重载与代际候选 (#6483)
* fix(plugin): preserve development source admission * test(plugin): cover runtime payload hot reload
This commit is contained in:
@@ -332,7 +332,7 @@ class PluginCatalogService:
|
||||
base_plugins: list[Any],
|
||||
markets: list[str],
|
||||
) -> list[Any]:
|
||||
"""每个仓库保留同一插件的最高兼容版本,供来源准入继续决策。"""
|
||||
"""每个仓库和代际保留同一插件的最高版本,供来源准入继续决策。"""
|
||||
higher_keys = {
|
||||
(
|
||||
plugin.repo_url,
|
||||
@@ -357,9 +357,13 @@ class PluginCatalogService:
|
||||
return markets.index(plugin.repo_url)
|
||||
return len(markets)
|
||||
|
||||
result_by_source: dict[tuple[Optional[str], str], Any] = {}
|
||||
result_by_source: dict[tuple[Optional[str], str, Optional[str]], Any] = {}
|
||||
for plugin in sorted(all_plugins, key=repo_order):
|
||||
key = (plugin.repo_url, normalize_physical_plugin_id(plugin.id))
|
||||
key = (
|
||||
plugin.repo_url,
|
||||
normalize_physical_plugin_id(plugin.id),
|
||||
plugin.package_version,
|
||||
)
|
||||
exists = result_by_source.get(key)
|
||||
if not exists or self._version_compare(
|
||||
plugin.plugin_version,
|
||||
|
||||
@@ -586,6 +586,12 @@ def select_plugin_candidate(
|
||||
and local_selection.status is PluginSelectionStatus.INCOMPLETE
|
||||
):
|
||||
return local_selection
|
||||
# 精确本地来源用于开发同步;未指定来源的启动与更新才参与在线版本协调。
|
||||
if explicit_source and local_candidates is not None:
|
||||
return local_selection or PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="所选本地仓库中没有该插件",
|
||||
)
|
||||
|
||||
online = inventory.candidates_for(plugin_id)
|
||||
if not online:
|
||||
|
||||
@@ -8,12 +8,18 @@ from packaging.version import Version
|
||||
from app.application.plugin.catalog import PluginCatalogService
|
||||
|
||||
|
||||
def _plugin(plugin_id: str, version: str, repo_url: str):
|
||||
def _plugin(
|
||||
plugin_id: str,
|
||||
version: str,
|
||||
repo_url: str,
|
||||
package_version: str | None = "v3",
|
||||
):
|
||||
"""构造目录合并测试使用的最小插件 DTO。"""
|
||||
return SimpleNamespace(
|
||||
id=plugin_id,
|
||||
plugin_version=version,
|
||||
repo_url=repo_url,
|
||||
package_version=package_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,21 +84,36 @@ def test_merge_treats_plugin_id_casing_as_one_physical_plugin():
|
||||
assert result == [local]
|
||||
|
||||
|
||||
def test_merge_by_source_treats_plugin_id_casing_as_one_physical_plugin():
|
||||
"""同一仓库跨代际大小写不一致时只保留最高版本候选。"""
|
||||
def test_merge_by_source_deduplicates_plugin_id_casing_within_generation():
|
||||
"""同一仓库和代际内的 ID 大小写差异不能产生重复候选。"""
|
||||
service = _service()
|
||||
old = _plugin("DownloadCenter", "3.2.1", "https://market-a")
|
||||
new = _plugin("downloadcenter", "3.3.2", "https://market-a")
|
||||
|
||||
result = service.merge_by_source(
|
||||
[new],
|
||||
[old],
|
||||
[old, new],
|
||||
[],
|
||||
["https://market-a"],
|
||||
)
|
||||
|
||||
assert result == [new]
|
||||
|
||||
|
||||
def test_merge_by_source_preserves_candidates_across_generations():
|
||||
"""跨代际候选必须保留,由来源准入按代际优先级继续决策。"""
|
||||
service = _service()
|
||||
v3 = _plugin("DownloadCenter", "3.2.1", "https://market-a", "v3")
|
||||
v2 = _plugin("downloadcenter", "9.0.0", "https://market-a", "v2")
|
||||
|
||||
result = service.merge_by_source(
|
||||
[v3, v2],
|
||||
[],
|
||||
["https://market-a"],
|
||||
)
|
||||
|
||||
assert result == [v3, v2]
|
||||
|
||||
|
||||
def test_load_maps_market_entries_with_installed_snapshot():
|
||||
"""单市场读取只获取一次已安装快照并按索引顺序映射 DTO。"""
|
||||
mapper = Mock(side_effect=lambda plugin_id, *_args: plugin_id)
|
||||
|
||||
@@ -175,6 +175,81 @@ async def test_local_only_requires_explicit_online_binding() -> None:
|
||||
assert explicit_admission.trusted_source_key == online.source_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_local_sync_uses_local_candidate_and_execution_mode() -> None:
|
||||
"""文件监控明确指定本地候选时必须同步本地载荷。"""
|
||||
online = PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key="github:jxxghp/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
repo_url=REPO_URL,
|
||||
package_generation="v3",
|
||||
plugin_version="9.0.0",
|
||||
dto={"v3": True},
|
||||
)
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?version=v3",
|
||||
package_generation="v3",
|
||||
plugin_version="1.0.0",
|
||||
dto={"v3": True},
|
||||
)
|
||||
identity = PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version="9.0.0",
|
||||
package_generation="v3",
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo online", "v3": True},
|
||||
declaration_version="9.0.0",
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
revision=2,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
executor = AsyncMock()
|
||||
executor.execute.return_value = type(
|
||||
"Result",
|
||||
(),
|
||||
{"success": True, "message": ""},
|
||||
)()
|
||||
gateway = PluginInstallGateway(
|
||||
inventory=AsyncMock(
|
||||
return_value=CandidateInventory(
|
||||
(MarketRead.present(REPO_URL, (online,)),),
|
||||
(local,),
|
||||
local_read=LocalCandidateRead.present((local,)),
|
||||
)
|
||||
),
|
||||
identity=AsyncMock(return_value=identity),
|
||||
candidate_compatibility=lambda _candidate: (True, ""),
|
||||
executor=executor,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
result = await gateway.install(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=local.repo_url,
|
||||
package_version="v3",
|
||||
force=True,
|
||||
local_sync=True,
|
||||
explicit_source=True,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert executor.execute.await_args.kwargs["admission"].candidate is local
|
||||
assert executor.execute.await_args.kwargs["local_sync"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_rejects_source_conflict_before_package_execution() -> None:
|
||||
"""来源准入失败时不进入文件和数据库事务。"""
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.adapters.external.market import PluginHelper
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.events import Event, eventmanager
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.runtime.extensions.plugin.paths import PluginPathResolver
|
||||
from app.runtime.extensions.plugin.system import get_plugin_system
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
@@ -780,6 +781,45 @@ def test_local_python_and_federated_changes_share_one_batch_sync(
|
||||
reload_spy.assert_called_once_with("DemoPlugin")
|
||||
|
||||
|
||||
def test_runtime_python_change_reloads_without_local_repository_sync(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
plugin_manager: PluginManager,
|
||||
) -> None:
|
||||
"""直接修改运行目录中的 Python 文件只重载当前载荷。"""
|
||||
runtime_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
runtime_file = runtime_dir / "__init__.py"
|
||||
runtime_dir.mkdir(parents=True)
|
||||
runtime_file.write_text(
|
||||
"from app.plugins import _PluginBase\n"
|
||||
"class DemoPlugin(_PluginBase):\n"
|
||||
" plugin_name = 'Demo'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_configure_local_watcher(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
tmp_path / "unused-local-repository",
|
||||
{(Change.modified, str(runtime_file))},
|
||||
)
|
||||
plugin_manager._plugin_paths = PluginPathResolver(
|
||||
runtime_root=tmp_path / "app" / "plugins",
|
||||
running=lambda: plugin_manager.running_plugins,
|
||||
system=get_plugin_system,
|
||||
strict_system_version=lambda: False,
|
||||
log=Mock(),
|
||||
)
|
||||
sync_spy = Mock()
|
||||
reload_spy = Mock()
|
||||
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
|
||||
monkeypatch.setattr(plugin_manager, "_reload_plugin_tree_from_monitor", reload_spy)
|
||||
|
||||
plugin_manager._run_file_watcher()
|
||||
|
||||
sync_spy.assert_not_called()
|
||||
reload_spy.assert_called_once_with("DemoPlugin")
|
||||
|
||||
|
||||
def test_plugin_reload_refreshes_scheduler_services_idempotently(monkeypatch):
|
||||
"""插件重载事件必须按当前服务拓扑幂等刷新 Scheduler。"""
|
||||
current_func = Mock()
|
||||
|
||||
@@ -348,6 +348,36 @@ def test_bound_online_and_local_candidates_choose_higher_version() -> None:
|
||||
assert local_higher.candidate is local
|
||||
|
||||
|
||||
def test_explicit_local_candidate_bypasses_bound_online_version_comparison() -> None:
|
||||
"""本地开发同步明确指定的候选不受绑定仓库版本号阻断。"""
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?version=v3",
|
||||
package_generation="v3",
|
||||
plugin_version="1.0.0",
|
||||
)
|
||||
selection = select_plugin_candidate(
|
||||
_inventory(
|
||||
MarketRead.present(
|
||||
"market-a",
|
||||
(_online(THIRD_PARTY_SOURCE, version="9.0.0"),),
|
||||
),
|
||||
local=(local,),
|
||||
),
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
identity=_identity(
|
||||
TrustedPluginSourceType.THIRD_PARTY,
|
||||
THIRD_PARTY_SOURCE,
|
||||
),
|
||||
local_candidates=(local,),
|
||||
explicit_source=True,
|
||||
)
|
||||
|
||||
assert selection.status is PluginSelectionStatus.SELECTED
|
||||
assert selection.candidate is local
|
||||
|
||||
|
||||
def test_equal_bound_and_local_versions_keep_current_payload_source() -> None:
|
||||
"""相同版本保持当前载荷来源,避免每次启动在本地与在线之间切换。"""
|
||||
local = PluginLocalCandidate(
|
||||
|
||||
Reference in New Issue
Block a user