# MoviePilot V2 插件开发指南 本文档说明如何开发适用于 MoviePilot V2 的插件,并尽量以当前 `MoviePilot` 与 `MoviePilot-Frontend` 主仓库的真实实现为准,而不是停留在早期兼容阶段的概念说明。 关联阅读: - [仓库指南](./Repository_Guide.md) - [FAQ 索引](./FAQ.md) - [MoviePilot 前端模块联邦开发指南](https://github.com/jxxghp/MoviePilot-Frontend/blob/v2/docs/module-federation-guide.md) ## 1. 先理解 V2 插件的运行模型 V2 插件始终运行在 `MoviePilot` 后端宿主内,当前插件仓库只提供: - 插件源码 - 插件市场索引 - 插件图标 - 插件文档 V2 插件的 UI 则有两种模式: - `vuetify`:插件返回 JSON 配置,由 `MoviePilot-Frontend` 负责渲染 - `vue`:插件提供联邦远程组件,由前端动态加载 因此,开发一个 V2 插件通常至少会涉及三个部分: 1. 本仓库中的插件实现与元数据 2. `MoviePilot` 中的插件宿主能力 3. `MoviePilot-Frontend` 中的渲染与加载逻辑 ## 2. V2 的版本选择规则 MoviePilot 处理插件版本时,当前逻辑可以总结为: 1. 宿主先根据当前版本标识优先读取 `package.v2.json` 2. 若目标插件不在 `package.v2.json` 中,再检查 `package.json` 3. `package.json` 中只有显式声明了 `"v2": true` 的插件,才会被视为 V2 兼容插件 建议按下列方式选型: - **V2 专用实现**:放在 `plugins.v2//`,元数据写入 `package.v2.json` - **单实现跨版本兼容**:代码继续放在 `plugins//`,在 `package.json` 中声明 `"v2": true` - **V1/V2 差异已经很大**:不要继续强行共用目录,直接拆到 `plugins.v2/` ## 3. 最小 V2 插件骨架 一个最小可运行的 V2 插件通常如下: ```text plugins.v2/ └── myplugin/ ├── __init__.py ├── requirements.txt # 可选,只有插件有额外依赖时才需要 └── README.md # 可选,插件自己的说明文档 ``` `__init__.py` 示例: ```python from typing import Any, Dict, List, Tuple from app.plugins import _PluginBase class MyPlugin(_PluginBase): # 插件在界面中的展示名称 plugin_name = "我的插件" # 插件描述 plugin_desc = "一个最小可运行的 V2 插件示例。" # 插件图标 plugin_icon = "Moviepilot_A.png" # 插件版本,必须和 package.v2.json 中保持一致 plugin_version = "1.0.0" # 作者信息 plugin_author = "your-name" author_url = "https://github.com/your-name" # 配置项前缀,建议保持唯一,避免与其他插件冲突 plugin_config_prefix = "myplugin_" # 插件加载顺序,数值越小越早 plugin_order = 50 # 插件可见权限级别 auth_level = 1 # 运行时状态字段 _enabled = False _message = "插件尚未初始化" def init_plugin(self, config: dict = None): """根据当前配置初始化插件。""" config = config or {} self._enabled = bool(config.get("enabled")) self._message = config.get("message") or "Hello MoviePilot" def get_state(self) -> bool: """返回插件当前是否启用。""" return self._enabled @staticmethod def get_command() -> List[Dict[str, Any]]: """没有远程命令时直接返回空列表。""" return [] def get_api(self) -> List[Dict[str, Any]]: """没有插件 API 时直接返回空列表。""" return [] def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: """返回配置页 JSON 和默认配置模型。""" return [ { "component": "VForm", "content": [ { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "enabled", "label": "启用插件", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 8}, "content": [ { "component": "VTextField", "props": { "model": "message", "label": "展示文本", }, } ], }, ], } ], } ], { "enabled": False, "message": "Hello MoviePilot", } def get_page(self) -> List[dict]: """返回详情页 JSON。""" return [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": self._message, }, } ] def stop_service(self): """没有后台任务时可以留空。""" pass ``` 对应的 `package.v2.json` 条目至少应包含: ```json { "MyPlugin": { "name": "我的插件", "description": "一个最小可运行的 V2 插件示例。", "labels": "示例", "version": "1.0.0", "icon": "Moviepilot_A.png", "author": "your-name", "system_version": ">=2.12.0", "level": 1 } } ``` `system_version` 是可选字段。插件依赖某个 MoviePilot 主系统版本才提供的能力时再声明,格式参考 pip 依赖版本范围;不声明时宿主不会做主系统版本检查。 ## 4. `_PluginBase` 的核心能力 V2 插件的核心宿主基类是 `MoviePilot/app/plugins/__init__.py` 中的 `_PluginBase`。开发时需要优先理解它暴露出来的扩展点。 ### 4.1 必选方法 以下方法通常必须实现: - `init_plugin(self, config: dict = None)`:读取配置并生效 - `get_state(self) -> bool`:返回当前运行状态 - `get_api(self) -> List[dict]`:声明插件 API - `get_form(self) -> Tuple[page_json, model]`:声明配置页 - `get_page(self) -> List[dict] | None`:声明详情页 - `stop_service(self)`:停用插件时清理后台任务、线程、调度器等 ### 4.2 常用可选方法 - `get_command()`:注册远程命令 - `get_service()`:注册公共服务 - `get_dashboard()`:声明仪表板内容 - `get_dashboard_meta()`:声明多仪表板元信息 - `get_render_mode()`:选择 `vuetify` / `vue` - `get_module()`:重载系统模块 - `get_actions()`:注册工作流动作 - `get_agent_tools()`:注册智能体工具 - `get_sidebar_nav()`:Vue 全页插件向主界面侧栏声明入口 ### 4.3 基类自带的辅助能力 基类已经提供了一些很关键的工具方法,通常不需要自行重复造轮子: - `get_config()` / `update_config()`:读取与保存插件配置 - `get_data_path()`:获取插件自己的数据目录 - `save_data()` / `get_data()` / `del_data()`:读写插件持久化数据 - `post_message()`:通过系统通知渠道发消息 - `self.chain`:插件链式能力入口 - `self.systemconfig` / `self.plugindata`:宿主已有的配置与数据操作封装 ## 5. 配置、数据与分身兼容 ### 5.1 配置读写 最常见的模式是: ```python def init_plugin(self, config: dict = None): config = config or {} self._enabled = bool(config.get("enabled")) def _save_current_config(self): # 这里保存的是插件自己的配置快照 self.update_config({ "enabled": self._enabled, }) ``` ### 5.2 数据保存 如果插件要保存运行结果、缓存文件或状态快照,优先使用基类提供的数据目录和数据表: ```python from pathlib import Path def write_report(self, content: str): # 每个插件都有自己的独立数据目录 report_path: Path = self.get_data_path() / "report.txt" report_path.write_text(content, encoding="utf-8") def save_runtime_state(self, state: dict): # 结构化小数据优先放 plugindata self.save_data("runtime_state", state) ``` ### 5.3 分身友好写法 MoviePilot 支持插件分身,因此建议遵守这些规则: - 不要把插件 ID 写死到字符串里到处拼接 - 优先使用 `self.__class__.__name__` - `plugin_config_prefix` 必须唯一 - 如果你需要通过宿主 API 反向查找自己,优先从当前类名或运行时实例出发 这样插件被分身后,配置前缀、类名替换和数据隔离更容易保持正确。 ## 6. V2 常见能力面 ### 6.1 远程命令 `get_command()` 用于注册 `/xx` 形式的远程命令。最常见的方式是: 1. `get_command()` 暴露命令元数据 2. 监听 `EventType.PluginAction` 3. 根据 `event_data["action"]` 判断是否是自己的动作 示例: ```python from typing import Any, Dict, List from app.core.event import eventmanager, Event from app.schemas.types import EventType @staticmethod def get_command() -> List[Dict[str, Any]]: return [ { "cmd": "/my_plugin_run", "event": EventType.PluginAction, "desc": "执行我的插件", "category": "插件命令", "data": { # 用 action 做路由最稳妥 "action": "my_plugin_run", }, } ] @eventmanager.register(EventType.PluginAction) def run_command(self, event: Event): event_data = event.event_data or {} if event_data.get("action") != "my_plugin_run": return # 这里写实际业务逻辑 ``` ### 6.2 插件 API `get_api()` 插件 API 会被动态注册到: ```text /api/v1/plugin// ``` 示例: ```python def get_api(self) -> List[Dict[str, Any]]: return [ { "path": "/history", "endpoint": self.get_history, "methods": ["GET"], # 前端插件页面通过 api 模块调用时,通常使用 bear "auth": "bear", "summary": "查询插件历史", "description": "返回插件最近的处理历史", } ] ``` 说明: - `auth` 支持 `apikey` 和 `bear` - 面向插件前端页面的接口,通常使用 `bear` - 面向外部系统调用的接口,可使用 `apikey` - 如无特殊原因,不要默认匿名开放 ### 6.3 公共服务 `get_service()` 服务注册后会出现在 MoviePilot 的服务管理中,适合定时任务、周期刷新、批处理工作。 示例: ```python from apscheduler.triggers.cron import CronTrigger def get_service(self) -> List[Dict[str, Any]]: if not self.get_state(): return [] return [ { "id": "MyPlugin.Refresh", "name": "我的插件定时刷新", "trigger": CronTrigger.from_crontab("0 */6 * * *"), "func": self.refresh, "kwargs": {}, } ] ``` 注意: - `id` 必须稳定且唯一 - 禁用插件时要在 `stop_service()` 中清理自己的后台资源 - 如果服务需要“启用后立刻跑一次”,可配合 `date` 触发器单独注册一条即时任务 ### 6.4 仪表板 `get_dashboard()` / `get_dashboard_meta()` 单仪表板插件可只实现 `get_dashboard()`;多仪表板插件建议额外实现 `get_dashboard_meta()`。 示例: ```python from typing import Any, Dict, List, Optional, Tuple def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]: return [ {"key": "summary", "name": "摘要"}, {"key": "trend", "name": "趋势"}, ] def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: col_config = {"cols": 12, "md": 6} global_config = { "title": "我的插件", "refresh": 30, "border": True, } page = [ { "component": "VAlert", "props": { "type": "info", "text": f"当前仪表板 key: {key}", }, } ] return col_config, global_config, page ``` ### 6.5 工作流动作 `get_actions()` 工作流动作适合把插件能力暴露给系统工作流调用。动作函数的第一个参数固定为 `ActionContent`,返回值需要遵循宿主约定。 ```python def get_actions(self) -> List[Dict[str, Any]]: return [ { "id": "my_plugin_action", "name": "执行我的插件动作", "func": self.run_action, "kwargs": { # 这里可以预置额外参数 "mode": "fast", }, } ] ``` ### 6.6 系统模块重载 `get_module()` 当插件要接管某个系统模块能力时,可通过 `get_module()` 映射方法实现。所有可重载的方法名,需要以 `MoviePilot/app/chain/` 中实际调用的模块名为准。 ```python def get_module(self) -> Dict[str, Any]: return { # 键名必须与宿主链式调用的模块名一致 "my_custom_handler": self.handle_custom_logic, } ``` 这种能力侵入性较强,只有在插件确实要扩展宿主链路时才建议使用。 ### 6.7 智能体工具 `get_agent_tools()` 插件可以为 MoviePilot 的 AI 智能体扩展工具。每个工具类必须继承 `MoviePilotTool`。 示例: ```python from typing import List, Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool class QueryInput(BaseModel): """工具入参模型。""" keyword: str = Field(..., description="要查询的关键字") class MyQueryTool(MoviePilotTool): """最小智能体工具示例。""" name: str = "my_query_tool" description: str = "Query plugin data by keyword." args_schema: Type[BaseModel] = QueryInput def get_tool_message(self, **kwargs) -> Optional[str]: # 这里返回给用户的提示语 return f"正在查询关键字:{kwargs.get('keyword', '')}" async def run(self, keyword: str, **kwargs) -> str: # 这里实现工具实际逻辑 return f"查询完成:{keyword}" def get_agent_tools(self) -> List[type]: return [MyQueryTool] ``` 如果需要真实案例,可以参考本仓库 `plugins.v2/lexiannot/agenttool.py`。 ## 7. 渲染模式 ### 7.1 Vuetify JSON 模式 这是默认模式,`get_render_mode()` 不需要额外实现,宿主默认按 `vuetify` 处理。 适用场景: - 普通配置表单 - 详情页 - 轻量数据列表 - 仪表板卡片 规则: - `get_form()` 返回“页面 JSON + 默认模型” - `get_page()` 返回页面 JSON - `get_dashboard()` 返回“列配置 + 全局配置 + 页面 JSON” - `props.model` 等效于 `v-model` - `props.show` 等效于 `v-show` - 配置页支持 `{{ ... }}` 表达式与 `onxxx` 事件 ### 7.2 Vue 联邦模式 如果插件要完全使用 Vue 组件渲染,需要实现: ```python from typing import Tuple def get_render_mode(self) -> Tuple[str, str]: # 第二个返回值是远程组件构建产物所在目录 return "vue", "dist/assets" ``` 此时: - `get_form()` / `get_page()` 可返回 `([], {})`、`[]` 或 `None` - 前端会通过 `/api/v1/plugin/remotes` 获取远程组件列表 - 静态资源由 `MoviePilot` 后端负责对外提供 若需要独立侧栏页面,还要实现 `get_sidebar_nav()`。 ### 7.3 侧栏全页入口 `get_sidebar_nav()` 只有 Vue 模式插件才会被主界面侧栏聚合。 示例: ```python from typing import Any, Dict, List def get_sidebar_nav(self) -> List[Dict[str, Any]]: return [ { "nav_key": "main", "title": "我的插件首页", "icon": "mdi-puzzle", "section": "system", "permission": "manage", "order": 10, }, { "nav_key": "settings", "title": "我的插件设置", "icon": "mdi-cog", "section": "system", "permission": "manage", "order": 11, }, ] ``` 当前宿主约束: - `section` 只接受:`start`、`discovery`、`subscribe`、`organize`、`system` - `permission` 只接受:`subscribe`、`discovery`、`search`、`manage`、`admin` - `nav_key` 不能包含 `/`、`?`、`#`、空格 多入口全页插件的联邦暴露名规则,详见前端仓库的模块联邦开发指南。 ## 8. 公共服务封装建议 V2 下很多插件都依赖下载器、媒体服务器、通知渠道等宿主服务。不要自行重复读取系统配置,优先使用宿主帮助类。 常见帮助类包括: - `DownloaderHelper` - `MediaServerHelper` - `NotificationHelper` 典型写法: ```python from app.helper.downloader import DownloaderHelper def run_with_downloader(self, name: str): # 通过帮助类获取已启用的下载器实例和配置 service = DownloaderHelper().get_service(name=name) if not service: return False downloader = service.instance # 这里调用实际下载器能力 return downloader is not None ``` 这样做的好处是: - 自动复用宿主对系统配置的解析 - 自动获取“启用中的实例” - 降低插件和底层模块的耦合 ## 9. 调试与校验 ### 9.1 Python 层 推荐最小校验: ```bash python3 -m py_compile plugins.v2/myplugin/__init__.py python3 -m compileall plugins.v2/myplugin git diff --check ``` ### 9.2 API 层 如果插件定义了 `get_api()`: - 启动宿主后检查 `/docs` - 确认路由实际注册在 `/api/v1/plugin//...` - 区分 `apikey` 与 `bear` 的认证方式是否符合调用场景 ### 9.3 前端层 如果插件使用 Vue 远程组件: - 在前端工程中先执行 `yarn typecheck` - 再执行 `yarn build` - 确认最终上传的是联邦所需产物,而不是整个前端源码目录 ## 10. 发布清单 发布前建议至少逐项确认: 1. 插件目录在 `plugins/` 或 `plugins.v2/` 下位置正确 2. 目录名与类名小写一致 3. 元数据已写入正确的索引文件 4. 索引里的 `version` 与代码里的 `plugin_version` 一致 5. `history` 已补齐本次变更说明 6. 若使用 Release 分发,条目已声明 `"release": true` 7. Python 代码完成最小语法校验 8. 若有 Vue 远程组件,构建产物已更新 ## 11. 什么时候还要回去看宿主源码 下面这些问题,不建议只看本仓库文档判断: - 插件为什么没有显示在插件市场 - 插件 API 为什么没有注册成功 - 服务为什么没有进入服务管理 - 插件仪表板为什么没有加载 - Vue 联邦页面为什么没有出现在侧栏 - 某个 `permission` / `section` / `nav_key` 为什么不生效 这类问题本质上都与宿主实现有关,应回到: - `MoviePilot/app/core/plugin.py` - `MoviePilot/app/api/endpoints/plugin.py` - `MoviePilot/app/plugins/__init__.py` - `MoviePilot-Frontend/docs/module-federation-guide.md` - `MoviePilot-Frontend/src/utils/federationLoader.ts` ## 12. 结论 开发 V2 插件时,最重要的不是“把代码放进 `plugins.v2/`”,而是同时把下面三件事做对: 1. 运行时契约对齐宿主 `_PluginBase` 2. 索引元数据与插件代码保持一致 3. 渲染模式与前端加载方式匹配 做到这三点,插件的开发、升级、迁移、分身和发布都会明显顺很多。