mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-22 07:26:50 +00:00
323 lines
13 KiB
Python
323 lines
13 KiB
Python
import json
|
||
import shutil
|
||
import subprocess
|
||
import traceback
|
||
from pathlib import Path
|
||
from typing import Dict, Tuple, Optional, List, Any
|
||
|
||
from cachetools import TTLCache, cached
|
||
|
||
from app.core.config import settings
|
||
from app.db.systemconfig_oper import SystemConfigOper
|
||
from app.log import logger
|
||
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):
|
||
"""
|
||
插件市场管理,下载安装插件到本地
|
||
"""
|
||
|
||
_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):
|
||
self.systemconfig = SystemConfigOper()
|
||
if settings.PLUGIN_STATISTIC_SHARE:
|
||
if not self.systemconfig.get(SystemConfigKey.PluginInstallReport):
|
||
if self.install_report():
|
||
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||
|
||
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
|
||
def get_plugins(self, repo_url: str, version: str = None) -> Dict[str, dict]:
|
||
"""
|
||
获取Github所有最新插件列表
|
||
:param repo_url: Github仓库地址
|
||
:param version: 版本
|
||
"""
|
||
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.format(user=user, repo=repo)
|
||
package_url = f"{raw_url}package.{version}.json" if version else f"{raw_url}package.json"
|
||
|
||
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 {}
|
||
|
||
@staticmethod
|
||
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
|
||
"""
|
||
获取GitHub仓库信息
|
||
"""
|
||
if not repo_url:
|
||
return None, None
|
||
if not repo_url.endswith("/"):
|
||
repo_url += "/"
|
||
if repo_url.count("/") < 6:
|
||
repo_url = f"{repo_url}main/"
|
||
try:
|
||
user, repo = repo_url.split("/")[-4:-2]
|
||
except Exception as e:
|
||
logger.error(f"解析GitHub仓库地址失败:{str(e)} - {traceback.format_exc()}")
|
||
return None, None
|
||
return user, repo
|
||
|
||
@cached(cache=TTLCache(maxsize=1, ttl=1800))
|
||
def get_statistic(self) -> Dict:
|
||
"""
|
||
获取插件安装统计
|
||
"""
|
||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||
return {}
|
||
res = RequestUtils(timeout=10).get_res(self._install_statistic)
|
||
if res and res.status_code == 200:
|
||
return res.json()
|
||
return {}
|
||
|
||
def install_reg(self, pid: str) -> bool:
|
||
"""
|
||
安装插件统计
|
||
"""
|
||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||
return False
|
||
if not pid:
|
||
return False
|
||
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
|
||
|
||
def install_report(self) -> bool:
|
||
"""
|
||
上报存量插件安装统计
|
||
"""
|
||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||
return False
|
||
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
|
||
if not plugins:
|
||
return False
|
||
res = RequestUtils(content_type="application/json",
|
||
timeout=5).post(self._install_report,
|
||
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]:
|
||
"""
|
||
安装插件
|
||
"""
|
||
if SystemUtils.is_frozen():
|
||
return False, "可执行文件模式下,只能安装本地插件"
|
||
|
||
# 验证参数
|
||
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}"
|
||
|
||
# 获取插件文件列表
|
||
file_list, msg = self.__get_file_list(pid.lower(), user_repo)
|
||
if not file_list:
|
||
return False, msg
|
||
|
||
# 删除旧的插件目录
|
||
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则安装依赖
|
||
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
|