diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index c72ae1c3..e98f879f 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -1,6 +1,5 @@ -import inspect import mimetypes -from typing import Annotated, Any, List, Optional, Callable, Tuple +from typing import Annotated, Any, List, Optional from fastapi import APIRouter, Depends, Header, HTTPException from starlette import status @@ -10,7 +9,7 @@ from app import schemas from app.command import Command from app.core.config import settings from app.core.plugin import PluginManager -from app.core.security import verify_apikey, verify_token +from app.core.security import verify_apikey, verify_token, verify_apitoken from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_superuser from app.factory import app @@ -221,6 +220,16 @@ def install(plugin_id: str, return schemas.Response(success=True) +@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict]) +def remotes(token: str) -> Any: + """ + 获取插件联邦组件列表 + """ + if token != "moviepilot": + raise HTTPException(status_code=403, detail="Forbidden") + return PluginManager().get_plugin_remotes() + + @router.get("/form/{plugin_id}", summary="获取插件表单页面") def plugin_form(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict: @@ -232,29 +241,16 @@ def plugin_form(plugin_id: str, raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") # 渲染模式 - render_mode = plugin_instance.get_render_mode() - if render_mode == "vue": - # Vue模式 - try: - vue_component_file, model = plugin_instance.get_form_file() - return { - "render_mode": "vue", - "component_url": f"/plugin/file/{plugin_id.lower()}/{vue_component_file}", - "model": PluginManager().get_plugin_config(plugin_id) or model - } - except Exception as e: - logger.error(f"插件 {plugin_id} 调用方法 get_form_file 出错: {str(e)}") - else: - # Vuetify模式 - try: - conf, model = plugin_instance.get_form() - return { - "render_mode": "vuetify", - "conf": conf, - "model": PluginManager().get_plugin_config(plugin_id) or model - } - except Exception as e: - logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}") + render_mode, _ = plugin_instance.get_render_mode() + try: + conf, model = plugin_instance.get_form() + return { + "render_mode": render_mode, + "conf": conf, + "model": PluginManager().get_plugin_config(plugin_id) or model + } + except Exception as e: + logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}") return {} @@ -268,27 +264,15 @@ def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_ac raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") # 渲染模式 - render_mode = plugin_instance.get_render_mode() - if render_mode == "vue": - # Vue模式 - try: - vue_component_file = plugin_instance.get_page_file() - return { - "render_mode": "vue", - "component_url": f"/plugin/file/{plugin_id.lower()}/{vue_component_file}", - } - except Exception as e: - logger.error(f"插件 {plugin_id} 调用方法 get_page_file 出错: {str(e)}") - - else: - try: - page = plugin_instance.get_page() - return { - "render_mode": "vuetify", - "page": page or [] - } - except Exception as e: - logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}") + render_mode, _ = plugin_instance.get_render_mode() + try: + page = plugin_instance.get_page() + return { + "render_mode": render_mode, + "page": page or [] + } + except Exception as e: + logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}") return {} @@ -302,62 +286,11 @@ def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> Li @router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置") def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]: + _: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]: """ 根据插件ID获取插件仪表板 """ - - def __get_params_count(func: Callable): - """ - 获取函数的参数信息 - """ - signature = inspect.signature(func) - return len(signature.parameters) - - # 获取插件实例 - plugin_instance = PluginManager().running_plugins.get(plugin_id) - if not plugin_instance: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") - # 渲染模式 - render_mode = plugin_instance.get_render_mode() - if render_mode == "vue": - # Vue模式 - try: - cols, attrs, vue_component_file = plugin_instance.get_dashboard_file(key=key, user_agent=user_agent) - return schemas.PluginDashboard( - id=plugin_id, - name=plugin_instance.plugin_name, - key=key, - render_mode=render_mode, - cols=cols or {}, - attrs=attrs or {}, - component_url=f"/plugin/file/{plugin_id.lower()}/{vue_component_file}", - ) - except Exception as e: - logger.error(f"插件 {plugin_id} 调用方法 get_dashboard_file 出错: {str(e)}") - else: - try: - # 检查方法的参数个数 - params_count = __get_params_count(plugin_instance.get_dashboard) - if params_count > 1: - dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent) - elif params_count > 0: - dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent) - else: - dashboard: Tuple = plugin_instance.get_dashboard() - if dashboard: - cols, attrs, elements = dashboard - return schemas.PluginDashboard( - id=plugin_id, - name=plugin_instance.plugin_name, - key=key, - render_mode="vuetify", - cols=cols or {}, - elements=elements, - attrs=attrs or {} - ) - except Exception as e: - logger.error(f"插件 {plugin_id} 调用方法 get_dashboard 出错: {str(e)}") + return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent) @router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置") @@ -390,8 +323,8 @@ def reset_plugin(plugin_id: str, return schemas.Response(success=True) -@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件") -def plugin_static_file(plugin_id: str, filepath: str, _: schemas.TokenPayload = Depends(verify_token)): +@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件") +def plugin_static_file(plugin_id: str, filepath: str, _: Annotated[str, Depends(verify_apitoken)]): """ 获取插件静态文件 """ @@ -413,9 +346,9 @@ def plugin_static_file(plugin_id: str, filepath: str, _: schemas.TokenPayload = # 强制修正 .mjs 和 .js 的 MIME 类型 if suffix in ['.js', '.mjs']: response_type = 'application/javascript' - elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正 + elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正 response_type = 'text/css' - elif not response_type: # 对于其他猜不出的类型 + elif not response_type: # 对于其他猜不出的类型 response_type = 'application/octet-stream' try: diff --git a/app/core/plugin.py b/app/core/plugin.py index e9c291e5..9b490124 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -1,13 +1,16 @@ import concurrent import concurrent.futures import importlib.util +import inspect import os import time import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path -from typing import Any, Dict, List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union, Callable, Tuple +from fastapi import HTTPException +from starlette import status from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer @@ -526,7 +529,40 @@ class PluginManager(metaclass=Singleton): logger.error(f"获取插件 {plugin_id} 模块出错:{str(e)}") return ret_modules - def get_plugin_dashboard_meta(self): + @staticmethod + def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str: + """ + 获取插件的远程入口地址 + :param plugin_id: 插件 ID + :param dist_path: 插件的分发路径 + :return: 远程入口地址 + """ + if dist_path.startswith("/"): + dist_path = dist_path[1:] + if dist_path.endswith("/"): + dist_path = dist_path[:-1] + return f"/plugin/file/{plugin_id.lower()}/{dist_path}/remoteEntry.js?token={settings.API_TOKEN}" + + def get_plugin_remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: + """ + 获取插件联邦组件列表 + """ + remotes = [] + for plugin_id, plugin in self._running_plugins.items(): + if pid and pid != plugin_id: + continue + if hasattr(plugin, "get_render_mode"): + render_mode, dist_path = plugin.get_render_mode() + if render_mode != "vue": + continue + remotes.append({ + "id": plugin_id, + "url": self.get_plugin_remote_entry(plugin_id, dist_path), + "name": plugin.plugin_name, + }) + return remotes + + def get_plugin_dashboard_meta(self) -> List[Dict[str, str]]: """ 获取所有插件仪表盘元信息 """ @@ -556,6 +592,49 @@ class PluginManager(metaclass=Singleton): logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}") return dashboard_meta + def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard: + """ + 获取插件仪表盘 + """ + + def __get_params_count(func: Callable): + """ + 获取函数的参数信息 + """ + signature = inspect.signature(func) + return len(signature.parameters) + + # 获取插件实例 + plugin_instance = self.running_plugins.get(pid) + if not plugin_instance: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {pid} 不存在或未加载") + + # 渲染模式 + render_mode, _ = plugin_instance.get_render_mode() + # 获取插件仪表板 + try: + # 检查方法的参数个数 + params_count = __get_params_count(plugin_instance.get_dashboard) + if params_count > 1: + dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent) + elif params_count > 0: + dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent) + else: + dashboard: Tuple = plugin_instance.get_dashboard() + except Exception as e: + logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}") + cols, attrs, elements = dashboard + return schemas.PluginDashboard( + id=pid, + name=plugin_instance.plugin_name, + key=key, + render_mode=render_mode, + cols=cols or {}, + attrs=attrs or {}, + ) + def get_plugin_attr(self, pid: str, attr: str) -> Any: """ 获取插件属性 diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 4af2b7f9..c1ebe777 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -84,12 +84,12 @@ class _PluginBase(metaclass=ABCMeta): pass @staticmethod - def get_render_mode() -> str: + def get_render_mode() -> Tuple[str, Optional[str]]: """ 获取插件渲染模式 - :return: 渲染模式,支持:vue/vuetify,默认vuetify + :return: 1、渲染模式,支持:vue/vuetify,默认vuetify;2、vue模式下编译后文件的相对路径,默认为`dist`,vuetify模式下为None """ - return "vuetify" + return "vuetify", None @abstractmethod def get_api(self) -> List[Dict[str, Any]]: @@ -106,34 +106,19 @@ class _PluginBase(metaclass=ABCMeta): pass @abstractmethod - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + def get_form(self) -> Tuple[Optional[List[dict]], Dict[str, Any]]: """ 拼装插件配置页面,插件配置页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ - :return: 1、页面配置;2、默认数据结构 - """ - pass - - @staticmethod - def get_form_file() -> Tuple[str, Dict[str, Any]]: - """ - 获取插件配置页面JS代码源文件(与get_from二选一使用) - :return: 1、编译后的JS代码插件目录下相对路径;2、默认数据结构 + :return: 1、页面配置(vuetify模式)或 None(vue模式);2、默认数据结构 """ pass @abstractmethod - def get_page(self) -> List[dict]: + def get_page(self) -> Optional[List[dict]]: """ 拼装插件详情页面,需要返回页面配置,同时附带数据 插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ - """ - pass - - @staticmethod - def get_page_file() -> Optional[str]: - """ - 获取插件数据页面JS代码源文件(与get_page二选一使用) - :return: 编译后的JS代码插件目录下相对路径 + :return: 页面配置(vuetify模式)或 None(vue模式) """ pass @@ -150,9 +135,9 @@ class _PluginBase(metaclass=ABCMeta): """ pass - def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: + def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[dict]]]]: """ - 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(布局、自动刷新等);3、仪表板页面元素配置json(含数据) + 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(布局、自动刷新等);3、仪表板页面元素配置含数据json(vuetify)或 None(vue模式) 1、col配置参考: { "cols": 12, "md": 6 @@ -164,7 +149,7 @@ class _PluginBase(metaclass=ABCMeta): "title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称 "subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题 } - 3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + 3、vuetify模式页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/;vue模式为None kwargs参数可获取的值:1、user_agent:浏览器UA @@ -172,13 +157,6 @@ class _PluginBase(metaclass=ABCMeta): """ pass - def get_dashboard_file(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], str]]: - """ - 获取插件仪表盘页面JS代码源文件(与get_dashboard二选一使用) - :return: 1、全局配置(布局、自动刷新等);2、仪表板页面元素配置json(含数据);3、编译后的JS代码插件目录下相对路径 - """ - pass - def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]: """ 获取插件仪表盘元信息 diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index ed11216a..b333debd 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -67,5 +67,3 @@ class PluginDashboard(Plugin): cols: Optional[dict] = Field(default_factory=dict) # 页面元素 elements: Optional[List[dict]] = Field(default_factory=list) - # 页面地址 - component_url: Optional[str] = None