mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-29 23:16:48 +00:00
feat: add plugin system version compatibility checks
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
# 插件不存在或需要强制安装,下载安装并注册插件
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# 仓库地址
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。
|
||||
|
||||
Reference in New Issue
Block a user