diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index a47509d5..e1ebd92e 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -93,6 +93,9 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]: "plugin_author": getattr(plugin, "plugin_author", None), "installed": bool(getattr(plugin, "installed", False)), "has_update": bool(getattr(plugin, "has_update", False)), + "system_version_compatible": getattr(plugin, "system_version_compatible", True) is not False, + "system_version": getattr(plugin, "system_version", None), + "system_version_message": getattr(plugin, "system_version_message", None), "state": bool(getattr(plugin, "state", False)), "repo_url": repo_url, "source": "local_repo" if PluginHelper.is_local_repo_url(repo_url) else "market", diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 33d64187..d10260f9 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -251,6 +251,12 @@ async def install( # 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计 plugin_helper = PluginHelper() if not force and plugin_id in PluginManager().get_plugin_ids(): + if repo_url: + compatible_message = await plugin_helper.async_get_plugin_system_version_check_message( + plugin_id, repo_url + ) + if compatible_message: + return schemas.Response(success=False, message=compatible_message) await plugin_helper.async_install_reg(pid=plugin_id, repo_url=repo_url) else: # 插件不存在或需要强制安装,下载安装并注册插件 diff --git a/app/core/plugin.py b/app/core/plugin.py index da0fcd9b..99dbf562 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -612,7 +612,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): # 确定需要安装的插件 plugins_to_install = [ plugin for plugin in candidate_plugins - if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id, plugin.plugin_version) + if plugin.id in install_plugins + and plugin.system_version_compatible is not False + and not self.is_plugin_exists(plugin.id, plugin.plugin_version) ] if not plugins_to_install: @@ -1417,6 +1419,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): if not isinstance(plugin_info, dict): return None + plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy()) # 如 package_version 为空,则需要判断插件是否兼容当前版本 if not package_version: if plugin_info.get(settings.VERSION_FLAG) is not True: @@ -1443,6 +1446,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")): # 需要更新 plugin.has_update = True + # 主系统版本兼容性 + if plugin_info.get("system_version"): + plugin.system_version = plugin_info.get("system_version") + if plugin_info.get("system_version_compatible") is False: + plugin.system_version_compatible = False + plugin.system_version_message = plugin_info.get("system_version_message") # 运行状态 if plugin_obj and hasattr(plugin_obj, "get_state"): try: diff --git a/app/helper/plugin.py b/app/helper/plugin.py index 693bc957..b5f60b60 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -35,9 +35,11 @@ from app.utils.singleton import WeakSingleton from app.utils.string import StringUtils from app.utils.system import SystemUtils from app.utils.url import UrlUtils +from version import APP_VERSION PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins" LOCAL_REPO_PREFIX = "local://" +PLUGIN_SYSTEM_VERSION_FIELD = "system_version" class PluginHelper(metaclass=WeakSingleton): @@ -163,6 +165,66 @@ class PluginHelper(metaclass=WeakSingleton): package_version=PluginHelper.parse_local_repo_package_version(repo_url) ) + @staticmethod + def get_current_system_version() -> Optional[Version]: + """ + 解析当前主程序版本,供插件 package 中的系统版本范围匹配使用。 + """ + try: + return Version(str(APP_VERSION)) + except InvalidVersion: + logger.error(f"当前主程序版本号无法解析:{APP_VERSION}") + return None + + @classmethod + def check_plugin_system_version(cls, plugin_info: Optional[dict]) -> Tuple[bool, str]: + """ + 检查插件 package 元数据中的主系统版本范围是否满足当前 MoviePilot 版本。 + """ + if not isinstance(plugin_info, dict): + return True, "" + + raw_specifier = plugin_info.get(PLUGIN_SYSTEM_VERSION_FIELD) + if raw_specifier is None or raw_specifier == "": + return True, "" + if not isinstance(raw_specifier, str): + return False, ( + f"插件限定的系统版本范围 {PLUGIN_SYSTEM_VERSION_FIELD} 必须是字符串," + f"请使用 pip 依赖版本格式,例如 >=2.12.0,<3" + ) + + system_version = cls.get_current_system_version() + if system_version is None: + return False, f"当前 MoviePilot 版本 {APP_VERSION} 无法解析,已拒绝安装带版本限制的插件" + + try: + specifier_set = SpecifierSet(raw_specifier) + except InvalidSpecifier: + return False, ( + f"插件限定的系统版本范围格式不正确:{raw_specifier}," + f"请使用 pip 依赖版本格式,例如 >=2.12.0,<3" + ) + + if specifier_set.contains(system_version, prereleases=True): + return True, "" + + return False, ( + f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {APP_VERSION} 不满足,已拒绝安装" + ) + + @classmethod + def annotate_plugin_system_version(cls, plugin_info: dict) -> dict: + """ + 为插件 package 元数据补充系统版本兼容状态,便于市场展示和安装流程复用。 + """ + if not isinstance(plugin_info, dict): + return plugin_info + + compatible, message = cls.check_plugin_system_version(plugin_info) + plugin_info["system_version_compatible"] = compatible + plugin_info["system_version_message"] = message + return plugin_info + @staticmethod def get_local_repo_paths() -> List[Path]: """ @@ -248,6 +310,7 @@ class PluginHelper(metaclass=WeakSingleton): candidate["repo_order"] = repo_order candidate["repo_path"] = repo_path candidate["path"] = plugin_dir + self.annotate_plugin_system_version(candidate) candidate_version = str(candidate.get("version") or "0") existing = candidates.get(pid) @@ -313,6 +376,10 @@ class PluginHelper(metaclass=WeakSingleton): if not is_compatible: candidate["compatible"] = False candidate["skip_reason"] = f"package.json 未声明 {settings.VERSION_FLAG} 兼容" + self.annotate_plugin_system_version(candidate) + if candidate.get("system_version_compatible") is False: + candidate["compatible"] = False + candidate["skip_reason"] = candidate.get("system_version_message") if package_version is not None: return candidate if not selected_candidate: @@ -537,6 +604,10 @@ class PluginHelper(metaclass=WeakSingleton): is_release = meta.get("release") # 插件版本号 plugin_version = meta.get("version") + compatible, message = self.check_plugin_system_version(meta) + if not compatible: + logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}") + return False, message if is_release: # 使用 插件ID_插件版本号 作为 Release tag if not plugin_version: @@ -575,6 +646,10 @@ class PluginHelper(metaclass=WeakSingleton): ) if not candidate: return False, f"未找到本地插件:{pid}" + compatible, message = self.check_plugin_system_version(candidate) + if not compatible: + logger.debug(f"{pid} 本地插件系统版本兼容性检查失败:{message}") + return False, message source_dir = Path(candidate.get("path")) dest_dir = PLUGIN_DIR / pid.lower() @@ -1427,6 +1502,49 @@ class PluginHelper(metaclass=WeakSingleton): logger.error(f"获取插件 {pid} 元数据失败:{e}") return {} + def get_plugin_system_version_check_message(self, pid: str, repo_url: str) -> Optional[str]: + """ + 获取指定插件来源的主系统版本兼容错误;兼容或无法定位元数据时返回 None。 + """ + if not pid or not repo_url: + return None + + if self.is_local_repo_url(repo_url): + candidate = self.get_local_plugin_candidate( + pid=pid, + package_version=self.parse_local_repo_package_version(repo_url), + repo_path=self.parse_local_repo_path(repo_url), + strict_compat=False + ) + if not candidate: + return None + compatible, message = self.check_plugin_system_version(candidate) + return None if compatible else message + + package_version = self.get_plugin_package_version(pid, repo_url, settings.VERSION_FLAG) + if package_version is None: + return None + meta = self.__get_plugin_meta(pid, repo_url, package_version) + compatible, message = self.check_plugin_system_version(meta) + return None if compatible else message + + async def async_get_plugin_system_version_check_message(self, pid: str, repo_url: str) -> Optional[str]: + """ + 异步获取指定插件来源的主系统版本兼容错误;兼容或无法定位元数据时返回 None。 + """ + if not pid or not repo_url: + return None + + if self.is_local_repo_url(repo_url): + return await asyncio.to_thread(self.get_plugin_system_version_check_message, pid, repo_url) + + package_version = await self.async_get_plugin_package_version(pid, repo_url, settings.VERSION_FLAG) + if package_version is None: + return None + meta = await self.__async_get_plugin_meta(pid, repo_url, package_version) + compatible, message = self.check_plugin_system_version(meta) + return None if compatible else message + def __install_flow_sync(self, pid: str, force_install: bool, prepare_content: Callable[[], Tuple[bool, str]], repo_url: Optional[str] = None) -> Tuple[bool, str]: @@ -2284,6 +2402,10 @@ class PluginHelper(metaclass=WeakSingleton): is_release = meta.get("release") # 插件版本号 plugin_version = meta.get("version") + compatible, message = self.check_plugin_system_version(meta) + if not compatible: + logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}") + return False, message if is_release: # 使用 插件ID_插件版本号 作为 Release tag if not plugin_version: diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index b8705db5..e043da33 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -36,6 +36,12 @@ class Plugin(BaseModel): has_page: Optional[bool] = False # 是否有新版本 has_update: Optional[bool] = False + # 主系统版本是否兼容 + system_version_compatible: Optional[bool] = True + # 主系统版本兼容提示 + system_version_message: Optional[str] = None + # 主系统版本限定范围 + system_version: Optional[str] = None # 是否本地 is_local: Optional[bool] = False # 仓库地址 diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 8346d484..2b7d2a53 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -32,6 +32,66 @@ class PluginHelperTest(TestCase): PluginHelper.sanitize_repo_url_for_statistic(repo_url) ) + def test_check_plugin_system_version_allows_missing_field(self): + """ + 未声明主系统版本范围时保持旧插件兼容,不做额外限制。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + self.skipTest(f"missing dependency: {exc}") + + success, message = PluginHelper.check_plugin_system_version({"version": "1.0.0"}) + + self.assertTrue(success) + self.assertEqual("", message) + + def test_check_plugin_system_version_rejects_out_of_range(self): + """ + 插件声明的主系统版本范围不满足当前版本时拒绝安装。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + self.skipTest(f"missing dependency: {exc}") + + with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")): + success, message = PluginHelper.check_plugin_system_version({"system_version": ">=2.13.0"}) + + self.assertFalse(success) + self.assertIn("MoviePilot 版本 >=2.13.0", message) + + def test_check_plugin_system_version_accepts_v_prefix_specifier(self): + """ + 兼容带 v 前缀的版本范围,降低插件索引维护成本。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + self.skipTest(f"missing dependency: {exc}") + + with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")): + success, message = PluginHelper.check_plugin_system_version({"system_version": ">=v2.12.0"}) + + self.assertTrue(success) + self.assertEqual("", message) + + def test_annotate_plugin_system_version_marks_incompatible(self): + """ + 插件市场列表会带出系统版本兼容状态,供前端禁用安装入口。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + self.skipTest(f"missing dependency: {exc}") + + plugin_info = {"system_version": ">=2.13.0"} + with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")): + annotated = PluginHelper.annotate_plugin_system_version(plugin_info) + + self.assertFalse(annotated["system_version_compatible"]) + self.assertIn("当前版本", annotated["system_version_message"]) + def test_pip_install_keeps_modules_imported_during_install(self): """ 验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。