mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-14 06:30:49 +08:00
feat: 添加 lx 音源导入
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
1
src/preload/index.d.ts
vendored
1
src/preload/index.d.ts
vendored
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
174
src/renderer/api/lxMusicStrategy.ts
Normal file
174
src/renderer/api/lxMusicStrategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 初始化应用内快捷键
|
||||
|
||||
619
src/renderer/services/LxMusicSourceRunner.ts
Normal file
619
src/renderer/services/LxMusicSourceRunner.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
2
src/renderer/types/electron.d.ts
vendored
2
src/renderer/types/electron.d.ts
vendored
@@ -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>;
|
||||
|
||||
146
src/renderer/types/lxMusic.ts
Normal file
146
src/renderer/types/lxMusic.ts
Normal 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'
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user