feat: 添加 lx 音源导入

This commit is contained in:
alger
2025-12-13 15:00:38 +08:00
parent b9287e1c36
commit 89c6b11110
14 changed files with 1569 additions and 123 deletions

View File

@@ -310,6 +310,47 @@ export function initializeFileManager() {
throw new Error(`文件读取或解析失败: ${error.message}`);
}
});
// 处理导入落雪音源脚本的请求
ipcMain.handle('import-lx-music-script', async () => {
const result = await dialog.showOpenDialog({
title: '选择落雪音源脚本文件',
filters: [{ name: 'JavaScript Files', extensions: ['js'] }],
properties: ['openFile']
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
const filePath = result.filePaths[0];
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
// 验证脚本格式:检查是否包含落雪音源特征
if (
!fileContent.includes('globalThis.lx') &&
!fileContent.includes('lx.on') &&
!fileContent.includes('EVENT_NAMES')
) {
throw new Error('无效的落雪音源脚本,未找到 globalThis.lx 相关代码。');
}
// 检查是否包含必要的元信息注释
const hasMetaComment = fileContent.includes('@name');
if (!hasMetaComment) {
console.warn('警告: 脚本缺少 @name 元信息注释');
}
return {
name: path.basename(filePath, '.js'),
content: fileContent
};
} catch (error: any) {
console.error('读取落雪音源脚本失败:', error);
throw new Error(`脚本读取失败: ${error.message}`);
}
});
}
/**

View File

@@ -24,7 +24,7 @@
"alwaysShowDownloadButton": false,
"unlimitedDownload": false,
"enableMusicUnblock": true,
"enabledMusicSources": ["migu", "kugou", "pyncmd", "bilibili"],
"enabledMusicSources": ["migu", "kugou", "pyncmd"],
"showTopAction": false,
"contentZoomFactor": 1,
"autoTheme": false,
@@ -32,5 +32,7 @@
"isMenuExpanded": false,
"customApiPlugin": "",
"customApiPluginName": "",
"lxMusicScripts": [],
"activeLxMusicApiId": null,
"enableGpuAcceleration": true
}

View File

@@ -22,6 +22,7 @@ interface API {
onLanguageChanged: (callback: (locale: string) => void) => void;
removeDownloadListeners: () => void;
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
importLxMusicScript: () => Promise<{ name: string; content: string } | null>;
invoke: (channel: string, ...args: any[]) => Promise<any>;
getSearchSuggestions: (keyword: string) => Promise<any>;
}

View File

@@ -19,6 +19,7 @@ const api = {
unblockMusic: (id, data, enabledSources) =>
ipcRenderer.invoke('unblock-music', id, data, enabledSources),
importCustomApiPlugin: () => ipcRenderer.invoke('import-custom-api-plugin'),
importLxMusicScript: () => ipcRenderer.invoke('import-lx-music-script'),
// 歌词窗口关闭事件
onLyricWindowClosed: (callback: () => void) => {
ipcRenderer.on('lyric-window-closed', () => callback());

View File

@@ -0,0 +1,174 @@
/**
* 落雪音乐 (LX Music) 音源解析策略
*
* 实现 MusicSourceStrategy 接口,作为落雪音源的解析入口
*/
import { getLxMusicRunner, initLxMusicRunner } from '@/services/LxMusicSourceRunner';
import { useSettingsStore } from '@/store';
import type { LxMusicInfo, LxQuality, LxSourceKey } from '@/types/lxMusic';
import { LX_SOURCE_NAMES, QUALITY_TO_LX } from '@/types/lxMusic';
import type { SongResult } from '@/types/music';
import type { MusicParseResult } from './musicParser';
import { CacheManager } from './musicParser';
/**
* 将 SongResult 转换为 LxMusicInfo 格式
*/
const convertToLxMusicInfo = (songResult: SongResult): LxMusicInfo => {
const artistName =
songResult.ar && songResult.ar.length > 0
? songResult.ar.map((a) => a.name).join('、')
: songResult.artists && songResult.artists.length > 0
? songResult.artists.map((a) => a.name).join('、')
: '';
const albumName = songResult.al?.name || (songResult.album as any)?.name || '';
const albumId = songResult.al?.id || (songResult.album as any)?.id || '';
// 计算时长(秒转分钟:秒格式)
const duration = songResult.dt || songResult.duration || 0;
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
const interval = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
return {
songmid: songResult.id,
name: songResult.name,
singer: artistName,
album: albumName,
albumId,
source: 'wy', // 默认使用网易云作为源,因为我们的数据来自网易云
interval,
img: songResult.picUrl || songResult.al?.picUrl || ''
};
};
/**
* 获取最佳匹配的落雪音源
* 因为我们的数据来自网易云,优先尝试 wy 音源
*/
const getBestMatchingSource = (
availableSources: LxSourceKey[],
_songSource?: string
): LxSourceKey | null => {
// 优先级顺序:网易云 > 酷我 > 咪咕 > 酷狗 > QQ音乐
const priority: LxSourceKey[] = ['wy', 'kw', 'mg', 'kg', 'tx'];
for (const source of priority) {
if (availableSources.includes(source)) {
return source;
}
}
return availableSources[0] || null;
};
/**
* 落雪音乐解析策略
*/
export class LxMusicStrategy {
name = 'lxMusic';
priority = 0; // 最高优先级
/**
* 检查是否可以处理
*/
canHandle(sources: string[], settingsStore?: any): boolean {
// 检查是否启用了落雪音源
if (!sources.includes('lxMusic')) {
return false;
}
// 检查是否导入了脚本
const script = settingsStore?.setData?.lxMusicScript;
return Boolean(script);
}
/**
* 解析音乐 URL
*/
async parse(
id: number,
data: SongResult,
quality?: string,
_sources?: string[]
): Promise<MusicParseResult | null> {
// 检查失败缓存
if (CacheManager.isInFailedCache(id, this.name)) {
return null;
}
try {
const settingsStore = useSettingsStore();
const script = settingsStore.setData?.lxMusicScript;
if (!script) {
console.log('[LxMusicStrategy] 未导入落雪音源脚本');
return null;
}
// 获取或初始化执行器
let runner = getLxMusicRunner();
if (!runner || !runner.isInitialized()) {
console.log('[LxMusicStrategy] 初始化落雪音源执行器...');
runner = await initLxMusicRunner(script);
}
// 获取可用音源
const sources = runner.getSources();
const availableSourceKeys = Object.keys(sources) as LxSourceKey[];
if (availableSourceKeys.length === 0) {
console.log('[LxMusicStrategy] 没有可用的落雪音源');
CacheManager.addFailedCache(id, this.name);
return null;
}
// 选择最佳音源
const bestSource = getBestMatchingSource(availableSourceKeys);
if (!bestSource) {
console.log('[LxMusicStrategy] 无法找到匹配的音源');
CacheManager.addFailedCache(id, this.name);
return null;
}
console.log(`[LxMusicStrategy] 使用音源: ${LX_SOURCE_NAMES[bestSource]} (${bestSource})`);
// 转换歌曲信息
const lxMusicInfo = convertToLxMusicInfo(data);
// 转换音质
const lxQuality: LxQuality = QUALITY_TO_LX[quality || 'higher'] || '320k';
// 获取音乐 URL
const url = await runner.getMusicUrl(bestSource, lxMusicInfo, lxQuality);
if (!url) {
console.log('[LxMusicStrategy] 获取 URL 失败');
CacheManager.addFailedCache(id, this.name);
return null;
}
console.log('[LxMusicStrategy] 解析成功:', url.substring(0, 50) + '...');
return {
data: {
code: 200,
message: 'success',
data: {
url,
source: `lx-${bestSource}`,
quality: lxQuality
}
}
};
} catch (error) {
console.error('[LxMusicStrategy] 解析失败:', error);
CacheManager.addFailedCache(id, this.name);
return null;
}
}
}

