From c6eeb253d0e5d322694cc549b1c7b06debe70a7d Mon Sep 17 00:00:00 2001 From: miao-moe Date: Sat, 20 Jun 2026 12:31:39 +0800 Subject: [PATCH] Update to v0.0.4: add aggregated plugins with API failover, update docs --- ...4039d1dfbbf7_Koneko_插件开发文档_v0.0.3.md | 2326 +++++++++++++++++ ...9d1dfbbf7_Koneko插件开发避坑指南_v0.0.3.md | 359 +++ Koneko_GIT音源_v0.0.4.js | 141 + Koneko_QQ音乐_v0.0.4.js | 450 ++++ Koneko_咪咕音乐_v0.0.4.js | 313 +++ Koneko_插件开发文档_v0.0.4.md | 239 ++ Koneko_网易云音乐_v0.0.4.js | 657 +++++ Koneko_酷我音乐_v0.0.4.js | 323 +++ Koneko_酷狗音乐_v0.0.4.js | 305 +++ Koneko插件开发避坑指南_v0.0.4.md | 146 ++ ...件规范│QZ-Music-Plugin-Development-Guide.md | 856 ++++++ QZ_Music-V2 插件规范(v1.0.3).md | 862 ++++++ ...初版)│QZ_Music-V2-Plugin-Development-Guide.md | 871 ++++++ README.md | 42 + 14 files changed, 7890 insertions(+) create mode 100644 6a2d8996cc974039d1dfbbf7_Koneko_插件开发文档_v0.0.3.md create mode 100644 6a2d8996cc974039d1dfbbf7_Koneko插件开发避坑指南_v0.0.3.md create mode 100644 Koneko_GIT音源_v0.0.4.js create mode 100644 Koneko_QQ音乐_v0.0.4.js create mode 100644 Koneko_咪咕音乐_v0.0.4.js create mode 100644 Koneko_插件开发文档_v0.0.4.md create mode 100644 Koneko_网易云音乐_v0.0.4.js create mode 100644 Koneko_酷我音乐_v0.0.4.js create mode 100644 Koneko_酷狗音乐_v0.0.4.js create mode 100644 Koneko插件开发避坑指南_v0.0.4.md create mode 100644 QZMusicV2 插件规范│QZ-Music-Plugin-Development-Guide.md create mode 100644 QZ_Music-V2 插件规范(v1.0.3).md create mode 100644 QZ_Music-V2 插件规范(初版)│QZ_Music-V2-Plugin-Development-Guide.md create mode 100644 README.md diff --git a/6a2d8996cc974039d1dfbbf7_Koneko_插件开发文档_v0.0.3.md b/6a2d8996cc974039d1dfbbf7_Koneko_插件开发文档_v0.0.3.md new file mode 100644 index 0000000..1497471 --- /dev/null +++ b/6a2d8996cc974039d1dfbbf7_Koneko_插件开发文档_v0.0.3.md @@ -0,0 +1,2326 @@ +# Koneko 插件开发踩坑与最终代码文档 + +> 版本:0.0.3 +> 作者:云汀(Miao-moe) +> 整理日期:2026-06-19 +> 用途:汇总与 QZ Music v2 拓展插件开发相关的全部踩坑点、最终代码、链接/API,便于迁移到其他 AI/工具继续迭代。 + +--- + +## 一、项目背景与目标 + +为 QZ Music v2 编写 6 个音乐平台拓展插件:QQ音乐、酷狗音乐、酷我音乐、网易云音乐、咪咕音乐、GIT音源。 + +核心需求: +- 官方搜索可用 +- 多 API 音源测速容灾 +- 环境变量读取 `global.env` +- 版本号从 0.0.1 开始,每次修改统一升版 +- 仅支持单 `.js` 文件格式(QZ Music v2 实际加载的是 `.js` 文件,`plugin.json` 内嵌在代码里) + +--- + +## 二、重大踩坑点(按时间顺序) + +### 1. axios 不可用 +QZ Music v2 运行环境没有 `axios`,必须用 Node.js 内置 `http`/`https` 模块。 + +### 2. 插件格式演变 +- 最初直接写 `module.exports = { ... }`,后来发现 app 期望 `plugin.musicSearch.search(...)` 这样的对象方法格式。 +- 12-4 版本之前尝试过 folder + plugin.json + index.js 结构,但用户最终确认只要单 `.js` 文件。 +- `pluginInfo` 必须内嵌在 `module.exports` 里,包含 `info`、`env`、`ext`、`quality`、`supportFunc`。 + +### 3. 环境变量读取 +用 `global.env`,不是 `process.env`。 + +```js +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' +``` + +### 4. Javet / V8 语法兼容性(导致"闪退") +必须使用最保守的 ES5 风格语法: +- `var` 代替 `let/const` +- 传统 `function` 声明,不用箭头函数 +- 不用 `async/await`,只用 Promise 链式调用 +- `catch` 必须带参数:`catch (e) { }`,不能写 `catch { }` +- 不用 `Promise.allSettled`,自己用 `Promise.all + .then/.catch` 包装 +- 不用对象解构等现代语法 + +### 5. 搜索结果字段必须严格匹配 +app 的 `MusicListResponse` 要求返回: + +```js +{ + list: [...], + allPage: number, + limit: number, + total: number, + source: string +} +``` + +单首歌曲字段要包含: +- `id`:歌曲唯一标识 +- `name`:歌名 +- `artists`:歌手名(用 `/` 或 `、`分隔) +- `albumName` / `albumId` +- `pic`、`mPic`、`sPic`:封面图 +- `interval`:时长字符串 `m:ss` +- `qualities`:音质大小映射 +- `source`:平台标识 + +网易云搜索之前用 `{ songs: [], total: 0 }` 导致报错:`Field 'list' is required for type ...`。 + +### 6. 音质参数格式 +HUIBQ 等第三方 API 要求音质参数带 `k`,如 `320k`、`128k`、`999k`。 +`mapBr` 返回 `'320'` 时返回的是 fallback 无效 URL,导致所有平台播放失败。 + +### 7. 网易云封面图 +网易云搜索 API 返回的 `album.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' +``` + +### 8. 酷狗搜索接口字段名 +`mobilecdn.kugou.com` 接口返回的字段是 `errcode` 不是 `error_code`,且歌曲信息字段为 `hash`、`songname`、`singername`、`imgurl`。 + +### 9. 酷狗封面图 +最初错误地用了酷我的 `img2.kuwo.cn` 域名。应使用酷狗搜索接口返回的 `imgurl`,替换 `{size}` 为 `400`。 + +### 10. 测速容灾模式 +所有平台 `getUrl` 采用并发请求多个 API,取第一个成功结果: + +```js +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 '' +}) +``` + +### 11. 版本号管理 +每次修改所有插件统一升级版本号。当前最终版本:**0.0.3**。 + +--- + +## 三、环境变量说明 + +| key | 用途 | 必填 | +|-----|------|------| +| `ceru_key` | 聆澜音源 API 密钥 | 否 | +| `playlist_url` | 网易云个人主页链接,用于 `userPlaylist` | 否 | +| `cookie` | 网易云 Cookie,用于搜索和 ext 功能 | 否 | + +--- + +## 四、所有相关链接/API + +### 4.1 文档/参考链接 +- QZ Music v2 插件文档(用户自维护,非公开链接) +- CSDN 网易云搜索 API:https://blog.csdn.net/2301_79279502/article/details/135568447 +- CSDN 酷狗 API 整理:https://blog.csdn.net/2301_78245299/article/details/140352615 +- 旧版插件参考:`Koneko_酷狗音乐_v1.0.9.js` +- 旧日志:`log_2026-06-19_0.txt` + +### 4.2 QQ音乐相关 API +- QQ音乐搜索签名接口:`https://u.y.qq.com/cgi-bin/musics.fcg?sign={zzcSign}` +- QQ音乐搜索 POST:`https://u.y.qq.com/cgi-bin/musics.fcg?sign=` +- QQ音乐热词:`https://u.y.qq.com/cgi-bin/musicu.fcg` +- QQ音乐搜索建议:`https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg` +- QQ音乐歌手封面:`https://y.gtimg.cn/music/photo_new/T001R500x500M000{singerMid}.jpg` +- QQ音乐专辑封面:`https://y.gtimg.cn/music/photo_new/T002R500x500M000{albumMid}.jpg` +- 聆澜 QQ 音源:`https://source.shiqianjiang.cn/api/music/url?source=tx&songId={id}&quality={q}` +- HUIBQ QQ 音源:`https://lxmusicapi.onrender.com/url/tx/{id}/{q}` +- 忆音 QQ 音源:`https://music.3e0.cn/?server=tencent&type=url&id={id}` +- 星海 QQ 音源:`https://music-api.gdstudio.xyz/api.php?types=url&source=tencent&id={id}&br={br}` +- 收集 QQ 音源:`https://cyapi.top/API/qq_music.php?apikey=...&type=json&mid={id}` +- 念心 QQ 音源:`https://music.nxinxz.com/kgqq/tx.php?id={id}&level={q}&type=mp3` +- 长青 QQ 音源:`http://175.27.166.236/kgqq/qq.php?type=mp3&id={id}&level={q}` +- 星海备 QQ 音源:`https://music-dl.sayqz.com/api/?source=qq&id={id}&type=url&br={q}` +- fish QQ 音源:`https://m-api.ceseet.me/url/tx/{id}/{q}` +- HYW QQ 音源:`https://music.bxa241d4.shop/api/music/url?source=tx&songId={id}&quality={q}` + +### 4.3 酷狗音乐相关 API +- 酷狗搜索(mobilecdn):`http://mobilecdn.kugou.com/api/v3/search/song?format=json&keyword={kw}&page={p}&pagesize={n}` +- 酷狗搜索(songsearch):`https://songsearch.kugou.com/song_search_v2?keyword={kw}&page={p}&pagesize={n}&...` +- 酷狗搜索建议:`https://searchtip.kugou.com/getSearchTip?MusicTipCount=10&keyword={kw}` +- 酷狗热词:`http://gateway.kugou.com/api/v3/search/hot_tab?...` +- 聆澜 酷狗音源:`https://source.shiqianjiang.cn/api/music/url?source=kg&songId={id}&quality={q}` +- HUIBQ 酷狗音源:`https://lxmusicapi.onrender.com/url/kg/{id}/{q}` +- 星海 酷狗音源:`https://music-api.gdstudio.xyz/api.php?types=url&source=kugou&id={id}&br={br}` +- 念心 酷狗音源:`https://music.nxinxz.com/kgqq/kg.php?id={id}&level={q}&type=mp3` +- 长青 酷狗音源:`https://music.haitangw.cc/kgqq/kg.php?id={id}&level={q}&type=mp3` +- 星海备 酷狗音源:`https://music-dl.sayqz.com/api/?source=kugou&id={id}&type=url&br={q}` +- fish 酷狗音源:`https://m-api.ceseet.me/url/kg/{id}/{q}` +- HYW 酷狗音源:`https://music.bxa241d4.shop/api/music/url?source=kg&songId={id}&quality={q}` + +### 4.4 酷我音乐相关 API +- 酷我搜索:`http://search.kuwo.cn/r.s?client=kt&all={kw}&pn={p}&rn={n}&...` +- 酷我搜索建议:`https://tips.kuwo.cn/t.s?...&w={kw}&...` +- 酷我热词:`http://hotword.kuwo.cn/hotword.s?...` +- 酷我专辑封面:`https://img2.kuwo.cn/star/albumcover/300/{albumId}.jpg` +- 酷我歌手封面:`http://artistpicserver.kuwo.cn/pic.web?...&rid={rid}` +- 聆澜 酷我音源:`https://source.shiqianjiang.cn/api/music/url?source=kw&songId={id}&quality={q}` +- HUIBQ 酷我音源:`https://lxmusicapi.onrender.com/url/kw/{id}/{q}` +- 星海 酷我音源:`https://music-api.gdstudio.xyz/api.php?types=url&source=kuwo&id={id}&br={br}` +- 收集 KW 音源:`https://kw-api.cenguigui.cn/api/song/url?id={id}&quality={q}` +- 念心 酷我音源:`https://music.nxinxz.com/kgqq/kw.php?id={id}&level={q}&type=mp3` +- 长青 酷我音源:`https://musicapi.haitangw.net/music/kw.php?id={id}&level={q}&type=mp3` +- 星海备 酷我音源:`https://music-dl.sayqz.com/api/?source=kuwo&id={id}&type=url&br={q}` +- fish 酷我音源:`https://m-api.ceseet.me/url/kw/{id}/{q}` +- HYW 酷我音源:`https://music.bxa241d4.shop/api/music/url?source=kw&songId={id}&quality={q}` + +### 4.5 网易云音乐相关 API +- 网易云搜索(GET):`https://music.163.com/api/search/get/web?csrf_token=&hlposttag=&s={kw}&type=1&offset={o}&total=true&limit={n}` +- 网易云搜索(weapi POST,备用):`https://music.163.com/weapi/cloudsearch/get/web` +- 网易云热词(weapi):`https://music.163.com/weapi/search/hot` +- 网易云官方播放链接(weapi):`https://music.163.com/weapi/song/enhance/player/url` +- 网易云用户歌单(weapi):`https://music.163.com/weapi/user/playlist` +- 网易云每日推荐(weapi):`https://music.163.com/weapi/v1/discovery/recommend/songs` +- 网易云私人 FM(weapi):`https://music.163.com/weapi/v1/radio/get` +- 网易云喜欢歌曲(weapi):`https://music.163.com/weapi/song/like/get` +- 网易云歌曲详情(weapi):`https://music.163.com/weapi/v3/song/detail` +- 网易云封面图:`https://p2.music.126.net/{base64(picId)}/{picId}.jpg` +- 聆澜 网易云音源:`https://ceruapi.lol/meting-api-0/?server=netease&type=url&id={id}&auth={key}&br={br}` +- bb 网易云音源:`https://api.bbdcz.cn/music/netease/url?id={id}&br={br}` +- lx 网易云音源:`https://lxmusicapi.onrender.com/url/wy/{id}/{br}` +- ymc 网易云音源:`https://api.ymusic.icu/netease/song?id={id}&quality={br}` +- unms 网易云音源:`https://unms.zeabur.app/netease/url?id={id}&br={br}` + +### 4.6 咪咕音乐相关 API +- 咪咕搜索:`https://jadeite.migu.cn/music_search/v3/search/searchAll?...` +- 聆澜 咪咕音源:`https://source.shiqianjiang.cn/api/music/url?source=mg&songId={id}&quality={q}` +- HUIBQ 咪咕音源:`https://lxmusicapi.onrender.com/url/mg/{id}/{q}` +- 星海 咪咕音源:`https://music-api.gdstudio.xyz/api.php?types=url&source=migu&id={id}&br={br}` +- 念心 咪咕音源:`https://music.nxinxz.com/kgqq/mg.php?id={id}&level={q}&type=mp3` +- 长青 咪咕音源:`https://music.haitangw.cc/musicapi/mg.php?id={id}&level={q}&type=mp3` +- 星海备 咪咕音源:`https://music-dl.sayqz.com/api/?source=migu&id={id}&type=url&br={q}` +- fish 咪咕音源:`https://m-api.ceseet.me/url/mg/{id}/{q}` +- HYW 咪咕音源:`https://music.bxa241d4.shop/api/music/url?source=mg&songId={id}&quality={q}` + +### 4.7 GIT音源相关 API +- 聆澜 GIT 音源:`https://source.shiqianjiang.cn/api/music/url?source=git&songId={id}&quality={q}` +- HUIBQ GIT 音源:`https://lxmusicapi.onrender.com/url/git/{id}/{q}` + +### 4.8 其他提及但未使用/备用 +- `https://music.163.com/weapi` 系列(weapi 备用) +- `https://m.kugou.com/rank/list&json=true` +- `http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0` + +--- + +## 五、最终代码 + +> 以下 6 个文件版本号均为 0.0.3,是截至 2026-06-19 的最终可用代码。 + +--- + +## Koneko_QQ音乐_v0.0.3 + +```js +'use strict' +var https = require('https') +var http = require('http') +var crypto = require('crypto') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128k' + if (q === '320k' || q === 'exhigh') return '320k' + return '999k' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function httpPost(url, body, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var postData = typeof body === 'string' ? body : JSON.stringify(body) + var opts = { + method: 'POST', + headers: {}, + timeout: timeout || 10000 + } + if (headers) { + for (var k in headers) { opts.headers[k] = headers[k] } + } + opts.headers['Content-Type'] = 'application/json' + opts.headers['Content-Length'] = Buffer.byteLength(postData) + var req = mod.request(url, opts, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + req.write(postData) + req.end() + }) +} + +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] + +function zzcSign(text) { + var hash = crypto.createHash('sha1').update(text).digest('hex') + var part1 = '' + for (var i = 0; i < PART_1_INDEXES.length; i++) { part1 += hash[PART_1_INDEXES[i]] } + var part2 = '' + for (var i = 0; i < PART_2_INDEXES.length; i++) { part2 += hash[PART_2_INDEXES[i]] } + var part3 = [] + for (var i = 0; i < SCRAMBLE_VALUES.length; i++) { + part3.push(SCRAMBLE_VALUES[i] ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16)) + } + var b64Part = Buffer.from(part3).toString('base64').replace(/[\/+=]/g, '') + return ('zzc' + part1 + b64Part + part2).toLowerCase() +} + +function signRequest(data) { + var sign = zzcSign(JSON.stringify(data)) + return httpPost('https://u.y.qq.com/cgi-bin/musics.fcg?sign=' + sign, data, { + 'User-Agent': 'QQMusic 14090508(android 12)' + }) +} + +function formatPlayTime(seconds) { + if (!seconds || isNaN(seconds)) return '--/--' + var m = Math.floor(seconds / 60) + var s = seconds % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +function formatSingerName(singers) { + if (!singers || singers.length === 0) return '' + var names = [] + for (var i = 0; i < singers.length; i++) { + if (singers[i].name) names.push(singers[i].name) + } + return names.join('、') +} + +function formatSize(bytes) { + if (!bytes) return '' + var n = parseFloat(bytes) + if (isNaN(n) || n < 0) return '' + return (n / (1024 * 1024)).toFixed(2) + 'MB' +} + +var musicSearch = { + limit: 30, + total: 0, + page: 0, + allPage: 1, + musicSearch: function(str, page, limit, retryNum) { + var self = this + if (retryNum === undefined) retryNum = 0 + if (retryNum > 3) return Promise.reject(new Error('搜索失败')) + var data = { + comm: { + ct: '11', cv: '14090508', v: '14090508', tmeAppID: 'qqmusic', + phonetype: 'EBG-AN10', deviceScore: '553.47', devicelevel: '50', newdevicelevel: '20', + rom: 'HuaWei/EMOTION/EmotionUI_14.2.0', os_ver: '12', + OpenUDID: '0', OpenUDID2: '0', QIMEI36: '0', udid: '0', + chid: '0', aid: '0', oaid: '0', taid: '0', tid: '0', wid: '0', uid: '0', sid: '0', + modeSwitch: '6', teenMode: '0', ui_mode: '2', nettype: '1020', v4ip: '' + }, + req: { + module: 'music.search.SearchCgiService', + method: 'DoSearchForQQMusicMobile', + param: { + search_type: 0, searchid: Math.random().toString().slice(2), + query: str, page_num: page, num_per_page: limit, + highlight: 0, nqc_flag: 0, multi_zhida: 0, cat: 2, grp: 1, sin: 0, sem: 0 + } + } + } + return signRequest(data).then(function(body) { + if (!body || typeof body !== 'object' || body.code !== 0 || !body.req || body.req.code !== 0) { + return self.musicSearch(str, page, limit, retryNum + 1) + } + return body.req.data + }) + }, + handleResult: function(rawList) { + if (!rawList || rawList.length === 0) return [] + var list = [] + for (var i = 0; i < rawList.length; i++) { + var item = rawList[i] + if (!item.file || !item.file.media_mid) continue + var albumId = '' + var albumName = '' + if (item.album) { albumName = item.album.name; albumId = item.album.mid } + var picUrl = '' + if (albumId === '' || albumId === '空') { + if (item.singer && item.singer.length) { + picUrl = 'https://y.gtimg.cn/music/photo_new/T001R500x500M000' + item.singer[0].mid + '.jpg' + } + } else { + picUrl = 'https://y.gtimg.cn/music/photo_new/T002R500x500M000' + albumId + '.jpg' + } + var qualities = {} + if (item.file && item.file.size_128mp3) qualities.standard = formatSize(item.file.size_128mp3) + if (item.file && item.file.size_320mp3) qualities.exhigh = formatSize(item.file.size_320mp3) + if (item.file && item.file.size_flac) qualities.lossless = formatSize(item.file.size_flac) + if (item.file && item.file.size_hires) qualities.hires = formatSize(item.file.size_hires) + list.push({ + id: String(item.mid), + name: item.name + (item.title_extra || ''), + artists: formatSingerName(item.singer), + source: 'tx', + pic: picUrl, + mPic: picUrl, + sPic: picUrl, + albumName: albumName, + albumId: String(albumId || ''), + interval: String(formatPlayTime(item.interval) || '--/--'), + qualities: qualities + }) + } + return list + }, + search: function(str, page, limit) { + var self = this + if (!page) page = 1 + if (limit == null) limit = this.limit + return this.musicSearch(str, page, limit).then(function(data) { + if (!data || typeof data !== 'object') { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' } + } + var list = self.handleResult(data.body && data.body.item_song ? data.body.item_song : []) + if (!list || list.length === 0) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' } + } + self.total = data.meta && data.meta.estimate_sum ? data.meta.estimate_sum : 0 + self.page = page + self.allPage = Math.ceil(self.total / limit) + return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'tx' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' } + }) + } +} + +var tipSearch = { + getList: function(str) { + return httpGet( + 'https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg?is_xml=0&format=json&key=' + encodeURIComponent(str) + '&loginUin=0&hostUin=0&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0', + { Referer: 'https://y.qq.com/portal/player.html' } + ).then(function(body) { + if (!body || typeof body !== 'object' || body.code !== 0) return [] + var result = { order: [], songs: [], artists: [], albums: [] } + if (body.data && body.data.song && body.data.song.count > 0) result.order.push('songs') + if (body.data && body.data.singer && body.data.singer.count > 0) result.order.push('artists') + if (body.data && body.data.album && body.data.album.count > 0) result.order.push('albums') + if (body.data && body.data.song && body.data.song.itemlist) { + for (var i = 0; i < body.data.song.itemlist.length; i++) { + var item = body.data.song.itemlist[i] + result.songs.push({ name: item.name, artist: { name: item.singer } }) + } + } + if (body.data && body.data.singer && body.data.singer.itemlist) { + for (var i = 0; i < body.data.singer.itemlist.length; i++) { + result.artists.push({ name: body.data.singer.itemlist[i].name }) + } + } + if (body.data && body.data.album && body.data.album.itemlist) { + for (var i = 0; i < body.data.album.itemlist.length; i++) { + result.albums.push({ name: body.data.album.itemlist[i].name }) + } + } + return result + }).catch(function(e) { return [] }) + } +} + +var hotSearch = { + getList: function() { + var data = { + comm: { ct: '19', cv: '1803', guid: '0', patch: '118' }, + hotkey: { + method: 'GetHotkeyForQQMusicPC', + module: 'tencent_musicsoso_hotkey.HotkeyService', + param: { search_id: '', uin: 0 } + } + } + return httpPost('https://u.y.qq.com/cgi-bin/musicu.fcg', data, { + Referer: 'https://y.qq.com/portal/player.html' + }).then(function(body) { + if (!body || typeof body !== 'object' || body.code !== 0 || !body.hotkey || !body.hotkey.data) return [] + var list = [] + for (var i = 0; i < body.hotkey.data.vec_hotkey.length; i++) { + list.push(body.hotkey.data.vec_hotkey[i].query) + } + return list + }).catch(function(e) { return [] }) + } +} + +function buildApis(songId, q) { + var br = mapBr(q) + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=tx&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push( + { + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/tx/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }, + { + name: '忆音', + url: 'https://music.3e0.cn/?server=tencent&type=url&id=' + songId, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (typeof res === 'string' && res.indexOf('http') === 0) return res + return null + } + }, + { + name: '星海', + url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=tencent&id=' + songId + '&br=' + br, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '收集QQ', + url: 'https://cyapi.top/API/qq_music.php?apikey=4d6f7369632d6170692e63656e6775696769692e636f6d&type=json&mid=' + songId, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '念心', + url: 'https://music.nxinxz.com/kgqq/tx.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '长青', + url: 'http://175.27.166.236/kgqq/qq.php?type=mp3&id=' + songId + '&level=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '星海备', + url: 'https://music-dl.sayqz.com/api/?source=qq&id=' + songId + '&type=url&br=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'fish', + url: 'https://m-api.ceseet.me/url/tx/' + songId + '/' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'HYW', + url: 'https://music.bxa241d4.shop/api/music/url?source=tx&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' }, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.code === 200 && res.data && res.data.url) return res.data.url + return null + } + } + ) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko QQ音乐] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko QQ音乐] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko QQ音乐] 所有API均失败') + return '' + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'tx', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'tx', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_tx', name: 'QQ音乐 - Koneko', version: '0.0.3', description: 'QQ音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} + +``` + +--- + +## Koneko_酷狗音乐_v0.0.3 + +```js +'use strict' +var https = require('https') +var http = require('http') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128k' + if (q === '320k' || q === 'exhigh') return '320k' + return '999k' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function formatPlayTime(seconds) { + if (!seconds || isNaN(seconds)) return '--/--' + var m = Math.floor(seconds / 60) + var s = seconds % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +var musicSearch = { + limit: 30, + total: 0, + page: 0, + allPage: 1, + search: function(str, page, limit, retryNum) { + var self = this + if (retryNum === undefined) retryNum = 0 + if (++retryNum > 3) return Promise.reject(new Error('搜索失败')) + if (!page) page = 1 + if (limit == null) limit = this.limit + return httpGet( + 'http://mobilecdn.kugou.com/api/v3/search/song?format=json&keyword=' + encodeURIComponent(str) + '&page=' + page + '&pagesize=' + limit, + HEADERS_COMMON + ).then(function(result) { + if (!result || typeof result !== 'object' || result.errcode !== 0 || !result.data || !result.data.info) { + return self.search(str, page, limit, retryNum) + } + var list = [] + for (var i = 0; i < result.data.info.length; i++) { + var item = result.data.info[i] + var picUrl = '' + if (item.imgurl && item.imgurl.indexOf('http') === 0) { + picUrl = item.imgurl.replace('{size}', '400') + } + if (!picUrl && item.album_img) { + picUrl = item.album_img + } + list.push({ + id: String(item.hash || ''), + name: String(item.songname || item.song_name || ''), + artists: String(item.singername || item.singer_name || ''), + albumName: String(item.album_name || ''), + albumId: String(item.album_id || ''), + source: 'kg', + interval: String(formatPlayTime(item.duration) || '--/--'), + pic: picUrl, + mPic: picUrl, + sPic: picUrl, + qualities: {} + }) + } + self.total = result.data.total + self.page = page + self.allPage = Math.ceil(self.total / limit) + return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'kg' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'kg' } + }) + } +} + +var tipSearch = { + getList: function(str) { + return httpGet( + 'https://searchtip.kugou.com/getSearchTip?MusicTipCount=10&keyword=' + encodeURIComponent(str), + { 'User-Agent': HEADERS_COMMON['User-Agent'], referer: 'https://www.kugou.com/' } + ).then(function(body) { + if (!body || !body.data) return [] + var result = { order: [], songs: [], artists: [], albums: [] } + if (body.data.songs && body.data.songs.length > 0) result.order.push('songs') + if (body.data.artists && body.data.artists.length > 0) result.order.push('artists') + result.songs = [] + if (body.data.songs) { + for (var i = 0; i < body.data.songs.length; i++) { + result.songs.push({ name: body.data.songs[i].name, artist: { name: body.data.songs[i].artist } }) + } + } + result.artists = [] + if (body.data.artists) { + for (var i = 0; i < body.data.artists.length; i++) { + result.artists.push({ name: body.data.artists[i].name }) + } + } + return result + }).catch(function(e) { return [] }) + } +} + +var hotSearch = { + getList: function() { + return httpGet( + 'http://gateway.kugou.com/api/v3/search/hot_tab?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0', + HEADERS_COMMON + ).then(function(body) { + if (!body || body.error_code !== 0 || !body.data) return [] + var list = [] + for (var i = 0; i < body.data.length; i++) { + list.push(body.data[i].keyword || body.data[i].searchword || '') + } + return list + }).catch(function(e) { return [] }) + } +} + +function buildApis(songId, q) { + var br = mapBr(q) + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=kg&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push( + { + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/kg/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }, + { + name: '星海', + url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=kugou&id=' + songId + '&br=' + br, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '念心', + url: 'https://music.nxinxz.com/kgqq/kg.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '长青', + url: 'https://music.haitangw.cc/kgqq/kg.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '星海备', + url: 'https://music-dl.sayqz.com/api/?source=kugou&id=' + songId + '&type=url&br=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'fish', + url: 'https://m-api.ceseet.me/url/kg/' + songId + '/' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'HYW', + url: 'https://music.bxa241d4.shop/api/music/url?source=kg&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' }, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.code === 200 && res.data && res.data.url) return res.data.url + return null + } + } + ) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko 酷狗音乐] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko 酷狗音乐] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko 酷狗音乐] 所有API均失败') + return '' + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'kg', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'kg', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_kg', name: '酷狗音乐 - Koneko', version: '0.0.3', description: '酷狗音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} + +``` + +--- + +## Koneko_酷我音乐_v0.0.3 + +```js +'use strict' +var https = require('https') +var http = require('http') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128k' + if (q === '320k' || q === 'exhigh') return '320k' + return '999k' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function formatPlayTime(seconds) { + if (!seconds || isNaN(seconds)) return '--/--' + var m = Math.floor(seconds / 60) + var s = seconds % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +var musicSearch = { + limit: 30, + total: 0, + page: 0, + allPage: 1, + search: function(str, page, limit, retryNum) { + var self = this + if (retryNum === undefined) retryNum = 0 + if (++retryNum > 3) return Promise.reject(new Error('搜索失败')) + if (!page) page = 1 + if (limit == null) limit = this.limit + return httpGet( + 'http://search.kuwo.cn/r.s?client=kt&all=' + encodeURIComponent(str) + '&pn=' + (page - 1) + '&rn=' + limit + '&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1', + HEADERS_COMMON + ).then(function(result) { + if (!result || !result.abslist || result.abslist.length === 0) { + return self.search(str, page, limit, retryNum) + } + var list = [] + for (var i = 0; i < result.abslist.length; i++) { + var info = result.abslist[i] + var songId = (info.MUSICRID || '').replace('MUSIC_', '') + var qualities = {} + if (info.N_MINFO) { + 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] + } + } + } + var picUrl = '' + if (info.ALBUMID) { + picUrl = 'https://img2.kuwo.cn/star/albumcover/300/' + info.ALBUMID + '.jpg' + } else { + picUrl = 'http://artistpicserver.kuwo.cn/pic.web?corp=kuwo&type=rid_pic&pictype=500&size=500&rid=' + songId + } + var artistStr = '' + if (info.ARTIST) artistStr = info.ARTIST.replace(/&/g, '、') + var duration = parseInt(info.DURATION) + list.push({ + id: String(songId), + name: String(info.SONGNAME || ''), + artists: artistStr, + source: 'kw', + pic: picUrl, + mPic: picUrl, + sPic: picUrl, + albumName: String(info.ALBUM || ''), + albumId: String(info.ALBUMID || ''), + interval: isNaN(duration) ? '--/--' : formatPlayTime(duration), + qualities: qualities + }) + } + self.total = parseInt(result.TOTAL) || 0 + self.page = page + self.allPage = Math.ceil(self.total / limit) + return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'kw' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'kw' } + }) + } +} + +var tipSearch = { + getList: function(str) { + return httpGet( + 'https://tips.kuwo.cn/t.s?corp=kuwo&newver=3&p2p=1¬race=0&c=mbox&w=' + encodeURIComponent(str) + '&encoding=utf8&rformat=json', + { 'User-Agent': HEADERS_COMMON['User-Agent'], Referer: 'http://www.kuwo.cn/' } + ).then(function(body) { + if (!body || !body.abs) return [] + var result = { order: [], songs: [] } + if (body.abs.length > 0) result.order.push('songs') + result.songs = [] + for (var i = 0; i < body.abs.length; i++) { + result.songs.push({ name: body.abs[i].name, artist: { name: body.abs[i].artist } }) + } + return result + }).catch(function(e) { return [] }) + } +} + +var hotSearch = { + getList: function() { + return httpGet( + 'http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2&vipver=9.3.0.1&source=kwplayer_ar_9.3.0.1_40.apk&p2p=1¬race=0&uid=0&plat=kwplayer_ar&rformat=json&encoding=utf8&tabid=1', + { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9;)' } + ).then(function(body) { + if (!body || !body.data) return [] + var list = [] + for (var i = 0; i < body.data.length; i++) { + list.push(body.data[i].keyword || body.data[i].searchWord || body.data[i].name || '') + } + return list + }).catch(function(e) { return [] }) + } +} + +function buildApis(songId, q) { + var br = mapBr(q) + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=kw&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push( + { + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/kw/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }, + { + name: '星海', + url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=kuwo&id=' + songId + '&br=' + br, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '收集KW', + url: 'https://kw-api.cenguigui.cn/api/song/url?id=' + songId + '&quality=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '念心', + url: 'https://music.nxinxz.com/kgqq/kw.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '长青', + url: 'https://musicapi.haitangw.net/music/kw.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '星海备', + url: 'https://music-dl.sayqz.com/api/?source=kuwo&id=' + songId + '&type=url&br=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'fish', + url: 'https://m-api.ceseet.me/url/kw/' + songId + '/' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'HYW', + url: 'https://music.bxa241d4.shop/api/music/url?source=kw&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' }, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.code === 200 && res.data && res.data.url) return res.data.url + return null + } + } + ) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko 酷我音乐] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko 酷我音乐] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko 酷我音乐] 所有API均失败') + return '' + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'kw', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'kw', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_kw', name: '酷我音乐 - Koneko', version: '0.0.3', description: '酷我音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} + +``` + +--- + +## Koneko_网易云音乐_v0.0.3 + +```js +'use strict' +var https = require('https') +var http = require('http') +var crypto = require('crypto') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' +var WY_COOKIE = env.cookie || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128000' + if (q === '320k' || q === 'exhigh') return '320000' + if (q === '999k' || q === 'lossless') return '999000' + if (q === 'hires') return '999000' + return '320000' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function httpPost(url, body, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var postData = typeof body === 'string' ? body : JSON.stringify(body) + var opts = { + method: 'POST', + headers: {}, + timeout: timeout || 10000 + } + if (headers) { + for (var k in headers) { opts.headers[k] = headers[k] } + } + opts.headers['Content-Type'] = 'application/x-www-form-urlencoded' + opts.headers['Content-Length'] = Buffer.byteLength(postData) + var req = mod.request(url, opts, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + req.write(postData) + req.end() + }) +} + +var EAPI_KEY = 'e82ckenh8dichen8' + +function aesEncryptEcb(text, key) { + var cipher = crypto.createCipheriv('aes-128-ecb', key, '') + cipher.setAutoPadding(true) + var encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + return encrypted +} + +function eapiEncrypt(url, text) { + var message = 'nobody' + url + 'use' + text + 'md5forencrypt' + var digest = crypto.createHash('md5').update(message).digest('hex') + var data = url + '-36cd479b6b5-' + text + '-36cd479b6b5-' + digest + return aesEncryptEcb(data, EAPI_KEY) +} + +function eapiRequest(url, params) { + var text = JSON.stringify(params) + var enc = eapiEncrypt(url, text) + var body = 'params=' + encodeURIComponent(enc) + var headers = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': 'https://music.163.com', + 'Cookie': WY_COOKIE || 'os=android; appver=8.9.0;' + } + return httpPost('https://music.163.com' + url, body, headers, 15000) +} + +var WEAPI_IV = '0102030405060708' +var WEAPI_PRESET_KEY = '0CoJUm6Qyw8W8jud' +var WEAPI_RSA_KEY = '010001' +var WEAPI_RSA_MODULUS = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7' +var WEAPI_BASE62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +function aesEncryptCbc(text, key, iv) { + var cipher = crypto.createCipheriv('aes-128-cbc', key, iv) + cipher.setAutoPadding(true) + var encrypted = cipher.update(text, 'utf8', 'base64') + encrypted += cipher.final('base64') + return encrypted +} + +function rsaEncrypt(text, exponent, modulus) { + var reversed = text.split('').reverse().join('') + var bigInt = BigInt('0x' + Buffer.from(reversed).toString('hex')) + var exp = BigInt('0x' + exponent) + var mod = BigInt('0x' + modulus) + var result = bigInt ** exp % mod + var hex = result.toString(16).padStart(256, '0') + return hex +} + +function weapiEncrypt(text) { + var secretKey = '' + for (var i = 0; i < 16; i++) { + secretKey += WEAPI_BASE62.charAt(Math.floor(Math.random() * 62)) + } + var firstEnc = aesEncryptCbc(text, WEAPI_PRESET_KEY, WEAPI_IV) + var secondEnc = aesEncryptCbc(firstEnc, secretKey, WEAPI_IV) + var rsa = rsaEncrypt(secretKey, WEAPI_RSA_KEY, WEAPI_RSA_MODULUS) + return { + params: secondEnc, + encSecKey: rsa + } +} + +function weapiRequest(url, params) { + var text = JSON.stringify(params) + var enc = weapiEncrypt(text) + var body = 'params=' + encodeURIComponent(enc.params) + '&encSecKey=' + encodeURIComponent(enc.encSecKey) + var headers = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': 'https://music.163.com', + 'Cookie': WY_COOKIE || 'os=android; appver=8.9.0;' + } + return httpPost('https://music.163.com' + url, body, headers, 15000) +} + +function formatPlayTime(ms) { + if (!ms || isNaN(ms)) return '--/--' + var totalSec = Math.floor(ms / 1000) + var m = Math.floor(totalSec / 60) + var s = totalSec % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +function wySearch(keyword, page, limit) { + if (!page) page = 1 + if (!limit) limit = 30 + var offset = (page - 1) * limit + var url = 'https://music.163.com/api/search/get/web?csrf_token=&hlposttag=&s=' + encodeURIComponent(keyword) + '&type=1&offset=' + offset + '&total=true&limit=' + limit + return httpGet(url, { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://music.163.com', + 'Cookie': WY_COOKIE || 'os=pc; appver=2.10.0;' + }, 10000).then(function(res) { + if (!res || !res.result || !res.result.songs) { + return { list: [], allPage: 1, limit: limit, total: 0, source: 'wy' } + } + var list = [] + for (var i = 0; i < res.result.songs.length; i++) { + var s = res.result.songs[i] + var artists = [] + if (s.artists) { + for (var j = 0; j < s.artists.length; j++) { + artists.push(s.artists[j].name) + } + } + var album = s.album ? s.album.name : '' + var albumId = s.album ? String(s.album.id) : '' + var pic = '' + if (s.album && s.album.picId) { + var picIdStr = String(s.album.picId) + var picIdB64 = Buffer.from(picIdStr).toString('base64').replace(/=/g, '') + pic = 'https://p2.music.126.net/' + picIdB64 + '/' + picIdStr + '.jpg' + } + list.push({ + id: s.id ? String(s.id) : '', + name: s.name || '', + artists: artists.join('、'), + albumName: album, + albumId: albumId, + source: 'wy', + pic: pic, + mPic: pic, + sPic: pic, + interval: formatPlayTime(s.duration), + qualities: {} + }) + } + var total = res.result.songCount || 0 + var allPage = Math.ceil(total / limit) + return { list: list, allPage: allPage, limit: limit, total: total, source: 'wy' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'wy' } + }) +} + +function wyTipSearch(keyword) { + var url = 'https://music.163.com/api/search/get/web?csrf_token=&hlposttag=&s=' + encodeURIComponent(keyword) + '&type=1&offset=0&total=true&limit=10' + return httpGet(url, { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://music.163.com', + 'Cookie': WY_COOKIE || 'os=pc; appver=2.10.0;' + }, 10000).then(function(res) { + if (!res || !res.result || !res.result.songs) return [] + var tips = [] + for (var i = 0; i < res.result.songs.length; i++) { + var s = res.result.songs[i] + tips.push(s.name + ' - ' + (s.artists && s.artists[0] ? s.artists[0].name : '未知')) + } + return tips + }).catch(function(e) { + return [] + }) +} + +function wyHotSearch() { + return weapiRequest('/weapi/search/hot', { type: 1111 }).then(function(res) { + if (!res || !res.result || !res.result.hots) return [] + var hots = [] + for (var i = 0; i < res.result.hots.length; i++) { + hots.push(res.result.hots[i].first) + } + return hots + }).catch(function(e) { + return [] + }) +} + +function wyGetUrl(songId, quality) { + var br = mapBr(quality) + var params = { + ids: '[' + songId + ']', + br: br, + csrf_token: '' + } + return weapiRequest('/weapi/song/enhance/player/url', params).then(function(res) { + if (res && res.data && res.data[0] && res.data[0].url) { + return { url: res.data[0].url, platform: 'wy' } + } + throw new Error('no url') + }) +} + +function wyUserPlaylist() { + var url = env.playlist_url || '' + var uid = '' + var match = url.match(/id=(\d+)/) + if (match) uid = match[1] + if (!uid) { + match = url.match(/(\d+)/) + if (match) uid = match[1] + } + if (!uid) return Promise.resolve({ playlists: [] }) + var params = { uid: uid, limit: 1000, offset: 0 } + return weapiRequest('/weapi/user/playlist', params).then(function(res) { + if (!res || !res.playlist) return { playlists: [] } + var list = [] + for (var i = 0; i < res.playlist.length; i++) { + var p = res.playlist[i] + list.push({ + id: String(p.id), + name: p.name, + picUrl: p.coverImgUrl, + trackCount: p.trackCount, + description: p.description || '' + }) + } + return { playlists: list } + }).catch(function(e) { + return { playlists: [] } + }) +} + +function wyDailyRecommend() { + return weapiRequest('/weapi/v1/discovery/recommend/songs', {}).then(function(res) { + if (!res || !res.data || !res.data.dailySongs) return { songs: [] } + var songs = [] + for (var i = 0; i < res.data.dailySongs.length; i++) { + var s = res.data.dailySongs[i] + var artists = [] + if (s.ar) { + for (var j = 0; j < s.ar.length; j++) { + artists.push(s.ar[j].name) + } + } + songs.push({ + id: String(s.id), + name: s.name, + artist: artists.join('/'), + album: s.al ? s.al.name : '', + picUrl: s.al ? s.al.picUrl : '', + duration: s.dt || 0, + platform: 'wy' + }) + } + return { songs: songs } + }).catch(function(e) { + return { songs: [] } + }) +} + +function wyPersonalFm() { + return weapiRequest('/weapi/v1/radio/get', {}).then(function(res) { + if (!res || !res.data) return { songs: [] } + var songs = [] + for (var i = 0; i < res.data.length; i++) { + var s = res.data[i] + var artists = [] + if (s.ar) { + for (var j = 0; j < s.ar.length; j++) { + artists.push(s.ar[j].name) + } + } + songs.push({ + id: String(s.id), + name: s.name, + artist: artists.join('/'), + album: s.al ? s.al.name : '', + picUrl: s.al ? s.al.picUrl : '', + duration: s.dt || 0, + platform: 'wy' + }) + } + return { songs: songs } + }).catch(function(e) { + return { songs: [] } + }) +} + +function wyMyLikedSongs() { + return weapiRequest('/weapi/song/like/get', {}).then(function(res) { + if (!res || !res.data || !res.data.checkPoint) return { songs: [] } + var ids = res.data.checkPoint + if (!ids || ids.length === 0) return { songs: [] } + var idStr = '' + for (var i = 0; i < ids.length; i++) { + if (i > 0) idStr += ',' + idStr += ids[i] + } + var params = { ids: '[' + idStr + ']', csrf_token: '' } + return weapiRequest('/weapi/v3/song/detail', params).then(function(detail) { + if (!detail || !detail.songs) return { songs: [] } + var songs = [] + for (var i = 0; i < detail.songs.length; i++) { + var s = detail.songs[i] + var artists = [] + if (s.ar) { + for (var j = 0; j < s.ar.length; j++) { + artists.push(s.ar[j].name) + } + } + songs.push({ + id: String(s.id), + name: s.name, + artist: artists.join('/'), + album: s.al ? s.al.name : '', + picUrl: s.al ? s.al.picUrl : '', + duration: s.dt || 0, + platform: 'wy' + }) + } + return { songs: songs } + }) + }).catch(function(e) { + return { songs: [] } + }) +} + +function ceruGetUrl(songId, quality) { + if (!CERU_KEY) return Promise.reject(new Error('no key')) + var br = mapBr(quality) + var url = 'https://ceruapi.lol/meting-api-0/?server=netease&type=url&id=' + encodeURIComponent(songId) + '&auth=' + encodeURIComponent(CERU_KEY) + '&br=' + br + return httpGet(url, HEADERS_COMMON, 10000).then(function(res) { + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function bbGetUrl(songId, quality) { + var br = mapBr(quality) + var url = 'https://api.bbdcz.cn/music/netease/url?id=' + encodeURIComponent(songId) + '&br=' + br + return httpGet(url, HEADERS_COMMON, 10000).then(function(res) { + if (res && res.data && res.data.url) return { url: res.data.url, platform: 'wy' } + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function lxGetUrl(songId, quality) { + var br = mapBr(quality) + var url = 'https://lxmusicapi.onrender.com/url/wy/' + encodeURIComponent(songId) + '/' + br + return httpGet(url, HEADERS_COMMON, 15000).then(function(res) { + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function ymcGetUrl(songId, quality) { + var br = mapBr(quality) + var url = 'https://api.ymusic.icu/netease/song?id=' + encodeURIComponent(songId) + '&quality=' + br + return httpGet(url, HEADERS_COMMON, 10000).then(function(res) { + if (res && res.data && res.data.url) return { url: res.data.url, platform: 'wy' } + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function unmsGetUrl(songId, quality) { + var br = mapBr(quality) + var url = 'https://unms.zeabur.app/netease/url?id=' + encodeURIComponent(songId) + '&br=' + br + return httpGet(url, HEADERS_COMMON, 10000).then(function(res) { + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function getUrl(songId, quality) { + var apis = [] + apis.push({ name: 'ceru', fn: ceruGetUrl }) + apis.push({ name: 'bb', fn: bbGetUrl }) + apis.push({ name: 'lx', fn: lxGetUrl }) + apis.push({ name: 'ymc', fn: ymcGetUrl }) + apis.push({ name: 'unms', fn: unmsGetUrl }) + apis.push({ name: 'official', fn: wyGetUrl }) + + var promises = [] + for (var i = 0; i < apis.length; i++) { + var api = apis[i] + promises.push( + api.fn(songId, quality).then(function(result) { + return { status: 'fulfilled', value: result } + }).catch(function(err) { + return { status: 'rejected', reason: err } + }) + ) + } + + return new Promise(function(resolve, reject) { + Promise.all(promises).then(function(results) { + for (var i = 0; i < results.length; i++) { + if (results[i].status === 'fulfilled') { + resolve(results[i].value) + return + } + } + reject(new Error('all apis failed')) + }).catch(function(e) { + reject(e) + }) + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'wy', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'wy', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_wy', name: '网易云音乐 - Koneko', version: '0.0.3', description: '网易云音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [ + { key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }, + { key: 'playlist_url', name: '个人主页链接', description: '网易云音乐个人主页链接,用于获取个人歌单' }, + { key: 'cookie', name: 'Cookie', description: '网易云音乐Cookie,用于搜索/每日推荐/私人FM/我喜欢的音乐' } + ], + ext: [ + { name: '个人歌单', description: '通过分享链接获取个人歌单', entry: 'plugin.userPlaylist()', type: 'playlists' }, + { name: '每日推荐', description: '获取每日推荐歌曲', entry: 'plugin.dailyRecommend()', type: 'songs' }, + { name: '私人FM', description: '获取私人FM歌曲', entry: 'plugin.personalFm()', type: 'songs' }, + { name: '我喜欢的音乐', description: '获取我喜欢的音乐列表', entry: 'plugin.myLikedSongs()', type: 'songs' } + ], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' }, + { name: '高清环绕声', ui: 'DB', id: 'jyeffect' }, + { name: '沉浸环绕声', ui: 'SK', id: 'sky' }, + { name: '超清母带', ui: 'MT', id: 'jymaster' } + ], + supportFunc: [] +} + +var musicSearch = { + search: function(keyword, page, limit) { + return wySearch(keyword, page, limit).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'wy' } + }) + }, + tipSearch: function(keyword) { + return wyTipSearch(keyword).catch(function(e) { + return [] + }) + }, + hotSearch: function() { + return wyHotSearch().catch(function(e) { + return [] + }) + } +} + +var tipSearch = { + getList: function(str) { + return wyTipSearch(str).catch(function(e) { + return [] + }) + } +} + +var hotSearch = { + getList: function() { + return wyHotSearch().catch(function(e) { + return [] + }) + } +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo, + userPlaylist: function() { + return wyUserPlaylist().catch(function(e) { + return { playlists: [] } + }) + }, + dailyRecommend: function() { + return wyDailyRecommend().catch(function(e) { + return { songs: [] } + }) + }, + personalFm: function() { + return wyPersonalFm().catch(function(e) { + return { songs: [] } + }) + }, + myLikedSongs: function() { + return wyMyLikedSongs().catch(function(e) { + return { songs: [] } + }) + } +} + +``` + +--- + +## Koneko_咪咕音乐_v0.0.3 + +```js +'use strict' +var https = require('https') +var http = require('http') +var crypto = require('crypto') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128k' + if (q === '320k' || q === 'exhigh') return '320k' + return '999k' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function formatPlayTime(seconds) { + if (!seconds || isNaN(seconds)) return '--/--' + var m = Math.floor(seconds / 60) + var s = seconds % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +function formatSize(bytes) { + if (!bytes) return '' + var n = parseFloat(bytes) + if (isNaN(n) || n < 0) return '' + return (n / (1024 * 1024)).toFixed(2) + 'MB' +} + +function createSignature(time, str) { + var deviceId = '963B7AA0D21511ED807EE5846EC87D20' + var signatureMd5 = '6cdc72a439cef99a3418d2a78aa28c73' + var sign = crypto.createHash('md5').update(str + signatureMd5 + 'yyapp2d16148780a1dcc7408e06336b98cfd50' + deviceId + time).digest('hex') + return { sign: sign, deviceId: deviceId } +} + +var musicSearch = { + limit: 30, + total: 0, + page: 0, + allPage: 1, + search: function(str, page, limit, retryNum) { + var self = this + if (retryNum === undefined) retryNum = 0 + if (++retryNum > 3) return Promise.reject(new Error('搜索失败')) + if (!page) page = 1 + if (limit == null) limit = this.limit + var time = Date.now().toString() + var signData = createSignature(time, str) + var searchSwitch = encodeURIComponent('{"song":1,"album":0,"singer":0,"tagSong":1,"mvSong":0,"bestShow":1,"songlist":0,"lyricSong":0}') + return httpGet( + 'https://jadeite.migu.cn/music_search/v3/search/searchAll?isCorrect=0&isCopyright=1&searchSwitch=' + searchSwitch + '&pageSize=' + limit + '&text=' + encodeURIComponent(str) + '&pageNo=' + page + '&sort=0&sid=USS', + { + 'User-Agent': HEADERS_COMMON['User-Agent'], + uiVersion: 'A_music_3.6.1', + deviceId: signData.deviceId, + timestamp: time, + sign: signData.sign, + channel: '0146921' + } + ).then(function(result) { + if (!result || typeof result !== 'object' || result.code !== '000000' || !result.songResultData) { + return self.search(str, page, limit, retryNum) + } + var songData = result.songResultData || { resultList: [], totalCount: 0 } + var ids = {} + var list = [] + var resultList = songData.resultList || [] + for (var i = 0; i < resultList.length; i++) { + var item = resultList[i] + if (!Array.isArray(item)) continue + for (var j = 0; j < item.length; j++) { + var data = item[j] + if (!data.songId || !data.copyrightId || ids[data.copyrightId]) continue + ids[data.copyrightId] = true + var qualities = {} + if (data.audioFormats) { + for (var k = 0; k < data.audioFormats.length; k++) { + var type = data.audioFormats[k] + var size = formatSize(type.asize || type.isize) + if (type.formatType === 'PQ') qualities.standard = size + else if (type.formatType === 'HQ') qualities.exhigh = size + else if (type.formatType === 'SQ') qualities.lossless = size + else if (type.formatType === 'ZQ24') qualities.hires = size + } + } + var img = data.img3 || data.img2 || data.img1 || '' + if (img && img.indexOf('http') !== 0) img = 'https://d.musicapp.migu.cn' + img + var artists = '' + if (data.singerList) { + var names = [] + for (var k = 0; k < data.singerList.length; k++) { + if (data.singerList[k].name) names.push(data.singerList[k].name) + } + artists = names.join('、') + } + list.push({ + artists: artists, + name: data.name || '', + albumName: data.album || '', + albumId: data.albumId || '', + id: data.contentId || '', + source: 'mg', + interval: formatPlayTime(data.duration), + pic: img, + mPic: img, + sPic: img, + qualities: qualities + }) + } + } + self.total = parseInt(songData.totalCount) || 0 + self.page = page + self.allPage = Math.ceil(self.total / limit) + return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'mg' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'mg' } + }) + } +} + +var tipSearch = { getList: function(str) { return Promise.resolve([]) } } +var hotSearch = { getList: function() { return Promise.resolve([]) } } + +function buildApis(songId, q) { + var br = mapBr(q) + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=mg&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push( + { + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/mg/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }, + { + name: '星海', + url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=migu&id=' + songId + '&br=' + br, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '念心', + url: 'https://music.nxinxz.com/kgqq/mg.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '长青', + url: 'https://music.haitangw.cc/musicapi/mg.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '星海备', + url: 'https://music-dl.sayqz.com/api/?source=migu&id=' + songId + '&type=url&br=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'fish', + url: 'https://m-api.ceseet.me/url/mg/' + songId + '/' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'HYW', + url: 'https://music.bxa241d4.shop/api/music/url?source=mg&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' }, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.code === 200 && res.data && res.data.url) return res.data.url + return null + } + } + ) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko 咪咕音乐] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko 咪咕音乐] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko 咪咕音乐] 所有API均失败') + return '' + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'mg', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'mg', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_mg', name: '咪咕音乐 - Koneko', version: '0.0.3', description: '咪咕音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} + +``` + +--- + +## Koneko_GIT音源_v0.0.3 + +```js +'use strict' +var https = require('https') +var http = require('http') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function buildApis(songId, q) { + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=git&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push({ + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/git/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko GIT音源] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko GIT音源] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko GIT音源] 所有API均失败') + return '' + }) +} + +var musicSearch = { + search: function(str, page, limit) { + return Promise.resolve({ list: [], total: 0, page: page || 1, limit: limit || 30, allPage: 0, source: 'koneko_git' }) + } +} +var tipSearch = { getList: function(str) { return Promise.resolve([]) } } +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'koneko_git', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var hotSearch = { getList: function() { return Promise.resolve([]) } } +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'koneko_git', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_git', name: 'GIT音源 - Koneko', version: '0.0.3', description: 'GIT音源聚合插件,聚合2+API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} + +``` diff --git a/6a2d8996cc974039d1dfbbf7_Koneko插件开发避坑指南_v0.0.3.md b/6a2d8996cc974039d1dfbbf7_Koneko插件开发避坑指南_v0.0.3.md new file mode 100644 index 0000000..b90092a --- /dev/null +++ b/6a2d8996cc974039d1dfbbf7_Koneko插件开发避坑指南_v0.0.3.md @@ -0,0 +1,359 @@ +# 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 容灾** diff --git a/Koneko_GIT音源_v0.0.4.js b/Koneko_GIT音源_v0.0.4.js new file mode 100644 index 0000000..1ca8fd0 --- /dev/null +++ b/Koneko_GIT音源_v0.0.4.js @@ -0,0 +1,141 @@ +/** + * @name GIT音源 - Koneko + * @description 聚合音源插件: 纯音源(无搜索)+ 多API音源容灾 + * @version 0.0.4 + * @author Miao-moe + * + * 环境变量: + * ceru_key - 聆澜API密钥(可选) + */ + +'use strict' +var https = require('https') +var http = require('http') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function buildApis(songId, q) { + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=git&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push({ + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/git/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko GIT音源] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko GIT音源] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko GIT音源] 所有API均失败') + return '' + }) +} + +var musicSearch = { + search: function(str, page, limit) { + return Promise.resolve({ list: [], total: 0, page: page || 1, limit: limit || 30, allPage: 0, source: 'koneko_git' }) + } +} +var tipSearch = { getList: function(str) { return Promise.resolve([]) } } +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'koneko_git', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var hotSearch = { getList: function() { return Promise.resolve([]) } } +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'koneko_git', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_git', name: 'GIT音源 - Koneko', version: '0.0.4', description: 'GIT音源聚合插件,聚合2+API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} diff --git a/Koneko_QQ音乐_v0.0.4.js b/Koneko_QQ音乐_v0.0.4.js new file mode 100644 index 0000000..5aee8a7 --- /dev/null +++ b/Koneko_QQ音乐_v0.0.4.js @@ -0,0 +1,450 @@ +/** + * @name QQ音乐 - Koneko + * @description 聚合音源插件: 官方搜索 + 多API音源容灾 + * @version 0.0.4 + * @author Miao-moe + * + * 环境变量: + * ceru_key - 聆澜API密钥(可选) + */ + +'use strict' +var https = require('https') +var http = require('http') +var crypto = require('crypto') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128k' + if (q === '320k' || q === 'exhigh') return '320k' + return '999k' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function httpPost(url, body, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var postData = typeof body === 'string' ? body : JSON.stringify(body) + var opts = { + method: 'POST', + headers: {}, + timeout: timeout || 10000 + } + if (headers) { + for (var k in headers) { opts.headers[k] = headers[k] } + } + opts.headers['Content-Type'] = 'application/json' + opts.headers['Content-Length'] = Buffer.byteLength(postData) + var req = mod.request(url, opts, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + req.write(postData) + req.end() + }) +} + +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] + +function zzcSign(text) { + var hash = crypto.createHash('sha1').update(text).digest('hex') + var part1 = '' + for (var i = 0; i < PART_1_INDEXES.length; i++) { part1 += hash[PART_1_INDEXES[i]] } + var part2 = '' + for (var i = 0; i < PART_2_INDEXES.length; i++) { part2 += hash[PART_2_INDEXES[i]] } + var part3 = [] + for (var i = 0; i < SCRAMBLE_VALUES.length; i++) { + part3.push(SCRAMBLE_VALUES[i] ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16)) + } + var b64Part = Buffer.from(part3).toString('base64').replace(/[\/+=]/g, '') + return ('zzc' + part1 + b64Part + part2).toLowerCase() +} + +function signRequest(data) { + var sign = zzcSign(JSON.stringify(data)) + return httpPost('https://u.y.qq.com/cgi-bin/musics.fcg?sign=' + sign, data, { + 'User-Agent': 'QQMusic 14090508(android 12)' + }) +} + +function formatPlayTime(seconds) { + if (!seconds || isNaN(seconds)) return '--/--' + var m = Math.floor(seconds / 60) + var s = seconds % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +function formatSingerName(singers) { + if (!singers || singers.length === 0) return '' + var names = [] + for (var i = 0; i < singers.length; i++) { + if (singers[i].name) names.push(singers[i].name) + } + return names.join('、') +} + +function formatSize(bytes) { + if (!bytes) return '' + var n = parseFloat(bytes) + if (isNaN(n) || n < 0) return '' + return (n / (1024 * 1024)).toFixed(2) + 'MB' +} + +var musicSearch = { + limit: 30, + total: 0, + page: 0, + allPage: 1, + musicSearch: function(str, page, limit, retryNum) { + var self = this + if (retryNum === undefined) retryNum = 0 + if (retryNum > 3) return Promise.reject(new Error('搜索失败')) + var data = { + comm: { + ct: '11', cv: '14090508', v: '14090508', tmeAppID: 'qqmusic', + phonetype: 'EBG-AN10', deviceScore: '553.47', devicelevel: '50', newdevicelevel: '20', + rom: 'HuaWei/EMOTION/EmotionUI_14.2.0', os_ver: '12', + OpenUDID: '0', OpenUDID2: '0', QIMEI36: '0', udid: '0', + chid: '0', aid: '0', oaid: '0', taid: '0', tid: '0', wid: '0', uid: '0', sid: '0', + modeSwitch: '6', teenMode: '0', ui_mode: '2', nettype: '1020', v4ip: '' + }, + req: { + module: 'music.search.SearchCgiService', + method: 'DoSearchForQQMusicMobile', + param: { + search_type: 0, searchid: Math.random().toString().slice(2), + query: str, page_num: page, num_per_page: limit, + highlight: 0, nqc_flag: 0, multi_zhida: 0, cat: 2, grp: 1, sin: 0, sem: 0 + } + } + } + return signRequest(data).then(function(body) { + if (!body || typeof body !== 'object' || body.code !== 0 || !body.req || body.req.code !== 0) { + return self.musicSearch(str, page, limit, retryNum + 1) + } + return body.req.data + }) + }, + handleResult: function(rawList) { + if (!rawList || rawList.length === 0) return [] + var list = [] + for (var i = 0; i < rawList.length; i++) { + var item = rawList[i] + if (!item.file || !item.file.media_mid) continue + var albumId = '' + var albumName = '' + if (item.album) { albumName = item.album.name; albumId = item.album.mid } + var picUrl = '' + if (albumId === '' || albumId === '空') { + if (item.singer && item.singer.length) { + picUrl = 'https://y.gtimg.cn/music/photo_new/T001R500x500M000' + item.singer[0].mid + '.jpg' + } + } else { + picUrl = 'https://y.gtimg.cn/music/photo_new/T002R500x500M000' + albumId + '.jpg' + } + var qualities = {} + if (item.file && item.file.size_128mp3) qualities.standard = formatSize(item.file.size_128mp3) + if (item.file && item.file.size_320mp3) qualities.exhigh = formatSize(item.file.size_320mp3) + if (item.file && item.file.size_flac) qualities.lossless = formatSize(item.file.size_flac) + if (item.file && item.file.size_hires) qualities.hires = formatSize(item.file.size_hires) + list.push({ + id: String(item.mid), + name: item.name + (item.title_extra || ''), + artists: formatSingerName(item.singer), + source: 'tx', + pic: picUrl, + mPic: picUrl, + sPic: picUrl, + albumName: albumName, + albumId: String(albumId || ''), + interval: String(formatPlayTime(item.interval) || '--/--'), + qualities: qualities + }) + } + return list + }, + search: function(str, page, limit) { + var self = this + if (!page) page = 1 + if (limit == null) limit = this.limit + return this.musicSearch(str, page, limit).then(function(data) { + if (!data || typeof data !== 'object') { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' } + } + var list = self.handleResult(data.body && data.body.item_song ? data.body.item_song : []) + if (!list || list.length === 0) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' } + } + self.total = data.meta && data.meta.estimate_sum ? data.meta.estimate_sum : 0 + self.page = page + self.allPage = Math.ceil(self.total / limit) + return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'tx' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' } + }) + } +} + +var tipSearch = { + getList: function(str) { + return httpGet( + 'https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg?is_xml=0&format=json&key=' + encodeURIComponent(str) + '&loginUin=0&hostUin=0&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0', + { Referer: 'https://y.qq.com/portal/player.html' } + ).then(function(body) { + if (!body || typeof body !== 'object' || body.code !== 0) return [] + var result = { order: [], songs: [], artists: [], albums: [] } + if (body.data && body.data.song && body.data.song.count > 0) result.order.push('songs') + if (body.data && body.data.singer && body.data.singer.count > 0) result.order.push('artists') + if (body.data && body.data.album && body.data.album.count > 0) result.order.push('albums') + if (body.data && body.data.song && body.data.song.itemlist) { + for (var i = 0; i < body.data.song.itemlist.length; i++) { + var item = body.data.song.itemlist[i] + result.songs.push({ name: item.name, artist: { name: item.singer } }) + } + } + if (body.data && body.data.singer && body.data.singer.itemlist) { + for (var i = 0; i < body.data.singer.itemlist.length; i++) { + result.artists.push({ name: body.data.singer.itemlist[i].name }) + } + } + if (body.data && body.data.album && body.data.album.itemlist) { + for (var i = 0; i < body.data.album.itemlist.length; i++) { + result.albums.push({ name: body.data.album.itemlist[i].name }) + } + } + return result + }).catch(function(e) { return [] }) + } +} + +var hotSearch = { + getList: function() { + var data = { + comm: { ct: '19', cv: '1803', guid: '0', patch: '118' }, + hotkey: { + method: 'GetHotkeyForQQMusicPC', + module: 'tencent_musicsoso_hotkey.HotkeyService', + param: { search_id: '', uin: 0 } + } + } + return httpPost('https://u.y.qq.com/cgi-bin/musicu.fcg', data, { + Referer: 'https://y.qq.com/portal/player.html' + }).then(function(body) { + if (!body || typeof body !== 'object' || body.code !== 0 || !body.hotkey || !body.hotkey.data) return [] + var list = [] + for (var i = 0; i < body.hotkey.data.vec_hotkey.length; i++) { + list.push(body.hotkey.data.vec_hotkey[i].query) + } + return list + }).catch(function(e) { return [] }) + } +} + +function buildApis(songId, q) { + var br = mapBr(q) + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=tx&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push( + { + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/tx/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }, + { + name: '忆音', + url: 'https://music.3e0.cn/?server=tencent&type=url&id=' + songId, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (typeof res === 'string' && res.indexOf('http') === 0) return res + return null + } + }, + { + name: '星海', + url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=tencent&id=' + songId + '&br=' + br, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '收集QQ', + url: 'https://cyapi.top/API/qq_music.php?apikey=4d6f7369632d6170692e63656e6775696769692e636f6d&type=json&mid=' + songId, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '念心', + url: 'https://music.nxinxz.com/kgqq/tx.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '长青', + url: 'http://175.27.166.236/kgqq/qq.php?type=mp3&id=' + songId + '&level=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '星海备', + url: 'https://music-dl.sayqz.com/api/?source=qq&id=' + songId + '&type=url&br=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'fish', + url: 'https://m-api.ceseet.me/url/tx/' + songId + '/' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'HYW', + url: 'https://music.bxa241d4.shop/api/music/url?source=tx&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' }, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.code === 200 && res.data && res.data.url) return res.data.url + return null + } + } + ) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko QQ音乐] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko QQ音乐] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko QQ音乐] 所有API均失败') + return '' + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'tx', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'tx', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_tx', name: 'QQ音乐 - Koneko', version: '0.0.4', description: 'QQ音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} diff --git a/Koneko_咪咕音乐_v0.0.4.js b/Koneko_咪咕音乐_v0.0.4.js new file mode 100644 index 0000000..e3a2e31 --- /dev/null +++ b/Koneko_咪咕音乐_v0.0.4.js @@ -0,0 +1,313 @@ +/** + * @name 咪咕音乐 - Koneko + * @description 聚合音源插件: 官方搜索 + 多API音源容灾 + * @version 0.0.4 + * @author Miao-moe + * + * 环境变量: + * ceru_key - 聆澜API密钥(可选) + */ + +'use strict' +var https = require('https') +var http = require('http') +var crypto = require('crypto') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128k' + if (q === '320k' || q === 'exhigh') return '320k' + return '999k' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function formatPlayTime(seconds) { + if (!seconds || isNaN(seconds)) return '--/--' + var m = Math.floor(seconds / 60) + var s = seconds % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +function formatSize(bytes) { + if (!bytes) return '' + var n = parseFloat(bytes) + if (isNaN(n) || n < 0) return '' + return (n / (1024 * 1024)).toFixed(2) + 'MB' +} + +function createSignature(time, str) { + var deviceId = '963B7AA0D21511ED807EE5846EC87D20' + var signatureMd5 = '6cdc72a439cef99a3418d2a78aa28c73' + var sign = crypto.createHash('md5').update(str + signatureMd5 + 'yyapp2d16148780a1dcc7408e06336b98cfd50' + deviceId + time).digest('hex') + return { sign: sign, deviceId: deviceId } +} + +var musicSearch = { + limit: 30, + total: 0, + page: 0, + allPage: 1, + search: function(str, page, limit, retryNum) { + var self = this + if (retryNum === undefined) retryNum = 0 + if (++retryNum > 3) return Promise.reject(new Error('搜索失败')) + if (!page) page = 1 + if (limit == null) limit = this.limit + var time = Date.now().toString() + var signData = createSignature(time, str) + var searchSwitch = encodeURIComponent('{"song":1,"album":0,"singer":0,"tagSong":1,"mvSong":0,"bestShow":1,"songlist":0,"lyricSong":0}') + return httpGet( + 'https://jadeite.migu.cn/music_search/v3/search/searchAll?isCorrect=0&isCopyright=1&searchSwitch=' + searchSwitch + '&pageSize=' + limit + '&text=' + encodeURIComponent(str) + '&pageNo=' + page + '&sort=0&sid=USS', + { + 'User-Agent': HEADERS_COMMON['User-Agent'], + uiVersion: 'A_music_3.6.1', + deviceId: signData.deviceId, + timestamp: time, + sign: signData.sign, + channel: '0146921' + } + ).then(function(result) { + if (!result || typeof result !== 'object' || result.code !== '000000' || !result.songResultData) { + return self.search(str, page, limit, retryNum) + } + var songData = result.songResultData || { resultList: [], totalCount: 0 } + var ids = {} + var list = [] + var resultList = songData.resultList || [] + for (var i = 0; i < resultList.length; i++) { + var item = resultList[i] + if (!Array.isArray(item)) continue + for (var j = 0; j < item.length; j++) { + var data = item[j] + if (!data.songId || !data.copyrightId || ids[data.copyrightId]) continue + ids[data.copyrightId] = true + var qualities = {} + if (data.audioFormats) { + for (var k = 0; k < data.audioFormats.length; k++) { + var type = data.audioFormats[k] + var size = formatSize(type.asize || type.isize) + if (type.formatType === 'PQ') qualities.standard = size + else if (type.formatType === 'HQ') qualities.exhigh = size + else if (type.formatType === 'SQ') qualities.lossless = size + else if (type.formatType === 'ZQ24') qualities.hires = size + } + } + var img = data.img3 || data.img2 || data.img1 || '' + if (img && img.indexOf('http') !== 0) img = 'https://d.musicapp.migu.cn' + img + var artists = '' + if (data.singerList) { + var names = [] + for (var k = 0; k < data.singerList.length; k++) { + if (data.singerList[k].name) names.push(data.singerList[k].name) + } + artists = names.join('、') + } + list.push({ + artists: artists, + name: data.name || '', + albumName: data.album || '', + albumId: data.albumId || '', + id: data.contentId || '', + source: 'mg', + interval: formatPlayTime(data.duration), + pic: img, + mPic: img, + sPic: img, + qualities: qualities + }) + } + } + self.total = parseInt(songData.totalCount) || 0 + self.page = page + self.allPage = Math.ceil(self.total / limit) + return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'mg' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'mg' } + }) + } +} + +var tipSearch = { getList: function(str) { return Promise.resolve([]) } } +var hotSearch = { getList: function() { return Promise.resolve([]) } } + +function buildApis(songId, q) { + var br = mapBr(q) + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=mg&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push( + { + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/mg/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }, + { + name: '星海', + url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=migu&id=' + songId + '&br=' + br, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '念心', + url: 'https://music.nxinxz.com/kgqq/mg.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '长青', + url: 'https://music.haitangw.cc/musicapi/mg.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '星海备', + url: 'https://music-dl.sayqz.com/api/?source=migu&id=' + songId + '&type=url&br=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'fish', + url: 'https://m-api.ceseet.me/url/mg/' + songId + '/' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'HYW', + url: 'https://music.bxa241d4.shop/api/music/url?source=mg&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' }, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.code === 200 && res.data && res.data.url) return res.data.url + return null + } + } + ) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko 咪咕音乐] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko 咪咕音乐] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko 咪咕音乐] 所有API均失败') + return '' + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'mg', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'mg', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_mg', name: '咪咕音乐 - Koneko', version: '0.0.4', description: '咪咕音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} diff --git a/Koneko_插件开发文档_v0.0.4.md b/Koneko_插件开发文档_v0.0.4.md new file mode 100644 index 0000000..215ff7b --- /dev/null +++ b/Koneko_插件开发文档_v0.0.4.md @@ -0,0 +1,239 @@ +# Koneko QZ Music v2/v3 插件开发文档 + +> 版本:0.0.4 | 作者:云汀(Miao-moe) | 整理日期:2026-06-20 + +--- + +## 一、概述 + +为 QZ Music v2/v3 编写 6 个音乐平台拓展插件: +- QQ音乐 (`koneko_tx`) +- 酷狗音乐 (`koneko_kg`) +- 酷我音乐 (`koneko_kw`) +- 网易云音乐 (`koneko_wy`) +- 咪咕音乐 (`koneko_mg`) +- GIT音源 (`koneko_git`) + +## 二、插件规范 + +### 2.1 运行环境 + +- Node.js 运行时(Javet/V8) +- CommonJS 模块规范 +- `module.exports` 导出接口 +- 不支持 `axios`,使用内置 `http`/`https` 模块 + +### 2.2 ES5 兼容(Javet/V8 限制) + +| 语法 | 状态 | 替代 | +|------|------|------| +| `let` / `const` | ❌ | `var` | +| 箭头函数 | ❌ | `function() {}` | +| `async`/`await` | ❌ | Promise 链式 | +| `catch { }` 无参数 | ❌ | `catch (e) { }` | +| `Promise.allSettled` | ❌ | `Promise.all` + 手动包装 | +| `Object.entries/values` | ❌ | `for...in` | +| `Array.includes` | ❌ | `indexOf` | +| `String.startsWith` | ❌ | `indexOf(...) === 0` | +| `class` | ❌ | 对象字面量 | +| 模板字符串 `${}` | ✅ | - | +| `Buffer` | ✅ | - | + +### 2.3 插件导出格式 + +```js +module.exports = { + musicSearch: { search: fn, tipSearch: fn, hotSearch: fn }, + tipSearch: { getList: fn }, + hotSearch: { getList: fn }, + getUrl: fn, + getLyric: fn, + songList: { getListDetail: fn }, + album: { getListDetail: fn }, + pluginInfo: { info, env, ext, quality, supportFunc }, + // 网易云特有 + userPlaylist: fn, + dailyRecommend: fn, + personalFm: fn, + myLikedSongs: fn +} +``` + +### 2.4 搜索结果格式 + +```js +{ + list: [{ + id: String, + name: String, + artists: String, // 用 "、" 分隔 + source: String, // tx/kg/kw/wy/mg + pic: String, // 封面大图 + mPic: String, // 封面中图 + sPic: String, // 封面小图 + albumName: String, + albumId: String, + interval: String, // "m:ss" + qualities: { standard: '3.21MB', exhigh: '7.85MB', ... } + }], + allPage: Number, + limit: Number, + total: Number, + source: String +} +``` + +### 2.5 环境变量 + +通过 `global.env` 读取: + +```js +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' +var WY_COOKIE = env.cookie || '' +var PLAYLIST_URL = env.playlist_url || '' +``` + +### 2.6 音质标识 + +| ID | 含义 | +|----|------| +| `standard` | 标准音质 (128k) | +| `exhigh` | 高品音质 (320k) | +| `lossless` | 无损音质 (FLAC) | +| `hires` | Hi-Res | +| `jyeffect` | 高清环绕声 | +| `sky` | 沉浸环绕声 | +| `jymaster` | 超清母带 | + +## 三、各平台 API + +### 3.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 音质参数**:`128k` / `320k` / `999k` + +### 3.2 酷狗音乐 (kg) + +**搜索接口**:`http://mobilecdn.kugou.com/api/v3/search/song` + +**注意**:返回字段是 `errcode`(不是 `error_code`) + +**封面图**:搜索结果自带 `imgurl`,替换 `{size}` 为 `400` + +### 3.3 酷我音乐 (kw) + +**搜索接口**:`http://search.kuwo.cn/r.s` + +**封面图**:`https://img2.kuwo.cn/star/albumcover/300/{ALBUMID}.jpg` + +**音质信息**:在 `N_MINFO` 字段中解析 + +### 3.4 网易云音乐 (wy) + +**搜索接口**:`https://music.163.com/api/search/get/web`(GET,不需要 weapi) + +**封面图**:`picId` 需 Base64 编码 + +```js +var picIdB64 = Buffer.from(String(s.album.picId)).toString('base64').replace(/=/g, '') +var pic = 'https://p2.music.126.net/' + picIdB64 + '/' + picIdStr + '.jpg' +``` + +**加密接口**: +- `eapi`:AES-128-ECB,key = `e82ckenh8dichen8` +- `weapi`:AES-128-CBC + RSA + +**Cookie 功能**(需设置 `cookie` 环境变量): +- `userPlaylist()` - 个人歌单(需 `playlist_url`) +- `dailyRecommend()` - 每日推荐 +- `personalFm()` - 私人FM +- `myLikedSongs()` - 我喜欢的音乐 +- `songList(id)` - 歌单详情 +- `album(id)` - 专辑详情 +- `getLyric(id)` - 歌词获取 + +### 3.5 咪咕音乐 (mg) + +**搜索签名**:MD5 拼接 + +```js +var sign = crypto.createHash('md5').update( + str + signatureMd5 + 'yyapp2d16148780a1dcc7408e06336b98cfd50' + deviceId + time +).digest('hex') +``` + +**封面图**:搜索结果可能返回相对路径,需拼接 `https://d.musicapp.migu.cn` + +### 3.6 GIT音源 (git) + +纯音源插件,无搜索功能,仅 `getUrl` + +## 四、getUrl 容灾机制 + +所有平台统一使用**并发测速**模式: + +```js +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 '' +}) +``` + +### 音源 API 列表 + +| API | 平台 | 说明 | +|-----|------|------| +| 聆澜 | 全部 | 需 `ceru_key`,最稳定 | +| HUIBQ | 全部 | `X-Request-Key: share-v3` | +| 星海 | 全部 | 聚合接口 | +| 念心 | tx/kg/kw/mg | 个人维护 | +| 长青 | tx/kg/kw/mg | 个人维护 | +| 星海备 | 全部 | 备用 | +| fish | 全部 | 个人维护 | +| HYW | 全部 | 需 `X-Card-Key` | +| 忆音 | tx | 直接返回 URL | +| 收集QQ | tx | QQ专用 | +| 收集KW | kw | 酷我专用 | +| bb | wy | 网易云专用 | +| ymc | wy | 网易云专用 | +| unms | wy | 网易云专用 | +| 官方 weapi | wy | 网易云官方 | + +## 五、版本管理 + +- 所有平台统一版本号 +- 当前版本:`0.0.4` +- 文件名格式:`Koneko_{平台名}_v{版本号}.js` + +## 六、常见问题 + +| 问题 | 原因 | 解决 | +|------|------|------| +| `Cannot find module 'axios'` | 用了 axios | 改用内置 `http`/`https` | +| `Field 'list' is required` | 返回 `songs` 而非 `list` | 改字段名为 `list` | +| 搜索无结果 | 字段名不匹配 | 检查 `errcode` vs `error_code` | +| 播放失败 | `mapBr` 返回格式不对 | tx/kg/kw/mg/git 用 `320k`,wy 用 `320000` | +| 封面图不显示 | URL 格式错误 | 检查各平台拼接规则 | + +## 七、相关链接 + +- Gitea: http://171.80.3.149:4321/miao-moe +- CeruMusic: http://171.80.3.149:4321/miao-moe/CeruMusic +- QZMusic PC: http://171.80.3.149:4321/miao-moe/QZMusic_PC diff --git a/Koneko_网易云音乐_v0.0.4.js b/Koneko_网易云音乐_v0.0.4.js new file mode 100644 index 0000000..ec6b056 --- /dev/null +++ b/Koneko_网易云音乐_v0.0.4.js @@ -0,0 +1,657 @@ +/** + * @name 网易云音乐 - Koneko + * @description 聚合音源插件: 官方搜索 + 多API音源容灾 + 完整Cookie功能 + * @version 0.0.4 + * @author Miao-moe + * + * 环境变量: + * ceru_key - 聆澜API密钥(可选) + * cookie - 网易云Cookie,用于搜索增强、每日推荐、私人FM、我喜欢的音乐 + * playlist_url - 网易云个人主页链接,用于获取个人歌单 + */ + +'use strict' +var https = require('https') +var http = require('http') +var crypto = require('crypto') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' +var WY_COOKIE = env.cookie || '' +var PLAYLIST_URL = env.playlist_url || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128000' + if (q === '320k' || q === 'exhigh') return '320000' + if (q === '999k' || q === 'lossless') return '999000' + if (q === 'hires') return '999000' + return '320000' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function httpPost(url, body, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var postData = typeof body === 'string' ? body : JSON.stringify(body) + var opts = { + method: 'POST', + headers: {}, + timeout: timeout || 10000 + } + if (headers) { + for (var k in headers) { opts.headers[k] = headers[k] } + } + opts.headers['Content-Type'] = 'application/x-www-form-urlencoded' + opts.headers['Content-Length'] = Buffer.byteLength(postData) + var req = mod.request(url, opts, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + req.write(postData) + req.end() + }) +} + +var EAPI_KEY = 'e82ckenh8dichen8' + +function aesEncryptEcb(text, key) { + var cipher = crypto.createCipheriv('aes-128-ecb', key, '') + cipher.setAutoPadding(true) + var encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + return encrypted +} + +function eapiEncrypt(url, text) { + var message = 'nobody' + url + 'use' + text + 'md5forencrypt' + var digest = crypto.createHash('md5').update(message).digest('hex') + var data = url + '-36cd479b6b5-' + text + '-36cd479b6b5-' + digest + return aesEncryptEcb(data, EAPI_KEY) +} + +function eapiRequest(url, params) { + var text = JSON.stringify(params) + var enc = eapiEncrypt(url, text) + var body = 'params=' + encodeURIComponent(enc) + var headers = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': 'https://music.163.com', + 'Cookie': WY_COOKIE || 'os=android; appver=8.9.0;' + } + return httpPost('https://music.163.com' + url, body, headers, 15000) +} + +var WEAPI_IV = '0102030405060708' +var WEAPI_PRESET_KEY = '0CoJUm6Qyw8W8jud' +var WEAPI_RSA_KEY = '010001' +var WEAPI_RSA_MODULUS = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7' +var WEAPI_BASE62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +function aesEncryptCbc(text, key, iv) { + var cipher = crypto.createCipheriv('aes-128-cbc', key, iv) + cipher.setAutoPadding(true) + var encrypted = cipher.update(text, 'utf8', 'base64') + encrypted += cipher.final('base64') + return encrypted +} + +function rsaEncrypt(text, exponent, modulus) { + var reversed = text.split('').reverse().join('') + var bigInt = BigInt('0x' + Buffer.from(reversed).toString('hex')) + var exp = BigInt('0x' + exponent) + var mod = BigInt('0x' + modulus) + var result = bigInt ** exp % mod + var hex = result.toString(16).padStart(256, '0') + return hex +} + +function weapiEncrypt(text) { + var secretKey = '' + for (var i = 0; i < 16; i++) { + secretKey += WEAPI_BASE62.charAt(Math.floor(Math.random() * 62)) + } + var firstEnc = aesEncryptCbc(text, WEAPI_PRESET_KEY, WEAPI_IV) + var secondEnc = aesEncryptCbc(firstEnc, secretKey, WEAPI_IV) + var rsa = rsaEncrypt(secretKey, WEAPI_RSA_KEY, WEAPI_RSA_MODULUS) + return { + params: secondEnc, + encSecKey: rsa + } +} + +function weapiRequest(url, params) { + var text = JSON.stringify(params) + var enc = weapiEncrypt(text) + var body = 'params=' + encodeURIComponent(enc.params) + '&encSecKey=' + encodeURIComponent(enc.encSecKey) + var headers = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': 'https://music.163.com', + 'Cookie': WY_COOKIE || 'os=android; appver=8.9.0;' + } + return httpPost('https://music.163.com' + url, body, headers, 15000) +} + +function formatPlayTime(ms) { + if (!ms || isNaN(ms)) return '--/--' + var totalSec = Math.floor(ms / 1000) + var m = Math.floor(totalSec / 60) + var s = totalSec % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +function wySearch(keyword, page, limit) { + if (!page) page = 1 + if (!limit) limit = 30 + var offset = (page - 1) * limit + var url = 'https://music.163.com/api/search/get/web?csrf_token=&hlposttag=&s=' + encodeURIComponent(keyword) + '&type=1&offset=' + offset + '&total=true&limit=' + limit + return httpGet(url, { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://music.163.com', + 'Cookie': WY_COOKIE || 'os=pc; appver=2.10.0;' + }, 10000).then(function(res) { + if (!res || !res.result || !res.result.songs) { + return { list: [], allPage: 1, limit: limit, total: 0, source: 'wy' } + } + var list = [] + for (var i = 0; i < res.result.songs.length; i++) { + var s = res.result.songs[i] + var artists = [] + if (s.artists) { + for (var j = 0; j < s.artists.length; j++) { + artists.push(s.artists[j].name) + } + } + var album = s.album ? s.album.name : '' + var albumId = s.album ? String(s.album.id) : '' + var pic = '' + if (s.album && s.album.picId) { + var picIdStr = String(s.album.picId) + var picIdB64 = Buffer.from(picIdStr).toString('base64').replace(/=/g, '') + pic = 'https://p2.music.126.net/' + picIdB64 + '/' + picIdStr + '.jpg' + } + list.push({ + id: s.id ? String(s.id) : '', + name: s.name || '', + artists: artists.join('、'), + albumName: album, + albumId: albumId, + source: 'wy', + pic: pic, + mPic: pic, + sPic: pic, + interval: formatPlayTime(s.duration), + qualities: {} + }) + } + var total = res.result.songCount || 0 + var allPage = Math.ceil(total / limit) + return { list: list, allPage: allPage, limit: limit, total: total, source: 'wy' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'wy' } + }) +} + +function wyTipSearch(keyword) { + var url = 'https://music.163.com/api/search/get/web?csrf_token=&hlposttag=&s=' + encodeURIComponent(keyword) + '&type=1&offset=0&total=true&limit=10' + return httpGet(url, { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://music.163.com', + 'Cookie': WY_COOKIE || 'os=pc; appver=2.10.0;' + }, 10000).then(function(res) { + if (!res || !res.result || !res.result.songs) return [] + var tips = [] + for (var i = 0; i < res.result.songs.length; i++) { + var s = res.result.songs[i] + tips.push(s.name + ' - ' + (s.artists && s.artists[0] ? s.artists[0].name : '未知')) + } + return tips + }).catch(function(e) { return [] }) +} + +function wyHotSearch() { + return weapiRequest('/weapi/search/hot', { type: 1111 }).then(function(res) { + if (!res || !res.result || !res.result.hots) return [] + var hots = [] + for (var i = 0; i < res.result.hots.length; i++) { + hots.push(res.result.hots[i].first) + } + return hots + }).catch(function(e) { return [] }) +} + +function wyOfficialUrl(songId, quality) { + var br = mapBr(quality) + var params = { + ids: '[' + songId + ']', + br: br, + csrf_token: '' + } + return weapiRequest('/weapi/song/enhance/player/url', params).then(function(res) { + if (res && res.data && res.data[0] && res.data[0].url) { + return { url: res.data[0].url, platform: 'wy' } + } + throw new Error('no url') + }) +} + +function ceruGetUrl(songId, quality) { + if (!CERU_KEY) return Promise.reject(new Error('no key')) + var br = mapBr(quality) + var url = 'https://ceruapi.lol/meting-api-0/?server=netease&type=url&id=' + encodeURIComponent(songId) + '&auth=' + encodeURIComponent(CERU_KEY) + '&br=' + br + return httpGet(url, HEADERS_COMMON, 10000).then(function(res) { + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function bbGetUrl(songId, quality) { + var br = mapBr(quality) + var url = 'https://api.bbdcz.cn/music/netease/url?id=' + encodeURIComponent(songId) + '&br=' + br + return httpGet(url, HEADERS_COMMON, 10000).then(function(res) { + if (res && res.data && res.data.url) return { url: res.data.url, platform: 'wy' } + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function lxGetUrl(songId, quality) { + var br = mapBr(quality) + var url = 'https://lxmusicapi.onrender.com/url/wy/' + encodeURIComponent(songId) + '/' + br + return httpGet(url, HEADERS_COMMON, 15000).then(function(res) { + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function ymcGetUrl(songId, quality) { + var br = mapBr(quality) + var url = 'https://api.ymusic.icu/netease/song?id=' + encodeURIComponent(songId) + '&quality=' + br + return httpGet(url, HEADERS_COMMON, 10000).then(function(res) { + if (res && res.data && res.data.url) return { url: res.data.url, platform: 'wy' } + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function unmsGetUrl(songId, quality) { + var br = mapBr(quality) + var url = 'https://unms.zeabur.app/netease/url?id=' + encodeURIComponent(songId) + '&br=' + br + return httpGet(url, HEADERS_COMMON, 10000).then(function(res) { + if (res && res.url) return { url: res.url, platform: 'wy' } + throw new Error('no url') + }) +} + +function getUrl(songId, quality) { + var apis = [] + apis.push({ name: 'ceru', fn: ceruGetUrl }) + apis.push({ name: 'bb', fn: bbGetUrl }) + apis.push({ name: 'lx', fn: lxGetUrl }) + apis.push({ name: 'ymc', fn: ymcGetUrl }) + apis.push({ name: 'unms', fn: unmsGetUrl }) + apis.push({ name: 'official', fn: wyOfficialUrl }) + + var promises = [] + for (var i = 0; i < apis.length; i++) { + var api = apis[i] + promises.push( + api.fn(songId, quality).then(function(result) { + return { status: 'fulfilled', value: result } + }).catch(function(err) { + return { status: 'rejected', reason: err } + }) + ) + } + + return new Promise(function(resolve, reject) { + Promise.all(promises).then(function(results) { + for (var i = 0; i < results.length; i++) { + if (results[i].status === 'fulfilled') { + resolve(results[i].value) + return + } + } + reject(new Error('all apis failed')) + }).catch(function(e) { + reject(e) + }) + }) +} + +function wyUserPlaylist() { + var uid = '' + var match = PLAYLIST_URL.match(/id=(\d+)/) + if (match) uid = match[1] + if (!uid) { + match = PLAYLIST_URL.match(/(\d+)/) + if (match) uid = match[1] + } + if (!uid) return Promise.resolve({ playlists: [] }) + var params = { uid: uid, limit: 1000, offset: 0 } + return weapiRequest('/weapi/user/playlist', params).then(function(res) { + if (!res || !res.playlist) return { playlists: [] } + var list = [] + for (var i = 0; i < res.playlist.length; i++) { + var p = res.playlist[i] + list.push({ + id: String(p.id), + name: p.name, + picUrl: p.coverImgUrl, + trackCount: p.trackCount, + description: p.description || '' + }) + } + return { playlists: list } + }).catch(function(e) { return { playlists: [] } }) +} + +function wyDailyRecommend() { + return weapiRequest('/weapi/v1/discovery/recommend/songs', {}).then(function(res) { + if (!res || !res.data || !res.data.dailySongs) return { songs: [] } + var songs = [] + for (var i = 0; i < res.data.dailySongs.length; i++) { + var s = res.data.dailySongs[i] + var artists = [] + if (s.ar) { + for (var j = 0; j < s.ar.length; j++) { + artists.push(s.ar[j].name) + } + } + songs.push({ + id: String(s.id), + name: s.name, + artist: artists.join('/'), + album: s.al ? s.al.name : '', + picUrl: s.al ? s.al.picUrl : '', + duration: s.dt || 0, + platform: 'wy' + }) + } + return { songs: songs } + }).catch(function(e) { return { songs: [] } }) +} + +function wyPersonalFm() { + return weapiRequest('/weapi/v1/radio/get', {}).then(function(res) { + if (!res || !res.data) return { songs: [] } + var songs = [] + for (var i = 0; i < res.data.length; i++) { + var s = res.data[i] + var artists = [] + if (s.ar) { + for (var j = 0; j < s.ar.length; j++) { + artists.push(s.ar[j].name) + } + } + songs.push({ + id: String(s.id), + name: s.name, + artist: artists.join('/'), + album: s.al ? s.al.name : '', + picUrl: s.al ? s.al.picUrl : '', + duration: s.dt || 0, + platform: 'wy' + }) + } + return { songs: songs } + }).catch(function(e) { return { songs: [] } }) +} + +function wyMyLikedSongs() { + return weapiRequest('/weapi/song/like/get', {}).then(function(res) { + if (!res || !res.data || !res.data.checkPoint) return { songs: [] } + var ids = res.data.checkPoint + if (!ids || ids.length === 0) return { songs: [] } + var idStr = '' + for (var i = 0; i < ids.length; i++) { + if (i > 0) idStr += ',' + idStr += ids[i] + } + var params = { ids: '[' + idStr + ']', csrf_token: '' } + return weapiRequest('/weapi/v3/song/detail', params).then(function(detail) { + if (!detail || !detail.songs) return { songs: [] } + var songs = [] + for (var i = 0; i < detail.songs.length; i++) { + var s = detail.songs[i] + var artists = [] + if (s.ar) { + for (var j = 0; j < s.ar.length; j++) { + artists.push(s.ar[j].name) + } + } + songs.push({ + id: String(s.id), + name: s.name, + artist: artists.join('/'), + album: s.al ? s.al.name : '', + picUrl: s.al ? s.al.picUrl : '', + duration: s.dt || 0, + platform: 'wy' + }) + } + return { songs: songs } + }) + }).catch(function(e) { return { songs: [] } }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + var params = { id: id, n: 100000, csrf_token: '' } + return weapiRequest('/weapi/v3/playlist/detail', params).then(function(res) { + if (!res || !res.playlist || !res.playlist.tracks) { + return { list: [], page: page || 1, limit: limit || 30, total: 0, source: 'wy', info: { name: '', img: '', desc: '', author: '' } } + } + var tracks = res.playlist.tracks + var list = [] + for (var i = 0; i < tracks.length; i++) { + var s = tracks[i] + var artists = [] + if (s.ar) { + for (var j = 0; j < s.ar.length; j++) { + artists.push(s.ar[j].name) + } + } + list.push({ + id: String(s.id), + name: s.name || '', + artists: artists.join('、'), + source: 'wy', + pic: s.al ? s.al.picUrl : '', + mPic: s.al ? s.al.picUrl : '', + sPic: s.al ? s.al.picUrl : '', + albumName: s.al ? s.al.name : '', + albumId: s.al ? String(s.al.id) : '', + interval: formatPlayTime(s.dt), + qualities: {} + }) + } + return { + list: list, + page: page || 1, + limit: limit || 30, + total: list.length, + source: 'wy', + info: { + name: res.playlist.name || '', + img: res.playlist.coverImgUrl || '', + desc: res.playlist.description || '', + author: res.playlist.creator ? res.playlist.creator.nickname : '' + } + } + }).catch(function(e) { + return { list: [], page: page || 1, limit: limit || 30, total: 0, source: 'wy', info: { name: '', img: '', desc: '', author: '' } } + }) + } +} + +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + var params = { id: id, csrf_token: '' } + return weapiRequest('/weapi/v1/album/' + id, params).then(function(res) { + if (!res || !res.album || !res.songs) { + return { list: [], page: 1, limit: 1000, total: 0, source: 'wy', info: { name: '', img: '', desc: '', author: '' } } + } + var list = [] + for (var i = 0; i < res.songs.length; i++) { + var s = res.songs[i] + var artists = [] + if (s.ar) { + for (var j = 0; j < s.ar.length; j++) { + artists.push(s.ar[j].name) + } + } + list.push({ + id: String(s.id), + name: s.name || '', + artists: artists.join('、'), + source: 'wy', + pic: res.album.picUrl || '', + mPic: res.album.picUrl || '', + sPic: res.album.picUrl || '', + albumName: res.album.name || '', + albumId: String(res.album.id || ''), + interval: formatPlayTime(s.dt), + qualities: {} + }) + } + return { + list: list, + page: 1, + limit: 1000, + total: list.length, + source: 'wy', + info: { + name: res.album.name || '', + img: res.album.picUrl || '', + desc: res.album.description || '', + author: res.album.artist ? res.album.artist.name : '' + } + } + }).catch(function(e) { + return { list: [], page: 1, limit: 1000, total: 0, source: 'wy', info: { name: '', img: '', desc: '', author: '' } } + }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { + var params = { id: id, lv: -1, tv: -1, rv: -1, kv: -1, csrf_token: '' } + return weapiRequest('/weapi/song/lyric', params).then(function(res) { + if (!res) return '' + return { + lrc: res.lrc ? res.lrc.lyric || '' : '', + krc: res.krc ? res.krc.lyric || '' : '', + translate: res.tlyric ? res.tlyric.lyric || '' : '' + } + }).catch(function(e) { return '' }) +} + +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var musicSearch = { + search: function(keyword, page, limit) { + return wySearch(keyword, page, limit).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'wy' } + }) + }, + tipSearch: function(keyword) { + return wyTipSearch(keyword).catch(function(e) { return [] }) + }, + hotSearch: function() { + return wyHotSearch().catch(function(e) { return [] }) + } +} + +var tipSearch = { + getList: function(str) { + return wyTipSearch(str).catch(function(e) { return [] }) + } +} + +var hotSearch = { + getList: function() { + return wyHotSearch().catch(function(e) { return [] }) + } +} + +var pluginInfo = { + info: { id: 'koneko_wy', name: '网易云音乐 - Koneko', version: '0.0.4', description: '网易云音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换,支持Cookie功能' }, + env: [ + { key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }, + { key: 'playlist_url', name: '个人主页链接', description: '网易云音乐个人主页链接,用于获取个人歌单' }, + { key: 'cookie', name: 'Cookie', description: '网易云音乐Cookie,用于每日推荐/私人FM/我喜欢的音乐/歌单/专辑/歌词等' } + ], + ext: [ + { name: '个人歌单', description: '通过分享链接获取个人歌单', entry: 'plugin.userPlaylist()', type: 'playlists' }, + { name: '每日推荐', description: '获取每日推荐歌曲', entry: 'plugin.dailyRecommend()', type: 'songs' }, + { name: '私人FM', description: '获取私人FM歌曲', entry: 'plugin.personalFm()', type: 'songs' }, + { name: '我喜欢的音乐', description: '获取我喜欢的音乐列表', entry: 'plugin.myLikedSongs()', type: 'songs' } + ], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' }, + { name: '高清环绕声', ui: 'DB', id: 'jyeffect' }, + { name: '沉浸环绕声', ui: 'SK', id: 'sky' }, + { name: '超清母带', ui: 'MT', id: 'jymaster' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo, + userPlaylist: function() { + return wyUserPlaylist().catch(function(e) { return { playlists: [] } }) + }, + dailyRecommend: function() { + return wyDailyRecommend().catch(function(e) { return { songs: [] } }) + }, + personalFm: function() { + return wyPersonalFm().catch(function(e) { return { songs: [] } }) + }, + myLikedSongs: function() { + return wyMyLikedSongs().catch(function(e) { return { songs: [] } }) + } +} diff --git a/Koneko_酷我音乐_v0.0.4.js b/Koneko_酷我音乐_v0.0.4.js new file mode 100644 index 0000000..34e4d6a --- /dev/null +++ b/Koneko_酷我音乐_v0.0.4.js @@ -0,0 +1,323 @@ +/** + * @name 酷我音乐 - Koneko + * @description 聚合音源插件: 官方搜索 + 多API音源容灾 + * @version 0.0.4 + * @author Miao-moe + * + * 环境变量: + * ceru_key - 聆澜API密钥(可选) + */ + +'use strict' +var https = require('https') +var http = require('http') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128k' + if (q === '320k' || q === 'exhigh') return '320k' + return '999k' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function formatPlayTime(seconds) { + if (!seconds || isNaN(seconds)) return '--/--' + var m = Math.floor(seconds / 60) + var s = seconds % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +var musicSearch = { + limit: 30, + total: 0, + page: 0, + allPage: 1, + search: function(str, page, limit, retryNum) { + var self = this + if (retryNum === undefined) retryNum = 0 + if (++retryNum > 3) return Promise.reject(new Error('搜索失败')) + if (!page) page = 1 + if (limit == null) limit = this.limit + return httpGet( + 'http://search.kuwo.cn/r.s?client=kt&all=' + encodeURIComponent(str) + '&pn=' + (page - 1) + '&rn=' + limit + '&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1', + HEADERS_COMMON + ).then(function(result) { + if (!result || !result.abslist || result.abslist.length === 0) { + return self.search(str, page, limit, retryNum) + } + var list = [] + for (var i = 0; i < result.abslist.length; i++) { + var info = result.abslist[i] + var songId = (info.MUSICRID || '').replace('MUSIC_', '') + var qualities = {} + if (info.N_MINFO) { + 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] + } + } + } + var picUrl = '' + if (info.ALBUMID) { + picUrl = 'https://img2.kuwo.cn/star/albumcover/300/' + info.ALBUMID + '.jpg' + } else { + picUrl = 'http://artistpicserver.kuwo.cn/pic.web?corp=kuwo&type=rid_pic&pictype=500&size=500&rid=' + songId + } + var artistStr = '' + if (info.ARTIST) artistStr = info.ARTIST.replace(/&/g, '、') + var duration = parseInt(info.DURATION) + list.push({ + id: String(songId), + name: String(info.SONGNAME || ''), + artists: artistStr, + source: 'kw', + pic: picUrl, + mPic: picUrl, + sPic: picUrl, + albumName: String(info.ALBUM || ''), + albumId: String(info.ALBUMID || ''), + interval: isNaN(duration) ? '--/--' : formatPlayTime(duration), + qualities: qualities + }) + } + self.total = parseInt(result.TOTAL) || 0 + self.page = page + self.allPage = Math.ceil(self.total / limit) + return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'kw' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'kw' } + }) + } +} + +var tipSearch = { + getList: function(str) { + return httpGet( + 'https://tips.kuwo.cn/t.s?corp=kuwo&newver=3&p2p=1¬race=0&c=mbox&w=' + encodeURIComponent(str) + '&encoding=utf8&rformat=json', + { 'User-Agent': HEADERS_COMMON['User-Agent'], Referer: 'http://www.kuwo.cn/' } + ).then(function(body) { + if (!body || !body.abs) return [] + var result = { order: [], songs: [] } + if (body.abs.length > 0) result.order.push('songs') + result.songs = [] + for (var i = 0; i < body.abs.length; i++) { + result.songs.push({ name: body.abs[i].name, artist: { name: body.abs[i].artist } }) + } + return result + }).catch(function(e) { return [] }) + } +} + +var hotSearch = { + getList: function() { + return httpGet( + 'http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2&vipver=9.3.0.1&source=kwplayer_ar_9.3.0.1_40.apk&p2p=1¬race=0&uid=0&plat=kwplayer_ar&rformat=json&encoding=utf8&tabid=1', + { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9;)' } + ).then(function(body) { + if (!body || !body.data) return [] + var list = [] + for (var i = 0; i < body.data.length; i++) { + list.push(body.data[i].keyword || body.data[i].searchWord || body.data[i].name || '') + } + return list + }).catch(function(e) { return [] }) + } +} + +function buildApis(songId, q) { + var br = mapBr(q) + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=kw&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push( + { + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/kw/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }, + { + name: '星海', + url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=kuwo&id=' + songId + '&br=' + br, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '收集KW', + url: 'https://kw-api.cenguigui.cn/api/song/url?id=' + songId + '&quality=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '念心', + url: 'https://music.nxinxz.com/kgqq/kw.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '长青', + url: 'https://musicapi.haitangw.net/music/kw.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '星海备', + url: 'https://music-dl.sayqz.com/api/?source=kuwo&id=' + songId + '&type=url&br=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'fish', + url: 'https://m-api.ceseet.me/url/kw/' + songId + '/' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'HYW', + url: 'https://music.bxa241d4.shop/api/music/url?source=kw&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' }, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.code === 200 && res.data && res.data.url) return res.data.url + return null + } + } + ) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko 酷我音乐] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko 酷我音乐] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko 酷我音乐] 所有API均失败') + return '' + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'kw', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'kw', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_kw', name: '酷我音乐 - Koneko', version: '0.0.4', description: '酷我音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} diff --git a/Koneko_酷狗音乐_v0.0.4.js b/Koneko_酷狗音乐_v0.0.4.js new file mode 100644 index 0000000..537e783 --- /dev/null +++ b/Koneko_酷狗音乐_v0.0.4.js @@ -0,0 +1,305 @@ +/** + * @name 酷狗音乐 - Koneko + * @description 聚合音源插件: 官方搜索 + 多API音源容灾 + * @version 0.0.4 + * @author Miao-moe + * + * 环境变量: + * ceru_key - 聆澜API密钥(可选) + */ + +'use strict' +var https = require('https') +var http = require('http') + +var env = global.env || {} +var CERU_KEY = env.ceru_key || '' + +var HEADERS_COMMON = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +function mapBr(q) { + if (q === '128k' || q === 'standard') return '128k' + if (q === '320k' || q === 'exhigh') return '320k' + return '999k' +} + +function httpGet(url, headers, timeout) { + return new Promise(function(resolve, reject) { + var mod = url.indexOf('https') === 0 ? https : http + var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) { + var data = '' + res.on('data', function(chunk) { data += chunk }) + res.on('end', function() { + try { resolve(JSON.parse(data)) } catch(e) { resolve(data) } + }) + }) + req.on('error', function(err) { reject(err) }) + req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) }) + }) +} + +function formatPlayTime(seconds) { + if (!seconds || isNaN(seconds)) return '--/--' + var m = Math.floor(seconds / 60) + var s = seconds % 60 + return m + ':' + (s < 10 ? '0' : '') + s +} + +var musicSearch = { + limit: 30, + total: 0, + page: 0, + allPage: 1, + search: function(str, page, limit, retryNum) { + var self = this + if (retryNum === undefined) retryNum = 0 + if (++retryNum > 3) return Promise.reject(new Error('搜索失败')) + if (!page) page = 1 + if (limit == null) limit = this.limit + return httpGet( + 'http://mobilecdn.kugou.com/api/v3/search/song?format=json&keyword=' + encodeURIComponent(str) + '&page=' + page + '&pagesize=' + limit, + HEADERS_COMMON + ).then(function(result) { + if (!result || typeof result !== 'object' || result.errcode !== 0 || !result.data || !result.data.info) { + return self.search(str, page, limit, retryNum) + } + var list = [] + for (var i = 0; i < result.data.info.length; i++) { + var item = result.data.info[i] + var picUrl = '' + if (item.imgurl && item.imgurl.indexOf('http') === 0) { + picUrl = item.imgurl.replace('{size}', '400') + } + if (!picUrl && item.album_img) { + picUrl = item.album_img + } + list.push({ + id: String(item.hash || ''), + name: String(item.songname || item.song_name || ''), + artists: String(item.singername || item.singer_name || ''), + albumName: String(item.album_name || ''), + albumId: String(item.album_id || ''), + source: 'kg', + interval: String(formatPlayTime(item.duration) || '--/--'), + pic: picUrl, + mPic: picUrl, + sPic: picUrl, + qualities: {} + }) + } + self.total = result.data.total + self.page = page + self.allPage = Math.ceil(self.total / limit) + return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'kg' } + }).catch(function(e) { + return { list: [], allPage: 1, limit: 30, total: 0, source: 'kg' } + }) + } +} + +var tipSearch = { + getList: function(str) { + return httpGet( + 'https://searchtip.kugou.com/getSearchTip?MusicTipCount=10&keyword=' + encodeURIComponent(str), + { 'User-Agent': HEADERS_COMMON['User-Agent'], referer: 'https://www.kugou.com/' } + ).then(function(body) { + if (!body || !body.data) return [] + var result = { order: [], songs: [], artists: [], albums: [] } + if (body.data.songs && body.data.songs.length > 0) result.order.push('songs') + if (body.data.artists && body.data.artists.length > 0) result.order.push('artists') + result.songs = [] + if (body.data.songs) { + for (var i = 0; i < body.data.songs.length; i++) { + result.songs.push({ name: body.data.songs[i].name, artist: { name: body.data.songs[i].artist } }) + } + } + result.artists = [] + if (body.data.artists) { + for (var i = 0; i < body.data.artists.length; i++) { + result.artists.push({ name: body.data.artists[i].name }) + } + } + return result + }).catch(function(e) { return [] }) + } +} + +var hotSearch = { + getList: function() { + return httpGet( + 'http://gateway.kugou.com/api/v3/search/hot_tab?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0', + HEADERS_COMMON + ).then(function(body) { + if (!body || body.error_code !== 0 || !body.data) return [] + var list = [] + for (var i = 0; i < body.data.length; i++) { + list.push(body.data[i].keyword || body.data[i].searchword || '') + } + return list + }).catch(function(e) { return [] }) + } +} + +function buildApis(songId, q) { + var br = mapBr(q) + var apis = [] + if (CERU_KEY) { + apis.push({ + name: '聆澜', + url: 'https://source.shiqianjiang.cn/api/music/url?source=kg&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY }, + extract: function(res) { return res && res.code === 200 && res.url ? res.url : null } + }) + } + apis.push( + { + name: 'HUIBQ', + url: 'https://lxmusicapi.onrender.com/url/kg/' + songId + '/' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' }, + extract: function(res) { return res && res.code === 0 && res.url ? res.url : null } + }, + { + name: '星海', + url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=kugou&id=' + songId + '&br=' + br, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '念心', + url: 'https://music.nxinxz.com/kgqq/kg.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '长青', + url: 'https://music.haitangw.cc/kgqq/kg.php?id=' + songId + '&level=' + q + '&type=mp3', + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: '星海备', + url: 'https://music-dl.sayqz.com/api/?source=kugou&id=' + songId + '&type=url&br=' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'fish', + url: 'https://m-api.ceseet.me/url/kg/' + songId + '/' + q, + headers: HEADERS_COMMON, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.data && res.data.url) return res.data.url + return null + } + }, + { + name: 'HYW', + url: 'https://music.bxa241d4.shop/api/music/url?source=kg&songId=' + songId + '&quality=' + q, + headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' }, + extract: function(res) { + if (res && res.url) return res.url + if (res && res.code === 200 && res.data && res.data.url) return res.data.url + return null + } + } + ) + return apis +} + +function getUrl(songId, quality) { + var q = quality || '320k' + var apis = buildApis(songId, q) + 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) { + console.log('[Koneko 酷狗音乐] ' + api.name + ' 成功') + return { name: api.name, url: url } + } + throw new Error(api.name + ' 无有效URL') + }).catch(function(err) { + console.error('[Koneko 酷狗音乐] ' + api.name + ' 失败: ' + err.message) + 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 + } + console.error('[Koneko 酷狗音乐] 所有API均失败') + return '' + }) +} + +var leaderboard = { getList: function() { return Promise.resolve([]) } } +var songList = { + getListDetail: function(id, page, limit) { + return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'kg', info: { name: '', img: '', desc: '', author: '' } }) + } +} +var singer = { getInfo: function(id) { return Promise.resolve(null) } } +var album = { + getListDetail: function(id) { + return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'kg', info: { name: '', img: '', desc: '', author: '' } }) + }, + search: function(str, page, limit) { return Promise.resolve([]) } +} + +function getLyric(id) { return Promise.resolve('') } +function getPic(songId) { return Promise.resolve('') } +function musicDetail(id) { return Promise.resolve(null) } +function musicInfo(id) { return Promise.resolve(null) } + +var pluginInfo = { + info: { id: 'koneko_kg', name: '酷狗音乐 - Koneko', version: '0.0.4', description: '酷狗音乐聚合音源插件,官方搜索+多API音源,自动测速容灾切换' }, + env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥,留空则跳过聆澜音源' }], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: 'standard' }, + { name: '高品音质', ui: 'HQ', id: 'exhigh' }, + { name: '无损音质', ui: 'SQ', id: 'lossless' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [] +} + +module.exports = { + musicSearch: musicSearch, + tipSearch: tipSearch, + leaderboard: leaderboard, + songList: songList, + hotSearch: hotSearch, + singer: singer, + album: album, + getLyric: getLyric, + getPic: getPic, + getUrl: getUrl, + musicDetail: musicDetail, + musicInfo: musicInfo, + pluginInfo: pluginInfo +} diff --git a/Koneko插件开发避坑指南_v0.0.4.md b/Koneko插件开发避坑指南_v0.0.4.md new file mode 100644 index 0000000..dba8f36 --- /dev/null +++ b/Koneko插件开发避坑指南_v0.0.4.md @@ -0,0 +1,146 @@ +# Koneko QZ Music v2/v3 插件开发避坑指南 + +> 版本: 0.0.4 | 作者: 云汀(Miao-moe) | 目标: 支持迁移到其他 AI 继续开发 + +--- + +## 一、项目背景 + +QZ Music v2/v3 是一款 Android/PC 音乐播放器,支持通过**拓展插件**接入多平台音源。插件系统基于 Node.js 运行时(Javet/V8),每个插件是一个单独的 `.js` 文件,通过 `module.exports` 导出接口。 + +### 插件加载机制 + +- 运行时环境变量通过 `global.env` 访问,**不是** `process.env` +- 插件为单 `.js` 文件格式 + +## 二、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` | +| `class` | ❌ | 对象字面量 | +| 模板字符串 `${}` | ✅ | 可用 | +| `Buffer` | ✅ | Node.js 内置 | + +### Promise.allSettled 替代方案 + +```js +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`! + +```js +// ✅ 正确 +return { list: [...], allPage: N, limit: N, total: N, source: 'tx' } + +// ❌ 错误 +return { songs: [...], total: N } +``` + +### 字段名对照 + +| 含义 | 正确字段 | 错误字段 | +|------|---------|---------| +| 歌手 | `artists` | `artist` | +| 封面图 | `pic`/`mPic`/`sPic` | `picUrl` | +| 时长 | `interval` (m:ss) | `duration` | +| 列表 | `list` | `songs` | + +## 四、各平台踩坑点 + +### QQ音乐 (tx) +- 搜索签名:`zzcSign` = SHA1 + 索引提取 + XOR + base64 +- 封面图:有专辑ID用 T002,无专辑ID用 T001 +- getUrl 音质参数:带 `k` (128k/320k/999k) + +### 酷狗音乐 (kg) +- 搜索返回值字段是 `errcode`(不是 `error_code`) +- 封面图:替换 `{size}` 为 `400` + +### 酷我音乐 (kw) +- 音质信息在 `N_MINFO` 字段解析 +- 封面图:`https://img2.kuwo.cn/star/albumcover/300/{ALBUMID}.jpg` + +### 网易云音乐 (wy) +- 搜索用 GET:`/api/search/get/web`,不需要 weapi +- 封面图:`picId` 需 Base64 编码 +- getUrl 音质参数:数字格式 `128000`/`320000`/`999000` +- Cookie 用于 weapi 加密接口(每日推荐、私人FM、喜欢歌曲、歌单、专辑、歌词) + +### 咪咕音乐 (mg) +- 搜索需 MD5 签名 +- 封面图:相对路径需拼接 `https://d.musicapp.migu.cn` + +### GIT音源 (git) +- 无搜索,纯音源 +- getUrl 音质参数:`128k`/`320k`/`999k` + +## 五、getUrl 容灾逻辑 + +所有平台使用**并发测速**模式:同时请求多个 API,取第一个成功结果。 + +API 调用顺序: +1. 聆澜(需 `ceru_key` 环境变量) +2. HUIBQ (lxmusicapi) +3. 星海 / 忆音 / 念心 / 长青 / 星海备 / fish / HYW + +网易云额外有: +4. bb / lx / ymc / unms / 官方 weapi + +## 六、环境变量说明 + +| key | 用途 | 适用平台 | 必填 | +|-----|------|---------|------| +| `ceru_key` | 聆澜音源 API 密钥 | 全部 | 否 | +| `playlist_url` | 网易云个人主页链接 | 网易云 | 否 | +| `cookie` | 网易云 Cookie | 网易云 | 否 | + +## 七、版本管理 + +- 所有平台统一版本号 +- 当前版本:**0.0.4** +- 文件名格式:`Koneko_{平台名}_v{版本号}.js` + +## 八、常见报错 + +| 报错 | 原因 | 解决 | +|------|------|------| +| `Cannot find module 'axios'` | 用了 axios | 改用 `http`/`https` 内置模块 | +| `Field 'list' is required` | 搜索返回 `songs` 而非 `list` | 改字段名为 `list` | +| `SyntaxError: Invalid or unexpected token` | 用了 `catch { }` | 改为 `catch (e) { }` | +| 搜索无结果 | 字段名不匹配 | 检查 `errcode` vs `error_code` | +| 播放失败 | `mapBr` 返回格式不对 | QQ/kg/kw/mg/git 用 `320k`,wy 用 `320000` | +| 封面图不显示 | URL 格式错误或跨域 | 检查各平台封面图拼接规则 | + +## 九、后续开发建议 + +1. 每次修改全部平台统一升级版本号 +2. 先在浏览器/curl 测试 API 是否可用 +3. 注意 Javet 兼容性,避免现代 JS 语法 +4. 搜索返回务必包含 `list` 字段 +5. getUrl 注意各平台音质参数格式差异 +6. 所有异步操作加 `.catch()` 兜底 +7. 优先使用官方搜索接口,音源用第三方 API 容灾 diff --git a/QZMusicV2 插件规范│QZ-Music-Plugin-Development-Guide.md b/QZMusicV2 插件规范│QZ-Music-Plugin-Development-Guide.md new file mode 100644 index 0000000..2cd9608 --- /dev/null +++ b/QZMusicV2 插件规范│QZ-Music-Plugin-Development-Guide.md @@ -0,0 +1,856 @@ +# QZ Music 插件开发帮助文档 + +## 目录 +1. [概述](#概述) +2. [核心设计原则](#核心设计原则) +3. [从 LX Music 迁移指南](#从-lx-music-迁移指南) +4. [插件基本结构](#插件基本结构) +5. [数据格式规范](#数据格式规范) +6. [示例代码详解](#示例代码详解) +7. [平台配置参考](#平台配置参考) +8. [常见问题](#常见问题) + +--- + +## 概述 + +本文档用于指导开发者编写 QZ Music 音源插件。QZ Music 使用 Node.js 运行时环境,插件采用 CommonJS 模块规范,通过 `module.exports` 导出功能接口。 + +### 与 LX Music 的区别 + +| 特性 | QZ Music | LX Music | +|------|----------|----------| +| 运行时 | Node.js | JavaScript 运行时 | +| 模块规范 | CommonJS | 全局事件监听 | +| 导出方式 | `module.exports` | `send(EVENT_NAMES.inited)` | +| 通信方式 | 直接函数调用 | 事件驱动 | +| HTTP 请求 | `axios` 或内置 `httpFetch` | `globalThis.lx.request` | + +### 插件类型 + +QZ Music 插件属于**完整音源插件**,核心功能包括: +- 搜索歌曲 +- 获取音频播放 URL +- 获取歌词 +- 获取歌单/专辑信息 +- 获取热搜/排行榜 + +--- + +## 核心设计原则 + +### 1. 单平台原则(重要) + +**每个插件只支持一个音乐平台**,例如: +- ✅ 网易云音乐插件(仅支持 wy) +- ✅ QQ音乐插件(仅支持 tx) +- ❌ 聚合插件(同时支持 wy + tx + kw) + +**原因说明**: +``` +用户向插件传配置只能通过环境变量(env) +如果插件支持多平台,切换平台时需要修改环境变量,操作繁琐 +单平台插件更清晰,维护成本低,不易出现"代码屎山" +``` + +**如需多平台支持**:建议自建后端服务,统一处理搜索和 URL 获取,前端插件只作为代理。 + +### 2. 配置方式 + +通过 `process.env` 或插件内置的 `env` 配置读取环境变量: + +```javascript +// 读取用户配置的 API 密钥 +const API_KEY = process.env.API_KEY || '' + +// 读取自定义服务端地址 +const CUSTOM_SERVER = process.env.SERVER_URL || '默认地址' +``` + +### 3. 支持的音质标识 + +| 标识 | 说明 | +|------|------| +| `128k` | 标准音质,MP3 格式 | +| `320k` | 高品音质,MP3 格式 | +| `flac` | 无损音质,16bit FLAC | +| `flac24bit` | 无损音质,24bit FLAC | +| `hires` | 高解析度无损 | + +--- + +## 从 LX Music 迁移指南 + +### 迁移对照表 + +| LX Music | QZ Music | 说明 | +|----------|----------|------| +| `globalThis.lx` | `require` 模块 | 不再需要全局对象 | +| `globalThis.lx.request` | `axios` 或 `httpFetch` | 使用标准 HTTP 库 | +| `globalThis.lx.env` | `process.env` | 环境变量读取方式 | +| `on(EVENT_NAMES.request, ...)` | 直接导出函数 | 改为函数导出 | +| `send(EVENT_NAMES.inited, ...)` | `module.exports` | 改为模块导出 | +| `musicInfo.songmid` | `musicInfo.id` | 字段名可能不同 | +| `info.type` | `quality` 参数 | 音质参数位置 | + +### 迁移步骤 + +#### 步骤 1:修改模块导入 + +**LX Music 原代码:** +```javascript +const { EVENT_NAMES, request, on, send, env, version } = globalThis.lx +``` + +**QZ Music 新代码:** +```javascript +const axios = require('axios') +const crypto = require('crypto') + +// 环境变量 +const API_KEY = process.env.API_KEY || '' +``` + +#### 步骤 2:修改 HTTP 请求 + +**LX Music 原代码:** +```javascript +function httpRequest(url) { + return new Promise((resolve, reject) => { + request(url, { headers }, (err, resp) => { + if (err) return reject(err) + resolve(resp.body) + }) + }) +} +``` + +**QZ Music 新代码:** +```javascript +async function httpRequest(url, options = {}) { + const response = await axios({ + url, + method: options.method || 'GET', + headers: options.headers, + timeout: options.timeout || 10000 + }) + return response.data +} +``` + +#### 步骤 3:修改函数导出 + +**LX Music 原代码:** +```javascript +// 事件监听方式 +on(EVENT_NAMES.request, ({ action, source, info }) => { + switch (action) { + case 'musicUrl': + return getMusicUrl(info.musicInfo, info.type) + } +}) + +// 初始化事件 +send(EVENT_NAMES.inited, { + status: true, + sources: musicSource +}) +``` + +**QZ Music 新代码:** +```javascript +// 直接导出函数 +module.exports = { + // 搜索功能 + musicSearch, + + // 获取音频 URL + getUrl, + + // 获取歌词 + getLyric, + + // 获取歌单 + songList, + + // 获取专辑 + album, + + // 获取热搜 + hotSearch, + + // 插件信息 + pluginInfo: { + info: { id: 'wy', name: '网易云', version: '3' }, + quality: [...], + supportFunc: [...] + } +} +``` + +#### 步骤 4:修改返回数据格式 + +**LX Music 原代码:** +```javascript +// 直接返回 URL 字符串 +return 'https://example.com/music.mp3' + +// 或返回歌词对象 +return { + lyric: '[00:00.000]歌词内容', + tlyric: '[00:00.000]翻译歌词' +} +``` + +**QZ Music 新代码:** +```javascript +// 搜索返回统一格式 +return { + list: [{ + id: '123456', + name: '歌曲名', + artists: '歌手名', + source: 'wy', + pic: '封面URL', + mPic: '中封面URL', + sPic: '小封面URL', + albumName: '专辑名', + albumId: '专辑ID', + interval: '03:45', + qualities: { '128k': '4.2M', 'flac': '35M' } + }], + total: 100, + page: 1, + limit: 20, + allPage: 5, + source: 'wy' +} + +// 歌词返回格式 +return { + lyric: '歌词内容', + tlyric: '翻译歌词' +} +``` + +--- + +## 插件基本结构 + +### 文件头部注释 + +```javascript +/** + * @name 网易云音乐源 + * @description QZ Music 音源插件 + * @version 3.0.0 + * @author 开发者 + * @homepage https://github.com/your-repo + * @license MIT + * + * 支持平台: 网易云音乐 (wy) + * 支持音质: 128k, 320k, flac, flac24bit, hires + */ +``` + +### 核心导入 + +```javascript +'use strict' + +// 标准 Node.js 模块 +const axios = require('axios') +const crypto = require('crypto') +``` + +### 配置区域 + +```javascript +// ========== 用户可配置区域 ========== + +// 服务端地址(用户可通过环境变量覆盖) +const API_BASE = process.env.SERVER_URL || 'https://your-server.com' + +// API 密钥(用户通过环境变量设置) +const API_KEY = process.env.API_KEY || '' + +// 当前平台标识(单平台插件固定值) +const PLATFORM = 'wy' // wy: 网易云, tx: QQ音乐, kw: 酷我, kg: 酷狗, mg: 咪咕 + +// 支持的音质列表 +const SUPPORT_QUALITIES = ['128k', '320k', 'flac', 'flac24bit', 'hires'] +``` + +### 导出结构 + +```javascript +// ========== 插件导出 ========== + +module.exports = { + // 核心功能(必须实现) + musicSearch, // 歌曲搜索 + getUrl, // 获取音频 URL + + // 可选功能 + getLyric, // 获取歌词 + songList, // 歌单详情 + album, // 专辑详情 + hotSearch, // 热搜词 + tipSearch, // 搜索提示 + leaderboard, // 排行榜 + + // 插件信息(必须) + pluginInfo: { + info: { + id: 'wy', // 平台标识 + name: '网易云', // 显示名称 + description: '网易云音乐插件', // 描述 + version: '3' // 版本号 + }, + env: [ // 环境变量配置 + { key: 'API_KEY', name: 'API密钥', description: '服务端API密钥' } + ], + ext: [], // 扩展功能 + quality: [ // 支持的音质 + { name: '标准音质', ui: '标', id: '128k' }, + { name: '高品音质', ui: 'HQ', id: '320k' }, + { name: '无损音质', ui: 'SQ', id: 'flac' }, + { name: 'Hi-Res', ui: 'HR', id: 'hires' } + ], + supportFunc: [ // 支持的功能 + 'search_song', + 'search_playlist', + 'playlist', + 'album', + 'lyric' + ] + } +} +``` + +--- + +## 数据格式规范 + +### 搜索结果统一格式 + +```javascript +{ + list: [ + { + id: String, // 歌曲唯一标识 + name: String, // 歌曲名(已解码 HTML 实体) + artists: String, // 歌手名(用 "、" 分隔) + source: String, // 平台标识: 'wy'/'tx'/'kw'/'kg'/'mg' + pic: String, // 大封面图 URL(500x500) + mPic: String, // 中封面图 URL(300x300) + sPic: String, // 小封面图 URL(150x150) + albumName: String, // 专辑名 + albumId: String, // 专辑 ID + interval: String, // 时长 "mm:ss" + qualities: { // 音质 -> 文件大小 + '128k': '4.2M', + '320k': '8.5M', + 'flac': '35M', + 'hires': '68M' + } + } + ], + total: Number, // 总数量 + page: Number, // 当前页码 + limit: Number, // 每页数量 + allPage: Number, // 总页数 + source: String // 平台标识 +} +``` + +### 歌词统一格式 + +```javascript +{ + lyric: String, // 普通歌词 (LRC 格式) + tlyric: String, // 翻译歌词 + qrc: String, // 逐字歌词 (QRC 格式) + roma: String // 音译歌词 +} + +// 注意:至少返回 lyric 或 qrc 之一 +``` + +### 歌单详情统一格式 + +```javascript +{ + list: [/* 歌曲列表,格式同搜索结果 */], + page: Number, + limit: Number, + total: Number, + source: String, + info: { + name: String, // 歌单名 + img: String, // 封面图 + desc: String, // 描述 + author: String // 作者 + } +} +``` + +### 专辑详情统一格式 + +```javascript +{ + list: [/* 歌曲列表,格式同搜索结果 */], + page: Number, + limit: Number, + total: Number, + source: String, + info: { + name: String, // 专辑名 + img: String, // 封面图 + desc: String, // 描述 + author: String // 艺术家 + } +} +``` + +--- + +## 示例代码详解 + +### 完整插件模板 + +```javascript +/** + * @name 网易云音乐源 + * @description QZ Music 音源插件示例 + * @version 3.0.0 + * @author 开发者 + * + * 支持平台: 网易云音乐 (wy) + * 支持音质: 128k, 320k, flac + */ + +'use strict' + +// ==================== 核心导入 ==================== + +const axios = require('axios') +const crypto = require('crypto') + +// ==================== 配置区域 ==================== + +// 平台标识(固定值,单平台插件) +const PLATFORM = 'wy' + +// 服务端配置 +const CONFIG = { + serverUrl: process.env.SERVER_URL || 'https://api.example.com', + apiKey: process.env.API_KEY || '', + timeout: 10000 +} + +// 支持的音质 +const SUPPORT_QUALITIES = ['128k', '320k', 'flac'] + +// ==================== 工具函数 ==================== + +/** + * 文件大小格式化 + * @param {number} size - 字节数 + * @returns {string} 格式化后的字符串 + */ +function sizeFormate(size) { + if (!size || isNaN(size)) return '' + if (size > 104857600) return (size / 104857600).toFixed(1) + 'MB' + if (size > 1048576) return (size / 1048576).toFixed(1) + 'MB' + if (size > 1024) return (size / 1024).toFixed(1) + 'KB' + return size + 'B' +} + +/** + * 播放时间格式化 + * @param {number} time - 秒数 + * @returns {string} 格式化后的字符串 + */ +function formatPlayTime(time) { + if (!time || isNaN(time)) return '--/--' + const m = Math.floor(time / 60) + const s = Math.floor(time % 60) + return m + ':' + s.toString().padStart(2, '0') +} + +/** + * 歌手名称格式化 + * @param {Array} singerList - 歌手列表 + * @returns {string} 用 "、" 连接的歌手名 + */ +function formatSingerName(singerList) { + if (!singerList || !Array.isArray(singerList)) return '' + return singerList.map(s => s.name || s).join('、') +} + +/** + * HTML 实体解码 + * @param {string} str - 含 HTML 实体的字符串 + * @returns {string} 解码后的字符串 + */ +function decodeName(str) { + if (!str) return '' + return str + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/ /g, ' ') +} + +// ==================== 核心功能 ==================== + +/** + * 搜索歌曲 + * @param {string} str - 搜索关键词 + * @param {number} page - 页码,从 1 开始 + * @param {number} limit - 每页数量 + * @returns {Promise} 搜索结果 + */ +async function musicSearch(str, page = 1, limit = 20) { + // 构造请求参数 + const params = { + keyword: str, + page: page, + limit: limit + } + + // 发送请求 + const url = `${CONFIG.serverUrl}/search` + const response = await axios.get(url, { + params, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + // 解析数据 + const data = response.data + + // 转换为统一格式 + const list = data.songs.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatSingerName(item.artists), + source: PLATFORM, + pic: item.picUrl || '', + mPic: item.picUrl || '', + sPic: item.picUrl || '', + albumName: decodeName(item.album?.name || ''), + albumId: String(item.album?.id || ''), + interval: formatPlayTime(item.duration), + qualities: { + '128k': sizeFormate(item.size128), + '320k': sizeFormate(item.size320), + 'flac': sizeFormate(item.sizeFlac) + } + })) + + return { + list, + total: data.total, + page: page, + limit: limit, + allPage: Math.ceil(data.total / limit), + source: PLATFORM + } +} + +/** + * 获取音乐播放 URL + * @param {string} songId - 歌曲 ID + * @param {string} quality - 音质标识 + * @returns {Promise} 播放 URL + */ +async function getUrl(songId, quality) { + // 检查音质 + if (!SUPPORT_QUALITIES.includes(quality)) { + quality = SUPPORT_QUALITIES[0] + } + + // 发送请求 + const url = `${CONFIG.serverUrl}/music/url` + const response = await axios.get(url, { + params: { id: songId, quality: quality }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + if (!data.url || !data.url.startsWith('http')) { + throw new Error('获取链接失败') + } + + return data.url +} + +/** + * 获取歌词 + * @param {string} songId - 歌曲 ID + * @returns {Promise} 歌词对象 + */ +async function getLyric(songId) { + const url = `${CONFIG.serverUrl}/music/lyric` + const response = await axios.get(url, { + params: { id: songId }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + return { + lyric: data.lyric || '', + tlyric: data.tlyric || '' + } +} + +/** + * 获取歌单详情 + * @param {string} id - 歌单 ID + * @param {number} page - 页码 + * @param {number} limit - 每页数量 + * @returns {Promise} 歌单详情 + */ +async function songList(id, page = 1, limit = 20) { + const url = `${CONFIG.serverUrl}/playlist` + const response = await axios.get(url, { + params: { id, page, limit }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + // 转换歌曲列表 + const list = data.songs.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatSingerName(item.artists), + source: PLATFORM, + pic: item.picUrl || '', + mPic: item.picUrl || '', + sPic: item.picUrl || '', + albumName: decodeName(item.album?.name || ''), + albumId: String(item.album?.id || ''), + interval: formatPlayTime(item.duration), + qualities: {} + })) + + return { + list, + page, + limit, + total: data.total, + source: PLATFORM, + info: { + name: data.name || '', + img: data.cover || '', + desc: data.description || '', + author: data.creator?.name || '' + } + } +} + +/** + * 获取专辑详情 + * @param {string} id - 专辑 ID + * @param {number} page - 页码 + * @returns {Promise} 专辑详情 + */ +async function album(id, page = 1) { + const url = `${CONFIG.serverUrl}/album` + const response = await axios.get(url, { + params: { id, page }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + // 转换歌曲列表 + const list = data.songs.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatSingerName(item.artists), + source: PLATFORM, + pic: item.picUrl || data.cover || '', + mPic: item.picUrl || data.cover || '', + sPic: item.picUrl || data.cover || '', + albumName: decodeName(data.name || ''), + albumId: String(id), + interval: formatPlayTime(item.duration), + qualities: {} + })) + + return { + list, + page, + limit: 1000, + total: data.total, + source: PLATFORM, + info: { + name: data.name || '', + img: data.cover || '', + desc: data.description || '', + author: data.artist?.name || '' + } + } +} + +/** + * 获取热搜词 + * @returns {Promise} 热搜列表 + */ +async function hotSearch() { + const url = `${CONFIG.serverUrl}/hotsearch` + const response = await axios.get(url, { + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + return { + source: PLATFORM, + list: response.data.list || [] + } +} + +// ==================== 插件导出 ==================== + +module.exports = { + // 核心功能 + musicSearch, + getUrl, + getLyric, + songList, + album, + hotSearch, + + // 插件信息 + pluginInfo: { + info: { + id: PLATFORM, + name: '网易云', + description: '网易云音乐插件', + version: '3' + }, + env: [ + { key: 'SERVER_URL', name: '服务端地址', description: '自定义服务端地址' }, + { key: 'API_KEY', name: 'API密钥', description: '服务端API密钥' } + ], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: '128k' }, + { name: '高品音质', ui: 'HQ', id: '320k' }, + { name: '无损音质', ui: 'SQ', id: 'flac' } + ], + supportFunc: ['search_song', 'search_playlist', 'playlist', 'album', 'lyric'] + } +} +``` + +--- + +## 平台配置参考 + +### 各平台标识对照表 + +| 平台 | 标识 | 常见歌曲 ID 字段 | +|------|------|-----------------| +| 网易云音乐 | `wy` | `id`, `songmid` | +| QQ音乐 | `tx` | `songmid`, `id` | +| 酷我音乐 | `kw` | `rid`, `id`, `hash` | +| 酷狗音乐 | `kg` | `hash`, `id` | +| 咪咕音乐 | `mg` | `copyrightId`, `id` | +| 汽水音乐 | `qx` | `id` | + +### 音质支持参考 + +```javascript +// 各平台常见音质配置 +const PLATFORM_QUALITIES = { + wy: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + tx: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + kw: ['128k', '320k', 'flac', 'flac24bit'], + kg: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + mg: ['128k', '320k', 'flac', 'flac24bit'], + qx: ['128k', '320k', 'flac'] +} +``` + +--- + +## 常见问题 + +### Q1: 如何从 LX Music 迁移到 QZ Music? + +**A**: 参考本文档的"从 LX Music 迁移指南"章节,主要修改点: +1. 将 `globalThis.lx.request` 改为 `axios` +2. 将事件监听改为函数导出 +3. 修改数据返回格式 +4. 添加 `module.exports` 导出 + +### Q2: 插件加载失败怎么办? + +**A**: 检查以下几点: +1. 文件语法是否正确:`node -c your-plugin.js` +2. `pluginInfo` 是否完整 +3. 导出的函数名是否正确 +4. 依赖模块是否已安装(如 `axios`) + +### Q3: 搜索返回空结果? + +**A**: 检查: +1. API 请求是否成功(查看日志) +2. 响应数据解析是否正确 +3. 数据格式是否符合规范 +4. 返回的 `source` 是否与平台标识一致 + +### Q4: 音频无法播放? + +**A**: 检查: +1. `getUrl` 返回的 URL 是否有效 +2. URL 是否以 `http` 开头 +3. 音质标识是否正确 +4. 是否有跨域或权限问题 + +### Q5: 用户如何配置插件? + +**A**: 用户通过 QZ Music 的设置界面配置环境变量: + +``` +SERVER_URL = 自定义服务端地址 +API_KEY = 用户的API密钥 +``` + +插件通过 `process.env` 读取: + +```javascript +const SERVER_URL = process.env.SERVER_URL || '默认地址' +``` + +--- + +## 附录 + +### 参考资源 + +- QZ Music 官方文档 +- 提供的音源文件(lx&qz官方部分音源) +- Node.js 官方文档 +- axios 文档 + +### 版本历史 + +- v1.0.0 (2024-05-28): 初始版本 + +--- + +**文档结束** diff --git a/QZ_Music-V2 插件规范(v1.0.3).md b/QZ_Music-V2 插件规范(v1.0.3).md new file mode 100644 index 0000000..056bd9f --- /dev/null +++ b/QZ_Music-V2 插件规范(v1.0.3).md @@ -0,0 +1,862 @@ +# 插件开发帮助文档 + +> **版本**: v1.0.3 +> +> 撰写人: 蜻蜓的好朋友 : Miao-moe +> GitHub: [Miao-moe](https://github.com/Miao-moe) + +## 目录 +1. [概述](#概述) +2. [核心设计原则](#核心设计原则) +3. [从其他平台迁移指南](#从其他平台迁移指南) +4. [插件基本结构](#插件基本结构) +5. [数据格式规范](#数据格式规范) +6. [示例代码详解](#示例代码详解) +7. [平台配置参考](#平台配置参考) +8. [开发建议](#开发建议) +9. [常见问题](#常见问题) + +--- + +## 概述 + +本文档用于指导开发者编写音源插件。本系统使用 Node.js 运行时环境,插件采用 CommonJS 模块规范,通过 `module.exports` 导出功能接口。 + +### 与其他系统的区别 + +| 特性 | 本系统 | 其他系统 | +|------|--------|----------| +| 运行时 | Node.js | JavaScript 运行时 | +| 模块规范 | CommonJS | 全局事件监听 | +| 导出方式 | `module.exports` | 事件发送 | +| 通信方式 | 直接函数调用 | 事件驱动 | +| HTTP 请求 | `axios` 或内置 `httpFetch` | 全局请求对象 | + +### 插件类型 + +本系统插件属于**完整音源插件**,核心功能包括: +- 搜索内容 +- 获取播放 URL +- 获取歌词 +- 获取列表/合集信息 +- 获取热搜/排行榜 + +--- + +## 核心设计原则 + +### 1. 单平台原则(重要) + +**每个插件只支持一个平台**,例如: +- ✅ 平台A插件(仅支持 A) +- ✅ 平台B插件(仅支持 B) +- ❌ 聚合插件(同时支持 A + B + C) + +**原因说明**: +``` +用户向插件传配置只能通过环境变量(env) +如果插件支持多平台,切换平台时需要修改环境变量,操作繁琐 +单平台插件更清晰,维护成本低,不易出现"代码屎山" +``` + +**如需多平台支持**:建议自建后端服务,统一处理搜索和 URL 获取,前端插件只作为代理。 + +### 2. 配置方式 + +通过 `global.env` 读取环境变量(JSON 格式): + +```javascript +// 读取用户配置的 API 密钥 +const env = global.env || {} +const API_KEY = env.API_KEY || '' + +// 读取自定义服务端地址 +const CUSTOM_SERVER = env.SERVER_URL || '默认地址' +``` + +**环境变量加载方式**: +```javascript +const plugin = require('./index.js') +global.env = $envCommand // 由系统注入,格式为 JSON + +// 在插件代码中通过 global.env 读取 +const env = global.env || {} +const API_KEY = env.API_KEY || '' +``` + +### 3. 支持的音质标识 + +| 标识 | 说明 | +|------|------| +| `128k` | 标准音质 | +| `320k` | 高品音质 | +| `flac` | 无损音质 | +| `flac24bit` | 高解析度无损 | +| `hires` | 超高解析度 | + +--- + +## 从其他平台迁移指南 + +### 迁移对照表 + +| 其他系统 | 本系统 | 说明 | +|----------|--------|------| +| `globalThis.lx` | `require` 模块 | 不再需要全局对象 | +| `globalThis.lx.request` | `axios` 或 `httpFetch` | 使用标准 HTTP 库 | +| `globalThis.lx.env` | `global.env` | 环境变量读取方式 | +| `on(EVENT_NAMES.request, ...)` | 直接导出函数 | 改为函数导出 | +| `send(EVENT_NAMES.inited, ...)` | `module.exports` | 改为模块导出 | +| `musicInfo.songmid` | `musicInfo.id` | 字段名可能不同 | +| `info.type` | `quality` 参数 | 音质参数位置 | + +### 迁移步骤 + +#### 步骤 1:修改模块导入 + +**其他系统原代码:** +```javascript +const { EVENT_NAMES, request, on, send, env, version } = globalThis.lx +``` + +**本系统新代码:** +```javascript +const axios = require('axios') +const crypto = require('crypto') + +// 环境变量从 global.env 读取 +const env = global.env || {} +const API_KEY = env.API_KEY || '' +``` + +#### 步骤 2:修改 HTTP 请求 + +**其他系统原代码:** +```javascript +function httpRequest(url) { + return new Promise((resolve, reject) => { + request(url, { headers }, (err, resp) => { + if (err) return reject(err) + resolve(resp.body) + }) + }) +} +``` + +**本系统新代码:** +```javascript +async function httpRequest(url, options = {}) { + const response = await axios({ + url, + method: options.method || 'GET', + headers: options.headers, + timeout: options.timeout || 10000 + }) + return response.data +} +``` + +#### 步骤 3:修改函数导出 + +**其他系统原代码:** +```javascript +// 事件监听方式 +on(EVENT_NAMES.request, ({ action, source, info }) => { + switch (action) { + case 'musicUrl': + return getMusicUrl(info.musicInfo, info.type) + } +}) + +// 初始化事件 +send(EVENT_NAMES.inited, { + status: true, + sources: musicSource +}) +``` + +**本系统新代码:** +```javascript +// 直接导出函数 +module.exports = { + // 搜索功能 + musicSearch, + + // 获取 URL + getUrl, + + // 获取歌词 + getLyric, + + // 获取列表 + songList, + + // 获取合集 + album, + + // 获取热搜 + hotSearch, + + // 插件信息 + pluginInfo: { + info: { id: 'A', name: '平台A', version: '3' }, + quality: [...], + supportFunc: [...] + } +} +``` + +#### 步骤 4:修改返回数据格式 + +**其他系统原代码:** +```javascript +// 直接返回 URL 字符串 +return 'https://example.com/audio.mp3' + +// 或返回歌词对象(其他系统格式) +return { + lyric: '[00:00.000]歌词内容', + tlyric: '[00:00.000]翻译歌词' +} +``` + +**本系统新代码:** +```javascript +// 搜索返回统一格式 +return { + list: [{ + id: '123456', + name: '内容名', + artists: '创作者', + source: 'A', + pic: '封面URL', + mPic: '中封面URL', + sPic: '小封面URL', + albumName: '合集名', + albumId: '合集ID', + interval: '03:45', + qualities: { '128k': '4.2M', 'flac': '35M' } + }], + total: 100, + page: 1, + limit: 20, + allPage: 5, + source: 'A' +} + +// 歌词返回格式(见下文歌词格式规范) +// 系统支持多种歌词格式,返回有的即可 +return { + lrc: 'LRC格式歌词', + qrc: 'QRC逐字歌词', + krc: 'KRC歌词', + ttml: 'TTML歌词', + translate: '翻译歌词' +} + +// 或直接返回歌词文本字符串(系统会自动判断格式) +return '[00:00.000]歌词内容\n[00:05.000]第二行歌词...' +``` + +--- + +## 插件基本结构 + +### 核心导入 + +```javascript +'use strict' + +// 标准 Node.js 模块 +const axios = require('axios') +const crypto = require('crypto') +``` + +### 配置区域 + +```javascript +// ========== 用户可配置区域 ========== + +// 从 global.env 读取环境变量(JSON 格式) +const env = global.env || {} + +// 服务端地址(用户可通过环境变量覆盖) +const API_BASE = env.SERVER_URL || 'https://your-server.com' + +// API 密钥(用户通过环境变量设置) +const API_KEY = env.API_KEY || '' + +// 当前平台标识(单平台插件固定值) +const PLATFORM = 'A' + +// 支持的音质列表 +const SUPPORT_QUALITIES = ['128k', '320k', 'flac'] +``` + +### 导出结构 + +```javascript +// ========== 插件导出 ========== + +module.exports = { + // 核心功能(必须实现) + musicSearch, // 内容搜索 + getUrl, // 获取 URL + + // 可选功能 + getLyric, // 获取歌词 + songList, // 列表详情 + album, // 合集详情 + hotSearch, // 热搜词 + tipSearch, // 搜索提示 + leaderboard, // 排行榜 + + // 插件信息(必须) + pluginInfo: { + info: { + id: 'A', // 平台标识 + name: '平台A', // 显示名称 + description: '平台A插件', // 描述 + version: '3' // 版本号 + }, + env: [ // 环境变量配置 + { key: 'API_KEY', name: 'API密钥', description: '服务端API密钥' } + ], + ext: [], // 扩展功能 + quality: [ // 支持的音质 + { name: '标准音质', ui: '标', id: '128k' }, + { name: '高品音质', ui: 'HQ', id: '320k' }, + { name: '无损音质', ui: 'SQ', id: 'flac' } + ], + supportFunc: [ // 支持的功能 + 'search_song', + 'search_playlist', + 'playlist', + 'album', + 'lyric' + ] + } +} +``` + +--- + +## 数据格式规范 + +### 搜索结果统一格式 + +```javascript +{ + list: [ + { + id: String, // 唯一标识 + name: String, // 名称 + artists: String, // 创作者(用 "、" 分隔) + source: String, // 平台标识 + pic: String, // 大封面 URL + mPic: String, // 中封面 URL + sPic: String, // 小封面 URL + albumName: String, // 合集名 + albumId: String, // 合集 ID + interval: String, // 时长 "mm:ss" + qualities: { // 音质 -> 文件大小 + '128k': '4.2M', + '320k': '8.5M', + 'flac': '35M' + } + } + ], + total: Number, // 总数量 + page: Number, // 当前页码 + limit: Number, // 每页数量 + allPage: Number, // 总页数 + source: String // 平台标识 +} +``` + +### 歌词返回格式 + +**重要说明**:系统会自动判断歌词格式(lrc、qrc、krc、ttml 等),插件只需返回对应的歌词字段即可。 + +```javascript +// 推荐返回格式 +{ + lrc: String, // LRC 格式歌词 + qrc: String, // QRC 逐字歌词 + krc: String, // KRC 歌词 + ttml: String, // TTML 歌词 + translate: String // 翻译歌词 +} + +// 或者直接返回歌词文本字符串(系统会自动判断格式) +return '[00:00.000]歌词内容\n[00:05.000]第二行歌词...' +``` + +**字段说明**: + +| 字段 | 说明 | 格式 | +|------|------|------| +| `lrc` | 标准 LRC 歌词 | `[mm:ss.ms]歌词内容` | +| `qrc` | 逐字歌词 | QRC 格式(Base64 编码) | +| `krc` | KRC 歌词 | KRC 格式 | +| `ttml` | TTML 歌词 | TTML/XML 格式 | +| `translate` | 翻译歌词 | LRC 格式或纯文本 | + +**注意事项**: +- 以上字段均为可选,返回有的即可 +- 系统会自动识别歌词格式 +- `translate` 用于翻译歌词 +- 直接返回歌词文本字符串也是允许的,系统会尝试自动判断其格式 +- 不同平台可能返回不同格式,如:平台A可能返回 `qrc` + `translate`,平台B可能返回 `lrc` + `tlyric` + +### 列表详情统一格式 + +```javascript +{ + list: [/* 内容列表,格式同搜索结果 */], + page: Number, + limit: Number, + total: Number, + source: String, + info: { + name: String, // 列表名 + img: String, // 封面图 + desc: String, // 描述 + author: String // 创建者 + } +} +``` + +### 合集详情统一格式 + +```javascript +{ + list: [/* 内容列表,格式同搜索结果 */], + page: Number, + limit: Number, + total: Number, + source: String, + info: { + name: String, // 合集名 + img: String, // 封面图 + desc: String, // 描述 + author: String // 创作者 + } +} +``` + +--- + +## 示例代码详解 + +### 完整插件模板 + +```javascript +'use strict' + +// ==================== 核心导入 ==================== + +const axios = require('axios') +const crypto = require('crypto') + +// ==================== 配置区域 ==================== + +// 从 global.env 读取环境变量(JSON 格式) +const env = global.env || {} + +// 平台标识(固定值,单平台插件) +const PLATFORM = 'A' + +// 服务端配置 +const CONFIG = { + serverUrl: env.SERVER_URL || 'https://api.example.com', + apiKey: env.API_KEY || '', + timeout: 10000 +} + +// 支持的音质 +const SUPPORT_QUALITIES = ['128k', '320k', 'flac'] + +// ==================== 工具函数 ==================== + +function sizeFormate(size) { + if (!size || isNaN(size)) return '' + if (size > 104857600) return (size / 104857600).toFixed(1) + 'MB' + if (size > 1048576) return (size / 1048576).toFixed(1) + 'MB' + if (size > 1024) return (size / 1024).toFixed(1) + 'KB' + return size + 'B' +} + +function formatPlayTime(time) { + if (!time || isNaN(time)) return '--/--' + const m = Math.floor(time / 60) + const s = Math.floor(time % 60) + return m + ':' + s.toString().padStart(2, '0') +} + +function formatArtistName(artistList) { + if (!artistList || !Array.isArray(artistList)) return '' + return artistList.map(a => a.name || a).join('、') +} + +function decodeName(str) { + if (!str) return '' + return str + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/ /g, ' ') +} + +// ==================== 核心功能 ==================== + +async function musicSearch(str, page = 1, limit = 20) { + const params = { keyword: str, page, limit } + + const url = CONFIG.serverUrl + '/search' + const response = await axios.get(url, { + params, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + const list = data.items.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatArtistName(item.artists), + source: PLATFORM, + pic: item.picUrl || '', + mPic: item.picUrl || '', + sPic: item.picUrl || '', + albumName: decodeName(item.album?.name || ''), + albumId: String(item.album?.id || ''), + interval: formatPlayTime(item.duration), + qualities: { + '128k': sizeFormate(item.size128), + '320k': sizeFormate(item.size320), + 'flac': sizeFormate(item.sizeFlac) + } + })) + + return { + list, + total: data.total, + page, + limit, + allPage: Math.ceil(data.total / limit), + source: PLATFORM + } +} + +async function getUrl(id, quality) { + if (!SUPPORT_QUALITIES.includes(quality)) { + quality = SUPPORT_QUALITIES[0] + } + + const url = CONFIG.serverUrl + '/url' + const response = await axios.get(url, { + params: { id, quality }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + if (!data.url || !data.url.startsWith('http')) { + throw new Error('获取链接失败') + } + + return data.url +} + +async function getLyric(id) { + const url = CONFIG.serverUrl + '/lyric' + const response = await axios.get(url, { + params: { id }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + // 返回歌词对象,系统会自动判断格式 + // 返回有的字段即可,不需要全部字段 + return { + lrc: data.lrc || '', + qrc: data.qrc || '', + krc: data.krc || '', + ttml: data.ttml || '', + translate: data.translate || '' + } + + // 或者直接返回歌词文本 + // return data.lyric +} + +async function songList(id, page = 1, limit = 20) { + const url = CONFIG.serverUrl + '/list' + const response = await axios.get(url, { + params: { id, page, limit }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + const list = data.items.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatArtistName(item.artists), + source: PLATFORM, + pic: item.picUrl || '', + mPic: item.picUrl || '', + sPic: item.picUrl || '', + albumName: decodeName(item.album?.name || ''), + albumId: String(item.album?.id || ''), + interval: formatPlayTime(item.duration), + qualities: {} + })) + + return { + list, + page, + limit, + total: data.total, + source: PLATFORM, + info: { + name: data.name || '', + img: data.cover || '', + desc: data.description || '', + author: data.creator?.name || '' + } + } +} + +async function album(id, page = 1) { + const url = CONFIG.serverUrl + '/album' + const response = await axios.get(url, { + params: { id, page }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + const list = data.items.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatArtistName(item.artists), + source: PLATFORM, + pic: item.picUrl || data.cover || '', + mPic: item.picUrl || data.cover || '', + sPic: item.picUrl || data.cover || '', + albumName: decodeName(data.name || ''), + albumId: String(id), + interval: formatPlayTime(item.duration), + qualities: {} + })) + + return { + list, + page, + limit: 1000, + total: data.total, + source: PLATFORM, + info: { + name: data.name || '', + img: data.cover || '', + desc: data.description || '', + author: data.artist?.name || '' + } + } +} + +async function hotSearch() { + const url = CONFIG.serverUrl + '/hotsearch' + const response = await axios.get(url, { + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + return { + source: PLATFORM, + list: response.data.list || [] + } +} + +// ==================== 插件导出 ==================== + +module.exports = { + musicSearch, + getUrl, + getLyric, + songList, + album, + hotSearch, + + pluginInfo: { + info: { + id: PLATFORM, + name: '平台A', + description: '平台A插件', + version: '3' + }, + env: [ + { key: 'SERVER_URL', name: '服务端地址', description: '自定义服务端地址' }, + { key: 'API_KEY', name: 'API密钥', description: '服务端API密钥' } + ], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: '128k' }, + { name: '高品音质', ui: 'HQ', id: '320k' }, + { name: '无损音质', ui: 'SQ', id: 'flac' } + ], + supportFunc: ['search_song', 'search_playlist', 'playlist', 'album', 'lyric'] + } +} +``` + +--- + +## 平台配置参考 + +### 各平台标识对照表 + +| 平台 | 标识 | 常见 ID 字段 | +|------|------|-------------| +| 平台A | `A` | `id` | +| 平台B | `B` | `id` | +| 平台C | `C` | `id`, `hash` | +| 平台D | `D` | `hash`, `id` | +| 平台E | `E` | `id` | +| 平台F | `F` | `id` | + +### 音质支持参考 + +```javascript +const PLATFORM_QUALITIES = { + A: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + B: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + C: ['128k', '320k', 'flac', 'flac24bit'], + D: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + E: ['128k', '320k', 'flac', 'flac24bit'], + F: ['128k', '320k', 'flac'] +} +``` + +--- + +## 开发建议 + +### 多文件开发 + +**推荐使用多文件开发模式**,将不同功能模块分离,防止代码堆叠: + +``` +plugin/ +├── index.js # 主入口,导出模块 +├── search.js # 搜索相关功能 +├── lyric.js # 歌词相关功能 +├── playlist.js # 列表相关功能 +├── utils.js # 工具函数 +└── config.js # 配置常量 +``` + +### 使用 ncc 打包 + +开发完成后,使用 `ncc` 将多文件打包为单文件即可: + +```bash +# 安装 ncc +npm install -g @vercel/ncc + +# 打包 +ncc build index.js -o dist + +# 输出的 dist/index.js 即为最终插件文件 +``` + +**ncc 打包优点**: +- 将多文件合并为单文件,便于分发 +- 自动处理依赖关系 +- 保持 CommonJS 兼容性 + +--- + +## 常见问题 + +### Q1: 如何从其他系统迁移到本系统? + +**A**: 参考本文档的"从其他平台迁移指南"章节,主要修改点: +1. 将 `globalThis.lx.request` 改为 `axios` +2. 将事件监听改为函数导出 +3. 修改数据返回格式 +4. 添加 `module.exports` 导出 +5. 环境变量从 `global.env` 读取 + +### Q2: 插件加载失败怎么办? + +**A**: 检查以下几点: +1. 文件语法是否正确:`node -c your-plugin.js` +2. `pluginInfo` 是否完整 +3. 导出的函数名是否正确 +4. 依赖模块是否已安装(如 `axios`) + +### Q3: 搜索返回空结果? + +**A**: 检查: +1. API 请求是否成功(查看日志) +2. 响应数据解析是否正确 +3. 数据格式是否符合规范 +4. 返回的 `source` 是否与平台标识一致 + +### Q4: 播放失败? + +**A**: 检查: +1. `getUrl` 返回的 URL 是否有效 +2. URL 是否以 `http` 开头 +3. 音质标识是否正确 +4. 是否有跨域或权限问题 + +### Q5: 用户如何配置插件? + +**A**: 用户通过设置界面配置环境变量: + +``` +SERVER_URL = 自定义服务端地址 +API_KEY = 用户的API密钥 +``` + +插件通过 `global.env` 读取: + +```javascript +const env = global.env || {} +const SERVER_URL = env.SERVER_URL || '默认地址' +``` + +### Q6: 歌词格式应该返回什么? + +**A**: 返回包含 `lrc`、`qrc`、`krc`、`ttml`、`translate` 等字段的对象,或直接返回歌词文本字符串。系统会自动判断歌词格式。 + +--- + +## 附录 + +### 参考资源 + +- Node.js 官方文档 +- axios 文档 +- @vercel/ncc 打包工具 + +### 版本历史 + +- v1.0.3: 当前版本 +- v1.0.0: 初始版本 + +--- + +**文档结束** diff --git a/QZ_Music-V2 插件规范(初版)│QZ_Music-V2-Plugin-Development-Guide.md b/QZ_Music-V2 插件规范(初版)│QZ_Music-V2-Plugin-Development-Guide.md new file mode 100644 index 0000000..4b0a3e8 --- /dev/null +++ b/QZ_Music-V2 插件规范(初版)│QZ_Music-V2-Plugin-Development-Guide.md @@ -0,0 +1,871 @@ +# 插件开发帮助文档 + +## 目录 +1. [概述](#概述) +2. [核心设计原则](#核心设计原则) +3. [从其他平台迁移指南](#从其他平台迁移指南) +4. [插件基本结构](#插件基本结构) +5. [数据格式规范](#数据格式规范) +6. [示例代码详解](#示例代码详解) +7. [平台配置参考](#平台配置参考) +8. [常见问题](#常见问题) + +--- + +## 概述 + +本文档用于指导开发者编写音源插件。本系统使用 Node.js 运行时环境,插件采用 CommonJS 模块规范,通过 `module.exports` 导出功能接口。 + +### 与其他系统的区别 + +| 特性 | 本系统 | 其他系统 | +|------|--------|----------| +| 运行时 | Node.js | JavaScript 运行时 | +| 模块规范 | CommonJS | 全局事件监听 | +| 导出方式 | `module.exports` | 事件发送 | +| 通信方式 | 直接函数调用 | 事件驱动 | +| HTTP 请求 | `axios` 或内置 `httpFetch` | 全局请求对象 | + +### 插件类型 + +本系统插件属于**完整音源插件**,核心功能包括: +- 搜索内容 +- 获取播放 URL +- 获取歌词 +- 获取列表/合集信息 +- 获取热搜/排行榜 + +--- + +## 核心设计原则 + +### 1. 单平台原则(重要) + +**每个插件只支持一个平台**,例如: +- ✅ 平台A插件(仅支持 A) +- ✅ 平台B插件(仅支持 B) +- ❌ 聚合插件(同时支持 A + B + C) + +**原因说明**: +``` +用户向插件传配置只能通过环境变量(env) +如果插件支持多平台,切换平台时需要修改环境变量,操作繁琐 +单平台插件更清晰,维护成本低,不易出现"代码屎山" +``` + +**如需多平台支持**:建议自建后端服务,统一处理搜索和 URL 获取,前端插件只作为代理。 + +### 2. 配置方式 + +通过 `global.env` 读取环境变量(JSON 格式): + +```javascript +// 读取用户配置的 API 密钥 +const API_KEY = global.env.API_KEY || '' + +// 读取自定义服务端地址 +const CUSTOM_SERVER = global.env.SERVER_URL || '默认地址' +``` + +**环境变量加载方式**: +```javascript +const plugin = require('./index.js') +global.env = $envCommand // 由系统注入,格式为 JSON + +// 在插件代码中通过 global.env 读取 +const env = global.env || {} +const API_KEY = env.API_KEY || '' +``` + +### 3. 支持的音质标识 + +| 标识 | 说明 | +|------|------| +| `128k` | 标准音质 | +| `320k` | 高品音质 | +| `flac` | 无损音质 | +| `flac24bit` | 高解析度无损 | +| `hires` | 超高解析度 | + +--- + +## 从其他平台迁移指南 + +### 迁移对照表 + +| 其他系统 | 本系统 | 说明 | +|----------|--------|------| +| `globalThis.lx` | `require` 模块 | 不再需要全局对象 | +| `globalThis.lx.request` | `axios` 或 `httpFetch` | 使用标准 HTTP 库 | +| `globalThis.lx.env` | `global.env` | 环境变量读取方式 | +| `on(EVENT_NAMES.request, ...)` | 直接导出函数 | 改为函数导出 | +| `send(EVENT_NAMES.inited, ...)` | `module.exports` | 改为模块导出 | +| `musicInfo.songmid` | `musicInfo.id` | 字段名可能不同 | +| `info.type` | `quality` 参数 | 音质参数位置 | + +### 迁移步骤 + +#### 步骤 1:修改模块导入 + +**其他系统原代码:** +```javascript +const { EVENT_NAMES, request, on, send, env, version } = globalThis.lx +``` + +**本系统新代码:** +```javascript +const axios = require('axios') +const crypto = require('crypto') + +// 环境变量从 global.env 读取 +const env = global.env || {} +const API_KEY = env.API_KEY || '' +``` + +#### 步骤 2:修改 HTTP 请求 + +**其他系统原代码:** +```javascript +function httpRequest(url) { + return new Promise((resolve, reject) => { + request(url, { headers }, (err, resp) => { + if (err) return reject(err) + resolve(resp.body) + }) + }) +} +``` + +**本系统新代码:** +```javascript +async function httpRequest(url, options = {}) { + const response = await axios({ + url, + method: options.method || 'GET', + headers: options.headers, + timeout: options.timeout || 10000 + }) + return response.data +} +``` + +#### 步骤 3:修改函数导出 + +**其他系统原代码:** +```javascript +// 事件监听方式 +on(EVENT_NAMES.request, ({ action, source, info }) => { + switch (action) { + case 'musicUrl': + return getMusicUrl(info.musicInfo, info.type) + } +}) + +// 初始化事件 +send(EVENT_NAMES.inited, { + status: true, + sources: musicSource +}) +``` + +**本系统新代码:** +```javascript +// 直接导出函数 +module.exports = { + // 搜索功能 + musicSearch, + + // 获取 URL + getUrl, + + // 获取歌词 + getLyric, + + // 获取列表 + songList, + + // 获取合集 + album, + + // 获取热搜 + hotSearch, + + // 插件信息 + pluginInfo: { + info: { id: 'A', name: '平台A', version: '3' }, + quality: [...], + supportFunc: [...] + } +} +``` + +#### 步骤 4:修改返回数据格式 + +**其他系统原代码:** +```javascript +// 直接返回 URL 字符串 +return 'https://example.com/audio.mp3' + +// 或返回歌词对象 +return { + lyric: '[00:00.000]歌词内容', + tlyric: '[00:00.000]翻译歌词' +} +``` + +**本系统新代码:** +```javascript +// 搜索返回统一格式 +return { + list: [{ + id: '123456', + name: '内容名', + artists: '创作者', + source: 'A', + pic: '封面URL', + mPic: '中封面URL', + sPic: '小封面URL', + albumName: '合集名', + albumId: '合集ID', + interval: '03:45', + qualities: { '128k': '4.2M', 'flac': '35M' } + }], + total: 100, + page: 1, + limit: 20, + allPage: 5, + source: 'A' +} + +// 歌词返回格式 +return { + lyric: '歌词内容', + tlyric: '翻译歌词' +} +``` + +--- + +## 插件基本结构 + +### 文件头部注释 + +```javascript +/** + * @name 平台A音源 + * @description 音源插件 + * @version 3.0.0 + * @author 开发者 + * @homepage https://github.com/your-repo + * @license MIT + * + * 支持平台: 平台A + * 支持音质: 128k, 320k, flac + */ +``` + +### 核心导入 + +```javascript +'use strict' + +// 标准 Node.js 模块 +const axios = require('axios') +const crypto = require('crypto') +``` + +### 配置区域 + +```javascript +// ========== 用户可配置区域 ========== + +// 从 global.env 读取环境变量(JSON 格式) +const env = global.env || {} + +// 服务端地址(用户可通过环境变量覆盖) +const API_BASE = env.SERVER_URL || 'https://your-server.com' + +// API 密钥(用户通过环境变量设置) +const API_KEY = env.API_KEY || '' + +// 当前平台标识(单平台插件固定值) +const PLATFORM = 'A' + +// 支持的音质列表 +const SUPPORT_QUALITIES = ['128k', '320k', 'flac'] +``` + +### 导出结构 + +```javascript +// ========== 插件导出 ========== + +module.exports = { + // 核心功能(必须实现) + musicSearch, // 内容搜索 + getUrl, // 获取 URL + + // 可选功能 + getLyric, // 获取歌词 + songList, // 列表详情 + album, // 合集详情 + hotSearch, // 热搜词 + tipSearch, // 搜索提示 + leaderboard, // 排行榜 + + // 插件信息(必须) + pluginInfo: { + info: { + id: 'A', // 平台标识 + name: '平台A', // 显示名称 + description: '平台A插件', // 描述 + version: '3' // 版本号 + }, + env: [ // 环境变量配置 + { key: 'API_KEY', name: 'API密钥', description: '服务端API密钥' } + ], + ext: [], // 扩展功能 + quality: [ // 支持的音质 + { name: '标准音质', ui: '标', id: '128k' }, + { name: '高品音质', ui: 'HQ', id: '320k' }, + { name: '无损音质', ui: 'SQ', id: 'flac' } + ], + supportFunc: [ // 支持的功能 + 'search_song', + 'search_playlist', + 'playlist', + 'album', + 'lyric' + ] + } +} +``` + +--- + +## 数据格式规范 + +### 搜索结果统一格式 + +```javascript +{ + list: [ + { + id: String, // 唯一标识 + name: String, // 名称 + artists: String, // 创作者(用 "、" 分隔) + source: String, // 平台标识 + pic: String, // 大封面 URL + mPic: String, // 中封面 URL + sPic: String, // 小封面 URL + albumName: String, // 合集名 + albumId: String, // 合集 ID + interval: String, // 时长 "mm:ss" + qualities: { // 音质 -> 文件大小 + '128k': '4.2M', + '320k': '8.5M', + 'flac': '35M' + } + } + ], + total: Number, // 总数量 + page: Number, // 当前页码 + limit: Number, // 每页数量 + allPage: Number, // 总页数 + source: String // 平台标识 +} +``` + +### 歌词统一格式 + +```javascript +{ + lyric: String, // 普通歌词 + tlyric: String, // 翻译歌词 + qrc: String, // 逐字歌词 + roma: String // 音译歌词 +} + +// 注意:至少返回 lyric 或 qrc 之一 +``` + +### 列表详情统一格式 + +```javascript +{ + list: [/* 内容列表,格式同搜索结果 */], + page: Number, + limit: Number, + total: Number, + source: String, + info: { + name: String, // 列表名 + img: String, // 封面图 + desc: String, // 描述 + author: String // 创建者 + } +} +``` + +### 合集详情统一格式 + +```javascript +{ + list: [/* 内容列表,格式同搜索结果 */], + page: Number, + limit: Number, + total: Number, + source: String, + info: { + name: String, // 合集名 + img: String, // 封面图 + desc: String, // 描述 + author: String // 创作者 + } +} +``` + +--- + +## 示例代码详解 + +### 完整插件模板 + +```javascript +/** + * @name 平台A音源 + * @description 音源插件示例 + * @version 3.0.0 + * @author 开发者 + * + * 支持平台: 平台A + * 支持音质: 128k, 320k, flac + */ + +'use strict' + +// ==================== 核心导入 ==================== + +const axios = require('axios') +const crypto = require('crypto') + +// ==================== 配置区域 ==================== + +// 从 global.env 读取环境变量(JSON 格式) +const env = global.env || {} + +// 平台标识(固定值,单平台插件) +const PLATFORM = 'A' + +// 服务端配置 +const CONFIG = { + serverUrl: env.SERVER_URL || 'https://api.example.com', + apiKey: env.API_KEY || '', + timeout: 10000 +} + +// 支持的音质 +const SUPPORT_QUALITIES = ['128k', '320k', 'flac'] + +// ==================== 工具函数 ==================== + +/** + * 文件大小格式化 + * @param {number} size - 字节数 + * @returns {string} 格式化后的字符串 + */ +function sizeFormate(size) { + if (!size || isNaN(size)) return '' + if (size > 104857600) return (size / 104857600).toFixed(1) + 'MB' + if (size > 1048576) return (size / 1048576).toFixed(1) + 'MB' + if (size > 1024) return (size / 1024).toFixed(1) + 'KB' + return size + 'B' +} + +/** + * 播放时间格式化 + * @param {number} time - 秒数 + * @returns {string} 格式化后的字符串 + */ +function formatPlayTime(time) { + if (!time || isNaN(time)) return '--/--' + const m = Math.floor(time / 60) + const s = Math.floor(time % 60) + return m + ':' + s.toString().padStart(2, '0') +} + +/** + * 创作者名称格式化 + * @param {Array} artistList - 创作者列表 + * @returns {string} 用 "、" 连接的创作者名 + */ +function formatArtistName(artistList) { + if (!artistList || !Array.isArray(artistList)) return '' + return artistList.map(a => a.name || a).join('、') +} + +/** + * HTML 实体解码 + * @param {string} str - 含 HTML 实体的字符串 + * @returns {string} 解码后的字符串 + */ +function decodeName(str) { + if (!str) return '' + return str + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/ /g, ' ') +} + +// ==================== 核心功能 ==================== + +/** + * 搜索内容 + * @param {string} str - 搜索关键词 + * @param {number} page - 页码,从 1 开始 + * @param {number} limit - 每页数量 + * @returns {Promise} 搜索结果 + */ +async function musicSearch(str, page = 1, limit = 20) { + // 构造请求参数 + const params = { + keyword: str, + page: page, + limit: limit + } + + // 发送请求 + const url = `${CONFIG.serverUrl}/search` + const response = await axios.get(url, { + params, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + // 解析数据 + const data = response.data + + // 转换为统一格式 + const list = data.items.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatArtistName(item.artists), + source: PLATFORM, + pic: item.picUrl || '', + mPic: item.picUrl || '', + sPic: item.picUrl || '', + albumName: decodeName(item.album?.name || ''), + albumId: String(item.album?.id || ''), + interval: formatPlayTime(item.duration), + qualities: { + '128k': sizeFormate(item.size128), + '320k': sizeFormate(item.size320), + 'flac': sizeFormate(item.sizeFlac) + } + })) + + return { + list, + total: data.total, + page: page, + limit: limit, + allPage: Math.ceil(data.total / limit), + source: PLATFORM + } +} + +/** + * 获取播放 URL + * @param {string} id - 内容 ID + * @param {string} quality - 音质标识 + * @returns {Promise} 播放 URL + */ +async function getUrl(id, quality) { + // 检查音质 + if (!SUPPORT_QUALITIES.includes(quality)) { + quality = SUPPORT_QUALITIES[0] + } + + // 发送请求 + const url = `${CONFIG.serverUrl}/url` + const response = await axios.get(url, { + params: { id: id, quality: quality }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + if (!data.url || !data.url.startsWith('http')) { + throw new Error('获取链接失败') + } + + return data.url +} + +/** + * 获取歌词 + * @param {string} id - 内容 ID + * @returns {Promise} 歌词对象 + */ +async function getLyric(id) { + const url = `${CONFIG.serverUrl}/lyric` + const response = await axios.get(url, { + params: { id: id }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + return { + lyric: data.lyric || '', + tlyric: data.tlyric || '' + } +} + +/** + * 获取列表详情 + * @param {string} id - 列表 ID + * @param {number} page - 页码 + * @param {number} limit - 每页数量 + * @returns {Promise} 列表详情 + */ +async function songList(id, page = 1, limit = 20) { + const url = `${CONFIG.serverUrl}/list` + const response = await axios.get(url, { + params: { id, page, limit }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + // 转换内容列表 + const list = data.items.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatArtistName(item.artists), + source: PLATFORM, + pic: item.picUrl || '', + mPic: item.picUrl || '', + sPic: item.picUrl || '', + albumName: decodeName(item.album?.name || ''), + albumId: String(item.album?.id || ''), + interval: formatPlayTime(item.duration), + qualities: {} + })) + + return { + list, + page, + limit, + total: data.total, + source: PLATFORM, + info: { + name: data.name || '', + img: data.cover || '', + desc: data.description || '', + author: data.creator?.name || '' + } + } +} + +/** + * 获取合集详情 + * @param {string} id - 合集 ID + * @param {number} page - 页码 + * @returns {Promise} 合集详情 + */ +async function album(id, page = 1) { + const url = `${CONFIG.serverUrl}/album` + const response = await axios.get(url, { + params: { id, page }, + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + const data = response.data + + // 转换内容列表 + const list = data.items.map(item => ({ + id: String(item.id), + name: decodeName(item.name), + artists: formatArtistName(item.artists), + source: PLATFORM, + pic: item.picUrl || data.cover || '', + mPic: item.picUrl || data.cover || '', + sPic: item.picUrl || data.cover || '', + albumName: decodeName(data.name || ''), + albumId: String(id), + interval: formatPlayTime(item.duration), + qualities: {} + })) + + return { + list, + page, + limit: 1000, + total: data.total, + source: PLATFORM, + info: { + name: data.name || '', + img: data.cover || '', + desc: data.description || '', + author: data.artist?.name || '' + } + } +} + +/** + * 获取热搜词 + * @returns {Promise} 热搜列表 + */ +async function hotSearch() { + const url = `${CONFIG.serverUrl}/hotsearch` + const response = await axios.get(url, { + headers: { 'X-API-Key': CONFIG.apiKey }, + timeout: CONFIG.timeout + }) + + return { + source: PLATFORM, + list: response.data.list || [] + } +} + +// ==================== 插件导出 ==================== + +module.exports = { + // 核心功能 + musicSearch, + getUrl, + getLyric, + songList, + album, + hotSearch, + + // 插件信息 + pluginInfo: { + info: { + id: PLATFORM, + name: '平台A', + description: '平台A插件', + version: '3' + }, + env: [ + { key: 'SERVER_URL', name: '服务端地址', description: '自定义服务端地址' }, + { key: 'API_KEY', name: 'API密钥', description: '服务端API密钥' } + ], + ext: [], + quality: [ + { name: '标准音质', ui: '标', id: '128k' }, + { name: '高品音质', ui: 'HQ', id: '320k' }, + { name: '无损音质', ui: 'SQ', id: 'flac' } + ], + supportFunc: ['search_song', 'search_playlist', 'playlist', 'album', 'lyric'] + } +} +``` + +--- + +## 平台配置参考 + +### 各平台标识对照表 + +| 平台 | 标识 | 常见 ID 字段 | +|------|------|-------------| +| 平台A | `A` | `id` | +| 平台B | `B` | `id` | +| 平台C | `C` | `id`, `hash` | +| 平台D | `D` | `hash`, `id` | +| 平台E | `E` | `id` | +| 平台F | `F` | `id` | + +### 音质支持参考 + +```javascript +// 各平台常见音质配置 +const PLATFORM_QUALITIES = { + A: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + B: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + C: ['128k', '320k', 'flac', 'flac24bit'], + D: ['128k', '320k', 'flac', 'flac24bit', 'hires'], + E: ['128k', '320k', 'flac', 'flac24bit'], + F: ['128k', '320k', 'flac'] +} +``` + +--- + +## 常见问题 + +### Q1: 如何从其他系统迁移到本系统? + +**A**: 参考本文档的"从其他平台迁移指南"章节,主要修改点: +1. 将 `globalThis.lx.request` 改为 `axios` +2. 将事件监听改为函数导出 +3. 修改数据返回格式 +4. 添加 `module.exports` 导出 +5. 环境变量从 `global.env` 读取 + +### Q2: 插件加载失败怎么办? + +**A**: 检查以下几点: +1. 文件语法是否正确:`node -c your-plugin.js` +2. `pluginInfo` 是否完整 +3. 导出的函数名是否正确 +4. 依赖模块是否已安装(如 `axios`) + +### Q3: 搜索返回空结果? + +**A**: 检查: +1. API 请求是否成功(查看日志) +2. 响应数据解析是否正确 +3. 数据格式是否符合规范 +4. 返回的 `source` 是否与平台标识一致 + +### Q4: 播放失败? + +**A**: 检查: +1. `getUrl` 返回的 URL 是否有效 +2. URL 是否以 `http` 开头 +3. 音质标识是否正确 +4. 是否有跨域或权限问题 + +### Q5: 用户如何配置插件? + +**A**: 用户通过设置界面配置环境变量: + +``` +SERVER_URL = 自定义服务端地址 +API_KEY = 用户的API密钥 +``` + +插件通过 `global.env` 读取: + +```javascript +const env = global.env || {} +const SERVER_URL = env.SERVER_URL || '默认地址' +``` + +--- + +## 附录 + +### 参考资源 + +- Node.js 官方文档 +- axios 文档 + +### 版本历史 + +- v1.0.0 (2024-05-28): 初始版本 + +--- + +**文档结束** diff --git a/README.md b/README.md new file mode 100644 index 0000000..aabce30 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Koneko API for QZ-Music + +QZ Music v2/v3 音源插件集合。每个插件仅包含一个平台,官方搜索 + 多 API 音源自动测速容灾。 + +## 插件列表 + +| 文件 | 平台 | 版本 | 说明 | +|------|------|------|------| +| `Koneko_QQ音乐_v0.0.4.js` | QQ音乐 | 0.0.4 | 官方搜索 + 10路API音源容灾 | +| `Koneko_酷狗音乐_v0.0.4.js` | 酷狗音乐 | 0.0.4 | 官方搜索 + 8路API音源容灾 | +| `Koneko_酷我音乐_v0.0.4.js` | 酷我音乐 | 0.0.4 | 官方搜索 + 9路API音源容灾 | +| `Koneko_网易云音乐_v0.0.4.js` | 网易云音乐 | 0.0.4 | 官方搜索 + 6路API音源容灾 + Cookie全功能 | +| `Koneko_咪咕音乐_v0.0.4.js` | 咪咕音乐 | 0.0.4 | 官方搜索 + 8路API音源容灾 | +| `Koneko_GIT音源_v0.0.4.js` | GIT音源 | 0.0.4 | 纯音源 + 2路API音源容灾 | + +## 环境变量 + +在 QZ Music 设置中配置环境变量,插件通过 `global.env` 读取: + +| Key | 适用平台 | 说明 | +|-----|---------|------| +| `ceru_key` | 全部 | 聆澜音源API密钥(可选) | +| `cookie` | 网易云 | 网易云Cookie,用于搜索/每日推荐/私人FM/我喜欢的音乐等 | +| `playlist_url` | 网易云 | 网易云个人主页链接,用于获取个人歌单 | + +## 音质标识 + +| ID | 显示 | +|----|------| +| `standard` | 标准音质 (128k) | +| `exhigh` | 高品音质 (320k) | +| `lossless` | 无损音质 (FLAC) | +| `hires` | Hi-Res | + +## 容灾机制 + +`getUrl` 采用并发请求多个 API,取第一个成功返回的 URL。聆澜 API 需要配置 `ceru_key`,未配置时自动跳过。 + +## 版权声明 + +本插件仅用于学习交流,不存储、不提供任何音乐文件。音乐版权归各平台所有。 +