# Koneko QZ Music v2 插件开发避坑指南 > 版本: 0.0.3 | 作者: 云汀(Miao-moe) | 目标: 支持到别的 AI 继续开发 --- ## 一、项目背景 QZ Music v2 是一款 Android 音乐播放器,支持通过**拓展插件**接入多平台音源。插件系统基于 Node.js 运行时(Javet/V8),每个插件是一个单独的 `.js` 文件,通过 `module.exports` 导出接口。 ### 1.1 插件加载机制 - canary 12-4 版本之前:直接加载 `.js` 文件 - **canary 12-4 及之后**:需要 `文件夹 + plugin.json + index.js` 结构(但用户要求只用 `.js`,所以当前版本是单文件) - 运行时环境变量通过 `global.env` 访问,**不是** `process.env` ### 1.2 插件导出格式 ```js module.exports = { musicSearch: { search: fn, tipSearch: fn, hotSearch: fn }, tipSearch: { getList: fn }, hotSearch: { getList: fn }, getUrl: fn, pluginInfo: { info: {...}, env: [...], ext: [...], quality: [...], supportFunc: [] }, // 网易云特有 userPlaylist: fn, dailyRecommend: fn, personalFm: fn, myLikedSongs: fn } ``` --- ## 二、Javet/V8 兼容性大坑(最重要) QZ Music 使用 Javet 作为 JS 运行时(基于 V8),**不支持现代 ES 语法**,必须用保守写法: | 语法 | 是否支持 | 正确写法 | |------|---------|---------| | `let` / `const` | ❌ | `var` | | 箭头函数 `() => {}` | ❌ | `function() {}` | | `async` / `await` | ❌ | `Promise` 链式调用 | | `catch { }`(无参数)| ❌ | `catch (e) { }` | | `Promise.allSettled` | ❌ | `Promise.all` + 手动包装 | | `Object.entries` / `Object.values` | ❌ | `for...in` 遍历 | | `Array.prototype.includes` | ❌ | `indexOf(...) !== -1` | | `String.prototype.startsWith` | ❌ | `indexOf(...) === 0` | | `BigInt` 字面量 | ⚠️ 慎用 | `BigInt('0x' + hex)` | | `class` | ❌ | 对象字面量 | | 模板字符串 `${}` | ✅ | 可用 | | `Buffer` | ✅ | Node.js 内置 | | `require` | ✅ | CommonJS | ### 2.1 Promise.allSettled 替代方案 ```js // ❌ 不支持 Promise.allSettled(promises) // ✅ 正确写法 Promise.all(promises.map(function(p) { return p.then(function(v) { return { status: 'fulfilled', value: v } }).catch(function(e) { return { status: 'rejected', reason: e } }) })).then(function(results) { for (var i = 0; i < results.length; i++) { if (results[i].status === 'fulfilled') { return results[i].value } } throw new Error('all failed') }) ``` --- ## 三、搜索结果格式大坑 App 的 `MusicListResponse` 反序列化要求**必须有 `list` 字段**,不是 `songs`! ### 3.1 正确的搜索返回格式 ```js return { list: [ { id: '歌曲ID字符串', name: '歌曲名', artists: '歌手1、歌手2', albumName: '专辑名', albumId: '专辑ID', source: 'tx', // tx/kg/kw/wy/mg/git pic: '封面大图URL', mPic: '封面中图URL', sPic: '封面小图URL', interval: '3:45', // 播放时长 m:ss qualities: { standard: '3.21MB', exhigh: '7.85MB', lossless: '25.3MB', hires: '48.2MB' } } ], allPage: 5, // 总页数 limit: 30, // 每页条数 total: 150, // 总条数 source: 'tx' // 平台标识 } ``` ### 3.2 字段名对照表 | 含义 | 正确字段名 | 错误字段名 | |------|----------|----------| | 歌手 | `artists` | `artist` | | 封面图 | `pic` / `mPic` / `sPic` | `picUrl` | | 时长 | `interval` (字符串 m:ss) | `duration` | | 歌曲列表 | `list` | `songs` | --- ## 四、各平台 API 踩坑记录 ### 4.1 QQ音乐 (tx) **搜索签名**:`zzcSign` = SHA1 + 自定义索引提取 + XOR 混淆 + base64 ```js var PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19] var PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5] var SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179] ``` **封面图规则**: - 有专辑ID:`https://y.gtimg.cn/music/photo_new/T002R500x500M000{albumId}.jpg` - 无专辑ID(歌手图):`https://y.gtimg.cn/music/photo_new/T001R500x500M000{singerMid}.jpg` **getUrl 音质参数**:API 需要带 `k`,如 `320k`,不是 `320` ### 4.2 酷狗音乐 (kg) **搜索接口**:`http://mobilecdn.kugou.com/api/v3/search/song` **重要**:返回字段是 `errcode`(不是 `error_code`)! ```js // ✅ 正确 if (result.errcode !== 0) { ... } // ❌ 错误 if (result.error_code !== 0) { ... } ``` **封面图**:搜索结果自带 `imgurl` 字段,替换 `{size}` 为 `400` ```js var picUrl = item.imgurl ? item.imgurl.replace('{size}', '400') : '' ``` **getUrl 音质参数**:`128k` / `320k` / `999k` ### 4.3 酷我音乐 (kw) **搜索接口**:`http://search.kuwo.cn/r.s` **封面图**:`https://img2.kuwo.cn/star/albumcover/300/{ALBUMID}.jpg` **音质信息**:在 `N_MINFO` 字段中,格式为 `level:xxx,bitrate:xxx,format:xxx,size:xxx;...` ```js var parts = info.N_MINFO.split(';') for (var j = 0; j < parts.length; j++) { var m = parts[j].match(/level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/) if (m) { if (m[2] === '20900') qualities.jymaster = m[4] else if (m[2] === '4000') qualities.hires = m[4] else if (m[2] === '2000') qualities.lossless = m[4] else if (m[2] === '320') qualities.exhigh = m[4] else if (m[2] === '128') qualities.standard = m[4] } } ``` **getUrl 音质参数**:`128k` / `320k` / `999k` ### 4.4 网易云音乐 (wy) **搜索接口**:`https://music.163.com/api/search/get/web`(简单 GET,不需要 weapi 加密) **封面图**:`picId` 需要 Base64 编码后拼接 ```js var picIdStr = String(s.album.picId) var picIdB64 = Buffer.from(picIdStr).toString('base64').replace(/=/g, '') var pic = 'https://p2.music.126.net/' + picIdB64 + '/' + picIdStr + '.jpg' ``` **getUrl 音质参数**:数字格式 `128000` / `320000` / `999000`(不是带k的) **加密接口**: - `eapi`:AES-128-ECB,key = `e82ckenh8dichen8` - `weapi`:AES-128-CBC + RSA(用于需要登录的接口) **ext 功能**: - `userPlaylist`:需要 `playlist_url` 环境变量(网易云个人主页链接) - `dailyRecommend`:每日推荐 - `personalFm`:私人FM - `myLikedSongs`:我喜欢的音乐 ### 4.5 咪咕音乐 (mg) **搜索签名**:MD5 拼接 ```js var sign = crypto.createHash('md5').update( str + signatureMd5 + 'yyapp2d16148780a1dcc7408e06336b98cfd50' + deviceId + time ).digest('hex') ``` **封面图**:搜索结果可能返回相对路径,需要拼接域名 ```js var img = data.img3 || data.img2 || data.img1 || '' if (img && img.indexOf('http') !== 0) img = 'https://d.musicapp.migu.cn' + img ``` **getUrl 音质参数**:`128k` / `320k` / `999k` ### 4.6 GIT音源 (git) - 无搜索功能(返回空列表) - 纯音源插件,只有 `getUrl` - getUrl 音质参数:`128k` / `320k` / `999k` --- ## 五、getUrl 测速容灾逻辑 所有平台统一使用**并发测速**模式:同时请求多个 API,取第一个成功的结果。 ```js function getUrl(songId, quality) { var apis = buildApis(songId, quality) var promises = [] for (var i = 0; i < apis.length; i++) { (function(api) { promises.push( httpGet(api.url, api.headers, 8000).then(function(res) { var url = api.extract(res) if (url) return { name: api.name, url: url } throw new Error('no url') }).catch(function(err) { throw err }) ) })(apis[i]) } return Promise.all(promises.map(function(p) { return p.then(function(v) { return { status: 'fulfilled', value: v } }) .catch(function(e) { return { status: 'rejected', reason: e } }) })).then(function(results) { for (var i = 0; i < results.length; i++) { if (results[i].status === 'fulfilled') return results[i].value.url } return '' }) } ``` ### 5.1 音源 API 列表 | API | 支持平台 | 特点 | |-----|---------|------| | 聆澜 | 全部 | 需要 `ceru_key`,最稳定 | | HUIBQ (lxmusicapi) | 全部 | `X-Request-Key: share-v3` | | 星海 | 全部 | 聚合接口 | | 念心 | tx/kg/kw/mg | 个人维护 | | 长青 | tx/kg/kw/mg | 个人维护 | | 星海备 | 全部 | 备用 | | fish | 全部 | 个人维护 | | HYW | 全部 | 需要 `X-Card-Key` | | 忆音 | tx | 直接返回 URL | | 收集QQ | tx | 专用 | | 收集KW | kw | 专用 | | bb | wy | 网易云专用 | | ymc | wy | 网易云专用 | | unms | wy | 网易云专用 | | 官方 | wy | 网易云官方 weapi | --- ## 六、版本号管理规范 **所有平台统一版本号**,每次修改全部升级: - 当前版本:`0.0.3` - 下次修改:`0.0.4` - 再下次:`0.0.5` 文件名格式:`Koneko_{平台名}_v{版本号}.js` --- ## 七、常见报错与解决 | 报错 | 原因 | 解决 | |------|------|------| | `Cannot find module 'axios'` | 用了 axios | 改用 `http`/`https` 内置模块 | | `String cannot be converted to JSONObject` | 搜索返回了非对象/字符串 | 加 `.catch()` 兜底返回正确格式 | | `Field 'list' is required` | 搜索返回 `songs` 而非 `list` | 改字段名为 `list` | | `SyntaxError: Invalid or unexpected token` | 用了 `catch { }` | 改为 `catch (e) { }` | | `FileNotFoundException: plugin.json` | 12-4版本需要文件夹结构 | 创建 `plugin.json` + `index.js` | | 搜索无结果 | 字段名不匹配 | 检查 `errcode` vs `error_code` | | 播放失败 | `mapBr` 返回格式不对 | QQ/kg/kw/mg/git 用 `320k`,wy 用 `320000` | | 封面图不显示 | URL 格式错误或跨域 | 检查各平台封面图拼接规则 | --- ## 八、环境变量 ```js var env = global.env || {} var CERU_KEY = env.ceru_key || '' // 聆澜API密钥 var WY_COOKIE = env.cookie || '' // 网易云Cookie var PLAYLIST_URL = env.playlist_url || '' // 网易云个人主页链接 ``` 在 QZ Music 设置中配置环境变量,插件通过 `global.env` 读取。 --- ## 九、完整代码参考 6个平台的完整代码见产物目录: - `Koneko_QQ音乐_v0.0.3.js` - `Koneko_酷狗音乐_v0.0.3.js` - `Koneko_酷我音乐_v0.0.3.js` - `Koneko_网易云音乐_v0.0.3.js` - `Koneko_咪咕音乐_v0.0.3.js` - `Koneko_GIT音源_v0.0.3.js` --- ## 十、后续开发建议 1. **每次修改全部平台统一升级版本号** 2. **先在浏览器/ curl 测试 API 是否可用** 3. **注意 Javet 兼容性,避免现代 JS 语法** 4. **搜索返回务必包含 `list` 字段** 5. **getUrl 注意各平台音质参数格式差异** 6. **封面图 URL 确保可访问(注意跨域和防盗链)** 7. **所有异步操作加 `.catch()` 兜底** 8. **优先使用官方搜索接口,音源用第三方 API 容灾**