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

View File

@@ -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",

View File

@@ -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:
# 插件不存在或需要强制安装,下载安装并注册插件

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
# 仓库地址

View File

@@ -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):
"""
验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。