feat:优化lx音源问题

This commit is contained in:
alger
2025-12-20 02:29:22 +08:00
parent a9fb487332
commit 5bcef29f10
10 changed files with 814 additions and 130 deletions

View File

@@ -35,6 +35,7 @@
"@electron-toolkit/utils": "^4.0.0",
"@unblockneteasemusic/server": "^0.27.10",
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
@@ -42,9 +43,12 @@
"file-type": "^21.1.1",
"flac-tagger": "^1.0.7",
"font-list": "^1.6.0",
"form-data": "^4.0.5",
"husky": "^9.1.7",
"jsencrypt": "^3.5.4",
"music-metadata": "^11.10.3",
"netease-cloud-music-api-alger": "^4.26.1",
"node-fetch": "^2.7.0",
"node-id3": "^0.2.9",
"node-machine-id": "^1.1.12",
"pinia-plugin-persistedstate": "^4.7.1",
@@ -59,6 +63,7 @@
"@rushstack/eslint-patch": "^1.15.0",
"@types/howler": "^2.2.12",
"@types/node": "^20.19.26",
"@types/node-fetch": "^2.6.13",
"@types/tinycolor2": "^1.4.6",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",

View File

@@ -17,6 +17,7 @@ import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager, setAppQuitting } from './modules/window';
import { initWindowSizeManager } from './modules/window-size';
import { startMusicApi } from './server';
import { initLxMusicHttp } from './modules/lxMusicHttp';
// 导入所有图标
const iconPath = join(__dirname, '../../resources');
@@ -57,6 +58,9 @@ function initialize(configStore: any) {
// 启动音乐API
startMusicApi();
// 初始化落雪音乐 HTTP 请求处理
initLxMusicHttp();
// 加载歌词窗口
loadLyricWindow(ipcMain, mainWindow);

View File

@@ -0,0 +1,153 @@
/**
* 落雪音乐 HTTP 请求处理(主进程)
* 绕过渲染进程的 CORS 限制
*/
import { ipcMain } from 'electron';
import fetch, { type RequestInit } from 'node-fetch';
interface LxHttpRequest {
url: string;
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
form?: Record<string, string>;
formData?: Record<string, string>;
timeout?: number;
};
requestId: string;
}
interface LxHttpResponse {
statusCode: number;
headers: Record<string, string | string[]>;
body: any;
}
// 取消控制器映射
const abortControllers = new Map<string, AbortController>();
/**
* 初始化 HTTP 请求处理
*/
export const initLxMusicHttp = () => {
// 处理 HTTP 请求
ipcMain.handle(
'lx-music-http-request',
async (_, request: LxHttpRequest): Promise<LxHttpResponse> => {
const { url, options, requestId } = request;
const controller = new AbortController();
// 保存取消控制器
abortControllers.set(requestId, controller);
try {
console.log(`[LxMusicHttp] 请求: ${options.method || 'GET'} ${url}`);
const fetchOptions: RequestInit = {
method: options.method || 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...(options.headers || {})
},
signal: controller.signal
};
// 处理请求体
if (options.body) {
fetchOptions.body = options.body;
} else if (options.form) {
const formData = new URLSearchParams(options.form);
fetchOptions.body = formData.toString();
fetchOptions.headers = {
...fetchOptions.headers,
'Content-Type': 'application/x-www-form-urlencoded'
};
} else if (options.formData) {
// node-fetch 的 FormData 需要特殊处理
const FormData = (await import('form-data')).default;
const formData = new FormData();
for (const [key, value] of Object.entries(options.formData)) {
formData.append(key, value);
}
fetchOptions.body = formData as any;
// FormData 会自动设置 Content-Type
}
// 设置超时
const timeout = options.timeout || 30000;
const timeoutId = setTimeout(() => {
console.warn(`[LxMusicHttp] 请求超时: ${url}`);
controller.abort();
}, timeout);
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
console.log(`[LxMusicHttp] 响应: ${response.status} ${url}`);
// 读取响应体
const rawBody = await response.text();
// 尝试解析 JSON
let parsedBody: any = rawBody;
const contentType = response.headers.get('content-type') || '';
if (
contentType.includes('application/json') ||
rawBody.startsWith('{') ||
rawBody.startsWith('[')
) {
try {
parsedBody = JSON.parse(rawBody);
} catch {
// 解析失败则使用原始字符串
}
}
// 转换 headers 为普通对象
const headers: Record<string, string | string[]> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const result: LxHttpResponse = {
statusCode: response.status,
headers,
body: parsedBody
};
return result;
} catch (error: any) {
console.error(`[LxMusicHttp] 请求失败: ${url}`, error.message);
throw error;
} finally {
// 清理取消控制器
abortControllers.delete(requestId);
}
}
);
// 处理请求取消
ipcMain.handle('lx-music-http-cancel', (_, requestId: string) => {
const controller = abortControllers.get(requestId);
if (controller) {
console.log(`[LxMusicHttp] 取消请求: ${requestId}`);
controller.abort();
abortControllers.delete(requestId);
}
});
console.log('[LxMusicHttp] HTTP 请求处理已初始化');
};
/**
* 清理所有正在进行的请求
*/
export const cleanupLxMusicHttp = () => {
for (const [requestId, controller] of abortControllers.entries()) {
console.log(`[LxMusicHttp] 清理请求: ${requestId}`);
controller.abort();
}
abortControllers.clear();
};

