mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-04-05 19:58:09 +08:00
feat(plugin): implement fallback mechanism for install plugin
This commit is contained in:
@@ -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指定的仓库请求头
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user