refactor: 架构整固——分层单向化、全量 TypeScript、直接运行

- 移除 bd/mg 两平台及磁盘残留产物;清理 api-test 死脚本、musicSdk/message+options 死模块、infSign vendor
- musicSdk/ 改名 sdk/;src/common、src/auth、src/apis 整体移入 sdk/,依赖方向定为 src → sdk 严格单向
- src/ 手写 JS 全量转 .ts,删除 JS 类型垫片 modules.d.ts;源码 import 统一 .ts 后缀
- Node 原生类型剥离直接运行 .ts(engines >=22.18),取消 tsc 发射产物,tsc 仅类型检查(pnpm typecheck)
- 删除 kwdes 备用实现与 src/index.ts 转发垫片;测试移至 test/smoke-test.ts
- common/request.ts 补显式导出类型;修复 set-cookie 单值形态、ApiError 类字段等隐患

验证:tsc 零错误,smoke-test 41/41
This commit is contained in:
snowy-yang committed 2026-09-25 23:06:23 +08:00
1 parent c8c939aa32
commit 354df2e739
126 files changed
+413 -2942

No files matched your search

-37
View File
@@ -1,37 +0,0 @@
// 百度音乐 URL 获取(直连)
// 接口: https://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.song.getSongLink
// 返回: result.songinfo.song_link
// 统一接口约定:apis('bd').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
import { httpFetch } from '../common/request.js'
const getSongLink = (songmid, retryNum = 0) => {
const url = `https://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.song.getSongLink&format=json&from=bmpc&version=1.0.0&version_d=11.1.6.0&songid=${songmid}&type=1&res=1&s_protocol=1&aac=2&project=tpass`
return httpFetch(url, {
method: 'get',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
timeout: 15000,
}).promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.error_code != 22000 || !body.result?.songinfo) {
if (retryNum < 2) return getSongLink(songmid, ++retryNum)
return Promise.reject(new Error('获取失败'))
}
return body.result.songinfo
})
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null }
requestObj.promise = getSongLink(songInfo.songmid).then(info => {
// song_link 为试听地址(128k),按音质选择字段
const url = info.song_link || info.file_link
if (!url) return Promise.reject(new Error('获取失败'))
return { type, url }
})
return requestObj
}
export default { getMusicUrl }
-17
View File
@@ -1,17 +0,0 @@
// 各平台 URL 直连接口汇总
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
// 外部聚合音源(sources/ 目录脚本)不走这里,由 musicSdk/api-source.ts 直接接入 source.js
import kw from './kw.js';
import kg from './kg.js';
import tx from './tx.js';
import wy from './wy.js';
import mg from './mg.js';
import bd from './bd.js';
export default {
kw: kw,
kg: kg,
tx: tx,
wy: wy,
mg: mg,
bd: bd,
};
-36
View File
@@ -1,36 +0,0 @@
// 各平台 URL 直连接口汇总
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
// 外部聚合音源(sources/ 目录脚本)不走这里,由 musicSdk/api-source.ts 直接接入 source.js
import kw from './kw.js'
import kg from './kg.js'
import tx from './tx.js'
import wy from './wy.js'
import mg from './mg.js'
import bd from './bd.js'
interface SongInfo {
songmid?: string
hash?: string
copyrightId?: string
[key: string]: any
}
interface MusicUrlResult {
type: string
url: string
}
interface ApiModule {
getMusicUrl?(songInfo: SongInfo, type: string): { promise: Promise<MusicUrlResult>; cancelHttp: () => void }
[key: string]: any
}
export default {
kw: kw as ApiModule,
kg: kg as ApiModule,
tx: tx as ApiModule,
wy: wy as ApiModule,
mg: mg as ApiModule,
bd: bd as ApiModule,
}
-86
View File
@@ -1,86 +0,0 @@
// 酷狗音乐 URL 获取(手机客户端通道)
// 接口: https://gateway.kugou.com/v5/url(x-router: trackercdn.kugou.com,android 签名)
// 登录后自动携带 token/userid(见 /login/kg);匿名仅能获取部分免费资源,
// VIP/受限资源会返回 status:2。quality: 128k→128, 320k→320, flac→flac, flac24bit→high
// 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.js'
import { getKgAuth } from '../auth/store.js'
import { androidSign, getDevice } from '../auth/kg.js'
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
const APPID = 1005
const CLIENTVER = 11430
const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' }
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null }
const hash = (songInfo._types?.[type]?.hash || songInfo.hash || '').toLowerCase()
if (!hash) {
requestObj.promise = Promise.reject(new Error('缺少 hash'))
return requestObj
}
requestObj.cancelHttp = () => {} // 单次 GET,无需取消
requestObj.promise = (async() => {
const dev = getDevice()
const auth = getKgAuth()
const userid = Number(auth?.userid || 0)
const params = {
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000),
album_id: Number(songInfo.albumId ?? 0),
area_code: 1,
hash,
ssa_flag: 'is_fromtrack',
version: CLIENTVER,
page_id: 151369488,
quality: qualityMap[type] ?? '128',
album_audio_id: Number(songInfo.songmid ?? 0),
behavior: 'play',
pid: 2,
cmd: 26,
pidversion: 3001,
IsFreePart: 0,
ppage_id: '463467626,350369493,788954147',
cdnBackup: 1,
module: '',
}
if (auth) {
params.token = auth.token
params.userid = userid
}
params.key = md5(`${hash}${SALT_KEY}${APPID}${dev.mid}${userid}`)
params.signature = androidSign(params)
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`https://gateway.kugou.com/v5/url?${qs}`, {
method: 'get',
headers: {
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
'x-router': 'trackercdn.kugou.com',
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
},
timeout: 15000,
}).promise
const body = r.body && typeof r.body === 'object' ? r.body : {}
if (body.status !== 1 || !Array.isArray(body.data)) {
throw new Error(body.error || (auth ? '获取失败' : '获取失败(该资源需要酷狗登录)'))
}
const item = body.data.find(d => d.url) ?? body.data[0]
const url = item?.url || item?.fallback
if (!url) throw new Error('获取失败')
return { type, url }
})()
return requestObj
}
export default { getMusicUrl }
-108
View File
@@ -1,108 +0,0 @@
// 酷我音乐 URL 获取(直连)
// 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
//
// 取流方案:
// 首选 antiserver.kuwo.cn/anti.s(convert_url3 老接口,无需 token,128k/320k 有效)
// flac 及以上音质该接口返回 refuse,回退 www.kuwo.cn/api/v1/www/music/playUrl
// (需要 csrf token,token 失效时自动重新获取重试一次)
import { httpFetch } from '../common/request.js'
// 音质映射:128k → 128kmp3, 320k → 320kmp3, flac → flac(antiserver 用 mp3/flac 格式)
const qualityMap = {
'128k': { br: '128kmp3', format: 'mp3' },
'320k': { br: '320kmp3', format: 'mp3' },
flac: { br: 'flac', format: 'flac' },
flac24bit: { br: 'flac', format: 'flac' },
}
let token = ''
let tokenPromise = null
/**
* 获取 kw csrf token(访问首页拿 cookie)
*/
const getToken = (retryNum = 0) => {
if (token) return Promise.resolve(token)
if (tokenPromise) return tokenPromise
tokenPromise = httpFetch('http://www.kuwo.cn/', {
method: 'get',
timeout: 15000,
}).promise.then(({ headers }) => {
tokenPromise = null
const match = (headers['set-cookie'] || []).join(';').match(/kw_token=(\w+)/)
if (!match) throw new Error('获取token失败')
token = match[1]
return token
}).catch(err => {
tokenPromise = null
if (retryNum > 2) throw err
return getToken(++retryNum)
})
return tokenPromise
}
/**
* 通过 antiserver 老接口获取(无需 token)
* @param {string} rid MUSIC_xxx
* @param {string} br 128kmp3 / 320kmp3 / flac
* @param {string} format mp3 / flac
*/
const getUrlByAntiServer = (rid, br, format) => {
return httpFetch(`http://antiserver.kuwo.cn/anti.s?type=convert_url3&rid=${rid}&format=${format}&response=url&br=${br}`, {
method: 'get',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
timeout: 15000,
}).promise.then(({ statusCode, body }) => {
if (statusCode != 200 || typeof body != 'object' || body.code != 200 || !body.url) {
return Promise.reject(new Error(body?.msg || '获取失败'))
}
return body.url
})
}
/**
* 通过官方 playUrl 接口获取(需要 csrf token)
*/
const getUrlByPlayUrl = (songmid, br, retryNum = 0) => {
const requestObj = httpFetch(`http://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&br=${br}`, {
method: 'get',
headers: {
csrf: token,
cookie: `kw_token=${token}`,
Referer: 'http://www.kuwo.cn/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
timeout: 15000,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 200) {
// token 失效时重新获取再试一次
if (token && /token|403|401/.test(body?.msg || '') && retryNum == 0) {
token = ''
return getUrlByPlayUrl(songmid, br, ++retryNum).promise
}
return Promise.reject(new Error(body?.msg || '获取失败'))
}
if (!body.data?.url) return Promise.reject(new Error('获取失败'))
return body.data.url
})
return requestObj
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null }
const { br, format } = qualityMap[type] || qualityMap['128k']
const rid = `MUSIC_${songInfo.songmid}`
requestObj.promise = getUrlByAntiServer(rid, br, format).catch(() => {
// antiserver 无此音质(flac 常被拒)时回退官方接口
return getToken().then(() => getUrlByPlayUrl(songInfo.songmid, br).promise)
}).then(url => ({ type, url }))
return requestObj
}
export default { getMusicUrl }
-84
View File
@@ -1,84 +0,0 @@
// 咪咕音乐 URL 获取(直连)
// 流程: resourceinfo.do(拿 contentId,需签名)→ listenSong.do(302 跳转到 CDN 音频地址)
// 统一接口约定:apis('mg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
import { httpFetch } from '../common/request.js'
import { toMD5 } from '../common/utils.js'
const rateMap = {
'128k': 'PQ',
'320k': 'HQ',
flac: 'SQ',
flac24bit: 'ZQ24',
}
const deviceId = '963B7AA0D21511ED807EE5846EC87D20'
const signatureMd5 = '6cdc72a439cef99a3418d2a78aa28c73'
const createSignature = (time, str) => {
const sign = toMD5(`${str}${signatureMd5}yyapp2d16148780a1dcc7408e06336b98cfd50${deviceId}${time}`)
return { sign, deviceId }
}
/**
* 通过 songId 获取歌曲内容信息(contentId)
*/
const getResourceInfo = songmid => {
const time = Date.now().toString()
const signData = createSignature(time, songmid)
return httpFetch(`http://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?resourceId=${songmid}&resourceType=2`, {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36',
timestamp: time,
sign: signData.sign,
deviceId: signData.deviceId,
appId: 'yyapp2',
channel: '0146921',
},
timeout: 15000,
}).promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != '000000' || !body.resource?.length) {
return Promise.reject(new Error('获取失败'))
}
return body.resource[0]
})
}
/**
* 获取播放地址(302 跳转)
* @param {string} contentId
* @param {string} copyrightId
* @param {string} rateType PQ / HQ / SQ
*/
const getSongUrl = (contentId, copyrightId, rateType, retryNum = 0) => {
if (retryNum > 2) return Promise.reject(new Error('获取失败'))
const url = `https://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/sub/listenSong.do?contentId=${contentId}&copyrightId=${copyrightId}&resourceType=2&rateType=${rateType}&channel=0146921&toneFlag=${rateType}&netType=00&version=5.0.1`
return httpFetch(url, {
method: 'get',
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36',
Referer: 'http://music.migu.cn/',
},
timeout: 15000,
}).promise.then(({ statusCode, headers }) => {
if (statusCode == 302 && headers.location) return headers.location
if (statusCode == 302 && !headers.location) {
// 手动跟随
return Promise.reject(new Error('获取失败'))
}
return getSongUrl(contentId, copyrightId, rateType, ++retryNum)
})
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null }
const rateType = rateMap[type] || 'PQ'
requestObj.promise = getResourceInfo(songInfo.songmid || songInfo.copyrightId)
.then(resource => getSongUrl(resource.contentId, resource.copyrightId, rateType))
.then(url => ({ type, url }))
return requestObj
}
export default { getMusicUrl }
-642
View File
@@ -1,642 +0,0 @@
// 外部聚合音源加载器(isolated-vm 硬隔离执行第三方脚本)
//
// 这是外部音源的唯一接入方式:在 isolated-vm 硬隔离沙箱中运行原始混淆脚本,
// 脚本的所有能力都经由本模块显式桥接的通道:
// - http:默认只放行脚本头部 SERVER_SCRIPT_CONFIG.apiUrl 同源请求,其余一律阻止
// - crypto / zlib / buffer:Node 侧实现,日志只记操作与长度,不记录密钥内容
// - 定时器 / console:桥接到 Node,随 isolate 销毁回收
// 对外接口:getMusicUrl(songInfo, type) → { promise, cancelHttp },与其它平台适配器一致。
// 脚本升版时只需替换 sources/ 目录下的脚本文件,无需改代码。
//
// 安全模型:
// - isolate 内存上限(memoryLimit),无 process/require/fs/网络直连能力
// - HTTP 出站默认拒绝(deny-all),白名单来自脚本自身声明的 apiUrl,可被 options 收紧或放宽
// - 每个音源 API 调用(on/send/request/utils/dispatch)都有日志,请求含耗时与状态
//
// 依赖:isolated-vm(未安装时工厂函数抛出明确错误)
// 用法:
// import { createIvmSource } from './source-ivm.js'
// const source = await createIvmSource({ scriptPath }) // scriptPath 必填(sources/ 扫描逻辑见 source.js)
// const source = await createIvmSource({ scriptPath, http }) // http 可注入 mock 做离线测试
// await source.kw.getMusicUrl({ songmid: '123' }, '320k').promise
// await source.destroy()
//
// 已知边界(相对桌面端 preload 的差异):
// - source.request 回调的 resp 不含 raw/bytes(Buffer 无法跨隔离边界,实测脚本未使用)
// - formData 选项不支持(httpFetch 不支持 multipart)
// - updateAlert 事件仅记录到日志,不弹窗
import fs from 'node:fs'
import crypto from 'node:crypto'
import zlib from 'node:zlib'
import { httpFetch } from '../common/request.js'
// 从脚本头部的 SERVER_SCRIPT_CONFIG 提取 apiUrl 声明,作为 HTTP 白名单缺省值。
// 兼容裸键(apiUrl:)与 JSON 风格("apiUrl":),并处理 \/ 转义
const extractAllowedOrigins = (script) => {
const origins = []
const re = /["']?apiUrl["']?\s*:\s*["'`]([^"'`\s]+)["'`]/g
let m
while ((m = re.exec(script))) {
try { origins.push(new URL(m[1].replace(/\\\//g, '/')).origin) } catch {}
}
return origins
}
const brief = (v, max = 72) => {
const s = typeof v === 'string' ? v : JSON.stringify(v) ?? String(v)
return s.length > max ? `${s.slice(0, max)}…(${s.length})` : s
}
// 从脚本头部注释解析 @name/@version 等(对齐音源脚本 currentScriptInfo)
const parseScriptInfo = (script, scriptPath) => {
const head = script.slice(0, 2000)
const pick = (key) => {
const m = new RegExp(`@${key}\\s+(.+)`).exec(head)
return m ? m[1].trim() : ''
}
return {
name: pick('name') || '未命名音源',
description: pick('description'),
version: pick('version') || '0',
author: pick('author'),
homepage: pick('homepage'),
rawScript: script,
path: scriptPath,
}
}
// 注入隔离环境的胶水层:提供音源脚本 API(globalThis.lx 为脚本契约名)、定时器、console、Bytes 包装。
// 与 Node 的数据交换一律走 JSON 字符串 / hex 字符串,避免跨隔离传输复杂对象。
const GLUE = `
'use strict'
var __currentDispatch = 0
var __pendingZlib = {}
var __timers = {}
// ---- Bytes 轻量包装(对齐 Buffer 的最小用法面)----
function __utf8Encode(str) {
var out = []
for (var i = 0; i < str.length; i++) {
var c = str.charCodeAt(i)
if (c >= 0xD800 && c <= 0xDBFF && i + 1 < str.length) {
var c2 = str.charCodeAt(i + 1)
if (c2 >= 0xDC00 && c2 <= 0xDFFF) { c = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); i++ }
}
if (c < 0x80) out.push(c)
else if (c < 0x800) out.push(0xC0 | (c >> 6), 0x80 | (c & 63))
else if (c < 0x10000) out.push(0xE0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
else out.push(0xF0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
}
return out
}
function __utf8Decode(bytes) {
var out = ''
for (var i = 0; i < bytes.length;) {
var b = bytes[i], cp
if (b < 0x80) { cp = b; i += 1 }
else if (b < 0xE0) { cp = ((b & 31) << 6) | (bytes[i + 1] & 63); i += 2 }
else if (b < 0xF0) { cp = ((b & 15) << 12) | ((bytes[i + 1] & 63) << 6) | (bytes[i + 2] & 63); i += 3 }
else { cp = ((b & 7) << 18) | ((bytes[i + 1] & 63) << 12) | ((bytes[i + 2] & 63) << 6) | (bytes[i + 3] & 63); i += 4 }
if (cp > 0xFFFF) { cp -= 0x10000; out += String.fromCharCode(0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF)) }
else out += String.fromCharCode(cp)
}
return out
}
function __bytesToBase64(bytes) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', out = ''
for (var i = 0; i < bytes.length; i += 3) {
var b0 = bytes[i], b1 = bytes[i + 1], b2 = bytes[i + 2]
out += chars[b0 >> 2]
out += chars[((b0 & 3) << 4) | ((b1 === undefined ? 0 : b1) >> 4)]
out += b1 === undefined ? '=' : chars[((b1 & 15) << 2) | ((b2 === undefined ? 0 : b2) >> 6)]
out += b2 === undefined ? '=' : chars[b2 & 63]
}
return out
}
function __base64ToBytes(b64) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', out = []
b64 = String(b64).replace(/[^A-Za-z0-9+/]/g, '')
for (var i = 0; i < b64.length; i += 4) {
var n = (chars.indexOf(b64[i]) << 18) | (chars.indexOf(b64[i + 1]) << 12) |
((chars.indexOf(b64[i + 2]) & 63) << 6) | (chars.indexOf(b64[i + 3]) & 63)
out.push((n >> 16) & 255)
if (b64[i + 2] && b64[i + 2] !== '=') out.push((n >> 8) & 255)
if (b64[i + 3] && b64[i + 3] !== '=') out.push(n & 255)
}
return out
}
function __hexToBytes(hex) {
var out = []
for (var i = 0; i + 1 < hex.length; i += 2) out.push(parseInt(hex.substr(i, 2), 16))
return out
}
function __bytesToHex(bytes) {
var out = ''
for (var i = 0; i < bytes.length; i++) out += ((bytes[i] & 255) < 16 ? '0' : '') + (bytes[i] & 255).toString(16)
return out
}
globalThis.__Bytes = function (bytes) { this._b = bytes }
globalThis.__Bytes.prototype.length = 0
Object.defineProperty(globalThis.__Bytes.prototype, 'length', {
get: function () { return this._b.length },
})
globalThis.__Bytes.prototype.toString = function (enc) {
enc = String(enc || 'utf8').toLowerCase()
if (enc === 'hex') return __bytesToHex(this._b)
if (enc === 'base64') return __bytesToBase64(this._b)
if (enc === 'binary' || enc === 'latin1') {
var s = ''
for (var i = 0; i < this._b.length; i++) s += String.fromCharCode(this._b[i] & 255)
return s
}
return __utf8Decode(this._b)
}
globalThis.__Bytes.prototype.slice = function (start, end) {
return new __Bytes(this._b.slice(start || 0, end === undefined || end === null ? undefined : end))
}
globalThis.__Bytes.prototype.toJSON = function () { return this.toString('utf8') }
function __toHex(data) {
if (data instanceof __Bytes) return data.toString('hex')
if (typeof data === 'string') return __bytesToHex(__utf8Encode(data))
if (data && typeof data.length === 'number') {
var out = ''
for (var i = 0; i < data.length; i++) out += ((data[i] & 255) < 16 ? '0' : '') + (data[i] & 255).toString(16)
return out
}
return ''
}
function __fromAny(data, enc) {
if (data instanceof __Bytes) return new __Bytes(data._b.slice())
if (typeof data === 'string') {
enc = String(enc || 'utf8').toLowerCase()
if (enc === 'hex') return new __Bytes(__hexToBytes(data))
if (enc === 'base64') return new __Bytes(__base64ToBytes(data))
if (enc === 'binary' || enc === 'latin1') {
var out = []
for (var i = 0; i < data.length; i++) out.push(data.charCodeAt(i) & 255)
return new __Bytes(out)
}
return new __Bytes(__utf8Encode(data))
}
return new __Bytes(Array.prototype.slice.call(data || []))
}
// ---- console / 定时器桥接 ----
globalThis.console = {
log: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
info: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
warn: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
error: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
debug: function () {},
}
globalThis.setTimeout = function (fn, ms) {
var id = __newTimer(fn, Array.prototype.slice.call(arguments, 2), false)
__startTimer(id, Number(ms) || 0, false)
return id
}
globalThis.clearTimeout = function (id) { __clearTimer(id) }
globalThis.setInterval = function (fn, ms) {
var id = __newTimer(fn, [], true)
__startTimer(id, Number(ms) || 1, true)
return id
}
globalThis.clearInterval = function (id) { __clearTimer(id) }
function __newTimer(fn, args, interval) {
var id = Date.now() % 100000000 + Math.floor(Math.random() * 100000000)
__timers[id] = { fn: fn, args: args, interval: interval }
return id
}
function __clearTimer(id) {
if (__timers[id]) { delete __timers[id]; __stopTimer(id) }
}
globalThis.__fireTimer = function (id) {
var t = __timers[id]
if (!t) return
if (!t.interval) delete __timers[id]
try { t.fn.apply(undefined, t.args) } catch (e) { __log('timer error: ' + (e && e.message)) }
}
// ---- 音源脚本 preload API ----
// 注意:globalThis.lx 是第三方脚本依赖的 API 契约名(脚本内部调用 lx.on/source.request 等),
// 属于对外兼容接口而非命名残留,不可改动,否则脚本初始化直接失败。
var __EVENT_NAMES = { request: 'request', inited: 'inited', updateAlert: 'updateAlert' }
var __handlers = {}
globalThis.lx = {
EVENT_NAMES: __EVENT_NAMES,
version: '2.0.0',
env: 'desktop',
currentScriptInfo: __scriptInfo,
on: function (name, handler) {
if (name !== __EVENT_NAMES.request) return Promise.reject(new Error('The event is not supported: ' + name))
__handlers[name] = handler
__on(String(name))
return Promise.resolve()
},
send: function (name, data) {
if (name !== __EVENT_NAMES.inited && name !== __EVENT_NAMES.updateAlert) {
return Promise.reject(new Error('The event is not supported: ' + name))
}
var err = __send(String(name), JSON.stringify(data === undefined ? null : data))
return err ? Promise.reject(new Error(err)) : Promise.resolve()
},
request: function (url, options, callback) { return __sourceRequest(url, options, callback, __currentDispatch) },
utils: {
crypto: {
md5: function (str) { return __md5(str) },
randomBytes: function (size) { return new __Bytes(__hexToBytes(__randomBytesHex(size))) },
aesEncrypt: function (buffer, mode, key, iv) {
return new __Bytes(__hexToBytes(__aesEncryptHex(__toHex(buffer), String(mode), __toHex(key), __toHex(iv))))
},
rsaEncrypt: function (buffer, key) {
return new __Bytes(__hexToBytes(__rsaEncryptHex(__toHex(buffer), String(key))))
},
},
buffer: {
from: function (data, enc) {
__log('source.utils.buffer.from(' + (data instanceof __Bytes ? 'Bytes(' + data.length + ')' : typeof data + (data && data.length !== undefined ? '[' + data.length + ']' : '')) + (enc ? ',' + enc : '') + ')')
return __fromAny(data, enc)
},
bufToString: function (buf, format) {
__log('source.utils.buffer.bufToString(' + (buf && buf.length !== undefined ? buf.length + 'B' : typeof buf) + ',' + (format || 'utf8') + ')')
return buf.toString(format || 'utf8')
},
},
zlib: {
inflate: function (buf) { return __zlibAsync('inflate', buf) },
deflate: function (data) { return __zlibAsync('deflate', data) },
},
},
}
// 隔离环境内不依赖 ivm 全局:回调统一走「隔离内注册表 + 自增 id」,
// Node 侧通过 jail.get 拿到 __completeHttp 等入口反调
function __sourceRequest(url, options, callback, dispatchId) {
options = options || {}
var payload = {
method: options.method || 'get',
timeout: options.timeout,
headers: options.headers || null,
body: options.body === undefined || options.body === null ? null :
(typeof options.body === 'string' ? options.body : JSON.stringify(options.body)),
form: options.form ? JSON.stringify(options.form) : null,
}
var cbId = ++__seq
__pendingCbs[cbId] = callback
var reqId = __http(dispatchId | 0, String(url), JSON.stringify(payload), cbId)
return function () { __cancelHttp(reqId) }
}
var __pendingCbs = {}
globalThis.__completeHttp = function (cbId, errMsg, respJson, bodyJson) {
var cb = __pendingCbs[cbId]
if (!cb) return
delete __pendingCbs[cbId]
try {
if (errMsg) cb.call(lx, new Error(errMsg), null, null)
else cb.call(lx, null, respJson === null ? null : JSON.parse(respJson), bodyJson === null ? null : JSON.parse(bodyJson))
} catch (e) { __log('request callback error: ' + (e && e.message)) }
}
function __zlibAsync(op, data) {
return new Promise(function (resolve, reject) {
var opId = ++__seqCounter()
__pendingZlib[opId] = { resolve: resolve, reject: reject }
__zlib(opId, op, __toHex(data))
})
}
var __seq = 0
function __seqCounter() { return ++__seq }
globalThis.__zlibDone = function (opId, errMsg, valHex) {
var p = __pendingZlib[opId]
if (!p) return
delete __pendingZlib[opId]
if (errMsg) p.reject(new Error(errMsg))
else p.resolve(new __Bytes(__hexToBytes(valHex)))
}
// ---- 请求分发(Node 侧调用)----
// 注意:本函数必须不返回值(结果经 __dispatchResult 回传),
// 否则 apply 会尝试克隆隔离内 Promise 而报 "could not be cloned"
globalThis.__dispatch = function (dispatchId, payloadJson) {
var payload = JSON.parse(payloadJson)
__currentDispatch = dispatchId | 0
var p
try {
var handler = __handlers[__EVENT_NAMES.request]
if (!handler) p = Promise.reject(new Error('request handler 未注册'))
else p = Promise.resolve().then(function () { return handler(payload) })
} catch (e) {
p = Promise.reject(e)
}
__currentDispatch = 0
p.then(function (result) {
if (payload.action === 'musicUrl') return JSON.stringify({ ok: true, url: result === undefined ? null : String(result) })
return JSON.stringify({ ok: true, data: result === undefined ? null : result })
}, function (err) {
return JSON.stringify({ ok: false, message: err && err.message ? String(err.message) : String(err) })
}).then(function (resultJson) {
__dispatchResult(dispatchId | 0, resultJson)
}, function (err) {
__dispatchResult(dispatchId | 0, JSON.stringify({ ok: false, message: String(err && err.message || err) }))
})
}
`
/**
* 创建隔离执行的外部音源
* @param {object} [options]
* @param {string} options.scriptPath 音源脚本路径(必填,sources/ 目录扫描逻辑见 source.js)
* @param {Function} [options.http] http 实现,默认项目统一 httpFetch;测试可注入 mock
* @param {number} [options.memoryLimit] 隔离内存上限 MB,默认 256
* @param {number} [options.initTimeout] 等待脚本上报 inited 的超时 ms,默认 10000
* @param {number} [options.requestTimeout] 单次取流超时 ms,默认 15000
* @param {boolean|Function} [options.log] 日志开关;true(默认)输出到 console,可传自定义 (msg)=>void,false 关闭
* @param {string[]} [options.allowedOrigins] HTTP 白名单 origin;缺省取脚本 SERVER_SCRIPT_CONFIG.apiUrl 同源
* @param {Function} [options.httpFilter] 自定义放行判定 (url) => boolean;提供时优先于 origin 白名单
* @param {boolean} [options.allowAllHttp] 放行全部 http 请求(调试用,默认 false)
* @returns {{ env: string, scriptInfo: object, inited: object|null, apis: object, allowedOrigins: string[], destroy: Function }}
*/
export const createIvmSource = async({
scriptPath,
http: httpImpl = httpFetch,
memoryLimit = 256,
initTimeout = 10000,
requestTimeout = 15000,
log: logOption = true,
allowedOrigins,
httpFilter,
allowAllHttp = false,
} = {}) => {
let ivm
try {
ivm = (await import('isolated-vm')).default
} catch {
throw new Error('未安装 isolated-vm,无法启用隔离执行音源:pnpm add isolated-vm')
}
if (!scriptPath) throw new Error('缺少音源脚本路径 scriptPath(sources/ 目录扫描逻辑见 source.js)')
const script = fs.readFileSync(scriptPath, 'utf8')
const scriptInfo = parseScriptInfo(script, scriptPath)
// ---- 日志与 HTTP 白名单 ----
const log = typeof logOption === 'function'
? logOption
: logOption === false
? () => {}
: (msg) => console.log('[source-ivm]', msg)
const scriptOrigins = extractAllowedOrigins(script)
const origins = (allowedOrigins ?? scriptOrigins).map(o => String(o).replace(/\/+$/, ''))
const httpAllowed = (urlStr) => {
if (allowAllHttp) return true
if (httpFilter) return !!httpFilter(urlStr)
try { return origins.includes(new URL(urlStr).origin) } catch { return false }
}
log(`加载脚本 ${scriptPath}(http 白名单: ${origins.join(', ') || '(空, 全部阻止)'}${allowAllHttp ? ' + allowAllHttp' : ''}${httpFilter ? ' + httpFilter' : ''})`)
const isolate = new ivm.Isolate({ memoryLimit })
// afterEvaluate:每次外部调用返回后推进隔离内的微任务队列,否则脚本内的 Promise 链永远不会执行
const context = await isolate.createContext({ microtaskMode: 'afterEvaluate' })
const jail = context.global
// ---- Node 侧状态 ----
const state = {
inited: null,
updateAlerts: [],
onRegistered: null,
initWaiters: [],
}
const pendingDispatch = new Map() // dispatchId -> { resolve, reject }
const httpRequests = new Map() // reqId -> requestObj
const dispatchHttp = new Map() // dispatchId -> Set<requestObj>
const nodeTimers = new Map() // timerId -> timeout对象
let httpSeq = 0
let dispatchSeq = 0
let disposed = false
const forgetHttp = (reqId, dispatchId, requestObj) => {
httpRequests.delete(reqId)
const set = dispatchHttp.get(dispatchId)
if (set) { set.delete(requestObj); if (!set.size) dispatchHttp.delete(dispatchId) }
}
// jail.get 拿到的是 ivm.Function:apply 为同步调用(async 选项无效),
// 隔离内异步结果一律经回调(__dispatchResult/__completeHttp 等)传回
const safeApply = (fnRef, args) => {
if (disposed) return
try {
fnRef.apply(undefined, args)
} catch (err) {
log(`isolate 调用失败: ${err?.message}`)
}
}
// ---- 桥接回调(Node ← isolate)----
await jail.set('__log', new ivm.Callback((msg) => log(String(msg))))
await jail.set('__on', new ivm.Callback((name) => {
state.onRegistered = name
log(`source.on(${String(name)})`)
}))
await jail.set('__send', new ivm.Callback((name, dataJson) => {
let data = null
try { data = JSON.parse(dataJson) } catch {}
log(`source.send(${String(name)}) ${brief(dataJson ?? '')}`)
if (name === 'inited') {
state.inited = data
const waiters = state.initWaiters.splice(0)
waiters.forEach(w => w.resolve(data))
} else if (name === 'updateAlert') {
state.updateAlerts.push(data)
}
return '' // 非空字符串会以异常形式抛回 isolate
}))
await jail.set('__dispatchResult', new ivm.Callback((dispatchId, resultJson) => {
const p = pendingDispatch.get(dispatchId)
if (!p) return
pendingDispatch.delete(dispatchId)
let parsed
try { parsed = JSON.parse(resultJson) } catch (err) { p.reject(err); return }
if (parsed.ok) p.resolve(parsed)
else p.reject(new Error(parsed.message || '获取失败'))
}))
await jail.set('__md5', new ivm.Callback((str) => {
log(`source.utils.crypto.md5(${brief(str)})`)
return crypto.createHash('md5').update(String(str)).digest('hex')
}))
await jail.set('__randomBytesHex', new ivm.Callback((size) => {
log(`source.utils.crypto.randomBytes(${size})`)
return crypto.randomBytes(size).toString('hex')
}))
await jail.set('__aesEncryptHex', new ivm.Callback((hexBuf, mode, hexKey, hexIv) => {
log(`source.utils.crypto.aesEncrypt(${Buffer.from(hexBuf, 'hex').length}B, ${mode})`)
const cipher = crypto.createCipheriv(mode, Buffer.from(hexKey, 'hex'), Buffer.from(hexIv, 'hex'))
return Buffer.concat([cipher.update(Buffer.from(hexBuf, 'hex')), cipher.final()]).toString('hex')
}))
await jail.set('__rsaEncryptHex', new ivm.Callback((hexBuf, pemKey) => {
log(`source.utils.crypto.rsaEncrypt(${Buffer.from(hexBuf, 'hex').length}B, key=${brief(pemKey, 24)})`)
let buf = Buffer.from(hexBuf, 'hex')
buf = Buffer.concat([Buffer.alloc(128 - buf.length), buf])
return crypto.publicEncrypt({ key: pemKey, padding: crypto.constants.RSA_NO_PADDING }, buf).toString('hex')
}))
await jail.set('__zlib', new ivm.Callback((opId, op, hex) => {
log(`source.utils.zlib.${op}(${Buffer.from(hex, 'hex').length}B)`)
const buf = Buffer.from(hex, 'hex')
zlib[op](buf, (err, out) => {
safeApply(zlibDoneRef, [opId, err ? String(err.message) : null, err ? '' : out.toString('hex')])
})
}))
await jail.set('__http', new ivm.Callback((dispatchId, url, optionsJson, cbId) => {
const reqId = ++httpSeq
const opts = JSON.parse(optionsJson)
const method = String(opts.method || 'get').toUpperCase()
if (!httpAllowed(url)) {
log(`✋ source.request ${method} ${url} → 已阻止(不在白名单)`)
safeApply(completeHttpRef, [cbId, `http blocked by allowlist: ${url}`, null, null])
return reqId
}
log(`→ source.request ${method} ${brief(url, 120)}`)
const startedAt = Date.now()
const options = {
method: opts.method || 'get',
timeout: typeof opts.timeout === 'number' && opts.timeout > 0 ? opts.timeout : 15000,
}
if (opts.headers) options.headers = opts.headers
if (opts.body) options.body = opts.body
if (opts.form) options.form = JSON.parse(opts.form)
const requestObj = httpImpl(String(url), options)
httpRequests.set(reqId, requestObj)
if (!dispatchHttp.has(dispatchId)) dispatchHttp.set(dispatchId, new Set())
dispatchHttp.get(dispatchId).add(requestObj)
requestObj.promise.then(({ statusCode, statusMessage, headers, body }) => {
log(`← source.request ${method} ${brief(url, 80)} → ${statusCode}(${Date.now() - startedAt}ms)`)
forgetHttp(reqId, dispatchId, requestObj)
const resp = { statusCode, statusMessage: statusMessage || '', headers: headers || {}, body }
return safeApply(completeHttpRef, [cbId, null, JSON.stringify(resp), JSON.stringify(body === undefined ? null : body)])
}).catch(err => {
log(`← source.request ${method} ${brief(url, 80)} → 失败(${Date.now() - startedAt}ms)${err?.message ? ' ' + err.message : ''}`)
forgetHttp(reqId, dispatchId, requestObj)
return safeApply(completeHttpRef, [cbId, String(err && err.message || err), null, null])
})
return reqId
}))
await jail.set('__cancelHttp', new ivm.Callback((reqId) => {
const requestObj = httpRequests.get(reqId)
if (requestObj) requestObj.cancelHttp()
}))
await jail.set('__startTimer', new ivm.Callback((id, ms, interval) => {
const timerRef = interval
? setInterval(() => safeApply(fireTimerRef, [id]), Math.max(1, ms))
: setTimeout(() => safeApply(fireTimerRef, [id]), Math.max(0, ms))
if (interval && typeof timerRef.unref === 'function') timerRef.unref()
nodeTimers.set(id, timerRef)
}))
await jail.set('__stopTimer', new ivm.Callback((id) => {
const t = nodeTimers.get(id)
if (t) { clearTimeout(t); clearInterval(t); nodeTimers.delete(id) }
}))
// ---- 注入胶水层与脚本元信息,再加载目标脚本 ----
await jail.set('__scriptInfo', new ivm.ExternalCopy(scriptInfo).copyInto())
await jail.set('SCRIPT_MD5', crypto.createHash('md5').update(script).digest('hex'))
await context.eval(GLUE)
const fireTimerRef = await jail.get('__fireTimer')
const zlibDoneRef = await jail.get('__zlibDone')
const completeHttpRef = await jail.get('__completeHttp')
const dispatchRef = await jail.get('__dispatch')
await context.eval(script, { async: true, timeout: 15000 })
// ---- 对外 dispatch ----
const dispatch = (payload) => {
if (disposed) return Promise.reject(new Error('isolate 已销毁'))
const id = ++dispatchSeq
return {
id,
promise: new Promise((resolve, reject) => {
pendingDispatch.set(id, { resolve, reject })
try {
dispatchRef.apply(undefined, [id, JSON.stringify(payload)])
} catch (err) {
if (pendingDispatch.delete(id)) reject(err)
}
}),
}
}
const getMusicUrlFor = (source) => (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null }
const id = songInfo?.hash || songInfo?.songmid || '?'
const dispatched = dispatch({ source, action: 'musicUrl', info: { type, musicInfo: songInfo } })
const startedAt = Date.now()
log(`▶ getMusicUrl ${source}/${type} id=${id}(dispatch #${dispatched.id})`)
requestObj.cancelHttp = () => {
const set = dispatchHttp.get(dispatched.id)
if (set) [...set].forEach(r => r.cancelHttp())
}
requestObj.promise = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
requestObj.cancelHttp()
pendingDispatch.delete(dispatched.id)
reject(new Error(`请求超时 (${requestTimeout}ms)`))
}, requestTimeout)
timer.unref?.()
dispatched.promise.then(result => {
clearTimeout(timer)
log(`✔ getMusicUrl ${source}/${type} 完成(${Date.now() - startedAt}ms)`)
if (!result.url) reject(new Error('响应无播放地址'))
else resolve({ type, url: result.url })
}, err => {
clearTimeout(timer)
log(`✖ getMusicUrl ${source}/${type} 失败(${Date.now() - startedAt}ms)${err?.message || ''}`)
reject(err)
})
})
return requestObj
}
// ---- 等待脚本 inited ----
const inited = await new Promise((resolve, reject) => {
if (state.inited) return resolve(state.inited)
const waiter = { resolve, reject }
state.initWaiters.push(waiter)
const t = setTimeout(() => {
const i = state.initWaiters.indexOf(waiter)
if (i >= 0) state.initWaiters.splice(i, 1)
reject(new Error(`脚本 ${initTimeout}ms 内未上报 inited(handler 注册: ${state.onRegistered || '无'})`))
}, initTimeout)
t.unref?.()
}).catch(async err => {
await destroy()
throw err
})
// 按脚本上报的 sources 生成分平台接口(过滤 local 这类服务器侧无意义条目仍保留键,但 local 无 qualitys)
const reportedSources = inited?.sources && typeof inited.sources === 'object' ? Object.keys(inited.sources) : []
const sources = reportedSources.length ? reportedSources : ['kw', 'kg', 'tx', 'wy', 'mg']
const apis = {}
for (const s of sources) apis[s] = { getMusicUrl: getMusicUrlFor(s) }
function destroy() {
if (disposed) return Promise.resolve()
disposed = true
for (const t of nodeTimers.values()) { clearTimeout(t); clearInterval(t) }
nodeTimers.clear()
for (const r of httpRequests.values()) { try { r.cancelHttp() } catch {} }
httpRequests.clear()
dispatchHttp.clear()
for (const p of pendingDispatch.values()) p.reject(new Error('isolate 已销毁'))
pendingDispatch.clear()
for (const w of state.initWaiters.splice(0)) w.reject(new Error('isolate 已销毁'))
try { isolate.dispose() } catch {}
return Promise.resolve()
}
return {
env: 'isolated-vm',
scriptInfo: { name: scriptInfo.name, version: scriptInfo.version, author: scriptInfo.author, path: scriptPath },
inited,
apis,
allowedOrigins: origins,
...apis,
destroy,
}
}
export default { createIvmSource }
-76
View File
@@ -1,76 +0,0 @@
// 外部聚合音源统一入口(isolated-vm 懒加载封装)
// 内部委托 source-ivm.js 在硬隔离沙箱中执行音源脚本。
// 首次调用 getMusicUrl 时才加载并初始化隔离环境,后续复用同一实例。
// 统一接口约定:source.kw.getMusicUrl(songInfo, type) → { promise, cancelHttp },resolve → { type, url }
//
// 脚本来源(优先级):
// 1. 环境变量 SOURCE_SCRIPT_PATH 指定的脚本文件
// 2. sources/ 目录下按文件名排序的第一个 .js(脚本文件不入库,见 .gitignore)
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createIvmSource } from './source-ivm.js'
export const SOURCES = ['kw', 'kg', 'tx', 'wy', 'mg']
const SOURCES_DIR = fileURLToPath(new URL('../../sources/', import.meta.url))
const resolveScriptPath = () => {
const custom = process.env.SOURCE_SCRIPT_PATH
if (custom) return custom
if (!fs.existsSync(SOURCES_DIR)) {
throw new Error(`音源目录不存在: ${SOURCES_DIR}(将脚本放入 sources/ 目录,或用 SOURCE_SCRIPT_PATH 指定)`)
}
const files = fs.readdirSync(SOURCES_DIR).filter(f => f.toLowerCase().endsWith('.js')).sort()
if (!files.length) {
throw new Error('sources/ 目录下没有 .js 音源脚本(将脚本放入该目录,或用 SOURCE_SCRIPT_PATH 指定)')
}
if (files.length > 1) {
console.log(`[source] sources/ 下有 ${files.length} 个脚本,使用 ${files[0]}(可用 SOURCE_SCRIPT_PATH 指定)`)
}
return path.join(SOURCES_DIR, files[0])
}
let sharedPromise = null
const getShared = () => {
if (!sharedPromise) {
// 配置可经环境变量调整,与项目其它适配器保持一致风格
sharedPromise = createIvmSource({
scriptPath: resolveScriptPath(),
log: process.env.SOURCE_LOG !== '0',
allowAllHttp: process.env.SOURCE_ALLOW_ALL_HTTP === '1',
}).catch(err => {
sharedPromise = null // 失败允许下次重试
throw err
})
}
return sharedPromise
}
const apiFor = (source) => ({
getMusicUrl(songInfo, type) {
const requestObj = { cancelHttp() {}, promise: null }
let inner = null
requestObj.cancelHttp = () => inner?.cancelHttp()
requestObj.promise = (async () => {
const api = await getShared()
if (!api[source]) throw new Error(`脚本不支持的平台: ${source}`)
inner = api[source].getMusicUrl(songInfo, type)
return inner.promise
})()
return requestObj
},
})
// 兼容导出:musicSdk/api-source.js 以 createApi(source) 方式接入聚合音源
export const createApi = apiFor
const sourceApi = {}
for (const s of SOURCES) sourceApi[s] = apiFor(s)
// 预热:提前触发脚本加载与初始化(可选调用)
sourceApi.init = getShared
export default sourceApi
-55
View File
@@ -1,55 +0,0 @@
// QQ音乐 URL 获取(直连)
// 接口: https://u.y.qq.com/cgi-bin/musicu.fcg
// 方法: vkey.GetVkeyServer.CgiGetVkey
// 统一接口约定:apis('tx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
import { httpFetch } from '../common/request.js'
const buildData = songmid => ({
comm: {
ct: '19',
cv: '1859',
uin: '0',
},
req: {
module: 'vkey.GetVkeyServer',
method: 'CgiGetVkey',
param: {
guid: '7342827269',
songmid: [songmid],
songtype: [0],
uin: '0',
loginflag: 1,
platform: '20',
needNewCode: 1,
},
},
})
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null }
requestObj.promise = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
Referer: 'https://y.qq.com/',
},
body: buildData(songInfo.songmid),
timeout: 15000,
}).promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 0 || body.req?.code != 0) {
return Promise.reject(new Error('获取失败'))
}
const data = body.req.data
const sip = data.sip?.[0] || 'http://aqqmusic.tc.qq.com/'
// 格式: C400{media_mid}.m4a?guid=&vkey=&uin=&fromtag=
const params = data.testfile2g?.split('?')[1] || ''
const url = `${sip}C400${songInfo.songmid}.m4a?${params}`
if (!params) return Promise.reject(new Error('获取失败'))
return { type, url }
})
return requestObj
}
export default { getMusicUrl }
-47
View File
@@ -1,47 +0,0 @@
// 网易云音乐 URL 获取(直连)
// 接口: https://interface3.music.163.com/eapi/song/enhance/player/url/v1(eapi 加密请求,响应为明文 JSON)
// 音质: 128k → standard, 320k → exhigh, flac → lossless
// 统一接口约定:apis('wy').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
import { httpFetch } from '../common/request.js'
import { eapi } from '../../musicSdk/wy/utils/crypto.ts'
import { getWyCookie } from '../auth/store.js'
const levelMap = {
'128k': 'standard',
'320k': 'exhigh',
flac: 'lossless',
flac24bit: 'hires',
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null }
const data = {
ids: `[${songInfo.songmid}]`,
level: levelMap[type] || 'standard',
encodeType: 'flac',
}
const cookie = getWyCookie()
const request = httpFetch('https://interface3.music.163.com/eapi/song/enhance/player/url/v1', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
origin: 'https://music.163.com',
...(cookie ? { cookie } : {}),
},
form: eapi('/api/song/enhance/player/url/v1', data),
timeout: 15000,
})
requestObj.promise = request.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 200) {
return Promise.reject(new Error('获取失败'))
}
const item = body.data?.[0]
if (!item?.url) return Promise.reject(new Error('获取失败'))
return { type, url: item.url }
})
return requestObj
}
export default { getMusicUrl }
-103
View File
@@ -1,103 +0,0 @@
// 酷狗登录 —— token 绑定与验证(网关 android 签名,与 KuGouMusicApi 对齐)
// 完整的手机号/扫码登录需移植其 RSA/AES 全套协议,后续按需扩展;
// token 可从酷狗 App 抓包获取(login.token)
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.js'
import { getCredentials, setCredentials } from './store.js'
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp'
const APPID = 1005
const CLIENTVER = 11430
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
-----END PUBLIC KEY-----`
const calculateMid = (guid) => BigInt(`0x${md5(guid)}`).toString()
const randomString = (n) => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
export const androidSign = (params, data = '') => {
const s = Object.keys(params).sort()
.map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`)
.join('')
return md5(SALT_SIGN + s + data + SALT_SIGN)
}
/** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */
export const getDevice = () => {
const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) }
if (!dev.mid) {
dev.mid = calculateMid(dev.guid)
setCredentials('_kg_device', dev)
}
return dev
}
/** 网关移动端签名请求(gateway.kugou.com + x-router) */
const gatewayRequest = async(url, { params: extraParams, data, router }) => {
const dev = getDevice()
const params = {
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000),
...extraParams,
}
const signature = androidSign(params, data ? JSON.stringify(data) : '')
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`https://gateway.kugou.com${url}?${qs}&signature=${signature}`, {
method: 'post',
headers: {
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
'x-router': router,
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
},
body: data ?? undefined,
timeout: 15000,
}).promise
return r.body ?? {}
}
/**
* 验证 token 并返回账号资料(usercenter /v3/get_my_info)
* p = RSA 原始加密(零填充 128 字节){ token, clienttime }
*/
export const verifyToken = async({ token, userid }) => {
const unixSec = Math.floor(Date.now() / 1000)
const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec }))
const padded = Buffer.concat([Buffer.alloc(128 - payload.length), payload])
const p = crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex').toUpperCase()
const body = await gatewayRequest('/v3/get_my_info', {
router: 'usercenter.kugou.com',
params: { plat: 1 },
data: { visit_time: unixSec, usertype: 1, p, userid: Number(userid) || 0 },
})
if (body.status !== 1 || !body.data) {
const err = new Error(body.error ?? 'token 无效或已过期')
err.status = 401
throw err
}
return {
userid: body.data.userid ?? Number(userid),
nickname: body.data.nickname ?? body.data.username ?? null,
vip: body.data.vip ? Boolean(body.data.vip) : null,
}
}
/** 绑定并验证 kg token */
export const loginByToken = async({ token, userid }) => {
const profile = await verifyToken({ token, userid })
setCredentials('kg', { token, userid: profile.userid, nickname: profile.nickname })
return profile
}
/** 当前 kg 登录资料(未登录返回 null) */
export const status = () => {
const cred = getCredentials('kg')
return cred?.token ? { userid: cred.userid, nickname: cred.nickname ?? null } : null
}
-45
View File
@@ -1,45 +0,0 @@
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
// 结构:{ wy: {...}, kg: {...}, _kg_device: {...} }
// 注意:本文件经 tsc 就地发射 store.js 供纯 JS 模块引用(与 src/apis/index.ts 同约定)
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json');
let cache = {};
let loaded = false;
const load = () => {
if (loaded)
return cache;
try {
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {};
}
catch {
cache = {};
}
loaded = true;
return cache;
};
const save = (data) => {
cache = data;
loaded = true;
mkdirSync(dirname(DATA_FILE), { recursive: true });
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
};
export const getCredentials = (platform) => load()[platform] ?? null;
export const setCredentials = (platform, credentials) => {
const data = load();
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() };
save(data);
};
export const clearCredentials = (platform) => {
const data = load();
delete data[platform];
save(data);
};
/** wy 登录 cookie 字符串(未登录返回空串) */
export const getWyCookie = () => getCredentials('wy')?.cookie || '';
/** kg 登录态(未登录返回 null) */
export const getKgAuth = () => {
const cred = getCredentials('kg');
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null;
};
-77
View File
@@ -1,77 +0,0 @@
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
// 结构:{ wy: {...}, kg: {...}, _kg_device: {...} }
// 注意:本文件经 tsc 就地发射 store.js 供纯 JS 模块引用(与 src/apis/index.ts 同约定)
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json')
export interface WyCredentials {
cookie: string
musicU: string
userid?: number
nickname?: string | null
avatar?: string | null
vipType?: number | null
updatedAt?: string
}
export interface KgCredentials {
token: string
userid: number | string
nickname?: string | null
updatedAt?: string
}
export interface KgDevice {
guid: string
mid: string
dfid: string
updatedAt?: string
}
let cache: Record<string, any> = {}
let loaded = false
const load = (): Record<string, any> => {
if (loaded) return cache
try {
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
} catch {
cache = {}
}
loaded = true
return cache
}
const save = (data: Record<string, any>): void => {
cache = data
loaded = true
mkdirSync(dirname(DATA_FILE), { recursive: true })
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
}
export const getCredentials = (platform: string): any => load()[platform] ?? null
export const setCredentials = (platform: string, credentials: Record<string, any>): void => {
const data = load()
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() }
save(data)
}
export const clearCredentials = (platform: string): void => {
const data = load()
delete data[platform]
save(data)
}
/** wy 登录 cookie 字符串(未登录返回空串) */
export const getWyCookie = (): string => (getCredentials('wy') as WyCredentials | null)?.cookie || ''
/** kg 登录态(未登录返回 null) */
export const getKgAuth = (): { token: string; userid: number | string } | null => {
const cred = getCredentials('kg') as KgCredentials | null
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null
}
-120
View File
@@ -1,120 +0,0 @@
// 网易云登录 —— 扫码 / 手机号,weapi 加密请求
// 参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.js'
import { weapi } from '../../musicSdk/wy/utils/crypto.ts'
import { getWyCookie, setCredentials } from './store.js'
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
const requestWeapi = async(path, data, cookie = getWyCookie()) => {
const r = await httpFetch(`https://music.163.com/weapi${path}`, {
method: 'post',
headers: {
'User-Agent': UA,
origin: 'https://music.163.com',
Referer: 'https://music.163.com/',
...(cookie ? { cookie } : {}),
},
form: weapi(data),
timeout: 15000,
}).promise
if (r.statusCode !== 200) throw new Error('网易请求失败')
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
}
/** 明文 api 请求(二维码接口只提供该形态,weapi 形态已 404) */
const requestApi = async(path, data) => {
const r = await httpFetch(`https://music.163.com${path}`, {
method: 'post',
headers: {
'User-Agent': UA,
origin: 'https://music.163.com',
Referer: 'https://music.163.com/',
},
form: data,
timeout: 15000,
}).promise
if (r.statusCode !== 200) throw new Error('网易请求失败')
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
}
/** 从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U */
const extractLoginCookie = (setCookie) => {
const joined = setCookie.map(c => c.split(';')[0]).filter(Boolean).join('; ')
const musicU = /MUSIC_U=([^;]+)/.exec(joined)?.[1]
return { cookie: joined, musicU }
}
/** 登录成功后拉取账号资料 */
const fetchProfile = async() => {
const { body } = await requestWeapi('/api/w/nuser/account/get', {})
return {
userid: body?.account?.id ?? null,
nickname: body?.profile?.nickname ?? null,
avatar: body?.profile?.avatarUrl ?? null,
vipType: body?.profile?.vipType ?? null,
}
}
/** 应用登录 cookie(成功后调用) */
const applyLogin = async(setCookie) => {
const { cookie, musicU } = extractLoginCookie(setCookie)
if (!musicU) throw new Error('登录响应中缺少 MUSIC_U')
const profile = await fetchProfile()
setCredentials('wy', { cookie, musicU, ...profile })
return profile
}
/** 创建扫码登录 key 与二维码内容 */
export const qrKey = async() => {
const { body } = await requestApi('/api/login/qrcode/unikey', { type: 3 })
if (body?.code !== 200 || !body.unikey) throw new Error('获取二维码 key 失败')
const key = body.unikey
const qrurl = `https://music.163.com/login?codekey=${key}`
const qrimg = await import('qrcode').then(m => m.default.toDataURL(qrurl)).catch(() => '')
return { key, qrurl, qrimg }
}
/**
* 轮询扫码状态
* @returns {{ code: 800|801|802|803, message?: string, profile? }} 800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)
*/
export const qrCheck = async(key) => {
const { body, setCookie } = await requestApi('/api/login/qrcode/client/login', { key, type: 3 })
if (body.code === 803) {
const profile = await applyLogin(setCookie)
return { code: 803, message: '登录成功', profile }
}
const messages = { 800: '二维码已过期', 801: '等待扫码', 802: '已扫码,待确认' }
return { code: body.code, message: messages[body.code] ?? body.message ?? '未知状态' }
}
/**
* 手机号登录(密码为明文,传输前本地 MD5;或直接传验证码 captcha)
*/
export const cellphoneLogin = async({ phone, password, captcha, countrycode = '86' }) => {
const data = {
type: '1',
https: 'true',
phone,
countrycode,
remember: 'true',
[captcha ? 'captcha' : 'password']: captcha ?? crypto.createHash('md5').update(password ?? '').digest('hex'),
}
const { body, setCookie } = await requestWeapi('/api/w/login/cellphone', data)
if (body.code !== 200) {
const err = new Error(body.message ?? body.msg ?? '登录失败')
err.status = 401
throw err
}
const profile = await applyLogin(setCookie)
return profile
}
/** 当前账号资料(未登录返回 null) */
export const status = async() => {
if (!getWyCookie()) return null
return fetchProfile()
}
-8
View File
@@ -1,8 +0,0 @@
// IPC 事件名
export const WIN_MAIN_RENDERER_EVENT_NAME = {
handle_kw_decode_lyric: 'handle_kw_decode_lyric',
handle_tx_decode_lyric: 'handle_tx_decode_lyric',
}
export default { WIN_MAIN_RENDERER_EVENT_NAME }
-66
View File
@@ -1,66 +0,0 @@
// 歌词解码(原主进程 IPC 处理器的纯 JS 实现)
// - handle_kw_decode_lyric: 酷我歌词(inflate + yeelion XOR + gb18030)
// 算法来源: kw 桌面端歌词解码实现
// - handle_tx_decode_lyric: QQ音乐歌词(qrc 解码:hex → XOR 偏移 → deflate)
// qrc 算法来源: 开源 qrc 解码器(ZhuYiQing/qrcDecode 纯 JS 版)
import zlib from 'node:zlib'
import iconv from 'iconv-lite'
// ==================== 酷我 ====================
const handleInflate = data => new Promise((resolve, reject) => {
zlib.inflate(data, (err, result) => {
if (err) return reject(err)
resolve(result)
})
})
const buf_key = Buffer.from('yeelion')
const buf_key_len = buf_key.length
const decodeKwLyric = async(buf, isGetLyricx) => {
if (buf.toString('utf8', 0, 10) != 'tp=content') return ''
const lrcData = await handleInflate(buf.subarray(buf.indexOf('\r\n\r\n') + 4))
if (!isGetLyricx) return iconv.decode(lrcData, 'gb18030')
const buf_str = Buffer.from(lrcData.toString(), 'base64')
const buf_str_len = buf_str.length
const output = new Uint8Array(buf_str_len)
let i = 0
while (i < buf_str_len) {
let j = 0
while (j < buf_key_len && i < buf_str_len) {
output[i] = buf_str[i] ^ buf_key[j]
i++
j++
}
}
return iconv.decode(Buffer.from(output), 'gb18030')
}
/**
* 酷我歌词解码
* @param {{ lrcBase64: string, isGetLyricx: boolean }} params
* @returns {Promise<string>} 解码后歌词的 base64
*/
export const handleKwDecodeLyric = async({ lrcBase64, isGetLyricx }) => {
const lrc = await decodeKwLyric(Buffer.from(lrcBase64, 'base64'), isGetLyricx)
return Buffer.from(lrc).toString('base64')
}
// ==================== QQ音乐(qrc) ====================
import { decodeQrc } from './qrcDecode.js'
/**
* QQ音乐歌词解码
* @param {{ lrc: string, tlrc: string, rlrc: string }} params 均为十六进制字符串
* @returns {Promise<{lyric: string, tlyric: string, rlyric: string}>}
*/
export const handleTxDecodeLyric = async({ lrc, tlrc, rlrc }) => {
const [lyric, tlyric, rlyric] = await Promise.all([decodeQrc(lrc), decodeQrc(tlrc), decodeQrc(rlrc)])
return { lyric, tlyric, rlyric }
}
-78
View File
@@ -1,78 +0,0 @@
// 酷狗 krc 歌词解码
import zlib from 'node:zlib'
import { decodeName } from '../utils.js'
// 基于社区通用的 krc 解析思路
const enc_key = Buffer.from([0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69], 'binary')
const decodeLyric = str => new Promise((resolve, reject) => {
if (!str.length) return
const buf_str = Buffer.from(str, 'base64').subarray(4)
for (let i = 0, len = buf_str.length; i < len; i++) {
buf_str[i] = buf_str[i] ^ enc_key[i % 16]
}
zlib.inflate(buf_str, (err, result) => {
if (err) return reject(err)
resolve(result.toString())
})
})
const headExp = /^.*\[id:\$\w+\]\n/
const parseLyric = str => {
str = str.replace(/\r/g, '')
if (headExp.test(str)) str = str.replace(headExp, '')
let trans = str.match(/\[language:([\w=\\/+]+)\]/)
let lyric
let rlyric
let tlyric
if (trans) {
str = str.replace(/\[language:[\w=\\/+]+\]\n/, '')
let json = JSON.parse(Buffer.from(trans[1], 'base64').toString())
for (const item of json.content) {
switch (item.type) {
case 0:
rlyric = item.lyricContent
break
case 1:
tlyric = item.lyricContent
break
}
}
}
let i = 0
let yrc = str.replace(/\[((\d+),\d+)\].*/g, str => {
let result = str.match(/\[((\d+),\d+)\].*/)
let time = parseInt(result[2])
let ms = time % 1000
time /= 1000
let m = parseInt(time / 60).toString().padStart(2, '0')
time %= 60
let s = parseInt(time).toString().padStart(2, '0')
time = `${m}:${s}.${ms}`
if (rlyric) rlyric[i] = `[${time}]${rlyric[i]?.join('') ?? ''}`
if (tlyric) tlyric[i] = `[${time}]${tlyric[i]?.join('') ?? ''}`
i++
return str.replace(result[1], time)
})
rlyric = rlyric ? rlyric.join('\n') : ''
tlyric = tlyric ? tlyric.join('\n') : ''
yrc = yrc.replace(/<(\d+,\d+),\d+>/g, '<$1>')
yrc = decodeName(yrc)
lyric = yrc.replace(/<\d+,\d+>/g, '')
rlyric = decodeName(rlyric)
tlyric = decodeName(tlyric)
return {
lyric,
tlyric,
rlyric,
yrc,
}
}
export const decodeKrc = async data => {
return decodeLyric(data).then(parseLyric)
}
export default { decodeKrc }
-10
View File
@@ -1,10 +0,0 @@
// 请求提示消息
export const requestMsg = {
cancelRequest: '请求已取消',
fail: '请求失败',
timeout: '请求超时',
tooManyRequests: '请求过多',
notConnectNetwork: '无法连接到服务器',
unachievable: '无法访问',
}
-252
View File
@@ -1,252 +0,0 @@
import { inflate, constants } from 'zlib'
/**
* QRC 歌词解密(3DES-ECB + zlib inflate)
*
* 由原 C++ native 插件(qrc_decode.node)1:1 移植为纯 JS 实现。
* 加密算法为标准 3DES(DES-EDE3,ECB 模式、无填充),密钥固定。
* 数据流程:hex 字符串 -> 字节 -> 3DES 解密 -> zlib inflate -> UTF-8 文本。
*/
const DES_ENCRYPT = 1
const DES_DECRYPT = 0
// DES S-box。注意:QRC 使用的是非标准 DES 变体,
// 其中 S2[23]=15、S4[53]=10 两处与标准 DES 不同(标准为 14、1)。
// 这两个值与原 native 插件(qrc_decode.node)二进制完全一致,不可改为标准值,
// 否则解密结果错误。下表逐字节对应原 C++ 实现。
const sbox = [
[
14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13,
],
[
15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10, 6, 9, 11, 5,
0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9,
],
[
10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12,
],
[
7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14,
],
[
2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3,
],
[
12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13,
],
[
4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12,
],
[
13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11,
],
]
const key_rnd_shift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
const key_perm_c = [
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35,
]
const key_perm_d = [
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4, 27, 19, 11, 3,
]
const key_compression = [
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51, 30, 36, 46,
54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31,
]
// 全部以无符号 32 位处理,>>> 0 保证溢出行为与 C++ uint32_t 一致
const bitnum = (a, b, c) => (((a[((b / 32) | 0) * 4 + 3 - (((b % 32) / 8) | 0)] >>> (7 - (b % 8))) & 1) << c) >>> 0
const bitnum_intr = (a, b, c) => (((a >>> (31 - b)) & 1) << c) >>> 0
const bitnum_intl = (a, b, c) => (((((a << b) >>> 0) & 0x80000000) >>> 0) >>> c) >>> 0
const sbox_bit = a => (a & 32) | ((a & 31) >>> 1) | ((a & 1) << 4)
// 初始置换:返回 [s0, s1]
const initial_permutation = (input) => {
const s0 = (
bitnum(input, 57, 31) | bitnum(input, 49, 30) | bitnum(input, 41, 29) | bitnum(input, 33, 28) |
bitnum(input, 25, 27) | bitnum(input, 17, 26) | bitnum(input, 9, 25) | bitnum(input, 1, 24) |
bitnum(input, 59, 23) | bitnum(input, 51, 22) | bitnum(input, 43, 21) | bitnum(input, 35, 20) |
bitnum(input, 27, 19) | bitnum(input, 19, 18) | bitnum(input, 11, 17) | bitnum(input, 3, 16) |
bitnum(input, 61, 15) | bitnum(input, 53, 14) | bitnum(input, 45, 13) | bitnum(input, 37, 12) |
bitnum(input, 29, 11) | bitnum(input, 21, 10) | bitnum(input, 13, 9) | bitnum(input, 5, 8) |
bitnum(input, 63, 7) | bitnum(input, 55, 6) | bitnum(input, 47, 5) | bitnum(input, 39, 4) |
bitnum(input, 31, 3) | bitnum(input, 23, 2) | bitnum(input, 15, 1) | bitnum(input, 7, 0)
) >>> 0
const s1 = (
bitnum(input, 56, 31) | bitnum(input, 48, 30) | bitnum(input, 40, 29) | bitnum(input, 32, 28) |
bitnum(input, 24, 27) | bitnum(input, 16, 26) | bitnum(input, 8, 25) | bitnum(input, 0, 24) |
bitnum(input, 58, 23) | bitnum(input, 50, 22) | bitnum(input, 42, 21) | bitnum(input, 34, 20) |
bitnum(input, 26, 19) | bitnum(input, 18, 18) | bitnum(input, 10, 17) | bitnum(input, 2, 16) |
bitnum(input, 60, 15) | bitnum(input, 52, 14) | bitnum(input, 44, 13) | bitnum(input, 36, 12) |
bitnum(input, 28, 11) | bitnum(input, 20, 10) | bitnum(input, 12, 9) | bitnum(input, 4, 8) |
bitnum(input, 62, 7) | bitnum(input, 54, 6) | bitnum(input, 46, 5) | bitnum(input, 38, 4) |
bitnum(input, 30, 3) | bitnum(input, 22, 2) | bitnum(input, 14, 1) | bitnum(input, 6, 0)
) >>> 0
return [s0, s1]
}
// 逆置换:将 s0/s1 写回 8 字节 out
const inverse_permutation = (s0, s1, out) => {
out[3] = (bitnum_intr(s1, 7, 7) | bitnum_intr(s0, 7, 6) | bitnum_intr(s1, 15, 5) | bitnum_intr(s0, 15, 4) | bitnum_intr(s1, 23, 3) | bitnum_intr(s0, 23, 2) | bitnum_intr(s1, 31, 1) | bitnum_intr(s0, 31, 0)) & 0xff
out[2] = (bitnum_intr(s1, 6, 7) | bitnum_intr(s0, 6, 6) | bitnum_intr(s1, 14, 5) | bitnum_intr(s0, 14, 4) | bitnum_intr(s1, 22, 3) | bitnum_intr(s0, 22, 2) | bitnum_intr(s1, 30, 1) | bitnum_intr(s0, 30, 0)) & 0xff
out[1] = (bitnum_intr(s1, 5, 7) | bitnum_intr(s0, 5, 6) | bitnum_intr(s1, 13, 5) | bitnum_intr(s0, 13, 4) | bitnum_intr(s1, 21, 3) | bitnum_intr(s0, 21, 2) | bitnum_intr(s1, 29, 1) | bitnum_intr(s0, 29, 0)) & 0xff
out[0] = (bitnum_intr(s1, 4, 7) | bitnum_intr(s0, 4, 6) | bitnum_intr(s1, 12, 5) | bitnum_intr(s0, 12, 4) | bitnum_intr(s1, 20, 3) | bitnum_intr(s0, 20, 2) | bitnum_intr(s1, 28, 1) | bitnum_intr(s0, 28, 0)) & 0xff
out[7] = (bitnum_intr(s1, 3, 7) | bitnum_intr(s0, 3, 6) | bitnum_intr(s1, 11, 5) | bitnum_intr(s0, 11, 4) | bitnum_intr(s1, 19, 3) | bitnum_intr(s0, 19, 2) | bitnum_intr(s1, 27, 1) | bitnum_intr(s0, 27, 0)) & 0xff
out[6] = (bitnum_intr(s1, 2, 7) | bitnum_intr(s0, 2, 6) | bitnum_intr(s1, 10, 5) | bitnum_intr(s0, 10, 4) | bitnum_intr(s1, 18, 3) | bitnum_intr(s0, 18, 2) | bitnum_intr(s1, 26, 1) | bitnum_intr(s0, 26, 0)) & 0xff
out[5] = (bitnum_intr(s1, 1, 7) | bitnum_intr(s0, 1, 6) | bitnum_intr(s1, 9, 5) | bitnum_intr(s0, 9, 4) | bitnum_intr(s1, 17, 3) | bitnum_intr(s0, 17, 2) | bitnum_intr(s1, 25, 1) | bitnum_intr(s0, 25, 0)) & 0xff
out[4] = (bitnum_intr(s1, 0, 7) | bitnum_intr(s0, 0, 6) | bitnum_intr(s1, 8, 5) | bitnum_intr(s0, 8, 4) | bitnum_intr(s1, 16, 3) | bitnum_intr(s0, 16, 2) | bitnum_intr(s1, 24, 1) | bitnum_intr(s0, 24, 0)) & 0xff
}
// Feistel 轮函数 f(state, key)
const des_f = (state, key) => {
const t1 = (
bitnum_intl(state, 31, 0) | (((state & 0xF0000000) >>> 0) >>> 1) | bitnum_intl(state, 4, 5) |
bitnum_intl(state, 3, 6) | (((state & 0x0F000000) >>> 0) >>> 3) | bitnum_intl(state, 8, 11) |
bitnum_intl(state, 7, 12) | (((state & 0x00F00000) >>> 0) >>> 5) | bitnum_intl(state, 12, 17) |
bitnum_intl(state, 11, 18) | (((state & 0x000F0000) >>> 0) >>> 7) | bitnum_intl(state, 16, 23)
) >>> 0
const t2 = (
bitnum_intl(state, 15, 0) | (((state & 0x0000F000) << 15) >>> 0) | bitnum_intl(state, 20, 5) |
bitnum_intl(state, 19, 6) | (((state & 0x00000F00) << 13) >>> 0) | bitnum_intl(state, 24, 11) |
bitnum_intl(state, 23, 12) | (((state & 0x000000F0) << 11) >>> 0) | bitnum_intl(state, 28, 17) |
bitnum_intl(state, 27, 18) | (((state & 0x0000000F) << 9) >>> 0) | bitnum_intl(state, 0, 23)
) >>> 0
const lrgstate = [
(t1 >>> 24) & 0xFF, (t1 >>> 16) & 0xFF, (t1 >>> 8) & 0xFF,
(t2 >>> 24) & 0xFF, (t2 >>> 16) & 0xFF, (t2 >>> 8) & 0xFF,
]
for (let i = 0; i < 6; i++) lrgstate[i] ^= key[i]
let s = (
(sbox[0][sbox_bit(lrgstate[0] >>> 2)] << 28) |
(sbox[1][sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) |
(sbox[2][sbox_bit(((lrgstate[1] & 0x0F) << 2) | (lrgstate[2] >>> 6))] << 20) |
(sbox[3][sbox_bit(lrgstate[2] & 0x3F)] << 16) |
(sbox[4][sbox_bit(lrgstate[3] >>> 2)] << 12) |
(sbox[5][sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) |
(sbox[6][sbox_bit(((lrgstate[4] & 0x0F) << 2) | (lrgstate[5] >>> 6))] << 4) |
sbox[7][sbox_bit(lrgstate[5] & 0x3F)]
) >>> 0
return (
bitnum_intl(s, 15, 0) | bitnum_intl(s, 6, 1) | bitnum_intl(s, 19, 2) | bitnum_intl(s, 20, 3) |
bitnum_intl(s, 28, 4) | bitnum_intl(s, 11, 5) | bitnum_intl(s, 27, 6) | bitnum_intl(s, 16, 7) |
bitnum_intl(s, 0, 8) | bitnum_intl(s, 14, 9) | bitnum_intl(s, 22, 10) | bitnum_intl(s, 25, 11) |
bitnum_intl(s, 4, 12) | bitnum_intl(s, 17, 13) | bitnum_intl(s, 30, 14) | bitnum_intl(s, 9, 15) |
bitnum_intl(s, 1, 16) | bitnum_intl(s, 7, 17) | bitnum_intl(s, 23, 18) | bitnum_intl(s, 13, 19) |
bitnum_intl(s, 31, 20) | bitnum_intl(s, 26, 21) | bitnum_intl(s, 2, 22) | bitnum_intl(s, 8, 23) |
bitnum_intl(s, 18, 24) | bitnum_intl(s, 12, 25) | bitnum_intl(s, 29, 26) | bitnum_intl(s, 5, 27) |
bitnum_intl(s, 21, 28) | bitnum_intl(s, 10, 29) | bitnum_intl(s, 3, 30) | bitnum_intl(s, 24, 31)
) >>> 0
}
// 单次 DES 加/解密一个 8 字节分组(轮密钥 schedule 为 16x6)
const des_crypt = (input, schedule, output) => {
let [s0, s1] = initial_permutation(input)
for (let i = 0; i < 15; i++) {
const prev = s1
s1 = (des_f(s1, schedule[i]) ^ s0) >>> 0
s0 = prev
}
s0 = (des_f(s1, schedule[15]) ^ s0) >>> 0
inverse_permutation(s0, s1, output)
}
// 生成 16 轮子密钥
const key_schedule = (key, mode) => {
const schedule = Array.from({ length: 16 }, () => new Uint8Array(6))
let c = 0
let d = 0
for (let i = 0; i < 28; i++) {
c = (c | bitnum(key, key_perm_c[i], 31 - i)) >>> 0
d = (d | bitnum(key, key_perm_d[i], 31 - i)) >>> 0
}
for (let i = 0; i < 16; i++) {
c = ((((c << key_rnd_shift[i]) >>> 0) | (c >>> (28 - key_rnd_shift[i]))) & 0xFFFFFFF0) >>> 0
d = ((((d << key_rnd_shift[i]) >>> 0) | (d >>> (28 - key_rnd_shift[i]))) & 0xFFFFFFF0) >>> 0
const togen = mode === DES_DECRYPT ? 15 - i : i
for (let j = 0; j < 24; j++) {
schedule[togen][(j / 8) | 0] |= bitnum_intr(c, key_compression[j], 7 - (j % 8))
}
for (let j = 24; j < 48; j++) {
schedule[togen][(j / 8) | 0] |= bitnum_intr(d, key_compression[j] - 27, 7 - (j % 8))
}
}
return schedule
}
// 3DES(EDE3)密钥编排,返回 3 组 schedule
const tripledes_key_setup = (key, mode) => {
if (mode === DES_ENCRYPT) {
return [
key_schedule(key.subarray(0, 8), DES_ENCRYPT),
key_schedule(key.subarray(8, 16), DES_DECRYPT),
key_schedule(key.subarray(16, 24), DES_ENCRYPT),
]
}
return [
key_schedule(key.subarray(16, 24), DES_DECRYPT),
key_schedule(key.subarray(8, 16), DES_ENCRYPT),
key_schedule(key.subarray(0, 8), DES_DECRYPT),
]
}
// 3DES 加/解密一个 8 字节分组
const tripledes_crypt = (input, schedule, output) => {
const buf = new Uint8Array(8)
des_crypt(input, schedule[0], buf)
des_crypt(buf, schedule[1], output)
des_crypt(output, schedule[2], buf)
output.set(buf)
}
// QRC 固定密钥:!@#)(*$%123ZXC!@!@#)(NHL
const QRC_KEY = Buffer.from([
0x21, 0x40, 0x23, 0x29, 0x28, 0x2a, 0x24, 0x25, 0x31, 0x32, 0x33, 0x5a,
0x58, 0x43, 0x21, 0x40, 0x21, 0x40, 0x23, 0x29, 0x28, 0x4e, 0x48, 0x4c,
])
const handleInflate = (data) => new Promise((resolve, reject) => {
// Z_SYNC_FLUSH:容忍尾部不完整的 zlib 流(对应原实现忽略 Z_BUF_ERROR 的行为)
inflate(data, { finishFlush: constants.Z_SYNC_FLUSH }, (err, result) => {
if (err) reject(err)
else resolve(result)
})
})
/**
* 解密腾讯 QRC 歌词。
* @param {string} hexData 服务端返回的十六进制字符串
* @returns {Promise<string>} 解密并解压后的歌词文本(解析失败时返回空字符串)
*/
export const decodeQrc = async(hexData) => {
if (!hexData || hexData.length % 2 !== 0) return ''
const encrypted = Buffer.from(hexData, 'hex')
if (encrypted.length === 0) return ''
const schedule = tripledes_key_setup(QRC_KEY, DES_DECRYPT)
const block = new Uint8Array(8)
for (let i = 0; i + 8 <= encrypted.length; i += 8) {
tripledes_crypt(encrypted.subarray(i, i + 8), schedule, block)
encrypted.set(block, i)
}
try {
const result = await handleInflate(encrypted)
return result.toString('utf8')
} catch {
return ''
}
}
-33
View File
@@ -1,33 +0,0 @@
// 渲染器 IPC 桥(替代 Electron ipcRenderer.invoke)
// 服务器环境下 IPC 调用直接映射到本地实现:
// handle_kw_decode_lyric → 酷我歌词解码(见 lyricDecode.js)
// handle_tx_decode_lyric → QQ音乐歌词解码(见 lyricDecode.js)
import { handleKwDecodeLyric, handleTxDecodeLyric } from './lyricDecode.js'
const localHandlers = {
[WIN_MAIN_RENDERER_EVENT_NAME.handle_kw_decode_lyric]: handleKwDecodeLyric,
[WIN_MAIN_RENDERER_EVENT_NAME.handle_tx_decode_lyric]: handleTxDecodeLyric,
}
/**
* 调用主进程能力(本地直调)
* @param {string} name 事件名
* @param {*} params 参数
*/
export const rendererInvoke = async(name, params) => {
const handler = localHandlers[name]
if (!handler) throw new Error(`Unsupported invoke: ${name}`)
return handler(params)
}
export const rendererSend = () => {}
export const rendererOn = () => {}
export const rendererOnce = () => {}
export const rendererOff = () => {}
export const rendererOffAll = () => {}
export const rendererSendSync = () => {}
import { WIN_MAIN_RENDERER_EVENT_NAME } from './ipcNames.js'
export default { rendererInvoke, rendererSend, rendererOn, rendererOnce, rendererOff, rendererOffAll, rendererSendSync }
-174
View File
@@ -1,174 +0,0 @@
// 网络请求模块
// 接口与原版保持一致:
// httpFetch(url, { method, headers, body, form, timeout, lookup, family })
// → { promise, cancelHttp }
// promise resolve → { statusCode, body, headers, raw }
// - body:JSON 已解析对象,或原始字符串(HTML/文本)
// - raw:响应体 Buffer
// promise reject → 错误(timeout 请求超时 / canceled 主动取消)
// 注意:promise 与 cancelHttp 均为普通可赋值属性(SDK 多处会重写 promise)
import http from 'node:http'
import https from 'node:https'
import zlib from 'node:zlib'
import { URL } from 'node:url'
const MAX_BUFFER_SIZE = 64 * 1024 * 1024 // 64MB
const defaultHeaders = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
}
const parseBody = (raw) => {
if (!raw || !raw.length) return null
const text = raw.toString('utf8')
// 与原版一致:无论 content-type 都尝试 JSON 解析,失败则返回原始文本
try {
return JSON.parse(text)
} catch {
return text
}
}
/**
* 发起请求(返回带 promise 与 cancelHttp 的对象)
*/
const buildHttpPromise = (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase()
const obj = {
isCancelled: false,
cancelHttp: () => {
if (!obj.requestObj) {
obj.isCancelled = true
return
}
obj.requestObj.destroy(new Error('Canceled'))
obj.requestObj = null
obj.promise = obj.cancelHttp = null
obj.cancelFn?.(new Error('Canceled'))
obj.cancelFn = null
},
}
obj.promise = new Promise((resolve, reject) => {
obj.cancelFn = reject
let parsedUrl
try {
parsedUrl = new URL(url)
} catch (err) {
reject(err)
return
}
const isHttps = parsedUrl.protocol === 'https:'
const client = isHttps ? https : http
// 数据体构造:body(对象/字符串/Buffer)与 form(对象 → urlencoded)
let body = null
let isForm = false
if (options.form) {
const formBody = new URLSearchParams()
for (const key of Object.keys(options.form)) {
const value = options.form[key]
if (value == null) continue
formBody.append(key, typeof value === 'object' ? JSON.stringify(value) : value)
}
body = formBody.toString()
isForm = true
} else if (options.body) {
body = typeof options.body === 'object' && !Buffer.isBuffer(options.body) ? JSON.stringify(options.body) : options.body
}
const headers = { ...defaultHeaders, ...(options.headers || {}) }
if (body && !headers['content-type'] && !headers['Content-Type']) {
headers['content-type'] = isForm ? 'application/x-www-form-urlencoded' : 'application/json'
}
if (body) headers['content-length'] = Buffer.byteLength(body)
const requestOptions = {
method,
headers,
family: options.family || undefined,
}
if (options.lookup) requestOptions.lookup = options.lookup
const req = client.request(parsedUrl, requestOptions, res => {
obj.requestObj = null
obj.cancelFn = null
const statusCode = res.statusCode
let stream = res
switch (res.headers['content-encoding']) {
case 'gzip': stream = res.pipe(zlib.createGunzip()); break
case 'deflate': stream = res.pipe(zlib.createInflate()); break
case 'br': stream = res.pipe(zlib.createBrotliDecompress()); break
}
const chunks = []
let size = 0
stream.on('data', chunk => {
size += chunk.length
if (size > MAX_BUFFER_SIZE) {
stream.destroy(new Error('Response too large'))
return
}
chunks.push(chunk)
})
stream.on('error', err => reject(err))
stream.on('end', () => {
const raw = Buffer.concat(chunks)
resolve({
statusCode,
headers: res.headers,
body: parseBody(raw),
raw,
})
})
})
obj.requestObj = req
req.on('error', err => {
if (obj.isCancelled || err.canceled) err.canceled = true
reject(err)
})
if (options.timeout) {
req.setTimeout(options.timeout, () => {
const err = new Error('Request timeout')
err.timeout = true
req.destroy(err)
})
}
if (body) req.write(body)
req.end()
})
return obj
}
/**
* 请求超时/断网错误统一处理(与原版行为一致)
*/
export const httpFetch = (url, options = { method: 'get' }) => {
const requestObj = buildHttpPromise(url, options)
requestObj.promise = requestObj.promise.catch(err => {
if (err.canceled) return Promise.reject(err)
switch (err.code) {
case 'ETIMEDOUT':
case 'ESOCKETTIMEDOUT':
return Promise.reject(new Error(requestMsg.timeout))
case 'ENOTFOUND':
return Promise.reject(new Error(requestMsg.notConnectNetwork))
case 'ECONNRESET':
return Promise.reject(new Error(requestMsg.fail))
default:
return Promise.reject(err)
}
})
return requestObj
}
import { requestMsg } from './message.js'
export default { httpFetch }
-101
View File
@@ -1,101 +0,0 @@
// 通用工具函数
import crypto from 'node:crypto'
/** MD5 十六进制串 */
export const toMD5 = str => crypto.createHash('md5').update(str).digest('hex')
/**
* 将大小格式化为可读字符串
* @param {number} size 字节数
*/
export const sizeFormate = size => {
// https://gist.github.com/thomseddon/3511330
if (!size) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const number = Math.floor(Math.log(size) / Math.log(1024))
return `${(size / Math.pow(1024, Math.floor(number))).toFixed(2)} ${units[number]}`
}
/**
* 将秒数格式化为 mm:ss
* @param {number} time 秒
*/
export const formatPlayTime = time => {
if (Number.isNaN(time) || time < 0) time = 0
const m = Math.trunc(time / 60)
const s = Math.trunc(time % 60)
return m == 0 && s == 0 ? '--/--' : `${m < 10 ? '0' + m : m}:${s < 10 ? '0' + s : s}`
}
/**
* 将时间对象/时间戳转成日期字符串
* @param {Date|number|string} _date 时间
* @param {string} [format='Y-M-D h:m:s'] 格式模板(Y/M/D/h/m/s)
*/
export const dateFormat = (_date, format = 'Y-M-D h:m:s') => {
if (!_date) return ''
let date
switch (typeof _date) {
case 'string':
if (!_date.includes('T')) _date = _date.split('.')[0].replace(/-/g, '/')
// fallthrough
case 'number':
date = new Date(_date)
break
case 'object':
date = _date
break
default:
return ''
}
const numFix = n => n < 10 ? '0' + n : String(n)
return format
.replace('Y', date.getFullYear().toString())
.replace('M', numFix(date.getMonth() + 1))
.replace('D', numFix(date.getDate()))
.replace('h', numFix(date.getHours()))
.replace('m', numFix(date.getMinutes()))
.replace('s', numFix(date.getSeconds()))
}
/**
* 相对时间格式化(用于评论时间)
* @param {number} time 毫秒时间戳
*/
export const dateFormat2 = time => {
const differ = Math.trunc((Date.now() - time) / 1000)
if (differ < 60) return `${differ}秒前`
if (differ < 3600) return `${Math.trunc(differ / 60)}分钟前`
if (differ < 86400) return `${Math.trunc(differ / 3600)}小时前`
if (differ < 2592000) return `${Math.trunc(differ / 86400)}天前`
return dateFormat(time)
}
/**
* 格式化播放量
* @param {number} num
*/
export const formatPlayCount = num => {
if (num > 100000000) return `${Math.trunc(num / 10000000) / 10}亿`
if (num > 10000) return `${Math.trunc(num / 1000) / 10}万`
return String(num)
}
/**
* 解码 HTML 实体
* @param {string} str
*/
export const decodeName = (str = '') => {
if (!str) return ''
if (typeof str !== 'string') str = String(str)
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&#(\d+);/g, (match, num) => String.fromCharCode(parseInt(num)))
}
-22
View File
@@ -1,22 +0,0 @@
// API 业务错误 —— 路由处理中抛出,由服务器统一转换为对应 HTTP 状态码的 JSON 响应
export class ApiError extends Error {
/**
* @param {number} status HTTP 状态码(同时作为响应体中的 code)
* @param {string} msg 错误描述
* @param {unknown} [detail] 可选的结构化错误详情
*/
constructor(status, msg, detail) {
super(msg)
this.name = 'ApiError'
this.status = status
this.code = status
this.detail = detail
}
}
/** 资源不存在(未知路由 / 不支持的平台) */
export const notFound = (msg, detail) => new ApiError(404, msg, detail)
/** 上游平台请求失败 */
export const upstream = (msg, detail) => new ApiError(502, msg, detail)
+24
View File
@@ -0,0 +1,24 @@
// API 业务错误 —— 路由处理中抛出,由服务器统一转换为对应 HTTP 状态码的 JSON 响应
export class ApiError extends Error {
/** HTTP 状态码(同时作为响应体中的 code) */
status: number
/** 与 status 同值,历史别名 */
code: number
/** 可选的结构化错误详情 */
detail?: unknown
constructor(status: number, msg: string, detail?: unknown) {
super(msg)
this.name = 'ApiError'
this.status = status
this.code = status
this.detail = detail
}
}
/** 资源不存在(未知路由 / 不支持的平台) */
export const notFound = (msg: string, detail?: unknown) => new ApiError(404, msg, detail)
/** 上游平台请求失败 */
export const upstream = (msg: string, detail?: unknown) => new ApiError(502, msg, detail)
-11
View File
@@ -1,11 +0,0 @@
// SDK 根模块 —— musicSdk 内 `import xxx from '../../index.js'` 的解析目标
// 聚合导出通用工具函数
export {
sizeFormate,
formatPlayTime,
dateFormat,
dateFormat2,
formatPlayCount,
decodeName,
} from './common/utils.js'
@@ -33,7 +33,7 @@ export const COMPONENT_SCHEMAS = {
properties: {
songmid: { type: 'string', description: '平台歌曲 id(kg 平台为 hash)' },
hash: { type: 'string', description: 'kg 平台的歌曲 hash' },
copyrightId: { type: 'string', description: 'mg 平台的版权 id' },
copyrightId: { type: 'string', description: '平台版权 id(原 mg 平台使用)' },
albumId: { type: 'string' },
name: { type: 'string', description: '歌名' },
singer: { type: 'string', description: '歌手' },
File renamed without changes.
-15
View File
@@ -1,15 +0,0 @@
// 替代原渲染器的 @renderer/store 模块(保留导入别名所需的接口形态)
// 服务器环境默认使用内置直连接口(apiSource.value === 'internal'),
// 设置 SOURCE_ENABLED 环境变量(任意非 0/false 值)即启用外部聚合音源。
const enabled = process.env.SOURCE_ENABLED
export const apiSource = {
value: enabled && enabled !== '0' && enabled !== 'false' ? 'source' : 'internal',
}
export const userApi = {
apis: {},
}
export default { apiSource, userApi }
+38 -4
View File
@@ -1,9 +1,9 @@
// 登录路由 —— wy 扫码/手机号登录,kg token 绑定,凭据持久化于 data/auth.json
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
import * as wyAuth from './auth/wy.js'
import * as kgAuth from './auth/kg.js'
import { clearCredentials, getCredentials } from './auth/store.js'
import * as wyAuth from '../sdk/auth/wy.ts'
import * as kgAuth from '../sdk/auth/kg.ts'
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
const fail = (reply, err) => {
const status = err.status ?? 502
@@ -102,19 +102,53 @@ export const loginRoutes = async(fastify) => {
return { logged: false }
})
// ---------- QQ音乐 ----------
fastify.post('/login/tx', {
schema: {
summary: '绑定 QQ 音乐 cookie',
description: 'cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),用于解锁 tx 取流的 320k/无损音质。',
tags: ['登录'],
body: {
type: 'object',
properties: {
cookie: { type: 'string' },
},
required: ['cookie'],
},
},
}, async(request, reply) => {
const cookie = request.body.cookie
const uin = cookie.match(/uin=(\d+)/)?.[1]
if (!uin || !/qm_keyst=\S+/.test(cookie)) {
return reply.code(422).send({ code: 422, msg: 'cookie 需包含 uin 与 qm_keyst 字段' })
}
setCredentials('tx', { cookie })
return { logged: true, uin }
})
fastify.delete('/login/tx', {
schema: { summary: '退出 QQ 音乐登录', tags: ['登录'] },
}, async() => {
clearCredentials('tx')
return { logged: false }
})
// ---------- 通用 ----------
fastify.get('/login/status', {
schema: {
summary: '查看登录状态',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg 为已存凭据。',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据。',
tags: ['登录'],
},
}, async() => {
const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())])
const tx = getTxAuth()
return {
wy: wy ? { logged: true, ...wy } : { logged: false },
kg: kg ? { logged: true, ...kg } : { logged: false },
tx: tx ? { logged: true, uin: tx.uin } : { logged: false },
}
})
}
+16 -16
View File
@@ -3,18 +3,18 @@
// 处理函数返回普通对象 → 由服务器的 preSerialization 钩子包成 { code: 0, data }
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
import { notFound, upstream } from './http/api-error.js'
import { notFound, upstream } from './http/api-error.ts'
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
/** 统一解包 SDK 返回值:{ promise, cancelHttp } 结构或裸 Promise 兼容 */
const unwrap = (result) => (
const unwrap = (result: any): any => (
result && typeof result.promise !== 'undefined' && typeof result.cancelHttp === 'function'
? result.promise
: result
)
const envelope = (dataRef) => ({
const envelope = (dataRef?: string) => ({
type: 'object',
properties: {
code: { type: 'integer', description: '0 表示成功', example: 0 },
@@ -24,7 +24,7 @@ const envelope = (dataRef) => ({
})
/** 组装路由的 schema.response:200 信封 + 通用错误码文档 */
const responses = (dataRef, { notFoundDoc = true, upstreamDoc = true } = {}) => ({
const responses = (dataRef: any, { notFoundDoc = true, upstreamDoc = true }: { notFoundDoc?: boolean; upstreamDoc?: boolean } = {}) => ({
'200': envelope(dataRef),
'422': { $ref: 'ErrorEnvelope#' },
...(notFoundDoc ? { '404': { $ref: 'ErrorEnvelope#' } } : {}),
@@ -43,10 +43,10 @@ const pLimit = { type: 'integer', default: 20, minimum: 1, maximum: 100, descrip
const pQuality = { type: 'string', enum: QUALITIES, default: '128k', description: '音质' }
const pId = { type: 'string', description: '平台歌曲 id', example: '28423286' }
const qs = (properties, required = []) => ({ type: 'object', properties, required })
const params = (properties, required) => qs(properties, required ?? Object.keys(properties))
const qs = (properties: any, required: string[] = []) => ({ type: 'object', properties, required })
const params = (properties: any, required?: string[]) => qs(properties, required ?? Object.keys(properties))
const getSource = (musicSdk, name, capability) => {
const getSource = (musicSdk: any, name: string, capability?: string) => {
const m = musicSdk[name]
if (!m || (capability && !m[capability])) throw notFound(`不支持的平台: ${name}`)
return m
@@ -56,7 +56,7 @@ const getSource = (musicSdk, name, capability) => {
* 注册全部业务路由
* @param {import('fastify').FastifyInstance} fastify
*/
export const musicRoutes = async(fastify) => {
export const musicRoutes = async(fastify: any) => {
const musicSdk = fastify.musicSdk
// ---------- 搜索 ----------
@@ -86,7 +86,7 @@ export const musicRoutes = async(fastify) => {
getSource(musicSdk, source, 'musicSearch')
try {
return { source, list: await doSearch(source), page, limit }
} catch (err) {
} catch (err: any) {
throw upstream(err.message)
}
}
@@ -119,7 +119,7 @@ export const musicRoutes = async(fastify) => {
return {
list: await musicSdk.findMusic({ name, singer, albumName, interval, source: excludeSource }),
}
} catch (err) {
} catch (err: any) {
throw upstream(err.message)
}
})
@@ -134,7 +134,7 @@ export const musicRoutes = async(fastify) => {
let result
try {
result = unwrap(m.getMusicUrl(songInfo, quality))
} catch (err) {
} catch (err: any) {
throw upstream(err.message)
}
return result.then(res => {
@@ -148,7 +148,7 @@ export const musicRoutes = async(fastify) => {
fastify.get('/url/:source/:id', {
schema: {
summary: '获取播放地址',
description: 'id 为平台歌曲 id:kw/tx/wy/mg 用 songmid,kg 用 hash。',
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash。',
tags: ['歌曲资源'],
params: params({
source: pSource('url'),
@@ -166,7 +166,7 @@ export const musicRoutes = async(fastify) => {
let result
try {
result = unwrap(m.getLyric({ songmid: id, hash: id, copyrightId: id, name: name || '', interval: interval || '' }))
} catch (err) {
} catch (err: any) {
throw upstream(err.message)
}
return result.then(res => (res || { lyric: '', tlyric: '' })).catch(err => {
@@ -194,7 +194,7 @@ export const musicRoutes = async(fastify) => {
let result
try {
result = unwrap(m.getPic({ songmid: id, hash: id, copyrightId: id, albumId: albumId || '' }))
} catch (err) {
} catch (err: any) {
throw upstream(err.message)
}
return result.then(url => ({ url })).catch(err => {
@@ -346,7 +346,7 @@ export const musicRoutes = async(fastify) => {
const { source, id } = request.params
const { page, limit } = request.query
const album = getSource(musicSdk, source, 'album').album
// 各平台适配器签名不同:kg/mg 为 getAlbumDetail(id, page, limit),
// 各平台适配器签名不同:kg 为 getAlbumDetail(id, page, limit),
// kw 为 getAlbumListDetail(id, page)——第三参数是 retryNum,不能透传 limit
if (album.getAlbumDetail) return album.getAlbumDetail(id, page, limit).catch(err => { throw upstream(err.message) })
return album.getAlbumListDetail(id, page).catch(err => { throw upstream(err.message) })
@@ -378,7 +378,7 @@ export const musicRoutes = async(fastify) => {
const m = musicSdk[id] || {}
const qualities = [...new Set(
Object.values(musicSdk.supportQuality || {})
.map(q => q[id])
.map((q: any) => q[id])
.filter(Boolean)
.flat(),
)]
+15 -15
View File
@@ -13,7 +13,7 @@
// GET /singer/:source/:id 歌手信息(kg/tx/wy)
// GET /singer/:source/:id/songs 歌手歌曲(kg/tx/wy)
// GET /singer/:source/:id/albums 歌手专辑(kg/tx/wy)
// GET /album/:source/:id 专辑详情+曲目(kg/kw/mg)
// GET /album/:source/:id 专辑详情+曲目(kg/kw)
// GET /sources /health GET /docs /redoc /openapi.json
//
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
@@ -26,19 +26,19 @@ import swaggerUi from '@fastify/swagger-ui'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import musicSdk from '../musicSdk/index.js'
import { ApiError } from './http/api-error.js'
import { musicRoutes } from './routes.js'
import { loginRoutes } from './routes-login.js'
import { COMPONENT_SCHEMAS } from './openapi/components.js'
import { redocHtml } from './openapi/ui.js'
import musicSdk from '../sdk/index.ts'
import { ApiError } from './http/api-error.ts'
import { musicRoutes } from './routes.ts'
import { loginRoutes } from './routes-login.ts'
import { COMPONENT_SCHEMAS } from './openapi/components.ts'
import { redocHtml } from './openapi/ui.ts'
// 默认端口 16688:本机 TCP 动态端口范围是 1024~15000(出站连接会随机占用低端端口),避开该区间
const PORT = process.env.PORT || 16688
const DOCS_PREFIX = '/docs'
const API_TITLE = 'MusicServer 聚合音乐 API'
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)平台。'
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)平台。'
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
@@ -57,7 +57,7 @@ for (const [name, schema] of Object.entries(COMPONENT_SCHEMAS)) {
// ---- 全局中间行为 ----
// 收集全部路由模式(含 :param 参数段),供 not-found 处理器做 405 判定
const routePatterns = []
const routePatterns: { method: any; regex: RegExp }[] = []
fastify.addHook('onRoute', (routeOptions) => {
if (!routeOptions.url || !routeOptions.method) return
// :param 段 → [^/]+;通配符 * → .*(如 @fastify/cors 注册的 catch-all 路由);其余按字面量转义
@@ -84,7 +84,7 @@ fastify.addHook('onResponse', async(request, reply) => {
// 必须豁免:Swagger UI 强制从该地址加载规范,包了信封顶层就没有 openapi 字段,页面直接报错
fastify.addHook('preSerialization', async(request, reply, payload) => {
if (reply.statusCode !== 200) return payload
if (request.routeOptions.config?.raw) return payload
if ((request.routeOptions.config as any)?.raw) return payload
if (request.routeOptions.url === `${DOCS_PREFIX}/json`) return payload
if (payload === null || typeof payload === 'object') return { code: 0, data: payload }
return payload
@@ -92,9 +92,9 @@ fastify.addHook('preSerialization', async(request, reply, payload) => {
// ---- 错误处理 ----
fastify.setErrorHandler((err, request, reply) => {
fastify.setErrorHandler((err: any, request, reply) => {
if (err instanceof ApiError) {
const payload = { code: err.status, msg: err.message }
const payload: Record<string, any> = { code: err.status, msg: err.message }
if (err.detail !== undefined) payload.detail = err.detail
return reply.code(err.status).send(payload)
}
@@ -129,7 +129,7 @@ fastify.setNotFoundHandler((request, reply) => {
fastify.register(swagger, {
// 组件按 addSchema 的 $id 命名(默认 def-1/def-2 不可读)
refResolver: {
buildLocalReference: (json, _baseUri, _fragment, i) => json.$id || `def-${i}`,
buildLocalReference: (json: any, _baseUri: any, _fragment: any, i: number) => json.$id || `def-${i}`,
},
openapi: {
openapi: '3.1.0',
@@ -185,7 +185,7 @@ fastify.register(musicRoutes)
fastify.register(loginRoutes)
export const startServer = async(port = PORT) => {
await fastify.listen({ port, host: '0.0.0.0' })
await fastify.listen({ port: Number(port), host: '0.0.0.0' })
console.log(`音乐服务器已启动: http://localhost:${port}`)
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)
@@ -205,6 +205,6 @@ process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
// 直接运行时启动
if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('src\\server.js'))) {
if (process.argv[1] && (process.argv[1].endsWith('server.ts') || process.argv[1].endsWith('src\\server.ts'))) {
startServer()
}
-419
View File
@@ -1,419 +0,0 @@
// 冒烟测试 —— 验证服务器各接口与各平台功能
// 运行:node src/smoke-test.js
//
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、
// 跨平台匹配 /find、歌手/专辑、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、
// 文档页、旧 query 路由移除回归
import http from 'node:http'
import { spawn } from 'node:child_process'
// 本地默认 16688(避开 1024~15000 的动态端口冲突区),可用 PORT/BASE_URL 覆盖
const PORT = process.env.PORT || 16688
const BASE = process.env.BASE_URL || `http://localhost:${PORT}`
const results = []
const get = (path) => new Promise((resolve, reject) => {
http.get(`${BASE}${path}`, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => {
try { resolve(JSON.parse(data)) } catch { reject(new Error(`JSON parse failed: ${data.slice(0, 100)}`)) }
})
}).on('error', reject)
})
// 原始响应(不解析 JSON,用于 HTML 页与状态码断言)
const getRaw = (path) => new Promise((resolve, reject) => {
http.get(`${BASE}${path}`, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
}).on('error', reject)
})
const postRaw = (path) => new Promise((resolve, reject) => {
const req = http.request(`${BASE}${path}`, { method: 'POST' }, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
})
req.on('error', reject)
req.end()
})
// JSON POST(登录等写入接口)
const postJson = (path, body) => new Promise((resolve, reject) => {
const payload = JSON.stringify(body)
const req = http.request(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
}, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => resolve({
status: res.statusCode,
body: (() => { try { return JSON.parse(data) } catch { return null } })(),
}))
})
req.on('error', reject)
req.write(payload)
req.end()
})
const test = async(name, fn) => {
try {
const result = await fn()
results.push({ name, ok: true, detail: result })
console.log(`✅ ${name}`)
} catch (err) {
results.push({ name, ok: false, detail: err.message })
console.log(`❌ ${name}: ${err.message}`)
}
}
/**
* 检查本地服务器是否已在运行
*/
const isServerRunning = () => new Promise(resolve => {
if (!BASE.startsWith('http://localhost:') && !BASE.startsWith('http://127.0.0.1:')) return resolve(true)
const req = http.get(`${BASE}/health`, res => {
res.resume()
resolve(res.statusCode === 200)
})
req.on('error', () => resolve(false))
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
})
/**
* 启动本地服务器并返回关闭句柄
*/
const startLocalServer = () => new Promise((resolve, reject) => {
const proc = spawn(process.execPath, ['src/server.js'], {
cwd: process.cwd(),
env: { ...process.env, PORT: String(PORT) },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
proc.stdout.on('data', d => { stdout += d })
proc.stderr.on('data', d => { stdout += d })
const timer = setTimeout(() => {
proc.kill()
reject(new Error('启动本地服务器超时'))
}, 10000)
const check = () => {
http.get(`${BASE}/health`, res => {
res.resume()
if (res.statusCode === 200) {
clearTimeout(timer)
resolve(proc)
} else {
setTimeout(check, 200)
}
}).on('error', () => setTimeout(check, 200))
}
setTimeout(check, 300)
})
let localServer = null
const running = await isServerRunning()
if (!running) {
console.log('本地服务器未运行,正在自动启动...')
localServer = await startLocalServer()
console.log('本地服务器已启动\n')
} else {
console.log('使用已运行的服务器\n')
}
// 搜索
for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) {
await test(`搜索 ${source}`, async() => {
const r = await get(`/search?source=${source}&name=${encodeURIComponent('稻香')}&limit=2`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list[0].name} / ${r.data.list[0].singer}`
})
}
await test('聚合搜索', async() => {
const r = await get(`/search?name=${encodeURIComponent('稻香')}&limit=2`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 条结果`
})
// URL(用已知歌曲 id,路径参数风格)
await test('URL kw 320k', async() => {
const r = await get('/url/kw/28423286?quality=320k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60)
})
await test('URL wy 128k', async() => {
const r = await get('/url/wy/3357698666?quality=128k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60)
})
await test('URL tx 320k', async() => {
const r = await get('/url/tx/003C9fBv2K4x8b?quality=320k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60)
})
// 歌词
await test('歌词 kw', async() => {
const r = await get('/lyric/kw/28423286')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.lyric) throw new Error('无歌词')
return `${r.data.lyric.slice(0, 30)}...`
})
// 封面
await test('封面 kw', async() => {
const r = await get('/pic/kw/28423286')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60)
})
// 榜单 & 热搜
for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) {
await test(`榜单 ${source}`, async() => {
const r = await get(`/boards/${source}`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 个榜单`
})
}
await test('热搜 kw', async() => {
const r = await get('/hot-search/kw')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return r.data.list[0]
})
// ---- 标准 HTTP 语义与文档 ----
await test('平台列表 /sources', async() => {
const r = await get('/sources')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.platforms?.length) throw new Error('空平台列表')
const kw = r.data.platforms.find(p => p.id === 'kw')
if (!kw?.capabilities?.search) throw new Error('kw 缺少 search 能力')
return `${r.data.platforms.length} 个平台`
})
await test('榜单详情 路径风格 /boards/:source/:boardId', async() => {
// 用列表返回的带前缀 id(回归:详情接口应自动归一化为裸 bangid)
const r = await get('/boards/kw/kw__93?page=1')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 首`
})
await test('跨平台匹配 /find', async() => {
const r = await get(`/find?name=${encodeURIComponent('稻香')}&singer=${encodeURIComponent('周杰伦')}`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list[0].name} / ${r.data.list[0].singer}`
})
await test('歌单 路径风格 /song-list/:source', async() => {
const r = await get('/song-list/kg?page=1')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 个歌单`
})
await test('歌单 默认排序 /song-list/kw', async() => {
const r = await get('/song-list/kw?page=1')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表(sortId 默认值未适配平台)')
return `${r.data.list.length} 个歌单`
})
// ---- 歌手 & 专辑 ----
await test('歌手信息 /singer/kg', async() => {
const r = await get('/singer/kg/3062')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name) throw new Error('无歌手名')
return r.data.info.name
})
await test('歌手信息 /singer/wy', async() => {
const r = await get('/singer/wy/3684')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name) throw new Error('无歌手名')
return r.data.info.name
})
await test('歌手信息 /singer/tx', async() => {
const r = await get('/singer/tx/0025NhlN2yWrP4')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name) throw new Error('无歌手名')
return r.data.info.name
})
await test('歌手歌曲 /singer/tx/:id/songs', async() => {
const r = await get('/singer/tx/0025NhlN2yWrP4/songs?page=1&limit=3')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 首 / 共 ${r.data.total}`
})
await test('歌手专辑 /singer/kg/:id/albums', async() => {
const r = await get('/singer/kg/3062/albums?page=1&limit=3')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 张`
})
await test('专辑详情 /album/kg', async() => {
const r = await get('/album/kg/171089481')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name || !r.data?.list) throw new Error('响应不完整')
return `${r.data.info.name}(${r.data.list.length} 首)`
})
await test('专辑详情 /album/kw', async() => {
const r = await get('/album/kw/29218')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整')
return `${r.data.info.name}(${r.data.list.length} 首)`
})
await test('专辑详情 /album/tx', async() => {
const r = await get('/album/tx/000MkMni19ClKG')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整')
return `${r.data.info.name}(${r.data.list.length} 首)`
})
await test('专辑详情 /album/mg', async() => {
const r = await get('/album/mg/25578')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name) throw new Error('无专辑名')
return `${r.data.info.name}(${r.data.list?.length ?? 0} 首)`
})
await test('参数校验 422(缺少 name)', async() => {
const r = await getRaw('/search')
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
const body = JSON.parse(r.body)
if (body.code !== 422 || !body.msg) throw new Error('响应体格式错误')
return body.msg
})
await test('参数校验 422(非法音质)', async() => {
const r = await getRaw('/url/kw/28423286?quality=999k')
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return JSON.parse(r.body).msg
})
await test('404 未知接口', async() => {
const r = await getRaw('/not-exist')
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
return JSON.parse(r.body).msg
})
await test('旧 query 路由已移除(404)', async() => {
const r = await getRaw('/url?source=kw&id=28423286')
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
const body = JSON.parse(r.body)
if (!/接口不存在/.test(body.msg)) throw new Error(`响应异常: ${body.msg}`)
return body.msg
})
await test('405 方法不允许(静态路由)', async() => {
const r = await postRaw('/search')
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
if (!r.headers.allow) throw new Error('缺少 Allow 头')
return `Allow: ${r.headers.allow}`
})
await test('405 方法不允许(参数路由)', async() => {
const r = await postRaw('/url/kw/28423286')
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
if (!r.headers.allow || !r.headers.allow.includes('GET')) throw new Error(`Allow 头异常: ${r.headers.allow}`)
return `Allow: ${r.headers.allow}`
})
await test('根路径 302 → /docs', async() => {
const r = await getRaw('/')
if (r.status !== 302) throw new Error(`期望 302,实际 ${r.status}`)
if (r.headers.location !== '/docs') throw new Error(`期望 /docs,实际 ${r.headers.location}`)
return r.headers.location
})
await test('Swagger UI 文档页 /docs', async() => {
const r = await getRaw('/docs')
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
if (!r.body.includes('swagger-ui')) throw new Error('页面内容异常')
return 'Swagger UI'
})
await test('OpenAPI 规范 /openapi.json', async() => {
const r = await getRaw('/openapi.json')
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
const spec = JSON.parse(r.body)
if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`)
if (!spec.paths?.['/search']) throw new Error('缺少 /search 定义')
if (!spec.paths?.['/url/{source}/{id}']) throw new Error('缺少路径参数路由定义')
return `${Object.keys(spec.paths).length} 个路径`
})
await test('Swagger UI 规范端点 /docs/json(不可包信封)', async() => {
const r = await getRaw('/docs/json')
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
const spec = JSON.parse(r.body)
// Swagger UI 强制从该地址加载规范:若被全局信封钩子包成 { code, data },顶层无 openapi 字段,文档页直接报错
if (!spec.openapi?.startsWith('3.')) throw new Error(`顶层缺少 openapi 字段(疑似被信封包裹): ${r.body.slice(0, 80)}`)
return spec.openapi
})
// ---- 登录 ----
await test('登录状态 /login/status', async() => {
const r = await get('/login/status')
if (r.code != 0) throw new Error(r.msg)
if (typeof r.data.wy?.logged !== 'boolean' || typeof r.data.kg?.logged !== 'boolean') throw new Error('响应不完整')
return `wy:${r.data.wy.logged} kg:${r.data.kg.logged}`
})
await test('扫码二维码创建 POST /login/wy/qr', async() => {
const r = await postJson('/login/wy/qr', {})
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
if (!r.body.data?.key || !r.body.data?.qrurl || !r.body.data?.qrimg) throw new Error('响应不完整')
return r.body.data.key.slice(0, 18) + '…'
})
await test('kg 假 token 拒绝 POST /login/kg', async() => {
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
return r.body?.msg
})
// 汇总
const passed = results.filter(r => r.ok).length
const failed = results.filter(r => !r.ok)
console.log(`\n===== 结果: ${passed}/${results.length} 通过 =====`)
if (failed.length) {
console.log('失败项:')
failed.forEach(f => console.log(` - ${f.name}: ${f.detail}`))
}
if (localServer) {
console.log('\n正在关闭本地服务器...')
localServer.kill()
await new Promise(resolve => { localServer.on('exit', resolve) })
}
if (failed.length) process.exit(1)
-56
View File
@@ -1,56 +0,0 @@
// 为项目中尚未迁移到 TypeScript 的 JavaScript 模块提供最小类型声明,
// 让 source-loader / apis 等 TypeScript 文件可以正常导入。
declare module '../common/request.js' {
export interface HttpFetchResponse {
statusCode: number
statusMessage?: string
headers: Record<string, string | string[]>
body: any
}
export interface HttpRequestObject {
promise: Promise<HttpFetchResponse>
cancelHttp(): void
}
export function httpFetch(url: string, options?: Record<string, any>): HttpRequestObject
}
declare module '../../src/auth/store.js' {
export function getCredentials(platform: string): any
export function setCredentials(platform: string, credentials: object): void
export function clearCredentials(platform: string): void
export function getWyCookie(): string
export function getKgAuth(): { token: string; userid: number | string } | null
}
declare module './kw.js' {
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
export = mod
}
declare module './kg.js' {
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
export = mod
}
declare module './tx.js' {
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
export = mod
}
declare module './wy.js' {
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
export = mod
}
declare module './mg.js' {
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
export = mod
}
declare module './bd.js' {
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
export = mod
}