mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08: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),
|
"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",
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
# 仓库地址
|
# 仓库地址
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。
|
验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。
|
||||||
|
|||||||
Reference in New Issue
Block a user