diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 664a5b63..59e6843e 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -11,6 +11,7 @@ from starlette.responses import StreamingResponse from app import schemas from app.command import Command +from app.core.cache import async_fresh from app.core.config import settings from app.core.event import eventmanager from app.core.plugin import PluginManager @@ -161,6 +162,7 @@ def _merge_plugin_market_metadata( """ plugin.repo_url = market_plugin.repo_url or plugin.repo_url plugin.history = market_plugin.history or {} + plugin.release = market_plugin.release plugin.has_update = market_plugin.has_update plugin.system_version = market_plugin.system_version or plugin.system_version plugin.system_version_compatible = market_plugin.system_version_compatible @@ -336,6 +338,80 @@ async def plugin_history( return plugin +@router.get("/releases/{plugin_id}", summary="获取插件Release版本", response_model=dict) +async def plugin_releases( + plugin_id: str, + _: User = Depends(get_current_active_superuser_async), + repo_url: Optional[str] = "", + force: bool = False, +) -> dict: + """ + 查询指定插件可直接安装的 GitHub Release 版本。 + """ + if not repo_url: + return { + "release_supported": False, + "latest_version": None, + "current_version": None, + "items": [], + } + + plugin_manager = PluginManager() + online_plugins = await plugin_manager.async_get_online_plugins(force=force) + market_plugin = next( + ( + plugin + for plugin in online_plugins + if plugin.id == plugin_id and plugin.repo_url == repo_url + ), + None, + ) + if not market_plugin: + market_plugin = next( + ( + plugin + for plugin in online_plugins + if plugin.id == plugin_id + ), + None, + ) + + installed_plugin = next( + ( + plugin + for plugin in plugin_manager.get_local_plugins() + if plugin.id == plugin_id and plugin.installed + ), + None, + ) + latest_version = market_plugin.plugin_version if market_plugin else None + current_version = installed_plugin.plugin_version if installed_plugin else None + if not getattr(market_plugin, "release", False): + return { + "release_supported": False, + "latest_version": latest_version, + "current_version": current_version, + "items": [], + } + + async with async_fresh(force): + release_items = await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url) + items = [] + for item in release_items: + version = item.get("version") + copied_item = item.copy() + copied_item["is_latest"] = bool(latest_version and version == latest_version) + copied_item["is_current"] = bool(current_version and version == current_version) + items.append(copied_item) + + return { + "release_supported": bool(items), + "latest_version": latest_version, + "current_version": current_version, + "items": items, + } + + @router.get("/statistic", summary="插件安装统计", response_model=dict) async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ @@ -364,6 +440,7 @@ def reload_plugin( async def install( plugin_id: str, repo_url: Optional[str] = "", + release_version: Optional[str] = None, force: Optional[bool] = False, _: User = Depends(get_current_active_superuser_async), ) -> Any: @@ -386,7 +463,7 @@ async def install( # 插件不存在或需要强制安装,下载安装并注册插件 if repo_url: state, msg = await plugin_helper.async_install( - pid=plugin_id, repo_url=repo_url, force_install=force + pid=plugin_id, repo_url=repo_url, release_version=release_version, force_install=force ) # 安装失败则直接响应 if not state: diff --git a/app/core/plugin.py b/app/core/plugin.py index c6cff26f..4bd4b71e 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -1199,6 +1199,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not settings.PLUGIN_MARKET: return [] + if force: + PluginHelper().get_plugin_release_versions.cache_clear() # 用于存储高于 v1 版本的插件(如 v2, v3 等) higher_version_plugins = [] @@ -1546,6 +1548,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): # 更新历史 if plugin_info.get("history"): plugin.history = plugin_info.get("history") + # Release 能力位来自插件市场索引,用于前端展示和后端安装入口双重校验。 + plugin.release = bool(plugin_info.get("release")) # 仓库链接 plugin.repo_url = market # 本地标志 @@ -1562,6 +1566,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not settings.PLUGIN_MARKET: return [] + if force: + await PluginHelper().async_get_plugin_release_versions.cache_clear() # 用于存储高于 v1 版本的插件(如 v2, v3 等) higher_version_plugins = [] diff --git a/app/helper/plugin.py b/app/helper/plugin.py index 268e7e66..667c0550 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -409,6 +409,55 @@ class PluginHelper(metaclass=WeakSingleton): return payload + @staticmethod + def __build_plugin_release_item(pid: str, release_info: dict) -> Optional[dict]: + """ + 从 GitHub release 响应中提取可安装版本,仅接受规范 tag 与同名 zip 资产。 + """ + if not isinstance(release_info, dict): + return None + + tag_name = release_info.get("tag_name") + if not isinstance(tag_name, str): + return None + + tag_prefix = f"{pid}_v" + if not tag_name.startswith(tag_prefix): + return None + + version = tag_name[len(tag_prefix):] + if not version: + return None + + asset_name = f"{tag_name.lower()}.zip" + assets = release_info.get("assets") or [] + if not any(isinstance(asset, dict) and asset.get("name") == asset_name for asset in assets): + return None + + return { + "version": version, + "tag_name": tag_name, + "name": release_info.get("name") or tag_name, + "published_at": release_info.get("published_at"), + "body": release_info.get("body") or "", + "asset_name": asset_name, + } + + @staticmethod + def __parse_plugin_release_response(pid: str, payload) -> List[dict]: + """ + 解析 GitHub release 列表,过滤出当前插件可直接安装的 release 资产。 + """ + if not isinstance(payload, list): + return [] + + releases = [] + for release_info in payload: + item = PluginHelper.__build_plugin_release_item(pid, release_info) + if item: + releases.append(item) + return releases + @cached(maxsize=128, ttl=1800) def get_plugins(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]: @@ -437,6 +486,51 @@ class PluginHelper(metaclass=WeakSingleton): return None return self.__parse_plugin_index_response(res.text) + @cached(maxsize=128, ttl=1800) + def get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]: + """ + 获取插件可安装的 GitHub Release 版本列表。 + """ + if not pid or not repo_url: + return [] + + user, repo = self.get_repo_info(repo_url) + if not user or not repo: + return [] + + user_repo = f"{user}/{repo}" + releases = [] + for page in range(1, 11): + release_api = f"https://api.github.com/repos/{user_repo}/releases?per_page=100&page={page}" + release_api = self.__append_cache_buster(release_api) + res = self.__request_with_fallback( + release_api, + headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), + timeout=30, + is_api=True, + ) + if res is None or res.status_code != 200: + break + + try: + payload = res.json() + if not payload: + break + releases.extend(self.__parse_plugin_release_response(pid, payload)) + if len(payload) < 100: + break + except Exception as e: + logger.error(f"解析插件 {pid} Release 列表失败:{e}") + break + return releases + + @staticmethod + def __has_installable_release_version(release_items: List[dict], release_version: str) -> bool: + """ + 指定版本必须来自已解析出的可安装 Release 列表,避免直接拼接任意 tag。 + """ + return any(item.get("version") == release_version for item in release_items) + def get_plugin_package_version(self, pid: str, repo_url: str, package_version: Optional[str] = None) -> Optional[str]: """ @@ -485,7 +579,8 @@ class PluginHelper(metaclass=WeakSingleton): return None, None return user, repo - def install(self, pid: str, repo_url: str, package_version: Optional[str] = None, force_install: bool = False) \ + def install(self, pid: str, repo_url: str, package_version: Optional[str] = None, + release_version: Optional[str] = None, force_install: bool = False) \ -> Tuple[bool, str]: """ 安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略 @@ -498,6 +593,7 @@ class PluginHelper(metaclass=WeakSingleton): :param pid: 插件 ID :param repo_url: 插件仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 + :param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本 :param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作 :return: (是否成功, 错误信息) """ @@ -541,6 +637,25 @@ class PluginHelper(metaclass=WeakSingleton): is_release = meta.get("release") # 插件版本号 plugin_version = meta.get("version") + if release_version: + if not is_release: + return False, f"{pid} 未声明 Release 安装,无法安装指定版本" + if not self.__has_installable_release_version( + self.get_plugin_release_versions(pid, repo_url), release_version + ): + return False, f"{pid} 未找到可安装的 Release 版本:{release_version}" + if release_version == plugin_version: + compatible, message = self.check_plugin_system_version(meta) + if not compatible: + logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}") + return False, message + release_tag = f"{pid}_v{release_version}" + + def prepare_selected_release() -> Tuple[bool, str]: + return self.__install_from_release(pid, user_repo, release_tag) + + return self.__install_flow_sync(pid, force_install, prepare_selected_release, repo_url) + compatible, message = self.check_plugin_system_version(meta) if not compatible: logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}") @@ -1885,6 +2000,44 @@ class PluginHelper(metaclass=WeakSingleton): return None return self.__parse_plugin_index_response(res.text) + @cached(maxsize=128, ttl=1800) + async def async_get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]: + """ + 异步获取插件可安装的 GitHub Release 版本列表。 + """ + if not pid or not repo_url: + return [] + + user, repo = self.get_repo_info(repo_url) + if not user or not repo: + return [] + + user_repo = f"{user}/{repo}" + releases = [] + for page in range(1, 11): + release_api = f"https://api.github.com/repos/{user_repo}/releases?per_page=100&page={page}" + release_api = self.__append_cache_buster(release_api) + res = await self.__async_request_with_fallback( + release_api, + headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), + timeout=30, + is_api=True, + ) + if res is None or res.status_code != 200: + break + + try: + payload = res.json() + if not payload: + break + releases.extend(self.__parse_plugin_release_response(pid, payload)) + if len(payload) < 100: + break + except Exception as e: + logger.error(f"解析插件 {pid} Release 列表失败:{e}") + break + return releases + async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \ Tuple[Optional[list], Optional[str]]: """ @@ -2243,6 +2396,7 @@ class PluginHelper(metaclass=WeakSingleton): return [] async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None, + release_version: Optional[str] = None, force_install: bool = False) -> Tuple[bool, str]: """ 异步安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略 @@ -2255,6 +2409,7 @@ class PluginHelper(metaclass=WeakSingleton): :param pid: 插件 ID :param repo_url: 插件仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 + :param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本 :param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作 :return: (是否成功, 错误信息) """ @@ -2297,6 +2452,24 @@ class PluginHelper(metaclass=WeakSingleton): is_release = meta.get("release") # 插件版本号 plugin_version = meta.get("version") + if release_version: + if not is_release: + return False, f"{pid} 未声明 Release 安装,无法安装指定版本" + release_items = await self.async_get_plugin_release_versions(pid, repo_url) + if not self.__has_installable_release_version(release_items, release_version): + return False, f"{pid} 未找到可安装的 Release 版本:{release_version}" + if release_version == plugin_version: + compatible, message = self.check_plugin_system_version(meta) + if not compatible: + logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}") + return False, message + release_tag = f"{pid}_v{release_version}" + + async def prepare_selected_release() -> Tuple[bool, str]: + return await self.__async_install_from_release(pid, user_repo, release_tag) + + return await self.__install_flow_async(pid, force_install, prepare_selected_release, repo_url) + compatible, message = self.check_plugin_system_version(meta) if not compatible: logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}") diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index e043da33..0bdeee49 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -42,6 +42,8 @@ class Plugin(BaseModel): system_version_message: Optional[str] = None # 主系统版本限定范围 system_version: Optional[str] = None + # 是否声明支持通过 GitHub Release 资产安装 + release: Optional[bool] = False # 是否本地 is_local: Optional[bool] = False # 仓库地址 diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index e7e8d8c9..f9082f94 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -3,6 +3,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch from app import schemas 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.schemas.event import PluginDataResetEventData @@ -64,6 +65,137 @@ def test_plugin_history_returns_installed_plugin_when_remote_missing(): assert result.history == {} +def test_plugin_releases_returns_supported_versions_with_latest_and_current(monkeypatch): + """ + release 列表接口返回可安装版本,并标记当前 package 最新版本与本地已安装版本。 + """ + market_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_version="1.2.3", + 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_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"}, + {"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0", "asset_name": "demoplugin_v1.2.0.zip"}, + ]) + + 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["release_supported"] is True + assert result["latest_version"] == "1.2.3" + assert result["current_version"] == "1.2.0" + assert result["items"][0]["is_latest"] is True + 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) + + +def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch): + """ + 接口标记当前/最新版本时不能修改 helper 返回对象,避免污染缓存中的 release 列表。 + """ + market_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_version="1.2.3", + 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_helper = MagicMock() + plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=release_items) + + 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["items"][0]["is_latest"] is True + assert "is_latest" not in release_items[0] + assert "is_current" not in release_items[0] + + +def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch): + """ + release 列表接口沿用插件市场的 force 语义,供前端手动刷新时绕过缓存。 + """ + 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_online_plugins = AsyncMock(return_value=[market_plugin]) + plugin_manager.get_local_plugins.return_value = [] + 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", 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") + + +def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch): + """ + 接口是否支持 Release 安装要与当前 package 的 release 声明保持一致。 + """ + market_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_version="1.2.3", + repo_url="https://github.com/demo/plugins", + release=False, + ) + plugin_manager = MagicMock() + plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin]) + plugin_manager.get_local_plugins.return_value = [] + 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"}, + ]) + + 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["release_supported"] is False + assert result["items"] == [] + plugin_helper.async_get_plugin_release_versions.assert_not_awaited() + + def test_sync_plugin_market_from_wiki_merges_and_deduplicates_repos(): """ Wiki 同步会提取标记区域内的 GitHub 仓库地址,并与本地配置合并去重后写入。 diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 79806482..2904ca62 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -39,6 +39,18 @@ class _FakeContentResponse(_FakeResponse): self.content = content +class _FakeTextResponse(_FakeResponse): + """带文本正文的响应对象,用于模拟 GitHub release 列表响应。""" + + def __init__(self, status_code: int, payload: list[dict] | dict): + super().__init__(status_code, payload if isinstance(payload, dict) else {}) + self._payload = payload + + def json(self): + """返回构造时注入的 JSON payload。""" + return self._payload + + def _build_zip(entries: dict[str, bytes]) -> bytes: """构造内存 zip 包,键为包内路径、值为文件内容。""" buffer = io.BytesIO() @@ -204,6 +216,134 @@ class TestPluginHelper: assert success assert "" == message + def test_get_plugin_release_versions_keeps_only_matching_zip_assets(self, monkeypatch): + """ + release 版本列表只暴露符合插件 tag 规范且存在同名 zip 资产的版本。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + payload = [ + { + "tag_name": "DemoPlugin_v1.2.3", + "name": "DemoPlugin v1.2.3", + "published_at": "2026-06-01T00:00:00Z", + "body": "稳定版本", + "assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}], + }, + { + "tag_name": "DemoPlugin_v1.2.2", + "name": "missing asset", + "assets": [{"name": "other.zip", "id": 2}], + }, + { + "tag_name": "OtherPlugin_v9.9.9", + "name": "other plugin", + "assets": [{"name": "otherplugin_v9.9.9.zip", "id": 3}], + }, + ] + helper = PluginHelper() + monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: _FakeTextResponse(200, payload)) + + releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL) + + assert releases == [ + { + "version": "1.2.3", + "tag_name": "DemoPlugin_v1.2.3", + "name": "DemoPlugin v1.2.3", + "published_at": "2026-06-01T00:00:00Z", + "body": "稳定版本", + "asset_name": "demoplugin_v1.2.3.zip", + } + ] + + def test_get_plugin_release_versions_uses_cache_buster_during_fresh_context(self, monkeypatch): + """ + 插件市场强制刷新时 Release 列表请求也要绕过 GitHub 镜像或代理缓存。 + """ + try: + from app.core.cache import fresh + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + requested_urls = [] + + def fake_request(url, **_kwargs): + requested_urls.append(url) + return _FakeTextResponse(200, []) + + helper = PluginHelper() + helper.get_plugin_release_versions.cache_clear() + monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request) + + with patch("app.helper.plugin.time.time_ns", return_value=1234567890): + with fresh(True): + helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL) + + assert requested_urls == [ + "https://api.github.com/repos/demo/MoviePilot-Plugins/releases?per_page=100&page=1&_refresh=1234567890" + ] + + def test_get_plugin_release_versions_fetches_multiple_pages(self, monkeypatch): + """ + 多插件共用 Release 列表时需要分页,避免目标插件历史发行版被第一页之外的数据遮蔽。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + payload_by_page = { + "1": [ + { + "tag_name": f"OtherPlugin_v9.9.{index}", + "assets": [{"name": f"otherplugin_v9.9.{index}.zip", "id": index}], + } + for index in range(100) + ], + "2": [{"tag_name": "DemoPlugin_v1.2.0", "assets": [{"name": "demoplugin_v1.2.0.zip", "id": 2}]}], + } + requested_pages = [] + + def fake_request(url, **_kwargs): + page = url.rsplit("page=", 1)[1].split("&", 1)[0] + requested_pages.append(page) + return _FakeTextResponse(200, payload_by_page[page]) + + helper = PluginHelper() + helper.get_plugin_release_versions.cache_clear() + monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request) + + releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL) + + 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): + """ + 插件市场缓存刷新会一并清理 Release 列表缓存,覆盖定时刷新服务入口。 + """ + try: + from app.core.plugin import PluginManager + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + clear_calls = [] + fake_release_method = SimpleNamespace(cache_clear=lambda: clear_calls.append("clear")) + fake_helper = SimpleNamespace(get_plugin_release_versions=fake_release_method) + 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, "get_plugins_from_market", lambda *_args, **_kwargs: []) + + PluginManager().get_online_plugins(force=True) + + assert clear_calls == ["clear"] + def test_annotate_plugin_system_version_marks_incompatible(self): """ 插件市场列表会带出系统版本兼容状态,供前端禁用安装入口。 @@ -660,6 +800,99 @@ class TestPluginHelper: assert "MoviePilot 版本 >=9.0.0" in message assert [] == calls + def test_install_rejects_latest_release_version_when_system_version_is_incompatible(self, monkeypatch): + """ + 指定安装当前最新 release 时仍按当前 package 元数据校验主程序版本。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + helper = PluginHelper() + calls = _patch_sync_remote_install( + helper, + monkeypatch, + {"release": True, "version": "1.2.3", "system_version": ">=9.0.0"}, + (True, ""), + ) + monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0")) + monkeypatch.setattr( + helper, + "get_plugin_release_versions", + lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}], + ) + + success, message = helper.install( + PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.3", force_install=True + ) + + assert not success + assert "MoviePilot 版本 >=9.0.0" in message + assert [] == calls + + def test_install_old_release_version_uses_release_asset_without_filelist_fallback(self, monkeypatch): + """ + 指定旧 release 版本时直接安装对应资产,失败也不回退当前分支文件列表。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + helper = PluginHelper() + calls = _patch_sync_remote_install( + helper, + monkeypatch, + {"release": True, "version": "1.2.3", "system_version": ">=9.0.0"}, + (False, "未找到资产文件:demoplugin_v1.2.0.zip"), + (True, ""), + ) + monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0")) + monkeypatch.setattr( + helper, + "get_plugin_release_versions", + lambda *_args: [{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0"}], + ) + + success, message = helper.install( + PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True + ) + + assert not success + assert "未找到资产文件:demoplugin_v1.2.0.zip" == message + assert ["remove", "release", "remove"] == calls + + def test_install_rejects_release_version_missing_from_release_list(self, monkeypatch): + """ + 指定版本必须来自可安装 Release 列表,避免客户端绕过前端约束拼接任意 tag。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + helper = PluginHelper() + calls = _patch_sync_remote_install( + helper, + monkeypatch, + {"release": True, "version": "1.2.3"}, + (True, ""), + ) + monkeypatch.setattr( + helper, + "get_plugin_release_versions", + lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}], + ) + + success, message = helper.install( + PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True + ) + + assert not success + assert f"{PLUGIN_ID} 未找到可安装的 Release 版本:1.2.0" == message + assert [] == calls + def test_install_rejects_invalid_parameters_before_remote_lookup(self): """ 远端安装缺少插件 ID 或仓库地址时直接拒绝。 @@ -824,6 +1057,72 @@ class TestPluginHelper: assert calls[:4] == ["remove", "release", "remove", "filelist"] assert calls[4][0] == "to_thread" + def test_async_install_old_release_version_uses_release_asset_without_filelist_fallback(self, monkeypatch): + """ + 异步路径指定旧 release 版本时直接安装对应资产,失败也不回退当前分支文件列表。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + helper = PluginHelper() + calls = _patch_async_remote_install( + helper, + monkeypatch, + {"release": True, "version": "1.2.3", "system_version": ">=9.0.0"}, + (False, "未找到资产文件:demoplugin_v1.2.0.zip"), + (True, ""), + ) + monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0")) + + async def fake_releases(*_args): + return [{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0"}] + + monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases) + + success, message = asyncio.run( + helper.async_install( + PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True + ) + ) + + assert not success + assert "未找到资产文件:demoplugin_v1.2.0.zip" == message + assert calls[:3] == ["remove", "release", "remove"] + + def test_async_install_rejects_release_version_missing_from_release_list(self, monkeypatch): + """ + 异步安装同样只接受 Release 列表中存在的指定版本。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + helper = PluginHelper() + calls = _patch_async_remote_install( + helper, + monkeypatch, + {"release": True, "version": "1.2.3"}, + (True, ""), + ) + + async def fake_releases(*_args): + return [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}] + + monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases) + + success, message = asyncio.run( + helper.async_install( + PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True + ) + ) + + assert not success + assert f"{PLUGIN_ID} 未找到可安装的 Release 版本:1.2.0" == message + assert [] == calls + def test_async_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch): """ 异步安装路径在 release 与文件列表都失败时返回文件列表错误,并保持失败清理顺序稳定。