feat: add plugin system version compatibility checks

This commit is contained in:
jxxghp
2026-05-20 19:55:44 +08:00
parent c661bc4764
commit c52ccaf75f
6 changed files with 207 additions and 1 deletions
@@ -93,6 +93,9 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]:
"plugin_author": getattr(plugin, "plugin_author", None), "plugin_author": getattr(plugin, "plugin_author", None),
"installed": bool(getattr(plugin, "installed", False)), "installed": bool(getattr(plugin, "installed", False)),
"has_update": bool(getattr(plugin, "has_update", 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)), "state": bool(getattr(plugin, "state", False)),
"repo_url": repo_url, "repo_url": repo_url,
"source": "local_repo" if PluginHelper.is_local_repo_url(repo_url) else "market", "source": "local_repo" if PluginHelper.is_local_repo_url(repo_url) else "market",
+6
View File
@@ -251,6 +251,12 @@ async def install(
# 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计 # 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计
plugin_helper = PluginHelper() plugin_helper = PluginHelper()
if not force and plugin_id in PluginManager().get_plugin_ids(): 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) await plugin_helper.async_install_reg(pid=plugin_id, repo_url=repo_url)
else: else:
# 插件不存在或需要强制安装,下载安装并注册插件 # 插件不存在或需要强制安装,下载安装并注册插件
+10 -1
View File
@@ -612,7 +612,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
# 确定需要安装的插件 # 确定需要安装的插件
plugins_to_install = [ plugins_to_install = [
plugin for plugin in candidate_plugins 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: if not plugins_to_install:
@@ -1417,6 +1419,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
if not isinstance(plugin_info, dict): if not isinstance(plugin_info, dict):
return None return None
plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy())
# 如 package_version 为空,则需要判断插件是否兼容当前版本 # 如 package_version 为空,则需要判断插件是否兼容当前版本
if not package_version: if not package_version:
if plugin_info.get(settings.VERSION_FLAG) is not True: 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")): if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")):
# 需要更新 # 需要更新
plugin.has_update = True 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"): if plugin_obj and hasattr(plugin_obj, "get_state"):
try: try:
+122
View File
@@ -35,9 +35,11 @@ from app.utils.singleton import WeakSingleton
from app.utils.string import StringUtils from app.utils.string import StringUtils
from app.utils.system import SystemUtils from app.utils.system import SystemUtils
from app.utils.url import UrlUtils from app.utils.url import UrlUtils
from version import APP_VERSION
PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins" PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins"
LOCAL_REPO_PREFIX = "local://" LOCAL_REPO_PREFIX = "local://"
PLUGIN_SYSTEM_VERSION_FIELD = "system_version"
class PluginHelper(metaclass=WeakSingleton): class PluginHelper(metaclass=WeakSingleton):
@@ -163,6 +165,66 @@ class PluginHelper(metaclass=WeakSingleton):
package_version=PluginHelper.parse_local_repo_package_version(repo_url) 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 @staticmethod
def get_local_repo_paths() -> List[Path]: def get_local_repo_paths() -> List[Path]:
""" """
@@ -248,6 +310,7 @@ class PluginHelper(metaclass=WeakSingleton):
candidate["repo_order"] = repo_order candidate["repo_order"] = repo_order
candidate["repo_path"] = repo_path candidate["repo_path"] = repo_path
candidate["path"] = plugin_dir candidate["path"] = plugin_dir
self.annotate_plugin_system_version(candidate)
candidate_version = str(candidate.get("version") or "0") candidate_version = str(candidate.get("version") or "0")
existing = candidates.get(pid) existing = candidates.get(pid)
@@ -313,6 +376,10 @@ class PluginHelper(metaclass=WeakSingleton):
if not is_compatible: if not is_compatible:
candidate["compatible"] = False candidate["compatible"] = False
candidate["skip_reason"] = f"package.json 未声明 {settings.VERSION_FLAG} 兼容" 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: if package_version is not None:
return candidate return candidate
if not selected_candidate: if not selected_candidate:
@@ -537,6 +604,10 @@ class PluginHelper(metaclass=WeakSingleton):
is_release = meta.get("release") is_release = meta.get("release")
# 插件版本号 # 插件版本号
plugin_version = meta.get("version") 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: if is_release:
# 使用 插件ID_插件版本号 作为 Release tag # 使用 插件ID_插件版本号 作为 Release tag
if not plugin_version: if not plugin_version:
@@ -575,6 +646,10 @@ class PluginHelper(metaclass=WeakSingleton):
) )
if not candidate: if not candidate:
return False, f"未找到本地插件:{pid}" 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")) source_dir = Path(candidate.get("path"))
dest_dir = PLUGIN_DIR / pid.lower() dest_dir = PLUGIN_DIR / pid.lower()
@@ -1427,6 +1502,49 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"获取插件 {pid} 元数据失败:{e}") logger.error(f"获取插件 {pid} 元数据失败:{e}")
return {} 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, def __install_flow_sync(self, pid: str, force_install: bool,
prepare_content: Callable[[], Tuple[bool, str]], prepare_content: Callable[[], Tuple[bool, str]],
repo_url: Optional[str] = None) -> Tuple[bool, str]: repo_url: Optional[str] = None) -> Tuple[bool, str]:
@@ -2284,6 +2402,10 @@ class PluginHelper(metaclass=WeakSingleton):
is_release = meta.get("release") is_release = meta.get("release")
# 插件版本号 # 插件版本号
plugin_version = meta.get("version") 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: if is_release:
# 使用 插件ID_插件版本号 作为 Release tag # 使用 插件ID_插件版本号 作为 Release tag
if not plugin_version: if not plugin_version:
+6
View File
@@ -36,6 +36,12 @@ class Plugin(BaseModel):
has_page: Optional[bool] = False has_page: Optional[bool] = False
# 是否有新版本 # 是否有新版本
has_update: 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 is_local: Optional[bool] = False
# 仓库地址 # 仓库地址
+60
View File
@@ -32,6 +32,66 @@ class PluginHelperTest(TestCase):
PluginHelper.sanitize_repo_url_for_statistic(repo_url) 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): def test_pip_install_keeps_modules_imported_during_install(self):
""" """
验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。 验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。