diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index b45e91df4..4c7565ef1 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -378,7 +378,7 @@ async def all_plugins( # 本地插件仓库目录中的插件 local_repo_plugins = plugin_manager.get_local_repo_plugins() # 在线插件 - online_plugins = await plugin_manager.async_get_online_plugins(force) + online_plugins = await plugin_manager.async_get_online_plugin_candidates(force) installed_ids = [plugin.id for plugin in installed_plugins if plugin.id] candidate_plugins = ( plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, []) diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py index 9ad58a4bb..76e705f6b 100644 --- a/app/application/plugin/catalog.py +++ b/app/application/plugin/catalog.py @@ -190,9 +190,10 @@ class PluginCatalogService: [str, Optional[str], bool], Awaitable[list[Any]], ], + preserve_sources: bool = False, progress_callback: Optional[ProgressCallback] = None, ) -> list[Any]: - """异步读取多个市场和代际,并持续报告稳定进度。""" + """异步读取多个市场和代际,并按调用方需要合并或保留仓库候选。""" async def fetch( market: str, package_version: Optional[str], @@ -249,7 +250,11 @@ class PluginCatalogService: target = higher_plugins if version == "higher_version" else base_plugins target.extend(plugins) - result = self.merge(higher_plugins, base_plugins, markets) + result = ( + self.merge_by_source(higher_plugins, base_plugins, markets) + if preserve_sources + else self.merge(higher_plugins, base_plugins, markets) + ) if progress_callback: progress_callback(value=100, text="插件市场缓存刷新完成") return result @@ -313,6 +318,41 @@ class PluginCatalogService: result_by_id[plugin.id] = plugin return list(result_by_id.values()) + def merge_by_source( + self, + higher_plugins: list[Any], + base_plugins: list[Any], + markets: list[str], + ) -> list[Any]: + """每个仓库保留同一插件的最高兼容版本,供来源准入继续决策。""" + higher_keys = { + (plugin.repo_url, plugin.id, plugin.plugin_version) + for plugin in higher_plugins + } + all_plugins = list(higher_plugins) + all_plugins.extend( + plugin + for plugin in base_plugins + if (plugin.repo_url, plugin.id, plugin.plugin_version) not in higher_keys + ) + + def repo_order(plugin: Any) -> int: + if plugin.repo_url in markets: + return markets.index(plugin.repo_url) + return len(markets) + + result_by_source: dict[tuple[Optional[str], Optional[str]], Any] = {} + for plugin in sorted(all_plugins, key=repo_order): + key = (plugin.repo_url, plugin.id) + exists = result_by_source.get(key) + if not exists or self._version_compare( + plugin.plugin_version, + ">", + exists.plugin_version, + ): + result_by_source[key] = plugin + return list(result_by_source.values()) + def _map_plugins( self, online_plugins: dict[str, dict], diff --git a/app/runtime/extensions/plugin/catalog.py b/app/runtime/extensions/plugin/catalog.py index 19fa400db..79e8e85f5 100644 --- a/app/runtime/extensions/plugin/catalog.py +++ b/app/runtime/extensions/plugin/catalog.py @@ -243,6 +243,23 @@ class PluginCatalogFacade: self._logger.info(f"获取到 {len(result)} 个线上插件") return result + async def async_online_candidates(self, force: bool = False) -> list[Plugin]: + """读取在线目录并保留每个仓库的最高候选,供来源准入使用。""" + plugin_market = get_runtime_setting('PLUGIN_MARKET') + if not plugin_market: + return [] + markets = [item for item in plugin_market.split(",") if item] + result: list[Plugin] = await self._market_catalog().async_collect( + markets=markets, + compatible_flags=self._system().compatible_flags( + get_runtime_setting('VERSION_FLAG') + ), + force=force, + loader=self._async_market_loader, + preserve_sources=True, + ) + return result + async def async_get_from_market( self, market: str, diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index e37f9eb9b..8211e2834 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -1364,6 +1364,13 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): progress_callback, ) + async def async_get_online_plugin_candidates( + self, + force: bool = False, + ) -> List[_SchemaPlugin]: + """获取按仓库保留的在线插件候选,供来源准入和更新选择。""" + return await self._plugin_catalog_view.async_online_candidates(force) + async def async_get_plugins_from_market(self, market: str, package_version: Optional[str] = None, force: bool = False) -> Optional[List[_SchemaPlugin]]: diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 7dce4499c..d3c4d25c0 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -26,7 +26,7 @@ class PluginSourceBindingStatus(str, _Enum): class PluginUpdateCandidate(BaseModel): # type: ignore[misc] - """插件市场为已安装插件发现的当前最高在线更新候选。""" + """插件市场为已安装插件选择的当前更新候选。""" source_type: Literal["official", "third_party"] = Field( description="候选仓库是官方来源还是第三方来源" @@ -93,7 +93,7 @@ class Plugin(BaseModel): has_page: Optional[bool] = False # 是否有新版本 has_update: Optional[bool] = False - # 当前市场选择的最高在线更新候选;没有确定候选时为空 + # 当前市场选择的更新候选;绑定仓库可更新时优先返回该仓库 update_candidate: Optional[PluginUpdateCandidate] = None # 插件仓库绑定状态;仅已安装物理插件由后端投影真实身份 source_binding_status: PluginSourceBindingStatus = PluginSourceBindingStatus.BOUND diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index 736a61d73..21cdc58bb 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -1,8 +1,8 @@ { "application": { - "covered_lines": 9292, - "percent": 77.76, - "statements": 11949 + "covered_lines": 9309, + "percent": 77.8, + "statements": 11965 }, "domain": { "covered_lines": 3390, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 76c498ce8..9cb172ad5 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1462,7 +1462,8 @@ }, "app/chain/media.py": { "arg-type": 34, - "assignment": 15, + "assignment": 16, + "call-overload": 1, "comparison-overlap": 3, "no-any-return": 3, "no-untyped-def": 3, diff --git a/tests/test_plugin_catalog_service.py b/tests/test_plugin_catalog_service.py index 4b068627d..aecf8cd10 100644 --- a/tests/test_plugin_catalog_service.py +++ b/tests/test_plugin_catalog_service.py @@ -113,6 +113,32 @@ async def test_async_collect_isolates_failure_and_completes_progress(): assert progress.call_args_list[-1].kwargs["value"] == 100 +@pytest.mark.asyncio +async def test_async_collect_preserves_each_repository_update_candidate(): + """来源准入读取应保留每个仓库的最高版本,不能先按插件 ID 全局去重。""" + service = _service() + + async def loader(market: str, package_version: str | None, _force: bool): + if package_version is None: + return [] + version = "2.0.0" if market == "https://market-bound" else "3.0.0" + return [_plugin("Demo", version, market)] + + result = await service.async_collect( + markets=["https://market-bound", "https://market-alternative"], + compatible_flags=["v3"], + force=False, + loader=loader, + preserve_sources=True, + ) + + assert [(plugin.repo_url, plugin.plugin_version) for plugin in result] == [ + ("https://market-bound", "2.0.0"), + ("https://market-alternative", "3.0.0"), + ] + assert service.merge(result, [], ["https://market-bound", "https://market-alternative"]) == [result[1]] + + @pytest.mark.asyncio async def test_async_collect_cancels_all_loaders_when_parent_is_cancelled(): """请求取消时必须取消并回收全部市场 loader,不能把子任务遗留在事件循环。""" diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 9dbb22442..0c4fb39d1 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -187,6 +187,58 @@ def test_bound_repository_update_precedes_a_higher_alternative(): assert result[0].update_candidate.is_bound is True +def test_market_endpoint_reads_source_preserving_candidates_for_bound_update(): + """市场接口必须在全局版本合并前保留绑定仓库候选。""" + installed = schemas.Plugin( + id="DemoPlugin", + plugin_version="1.0.0", + installed=True, + ) + bound_update = schemas.Plugin( + id="DemoPlugin", + plugin_version="2.0.0", + repo_url=SOURCE_URL, + has_update=True, + ) + alternative_update = schemas.Plugin( + id="DemoPlugin", + plugin_version="3.0.0", + repo_url="https://github.com/jxxghp/MoviePilot-Plugins", + has_update=True, + ) + plugin_manager = MagicMock() + plugin_manager.get_installed_plugins.return_value = [installed] + plugin_manager.get_local_plugins.return_value = [] + plugin_manager.get_local_repo_plugins.return_value = [] + plugin_manager.async_get_online_plugin_candidates = AsyncMock( + return_value=[bound_update, alternative_update] + ) + plugin_manager.process_plugins_list.side_effect = ( + lambda higher, base: [ + max( + higher + base, + key=lambda plugin: tuple( + int(part) for part in plugin.plugin_version.split(".") + ), + ) + ] + ) + persistence = MagicMock() + persistence.list_identities = AsyncMock(return_value=[_plugin_identity()]) + + with ( + patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), + patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + ): + result = asyncio.run(plugin_endpoint.all_plugins(None, "market", False)) + + assert result == [bound_update] + assert result[0].update_candidate is not None + assert result[0].update_candidate.version == "2.0.0" + assert result[0].update_candidate.is_bound is True + plugin_manager.async_get_online_plugin_candidates.assert_awaited_once_with(False) + + def _persistence(identity: PluginIdentity) -> MagicMock: """构造只暴露身份读取合同的异步持久化替身。""" persistence = MagicMock()