mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-07-07 18:07:32 +08:00
fix(lx): 修复落雪音源脚本导入失败及 API 端点死链导致的连环跳歌
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 环境编写(隐藏 BrowserWindow,window 存在)。
|
||||
* 混淆脚本常用 `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);
|
||||
}
|
||||
|
||||
@@ -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 归一化:脚本可能传字符串、Buffer(Uint8Array)或 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;
|
||||
|
||||
Reference in New Issue
Block a user