Merge pull request #465 from souvenp/feat/add-custom-api

This commit is contained in:
Alger
2025-09-13 22:53:37 +08:00
committed by GitHub
20 changed files with 1463 additions and 1001 deletions
+62
View File
@@ -0,0 +1,62 @@
## 🎵 自定义音源API配置
现在支持通过导入一个简单的 JSON 配置文件来对接第三方的音乐解析 API。这将提供极大的灵活性,可以接入任意第三方音源。
### 如何使用
1. 前往 **设置 -> 播放设置 -> 音源设置**
2.**自定义 API 设置** 区域,点击 **“导入 JSON 配置”** 按钮。
3. 选择你已经编写好的 `xxx.json` 配置文件。
4. 导入成功后,程序将优先使用你的自定义 API 进行解析。
### JSON 配置文件格式说明
导入的配置文件必须是一个合法的 JSON 文件,并包含以下字段:
| 字段名 | 类型 | 是否必须 | 描述 |
| ---------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | 是 | API 名称,将显示在应用的 UI 界面上。 |
| `apiUrl` | `string` | 是 | API 的基础请求地址。 |
| `method` | `string` | 否 | HTTP 请求方法。可以是 `"GET"``"POST"`。**如果省略,默认为 "GET"**。 |
| `params` | `object` | 是 | 请求时需要发送的参数。对于 `GET` 请求,它们会作为查询字符串;对于 `POST` 请求,它们会作为请求体。 |
| `qualityMapping` | `object` | 否 | **音质映射表**。用于将应用内部的音质值(如 `"lossless"`)翻译成你的 API 需要的特定值。如果省略,则直接使用应用内部值。 |
| `responseUrlPath`| `string` | 是 | **URL提取路径**。用于从 API 返回的 JSON 响应中找到最终可播放的音乐链接。支持点 `.` 和方括号 `[]` 语法来访问嵌套对象和数组。 |
#### 占位符
`params` 对象的值中,你可以使用以下占位符,程序在请求时会自动替换它们:
* `{songId}`: 将被替换为当前歌曲的 ID。
* `{quality}`: 将被替换为当前用户设置的音质字符串 (例如, `"higher"`, `"lossless"`)。
#### 音质值列表
应用内部使用的音质值如下,你可以在 `qualityMapping` 中使用它们作为**键**
`standard`, `higher`, `exhigh`, `lossless`, `hires`, `jyeffect`, `sky`, `dolby`, `jymaster`
### 示例
假设有一个 API 如下:
`https://api.example.com/music?song_id=12345&bitrate=320000`
它返回的 JSON 是:
`{ "code": 200, "data": { "play_url": "http://..." } }`
那么对应的 JSON 配置文件应该是:
```json
{
"name": "Example API",
"apiUrl": "https://api.example.com/music",
"method": "GET",
"params": {
"song_id": "{songId}",
"bitrate": "{quality}"
},
"qualityMapping": {
"higher": "128000",
"exhigh": "320000",
"lossless": "999000"
},
"responseUrlPath": "data.play_url"
}
```
+3 -1
View File
@@ -39,7 +39,9 @@ export default {
warning: 'Please select a music source', warning: 'Please select a music source',
bilibiliNotSupported: 'Bilibili videos do not support reparsing', bilibiliNotSupported: 'Bilibili videos do not support reparsing',
processing: 'Processing...', processing: 'Processing...',
clear: 'Clear Custom Source' clear: 'Clear Custom Source',
customApiFailed: 'Custom API parsing failed, trying built-in sources...',
customApiError: 'Custom API request error, trying built-in sources...'
}, },
playBar: { playBar: {
expand: 'Expand Lyrics', expand: 'Expand Lyrics',
+13 -1
View File
@@ -80,7 +80,19 @@ export default {
autoPlayDesc: 'Auto resume playback when reopening the app', autoPlayDesc: 'Auto resume playback when reopening the app',
showStatusBar: 'Show Status Bar', showStatusBar: 'Show Status Bar',
showStatusBarContent: showStatusBarContent:
'You can display the music control function in your mac status bar (effective after a restart)' 'You can display the music control function in your mac status bar (effective after a restart)',
fallbackParser: 'Fallback Parser (GD Music)',
fallbackParserDesc: 'When "GD Music" is checked and regular sources fail, this service will be used.',
parserGD: 'GD Music (Built-in)',
parserCustom: 'Custom API',
customApi: {
importConfig: 'Import JSON Config',
currentSource: 'Current Source',
notImported: 'No custom source imported yet.',
importSuccess: 'Successfully imported source: {name}',
importFailed: 'Import failed: {message}',
},
}, },
application: { application: {
closeAction: 'Close Action', closeAction: 'Close Action',
+3 -1
View File
@@ -39,7 +39,9 @@ export default {
warning: '音源を選択してください', warning: '音源を選択してください',
bilibiliNotSupported: 'Bilibili動画は再解析をサポートしていません', bilibiliNotSupported: 'Bilibili動画は再解析をサポートしていません',
processing: '解析中...', processing: '解析中...',
clear: 'カスタム音源をクリア' clear: 'カスタム音源をクリア',
customApiFailed: 'カスタムAPIの解析に失敗しました。内蔵音源を試しています...',
customApiError: 'カスタムAPIのリクエストでエラーが発生しました。内蔵音源を試しています...'
}, },
playBar: { playBar: {
expand: '歌詞を展開', expand: '歌詞を展開',
+13 -1
View File
@@ -78,7 +78,19 @@ export default {
autoPlay: '自動再生', autoPlay: '自動再生',
autoPlayDesc: 'アプリを再起動した際に自動的に再生を継続するかどうか', autoPlayDesc: 'アプリを再起動した際に自動的に再生を継続するかどうか',
showStatusBar: 'ステータスバーコントロール機能を表示するかどうか', showStatusBar: 'ステータスバーコントロール機能を表示するかどうか',
showStatusBarContent: 'Macのステータスバーに音楽コントロール機能を表示できます(再起動後に有効)' showStatusBarContent: 'Macのステータスバーに音楽コントロール機能を表示できます(再起動後に有効)',
fallbackParser: '代替解析サービス (GD音楽台)',
fallbackParserDesc: '「GD音楽台」にチェックが入っていて、通常の音源で再生できない場合、このサービスが使用されます。',
parserGD: 'GD音楽台 (内蔵)',
parserCustom: 'カスタムAPI',
customApi: {
importConfig: 'JSON設定をインポート',
currentSource: '現在の音源',
notImported: 'カスタム音源はまだインポートされていません。',
importSuccess: '音源のインポートに成功しました: {name}',
importFailed: 'インポートに失敗しました: {message}',
},
}, },
application: { application: {
closeAction: '閉じる動作', closeAction: '閉じる動作',
+3 -1
View File
@@ -39,7 +39,9 @@ export default {
warning: '음원을 선택해주세요', warning: '음원을 선택해주세요',
bilibiliNotSupported: 'B站 비디오는 재분석을 지원하지 않습니다', bilibiliNotSupported: 'B站 비디오는 재분석을 지원하지 않습니다',
processing: '분석 중...', processing: '분석 중...',
clear: '사용자 정의 음원 지우기' clear: '사용자 정의 음원 지우기',
customApiFailed: '사용자 정의 API 분석 실패, 기본 음원을 시도합니다...',
customApiError: '사용자 정의 API 요청 오류, 기본 음원을 시도합니다...'
}, },
playBar: { playBar: {
expand: '가사 펼치기', expand: '가사 펼치기',
+13 -1
View File
@@ -78,7 +78,19 @@ export default {
autoPlay: '자동 재생', autoPlay: '자동 재생',
autoPlayDesc: '앱을 다시 열 때 자동으로 재생을 계속할지 여부', autoPlayDesc: '앱을 다시 열 때 자동으로 재생을 계속할지 여부',
showStatusBar: '상태바 제어 기능 표시 여부', showStatusBar: '상태바 제어 기능 표시 여부',
showStatusBarContent: 'Mac 상태바에 음악 제어 기능을 표시할 수 있습니다 (재시작 후 적용)' showStatusBarContent: 'Mac 상태바에 음악 제어 기능을 표시할 수 있습니다 (재시작 후 적용)',
fallbackParser: '대체 분석 서비스 (GD Music)',
fallbackParserDesc: '"GD Music"을 선택하고 일반 음원을 사용할 수 없을 때 이 서비스를 사용합니다.',
parserGD: 'GD Music (내장)',
parserCustom: '사용자 지정 API',
customApi: {
importConfig: 'JSON 설정 가져오기',
currentSource: '현재 음원',
notImported: '아직 사용자 지정 음원을 가져오지 않았습니다.',
importSuccess: '음원 가져오기 성공: {name}',
importFailed: '가져오기 실패: {message}',
},
}, },
application: { application: {
closeAction: '닫기 동작', closeAction: '닫기 동작',
+3 -1
View File
@@ -39,7 +39,9 @@ export default {
warning: '请选择一个音源', warning: '请选择一个音源',
bilibiliNotSupported: 'B站视频不支持重新解析', bilibiliNotSupported: 'B站视频不支持重新解析',
processing: '解析中...', processing: '解析中...',
clear: '清除自定义音源' clear: '清除自定义音源',
customApiFailed: '自定义API解析失败,正在尝试使用内置音源...',
customApiError: '自定义API请求出错,正在尝试使用内置音源...'
}, },
playBar: { playBar: {
expand: '展开歌词', expand: '展开歌词',
+15 -1
View File
@@ -78,7 +78,21 @@ export default {
autoPlay: '自动播放', autoPlay: '自动播放',
autoPlayDesc: '重新打开应用时是否自动继续播放', autoPlayDesc: '重新打开应用时是否自动继续播放',
showStatusBar: '是否显示状态栏控制功能', showStatusBar: '是否显示状态栏控制功能',
showStatusBarContent: '可以在您的mac状态栏显示音乐控制功能(重启后生效)' showStatusBarContent: '可以在您的mac状态栏显示音乐控制功能(重启后生效)',
fallbackParser: 'GD音乐台(music.gdstudio.xyz)设置',
fallbackParserDesc: 'GD音乐台将自动尝试多个音乐平台进行解析,无需额外配置。优先级高于其他解析方式,但是请求可能较慢。感谢(music.gdstudio.xyz\n',
parserGD: 'GD 音乐台 (内置)',
parserCustom: '自定义 API',
// 自定义API相关的提示
customApi: {
importConfig: '导入 JSON 配置',
currentSource: '当前音源',
notImported: '尚未导入自定义音源。',
importSuccess: '成功导入音源: {name}',
importFailed: '导入失败: {message}',
},
}, },
application: { application: {
closeAction: '关闭行为', closeAction: '关闭行为',
+3 -1
View File
@@ -39,7 +39,9 @@ export default {
warning: '請選擇一個音源', warning: '請選擇一個音源',
bilibiliNotSupported: 'B站影片不支援重新解析', bilibiliNotSupported: 'B站影片不支援重新解析',
processing: '解析中...', processing: '解析中...',
clear: '清除自訂音源' clear: '清除自訂音源',
customApiFailed: '自定義API解析失敗,正在嘗試使用內置音源...',
customApiError: '自定義API請求出錯,正在嘗試使用內置音源...'
}, },
playBar: { playBar: {
expand: '展開歌詞', expand: '展開歌詞',
+13 -1
View File
@@ -78,7 +78,19 @@ export default {
autoPlay: '自動播放', autoPlay: '自動播放',
autoPlayDesc: '重新開啟應用程式時是否自動繼續播放', autoPlayDesc: '重新開啟應用程式時是否自動繼續播放',
showStatusBar: '是否顯示狀態列控制功能', showStatusBar: '是否顯示狀態列控制功能',
showStatusBarContent: '可以在您的mac狀態列顯示音樂控制功能(重啟後生效)' showStatusBarContent: '可以在您的mac狀態列顯示音樂控制功能(重啟後生效)',
fallbackParser: '備用解析服務 (GD音樂台)',
fallbackParserDesc: '當勾選「GD音樂台」且常規音源無法播放時,將使用此服務嘗試解析。',
parserGD: 'GD 音樂台 (內建)',
parserCustom: '自訂 API',
customApi: {
importConfig: '匯入 JSON 設定',
currentSource: '目前音源',
notImported: '尚未匯入自訂音源。',
importSuccess: '成功匯入音源:{name}',
importFailed: '匯入失敗:{message}',
},
}, },
application: { application: {
closeAction: '關閉行為', closeAction: '關閉行為',
+33
View File
@@ -276,6 +276,39 @@ export function initializeFileManager() {
} }
} }
}); });
// 处理导入自定义API插件的请求
ipcMain.handle('import-custom-api-plugin', async () => {
const result = await dialog.showOpenDialog({
title: '选择自定义音源配置文件',
filters: [{ name: 'JSON Files', extensions: ['json'] }],
properties: ['openFile']
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
const filePath = result.filePaths[0];
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
// 基础验证,确保它是个合法的JSON并且包含关键字段
const pluginData = JSON.parse(fileContent);
if (!pluginData.name || !pluginData.apiUrl) {
throw new Error('无效的插件文件,缺少 name 或 apiUrl 字段。');
}
return {
name: pluginData.name,
content: fileContent // 返回完整的JSON字符串
};
} catch (error: any) {
console.error('读取或解析插件文件失败:', error);
// 向渲染进程抛出错误,以便UI可以显示提示
throw new Error(`文件读取或解析失败: ${error.message}`);
}
});
} }
/** /**
+3 -1
View File
@@ -27,5 +27,7 @@
"showTopAction": false, "showTopAction": false,
"contentZoomFactor": 1, "contentZoomFactor": 1,
"autoTheme": false, "autoTheme": false,
"manualTheme": "light" "manualTheme": "light",
"customApiPlugin": "",
"customApiPluginName": ""
} }
+1
View File
@@ -21,6 +21,7 @@ interface API {
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void; onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
onLanguageChanged: (callback: (locale: string) => void) => void; onLanguageChanged: (callback: (locale: string) => void) => void;
removeDownloadListeners: () => void; removeDownloadListeners: () => void;
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
invoke: (channel: string, ...args: any[]) => Promise<any>; invoke: (channel: string, ...args: any[]) => Promise<any>;
} }
+2 -1
View File
@@ -18,6 +18,7 @@ const api = {
sendSong: (data) => ipcRenderer.send('update-current-song', data), sendSong: (data) => ipcRenderer.send('update-current-song', data),
unblockMusic: (id, data, enabledSources) => unblockMusic: (id, data, enabledSources) =>
ipcRenderer.invoke('unblock-music', id, data, enabledSources), ipcRenderer.invoke('unblock-music', id, data, enabledSources),
importCustomApiPlugin: () => ipcRenderer.invoke('import-custom-api-plugin'),
// 歌词窗口关闭事件 // 歌词窗口关闭事件
onLyricWindowClosed: (callback: () => void) => { onLyricWindowClosed: (callback: () => void) => {
ipcRenderer.on('lyric-window-closed', () => callback()); ipcRenderer.on('lyric-window-closed', () => callback());
@@ -54,7 +55,7 @@ const api = {
return ipcRenderer.invoke(channel, ...args); return ipcRenderer.invoke(channel, ...args);
} }
return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`)); return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`));
} },
}; };
// 创建带类型的ipcRenderer对象,暴露给渲染进程 // 创建带类型的ipcRenderer对象,暴露给渲染进程
+110 -34
View File
@@ -9,7 +9,9 @@ import request from '@/utils/request';
import requestMusic from '@/utils/request_music'; import requestMusic from '@/utils/request_music';
import { searchAndGetBilibiliAudioUrl } from './bilibili'; import { searchAndGetBilibiliAudioUrl } from './bilibili';
import type { ParsedMusicResult } from './gdmusic';
import { parseFromGDMusic } from './gdmusic'; import { parseFromGDMusic } from './gdmusic';
import { parseFromCustomApi } from './parseFromCustomApi';
const { addData, getData, deleteData } = musicDB; const { addData, getData, deleteData } = musicDB;
@@ -30,6 +32,8 @@ export const getMusicUrl = async (id: number, isDownloaded: boolean = false) =>
params: { params: {
id, id,
level: settingStore.setData.musicQuality || 'higher', level: settingStore.setData.musicQuality || 'higher',
encodeType: settingStore.setData.musicQuality == 'lossless' ? 'aac' : 'flac',
// level为lossless时,encodeType=flac时网易云会返回hires音质,encodeType=aac时网易云会返回lossless音质
cookie: `${localStorage.getItem('token')} os=pc;` cookie: `${localStorage.getItem('token')} os=pc;`
} }
}); });
@@ -45,7 +49,8 @@ export const getMusicUrl = async (id: number, isDownloaded: boolean = false) =>
return await request.get('/song/url/v1', { return await request.get('/song/url/v1', {
params: { params: {
id, id,
level: settingStore.setData.musicQuality || 'higher' level: settingStore.setData.musicQuality || 'higher',
encodeType: settingStore.setData.musicQuality == 'lossless' ? 'aac' : 'flac'
} }
}); });
}; };
@@ -114,7 +119,8 @@ const getBilibiliAudio = async (data: SongResult) => {
* @param data 歌曲数据 * @param data 歌曲数据
* @returns 解析结果,失败时返回null * @returns 解析结果,失败时返回null
*/ */
const getGDMusicAudio = async (id: number, data: SongResult) => { const getGDMusicAudio = async (id: number, data: SongResult): Promise<ParsedMusicResult | null> => {
// <-- 在这里明确声明返回类型
try { try {
const gdResult = await parseFromGDMusic(id, data, '999'); const gdResult = await parseFromGDMusic(id, data, '999');
if (gdResult) { if (gdResult) {
@@ -146,59 +152,123 @@ const getUnblockMusicAudio = (id: number, data: SongResult, sources: any[]) => {
* @returns 解析结果 * @returns 解析结果
*/ */
export const getParsingMusicUrl = async (id: number, data: SongResult) => { export const getParsingMusicUrl = async (id: number, data: SongResult) => {
try {
if (isElectron) { if (isElectron) {
let musicSources: any[] = [];
let quality: string = 'higher';
try {
const settingStore = useSettingsStore(); const settingStore = useSettingsStore();
const enableMusicUnblock = settingStore?.setData?.enableMusicUnblock;
// 如果禁用了音乐解析功能,则直接返回空结果 // 如果禁用了音乐解析功能,则直接返回空结果
if (!settingStore.setData.enableMusicUnblock) { if (!enableMusicUnblock) {
return Promise.resolve({ data: { code: 404, message: '音乐解析功能已禁用' } }); return Promise.resolve({ data: { code: 404, message: '音乐解析功能已禁用' } });
} }
// 1. 确定使用的音源列表(自定义或全局) // 1. 确定使用的音源列表(自定义或全局)
const songId = String(id); const songId = String(id);
const savedSourceStr = localStorage.getItem(`song_source_${songId}`); const savedSourceStr = (() => {
let musicSources: any[] = [];
try { try {
return localStorage.getItem(`song_source_${songId}`);
} catch (e) {
console.warn('读取本地存储失败,忽略自定义音源', e);
return null;
}
})();
if (savedSourceStr) { if (savedSourceStr) {
// 使用自定义音源 try {
musicSources = JSON.parse(savedSourceStr); musicSources = JSON.parse(savedSourceStr);
console.log(`使用歌曲 ${id} 自定义音源:`, musicSources); console.log(`使用歌曲 ${id} 自定义音源:`, musicSources);
} catch (e) {
console.error('解析音源设置失败,回退到默认全局设置', e);
musicSources = settingStore?.setData?.enabledMusicSources || [];
}
} else { } else {
// 使用全局音源设置 // 使用全局音源设置
musicSources = settingStore.setData.enabledMusicSources || []; musicSources = settingStore?.setData?.enabledMusicSources || [];
console.log(`使用全局音源设置:`, musicSources); console.log(`使用全局音源设置:`, musicSources);
if (musicSources.length > 0) { }
return getUnblockMusicAudio(id, data, musicSources);
quality = settingStore?.setData?.musicQuality || 'higher';
} catch (e) {
console.error('读取设置失败,使用默认配置', e);
musicSources = [];
quality = 'higher';
}
// 优先级 1: 自定义 API
try {
const hasCustom = Array.isArray(musicSources) && musicSources.includes('custom');
const customEnabled = (() => {
try {
const st = useSettingsStore();
return Boolean(st?.setData?.customApiPlugin);
} catch {
return false;
}
})();
if (hasCustom && customEnabled) {
console.log('尝试使用 自定义API 解析...');
const customResult = await parseFromCustomApi(id, data, quality);
if (customResult) {
return customResult; // 成功则直接返回
}
console.log('自定义API解析失败,继续尝试其他音源...');
}
} catch (e) {
console.error('自定义API解析发生异常,继续尝试其他音源', e);
}
// 优先级 2: Bilibili
try {
if (Array.isArray(musicSources) && musicSources.includes('bilibili')) {
console.log('尝试使用 Bilibili 解析...');
const bilibiliResult = await getBilibiliAudio(data);
if (bilibiliResult?.data?.data?.url) {
return bilibiliResult;
}
console.log('Bilibili解析失败,继续尝试其他音源...');
}
} catch (e) {
console.error('Bilibili解析发生异常,继续尝试其他音源', e);
}
// 优先级 3: GD 音乐台
try {
if (Array.isArray(musicSources) && musicSources.includes('gdmusic')) {
console.log('尝试使用 GD音乐台 解析...');
const gdResult = await getGDMusicAudio(id, data);
if (gdResult) {
return gdResult;
}
console.log('GD音乐台解析失败,继续尝试其他音源...');
}
} catch (e) {
console.error('GD音乐台解析发生异常,继续尝试其他音源', e);
}
// 优先级 4: UnblockMusic (migu, kugou, pyncmd)
try {
const unblockSources = (Array.isArray(musicSources) ? musicSources : []).filter(
(source) => !['custom', 'bilibili', 'gdmusic'].includes(source)
);
if (unblockSources.length > 0) {
console.log('尝试使用 UnblockMusic 解析:', unblockSources);
// 捕获内部可能的异常
return await getUnblockMusicAudio(id, data, unblockSources);
} else {
console.warn('UnblockMusic API 不可用,跳过此解析方式');
}
} catch (e) {
console.error('UnblockMusic 解析发生异常,继续后备方案', e);
} }
} }
} catch (e) { } catch (e) {
console.error('解析音源设置失败,使用全局设置', e); console.error('getParsingMusicUrl 执行异常,将使用后备方案:', e);
musicSources = settingStore.setData.enabledMusicSources || [];
} }
// 2. 按优先级解析 // 后备方案:使用API请求
// 2.1 Bilibili解析(优先级最高)
if (musicSources.includes('bilibili')) {
return await getBilibiliAudio(data);
}
// 2.2 GD音乐台解析
if (musicSources.includes('gdmusic')) {
const gdResult = await getGDMusicAudio(id, data);
if (gdResult) return gdResult;
// GD解析失败,继续下一步
console.log('GD音乐台解析失败,尝试使用其他音源');
}
console.log('musicSources', musicSources);
// 2.3 使用unblockMusic解析其他音源
if (musicSources.length > 0) {
return getUnblockMusicAudio(id, data, musicSources);
}
}
// 3. 后备方案:使用API请求
console.log('无可用音源或不在Electron环境中,使用API请求'); console.log('无可用音源或不在Electron环境中,使用API请求');
return requestMusic.get<any>('/music', { params: { id } }); return requestMusic.get<any>('/music', { params: { id } });
}; };
@@ -208,6 +278,12 @@ export const likeSong = (id: number, like: boolean = true) => {
return request.get('/like', { params: { id, like } }); return request.get('/like', { params: { id, like } });
}; };
// 将每日推荐中的歌曲标记为不感兴趣,并获取一首新歌
export const dislikeRecommendedSong = (id: number | string) => {
return request.get('/recommend/songs/dislike', {
params: { id }
});
};
// 获取用户喜欢的音乐列表 // 获取用户喜欢的音乐列表
export const getLikedList = (uid: number) => { export const getLikedList = (uid: number) => {
return request.get('/likelist', { return request.get('/likelist', {
+107
View File
@@ -0,0 +1,107 @@
import axios from 'axios';
import { get } from 'lodash';
import { useSettingsStore } from '@/store';
import type { ParsedMusicResult } from './gdmusic';
/**
* 定义自定义API JSON插件的结构
*/
interface CustomApiPlugin {
name: string;
apiUrl: string;
method?: 'GET' | 'POST';
params: Record<string, string>;
qualityMapping?: Record<string, string>;
responseUrlPath: string;
}
/**
* 从用户导入的自定义API JSON配置中解析音乐URL
*/
export const parseFromCustomApi = async (
id: number,
_songData: any,
quality: string = 'higher',
timeout: number = 10000
): Promise<ParsedMusicResult | null> => {
const settingsStore = useSettingsStore();
const pluginString = settingsStore.setData.customApiPlugin;
if (!pluginString) {
return null;
}
let plugin: CustomApiPlugin;
try {
plugin = JSON.parse(pluginString);
if (!plugin.apiUrl || !plugin.params || !plugin.responseUrlPath) {
console.error('自定义APIJSON配置文件格式不正确。');
return null;
}
} catch (error) {
console.error('自定义API:解析JSON配置文件失败。', error);
return null;
}
console.log(`自定义API:正在使用插件 [${plugin.name}] 进行解析...`);
try {
// 1. 准备请求参数,替换占位符
const finalParams: Record<string, string> = {};
for (const [key, value] of Object.entries(plugin.params)) {
if (value === '{songId}') {
finalParams[key] = String(id);
} else if (value === '{quality}') {
// 使用 qualityMapping (如果存在) 进行音质翻译,否则直接使用原quality
finalParams[key] = plugin.qualityMapping?.[quality] ?? quality;
} else {
// 固定值参数
finalParams[key] = value;
}
}
// 2. 判断请求方法,默认为GET
const method = plugin.method?.toUpperCase() === 'POST' ? 'POST' : 'GET';
let response;
// 3. 根据方法发送不同的请求
if (method === 'POST') {
console.log('自定义API:发送 POST 请求到:', plugin.apiUrl, '参数:', finalParams);
response = await axios.post(plugin.apiUrl, finalParams, { timeout });
} else {
// 默认为 GET
const finalUrl = `${plugin.apiUrl}?${new URLSearchParams(finalParams).toString()}`;
console.log('自定义API:发送 GET 请求到:', finalUrl);
response = await axios.get(finalUrl, { timeout });
}
// 4. 使用 lodash.get 安全地从响应数据中提取URL
const musicUrl = get(response.data, plugin.responseUrlPath);
if (musicUrl && typeof musicUrl === 'string') {
console.log('自定义API:成功获取URL');
// 5. 组装成应用所需的标准格式并返回
return {
data: {
data: {
url: musicUrl,
br: parseInt(quality) * 1000,
size: 0,
md5: '',
platform: plugin.name.toLowerCase().replace(/\s/g, ''),
gain: 0
},
params: { id, type: 'song' }
}
};
} else {
console.error('自定义API:根据路径未能从响应中找到URL:', plugin.responseUrlPath);
return null;
}
} catch (error) {
console.error(`自定义API [${plugin.name}] 执行失败:`, error);
return null;
}
};
@@ -10,13 +10,14 @@
> >
<n-space vertical> <n-space vertical>
<p>{{ t('settings.playback.musicSourcesDesc') }}</p> <p>{{ t('settings.playback.musicSourcesDesc') }}</p>
<n-checkbox-group v-model:value="selectedSources"> <n-checkbox-group v-model:value="selectedSources">
<n-grid :cols="2" :x-gap="12" :y-gap="8"> <n-grid :cols="2" :x-gap="12" :y-gap="8">
<n-grid-item v-for="source in musicSourceOptions" :key="source.value"> <!-- 遍历常规音源 -->
<n-grid-item v-for="source in regularMusicSources" :key="source.value">
<n-checkbox :value="source.value"> <n-checkbox :value="source.value">
{{ source.label }} {{ source.label }}
<template v-if="source.value === 'gdmusic'"> <n-tooltip v-if="source.value === 'gdmusic'">
<n-tooltip>
<template #trigger> <template #trigger>
<n-icon size="16" class="ml-1 text-blue-500 cursor-help"> <n-icon size="16" class="ml-1 text-blue-500 cursor-help">
<i class="ri-information-line"></i> <i class="ri-information-line"></i>
@@ -24,42 +25,62 @@
</template> </template>
{{ t('settings.playback.gdmusicInfo') }} {{ t('settings.playback.gdmusicInfo') }}
</n-tooltip> </n-tooltip>
</n-checkbox>
</n-grid-item>
<!-- 单独处理自定义API选项 -->
<n-grid-item>
<n-checkbox value="custom" :disabled="!settingsStore.setData.customApiPlugin">
自定义 API
<n-tooltip v-if="!settingsStore.setData.customApiPlugin">
<template #trigger>
<n-icon size="16" class="ml-1 text-gray-400 cursor-help">
<i class="ri-question-line"></i>
</n-icon>
</template> </template>
请先导入JSON配置文件才能启用
</n-tooltip>
</n-checkbox> </n-checkbox>
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
</n-checkbox-group> </n-checkbox-group>
<div v-if="selectedSources.length === 0" class="text-red-500 text-sm">
{{ t('settings.playback.musicSourcesWarning') }}
</div>
<!-- GD音乐台设置 --> <!-- 分割线 -->
<div <div class="mt-4 border-t pt-4 border-gray-200 dark:border-gray-700"></div>
v-if="selectedSources.includes('gdmusic')"
class="mt-4 border-t pt-4 border-gray-200 dark:border-gray-700" <!-- 自定义API导入区域 -->
> <div>
<h3 class="text-base font-medium mb-2">GD音乐台(music.gdstudio.xyz)设置</h3> <h3 class="text-base font-medium mb-2">自定义 API 设置</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2"> <div class="flex items-center gap-4">
GD音乐台将自动尝试多个音乐平台进行解析无需额外配置优先级高于其他解析方式但是请求可能较慢感谢music.gdstudio.xyz <n-button @click="importPlugin" size="small"> 导入 JSON 配置 </n-button>
<p v-if="settingsStore.setData.customApiPluginName" class="text-sm">
当前: <span class="font-semibold">{{ settingsStore.setData.customApiPluginName }}</span>
</p> </p>
<p v-else class="text-sm text-gray-500">尚未导入</p>
</div>
</div> </div>
</n-space> </n-space>
</n-modal> </n-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineEmits, defineProps, ref, watch } from 'vue'; import { useMessage } from 'naive-ui';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useSettingsStore } from '@/store';
import { type Platform } from '@/types/music'; import { type Platform } from '@/types/music';
// 扩展 Platform 类型以包含 'custom'
type ExtendedPlatform = Platform | 'custom';
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
default: false default: false
}, },
sources: { sources: {
type: Array as () => Platform[], type: Array as () => ExtendedPlatform[],
default: () => ['migu', 'kugou', 'pyncmd', 'bilibili'] default: () => ['migu', 'kugou', 'pyncmd', 'bilibili']
} }
}); });
@@ -67,10 +88,13 @@ const props = defineProps({
const emit = defineEmits(['update:show', 'update:sources']); const emit = defineEmits(['update:show', 'update:sources']);
const { t } = useI18n(); const { t } = useI18n();
const settingsStore = useSettingsStore();
const message = useMessage();
const visible = ref(props.show); const visible = ref(props.show);
const selectedSources = ref<Platform[]>(props.sources); const selectedSources = ref<ExtendedPlatform[]>(props.sources);
const musicSourceOptions = ref([ // 将常规音源和自定义音源分开定义
const regularMusicSources = ref([
{ label: 'MG', value: 'migu' }, { label: 'MG', value: 'migu' },
{ label: 'KG', value: 'kugou' }, { label: 'KG', value: 'kugou' },
{ label: 'pyncmd', value: 'pyncmd' }, { label: 'pyncmd', value: 'pyncmd' },
@@ -78,6 +102,35 @@ const musicSourceOptions = ref([
{ label: 'GD音乐台', value: 'gdmusic' } { label: 'GD音乐台', value: 'gdmusic' }
]); ]);
const importPlugin = async () => {
try {
const result = await window.api.importCustomApiPlugin();
if (result && result.name && result.content) {
settingsStore.setCustomApiPlugin(result);
message.success(`成功导入音源: ${result.name}`);
// 导入成功后,如果用户还没勾选,则自动勾选上
if (!selectedSources.value.includes('custom')) {
selectedSources.value.push('custom');
}
}
} catch (error: any) {
message.error(`导入失败: ${error.message}`);
}
};
// 监听自定义插件内容的变化。如果用户清除了插件,要确保 'custom' 选项被取消勾选
watch(
() => settingsStore.setData.customApiPlugin,
(newPluginContent) => {
if (!newPluginContent) {
const index = selectedSources.value.indexOf('custom');
if (index > -1) {
selectedSources.value.splice(index, 1);
}
}
}
);
// 同步外部show属性变化 // 同步外部show属性变化
watch( watch(
() => props.show, () => props.show,
@@ -108,11 +161,9 @@ const handleConfirm = () => {
const defaultPlatforms = ['migu', 'kugou', 'pyncmd', 'bilibili']; const defaultPlatforms = ['migu', 'kugou', 'pyncmd', 'bilibili'];
const valuesToEmit = const valuesToEmit =
selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms; selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms;
emit('update:sources', valuesToEmit); emit('update:sources', valuesToEmit);
visible.value = false; visible.value = false;
}; };
const handleCancel = () => { const handleCancel = () => {
// 取消时还原为props传入的初始值 // 取消时还原为props传入的初始值
selectedSources.value = [...props.sources]; selectedSources.value = [...props.sources];
+68 -23
View File
@@ -82,6 +82,10 @@ export const getSongUrl = async (
songData: SongResult, songData: SongResult,
isDownloaded: boolean = false isDownloaded: boolean = false
) => { ) => {
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
const settingsStore = useSettingsStore();
const { message } = createDiscreteApi(['message']); // 引入 message API 用于提示
try { try {
if (songData.playMusicUrl) { if (songData.playMusicUrl) {
return songData.playMusicUrl; return songData.playMusicUrl;
@@ -104,14 +108,50 @@ export const getSongUrl = async (
return songData.playMusicUrl || ''; return songData.playMusicUrl || '';
} }
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
// 检查是否有自定义音源设置 // ==================== 自定义API最优先 ====================
// 检查用户是否在全局设置中启用了 'custom' 音源
const globalSources = settingsStore.setData.enabledMusicSources || [];
const useCustomApiGlobally = globalSources.includes('custom');
// 检查歌曲是否有专属的 'custom' 音源设置
const songId = String(id); const songId = String(id);
const savedSource = localStorage.getItem(`song_source_${songId}`); const savedSourceStr = localStorage.getItem(`song_source_${songId}`);
let useCustomApiForSong = false;
if (savedSourceStr) {
try {
const songSources = JSON.parse(savedSourceStr);
useCustomApiForSong = songSources.includes('custom');
} catch (e) {
console.error('解析歌曲音源设置失败:', e);
}
}
// 如果全局或歌曲专属设置中启用了自定义API,则最优先尝试
if ( (useCustomApiGlobally || useCustomApiForSong) && settingsStore.setData.customApiPlugin) {
console.log(`优先级 1: 尝试使用自定义API解析歌曲 ${id}...`);
try {
// 直接从 api 目录导入 parseFromCustomApi 函数
const { parseFromCustomApi } = await import('@/api/parseFromCustomApi');
const customResult = await parseFromCustomApi(numericId, cloneDeep(songData), settingsStore.setData.musicQuality || 'higher');
if (customResult && customResult.data && customResult.data.data && customResult.data.data.url) {
console.log('自定义API解析成功!');
if (isDownloaded) return customResult.data.data as any;
return customResult.data.data.url;
} else {
// 自定义API失败,给出提示,然后继续走默认流程
console.log('自定义API解析失败,将使用默认降级流程...');
message.warning(i18n.global.t('player.reparse.customApiFailed')); // 给用户一个提示
}
} catch (error) {
console.error('调用自定义API时发生错误:', error);
message.error(i18n.global.t('player.reparse.customApiError'));
}
}
// 如果自定义API失败或未启用,则执行【原有】的解析流程
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL // 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
if (savedSource && songData.source !== 'bilibili') { if (savedSourceStr && songData.source !== 'bilibili') {
try { try {
console.log(`使用自定义音源解析歌曲 ID: ${songId}`); console.log(`使用自定义音源解析歌曲 ID: ${songId}`);
const res = await getParsingMusicUrl(numericId, cloneDeep(songData)); const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
@@ -129,28 +169,33 @@ export const getSongUrl = async (
// 正常获取URL流程 // 正常获取URL流程
const { data } = await getMusicUrl(numericId, isDownloaded); const { data } = await getMusicUrl(numericId, isDownloaded);
let url = ''; if (data && data.data && data.data[0]) {
let songDetail = null; const songDetail = data.data[0];
try { const hasNoUrl = !songDetail.url;
if (data.data[0].freeTrialInfo || !data.data[0].url) { const isTrial = !!songDetail.freeTrialInfo;
if (hasNoUrl || isTrial) {
console.log(`官方URL无效 (无URL: ${hasNoUrl}, 试听: ${isTrial}),进入内置备用解析...`);
const res = await getParsingMusicUrl(numericId, cloneDeep(songData)); const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
url = res.data.data.url; if (isDownloaded) return res?.data?.data as any;
songDetail = res.data.data; return res?.data?.data?.url || null;
} else {
songDetail = data.data[0] as any;
} }
console.log('官方API解析成功!');
if (isDownloaded) return songDetail as any;
return songDetail.url;
}
console.log('官方API返回数据结构异常,进入内置备用解析...');
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
if (isDownloaded) return res?.data?.data as any;
return res?.data?.data?.url || null;
} catch (error) { } catch (error) {
console.error('error', error); console.error('官方API请求失败,进入内置备用解析流程:', error);
url = data.data[0].url || ''; const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
} if (isDownloaded) return res?.data?.data as any;
if (isDownloaded) { return res?.data?.data?.url || null;
return songDetail;
}
url = url || data.data[0].url;
return url;
} catch (error) {
console.error('error', error);
return null;
} }
}; };
+13 -1
View File
@@ -64,6 +64,17 @@ export const useSettingsStore = defineStore('settings', () => {
// 初始化 setData // 初始化 setData
setData.value = getInitialSettings(); setData.value = getInitialSettings();
/**
* 保存导入的自定义API插件
* @param plugin 包含name和content的对象
*/
const setCustomApiPlugin = (plugin: { name: string; content: string }) => {
setSetData({
customApiPlugin: plugin.content,
customApiPluginName: plugin.name
});
};
const toggleTheme = () => { const toggleTheme = () => {
if (setData.value.autoTheme) { if (setData.value.autoTheme) {
// 如果是自动模式,切换到手动模式并设置相反的主题 // 如果是自动模式,切换到手动模式并设置相反的主题
@@ -208,6 +219,7 @@ export const useSettingsStore = defineStore('settings', () => {
setLanguage, setLanguage,
initializeSettings, initializeSettings,
initializeTheme, initializeTheme,
initializeSystemFonts initializeSystemFonts,
setCustomApiPlugin,
}; };
}); });