存档:TS 重构前基线(lx 聚合源 = isolated-vm 加载器,签名方案已移除)

This commit is contained in:
archive committed 2026-08-28 23:24:49 +08:00
commit f289df4cd7
124 files changed
+14110

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
// 百度音乐 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 }
+19
View File
@@ -0,0 +1,19 @@
// 各平台 URL 直连接口汇总
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
// lx 为 lx-music 聚合源:isolated-vm 硬隔离执行原始脚本(签名方案已放弃,见 docs/lx-source-ivm.md)
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';
import lx from './lx-source.js';
export default {
kw: kw,
kg: kg,
tx: tx,
wy: wy,
mg: mg,
bd: bd,
lx: lx,
};
+38
View File
@@ -0,0 +1,38 @@
// 各平台 URL 直连接口汇总
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
// lx 为 lx-music 聚合源:isolated-vm 硬隔离执行原始脚本(签名方案已放弃,见 docs/lx-source-ivm.md)
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'
import lx from './lx-source.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,
lx: lx as unknown as Record<string, ApiModule>,
}
+50
View File
@@ -0,0 +1,50 @@
// 酷狗音乐 URL 获取(直连)
// 接口: https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash={hash}&album_id={albumId}&mid={mid}
// 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
import { httpFetch } from '../common/request.js'
let mid = ''
const getMid = () => {
if (mid) return Promise.resolve(mid)
// 随机生成 26 位小写字母数字混合的 mid
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
mid = Array.from({ length: 26 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
return Promise.resolve(mid)
}
const getPlayUrl = (hash, albumId) => getMid().then(m => {
const url = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${hash}${albumId ? `&album_id=${albumId}` : ''}&mid=${m}`
return httpFetch(url, {
method: 'get',
headers: {
Referer: 'https://www.kugou.com/',
'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.err_code != 0 || !body.data) {
return Promise.reject(new Error('获取失败'))
}
return body.data
})
})
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null }
const hash = songInfo._types?.[type]?.hash || songInfo.hash
if (!hash) {
requestObj.promise = Promise.reject(new Error('缺少 hash'))
return requestObj
}
requestObj.promise = getPlayUrl(hash, songInfo.albumId).then(data => {
const url = data.play_url || data.play_backup_url
if (!url) return Promise.reject(new Error('获取失败'))
return { type, url }
})
return requestObj
}
export default { getMusicUrl }
+108
View File
@@ -0,0 +1,108 @@
// 酷我音乐 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 }
+642
View File
@@ -0,0 +1,642 @@
// lx 聚合源加载器(isolated-vm 硬隔离执行第三方脚本)
//
// 这是 lx 聚合源的唯一接入方式:在 isolated-vm 硬隔离沙箱中运行原始混淆脚本
// (如 lx-music-source-v5.js),脚本的所有能力都经由本模块显式桥接的通道:
// - http:默认只放行脚本头部 SERVER_SCRIPT_CONFIG.apiUrl 同源请求,其余一律阻止
// - crypto / zlib / buffer:Node 侧实现,日志只记操作与长度,不记录密钥内容
// - 定时器 / console:桥接到 Node,随 isolate 销毁回收
// 对外接口:getMusicUrl(songInfo, type) → { promise, cancelHttp },与其它平台适配器一致。
// 脚本升版时只需替换脚本文件,无需改代码。
//
// 安全模型:
// - isolate 内存上限(memoryLimit),无 process/require/fs/网络直连能力
// - HTTP 出站默认拒绝(deny-all),白名单来自脚本自身声明的 apiUrl,可被 options 收紧或放宽
// - 每个 lx API 调用(on/send/request/utils/dispatch)都有日志,请求含耗时与状态
//
// 依赖:isolated-vm(未安装时工厂函数抛出明确错误)
// 用法:
// import { createIvmLxSource } from './lx-source-ivm.js'
// const lx = await createIvmLxSource() // 默认加载项目根目录的 lx-music-source-v5.js
// const lx = await createIvmLxSource({ scriptPath, http }) // http 可注入 mock 做离线测试
// await lx.kw.getMusicUrl({ songmid: '123' }, '320k').promise
// await lx.destroy()
//
// 已知边界(相对桌面端 preload 的差异):
// - lx.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 { fileURLToPath } from 'node:url'
import { httpFetch } from '../common/request.js'
const DEFAULT_SCRIPT_PATH = fileURLToPath(new URL('../../lx-music-source-v5.js', import.meta.url))
// 从脚本头部的 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 等(对齐 lx.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,
}
}
// 注入隔离环境的胶水层:提供 lx API、定时器、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('[lx] ' + Array.prototype.map.call(arguments, String).join(' ')) },
info: function () { __log('[lx] ' + Array.prototype.map.call(arguments, String).join(' ')) },
warn: function () { __log('[lx] ' + Array.prototype.map.call(arguments, String).join(' ')) },
error: function () { __log('[lx] ' + 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)) }
}
// ---- lx API(对齐 lx-music-desktop preload.js)----
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 __lxRequest(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('lx.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('lx.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 __lxRequest(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) }))
})
}
`
/**
* 创建隔离执行的 lx 聚合源
* @param {object} [options]
* @param {string} [options.scriptPath] 原始脚本路径,默认项目根目录 lx-music-source-v5.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 createIvmLxSource = async({
scriptPath = DEFAULT_SCRIPT_PATH,
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,无法启用隔离执行兜底:npm i isolated-vm')
}
const script = fs.readFileSync(scriptPath, 'utf8')
const scriptInfo = parseScriptInfo(script, scriptPath)
// ---- 日志与 HTTP 白名单 ----
const log = typeof logOption === 'function'
? logOption
: logOption === false
? () => {}
: (msg) => console.log('[lx-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(`lx.on(${String(name)})`)
}))
await jail.set('__send', new ivm.Callback((name, dataJson) => {
let data = null
try { data = JSON.parse(dataJson) } catch {}
log(`lx.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(`lx.utils.crypto.md5(${brief(str)})`)
return crypto.createHash('md5').update(String(str)).digest('hex')
}))
await jail.set('__randomBytesHex', new ivm.Callback((size) => {
log(`lx.utils.crypto.randomBytes(${size})`)
return crypto.randomBytes(size).toString('hex')
}))
await jail.set('__aesEncryptHex', new ivm.Callback((hexBuf, mode, hexKey, hexIv) => {
log(`lx.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(`lx.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(`lx.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(`✋ lx.request ${method} ${url} → 已阻止(不在白名单)`)
safeApply(completeHttpRef, [cbId, `http blocked by allowlist: ${url}`, null, null])
return reqId
}
log(`→ lx.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(`← lx.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(`← lx.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 { createIvmLxSource }
+52
View File
@@ -0,0 +1,52 @@
// lx 聚合源统一入口(isolated-vm 懒加载封装)
// 内部委托 lx-source-ivm.js 在硬隔离沙箱中执行原始脚本(签名方案已放弃:v5 的
// to/si 为脚本内部哈希、服务端独立校验,直连复刻不可行,详见 docs/lx-source-ivm.md)。
// 首次调用 getMusicUrl 时才加载并初始化隔离环境,后续复用同一实例。
// 统一接口约定:lx.kw.getMusicUrl(songInfo, type) → { promise, cancelHttp },resolve → { type, url }
import { createIvmLxSource } from './lx-source-ivm.js'
export const SOURCES = ['kw', 'kg', 'tx', 'wy', 'mg']
let sharedPromise = null
const getShared = () => {
if (!sharedPromise) {
// 配置可经环境变量调整,与项目其它适配器保持一致风格
sharedPromise = createIvmLxSource({
scriptPath: process.env.LX_SOURCE_SCRIPT_PATH || undefined,
log: process.env.LX_SOURCE_LOG !== '0',
allowAllHttp: process.env.LX_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 lx = await getShared()
if (!lx[source]) throw new Error(`脚本不支持的平台: ${source}`)
inner = lx[source].getMusicUrl(songInfo, type)
return inner.promise
})()
return requestObj
},
})
// 兼容导出:musicSdk/api-source.js 以 createApi(source) 方式接入 v5_* 聚合源
export const createApi = apiFor
const lxSource = {}
for (const s of SOURCES) lxSource[s] = apiFor(s)
// 预热:提前触发脚本加载与初始化(可选调用)
lxSource.init = getShared
export default lxSource
+84
View File
@@ -0,0 +1,84 @@
// 咪咕音乐 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 }
+55
View File
@@ -0,0 +1,55 @@
// 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 }
+44
View File
@@ -0,0 +1,44 @@
// 网易云音乐 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.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 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',
},
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 }
+8
View File
@@ -0,0 +1,8 @@
// IPC 事件名(对齐原 lx-music src/common/ipcNames.ts)
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
@@ -0,0 +1,66 @@
// 歌词解码(替代原 lx-music 主进程 IPC 处理器)
// - handle_kw_decode_lyric: 酷我歌词(inflate + yeelion XOR + gb18030)
// 算法来源: lx-music-desktop src/main/modules/winMain/rendererEvent/kw_decodeLyric.ts
// - 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
@@ -0,0 +1,78 @@
// 酷狗 krc 歌词解码(对齐原 lx-music src/common/utils/lyricUtils/kg.js)
import zlib from 'node:zlib'
import { decodeName } from '../utils.js'
// https://github.com/lyswhut/lx-music-desktop/issues/296#issuecomment-683285784
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 lxlyric = 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') : ''
lxlyric = lxlyric.replace(/<(\d+,\d+),\d+>/g, '<$1>')
lxlyric = decodeName(lxlyric)
lyric = lxlyric.replace(/<\d+,\d+>/g, '')
rlyric = decodeName(rlyric)
tlyric = decodeName(tlyric)
return {
lyric,
tlyric,
rlyric,
lxlyric,
}
}
export const decodeKrc = async data => {
return decodeLyric(data).then(parseLyric)
}
export default { decodeKrc }
+10
View File
@@ -0,0 +1,10 @@
// 请求提示消息(替代原 lx-music 的 message.js)
export const requestMsg = {
cancelRequest: '请求已取消',
fail: '请求失败',
timeout: '请求超时',
tooManyRequests: '请求过多',
notConnectNetwork: '无法连接到服务器',
unachievable: '无法访问',
}
+252
View File
@@ -0,0 +1,252 @@
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
@@ -0,0 +1,33 @@
// 替代原 lx-music 的 @common/rendererIpc(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
@@ -0,0 +1,174 @@
// 网络请求模块(替代原 lx-music 渲染器 src/renderer/utils/request.js)
// 接口与原版保持一致:
// 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
@@ -0,0 +1,101 @@
// 通用工具函数(对齐 lx-music 原版 src/common/utils/common.ts 与 src/renderer/utils/index.ts)
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)))
}
+11
View File
@@ -0,0 +1,11 @@
// SDK 根模块 —— musicSdk 内 `import xxx from '../../index.js'` 的解析目标
// 聚合导出通用工具函数(对齐原 lx-music src/renderer/utils/index.ts 的导出)
export {
sizeFormate,
formatPlayTime,
dateFormat,
dateFormat2,
formatPlayCount,
decodeName,
} from './common/utils.js'
+13
View File
@@ -0,0 +1,13 @@
// 替代原 lx-music 渲染器的 @renderer/store
// 服务器环境默认使用内置直连接口(apiSource.value === 'internal'),
// 设置 LX_SOURCE_ENABLED=true 可切换至 lx-music-source-v5 聚合源。
export const apiSource = {
value: process.env.LX_SOURCE_ENABLED === 'true' ? 'v5' : 'internal',
}
export const userApi = {
apis: {},
}
export default { apiSource, userApi }
+3
View File
@@ -0,0 +1,3 @@
// 替代原 lx-music 渲染器的 @renderer/utils(SDK 内 `@renderer/utils` 别名目标)
export { decodeName, formatPlayTime, sizeFormate, dateFormat, dateFormat2, formatPlayCount } from '../common/utils.js'
+304
View File
@@ -0,0 +1,304 @@
// HTTP 服务器 —— 多平台音乐聚合 API
// 接口设计参考 lx-music-api-server 的路径约定:
// GET /search?name=&singer=&source=&page=&limit= 搜索(source 省略则聚合多平台)
// GET /find?name=&singer=&albumName=&interval= 精确匹配搜索(跨平台去重排序)
// GET /url?source=&id=&quality= 获取播放地址(id 为平台歌曲 id,如 songmid/hash/copyrightId)
// GET /lyric?source=&id=&trans=0 获取歌词
// GET /pic?source=&id= 获取封面
// GET /boards?source= 榜单列表
// GET /board?source=&id=&page= 榜单详情
// GET /hotSearch?source= 热搜
// GET /songList?source=&page= 歌单列表
// GET /health 健康检查
// 返回格式:{ code: 0, data: ... } 或 { code: 非0, msg: ... }
import http from 'node:http'
import { URL } from 'node:url'
import musicSdk from '../musicSdk/index.js'
const PORT = process.env.PORT || 3300
// 统一响应
const setJsonHeaders = (res, status = 200) => {
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
})
}
const ok = (res, data) => {
if (res.writableEnded) return
setJsonHeaders(res, 200)
res.end(JSON.stringify({ code: 0, data }))
}
const fail = (res, msg, code = 1) => {
if (res.writableEnded) return
setJsonHeaders(res, 200)
res.end(JSON.stringify({ code, msg: String(msg || '未知错误') }))
}
// 音质映射(SDK 内类型)
const qualitys = ['128k', '320k', 'flac', 'flac24bit']
/**
* 统一解包 SDK 返回值:getMusicUrl/getLyric/getPic 等返回 { promise, cancelHttp } 结构,
* 个别平台(如 mg/pic)返回 Promise,兼容两种形态
*/
const unwrap = async(result) => {
if (result && typeof result.promise !== 'undefined' && typeof result.cancelHttp === 'function') {
return result.promise
}
return result
}
/**
* 解析搜索/列表结果,得到歌曲对象列表
* @param {object} source 平台 SDK 对象(musicSdk[kw/kg/tx/wy/mg/bd])
* @param {string} name 歌名
* @param {string} singer 歌手
* @param {number} page
* @param {number} limit
*/
const searchSource = async(sourceName, source, name, singer, page, limit) => {
const result = await source.musicSearch.search(`${name} ${singer || ''}`.trim(), page, limit)
return result.list
}
const routes = {
/**
* 搜索歌曲
* GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20
* source 省略时聚合所有平台(排除 xm)
*/
async search(req, res, params) {
const { name, singer } = params
if (!name) return fail(res, '缺少参数 name')
const page = parseInt(params.page || 1)
const limit = parseInt(params.limit || 20)
const sourceName = params.source
if (sourceName) {
const source = musicSdk[sourceName]
if (!source?.musicSearch) return fail(res, `不支持的平台: ${sourceName}`)
try {
const list = await searchSource(sourceName, source, name, singer, page, limit)
return ok(res, { source: sourceName, list, page, limit })
} catch (err) {
return fail(res, err.message)
}
}
// 聚合搜索
const tasks = []
for (const s of musicSdk.sources) {
if (!musicSdk[s.id]?.musicSearch || s.id == 'xm') continue
tasks.push(searchSource(s.id, musicSdk[s.id], name, singer, page, limit)
.catch(() => []))
}
const lists = await Promise.all(tasks)
const list = lists.flat()
return ok(res, { source: 'all', list, page, limit })
},
/**
* 精确匹配搜索(返回最匹配项,跨平台聚合)
* GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx
*/
async find(req, res, params) {
if (!params.name) return fail(res, '缺少参数 name')
try {
const list = await musicSdk.findMusic(params)
return ok(res, { list })
} catch (err) {
return fail(res, err.message)
}
},
/**
* 获取播放地址
* GET /url?source=kw&id=xxx&quality=320k
* id 为平台歌曲 id(kw/tx/wy/mg/bd 用 songmid,kg 用 hash)
*/
async url(req, res, params) {
const { source, id } = params
if (!source || !id) return fail(res, '缺少参数 source 或 id')
const quality = qualitys.includes(params.quality) ? params.quality : '128k'
const sourceObj = musicSdk[source]
if (!sourceObj?.getMusicUrl) return fail(res, `不支持的平台: ${source}`)
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: params.albumId || '', _types: {} }
try {
const result = await unwrap(sourceObj.getMusicUrl(songInfo, quality))
if (!result?.url) return fail(res, '获取失败')
return ok(res, { type: quality, url: result.url })
} catch (err) {
return fail(res, err.message)
}
},
/**
* 获取歌词
* GET /lyric?source=kw&id=xxx&trans=0
* id 为平台歌曲 id
*/
async lyric(req, res, params) {
const { source, id } = params
if (!source || !id) return fail(res, '缺少参数 source 或 id')
const sourceObj = musicSdk[source]
if (!sourceObj?.getLyric) return fail(res, `不支持的平台: ${source}`)
try {
const result = await unwrap(sourceObj.getLyric({ songmid: id, hash: id, copyrightId: id, name: params.name || '', interval: params.interval || '' }))
return ok(res, result || { lyric: '', tlyric: '' })
} catch (err) {
return fail(res, err.message)
}
},
/**
* 获取封面
* GET /pic?source=kw&id=xxx
*/
async pic(req, res, params) {
const { source, id } = params
if (!source || !id) return fail(res, '缺少参数 source 或 id')
const sourceObj = musicSdk[source]
if (!sourceObj?.getPic) return fail(res, `不支持的平台: ${source}`)
try {
const url = await unwrap(sourceObj.getPic({ songmid: id, hash: id, copyrightId: id, albumId: params.albumId || '' }))
return ok(res, { url })
} catch (err) {
return fail(res, err.message)
}
},
/**
* 获取榜单列表
* GET /boards?source=kw
*/
async boards(req, res, params) {
const { source } = params
if (!source) return fail(res, '缺少参数 source')
const sourceObj = musicSdk[source]
if (!sourceObj?.leaderboard) return fail(res, `不支持的平台: ${source}`)
try {
const result = await sourceObj.leaderboard.getBoards()
return ok(res, result)
} catch (err) {
return fail(res, err.message)
}
},
/**
* 获取榜单详情
* GET /board?source=kw&id=xxx&page=1
*/
async board(req, res, params) {
const { source, id } = params
if (!source || !id) return fail(res, '缺少参数 source 或 id')
const sourceObj = musicSdk[source]
if (!sourceObj?.leaderboard) return fail(res, `不支持的平台: ${source}`)
try {
const page = parseInt(params.page || 1)
const result = await sourceObj.leaderboard.getList(id, page)
return ok(res, result)
} catch (err) {
return fail(res, err.message)
}
},
/**
* 获取热搜
* GET /hotSearch?source=kw
*/
async hotSearch(req, res, params) {
const { source } = params
if (!source) return fail(res, '缺少参数 source')
const sourceObj = musicSdk[source]
if (!sourceObj?.hotSearch) return fail(res, `不支持的平台: ${source}`)
try {
const result = await sourceObj.hotSearch.getList()
return ok(res, result)
} catch (err) {
return fail(res, err.message)
}
},
/**
* 歌单列表
* GET /songList?source=kw&page=1
*/
async songList(req, res, params) {
const { source } = params
if (!source) return fail(res, '缺少参数 source')
const sourceObj = musicSdk[source]
if (!sourceObj?.songList) return fail(res, `不支持的平台: ${source}`)
try {
const page = parseInt(params.page || 1)
const result = await sourceObj.songList.getList(params.sortId || 3, params.tagId || 0, page)
return ok(res, result)
} catch (err) {
return fail(res, err.message)
}
},
/** 健康检查 */
async health(req, res) {
return ok(res, { status: 'ok' })
},
}
const handler = async(req, res) => {
// OPTIONS 预检请求快速响应
if (req.method === 'OPTIONS') {
setJsonHeaders(res, 204)
res.end()
return
}
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
const path = parsedUrl.pathname.replace(/^\/+|\/+$/g, '')
const params = Object.fromEntries(parsedUrl.searchParams.entries())
const route = routes[path]
if (!route) {
setJsonHeaders(res, 404)
res.end(JSON.stringify({ code: 404, msg: 'Not Found' }))
return
}
try {
await route(req, res, params)
} catch (err) {
if (process.env.DEBUG) {
console.error('[route error]', req.url, err?.message, err?.stack?.split('\n').slice(0, 4).join('\n'))
}
fail(res, err.message)
}
}
export const startServer = (port = PORT) => {
const server = http.createServer(handler)
server.listen(port, () => {
console.log(`音乐服务器已启动: http://localhost:${port}`)
console.log('可用接口: /search /find /url /lyric /pic /boards /board /hotSearch /songList /health')
})
// 优雅关闭
const shutdown = (signal) => {
console.log(`\n收到 ${signal},正在关闭服务器...`)
server.close(() => {
console.log('服务器已关闭')
process.exit(0)
})
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
return server
}
// 直接运行时启动
if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('src\\server.js'))) {
startServer()
}
+174
View File
@@ -0,0 +1,174 @@
// 冒烟测试 —— 验证服务器各接口与各平台功能
// 运行:node src/smoke-test.js
//
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词(kw)、封面、榜单、热搜
import http from 'node:http'
import { spawn } from 'node:child_process'
const BASE = process.env.BASE_URL || 'http://localhost:3300'
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)
})
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(),
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?source=kw&id=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?source=wy&id=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?source=tx&id=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?source=kw&id=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?source=kw&id=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=${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('/hotSearch?source=kw')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return r.data.list[0]
})
// 汇总
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)
+48
View File
@@ -0,0 +1,48 @@
// 为项目中尚未迁移到 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 './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
}