diff --git a/README.md b/README.md index 7befa63d..c8368845 100644 --- a/README.md +++ b/README.md @@ -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`设置项: diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 1676d4f5..c6334dc3 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -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) diff --git a/app/core/config.py b/app/core/config.py index 8a66765d..19275123 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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): diff --git a/app/core/plugin.py b/app/core/plugin.py index 6e3b9499..4932ce47 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -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 diff --git a/app/helper/plugin.py b/app/helper/plugin.py index ca1decef..250902eb 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -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, "" diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index a0f2ee0b..88d3e047 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -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 diff --git a/app/utils/string.py b/app/utils/string.py index 1425bdbc..c2a75067 100644 --- a/app/utils/string.py +++ b/app/utils/string.py @@ -688,3 +688,26 @@ class StringUtils: break return ''.join(common_prefix) + + @staticmethod + def compare_version(v1: str, v2: str) -> int: + """ + 比较两个版本号的大小,v1 > v2时返回1,v1 < v2时返回-1,v1 = 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