存档:TS 重构前基线(lx 聚合源 = isolated-vm 加载器,签名方案已移除)
This commit is contained in:
commit
f289df4cd7
124 files changed
+14110
No files matched your search
@@ -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 }
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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}©rightId=${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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,10 @@
|
||||
// 请求提示消息(替代原 lx-music 的 message.js)
|
||||
|
||||
export const requestMsg = {
|
||||
cancelRequest: '请求已取消',
|
||||
fail: '请求失败',
|
||||
timeout: '请求超时',
|
||||
tooManyRequests: '请求过多',
|
||||
notConnectNetwork: '无法连接到服务器',
|
||||
unachievable: '无法访问',
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&#(\d+);/g, (match, num) => String.fromCharCode(parseInt(num)))
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,3 @@
|
||||
// 替代原 lx-music 渲染器的 @renderer/utils(SDK 内 `@renderer/utils` 别名目标)
|
||||
|
||||
export { decodeName, formatPlayTime, sizeFormate, dateFormat, dateFormat2, formatPlayCount } from '../common/utils.js'
|
||||
+304
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
Vendored
+48
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user