feat 在线仓库插件安装

This commit is contained in:
jxxghp 2023-11-01 20:56:38 +08:00
parent 0dac3f1b1d
commit fbe306ba90
7 changed files with 238 additions and 21 deletions

View File

@ -97,7 +97,6 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- **❗COOKIECLOUD_PASSWORD** CookieCloud端对端加密密码
- **❗COOKIECLOUD_INTERVAL** CookieCloud同步间隔分钟
- **❗USER_AGENT** CookieCloud保存Cookie对应的浏览器UA建议配置设置后可增加连接站点的成功率同步站点后可以在管理界面中修改
- **OCR_HOST** OCR识别服务器地址格式`http(s)://ip:port`用于识别站点验证码实现自动登录获取Cookie等不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
---
- **SUBSCRIBE_MODE** 订阅模式,`rss`/`spider`,默认`spider``rss`模式通过定时刷新RSS来匹配订阅RSS地址会自动获取也可手动维护对站点压力小同时可设置订阅刷新周期24小时运行但订阅和下载通知不能过滤和显示免费推荐使用rss模式。
- **SUBSCRIBE_RSS_INTERVAL** RSS订阅模式刷新时间间隔分钟默认`30`分钟不能小于5分钟。
@ -106,6 +105,9 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
---
- **AUTO_DOWNLOAD_USER** 远程交互搜索时自动择优下载的用户ID多个用户使用,分割,未设置需要选择资源或者回复`0`
- **❗MESSAGER** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
---
- **OCR_HOST** OCR识别服务器地址格式`http(s)://ip:port`用于识别站点验证码实现自动登录获取Cookie等不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
- **PLUGIN_MARKET** 插件市场仓库地址,多个地址使用`,`分隔,保留最后的/,默认为官方插件仓库:`https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/`
- `wechat`设置项:

View File

@ -6,6 +6,7 @@ from app import schemas
from app.core.plugin import PluginManager
from app.core.security import verify_token
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.plugin import PluginHelper
from app.schemas.types import SystemConfigKey
router = APIRouter()
@ -16,7 +17,25 @@ def all_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询所有插件清单
"""
return PluginManager().get_plugin_apps()
# 查询本地插件
local_plugins = PluginManager().get_local_plugins()
# 在线插件
online_plugins = PluginManager().get_online_plugins()
# 全并去重,在线插件有的以在线插件为准
plugins = []
if not local_plugins:
return online_plugins
for plugin in local_plugins:
for online_plugin in online_plugins:
if plugin["id"] == online_plugin["id"]:
plugins.append(online_plugin)
break
else:
plugins.append(plugin)
for plugin in online_plugins:
if plugin not in plugins:
plugins.append(plugin)
return plugins
@router.get("/installed", summary="已安装插件", response_model=List[str])
@ -29,18 +48,34 @@ def installed_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
def install_plugin(plugin_id: str,
repo_url: str = "",
force: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
安装插件
"""
# 已安装插件
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
# 重载标志
reload_flag = False
# 如果是非本地括件,或者强制安装时,则需要下载安装
if repo_url and (force or plugin_id not in PluginManager().get_plugin_ids()):
# 下载安装
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
if state:
# 安装成功
reload_flag = True
else:
# 安装失败
return schemas.Response(success=False, msg=msg)
# 安装插件
if plugin_id not in install_plugins:
reload_flag = True
install_plugins.append(plugin_id)
# 保存设置
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 重载插件管理器
# 重载插件管理器
if reload_flag:
PluginManager().init_config()
return schemas.Response(success=True)

View File

@ -210,10 +210,8 @@ class Settings(BaseSettings):
OVERWRITE_MODE: str = "size"
# 大内存模式
BIG_MEMORY_MODE: bool = False
# 插件市场地址
PLUGIN_MARKET: str = "https://movie-pilot.org/pluginmarket"
# 资源包更新地址
RESOURCE_HOST: str = "https://movie-pilot.org/resources"
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
PLUGIN_MARKET: str = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/"
@property
def INNER_CONFIG_PATH(self):

