diff --git a/README.md b/README.md index 3f21254..73b1616 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,106 @@ # MoviePilot-Plugins -MoviePilot官方插件市场:https://github.com/jxxghp/MoviePilot-Plugins -## 目录 -- [第三方插件库开发说明](#第三方插件库开发说明) - - [1. 目录结构](#1-目录结构) - - [2. 插件图标](#2-插件图标) - - [3. 插件命名](#3-插件命名) - - [4. 依赖](#4-依赖) - - [5. 界面开发](#5-界面开发) -- [常见问题](#常见问题) - - [1. 如何扩展消息推送渠道?](#1-如何扩展消息推送渠道) - - [2. 如何在插件中实现远程命令响应?](#2-如何在插件中实现远程命令响应) - - [3. 如何在插件中对外暴露API?](#3-如何在插件中对外暴露api) - - [4. 如何在插件中注册公共定时服务?](#4-如何在插件中注册公共定时服务) - - [5. 如何通过插件增强MoviePilot的识别功能?](#5-如何通过插件增强moviepilot的识别功能) - - [6. 如何扩展内建索引器的索引站点?](#6-如何扩展内建索引器的索引站点) - - [7. 如何在插件中调用API接口?](#7-如何在插件中调用api接口) - - [8. 如何将插件内容显示到仪表板?](#8-如何将插件内容显示到仪表板) - - [9. 如何扩展探索功能的媒体数据源?](#9-如何扩展探索功能的媒体数据源) - - [10. 如何扩展推荐功能的媒体数据源?](#10-如何扩展推荐功能的媒体数据源) - - [11. 如何通过插件重载实现系统模块功能?](#11-如何通过插件重载实现系统模块功能) - - [12. 如何通过插件扩展支持的存储类型?](#12-如何通过插件扩展支持的存储类型) - - [13. 如何将插件功能集成到工作流?](#13-如何将插件功能集成到工作流) - - [14. 如何在插件中通过消息持续与用户交互?](#14-如何在插件中通过消息持续与用户交互) - - [15. 如何在插件中使用系统级统一缓存?](#15-如何在插件中使用系统级统一缓存) - - [16. 如何在插件中注册智能体工具?](#16-如何在插件中注册智能体工具) -- [版本发布](#版本发布) - - [1. 如何发布插件版本?](#1-如何发布插件版本) - - [2. 如何开发V2版本的插件以及实现插件多版本兼容?](#2-如何开发v2版本的插件以及实现插件多版本兼容) +MoviePilot 官方插件仓库,也是 MoviePilot 插件市场默认读取的插件索引与源码仓库: + + +这个仓库本身并不是独立运行时,插件真正的运行宿主在后端仓库 `MoviePilot`,插件 UI 的渲染宿主在前端仓库 `MoviePilot-Frontend`。因此,开发插件时需要同时理解这三个仓库的分工。 + +## 文档导航 + +- [仓库指南](./docs/Repository_Guide.md):先看这份,了解本仓库的目录、元数据、发布链路,以及和主仓库/前端仓库的边界。 +- [V2 插件开发指南](./docs/V2_Plugin_Development.md):开发或迁移 V2 插件时的主文档,覆盖生命周期、渲染模式、接口能力和校验建议。 +- [MoviePilot 前端模块联邦开发指南](https://github.com/jxxghp/MoviePilot-Frontend/blob/v2/docs/module-federation-guide.md):当插件需要使用 Vue 远程组件时必读。 +- [常见问题](#常见问题):这里保留了插件能力扩展的 FAQ 和代码片段,适合按场景查阅。 + +## 仓库定位 + +- `MoviePilot` 负责插件加载、事件分发、API 注册、公共服务、数据持久化和权限控制。 +- `MoviePilot-Frontend` 负责插件市场、插件配置/详情弹窗、仪表板渲染,以及 Vue 联邦远程组件的加载。 +- `MoviePilot-Plugins` 负责插件源码、插件市场索引、插件图标与插件开发文档。 + +如果你要判断某个问题该在哪个仓库处理,可以按下面这条经验规则: + +- 插件类、事件、链式扩展、服务、API、数据保存问题,先看 `MoviePilot`。 +- 插件页面渲染、模块联邦、侧栏全页入口、前端交互问题,先看 `MoviePilot-Frontend`。 +- 插件元数据、版本号、图标、插件市场展示、Release 打包问题,先看本仓库。 + +## 仓库结构 + +```text +MoviePilot-Plugins/ +├── plugins/ # 默认插件目录,通常也是兼容旧版本或通用版本的入口 +├── plugins.v2/ # V2 专用插件目录 +├── icons/ # 插件图标资源 +├── package.json # 默认插件索引;可通过 "v2": true 声明兼容 V2 +├── package.v2.json # V2 优先插件索引 +├── docs/ # 开发与维护文档 +└── .github/workflows/ # 发布工作流 +``` + +## 版本与加载规则 + +- MoviePilot 会优先读取 `package.v2.json` 中与当前版本标识匹配的插件定义。 +- 如果某个插件不在 `package.v2.json` 中,但其 `package.json` 条目声明了 `"v2": true`,则会作为“兼容 V2 的默认插件”继续显示和安装。 +- `package.v2.json` 中的插件代码通常放在 `plugins.v2//`;`package.json` 中的插件代码通常放在 `plugins//`。 +- 插件目录名必须是插件类名的小写形式,插件主类必须定义在对应目录的 `__init__.py` 中。 +- 插件市场里看到的版本、图标、作者、权限级别,都来自 `package.json` / `package.v2.json`;运行时真正生效的类属性来自插件代码中的 `plugin_*` 字段,两者必须保持同步。 ## 第三方插件库开发说明 -> 请不要开发用于破解MoviePilot用户认证、色情、赌博等违法违规内容的插件,共同维护健康的开发环境! +> 请不要开发用于破解 MoviePilot 用户认证、色情、赌博等违法违规内容的插件,共同维护健康的开发环境。 ### 1. 目录结构 -- 插件仓库需要保持与本项目一致的目录结构(建议fork后修改),仅支持Github仓库,`plugins`存放插件代码,一个插件一个子目录,**子目录名必须为插件类名的小写**,插件主类在`__init__.py`中编写。 -- `package.json`为插件仓库中所有插件概要信息,用于在MoviePilot的插件市场显示,其中版本号等需与插件代码保持一致,通过修改版本号可触发MoviePilot显示插件更新。 +- 插件仓库建议直接 fork 本项目并保持同样的目录布局,仅支持 GitHub 仓库。 +- `plugins` 和 `plugins.v2` 都是“一个插件一个目录”的结构,**目录名必须为插件类名的小写**,插件主类放在对应目录的 `__init__.py` 中。 +- `package.json` / `package.v2.json` 是插件市场的索引文件。MoviePilot 会按版本选择合适的索引读取插件信息,因此这两个文件中的元数据需要和插件代码保持一致。 +- 如果插件带有独立文档、示例或远程组件产物,建议放在插件目录下并在插件目录内提供 `README.md` 说明。 ### 2. 插件图标 -- 插件图标可复用官方插件库中`icons`下已有图标,否则需使用完整的http格式的url图片链接(包括package.json中的icon和插件代码中的plugin_icon)。 -- 插件的背景颜色会自动提取使用图标的主色调。 +- 优先复用官方插件库 `icons/` 下已有图标;如需自定义图标,也可以在元数据中使用完整的 HTTP 图片 URL。 +- `package.json` / `package.v2.json` 里的 `icon` 与插件类中的 `plugin_icon` 应保持一致。 +- 插件卡片背景色会自动提取图标主色调,因此图标尽量避免透明度过高或主体过小。 ### 3. 插件命名 -- 插件命名请勿与官方库插件中的插件冲突,否则会在MoviePilot版本升级时被官方插件覆盖。 +- 插件 ID 以插件类名为准,例如 `class MyPlugin(_PluginBase)` 对应目录名 `myplugin`、插件 ID `MyPlugin`。 +- 插件命名请勿与官方库中的现有插件冲突,否则在用户升级 MoviePilot 或同步插件市场时,可能被官方同名插件覆盖。 +- 如果插件未来需要支持“插件分身”,请不要在代码中硬编码原始插件 ID,尽量使用 `self.__class__.__name__` 作为配置和数据命名空间。 ### 4. 依赖 -- 可在插件目录中放置`requirements.txt`文件,用于指定插件依赖的第三方库,MoviePilot会在插件安装时自动安装依赖库。 +- 可在插件目录中放置 `requirements.txt` 文件声明额外依赖,MoviePilot 安装插件时会自动安装。 +- 依赖尽量保持最小化,优先复用主程序已提供的公共能力,例如下载器、媒体服务器、通知渠道、缓存、链式处理等封装。 +- 如果插件还依赖 Vue 远程组件,请将前端依赖放在独立的前端工程中构建后再产出到插件目录,不要把前端源码直接混入主插件包。 ### 5. 界面开发 -- 插件支持`插件配置`、`详情展示`、`仪表板Widget`三个展示页面,支持两种方式开发: - 1. 通过配置化的方式组装,使用 [Vuetify](https://vuetifyjs.com/) 组件库,所有该组件库有的组件都可以通过Json配置使用,详情参考已有插件。 - - `props`中`model`属性等效于v-model,`show`等效于v-show,其它属于会直接绑定到元素上。 - - 插件配置页面props属性支持表达式,使用`{{}}`概起来;支持事件,以`on`开头,比如:onclick。 - - 详情展示页面支持API调用,在`events`属性中定义。 - 2. 通过Vue编写联邦远程组件,参考:[模块联邦开发指南](https://github.com/jxxghp/MoviePilot-Frontend/blob/v2/docs/module-federation-guide.md) +- 插件支持 `插件配置`、`详情展示`、`仪表板 Widget` 三类界面,V2 下还可以通过 Vue 联邦远程组件扩展侧栏全页入口。 +- 推荐先判断你的界面属于哪一类: + 1. 纯配置表单、简单详情展示、轻量数据表,优先使用 Vuetify JSON 配置方式。 + 2. 交互复杂、状态较多、需要独立全页或自定义布局时,使用 Vue 联邦远程组件。 +- Vuetify JSON 模式说明: + - `props.model` 等效于 `v-model`,`props.show` 等效于 `v-show`。 + - 插件配置页面的 `props` 支持表达式,使用 `{{ ... }}` 包裹。 + - 事件以 `on` 开头,例如 `onclick`、`onchange`。 + - 详情页面和仪表板可通过 `events` 发起 API 调用。 +- Vue 联邦模式说明: + - 插件后端需要实现 `get_render_mode()` 并返回 `("vue", "dist/assets")`。 + - 如果需要在主界面左侧导航新增入口,还需要实现 `get_sidebar_nav()`。 + - 远程组件的构建、暴露名约定、侧栏多入口、静态资源打包方式,请参考 [模块联邦开发指南](https://github.com/jxxghp/MoviePilot-Frontend/blob/v2/docs/module-federation-guide.md)。 + +### 6. 开发与校验建议 +- 这个仓库只提供插件源码与索引,不提供完整宿主环境。开发后应至少在 `MoviePilot` 宿主里完成一次真实加载验证。 +- 对 Python 插件代码,建议在宿主仓库环境中执行最小校验,例如: + - `python3 -m py_compile ` + - `python3 -m compileall ` + - `git diff --check` +- 如果插件带有 Vue 远程组件,建议在对应前端工程中执行: + - `yarn typecheck` + - `yarn build` +- 如果插件接口依赖 MoviePilot 新增的后端能力或前端入口,请同步更新对应主仓库文档,避免文档和运行时行为脱节。 + +### 7. 元数据同步要求 +- `package.json` / `package.v2.json` 中的 `version` 必须与插件类中的 `plugin_version` 保持一致,否则用户会看到错误的升级提示。 +- `name`、`description`、`icon`、`author`、`level` 建议与插件类属性保持一致,避免插件市场展示与实际运行信息不一致。 +- `history` 用于展示插件更新日志,建议每次发布都补齐一条可读变更说明。 +- 需要走 GitHub Release 压缩包分发的插件,请在对应索引条目中增加 `"release": true`,并确保仓库中的发布工作流能够定位到对应目录。 ## 常见问题 @@ -1458,9 +1505,14 @@ def get_actions(self) -> List[Dict[str, Any]]: ## 版本发布 ### 1. 如何发布插件版本? -- 修改插件代码后,需要修改`package.json`中的`version`版本号,MoviePilot才会提示用户有更新,注意版本号需要与`__init__.py`文件中的`plugin_version`保持一致。 -- `package.json`中的`level`用于定义插件用户可见权限,`1`为所有用户可见,`2`为仅认证用户可见,`3`为需要密钥才可见(一般用于测试)。如果插件功能需要使用到站点则应该为2,否则即使插件对用户可见但因为用户未认证相关功能也无法正常使用。 -- `package.json`中的`history`用于记录插件更新日志,格式如下: +- 修改插件代码后,需要同步更新对应索引文件中的 `version`,MoviePilot 才会提示用户有更新。这里的版本号需要与插件类中的 `plugin_version` 保持一致。 +- 默认插件改 `package.json`,V2 专用插件改 `package.v2.json`;如果一个插件同时在两个索引文件中维护,需要分别确认目标版本与兼容策略。 +- 索引中的 `level` 用于定义插件用户可见权限: + - `1`:所有用户可见 + - `2`:站点认证用户可见 + - `3`:站点与密钥认证后可见 + - 插件类中的 `auth_level` 还可以使用更高的运行时限制,例如特殊密钥场景 +- `history` 用于展示插件更新日志,建议每次发布都补齐一条可读的变更说明,格式如下: ```json { "history": { @@ -1469,8 +1521,16 @@ def get_actions(self) -> List[Dict[str, Any]]: } } ``` -- 新增加的插件请配置在`package.json`中的末尾,这样可被识别为最新增加,可用于用户排序。 -- 默认通过遍历下载项目文件的方式安装插件,如果插件文件较多,可以使用release的方式发布插件,package对应的插件描述中增加`release`字段并设置为`true`,此时插件安装时会直接下载tag为`插件ID_v插件版本号`的release包,release打包脚本参照插件仓库中的`release.yml`脚本。 +- 新增加的插件建议追加在索引文件末尾,便于在插件市场中作为较新的条目出现。 +- 如果插件目录文件较多,或你希望用户直接下载压缩包安装,可以在对应索引条目中增加 `"release": true`。 +- 当前仓库的 GitHub Actions 发布工作流只会在 `package.json` 或 `package.v2.json` 发生变更时触发,并且只处理声明了 `"release": true` 的插件。 +- 发布工作流会按下面的规则打包与创建 Release: + - 插件目录优先在 `plugins/` 和 `plugins.v2/` 中查找 + - Tag 格式为 `插件ID_v插件版本号` + - 资产文件名格式为 `插件目录小写_v插件版本号.zip` + - 如果自上一个同插件 Tag 以来目录没有变化,则会跳过打包 + - 如果同名 Tag / Release 已存在,工作流会先删除旧版本再创建新版本 +- 示例: ```json { "release": true @@ -1479,4 +1539,5 @@ def get_actions(self) -> List[Dict[str, Any]]: ### 2. 如何开发V2版本的插件以及实现插件多版本兼容? -- 请参阅 [V2版本插件开发指南](./docs/V2_Plugin_Development.md) +- 请参阅 [V2 版本插件开发指南](./docs/V2_Plugin_Development.md)。 +- 如果你要先理解本仓库与 `MoviePilot` / `MoviePilot-Frontend` 的分工,以及元数据和发布链路,再开始写代码,建议先看 [仓库指南](./docs/Repository_Guide.md)。 diff --git a/docs/Repository_Guide.md b/docs/Repository_Guide.md new file mode 100644 index 0000000..73d67b8 --- /dev/null +++ b/docs/Repository_Guide.md @@ -0,0 +1,263 @@ +# MoviePilot-Plugins 仓库指南 + +本文档面向维护者和插件开发者,说明 `MoviePilot-Plugins` 在整个 MoviePilot 体系中的职责、目录约定、元数据规则、发布流程,以及与 `MoviePilot` / `MoviePilot-Frontend` 两个主仓库的边界。 + +## 1. 仓库职责 + +`MoviePilot-Plugins` 不是独立运行时,而是插件市场和插件源码仓库。 + +- `MoviePilot` 后端仓库负责: + - 插件类加载与生命周期管理 + - 事件与链式扩展 + - 插件 API / 服务 / 仪表板注册 + - 配置、插件数据、权限控制 + - 插件安装、升级、分身、远程组件静态资源服务 +- `MoviePilot-Frontend` 前端仓库负责: + - 插件市场与插件卡片展示 + - 插件配置页、详情页、仪表板渲染 + - Vue 联邦远程组件加载 + - 插件侧栏全页入口 +- `MoviePilot-Plugins` 负责: + - 插件源码目录 + - 插件市场索引文件 + - 插件图标资源 + - 插件开发与维护文档 + +因此,开发插件时要避免把“宿主逻辑”误写进本仓库文档。例如: + +- 某个 `get_api()` 为什么没有被挂载,应该先看 `MoviePilot/app/api/endpoints/plugin.py` +- 某个 Vue 远程页面为什么没有出现在侧栏,应该先看 `MoviePilot-Frontend` 的联邦加载与菜单逻辑 +- 某个插件为什么在插件市场里没显示,才应该先看本仓库的 `package.json` / `package.v2.json` + +## 2. 目录结构 + +本仓库当前采用如下结构: + +```text +MoviePilot-Plugins/ +├── plugins/ # 默认插件目录 +├── plugins.v2/ # V2 专用插件目录 +├── icons/ # 插件图标 +├── docs/ # 文档 +├── package.json # 默认插件索引 +├── package.v2.json # V2 优先插件索引 +└── .github/workflows/ # 自动发布工作流 +``` + +关键约定: + +- 一个插件一个目录。 +- 目录名必须是插件类名的小写,例如 `class AutoSignIn` 对应目录 `autosignin/`。 +- 插件主类必须定义在该目录的 `__init__.py` 中。 +- 插件目录内可附带: + - `requirements.txt`:额外 Python 依赖 + - `README.md`:插件专属使用说明 + - `dist/assets/`:Vue 联邦构建产物 + - 其他运行时所需静态文件 + +## 3. 元数据文件说明 + +### 3.1 `package.json` + +默认插件索引文件,用于: + +- 旧版兼容或默认版本插件 +- 对 V2 兼容但不需要单独维护代码目录的插件 + +如果某个默认插件也能用于 V2,需要在条目上声明: + +```json +{ + "MyPlugin": { + "version": "1.2.3", + "v2": true + } +} +``` + +### 3.2 `package.v2.json` + +V2 优先插件索引文件。MoviePilot 在 V2 环境下会优先读取这里的条目;找不到时,才会回退到 `package.json` 中声明了 `"v2": true` 的兼容插件。 + +### 3.3 常用字段 + +每个索引条目通常包含: + +- `name`:插件展示名 +- `description`:插件简介 +- `labels`:标签,多个标签使用英文逗号分隔 +- `version`:插件版本 +- `icon`:图标文件名或完整 HTTP URL +- `author`:作者 +- `level`:用户可见级别 +- `history`:更新日志 +- `release`:是否使用 GitHub Release 压缩包发布 +- `v2`:默认索引中的插件是否兼容 V2 + +这些字段是“插件市场展示元数据”,而不是运行时唯一真相。真正加载后的插件类仍然需要在代码里声明自己的 `plugin_name`、`plugin_desc`、`plugin_version` 等属性。两者必须同步。 + +## 4. 版本选择与加载规则 + +MoviePilot 当前的插件版本选择逻辑可以概括为: + +1. 先确定当前宿主版本标识,例如 `v2` +2. 优先检查 `package.v2.json` 中是否存在该插件 +3. 若不存在,再检查 `package.json` +4. 只有当 `package.json` 中对应条目显式声明 `"v2": true` 时,才会作为 V2 兼容插件继续使用 + +这意味着: + +- 同一个插件若在 `package.v2.json` 中已有专用实现,就不要再依赖 `package.json` 中的兼容声明做“隐式覆盖”。 +- 新写的 V2 专用插件,优先放 `plugins.v2/`,并把元数据写入 `package.v2.json`。 +- 真正跨版本共用一套实现时,再使用 `package.json + "v2": true` 的方式。 + +## 5. 与宿主仓库的协作边界 + +### 5.1 与 `MoviePilot` 后端的边界 + +本仓库只保存插件实现,不应复制宿主的公共能力。插件应优先复用后端仓库已经提供的抽象,例如: + +- `_PluginBase` +- `eventmanager` +- `DownloaderHelper` / `MediaServerHelper` / `NotificationHelper` +- `save_data()` / `get_data()` / `get_data_path()` +- 插件 API 动态注册 +- 插件仪表板、服务、工作流动作、智能体工具扩展点 + +如果插件需要新增宿主接口,例如: + +- 新的链式事件 +- 新的插件 API 渲染能力 +- 新的工作流动作契约 +- 新的智能体工具注入点 + +应先在 `MoviePilot` 中补齐宿主能力,再回到本仓库落插件实现。 + +### 5.2 与 `MoviePilot-Frontend` 的边界 + +插件有两种主要 UI 方式: + +- Vuetify JSON 配置 +- Vue 联邦远程组件 + +前者的宿主渲染在 `MoviePilot-Frontend` 已经实现,插件只需要返回 JSON 结构;后者需要遵守前端仓库的联邦组件暴露规范、共享依赖规范和侧栏入口规范。 + +如果你在本仓库写了 Vue 模式插件,需要同时关注: + +- `MoviePilot-Frontend/docs/module-federation-guide.md` +- `MoviePilot-Frontend/src/utils/federationLoader.ts` +- `MoviePilot-Frontend` 中与插件页面、侧栏导航、仪表板相关的组件 + +## 6. 开发一个插件时的推荐流程 + +### 6.1 先判断插件形态 + +- 只是扩展后端能力、配置项简单:优先写 Vuetify JSON 模式插件 +- 需要复杂交互或完整页面:使用 Vue 联邦模式 +- 只是给现有插件补 V2 兼容:优先评估能否复用 `package.json + "v2": true` +- 已经与 V1 / 默认版本差异很大:直接转为 `plugins.v2/ + package.v2.json` + +### 6.2 再落目录与元数据 + +最小步骤通常是: + +1. 在 `plugins/` 或 `plugins.v2/` 下新建目录 +2. 在 `__init__.py` 中实现插件类 +3. 如有依赖,增加 `requirements.txt` +4. 在 `package.json` 或 `package.v2.json` 中补齐元数据 +5. 如有插件文档,在插件目录补充 `README.md` +6. 如有 Vue UI,构建后把产物放进 `dist/assets/` + +### 6.3 维护版本一致性 + +发布前至少核对以下三处是否一致: + +- 索引里的 `version` +- 插件类里的 `plugin_version` +- `history` 中最新一条变更说明 + +## 7. 校验建议 + +这个仓库没有独立的完整测试宿主,因此校验应该尽量贴近真实运行层。 + +### 7.1 Python 插件代码 + +建议在宿主环境里做最小校验: + +```bash +# 对修改过的插件文件做语法检查 +python3 -m py_compile plugins.v2/myplugin/__init__.py + +# 或者对整个插件目录做批量编译检查 +python3 -m compileall plugins.v2/myplugin + +# 顺手检查 diff 中是否有空白符问题 +git diff --check +``` + +### 7.2 Vue 远程组件 + +如果插件使用独立的前端工程,建议至少执行: + +```bash +# 类型检查 +yarn typecheck + +# 构建联邦产物 +yarn build +``` + +然后再把构建产物拷贝到插件目录中的 `dist/assets/`。 + +### 7.3 宿主联调 + +以下场景必须回到宿主仓库验证: + +- `get_api()` 是否真正注册成功 +- `get_service()` 是否出现在服务列表 +- `get_dashboard()` / `get_dashboard_meta()` 是否正常显示 +- `get_render_mode() == "vue"` 的远程组件是否能成功加载 +- `get_sidebar_nav()` 是否正确出现在前端侧栏 + +## 8. 发布流程 + +本仓库的自动发布逻辑位于 `.github/workflows/release.yml`,当前规则如下: + +- 只有当 `package.json` 或 `package.v2.json` 发生变更时,工作流才会触发 +- 只有索引条目中声明了 `"release": true` 的插件会参与自动打包 +- 工作流会尝试在 `plugins/` 和 `plugins.v2/` 中寻找插件目录 +- Release Tag 格式为 `插件ID_v插件版本号` +- 压缩包文件名格式为 `插件目录小写_v插件版本号.zip` +- 若插件目录自上一个 Tag 以来没有变化,则会跳过打包 +- 若同名 Release / Tag 已存在,工作流会先删除旧对象再重新创建 + +这意味着发布一个可下载压缩包的插件时,最少要确认: + +1. 插件目录存在且名称正确 +2. 索引条目中已声明 `"release": true` +3. 索引版本号与代码版本号一致 +4. 目标目录自上一个同插件 Tag 以来确实有代码变化 + +## 9. 文档维护建议 + +如果一次改动同时涉及: + +- 插件能力扩展点变更 +- 宿主后端新增接口或新契约 +- 前端新增加载规则或侧栏行为 + +应同步更新对应仓库文档,不要只改本仓库 README。 + +推荐文档分工: + +- 本仓库 `README.md`:总览与 FAQ +- 本仓库 `docs/Repository_Guide.md`:仓库维护与发布规则 +- 本仓库 `docs/V2_Plugin_Development.md`:V2 插件开发主文档 +- 前端仓库 `docs/module-federation-guide.md`:Vue 联邦远程组件开发规范 + +## 10. 开始之前先读哪一份 + +- 想知道“这个仓库该怎么维护、改哪个文件、怎么发布”:看本文档 +- 想直接开发一个 V2 插件:看 `docs/V2_Plugin_Development.md` +- 想做 Vue 远程组件或侧栏全页:看前端仓库模块联邦文档 +- 想按功能场景抄现成模式:回到根目录 `README.md` 的 FAQ diff --git a/docs/V2_Plugin_Development.md b/docs/V2_Plugin_Development.md index 05c11de..81fd1cb 100644 --- a/docs/V2_Plugin_Development.md +++ b/docs/V2_Plugin_Development.md @@ -1,573 +1,669 @@ -# MoviePilot V2 插件开发指南(更新版) +# MoviePilot V2 插件开发指南 -本指南详细介绍了如何开发适用于 MoviePilot V2 版本的插件,并实现插件的多版本兼容性,同时包括了服务封装类的使用示例,帮助开发者快速升级插件至 V2 版本。 +本文档说明如何开发适用于 MoviePilot V2 的插件,并尽量以当前 `MoviePilot` 与 `MoviePilot-Frontend` 主仓库的真实实现为准,而不是停留在早期兼容阶段的概念说明。 -## 1. 多版本插件开发与兼容性 +关联阅读: -### 1.1 开发 V2 版本的插件 +- [仓库指南](./Repository_Guide.md) +- [根 README 中的 FAQ](../README.md) +- [MoviePilot 前端模块联邦开发指南](https://github.com/jxxghp/MoviePilot-Frontend/blob/v2/docs/module-federation-guide.md) -要开发适用于 MoviePilot V2 版本的插件,请按照以下步骤操作: +## 1. 先理解 V2 插件的运行模型 -1. **目录结构调整**: - - 将插件代码放置在 `plugins.v2` 文件夹中。 - - 将插件的定义放置在 `package.v2.json` 中,以实现该插件仅在 MoviePilot V2 版本中可见。 +V2 插件始终运行在 `MoviePilot` 后端宿主内,当前插件仓库只提供: -2. **插件定义示例**: +- 插件源码 +- 插件市场索引 +- 插件图标 +- 插件文档 - ```json - { - "CustomSites": { - "name": "自定义站点", - "description": "增加自定义站点为签到和统计使用。", - "labels": "站点", - "version": "1.0", - "icon": "world.png", - "author": "lightolly", - "level": 2 - } - } - ``` +V2 插件的 UI 则有两种模式: -### 1.2 实现插件多版本兼容 +- `vuetify`:插件返回 JSON 配置,由 `MoviePilot-Frontend` 负责渲染 +- `vue`:插件提供联邦远程组件,由前端动态加载 -如果 V1 版本插件在 V2 版本中实际可用,或在插件中主动兼容了 V1 和 V2 版本,则可以在 `package.json` 中定义 `"v2": true` 属性,以便在 MoviePilot V2 版本插件市场中显示。 +因此,开发一个 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 { - "CustomSites": { - "name": "自定义站点", - "description": "增加自定义站点为签到和统计使用。", - "labels": "站点", - "version": "1.0", - "icon": "world.png", - "author": "lightolly", - "level": 2, - "v2": true + "MyPlugin": { + "name": "我的插件", + "description": "一个最小可运行的 V2 插件示例。", + "labels": "示例", + "version": "1.0.0", + "icon": "Moviepilot_A.png", + "author": "your-name", + "level": 1 } } ``` -- **目录结构示例**: +## 4. `_PluginBase` 的核心能力 - ``` - plugins/ - ├── customsites/ - │ ├── __init__.py - │ └── ... - plugins.v2/ - ├── customsites/ - │ ├── __init__.py - │ └── ... - package.json - package.v2.json - ``` +V2 插件的核心宿主基类是 `MoviePilot/app/plugins/__init__.py` 中的 `_PluginBase`。开发时需要优先理解它暴露出来的扩展点。 -- **插件代码中实现版本兼容**: +### 4.1 必选方法 - 在插件代码中,可以根据 `version` 变量执行不同的逻辑,以适应不同的 MoviePilot 版本。 +以下方法通常必须实现: - ```python - from app.core.config import settings +- `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)`:停用插件时清理后台任务、线程、调度器等 - class MyPlugin: - def init_plugin(self, config: dict = None): - if hasattr(settings, 'VERSION_FLAG'): - version = settings.VERSION_FLAG # V2 - else: - version = "v1" +### 4.2 常用可选方法 - if version == "v2": - self.setup_v2() - else: - self.setup_v1() +- `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 全页插件向主界面侧栏声明入口 - def setup_v2(self): - # V2版本特有的初始化逻辑 - pass +### 4.3 基类自带的辅助能力 - def setup_v1(self): - # V1版本特有的初始化逻辑 - pass - ``` +基类已经提供了一些很关键的工具方法,通常不需要自行重复造轮子: -## 2. 服务封装与使用示例 +- `get_config()` / `update_config()`:读取与保存插件配置 +- `get_data_path()`:获取插件自己的数据目录 +- `save_data()` / `get_data()` / `del_data()`:读写插件持久化数据 +- `post_message()`:通过系统通知渠道发消息 +- `self.chain`:插件链式能力入口 +- `self.systemconfig` / `self.plugindata`:宿主已有的配置与数据操作封装 -为了插件调用并共享实例,主程序针对几种服务进行了封装。以下是相关实现及如何在插件中使用这些封装的详细说明,帮助开发者快速将插件从 V1 升级到 V2。 +## 5. 配置、数据与分身兼容 -### 2.1 服务封装类介绍 +### 5.1 配置读写 -#### `ServiceInfo` -`ServiceInfo` 是一个数据类,用于封装服务的相关信息。 +最常见的模式是: ```python -from dataclasses import dataclass -from typing import Optional, Any +def init_plugin(self, config: dict = None): + config = config or {} + self._enabled = bool(config.get("enabled")) -@dataclass -class ServiceInfo: - """ - 封装服务相关信息的数据类 - """ - # 名称 - name: Optional[str] = None - # 实例 - instance: Optional[Any] = None - # 模块 - module: Optional[Any] = None - # 类型 - type: Optional[str] = None - # 配置 - config: Optional[Any] = None + +def _save_current_config(self): + # 这里保存的是插件自己的配置快照 + self.update_config({ + "enabled": self._enabled, + }) ``` -#### `ServiceConfigHelper` -`ServiceConfigHelper` 是一个配置帮助类,用于获取不同类型的服务配置。 +### 5.2 数据保存 + +如果插件要保存运行结果、缓存文件或状态快照,优先使用基类提供的数据目录和数据表: ```python -from typing import List, Optional +from pathlib import Path -from app.db.systemconfig_oper import SystemConfigOper -from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf -class ServiceConfigHelper: - """ - 配置帮助类,获取不同类型的服务配置 - """ +def write_report(self, content: str): + # 每个插件都有自己的独立数据目录 + report_path: Path = self.get_data_path() / "report.txt" + report_path.write_text(content, encoding="utf-8") - @staticmethod - def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List: - """ - 通用获取配置的方法,根据 config_key 获取相应的配置并返回指定类型的配置列表 - :param config_key: 系统配置的 key - :param conf_type: 用于实例化配置对象的类类型 - :return: 配置对象列表 - """ - config_data = SystemConfigOper().get(config_key) - if not config_data: - return [] - # 直接使用 conf_type 来实例化配置对象 - return [conf_type(**conf) for conf in config_data] - - @staticmethod - def get_downloader_configs() -> List[DownloaderConf]: - """ - 获取下载器的配置 - """ - return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf) - - @staticmethod - def get_mediaserver_configs() -> List[MediaServerConf]: - """ - 获取媒体服务器的配置 - """ - return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf) - - @staticmethod - def get_notification_configs() -> List[NotificationConf]: - """ - 获取消息通知渠道的配置 - """ - return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf) - - @staticmethod - def get_notification_switches() -> List[NotificationSwitchConf]: - """ - 获取消息通知场景的开关 - """ - return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf) - - @staticmethod - def get_notification_switch(mtype: NotificationType) -> Optional[str]: - """ - 获取指定类型的消息通知场景的开关 - """ - switchs = ServiceConfigHelper.get_notification_switches() - for switch in switchs: - if switch.type == mtype.value: - return switch.action - return None +def save_runtime_state(self, state: dict): + # 结构化小数据优先放 plugindata + self.save_data("runtime_state", state) ``` -#### `ServiceBaseHelper` -`ServiceBaseHelper` 是一个通用的服务帮助类,提供了获取配置和服务实例的通用逻辑。 +### 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 Dict, List, Optional, Type, TypeVar, Generic, Iterator +from typing import Any, Dict, List -from app.core.module import ModuleManager -from app.schemas import ServiceInfo -from app.schemas.types import SystemConfigKey, ModuleType +from app.core.event import eventmanager, Event +from app.schemas.types import EventType -TConf = TypeVar("TConf") -class ServiceBaseHelper(Generic[TConf]): - """ - 通用服务帮助类,抽象获取配置和服务实例的通用逻辑 - """ - - def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], module_type: ModuleType): - self.modulemanager = ModuleManager() - self.config_key = config_key - self.conf_type = conf_type - self.module_type = module_type - - def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]: - """ - 获取配置列表 - - :param include_disabled: 是否包含禁用的配置,默认 False(仅返回启用的配置) - :return: 配置字典 - """ - configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type) - return { - config.name: config - for config in configs - if (config.name and config.type and config.enabled) or include_disabled - } if configs else {} - - def get_config(self, name: str) -> Optional[TConf]: - """ - 获取指定名称配置 - """ - if not name: - return None - configs = self.get_configs() - return configs.get(name) - - def iterate_module_instances(self) -> Iterator[ServiceInfo]: - """ - 迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例 - """ - configs = self.get_configs() - modules = self.modulemanager.get_running_type_modules(self.module_type) - for module in modules: - if not module: - continue - module_instances = module.get_instances() - if not isinstance(module_instances, dict): - continue - for name, instance in module_instances.items(): - if not instance: - continue - config = configs.get(name) - service_info = ServiceInfo( - name=name, - instance=instance, - module=module, - type=config.type if config else None, - config=config - ) - yield service_info - - def get_services(self, type_filter: Optional[str] = None, name_filters: Optional[List[str]] = None) \ - -> Dict[str, ServiceInfo]: - """ - 获取服务信息列表,并根据类型和名称列表进行过滤 - - :param type_filter: 需要过滤的服务类型 - :param name_filters: 需要过滤的服务名称列表 - :return: 过滤后的服务信息字典 - """ - name_filters_set = set(name_filters) if name_filters else None - - return { - service_info.name: service_info - for service_info in self.iterate_module_instances() - if service_info.config and ( - type_filter is None or service_info.type == type_filter - ) and ( - name_filters_set is None or service_info.name in name_filters_set) +@staticmethod +def get_command() -> List[Dict[str, Any]]: + return [ + { + "cmd": "/my_plugin_run", + "event": EventType.PluginAction, + "desc": "执行我的插件", + "category": "插件命令", + "data": { + # 用 action 做路由最稳妥 + "action": "my_plugin_run", + }, } + ] - def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]: - """ - 获取指定名称的服务信息,并根据类型过滤 - :param name: 服务名称 - :param type_filter: 需要过滤的服务类型 - :return: 对应的服务信息,若不存在或类型不匹配则返回 None - """ - if not name: - return None - for service_info in self.iterate_module_instances(): - if service_info.name == name: - if service_info.config and (type_filter is None or service_info.type == type_filter): - return service_info - return None +@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 + # 这里写实际业务逻辑 ``` -### 2.2 特定服务的帮助类 +### 6.2 插件 API `get_api()` -以下是针对不同服务类型的帮助类,这些类继承自 `ServiceBaseHelper`,并预设了特定的配置。同时,为了简化类型检查,新增了相应的方法来判断服务类型。 +插件 API 会被动态注册到: -#### `DownloaderHelper` -用于管理下载器服务。 +```text +/api/v1/plugin// +``` + +示例: ```python -from typing import Optional - -from app.helper.service import ServiceBaseHelper -from app.schemas import DownloaderConf, ServiceInfo -from app.schemas.types import SystemConfigKey, ModuleType - - -class DownloaderHelper(ServiceBaseHelper[DownloaderConf]): - """ - 下载器帮助类 - """ - - def __init__(self): - super().__init__( - config_key=SystemConfigKey.Downloaders, - conf_type=DownloaderConf, - module_type=ModuleType.Downloader - ) - - def is_downloader( - self, - service_type: Optional[str] = None, - service: Optional[ServiceInfo] = None, - name: Optional[str] = None, - ) -> bool: - """ - 通用的下载器类型判断方法 - - :param service_type: 下载器的类型名称(如 'qbittorrent', 'transmission') - :param service: 要判断的服务信息 - :param name: 服务的名称 - :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False - """ - # 如果未提供 service 则通过 name 获取服务 - service = service or self.get_service(name=name) - - # 判断服务类型是否为指定类型 - return bool(service and service.type == service_type) +def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/history", + "endpoint": self.get_history, + "methods": ["GET"], + # 前端插件页面通过 api 模块调用时,通常使用 bear + "auth": "bear", + "summary": "查询插件历史", + "description": "返回插件最近的处理历史", + } + ] ``` -#### `MediaServerHelper` -用于管理媒体服务器服务。 +说明: + +- `auth` 支持 `apikey` 和 `bear` +- 面向插件前端页面的接口,通常使用 `bear` +- 面向外部系统调用的接口,可使用 `apikey` +- 如无特殊原因,不要默认匿名开放 + +### 6.3 公共服务 `get_service()` + +服务注册后会出现在 MoviePilot 的服务管理中,适合定时任务、周期刷新、批处理工作。 + +示例: ```python -from typing import Optional - -from app.helper.service import ServiceBaseHelper -from app.schemas import MediaServerConf, ServiceInfo -from app.schemas.types import SystemConfigKey, ModuleType +from apscheduler.triggers.cron import CronTrigger -class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): - """ - 媒体服务器帮助类 - """ - - def __init__(self): - super().__init__( - config_key=SystemConfigKey.MediaServers, - conf_type=MediaServerConf, - module_type=ModuleType.MediaServer - ) - - def is_media_server( - self, - service_type: Optional[str] = None, - service: Optional[ServiceInfo] = None, - name: Optional[str] = None, - ) -> bool: - """ - 通用的媒体服务器类型判断方法 - :param service_type: 媒体服务器的类型名称(如 'plex', 'emby', 'jellyfin') - :param service: 要判断的服务信息 - :param name: 服务的名称 - :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False - """ - # 如果未提供 service 则通过 name 获取服务 - service = service or self.get_service(name=name) - - # 判断服务类型是否为指定类型 - return bool(service and service.type == service_type) +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": {}, + } + ] ``` -#### `NotificationHelper` -用于管理消息通知服务。 +注意: + +- `id` 必须稳定且唯一 +- 禁用插件时要在 `stop_service()` 中清理自己的后台资源 +- 如果服务需要“启用后立刻跑一次”,可配合 `date` 触发器单独注册一条即时任务 + +### 6.4 仪表板 `get_dashboard()` / `get_dashboard_meta()` + +单仪表板插件可只实现 `get_dashboard()`;多仪表板插件建议额外实现 `get_dashboard_meta()`。 + +示例: ```python -from typing import Optional - -from app.helper.service import ServiceBaseHelper -from app.schemas import NotificationConf, ServiceInfo -from app.schemas.types import SystemConfigKey, ModuleType +from typing import Any, Dict, List, Optional, Tuple -class NotificationHelper(ServiceBaseHelper[NotificationConf]): - """ - 消息通知帮助类 - """ +def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]: + return [ + {"key": "summary", "name": "摘要"}, + {"key": "trend", "name": "趋势"}, + ] - def __init__(self): - super().__init__( - config_key=SystemConfigKey.Notifications, - conf_type=NotificationConf, - module_type=ModuleType.Notification - ) - def is_notification( - self, - service_type: Optional[str] = None, - service: Optional[ServiceInfo] = None, - name: Optional[str] = None, - ) -> bool: - """ - 通用的消息通知服务类型判断方法 - - :param service_type: 消息通知服务的类型名称(如 'wechat', 'voicechat', 'telegram', 等) - :param service: 要判断的服务信息 - :param name: 服务的名称 - :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False - """ - # 如果未提供 service 则通过 name 获取服务 - service = service or self.get_service(name=name) - - # 判断服务类型是否为指定类型 - return bool(service and service.type == service_type) +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 ``` -### 2.3 在插件中使用服务帮助类 +### 6.5 工作流动作 `get_actions()` -通过这些帮助类,插件可以方便地获取和管理各种服务。以下是 `DownloaderHelper` 的使用示例,包括类型检查服务和监听模块重载事件的两种方法。 +工作流动作适合把插件能力暴露给系统工作流调用。动作函数的第一个参数固定为 `ActionContent`,返回值需要遵循宿主约定。 -#### 获取下载器选项 +```python +def get_actions(self) -> List[Dict[str, Any]]: + return [ + { + "id": "my_plugin_action", + "name": "执行我的插件动作", + "func": self.run_action, + "kwargs": { + # 这里可以预置额外参数 + "mode": "fast", + }, + } + ] +``` -插件可以通过 `DownloaderHelper` 获取所有可用的下载器配置,并生成选项列表供用户选择。 +### 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 -class MyPlugin: - def init_plugin(self, config: dict = None): - self.downloaderhelper = DownloaderHelper() - self.downloader_options = [ - {"title": config.name, "value": config.name} - for config in self.downloaderhelper.get_configs().values() - ] + +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 ``` -#### 获取特定下载器服务 +这样做的好处是: -根据用户选择的下载器名称,插件可以获取对应的服务实例,并执行相应的操作。以下展示了两种方法: +- 自动复用宿主对系统配置的解析 +- 自动获取“启用中的实例” +- 降低插件和底层模块的耦合 -1. **使用事件监听进行模块重载,从而保持服务实例共享** +## 9. 调试与校验 - 如果外部模块进行了重载,需要监听模块重载事件以重置下载器服务。 +### 9.1 Python 层 - ```python - from typing import Optional, Union - from app.helper.downloader import DownloaderHelper - from app.modules.qbittorrent import Qbittorrent - from app.modules.transmission import Transmission - from app.events import EventType, eventmanager +推荐最小校验: - class MyPlugin: - def init_plugin(self, config: dict = None): - self.downloaderhelper = DownloaderHelper() - self._downloader = None - self.__setup_downloader(config.get("downloader_name")) +```bash +python3 -m py_compile plugins.v2/myplugin/__init__.py +python3 -m compileall plugins.v2/myplugin +git diff --check +``` - def __setup_downloader(self, downloader_name: str): - self._downloader = self.downloaderhelper.get_service(name=downloader_name) +### 9.2 API 层 - def __get_downloader(self) -> Optional[Union[Transmission, Qbittorrent]]: - """ - 获取下载器实例 - """ - if not self._downloader: - return None - return self._downloader.instance +如果插件定义了 `get_api()`: - @eventmanager.register(EventType.ModuleReload) - def module_reload(self, event: Event): - """ - 模块重载事件 - """ - if not event: - return - event_data = event.event_data or {} - module_id = event_data.get("module_id") - # 如果模块标识不存在,则说明所有模块均发生重载 - if not module_id: - self.__setup_downloader() +- 启动宿主后检查 `/docs` +- 确认路由实际注册在 `/api/v1/plugin//...` +- 区分 `apikey` 与 `bear` 的认证方式是否符合调用场景 - def check_downloader_type(self) -> bool: - """ - 检查下载器类型是否为 qbittorrent 或 transmission - """ - downloader = self.__get_downloader() - if self.downloaderhelper.is_downloader(service_type="qbittorrent", service=downloader): - # 处理 qbittorrent 类型 - return True - elif self.downloaderhelper.is_downloader(service_type="transmission", service=downloader): - # 处理 transmission 类型 - return True - return False - ``` +### 9.3 前端层 -2. **使用 Property 实现服务实例共享** +如果插件使用 Vue 远程组件: - 通过 `Property` 方法,从而保持服务实例共享,而无需通过事件监听。 +- 在前端工程中先执行 `yarn typecheck` +- 再执行 `yarn build` +- 确认最终上传的是联邦所需产物,而不是整个前端源码目录 - ```python - from typing import Optional, Union - from app.helper.downloader import DownloaderHelper - from app.modules.qbittorrent import Qbittorrent - from app.modules.transmission import Transmission +## 10. 发布清单 - class MyPlugin: - def init_plugin(self, config: dict = None): - self.downloaderhelper = DownloaderHelper() - self.downloader_name = config.get("downloader_name") +发布前建议至少逐项确认: - @property - def service_info(self) -> Optional[ServiceInfo]: - """ - 服务信息 - """ - service = self.downloaderhelper.get_service(name=self.downloader_name) - if not service: - return None +1. 插件目录在 `plugins/` 或 `plugins.v2/` 下位置正确 +2. 目录名与类名小写一致 +3. 元数据已写入正确的索引文件 +4. 索引里的 `version` 与代码里的 `plugin_version` 一致 +5. `history` 已补齐本次变更说明 +6. 若使用 Release 分发,条目已声明 `"release": true` +7. Python 代码完成最小语法校验 +8. 若有 Vue 远程组件,构建产物已更新 - if service.instance.is_inactive(): - return None +## 11. 什么时候还要回去看宿主源码 - return service +下面这些问题,不建议只看本仓库文档判断: - @property - def downloader(self) -> Optional[Union[Qbittorrent, Transmission]]: - """ - 下载器实例 - """ - return self.service_info.instance if self.service_info else None +- 插件为什么没有显示在插件市场 +- 插件 API 为什么没有注册成功 +- 服务为什么没有进入服务管理 +- 插件仪表板为什么没有加载 +- Vue 联邦页面为什么没有出现在侧栏 +- 某个 `permission` / `section` / `nav_key` 为什么不生效 - def check_downloader_type(self) -> bool: - """ - 检查下载器类型是否为 qbittorrent 或 transmission - """ - if self.downloaderhelper.is_downloader(service_type="qbittorrent", service=self.service_info): - # 处理 qbittorrent 类型 - return True - elif self.downloaderhelper.is_downloader(service_type="transmission", service=self.service_info): - # 处理 transmission 类型 - return True - return False - ``` +这类问题本质上都与宿主实现有关,应回到: -### 2.4 服务封装的优势 +- `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` -- **统一管理**:通过 `ServiceBaseHelper`,不同类型的服务配置和实例管理变得统一和简洁。 -- **灵活扩展**:新增服务类型时,只需创建相应的帮助类,无需修改现有逻辑。 -- **便捷调用**:插件可以轻松获取所需的服务实例,简化了服务的调用过程。 +## 12. 结论 -### 2.5 从 V1 升级到 V2 的注意事项 +开发 V2 插件时,最重要的不是“把代码放进 `plugins.v2/`”,而是同时把下面三件事做对: -- **使用帮助类**:确保插件中使用了新的服务帮助类,如 `DownloaderHelper`、`MediaServerHelper`、`NotificationHelper` 等,而不是直接操作服务实例。 -- **更新依赖**:检查并更新 `requirements.txt` 中的依赖,确保与 V2 的服务封装兼容。 -- **测试插件**:在 V2 环境中全面测试插件,确保所有服务调用正常工作。 \ No newline at end of file +1. 运行时契约对齐宿主 `_PluginBase` +2. 索引元数据与插件代码保持一致 +3. 渲染模式与前端加载方式匹配 + +做到这三点,插件的开发、升级、迁移、分身和发布都会明显顺很多。