View File

@@ -25,6 +25,12 @@ interface API {
importLxMusicScript: () => Promise<{ name: string; content: string } | null>;
invoke: (channel: string, ...args: any[]) => Promise<any>;
getSearchSuggestions: (keyword: string) => Promise<any>;
lxMusicHttpRequest: (request: {
url: string;
options: any;
requestId: string;
}) => Promise<any>;
lxMusicHttpCancel: (requestId: string) => Promise<void>;
}
// 自定义IPC渲染进程通信接口

View File

@@ -58,7 +58,16 @@ const api = {
return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`));
},
// 搜索建议
getSearchSuggestions: (keyword: string) => ipcRenderer.invoke('get-search-suggestions', keyword)
getSearchSuggestions: (keyword: string) => ipcRenderer.invoke('get-search-suggestions', keyword),
// 落雪音乐 HTTP 请求(绕过 CORS
lxMusicHttpRequest: (request: {
url: string;
options: any;
requestId: string;
}) => ipcRenderer.invoke('lx-music-http-request', request),
lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId)
};
// 创建带类型的ipcRenderer对象暴露给渲染进程

View File

@@ -27,6 +27,7 @@ import { checkLoginStatus } from '@/utils/auth';
import { initAudioListeners, initMusicHook } from './hooks/MusicHook';
import { audioService } from './services/audioService';
import { initLxMusicRunner } from './services/LxMusicSourceRunner';
import { isMobile } from './utils';
import { useAppShortcuts } from './utils/appShortcuts';
@@ -125,6 +126,21 @@ onMounted(async () => {
// 初始化播放状态
await playerStore.initializePlayState();
// 初始化落雪音源(如果有激活的音源)
const activeLxApiId = settingsStore.setData?.activeLxMusicApiId;
if (activeLxApiId) {
const lxMusicScripts = settingsStore.setData?.lxMusicScripts || [];
const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId);
if (activeScript && activeScript.script) {
try {
console.log('[App] 初始化激活的落雪音源:', activeScript.name);
await initLxMusicRunner(activeScript.script);
} catch (error) {
console.error('[App] 初始化落雪音源失败:', error);
}
}
}
// 如果有正在播放的音乐,则初始化音频监听器
if (playerStore.playMusic && playerStore.playMusic.id) {
// 使用 nextTick 确保 DOM 更新后再初始化

View File

@@ -13,6 +13,74 @@ import type { SongResult } from '@/types/music';
import type { MusicParseResult } from './musicParser';
import { CacheManager } from './musicParser';
/**
* 解析可能是 API 端点的 URL获取真实音频 URL
* 一些音源脚本返回的是 API 端点,需要额外请求才能获取真实音频 URL
*/
const resolveAudioUrl = async (url: string): Promise<string> => {
try {
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
if (!isApiEndpoint) {
// 看起来像直接的音频 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;
if (audioUrl && typeof audioUrl === 'string') {
console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl);
return audioUrl;
}
}
// 如果都不是,返回原始 URL可能直接可用
console.warn('[LxMusicStrategy] 无法解析 API 端点,返回原始 URL');
return url;
} catch (error) {
console.error('[LxMusicStrategy] URL 解析失败:', error);
// 解析失败时返回原始 URL
return url;
}
};
/**
* 将 SongResult 转换为 LxMusicInfo 格式
*/
@@ -82,9 +150,17 @@ export class LxMusicStrategy {
return false;
}
// 检查是否导入了脚本
const script = settingsStore?.setData?.lxMusicScript;
return Boolean(script);
// 检查是否有激活的音源
const activeLxApiId = settingsStore?.setData?.activeLxMusicApiId;
if (!activeLxApiId) {
return false;
}
// 检查音源列表中是否存在该 ID
const lxMusicScripts = settingsStore?.setData?.lxMusicScripts || [];
const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId);
return Boolean(activeScript && activeScript.script);
}
/**
@@ -103,18 +179,30 @@ export class LxMusicStrategy {
try {
const settingsStore = useSettingsStore();
const script = settingsStore.setData?.lxMusicScript;
if (!script) {
console.log('[LxMusicStrategy] 未导入落雪音源脚本');
// 获取激活的音源 ID
const activeLxApiId = settingsStore.setData?.activeLxMusicApiId;
if (!activeLxApiId) {
console.log('[LxMusicStrategy] 未选择激活的落雪音源');
return null;
}
// 从音源列表中获取激活的脚本
const lxMusicScripts = settingsStore.setData?.lxMusicScripts || [];
const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId);
if (!activeScript || !activeScript.script) {
console.log('[LxMusicStrategy] 未找到激活的落雪音源脚本');
return null;
}
console.log(`[LxMusicStrategy] 使用激活的音源: ${activeScript.name} (ID: ${activeScript.id})`);
// 获取或初始化执行器
let runner = getLxMusicRunner();
if (!runner || !runner.isInitialized()) {
console.log('[LxMusicStrategy] 初始化落雪音源执行器...');
runner = await initLxMusicRunner(script);
runner = await initLxMusicRunner(activeScript.script);
}
// 获取可用音源
@@ -144,22 +232,33 @@ export class LxMusicStrategy {
const lxQuality: LxQuality = QUALITY_TO_LX[quality || 'higher'] || '320k';
// 获取音乐 URL
const url = await runner.getMusicUrl(bestSource, lxMusicInfo, lxQuality);
const rawUrl = await runner.getMusicUrl(bestSource, lxMusicInfo, lxQuality);
if (!url) {
if (!rawUrl) {
console.log('[LxMusicStrategy] 获取 URL 失败');
CacheManager.addFailedCache(id, this.name);
return null;
}
console.log('[LxMusicStrategy] 解析成功:', url.substring(0, 50) + '...');
console.log('[LxMusicStrategy] 脚本返回 URL:', rawUrl.substring(0, 80) + '...');
// 解析可能是 API 端点的 URL
const resolvedUrl = await resolveAudioUrl(rawUrl);
if (!resolvedUrl) {
console.log('[LxMusicStrategy] URL 解析失败');
CacheManager.addFailedCache(id, this.name);
return null;
}
console.log('[LxMusicStrategy] 最终音频 URL:', resolvedUrl.substring(0, 80) + '...');
return {
data: {
code: 200,
message: 'success',
data: {
url,
url: resolvedUrl,
source: `lx-${bestSource}`,
quality: lxQuality
}

View File

@@ -55,7 +55,7 @@
class="source-card source-card--lxmusic"
:class="{
'source-card--selected': isSourceSelected('lxMusic'),
'source-card--disabled': !settingsStore.setData.lxMusicScript
'source-card--disabled': !activeLxApiId || lxMusicApis.length === 0
}"
style="--source-color: #10b981"
@click="toggleSource('lxMusic')"
@@ -70,9 +70,9 @@
</div>
<p class="source-card__description">
{{
settingsStore.setData.lxMusicScript
? lxMusicScriptInfo?.name || '已导入'
: '未导入 (请去落雪音源Tab配置)'
activeLxApiId && lxMusicScriptInfo
? lxMusicScriptInfo.name
: '未配置 (请去落雪音源Tab配置)'
}}
</p>
</div>
@@ -321,8 +321,19 @@ const activeLxApiId = computed<string | null>({
// 落雪音源脚本信息(保持向后兼容)
const lxMusicScriptInfo = computed<LxScriptInfo | null>(() => {
const activeId = activeLxApiId.value;
if (!activeId) return null;
if (!activeId) {
console.log('[lxMusicScriptInfo] 没有激活的音源 ID');
return null;
}
const activeApi = lxMusicApis.value.find((api) => api.id === activeId);
console.log('[lxMusicScriptInfo] 查找激活的音源:', {
activeId,
found: !!activeApi,
name: activeApi?.name,
infoName: activeApi?.info?.name
});
return activeApi?.info || null;
});
@@ -351,10 +362,16 @@ const toggleSource = (sourceKey: string) => {
return;
}
// 检查是否是落雪音源且未导入
if (sourceKey === 'lxMusic' && !settingsStore.setData.lxMusicScript) {
message.warning('请先导入落雪音源脚本');
return;
// 检查是否是落雪音源且未配置
if (sourceKey === 'lxMusic') {
if (lxMusicApis.value.length === 0) {
message.warning('请先导入落雪音源脚本');
return;
}
if (!activeLxApiId.value) {
message.warning('请先选择一个落雪音源');
return;
}
}
const index = selectedSources.value.indexOf(sourceKey as ExtendedPlatform);
@@ -411,6 +428,7 @@ const importLxMusicScript = async () => {
const addLxMusicScript = async (scriptContent: string) => {
// 解析脚本信息
const scriptInfo = parseScriptInfo(scriptContent);
console.log('[MusicSourceSettings] 解析到的脚本信息:', scriptInfo);
// 尝试初始化执行器以验证脚本
try {
@@ -418,6 +436,8 @@ const addLxMusicScript = async (scriptContent: string) => {
const sources = runner.getSources();
const sourceKeys = Object.keys(sources) as LxSourceKey[];
console.log('[MusicSourceSettings] 脚本支持的音源:', sourceKeys);
// 生成唯一 ID
const id = `lx_api_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
@@ -432,6 +452,13 @@ const addLxMusicScript = async (scriptContent: string) => {
createdAt: Date.now()
};
console.log('[MusicSourceSettings] 创建的音源配置:', {
id: newApiConfig.id,
name: newApiConfig.name,
infoName: newApiConfig.info.name,
sources: newApiConfig.sources
});
// 添加到列表
const scripts = [...(settingsStore.setData.lxMusicScripts || []), newApiConfig];
@@ -440,6 +467,12 @@ const addLxMusicScript = async (scriptContent: string) => {
activeLxMusicApiId: id // 自动激活新添加的音源
});
console.log('[MusicSourceSettings] 保存后的 store 数据:', {
scriptsCount: scripts.length,
activeId: id,
firstScript: scripts[0] ? { id: scripts[0].id, name: scripts[0].name } : null
});
message.success(`音源脚本导入成功:${scriptInfo.name},支持 ${sourceKeys.length} 个音源`);
// 导入成功后自动勾选
@@ -447,7 +480,7 @@ const addLxMusicScript = async (scriptContent: string) => {
selectedSources.value.push('lxMusic');
}
} catch (initError: any) {
console.error('落雪音源脚本初始化失败:', initError);
console.error('[MusicSourceSettings] 落雪音源脚本初始化失败:', initError);
message.error(`脚本初始化失败:${initError.message}`);
}
};
@@ -463,8 +496,19 @@ const setActiveLxApi = async (apiId: string) => {
}
try {
// 初始化选中的脚本
await initLxMusicRunner(api.script);
console.log('[MusicSourceSettings] 切换音源:', {
id: api.id,
name: api.name,
version: api.info?.version,
sources: api.sources
});
// 清除旧的 runner
setLxMusicRunner(null);
// 初始化新选中的脚本
const runner = await initLxMusicRunner(api.script);
console.log('[MusicSourceSettings] 音源初始化成功,支持的音源:', runner.getSources());
// 更新激活的音源 ID
activeLxApiId.value = apiId;
@@ -476,7 +520,7 @@ const setActiveLxApi = async (apiId: string) => {
message.success(`已切换到音源: ${api.name}`);
} catch (error: any) {
console.error('切换落雪音源失败:', error);
console.error('[MusicSourceSettings] 切换落雪音源失败:', error);
message.error(`切换失败:${error.message}`);
}
};
@@ -641,6 +685,21 @@ watch(
}
);
// 监听落雪音源列表变化
watch(
() => [lxMusicApis.value.length, activeLxApiId.value],
([apiCount, activeId]) => {
// 如果没有音源或没有激活的音源,自动从已选音源中移除 lxMusic
if (apiCount === 0 || !activeId) {
const index = selectedSources.value.indexOf('lxMusic');
if (index > -1) {
selectedSources.value.splice(index, 1);
}
}
},
{ deep: true }
);
// 同步外部show属性变化
watch(
() => props.show,

View File

@@ -17,6 +17,7 @@ import type {
LxSourceConfig,
LxSourceKey
} from '@/types/lxMusic';
import * as lxCrypto from '@/utils/lxCrypto';
/**
* 解析脚本头部注释中的元信息
@@ -27,27 +28,46 @@ export const parseScriptInfo = (script: string): LxScriptInfo => {
rawScript: script
};
// 匹配头部注释块
const headerMatch = script.match(/^\/\*\*[\s\S]*?\*\//);
if (!headerMatch) return info;
// 尝试匹配不同格式的头部注释块
// 支持 /** ... */ 和 /* ... */ 格式
const headerMatch = script.match(/^\/\*+[\s\S]*?\*\//);
if (!headerMatch) {
console.warn('[parseScriptInfo] 未找到脚本头部注释块');
return info;
}
const header = headerMatch[0];
console.log('[parseScriptInfo] 解析脚本头部:', header.substring(0, 200));
// 解析各个字段
// 解析各个字段(支持 * 前缀和无前缀两种格式)
const nameMatch = header.match(/@name\s+(.+?)(?:\r?\n|\*\/)/);
if (nameMatch) info.name = nameMatch[1].trim();
if (nameMatch) {
info.name = nameMatch[1].trim().replace(/^\*\s*/, '');
console.log('[parseScriptInfo] 解析到名称:', info.name);
} else {
console.warn('[parseScriptInfo] 未找到 @name 标签');
}
const descMatch = header.match(/@description\s+(.+?)(?:\r?\n|\*\/)/);
if (descMatch) info.description = descMatch[1].trim();
if (descMatch) {
info.description = descMatch[1].trim().replace(/^\*\s*/, '');
}
const versionMatch = header.match(/@version\s+(.+?)(?:\r?\n|\*\/)/);
if (versionMatch) info.version = versionMatch[1].trim();
if (versionMatch) {
info.version = versionMatch[1].trim().replace(/^\*\s*/, '');
console.log('[parseScriptInfo] 解析到版本:', info.version);
}
const authorMatch = header.match(/@author\s+(.+?)(?:\r?\n|\*\/)/);
if (authorMatch) info.author = authorMatch[1].trim();
if (authorMatch) {
info.author = authorMatch[1].trim().replace(/^\*\s*/, '');
}
const homepageMatch = header.match(/@homepage\s+(.+?)(?:\r?\n|\*\/)/);
if (homepageMatch) info.homepage = homepageMatch[1].trim();
if (homepageMatch) {
info.homepage = homepageMatch[1].trim().replace(/^\*\s*/, '');
}
return info;
};
@@ -209,26 +229,16 @@ export class LxMusicSourceRunner {
}
},
crypto: {
md5: (str: string) => {
// 简化的 MD5 实现,实际使用可能需要引入完整库
console.warn('[LxMusicRunner] MD5 暂未完整实现');
return str;
},
randomBytes: (size: number) => {
const array = new Uint8Array(size);
crypto.getRandomValues(array);
return Array.from(array)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
},
aesEncrypt: (buffer: any, _mode: string, _key: any, _iv: any) => {
console.warn('[LxMusicRunner] AES 加密暂未实现');
return buffer;
},
rsaEncrypt: (buffer: any, _key: string) => {
console.warn('[LxMusicRunner] RSA 加密暂未实现');
return buffer;
}
md5: lxCrypto.md5,
sha1: lxCrypto.sha1,
sha256: lxCrypto.sha256,
randomBytes: lxCrypto.randomBytes,
aesEncrypt: lxCrypto.aesEncrypt,
aesDecrypt: lxCrypto.aesDecrypt,
rsaEncrypt: lxCrypto.rsaEncrypt,
rsaDecrypt: lxCrypto.rsaDecrypt,
base64Encode: lxCrypto.base64Encode,
base64Decode: lxCrypto.base64Decode
},
zlib: {
inflate: async (buffer: ArrayBuffer) => {
@@ -337,96 +347,135 @@ export class LxMusicSourceRunner {
}
/**
* 处理 HTTP 请求
* 处理 HTTP 请求(优先使用主进程,绕过 CORS 限制)
*/
private handleHttpRequest(
url: string,
options: any,
callback: (err: Error | null, resp: any, body: any) => void
): () => void {
const controller = new AbortController();
console.log(`[LxMusicRunner] HTTP 请求: ${options.method || 'GET'} ${url}`);
const fetchOptions: RequestInit = {
method: options.method || 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...options.headers
},
signal: controller.signal,
// 使用 cors 模式尝试跨域请求
mode: 'cors',
// 不发送凭据
credentials: 'omit'
};
if (options.body) {
fetchOptions.body = options.body;
} else if (options.form) {
fetchOptions.body = new URLSearchParams(options.form);
fetchOptions.headers = {
...fetchOptions.headers,
'Content-Type': 'application/x-www-form-urlencoded'
};
} else if (options.formData) {
const formData = new FormData();
for (const [key, value] of Object.entries(options.formData)) {
formData.append(key, value as string);
}
fetchOptions.body = formData;
}
const timeout = options.timeout || 30000;
const timeoutId = setTimeout(() => {
console.warn(`[LxMusicRunner] HTTP 请求超时: ${url}`);
controller.abort();
}, timeout);
const requestId = `lx_http_${Date.now()}_${Math.random().toString(36).substring(7)}`;
fetch(url, fetchOptions)
.then(async (response) => {
clearTimeout(timeoutId);
console.log(`[LxMusicRunner] HTTP 响应: ${response.status} ${url}`);
// 尝试使用主进程 HTTP 请求(如果可用)
const hasMainProcessHttp = typeof window.api?.lxMusicHttpRequest === 'function';
const rawBody = await response.text();
if (hasMainProcessHttp) {
// 使用主进程 HTTP 请求(绕过 CORS
console.log(`[LxMusicRunner] 使用主进程 HTTP 请求`);
// 尝试解析 JSON如果是 JSON 则返回解析后的对象
let parsedBody: any = rawBody;
const contentType = response.headers.get('content-type') || '';
if (
contentType.includes('application/json') ||
rawBody.startsWith('{') ||
rawBody.startsWith('[')
) {
try {
parsedBody = JSON.parse(rawBody);
// 如果响应中包含 URL缓存下来以备后用
if (parsedBody && parsedBody.url && typeof parsedBody.url === 'string') {
this.lastMusicUrl = parsedBody.url;
}
} catch {
// 解析失败则使用原始字符串
}
}
callback(
null,
{
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
body: parsedBody
window.api
.lxMusicHttpRequest({
url,
options: {
...options,
timeout
},
parsedBody
);
})
.catch((error) => {
clearTimeout(timeoutId);
console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message);
callback(error, null, null);
});
requestId
})
.then((response: any) => {
console.log(`[LxMusicRunner] HTTP 响应: ${response.statusCode} ${url}`);
// 返回取消函数
return () => controller.abort();
// 如果响应中包含 URL缓存下来以备后用
if (response.body && response.body.url && typeof response.body.url === 'string') {
this.lastMusicUrl = response.body.url;
}
callback(null, response, response.body);
})
.catch((error: Error) => {
console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message);
callback(error, null, null);
});
// 返回取消函数
return () => {
void window.api?.lxMusicHttpCancel?.(requestId);
};
} else {
// 回退到渲染进程 fetch可能受 CORS 限制)
console.log(`[LxMusicRunner] 主进程 HTTP 不可用,使用渲染进程 fetch`);
const controller = new AbortController();
const fetchOptions: RequestInit = {
method: options.method || 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...options.headers
},
signal: controller.signal,
mode: 'cors',
credentials: 'omit'
};
if (options.body) {
fetchOptions.body = options.body;
} else if (options.form) {
fetchOptions.body = new URLSearchParams(options.form);
fetchOptions.headers = {
...fetchOptions.headers,
'Content-Type': 'application/x-www-form-urlencoded'
};
} else if (options.formData) {
const formData = new FormData();
for (const [key, value] of Object.entries(options.formData)) {
formData.append(key, value as string);
}
fetchOptions.body = formData;
}
const timeoutId = setTimeout(() => {
console.warn(`[LxMusicRunner] HTTP 请求超时: ${url}`);
controller.abort();
}, timeout);
fetch(url, fetchOptions)
.then(async (response) => {
clearTimeout(timeoutId);
console.log(`[LxMusicRunner] HTTP 响应: ${response.status} ${url}`);
const rawBody = await response.text();
// 尝试解析 JSON
let parsedBody: any = rawBody;
const contentType = response.headers.get('content-type') || '';
if (
contentType.includes('application/json') ||
rawBody.startsWith('{') ||
rawBody.startsWith('[')
) {
try {
parsedBody = JSON.parse(rawBody);
if (parsedBody && parsedBody.url && typeof parsedBody.url === 'string') {
this.lastMusicUrl = parsedBody.url;
}
} catch {
// 解析失败则使用原始字符串
}
}
callback(
null,
{
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
body: parsedBody
},
parsedBody
);
})
.catch((error) => {
clearTimeout(timeoutId);
console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message);
callback(error, null, null);
});
// 返回取消函数
return () => controller.abort();
}
}
/**

View File

@@ -0,0 +1,284 @@
/**
* 落雪音乐加密工具
* 实现 lx.utils.crypto API
*
* 提供 MD5、AES、RSA 等加密功能
*/
import CryptoJS from 'crypto-js';
import { JSEncrypt } from 'jsencrypt';
/**
* MD5 哈希
*/
export const md5 = (str: string): string => {
return CryptoJS.MD5(str).toString();
};
/**
* 生成随机字节返回16进制字符串
*/
export const randomBytes = (size: number): string => {
const array = new Uint8Array(size);
crypto.getRandomValues(array);
return Array.from(array)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};
/**
* AES 加密
*
* @param buffer - 要加密的数据(字符串或 Buffer
* @param mode - 加密模式(如 'cbc'
* @param key - 密钥(字符串或 WordArray
* @param iv - 初始化向量(字符串或 WordArray
* @returns 加密后的 BufferUint8Array
*/
export const aesEncrypt = (
buffer: string | Uint8Array,
mode: string,
key: string | CryptoJS.lib.WordArray,
iv: string | CryptoJS.lib.WordArray
): Uint8Array => {
try {
// 将输入转换为 WordArray
let wordArray: CryptoJS.lib.WordArray;
if (typeof buffer === 'string') {
wordArray = CryptoJS.enc.Utf8.parse(buffer);
} else {
// Uint8Array 转 WordArray
const words: number[] = [];
for (let i = 0; i < buffer.length; i += 4) {
words.push(
((buffer[i] || 0) << 24) |
((buffer[i + 1] || 0) << 16) |
((buffer[i + 2] || 0) << 8) |
(buffer[i + 3] || 0)
);
}
wordArray = 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 modeObj = getModeFromString(mode);
// 执行加密
const encrypted = CryptoJS.AES.encrypt(wordArray, keyWordArray, {
iv: ivWordArray,
mode: modeObj,
padding: CryptoJS.pad.Pkcs7
});
// 将结果转换为 Uint8Array
const ciphertext = encrypted.ciphertext;
const result = new Uint8Array(ciphertext.words.length * 4);
for (let i = 0; i < ciphertext.words.length; i++) {
const word = ciphertext.words[i];
result[i * 4] = (word >>> 24) & 0xff;
result[i * 4 + 1] = (word >>> 16) & 0xff;
result[i * 4 + 2] = (word >>> 8) & 0xff;
result[i * 4 + 3] = word & 0xff;
}
return result.slice(0, ciphertext.sigBytes);
} catch (error) {
console.error('[lxCrypto] AES 加密失败:', error);
throw error;
}
};
/**
* AES 解密
*/
export const aesDecrypt = (
buffer: Uint8Array,
mode: string,
key: string | CryptoJS.lib.WordArray,
iv: string | CryptoJS.lib.WordArray
): Uint8Array => {
try {
// Uint8Array 转 WordArray
const words: number[] = [];
for (let i = 0; i < buffer.length; i += 4) {
words.push(
((buffer[i] || 0) << 24) |
((buffer[i + 1] || 0) << 16) |
((buffer[i + 2] || 0) << 8) |
(buffer[i + 3] || 0)
);
}
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 modeObj = getModeFromString(mode);
// 构造加密对象
const cipherParams = CryptoJS.lib.CipherParams.create({
ciphertext
});
// 执行解密
const decrypted = CryptoJS.AES.decrypt(cipherParams, keyWordArray, {
iv: ivWordArray,
mode: modeObj,
padding: CryptoJS.pad.Pkcs7
});
// 转换为 Uint8Array
const result = new Uint8Array(decrypted.words.length * 4);
for (let i = 0; i < decrypted.words.length; i++) {
const word = decrypted.words[i];
result[i * 4] = (word >>> 24) & 0xff;
result[i * 4 + 1] = (word >>> 16) & 0xff;
result[i * 4 + 2] = (word >>> 8) & 0xff;
result[i * 4 + 3] = word & 0xff;
}
return result.slice(0, decrypted.sigBytes);
} catch (error) {
console.error('[lxCrypto] AES 解密失败:', error);
throw error;
}
};
/**
* RSA 加密
*
* @param buffer - 要加密的数据
* @param publicKey - RSA 公钥PEM 格式)
* @returns 加密后的数据Uint8Array
*/
export const rsaEncrypt = (buffer: string | Uint8Array, publicKey: string): Uint8Array => {
try {
const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);
// 转换输入为字符串
let input: string;
if (typeof buffer === 'string') {
input = buffer;
} else {
// Uint8Array 转字符串
input = new TextDecoder().decode(buffer);
}
// 执行加密(返回 base64
const encrypted = encrypt.encrypt(input);
if (!encrypted) {
throw new Error('RSA encryption failed');
}
// Base64 解码为 Uint8Array
const binaryString = atob(encrypted);
const result = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
result[i] = binaryString.charCodeAt(i);
}
return result;
} catch (error) {
console.error('[lxCrypto] RSA 加密失败:', error);
throw error;
}
};
/**
* RSA 解密
*/
export const rsaDecrypt = (buffer: Uint8Array, privateKey: string): Uint8Array => {
try {
const decrypt = new JSEncrypt();
decrypt.setPrivateKey(privateKey);
// Uint8Array 转 Base64
let binaryString = '';
for (let i = 0; i < buffer.length; i++) {
binaryString += String.fromCharCode(buffer[i]);
}
const base64 = btoa(binaryString);
// 执行解密
const decrypted = decrypt.decrypt(base64);
if (!decrypted) {
throw new Error('RSA decryption failed');
}
// 字符串转 Uint8Array
return new TextEncoder().encode(decrypted);
} catch (error) {
console.error('[lxCrypto] RSA 解密失败:', error);
throw error;
}
};
/**
* 从字符串获取加密模式
*/
const getModeFromString = (mode: string): CryptoJS.lib.Mode => {
const modeStr = mode.toLowerCase();
switch (modeStr) {
case 'cbc':
return CryptoJS.mode.CBC;
case 'cfb':
return CryptoJS.mode.CFB;
case 'ctr':
return CryptoJS.mode.CTR;
case 'ofb':
return CryptoJS.mode.OFB;
case 'ecb':
return CryptoJS.mode.ECB;
default:
console.warn(`[lxCrypto] 未知的加密模式: ${mode}, 使用 CBC`);
return CryptoJS.mode.CBC;
}
};
/**
* SHA1 哈希
*/
export const sha1 = (str: string): string => {
return CryptoJS.SHA1(str).toString();
};
/**
* SHA256 哈希
*/
export const sha256 = (str: string): string => {
return CryptoJS.SHA256(str).toString();
};
/**
* Base64 编码
*/
export const base64Encode = (data: string | Uint8Array): string => {
if (typeof data === 'string') {
return btoa(data);
} else {
let binary = '';
for (let i = 0; i < data.length; i++) {
binary += String.fromCharCode(data[i]);
}
return btoa(binary);
}
};
/**
* Base64 解码
*/
export const base64Decode = (str: string): Uint8Array => {
const binaryString = atob(str);
const result = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
result[i] = binaryString.charCodeAt(i);
}
return result;
};