View File

@ -1,14 +1,17 @@
import traceback
from typing import List, Any, Dict, Tuple
from app.core.config import settings
from app.core.event import eventmanager
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.module import ModuleHelper
from app.helper.plugin import PluginHelper
from app.helper.sites import SitesHelper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class PluginManager(metaclass=Singleton):
@ -26,6 +29,7 @@ class PluginManager(metaclass=Singleton):
def __init__(self):
self.siteshelper = SitesHelper()
self.pluginhelper = PluginHelper()
self.init_config()
def init_config(self):
@ -188,9 +192,91 @@ class PluginManager(metaclass=Singleton):
"""
return list(self._plugins.keys())
def get_plugin_apps(self) -> List[dict]:
def get_online_plugins(self) -> List[Dict[str, dict]]:
"""
获取所有插件信息
获取所有在线插件信息
"""
# 返回值
all_confs = []
if not settings.PLUGIN_MARKET:
return all_confs
# 已安装插件
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
# 线上插件列表
markets = settings.PLUGIN_MARKET.split(",")
for market in markets:
online_plugins = self.pluginhelper.get_plugins(market) or {}
for pid, plugin in online_plugins.items():
# 运行状插件
plugin_obj = self._running_plugins.get(pid)
# 非运行态插件
plugin_static = self._plugins.get(pid)
# 基本属性
conf = {}
# ID
conf.update({"id": pid})
# 安装状态,是否有新版本
if plugin_static:
# 已安装
if pid in installed_apps:
conf.update({"installed": True})
else:
conf.update({"installed": False})
conf.update({"has_update": False})
if plugin_obj:
installed_version = getattr(plugin_static, "plugin_version")
if StringUtils.compare_version(installed_version, plugin.get("version")) < 0:
# 需要更新
conf.update({"installed": False})
conf.update({"has_update": True})
else:
# 未安装
conf.update({"installed": False})
conf.update({"has_update": False})
# 运行状态
if plugin_obj and hasattr(plugin_obj, "get_state"):
conf.update({"state": plugin_obj.get_state()})
else:
conf.update({"state": False})
# 是否有详情页面
conf.update({"has_page": False})
if plugin_obj and hasattr(plugin_obj, "get_page"):
if ObjectUtils.check_method(plugin_obj.get_page):
conf.update({"has_page": True})
# 权限
if plugin.get("level"):
conf.update({"auth_level": plugin.get("level")})
if self.siteshelper.auth_level < plugin.get("level"):
continue
# 名称
if plugin.get("name"):
conf.update({"plugin_name": plugin.get("name")})
# 描述
if plugin.get("description"):
conf.update({"plugin_desc": plugin.get("description")})
# 版本
if plugin.get("version"):
conf.update({"plugin_version": plugin.get("version")})
# 图标
if plugin.get("icon"):
conf.update({"plugin_icon": plugin.get("icon")})
# 主题色
if plugin.get("color"):
conf.update({"plugin_color": plugin.get("color")})
# 作者
if plugin.get("author"):
conf.update({"plugin_author": plugin.get("author")})
# 仓库链接
conf.update({"repo_url": market})
# 本地标志
conf.update({"is_local": False})
# 汇总
all_confs.append(conf)
return all_confs
def get_local_plugins(self) -> List[Dict[str, dict]]:
"""
获取所有本地已下载的插件信息
"""
# 返回值
all_confs = []
@ -209,7 +295,7 @@ class PluginManager(metaclass=Singleton):
else:
conf.update({"installed": False})
# 运行状态
if plugin_obj and hasattr(plugin, "get_state"):
if plugin_obj and hasattr(plugin_obj, "get_state"):
conf.update({"state": plugin_obj.get_state()})
else:
conf.update({"state": False})
@ -221,6 +307,7 @@ class PluginManager(metaclass=Singleton):
conf.update({"has_page": False})
# 权限
if hasattr(plugin, "auth_level"):
conf.update({"auth_level": plugin.auth_level})
if self.siteshelper.auth_level < plugin.auth_level:
continue
# 名称
@ -244,6 +331,10 @@ class PluginManager(metaclass=Singleton):
# 作者链接
if hasattr(plugin, "author_url"):
conf.update({"author_url": plugin.author_url})
# 是否需要更新
conf.update({"has_update": False})
# 本地标志
conf.update({"is_local": True})
# 汇总
all_confs.append(conf)
return all_confs

View File

@ -1,9 +1,14 @@
import json
import shutil
from pathlib import Path
from typing import Dict
from typing import Dict, Tuple
from cachetools import TTLCache, cached
from app.core.config import settings
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.system import SystemUtils
class PluginHelper(metaclass=Singleton):
@ -12,20 +17,77 @@ class PluginHelper(metaclass=Singleton):
"""
@cached(cache=TTLCache(maxsize=1, ttl=1800))
def get_plugins(self) -> Dict[str, dict]:
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
"""
获取Github所有最新插件列表
:param repo_url: Github仓库地址
"""
pass
if not repo_url:
return {}
res = RequestUtils(proxies=settings.PROXY).get_res(f"{repo_url}package.json")
if res:
return json.loads(res.text)
return {}
def download(self, name: str, dest: Path) -> bool:
"""
下载插件到本地
"""
pass
def install(self, name: str) -> bool:
@staticmethod
def install(pid: str, repo_url: str) -> Tuple[bool, str]:
"""
安装插件
"""
pass
if not pid or not repo_url:
return False, "参数错误"
# 从Github的repo_url获取用户和项目名
try:
user, repo = repo_url.split("/")[-4:-2]
except Exception as e:
return False, f"不支持的插件仓库地址格式:{str(e)}"
if not user or not repo:
return False, "不支持的插件仓库地址格式"
if SystemUtils.is_frozen():
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_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{pid.lower()}"
res = RequestUtils(proxies=settings.PROXY).get_res(file_api)
if not res or res.status_code != 200:
return False, f"连接仓库失败:{res.status_code} - {res.reason}"
ret_json = res.json()
if ret_json and ret_json[0].get("message") == "Not Found":
return False, "插件在仓库中不存在"
# 本地存在时先删除
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
if plugin_dir.exists():
shutil.rmtree(plugin_dir)
# 下载所有文件
for item in ret_json:
if item.get("download_url"):
# 下载插件文件
res = RequestUtils(proxies=settings.PROXY).get_res(item["download_url"])
if not res or res.status_code != 200:
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - {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)
return True, ""

View File

@ -34,3 +34,9 @@ class Plugin(BaseModel):
state: Optional[bool] = False
# 是否有详情页面
has_page: Optional[bool] = False
# 是否有新版本
has_update: Optional[bool] = False
# 是否本地
is_local: Optional[bool] = False
# 仓库地址
repo_url: Optional[str] = None

View File

@ -688,3 +688,26 @@ class StringUtils:
break
return ''.join(common_prefix)
@staticmethod
def compare_version(v1: str, v2: str) -> int:
"""
比较两个版本号的大小v1 > v2时返回1v1 < v2时返回-1v1 = v2时返回0
"""
if not v1 or not v2:
return 0
v1 = v1.replace('v', '')
v2 = v2.replace('v', '')
v1 = [int(x) for x in v1.split('.')]
v2 = [int(x) for x in v2.split('.')]
for i in range(min(len(v1), len(v2))):
if v1[i] > v2[i]:
return 1
elif v1[i] < v2[i]:
return -1
if len(v1) > len(v2):
return 1
elif len(v1) < len(v2):
return -1
else:
return 0