diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index eef8030..11219f9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,7 +27,6 @@ SPlayer-Next 是基于 **Electron + Vue 3 + TypeScript** 的桌面音乐播放 | `@/*` | `src/*` | 渲染进程 | | `@shared/*` | `shared/*` | 主进程 + 渲染进程 + 原生类型 | | `@main/*` | `electron/main/*` | 主进程 | -| `@server/*` | `electron/server/*` | 主进程 | | `@windows/*` | `windows/*` | 三个独立歌词窗口(复用 `windows/shared/`) | | `@splayer/audio-engine` | `native/audio-engine` | 主进程,类型从自动生成的 `index.d.ts` 导入 | | `@splayer/media-ctrl` | `native/media-ctrl` | 同上 | diff --git a/CLAUDE.md b/CLAUDE.md index ce5d1f6..007dd2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,8 +61,9 @@ Two-tier position tracking to separate high-frequency animation from low-frequen ### Type System -- **`src/types/song.ts`** — `Track` (playlist-friendly, lightweight), `TrackDetail` (on-demand), `Artist`, `Album`, `AudioQuality`, `OnlineMatch`, `ExternalLyric` -- **`src/types/player.ts`** — `PlayerState`, `PlayerStatus`, `PlayerEvent`, `LoadResult`, `PlayerApi`, `IpcResponse` +- **`shared/types/player.ts`** — `Track` (playlist-friendly, lightweight), `TrackDetail` (on-demand), `Artist`, `Album`, `AudioQuality`, `PlayerState`, `PlayerStatus`, `PlayerEvent`, `LoadResult`, `PlayerApi`, `IpcResponse` +- **`shared/types/lyrics.ts`** — `LyricFormat`, `LyricSource`(`"external" | "embedded" | "online"`), `LyricData`(当前激活歌词描述:source + format + 可选 platform), `LyricLine`, `LyricWord`, `LyricSpan` +- **`shared/types/platform.ts`** — `Platform`(`"netease" | "qqmusic" | "kugou"`) `Track` is designed for playlist storage (no lyrics/heavy data). `TrackDetail` is loaded on demand when a track becomes active. diff --git a/components.d.ts b/components.d.ts index ded83af..bbb720f 100644 --- a/components.d.ts +++ b/components.d.ts @@ -42,6 +42,7 @@ declare module 'vue' { DropdownMenuTrigger: typeof import('reka-ui')['DropdownMenuTrigger'] EffectsLyrics: typeof import('./src/components/player/EffectsLyrics/index.vue')['default'] FullPlayer: typeof import('./src/components/player/FullPlayer/index.vue')['default'] + IconLucideArrowUpCircle: typeof import('~icons/lucide/arrow-up-circle')['default'] IconLucideCheck: typeof import('~icons/lucide/check')['default'] IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] IconLucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default'] @@ -49,12 +50,16 @@ declare module 'vue' { IconLucideChevronUp: typeof import('~icons/lucide/chevron-up')['default'] IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideCircleX: typeof import('~icons/lucide/circle-x')['default'] + IconLucideDatabase: typeof import('~icons/lucide/database')['default'] IconLucideDisc3: typeof import('~icons/lucide/disc3')['default'] IconLucideEllipsis: typeof import('~icons/lucide/ellipsis')['default'] + IconLucideExternalLink: typeof import('~icons/lucide/external-link')['default'] IconLucideFolder: typeof import('~icons/lucide/folder')['default'] + IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default'] IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default'] IconLucideHardDrive: typeof import('~icons/lucide/hard-drive')['default'] IconLucideInfo: typeof import('~icons/lucide/info')['default'] + IconLucideLink: typeof import('~icons/lucide/link')['default'] IconLucideListMusic: typeof import('~icons/lucide/list-music')['default'] IconLucideLocate: typeof import('~icons/lucide/locate')['default'] IconLucideLock: typeof import('~icons/lucide/lock')['default'] @@ -64,6 +69,8 @@ declare module 'vue' { IconLucideMusic: typeof import('~icons/lucide/music')['default'] IconLucidePause: typeof import('~icons/lucide/pause')['default'] IconLucidePlay: typeof import('~icons/lucide/play')['default'] + IconLucidePower: typeof import('~icons/lucide/power')['default'] + IconLucidePuzzle: typeof import('~icons/lucide/puzzle')['default'] IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default'] IconLucideRepeat: typeof import('~icons/lucide/repeat')['default'] IconLucideRepeat1: typeof import('~icons/lucide/repeat1')['default'] @@ -89,6 +96,7 @@ declare module 'vue' { PlayerCover: typeof import('./src/components/player/FullPlayer/PlayerCover.vue')['default'] PlayerData: typeof import('./src/components/player/FullPlayer/PlayerData.vue')['default'] PlaylistPanel: typeof import('./src/components/list/PlaylistPanel.vue')['default'] + PluginManager: typeof import('./src/components/settings/custom/PluginManager.vue')['default'] PopoverArrow: typeof import('reka-ui')['PopoverArrow'] PopoverContent: typeof import('reka-ui')['PopoverContent'] PopoverPortal: typeof import('reka-ui')['PopoverPortal'] diff --git a/demo/audio-architecture.md b/demo/audio-architecture.md index f2c744d..30e3a8b 100644 --- a/demo/audio-architecture.md +++ b/demo/audio-architecture.md @@ -400,7 +400,7 @@ Rust 编译为 WASM,通过 `wasm-bindgen` 暴露给前端。 | ----- | ---------- | --------------- | --------------------------------- | | LRC | `lrc.rs` | nom | 标准时间标签格式 `[mm:ss.ms]text` | | TTML | `ttml/` | quick-xml + nom | Apple Music 格式,逐字时间 | -| YRC | `yrc.rs` | nom | 网易云格式,base64 编码 | +| YRC | `yrc.rs` | nom | Netease 格式,base64 编码 | | QRC | `qrc.rs` | nom | QQ 音乐格式,加密 | | EQRC | `eqrc/` | nom | QRC 加密变体 | | LYS | `lys.rs` | nom | 荔枝 FM 格式 | diff --git a/docs/plugins-development.md b/docs/plugins-development.md new file mode 100644 index 0000000..625aec3 --- /dev/null +++ b/docs/plugins-development.md @@ -0,0 +1,334 @@ +# 插件开发指南 + +本文面向想为 SPlayer-Next 编写插件的开发者。插件是一段运行在隔离沙箱里的 JavaScript,通过宿主注入的 `splayer` 全局对象对外提供能力。 + +> 最终用户如何安装使用见 [plugins-usage.md](./plugins-usage.md)。 + +## 快速开始 + +一个最小插件就是一个 `.js` 文件。头部需要 JSDoc 声明元数据: + +```js +/** + * @name Example + * @version 1.0.0 + * @description 示例插件 + * @author you + * @homepage https://example.com + * @platform splayer + * @apiLevel 1 + */ + +splayer.register({ + sources: { + demo: { + name: "Demo 源", + actions: ["musicUrl", "lyric"], + qualities: ["lq", "hq", "lossless"], + }, + }, +}); + +splayer.on("musicUrl", async (req) => { + const { musicInfo, quality } = req; + const resp = await splayer.request( + `https://api.example.com/url?id=${musicInfo.songmid}&q=${quality}`, + { responseType: "json" }, + ); + return { url: resp.body.url, quality, expire: resp.body.expire }; +}); + +splayer.on("lyric", async (req) => { + const { musicInfo } = req; + const resp = await splayer.request(`https://api.example.com/lyric?id=${musicInfo.songmid}`, { + responseType: "json", + }); + return { lyric: resp.body.lrc, tlyric: resp.body.tlrc ?? null }; +}); +``` + +保存为 `example.js`,在 SPlayer 设置里「本地导入」即可。 + +## 脚本头部(JSDoc Manifest) + +| 字段 | 必填 | 上限 | 说明 | +| -------------- | ---- | --------- | ------------------------------------------------------------------ | +| `@name` | ✅ | 24 字符 | 插件展示名 | +| `@version` | ✅ | 36 字符 | 语义化版本号 | +| `@description` | | 256 字符 | 简介 | +| `@author` | | 56 字符 | 作者 | +| `@homepage` | | 1024 字符 | 主页 URL | +| `@platform` | | | `splayer` 或 `lx`,不写默认 `splayer`(`gz_` 压缩脚本默认为 `lx`) | +| `@apiLevel` | | | 声明兼容的 Host API 级别,当前宿主 = 1,超过会拒绝加载 | + +缺少 `@name` 或 `@version` → 安装失败。 + +插件 `id` 由宿主自动生成:`slugify(name) + "-" + sha1(source).slice(0,8)`。你不需要自己指定。 + +## 沙箱环境 + +插件运行在独立 `utilityProcess` 子进程 + `vm.createContext` 双层隔离中: + +- **没有** Node 内置模块(`fs` / `net` / `child_process` 等),**没有** `require` / `import` +- **没有** DOM / Electron API(`window` 仅在 lx 兼容模式下作为 `{ lx }` 垫片) +- **有** 以下全局:`splayer`、`Buffer`、`URL` / `URLSearchParams`、`TextEncoder` / `TextDecoder`、`Promise`、`setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` / `setImmediate` / `clearImmediate` / `queueMicrotask`、`console`(重定向到 `splayer.log`) + +硬性约束: + +- **脚本同步部分执行时间 ≤ 5 秒**(V8 超时后中止并上报脚本错误) +- **从 fork 到脚本 ready ≤ 10 秒**,超时即判定加载失败 +- **每插件并发请求 ≤ 4,全局并发 ≤ 16** +- **心跳间隔 10 秒**,连续 3 次未回 pong 视为卡死,自动杀掉重启 +- 崩溃后重启节奏 **2s / 8s / 30s**,累计 3 次失败进入 `error` 状态 + +## `splayer` API + +注入到沙箱全局的对象。以下是完整表面,基于 Host API level 1。 + +### 只读字段 + +```ts +splayer.pluginId: string // 宿主分配的插件 ID +splayer.apiLevel: number // 宿主 Host API level(= 1) +splayer.locale: string // 当前界面语言(如 "zh-CN") +splayer.appVersion: string // SPlayer 应用版本 +``` + +### 注册能力 + +```ts +splayer.register({ + sources: { + [sourceKey: string]: { + name: string; // 展示名 + actions: ("musicUrl" | "lyric" | "pic")[]; + qualities?: ("lq" | "sq" | "hq" | "lossless" | "hi-res")[]; + }; + }; +}); +``` + +音质等级对齐宿主的 `QualityLevel`(见 `src/utils/quality.ts`): + +| 值 | 含义 | +| ---------- | --------------------------------------------- | +| `hi-res` | 高解析度无损(采样率 ≥ 96kHz + 位深 ≥ 24bit) | +| `lossless` | 无损(flac / ape / wav 等) | +| `hq` | 有损 ≥ 320kbps | +| `sq` | 有损 ≥ 192kbps | +| `lq` | 有损 < 192kbps | + +lx 脚本声明的 `128k/192k/320k/flac/flac24bit/ape/wav` 会被垫片自动映射到上面的等级;handler 收到的 `info.type` 也会反向映射为 lx 原生值,老脚本无需改动。 + +**必须在脚本同步部分调用**。注册完后宿主才知道这个插件能做什么、支持哪些源。 + +### 注册动作处理器 + +```ts +splayer.on("musicUrl", async (req) => res); +splayer.on("lyric", async (req) => res); +splayer.on("pic", async (req) => res); +``` + +每个 action 最多一个 handler,重复注册后者覆盖前者。目前仅这三个动作,搜索和元数据由宿主自身负责。 + +#### 请求/响应形状 + +| Action | 请求 | 响应 | 默认超时 | +| ---------- | -------------------------------- | --------------------------------------- | -------- | +| `musicUrl` | `{ source, quality, musicInfo }` | `{ url, quality?, expire? }` | 20 s | +| `lyric` | `{ source, musicInfo }` | `{ lyric, tlyric?, rlyric?, lxlyric? }` | 15 s | +| `pic` | `{ source, musicInfo }` | `{ url }` | 15 s | + +`musicInfo`:宿主会传至少 `{ songmid }`,可能还带 `name` / `singer` 等用于辅助识别。 + +handler 抛出的异常会被宿主捕获,错误码透传到上层。超时未返回 → 被主进程 cancel。 + +### 网络请求 + +```ts +splayer.request(url: string, opts?: HostRequestOptions): Promise + +interface HostRequestOptions { + method?: "GET" | "POST"; + headers?: Record; + body?: string | ArrayBuffer | Uint8Array; + timeout?: number; // 毫秒,默认 15000,最大 60000 + responseType?: "text" | "json" | "arraybuffer"; // 默认 "text" +} + +interface HostRequestResult { + status: number; + headers: Record; + body: unknown; // text → string, json → 已 parse 对象,arraybuffer → Uint8Array +} +``` + +仅允许 `http://` 和 `https://`。请求由主进程的 `net.fetch` 发出,遵循系统代理设置。 + +### 日志 + +```ts +splayer.log.debug(...args); +splayer.log.info(...args); +splayer.log.warn(...args); +splayer.log.error(...args); +``` + +转发到宿主主日志系统,并落盘到 `{userData}/plugins/logs/{id}.log`。`console.*` 也会自动转发到同样的通道。 + +### 私有 KV 存储 + +```ts +splayer.storage.get(key: string): Promise; +splayer.storage.set(key: string, value: unknown): Promise; +splayer.storage.remove(key: string): Promise; +splayer.storage.keys(): Promise; +``` + +每个插件一个独立命名空间,落盘到 `{userData}/plugins/data/{id}.json`。卸载插件会自动清除。 + +### 用户设置 + +```ts +splayer.getSetting(key: string): T | undefined; +``` + +同步读取用户在设置界面给此插件配置的值。设置 schema 通过 `register()` 扩展的能力将在后续 API level 加入;当前 level 1 下返回 `undefined`。 + +### 工具(`splayer.utils`) + +常用 Node 原语的安全封装,**不需要**自己调用 Node 内置模块: + +```ts +splayer.utils.crypto.md5(data) +splayer.utils.crypto.sha1(data) +splayer.utils.crypto.sha256(data) +splayer.utils.crypto.hmac(algo, key, data) +splayer.utils.crypto.randomBytes(size) +splayer.utils.crypto.aesEncrypt(data, key, mode, iv?) +splayer.utils.crypto.aesDecrypt(data, key, mode, iv?) +splayer.utils.crypto.rsaEncrypt(data, publicKey) + +splayer.utils.buffer.from(data, encoding?) +splayer.utils.buffer.bufToString(buf, encoding?) +splayer.utils.buffer.concat(list) + +splayer.utils.base64.encode(data) +splayer.utils.base64.decode(data) + +splayer.utils.zlib.inflate(data) / deflate(data) +splayer.utils.zlib.gunzip(data) / gzip(data) +``` + +## 错误码 + +handler 抛异常时可通过 `err.code` 带上错误码;不带的话宿主默认 `PLUGIN_HANDLER_ERROR`。 + +| Code | 含义 | +| --------------------------- | ----------------------- | +| `PLUGIN_NOT_FOUND` | 找不到指定插件 | +| `PLUGIN_DISABLED` | 插件已禁用 | +| `PLUGIN_NOT_READY` | 插件未就绪 / 沙箱未启动 | +| `PLUGIN_ACTION_UNSUPPORTED` | 插件没注册该动作 | +| `PLUGIN_LOAD_TIMEOUT` | 加载超 10 秒 | +| `PLUGIN_SCRIPT_ERROR` | 脚本语法或运行错误 | +| `PLUGIN_INVALID_MANIFEST` | 头部字段缺失或不合法 | +| `PLUGIN_API_LEVEL_MISMATCH` | 声明 apiLevel 高于宿主 | +| `PLUGIN_REQUEST_TIMEOUT` | 动作或 request 超时 | +| `PLUGIN_CANCELLED` | 被上层取消 | +| `PLUGIN_NETWORK_ERROR` | 网络错误 | +| `PLUGIN_URL_NOT_ALLOWED` | URL 协议不在白名单 | +| `PLUGIN_HANDLER_ERROR` | handler 默认错误码 | +| `PLUGIN_WORKER_CRASHED` | 子进程崩溃 | + +## 完整示例结构 + +```js +/** + * @name My Plugin + * @version 1.0.0 + * @description 一个多源聚合示例 + * @author you + * @apiLevel 1 + */ + +const SOURCES = ["sa", "sb"]; // 你内部的源标识 + +splayer.register({ + sources: { + sa: { name: "SA 音源", actions: ["musicUrl", "lyric"], qualities: ["lq", "hq"] }, + sb: { name: "SB 音源", actions: ["musicUrl", "pic"], qualities: ["lq", "hq", "lossless"] }, + }, +}); + +const apis = { + sa: { + musicUrl: async ({ musicInfo, quality }) => { + /* ... */ + }, + lyric: async ({ musicInfo }) => { + /* ... */ + }, + }, + sb: { + musicUrl: async ({ musicInfo, quality }) => { + /* ... */ + }, + pic: async ({ musicInfo }) => { + /* ... */ + }, + }, +}; + +const dispatch = (action) => async (req) => { + const fn = apis[req.source]?.[action]; + if (!fn) + throw Object.assign(new Error("source not supported"), { code: "PLUGIN_ACTION_UNSUPPORTED" }); + return fn(req); +}; + +splayer.on("musicUrl", dispatch("musicUrl")); +splayer.on("lyric", dispatch("lyric")); +splayer.on("pic", dispatch("pic")); +``` + +## 调试 + +1. 控制台里(DevTools)直接调: + + ```js + await window.api.plugins.list(); + await window.api.plugins.resolveUrl({ + pluginId: "my-plugin-xxxxxxxx", + source: "sa", + quality: "hq", + musicInfo: { songmid: "123" }, + }); + ``` + +2. 查看 `{userData}/plugins/logs/{id}.log` 拿插件的运行日志 + +3. 修改脚本 → 重新导入一次(id 会因为源码 sha1 变化而变化,旧版本会自动被替换) + +## 发布 + +脚本可以: + +- 直接作为 `.js` 分发(推荐) +- 用 `gz_` 前缀 + zlib + base64 压缩成单行文本(兼容 lx 生态) + +用户通过「本地导入」或「在线导入(粘贴 raw URL)」装上即可。 + +## 兼容 lx 插件 + +SPlayer 提供了 `window.lx` / `globalThis.lx` 垫片,覆盖 lx-music-desktop 的 user_api 常用 API: + +- `lx.request(url, opts, callback)` — 回调风格 HTTP 请求 +- `lx.on("request", handler)` — 注册统一 handler,宿主根据 `action` 分派 +- `lx.send("inited", { sources })` — 异步上报能力 +- `lx.utils.crypto` / `lx.utils.buffer` / `lx.utils.zlib` / `lx.utils.base64` — 与 `splayer.utils` 等价 + +绝大多数 lx 公开脚本无需修改即可运行。头部写 `@platform lx` 或脚本整体以 `gz_` 压缩会自动启用垫片。 + +**注意**:若你在写**新插件**,请直接用 `splayer.*` API,lx 垫片仅用于跑存量 lx 脚本。 diff --git a/docs/plugins-usage.md b/docs/plugins-usage.md new file mode 100644 index 0000000..1d9deb6 --- /dev/null +++ b/docs/plugins-usage.md @@ -0,0 +1,100 @@ +# 插件使用指南 + +SPlayer-Next 支持通过插件扩展音乐能力,包括 URL 解析、歌词、封面等。本文面向最终用户,讲清楚如何安装、启用、管理插件。 + +> 如果你想自己写插件,见 [plugins-development.md](./plugins-development.md)。 + +## 从哪里打开插件管理 + +**设置 → 插件管理**。这里展示所有已安装的插件卡片:名称、版本、作者、状态(已就绪 / 加载中 / 错误 / 已禁用)、支持的源。 + +## 安装插件 + +提供两种方式,效果完全等价: + +### 本地导入 + +点击顶部「**本地导入**」→ 选一个 `.js` 脚本文件。脚本会被复制到: + +``` +{userData}/plugins/scripts/{id}.js +``` + +`{id}` 由 `插件名 + 源码 SHA1 前 8 位` 自动生成,不用你操心。 + +### 在线导入 + +点击「**在线导入**」→ 粘贴一个 `https://` 或 `http://` 链接(指向一个 `.js` 脚本),回车或点「导入」。 + +限制: + +- 仅支持 HTTP/HTTPS +- 单次下载上限 **9 MB** +- 请求超时 15 秒,重定向策略以当前运行时实现为准 +- 下载完后走和本地导入完全一样的落盘 + 加载流程 + +常见链接来源:GitHub raw、Gitee raw、开发者个人站。 + +## 启用 / 禁用 / 切换插件 + +**同一时刻只能启用一个插件**,行为与 lx-music-desktop 的 `user_api` 一致。 + +每张卡片右侧有两个按钮: + +- **启用 / 已启用**:点击未启用的插件 → 立即启用它,并把当前已启用的那个自动关掉 +- **卸载**:会删除脚本文件、清理它的本地 KV 存储、从已安装列表移除,**不可恢复** + +状态徽章的含义: + +| 徽章 | 含义 | +| ------ | ---------------------------------------- | +| 已就绪 | 插件已启动并注册了能力,可以用 | +| 加载中 | 沙箱正在启动或脚本刚在初始化 | +| 错误 | 加载失败或崩溃(卡片下方会显示错误信息) | +| 已禁用 | 用户主动关闭 | +| 未加载 | 导入了但未启动(通常瞬态) | + +## gz\_ 压缩脚本 + +部分插件作者会把脚本用 `gz_` 前缀的方式压缩(base64 + zlib)。SPlayer-Next 会**自动识别并解压**,无需额外操作。压缩脚本默认按 lx 兼容脚本处理。 + +## 兼容 lx 插件 + +我们兼容 lx-music-desktop 的 user_api 脚本。绝大多数 lx 公开脚本可以直接拖入使用,SPlayer 会自动注入 `window.lx` 垫片。两点注意: + +- lx 脚本默认只一个能启用(跟 lx 本身一致) +- 垫片覆盖了 `lx.request` / `lx.on('request')` / `lx.send('inited')` / `lx.utils`(crypto / buffer / zlib / base64)这些最常用的 API,如果脚本用了非主流或 lx 新版 API,可能出现"插件加载错误" + +## 常见问题 + +**1. 导入成功但状态是"错误"?** + +卡片下方会显示错误信息。常见: + +- `PLUGIN_LOAD_TIMEOUT`:脚本启动超 10 秒,通常是首包网络慢或脚本初始化里卡住 +- `PLUGIN_SCRIPT_ERROR`:脚本语法错误或运行抛异常 +- `PLUGIN_API_LEVEL_MISMATCH`:脚本声明的 `@apiLevel` 高于当前 Host API 级别(当前 = 1),需要等宿主升级 + +**2. 插件崩了会怎样?** + +沙箱是独立子进程,不会影响 SPlayer 主进程。崩溃后会自动重启,重启节奏 2s → 8s → 30s,连续 3 次失败后会标记为错误状态,停止重试。 + +**3. 插件的本地数据存哪?** + +``` +{userData}/ +├── plugins/ +│ ├── scripts/{id}.js 脚本源码 +│ ├── manifest.json 已安装列表 +│ └── data/{id}.json 插件私有 KV 存储(splayer.storage 写入的) +``` + +卸载插件会自动清理上述所有内容。插件日志通过 `splayer.log` 转发到主进程日志,不单独落盘。 + +**4. 为什么插件的请求速度很慢?** + +插件请求通过宿主主进程 `net.fetch` 发送,会遵循系统代理。默认超时 15 秒(单次请求),最大可设置 60 秒。 + +**5. 我能同时启用两个插件凑齐 URL 和歌词吗?** + +现阶段**不能**,只能单选。未来可能开放按动作(URL / 歌词 / 封面)分别配置优先级的能力,见项目待办。 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7eb6bb0..fb64815 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -16,13 +16,14 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, "electron/main/index.ts"), + // 插件沙箱 worker(utilityProcess 入口) + "sandbox.worker": resolve(__dirname, "electron/main/plugins/sandbox.worker.ts"), }, }, }, resolve: { alias: { "@main": resolve(__dirname, "electron/main"), - "@server": resolve(__dirname, "electron/server"), "@shared": resolve(__dirname, "shared"), "@splayer/audio-engine": resolve(__dirname, "native/audio-engine"), "@splayer/media-ctrl": resolve(__dirname, "native/media-ctrl"), diff --git a/electron/main/apis/kugou/core/config.ts b/electron/main/apis/kugou/core/config.ts new file mode 100644 index 0000000..a11e160 --- /dev/null +++ b/electron/main/apis/kugou/core/config.ts @@ -0,0 +1,64 @@ +/** + * KG API 通用常量 + */ + +/** 搜索接口(WebFilter 平台,无需鉴权) */ +export const KG_SEARCH_URL = "https://songsearch.kugou.com/song_search_v2"; + +/** 歌词搜索/下载接口(走 lyrics.kugou.com 的 expand_search 通道) */ +export const KG_LYRIC_SEARCH_URL = "http://lyrics.kugou.com/search"; +export const KG_LYRIC_DOWNLOAD_URL = "http://lyrics.kugou.com/download"; + +/** 歌词接口需要的伪装 headers(来自 KuGou2012 PC 客户端) */ +export const KG_LYRIC_HEADERS: Record = { + "KG-RC": "1", + "KG-THash": "expand_search_manager.cpp:852736169:451", + "User-Agent": "KuGou2012-9020-ExpandSearchManager", +}; + +/** HTML 实体反转义(KG 搜索结果含 `&`、`'` 等) */ +const ENTITY_MAP: Record = { + " ": " ", + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + "'": "'", +}; + +export const decodeName = (str: string | null | undefined): string => { + if (!str) return ""; + return str.replace(/ |&|<|>|"|'|'/g, (s) => ENTITY_MAP[s] ?? s); +}; + +/** 歌手数组 `[{name:'A'},{name:'B'}]` → `A / B` */ +export const formatSingerName = ( + singers: Array<{ name?: string }> | undefined, + join = " / ", +): string => { + if (!singers?.length) return ""; + return singers + .map((s) => s.name) + .filter((n): n is string => !!n) + .map(decodeName) + .join(join); +}; + +/** + * 把 `MM:SS` / `HH:MM:SS` 格式的时长字符串转成秒 + * 歌词接口需要秒数作为参数,而搜索结果里已有的 Duration 就是秒,这里只处理兜底情况 + */ +export const intervalToSeconds = (interval: string | number | undefined): number => { + if (typeof interval === "number") return Math.floor(interval); + if (!interval) return 0; + const parts = String(interval).split(":").map(Number); + let seconds = 0; + let unit = 1; + while (parts.length) { + const v = parts.pop(); + if (Number.isFinite(v)) seconds += (v as number) * unit; + unit *= 60; + } + return Math.floor(seconds); +}; diff --git a/electron/main/apis/kugou/core/krc.ts b/electron/main/apis/kugou/core/krc.ts new file mode 100644 index 0000000..bf88a5d --- /dev/null +++ b/electron/main/apis/kugou/core/krc.ts @@ -0,0 +1,115 @@ +/** + * KRC 歌词解密与格式化 + * + * 加密:base64(content) 去头 4 字节 → 与 16 字节定 key 循环 XOR → zlib inflate → UTF-8 文本 + * 文本格式示例:[285,3800]<0,120,0>字<120,200,0>字... + * - 行首 [start_ms, duration_ms] + * - 行内 每字时间 + * + * 本文件输出 4 种歌词: + * - lrc 标准 LRC(行级) + * - krc 逐字 LRC(LX 格式:`字`) + * - trans 翻译(行级 LRC) + * - roma 罗马音(行级 LRC) + * + * key 与解析逻辑来源:lx-music-desktop/src/common/utils/lyricUtils/kg.js + */ + +import { inflate } from "node:zlib"; +import { promisify } from "node:util"; +import { decodeName } from "./config"; + +const inflateAsync = promisify(inflate); + +const KRC_KEY = Uint8Array.from([ + 0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69, +]); + +/** base64 → XOR → inflate → 文本 */ +const decryptKrc = async (base64: string): Promise => { + if (!base64) throw new Error("empty krc content"); + const buf = Buffer.from(base64, "base64").subarray(4); + for (let i = 0; i < buf.length; i++) buf[i] ^= KRC_KEY[i % 16]; + const out = await inflateAsync(buf); + return out.toString("utf8"); +}; + +const HEAD_ID_REG = /^.*\[id:\$\w+\]\n/; +const LANGUAGE_REG = /\[language:([\w=\\/+]+)\]/; +const LANGUAGE_LINE_REG = /\[language:[\w=\\/+]+\]\n/; +const LINE_TIME_REG = /\[((\d+),\d+)\].*/g; +const LINE_TIME_EACH_REG = /\[((\d+),\d+)\].*/; + +/** 把毫秒格式化成 `MM:SS.xxx` */ +const msToTimeTag = (ms: number): string => { + const m = Math.floor(ms / 60000); + const s = Math.floor((ms % 60000) / 1000); + const x = Math.floor(ms % 1000); + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${x}`; +}; + +export interface KrcParsed { + lrc: string; + krc: string; + trans: string; + roma: string; +} + +/** 解析解密后的 KRC 文本 → 四种歌词 */ +const parseKrc = (raw: string): KrcParsed => { + let text = raw.replace(/\r/g, ""); + if (HEAD_ID_REG.test(text)) text = text.replace(HEAD_ID_REG, ""); + + // 翻译 & 罗马音以 [language:base64(json)] 整体嵌入 + let transLines: string[] | undefined; + let romaLines: string[] | undefined; + const langMatch = text.match(LANGUAGE_REG); + if (langMatch) { + text = text.replace(LANGUAGE_LINE_REG, ""); + try { + const json = JSON.parse(Buffer.from(langMatch[1], "base64").toString("utf8")) as { + content?: Array<{ type: number; lyricContent: string[][] }>; + }; + for (const item of json.content ?? []) { + const lines = item.lyricContent.map((arr) => arr.join("")); + if (item.type === 0) romaLines = lines; + else if (item.type === 1) transLines = lines; + } + } catch { + // 译文解析失败不影响主歌词 + } + } + + // 逐行替换行首时间标签:把 [start_ms,dur_ms] 改成 [MM:SS.xxx] + // 同时按行索引同步给翻译/罗马音补上时间头 + let idx = 0; + let krcBody = text.replace(LINE_TIME_REG, (line) => { + const match = line.match(LINE_TIME_EACH_REG); + if (!match) return line; + const startMs = parseInt(match[2], 10); + const timeTag = msToTimeTag(startMs); + if (romaLines && romaLines[idx] !== undefined) romaLines[idx] = `[${timeTag}]${romaLines[idx]}`; + if (transLines && transLines[idx] !== undefined) + transLines[idx] = `[${timeTag}]${transLines[idx]}`; + idx++; + return line.replace(match[1], timeTag); + }); + + // 字级时间标签 (去除末尾的 0) + krcBody = krcBody.replace(/<(\d+,\d+),\d+>/g, "<$1>"); + const krc = decodeName(krcBody); + const lrc = krc.replace(/<\d+,\d+>/g, ""); + + return { + lrc, + krc, + trans: decodeName(transLines ? transLines.join("\n") : ""), + roma: decodeName(romaLines ? romaLines.join("\n") : ""), + }; +}; + +/** 解密并解析一段 KRC base64 内容 */ +export const decodeKrc = async (base64Content: string): Promise => { + const text = await decryptKrc(base64Content); + return parseKrc(text); +}; diff --git a/electron/main/apis/kugou/core/request.ts b/electron/main/apis/kugou/core/request.ts new file mode 100644 index 0000000..4edf6f4 --- /dev/null +++ b/electron/main/apis/kugou/core/request.ts @@ -0,0 +1,61 @@ +/** + * KG 请求层 + * + * 设计: + * - 搜索走 songsearch.kugou.com(JSON,无鉴权),透传查询参数 + * - 歌词走 lyrics.kugou.com(JSON,需要 KG-RC/KG-THash/UA 伪装 PC 客户端) + * - 出错自动重试,最多 3 次;非 200 或 error_code != 0 视为失败 + * - 没有加密 body,纯 fetch GET + */ + +interface FetchOptions { + headers?: Record; + /** 最大重试次数(不含首次),默认 2 → 总共最多 3 次 */ + retry?: number; +} + +interface KGRawBody { + status?: number; + error_code?: number; + errcode?: number; + err_code?: number; + data?: unknown; + info?: unknown; + [key: string]: unknown; +} + +/** + * 发一次 KG GET 请求,返回解析后的 JSON body + * 失败自动重试;超出次数抛错 + */ +export const kgRequest = async ( + url: string, + options: FetchOptions = {}, +): Promise => { + const maxRetry = options.retry ?? 2; + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetry; attempt++) { + try { + const res = await fetch(url, { + method: "GET", + headers: options.headers, + }); + if (res.status !== 200) { + lastError = new Error(`KG HTTP ${res.status}`); + continue; + } + const body = (await res.json()) as KGRawBody; + const code = body.error_code ?? body.errcode ?? body.err_code ?? 0; + if (code !== 0) { + lastError = new Error(`KG API error_code=${code}`); + continue; + } + return body as T; + } catch (err) { + lastError = err; + } + } + + throw lastError instanceof Error ? lastError : new Error("KG request failed"); +}; diff --git a/electron/main/apis/kugou/core/types.ts b/electron/main/apis/kugou/core/types.ts new file mode 100644 index 0000000..bba82b7 --- /dev/null +++ b/electron/main/apis/kugou/core/types.ts @@ -0,0 +1,8 @@ +/** + * KG 模块函数签名 + * 入参来自 IPC 非受控数据,模块内部解构即可 + */ + +export type KGParams = Record; + +export type KGModule = (params: KGParams) => Promise; diff --git a/electron/main/apis/kugou/index.ts b/electron/main/apis/kugou/index.ts new file mode 100644 index 0000000..6e41c0f --- /dev/null +++ b/electron/main/apis/kugou/index.ts @@ -0,0 +1,73 @@ +/** + * KG 主进程服务 + * + * 与 netease 不同之处: + * - 无账号体系、无 cookie、无加密 body,纯 fetch GET + * - 搜索走 songsearch.kugou.com;歌词走 lyrics.kugou.com(需 KG-RC/KG-THash/UA 伪装) + * - 歌词是 hash + 歌名 + 时长 三元组匹配(KG 特有,不能只凭 ID) + * + * 统一入口:callKugou(name, params) + */ + +import { createHash } from "node:crypto"; +import { modules } from "./modules"; +import type { KGParams } from "./core/types"; + +/** 2 分钟响应缓存 */ +const DEFAULT_TTL = 2 * 60 * 1000; +const MAX_ENTRIES = 200; + +interface CacheEntry { + value: unknown; + expireAt: number; +} + +const cache = new Map(); + +const hashParams = (params: unknown): string => + createHash("md5") + .update(JSON.stringify(params ?? {})) + .digest("hex") + .slice(0, 8); + +const cacheGet = (key: string): unknown => { + const hit = cache.get(key); + if (!hit) return undefined; + if (hit.expireAt <= Date.now()) { + cache.delete(key); + return undefined; + } + cache.delete(key); + cache.set(key, hit); + return hit.value; +}; + +const cacheSet = (key: string, value: unknown, ttl = DEFAULT_TTL): void => { + if (cache.size >= MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { value, expireAt: Date.now() + ttl }); +}; + +export const clearKugouCache = (): void => { + cache.clear(); +}; + +/** + * 调用任意 KG API + * @param name 见 modules/index.ts(search / lyric) + * @param params 业务参数;不想命中缓存可传 `timestamp: Date.now()` + */ +export const callKugou = async (name: string, params: KGParams = {}): Promise => { + const fn = modules[name]; + if (!fn) throw new Error(`unknown kg api: ${name}`); + + const key = `${name}|${hashParams(params)}`; + const hit = cacheGet(key); + if (hit !== undefined) return hit; + + const value = await fn(params); + cacheSet(key, value); + return value; +}; diff --git a/electron/main/apis/kugou/modules/index.ts b/electron/main/apis/kugou/modules/index.ts new file mode 100644 index 0000000..b84f303 --- /dev/null +++ b/electron/main/apis/kugou/modules/index.ts @@ -0,0 +1,13 @@ +/** + * KG 模块注册表 + */ + +import type { KGModule } from "../core/types"; + +import lyric from "./lyric"; +import search from "./search"; + +export const modules: Record = { + lyric, + search, +}; diff --git a/electron/main/apis/kugou/modules/lyric.ts b/electron/main/apis/kugou/modules/lyric.ts new file mode 100644 index 0000000..14f6398 --- /dev/null +++ b/electron/main/apis/kugou/modules/lyric.ts @@ -0,0 +1,124 @@ +/** + * 歌词(KG) + * + * 两步流程: + * 1. GET lyrics.kugou.com/search?keyword=&hash=&timelength= → 取第一候选 {id, accesskey, fmt} + * 2. GET lyrics.kugou.com/download?id=&accesskey=&fmt= → base64 content + * - fmt=krc:XOR+zlib 解密 → LRC + 逐字 KRC + 翻译 + 罗马音 + * - fmt=lrc:base64 直接 utf8 解码 → 只有 LRC + * + * 调用这个接口必须同时提供 hash + 歌名 + 时长(秒),这是 KG 的硬要求 + * + * params: + * - hash 文件 hash(搜索结果里的 hash / hashes['128k'] 等) + * - name 歌曲名(URL encode) + * - duration 时长(秒) + */ + +import { + KG_LYRIC_DOWNLOAD_URL, + KG_LYRIC_HEADERS, + KG_LYRIC_SEARCH_URL, + intervalToSeconds, +} from "../core/config"; +import { kgRequest } from "../core/request"; +import { decodeKrc } from "../core/krc"; +import type { KGModule } from "../core/types"; + +interface KGLyricCandidate { + id: string; + accesskey: string; + /** 1 = 逐字,0 = 行级 */ + krctype?: number; + contenttype?: number; +} + +interface KGLyricSearchResp { + candidates?: KGLyricCandidate[]; +} + +interface KGLyricDownloadResp { + fmt?: string; + content?: string; +} + +interface LyricOut { + code: number; + lrc?: string; + krc?: string; + trans?: string; + roma?: string; + message?: string; +} + +const lyric: KGModule = async (params) => { + const { + hash, + name = "", + duration, + } = params as { + hash?: string; + name?: string; + duration?: number | string; + }; + + if (!hash) return { code: 400, message: "hash required" } satisfies LyricOut; + + const seconds = intervalToSeconds(duration); + + try { + // 第 1 步:按 hash+name+时长 查候选 + const searchUrl = + `${KG_LYRIC_SEARCH_URL}?ver=1&man=yes&client=pc&lrctxt=1` + + `&keyword=${encodeURIComponent(name)}` + + `&hash=${encodeURIComponent(hash)}` + + `&timelength=${seconds}`; + const searchResp = await kgRequest(searchUrl, { + headers: KG_LYRIC_HEADERS, + }); + + const candidate = searchResp.candidates?.[0]; + if (!candidate) return { code: 404, message: "no lyric candidate" } satisfies LyricOut; + + const fmt = candidate.krctype === 1 && candidate.contenttype !== 1 ? "krc" : "lrc"; + + // 第 2 步:下载 + 解码 + const downloadUrl = + `${KG_LYRIC_DOWNLOAD_URL}?ver=1&client=pc&charset=utf8` + + `&id=${encodeURIComponent(candidate.id)}` + + `&accesskey=${encodeURIComponent(candidate.accesskey)}` + + `&fmt=${fmt}`; + const dl = await kgRequest(downloadUrl, { + headers: KG_LYRIC_HEADERS, + }); + + if (!dl.content) return { code: 404, message: "empty lyric" } satisfies LyricOut; + + if (dl.fmt === "krc") { + const parsed = await decodeKrc(dl.content); + return { + code: 200, + lrc: parsed.lrc, + krc: parsed.krc, + trans: parsed.trans || undefined, + roma: parsed.roma || undefined, + } satisfies LyricOut; + } + + if (dl.fmt === "lrc") { + return { + code: 200, + lrc: Buffer.from(dl.content, "base64").toString("utf8"), + } satisfies LyricOut; + } + + return { code: 500, message: `unknown lyric fmt: ${dl.fmt}` } satisfies LyricOut; + } catch (err) { + return { + code: 500, + message: err instanceof Error ? err.message : String(err), + } satisfies LyricOut; + } +}; + +export default lyric; diff --git a/electron/main/apis/kugou/modules/search.ts b/electron/main/apis/kugou/modules/search.ts new file mode 100644 index 0000000..29bd53c --- /dev/null +++ b/electron/main/apis/kugou/modules/search.ts @@ -0,0 +1,134 @@ +/** + * 搜索歌曲(KG) + * endpoint: https://songsearch.kugou.com/song_search_v2 + * + * params: + * - keywords 关键词(必填) + * - page 页码,默认 1 + * - limit 每页数,默认 30 + * + * 返回字段遵循 qqmusic.search 的结构(duration 为毫秒),并额外带上 + * KG 特有的 hash / audioId / 多品质 sizes&hashes,播放/歌词都要用 + */ + +import { KG_SEARCH_URL, decodeName, formatSingerName } from "../core/config"; +import { kgRequest } from "../core/request"; +import type { KGModule } from "../core/types"; + +interface KGSearchSong { + Audioid: number; + SongName: string; + Singers?: Array<{ name?: string }>; + AlbumName?: string; + AlbumID?: number | string; + Duration: number; + FileHash: string; + FileSize: number; + HQFileHash?: string; + HQFileSize?: number; + SQFileHash?: string; + SQFileSize?: number; + ResFileHash?: string; + ResFileSize?: number; + Grp?: KGSearchSong[]; +} + +interface KGSearchResp { + status?: number; + error_code?: number; + data?: { + total?: number; + lists?: KGSearchSong[]; + }; +} + +type Quality = "128k" | "320k" | "flac" | "flac24bit"; + +const normalizeSong = (raw: KGSearchSong) => { + const sizes: Partial> = {}; + const hashes: Partial> = {}; + + if (raw.FileSize) { + sizes["128k"] = raw.FileSize; + hashes["128k"] = raw.FileHash; + } + if (raw.HQFileSize && raw.HQFileHash) { + sizes["320k"] = raw.HQFileSize; + hashes["320k"] = raw.HQFileHash; + } + if (raw.SQFileSize && raw.SQFileHash) { + sizes.flac = raw.SQFileSize; + hashes.flac = raw.SQFileHash; + } + if (raw.ResFileSize && raw.ResFileHash) { + sizes.flac24bit = raw.ResFileSize; + hashes.flac24bit = raw.ResFileHash; + } + + return { + id: String(raw.Audioid), + audioId: raw.Audioid, + hash: raw.FileHash, + name: decodeName(raw.SongName), + artist: formatSingerName(raw.Singers), + album: decodeName(raw.AlbumName ?? ""), + albumId: raw.AlbumID ?? "", + /** 秒 */ + interval: raw.Duration, + /** 毫秒,与其它源对齐 */ + duration: raw.Duration * 1000, + /** 支持的品质列表(从高到低) */ + qualities: Object.keys(hashes) as Quality[], + hashes, + sizes, + }; +}; + +const search: KGModule = async (params) => { + const { + keywords, + page = 1, + limit = 30, + } = params as { + keywords?: string; + page?: number; + limit?: number; + }; + + if (!keywords) { + return { code: 400, total: 0, songs: [], message: "keywords required" }; + } + + const url = + `${KG_SEARCH_URL}?keyword=${encodeURIComponent(keywords)}` + + `&page=${page}&pagesize=${limit}` + + `&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`; + + const body = await kgRequest(url); + + const raw = body.data?.lists ?? []; + const songs: ReturnType[] = []; + // 去重键:audioId + hash(同一首歌不同品质会出现多条) + const seen = new Set(); + + const push = (item: KGSearchSong) => { + const key = `${item.Audioid}_${item.FileHash}`; + if (seen.has(key)) return; + seen.add(key); + songs.push(normalizeSong(item)); + }; + + for (const item of raw) { + push(item); + // Grp 里是翻唱/不同版本,一并展开 + for (const sub of item.Grp ?? []) push(sub); + } + + return { + code: 200, + total: body.data?.total ?? songs.length, + songs, + }; +}; + +export default search; diff --git a/electron/server/artistAvatar.ts b/electron/main/apis/musicbrainz.ts similarity index 100% rename from electron/server/artistAvatar.ts rename to electron/main/apis/musicbrainz.ts diff --git a/electron/main/apis/netease/core/cache.ts b/electron/main/apis/netease/core/cache.ts new file mode 100644 index 0000000..7069e5d --- /dev/null +++ b/electron/main/apis/netease/core/cache.ts @@ -0,0 +1,74 @@ +/** + * 接口响应内存缓存 + * + * 对齐原 @neteasecloudmusicapienhanced/api util/apicache.js 的行为: + * - 默认 2 分钟 TTL + * - 只缓存 status === 200 的响应 + * - key = `${name}|${md5(params)}` + */ + +import { createHash } from "node:crypto"; + +/** 默认 TTL:2 分钟 */ +const DEFAULT_TTL = 2 * 60 * 1000; + +/** 容量上限:超过后 LRU 淘汰 */ +const MAX_ENTRIES = 200; + +interface CacheEntry { + value: { status: number; body: unknown }; + expireAt: number; +} + +const store = new Map(); + +/** 快速稳定 hash:JSON.stringify + md5 8 位前缀 */ +const hash = (params: unknown): string => + createHash("md5") + .update(JSON.stringify(params ?? {})) + .digest("hex") + .slice(0, 8); + +/** 构造缓存 key */ +export const buildCacheKey = (name: string, params: unknown): string => `${name}|${hash(params)}`; + +/** + * 获取缓存 + * @param key 缓存 key + * @returns 缓存 value + */ +export const cacheGet = (key: string): CacheEntry["value"] | undefined => { + const hit = store.get(key); + if (!hit) return undefined; + if (hit.expireAt <= Date.now()) { + store.delete(key); + return undefined; + } + // LRU:命中时重新插入到末尾 + store.delete(key); + store.set(key, hit); + return hit.value; +}; + +/** + * 设置缓存 + * @param key 缓存 key + * @param value 缓存 value + * @param ttl 缓存 TTL + */ +export const cacheSet = ( + key: string, + value: CacheEntry["value"], + ttl: number = DEFAULT_TTL, +): void => { + if (store.size >= MAX_ENTRIES) { + const oldest = store.keys().next().value; + if (oldest !== undefined) store.delete(oldest); + } + store.set(key, { value, expireAt: Date.now() + ttl }); +}; + +/** 清空全部 */ +export const cacheClear = (): void => { + store.clear(); +}; diff --git a/electron/main/apis/netease/core/config.ts b/electron/main/apis/netease/core/config.ts new file mode 100644 index 0000000..812571d --- /dev/null +++ b/electron/main/apis/netease/core/config.ts @@ -0,0 +1,79 @@ +/** + * Netease API 通用常量 + * 来源:@neteasecloudmusicapienhanced/api util/crypto.js + util/request.js + util/config.json + */ + +/** AES-CBC 初始向量 */ +export const IV = "0102030405060708"; +/** weapi 第一层 AES 预设密钥 */ +export const PRESET_KEY = "0CoJUm6Qyw8W8jud"; +/** linuxapi AES 密钥 */ +export const LINUX_API_KEY = "rFgB&h#%2?^eDg:Q"; +/** eapi AES 密钥 */ +export const EAPI_KEY = "e82ckenh8dichen8"; +/** weapi 随机 secretKey 字符集 */ +export const BASE62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +/** weapi RSA 公钥(1024bit) */ +export const PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB +-----END PUBLIC KEY-----`; + +/** web 域名(weapi) */ +export const DOMAIN = "https://music.163.com"; +/** 客户端接口域名(api/eapi) */ +export const API_DOMAIN = "https://interface.music.163.com"; +/** 是否默认对响应体做 eapi 加密(e_r) */ +export const ENCRYPT_RESPONSE = false; + +/** 这些业务码视作 HTTP 200 成功(接口层的登录/重定向/限流语义) */ +export const SPECIAL_STATUS_CODES: ReadonlySet = new Set([ + 201, 302, 400, 502, 800, 801, 802, 803, +]); + +/** 客户端伪装:不同 os 标识对应的 appver / osver / channel */ +export const OS_MAP = { + pc: { + os: "pc", + appver: "3.1.17.204416", + osver: "Microsoft-Windows-10-Professional-build-19045-64bit", + channel: "netease", + }, + linux: { + os: "linux", + appver: "1.2.1.0428", + osver: "Deepin 20.9", + channel: "netease", + }, + android: { + os: "android", + appver: "8.20.20.231215173437", + osver: "14", + channel: "xiaomi", + }, + iphone: { + os: "iPhone OS", + appver: "9.0.90", + osver: "16.2", + channel: "distribution", + }, +} as const; + +/** 不同加密方式 + 设备类型下的 User-Agent */ +export const UA_MAP = { + weapi: { + pc: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0", + }, + linuxapi: { + linux: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36", + }, + api: { + pc: "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.0.18.203152", + android: + "NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)", + iphone: "NeteaseMusic 9.0.90/5038 (iPhone; iOS 16.2; zh_CN)", + }, +} as const; + +/** 支持的加密方式 */ +export type CryptoMode = "weapi" | "linuxapi" | "eapi" | "api"; diff --git a/electron/main/apis/netease/core/cookie.ts b/electron/main/apis/netease/core/cookie.ts new file mode 100644 index 0000000..8c177fb --- /dev/null +++ b/electron/main/apis/netease/core/cookie.ts @@ -0,0 +1,30 @@ +/** + * Cookie 解析与拼装 + * 对齐 @neteasecloudmusicapienhanced/api util/index.js 中的 cookieToJson / cookieObjToString + */ + +/** + * 将 cookie 字符串转换为对象 + * @param cookie cookie 字符串 + * @returns 对象 + */ +export const cookieToJson = (cookie: string | undefined): Record => { + if (!cookie) return {}; + const obj: Record = {}; + for (const item of cookie.split(";")) { + const eq = item.indexOf("="); + if (eq <= 0) continue; + obj[item.slice(0, eq).trim()] = item.slice(eq + 1).trim(); + } + return obj; +}; + +/** + * 将对象转换为 cookie 字符串 + * @param cookie 对象 + * @returns cookie 字符串 + */ +export const cookieObjToString = (cookie: Record): string => + Object.keys(cookie) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(String(cookie[key]))}`) + .join("; "); diff --git a/electron/main/apis/netease/core/crypto.ts b/electron/main/apis/netease/core/crypto.ts new file mode 100644 index 0000000..ea154c5 --- /dev/null +++ b/electron/main/apis/netease/core/crypto.ts @@ -0,0 +1,154 @@ +/** + * Netease API 加解密层(完整移植自 @neteasecloudmusicapienhanced/api util/crypto.js) + * + * - 三套加密:weapi(web 端)、linuxapi(Linux 客户端)、eapi(桌面/移动客户端) + * - 对应三把对称密钥 + 一把 RSA 公钥 + * - 原实现依赖 crypto-js + node-forge,这里用 Node 原生 node:crypto 等价重写 + */ + +import { + createCipheriv, + createDecipheriv, + createHash, + publicEncrypt, + constants, + randomInt, +} from "node:crypto"; +import { gunzipSync } from "node:zlib"; +import { BASE62, EAPI_KEY, IV, LINUX_API_KEY, PRESET_KEY, PUBLIC_KEY } from "./config"; + +/** + * AES 加密 + * @param text 明文 + * @param mode 加密模式 + * @param key 密钥 + * @param iv 初始化向量 + * @param format 输出格式 + * @returns 加密后的文本 + */ +export const aesEncrypt = ( + text: string | Buffer, + mode: "cbc" | "ecb", + key: string, + iv: string, + format: "base64" | "hex" = "base64", +): string => { + const algorithm = mode === "cbc" ? "aes-128-cbc" : "aes-128-ecb"; + const ivBuf = mode === "cbc" ? Buffer.from(iv, "utf8") : Buffer.alloc(0); + const cipher = createCipheriv(algorithm, Buffer.from(key, "utf8"), ivBuf); + const encrypted = Buffer.concat([ + cipher.update(typeof text === "string" ? Buffer.from(text, "utf8") : text), + cipher.final(), + ]); + return format === "base64" + ? encrypted.toString("base64") + : encrypted.toString("hex").toUpperCase(); +}; + +/** + * AES 解密 + * @param ciphertext 密文 + * @param key 密钥 + * @param format 输出格式 + * @returns 解密后的文本 + */ +export const aesDecrypt = ( + ciphertext: string, + key: string, + format: "base64" | "hex" = "base64", +): Buffer => { + const decipher = createDecipheriv("aes-128-ecb", Buffer.from(key, "utf8"), Buffer.alloc(0)); + const input = Buffer.from(ciphertext, format); + return Buffer.concat([decipher.update(input), decipher.final()]); +}; + +/** + * RSA 加密 + * + * 网易云的 weapi 要求「裸 RSA」:将明文左侧补 0 到 128 字节(1024bit 模长), + * 再用公钥做一次模幂运算,输出 hex。node:crypto 的 RSA_NO_PADDING 正好对应 + */ +export const rsaEncrypt = (str: string, publicKey: string = PUBLIC_KEY): string => { + const buffer = Buffer.alloc(128); + const data = Buffer.from(str, "utf8"); + data.copy(buffer, 128 - data.length); + const encrypted = publicEncrypt({ key: publicKey, padding: constants.RSA_NO_PADDING }, buffer); + return encrypted.toString("hex"); +}; + +/** + * weapi 加密 + * 1) 生成 16 字节随机 base62 secretKey + * 2) 明文经 AES-CBC(PRESET_KEY) 加密一次,再用 secretKey 再加密一次 + * 3) secretKey 倒序后用 RSA 加密为 encSecKey + * @param object 业务参数 + * @returns 加密后的参数 + */ +export const weapi = (object: unknown): { params: string; encSecKey: string } => { + const text = JSON.stringify(object); + let secretKey = ""; + for (let i = 0; i < 16; i++) { + secretKey += BASE62.charAt(randomInt(0, 62)); + } + const first = aesEncrypt(text, "cbc", PRESET_KEY, IV); + const params = aesEncrypt(first, "cbc", secretKey, IV); + const encSecKey = rsaEncrypt(secretKey.split("").reverse().join("")); + return { params, encSecKey }; +}; + +/** + * linuxapi 加密 + * @param object 业务参数 + * @returns 加密后的参数 + */ +export const linuxapi = (object: unknown): { eparams: string } => { + const text = JSON.stringify(object); + return { eparams: aesEncrypt(text, "ecb", LINUX_API_KEY, "", "hex") }; +}; + +/** + * eapi 加密 + * 1) 用 url + 明文 + 固定盐拼接后 MD5 作为签名 digest + * 2) 整串 `${url}-36cd479b6b5-${text}-36cd479b6b5-${digest}` 经 AES-ECB(hex) 加密 + * @param url 接口路径 + * @param object 业务参数 + * @returns 加密后的参数 + */ +export const eapi = (url: string, object: unknown): { params: string } => { + const text = typeof object === "object" ? JSON.stringify(object) : String(object); + const message = `nobody${url}use${text}md5forencrypt`; + const digest = createHash("md5").update(message).digest("hex"); + const data = `${url}-36cd479b6b5-${text}-36cd479b6b5-${digest}`; + return { params: aesEncrypt(data, "ecb", EAPI_KEY, "", "hex") }; +}; + +/** + * eapi 响应解密 + * @param encryptedHex 加密后的文本 + * @param aeapi 是否是 gzip 压缩的 + * @returns 解密后的文本 + */ +export const eapiResDecrypt = (encryptedHex: string, aeapi = false): unknown => { + try { + const decrypted = aesDecrypt(encryptedHex, EAPI_KEY, "hex"); + if (aeapi) { + const decompressed = gunzipSync(decrypted); + return JSON.parse(decompressed.toString("utf8")); + } + return JSON.parse(decrypted.toString("utf8")); + } catch { + return null; + } +}; + +/** + * eapi 请求体解密 + * @param encryptedHex 加密后的文本 + * @returns 解密后的文本 + */ +export const eapiReqDecrypt = (encryptedHex: string): { url: string; data: unknown } | null => { + const text = aesDecrypt(encryptedHex, EAPI_KEY, "hex").toString("utf8"); + const match = text.match(/(.*?)-36cd479b6b5-(.*?)-36cd479b6b5-(.*)/); + if (!match) return null; + return { url: match[1], data: JSON.parse(match[2]) }; +}; diff --git a/electron/main/apis/netease/core/device.ts b/electron/main/apis/netease/core/device.ts new file mode 100644 index 0000000..65c14ef --- /dev/null +++ b/electron/main/apis/netease/core/device.ts @@ -0,0 +1,29 @@ +/** + * 进程级设备信息 + * - deviceId:52 位大写十六进制字符,进程启动时生成一次 + * - anonymous token:匿名态下注入 header 的 MUSIC_A(由 register_anonimous 接口返回后刷新) + */ + +import { randomBytes } from "node:crypto"; + +const generate = (): string => randomBytes(26).toString("hex").toUpperCase(); + +let deviceId = generate(); +let anonymousToken = ""; + +export const getDeviceId = (): string => deviceId; + +export const setDeviceId = (id: string): void => { + deviceId = id; +}; + +export const regenerateDeviceId = (): string => { + deviceId = generate(); + return deviceId; +}; + +export const getAnonymousToken = (): string => anonymousToken; + +export const setAnonymousToken = (token: string): void => { + anonymousToken = token; +}; diff --git a/electron/main/apis/netease/core/option.ts b/electron/main/apis/netease/core/option.ts new file mode 100644 index 0000000..ccf9888 --- /dev/null +++ b/electron/main/apis/netease/core/option.ts @@ -0,0 +1,47 @@ +/** + * 请求 options 工厂 + * 对齐 @neteasecloudmusicapienhanced/api util/option.js:从调用方 query 中抽取 + * crypto / cookie / ua / proxy / realIP 等,拼成 createRequest 的第三参数 + */ + +import type { CryptoMode } from "./config"; +import type { RequestOptions } from "./request"; + +/** 调用方传入的可选参数 */ +export interface Query { + /** 加密方式 */ + crypto?: CryptoMode; + /** 预注入 cookie,可为字符串或对象 */ + cookie?: string | Record; + /** 自定义 User-Agent */ + ua?: string; + /** 真实 IP(X-Real-IP / X-Forwarded-For) */ + realIP?: string; + /** 真实 IP(X-Real-IP / X-Forwarded-For) */ + ip?: string; + /** 是否让服务端加密响应体(仅 weapi/eapi 有效) */ + e_r?: boolean; + /** 自定义 Referer/域名覆盖 */ + domain?: string; + /** 强制附加 anti-cheat token(暂未启用) */ + checkToken?: boolean; + /** 其他可选参数 */ + [key: string]: unknown; +} + +/** + * 创建请求 options + * @param query 调用方传入的可选参数 + * @param crypto 加密方式 + * @returns 请求 options + */ +export const createOption = (query: Query, crypto: CryptoMode | "" = ""): RequestOptions => ({ + crypto: (query.crypto as CryptoMode | undefined) || crypto, + cookie: query.cookie, + ua: query.ua || "", + realIP: query.realIP, + ip: query.ip, + e_r: query.e_r, + domain: query.domain || "", + checkToken: query.checkToken || false, +}); diff --git a/electron/main/apis/netease/core/request.ts b/electron/main/apis/netease/core/request.ts new file mode 100644 index 0000000..e324823 --- /dev/null +++ b/electron/main/apis/netease/core/request.ts @@ -0,0 +1,265 @@ +/** + * Netease API 请求层(完整移植自 @neteasecloudmusicapienhanced/api util/request.js) + * + * 核心职责:根据加密方式(weapi / linuxapi / eapi / api)构造 URL、headers、form body, + * 处理 cookie 合并、响应解密、状态码归一化。使用 Node 原生 fetch 替代 axios。 + */ + +import { randomBytes } from "node:crypto"; +import { + API_DOMAIN, + DOMAIN, + ENCRYPT_RESPONSE, + OS_MAP, + SPECIAL_STATUS_CODES, + UA_MAP, + type CryptoMode, +} from "./config"; +import { cookieObjToString, cookieToJson } from "./cookie"; +import * as encrypt from "./crypto"; +import { getAnonymousToken, getDeviceId } from "./device"; + +/** 调用方传入的可选参数 */ +export interface RequestOptions { + /** 加密方式;省略时依据路径默认规则,详见 createRequest */ + crypto?: CryptoMode | ""; + /** 预注入 cookie,可为字符串或对象 */ + cookie?: string | Record; + /** 自定义 User-Agent */ + ua?: string; + /** 自定义 Referer/域名覆盖 */ + domain?: string; + /** 真实 IP(X-Real-IP / X-Forwarded-For) */ + realIP?: string; + ip?: string; + /** 是否让服务端加密响应体(仅 weapi/eapi 有效) */ + e_r?: boolean; + /** 强制附加 anti-cheat token(暂未启用) */ + checkToken?: boolean; +} + +/** 响应统一结构 */ +export interface RequestResponse { + status: number; + body: Record; + cookie: string[]; +} + +interface NeteaseBody { + code?: number | string; + [key: string]: unknown; +} + +/** weapi 专用 CSRF:从 cookie 中取 __csrf */ +const csrfFrom = (cookie: Record): string => cookie["__csrf"] || ""; + +/** 生成 WNMCID(进程级常量):6 位小写字母.时间戳.01.0 */ +const WNMCID = (() => { + const chars = "abcdefghijklmnopqrstuvwxyz"; + let s = ""; + for (let i = 0; i < 6; i++) s += chars.charAt(Math.floor(Math.random() * chars.length)); + return `${s}.${Date.now()}.01.0`; +})(); + +/** 每次请求生成:timestamp_XXXX 的递增式 id */ +const generateRequestId = (): string => { + const rand = Math.floor(Math.random() * 1000) + .toString() + .padStart(4, "0"); + return `${Date.now()}_${rand}`; +}; + +/** 补齐 cookie:注入 _ntes_nuid/_ntes_nnid/WNMCID/deviceId/appver 等客户端必备字段 */ +const processCookieObject = ( + cookie: Record, + uri: string, +): Record => { + const ntesNuid = cookie._ntes_nuid || randomBytes(16).toString("hex"); + const os = OS_MAP[(cookie.os as keyof typeof OS_MAP) || "pc"] || OS_MAP.pc; + + const processed: Record = { + ...cookie, + __remember_me: "true", + ntes_kaola_ad: "1", + _ntes_nuid: cookie._ntes_nuid || ntesNuid, + _ntes_nnid: cookie._ntes_nnid || `${ntesNuid},${Date.now()}`, + WNMCID: cookie.WNMCID || WNMCID, + WEVNSM: cookie.WEVNSM || "1.0.0", + osver: cookie.osver || os.osver, + deviceId: cookie.deviceId || getDeviceId(), + os: cookie.os || os.os, + channel: cookie.channel || os.channel, + appver: cookie.appver || os.appver, + }; + + // 登录类接口不带 NMTID(服务端要求) + if (uri.indexOf("login") === -1) { + processed.NMTID = randomBytes(8).toString("hex"); + } + + if (!processed.MUSIC_U) { + processed.MUSIC_A = processed.MUSIC_A || getAnonymousToken(); + if (!processed.MUSIC_A) delete processed.MUSIC_A; + } + + return processed; +}; + +/** 根据加密方式 + 设备类型选择 User-Agent */ +const chooseUserAgent = ( + crypto: keyof typeof UA_MAP, + uaType: "pc" | "android" | "iphone" | "linux" = "pc", +): string => { + const map = UA_MAP[crypto] as Record | undefined; + return (map && map[uaType]) || ""; +}; + +/** + * 构造并发送请求 + * @param uri 接口路径,例如 `/api/w/login`;weapi 会自动替换前缀为 `/weapi/` + * @param data 业务参数(不含 cookie/csrf,请求层会自动注入) + * @param options 加密方式 / cookie / 代理等 + */ +export const createRequest = async ( + uri: string, + data: Record, + options: RequestOptions, +): Promise => { + const headers: Record = {}; + const ip = options.realIP || options.ip || ""; + if (ip) { + headers["X-Real-IP"] = ip; + headers["X-Forwarded-For"] = ip; + } + + // 归一化 cookie 到对象并做一次补全 + let cookie: Record = + typeof options.cookie === "string" ? cookieToJson(options.cookie) : options.cookie || {}; + cookie = processCookieObject(cookie, uri); + headers["Cookie"] = cookieObjToString(cookie); + + let crypto: CryptoMode | "" = options.crypto ?? ""; + if (crypto === "") crypto = "eapi"; + + const csrfToken = csrfFrom(cookie); + const useER = toBoolean( + options.e_r !== undefined ? options.e_r : data.e_r !== undefined ? data.e_r : ENCRYPT_RESPONSE, + ); + data.e_r = useER; + + let url = ""; + let encryptData: Record | typeof data; + + switch (crypto) { + case "weapi": { + headers["Referer"] = options.domain || DOMAIN; + headers["User-Agent"] = options.ua || chooseUserAgent("weapi"); + data.csrf_token = csrfToken; + encryptData = encrypt.weapi(data); + url = (options.domain || DOMAIN) + "/weapi/" + uri.slice(5); + break; + } + case "linuxapi": { + headers["User-Agent"] = options.ua || chooseUserAgent("linuxapi", "linux"); + encryptData = encrypt.linuxapi({ + method: "POST", + url: (options.domain || DOMAIN) + uri, + params: data, + }); + url = (options.domain || DOMAIN) + "/api/linux/forward"; + break; + } + case "eapi": + case "api": { + const header: Record = { + osver: cookie.osver, + deviceId: cookie.deviceId, + os: cookie.os, + appver: cookie.appver, + versioncode: cookie.versioncode || "140", + mobilename: cookie.mobilename || "", + buildver: cookie.buildver || Date.now().toString().slice(0, 10), + resolution: cookie.resolution || "1920x1080", + __csrf: csrfToken, + channel: cookie.channel, + requestId: generateRequestId(), + }; + if (cookie.MUSIC_U) header.MUSIC_U = cookie.MUSIC_U; + if (cookie.MUSIC_A) header.MUSIC_A = cookie.MUSIC_A; + headers["Cookie"] = cookieObjToString(header); + headers["User-Agent"] = options.ua || chooseUserAgent("api", "iphone"); + + if (crypto === "eapi") { + (data as Record).header = header; + encryptData = encrypt.eapi(uri, data); + url = (options.domain || API_DOMAIN) + "/eapi/" + uri.slice(5); + } else { + url = (options.domain || API_DOMAIN) + uri; + encryptData = data; + } + break; + } + default: + throw new Error(`Unknown crypto: ${crypto}`); + } + + const body = new URLSearchParams(encryptData as Record).toString(); + headers["Content-Type"] = "application/x-www-form-urlencoded"; + + const answer: RequestResponse = { status: 500, body: {}, cookie: [] }; + const needDecrypt = (crypto === "eapi" || crypto === "weapi") && useER; + + let res: Response; + try { + res = await fetch(url, { method: "POST", headers, body }); + } catch (err) { + answer.status = 502; + answer.body = { code: 502, msg: err instanceof Error ? err.message : String(err) }; + throw answer; + } + + // 收集 set-cookie(Node fetch 通过 headers.getSetCookie 暴露原始多值头) + const setCookie = + (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? + (res.headers.get("set-cookie") ? [res.headers.get("set-cookie") as string] : []); + answer.cookie = setCookie.map((x) => x.replace(/\s*Domain=[^(;|$)]+;*/, "")); + + let parsed: NeteaseBody; + try { + if (needDecrypt) { + const buf = Buffer.from(await res.arrayBuffer()); + parsed = encrypt.eapiResDecrypt( + buf.toString("hex").toUpperCase(), + headers["x-aeapi"] === "true", + ) as NeteaseBody; + } else { + const text = await res.text(); + try { + parsed = JSON.parse(text); + } catch { + parsed = { code: res.status, raw: text }; + } + } + answer.body = parsed; + if (parsed?.code !== undefined) parsed.code = Number(parsed.code); + answer.status = Number(parsed?.code || res.status); + if (typeof parsed?.code === "number" && SPECIAL_STATUS_CODES.has(parsed.code)) { + answer.status = 200; + } + } catch { + answer.body = { code: res.status, msg: "parse failed" }; + answer.status = res.status; + } + + answer.status = answer.status > 100 && answer.status < 600 ? answer.status : 400; + + if (answer.status === 200) return answer; + throw answer; +}; + +/** 宽松的 boolean 解析:原始 util/index.js 里的 toBoolean */ +const toBoolean = (val: unknown): boolean => { + if (typeof val === "boolean") return val; + if (val === "") return false; + return val === "true" || val === "1" || val === 1; +}; diff --git a/electron/main/apis/netease/core/types.ts b/electron/main/apis/netease/core/types.ts new file mode 100644 index 0000000..0e280ee --- /dev/null +++ b/electron/main/apis/netease/core/types.ts @@ -0,0 +1,13 @@ +/** + * Netease API 模块函数签名 + * 每个 module 都是 `(query, request) => Promise` + */ + +import type { createRequest, RequestResponse } from "./request"; +import type { Query } from "./option"; + +export type RequestFn = typeof createRequest; + +export type NeteaseModule = (query: Query, request: RequestFn) => Promise; + +export type { Query, RequestResponse }; diff --git a/electron/main/apis/netease/index.ts b/electron/main/apis/netease/index.ts new file mode 100644 index 0000000..46ef59c --- /dev/null +++ b/electron/main/apis/netease/index.ts @@ -0,0 +1,130 @@ +/** + * Netease API 主进程服务 + * + * 直接在 Node 侧实现加解密 + HTTP 调用,不再依赖任何网易云服务端 npm 包。 + * 加密算法等核心逻辑移植自 @neteasecloudmusicapienhanced/api(见 core/crypto.ts)。 + * + * 统一入口 `callNetease(name, params)`: + * 1) 从 sessions 表加载 cookies 注入(内存缓存,不重复读 SQLite) + * 2) 走一层内存响应缓存(2 分钟,对齐原包 apicache 行为) + * 3) 路由到 modules/ + * 4) 只在登录相关接口上把响应 set-cookie 写回 sessions;其它接口不落库 + */ + +import { + clearSessionCookies, + getSessionCookies, + saveSessionCookies, +} from "@main/database/sessions"; +import { buildCacheKey, cacheClear, cacheGet, cacheSet } from "./core/cache"; +import { cookieToJson } from "./core/cookie"; +import { createRequest } from "./core/request"; +import { modules } from "./modules"; + +/** 会变更登录态的接口:响应里若带 set-cookie,才值得写回 SQLite */ +const SESSION_MUTATING: ReadonlySet = new Set([ + "login", + "login_cellphone", + "login_qr_check", + "login_refresh", + "logout", + "register_anonimous", +]); + +/** 内存缓存:避免每次调用都走 SELECT(SQLite 仅在首次 load 时读一次) */ +let sessionCache: Record | null = null; + +const loadSession = (): Record => { + if (!sessionCache) sessionCache = getSessionCookies("netease"); + return sessionCache; +}; + +const persistSession = (cookies: Record): void => { + sessionCache = cookies; + saveSessionCookies("netease", cookies); +}; + +/** "k1=v1; k2=v2; ..." 形式序列化 */ +const serialize = (cookies: Record): string => + Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + +export const getNeteaseCookies = (): Record => ({ ...loadSession() }); + +export const setNeteaseCookies = (cookies: Record): void => { + persistSession(cookies); + cacheClear(); +}; + +export const mergeNeteaseCookies = (patch: Record): void => { + persistSession({ ...loadSession(), ...patch }); + cacheClear(); +}; + +export const clearNeteaseCookies = (): void => { + sessionCache = {}; + clearSessionCookies("netease"); + cacheClear(); +}; + +/** set-cookie 数组 → 扁平对象(只取 key=value,忽略 Path/Domain/Max-Age 等属性) */ +const parseSetCookie = (arr: string[]): Record => { + const out: Record = {}; + for (const raw of arr) { + const first = raw.split(";")[0]; + const eq = first.indexOf("="); + if (eq <= 0) continue; + const key = first.slice(0, eq).trim(); + const val = first.slice(eq + 1).trim(); + if (key) out[key] = val; + } + return out; +}; + +/** + * 调用任意 Netease API + * @param name 见 modules/index.ts 中的 key + * @param params 业务参数;cookie 自动注入,无需调用方传 + */ +export const callNetease = async ( + name: string, + params: Record = {}, +): Promise<{ status: number; body: unknown }> => { + const fn = modules[name]; + if (!fn) throw new Error(`unknown netease api: ${name}`); + + const session = loadSession(); + + // 读缓存;调用方如不想命中,按原项目惯例在 params 里带 `timestamp: Date.now()` 即可 + const cacheKey = buildCacheKey(name, params); + const hit = cacheGet(cacheKey); + if (hit) return hit; + + const query = { + ...params, + cookie: + typeof params.cookie === "string" + ? cookieToJson(params.cookie) + : (params.cookie as Record | undefined) || { ...session }, + }; + + const res = await fn(query, createRequest); + + // 仅登录态变更接口才把响应 cookie 写回 SQLite + if (SESSION_MUTATING.has(name) && res.cookie?.length) { + const patch = parseSetCookie(res.cookie); + if (Object.keys(patch).length) { + persistSession({ ...loadSession(), ...patch }); + cacheClear(); + } + } + + const value = { status: res.status, body: res.body }; + if (res.status === 200) cacheSet(cacheKey, value); + + return value; +}; + +/** 调试用:当前 cookie 序列化字符串 */ +export const currentCookieString = (): string => serialize(loadSession()); diff --git a/electron/main/apis/netease/modules/captcha_sent.ts b/electron/main/apis/netease/modules/captcha_sent.ts new file mode 100644 index 0000000..5c730c8 --- /dev/null +++ b/electron/main/apis/netease/modules/captcha_sent.ts @@ -0,0 +1,17 @@ +/** + * 发送短信验证码 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const captchaSent: NeteaseModule = (query, request) => { + const data = { + ctcode: query.ctcode || "86", + secrete: "music_middleuser_pclogin", + cellphone: query.phone, + }; + return request("/api/sms/captcha/sent", data, createOption(query, "weapi")); +}; + +export default captchaSent; diff --git a/electron/main/apis/netease/modules/captcha_verify.ts b/electron/main/apis/netease/modules/captcha_verify.ts new file mode 100644 index 0000000..2fe542f --- /dev/null +++ b/electron/main/apis/netease/modules/captcha_verify.ts @@ -0,0 +1,17 @@ +/** + * 校验短信验证码 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const captchaVerify: NeteaseModule = (query, request) => { + const data = { + ctcode: query.ctcode || "86", + cellphone: query.phone, + captcha: query.captcha, + }; + return request("/api/sms/captcha/verify", data, createOption(query, "weapi")); +}; + +export default captchaVerify; diff --git a/electron/main/apis/netease/modules/cloud_lyric_get.ts b/electron/main/apis/netease/modules/cloud_lyric_get.ts new file mode 100644 index 0000000..2b452ce --- /dev/null +++ b/electron/main/apis/netease/modules/cloud_lyric_get.ts @@ -0,0 +1,21 @@ +/** + * 云盘歌词 + * params: + * - uid 用户 id + * - sid 云盘歌曲 id + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const cloud_lyric_get: NeteaseModule = (query, request) => { + const data = { + userId: query.uid, + songId: query.sid, + lv: -1, + kv: -1, + }; + return request("/api/cloud/lyric/get", data, createOption(query, "eapi")); +}; + +export default cloud_lyric_get; diff --git a/electron/main/apis/netease/modules/cloudsearch.ts b/electron/main/apis/netease/modules/cloudsearch.ts new file mode 100644 index 0000000..a82d2fb --- /dev/null +++ b/electron/main/apis/netease/modules/cloudsearch.ts @@ -0,0 +1,19 @@ +/** + * 云端搜索(返回更完整的 privileges 等字段,推荐使用) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const cloudsearch: NeteaseModule = (query, request) => { + const data = { + s: query.keywords, + type: query.type ?? 1, + limit: query.limit ?? 30, + offset: query.offset ?? 0, + total: true, + }; + return request("/api/cloudsearch/pc", data, createOption(query)); +}; + +export default cloudsearch; diff --git a/electron/main/apis/netease/modules/index.ts b/electron/main/apis/netease/modules/index.ts new file mode 100644 index 0000000..db5e26f --- /dev/null +++ b/electron/main/apis/netease/modules/index.ts @@ -0,0 +1,88 @@ +/** + * Netease API 模块注册表 + * + * 每新增一个 module,在这里用一行 import + export 接入即可。 + * 运行时通过 `callNetease(name, params)` 按 key 路由。 + */ + +import type { NeteaseModule } from "../core/types"; + +// 登录 / 会话 +import captcha_sent from "./captcha_sent"; +import captcha_verify from "./captcha_verify"; +import login from "./login"; +import login_cellphone from "./login_cellphone"; +import login_qr_check from "./login_qr_check"; +import login_qr_create from "./login_qr_create"; +import login_qr_key from "./login_qr_key"; +import login_refresh from "./login_refresh"; +import login_status from "./login_status"; +import logout from "./logout"; +import register_anonimous from "./register_anonimous"; + +// 用户 +import user_account from "./user_account"; +import user_cloud from "./user_cloud"; +import user_detail from "./user_detail"; +import user_detail_new from "./user_detail_new"; +import user_followeds from "./user_followeds"; +import user_follows from "./user_follows"; +import user_level from "./user_level"; +import user_playlist from "./user_playlist"; +import user_record from "./user_record"; +import user_subcount from "./user_subcount"; + +// 搜索 +import cloudsearch from "./cloudsearch"; +import search from "./search"; +import search_default from "./search_default"; +import search_hot from "./search_hot"; +import search_hot_detail from "./search_hot_detail"; +import search_match from "./search_match"; +import search_multimatch from "./search_multimatch"; +import search_suggest from "./search_suggest"; +import search_suggest_pc from "./search_suggest_pc"; + +// 歌词 +import lyric from "./lyric"; +import lyric_new from "./lyric_new"; +import cloud_lyric_get from "./cloud_lyric_get"; + +export const modules: Record = { + captcha_sent, + captcha_verify, + login, + login_cellphone, + login_qr_check, + login_qr_create, + login_qr_key, + login_refresh, + login_status, + logout, + register_anonimous, + + user_account, + user_cloud, + user_detail, + user_detail_new, + user_followeds, + user_follows, + user_level, + user_playlist, + user_record, + user_subcount, + + cloudsearch, + search, + search_default, + search_hot, + search_hot_detail, + search_match, + search_multimatch, + search_suggest, + search_suggest_pc, + + lyric, + lyric_new, + cloud_lyric_get, +}; diff --git a/electron/main/apis/netease/modules/login.ts b/electron/main/apis/netease/modules/login.ts new file mode 100644 index 0000000..fd66030 --- /dev/null +++ b/electron/main/apis/netease/modules/login.ts @@ -0,0 +1,41 @@ +/** + * 邮箱登录 + */ + +import { createHash } from "node:crypto"; +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const md5 = (text: string): string => createHash("md5").update(text).digest("hex"); + +const login: NeteaseModule = async (query, request) => { + const password = (query.md5_password as string) || md5((query.password as string) || ""); + const data = { + type: "0", + https: "true", + username: query.email as string, + password, + rememberLogin: "true", + }; + let result = await request("/api/w/login", data, createOption(query)); + const body = result.body as { code?: number; [key: string]: unknown }; + + if (body.code === 502) { + return { + status: 200, + body: { msg: "账号或密码错误", code: 502, message: "账号或密码错误" }, + cookie: result.cookie, + }; + } + if (body.code === 200) { + const renamed = JSON.parse(JSON.stringify(body).replace(/avatarImgId_str/g, "avatarImgIdStr")); + result = { + status: 200, + body: { ...renamed, cookie: result.cookie.join(";") }, + cookie: result.cookie, + }; + } + return result; +}; + +export default login; diff --git a/electron/main/apis/netease/modules/login_cellphone.ts b/electron/main/apis/netease/modules/login_cellphone.ts new file mode 100644 index 0000000..e405611 --- /dev/null +++ b/electron/main/apis/netease/modules/login_cellphone.ts @@ -0,0 +1,41 @@ +/** + * 手机号登录(密码或验证码均可) + */ + +import { createHash } from "node:crypto"; +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const md5 = (text: string): string => createHash("md5").update(text).digest("hex"); + +const loginCellphone: NeteaseModule = async (query, request) => { + const hasCaptcha = Boolean(query.captcha); + const data: Record = { + type: "1", + https: "true", + phone: query.phone, + countrycode: query.countrycode || "86", + captcha: query.captcha, + remember: "true", + }; + if (hasCaptcha) { + data.captcha = query.captcha; + } else { + data.password = (query.md5_password as string) || md5((query.password as string) || ""); + } + + let result = await request("/api/w/login/cellphone", data, createOption(query, "weapi")); + const body = result.body as { code?: number; [key: string]: unknown }; + + if (body.code === 200) { + const renamed = JSON.parse(JSON.stringify(body).replace(/avatarImgId_str/g, "avatarImgIdStr")); + result = { + status: 200, + body: { ...renamed, cookie: result.cookie.join(";") }, + cookie: result.cookie, + }; + } + return result; +}; + +export default loginCellphone; diff --git a/electron/main/apis/netease/modules/login_qr_check.ts b/electron/main/apis/netease/modules/login_qr_check.ts new file mode 100644 index 0000000..119c012 --- /dev/null +++ b/electron/main/apis/netease/modules/login_qr_check.ts @@ -0,0 +1,24 @@ +/** + * 轮询二维码扫码状态 + * - 801 待扫码、802 待确认、800 已过期、803 已确认(此时 cookie 里有 MUSIC_U) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const loginQrCheck: NeteaseModule = async (query, request) => { + const data = { key: query.key, type: 3 }; + try { + const result = await request("/api/login/qrcode/client/login", data, createOption(query)); + return { + status: 200, + body: { ...result.body, cookie: result.cookie.join(";") }, + cookie: result.cookie, + }; + } catch (err) { + const fallback = err as { cookie?: string[] }; + return { status: 200, body: {}, cookie: fallback?.cookie ?? [] }; + } +}; + +export default loginQrCheck; diff --git a/electron/main/apis/netease/modules/login_qr_create.ts b/electron/main/apis/netease/modules/login_qr_create.ts new file mode 100644 index 0000000..0f13d57 --- /dev/null +++ b/electron/main/apis/netease/modules/login_qr_create.ts @@ -0,0 +1,22 @@ +/** + * 生成二维码登录 URL(可选返回 data URL 图像) + * + * 注:原实现依赖 qrcode 包生成图像;这里只返回 URL, + * 渲染端可自行使用现成的 QR 生成器(更贴合 Electron 架构,避免再引入一个 Node 依赖)。 + */ + +import type { NeteaseModule } from "../core/types"; + +const loginQrCreate: NeteaseModule = async (query) => { + const url = `https://music.163.com/login?codekey=${query.key}`; + return { + status: 200, + body: { + code: 200, + data: { qrurl: url, qrimg: "" }, + }, + cookie: [], + }; +}; + +export default loginQrCreate; diff --git a/electron/main/apis/netease/modules/login_qr_key.ts b/electron/main/apis/netease/modules/login_qr_key.ts new file mode 100644 index 0000000..6e5ee09 --- /dev/null +++ b/electron/main/apis/netease/modules/login_qr_key.ts @@ -0,0 +1,17 @@ +/** + * 获取二维码登录 unikey + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const loginQrKey: NeteaseModule = async (query, request) => { + const result = await request("/api/login/qrcode/unikey", { type: 3 }, createOption(query)); + return { + status: 200, + body: { data: result.body, code: 200 }, + cookie: result.cookie, + }; +}; + +export default loginQrKey; diff --git a/electron/main/apis/netease/modules/login_refresh.ts b/electron/main/apis/netease/modules/login_refresh.ts new file mode 100644 index 0000000..f4ec0c0 --- /dev/null +++ b/electron/main/apis/netease/modules/login_refresh.ts @@ -0,0 +1,21 @@ +/** + * 登录态刷新(延长 MUSIC_U 有效期) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const loginRefresh: NeteaseModule = async (query, request) => { + const result = await request("/api/login/token/refresh", {}, createOption(query)); + const body = result.body as { code?: number; [key: string]: unknown }; + if (body.code === 200) { + return { + status: 200, + body: { ...body, cookie: result.cookie.join(";") }, + cookie: result.cookie, + }; + } + return result; +}; + +export default loginRefresh; diff --git a/electron/main/apis/netease/modules/login_status.ts b/electron/main/apis/netease/modules/login_status.ts new file mode 100644 index 0000000..234b038 --- /dev/null +++ b/electron/main/apis/netease/modules/login_status.ts @@ -0,0 +1,21 @@ +/** + * 登录状态(当前账号信息) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const loginStatus: NeteaseModule = async (query, request) => { + const result = await request("/api/w/nuser/account/get", {}, createOption(query, "weapi")); + const body = result.body as { code?: number; [key: string]: unknown }; + if (body.code === 200) { + return { + status: 200, + body: { data: { ...body } }, + cookie: result.cookie, + }; + } + return result; +}; + +export default loginStatus; diff --git a/electron/main/apis/netease/modules/logout.ts b/electron/main/apis/netease/modules/logout.ts new file mode 100644 index 0000000..b8882d0 --- /dev/null +++ b/electron/main/apis/netease/modules/logout.ts @@ -0,0 +1,10 @@ +/** + * 退出登录 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const logout: NeteaseModule = (query, request) => request("/api/logout", {}, createOption(query)); + +export default logout; diff --git a/electron/main/apis/netease/modules/lyric.ts b/electron/main/apis/netease/modules/lyric.ts new file mode 100644 index 0000000..df63a11 --- /dev/null +++ b/electron/main/apis/netease/modules/lyric.ts @@ -0,0 +1,22 @@ +/** + * 歌词(旧版) + * params: + * - id 歌曲 id + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const lyric: NeteaseModule = (query, request) => { + const data = { + id: query.id, + tv: -1, + lv: -1, + rv: -1, + kv: -1, + _nmclfl: 1, + }; + return request("/api/song/lyric", data, createOption(query)); +}; + +export default lyric; diff --git a/electron/main/apis/netease/modules/lyric_new.ts b/electron/main/apis/netease/modules/lyric_new.ts new file mode 100644 index 0000000..b7d033b --- /dev/null +++ b/electron/main/apis/netease/modules/lyric_new.ts @@ -0,0 +1,25 @@ +/** + * 歌词(新版,含逐字歌词 yrc) + * params: + * - id 歌曲 id + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const lyric_new: NeteaseModule = (query, request) => { + const data = { + id: query.id, + cp: false, + tv: 0, + lv: 0, + rv: 0, + kv: 0, + yv: 0, + ytv: 0, + yrv: 0, + }; + return request("/api/song/lyric/v1", data, createOption(query)); +}; + +export default lyric_new; diff --git a/electron/main/apis/netease/modules/register_anonimous.ts b/electron/main/apis/netease/modules/register_anonimous.ts new file mode 100644 index 0000000..abcdf55 --- /dev/null +++ b/electron/main/apis/netease/modules/register_anonimous.ts @@ -0,0 +1,46 @@ +/** + * 注册匿名态(获取 MUSIC_A) + * + * 逻辑来源:@neteasecloudmusicapienhanced/api module/register_anonimous.js + * - 生成 52 位 hex deviceId + * - 用 `${deviceId} ${md5(deviceId ^ ID_XOR_KEY_1)}` 做 Base64 作为 username + * - 调用 weapi 注册,将返回的 MUSIC_A 缓存到设备态 + */ + +import { createHash } from "node:crypto"; +import { createOption } from "../core/option"; +import { regenerateDeviceId, setAnonymousToken } from "../core/device"; +import type { NeteaseModule } from "../core/types"; + +const ID_XOR_KEY = "3go8&$8*3*3h0k(2)2"; + +const encodeId = (deviceId: string): string => { + let xored = ""; + for (let i = 0; i < deviceId.length; i++) { + xored += String.fromCharCode( + deviceId.charCodeAt(i) ^ ID_XOR_KEY.charCodeAt(i % ID_XOR_KEY.length), + ); + } + return createHash("md5").update(xored, "utf8").digest("base64"); +}; + +const registerAnonimous: NeteaseModule = async (query, request) => { + const deviceId = regenerateDeviceId(); + const username = Buffer.from(`${deviceId} ${encodeId(deviceId)}`, "utf8").toString("base64"); + const data = { username }; + + const result = await request("/api/register/anonimous", data, createOption(query, "weapi")); + const body = result.body as { code?: number; [key: string]: unknown }; + + if (body.code === 200) { + if (typeof body.token === "string") setAnonymousToken(body.token); + return { + status: 200, + body: { ...body, cookie: result.cookie.join(";") }, + cookie: result.cookie, + }; + } + return result; +}; + +export default registerAnonimous; diff --git a/electron/main/apis/netease/modules/search.ts b/electron/main/apis/netease/modules/search.ts new file mode 100644 index 0000000..c5b06ea --- /dev/null +++ b/electron/main/apis/netease/modules/search.ts @@ -0,0 +1,29 @@ +/** + * 搜索(普通) + * type: 1 单曲 / 10 专辑 / 100 歌手 / 1000 歌单 / 1002 用户 / 1004 MV / 1006 歌词 / 1009 电台 / 1014 视频 + * 特例:type=2000 走语音搜索接口 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const search: NeteaseModule = (query, request) => { + if (query.type && String(query.type) === "2000") { + const voice = { + keyword: query.keywords, + scene: "normal", + limit: query.limit ?? 30, + offset: query.offset ?? 0, + }; + return request("/api/search/voice/get", voice, createOption(query)); + } + const data = { + s: query.keywords, + type: query.type ?? 1, + limit: query.limit ?? 30, + offset: query.offset ?? 0, + }; + return request("/api/search/get", data, createOption(query)); +}; + +export default search; diff --git a/electron/main/apis/netease/modules/search_default.ts b/electron/main/apis/netease/modules/search_default.ts new file mode 100644 index 0000000..5dca968 --- /dev/null +++ b/electron/main/apis/netease/modules/search_default.ts @@ -0,0 +1,11 @@ +/** + * 默认搜索关键词(搜索框 placeholder) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const searchDefault: NeteaseModule = (query, request) => + request("/api/search/defaultkeyword/get", {}, createOption(query)); + +export default searchDefault; diff --git a/electron/main/apis/netease/modules/search_hot.ts b/electron/main/apis/netease/modules/search_hot.ts new file mode 100644 index 0000000..983cfb4 --- /dev/null +++ b/electron/main/apis/netease/modules/search_hot.ts @@ -0,0 +1,11 @@ +/** + * 热门搜索(简版) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const searchHot: NeteaseModule = (query, request) => + request("/api/search/hot", { type: 1111 }, createOption(query)); + +export default searchHot; diff --git a/electron/main/apis/netease/modules/search_hot_detail.ts b/electron/main/apis/netease/modules/search_hot_detail.ts new file mode 100644 index 0000000..510e47a --- /dev/null +++ b/electron/main/apis/netease/modules/search_hot_detail.ts @@ -0,0 +1,11 @@ +/** + * 热搜详情(带热度/图标) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const searchHotDetail: NeteaseModule = (query, request) => + request("/api/hotsearchlist/get", {}, createOption(query, "weapi")); + +export default searchHotDetail; diff --git a/electron/main/apis/netease/modules/search_match.ts b/electron/main/apis/netease/modules/search_match.ts new file mode 100644 index 0000000..c37800f --- /dev/null +++ b/electron/main/apis/netease/modules/search_match.ts @@ -0,0 +1,22 @@ +/** + * 本地歌曲匹配(根据 title/album/artist/duration 猜云端对应歌曲) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const searchMatch: NeteaseModule = (query, request) => { + const songs = [ + { + title: query.title || "", + album: query.album || "", + artist: query.artist || "", + duration: query.duration || 0, + persistId: query.md5, + }, + ]; + const data = { songs: JSON.stringify(songs) }; + return request("/api/search/match/new", data, createOption(query)); +}; + +export default searchMatch; diff --git a/electron/main/apis/netease/modules/search_multimatch.ts b/electron/main/apis/netease/modules/search_multimatch.ts new file mode 100644 index 0000000..b4ebaa0 --- /dev/null +++ b/electron/main/apis/netease/modules/search_multimatch.ts @@ -0,0 +1,16 @@ +/** + * 多类型搜索(一次返回歌曲/歌手/歌单的前几条命中) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const searchMultimatch: NeteaseModule = (query, request) => { + const data = { + type: query.type ?? 1, + s: query.keywords || "", + }; + return request("/api/search/suggest/multimatch", data, createOption(query, "weapi")); +}; + +export default searchMultimatch; diff --git a/electron/main/apis/netease/modules/search_suggest.ts b/electron/main/apis/netease/modules/search_suggest.ts new file mode 100644 index 0000000..829bd6b --- /dev/null +++ b/electron/main/apis/netease/modules/search_suggest.ts @@ -0,0 +1,14 @@ +/** + * 搜索建议(web / mobile) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const searchSuggest: NeteaseModule = (query, request) => { + const data = { s: query.keywords || "" }; + const type = query.type === "mobile" ? "keyword" : "web"; + return request(`/api/search/suggest/${type}`, data, createOption(query, "weapi")); +}; + +export default searchSuggest; diff --git a/electron/main/apis/netease/modules/search_suggest_pc.ts b/electron/main/apis/netease/modules/search_suggest_pc.ts new file mode 100644 index 0000000..80700d2 --- /dev/null +++ b/electron/main/apis/netease/modules/search_suggest_pc.ts @@ -0,0 +1,13 @@ +/** + * 搜索建议(PC 版) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const searchSuggestPc: NeteaseModule = (query, request) => { + const data = { keyword: query.keyword || "" }; + return request("/api/search/pc/suggest/keyword/get", data, createOption(query)); +}; + +export default searchSuggestPc; diff --git a/electron/main/apis/netease/modules/user_account.ts b/electron/main/apis/netease/modules/user_account.ts new file mode 100644 index 0000000..6c1eca8 --- /dev/null +++ b/electron/main/apis/netease/modules/user_account.ts @@ -0,0 +1,11 @@ +/** + * 当前登录账号信息 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userAccount: NeteaseModule = (query, request) => + request("/api/nuser/account/get", {}, createOption(query, "weapi")); + +export default userAccount; diff --git a/electron/main/apis/netease/modules/user_cloud.ts b/electron/main/apis/netease/modules/user_cloud.ts new file mode 100644 index 0000000..eb1dcf7 --- /dev/null +++ b/electron/main/apis/netease/modules/user_cloud.ts @@ -0,0 +1,16 @@ +/** + * 用户云盘歌曲列表 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userCloud: NeteaseModule = (query, request) => { + const data = { + limit: query.limit ?? 30, + offset: query.offset ?? 0, + }; + return request("/api/v1/cloud/get", data, createOption(query, "weapi")); +}; + +export default userCloud; diff --git a/electron/main/apis/netease/modules/user_detail.ts b/electron/main/apis/netease/modules/user_detail.ts new file mode 100644 index 0000000..a0b17dc --- /dev/null +++ b/electron/main/apis/netease/modules/user_detail.ts @@ -0,0 +1,14 @@ +/** + * 用户详情(旧版) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userDetail: NeteaseModule = async (query, request) => { + const res = await request(`/api/v1/user/detail/${query.uid}`, {}, createOption(query, "weapi")); + const renamed = JSON.stringify(res).replace(/avatarImgId_str/g, "avatarImgIdStr"); + return JSON.parse(renamed); +}; + +export default userDetail; diff --git a/electron/main/apis/netease/modules/user_detail_new.ts b/electron/main/apis/netease/modules/user_detail_new.ts new file mode 100644 index 0000000..cdfaf24 --- /dev/null +++ b/electron/main/apis/netease/modules/user_detail_new.ts @@ -0,0 +1,13 @@ +/** + * 用户详情(新版,eapi) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userDetailNew: NeteaseModule = (query, request) => { + const data = { all: "true", userId: query.uid }; + return request(`/api/w/v1/user/detail/${query.uid}`, data, createOption(query, "eapi")); +}; + +export default userDetailNew; diff --git a/electron/main/apis/netease/modules/user_followeds.ts b/electron/main/apis/netease/modules/user_followeds.ts new file mode 100644 index 0000000..5c242e5 --- /dev/null +++ b/electron/main/apis/netease/modules/user_followeds.ts @@ -0,0 +1,19 @@ +/** + * 粉丝列表(关注 TA 的人) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userFolloweds: NeteaseModule = (query, request) => { + const data = { + userId: query.uid, + time: "0", + limit: query.limit ?? 20, + offset: query.offset ?? 0, + getcounts: "true", + }; + return request(`/api/user/getfolloweds/${query.uid}`, data, createOption(query)); +}; + +export default userFolloweds; diff --git a/electron/main/apis/netease/modules/user_follows.ts b/electron/main/apis/netease/modules/user_follows.ts new file mode 100644 index 0000000..23cbaab --- /dev/null +++ b/electron/main/apis/netease/modules/user_follows.ts @@ -0,0 +1,17 @@ +/** + * 关注列表(TA 关注的人) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userFollows: NeteaseModule = (query, request) => { + const data = { + offset: query.offset ?? 0, + limit: query.limit ?? 30, + order: true, + }; + return request(`/api/user/getfollows/${query.uid}`, data, createOption(query, "weapi")); +}; + +export default userFollows; diff --git a/electron/main/apis/netease/modules/user_level.ts b/electron/main/apis/netease/modules/user_level.ts new file mode 100644 index 0000000..2bc988f --- /dev/null +++ b/electron/main/apis/netease/modules/user_level.ts @@ -0,0 +1,11 @@ +/** + * 用户等级(听歌时长、登录天数等) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userLevel: NeteaseModule = (query, request) => + request("/api/user/level", {}, createOption(query, "weapi")); + +export default userLevel; diff --git a/electron/main/apis/netease/modules/user_playlist.ts b/electron/main/apis/netease/modules/user_playlist.ts new file mode 100644 index 0000000..232d7be --- /dev/null +++ b/electron/main/apis/netease/modules/user_playlist.ts @@ -0,0 +1,18 @@ +/** + * 用户歌单列表 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userPlaylist: NeteaseModule = (query, request) => { + const data = { + uid: query.uid, + limit: query.limit ?? 30, + offset: query.offset ?? 0, + includeVideo: true, + }; + return request("/api/user/playlist", data, createOption(query, "weapi")); +}; + +export default userPlaylist; diff --git a/electron/main/apis/netease/modules/user_record.ts b/electron/main/apis/netease/modules/user_record.ts new file mode 100644 index 0000000..9094279 --- /dev/null +++ b/electron/main/apis/netease/modules/user_record.ts @@ -0,0 +1,17 @@ +/** + * 听歌排行 + * type: 1 最近一周;0 所有时间 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userRecord: NeteaseModule = (query, request) => { + const data = { + uid: query.uid, + type: query.type ?? 0, + }; + return request("/api/v1/play/record", data, createOption(query, "weapi")); +}; + +export default userRecord; diff --git a/electron/main/apis/netease/modules/user_subcount.ts b/electron/main/apis/netease/modules/user_subcount.ts new file mode 100644 index 0000000..0a72dd5 --- /dev/null +++ b/electron/main/apis/netease/modules/user_subcount.ts @@ -0,0 +1,11 @@ +/** + * 用户收藏计数(歌单/专辑/MV 等) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userSubcount: NeteaseModule = (query, request) => + request("/api/subcount", {}, createOption(query, "weapi")); + +export default userSubcount; diff --git a/electron/main/apis/qqmusic/core/config.ts b/electron/main/apis/qqmusic/core/config.ts new file mode 100644 index 0000000..3fe113d --- /dev/null +++ b/electron/main/apis/qqmusic/core/config.ts @@ -0,0 +1,46 @@ +/** + * QM API 通用常量 + */ + +/** 统一接口入口(移动端 musicu) */ +export const QM_API_URL = "https://u.y.qq.com/cgi-bin/musicu.fcg"; + +/** 模拟移动端的默认 headers */ +export const QM_HEADERS: Record = { + "Content-Type": "application/json", + "Accept-Encoding": "gzip", + "User-Agent": "okhttp/3.14.9", + Referer: "https://y.qq.com", + Cookie: "tmeLoginType=-1;", +}; + +/** 请求体 comm 字段(伪装 Android 客户端) */ +export const getCommonParams = (): Record => ({ + ct: 11, + cv: "1003006", + v: "1003006", + os_ver: "15", + phonetype: "24122RKC7C", + tmeAppID: "qqmusiclight", + nettype: "NETWORK_WIFI", + udid: "0", + OpenUDID: "0", + QIMEI36: "0", + uin: "0", +}); + +/** Session 缓存时长(毫秒) */ +export const SESSION_TTL = 60 * 60 * 1000; + +/** 歌手数组格式化工具:`[{name:'A'},{name:'B'}]` → `A / B` */ +export const formatSingerName = ( + singers: Array<{ name?: string; title?: string }> | undefined, + key: "name" | "title" = "name", + join = " / ", +): string => { + if (!singers?.length) return ""; + return singers + .map((item) => item[key]) + .filter((item): item is string => !!item) + .join(join); +}; diff --git a/electron/main/apis/qqmusic/core/qrc.ts b/electron/main/apis/qqmusic/core/qrc.ts new file mode 100644 index 0000000..fd90082 --- /dev/null +++ b/electron/main/apis/qqmusic/core/qrc.ts @@ -0,0 +1,52 @@ +import { inflateRawSync, inflateSync, unzipSync } from "zlib"; +import { qrcDecrypt } from "./tripledes"; + +/** + * QRC 解密密钥 - 24 字节 + * 来源: LDDC 项目 + */ +const QRC_KEY = new Uint8Array(Buffer.from("!@#)(*$%123ZXC!@!@#)(NHL", "utf8")); + +/** + * 解密 QRC 歌词(云端版本) + * 使用 LDDC 的 Triple DES 实现 + Zlib 解压 + * + * @param encryptedQrc - 十六进制编码的加密歌词字符串 + * @returns 解密后的歌词文本 + */ +export const decryptQrc = (encryptedQrc: string): string => { + if (!encryptedQrc || encryptedQrc.trim() === "") { + throw new Error("没有可解密的数据"); + } + + // Hex 转 Uint8Array + const encryptedBuffer = Buffer.from(encryptedQrc, "hex"); + const encryptedData = new Uint8Array(encryptedBuffer); + + // Triple DES 解密 + const decrypted = qrcDecrypt(encryptedData, QRC_KEY); + const decryptedBuffer = Buffer.from(decrypted); + + // Zlib 解压:依次尝试 inflate / raw inflate / gzip + try { + return inflateSync(decryptedBuffer).toString("utf8"); + } catch { + // 尝试下一种 + } + try { + return inflateRawSync(decryptedBuffer).toString("utf8"); + } catch { + // 尝试下一种 + } + try { + return unzipSync(decryptedBuffer).toString("utf8"); + } catch { + // 尝试下一种 + } + + // 也可能本身就不是压缩数据 + const str = decryptedBuffer.toString("utf8"); + if (str.includes("[") || str.includes("<")) return str; + + throw new Error("无法解压数据"); +}; diff --git a/electron/main/apis/qqmusic/core/request.ts b/electron/main/apis/qqmusic/core/request.ts new file mode 100644 index 0000000..fe9d5ee --- /dev/null +++ b/electron/main/apis/qqmusic/core/request.ts @@ -0,0 +1,109 @@ +/** + * QM 请求层 + * + * 设计: + * - 统一走 u.y.qq.com/cgi-bin/musicu.fcg 的 `{ comm, request: {module, method, param} }` 协议 + * - 首次请求前先调 music.getSession.session 拿 uid/sid/userip,缓存 1 小时 + * - 没有加密:API 本身明文 JSON POST,靠 UA + QIMEI36 等 comm 字段伪装客户端 + * - Referer 设为 https://y.qq.com,部分接口会校验 + */ + +import { QM_API_URL, QM_HEADERS, SESSION_TTL, getCommonParams } from "./config"; + +/** Session 字段(可能缺失则下次请求会自动补拿) */ +interface SessionCache { + uid?: string; + sid?: string; + userip?: string; + expireAt: number; +} + +let session: SessionCache = { expireAt: 0 }; +let initPromise: Promise | null = null; + +interface FcgResponse { + code?: number; + request?: { code?: number; data?: unknown }; + [key: string]: unknown; +} + +/** 直接发起一次 fcg POST(不做 session 注入,用于初始化自身) */ +const postRaw = async (body: unknown): Promise => { + const res = await fetch(QM_API_URL, { + method: "POST", + headers: QM_HEADERS, + body: JSON.stringify(body), + }); + return (await res.json()) as FcgResponse; +}; + +/** 初始化 / 刷新 session(1h 过期);并发安全:同一时刻只发一次 */ +const ensureSession = (): Promise => { + if (session.uid && session.expireAt > Date.now()) return Promise.resolve(); + if (initPromise) return initPromise; + + initPromise = (async () => { + try { + const body = { + comm: getCommonParams(), + request: { + module: "music.getSession.session", + method: "GetSession", + param: { caller: 0, uid: "0", vkey: 0 }, + }, + }; + const data = await postRaw(body); + if (data.code === 0 && data.request?.code === 0) { + const info = + ((data.request.data as { session?: Partial }) ?? {}).session ?? {}; + session = { + uid: info.uid, + sid: info.sid, + userip: info.userip, + expireAt: Date.now() + SESSION_TTL, + }; + } + } catch { + // session 失败不阻塞后续调用,大部分接口无 session 也能回结果 + } finally { + initPromise = null; + } + })(); + + return initPromise; +}; + +/** + * 发送一次 musicu.fcg 请求 + * @param module 业务 module(如 music.search.SearchCgiService) + * @param method 业务 method(如 DoSearchForQQMusicMobile) + * @param param 业务 param + * @returns request.data 的业务数据段 + */ +export const qmRequest = async ( + module: string, + method: string, + param: Record, +): Promise => { + await ensureSession(); + + const comm = { + ...getCommonParams(), + ...(session.uid ? { uid: session.uid } : {}), + ...(session.sid ? { sid: session.sid } : {}), + ...(session.userip ? { userip: session.userip } : {}), + }; + + const body = { comm, request: { module, method, param } }; + const data = await postRaw(body); + + const outerCode = data.code ?? 0; + const innerCode = data.request?.code ?? 0; + if (outerCode !== 0 || innerCode !== 0) { + throw new Error(`QM API 错误: outer=${outerCode} inner=${innerCode}`); + } + return data.request?.data as T; +}; + +/** 调试用:取当前 session 快照 */ +export const getQMSession = (): Readonly => session; diff --git a/electron/main/apis/qqmusic/core/tripledes.ts b/electron/main/apis/qqmusic/core/tripledes.ts new file mode 100644 index 0000000..d7b9ec5 --- /dev/null +++ b/electron/main/apis/qqmusic/core/tripledes.ts @@ -0,0 +1,421 @@ +/** + * Triple DES 实现 + * 移植自 LDDC 项目: https://github.com/chenmozhijin/LDDC + * 原始代码: LDDC/core/decryptor/tripledes.py + */ + +const ENCRYPT = 1; +const DECRYPT = 0; + +// S-boxes +const sbox: number[][] = [ + // sbox1 + [ + 14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, + 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, + 11, 3, 14, 10, 0, 6, 13, + ], + // sbox2 + [ + 15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10, + 6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, + 11, 6, 7, 12, 0, 5, 14, 9, + ], + // sbox3 + [ + 10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, + 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, + 15, 14, 3, 11, 5, 2, 12, + ], + // sbox4 + [ + 7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, + 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13, 8, 9, + 4, 5, 11, 12, 7, 2, 14, + ], + // sbox5 + [ + 2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, + 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, + 15, 0, 9, 10, 4, 5, 3, + ], + // sbox6 + [ + 12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, + 0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, + 11, 14, 1, 7, 6, 0, 8, 13, + ], + // sbox7 + [ + 4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, + 2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, + 5, 0, 15, 14, 2, 3, 12, + ], + // sbox8 + [ + 13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, + 0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, + 15, 12, 9, 0, 3, 5, 6, 11, + ], +]; + +const bitnum = (a: Uint8Array, b: number, c: number): number => { + const byteIndex = Math.floor(b / 32) * 4 + 3 - Math.floor((b % 32) / 8); + return ((a[byteIndex] >> (7 - (b % 8))) & 1) << c; +}; + +const bitnumIntr = (a: number, b: number, c: number): number => ((a >> (31 - b)) & 1) << c; + +const bitnumIntl = (a: number, b: number, c: number): number => + (((a << b) & 0x80000000) >>> c) >>> 0; + +const sboxBit = (a: number): number => (a & 32) | ((a & 31) >> 1) | ((a & 1) << 4); + +const initialPermutation = (inputData: Uint8Array): [number, number] => { + const s0 = + (bitnum(inputData, 57, 31) | + bitnum(inputData, 49, 30) | + bitnum(inputData, 41, 29) | + bitnum(inputData, 33, 28) | + bitnum(inputData, 25, 27) | + bitnum(inputData, 17, 26) | + bitnum(inputData, 9, 25) | + bitnum(inputData, 1, 24) | + bitnum(inputData, 59, 23) | + bitnum(inputData, 51, 22) | + bitnum(inputData, 43, 21) | + bitnum(inputData, 35, 20) | + bitnum(inputData, 27, 19) | + bitnum(inputData, 19, 18) | + bitnum(inputData, 11, 17) | + bitnum(inputData, 3, 16) | + bitnum(inputData, 61, 15) | + bitnum(inputData, 53, 14) | + bitnum(inputData, 45, 13) | + bitnum(inputData, 37, 12) | + bitnum(inputData, 29, 11) | + bitnum(inputData, 21, 10) | + bitnum(inputData, 13, 9) | + bitnum(inputData, 5, 8) | + bitnum(inputData, 63, 7) | + bitnum(inputData, 55, 6) | + bitnum(inputData, 47, 5) | + bitnum(inputData, 39, 4) | + bitnum(inputData, 31, 3) | + bitnum(inputData, 23, 2) | + bitnum(inputData, 15, 1) | + bitnum(inputData, 7, 0)) >>> + 0; + + const s1 = + (bitnum(inputData, 56, 31) | + bitnum(inputData, 48, 30) | + bitnum(inputData, 40, 29) | + bitnum(inputData, 32, 28) | + bitnum(inputData, 24, 27) | + bitnum(inputData, 16, 26) | + bitnum(inputData, 8, 25) | + bitnum(inputData, 0, 24) | + bitnum(inputData, 58, 23) | + bitnum(inputData, 50, 22) | + bitnum(inputData, 42, 21) | + bitnum(inputData, 34, 20) | + bitnum(inputData, 26, 19) | + bitnum(inputData, 18, 18) | + bitnum(inputData, 10, 17) | + bitnum(inputData, 2, 16) | + bitnum(inputData, 60, 15) | + bitnum(inputData, 52, 14) | + bitnum(inputData, 44, 13) | + bitnum(inputData, 36, 12) | + bitnum(inputData, 28, 11) | + bitnum(inputData, 20, 10) | + bitnum(inputData, 12, 9) | + bitnum(inputData, 4, 8) | + bitnum(inputData, 62, 7) | + bitnum(inputData, 54, 6) | + bitnum(inputData, 46, 5) | + bitnum(inputData, 38, 4) | + bitnum(inputData, 30, 3) | + bitnum(inputData, 22, 2) | + bitnum(inputData, 14, 1) | + bitnum(inputData, 6, 0)) >>> + 0; + + return [s0, s1]; +}; + +const inversePermutation = (s0: number, s1: number): Uint8Array => { + const data = new Uint8Array(8); + + data[3] = + bitnumIntr(s1, 7, 7) | + bitnumIntr(s0, 7, 6) | + bitnumIntr(s1, 15, 5) | + bitnumIntr(s0, 15, 4) | + bitnumIntr(s1, 23, 3) | + bitnumIntr(s0, 23, 2) | + bitnumIntr(s1, 31, 1) | + bitnumIntr(s0, 31, 0); + + data[2] = + bitnumIntr(s1, 6, 7) | + bitnumIntr(s0, 6, 6) | + bitnumIntr(s1, 14, 5) | + bitnumIntr(s0, 14, 4) | + bitnumIntr(s1, 22, 3) | + bitnumIntr(s0, 22, 2) | + bitnumIntr(s1, 30, 1) | + bitnumIntr(s0, 30, 0); + + data[1] = + bitnumIntr(s1, 5, 7) | + bitnumIntr(s0, 5, 6) | + bitnumIntr(s1, 13, 5) | + bitnumIntr(s0, 13, 4) | + bitnumIntr(s1, 21, 3) | + bitnumIntr(s0, 21, 2) | + bitnumIntr(s1, 29, 1) | + bitnumIntr(s0, 29, 0); + + data[0] = + bitnumIntr(s1, 4, 7) | + bitnumIntr(s0, 4, 6) | + bitnumIntr(s1, 12, 5) | + bitnumIntr(s0, 12, 4) | + bitnumIntr(s1, 20, 3) | + bitnumIntr(s0, 20, 2) | + bitnumIntr(s1, 28, 1) | + bitnumIntr(s0, 28, 0); + + data[7] = + bitnumIntr(s1, 3, 7) | + bitnumIntr(s0, 3, 6) | + bitnumIntr(s1, 11, 5) | + bitnumIntr(s0, 11, 4) | + bitnumIntr(s1, 19, 3) | + bitnumIntr(s0, 19, 2) | + bitnumIntr(s1, 27, 1) | + bitnumIntr(s0, 27, 0); + + data[6] = + bitnumIntr(s1, 2, 7) | + bitnumIntr(s0, 2, 6) | + bitnumIntr(s1, 10, 5) | + bitnumIntr(s0, 10, 4) | + bitnumIntr(s1, 18, 3) | + bitnumIntr(s0, 18, 2) | + bitnumIntr(s1, 26, 1) | + bitnumIntr(s0, 26, 0); + + data[5] = + bitnumIntr(s1, 1, 7) | + bitnumIntr(s0, 1, 6) | + bitnumIntr(s1, 9, 5) | + bitnumIntr(s0, 9, 4) | + bitnumIntr(s1, 17, 3) | + bitnumIntr(s0, 17, 2) | + bitnumIntr(s1, 25, 1) | + bitnumIntr(s0, 25, 0); + + data[4] = + bitnumIntr(s1, 0, 7) | + bitnumIntr(s0, 0, 6) | + bitnumIntr(s1, 8, 5) | + bitnumIntr(s0, 8, 4) | + bitnumIntr(s1, 16, 3) | + bitnumIntr(s0, 16, 2) | + bitnumIntr(s1, 24, 1) | + bitnumIntr(s0, 24, 0); + + return data; +}; + +const f = (state: number, key: number[]): number => { + const t1 = + (bitnumIntl(state, 31, 0) | + ((state & 0xf0000000) >>> 1) | + bitnumIntl(state, 4, 5) | + bitnumIntl(state, 3, 6) | + ((state & 0x0f000000) >>> 3) | + bitnumIntl(state, 8, 11) | + bitnumIntl(state, 7, 12) | + ((state & 0x00f00000) >>> 5) | + bitnumIntl(state, 12, 17) | + bitnumIntl(state, 11, 18) | + ((state & 0x000f0000) >>> 7) | + bitnumIntl(state, 16, 23)) >>> + 0; + + const t2 = + (bitnumIntl(state, 15, 0) | + ((state & 0x0000f000) << 15) | + bitnumIntl(state, 20, 5) | + bitnumIntl(state, 19, 6) | + ((state & 0x00000f00) << 13) | + bitnumIntl(state, 24, 11) | + bitnumIntl(state, 23, 12) | + ((state & 0x000000f0) << 11) | + bitnumIntl(state, 28, 17) | + bitnumIntl(state, 27, 18) | + ((state & 0x0000000f) << 9) | + bitnumIntl(state, 0, 23)) >>> + 0; + + const lrgstate = [ + ((t1 >>> 24) & 0x000000ff) ^ key[0], + ((t1 >>> 16) & 0x000000ff) ^ key[1], + ((t1 >>> 8) & 0x000000ff) ^ key[2], + ((t2 >>> 24) & 0x000000ff) ^ key[3], + ((t2 >>> 16) & 0x000000ff) ^ key[4], + ((t2 >>> 8) & 0x000000ff) ^ key[5], + ]; + + state = + ((sbox[0][sboxBit(lrgstate[0] >>> 2)] << 28) | + (sbox[1][sboxBit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) | + (sbox[2][sboxBit(((lrgstate[1] & 0x0f) << 2) | (lrgstate[2] >>> 6))] << 20) | + (sbox[3][sboxBit(lrgstate[2] & 0x3f)] << 16) | + (sbox[4][sboxBit(lrgstate[3] >>> 2)] << 12) | + (sbox[5][sboxBit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) | + (sbox[6][sboxBit(((lrgstate[4] & 0x0f) << 2) | (lrgstate[5] >>> 6))] << 4) | + sbox[7][sboxBit(lrgstate[5] & 0x3f)]) >>> + 0; + + return ( + (bitnumIntl(state, 15, 0) | + bitnumIntl(state, 6, 1) | + bitnumIntl(state, 19, 2) | + bitnumIntl(state, 20, 3) | + bitnumIntl(state, 28, 4) | + bitnumIntl(state, 11, 5) | + bitnumIntl(state, 27, 6) | + bitnumIntl(state, 16, 7) | + bitnumIntl(state, 0, 8) | + bitnumIntl(state, 14, 9) | + bitnumIntl(state, 22, 10) | + bitnumIntl(state, 25, 11) | + bitnumIntl(state, 4, 12) | + bitnumIntl(state, 17, 13) | + bitnumIntl(state, 30, 14) | + bitnumIntl(state, 9, 15) | + bitnumIntl(state, 1, 16) | + bitnumIntl(state, 7, 17) | + bitnumIntl(state, 23, 18) | + bitnumIntl(state, 13, 19) | + bitnumIntl(state, 31, 20) | + bitnumIntl(state, 26, 21) | + bitnumIntl(state, 2, 22) | + bitnumIntl(state, 8, 23) | + bitnumIntl(state, 18, 24) | + bitnumIntl(state, 12, 25) | + bitnumIntl(state, 29, 26) | + bitnumIntl(state, 5, 27) | + bitnumIntl(state, 21, 28) | + bitnumIntl(state, 10, 29) | + bitnumIntl(state, 3, 30) | + bitnumIntl(state, 24, 31)) >>> + 0 + ); +}; + +const crypt = (inputData: Uint8Array, key: number[][]): Uint8Array => { + let [s0, s1] = initialPermutation(inputData); + + for (let idx = 0; idx < 15; idx++) { + const previousS1 = s1; + s1 = (f(s1, key[idx]) ^ s0) >>> 0; + s0 = previousS1; + } + s0 = (f(s1, key[15]) ^ s0) >>> 0; + + return inversePermutation(s0, s1); +}; + +const keySchedule = (key: Uint8Array, mode: number): number[][] => { + const schedule: number[][] = Array.from({ length: 16 }, () => Array(6).fill(0)); + const keyRndShift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]; + const keyPermC = [ + 56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, + 51, 43, 35, + ]; + const keyPermD = [ + 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4, 27, + 19, 11, 3, + ]; + const keyCompression = [ + 13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51, + 30, 36, 46, 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31, + ]; + + let c = 0; + let d = 0; + for (let i = 0; i < 28; i++) { + c |= bitnum(key, keyPermC[i], 31 - i); + d |= bitnum(key, keyPermD[i], 31 - i); + } + + for (let i = 0; i < 16; i++) { + c = (((c << keyRndShift[i]) | (c >>> (28 - keyRndShift[i]))) & 0xfffffff0) >>> 0; + d = (((d << keyRndShift[i]) | (d >>> (28 - keyRndShift[i]))) & 0xfffffff0) >>> 0; + + const togen = mode === DECRYPT ? 15 - i : i; + + for (let j = 0; j < 6; j++) { + schedule[togen][j] = 0; + } + + for (let j = 0; j < 24; j++) { + schedule[togen][Math.floor(j / 8)] |= bitnumIntr(c, keyCompression[j], 7 - (j % 8)); + } + + for (let j = 24; j < 48; j++) { + schedule[togen][Math.floor(j / 8)] |= bitnumIntr(d, keyCompression[j] - 27, 7 - (j % 8)); + } + } + + return schedule; +}; + +const tripleDesKeySetup = (key: Uint8Array, mode: number): number[][][] => { + if (mode === ENCRYPT) { + return [ + keySchedule(key.slice(0), ENCRYPT), + keySchedule(key.slice(8), DECRYPT), + keySchedule(key.slice(16), ENCRYPT), + ]; + } + return [ + keySchedule(key.slice(16), DECRYPT), + keySchedule(key.slice(8), ENCRYPT), + keySchedule(key.slice(0), DECRYPT), + ]; +}; + +const tripleDesCrypt = (data: Uint8Array, key: number[][][]): Uint8Array => { + let result = data; + for (let i = 0; i < 3; i++) { + result = crypt(result, key[i]); + } + return result; +}; + +/** + * 解密 QRC 歌词 + * @param encryptedData - 加密的字节数组 + * @param key - 24字节密钥 + * @returns 解密后的字节数组 + */ +export const qrcDecrypt = (encryptedData: Uint8Array, key: Uint8Array): Uint8Array => { + const schedule = tripleDesKeySetup(key, DECRYPT); + const result: number[] = []; + + // 以 8 字节为单位迭代 + for (let i = 0; i < encryptedData.length; i += 8) { + const block = encryptedData.slice(i, i + 8); + const decrypted = tripleDesCrypt(block, schedule); + result.push(...decrypted); + } + + return new Uint8Array(result); +}; diff --git a/electron/main/apis/qqmusic/core/types.ts b/electron/main/apis/qqmusic/core/types.ts new file mode 100644 index 0000000..529f0c1 --- /dev/null +++ b/electron/main/apis/qqmusic/core/types.ts @@ -0,0 +1,9 @@ +/** + * QM 模块函数签名 + * 入参来自 IPC 非受控数据;module 内部直接解构即可, + * 值类型为 unknown,但 qmRequest 的 param 也是 `Record`,直接透传不需 cast + */ + +export type QMParams = Record; + +export type QMModule = (params: QMParams) => Promise; diff --git a/electron/main/apis/qqmusic/index.ts b/electron/main/apis/qqmusic/index.ts new file mode 100644 index 0000000..7fa194c --- /dev/null +++ b/electron/main/apis/qqmusic/index.ts @@ -0,0 +1,73 @@ +/** + * QM 主进程服务 + * + * 与 netease 不同之处: + * - 无持久化 session(uid/sid 是匿名态,内存缓存 1h 足够) + * - 无 cookie 登录态(播放 URL 由插件实现,不走账号) + * - 走 fetch 原生 HTTP,无加密 body(靠 UA + comm 伪装) + * + * 统一入口:callQQMusic(name, params) + */ + +import { createHash } from "node:crypto"; +import { modules } from "./modules"; +import type { QMParams } from "./core/types"; + +/** 2 分钟响应缓存 */ +const DEFAULT_TTL = 2 * 60 * 1000; +const MAX_ENTRIES = 200; + +interface CacheEntry { + value: unknown; + expireAt: number; +} + +const cache = new Map(); + +const hashParams = (params: unknown): string => + createHash("md5") + .update(JSON.stringify(params ?? {})) + .digest("hex") + .slice(0, 8); + +const cacheGet = (key: string): unknown => { + const hit = cache.get(key); + if (!hit) return undefined; + if (hit.expireAt <= Date.now()) { + cache.delete(key); + return undefined; + } + cache.delete(key); + cache.set(key, hit); + return hit.value; +}; + +const cacheSet = (key: string, value: unknown, ttl = DEFAULT_TTL): void => { + if (cache.size >= MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { value, expireAt: Date.now() + ttl }); +}; + +export const clearQQMusicCache = (): void => { + cache.clear(); +}; + +/** + * 调用任意 QM API + * @param name 见 modules/index.ts 中的 key(search / song_info / lyric / match / hot_search / leaderboard / song_list) + * @param params 业务参数;不想命中缓存可传 `timestamp: Date.now()` + */ +export const callQQMusic = async (name: string, params: QMParams = {}): Promise => { + const fn = modules[name]; + if (!fn) throw new Error(`unknown qm api: ${name}`); + + const key = `${name}|${hashParams(params)}`; + const hit = cacheGet(key); + if (hit !== undefined) return hit; + + const value = await fn(params); + cacheSet(key, value); + return value; +}; diff --git a/electron/main/apis/qqmusic/modules/hot_search.ts b/electron/main/apis/qqmusic/modules/hot_search.ts new file mode 100644 index 0000000..d256edd --- /dev/null +++ b/electron/main/apis/qqmusic/modules/hot_search.ts @@ -0,0 +1,30 @@ +/** + * 热搜关键词 + * module: tencent_musicsoso_hotkey.HotkeyService / GetHotkeyForQQMusicPC + */ + +import { qmRequest } from "../core/request"; +import type { QMModule } from "../core/types"; + +interface HotItem { + query?: string; + title?: string; + id?: number; +} + +const hotSearch: QMModule = async () => { + const data = await qmRequest<{ vec_hotkey?: HotItem[] }>( + "tencent_musicsoso_hotkey.HotkeyService", + "GetHotkeyForQQMusicPC", + { search_id: "", uin: 0 }, + ); + + const list = (data?.vec_hotkey ?? []).map((item) => ({ + keyword: item.query || item.title || "", + id: item.id, + })); + + return { code: 200, list }; +}; + +export default hotSearch; diff --git a/electron/main/apis/qqmusic/modules/index.ts b/electron/main/apis/qqmusic/modules/index.ts new file mode 100644 index 0000000..5309a88 --- /dev/null +++ b/electron/main/apis/qqmusic/modules/index.ts @@ -0,0 +1,23 @@ +/** + * QM 模块注册表 + */ + +import type { QMModule } from "../core/types"; + +import hot_search from "./hot_search"; +import leaderboard from "./leaderboard"; +import lyric from "./lyric"; +import match from "./match"; +import search from "./search"; +import song_info from "./song_info"; +import song_list from "./song_list"; + +export const modules: Record = { + hot_search, + leaderboard, + lyric, + match, + search, + song_info, + song_list, +}; diff --git a/electron/main/apis/qqmusic/modules/leaderboard.ts b/electron/main/apis/qqmusic/modules/leaderboard.ts new file mode 100644 index 0000000..df937e3 --- /dev/null +++ b/electron/main/apis/qqmusic/modules/leaderboard.ts @@ -0,0 +1,66 @@ +/** + * 排行榜 + * module: musicToplist.ToplistInfoServer / GetDetail + * + * params: + * - topid 榜单 ID(必填):26 流行榜 / 27 新歌榜 / 4 飙升榜 / 62 热歌榜 等 + * - period 期数(YYYY_ww),不传取最新 + * - limit 返回条数,默认 50 + * - offset 偏移量 + */ + +import { qmRequest } from "../core/request"; +import { formatSingerName } from "../core/config"; +import type { QMModule } from "../core/types"; + +interface ToplistSong { + songInfo?: { + id?: number; + mid?: string; + title?: string; + interval?: number; + singer?: Array<{ name?: string; mid?: string }>; + album?: { name?: string; mid?: string }; + }; +} + +const leaderboard: QMModule = async (params) => { + const { topid, period = "", limit = 50, offset = 0 } = params; + + const data = await qmRequest<{ + title?: string; + titleDetail?: string; + updateTime?: string; + headPicUrl?: string; + songInfoList?: ToplistSong[]; + }>("musicToplist.ToplistInfoServer", "GetDetail", { + topid, + num: limit, + offset, + period, + }); + + const songs = (data?.songInfoList ?? []) + .map((item) => item.songInfo) + .filter((song): song is NonNullable => !!song) + .map((song) => ({ + id: String(song.id ?? ""), + mid: song.mid ?? "", + name: song.title ?? "", + artist: formatSingerName(song.singer), + album: song.album?.name ?? "", + albumMid: song.album?.mid ?? "", + duration: (song.interval ?? 0) * 1000, + })); + + return { + code: 200, + title: data?.title ?? "", + subTitle: data?.titleDetail ?? "", + updateTime: data?.updateTime ?? "", + cover: data?.headPicUrl ?? "", + songs, + }; +}; + +export default leaderboard; diff --git a/electron/main/apis/qqmusic/modules/lyric.ts b/electron/main/apis/qqmusic/modules/lyric.ts new file mode 100644 index 0000000..1f457f3 --- /dev/null +++ b/electron/main/apis/qqmusic/modules/lyric.ts @@ -0,0 +1,114 @@ +/** + * 歌词(含 QRC 逐字 / 翻译 / 罗马音) + * module: music.musichallSong.PlayLyricInfo / GetPlayLyricInfo + * + * 流程:请求 crypt=1 → 返回 hex 密文 → Triple DES + zlib 解压 → 原始 QRC/LRC 文本 + * + * params: + * - id 歌曲数字 ID(必填) + * - name 歌曲名(用于服务端匹配,Base64 编码后发送) + * - artist 歌手名 + * - album 专辑名 + * - duration 时长(秒) + */ + +import { qmRequest } from "../core/request"; +import { decryptQrc } from "../core/qrc"; +import type { QMModule } from "../core/types"; + +interface LyricResponse { + code: number; + lrc?: string; + qrc?: string; + trans?: string; + roma?: string; + message?: string; +} + +const b64 = (text: unknown): string => Buffer.from(String(text ?? ""), "utf8").toString("base64"); + +interface LyricResp { + lyric?: string; + qrc_t?: number; + trans?: string; + roma?: string; +} + +const tryDecrypt = (hex: string | undefined): string | undefined => { + if (!hex) return undefined; + try { + return decryptQrc(hex); + } catch { + return undefined; + } +}; + +const lyric: QMModule = async (params) => { + const { id, name = "", artist = "", album = "", duration = 0 } = params; + + const baseParam = { + albumName: b64(album), + crypt: 1, + ct: 19, + cv: 2111, + interval: duration, + lrc_t: 0, + qrc: 1, + qrc_t: 0, + roma: 1, + roma_t: 0, + singerName: b64(artist), + songID: id, + songName: b64(name), + trans: 1, + trans_t: 0, + type: 0, + }; + + try { + const resp = await qmRequest( + "music.musichallSong.PlayLyricInfo", + "GetPlayLyricInfo", + baseParam, + ); + + const result: LyricResponse = { code: 200 }; + + // 主歌词:按 qrc_t 判断服务端返回的是 QRC 还是 LRC + const mainDecrypted = tryDecrypt(resp.lyric); + if (mainDecrypted) { + if (resp.qrc_t === 0) { + // 未开启 QRC,lyric 字段是 LRC + result.lrc = mainDecrypted; + } else { + result.qrc = mainDecrypted; + } + } + + // 若只拿到 QRC,再单独请求一次 LRC 格式 + if (result.qrc && !result.lrc) { + try { + const lrcResp = await qmRequest( + "music.musichallSong.PlayLyricInfo", + "GetPlayLyricInfo", + { ...baseParam, qrc: 0, qrc_t: 0 }, + ); + const lrcText = tryDecrypt(lrcResp.lyric); + if (lrcText) result.lrc = lrcText; + } catch { + // 次级失败不影响主结果 + } + } + + result.trans = tryDecrypt(resp.trans); + result.roma = tryDecrypt(resp.roma); + return result; + } catch (err) { + return { + code: 500, + message: err instanceof Error ? err.message : String(err), + }; + } +}; + +export default lyric; diff --git a/electron/main/apis/qqmusic/modules/match.ts b/electron/main/apis/qqmusic/modules/match.ts new file mode 100644 index 0000000..d4bf3c7 --- /dev/null +++ b/electron/main/apis/qqmusic/modules/match.ts @@ -0,0 +1,60 @@ +/** + * 模糊匹配歌词 + * 组合 search + lyric:搜第一条命中后取其歌词 + * + * params: + * - keywords 搜索关键词,建议 "歌曲名-歌手名" 格式 + */ + +import searchModule from "./search"; +import lyricModule from "./lyric"; +import type { QMModule } from "../core/types"; + +interface SearchedSong { + id: string; + mid: string; + name: string; + artist: string; + album: string; + duration: number; +} + +interface LyricOut { + code: number; + lrc?: string; + qrc?: string; + trans?: string; + roma?: string; + message?: string; +} + +const match: QMModule = async (params) => { + const { keywords } = params; + + const searched = (await searchModule({ keywords, page: 1, limit: 1 })) as { + songs?: SearchedSong[]; + }; + const song = searched.songs?.[0]; + if (!song) return { code: 404, message: "未找到匹配的歌曲" }; + + const lyricData = (await lyricModule({ + id: Number(song.id), + name: song.name, + artist: song.artist, + album: song.album, + duration: Math.floor(song.duration / 1000), + })) as LyricOut; + + if (lyricData.code !== 200) return lyricData; + + return { + code: 200, + song, + lrc: lyricData.lrc, + qrc: lyricData.qrc, + trans: lyricData.trans, + roma: lyricData.roma, + }; +}; + +export default match; diff --git a/electron/main/apis/qqmusic/modules/search.ts b/electron/main/apis/qqmusic/modules/search.ts new file mode 100644 index 0000000..88b9aba --- /dev/null +++ b/electron/main/apis/qqmusic/modules/search.ts @@ -0,0 +1,76 @@ +/** + * 搜索歌曲 + * module: music.search.SearchCgiService / DoSearchForQQMusicLite + * + * params: + * - keywords 关键词(必填) + * - page 页码,默认 1 + * - limit 每页数,默认 30 + * - type 0 单曲 / 1 歌手 / 2 专辑 / 3 歌单 / 4 MV / 7 歌词 / 8 用户,默认 0 + */ + +import { qmRequest } from "../core/request"; +import { formatSingerName } from "../core/config"; +import type { QMModule } from "../core/types"; + +interface QMSong { + id: number; + mid: string; + title: string; + interval: number; + singer?: Array<{ id?: number; mid?: string; name?: string }>; + album?: { id?: number; mid?: string; name?: string }; + file?: { + media_mid?: string; + size_128mp3?: number; + size_320mp3?: number; + size_flac?: number; + size_hires?: number; + }; +} + +/** 搜索 search_id:移动端随机数,用于服务端日志/去重 */ +const genSearchId = (): string => + String( + Math.floor(Math.random() * 20) * 18014398509481984 + + Math.floor(Math.random() * 4194304) * 4294967296 + + (Date.now() % 86400000), + ); + +const search: QMModule = async (params) => { + const { keywords, page = 1, limit = 30, type = 0 } = params; + + const data = await qmRequest<{ + body?: { item_song?: QMSong[]; meta?: { sum?: number } }; + }>("music.search.SearchCgiService", "DoSearchForQQMusicLite", { + search_id: genSearchId(), + remoteplace: "search.android.keyboard", + query: keywords, + search_type: type, + num_per_page: limit, + page_num: page, + highlight: 0, + nqc_flag: 0, + page_id: 1, + grp: 1, + }); + + const songs = (data?.body?.item_song ?? []).map((song) => ({ + id: String(song.id), + mid: song.mid, + name: song.title, + artist: formatSingerName(song.singer), + album: song.album?.name ?? "", + albumMid: song.album?.mid ?? "", + duration: (song.interval ?? 0) * 1000, + mediaMid: song.file?.media_mid ?? "", + })); + + return { + code: 200, + total: data?.body?.meta?.sum ?? songs.length, + songs, + }; +}; + +export default search; diff --git a/electron/main/apis/qqmusic/modules/song_info.ts b/electron/main/apis/qqmusic/modules/song_info.ts new file mode 100644 index 0000000..58c4df9 --- /dev/null +++ b/electron/main/apis/qqmusic/modules/song_info.ts @@ -0,0 +1,50 @@ +/** + * 单曲详情 + * module: music.pf_song_detail_svr / get_song_detail_yqq + * + * params: + * - mid songmid,例如 "001qvvgF38HVc4" + */ + +import { qmRequest } from "../core/request"; +import { formatSingerName } from "../core/config"; +import type { QMModule } from "../core/types"; + +interface TrackInfo { + id: number; + mid: string; + title: string; + interval: number; + singer?: Array<{ name?: string; mid?: string }>; + album?: { name?: string; mid?: string }; + file?: Record; +} + +const songInfo: QMModule = async (params) => { + const { mid } = params; + + const data = await qmRequest<{ track_info?: TrackInfo }>( + "music.pf_song_detail_svr", + "get_song_detail_yqq", + { song_type: 0, song_mid: mid }, + ); + + const track = data?.track_info; + if (!track) return { code: 404, message: "song not found" }; + + return { + code: 200, + song: { + id: String(track.id), + mid: track.mid, + name: track.title, + artist: formatSingerName(track.singer), + album: track.album?.name ?? "", + albumMid: track.album?.mid ?? "", + duration: (track.interval ?? 0) * 1000, + file: track.file, + }, + }; +}; + +export default songInfo; diff --git a/electron/main/apis/qqmusic/modules/song_list.ts b/electron/main/apis/qqmusic/modules/song_list.ts new file mode 100644 index 0000000..604e67e --- /dev/null +++ b/electron/main/apis/qqmusic/modules/song_list.ts @@ -0,0 +1,71 @@ +/** + * 歌单详情 + * 使用 c.y.qq.com 的 GET 接口(不走 musicu.fcg) + * 端点:https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg + * + * params: + * - id disstid(歌单 ID,必填) + */ + +import { QM_HEADERS, formatSingerName } from "../core/config"; +import type { QMModule } from "../core/types"; + +interface CdSongItem { + songid?: number; + songmid?: string; + songname?: string; + interval?: number; + singer?: Array<{ name?: string; mid?: string }>; + albumname?: string; + albummid?: string; +} + +interface CdListResp { + code?: number; + cdlist?: Array<{ + disstid?: number; + dissname?: string; + desc?: string; + nickname?: string; + logo?: string; + visitnum?: number; + songlist?: CdSongItem[]; + }>; +} + +const SONGLIST_URL = + "https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysonglist=0&platform=yqq&needNewCode=0"; + +const songList: QMModule = async (params) => { + const { id } = params; + + const url = `${SONGLIST_URL}&disstid=${id}`; + const res = await fetch(url, { headers: QM_HEADERS }); + const data = (await res.json()) as CdListResp; + + const cd = data.cdlist?.[0]; + if (!cd) return { code: 404, message: "歌单不存在" }; + + const songs = (cd.songlist ?? []).map((item) => ({ + id: String(item.songid ?? ""), + mid: item.songmid ?? "", + name: item.songname ?? "", + artist: formatSingerName(item.singer), + album: item.albumname ?? "", + albumMid: item.albummid ?? "", + duration: (item.interval ?? 0) * 1000, + })); + + return { + code: 200, + id: cd.disstid, + name: cd.dissname ?? "", + description: cd.desc ?? "", + creator: cd.nickname ?? "", + cover: cd.logo ?? "", + playCount: cd.visitnum ?? 0, + songs, + }; +}; + +export default songList; diff --git a/electron/main/core/index.ts b/electron/main/core/index.ts index 82c52f3..4ff3221 100644 --- a/electron/main/core/index.ts +++ b/electron/main/core/index.ts @@ -10,9 +10,11 @@ import { import { registerIpcHandlers } from "@main/ipc"; import { init as initMedia, shutdown as shutdownMedia } from "@main/services/media"; import { initDatabase, closeDatabase } from "@main/database"; +import { pluginRegistry } from "@main/plugins/registry"; import { registerCacheScheme, handleCacheProtocol } from "@main/utils/protocol"; import { coreLog, initLogger } from "@main/utils/logger"; import { store } from "@main/store"; +import { isWin } from "@main/utils/config"; /** * 配置 Chromium 启动参数以优化内存占用 @@ -25,11 +27,7 @@ const configureMemoryOptimizations = (): void => { // 禁用不需要的 Chromium 功能 app.commandLine.appendSwitch( "disable-features", - [ - "MediaRouter", // 不需要 Chromecast - "TranslateUI", // 不需要翻译 - "SpareRendererForSitePerProcess", // 不需要备用渲染进程 - ].join(","), + ["MediaRouter", "TranslateUI", "SpareRendererForSitePerProcess"].join(","), ); // 减少渲染进程内存分配器保留 app.commandLine.appendSwitch("renderer-process-limit", "1"); @@ -71,6 +69,8 @@ export const initApp = (): void => { registerIpcHandlers(); // 初始化系统媒体控件 initMedia(); + // 初始化插件系统(扫描并启动已启用的插件) + pluginRegistry.init(); // 创建主窗口 createMainWindow(); // 恢复歌词相关窗口 @@ -93,8 +93,11 @@ export const initApp = (): void => { // 快照歌词相关窗口的打开状态,供下次启动恢复 store.set("windowStates.desktopLyric.visible", !!getDesktopLyricWindow()); store.set("windowStates.dynamicIsland.visible", !!getDynamicIslandWindow()); - store.set("windowStates.taskbarLyric.visible", !!getTaskbarLyricWindow()); + if (isWin) { + store.set("windowStates.taskbarLyric.visible", !!getTaskbarLyricWindow()); + } shutdownMedia(); closeDatabase(); + void pluginRegistry.shutdown(); }); }; diff --git a/electron/main/database/index.ts b/electron/main/database/index.ts index a36fee3..fe21add 100644 --- a/electron/main/database/index.ts +++ b/electron/main/database/index.ts @@ -43,6 +43,12 @@ export const initDatabase = (): void => { ); CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks(title); CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album); + + CREATE TABLE IF NOT EXISTS account_sessions ( + platform TEXT PRIMARY KEY, + cookies TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL + ); `); migrate(db); libraryLog.info(`数据库已初始化: ${dbPath}`); diff --git a/electron/main/database/sessions.ts b/electron/main/database/sessions.ts new file mode 100644 index 0000000..28c1f1a --- /dev/null +++ b/electron/main/database/sessions.ts @@ -0,0 +1,51 @@ +/** + * 第三方音源账号会话(cookies)存储 + * + * 表:account_sessions(platform PK, cookies JSON, updated_at) + * - cookies 字段以 JSON 字符串存 Record + */ + +import { getDb } from "./index"; + +/** 支持的音源平台标识 */ +export type AccountPlatform = "netease"; + +interface SessionRow { + platform: string; + cookies: string; + updated_at: number; +} + +/** 读取某平台的 cookies;未登录返回空对象 */ +export const getSessionCookies = (platform: AccountPlatform): Record => { + const row = getDb() + .prepare("SELECT cookies FROM account_sessions WHERE platform = ?") + .get(platform) as Pick | undefined; + if (!row) return {}; + try { + const parsed = JSON.parse(row.cookies) as Record; + return parsed ?? {}; + } catch { + return {}; + } +}; + +/** 整体替换某平台的 cookies(登出传 {} 即可) */ +export const saveSessionCookies = ( + platform: AccountPlatform, + cookies: Record, +): void => { + getDb() + .prepare( + `INSERT INTO account_sessions (platform, cookies, updated_at) VALUES (?, ?, ?) + ON CONFLICT(platform) DO UPDATE SET + cookies = excluded.cookies, + updated_at = excluded.updated_at`, + ) + .run(platform, JSON.stringify(cookies), Date.now()); +}; + +/** 清除某平台的 cookies(登出) */ +export const clearSessionCookies = (platform: AccountPlatform): void => { + saveSessionCookies(platform, {}); +}; diff --git a/electron/main/ipc/apis.ts b/electron/main/ipc/apis.ts new file mode 100644 index 0000000..e454d43 --- /dev/null +++ b/electron/main/ipc/apis.ts @@ -0,0 +1,57 @@ +/** + * 音源 API 统一 IPC + * + * 只注册两个通道: + * - apis:call(platform, name, params) 调用对应平台的任意接口 + * - apis:clearSession(platform) 清空某平台登录态 + */ + +import { ipcMain } from "electron"; +import { callNetease, clearNeteaseCookies } from "@main/apis/netease"; +import { callQQMusic } from "@main/apis/qqmusic"; +import { callKugou } from "@main/apis/kugou"; +import { coreLog } from "@main/utils/logger"; +import type { ApiPlatform } from "@shared/types/apis"; + +/** 各平台的调用器:统一返回 `{ status?, body?, data? }` 由前端按需取 */ +const dispatch = async ( + platform: ApiPlatform, + name: string, + params: Record, +): Promise> => { + switch (platform) { + case "netease": { + const res = await callNetease(name, params); + return { status: res.status, body: res.body }; + } + case "qqmusic": { + const data = await callQQMusic(name, params); + return { data }; + } + case "kugou": { + const data = await callKugou(name, params); + return { data }; + } + default: + throw new Error(`unknown platform: ${platform}`); + } +}; + +export const registerApisIpc = (): void => { + ipcMain.handle( + "apis:call", + async (_evt, platform: ApiPlatform, name: string, params?: Record) => { + try { + const result = await dispatch(platform, name, params ?? {}); + return { ok: true, ...result }; + } catch (err) { + coreLog.warn(`[apis] ${platform}.${name} failed:`, err); + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + ); + + ipcMain.handle("apis:clearSession", (_evt, platform: ApiPlatform) => { + if (platform === "netease") clearNeteaseCookies(); + }); +}; diff --git a/electron/main/ipc/config.ts b/electron/main/ipc/config.ts index fe04501..c1c2ce2 100644 --- a/electron/main/ipc/config.ts +++ b/electron/main/ipc/config.ts @@ -17,6 +17,7 @@ import { applyTaskbarLyricLayout, } from "@main/window"; import { broadcast } from "@main/utils/broadcast"; +import { isWin } from "@main/utils/config"; /** 配置写入后的副作用 */ const applyConfigChange = (keyPath: string, value: unknown): void => { @@ -53,7 +54,7 @@ const applyConfigChange = (keyPath: string, value: unknown): void => { case "taskbarLyric.position": case "taskbarLyric.autoMaxWidth": case "taskbarLyric.maxWidth": - applyTaskbarLyricLayout(); + if (isWin) applyTaskbarLyricLayout(); break; } // 桌面歌词配置变更广播到所有窗口 @@ -64,8 +65,8 @@ const applyConfigChange = (keyPath: string, value: unknown): void => { if (keyPath.startsWith("dynamicIsland.")) { broadcast("dynamicIsland:configChange", store.get("dynamicIsland")); } - // 任务栏歌词配置变更广播到所有窗口 - if (keyPath.startsWith("taskbarLyric.")) { + // 任务栏歌词配置变更广播到所有窗口(仅 Windows) + if (isWin && keyPath.startsWith("taskbarLyric.")) { broadcast("taskbarLyric:configChange", store.get("taskbarLyric")); } }; diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index e2705aa..1adef99 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -4,6 +4,8 @@ import { registerConfigIpc } from "./config"; import { registerLibraryIpc } from "./library"; import { registerNowPlayingIpc } from "./nowPlaying"; import { registerWindowIpc } from "./window"; +import { registerPluginIpc } from "./plugin"; +import { registerApisIpc } from "./apis"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -13,4 +15,6 @@ export const registerIpcHandlers = (): void => { registerLibraryIpc(); registerNowPlayingIpc(); registerWindowIpc(); + registerPluginIpc(); + registerApisIpc(); }; diff --git a/electron/main/ipc/library.ts b/electron/main/ipc/library.ts index 0896cc0..09ef659 100644 --- a/electron/main/ipc/library.ts +++ b/electron/main/ipc/library.ts @@ -14,7 +14,7 @@ import { getTracksByIds, } from "@main/database"; import { startScan, cancelScan, isScanning } from "@main/services/scanner"; -import { fetchArtistAvatar, prefetchArtistAvatars } from "@server/artistAvatar"; +import { fetchArtistAvatar, prefetchArtistAvatars } from "@main/apis/musicbrainz"; import { libraryLog } from "@main/utils/logger"; import { ErrorCode } from "@shared/types/errors"; diff --git a/electron/main/ipc/plugin.ts b/electron/main/ipc/plugin.ts new file mode 100644 index 0000000..1e1912f --- /dev/null +++ b/electron/main/ipc/plugin.ts @@ -0,0 +1,118 @@ +/** + * 插件系统 IPC + * + * 渲染端通过 `window.api.plugins.*` 调用以下 channel: + * - plugin:list / install / pickAndInstall / installFromUrl / uninstall / setEnabled + * - plugin:search / resolveUrl + * 并订阅 `plugin:status` 广播以更新 UI。 + */ + +import { ipcMain, dialog, net } from "electron"; +import type { PluginInfo } from "@shared/types/plugin"; +import { INSTALL_URL_MAX_SIZE, INSTALL_URL_TIMEOUT } from "@shared/defaults/plugin-api"; +import { pluginRegistry } from "@main/plugins/registry"; +import { resolveUrl } from "@main/plugins/router"; +import { broadcast } from "@main/utils/broadcast"; +import { coreLog } from "@main/utils/logger"; + +/** 从 URL 拉取脚本源码,带大小与超时限制 */ +const fetchScriptFromUrl = async (url: string): Promise => { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`protocol not allowed: ${parsed.protocol}`); + } + const resp = await net.fetch(url, { + method: "GET", + redirect: "follow", + signal: AbortSignal.timeout(INSTALL_URL_TIMEOUT), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + // Content-Length 预检(仅提示,不强信任) + const lenHeader = resp.headers.get("content-length"); + if (lenHeader && Number(lenHeader) > INSTALL_URL_MAX_SIZE) { + throw new Error("PLUGIN_INSTALL_URL_TOO_LARGE"); + } + const buf = await resp.arrayBuffer(); + if (buf.byteLength > INSTALL_URL_MAX_SIZE) { + throw new Error("PLUGIN_INSTALL_URL_TOO_LARGE"); + } + return new TextDecoder("utf-8").decode(buf); +}; + +export const registerPluginIpc = (): void => { + ipcMain.handle("plugin:list", (): PluginInfo[] => pluginRegistry.listInfo()); + + ipcMain.handle("plugin:install", async (_evt, filePath: string) => { + try { + const info = await pluginRegistry.install(filePath); + return { ok: true, id: info.manifest.id }; + } catch (err) { + coreLog.warn("[plugin] install failed:", err); + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + // 弹出原生文件选择框 → 安装选中的 .js 脚本 + // 注意:不要把主窗口作为 parent 传入,frameless 窗口 + 模态对话框会在 Windows 上卡死主窗 + ipcMain.handle("plugin:pickAndInstall", async () => { + const res = await dialog.showOpenDialog({ + title: "选择插件脚本", + filters: [{ name: "Plugin Script", extensions: ["js"] }], + properties: ["openFile"], + }); + if (res.canceled || !res.filePaths[0]) return { ok: false, cancelled: true }; + try { + const info = await pluginRegistry.install(res.filePaths[0]); + return { ok: true, id: info.manifest.id }; + } catch (err) { + coreLog.warn("[plugin] pickAndInstall failed:", err); + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + // 从远端 URL 下载并安装 + ipcMain.handle("plugin:installFromUrl", async (_evt, url: string) => { + try { + const source = await fetchScriptFromUrl(url); + const info = await pluginRegistry.installFromSource(source); + return { ok: true, id: info.manifest.id }; + } catch (err) { + coreLog.warn("[plugin] installFromUrl failed:", err); + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + ipcMain.handle("plugin:uninstall", async (_evt, id: string) => { + try { + await pluginRegistry.uninstall(id); + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + ipcMain.handle("plugin:setEnabled", async (_evt, id: string, enabled: boolean) => { + await pluginRegistry.setEnabled(id, enabled); + }); + + ipcMain.handle("plugin:resolveUrl", async (_evt, args) => { + return resolveUrl(args); + }); + + // 状态变化广播 + pluginRegistry.on("status", (info: PluginInfo) => { + broadcast("plugin:status", info); + }); +}; diff --git a/electron/main/ipc/window.ts b/electron/main/ipc/window.ts index 7a87ae0..ed3a36b 100644 --- a/electron/main/ipc/window.ts +++ b/electron/main/ipc/window.ts @@ -1,5 +1,6 @@ import { app, ipcMain } from "electron"; import { store } from "@main/store"; +import { isWin } from "@main/utils/config"; import { toggleDesktopLyricWindow, closeDesktopLyricWindow, @@ -90,14 +91,19 @@ export const registerWindowIpc = (): void => { return saved.mode === "floating" ? "floating" : "snapped"; }); - // 切换任务栏歌词窗口 - ipcMain.handle("window:toggleTaskbarLyric", () => toggleTaskbarLyricWindow()); - - // 关闭任务栏歌词窗口 - ipcMain.handle("window:closeTaskbarLyric", () => closeTaskbarLyricWindow()); - - // 查询任务栏歌词窗口是否打开 - ipcMain.handle("window:isTaskbarLyricOpen", () => !!getTaskbarLyricWindow()); + // 任务栏歌词仅在 Windows 注册 + if (isWin) { + // 切换任务栏歌词窗口 + ipcMain.handle("window:toggleTaskbarLyric", () => toggleTaskbarLyricWindow()); + // 关闭任务栏歌词窗口 + ipcMain.handle("window:closeTaskbarLyric", () => closeTaskbarLyricWindow()); + // 查询任务栏歌词窗口是否打开 + ipcMain.handle("window:isTaskbarLyricOpen", () => !!getTaskbarLyricWindow()); + } else { + ipcMain.handle("window:toggleTaskbarLyric", () => false); + ipcMain.handle("window:closeTaskbarLyric", () => undefined); + ipcMain.handle("window:isTaskbarLyricOpen", () => false); + } // 主窗口控制 ipcMain.on("window:minimize", () => minimizeMainWindow()); diff --git a/electron/main/plugins/host.ts b/electron/main/plugins/host.ts new file mode 100644 index 0000000..461c329 --- /dev/null +++ b/electron/main/plugins/host.ts @@ -0,0 +1,59 @@ +/** + * Host API 主进程侧实现 + * + * 当 sandbox 收到 worker 的 hostCall 消息,调用本模块的 dispatch(), + * dispatch 根据 method 去做真实工作(网络/存储),再通过 sandbox.sendHostResult 回传。 + */ + +import type { HostCallMethod, HostRequestOptions } from "@shared/types/plugin"; +import { PluginErrorCodes } from "@shared/defaults/plugin-api"; +import type { Sandbox } from "./sandbox"; +import { hostRequest } from "./net"; +import { + pluginStorageGet, + pluginStorageKeys, + pluginStorageRemove, + pluginStorageSet, +} from "./storage"; + +/** 处理一次 worker→host 调用 */ +export const dispatchHostCall = async ( + sandbox: Sandbox, + pluginId: string, + callId: string, + method: HostCallMethod, + args: unknown[], +): Promise => { + try { + let data: unknown; + switch (method) { + case "request": + data = await hostRequest(args[0] as string, (args[1] ?? {}) as HostRequestOptions); + break; + case "storage.get": + data = pluginStorageGet(pluginId, args[0] as string); + break; + case "storage.set": + pluginStorageSet(pluginId, args[0] as string, args[1]); + data = undefined; + break; + case "storage.remove": + pluginStorageRemove(pluginId, args[0] as string); + data = undefined; + break; + case "storage.keys": + data = pluginStorageKeys(pluginId); + break; + default: + throw Object.assign(new Error(`unknown host method: ${method}`), { + code: PluginErrorCodes.UNKNOWN, + }); + } + sandbox.sendHostResult(callId, true, data); + } catch (err) { + sandbox.sendHostResult(callId, false, undefined, { + code: ((err as any)?.code as string) ?? PluginErrorCodes.UNKNOWN, + message: err instanceof Error ? err.message : String(err), + }); + } +}; diff --git a/electron/main/plugins/loader.ts b/electron/main/plugins/loader.ts new file mode 100644 index 0000000..b80b861 --- /dev/null +++ b/electron/main/plugins/loader.ts @@ -0,0 +1,145 @@ +/** + * 插件脚本加载器 + * + * - 读取脚本文件(.js 或 gz_ 压缩文本) + * - 解析头部 JSDoc 元数据(`@name` / `@version` / ...) + * - 生成稳定的 pluginId(name + sha1(source).slice(0,8)) + * - 返回 { source, manifest } + */ + +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import zlib from "node:zlib"; +import type { PluginManifest, PluginPlatform } from "@shared/types/plugin"; +import { HOST_API_LEVEL, PluginErrorCodes } from "@shared/defaults/plugin-api"; + +const GZ_PREFIX = "gz_"; + +/** 脚本头部字段长度上限 */ +const FIELD_LIMITS: Record = { + name: 24, + description: 256, + author: 56, + homepage: 1024, + version: 36, +}; + +// JSDoc 风格的 `* @key value` +const HEADER_RE = /^\s?\*\s?@(\w+)\s(.+)$/; + +// 源码开头的第一个块注释(`/*` 或 `/**` 都行,允许前置空白,非贪婪) +const BLOCK_COMMENT_RE = /^\s*\/\*[\s\S]+?\*\//; + +/** 解压 gz_ 前缀脚本;若不是 gz_ 直接返回原文 */ +export const decompressIfNeeded = (raw: string): string => { + const trimmed = raw.trim(); + if (!trimmed.startsWith(GZ_PREFIX)) return raw; + const payload = trimmed.slice(GZ_PREFIX.length); + const buf = Buffer.from(payload, "base64"); + return zlib.inflateSync(buf).toString("utf-8"); +}; + +interface HeaderFields { + name?: string; + description?: string; + version?: string; + author?: string; + homepage?: string; + platform?: PluginPlatform; + apiLevel?: number; +} + +const parseHeader = (source: string): HeaderFields => { + const out: HeaderFields = {}; + const m0 = BLOCK_COMMENT_RE.exec(source); + if (!m0) return out; + const block = m0[0].slice(2, -2); + + for (const rawLine of block.split(/\r?\n/)) { + const m = HEADER_RE.exec(rawLine); + if (!m) continue; + const key = m[1]; + const raw = m[2].trim(); + const limit = FIELD_LIMITS[key]; + const val = limit && raw.length > limit ? raw.slice(0, limit) + "..." : raw; + switch (key) { + case "name": + case "description": + case "version": + case "author": + case "homepage": + out[key] = val; + break; + case "platform": + out.platform = val === "lx" ? "lx" : "splayer"; + break; + case "apiLevel": { + const n = parseInt(val, 10); + if (!Number.isNaN(n)) out.apiLevel = n; + break; + } + } + } + return out; +}; + +const sha1 = (data: string): string => crypto.createHash("sha1").update(data).digest("hex"); + +/** 规范化 name 为 id 可用的 slug */ +const slugify = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") || "plugin"; + +export interface LoadedScript { + /** 纯文本源码 */ + source: string; + /** 完整 manifest(含 id / hash / installedAt) */ + manifest: PluginManifest; + /** 是否经过 gz_ 解压 */ + decompressed: boolean; +} + +/** 从磁盘或原始字符串加载并解析 */ +export const loadScript = (rawOrPath: string, isPath: boolean, fileName?: string): LoadedScript => { + const raw = isPath ? fs.readFileSync(rawOrPath, "utf-8") : rawOrPath; + const wasCompressed = raw.trim().startsWith(GZ_PREFIX); + const source = decompressIfNeeded(raw); + const header = parseHeader(source); + const hash = sha1(source); + + // 稳定兜底——同一脚本 hash 一致,id 就一致 + const name = header.name || `user_api_${hash.slice(0, 6)}`; + const version = header.version || "0.0.0"; + + const platform: PluginPlatform = header.platform ?? (wasCompressed ? "lx" : "splayer"); + const apiLevel = header.apiLevel ?? 1; + + if (apiLevel > HOST_API_LEVEL) { + throw Object.assign( + new Error(`plugin requires apiLevel ${apiLevel} but host supports ${HOST_API_LEVEL}`), + { code: PluginErrorCodes.API_LEVEL_MISMATCH }, + ); + } + + const id = `${slugify(name)}-${hash.slice(0, 8)}`; + const finalFileName = fileName ?? (isPath ? path.basename(rawOrPath) : `${id}.js`); + + const manifest: PluginManifest = { + id, + name, + version, + description: header.description, + author: header.author, + homepage: header.homepage, + platform, + apiLevel, + hash, + installedAt: Date.now(), + fileName: finalFileName, + }; + + return { source, manifest, decompressed: wasCompressed }; +}; diff --git a/electron/main/plugins/lx-shim.ts b/electron/main/plugins/lx-shim.ts new file mode 100644 index 0000000..829e0fc --- /dev/null +++ b/electron/main/plugins/lx-shim.ts @@ -0,0 +1,349 @@ +/** + * lx-music-desktop user_api 脚本兼容垫片 + * + * 在沙箱里注入 `window.lx` / `globalThis.lx`,把 lx 的 `EVENT_NAMES` / `request` / `on` / `send` / `utils` + * / `currentScriptInfo` / `version` / `env` 桥接到 splayer 宿主 API。 + * + * 被 sandbox.worker.ts 导入,运行在 utilityProcess + vm.Context 外层(注入前)。 + */ + +import crypto from "node:crypto"; +import zlib from "node:zlib"; +import type { + HostApi, + MusicUrlRes, + PluginAction, + PluginQuality, + PluginUpdateInfo, + SourceCapability, +} from "@shared/types/plugin"; + +/** + * lx 原生音质枚举 → 宿主 PluginQuality 的映射 + * lx 用 128k/192k/320k/flac/flac24bit/ape/wav 这种具体编码描述, + * 宿主按 lq/sq/hq/lossless/hi-res 等级分类,两端做一次转换即可对齐 + */ +const LX_TO_HOST_QUALITY: Record = { + "128k": "lq", + "192k": "sq", + "320k": "hq", + flac: "lossless", + ape: "lossless", + wav: "lossless", + flac24bit: "hi-res", +}; + +const HOST_TO_LX_QUALITY: Record = { + lq: "128k", + sq: "192k", + hq: "320k", + lossless: "flac", + "hi-res": "flac24bit", +}; + +const mapLxQualityToHost = (q: string): PluginQuality | null => LX_TO_HOST_QUALITY[q] ?? null; + +const mapHostQualityToLx = (q: PluginQuality): string => HOST_TO_LX_QUALITY[q] ?? "320k"; + +const EVENT_NAMES = { + request: "request", + inited: "inited", + updateAlert: "updateAlert", +} as const; + +const eventNames: readonly string[] = Object.values(EVENT_NAMES); + +/** lx.request 回调签名(与 lx-music-desktop preload 对齐) */ +type LxRequestCallback = ( + err: Error | null, + resp?: { + statusCode: number; + statusMessage?: string; + headers: Record; + bytes?: number; + raw?: Buffer; + body?: unknown; + }, + body?: unknown, +) => void; + +/** lx.on('request', handler) 的 handler 形状 */ +type LxRequestHandler = (req: { + source: string; + action: string; + info: Record; +}) => unknown | Promise; + +export interface LxCurrentScriptInfo { + name: string; + description: string; + version: string; + author: string; + homepage: string; + rawScript: string; +} + +/** lx.utils — 对齐 lx-music-desktop 的签名(不同于 splayer.utils) */ +const buildLxUtils = (): object => ({ + crypto: { + // 注意:lx 的参数顺序是 (buffer, mode, key, iv),与我们自家 splayer.utils 不同 + aesEncrypt: ( + buffer: Buffer | Uint8Array, + mode: string, + key: Buffer | Uint8Array, + iv: Buffer | Uint8Array, + ): Buffer => { + const cipher = crypto.createCipheriv(mode, key as crypto.CipherKey, iv as crypto.BinaryLike); + return Buffer.concat([cipher.update(Buffer.from(buffer)), cipher.final()]); + }, + rsaEncrypt: (buffer: Buffer | Uint8Array, key: string): Buffer => { + // lx 行为:先左填充 0 到 128 字节,再用 RSA_NO_PADDING 加密 + const padded = Buffer.concat([Buffer.alloc(128 - buffer.length), Buffer.from(buffer)]); + return crypto.publicEncrypt({ key, padding: crypto.constants.RSA_NO_PADDING }, padded); + }, + randomBytes: (size: number): Buffer => crypto.randomBytes(size), + md5: (str: string | Uint8Array): string => + crypto + .createHash("md5") + .update(str as crypto.BinaryLike) + .digest("hex"), + }, + buffer: { + from: ( + data: ArrayBuffer | SharedArrayBuffer | string | Uint8Array | number[], + encoding?: BufferEncoding, + ): Buffer => + typeof data === "string" ? Buffer.from(data, encoding) : Buffer.from(data as ArrayBuffer), + bufToString: (buf: Buffer | Uint8Array | string, format: BufferEncoding): string => + typeof buf === "string" + ? Buffer.from(buf, "binary").toString(format) + : Buffer.from(buf).toString(format), + }, + zlib: { + // lx 的 zlib API 是异步 Promise 版本 + inflate: (buf: Buffer | Uint8Array): Promise => + new Promise((resolve, reject) => { + zlib.inflate(buf, (err, data) => { + if (err) reject(new Error(err.message)); + else resolve(data); + }); + }), + deflate: (data: Buffer | Uint8Array | string): Promise => + new Promise((resolve, reject) => { + zlib.deflate(data, (err, buf) => { + if (err) reject(new Error(err.message)); + else resolve(buf); + }); + }), + }, +}); + +/** + * 安装 lx 垫片 + * @param sandboxGlobal 沙箱上下文对象(vm.createContext 前的 plain object) + * @param splayer 宿主 API 实例 + * @param handlers 共享的 action handler 注册表 + * @param onSources 脚本通过 lx.send('inited', {sources}) 注册能力时的回调 + * @param onUpdateAvailable 脚本通过 lx.send('updateAlert', ...) 上报新版本时的回调 + * @param scriptInfo lx 脚本 currentScriptInfo(主进程解析完头注释后传入) + */ +export const installLxShim = ( + sandboxGlobal: Record, + splayer: HostApi, + handlers: Map Promise>, + onSources: (sources: Record) => void, + onUpdateAvailable: (info: PluginUpdateInfo) => void, + scriptInfo?: LxCurrentScriptInfo, +): void => { + let requestHandler: LxRequestHandler | null = null; + let inited = false; + let updateAlerted = false; + + const lxApi = { + EVENT_NAMES, + version: "2.0.0", + env: "desktop", + + request( + url: string, + opts: Record | undefined, + callback: LxRequestCallback, + ): () => void { + const o = opts ?? {}; + const method = ((o.method as string) ?? "GET").toUpperCase() as "GET" | "POST"; + const timeout = typeof o.timeout === "number" ? (o.timeout as number) : undefined; + const headers = (o.headers as Record) ?? {}; + const body = (o.body ?? o.form ?? o.formData) as + | string + | Uint8Array + | ArrayBuffer + | undefined; + + let aborted = false; + + splayer + .request(url, { + method, + headers, + body, + timeout, + responseType: "text", + }) + .then((resp) => { + if (aborted) return; + const rawText = typeof resp.body === "string" ? (resp.body as string) : ""; + let parsedBody: unknown = rawText; + try { + parsedBody = JSON.parse(rawText); + } catch { + /* 保留原字符串 */ + } + const raw = Buffer.from(rawText, "utf-8"); + callback( + null, + { + statusCode: resp.status, + statusMessage: "", + headers: resp.headers, + bytes: raw.byteLength, + raw, + body: parsedBody, + }, + parsedBody, + ); + }) + .catch((err: Error) => { + if (aborted) return; + callback(err); + }); + + // lx 返回一个 abort 函数;当前无法真正取消底层 fetch,置 aborted 丢弃结果 + return () => { + aborted = true; + }; + }, + + on(eventName: string, handler: LxRequestHandler): Promise { + if (!eventNames.includes(eventName)) { + return Promise.reject(new Error("The event is not supported: " + eventName)); + } + if (eventName === EVENT_NAMES.request) { + requestHandler = handler; + return Promise.resolve(); + } + return Promise.reject(new Error("The event is not supported: " + eventName)); + }, + + send(eventName: string, data: Record): Promise { + return new Promise((resolve, reject) => { + if (!eventNames.includes(eventName)) { + reject(new Error("The event is not supported: " + eventName)); + return; + } + switch (eventName) { + case EVENT_NAMES.inited: { + if (inited) { + reject(new Error("Script is inited")); + return; + } + inited = true; + // lx 脚本上报的 sources.qualitys / qualities 是 lx 原生音质字符串, + // 转成宿主 PluginQuality 去重后再注册给 router + const rawSources = + (data?.sources as Record< + string, + { + name?: string; + actions?: string[]; + qualitys?: string[]; + qualities?: string[]; + [key: string]: unknown; + } + >) ?? {}; + const normalized: Record = {}; + for (const [key, cap] of Object.entries(rawSources)) { + const rawQualities = cap.qualitys ?? cap.qualities ?? []; + const mapped = new Set(); + for (const q of rawQualities) { + const host = mapLxQualityToHost(q); + if (host) mapped.add(host); + } + const actions = (cap.actions ?? []).filter( + (a): a is PluginAction => a === "musicUrl", + ); + if (actions.length === 0) continue; + normalized[key] = { + name: cap.name ?? key, + actions, + qualities: Array.from(mapped), + }; + } + onSources(normalized); + resolve(); + return; + } + case EVENT_NAMES.updateAlert: { + if (updateAlerted) { + reject(new Error("The update alert can only be called once.")); + return; + } + updateAlerted = true; + // 上报给宿主,由 UI 层展示"有更新"徽章与打开下载链接按钮; + // 不再落日志(脚本通常会自己 console.log,重复输出无意义) + onUpdateAvailable({ + log: typeof data?.log === "string" ? (data.log as string) : undefined, + updateUrl: + typeof data?.updateUrl === "string" ? (data.updateUrl as string) : undefined, + version: typeof data?.version === "string" ? (data.version as string) : undefined, + updatedAt: Date.now(), + }); + resolve(); + return; + } + default: + reject(new Error("Unknown event name: " + eventName)); + } + }); + }, + + utils: buildLxUtils(), + + currentScriptInfo: scriptInfo ?? { + name: "", + description: "", + version: "", + author: "", + homepage: "", + rawScript: "", + }, + }; + + sandboxGlobal.lx = lxApi; + // 部分脚本通过 window.lx 访问 + sandboxGlobal.window = { lx: lxApi }; + + // 为每个 action 安装一个通用分派器:把 router 的 call 转译成 lx 的 request 形状 + const registerAction = (action: PluginAction): void => { + handlers.set(action, async (req: unknown) => { + if (!requestHandler) { + throw Object.assign(new Error("lx plugin has not registered request handler"), { + code: "PLUGIN_NOT_READY", + }); + } + const reqObj = req as Record; + const source = (reqObj.source as string) ?? ""; + // lx 期待 128k/320k/flac/... 音质字符串,宿主的 quality 做一次翻译 + const hostQuality = reqObj.quality as PluginQuality | undefined; + const info: Record = { + type: hostQuality ? mapHostQualityToLx(hostQuality) : undefined, + musicInfo: reqObj.musicInfo ?? {}, + }; + + const raw = await Promise.resolve(requestHandler({ source, action, info })); + if (typeof raw === "string") return { url: raw } as MusicUrlRes; + return raw; + }); + }; + + (["musicUrl"] as PluginAction[]).forEach(registerAction); +}; diff --git a/electron/main/plugins/net.ts b/electron/main/plugins/net.ts new file mode 100644 index 0000000..2c4d2c1 --- /dev/null +++ b/electron/main/plugins/net.ts @@ -0,0 +1,100 @@ +/** + * 宿主网络代理 + * + * 插件通过 splayer.request(url, opts) 调用本模块, + * 主进程用 Electron `net.fetch`(支持系统代理)去请求,应用超时与 URL 白名单。 + */ + +import { net } from "electron"; +import type { HostRequestOptions, HostRequestResult } from "@shared/types/plugin"; +import { + REQUEST_DEFAULT_TIMEOUT, + REQUEST_MAX_TIMEOUT, + PluginErrorCodes, +} from "@shared/defaults/plugin-api"; + +/** 校验并发起请求;抛出的错误携带 code 字段 */ +export const hostRequest = async ( + url: string, + opts: HostRequestOptions = {}, +): Promise => { + // URL 白名单:仅 http/https + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw Object.assign(new Error(`invalid url: ${url}`), { + code: PluginErrorCodes.URL_NOT_ALLOWED, + }); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw Object.assign(new Error(`protocol not allowed: ${parsed.protocol}`), { + code: PluginErrorCodes.URL_NOT_ALLOWED, + }); + } + + const timeoutMs = Math.min( + Math.max(opts.timeout ?? REQUEST_DEFAULT_TIMEOUT, 1_000), + REQUEST_MAX_TIMEOUT, + ); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + + // 构造 body + // 注意:Uint8Array 要按 byteOffset/byteLength 切片,避免 subarray 场景下 + // `.buffer` 把多余的底层字节一起发出去 + let body: BodyInit | undefined; + if (opts.body != null) { + if (typeof opts.body === "string") body = opts.body; + else if (opts.body instanceof ArrayBuffer) body = opts.body; + else { + const u8 = opts.body as Uint8Array; + body = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer; + } + } + + try { + const resp = await net.fetch(url, { + method: opts.method ?? "GET", + headers: opts.headers ?? {}, + body, + signal: ctrl.signal, + // 让 Electron 遵循系统代理 + bypassCustomProtocolHandlers: true, + }); + + const headers: Record = {}; + resp.headers.forEach((value, key) => { + headers[key] = value; + }); + + let responseBody: unknown; + const type = opts.responseType ?? "text"; + if (type === "arraybuffer") { + responseBody = new Uint8Array(await resp.arrayBuffer()); + } else if (type === "json") { + const text = await resp.text(); + try { + responseBody = JSON.parse(text); + } catch { + responseBody = text; + } + } else { + responseBody = await resp.text(); + } + + return { status: resp.status, headers, body: responseBody }; + } catch (err) { + if ((err as Error).name === "AbortError") { + throw Object.assign(new Error("request timeout"), { + code: PluginErrorCodes.REQUEST_TIMEOUT, + }); + } + throw Object.assign( + new Error(`network error: ${err instanceof Error ? err.message : String(err)}`), + { code: PluginErrorCodes.NETWORK_ERROR }, + ); + } finally { + clearTimeout(timer); + } +}; diff --git a/electron/main/plugins/registry.ts b/electron/main/plugins/registry.ts new file mode 100644 index 0000000..b0f37fc --- /dev/null +++ b/electron/main/plugins/registry.ts @@ -0,0 +1,377 @@ +/** + * 插件注册表 + * + * - 扫描 `{userData}/plugins/scripts/` 下的 .js 文件 + * - 维护 `Map`(manifest + 运行时状态 + sandbox) + * - 提供 install / uninstall / setEnabled / 启停 + * - 订阅 sandbox 事件,处理 hostCall、crash、重启 + */ + +import fs from "node:fs"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import { app } from "electron"; +import { writeFileSync as atomicWriteSync } from "atomically"; +import type { + PluginAction, + PluginInfo, + PluginManifest, + PluginStatus, + PluginUpdateInfo, +} from "@shared/types/plugin"; +import { PluginErrorCodes, RESTART_MAX_ATTEMPTS } from "@shared/defaults/plugin-api"; +import { store } from "@main/store"; +import { getLocale } from "@main/utils/i18n"; +import { coreLog } from "@main/utils/logger"; +import { Sandbox } from "./sandbox"; +import { loadScript } from "./loader"; +import { dispatchHostCall } from "./host"; +import { pluginStorageDrop } from "./storage"; + +const pluginsRoot = (): string => path.join(app.getPath("userData"), "plugins"); +const scriptsDir = (): string => path.join(pluginsRoot(), "scripts"); +const manifestFile = (): string => path.join(pluginsRoot(), "manifest.json"); + +interface StoredManifest { + version: 1; + plugins: Record; +} + +const ensureDirs = (): void => { + const dirs = [pluginsRoot(), scriptsDir(), path.join(pluginsRoot(), "data")]; + for (const d of dirs) if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); +}; + +const readStored = (): StoredManifest => { + try { + const raw = fs.readFileSync(manifestFile(), "utf-8"); + const data = JSON.parse(raw) as StoredManifest; + if (data?.version === 1 && data.plugins) return data; + } catch { + /* ignore */ + } + return { version: 1, plugins: {} }; +}; + +const writeStored = (data: StoredManifest): void => { + ensureDirs(); + atomicWriteSync(manifestFile(), JSON.stringify(data, null, 2)); +}; + +interface PluginRuntime { + manifest: PluginManifest; + enabled: boolean; + status: PluginStatus; + sandbox: Sandbox | null; + source: string; + restartAttempts: number; + /** 脚本上报的"有新版本"信息,null/undefined 表示没提示过 */ + updateInfo: PluginUpdateInfo | null; + /** router 注册的 pending 调用 */ + pending: Map< + string, + { + resolve: (data: unknown) => void; + reject: (err: Error) => void; + timer: NodeJS.Timeout; + } + >; +} + +class PluginRegistry extends EventEmitter { + private runtimes = new Map(); + + /** 应用启动时调用 */ + init(): void { + ensureDirs(); + const stored = readStored(); + const enabledMap = store.get("plugins.enabled") as Record; + + // 首先加载 stored manifest + for (const [id, manifest] of Object.entries(stored.plugins)) { + const scriptPath = path.join(scriptsDir(), manifest.fileName); + let source = ""; + try { + source = fs.readFileSync(scriptPath, "utf-8"); + // 重新解压(防止脚本外部被替换为 gz_) + const { source: s } = loadScript(source, false, manifest.fileName); + source = s; + } catch (err) { + coreLog.warn(`[plugin] failed to read ${manifest.fileName}:`, err); + continue; + } + const enabled = enabledMap[id] ?? true; + this.runtimes.set(id, { + manifest, + enabled, + source, + status: { state: "unloaded" }, + sandbox: null, + restartAttempts: 0, + updateInfo: null, + pending: new Map(), + }); + } + + // 启动已启用的插件 + for (const rt of this.runtimes.values()) { + if (rt.enabled) this.start(rt).catch(() => {}); + } + coreLog.info(`[plugin] registry initialized, ${this.runtimes.size} plugins loaded`); + } + + listInfo(): PluginInfo[] { + return Array.from(this.runtimes.values()).map((rt) => ({ + manifest: rt.manifest, + enabled: rt.enabled, + status: rt.status, + updateInfo: rt.updateInfo, + })); + } + + getRuntime(id: string): PluginRuntime | undefined { + return this.runtimes.get(id); + } + + /** 按动作选一个已就绪的插件(优先级 → 首个 ready) */ + pickForAction(action: PluginAction, source?: string): PluginRuntime | undefined { + const priority = store.get(`plugins.priority.${action}` as never) as string[] | undefined; + const ordered = (priority ?? []).slice(); + for (const rt of this.runtimes.values()) { + if (!ordered.includes(rt.manifest.id)) ordered.push(rt.manifest.id); + } + for (const id of ordered) { + const rt = this.runtimes.get(id); + if (!rt || !rt.enabled || rt.status.state !== "ready") continue; + const sources = rt.status.sources; + const sourceKeys = source ? [source] : Object.keys(sources); + for (const key of sourceKeys) { + const cap = sources[key]; + if (cap && cap.actions.includes(action)) return rt; + } + } + return undefined; + } + + /** 导入本地脚本文件 */ + async install(filePath: string): Promise { + const raw = fs.readFileSync(filePath, "utf-8"); + return this.installFromSource(raw); + } + + /** 从脚本源码安装(供本地文件、URL 下载等入口复用) */ + async installFromSource(raw: string): Promise { + ensureDirs(); + const { source, manifest } = loadScript(raw, false); + // 脚本落盘(明文) + const fileName = `${manifest.id}.js`; + fs.writeFileSync(path.join(scriptsDir(), fileName), source, "utf-8"); + manifest.fileName = fileName; + + // 记入 manifest.json + const stored = readStored(); + stored.plugins[manifest.id] = manifest; + writeStored(stored); + + // 互斥:新装的插件默认启用,先把其他已启用的插件停掉(sandbox + 配置 + 状态广播) + const others = [...this.runtimes.values()].filter( + (rt) => rt.manifest.id !== manifest.id && rt.enabled, + ); + for (const other of others) { + await this.setEnabled(other.manifest.id, false); + } + + // 默认启用新插件 + const enabledMap = { + ...(store.get("plugins.enabled") as Record), + [manifest.id]: true, + }; + store.set("plugins.enabled", enabledMap); + + // 放入运行时 + const existing = this.runtimes.get(manifest.id); + if (existing) await this.stop(existing); + const rt: PluginRuntime = { + manifest, + enabled: true, + source, + status: { state: "unloaded" }, + sandbox: null, + restartAttempts: 0, + updateInfo: null, + pending: new Map(), + }; + this.runtimes.set(manifest.id, rt); + await this.start(rt).catch(() => {}); + return { manifest, enabled: rt.enabled, status: rt.status, updateInfo: rt.updateInfo }; + } + + async uninstall(id: string): Promise { + const rt = this.runtimes.get(id); + if (!rt) return; + await this.stop(rt); + this.runtimes.delete(id); + + const stored = readStored(); + delete stored.plugins[id]; + writeStored(stored); + + try { + fs.unlinkSync(path.join(scriptsDir(), rt.manifest.fileName)); + } catch { + /* ignore */ + } + pluginStorageDrop(id); + + const enabledMap = { ...(store.get("plugins.enabled") as Record) }; + delete enabledMap[id]; + store.set("plugins.enabled", enabledMap); + } + + async setEnabled(id: string, enabled: boolean): Promise { + const rt = this.runtimes.get(id); + if (!rt) return; + rt.enabled = enabled; + const enabledMap = { + ...(store.get("plugins.enabled") as Record), + [id]: enabled, + }; + store.set("plugins.enabled", enabledMap); + + if (enabled) { + if (rt.status.state !== "ready") await this.start(rt).catch(() => {}); + } else { + await this.stop(rt); + this.setStatus(rt, { state: "disabled" }); + } + } + + /** 启动单个插件的 sandbox */ + private async start(rt: PluginRuntime): Promise { + if (rt.sandbox?.isAlive()) return; + this.setStatus(rt, { state: "loading" }); + + const userSettings = + (store.get(`plugins.perPlugin.${rt.manifest.id}` as never) as + | Record + | undefined) ?? {}; + + const sandbox = new Sandbox( + { + manifest: rt.manifest, + source: rt.source, + userSettings, + locale: getLocale(), + }, + { + onReady: (sources) => { + rt.restartAttempts = 0; + this.setStatus(rt, { state: "ready", sources }); + }, + onResult: (requestId, ok, data, error) => { + const p = rt.pending.get(requestId); + if (!p) return; + rt.pending.delete(requestId); + clearTimeout(p.timer); + if (ok) p.resolve(data); + else { + const err = new Error(error?.message ?? "call failed"); + + (err as any).code = error?.code ?? PluginErrorCodes.UNKNOWN; + p.reject(err); + } + }, + onHostCall: (callId, method, args) => { + void dispatchHostCall(sandbox, rt.manifest.id, callId, method, args); + }, + onLog: (level, args) => { + coreLog[level](`[plugin:${rt.manifest.id}]`, ...args); + }, + onUpdateAvailable: (info) => { + rt.updateInfo = info; + // 沿当前状态再广播一次,渲染端就能拿到 updateInfo 字段 + this.setStatus(rt, rt.status); + }, + onFatal: (error) => { + // 同时记录到主日志,避免错误只在 UI 卡片里可见 + coreLog.error(`[plugin:${rt.manifest.id}] fatal ${error.code}: ${error.message}`); + this.setStatus(rt, { state: "error", error }); + // 把所有 pending 失败掉 + for (const p of rt.pending.values()) { + clearTimeout(p.timer); + p.reject(Object.assign(new Error(error.message), { code: error.code })); + } + rt.pending.clear(); + }, + onExit: (isCrash) => { + if (!isCrash) return; + rt.restartAttempts++; + if (rt.restartAttempts > RESTART_MAX_ATTEMPTS) { + this.setStatus(rt, { + state: "error", + error: { + code: PluginErrorCodes.WORKER_CRASHED, + message: "plugin crashed too many times", + }, + }); + return; + } + const delayMs = [2_000, 8_000, 30_000][rt.restartAttempts - 1] ?? 30_000; + setTimeout(() => { + if (rt.enabled) this.start(rt).catch(() => {}); + }, delayMs); + }, + }, + ); + + rt.sandbox = sandbox; + try { + await sandbox.start(); + } catch (err) { + const code = ((err as any)?.code as string) ?? PluginErrorCodes.UNKNOWN; + const message = err instanceof Error ? err.message : String(err); + coreLog.error(`[plugin:${rt.manifest.id}] start failed ${code}: ${message}`); + this.setStatus(rt, { + state: "error", + error: { code, message }, + }); + rt.sandbox = null; + } + } + + private async stop(rt: PluginRuntime): Promise { + if (rt.sandbox) { + await rt.sandbox.dispose(); + rt.sandbox = null; + } + // 失败掉残留 pending + for (const p of rt.pending.values()) { + clearTimeout(p.timer); + p.reject( + Object.assign(new Error("plugin stopped"), { + code: PluginErrorCodes.NOT_READY, + }), + ); + } + rt.pending.clear(); + this.setStatus(rt, { state: "unloaded" }); + } + + private setStatus(rt: PluginRuntime, status: PluginStatus): void { + rt.status = status; + this.emit("status", { + manifest: rt.manifest, + enabled: rt.enabled, + status, + updateInfo: rt.updateInfo, + } satisfies PluginInfo); + } + + /** 应用退出前调用 */ + async shutdown(): Promise { + await Promise.all(Array.from(this.runtimes.values()).map((rt) => this.stop(rt))); + } +} + +export const pluginRegistry = new PluginRegistry(); +export type { PluginRuntime }; diff --git a/electron/main/plugins/router.ts b/electron/main/plugins/router.ts new file mode 100644 index 0000000..960b7eb --- /dev/null +++ b/electron/main/plugins/router.ts @@ -0,0 +1,95 @@ +/** + * 插件动作路由 + * + * 把渲染端 / 主进程内部的 action 请求转发给对应插件的 sandbox,并处理超时、取消。 + * 当前只有 musicUrl 一个动作——新增动作时,在 `shared/types/plugin.ts` 的 `PluginAction` + * / `ActionIO` 里登记,然后在这里补一个公共入口函数即可(callOn / supportsAction 都是泛型)。 + */ + +import type { + MusicUrlReq, + MusicUrlRes, + PluginAction, + PluginResolveUrlArgs, + SourceCapability, +} from "@shared/types/plugin"; +import { ACTION_TIMEOUTS, PluginErrorCodes } from "@shared/defaults/plugin-api"; +import { pluginRegistry, type PluginRuntime } from "./registry"; + +let reqSeq = 0; +const nextRequestId = (): string => `r${Date.now().toString(36)}-${++reqSeq}`; + +/** 在某个插件上调用一个动作,返回结果 */ +const callOn = ( + rt: PluginRuntime, + action: PluginAction, + params: unknown, + timeoutMs: number, +): Promise => { + if (!rt.sandbox?.isAlive()) { + return Promise.reject( + Object.assign(new Error(`plugin ${rt.manifest.id} not ready`), { + code: PluginErrorCodes.NOT_READY, + }), + ); + } + const requestId = nextRequestId(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + rt.pending.delete(requestId); + rt.sandbox?.sendCancel(requestId); + reject( + Object.assign(new Error(`plugin ${rt.manifest.id} request timeout`), { + code: PluginErrorCodes.REQUEST_TIMEOUT, + }), + ); + }, timeoutMs); + rt.pending.set(requestId, { + resolve: (v) => resolve(v as T), + reject, + timer, + }); + rt.sandbox!.sendCall(requestId, action, params); + }); +}; + +/** 判断某插件的指定源是否支持该动作 */ +const supportsAction = ( + rt: PluginRuntime, + source: string | undefined, + action: PluginAction, +): { ok: boolean; source?: string } => { + if (rt.status.state !== "ready") return { ok: false }; + const sources = rt.status.sources; + if (source) { + const cap: SourceCapability | undefined = sources[source]; + if (!cap) return { ok: false }; + return { ok: cap.actions.includes(action), source }; + } + for (const [key, cap] of Object.entries(sources)) { + if (cap.actions.includes(action)) return { ok: true, source: key }; + } + return { ok: false }; +}; + +export const resolveUrl = async (args: PluginResolveUrlArgs): Promise => { + const rt = pluginRegistry.getRuntime(args.pluginId); + if (!rt) { + throw Object.assign(new Error(`plugin ${args.pluginId} not found`), { + code: PluginErrorCodes.NOT_FOUND, + }); + } + const check = supportsAction(rt, args.source, "musicUrl"); + if (!check.ok) { + throw Object.assign( + new Error(`plugin ${args.pluginId} does not support musicUrl on source ${args.source}`), + { code: PluginErrorCodes.ACTION_UNSUPPORTED }, + ); + } + const params: MusicUrlReq = { + source: check.source!, + quality: args.quality ?? "hq", + musicInfo: args.musicInfo, + }; + return callOn(rt, "musicUrl", params, ACTION_TIMEOUTS.musicUrl); +}; diff --git a/electron/main/plugins/sandbox.ts b/electron/main/plugins/sandbox.ts new file mode 100644 index 0000000..8b22f57 --- /dev/null +++ b/electron/main/plugins/sandbox.ts @@ -0,0 +1,270 @@ +/** + * 插件沙箱(主进程侧) + * + * 封装 utilityProcess.fork,负责: + * - 启动/停止/重启子进程 + * - 发送 init 并等待 ready + * - 转发 call / cancel / hostResult + * - 心跳检测卡死 + * - 向上派发事件(ready/result/hostCall/log/fatal/exit) + */ + +import path from "node:path"; +import { utilityProcess, type UtilityProcess, app } from "electron"; +import type { + HostCallMethod, + PluginAction, + PluginErrorPayload, + PluginManifest, + PluginUpdateInfo, + SandboxIn, + SandboxOut, + SourceCapability, +} from "@shared/types/plugin"; +import { + HEARTBEAT_INTERVAL, + HEARTBEAT_MAX_MISSES, + PLUGIN_LOAD_TIMEOUT, + PluginErrorCodes, +} from "@shared/defaults/plugin-api"; + +export interface SandboxEvents { + onReady: (sources: Record) => void; + onResult: (requestId: string, ok: boolean, data?: unknown, error?: PluginErrorPayload) => void; + onHostCall: (callId: string, method: HostCallMethod, args: unknown[]) => void; + onLog: (level: "debug" | "info" | "warn" | "error", args: unknown[]) => void; + onFatal: (error: PluginErrorPayload) => void; + /** 脚本上报"有新版本" */ + onUpdateAvailable: (info: PluginUpdateInfo) => void; + /** 子进程退出(可能是崩溃或主动 kill)。isCrash=true 表示非主动 kill */ + onExit: (isCrash: boolean, code: number | null) => void; +} + +export interface SandboxStartOptions { + manifest: PluginManifest; + /** 插件脚本源码(已解压并去除 gz_ 前缀) */ + source: string; + /** 用户为此插件保存的设置(setting key → value) */ + userSettings: Record; + /** 主进程当前 locale,透传给插件 */ + locale: string; +} + +/** utilityProcess 入口脚本的绝对路径 */ +const resolveWorkerEntry = (): string => { + // 开发:由 electron-vite 输出到 out/main/sandbox.worker.js + // 生产:app.asar 解压后同样在 out/main/ + const appPath = app.getAppPath(); + return path.join(appPath, "out", "main", "sandbox.worker.js"); +}; + +export class Sandbox { + private child: UtilityProcess | null = null; + private ready = false; + private disposed = false; + private heartbeatTimer: NodeJS.Timeout | null = null; + private heartbeatMisses = 0; + private intentionalKill = false; + + constructor( + private readonly opts: SandboxStartOptions, + private readonly events: SandboxEvents, + ) {} + + /** 启动子进程并等待 ready。超时或 fatal 时 reject */ + start(): Promise> { + if (this.child) return Promise.reject(new Error("sandbox already started")); + const entry = resolveWorkerEntry(); + this.child = utilityProcess.fork(entry, [], { + serviceName: `splayer-plugin-${this.opts.manifest.id}`, + stdio: "pipe", + }); + + return new Promise((resolve, reject) => { + let settled = false; + + const done = (err: Error | null, sources?: Record): void => { + if (settled) return; + settled = true; + clearTimeout(loadTimer); + if (err) reject(err); + else { + this.ready = true; + this.startHeartbeat(); + resolve(sources ?? {}); + } + }; + + const loadTimer = setTimeout(() => { + done( + Object.assign(new Error("plugin load timeout"), { + code: PluginErrorCodes.LOAD_TIMEOUT, + }), + ); + this.kill(); + }, PLUGIN_LOAD_TIMEOUT); + + this.child!.on("message", (msg: SandboxOut) => { + this.onMessage(msg, done); + }); + + this.child!.on("exit", (code) => { + const wasReady = this.ready; + this.ready = false; + this.stopHeartbeat(); + this.child = null; + if (!settled) { + done( + Object.assign(new Error(`worker exited before ready, code=${code}`), { + code: PluginErrorCodes.WORKER_CRASHED, + }), + ); + } + if (wasReady && !this.disposed) { + this.events.onExit(!this.intentionalKill, code); + } + this.intentionalKill = false; + }); + + // stdout/stderr 转发到 log 事件 + if (this.child!.stdout) { + this.child!.stdout.on("data", (chunk: Buffer) => { + this.events.onLog("info", [chunk.toString().trimEnd()]); + }); + } + if (this.child!.stderr) { + this.child!.stderr.on("data", (chunk: Buffer) => { + this.events.onLog("error", [chunk.toString().trimEnd()]); + }); + } + + // 发送 init + const initMsg: SandboxIn = { + kind: "init", + pluginId: this.opts.manifest.id, + apiLevel: this.opts.manifest.apiLevel, + locale: this.opts.locale, + appVersion: app.getVersion(), + platform: this.opts.manifest.platform, + userSettings: this.opts.userSettings, + source: this.opts.source, + scriptInfo: { + name: this.opts.manifest.name, + description: this.opts.manifest.description ?? "", + version: this.opts.manifest.version, + author: this.opts.manifest.author ?? "", + homepage: this.opts.manifest.homepage ?? "", + }, + }; + this.child!.postMessage(initMsg); + }); + } + + /** 把 action call 下发到沙箱 */ + sendCall(requestId: string, action: PluginAction, params: unknown): void { + if (!this.child || !this.ready) { + this.events.onResult(requestId, false, undefined, { + code: PluginErrorCodes.NOT_READY, + message: "plugin is not ready", + }); + return; + } + const msg: SandboxIn = { kind: "call", requestId, action, params }; + this.child.postMessage(msg); + } + + sendCancel(requestId: string): void { + if (!this.child) return; + this.child.postMessage({ kind: "cancel", requestId } satisfies SandboxIn); + } + + sendHostResult(callId: string, ok: boolean, data?: unknown, error?: PluginErrorPayload): void { + if (!this.child) return; + this.child.postMessage({ kind: "hostResult", callId, ok, data, error } satisfies SandboxIn); + } + + isAlive(): boolean { + return this.child != null && this.ready && !this.disposed; + } + + /** 主动 kill 子进程,会触发 onExit(isCrash=false) */ + kill(): void { + if (!this.child) return; + this.intentionalKill = true; + try { + this.child.kill(); + } catch { + /* ignore */ + } + this.stopHeartbeat(); + } + + /** 永久释放,之后不再重启 */ + async dispose(): Promise { + this.disposed = true; + this.kill(); + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatMisses = 0; + this.heartbeatTimer = setInterval(() => { + if (!this.child) return; + this.heartbeatMisses++; + if (this.heartbeatMisses > HEARTBEAT_MAX_MISSES) { + this.events.onLog("warn", [`plugin ${this.opts.manifest.id} heartbeat lost, killing`]); + this.kill(); + return; + } + this.child.postMessage({ kind: "ping" } satisfies SandboxIn); + }, HEARTBEAT_INTERVAL); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + private onMessage( + msg: SandboxOut, + done: (err: Error | null, sources?: Record) => void, + ): void { + switch (msg.kind) { + case "ready": + this.events.onReady(msg.sources); + done(null, msg.sources); + return; + case "result": + this.events.onResult(msg.requestId, msg.ok, msg.data, msg.error); + return; + case "hostCall": + this.events.onHostCall(msg.callId, msg.method, msg.args); + return; + case "updateAvailable": + this.events.onUpdateAvailable(msg.info); + return; + case "log": + this.events.onLog(msg.level, msg.args); + return; + case "pong": + this.heartbeatMisses = 0; + return; + case "fatal": + // fatal = 永久失败,不走 crash-restart。kill 是 worker 未自我退出时的兜底 + this.intentionalKill = true; + this.events.onFatal(msg.error); + done(Object.assign(new Error(msg.error.message), { code: msg.error.code })); + if (this.child) { + try { + this.child.kill(); + } catch { + /* ignore */ + } + } + this.stopHeartbeat(); + return; + } + } +} diff --git a/electron/main/plugins/sandbox.worker.ts b/electron/main/plugins/sandbox.worker.ts new file mode 100644 index 0000000..efd9341 --- /dev/null +++ b/electron/main/plugins/sandbox.worker.ts @@ -0,0 +1,380 @@ +/** + * 插件沙箱子进程入口(utilityProcess fork 的目标) + * + * 职责: + * - 接收主进程 `init` 消息,收到后创建 vm.createContext 作为运行沙箱 + * - 在沙箱内注入 globalThis.splayer(HostApi 实现),可选 globalThis.lx 垫片 + * - 用 vm.runInContext 执行插件源码 + * - 转发 call → handler;hostCall → 主进程;ping → pong + * + * 注意:本文件在 utilityProcess 里跑,没有 DOM / Electron,只有 Node。 + * `parentPort` 是 Electron utilityProcess 暴露的双向通道。 + */ + +import vm from "node:vm"; +import crypto from "node:crypto"; +import zlib from "node:zlib"; +import type { + ActionIO, + HostApi, + HostCallMethod, + HostRequestOptions, + HostRequestResult, + PluginAction, + PluginErrorPayload, + SandboxIn, + SandboxOut, + SourceCapability, +} from "@shared/types/plugin"; +import { installLxShim } from "./lx-shim"; + +// utilityProcess 注入的全局 parentPort(Electron 类型没导出,用 any) +// 注意:process.parentPort 仿 Web Worker 接口,'message' 回调参数是 MessageEvent, +// 消息体在 event.data;与主进程侧 UtilityProcess.on('message', msg) 不同 + +const parentPort: { + on: (evt: "message", cb: (event: { data: SandboxIn }) => void) => void; + postMessage: (msg: SandboxOut) => void; +} = (process as any).parentPort; + +if (!parentPort) { + // 未在 utilityProcess 下运行,退出 + process.exit(1); +} + +const send = (msg: SandboxOut): void => parentPort.postMessage(msg); + +/** 等待主进程 init 再启动,避免 TDZ */ +let initialized = false; + +/** action → handler 注册表 */ +const handlers = new Map Promise>(); + +/** 已注册的 sources(等 script 执行完后随 ready 消息上报) */ +let registeredSources: Record = {}; + +/** 当前请求的 AbortController,key = requestId */ +const inflight = new Map(); + +/** hostCall 回调登记,key = callId */ +const hostCallWaiters = new Map< + string, + { resolve: (v: unknown) => void; reject: (err: Error) => void } +>(); + +let callSeq = 0; +const nextCallId = (): string => `c${++callSeq}`; + +/** 用户设置缓存(init 时传入,getSetting 同步读) */ +let userSettingsCache: Record = {}; + +/** 调用主进程,返回主进程 hostResult 的 data */ +const hostCall = (method: HostCallMethod, args: unknown[]): Promise => { + const callId = nextCallId(); + return new Promise((resolve, reject) => { + hostCallWaiters.set(callId, { resolve, reject }); + send({ kind: "hostCall", callId, method, args }); + }); +}; + +/** 构造注入沙箱的 splayer 对象 */ +const buildSplayer = (init: Extract): HostApi => ({ + pluginId: init.pluginId, + apiLevel: init.apiLevel, + locale: init.locale, + appVersion: init.appVersion, + + request: (url: string, opts?: HostRequestOptions): Promise => + hostCall("request", [url, opts ?? {}]) as Promise, + + register: (caps) => { + registeredSources = { ...registeredSources, ...caps.sources }; + }, + + on: ( + action: A, + handler: (req: ActionIO[A]["req"]) => Promise, + ) => { + handlers.set(action, handler as (req: unknown) => Promise); + }, + + log: { + debug: (...args) => send({ kind: "log", level: "debug", args }), + info: (...args) => send({ kind: "log", level: "info", args }), + warn: (...args) => send({ kind: "log", level: "warn", args }), + error: (...args) => send({ kind: "log", level: "error", args }), + }, + + storage: { + get: (key: string): Promise => + hostCall("storage.get", [key]) as Promise, + set: (key, value) => hostCall("storage.set", [key, value]) as Promise, + remove: (key) => hostCall("storage.remove", [key]) as Promise, + keys: () => hostCall("storage.keys", []) as Promise, + }, + + getSetting: (key: string): T | undefined => userSettingsCache[key] as T | undefined, +}); + +/** 把 utils 暴露给沙箱(原生 Node 模块包装) */ +const buildUtils = (): object => ({ + crypto: { + md5: (data: string | Uint8Array) => + crypto + .createHash("md5") + .update(data as crypto.BinaryLike) + .digest("hex"), + sha1: (data: string | Uint8Array) => + crypto + .createHash("sha1") + .update(data as crypto.BinaryLike) + .digest("hex"), + sha256: (data: string | Uint8Array) => + crypto + .createHash("sha256") + .update(data as crypto.BinaryLike) + .digest("hex"), + hmac: (algo: string, key: string | Uint8Array, data: string | Uint8Array) => + crypto + .createHmac(algo, key as crypto.BinaryLike) + .update(data as crypto.BinaryLike) + .digest("hex"), + randomBytes: (size: number) => crypto.randomBytes(size), + aesEncrypt: ( + data: string | Uint8Array, + key: Buffer | Uint8Array, + mode: string, + iv?: Buffer | Uint8Array, + ) => { + const cipher = crypto.createCipheriv(mode, key as crypto.CipherKey, iv ?? null); + const input = typeof data === "string" ? Buffer.from(data, "utf-8") : Buffer.from(data); + return Buffer.concat([cipher.update(input), cipher.final()]); + }, + aesDecrypt: ( + data: Buffer | Uint8Array, + key: Buffer | Uint8Array, + mode: string, + iv?: Buffer | Uint8Array, + ) => { + const decipher = crypto.createDecipheriv(mode, key as crypto.CipherKey, iv ?? null); + return Buffer.concat([decipher.update(Buffer.from(data)), decipher.final()]); + }, + rsaEncrypt: (data: Buffer | Uint8Array, publicKey: string) => + crypto.publicEncrypt(publicKey, Buffer.from(data)), + }, + buffer: { + from: ( + data: ArrayBuffer | SharedArrayBuffer | string | Uint8Array | number[], + enc?: BufferEncoding, + ) => (typeof data === "string" ? Buffer.from(data, enc) : Buffer.from(data as ArrayBuffer)), + bufToString: (buf: Buffer | Uint8Array, enc: BufferEncoding = "utf-8") => + Buffer.from(buf).toString(enc), + concat: (list: Array) => Buffer.concat(list), + }, + base64: { + encode: (data: string | Uint8Array) => Buffer.from(data as Buffer).toString("base64"), + decode: (data: string) => Buffer.from(data, "base64").toString("utf-8"), + }, + zlib: { + inflate: (data: Buffer | Uint8Array) => zlib.inflateSync(data), + deflate: (data: Buffer | Uint8Array) => zlib.deflateSync(data), + gunzip: (data: Buffer | Uint8Array) => zlib.gunzipSync(data), + gzip: (data: Buffer | Uint8Array) => zlib.gzipSync(data), + }, +}); + +/** 执行插件脚本 */ +const runScript = (init: Extract): void => { + const splayer = buildSplayer(init); + // splayer.utils 单独挂,避免被类型定义暴露(保持 HostApi 纯净) + + (splayer as any).utils = buildUtils(); + + const sandboxGlobal: Record = { + splayer, + // 基础 Node 工具 + Buffer, + setTimeout, + setInterval, + clearTimeout, + clearInterval, + setImmediate, + clearImmediate, + queueMicrotask, + Promise, + URL, + URLSearchParams, + TextEncoder, + TextDecoder, + // console 转发到 log + console: { + log: splayer.log.info, + info: splayer.log.info, + debug: splayer.log.debug, + warn: splayer.log.warn, + error: splayer.log.error, + }, + }; + + // 统一注入 lx 垫片——无论脚本声明的 platform 是什么。 + // 原因:很多 lx 脚本以明文 .js 分发且头注释里没写 @platform lx, + // 按 platform 推断会错判为 splayer,导致 globalThis.lx 为 undefined。 + // splayer-native 脚本本身不会碰 lx 全局,多挂一个对象不干扰; + // lx 脚本调 splayer.on 又覆盖 lx 垫片预置的 handler,两条路径都能走通。 + installLxShim( + sandboxGlobal, + splayer, + handlers, + (sources) => { + registeredSources = { ...registeredSources, ...sources }; + }, + (info) => { + send({ kind: "updateAvailable", info }); + }, + { + name: init.scriptInfo.name, + description: init.scriptInfo.description, + version: init.scriptInfo.version, + author: init.scriptInfo.author, + homepage: init.scriptInfo.homepage, + rawScript: init.source, + }, + ); + + // 允许脚本自引用全局 + sandboxGlobal.globalThis = sandboxGlobal; + + const context = vm.createContext(sandboxGlobal, { + name: `plugin:${init.pluginId}`, + codeGeneration: { strings: true, wasm: false }, + }); + + try { + const script = new vm.Script(init.source, { + filename: `plugin-${init.pluginId}.js`, + }); + script.runInContext(context, { timeout: 5_000, breakOnSigint: false }); + } catch (err) { + send({ + kind: "fatal", + error: { + code: "PLUGIN_SCRIPT_ERROR", + message: err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err), + }, + }); + // fatal 即终止:避免子进程在脚本未正常加载时继续空转 + process.exit(1); + } + + // 脚本同步部分执行完,上报 ready + 能力 + // 注意:lx 脚本通常靠 `lx.send('inited', ...)` 异步声明,shim 里会改 registeredSources + // 所以再 microtask 后发 ready + queueMicrotask(() => { + send({ kind: "ready", sources: registeredSources }); + }); +}; + +/** 消息入口:event.data 是主进程发来的 SandboxIn */ +parentPort.on("message", async (event) => { + const msg = event.data; + try { + switch (msg.kind) { + case "init": { + if (initialized) return; + initialized = true; + userSettingsCache = msg.userSettings ?? {}; + runScript(msg); + return; + } + case "ping": { + send({ kind: "pong" }); + return; + } + case "cancel": { + const ctrl = inflight.get(msg.requestId); + if (ctrl) { + ctrl.abort(); + inflight.delete(msg.requestId); + } + return; + } + case "hostResult": { + const w = hostCallWaiters.get(msg.callId); + if (!w) return; + hostCallWaiters.delete(msg.callId); + if (msg.ok) w.resolve(msg.data); + else { + const err = new Error(msg.error?.message ?? "host call failed"); + + (err as any).code = msg.error?.code; + w.reject(err); + } + return; + } + case "call": { + const handler = handlers.get(msg.action); + if (!handler) { + send({ + kind: "result", + requestId: msg.requestId, + ok: false, + error: { + code: "PLUGIN_ACTION_UNSUPPORTED", + message: `action "${msg.action}" not registered`, + }, + }); + return; + } + const ctrl = new AbortController(); + inflight.set(msg.requestId, ctrl); + try { + const data = await handler(msg.params); + inflight.delete(msg.requestId); + if (ctrl.signal.aborted) { + send({ + kind: "result", + requestId: msg.requestId, + ok: false, + error: { code: "PLUGIN_CANCELLED", message: "cancelled" }, + }); + } else { + send({ kind: "result", requestId: msg.requestId, ok: true, data }); + } + } catch (err) { + inflight.delete(msg.requestId); + const payload: PluginErrorPayload = { + code: ((err as any)?.code as string) ?? "PLUGIN_HANDLER_ERROR", + message: err instanceof Error ? err.message : String(err), + }; + send({ kind: "result", requestId: msg.requestId, ok: false, error: payload }); + } + return; + } + } + } catch (err) { + send({ + kind: "fatal", + error: { + code: "PLUGIN_UNKNOWN", + message: err instanceof Error ? err.message : String(err), + }, + }); + process.exit(1); + } +}); + +process.on("unhandledRejection", (reason) => { + send({ + kind: "log", + level: "error", + args: ["unhandledRejection:", reason instanceof Error ? reason.message : reason], + }); +}); + +process.on("uncaughtException", (err) => { + send({ + kind: "fatal", + error: { code: "PLUGIN_SCRIPT_ERROR", message: err.message }, + }); + process.exit(1); +}); diff --git a/electron/main/plugins/storage.ts b/electron/main/plugins/storage.ts new file mode 100644 index 0000000..4c3b2e9 --- /dev/null +++ b/electron/main/plugins/storage.ts @@ -0,0 +1,76 @@ +/** + * 每插件隔离的 KV 存储 + * + * 落盘:`{userData}/plugins/data/{pluginId}.json` + * 原子写(atomically)防撕裂;内存缓存避免频繁读。 + */ + +import fs from "node:fs"; +import path from "node:path"; +import { app } from "electron"; +import { writeFileSync as atomicWriteSync } from "atomically"; + +/** 所有插件数据的根目录 */ +export const getPluginsDataDir = (): string => + path.join(app.getPath("userData"), "plugins", "data"); + +const caches = new Map>(); + +const ensureDir = (): void => { + const dir = getPluginsDataDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +}; + +const fileOf = (pluginId: string): string => path.join(getPluginsDataDir(), `${pluginId}.json`); + +const load = (pluginId: string): Record => { + const cached = caches.get(pluginId); + if (cached) return cached; + try { + const raw = fs.readFileSync(fileOf(pluginId), "utf-8"); + const data = JSON.parse(raw) as Record; + caches.set(pluginId, data); + return data; + } catch { + const empty: Record = {}; + caches.set(pluginId, empty); + return empty; + } +}; + +const flush = (pluginId: string, data: Record): void => { + ensureDir(); + atomicWriteSync(fileOf(pluginId), JSON.stringify(data, null, 2)); + caches.set(pluginId, data); +}; + +export const pluginStorageGet = (pluginId: string, key: string): unknown => { + return load(pluginId)[key] ?? null; +}; + +export const pluginStorageSet = (pluginId: string, key: string, value: unknown): void => { + const data = { ...load(pluginId), [key]: value }; + flush(pluginId, data); +}; + +export const pluginStorageRemove = (pluginId: string, key: string): void => { + const cur = load(pluginId); + if (!(key in cur)) return; + const next = { ...cur }; + delete next[key]; + flush(pluginId, next); +}; + +export const pluginStorageKeys = (pluginId: string): string[] => { + return Object.keys(load(pluginId)); +}; + +/** 彻底删除某插件的数据文件与缓存 */ +export const pluginStorageDrop = (pluginId: string): void => { + caches.delete(pluginId); + try { + fs.unlinkSync(fileOf(pluginId)); + } catch { + /* ignore */ + } +}; diff --git a/electron/main/services/nowPlaying.ts b/electron/main/services/nowPlaying.ts index 53d4508..194ad28 100644 --- a/electron/main/services/nowPlaying.ts +++ b/electron/main/services/nowPlaying.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; import type { Track } from "@shared/types/player"; -import type { LyricLine, LyricSource } from "@shared/types/lyrics"; +import type { LyricLine, LyricData } from "@shared/types/lyrics"; import type { NowPlayingSnapshot, NowPlayingPositionSync } from "@shared/types/nowPlaying"; type NowPlayingEvents = { @@ -17,7 +17,7 @@ let currentTrack: Track | null = null; /** 当前歌曲的完整解析歌词 */ let currentLyric: LyricLine[] = []; /** 当前激活的歌词源 */ -let currentSource: LyricSource = null; +let currentSource: LyricData = null; /** 最近一次播放位置(毫秒) */ let lastPosition = 0; /** 当前是否处于播放态 */ @@ -27,7 +27,7 @@ let playing = false; const emitter = new EventEmitter(); /** 渲染进程同步当前播放状态 */ -export const update = (track: Track | null, lyric: LyricLine[], source: LyricSource): void => { +export const update = (track: Track | null, lyric: LyricLine[], source: LyricData): void => { const trackChanged = (currentTrack?.id ?? null) !== (track?.id ?? null); currentTrack = track; currentLyric = lyric; diff --git a/electron/main/services/tray.ts b/electron/main/services/tray.ts index f6cf905..5bfb679 100644 --- a/electron/main/services/tray.ts +++ b/electron/main/services/tray.ts @@ -119,13 +119,14 @@ const buildMenu = (): Menu => { icon: menuIcon("lyric"), click: () => toggleDynamicIslandWindow(), }, + // 任务栏歌词 ...(isWin ? [ { label: taskbarLyricOpen ? t("closeTaskbarLyric") : t("openTaskbarLyric"), icon: menuIcon("lyric"), click: () => toggleTaskbarLyricWindow(), - } as MenuItemConstructorOptions, + }, ] : []), { type: "separator" }, @@ -147,7 +148,6 @@ export const refreshTray = (): void => { /** 初始化系统托盘 */ export const initTray = (): void => { - const isWin = process.platform === "win32"; const isMac = process.platform === "darwin"; let icon: string | Electron.NativeImage; if (isWin) { @@ -214,6 +214,7 @@ export const setTrayDynamicIsland = (open: boolean): void => { /** 同步任务栏歌词窗口开关状态到托盘 */ export const setTrayTaskbarLyric = (open: boolean): void => { + if (!isWin) return; if (taskbarLyricOpen === open) return; taskbarLyricOpen = open; refreshTray(); diff --git a/electron/main/window/index.ts b/electron/main/window/index.ts index 0340c8d..f8dc3b6 100644 --- a/electron/main/window/index.ts +++ b/electron/main/window/index.ts @@ -1,8 +1,8 @@ import { store } from "@main/store"; +import { isWin } from "@main/utils/config"; import { createDesktopLyricWindow } from "./desktopLyric"; import { createDynamicIslandWindow } from "./dynamicIsland"; import { createTaskbarLyricWindow } from "./taskbarLyric"; -import { isWin } from "@main/utils/config"; export { createWindow } from "./create"; export { @@ -50,7 +50,6 @@ export { /** 恢复歌词相关窗口 */ export const restoreLyricWindows = (): void => { - if (!(store.get("system.rememberWindowState") ?? true)) return; if (store.get("windowStates.desktopLyric.visible")) createDesktopLyricWindow(); if (store.get("windowStates.dynamicIsland.visible")) createDynamicIslandWindow(); if (isWin && store.get("windowStates.taskbarLyric.visible")) { diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 12cd6bb..101fced 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -3,6 +3,8 @@ import { PlayerApi } from "@shared/types/player"; import { ConfigApi, LocaleCode } from "@shared/types/settings"; import { LibraryApi } from "@shared/types/library"; import { NowPlayingApi } from "@shared/types/nowPlaying"; +import { PluginsApi } from "@shared/types/plugin"; +import { ApisApi } from "@shared/types/apis"; import { WindowApi, DesktopLyricApi, @@ -32,6 +34,8 @@ declare global { dynamicIsland: DynamicIslandApi; taskbarLyric: TaskbarLyricApi; nowPlaying: NowPlayingApi; + plugins: PluginsApi; + apis: ApisApi; }; } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index ac40382..8879e4c 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from "electron"; import { electronAPI } from "@electron-toolkit/preload"; import type { TaskbarLyricSettings } from "@shared/types/settings"; +import type { PluginInfo, PluginResolveUrlArgs } from "@shared/types/plugin"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -220,6 +221,33 @@ const api = { onConfigChange: (callback: (config: TaskbarLyricSettings) => void) => subscribe("taskbarLyric:configChange", callback), }, + plugins: { + // 列出所有已安装插件 + list: () => ipcRenderer.invoke("plugin:list"), + // 从指定路径导入插件 + install: (filePath: string) => ipcRenderer.invoke("plugin:install", filePath), + // 弹出原生文件选择框导入插件 + pickAndInstall: () => ipcRenderer.invoke("plugin:pickAndInstall"), + // 从远端 URL 下载并导入 + installFromUrl: (url: string) => ipcRenderer.invoke("plugin:installFromUrl", url), + // 卸载 + uninstall: (id: string) => ipcRenderer.invoke("plugin:uninstall", id), + // 启用/禁用 + setEnabled: (id: string, enabled: boolean) => + ipcRenderer.invoke("plugin:setEnabled", id, enabled), + // 解析播放 URL + resolveUrl: (args: PluginResolveUrlArgs) => ipcRenderer.invoke("plugin:resolveUrl", args), + // 订阅插件状态变化 + onStatus: (callback: (info: PluginInfo) => void) => + subscribe("plugin:status", callback), + }, + apis: { + // 调用任意平台的任意接口 + call: (platform: string, name: string, params?: Record) => + ipcRenderer.invoke("apis:call", platform, name, params ?? {}), + // 清空指定平台的登录态 + clearSession: (platform: string) => ipcRenderer.invoke("apis:clearSession", platform), + }, nowPlaying: { // 渲染进程同步当前播放状态到主进程 update: (payload: unknown) => ipcRenderer.send("nowPlaying:update", payload), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ef86ce..f8694ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -956,42 +956,49 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/lzma-linux-arm64-musl@1.4.5': resolution: {integrity: sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': resolution: {integrity: sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': resolution: {integrity: sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/lzma-linux-s390x-gnu@1.4.5': resolution: {integrity: sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/lzma-linux-x64-gnu@1.4.5': resolution: {integrity: sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/lzma-linux-x64-musl@1.4.5': resolution: {integrity: sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/lzma-wasm32-wasi@1.4.5': resolution: {integrity: sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==} @@ -1061,36 +1068,42 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/tar-linux-arm64-musl@1.1.0': resolution: {integrity: sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/tar-linux-ppc64-gnu@1.1.0': resolution: {integrity: sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/tar-linux-s390x-gnu@1.1.0': resolution: {integrity: sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/tar-linux-x64-gnu@1.1.0': resolution: {integrity: sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/tar-linux-x64-musl@1.1.0': resolution: {integrity: sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/tar-wasm32-wasi@1.1.0': resolution: {integrity: sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==} @@ -1160,24 +1173,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': resolution: {integrity: sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': resolution: {integrity: sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': resolution: {integrity: sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/wasm-tools-wasm32-wasi@1.0.1': resolution: {integrity: sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==} @@ -1313,48 +1330,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.124.0': resolution: {integrity: sha512-uvG7v4Tz9S8/PVqY0SP0DLHxo4hZGe+Pv2tGVnwcsjKCCUPjplbrFVvDzXq+kOaEoUkiCY0Kt1hlZ6FDJ1LKNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.124.0': resolution: {integrity: sha512-t7KZaaUhfp2au0MRpoENEFqwLKYDdptEry6V7pTAVdPEcFG4P6ii8yeGU9m6p5vb+b8WEKmdpGMNXBEYy7iJdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.124.0': resolution: {integrity: sha512-eurGGaxHZiIQ+fBSageS8TAkRqZgdOiBeqNrWAqAPup9hXBTmQ0WcBjwsLElf+3jvDL9NhnX0dOgOqPfsjSjdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.124.0': resolution: {integrity: sha512-d1V7/ll1i/LhqE/gZy6Wbz6evlk0egh2XKkwMI3epiojtbtUwQSLIER0Y3yDBBocPuWOjJdvmjtEmPTTLXje/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.124.0': resolution: {integrity: sha512-w1+cBvriUteOpox6ATqCFVkpGL47PFdcfCPGmgUZbd78Fw44U0gQkc+kVGvAOTvGrptMYgwomD1c6OTVvkrpGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.124.0': resolution: {integrity: sha512-RRB1evQiXRtMCsQQiAh9U0H3HzguLpE0ytfStuhRgmOj7tqUCOVxkHsvM9geZjAax6NqVRj7VXx32qjjkZPsBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.124.0': resolution: {integrity: sha512-asVYN0qmSHlCU8H9Q47SmeJ/Z5EG4IWCC+QGxkfFboI5qh15aLlJnHmnrV61MwQRPXGnVC/sC3qKhrUyqGxUqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.124.0': resolution: {integrity: sha512-nhwuxm6B8pn9lzAzMUfa571L5hCXYwQo8C8cx5aGOuHWCzruR8gPJnRRXGBci+uGaIIQEZDyU/U6HDgrSp/JlQ==} @@ -1435,66 +1460,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -1818,6 +1856,7 @@ packages: '@xmldom/xmldom@0.8.12': resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} diff --git a/shared/defaults/plugin-api.ts b/shared/defaults/plugin-api.ts new file mode 100644 index 0000000..2afff5a --- /dev/null +++ b/shared/defaults/plugin-api.ts @@ -0,0 +1,79 @@ +import type { PluginsConfig } from "../types/plugin"; + +/** 当前 Host API 级别;插件 `@apiLevel` 必须 ≤ 此值才加载 */ +export const HOST_API_LEVEL = 1; + +/** 各动作的默认超时(毫秒)。新增动作时在此追加。 */ +export const ACTION_TIMEOUTS = { + musicUrl: 20_000, +} as const; + +/** 网络请求最大超时 */ +export const REQUEST_MAX_TIMEOUT = 60_000; + +/** 网络请求默认超时 */ +export const REQUEST_DEFAULT_TIMEOUT = 15_000; + +/** 插件加载超时(从 fork 到收到 ready) */ +export const PLUGIN_LOAD_TIMEOUT = 10_000; + +/** 在线导入脚本大小上限(字节) */ +export const INSTALL_URL_MAX_SIZE = 9_000_000; + +/** 在线导入请求超时(毫秒) */ +export const INSTALL_URL_TIMEOUT = 15_000; + +/** 心跳间隔 */ +export const HEARTBEAT_INTERVAL = 10_000; + +/** 连续多少次未收到 pong 视为卡死 */ +export const HEARTBEAT_MAX_MISSES = 3; + +/** 自动重启次数 */ +export const RESTART_MAX_ATTEMPTS = 3; + +/** 每插件并发上限 */ +export const PER_PLUGIN_CONCURRENCY = 4; + +/** 错误码 */ +export const PluginErrorCodes = { + /** 未知错误 */ + UNKNOWN: "PLUGIN_UNKNOWN", + /** 插件未找到 */ + NOT_FOUND: "PLUGIN_NOT_FOUND", + /** 插件已禁用 */ + DISABLED: "PLUGIN_DISABLED", + /** 插件未就绪 */ + NOT_READY: "PLUGIN_NOT_READY", + /** 插件未注册该动作 */ + ACTION_UNSUPPORTED: "PLUGIN_ACTION_UNSUPPORTED", + /** 加载超时 */ + LOAD_TIMEOUT: "PLUGIN_LOAD_TIMEOUT", + /** 脚本语法错误或执行错误 */ + SCRIPT_ERROR: "PLUGIN_SCRIPT_ERROR", + /** 元数据校验失败 */ + INVALID_MANIFEST: "PLUGIN_INVALID_MANIFEST", + /** API level 不兼容 */ + API_LEVEL_MISMATCH: "PLUGIN_API_LEVEL_MISMATCH", + /** 请求超时 */ + REQUEST_TIMEOUT: "PLUGIN_REQUEST_TIMEOUT", + /** 请求被取消 */ + CANCELLED: "PLUGIN_CANCELLED", + /** 网络错误 */ + NETWORK_ERROR: "PLUGIN_NETWORK_ERROR", + /** URL 协议不允许 */ + URL_NOT_ALLOWED: "PLUGIN_URL_NOT_ALLOWED", + /** 处理器抛出异常 */ + HANDLER_ERROR: "PLUGIN_HANDLER_ERROR", + /** 子进程崩溃 */ + WORKER_CRASHED: "PLUGIN_WORKER_CRASHED", +} as const; + +/** 默认插件配置 */ +export const defaultPluginsConfig: PluginsConfig = { + enabled: {}, + priority: { + musicUrl: [], + }, + perPlugin: {}, +}; diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index 67b3eaf..7bac5bc 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -1,4 +1,5 @@ import type { SystemConfig } from "../types/settings"; +import { defaultPluginsConfig } from "./plugin-api"; /** * 灵动岛基准高度(缩放比例 = 1 时的物理像素,等于"主行高度") @@ -102,4 +103,5 @@ export const defaultSystemConfig: SystemConfig = { visible: false, }, }, + plugins: defaultPluginsConfig, }; diff --git a/shared/types/apis.ts b/shared/types/apis.ts new file mode 100644 index 0000000..241e957 --- /dev/null +++ b/shared/types/apis.ts @@ -0,0 +1,28 @@ +/** + * 主进程音源 API 统一类型 + */ + +/** 支持的音源平台 */ +export type ApiPlatform = "netease" | "qqmusic" | "kugou"; + +/** 通用响应包装 */ +export type ApiCallResponse = + | { ok: true; status?: number; body?: unknown; data?: unknown } + | { ok: false; error: string }; + +/** 渲染端统一入口 */ +export interface ApisApi { + /** + * 调用任意音源接口 + * @param platform 音源平台 + * @param name 接口名 + * @param params 接口参数 + */ + call: ( + platform: ApiPlatform, + name: string, + params?: Record, + ) => Promise; + /** 清空指定平台的登录态(目前仅 netease 有意义) */ + clearSession: (platform: ApiPlatform) => Promise; +} diff --git a/shared/types/lyrics.ts b/shared/types/lyrics.ts index d76fad9..be97be9 100644 --- a/shared/types/lyrics.ts +++ b/shared/types/lyrics.ts @@ -1,17 +1,18 @@ +import type { Platform } from "./platform"; + /** 歌词格式 */ export type LyricFormat = "ttml" | "lys" | "yrc" | "qrc" | "lrc" | "srt" | "ass"; -/** 外部歌词文件 */ -export interface ExternalLyric { - format: LyricFormat; - path: string; -} +/** 歌词来源 */ +export type LyricSource = "external" | "embedded" | "online"; -/** 歌词来源标识 */ -export type LyricSource = - | { type: "external"; format: LyricFormat } - | { type: "embedded"; format: LyricFormat } - | null; +/** 歌词数据 */ +export type LyricData = { + source: LyricSource; + format: LyricFormat; + /** 在线歌词所属平台,仅 source=online 时有值 */ + platform?: Platform; +} | null; /** 歌词时间片段 */ export interface LyricSpan { @@ -50,6 +51,6 @@ export interface LyricLine { endTime: number; /** 是否为背景歌词行 */ isBG: boolean; - /** 是否为对唱歌词行(右对齐) */ + /** 是否为对唱歌词行 */ isDuet: boolean; } diff --git a/shared/types/nowPlaying.ts b/shared/types/nowPlaying.ts index cd76628..49f9506 100644 --- a/shared/types/nowPlaying.ts +++ b/shared/types/nowPlaying.ts @@ -1,18 +1,18 @@ import type { Track } from "./player"; -import type { LyricLine, LyricSource } from "./lyrics"; +import type { LyricLine, LyricData } from "./lyrics"; /** 渲染进程 → 主进程:同步当前播放状态(track + 歌词 + 源) */ export interface NowPlayingUpdatePayload { track: Track | null; lyric: LyricLine[]; - source: LyricSource; + source: LyricData; } /** 主进程 → 窗口:当前播放的完整快照 */ export interface NowPlayingSnapshot { track: Track | null; lyric: LyricLine[]; - source: LyricSource; + source: LyricData; position: number; playing: boolean; /** 发送时刻的主进程时钟(Date.now 毫秒),接收端用于补偿 IPC 延迟 */ diff --git a/shared/types/platform.ts b/shared/types/platform.ts new file mode 100644 index 0000000..12b32cf --- /dev/null +++ b/shared/types/platform.ts @@ -0,0 +1,2 @@ +/** 平台类型 */ +export type Platform = "netease" | "qqmusic" | "kugou"; diff --git a/shared/types/player.ts b/shared/types/player.ts index 509077d..89134f1 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -1,4 +1,5 @@ -import type { ExternalLyric } from "./lyrics"; +import type { LyricFormat } from "./lyrics"; +import type { Platform } from "./platform"; /** 播放器状态 */ export type PlayerState = "idle" | "loading" | "playing" | "paused" | "stopped"; @@ -34,27 +35,27 @@ export interface AudioQuality { codec: string; } -/** 在线匹配信息 */ -export interface OnlineMatch { - id: string; - artists?: Artist[]; - album?: Album; - cover?: string; - coverOriginal?: string; -} - /** 歌曲信息 */ export interface Track { id: string; source: TrackSource; + /** 在线平台 */ + platform?: Platform; + /** 本地路径 */ path?: string; + /** 标题 */ title: string; /** 注释/副标题 */ comment?: string; + /** 歌手 */ artists: Artist[]; + /** 专辑 */ album?: Album; + /** 时长(毫秒) */ duration: number; + /** 封面 */ cover?: string; + /** 原始封面 */ coverOriginal?: string; /** 文件大小(字节) */ fileSize?: number; @@ -64,14 +65,14 @@ export interface Track { ctime?: number; /** 音质信息 */ quality?: AudioQuality; - matched?: OnlineMatch; } /** 歌曲详细信息 */ export interface TrackDetail { quality: AudioQuality; embeddedLyric?: string; - externalLyrics: ExternalLyric[]; + /** 外部歌词文件列表(同目录下扫描到的所有歌词文件) */ + externalLyrics: { format: LyricFormat; path: string }[]; } /** 播放器加载后返回的完整数据 */ diff --git a/shared/types/plugin.ts b/shared/types/plugin.ts new file mode 100644 index 0000000..71cd2b8 --- /dev/null +++ b/shared/types/plugin.ts @@ -0,0 +1,287 @@ +/** + * 插件系统共享类型 + * 用于主进程、预加载、渲染进程、沙箱子进程之间的契约 + */ + +/** + * 支持的插件动作 + * 当前仅有 musicUrl。扩展新动作的步骤: + * 1. 在此 union 追加字面量;2. 补 `ActionIO` 映射;3. 补 `ACTION_TIMEOUTS` / `PluginsConfig.priority`; + * 4. router 加入相应入口;其余(HostApi.on / handlers Map / SandboxIn.call)已泛型化,无需改动。 + */ +export type PluginAction = "musicUrl"; + +/** + * 音质等级 + * 对齐 src/utils/quality.ts 的 QualityLevel(去掉 null),保持宿主与插件一致 + * - hi-res:高解析度无损(采样率 ≥ 96kHz + 位深 ≥ 24bit) + * - lossless:无损(flac/ape/wav 等) + * - hq:有损 ≥ 320kbps + * - sq:有损 ≥ 192kbps + * - lq:有损 < 192kbps + */ +export type PluginQuality = "hi-res" | "lossless" | "hq" | "sq" | "lq"; + +/** 插件脚本来源平台(用于识别 lx 脚本并启用垫片) */ +export type PluginPlatform = "splayer" | "lx"; + +/** 插件头部 JSDoc 元数据 */ +export interface PluginManifest { + /** 插件唯一 ID = name + sha1(source).slice(0,8) */ + id: string; + /** 展示名 */ + name: string; + /** 版本号 */ + version: string; + /** 简介 */ + description?: string; + /** 作者 */ + author?: string; + /** 主页 */ + homepage?: string; + /** 脚本平台 */ + platform: PluginPlatform; + /** 声明兼容的 Host API 级别 */ + apiLevel: number; + /** 源码 SHA1 */ + hash: string; + /** 安装时间戳(ms) */ + installedAt: number; + /** 脚本相对 `{userData}/plugins/scripts/` 的文件名 */ + fileName: string; +} + +/** 单个源(如 kw/kg)的能力声明 */ +export interface SourceCapability { + /** 展示名 */ + name: string; + /** 支持的动作 */ + actions: PluginAction[]; + /** 支持的音质 */ + qualities?: PluginQuality[]; +} + +/** 插件运行状态 */ +export type PluginStatus = + | { state: "unloaded" } + | { state: "loading" } + | { state: "ready"; sources: Record } + | { state: "error"; error: { code: string; message: string } } + | { state: "disabled" }; + +/** 插件脚本自己上报的更新信息 */ +export interface PluginUpdateInfo { + /** 新版本号(若脚本提供) */ + version?: string; + /** 人类可读的更新说明 */ + log?: string; + /** 新版本下载/介绍页链接 */ + updateUrl?: string; + /** 收到更新提示的时间戳(ms) */ + updatedAt: number; +} + +/** 渲染端看到的插件条目(manifest + 状态) */ +export interface PluginInfo { + manifest: PluginManifest; + enabled: boolean; + status: PluginStatus; + /** 脚本上报过"有新版本"时填充,用户更新/卸载后清空 */ + updateInfo?: PluginUpdateInfo | null; +} + +/* ========== 调用请求 / 响应 ========== */ + +export interface MusicUrlReq { + source: string; + quality: PluginQuality; + musicInfo: { + songmid: string; + name?: string; + singer?: string; + [key: string]: unknown; + }; +} +export interface MusicUrlRes { + url: string; + quality?: PluginQuality; + expire?: number; +} + +/** Action → 请求/响应映射,用于 HostApi.on 的重载。新增动作时在此追加。 */ +export interface ActionIO { + musicUrl: { req: MusicUrlReq; res: MusicUrlRes }; +} + +/* ========== 宿主暴露给插件的 API(在沙箱内注入为 globalThis.splayer) ========== */ + +export interface HostRequestOptions { + method?: "GET" | "POST"; + headers?: Record; + body?: string | ArrayBuffer | Uint8Array; + /** 毫秒,默认 15000,最大 60000 */ + timeout?: number; + /** 默认 text;arraybuffer 返回 Uint8Array */ + responseType?: "text" | "json" | "arraybuffer"; +} + +export interface HostRequestResult { + status: number; + headers: Record; + body: unknown; +} + +export interface HostLogger { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +export interface HostStorage { + get: (key: string) => Promise; + set: (key: string, value: unknown) => Promise; + remove: (key: string) => Promise; + keys: () => Promise; +} + +/** 注入沙箱的全局对象形状 */ +export interface HostApi { + readonly pluginId: string; + readonly apiLevel: number; + readonly locale: string; + readonly appVersion: string; + + /** 发起网络请求 */ + request: (url: string, opts?: HostRequestOptions) => Promise; + + /** 声明支持的源与动作 */ + register: (caps: { sources: Record }) => void; + + /** 注册动作处理器 */ + on: ( + action: A, + handler: (req: ActionIO[A]["req"]) => Promise, + ) => void; + + /** 日志 */ + log: HostLogger; + + /** 每插件隔离 KV */ + storage: HostStorage; + + /** 用户在设置里为此插件配置的值 */ + getSetting: (key: string) => T | undefined; +} + +/* ========== 沙箱 ↔ 主进程消息协议 ========== */ + +export interface PluginErrorPayload { + code: string; + message: string; +} + +/** 主 → worker */ +export type SandboxIn = + | { + kind: "init"; + pluginId: string; + apiLevel: number; + locale: string; + appVersion: string; + platform: PluginPlatform; + userSettings: Record; + source: string; + scriptInfo: { + name: string; + description: string; + version: string; + author: string; + homepage: string; + }; + } + | { kind: "call"; requestId: string; action: PluginAction; params: unknown } + | { kind: "cancel"; requestId: string } + | { + kind: "hostResult"; + callId: string; + ok: boolean; + data?: unknown; + error?: PluginErrorPayload; + } + | { kind: "ping" }; + +/** worker → 主 */ +export type SandboxOut = + | { kind: "ready"; sources: Record } + | { + kind: "result"; + requestId: string; + ok: boolean; + data?: unknown; + error?: PluginErrorPayload; + } + | { kind: "hostCall"; callId: string; method: HostCallMethod; args: unknown[] } + | { kind: "updateAvailable"; info: PluginUpdateInfo } + | { + kind: "log"; + level: "debug" | "info" | "warn" | "error"; + args: unknown[]; + } + | { kind: "fatal"; error: PluginErrorPayload } + | { kind: "pong" }; + +/** worker 调用回宿主的方法名 */ +export type HostCallMethod = + | "request" + | "storage.get" + | "storage.set" + | "storage.remove" + | "storage.keys"; + +/* ========== 渲染端 ↔ 主进程的 IPC 请求参数 ========== */ + +export interface PluginResolveUrlArgs { + pluginId: string; + source: string; + quality?: PluginQuality; + musicInfo: { songmid: string; [key: string]: unknown }; +} + +/** 渲染端插件 API */ +export interface PluginsApi { + /** 列出所有已安装插件 */ + list: () => Promise; + /** 从指定路径导入插件(进阶:一般由 pickAndInstall 触发) */ + install: (filePath: string) => Promise<{ ok: boolean; id?: string; error?: string }>; + /** 弹出原生文件选择框导入插件 */ + pickAndInstall: () => Promise<{ + ok: boolean; + id?: string; + error?: string; + cancelled?: boolean; + }>; + /** 从远端 URL 下载并导入 */ + installFromUrl: (url: string) => Promise<{ ok: boolean; id?: string; error?: string }>; + /** 卸载(同时删除 scripts/{id}.js) */ + uninstall: (id: string) => Promise<{ ok: boolean; error?: string }>; + /** 启用/禁用 */ + setEnabled: (id: string, enabled: boolean) => Promise; + /** 获取播放 URL */ + resolveUrl: (args: PluginResolveUrlArgs) => Promise; + /** 订阅插件状态变化 */ + onStatus: (cb: (info: PluginInfo) => void) => () => void; +} + +/* ========== 配置 ========== */ + +export interface PluginsConfig { + /** 插件启用开关,key = pluginId */ + enabled: Record; + /** 各动作的插件优先级列表 */ + priority: { + musicUrl: string[]; + }; + /** 每插件的用户设置 */ + perPlugin: Record>; +} diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 4cc499c..68cc36c 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -1,3 +1,5 @@ +import type { PluginsConfig } from "./plugin"; + /** 支持的语言代码 */ export type LocaleCode = "zh-CN" | "en-US"; @@ -206,6 +208,8 @@ export interface SystemConfig { }; /** 窗口几何状态(运行时自动记录,非用户主动配置) */ windowStates: WindowStates; + /** 插件系统配置 */ + plugins: PluginsConfig; } /** 配置 API */ diff --git a/src/apis/kugou.ts b/src/apis/kugou.ts new file mode 100644 index 0000000..b6a559c --- /dev/null +++ b/src/apis/kugou.ts @@ -0,0 +1,34 @@ +/** + * KG API 渲染端 + * + * 用 Proxy 代理所有接口到主进程:`kugou.search({keywords})` 等于 + * `window.api.apis.call("kugou", "search", {keywords})`。 + * + * 调用约定:成功 → 返回 data;失败 → 抛 Error。 + */ + +import type { ApiCallResponse } from "@shared/types/apis"; + +/** + * 调用 KG API,返回业务数据 + * @param name 接口名(search / lyric) + * @param params 接口参数 + */ +export const kugouCall = async ( + name: string, + params?: Record, +): Promise => { + const res: ApiCallResponse = await window.api.apis.call("kugou", name, params); + if (!res.ok) throw new Error(res.error); + return res.data as T; +}; + +type KugouProxy = Record(params?: Record) => Promise>; + +/** 任意方法调用:`kugou.search(...)` / `kugou.lyric(...)` */ +export const kugou: KugouProxy = new Proxy({} as KugouProxy, { + get: + (_t, name: string) => + (params?: Record) => + kugouCall(name, params), +}); diff --git a/src/apis/netease.ts b/src/apis/netease.ts new file mode 100644 index 0000000..f8cbd1e --- /dev/null +++ b/src/apis/netease.ts @@ -0,0 +1,54 @@ +/** + * Netease API 渲染端 + * + * 用 Proxy 把所有接口代理到主进程:`netease.search({keywords})` 实际等于 + * `window.api.apis.call("netease", "search", {keywords})`。 + * + * 调用约定:成功 → 返回 body;失败 → 抛 Error。 + * 想取原始响应(含 HTTP status)用 `neteaseRaw`。 + */ + +import type { ApiCallResponse } from "@shared/types/apis"; + +/** + * 调用 Netease API,返回原始响应 + * @param name 接口名 + * @param params 接口参数 + * @returns 原始响应(含 status + body) + */ +export const neteaseRaw = async ( + name: string, + params?: Record, +): Promise<{ status: number; body: unknown }> => { + const res: ApiCallResponse = await window.api.apis.call("netease", name, params); + if (!res.ok) throw new Error(res.error); + return { status: res.status ?? 200, body: res.body }; +}; + +/** + * 调用 Netease API,只返回 body + * @param name 接口名 + * @param params 接口参数 + */ +export const neteaseCall = async ( + name: string, + params?: Record, +): Promise => { + const res = await neteaseRaw(name, params); + return res.body as T; +}; + +type NeteaseProxy = Record(params?: Record) => Promise>; + +/** + * 任意方法调用:`netease.search(...)` / `netease.song_url_v1(...)` + */ +export const netease: NeteaseProxy = new Proxy({} as NeteaseProxy, { + get: + (_t, name: string) => + (params?: Record) => + neteaseCall(name, params), +}); + +/** 清空登录态 cookie */ +export const clearNeteaseSession = (): Promise => window.api.apis.clearSession("netease"); diff --git a/src/apis/qqmusic.ts b/src/apis/qqmusic.ts new file mode 100644 index 0000000..bed53c6 --- /dev/null +++ b/src/apis/qqmusic.ts @@ -0,0 +1,34 @@ +/** + * QM API 渲染端 + * + * 用 Proxy 代理所有接口到主进程:`qqmusic.search({keywords})` 等于 + * `window.api.apis.call("qqmusic", "search", {keywords})`。 + * + * 调用约定:成功 → 返回 data;失败 → 抛 Error。 + */ + +import type { ApiCallResponse } from "@shared/types/apis"; + +/** + * 调用 QM API,返回业务数据 + * @param name 接口名(search / song_info / lyric / match / hot_search / leaderboard / song_list) + * @param params 接口参数 + */ +export const qqmusicCall = async ( + name: string, + params?: Record, +): Promise => { + const res: ApiCallResponse = await window.api.apis.call("qqmusic", name, params); + if (!res.ok) throw new Error(res.error); + return res.data as T; +}; + +type QQMusicProxy = Record(params?: Record) => Promise>; + +/** 任意方法调用:`qqmusic.search(...)` / `qqmusic.lyric(...)` */ +export const qqmusic: QQMusicProxy = new Proxy({} as QQMusicProxy, { + get: + (_t, name: string) => + (params?: Record) => + qqmusicCall(name, params), +}); diff --git a/src/components/settings/SettingsItem.vue b/src/components/settings/SettingsItem.vue index 8db2223..dac69ed 100644 --- a/src/components/settings/SettingsItem.vue +++ b/src/components/settings/SettingsItem.vue @@ -32,7 +32,15 @@ const descriptionText = computed(() =>