feat(plugin): implement fallback mechanism for install plugin

This commit is contained in:
InfinityPacer
2024-09-14 11:26:43 +08:00
parent 5ec7357c56
commit e0e4b31933
2 changed files with 199 additions and 176 deletions

View File

@@ -280,46 +280,6 @@ class Settings(BaseSettings):
}
return None
@property
def PROXY_URLPARSE(self):
"""
解析地址组成
"""
if self.PROXY_HOST:
parsed_url = urlparse(self.PROXY_HOST)
protocol = parsed_url.scheme or "" # 协议
username = parsed_url.username or "" # 用户名
password = parsed_url.password or "" # 密码
host = parsed_url.hostname or "" # 主机
port = parsed_url.port or "" # 端口
path = parsed_url.path or "" # 路径
netloc = parsed_url.netloc or "" # 用户名:密码@主机:端口
query = parsed_url.query or "" # 查询参数: ?key=value
params = parsed_url.params or "" # 使用;分割的参数
fragment = parsed_url.fragment or "" # 片段: #fragment
if not port:
if protocol == "https":
port = 443
elif protocol == "http":
port = 80
elif protocol in {"socks5", "socks5h", "socks4", "socks4a"}:
port = 1080
return {
"protocol": protocol,
"username": username,
"password": password,
"host": host,
"port": port,
"path": path,
"netloc": netloc,
"query": query,
"params": params,
"fragment": fragment
}
return None
@property
def PROXY_SERVER(self):
if self.PROXY_HOST:
@@ -338,28 +298,6 @@ class Settings(BaseSettings):
}
return {}
@property
def PIP_OPTIONS(self):
"""
pip调用附加参数
"""
protocol = host = port = ""
parsed_url = self.PROXY_URLPARSE
if parsed_url:
protocol = parsed_url.get("scheme", "").lower()
host = parsed_url.get("host", "").lower()
port = parsed_url.get("port", "")
# 优先级:镜像站 > 全局 > 不代理
if settings.PIP_PROXY:
PIP_OPTIONS = f" -i {settings.PIP_PROXY} "
# 全局代理地址
elif protocol in {"http", "https", "socks4", "socks4a", "socks5", "socks5h"} and host and port:
PIP_OPTIONS = f" --proxy={settings.PROXY_HOST} "
# 不使用代理
else:
PIP_OPTIONS = ""
return PIP_OPTIONS
def REPO_GITHUB_HEADERS(self, repo: str = None):
"""
Github指定的仓库请求头

View File

@@ -1,8 +1,9 @@
import json
import shutil
import subprocess
import traceback
from pathlib import Path
from typing import Dict, Tuple, Optional, List
from typing import Dict, Tuple, Optional, List, Any
from cachetools import TTLCache, cached
@@ -13,6 +14,7 @@ from app.schemas.types import SystemConfigKey
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.system import SystemUtils
from app.utils.url import UrlUtils
class PluginHelper(metaclass=Singleton):
@@ -20,12 +22,9 @@ class PluginHelper(metaclass=Singleton):
插件市场管理,下载安装插件到本地
"""
_base_url = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/%s/%s/main/"
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/%s"
_base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/"
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/{{pid}}"
_install_report = f"{settings.MP_SERVER_HOST}/plugin/install"
_install_statistic = f"{settings.MP_SERVER_HOST}/plugin/statistic"
def __init__(self):
@@ -35,10 +34,6 @@ class PluginHelper(metaclass=Singleton):
if self.install_report():
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
@property
def proxies(self):
return None if settings.GITHUB_PROXY else settings.PROXY
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
def get_plugins(self, repo_url: str, version: str = None) -> Dict[str, dict]:
"""
@@ -48,27 +43,26 @@ class PluginHelper(metaclass=Singleton):
"""
if not repo_url:
return {}
user, repo = self.get_repo_info(repo_url)
if not user or not repo:
return {}
raw_url = self._base_url % (user, repo)
raw_url = self._base_url.format(user=user, repo=repo)
package_url = f"{raw_url}package.{version}.json" if version else f"{raw_url}package.json"
res = RequestUtils(proxies=self.proxies,
headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"),
timeout=10).get_res(package_url)
res = self.__request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"))
if res:
try:
return json.loads(res.text)
except json.JSONDecodeError:
logger.error(f"插件包数据解析失败:{res.text}")
return {}
return {}
@staticmethod
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
"""
获取Github仓库信息
:param repo_url: Github仓库地址
获取GitHub仓库信息
"""
if not repo_url:
return None, None
@@ -79,7 +73,7 @@ class PluginHelper(metaclass=Singleton):
try:
user, repo = repo_url.split("/")[-4:-2]
except Exception as e:
logger.error(f"解析Github仓库地址失败{str(e)} - {traceback.format_exc()}")
logger.error(f"解析GitHub仓库地址失败{str(e)} - {traceback.format_exc()}")
return None, None
return user, repo
@@ -103,7 +97,8 @@ class PluginHelper(metaclass=Singleton):
return False
if not pid:
return False
res = RequestUtils(timeout=5).get_res(self._install_reg % pid)
install_reg_url = self._install_reg.format(pid=pid)
res = RequestUtils(timeout=5).get_res(install_reg_url)
if res and res.status_code == 200:
return True
return False
@@ -119,13 +114,7 @@ class PluginHelper(metaclass=Singleton):
return False
res = RequestUtils(content_type="application/json",
timeout=5).post(self._install_report,
json={
"plugins": [
{
"plugin_id": plugin,
} for plugin in plugins
]
})
json={"plugins": [{"plugin_id": plugin} for plugin in plugins]})
return True if res else False
def install(self, pid: str, repo_url: str) -> Tuple[bool, str]:
@@ -135,103 +124,199 @@ class PluginHelper(metaclass=Singleton):
if SystemUtils.is_frozen():
return False, "可执行文件模式下,只能安装本地插件"
# 从Github的repo_url获取用户和项目名
# 验证参数
if not pid or not repo_url:
return False, "参数错误"
# 从GitHub的repo_url获取用户和项目名
user, repo = self.get_repo_info(repo_url)
if not user or not repo:
return False, "不支持的插件仓库地址格式"
user_repo = f"{user}/{repo}"
def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]:
"""
获取插件的文件列表
"""
file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins/{_p}"
r = RequestUtils(proxies=settings.PROXY,
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
timeout=30).get_res(file_api)
if r is None:
return None, "连接仓库失败"
elif r.status_code != 200:
return None, f"连接仓库失败:{r.status_code} - " \
f"{'超出速率限制请配置GITHUB_TOKEN环境变量或稍后重试' if r.status_code == 403 else r.reason}"
ret = r.json()
if ret and ret[0].get("message") == "Not Found":
return None, "插件在仓库中不存在"
return ret, ""
def __download_files(_p: str, _l: List[dict]) -> Tuple[bool, str]:
"""
下载插件文件
"""
if not _l:
return False, "文件列表为空"
for item in _l:
if item.get("download_url"):
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
# 下载插件文件
res = RequestUtils(proxies=self.proxies,
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
timeout=60).get_res(download_url)
if not res:
return False, f"文件 {item.get('name')} 下载失败!"
elif res.status_code != 200:
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - " \
f"{'超出速率限制请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
# 创建插件文件夹
file_path = Path(settings.ROOT_PATH) / "app" / item.get("path")
if not file_path.parent.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(res.text)
else:
# 递归下载子目录
p = f"{_p}/{item.get('name')}"
l, m = __get_filelist(p)
if not l:
return False, m
__download_files(p, l)
return True, ""
if not pid or not repo_url:
return False, "参数错误"
# 获取插件的文件列表
"""
[
{
"name": "__init__.py",
"path": "plugins/autobackup/__init__.py",
"sha": "cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
"size": 12385,
"url": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/contents/plugins/autobackup/__init__.py?ref=main",
"html_url": "https://github.com/jxxghp/MoviePilot-Plugins/blob/main/plugins/autobackup/__init__.py",
"git_url": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/git/blobs/cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
"download_url": "https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/plugins/autobackup/__init__.py",
"type": "file",
"_links": {
"self": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/contents/plugins/autobackup/__init__.py?ref=main",
"git": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/git/blobs/cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
"html": "https://github.com/jxxghp/MoviePilot-Plugins/blob/main/plugins/autobackup/__init__.py"
}
}
]
"""
# 获取第一级文件列表
file_list, msg = __get_filelist(pid.lower())
# 获取插件文件列表
file_list, msg = self.__get_file_list(pid.lower(), user_repo)
if not file_list:
return False, msg
# 本地存在时先删除
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
if plugin_dir.exists():
shutil.rmtree(plugin_dir, ignore_errors=True)
# 下载所有文件
__download_files(pid.lower(), file_list)
# 删除旧的插件目录
self.__remove_old_plugin(pid.lower())
# 下载所有插件文件
download_success, download_msg = self.__download_files(pid.lower(), file_list, user_repo)
if not download_success:
return False, download_msg
# 插件目录下如有requirements.txt则安装依赖
requirements_file = plugin_dir / "requirements.txt"
if requirements_file.exists():
SystemUtils.execute(f"pip install -r {requirements_file} {settings.PIP_OPTIONS} > /dev/null 2>&1")
# 安装成功后统计
success, message = self.__install_dependencies_if_required(pid.lower())
if not success:
return False, message
# 插件安装成功后,统计安装信息
self.install_reg(pid)
return True, ""
def __get_file_list(self, pid: str, user_repo: str) -> Tuple[Optional[list], Optional[str]]:
"""
获取插件的文件列表
"""
file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins/{pid}"
res = self.__request_with_fallback(file_api,
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
is_api=True,
timeout=30)
if res is None:
return None, "连接仓库失败"
elif res.status_code != 200:
return None, f"连接仓库失败:{res.status_code} - " \
f"{'超出速率限制请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
try:
ret = res.json()
if isinstance(ret, list) and len(ret) > 0 and "message" not in ret[0]:
return ret, ""
else:
return None, "插件在仓库中不存在或返回数据格式不正确"
except Exception as e:
logger.error(f"插件数据解析失败:{res.text}{e}")
return None, "插件数据解析失败"
def __download_files(self, pid: str, file_list: List[dict], user_repo: str) -> Tuple[bool, str]:
"""
下载插件文件
"""
if not file_list:
return False, "文件列表为空"
# 使用栈结构来替代递归调用,避免递归深度过大问题
stack = [(pid, file_list)]
while stack:
current_pid, current_file_list = stack.pop()
for item in current_file_list:
if item.get("download_url"):
logger.debug(f"正在下载文件:{item.get('path')}")
res = self.__request_with_fallback(item.get('download_url'),
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))
if not res:
return False, f"文件 {item.get('path')} 下载失败!"
elif res.status_code != 200:
return False, f"下载文件 {item.get('path')} 失败:{res.status_code} - " \
f"{'超出速率限制请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
# 创建插件文件夹并写入文件
file_path = Path(settings.ROOT_PATH) / "app" / item.get("path")
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(res.text)
logger.debug(f"文件 {item.get('path')} 下载成功,保存路径:{file_path}")
else:
# 将子目录加入栈中以便处理
sub_list, msg = self.__get_file_list(f"{current_pid}/{item.get('name')}", user_repo)
if not sub_list:
return False, msg
stack.append((f"{current_pid}/{item.get('name')}", sub_list))
return True, ""
def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, str]:
"""
安装插件依赖如果有requirements.txt
"""
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid
requirements_file = plugin_dir / "requirements.txt"
if requirements_file.exists():
return self.__pip_install_with_fallback(requirements_file)
return True, ""
@staticmethod
def __remove_old_plugin(pid: str):
"""
删除旧插件
"""
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid
if plugin_dir.exists():
shutil.rmtree(plugin_dir, ignore_errors=True)
@staticmethod
def __pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
"""
使用自动降级策略 PIP 安装依赖,优先级依次为镜像站、代理、直连
:param requirements_file: 依赖的 requirements.txt 文件路径
:return: 依赖安装成功返回 (True, ""),失败返回 (False, 错误信息)
"""
# 构建三种不同策略下的 PIP 命令
pip_commands = [
["pip", "install", "-r", str(requirements_file), "-i", settings.PIP_PROXY] if settings.PIP_PROXY else None,
# 使用镜像站
["pip", "install", "-r", str(requirements_file), "--proxy",
settings.PROXY_HOST] if settings.PROXY_HOST else None, # 使用代理
["pip", "install", "-r", str(requirements_file)] # 直连
]
# 过滤掉 None 的命令
pip_commands = [cmd for cmd in pip_commands if cmd is not None]
for pip_command in pip_commands:
try:
logger.info(f"尝试使用PIP安装依赖命令{' '.join(pip_command)}")
# 使用 subprocess.run 捕获标准输出和标准错误
result = subprocess.run(pip_command, check=True, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
logger.info(f"依赖安装成功,输出:{result.stdout}")
return True, result.stdout
except subprocess.CalledProcessError as e:
error_message = f"命令:{' '.join(pip_command)},执行失败,错误信息:{e.stderr.strip()}"
logger.error(error_message)
return False, error_message
except Exception as e:
error_message = f"未知错误,命令:{' '.join(pip_command)},错误:{str(e)}"
logger.error(error_message)
return False, error_message
return False, "所有依赖安装方式均失败,请检查网络连接或 PIP 配置"
@staticmethod
def __request_with_fallback(url: str,
headers: Optional[dict] = None,
timeout: int = 60,
is_api: bool = False) -> Optional[Any]:
"""
使用自动降级策略请求资源,优先级依次为镜像站、代理、直连
:param url: 目标URL
:param headers: 请求头信息
:param timeout: 请求超时时间
:param is_api: 是否为GitHub API请求API请求不走镜像站
:return: 请求成功则返回Response失败返回None
"""
# 镜像站一般不支持API请求因此API请求直接跳过镜像站
if not is_api and settings.GITHUB_PROXY:
proxy_url = f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}"
try:
res = RequestUtils(headers=headers, timeout=timeout).get_res(url=proxy_url,
raise_exception=True)
return res
except Exception as e:
logger.error(f"使用镜像站 {settings.GITHUB_PROXY} 访问 {url} 失败: {str(e)}")
# 使用代理
if settings.PROXY_HOST:
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST}
try:
res = RequestUtils(headers=headers, proxies=proxies, timeout=timeout).get_res(url=url,
raise_exception=True)
return res
except Exception as e:
logger.error(f"使用代理 {settings.PROXY_HOST} 访问 {url} 失败: {str(e)}")
# 最后尝试直连
try:
res = RequestUtils(headers=headers, timeout=timeout).get_res(url=url,
raise_exception=True)
return res
except Exception as e:
logger.error(f"直连访问 {url} 失败: {str(e)}")
return None