# JS 插件开发指南 本文档详细介绍 Songloft JS 插件系统的架构、API 和开发流程。 --- ## 1. 概述 Songloft JS 插件系统允许开发者使用 JavaScript 扩展音乐服务器功能,无需编译 Go 代码。 ### 设计理念 系统基于 **Skynet Actor 模型**设计: - 每个插件是一个独立的 **Actor(JSService)**,拥有自己的 JS 虚拟机 - 插件之间通过 **消息** 通信,互不干扰 - 所有消息由 **ServiceScheduler** 统一调度,保证串行处理 - 双层 SHA256 校验确保插件代码完整性 ### 核心特性 | 特性 | 说明 | |------|------| | 沙箱隔离 | 每个插件运行在独立的 QuickJS 虚拟机中 | | 权限控制 | 细粒度权限声明,按需授权 | | 热更新 | 运行时更新插件,无需重启服务 | | 插件间通信 | send/call 消息机制 | | 静态资源 | 内置 Web UI 托管 | | 健康检查 | 自动检测异常插件并处理 | ### 架构示意 ``` Manager(管理器) ├── PackageManager(包管理:安装/更新/卸载) ├── ServiceScheduler(消息调度器) │ ├── JSService[plugin-a](Actor + QuickJS VM) │ ├── JSService[plugin-b](Actor + QuickJS VM) │ └── ... ├── HotReloader(热更新监控) └── HealthChecker(健康检查) ``` --- ## 2. 快速开始 推荐使用官方工具链 [songloft-plugin-toolchain](https://github.com/songloft-org/plugin-toolchain),5 分钟创建、构建并上传你的第一个 JS 插件。 ### Step 1: 用脚手架创建项目 ```bash npx create-songloft-plugin@latest # 或 pnpm create songloft-plugin cd <你的插件目录> npm install # 或 pnpm install / yarn install ``` 脚手架会交互式引导你完成以下配置: 1. **基本信息** — 目录名、插件显示名称、entryPath、简介、作者 2. **权限选择**(多选) — `storage`、`persistent-storage`、`songs.read`、`songs.write`、`playlists.read`、`playlists.write`、`inter-plugin`、`command`、`jsenv`、`fs`、`fs:music`、`fs:external`、`websocket`、`net` 3. **附加功能模板**(多选,可跳过) — 静态页面 (`static/`)、可执行文件管理 (`bin/`) 4. **包管理器** — npm / pnpm / yarn 生成的项目结构(选择全部附加功能时): ``` my-plugin/ ├── plugin.json # 插件清单(entryHash / zipHash 由 builder 生成) ├── package.json # npm 依赖(@songloft/plugin-sdk / @songloft/plugin-builder) ├── tsconfig.json ├── src/ │ └── main.ts # TypeScript 源码入口 ├── static/ # [附加功能] 静态资源(HTML + 插件自定义 JS) │ ├── index.html │ └── js/ │ └── app.js └── bin/ # [附加功能] 可执行文件管理(打包/下载/运行外部程序) ``` 模板采用叠加层设计:始终包含基础模板,选中的附加功能会额外合并对应文件。 ### Step 2: 编写业务逻辑 `src/main.ts` 使用 `@songloft/plugin-sdk` 提供的全局类型与 helper: ```typescript /// import { jsonResponse, createRouter } from '@songloft/plugin-sdk'; const router = createRouter(); router.get('/hello', (req) => jsonResponse({ message: 'Hello!', query: req.query })); router.get('/songs', async (req) => { const songs = await songloft.songs.list({ limit: 10 }); return jsonResponse({ count: songs.length, songs }); }); function onInit(): void { songloft.log.info('my-plugin initialized'); } function onDeinit(): void { songloft.log.info('my-plugin deinitialized'); } function onHTTPRequest(req: HTTPRequest): HTTPResponse { return router.handle(req); } // @ts-expect-error — QuickJS 全局注入 globalThis.onInit = onInit; // @ts-expect-error globalThis.onDeinit = onDeinit; // @ts-expect-error globalThis.onHTTPRequest = onHTTPRequest; ``` ### Step 3: 启动开发模式(推荐) ```bash pnpm run dev # 等价于 songloft-plugin dev ``` 首次运行会交互式询问 Songloft 实例地址、用户名与密码,之后: 1. 把账号密码写入项目根目录的 `.songloft-dev.json`(builder 会自动把它追加到 `.gitignore`),后续运行直接静默登录; 2. 立即执行一次构建并上传,首次安装时自动启用插件; 3. 监听 `src/`、`static/`、`plugin.json`,源码变更时自动重建上传,已激活的插件会被后端自动热重载。 > Token 不缓存:每次会话用账号密码即时登录,因此无需关心 token 过期 / 刷新。要换帐号或改密码,编辑(或直接删除)`.songloft-dev.json` 即可。 控制台会打印插件的访问入口(例如 `http://localhost:58091/api/v1/jsplugin//`),按 `Ctrl+C` 退出。 > 开发模式的详细 CLI 选项、环境变量与配置文件字段见下文 [开发模式详解](#开发模式详解-songloft-plugin-dev)。 ### Step 4: 构建生产包 发布前生成可分发的 `.jsplugin.zip`: ```bash pnpm run build # 等价于 songloft-plugin build ``` builder 会: 1. 用 esbuild 把 `src/main.ts` 打包为 `build/main.js`(`format: iife`, `target: es2020`,禁止引用 Node 内置模块); 2. 拷贝 `static/` 到 `build/`,并对 JS/CSS/字体/图片注入内容 hash(可在 `plugin.json` 中设置 `"staticHash": false` 关闭); 3. 若检测到可用的 `jsc` 工具,将 `main.js` 进一步编译为 `main.jsc` 字节码; 4. 计算 `entryHash = sha256(main 文件)` 与 `zipHash`(规范化算法,排除 `plugin.json` 自身),写回 `build/plugin.json`; 5. 打包为 `dist/.jsplugin.zip`,并生成 `dist/.json` 远程更新元数据。 ### Step 5: 安装到目标实例 任选其一: - **开发模式自动上传** —— `pnpm run dev`(见 Step 3),适合本地迭代; - **设置页面上传** —— 在 Songloft 客户端的插件管理页选择 `dist/.jsplugin.zip`; - **目录放置** —— 把 zip 放进服务器的 `data/jsplugins/` 目录,下次启动时自动扫描; - **API 上传** —— `POST /api/v1/jsplugins/upload`,multipart 字段名 `file`(开发模式底层即此接口)。 安装后,插件的 HTTP API 通过 `/api/v1/jsplugin//` 访问,静态资源通过 `/api/v1/jsplugin//static/...` 访问。 ### 开发模式详解 (songloft-plugin dev) `songloft-plugin dev` 把"构建 → 上传 → 热重载"压缩成一个常驻命令,适合本地开发与远程实例联调。 #### 默认行为 | 阶段 | 行为 | |------|------| | 启动 | 读取 `.songloft-dev.json`,缺失 `username` / `password` 时交互式询问,登录成功后落地保存 | | 登录策略 | 不缓存 token;每次启动用账号密码即时登录,会话期间出现 `401` 时自动用同一密码重登 | | 首次上传 | 调用 `POST /api/v1/jsplugins/upload`,新装后自动调用 `enable` | | 后续上传 | 同一 `entryPath` 复用 upload 接口,由后端识别为覆盖更新;插件处于活跃状态时自动热重载 | | 文件监听 | 监听 `src/`、`static/`、`plugin.json`,250ms debounce 触发增量构建 | | 密码失效 | 若服务器拒绝缓存的密码(如已被修改),自动清除 `.songloft-dev.json` 中的 `password` 字段并提示重新运行 | #### CLI 选项 ```text songloft-plugin dev [options] --host Songloft 实例 URL(默认 http://localhost:58091, 亦可读 $MIMUSIC_HOST 或 .songloft-dev.json) --username 登录用户名(或 $MIMUSIC_USER) --password 登录密码(或 $MIMUSIC_PASSWORD;缺省时静默提示输入) --token 直接使用预签发的 access token(或 $MIMUSIC_TOKEN) --once 构建+上传一次后退出,跳过 watch --no-enable 首次安装后不自动启用插件 ``` #### 环境变量 | 变量 | 等价选项 | |------|----------| | `MIMUSIC_HOST` | `--host` | | `MIMUSIC_USER` | `--username` | | `MIMUSIC_PASSWORD` | `--password` | | `MIMUSIC_TOKEN` | `--token` | #### `.songloft-dev.json` 字段 dev 命令自动在项目根目录维护下面的配置文件(同时把它追加到 `.gitignore`): ```json { "host": "http://localhost:58091", "username": "admin", "password": "your-password", "pluginId": 12, "entryPath": "my-plugin" } ``` | 字段 | 写入时机 | 说明 | |------|----------|------| | `host` | 首次启动 | Songloft 实例 URL | | `username` / `password` | 首次启动交互输入后写入,亦可手填 | 用于每次会话登录;明文存储,**切勿提交** | | `pluginId` / `entryPath` | 首次上传后写入 | 仅供参考,dev 命令实际通过 `entryPath` 与后端对账 | > 不存在 `accessToken` / `refreshToken` 字段:dev 命令不缓存 token。 > > 不想让密码明文落地?改用 `--token ` 或 `$MIMUSIC_TOKEN` 提供预签发的 access token;token 模式下不会读写 `.songloft-dev.json` 中的凭据字段。 > > 删除整个文件等同于重置登录状态。 --- ## 3. 插件结构 ### ZIP 打包格式 插件以 `.jsplugin.zip` 格式分发,文件名规则:`{entryPath}.jsplugin.zip` ZIP 内部结构(所有文件在根级别,不含父目录): ``` plugin.json # 插件清单(必须) main.js # 入口文件(必须,或 main.jsc 字节码) static/ # 静态资源目录(可选) ├── index.html └── js/ └── app.js ``` > 公共资源(CSS 变量/reset/MD3 组件样式、字体、API 工具库)由主程序自动注入,插件无需打包。详见 [§8. 静态资源](#8-静态资源)。 ### plugin.json 字段说明 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 插件名称(2-50 字符) | | `version` | string | 是 | 语义化版本号(如 `1.0.0`) | | `description` | string | 否 | 插件描述 | | `author` | string | 否 | 作者 | | `homepage` | string | 否 | 主页 URL | | `license` | string | 否 | 许可证 | | `entryPath` | string | 是 | 路由前缀(小写字母+数字+连字符,如 `my-plugin`) | | `main` | string | 是 | 入口文件路径(必须以 `.js` 结尾) | | `minHostVersion` | string | 否 | 最低宿主版本要求 | | `permissions` | string[] | 是 | 权限列表(可为空数组 `[]`) | | `updateUrl` | string | 否 | 远程更新检查 URL | | `download_url` | string | 否 | 插件下载 URL | | `entryHash` | string | 是 | `sha256(main.js)` 64 位小写 hex,由 `@songloft/plugin-builder` 自动生成,请勿手动编辑 | | `zipHash` | string | 是 | zip 内除 `plugin.json` 外所有文件的规范化 sha256 64 位小写 hex,由 `@songloft/plugin-builder` 自动生成,请勿手动编辑 | > `entryHash` / `zipHash` 为强制校验字段,缺失或与实际内容不匹配时,安装与加载均会被后端拒绝。`zipHash` 计算范围**不含** `plugin.json` 自身,避免 hash 写回 `plugin.json` 引起的循环依赖。 ### entryPath 命名规则 - 仅允许小写字母、数字和连字符 - 必须以小写字母开头 - 正则:`^[a-z][a-z0-9-]*$` - 示例:`example-basic`、`music-sync`、`metadata-helper` --- ## 4. 生命周期 插件有三个核心生命周期回调函数: ### onInit() 插件加载完成后调用。用于初始化资源、设置定时器等。 ```javascript async function onInit() { songloft.log.info("Plugin initialized"); await songloft.storage.set("start_time", new Date().toISOString()); } ``` **注意**:`onInit()` 失败不会阻止插件运行,插件仍可响应 HTTP 请求。 ### onDeinit() 插件卸载前调用。用于清理资源、保存状态。 ```javascript function onDeinit() { songloft.log.info("Plugin shutting down, saving state..."); } ``` ### onHTTPRequest(req) 收到 HTTP 请求时调用。这是插件对外提供服务的主要入口。 **参数 `req` 结构:** ```javascript { method: "GET", // HTTP 方法 path: "/songs", // 请求路径(相对于插件的 entryPath) headers: {}, // 请求头 map body: "", // 请求体(POST/PUT 时) query: "limit=10&offset=0" // URL 查询字符串 } ``` **返回值结构:** ```javascript { statusCode: 200, // HTTP 状态码 headers: { // 响应头 "Content-Type": "application/json" }, body: "..." // 响应体(字符串) } ``` **示例:路由分发** ```javascript function onHTTPRequest(req) { switch (req.path) { case "/": case "": return { statusCode: 200, body: "Hello!", headers: {} }; case "/api/data": if (req.method === "POST") { return handlePost(req); } return handleGet(req); default: return { statusCode: 404, body: "Not Found", headers: {} }; } } ``` ### onWebSocket(req, socket) 客户端连接 `/api/v1/jsplugin/{entryPath}/...` 并发起 WebSocket upgrade 时调用。插件必须声明 `websocket` 权限。`onWebSocket` 应注册消息/关闭/错误回调后返回,连接生命周期由宿主托管。 **参数 `req` 结构:** ```javascript { method: "GET", path: "/api/inbound", headers: {}, query: "access_token=...", remoteAddr: "127.0.0.1:12345" } ``` **`socket` 常用方法:** - `socket.send(string | Uint8Array | ArrayBuffer)`:发送文本或二进制消息 - `socket.close(code?, reason?)`:关闭连接 - `socket.onMessage(fn)` / `socket.onClose(fn)` / `socket.onError(fn)`:注册事件回调 - `socket.onmessage = fn` / `socket.addEventListener(...)`:兼容浏览器 WebSocket 风格 **示例:Echo 服务** ```javascript globalThis.onWebSocket = async function(req, socket) { socket.onMessage(async function(event) { await socket.send(event.data); }); }; ``` --- ## 5. API 参考 所有 API 通过全局 `songloft` 对象访问。 > **重要:所有 `songloft.*` 方法均为异步、返回 Promise,必须在 `async` 函数中 `await`。** 这与 `fetch` 等 Web 标准 API 行为一致。下文示例均置于 `async` 函数上下文中。**例外:** `songloft.log.*`(同步本地日志)和 `songloft.comm.onMessage(...)`(同步注册回调)无需 `await`。 ### HTTP 请求(全局 fetch) 使用标准全局 `fetch` 函数发起 HTTP 请求(由运行时 polyfill 提供,返回 Promise)。**无需声明权限**。 ```javascript // GET const resp = await fetch("https://example.com/api"); const data = await resp.json(); // POST const postResp = await fetch("https://example.com/api", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hello: "world" }) }); const text = await postResp.text(); ``` 请求头里可以使用两个运行时内部控制头:`X-Fetch-No-Redirect` 禁止自动跟随重定向,`X-Fetch-Timeout-Ms` 设置单次请求超时(100-30000ms)。这两个头只影响运行时行为,不会转发给目标服务器。 **`Response` 对象字段:** - `ok` — `status >= 200 && status < 300` - `status` — HTTP 状态码 - `statusText` — 状态文本 - `headers` — 响应头对象 - `json()` — 返回 `Promise`,解析 JSON - `text()` — 返回 `Promise`,原始文本 `onHTTPRequest`、`onWebSocket` 和事件回调都可以是 `async function`,框架会等待 Promise settle。 ### Crypto(全局 crypto) 运行时提供轻量 `crypto` 工具对象。**无需声明权限**。 ```javascript const md5 = crypto.md5("data"); const sha256 = crypto.sha256Bytes(Buffer.from("data", "utf8")).toString("hex"); const key = Buffer.from("1234567890abcdef", "utf8"); const iv = Buffer.from("abcdef1234567890", "utf8"); const encrypted = crypto.aesEncrypt("hello", "cbc", key, iv).toString("base64"); const decrypted = crypto.aesDecrypt(encrypted, "cbc", key, iv).toString("utf8"); ``` 常用方法:`md5(str)`、`sha1(str)`、`sha256Bytes(buffer)`、`rc4(key, data)`、`aesEncrypt(buffer, "cbc" | "ecb", key, iv?)`、`aesDecrypt(buffer, "cbc" | "ecb", key, iv?)`、`rsaEncrypt(buffer, publicKeyPEM)`、`randomBytes(size)`。AES 使用 PKCS7 padding;`aesDecrypt` 的字符串密文默认按 base64 解析,传入 `Buffer` 时按原始字节解析。 ### 定时器(全局 setTimeout / setInterval) 使用标准全局定时器 API(由运行时 polyfill 提供)。**无需声明权限**,插件卸载时运行时会自动清理未清除的定时器。 ```javascript // 一次性延迟 const t = setTimeout(() => songloft.log.info("tick"), 1000); clearTimeout(t); // 周期执行 const i = setInterval(() => songloft.log.info("heartbeat"), 60000); clearInterval(i); ``` **注意:** 定时器回调在独立的后台 goroutine 中执行(每 500ms 检查一次到期定时器),使用 TryLock 机制确保**不阻塞 HTTP 请求处理**。当 HTTP 请求正在处理时,定时器自动让步等待下一轮。`setInterval` 的最小间隔被限制为 10ms。 ### songloft.storage — 持久化存储 需要权限:`storage` ```javascript async function storageExample() { // 读取值(异步返回原类型值或 null) var value = await songloft.storage.get("key"); // 写入值(值经 JSON 自动序列化,可直接存对象/数组) await songloft.storage.set("config", { volume: 80, list: [1, 2, 3] }); // 删除键 await songloft.storage.delete("key"); // 获取所有键名 var keys = await songloft.storage.keys(); // ["key1", "key2", ...] } ``` **存储限制:** - 键名为字符串 - 值经 JSON 自动序列化,可直接存对象/数组/数字;`get` 异步返回原类型值或 null - 每个插件有独立的存储空间 ### songloft.songs — 歌曲操作 需要权限:`songs.read` ```javascript async function songsExample() { // 获取歌曲列表 var songs = await songloft.songs.list({ limit: 20, offset: 0 }); // 根据 ID 获取歌曲 var song = await songloft.songs.getById(123); // 搜索歌曲 var results = await songloft.songs.search("关键词"); } ``` **Song 对象结构:** ```javascript { id: 1, type: "local", // "local" | "remote" | "radio" title: "歌曲名", artist: "艺术家", album: "专辑名", duration: 240.5, // 秒 file_path: "/path/to/file.mp3", url: "", cover_url: "", // 封面 URL(CoverPath 内部字段不会序列化输出) is_video: false // 是否为视频容器 } ``` ### songloft.playlists — 歌单操作 需要权限:`playlists.read`(读取)或 `playlists.write`(修改);或者通配符糖 `playlists.*`。 ```javascript async function playlistsExample() { // 需要 playlists.read var playlists = await songloft.playlists.list(); var playlist = await songloft.playlists.getById(1); var songs = await songloft.playlists.getSongs(1, { limit: 50, offset: 0 }); } ``` ### songloft.comm — 插件间通信 需要权限:`inter-plugin` ```javascript async function commExample() { // 发送消息(fire-and-forget) await songloft.comm.send("target-plugin", "action-name", { data: "hello" }); // 请求-响应调用(等待响应,超时默认 10s) var resp = await songloft.comm.call("target-plugin", "action-name", { data: "hello" }, 5000); // resp = { success: true, data: { ... } } } // 注册消息处理器(同步注册,无需 await) songloft.comm.onMessage("action-name", function(payload, from) { // payload: 发送方传递的数据 // from: 发送方的 entryPath return { result: "processed" }; // 返回值作为 call 的响应 }); ``` ### songloft.log — 日志 无需权限。 ```javascript songloft.log.info("informational message"); songloft.log.warn("warning message"); songloft.log.error("error message"); ``` 日志输出到服务器标准日志,带 `[plugin]` 前缀。 ### songloft.plugin — 插件信息 无需权限。 ```javascript async function pluginInfoExample() { // 获取插件的 JWT Token(用于访问宿主 API,如音乐文件、封面等需认证的资源) var token = await songloft.plugin.getToken(); // 获取宿主服务的基础 URL(如 http://192.168.1.100:58091) var hostUrl = await songloft.plugin.getHostUrl(); } ``` **典型用法:构建带认证的资源 URL** ```javascript async function getMusicUrl(songId) { var host = await songloft.plugin.getHostUrl(); var token = await songloft.plugin.getToken(); return host + "/music/" + encodedPath + "?access_token=" + token; } ``` **方法说明:** - `getToken()` — 返回当前有效的 JWT access_token 字符串,可用于访问宿主的受保护 API - `getHostUrl()` — 返回宿主服务的基础 URL,用于构建完整的 API 或资源地址 ### songloft.lyrics — 歌词提供者 无需权限。 插件可以注册为歌词提供者,在歌曲没有歌词时由宿主自动调用。 #### 注册与取消 ```javascript // 注册为歌词提供者 songloft.lyrics.registerProvider(); // 取消注册 songloft.lyrics.unregisterProvider(); ``` #### 实现搜索端点 注册后,宿主会通过 `InvokeHTTP` 调用插件的 `/lyric-search` 端点搜索歌词。插件需自行实现该路由。 **请求参数(Query String):** | 参数 | 类型 | 说明 | |------|------|------| | `title` | string | 歌曲标题 | | `artist` | string | 艺术家 | | `album` | string | 专辑名 | | `duration` | number | 时长(秒) | | `fingerprint` | string | 音频指纹(Chromaprint,可选,有值时才传) | | `isrc` | string | ISRC 国际标准录音编码(可选,有值时才传) | **响应格式(HTTP 200 + JSON):** ```json { "lyric": "[00:01.00]歌词第一行\n[00:05.00]歌词第二行", "tlyric": "[00:01.00]翻译第一行", "rlyric": "[00:01.00]罗马音第一行", "lxlyric": "[00:01.00]逐字歌词" } ``` - `lyric`(必填):主歌词,LRC 格式 - `tlyric`(可选):翻译歌词 - `rlyric`(可选):罗马音歌词 - `lxlyric`(可选):逐字歌词 无结果时返回 HTTP 404 或空 body。 #### 完整示例 ```typescript /// import { createRouter, jsonResponse, parseQuery } from '@songloft/plugin-sdk'; const router = createRouter(); let registered = false; router.get('/lyric-search', async (req: HTTPRequest) => { const q = parseQuery(req.query); const result = await searchFromMySource( q.title, q.artist, q.album, parseFloat(q.duration) || 0, q.fingerprint, // 可选,用于精确匹配 q.isrc // 可选,用于精确匹配 ); if (!result) return jsonResponse(null, 404); return jsonResponse(result); // { lyric, tlyric?, rlyric?, lxlyric? } }); globalThis.onInit = async () => { songloft.lyrics.registerProvider(); registered = true; }; globalThis.onDeinit = async () => { if (registered) songloft.lyrics.unregisterProvider(); }; globalThis.onHTTPRequest = (req: HTTPRequest) => router.handle(req); ``` #### 工作流程 1. 用户播放无歌词的歌曲,客户端请求 `GET /api/v1/songs/{id}/lyric` 2. 宿主发现歌词为空,遍历所有已注册的歌词提供者插件 3. 对每个插件调用 `GET /lyric-search?title=...&artist=...`(15 秒超时) 4. 第一个返回 HTTP 200 + 非空歌词的插件胜出,停止遍历 5. 搜到的歌词异步写入数据库缓存(`lyric_source=scraped`),后续请求直接返回缓存 6. 本地歌曲还会将歌词嵌入音频文件标签 ### songloft.covers — 封面提供者 无需权限。 插件可以注册为封面提供者,在歌曲没有封面时由宿主自动调用。 #### 注册与取消 ```javascript // 注册为封面提供者 songloft.covers.registerProvider(); // 取消注册 songloft.covers.unregisterProvider(); ``` #### 实现搜索端点 注册后,宿主会通过 `InvokeHTTP` 调用插件的 `/cover-search` 端点搜索封面。插件需自行实现该路由。 **请求参数(Query String):** | 参数 | 类型 | 说明 | |------|------|------| | `title` | string | 歌曲标题 | | `artist` | string | 艺术家 | | `album` | string | 专辑名 | | `fingerprint` | string | 音频指纹(Chromaprint,可选,有值时才传) | | `isrc` | string | ISRC 国际标准录音编码(可选,有值时才传) | **响应格式(HTTP 200 + JSON):** ```json { "cover_url": "https://example.com/covers/album.jpg" } ``` - `cover_url`(必填):封面图片的完整 URL 无结果时返回 HTTP 404 或空 body。 #### 完整示例 ```typescript /// import { createRouter, jsonResponse, parseQuery } from '@songloft/plugin-sdk'; const router = createRouter(); let registered = false; router.get('/cover-search', async (req: HTTPRequest) => { const q = parseQuery(req.query); const coverUrl = await searchCoverFromMySource( q.title, q.artist, q.album, q.fingerprint, // 可选,用于精确匹配 q.isrc // 可选,用于精确匹配 ); if (!coverUrl) return jsonResponse(null, 404); return jsonResponse({ cover_url: coverUrl }); }); globalThis.onInit = async () => { songloft.covers.registerProvider(); registered = true; }; globalThis.onDeinit = async () => { if (registered) songloft.covers.unregisterProvider(); }; globalThis.onHTTPRequest = (req: HTTPRequest) => router.handle(req); ``` #### 工作流程 1. 用户播放无封面的歌曲,客户端请求 `GET /api/v1/songs/{id}/cover` 2. 宿主发现封面为空,遍历所有已注册的封面提供者插件 3. 对每个插件调用 `GET /cover-search?title=...&artist=...`(15 秒超时) 4. 第一个返回 HTTP 200 + 非空 `cover_url` 的插件胜出,停止遍历 5. 搜到的封面异步持久化: - **本地歌曲**:下载封面图片 → 保存到本地 `cover_path` → 嵌入音频文件标签 - **远程歌曲**:存储 `cover_url` 到数据库 6. 后续请求直接返回缓存,不再调用插件 ### 提供者机制通用说明 歌词和封面提供者共享相同的架构: - **多插件支持**:多个插件可同时注册为同一类型的提供者,宿主按 first-match-wins 策略依次尝试 - **空闲驱逐安全**:插件被空闲驱逐(内存回收)后,提供者注册不会丢失;下次搜索时宿主会自动重新加载插件 - **惰性清理**:已禁用或已删除的插件会在搜索遍历时被自动从提供者集合中移除 - **指纹与 ISRC**:建议插件优先使用 `fingerprint` 和 `isrc` 进行精确匹配(如果有值),再 fallback 到 title/artist 模糊搜索 --- ## 6. 权限系统 插件必须在 `plugin.json` 的 `permissions` 字段中声明所需权限。运行时调用 API 时会校验权限,未声明的权限将被拒绝。 ### 可用权限列表 与后端 `internal/jsplugin/permissions.go` 的 `AllPermissions` 保持一致: | 权限 | 说明 | |------|------| | `storage` | 读写插件私有持久化存储 | | `songs.read` | 读取歌曲元数据 | | `songs.write` | 修改/写入歌曲元数据 | | `songs.*` | 歌曲读写通配符(一把梭糖) | | `playlists.read` | 读取歌单及歌单中的歌曲 | | `playlists.write` | 创建/修改/删除歌单及其歌曲 | | `playlists.*` | 歌单读写通配符(一把梭糖) | | `inter-plugin` | 插件间通信 | | `command` | 执行外部命令/管理可执行文件 | | `jsenv` | 创建/执行子 JS 沙箱环境 | | `fs` | 读写插件数据目录内文件 | | `fs:music` | 访问 music_path 音乐目录 | | `fs:external` | 访问管理员配置的外部目录 | | `websocket` | 使用 `new WebSocket(...)` 主动连接外部服务,或处理入站 `onWebSocket` upgrade | | `persistent-storage` | 读写卸载插件后仍保留的持久化存储 | | `net` | 使用原始网络 socket(UDP / 出站 TCP) | > 注意:网络请求 (`fetch`)、定时器 (`setTimeout/setInterval`)、日志等能力**无需权限声明**,是默认宿主能力。 ### 通配符糖 以 `.*` 结尾的权限在声明层作为一把梭糖,runner 在检查时用前缀匹配。例如声明 `playlists.*` 既包括 `playlists.read` 也包括 `playlists.write`;而单声明 `playlists.read` 时无法调用写接口。 ### 最小权限原则 只声明实际需要的权限,减少安全风险: ```json { "permissions": ["storage", "songs.read"] } ``` --- ## 7. 插件间通信 插件可以通过消息机制相互协作。 ### 异步发送(Send) 发送方不等待响应,适合通知类场景: ```javascript // Plugin A: 通知 Plugin B async function notifyB() { await songloft.comm.send("plugin-b", "data-updated", { source: "plugin-a" }); } ``` ### 同步调用(Call) 发送方等待接收方处理并返回结果: ```javascript // Plugin A: 调用 Plugin B 的服务 async function fetchFromB() { var response = await songloft.comm.call("plugin-b", "get-data", { id: 123 }, 5000); if (response.success) { var data = response.data; } } ``` ### 注册处理器(onMessage) 接收方注册处理特定 action 的函数: ```javascript // Plugin B: 注册 action handler songloft.comm.onMessage("get-data", function(payload, from) { songloft.log.info("Request from: " + from); // payload = { id: 123 } return { name: "example", value: 42 }; }); songloft.comm.onMessage("data-updated", function(payload, from) { songloft.log.info("Got notification from: " + from); // 无需返回值(send 场景) }); ``` ### 通信权限 通信双方都需要 `inter-plugin` 权限。 --- ## 8. 静态资源 插件可以通过 `static/` 目录提供 Web UI。 ### 目录结构 ``` my-plugin/ ├── plugin.json ├── main.js └── static/ ├── index.html └── js/ └── app.js # 插件自定义逻辑 ``` > 公共资源(CSS 变量/reset/MD3 组件样式、字体文件、API 工具库 `common.js`)由主程序自动注入,**无需**在插件中打包。 ### 主程序自动注入 后端在返回插件 HTML 页面时,会在 `` 顶部自动注入以下内容(按顺序): 1. **``** — 设置相对路径基准,HTML 中可直接用相对路径引用 `static/...` 和插件 API 2. **Auth bridge 脚本** — 从 URL `?access_token=` 写入 localStorage、fetch 503 自动重试 3. **`common.css`** — MD3 颜色变量(含亮/暗双主题)、CSS reset、字体声明、通用组件样式 4. **`common.js`** — embed 检测、主题桥接、`window.SongloftPlugin` 全局 API 因此插件 HTML **不需要**: - `` 引用 fonts.css 或 style.css(主程序提供) - embed 检测脚本(主程序提供) - 打包字体文件(主程序通过 `/api/v1/jsplugin-assets/fonts/` 提供) ### window.SongloftPlugin — 浏览器端全局 API 主程序注入的 `common.js` 暴露 `window.SongloftPlugin` 全局对象,提供以下方法: ```javascript // API 请求 SongloftPlugin.getAuthToken() // 从 localStorage 读取 JWT token SongloftPlugin.apiGet(path) // GET 请求,返回 Promise SongloftPlugin.apiPost(path, body) // POST 请求 SongloftPlugin.apiPut(path, body) // PUT 请求 SongloftPlugin.apiDelete(path) // DELETE 请求 // 主题 SongloftPlugin.getTheme() // 返回 'light' | 'dark' SongloftPlugin.onThemeChange(cb) // 监听主题变化,cb(theme: 'light' | 'dark') ``` 插件 JS 可直接使用: ```javascript const { apiGet, getTheme, onThemeChange } = SongloftPlugin; const data = await apiGet('/api/hello'); console.log('当前主题:', getTheme()); onThemeChange(theme => console.log('主题切换到:', theme)); ``` 如果插件有多个 JS 文件,每个文件顶部直接从全局解构即可: ```javascript const { apiGet, apiPost } = SongloftPlugin; ``` ### 客户端 SDK —— 调用宿主播放器(webview 页面专用) 在 Songloft 客户端中打开的插件页面,可通过 `window.SongloftPlugin.host` / `.player` 调用宿主客户端能力——最常见的是改写宿主的「正在播放队列」。 > - 生效范围:**native 客户端**(Android/iOS/macOS/Windows/Linux)的 webview 插件页;**Web 端插件页**(Tab 内嵌页与首页/全屏页均在宿主 iframe 内打开,走 postMessage 桥接)。 > - 不生效:仅当用户通过「在浏览器中打开」把插件页在独立新浏览器标签打开时(无宿主父窗口)——此时 `host.isAvailable()` 返回 `false`,调用会抛错,务必先 feature-detect。 > - 能力由宿主客户端注入,跟随客户端版本。请在 `plugin.json` 设置合适的 `minHostVersion`,并用 `host.getInfo().capabilities` 做能力协商。 ```javascript const { host, player } = SongloftPlugin; if (host && host.isAvailable()) { // 能力协商 const info = await host.getInfo(); // { version, platform, capabilities: ['player'] } // 用歌曲 id 替换正在播放队列并从第 0 首开始播(id 通常来自你自己的搜索结果, // 先经服务端 songs.create 持久化拿到 id) await player.setQueue([101, 102, 103], { startIndex: 0 }); // 追加到队列末尾(不打断当前播放) await player.addToQueue([104]); // 读取状态 / 订阅状态变更 const state = await player.getState(); // { queue, current_index, is_playing, ... } const off = player.onStateChange(s => console.log('当前第', s.current_index, '首')); } ``` `player` 命名空间方法:`getState` / `setQueue` / `addToQueue` / `insertToQueue` / `removeFromQueue` / `reorderQueue` / `clearQueue` / `play(id?)` / `pause` / `togglePlay` / `next` / `prev` / `seek(seconds)` / `setVolume(0-100)` / `setPlayMode('order'|'loop'|'single'|'random'|'singlePlay')` / `playPlaylistById(id)` / `onStateChange(cb)`。 用 TypeScript / 构建工具(如 Vue 模板)开发时,安装 [`@songloft/client-sdk`](https://github.com/songloft-org/plugin-toolchain/tree/main/packages/client-sdk) 获得完整类型与便捷封装: ```ts import { player, host, isClient } from '@songloft/client-sdk'; if (isClient()) { await player.setQueue([101, 102], { startIndex: 0 }); } ``` 免构建的 vanilla 静态页面无需安装,直接用注入的 `window.SongloftPlugin.player` 即可(仅少了类型提示)。 ### 主题适配 主程序的 `common.css` 在 `:root` 下定义了 `--md-*` CSS 变量(亮色),并在 `html[data-theme="dark"]` 下覆盖为暗色值。插件页面使用这些变量即可自动适配主题: ```css /* 插件自定义样式 — 引用 --md-* 变量自动跟随主题 */ .my-card { background: var(--md-surface); color: var(--md-on-surface); border: 1px solid var(--md-outline-variant); } ``` 主题变化时(用户在主程序设置中切换),`common.js` 会: 1. 更新 `` 的 `data-theme` 属性和 `theme-light`/`theme-dark` CSS class 2. 派发 `songloft-theme-change` CustomEvent 3. 写入 `localStorage['songloft-theme']` 插件 JS 可通过 `SongloftPlugin.onThemeChange(callback)` 监听主题变化做额外处理。 ### 访问路径 安装后,静态文件通过以下路径访问(注意:运行时路由是单数 `jsplugin`,与管理 API `/api/v1/jsplugins`(复数)不同): ``` GET /api/v1/jsplugin/{entryPath}/ → static/index.html(自动注入) GET /api/v1/jsplugin/{entryPath}/static → static/index.html GET /api/v1/jsplugin/{entryPath}/static/ → 任意静态资源 GET /api/v1/jsplugin-assets/* → 主程序公共资源(CSS/JS/字体) ``` ### 注意事项 - 静态文件在安装时从 ZIP 解压到 `data/jsplugins_data/{entryPath}/static/` - 更新插件时会重新解压静态文件 - 建议使用相对路径引用插件 API - 公共资源由主程序提供,插件不需要也不应该打包自己的 CSS 变量/字体/API 工具库 --- ## 9. 安全机制 ### 双层 Hash 校验 插件系统使用两层 SHA256 校验保护代码完整性: 1. **Layer 1 — ZIP Hash**:整个 ZIP 文件的 SHA256 2. **Layer 2 — Entry Hash**:入口文件(main.js)内容的 SHA256 #### 校验流程 ``` 加载插件时: 1. 计算 ZIP 文件 SHA256 → 与数据库中的 zip_hash 比对 2. 若不匹配: - 检查文件 mtime 是否变化 - mtime 未变 = 文件被篡改 → 拒绝加载 - mtime 已变 = 合法更新 → 允许并更新 hash 3. 从 ZIP 内存中读取 main.js(不落盘) 4. 计算 main.js SHA256 → 与 entry_hash 比对 5. 若不匹配且 ZIP hash 未变 → 拒绝(内部篡改) ``` ### main.js 不落盘 入口文件从 ZIP 直接读入内存,不写入磁盘文件系统,减少被篡改风险。 ### 权限隔离 - 每个插件声明权限,运行时严格校验 - 未声明权限的 API 调用会被拒绝 - QuickJS 虚拟机提供运行时隔离 --- ## 10. 打包发布 ### 打包步骤 ```bash # 1. 确保目录结构正确 my-plugin/ ├── plugin.json ├── main.js └── static/ └── index.html # 2. 进入插件目录 cd my-plugin/ # 3. 打包为 ZIP(文件在根级别,不含父目录) zip -r ../my-plugin.jsplugin.zip plugin.json main.js static/ # 4. 验证 ZIP 结构 unzip -l ../my-plugin.jsplugin.zip # 应该看到: # plugin.json # main.js # static/index.html ``` ### 文件命名 ZIP 文件名格式:`{entryPath}.jsplugin.zip` 系统会从文件名提取 entryPath:`my-plugin.jsplugin.zip` → `my-plugin` ### 安装方式 1. **开发模式(推荐)**:`songloft-plugin dev` 在本地迭代,参见 [§2.6](#26-开发模式详解-songloft-plugin-dev) 2. **UI 上传**:通过 Songloft 客户端的设置页面 → 插件管理上传 ZIP 3. **目录放置**:将 ZIP 放入服务器的 `data/jsplugins/` 目录,服务启动时自动发现 4. **API 上传**:`POST /api/v1/jsplugins/upload`,multipart 字段名 `file`(开发模式底层即此接口) ### 更新已有插件 - 重新上传同 `entryPath` 的新版本 ZIP 即可(`/upload` 端点同时处理新装与覆盖更新,由后端用响应状态码 `201` / `200` 区分) - 也可显式调用 `PUT /api/v1/jsplugins/{id}` 上传新 ZIP - 或直接替换 `data/jsplugins/` 目录中的 ZIP 文件 无论哪种方式,原插件若处于 `active` 状态,更新成功后后端会自动触发热重载。 --- ## 11. 热更新 插件支持运行时更新,无需重启 Songloft 服务。 ### 热更新流程 ``` 1. 检测到 ZIP 文件变化(mtime 改变) 2. 冻结当前服务(停止接收新消息) 3. 调用 onDeinit() 回调 4. 销毁旧的 QuickJS 虚拟机 5. 从新 ZIP 重新加载代码 6. 创建新的 QuickJS 虚拟机 7. 调用 onInit() 回调 8. 解冻服务,恢复消息处理 ``` ### 自动检测 系统每 30 秒轮询 `data/jsplugins/` 目录,检测 ZIP 文件 mtime 变化。若检测到变化,自动触发热更新。 ### 手动触发 目前未提供独立的 `reload` 端点。重新触发热更新的常用做法: - **开发期**:保持 `songloft-plugin dev` 运行,保存源码即可; - **运维**:重新上传同 `entryPath` 的 ZIP(`POST /api/v1/jsplugins/upload`)或调用 `PUT /api/v1/jsplugins/{id}`,后端在更新成功后会自动对处于 `active` 状态的插件触发热重载; - **远程更新**:调用 `POST /api/v1/jsplugins/{id}/update` 拉取 `updateUrl` 中的新版本,同样会自动热重载。 ### 错误回滚 如果新版本加载失败,系统会尝试回滚到旧版本。若回滚也失败,则将插件标记为 `error` 状态。 ### 注意事项 - 热更新期间,正在处理的请求会完成后再切换 - 定时器和存储状态在热更新后需要重新初始化 - 建议在 `onInit()` 中恢复必要状态 --- ## 12. 最佳实践 ### 性能建议 1. **避免长时间阻塞** — `onHTTPRequest` 应快速返回 2. **合理使用定时器** — 定时器回调在独立线程中执行,不阻塞 HTTP 请求。但回调中的 `fetch` 等网络操作仍会占用 VM 锁,建议避免在单次回调中执行多个串行网络请求 3. **缓存计算结果** — 使用 `songloft.storage` 缓存频繁访问的数据 4. **控制响应体大小** — 避免返回过大的 JSON 响应 5. **定时器间隔** — 建议 `setInterval` 间隔不低于 1 秒;系统每 500ms 检查一次到期定时器 ### 错误处理 ```javascript function onHTTPRequest(req) { try { // 业务逻辑 var data = processRequest(req); return { statusCode: 200, body: JSON.stringify(data), headers: { "Content-Type": "application/json" } }; } catch (e) { songloft.log.error("Request failed: " + e.message); return { statusCode: 500, body: JSON.stringify({ error: e.message }), headers: { "Content-Type": "application/json" } }; } } ``` ### 版本管理 - 遵循语义化版本(SemVer) - 在 `plugin.json` 中设置 `updateUrl` 支持远程更新检查 - 重大变更时更新主版本号 ### 开发调试 1. 查看服务器日志中 `[plugin]` 前缀的输出 2. 使用 `songloft.log.info/warn/error` 输出调试信息 3. 健康检查失败会在日志中记录 ### 存储使用模式 ```javascript // 存储复杂对象(storage 自动 JSON 序列化,直接存对象即可) async function saveConfig(config) { await songloft.storage.set("config", config); } async function loadConfig() { var config = await songloft.storage.get("config"); return config || { defaultKey: "defaultValue" }; } ``` ### 插件间协作模式 ```javascript // 服务提供者模式 songloft.comm.onMessage("get-service", function(payload, from) { switch (payload.method) { case "translate": return { text: translate(payload.text) }; case "summarize": return { summary: summarize(payload.text) }; default: return { error: "unknown method" }; } }); // 服务消费者模式 async function useTranslation(text) { var resp = await songloft.comm.call("translator-plugin", "get-service", { method: "translate", text: text }, 5000); if (resp.success && resp.data) { return resp.data.text; } return text; // fallback } ``` --- ## 附录:完整示例 参见 [plugin-toolchain/examples/basic](https://github.com/songloft-org/plugin-toolchain/tree/main/examples/basic) 目录,包含基于官方工具链的完整示例插件代码。