mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-05 14:51:28 +08:00
feat(plugin): support installing release versions (#5964)
This commit is contained in:
@@ -11,6 +11,7 @@ from starlette.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.command import Command
|
||||
from app.core.cache import async_fresh
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.plugin import PluginManager
|
||||
@@ -161,6 +162,7 @@ def _merge_plugin_market_metadata(
|
||||
"""
|
||||
plugin.repo_url = market_plugin.repo_url or plugin.repo_url
|
||||
plugin.history = market_plugin.history or {}
|
||||
plugin.release = market_plugin.release
|
||||
plugin.has_update = market_plugin.has_update
|
||||
plugin.system_version = market_plugin.system_version or plugin.system_version
|
||||
plugin.system_version_compatible = market_plugin.system_version_compatible
|
||||
@@ -336,6 +338,80 @@ async def plugin_history(
|
||||
return plugin
|
||||
|
||||
|
||||
@router.get("/releases/{plugin_id}", summary="获取插件Release版本", response_model=dict)
|
||||
async def plugin_releases(
|
||||
plugin_id: str,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
repo_url: Optional[str] = "",
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
查询指定插件可直接安装的 GitHub Release 版本。
|
||||
"""
|
||||
if not repo_url:
|
||||
return {
|
||||
"release_supported": False,
|
||||
"latest_version": None,
|
||||
"current_version": None,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
online_plugins = await plugin_manager.async_get_online_plugins(force=force)
|
||||
market_plugin = next(
|
||||
(
|
||||
plugin
|
||||
for plugin in online_plugins
|
||||
if plugin.id == plugin_id and plugin.repo_url == repo_url
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not market_plugin:
|
||||
market_plugin = next(
|
||||
(
|
||||
plugin
|
||||
for plugin in online_plugins
|
||||
if plugin.id == plugin_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
installed_plugin = next(
|
||||
(
|
||||
plugin
|
||||
for plugin in plugin_manager.get_local_plugins()
|
||||
if plugin.id == plugin_id and plugin.installed
|
||||
),
|
||||
None,
|
||||
)
|
||||
latest_version = market_plugin.plugin_version if market_plugin else None
|
||||
current_version = installed_plugin.plugin_version if installed_plugin else None
|
||||
if not getattr(market_plugin, "release", False):
|
||||
return {
|
||||
"release_supported": False,
|
||||
"latest_version": latest_version,
|
||||
"current_version": current_version,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
async with async_fresh(force):
|
||||
release_items = await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url)
|
||||
items = []
|
||||
for item in release_items:
|
||||
version = item.get("version")
|
||||
copied_item = item.copy()
|
||||
copied_item["is_latest"] = bool(latest_version and version == latest_version)
|
||||
copied_item["is_current"] = bool(current_version and version == current_version)
|
||||
items.append(copied_item)
|
||||
|
||||
return {
|
||||
"release_supported": bool(items),
|
||||
"latest_version": latest_version,
|
||||
"current_version": current_version,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/statistic", summary="插件安装统计", response_model=dict)
|
||||
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -364,6 +440,7 @@ def reload_plugin(
|
||||
async def install(
|
||||
plugin_id: str,
|
||||
repo_url: Optional[str] = "",
|
||||
release_version: Optional[str] = None,
|
||||
force: Optional[bool] = False,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
@@ -386,7 +463,7 @@ async def install(
|
||||
# 插件不存在或需要强制安装,下载安装并注册插件
|
||||
if repo_url:
|
||||
state, msg = await plugin_helper.async_install(
|
||||
pid=plugin_id, repo_url=repo_url, force_install=force
|
||||
pid=plugin_id, repo_url=repo_url, release_version=release_version, force_install=force
|
||||
)
|
||||
# 安装失败则直接响应
|
||||
if not state:
|
||||
|
||||
@@ -1199,6 +1199,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
if not settings.PLUGIN_MARKET:
|
||||
return []
|
||||
if force:
|
||||
PluginHelper().get_plugin_release_versions.cache_clear()
|
||||
|
||||
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
||||
higher_version_plugins = []
|
||||
@@ -1546,6 +1548,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
# 更新历史
|
||||
if plugin_info.get("history"):
|
||||
plugin.history = plugin_info.get("history")
|
||||
# Release 能力位来自插件市场索引,用于前端展示和后端安装入口双重校验。
|
||||
plugin.release = bool(plugin_info.get("release"))
|
||||
# 仓库链接
|
||||
plugin.repo_url = market
|
||||
# 本地标志
|
||||
@@ -1562,6 +1566,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
if not settings.PLUGIN_MARKET:
|
||||
return []
|
||||
if force:
|
||||
await PluginHelper().async_get_plugin_release_versions.cache_clear()
|
||||
|
||||
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
||||
higher_version_plugins = []
|
||||
|
||||
@@ -409,6 +409,55 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def __build_plugin_release_item(pid: str, release_info: dict) -> Optional[dict]:
|
||||
"""
|
||||
从 GitHub release 响应中提取可安装版本,仅接受规范 tag 与同名 zip 资产。
|
||||
"""
|
||||
if not isinstance(release_info, dict):
|
||||
return None
|
||||
|
||||
tag_name = release_info.get("tag_name")
|
||||
if not isinstance(tag_name, str):
|
||||
return None
|
||||
|
||||
tag_prefix = f"{pid}_v"
|
||||
if not tag_name.startswith(tag_prefix):
|
||||
return None
|
||||
|
||||
version = tag_name[len(tag_prefix):]
|
||||
if not version:
|
||||
return None
|
||||
|
||||
asset_name = f"{tag_name.lower()}.zip"
|
||||
assets = release_info.get("assets") or []
|
||||
if not any(isinstance(asset, dict) and asset.get("name") == asset_name for asset in assets):
|
||||
return None
|
||||
|
||||
return {
|
||||
"version": version,
|
||||
"tag_name": tag_name,
|
||||
"name": release_info.get("name") or tag_name,
|
||||
"published_at": release_info.get("published_at"),
|
||||
"body": release_info.get("body") or "",
|
||||
"asset_name": asset_name,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def __parse_plugin_release_response(pid: str, payload) -> List[dict]:
|
||||
"""
|
||||
解析 GitHub release 列表,过滤出当前插件可直接安装的 release 资产。
|
||||
"""
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
|
||||
releases = []
|
||||
for release_info in payload:
|
||||
item = PluginHelper.__build_plugin_release_item(pid, release_info)
|
||||
if item:
|
||||
releases.append(item)
|
||||
return releases
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
def get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
@@ -437,6 +486,51 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return None
|
||||
return self.__parse_plugin_index_response(res.text)
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
def get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
|
||||
"""
|
||||
获取插件可安装的 GitHub Release 版本列表。
|
||||
"""
|
||||
if not pid or not repo_url:
|
||||
return []
|
||||
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return []
|
||||
|
||||
user_repo = f"{user}/{repo}"
|
||||
releases = []
|
||||
for page in range(1, 11):
|
||||
release_api = f"https://api.github.com/repos/{user_repo}/releases?per_page=100&page={page}"
|
||||
release_api = self.__append_cache_buster(release_api)
|
||||
res = self.__request_with_fallback(
|
||||
release_api,
|
||||
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
|
||||
timeout=30,
|
||||
is_api=True,
|
||||
)
|
||||
if res is None or res.status_code != 200:
|
||||
break
|
||||
|
||||
try:
|
||||
payload = res.json()
|
||||
if not payload:
|
||||
break
|
||||
releases.extend(self.__parse_plugin_release_response(pid, payload))
|
||||
if len(payload) < 100:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"解析插件 {pid} Release 列表失败:{e}")
|
||||
break
|
||||
return releases
|
||||
|
||||
@staticmethod
|
||||
def __has_installable_release_version(release_items: List[dict], release_version: str) -> bool:
|
||||
"""
|
||||
指定版本必须来自已解析出的可安装 Release 列表,避免直接拼接任意 tag。
|
||||
"""
|
||||
return any(item.get("version") == release_version for item in release_items)
|
||||
|
||||
def get_plugin_package_version(self, pid: str, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
@@ -485,7 +579,8 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return None, None
|
||||
return user, repo
|
||||
|
||||
def install(self, pid: str, repo_url: str, package_version: Optional[str] = None, force_install: bool = False) \
|
||||
def install(self, pid: str, repo_url: str, package_version: Optional[str] = None,
|
||||
release_version: Optional[str] = None, force_install: bool = False) \
|
||||
-> Tuple[bool, str]:
|
||||
"""
|
||||
安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
|
||||
@@ -498,6 +593,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
:param pid: 插件 ID
|
||||
:param repo_url: 插件仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
|
||||
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
@@ -541,6 +637,25 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
is_release = meta.get("release")
|
||||
# 插件版本号
|
||||
plugin_version = meta.get("version")
|
||||
if release_version:
|
||||
if not is_release:
|
||||
return False, f"{pid} 未声明 Release 安装,无法安装指定版本"
|
||||
if not self.__has_installable_release_version(
|
||||
self.get_plugin_release_versions(pid, repo_url), release_version
|
||||
):
|
||||
return False, f"{pid} 未找到可安装的 Release 版本:{release_version}"
|
||||
if release_version == plugin_version:
|
||||
compatible, message = self.check_plugin_system_version(meta)
|
||||
if not compatible:
|
||||
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
||||
return False, message
|
||||
release_tag = f"{pid}_v{release_version}"
|
||||
|
||||
def prepare_selected_release() -> Tuple[bool, str]:
|
||||
return self.__install_from_release(pid, user_repo, release_tag)
|
||||
|
||||
return self.__install_flow_sync(pid, force_install, prepare_selected_release, repo_url)
|
||||
|
||||
compatible, message = self.check_plugin_system_version(meta)
|
||||
if not compatible:
|
||||
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
||||
@@ -1885,6 +2000,44 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return None
|
||||
return self.__parse_plugin_index_response(res.text)
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
async def async_get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
|
||||
"""
|
||||
异步获取插件可安装的 GitHub Release 版本列表。
|
||||
"""
|
||||
if not pid or not repo_url:
|
||||
return []
|
||||
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return []
|
||||
|
||||
user_repo = f"{user}/{repo}"
|
||||
releases = []
|
||||
for page in range(1, 11):
|
||||
release_api = f"https://api.github.com/repos/{user_repo}/releases?per_page=100&page={page}"
|
||||
release_api = self.__append_cache_buster(release_api)
|
||||
res = await self.__async_request_with_fallback(
|
||||
release_api,
|
||||
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
|
||||
timeout=30,
|
||||
is_api=True,
|
||||
)
|
||||
if res is None or res.status_code != 200:
|
||||
break
|
||||
|
||||
try:
|
||||
payload = res.json()
|
||||
if not payload:
|
||||
break
|
||||
releases.extend(self.__parse_plugin_release_response(pid, payload))
|
||||
if len(payload) < 100:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"解析插件 {pid} Release 列表失败:{e}")
|
||||
break
|
||||
return releases
|
||||
|
||||
async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \
|
||||
Tuple[Optional[list], Optional[str]]:
|
||||
"""
|
||||
@@ -2243,6 +2396,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return []
|
||||
|
||||
async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None,
|
||||
release_version: Optional[str] = None,
|
||||
force_install: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
|
||||
@@ -2255,6 +2409,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
:param pid: 插件 ID
|
||||
:param repo_url: 插件仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
|
||||
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
@@ -2297,6 +2452,24 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
is_release = meta.get("release")
|
||||
# 插件版本号
|
||||
plugin_version = meta.get("version")
|
||||
if release_version:
|
||||
if not is_release:
|
||||
return False, f"{pid} 未声明 Release 安装,无法安装指定版本"
|
||||
release_items = await self.async_get_plugin_release_versions(pid, repo_url)
|
||||
if not self.__has_installable_release_version(release_items, release_version):
|
||||
return False, f"{pid} 未找到可安装的 Release 版本:{release_version}"
|
||||
if release_version == plugin_version:
|
||||
compatible, message = self.check_plugin_system_version(meta)
|
||||
if not compatible:
|
||||
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
||||
return False, message
|
||||
release_tag = f"{pid}_v{release_version}"
|
||||
|
||||
async def prepare_selected_release() -> Tuple[bool, str]:
|
||||
return await self.__async_install_from_release(pid, user_repo, release_tag)
|
||||
|
||||
return await self.__install_flow_async(pid, force_install, prepare_selected_release, repo_url)
|
||||
|
||||
compatible, message = self.check_plugin_system_version(meta)
|
||||
if not compatible:
|
||||
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
||||
|
||||
@@ -42,6 +42,8 @@ class Plugin(BaseModel):
|
||||
system_version_message: Optional[str] = None
|
||||
# 主系统版本限定范围
|
||||
system_version: Optional[str] = None
|
||||
# 是否声明支持通过 GitHub Release 资产安装
|
||||
release: Optional[bool] = False
|
||||
# 是否本地
|
||||
is_local: Optional[bool] = False
|
||||
# 仓库地址
|
||||
|
||||
Reference in New Issue
Block a user