View File

@@ -10,6 +10,7 @@ import requestMusic from '@/utils/request_music';
import { searchAndGetBilibiliAudioUrl } from './bilibili';
import type { ParsedMusicResult } from './gdmusic';
import { parseFromGDMusic } from './gdmusic';
import { LxMusicStrategy } from './lxMusicStrategy';
import { parseFromCustomApi } from './parseFromCustomApi';
const { saveData, getData, deleteData } = musicDB;
@@ -499,6 +500,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
*/
class MusicSourceStrategyFactory {
private static strategies: MusicSourceStrategy[] = [
new LxMusicStrategy(),
new CustomApiStrategy(),
new BilibiliStrategy(),
new GDMusicStrategy(),

View File

@@ -122,7 +122,8 @@ const getSourceIcon = (source: Platform) => {
pyncmd: 'ri-netease-cloud-music-fill',
bilibili: 'ri-bilibili-fill',
gdmusic: 'ri-google-fill',
kuwo: 'ri-music-fill'
kuwo: 'ri-music-fill',
lxMusic: 'ri-leaf-fill'
};
return iconMap[source] || 'ri-music-2-fill';

View File

@@ -10,107 +10,262 @@
@negative-click="handleCancel"
style="width: 800px; max-width: 90vw"
>
<n-space vertical :size="20">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('settings.playback.musicSourcesDesc') }}
</p>
<!-- 音源卡片列表 -->
<div class="music-sources-grid">
<div
v-for="source in MUSIC_SOURCES"
:key="source.key"
class="source-card"
:class="{
'source-card--selected': isSourceSelected(source.key),
'source-card--disabled': source.disabled && !isSourceSelected(source.key)
}"
:style="{ '--source-color': source.color }"
@click="toggleSource(source.key)"
>
<div class="source-card__indicator"></div>
<div class="source-card__content">
<div class="source-card__header">
<span class="source-card__name">{{ source.key }}</span>
<n-icon v-if="isSourceSelected(source.key)" size="18" class="source-card__check">
<i class="ri-checkbox-circle-fill"></i>
</n-icon>
</div>
<p v-if="source.description" class="source-card__description">
{{ source.description }}
<div class="h-[400px]">
<n-tabs type="segment" animated class="h-full flex flex-col">
<!-- Tab 1: 音源选择 -->
<n-tab-pane name="sources" tab="音源选择" class="h-full overflow-y-auto">
<n-space vertical :size="20" class="pt-4 pr-2">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('settings.playback.musicSourcesDesc') }}
</p>
</div>
</div>
<!-- 自定义API卡片 -->
<div
class="source-card source-card--custom"
:class="{
'source-card--selected': isSourceSelected('custom'),
'source-card--disabled': !settingsStore.setData.customApiPlugin
}"
style="--source-color: #8b5cf6"
@click="toggleSource('custom')"
>
<div class="source-card__indicator"></div>
<div class="source-card__content">
<div class="source-card__header">
<span class="source-card__name">{{
t('settings.playback.sourceLabels.custom')
}}</span>
<n-icon v-if="isSourceSelected('custom')" size="18" class="source-card__check">
<i class="ri-checkbox-circle-fill"></i>
</n-icon>
<!-- 音源卡片列表 -->
<div class="music-sources-grid">
<div
v-for="source in MUSIC_SOURCES"
:key="source.key"
class="source-card"
:class="{
'source-card--selected': isSourceSelected(source.key),
'source-card--disabled': source.disabled && !isSourceSelected(source.key)
}"
:style="{ '--source-color': source.color }"
@click="toggleSource(source.key)"
>
<div class="source-card__indicator"></div>
<div class="source-card__content">
<div class="source-card__header">
<span class="source-card__name">{{ source.key }}</span>
<n-icon
v-if="isSourceSelected(source.key)"
size="18"
class="source-card__check"
>
<i class="ri-checkbox-circle-fill"></i>
</n-icon>
</div>
<p v-if="source.description" class="source-card__description">
{{ source.description }}
</p>
</div>
</div>
<!-- 落雪音源卡片 (仅开关) -->
<div
class="source-card source-card--lxmusic"
:class="{
'source-card--selected': isSourceSelected('lxMusic'),
'source-card--disabled': !settingsStore.setData.lxMusicScript
}"
style="--source-color: #10b981"
@click="toggleSource('lxMusic')"
>
<div class="source-card__indicator"></div>
<div class="source-card__content">
<div class="source-card__header">
<span class="source-card__name">落雪音源</span>
<n-icon v-if="isSourceSelected('lxMusic')" size="18" class="source-card__check">
<i class="ri-checkbox-circle-fill"></i>
</n-icon>
</div>
<p class="source-card__description">
{{
settingsStore.setData.lxMusicScript
? lxMusicScriptInfo?.name || '已导入'
: '未导入 (请去落雪音源Tab配置)'
}}
</p>
</div>
</div>
<!-- 自定义API卡片 (仅开关) -->
<div
class="source-card source-card--custom"
:class="{
'source-card--selected': isSourceSelected('custom'),
'source-card--disabled': !settingsStore.setData.customApiPlugin
}"
style="--source-color: #8b5cf6"
@click="toggleSource('custom')"
>
<div class="source-card__indicator"></div>
<div class="source-card__content">
<div class="source-card__header">
<span class="source-card__name">{{
t('settings.playback.sourceLabels.custom')
}}</span>
<n-icon v-if="isSourceSelected('custom')" size="18" class="source-card__check">
<i class="ri-checkbox-circle-fill"></i>
</n-icon>
</div>
<p class="source-card__description">
{{
settingsStore.setData.customApiPlugin
? t('settings.playback.customApi.status.imported')
: t('settings.playback.customApi.status.notImported')
}}
</p>
</div>
</div>
</div>
</n-space>
</n-tab-pane>
<!-- Tab 2: 落雪音源管理 -->
<n-tab-pane name="lxMusic" tab="落雪音源" class="h-full overflow-y-auto">
<div class="pt-4 pr-2">
<div class="flex justify-between items-center mb-4">
<h3 class="text-base font-medium">已导入的音源脚本</h3>
<div class="flex gap-2">
<n-button @click="importLxMusicScript" size="small" secondary type="success">
<template #icon>
<n-icon><i class="ri-upload-line"></i></n-icon>
</template>
本地导入
</n-button>
</div>
</div>
<!-- 已导入的音源列表 -->
<div v-if="lxMusicApis.length > 0" class="lx-api-list mb-4">
<div
v-for="api in lxMusicApis"
:key="api.id"
class="lx-api-item"
:class="{ 'lx-api-item--active': activeLxApiId === api.id }"
>
<div class="lx-api-item__radio">
<n-radio
:checked="activeLxApiId === api.id"
@update:checked="() => setActiveLxApi(api.id)"
/>
</div>
<div class="lx-api-item__info">
<div class="flex items-center gap-2">
<span class="lx-api-item__name" v-if="editingScriptId !== api.id">{{
api.name
}}</span>
<n-input
v-else
v-model:value="editingName"
size="tiny"
class="w-32"
ref="renameInputRef"
@blur="saveScriptName(api.id)"
@keyup.enter="saveScriptName(api.id)"
/>
<n-button
v-if="editingScriptId !== api.id"
text
size="tiny"
@click="startRenaming(api)"
>
<template #icon>
<n-icon class="text-gray-400 hover:text-primary"
><i class="ri-edit-line"></i
></n-icon>
</template>
</n-button>
</div>
<span v-if="api.info.version" class="lx-api-item__version"
>v{{ api.info.version }}</span
>
</div>
<div class="lx-api-item__actions">
<n-button text size="tiny" type="error" @click="removeLxApi(api.id)">
<template #icon>
<n-icon><i class="ri-close-line"></i></n-icon>
</template>
</n-button>
</div>
</div>
</div>
<div v-else class="empty-state">
<n-empty description="暂无已导入的落雪音源" />
</div>
<!-- URL 导入区域 -->
<div class="mt-6">
<h4 class="text-sm font-medium mb-2 text-gray-600 dark:text-gray-400">在线导入</h4>
<div class="flex items-center gap-2">
<n-input
v-model:value="lxScriptUrl"
placeholder="输入落雪音源脚本 URL"
size="small"
class="flex-1"
:disabled="isImportingFromUrl"
/>
<n-button
@click="importLxMusicScriptFromUrl"
size="small"
type="primary"
:loading="isImportingFromUrl"
:disabled="!lxScriptUrl.trim()"
>
<template #icon>
<n-icon><i class="ri-download-line"></i></n-icon>
</template>
导入
</n-button>
</div>
</div>
<p class="source-card__description">
{{
settingsStore.setData.customApiPlugin
? t('settings.playback.customApi.status.imported')
: t('settings.playback.customApi.status.notImported')
}}
</p>
</div>
</div>
</div>
</n-tab-pane>
<!-- 分割线 -->
<div class="divider"></div>
<!-- Tab 3: 自定义API管理 -->
<n-tab-pane name="customApi" tab="自定义API" class="h-full overflow-y-auto">
<div class="pt-4 flex flex-col items-center justify-center h-full gap-4">
<div class="text-center">
<h3 class="text-lg font-medium mb-2">
{{ t('settings.playback.customApi.sectionTitle') }}
</h3>
<p class="text-gray-500 text-sm mb-4">导入兼容的自定义 API 插件以扩展音源</p>
</div>
<!-- 自定义API导入区域 -->
<div class="custom-api-section">
<h3 class="custom-api-section__title">
{{ t('settings.playback.customApi.sectionTitle') }}
</h3>
<div class="custom-api-section__content">
<n-button @click="importPlugin" size="small" secondary>
<template #icon>
<n-icon><i class="ri-upload-line"></i></n-icon>
</template>
{{ t('settings.playback.customApi.importConfig') }}
</n-button>
<p v-if="settingsStore.setData.customApiPluginName" class="custom-api-section__status">
{{ t('settings.playback.customApi.currentSource') }}:
<span class="font-semibold">{{ settingsStore.setData.customApiPluginName }}</span>
</p>
<p v-else class="custom-api-section__status custom-api-section__status--empty">
{{ t('settings.playback.customApi.notImported') }}
</p>
</div>
</div>
</n-space>
<div class="flex flex-col items-center gap-2">
<n-button @click="importPlugin" type="primary" secondary>
<template #icon>
<n-icon><i class="ri-upload-line"></i></n-icon>
</template>
{{ t('settings.playback.customApi.importConfig') }}
</n-button>
<p
v-if="settingsStore.setData.customApiPluginName"
class="text-green-600 text-sm mt-2 flex items-center gap-1"
>
<i class="ri-check-circle-line"></i>
{{ t('settings.playback.customApi.currentSource') }}:
<span class="font-semibold">{{ settingsStore.setData.customApiPluginName }}</span>
</p>
<p v-else class="text-gray-400 text-sm mt-2">
{{ t('settings.playback.customApi.notImported') }}
</p>
</div>
</div>
</n-tab-pane>
</n-tabs>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import { ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
initLxMusicRunner,
parseScriptInfo,
setLxMusicRunner
} from '@/services/LxMusicSourceRunner';
import { useSettingsStore } from '@/store';
import type { LxMusicScriptConfig, LxScriptInfo, LxSourceKey } from '@/types/lxMusic';
import { type Platform } from '@/types/music';
// ==================== 类型定义 ====================
type ExtendedPlatform = Platform | 'custom';
type ExtendedPlatform = Platform | 'custom' | 'lxMusic';
interface MusicSourceConfig {
key: string;
@@ -149,6 +304,37 @@ const message = useMessage();
const visible = ref(props.show);
const selectedSources = ref<ExtendedPlatform[]>([...props.sources]);
// 落雪音源列表(从 store 中的脚本解析)
const lxMusicApis = computed<LxMusicScriptConfig[]>(() => {
const scripts = settingsStore.setData.lxMusicScripts || [];
return scripts;
});
// 当前激活的音源 ID
const activeLxApiId = computed<string | null>({
get: () => settingsStore.setData.activeLxMusicApiId || null,
set: (id) => {
settingsStore.setSetData({ activeLxMusicApiId: id });
}
});
// 落雪音源脚本信息(保持向后兼容)
const lxMusicScriptInfo = computed<LxScriptInfo | null>(() => {
const activeId = activeLxApiId.value;
if (!activeId) return null;
const activeApi = lxMusicApis.value.find((api) => api.id === activeId);
return activeApi?.info || null;
});
// URL 导入相关状态
const lxScriptUrl = ref('');
const isImportingFromUrl = ref(false);
// 重命名相关状态
const editingScriptId = ref<string | null>(null);
const editingName = ref('');
const renameInputRef = ref<HTMLInputElement | null>(null);
// ==================== 计算属性 ====================
const isSourceSelected = (sourceKey: string): boolean => {
return selectedSources.value.includes(sourceKey as ExtendedPlatform);
@@ -165,6 +351,12 @@ const toggleSource = (sourceKey: string) => {
return;
}
// 检查是否是落雪音源且未导入
if (sourceKey === 'lxMusic' && !settingsStore.setData.lxMusicScript) {
message.warning('请先导入落雪音源脚本');
return;
}
const index = selectedSources.value.indexOf(sourceKey as ExtendedPlatform);
if (index > -1) {
// 至少保留一个音源
@@ -198,6 +390,224 @@ const importPlugin = async () => {
}
};
/**
* 导入落雪音源脚本
*/
const importLxMusicScript = async () => {
try {
const result = await window.api.importLxMusicScript();
if (result && result.content) {
await addLxMusicScript(result.content);
}
} catch (error: any) {
console.error('导入落雪音源脚本失败:', error);
message.error(`导入失败:${error.message}`);
}
};
/**
* 添加落雪音源脚本到列表
*/
const addLxMusicScript = async (scriptContent: string) => {
// 解析脚本信息
const scriptInfo = parseScriptInfo(scriptContent);
// 尝试初始化执行器以验证脚本
try {
const runner = await initLxMusicRunner(scriptContent);
const sources = runner.getSources();
const sourceKeys = Object.keys(sources) as LxSourceKey[];
// 生成唯一 ID
const id = `lx_api_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 创建新的脚本配置
const newApiConfig: LxMusicScriptConfig = {
id,
name: scriptInfo.name,
script: scriptContent,
info: scriptInfo,
sources: sourceKeys,
enabled: true,
createdAt: Date.now()
};
// 添加到列表
const scripts = [...(settingsStore.setData.lxMusicScripts || []), newApiConfig];
settingsStore.setSetData({
lxMusicScripts: scripts,
activeLxMusicApiId: id // 自动激活新添加的音源
});
message.success(`音源脚本导入成功:${scriptInfo.name},支持 ${sourceKeys.length} 个音源`);
// 导入成功后自动勾选
if (!selectedSources.value.includes('lxMusic')) {
selectedSources.value.push('lxMusic');
}
} catch (initError: any) {
console.error('落雪音源脚本初始化失败:', initError);
message.error(`脚本初始化失败:${initError.message}`);
}
};
/**
* 设置激活的落雪音源
*/
const setActiveLxApi = async (apiId: string) => {
const api = lxMusicApis.value.find((a) => a.id === apiId);
if (!api) {
message.error('音源不存在');
return;
}
try {
// 初始化选中的脚本
await initLxMusicRunner(api.script);
// 更新激活的音源 ID
activeLxApiId.value = apiId;
// 确保 lxMusic 在已选音源中
if (!selectedSources.value.includes('lxMusic')) {
selectedSources.value.push('lxMusic');
}
message.success(`已切换到音源: ${api.name}`);
} catch (error: any) {
console.error('切换落雪音源失败:', error);
message.error(`切换失败:${error.message}`);
}
};
/**
* 删除落雪音源
*/
const removeLxApi = (apiId: string) => {
const scripts = [...(settingsStore.setData.lxMusicScripts || [])];
const index = scripts.findIndex((s) => s.id === apiId);
if (index === -1) return;
const removedScript = scripts[index];
scripts.splice(index, 1);
// 更新 store
settingsStore.setSetData({
lxMusicScripts: scripts
});
// 如果删除的是当前激活的音源
if (activeLxApiId.value === apiId) {
// 自动选择下一个可用音源,或者清空
if (scripts.length > 0) {
setActiveLxApi(scripts[0].id);
} else {
setLxMusicRunner(null);
settingsStore.setSetData({ activeLxMusicApiId: null });
// 从已选音源中移除 lxMusic
const srcIndex = selectedSources.value.indexOf('lxMusic');
if (srcIndex > -1) {
selectedSources.value.splice(srcIndex, 1);
}
}
}
message.success(`已删除音源: ${removedScript.name}`);
};
/**
* 从 URL 导入落雪音源脚本
*/
const importLxMusicScriptFromUrl = async () => {
const url = lxScriptUrl.value.trim();
if (!url) {
message.warning('请输入脚本 URL');
return;
}
// 验证 URL 格式
try {
new URL(url);
} catch {
message.error('无效的 URL 格式');
return;
}
isImportingFromUrl.value = true;
try {
// 下载脚本内容
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const content = await response.text();
// 验证脚本格式
if (
!content.includes('globalThis.lx') &&
!content.includes('lx.on') &&
!content.includes('EVENT_NAMES')
) {
throw new Error('无效的落雪音源脚本,未找到 globalThis.lx 相关代码');
}
// 使用统一的添加方法
await addLxMusicScript(content);
// 清空 URL 输入框
lxScriptUrl.value = '';
} catch (error: any) {
console.error('从 URL 导入落雪音源脚本失败:', error);
message.error(`在线导入失败:${error.message}`);
} finally {
isImportingFromUrl.value = false;
}
};
/**
* 开始重命名
*/
const startRenaming = (api: LxMusicScriptConfig) => {
editingScriptId.value = api.id;
editingName.value = api.name;
nextTick(() => {
renameInputRef.value?.focus();
});
};
/**
* 保存脚本名称
*/
const saveScriptName = (apiId: string) => {
if (!editingName.value.trim()) {
message.warning('名称不能为空');
return;
}
const scripts = [...(settingsStore.setData.lxMusicScripts || [])];
const index = scripts.findIndex((s) => s.id === apiId);
if (index > -1) {
scripts[index] = {
...scripts[index],
name: editingName.value.trim()
};
settingsStore.setSetData({
lxMusicScripts: scripts
});
message.success('重命名成功');
}
editingScriptId.value = null;
editingName.value = '';
};
/**
* 确认选择
*/
@@ -392,52 +802,83 @@ watch(
}
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #e5e5e5 50%, transparent);
margin: 8px 0;
.lx-api-list {
display: flex;
flex-direction: column;
gap: 8px;
}
:global(.dark) .divider {
background: linear-gradient(90deg, transparent, #333 50%, transparent);
}
.lx-api-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.2s ease;
.custom-api-section {
&__title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
&--active {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(59, 130, 246, 0.08));
border-color: rgba(16, 185, 129, 0.3);
}
&__content {
&__radio {
flex-shrink: 0;
}
&__info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16px;
gap: 8px;
}
&__status {
&__name {
font-size: 13px;
color: #666;
margin: 0;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&--empty {
color: #999;
&__version {
font-size: 12px;
color: #999;
background: rgba(0, 0, 0, 0.05);
padding: 1px 6px;
border-radius: 4px;
}
&__actions {
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover &__actions {
opacity: 1;
}
}
:global(.dark) {
.lx-api-item {
background: #2a2a2a;
&__name {
color: #e5e5e5;
}
&__version {
background: rgba(255, 255, 255, 0.1);
}
}
}
:global(.dark) .custom-api-section {
&__title {
color: #e5e5e5;
}
&__status {
color: #999;
&--empty {
color: #666;
}
}
.empty-state {
padding: 32px 0;
display: flex;
justify-content: center;
}
</style>

View File

@@ -20,7 +20,7 @@ Object.keys(directives).forEach((key: string) => {
app.use(pinia);
app.use(router);
app.use(i18n);
app.use(i18n as any);
app.mount('#app');
// 初始化应用内快捷键

View File

@@ -0,0 +1,619 @@
/**
* 落雪音乐 (LX Music) 音源脚本执行器
*
* 核心职责:
* 1. 解析脚本元信息
* 2. 在隔离环境中执行用户脚本
* 3. 模拟 globalThis.lx API
* 4. 处理初始化和音乐解析请求
*/
import type {
LxInitedData,
LxLyricResult,
LxMusicInfo,
LxQuality,
LxScriptInfo,
LxSourceConfig,
LxSourceKey
} from '@/types/lxMusic';
/**
* 解析脚本头部注释中的元信息
*/
export const parseScriptInfo = (script: string): LxScriptInfo => {
const info: LxScriptInfo = {
name: '未知音源',
rawScript: script
};
// 匹配头部注释块
const headerMatch = script.match(/^\/\*\*[\s\S]*?\*\//);
if (!headerMatch) return info;
const header = headerMatch[0];
// 解析各个字段
const nameMatch = header.match(/@name\s+(.+?)(?:\r?\n|\*\/)/);
if (nameMatch) info.name = nameMatch[1].trim();
const descMatch = header.match(/@description\s+(.+?)(?:\r?\n|\*\/)/);
if (descMatch) info.description = descMatch[1].trim();
const versionMatch = header.match(/@version\s+(.+?)(?:\r?\n|\*\/)/);
if (versionMatch) info.version = versionMatch[1].trim();
const authorMatch = header.match(/@author\s+(.+?)(?:\r?\n|\*\/)/);
if (authorMatch) info.author = authorMatch[1].trim();
const homepageMatch = header.match(/@homepage\s+(.+?)(?:\r?\n|\*\/)/);
if (homepageMatch) info.homepage = homepageMatch[1].trim();
return info;
};
/**
* 落雪音源脚本执行器
* 使用 Worker 或 iframe 隔离执行用户脚本
*/
export class LxMusicSourceRunner {
private script: string;
private scriptInfo: LxScriptInfo;
private sources: Partial<Record<LxSourceKey, LxSourceConfig>> = {};
private requestHandler: ((data: any) => Promise<any>) | null = null;
private initialized = false;
private initPromise: Promise<LxInitedData> | null = null;
// 临时存储最后一次 HTTP 请求返回的音乐 URL用于脚本返回 undefined 时的后备)
private lastMusicUrl: string | null = null;
constructor(script: string) {
this.script = script;
this.scriptInfo = parseScriptInfo(script);
}
/**
* 获取脚本信息
*/
getScriptInfo(): LxScriptInfo {
return this.scriptInfo;
}
/**
* 获取支持的音源列表
*/
getSources(): Partial<Record<LxSourceKey, LxSourceConfig>> {
return this.sources;
}
/**
* 初始化执行器
*/
async initialize(): Promise<LxInitedData> {
if (this.initPromise) return this.initPromise;
this.initPromise = new Promise<LxInitedData>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('脚本初始化超时'));
}, 10000);
try {
// 创建沙盒环境并执行脚本
this.executeSandboxed(
(initedData) => {
clearTimeout(timeout);
this.sources = initedData.sources;
this.initialized = true;
console.log('[LxMusicRunner] 初始化成功:', initedData.sources);
resolve(initedData);
},
(error) => {
clearTimeout(timeout);
reject(error);
}
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
return this.initPromise;
}
/**
* 在沙盒中执行脚本
*/
private executeSandboxed(
onInited: (data: LxInitedData) => void,
onError: (error: Error) => void
): void {
// 构建沙盒执行环境
const sandbox = this.createSandbox(onInited, onError);
try {
// 使用 Function 构造器在受限环境中执行
// 注意:不能使用 const/let 声明 globalThis因为它是保留标识符
const sandboxedScript = `
(function() {
${sandbox.apiSetup}
${this.script}
}).call(this);
`;
// 创建执行上下文
const context = sandbox.context;
const executor = new Function(sandboxedScript);
// 在隔离上下文中执行context 将作为 this
executor.call(context);
} catch (error) {
onError(error as Error);
}
}
/**
* 创建沙盒环境
*/
private createSandbox(
onInited: (data: LxInitedData) => void,
_onError: (error: Error) => void
): { apiSetup: string; context: any } {
const self = this;
// 创建 globalThis.lx 对象
// 版本号使用落雪音乐最新版本以通过脚本版本检测
const context = {
lx: {
version: '2.8.0',
env: 'desktop',
appInfo: {
version: '2.8.0',
versionNum: 208,
locale: 'zh-cn'
},
currentScriptInfo: this.scriptInfo,
EVENT_NAMES: {
inited: 'inited',
request: 'request',
updateAlert: 'updateAlert'
},
on: (eventName: string, handler: (data: any) => Promise<any>) => {
if (eventName === 'request') {
self.requestHandler = handler;
}
},
send: (eventName: string, data: any) => {
if (eventName === 'inited') {
onInited(data as LxInitedData);
} else if (eventName === 'updateAlert') {
console.log('[LxMusicRunner] 更新提醒:', data);
}
},
request: (
url: string,
options: any,
callback: (err: Error | null, resp: any, body: any) => void
) => {
return self.handleHttpRequest(url, options, callback);
},
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);
}
},
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;
}
},
zlib: {
inflate: async (buffer: ArrayBuffer) => {
try {
const ds = new DecompressionStream('deflate');
const writer = ds.writable.getWriter();
writer.write(buffer);
writer.close();
const reader = ds.readable.getReader();
const chunks: Uint8Array[] = [];
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) chunks.push(result.value);
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result.buffer;
} catch {
return buffer;
}
},
deflate: async (buffer: ArrayBuffer) => {
try {
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(buffer);
writer.close();
const reader = cs.readable.getReader();
const chunks: Uint8Array[] = [];
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) chunks.push(result.value);
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result.buffer;
} catch {
return buffer;
}
}
}
}
},
console: {
log: (...args: any[]) => console.log('[LxScript]', ...args),
error: (...args: any[]) => console.error('[LxScript]', ...args),
warn: (...args: any[]) => console.warn('[LxScript]', ...args),
info: (...args: any[]) => console.info('[LxScript]', ...args)
},
setTimeout,
setInterval,
clearTimeout,
clearInterval,
Promise,
JSON,
Object,
Array,
String,
Number,
Boolean,
Date,
Math,
RegExp,
Error,
Map,
Set,
WeakMap,
WeakSet,
Symbol,
Proxy,
Reflect,
encodeURIComponent,
decodeURIComponent,
encodeURI,
decodeURI,
atob,
btoa,
TextEncoder,
TextDecoder,
Uint8Array,
ArrayBuffer,
crypto
};
// 只设置 lx 和 globalThis不解构变量避免与脚本内部声明冲突
const apiSetup = `
var lx = this.lx;
var globalThis = this;
`;
return { apiSetup, context };
}
/**
* 处理 HTTP 请求
*/
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);
fetch(url, fetchOptions)
.then(async (response) => {
clearTimeout(timeoutId);
console.log(`[LxMusicRunner] HTTP 响应: ${response.status} ${url}`);
const rawBody = await response.text();
// 尝试解析 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
},
parsedBody
);
})
.catch((error) => {
clearTimeout(timeoutId);
console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message);
callback(error, null, null);
});
// 返回取消函数
return () => controller.abort();
}
/**
* 获取音乐 URL
*/
async getMusicUrl(
source: LxSourceKey,
musicInfo: LxMusicInfo,
quality: LxQuality
): Promise<string> {
if (!this.initialized) {
await this.initialize();
}
if (!this.requestHandler) {
throw new Error('脚本未注册请求处理器');
}
const sourceConfig = this.sources[source];
if (!sourceConfig) {
throw new Error(`脚本不支持音源: ${source}`);
}
if (!sourceConfig.actions.includes('musicUrl')) {
throw new Error(`音源 ${source} 不支持获取音乐 URL`);
}
// 选择最佳音质
let targetQuality = quality;
if (!sourceConfig.qualitys.includes(quality)) {
// 按优先级选择可用音质
const qualityPriority: LxQuality[] = ['flac24bit', 'flac', '320k', '128k'];
for (const q of qualityPriority) {
if (sourceConfig.qualitys.includes(q)) {
targetQuality = q;
break;
}
}
}
console.log(`[LxMusicRunner] 请求音乐 URL: 音源=${source}, 音质=${targetQuality}`);
try {
const result = await this.requestHandler({
source,
action: 'musicUrl',
info: {
type: targetQuality,
musicInfo
}
});
console.log(`[LxMusicRunner] 脚本返回结果:`, result, typeof result);
// 脚本可能返回对象或字符串
let url: string | undefined;
if (typeof result === 'string') {
url = result;
} else if (result && typeof result === 'object') {
// 某些脚本可能返回 { url: '...' } 格式
url = result.url || result.data || result;
}
if (typeof url !== 'string' || !url) {
// 如果脚本返回 undefined尝试使用缓存的 URL
if (this.lastMusicUrl) {
console.log('[LxMusicRunner] 脚本返回 undefined使用缓存的 URL');
url = this.lastMusicUrl;
this.lastMusicUrl = null; // 清除缓存
} else {
console.error('[LxMusicRunner] 无效的返回值:', result);
throw new Error(result?.message || result?.msg || '获取音乐 URL 失败');
}
}
console.log('[LxMusicRunner] 获取到 URL:', url.substring(0, 80) + '...');
return url;
} catch (error) {
console.error('[LxMusicRunner] 获取音乐 URL 失败:', error);
throw error;
}
}
/**
* 获取歌词
*/
async getLyric(source: LxSourceKey, musicInfo: LxMusicInfo): Promise<LxLyricResult | null> {
if (!this.initialized) {
await this.initialize();
}
if (!this.requestHandler) {
return null;
}
const sourceConfig = this.sources[source];
if (!sourceConfig || !sourceConfig.actions.includes('lyric')) {
return null;
}
try {
const result = await this.requestHandler({
source,
action: 'lyric',
info: {
type: null,
musicInfo
}
});
return result as LxLyricResult;
} catch (error) {
console.error('[LxMusicRunner] 获取歌词失败:', error);
return null;
}
}
/**
* 获取封面图
*/
async getPic(source: LxSourceKey, musicInfo: LxMusicInfo): Promise<string | null> {
if (!this.initialized) {
await this.initialize();
}
if (!this.requestHandler) {
return null;
}
const sourceConfig = this.sources[source];
if (!sourceConfig || !sourceConfig.actions.includes('pic')) {
return null;
}
try {
const url = await this.requestHandler({
source,
action: 'pic',
info: {
type: null,
musicInfo
}
});
return typeof url === 'string' ? url : null;
} catch (error) {
console.error('[LxMusicRunner] 获取封面失败:', error);
return null;
}
}
/**
* 检查是否已初始化
*/
isInitialized(): boolean {
return this.initialized;
}
}
// 全局单例
let runnerInstance: LxMusicSourceRunner | null = null;
/**
* 获取落雪音源执行器实例
*/
export const getLxMusicRunner = (): LxMusicSourceRunner | null => {
return runnerInstance;
};
/**
* 设置落雪音源执行器实例
*/
export const setLxMusicRunner = (runner: LxMusicSourceRunner | null): void => {
runnerInstance = runner;
};
/**
* 初始化落雪音源执行器(从脚本内容)
*/
export const initLxMusicRunner = async (script: string): Promise<LxMusicSourceRunner> => {
// 销毁旧实例
runnerInstance = null;
// 创建新实例
const runner = new LxMusicSourceRunner(script);
await runner.initialize();
runnerInstance = runner;
return runner;
};

View File

@@ -1,4 +1,4 @@
import { cloneDeep, merge } from 'lodash';
import { cloneDeep, isArray, mergeWith } from 'lodash';
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
@@ -55,8 +55,16 @@ export const useSettingsStore = defineStore('settings', () => {
? window.electron.ipcRenderer.sendSync('get-store-value', 'set')
: JSON.parse(localStorage.getItem('appSettings') || '{}');
// 自定义合并策略:如果是数组,直接使用源数组(覆盖默认值)
const customizer = (_objValue: any, srcValue: any) => {
if (isArray(srcValue)) {
return srcValue;
}
return undefined;
};
// 合并默认设置和保存的设置
const mergedSettings = merge({}, setDataDefault, savedSettings);
const mergedSettings = mergeWith({}, setDataDefault, savedSettings, customizer);
// 更新设置并返回
setSetData(mergedSettings);

View File

@@ -8,6 +8,8 @@ export interface IElectronAPI {
openLyric: () => void;
sendLyric: (_data: string) => void;
unblockMusic: (_id: number) => Promise<string>;
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
importLxMusicScript: () => Promise<{ name: string; content: string } | null>;
onLanguageChanged: (_callback: (_locale: string) => void) => void;
store: {
get: (_key: string) => Promise<any>;

View File

@@ -0,0 +1,146 @@
/**
* 落雪音乐 (LX Music) 自定义源类型定义
*
* 参考文档: https://lxmusic.toside.cn/desktop/custom-source
*/
/**
* 脚本元信息(从注释头解析)
*/
export type LxScriptInfo = {
name: string;
description?: string;
version?: string;
author?: string;
homepage?: string;
rawScript: string;
};
/**
* 支持的音质类型
*/
export type LxQuality = '128k' | '320k' | 'flac' | 'flac24bit';
/**
* 支持的音源 key
* - kw: 酷我
* - kg: 酷狗
* - tx: QQ音乐
* - wy: 网易云
* - mg: 咪咕
* - local: 本地音乐
*/
export type LxSourceKey = 'kw' | 'kg' | 'tx' | 'wy' | 'mg' | 'local';
/**
* 音源配置
*/
export type LxSourceConfig = {
name: string;
type: 'music';
actions: ('musicUrl' | 'lyric' | 'pic')[];
qualitys: LxQuality[];
};
/**
* 初始化事件数据
*/
export type LxInitedData = {
openDevTools?: boolean;
sources: Partial<Record<LxSourceKey, LxSourceConfig>>;
};
/**
* 请求事件数据
*/
export type LxRequestData = {
source: LxSourceKey;
action: 'musicUrl' | 'lyric' | 'pic';
info: {
type: LxQuality | null;
musicInfo: LxMusicInfo;
};
};
/**
* 落雪音乐信息格式
* 需要从 SongResult 转换而来
*/
export type LxMusicInfo = {
songmid: string | number;
name: string;
singer: string;
album?: string;
albumId?: string | number;
source?: string;
interval?: string;
img?: string;
types?: { type: LxQuality; size?: string }[];
};
/**
* 歌词返回格式
*/
export type LxLyricResult = {
lyric: string;
tlyric?: string | null;
rlyric?: string | null;
lxlyric?: string | null;
};
/**
* 存储在 settings 中的单个落雪音源配置
*/
export type LxMusicScriptConfig = {
id: string; // 唯一标识
name: string; // 用户自定义名称,可编辑
script: string; // 脚本内容
info: LxScriptInfo; // 解析的脚本元信息
sources: LxSourceKey[];
enabled: boolean; // 是否启用
createdAt: number; // 创建时间戳
};
/**
* 存储在 settings 中的落雪音源列表
*/
export type LxMusicApiList = {
apis: LxMusicScriptConfig[];
activeId: string | null; // 当前激活的音源 ID
};
/**
* globalThis.lx API 的事件名称
*/
export const LX_EVENT_NAMES = {
inited: 'inited',
request: 'request',
updateAlert: 'updateAlert'
} as const;
/**
* 落雪音源 key 到平台名称的映射
*/
export const LX_SOURCE_NAMES: Record<LxSourceKey, string> = {
kw: '酷我',
kg: '酷狗',
tx: 'QQ音乐',
wy: '网易云',
mg: '咪咕',
local: '本地'
};
/**
* 本项目音质到落雪音质的映射
*/
export const QUALITY_TO_LX: Record<string, LxQuality> = {
standard: '128k',
higher: '320k',
exhigh: '320k',
lossless: 'flac',
hires: 'flac24bit',
jyeffect: 'flac',
sky: 'flac',
dolby: 'flac',
jymaster: 'flac24bit'
};

View File

@@ -7,10 +7,18 @@ export type Platform =
| 'pyncmd'
| 'joox'
| 'bilibili'
| 'gdmusic';
| 'gdmusic'
| 'lxMusic';
// 默认平台列表
export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
export const DEFAULT_PLATFORMS: Platform[] = [
'lxMusic',
'migu',
'kugou',
'kuwo',
'pyncmd',
'bilibili'
];
export interface IRecommendMusic {
code: number;