fix(lx): 修复落雪音源脚本导入失败及 API 端点死链导致的连环跳歌

This commit is contained in:
alger
2026-07-05 17:51:04 +08:00
parent 9c00657842
commit 58a2075179
4 changed files with 204 additions and 81 deletions
+54 -50
View File
@@ -16,68 +16,72 @@ import { CacheManager } from './musicParser';
/**
* 解析可能是 API 端点的 URL,获取真实音频 URL
* 一些音源脚本返回的是 API 端点,需要额外请求才能获取真实音频 URL
*
* 通过主进程请求验证(绕过渲染进程 CORS)。端点确认失效时返回 null,
* 让解析策略正确失败并进入失败缓存,而不是把不可播放的 URL 当成功缓存
* (否则会导致:坏 URL 进缓存 → audio 元素 Format error → 连续跳歌,
* 且后续策略如 unblockMusic 永远没有机会接手)
*/
const resolveAudioUrl = async (url: string): Promise<string> => {
try {
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
const resolveAudioUrl = async (url: string): Promise<string | null> => {
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
if (!isApiEndpoint) {
// 看起来像直接的音频 URL,直接返回
if (!isApiEndpoint) {
// 看起来像直接的音频 URL,直接返回
return url;
}
console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
// 非 Electron 环境无法绕过 CORS 验证,保持乐观返回
if (typeof window.api?.lxMusicHttpRequest !== 'function') {
return url;
}
try {
const requestId = `lx_resolve_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const response = await window.api.lxMusicHttpRequest({
url,
options: {
method: 'GET',
// 端点若直接返回音频流,用 Range 避免整段下载;返回 JSON 时 8KB 也足够
headers: { Range: 'bytes=0-8191' },
timeout: 15000
},
requestId
});
const status = response?.statusCode ?? 0;
const contentType = String(response?.headers?.['content-type'] || '');
if (status < 200 || status >= 400) {
console.warn(`[LxMusicStrategy] API 端点返回 ${status},判定解析失败`);
return null;
}
// 端点直接返回音频流(或重定向到音频,主进程已自动跟随),
// audio 元素可以直接播放原始 URL
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
console.log('[LxMusicStrategy] API 端点为音频流,直接使用原始 URL');
return url;
}
console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
// 尝试获取真实 URL
const response = await fetch(url, {
method: 'HEAD',
redirect: 'manual' // 不自动跟随重定向
});
// 检查是否是重定向
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('Location');
if (location) {
console.log('[LxMusicStrategy] API 返回重定向 URL:', location);
return location;
}
}
// 如果 HEAD 请求没有重定向,尝试 GET 请求
const getResponse = await fetch(url, {
redirect: 'follow'
});
// 检查 Content-Type
const contentType = getResponse.headers.get('Content-Type') || '';
// 如果是音频类型,返回最终 URL
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
console.log('[LxMusicStrategy] 解析到音频 URL:', getResponse.url);
return getResponse.url;
}
// 如果是 JSON,尝试解析
if (contentType.includes('application/json') || contentType.includes('text/json')) {
const json = await getResponse.json();
console.log('[LxMusicStrategy] API 返回 JSON:', json);
// 尝试从 JSON 中提取 URL(常见字段)
const audioUrl = json.url || json.data?.url || json.audio_url || json.link || json.src;
// JSON 响应:尝试提取常见字段中的音频 URL
const body = response?.body;
if (body && typeof body === 'object') {
const audioUrl = body.url || body.data?.url || body.audio_url || body.link || body.src;
if (audioUrl && typeof audioUrl === 'string') {
console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl);
return audioUrl;
}
}
// 如果都不是,返回原始 URL(可能直接可用)
console.warn('[LxMusicStrategy] 无法解析 API 端点,返回原始 URL');
return url;
// 2xx 但既不是音频也提取不到 URL(如 HTML 错误页),视为不可播放
console.warn('[LxMusicStrategy] API 端点响应无法解析为音频,判定解析失败');
return null;
} catch (error) {
console.error('[LxMusicStrategy] URL 解析失败:', error);
// 解析失败时返回原始 URL
return url;
console.error('[LxMusicStrategy] URL 解析请求失败:', error);
return null;
}
};
+1 -1
View File
@@ -366,7 +366,7 @@ export class LxMusicSourceRunner {
this.initResolver = resolve;
this.initRejecter = reject;
this.initTimeoutId = window.setTimeout(() => {
this.rejectInitialization(new Error('脚本初始化超时'));
this.rejectInitialization(new Error('脚本初始化超时10 秒内未调用 lx.send(inited)'));
}, 10000);
});
@@ -67,7 +67,6 @@ type HostWorkerMessage =
| HostLogMessage;
let requestHandler: ((data: any) => Promise<any>) | null = null;
let initialized = false;
let requestCounter = 0;
const pendingHttpCallbacks = new Map<
@@ -87,6 +86,90 @@ const postLog = (level: HostLogMessage['level'], ...args: any[]) => {
});
};
/**
* Node Buffer 语义的最小实现。
* 落雪脚本按 lx-music-desktop 的 Node 环境编写:
* buffer.from/bufToString 依赖 encoding 参数(base64/hex 等),
* 且会对返回的 Buffer 调用 .toString(encoding)
*/
class LxBuffer extends Uint8Array {
toString(encoding = 'utf-8'): string {
return bytesToString(this, encoding);
}
}
const toLxBuffer = (bytes: Uint8Array): LxBuffer =>
new LxBuffer(bytes.buffer as ArrayBuffer, bytes.byteOffset, bytes.byteLength);
const hexToBytes = (hex: string): Uint8Array => {
const clean = hex.length % 2 ? `0${hex}` : hex;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16) || 0;
}
return out;
};
const toBytes = (data: any, encoding = 'utf-8'): Uint8Array => {
if (typeof data === 'string') {
switch (encoding.toLowerCase()) {
case 'base64':
return lxCrypto.base64Decode(data);
case 'hex':
return hexToBytes(data);
case 'binary':
case 'latin1': {
const out = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
out[i] = data.charCodeAt(i) & 0xff;
}
return out;
}
default:
return new TextEncoder().encode(data);
}
}
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (Array.isArray(data)) return Uint8Array.from(data);
return new Uint8Array(0);
};
const bytesToString = (data: any, encoding = 'utf-8'): string => {
const bytes = toBytes(data);
switch (encoding.toLowerCase()) {
case 'base64':
return lxCrypto.base64Encode(bytes);
case 'hex': {
let out = '';
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
return out;
}
case 'binary':
case 'latin1': {
let out = '';
for (const byte of bytes) out += String.fromCharCode(byte);
return out;
}
default:
return new TextDecoder(encoding).decode(bytes);
}
};
/**
* 落雪脚本参照 lx-music-desktop 环境编写(隐藏 BrowserWindowwindow 存在)。
* 混淆脚本常用 `window -> (process/require ? global : this)` 探测全局对象,
* 而 module worker 中三者全为 undefined,随后访问 `.console` 即抛
* "Cannot read properties of undefined (reading 'console')",这里补齐别名。
*/
const exposeNodeLikeGlobals = () => {
const g = globalThis as any;
if (!g.window) g.window = g;
if (!g.global) g.global = g;
};
const hardenGlobalScope = () => {
const blockedKeys: Array<keyof typeof globalThis> = [
'fetch',
@@ -130,7 +213,6 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
},
send: (eventName: string, data: any) => {
if (eventName === 'inited') {
initialized = true;
postToHost({
type: 'initialized',
data: data as LxInitedData
@@ -158,27 +240,37 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
},
utils: {
buffer: {
from: (data: any, _encoding?: string) => {
if (typeof data === 'string') {
return new TextEncoder().encode(data);
}
return new Uint8Array(data);
},
bufToString: (buffer: Uint8Array, encoding?: string) => {
return new TextDecoder(encoding || 'utf-8').decode(buffer);
}
from: (data: any, encoding?: string) => toLxBuffer(toBytes(data, encoding)),
bufToString: (buffer: any, encoding?: string) => bytesToString(buffer, encoding)
},
crypto: {
md5: lxCrypto.md5,
sha1: lxCrypto.sha1,
sha256: lxCrypto.sha256,
randomBytes: lxCrypto.randomBytes,
aesEncrypt: lxCrypto.aesEncrypt,
aesDecrypt: lxCrypto.aesDecrypt,
rsaEncrypt: lxCrypto.rsaEncrypt,
rsaDecrypt: lxCrypto.rsaDecrypt,
// lx-music 中 randomBytes 返回 Node Buffer,脚本会对其调用 .toString(encoding)
randomBytes: (size: number) => {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return toLxBuffer(bytes);
},
aesEncrypt: (
buffer: string | Uint8Array,
mode: string,
key: string | Uint8Array,
iv: string | Uint8Array
) => toLxBuffer(lxCrypto.aesEncrypt(buffer, mode, key as any, iv as any)),
aesDecrypt: (
buffer: Uint8Array,
mode: string,
key: string | Uint8Array,
iv: string | Uint8Array
) => toLxBuffer(lxCrypto.aesDecrypt(buffer, mode, key as any, iv as any)),
rsaEncrypt: (buffer: string | Uint8Array, key: string) =>
toLxBuffer(lxCrypto.rsaEncrypt(buffer, key)),
rsaDecrypt: (buffer: Uint8Array, key: string) =>
toLxBuffer(lxCrypto.rsaDecrypt(buffer, key)),
base64Encode: lxCrypto.base64Encode,
base64Decode: lxCrypto.base64Decode
base64Decode: (str: string) => toLxBuffer(lxCrypto.base64Decode(str))
},
zlib: {
inflate: async (buffer: ArrayBuffer) => {
@@ -240,7 +332,6 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
const resetWorkerState = () => {
requestHandler = null;
initialized = false;
pendingHttpCallbacks.clear();
requestCounter = 0;
};
@@ -248,6 +339,7 @@ const resetWorkerState = () => {
const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => {
resetWorkerState();
hardenGlobalScope();
exposeNodeLikeGlobals();
(globalThis as any).lx = createLxApi(scriptInfo);
@@ -265,9 +357,8 @@ const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => {
try {
await import(/* @vite-ignore */ scriptUrl);
if (!initialized) {
throw new Error('脚本未调用 lx.send(EVENT_NAMES.inited, data)');
}
// 不在此处判定 initialized:脚本可能异步初始化(如先请求远端配置,
// 回调里才调用 lx.send(inited)),由 Runner 侧的初始化超时兜底
} finally {
URL.revokeObjectURL(scriptUrl);
}
+37 -9
View File
@@ -15,6 +15,32 @@ export const md5 = (str: string): string => {
return CryptoJS.MD5(str).toString();
};
/**
* Uint8Array 转 WordArray
*/
const u8ToWordArray = (u8: Uint8Array): CryptoJS.lib.WordArray => {
const words: number[] = [];
for (let i = 0; i < u8.length; i += 4) {
words.push(
((u8[i] || 0) << 24) | ((u8[i + 1] || 0) << 16) | ((u8[i + 2] || 0) << 8) | (u8[i + 3] || 0)
);
}
return CryptoJS.lib.WordArray.create(words, u8.length);
};
/**
* key/iv 归一化:脚本可能传字符串、BufferUint8Array)或 WordArray
* ECB 模式下 iv 还可能是 null/undefined
*/
const toKeyWordArray = (
input: string | Uint8Array | CryptoJS.lib.WordArray | null | undefined
): CryptoJS.lib.WordArray | undefined => {
if (input == null) return undefined;
if (typeof input === 'string') return CryptoJS.enc.Utf8.parse(input);
if (input instanceof Uint8Array) return u8ToWordArray(input);
return input;
};
/**
* 生成随机字节(返回16进制字符串)
*/
@@ -38,8 +64,8 @@ export const randomBytes = (size: number): string => {
export const aesEncrypt = (
buffer: string | Uint8Array,
mode: string,
key: string | CryptoJS.lib.WordArray,
iv: string | CryptoJS.lib.WordArray
key: string | Uint8Array | CryptoJS.lib.WordArray,
iv?: string | Uint8Array | CryptoJS.lib.WordArray | null
): Uint8Array => {
try {
// 将输入转换为 WordArray
@@ -61,8 +87,8 @@ export const aesEncrypt = (
}
// 处理密钥和 IV
const keyWordArray = typeof key === 'string' ? CryptoJS.enc.Utf8.parse(key) : key;
const ivWordArray = typeof iv === 'string' ? CryptoJS.enc.Utf8.parse(iv) : iv;
const keyWordArray = toKeyWordArray(key)!;
const ivWordArray = toKeyWordArray(iv);
// 根据模式选择加密方式
const modeObj = getModeFromString(mode);
@@ -98,8 +124,8 @@ export const aesEncrypt = (
export const aesDecrypt = (
buffer: Uint8Array,
mode: string,
key: string | CryptoJS.lib.WordArray,
iv: string | CryptoJS.lib.WordArray
key: string | Uint8Array | CryptoJS.lib.WordArray,
iv?: string | Uint8Array | CryptoJS.lib.WordArray | null
): Uint8Array => {
try {
// Uint8Array 转 WordArray
@@ -115,8 +141,8 @@ export const aesDecrypt = (
const ciphertext = CryptoJS.lib.WordArray.create(words, buffer.length);
// 处理密钥和 IV
const keyWordArray = typeof key === 'string' ? CryptoJS.enc.Utf8.parse(key) : key;
const ivWordArray = typeof iv === 'string' ? CryptoJS.enc.Utf8.parse(iv) : iv;
const keyWordArray = toKeyWordArray(key)!;
const ivWordArray = toKeyWordArray(iv);
// 根据模式选择解密方式
const modeObj = getModeFromString(mode);
@@ -222,9 +248,11 @@ export const rsaDecrypt = (buffer: Uint8Array, privateKey: string): Uint8Array =
/**
* 从字符串获取加密模式
* 兼容 Node 风格的完整算法名(如 'aes-128-ecb'、'aes-128-cbc'),
* 落雪脚本传入的 mode 与 Node crypto.createCipheriv 一致
*/
const getModeFromString = (mode: string): CryptoJS.lib.Mode => {
const modeStr = mode.toLowerCase();
const modeStr = mode.toLowerCase().split('-').pop() || mode.toLowerCase();
switch (modeStr) {
case 'cbc':
return CryptoJS.mode.CBC;