mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +08:00
fix: 修复音源解析致命性错误
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ android/app/release
|
||||
|
||||
.cursor
|
||||
.windsurf
|
||||
.agent
|
||||
|
||||
|
||||
.auto-imports.d.ts
|
||||
|
||||
90
package.json
90
package.json
@@ -33,81 +33,81 @@
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@unblockneteasemusic/server": "^0.27.8-patch.1",
|
||||
"@unblockneteasemusic/server": "^0.27.10",
|
||||
"cors": "^2.8.5",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"express": "^4.18.2",
|
||||
"file-type": "^21.0.0",
|
||||
"express": "^4.22.1",
|
||||
"file-type": "^21.1.1",
|
||||
"flac-tagger": "^1.0.7",
|
||||
"font-list": "^1.5.1",
|
||||
"font-list": "^1.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"music-metadata": "^11.2.3",
|
||||
"music-metadata": "^11.10.3",
|
||||
"netease-cloud-music-api-alger": "^4.26.1",
|
||||
"node-id3": "^0.2.9",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"sharp": "^0.34.3",
|
||||
"vue-i18n": "^11.1.3"
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"sharp": "^0.34.5",
|
||||
"vue-i18n": "^11.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config": "^2.1.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@rushstack/eslint-patch": "^1.15.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/node": "^20.14.8",
|
||||
"@types/node": "^20.19.26",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.30.1",
|
||||
"@typescript-eslint/parser": "^8.30.1",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@vue/compiler-sfc": "^3.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/compiler-sfc": "^3.5.25",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.0",
|
||||
"@vue/runtime-core": "^3.5.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/runtime-core": "^3.5.25",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"@vueuse/electron": "^13.8.0",
|
||||
"@vueuse/electron": "^13.9.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.7",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.13.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^38.1.2",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^4.0.0",
|
||||
"eslint": "^9.34.0",
|
||||
"electron-vite": "^4.0.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-vue": "^10.3.0",
|
||||
"eslint-plugin-vue-scoped-css": "^2.11.0",
|
||||
"globals": "^16.3.0",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"eslint-plugin-vue-scoped-css": "^2.12.0",
|
||||
"globals": "^16.5.0",
|
||||
"howler": "^2.2.4",
|
||||
"lint-staged": "^15.2.10",
|
||||
"lint-staged": "^15.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^15.0.4",
|
||||
"naive-ui": "^2.41.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinyin-match": "^1.2.6",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.6.2",
|
||||
"remixicon": "^4.6.0",
|
||||
"sass": "^1.86.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"marked": "^15.0.12",
|
||||
"naive-ui": "^2.43.2",
|
||||
"pinia": "^3.0.4",
|
||||
"pinyin-match": "^1.2.10",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.4",
|
||||
"remixicon": "^4.7.0",
|
||||
"sass": "^1.96.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tunajs": "^1.0.15",
|
||||
"typescript": "^5.5.2",
|
||||
"unplugin-auto-import": "^19.1.1",
|
||||
"unplugin-vue-components": "^28.4.1",
|
||||
"vite": "^6.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-vue-devtools": "7.7.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue": "^3.5.25",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-tsc": "^2.0.22"
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-tsc": "^2.2.12"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.alger.music",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { musicDB } from '@/hooks/MusicHook';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { isElectron } from '@/utils';
|
||||
@@ -33,13 +34,18 @@ export interface MusicParseResult {
|
||||
const CACHE_CONFIG = {
|
||||
// 音乐URL缓存时间:30分钟
|
||||
MUSIC_URL_CACHE_TIME: 30 * 60 * 1000,
|
||||
// 失败缓存时间:5分钟
|
||||
FAILED_CACHE_TIME: 5 * 60 * 1000,
|
||||
// 失败缓存时间:1分钟(减少到 1 分钟以便更快恢复)
|
||||
FAILED_CACHE_TIME: 1 * 60 * 1000,
|
||||
// 重试配置
|
||||
MAX_RETRY_COUNT: 2,
|
||||
RETRY_DELAY: 1000
|
||||
};
|
||||
|
||||
/**
|
||||
* 内存失败缓存(替代 IndexedDB,更轻量且应用重启后自动失效)
|
||||
*/
|
||||
const failedCacheMap = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* 缓存管理器
|
||||
*/
|
||||
@@ -104,39 +110,46 @@ export class CacheManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在失败缓存期内
|
||||
* 检查是否在失败缓存期内(使用内存缓存)
|
||||
*/
|
||||
static async isInFailedCache(id: number, strategyName: string): Promise<boolean> {
|
||||
try {
|
||||
const cacheKey = `${id}_${strategyName}`;
|
||||
const cached = await getData('music_failed_cache', cacheKey);
|
||||
if (cached?.createTime && Date.now() - cached.createTime < CACHE_CONFIG.FAILED_CACHE_TIME) {
|
||||
console.log(`策略 ${strategyName} 在失败缓存期内,跳过`);
|
||||
return true;
|
||||
}
|
||||
// 清理过期缓存
|
||||
if (cached) {
|
||||
await deleteData('music_failed_cache', cacheKey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('检查失败缓存失败:', error);
|
||||
static isInFailedCache(id: number, strategyName: string): boolean {
|
||||
const cacheKey = `${id}_${strategyName}`;
|
||||
const cachedTime = failedCacheMap.get(cacheKey);
|
||||
if (cachedTime && Date.now() - cachedTime < CACHE_CONFIG.FAILED_CACHE_TIME) {
|
||||
console.log(`策略 ${strategyName} 在失败缓存期内,跳过`);
|
||||
return true;
|
||||
}
|
||||
// 清理过期缓存
|
||||
if (cachedTime) {
|
||||
failedCacheMap.delete(cacheKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加失败缓存
|
||||
* 添加失败缓存(使用内存缓存)
|
||||
*/
|
||||
static async addFailedCache(id: number, strategyName: string): Promise<void> {
|
||||
try {
|
||||
const cacheKey = `${id}_${strategyName}`;
|
||||
await saveData('music_failed_cache', {
|
||||
id: cacheKey,
|
||||
createTime: Date.now()
|
||||
});
|
||||
console.log(`添加失败缓存成功: ${strategyName}`);
|
||||
} catch (error) {
|
||||
console.error('添加失败缓存失败:', error);
|
||||
static addFailedCache(id: number, strategyName: string): void {
|
||||
const cacheKey = `${id}_${strategyName}`;
|
||||
failedCacheMap.set(cacheKey, Date.now());
|
||||
console.log(
|
||||
`添加失败缓存成功: ${strategyName} (缓存时间: ${CACHE_CONFIG.FAILED_CACHE_TIME / 1000}秒)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定歌曲的失败缓存
|
||||
*/
|
||||
static clearFailedCache(id: number): void {
|
||||
const keysToDelete: string[] = [];
|
||||
failedCacheMap.forEach((_, key) => {
|
||||
if (key.startsWith(`${id}_`)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
});
|
||||
keysToDelete.forEach((key) => failedCacheMap.delete(key));
|
||||
if (keysToDelete.length > 0) {
|
||||
console.log(`清除歌曲 ${id} 的失败缓存: ${keysToDelete.length} 项`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +335,7 @@ class CustomApiStrategy implements MusicSourceStrategy {
|
||||
|
||||
async parse(id: number, data: SongResult, quality = 'higher'): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (await CacheManager.isInFailedCache(id, this.name)) {
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -339,11 +352,11 @@ class CustomApiStrategy implements MusicSourceStrategy {
|
||||
}
|
||||
|
||||
// 解析失败,添加失败缓存
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('自定义API解析失败:', error);
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -362,7 +375,7 @@ class BilibiliStrategy implements MusicSourceStrategy {
|
||||
|
||||
async parse(id: number, data: SongResult): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (await CacheManager.isInFailedCache(id, this.name)) {
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -379,11 +392,11 @@ class BilibiliStrategy implements MusicSourceStrategy {
|
||||
}
|
||||
|
||||
// 解析失败,添加失败缓存
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Bilibili解析失败:', error);
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -402,7 +415,7 @@ class GDMusicStrategy implements MusicSourceStrategy {
|
||||
|
||||
async parse(id: number, data: SongResult): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (await CacheManager.isInFailedCache(id, this.name)) {
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -419,11 +432,11 @@ class GDMusicStrategy implements MusicSourceStrategy {
|
||||
}
|
||||
|
||||
// 解析失败,添加失败缓存
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('GD音乐台解析失败:', error);
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -450,7 +463,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
||||
sources?: string[]
|
||||
): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (await CacheManager.isInFailedCache(id, this.name)) {
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -471,11 +484,11 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
||||
}
|
||||
|
||||
// 解析失败,添加失败缓存
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('UnblockMusic解析失败:', error);
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -512,23 +525,15 @@ class MusicSourceStrategyFactory {
|
||||
* @returns 音源列表和音质设置
|
||||
*/
|
||||
const getMusicConfig = (id: number, settingsStore?: any) => {
|
||||
const songId = String(id);
|
||||
let musicSources: string[] = [];
|
||||
let quality = 'higher';
|
||||
|
||||
try {
|
||||
// 尝试获取歌曲自定义音源
|
||||
const savedSourceStr = localStorage.getItem(`song_source_${songId}`);
|
||||
if (savedSourceStr) {
|
||||
try {
|
||||
const customSources = JSON.parse(savedSourceStr);
|
||||
if (Array.isArray(customSources)) {
|
||||
musicSources = customSources;
|
||||
console.log(`使用歌曲 ${id} 自定义音源:`, musicSources);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析自定义音源设置失败:', error);
|
||||
}
|
||||
// 尝试获取歌曲自定义音源(使用 SongSourceConfigManager)
|
||||
const songConfig = SongSourceConfigManager.getConfig(id);
|
||||
if (songConfig && songConfig.sources.length > 0) {
|
||||
musicSources = songConfig.sources;
|
||||
console.log(`使用歌曲 ${id} 自定义音源:`, musicSources);
|
||||
}
|
||||
|
||||
// 如果没有自定义音源,使用全局设置
|
||||
|
||||
@@ -81,7 +81,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { CacheManager } from '@/api/musicParser';
|
||||
import { playMusic } from '@/hooks/MusicHook';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import type { Platform } from '@/types/music';
|
||||
|
||||
@@ -130,16 +130,11 @@ const getSourceIcon = (source: Platform) => {
|
||||
|
||||
// 初始化选中的音源
|
||||
const initSelectedSources = () => {
|
||||
const songId = String(playMusic.value.id);
|
||||
const savedSource = localStorage.getItem(`song_source_${songId}`);
|
||||
const songId = playMusic.value.id;
|
||||
const config = SongSourceConfigManager.getConfig(songId);
|
||||
|
||||
if (savedSource) {
|
||||
try {
|
||||
selectedSourcesValue.value = JSON.parse(savedSource);
|
||||
} catch (e) {
|
||||
console.error('解析保存的音源设置失败:', e);
|
||||
selectedSourcesValue.value = [];
|
||||
}
|
||||
if (config) {
|
||||
selectedSourcesValue.value = config.sources;
|
||||
} else {
|
||||
selectedSourcesValue.value = [];
|
||||
}
|
||||
@@ -147,7 +142,7 @@ const initSelectedSources = () => {
|
||||
|
||||
// 清除自定义音源
|
||||
const clearCustomSource = () => {
|
||||
localStorage.removeItem(`song_source_${String(playMusic.value.id)}`);
|
||||
SongSourceConfigManager.clearConfig(playMusic.value.id);
|
||||
selectedSourcesValue.value = [];
|
||||
};
|
||||
|
||||
@@ -168,11 +163,8 @@ const directReparseMusic = async (source: Platform) => {
|
||||
// 更新选中的音源值为当前点击的音源
|
||||
selectedSourcesValue.value = [source];
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem(
|
||||
`song_source_${String(songId)}`,
|
||||
JSON.stringify(selectedSourcesValue.value)
|
||||
);
|
||||
// 使用 SongSourceConfigManager 保存配置(手动选择)
|
||||
SongSourceConfigManager.setConfig(songId, [source], 'manual');
|
||||
|
||||
const success = await playerStore.reparseCurrentSong(source, false);
|
||||
|
||||
@@ -200,49 +192,6 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听歌曲变化,检查是否有自定义音源
|
||||
watch(
|
||||
() => playMusic.value.id,
|
||||
async (newId) => {
|
||||
if (newId) {
|
||||
const songId = String(newId);
|
||||
const savedSource = localStorage.getItem(`song_source_${songId}`);
|
||||
|
||||
// 如果有保存的音源设置但当前不是使用自定义解析的播放,尝试应用
|
||||
if (savedSource && playMusic.value.source !== 'bilibili') {
|
||||
try {
|
||||
const sources = JSON.parse(savedSource) as Platform[];
|
||||
console.log(`检测到歌曲ID ${songId} 有自定义音源设置:`, sources);
|
||||
|
||||
// 当URL加载失败或过期时,自动应用自定义音源重新加载
|
||||
audioService.on('url_expired', async (trackInfo) => {
|
||||
if (trackInfo && trackInfo.id === playMusic.value.id) {
|
||||
console.log('URL已过期,自动应用自定义音源重新加载');
|
||||
try {
|
||||
isReparsing.value = true;
|
||||
const songId = String(playMusic.value.id);
|
||||
const sourceType = localStorage.getItem(`song_source_type_${songId}`);
|
||||
const isAuto = sourceType === 'auto';
|
||||
const success = await playerStore.reparseCurrentSong(sources[0], isAuto);
|
||||
if (!success) {
|
||||
message.error(t('player.reparse.failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('自动重新解析失败:', e);
|
||||
message.error(t('player.reparse.failed'));
|
||||
} finally {
|
||||
isReparsing.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('解析保存的音源设置失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -5,6 +5,7 @@ import i18n from '@/../i18n/renderer';
|
||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
@@ -64,17 +65,8 @@ export const getSongUrl = async (
|
||||
const globalSources = settingsStore.setData.enabledMusicSources || [];
|
||||
const useCustomApiGlobally = globalSources.includes('custom');
|
||||
|
||||
const songId = String(id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
const songConfig = SongSourceConfigManager.getConfig(id);
|
||||
const useCustomApiForSong = songConfig?.sources.includes('custom' as any) ?? false;
|
||||
|
||||
// 如果全局或歌曲专属设置中启用了自定义API,则最优先尝试
|
||||
if ((useCustomApiGlobally || useCustomApiForSong) && settingsStore.setData.customApiPlugin) {
|
||||
@@ -116,9 +108,9 @@ export const getSongUrl = async (
|
||||
}
|
||||
|
||||
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
|
||||
if (savedSourceStr && songData.source !== 'bilibili') {
|
||||
if (songConfig && songData.source !== 'bilibili') {
|
||||
try {
|
||||
console.log(`使用自定义音源解析歌曲 ID: ${songId}`);
|
||||
console.log(`使用自定义音源解析歌曲 ID: ${id}`);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
console.log('res', res);
|
||||
|
||||
|
||||
198
src/renderer/services/SongSourceConfigManager.ts
Normal file
198
src/renderer/services/SongSourceConfigManager.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 歌曲音源配置管理器
|
||||
*
|
||||
* 职责:
|
||||
* 1. 统一管理每首歌曲的自定义音源配置
|
||||
* 2. 提供清晰的读取/写入/清除 API
|
||||
* 3. 区分"手动"和"自动"设置的音源
|
||||
* 4. 管理已尝试的音源列表(按歌曲隔离)
|
||||
*/
|
||||
|
||||
import type { Platform } from '@/types/music';
|
||||
|
||||
// 歌曲音源配置类型
|
||||
export type SongSourceConfig = {
|
||||
sources: Platform[];
|
||||
type: 'manual' | 'auto';
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
// 内存中缓存已尝试的音源(按歌曲隔离)
|
||||
const triedSourcesMap = new Map<string, Set<string>>();
|
||||
const triedSourceDiffsMap = new Map<string, Map<string, number>>();
|
||||
|
||||
// localStorage key 前缀
|
||||
const STORAGE_KEY_PREFIX = 'song_source_';
|
||||
const STORAGE_TYPE_KEY_PREFIX = 'song_source_type_';
|
||||
|
||||
/**
|
||||
* 歌曲音源配置管理器
|
||||
*/
|
||||
export class SongSourceConfigManager {
|
||||
/**
|
||||
* 获取歌曲的自定义音源配置
|
||||
*/
|
||||
static getConfig(songId: number | string): SongSourceConfig | null {
|
||||
const id = String(songId);
|
||||
const sourcesStr = localStorage.getItem(`${STORAGE_KEY_PREFIX}${id}`);
|
||||
const typeStr = localStorage.getItem(`${STORAGE_TYPE_KEY_PREFIX}${id}`);
|
||||
|
||||
if (!sourcesStr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sources = JSON.parse(sourcesStr) as Platform[];
|
||||
if (!Array.isArray(sources) || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sources,
|
||||
type: typeStr === 'auto' ? 'auto' : 'manual',
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[SongSourceConfigManager] 解析歌曲 ${id} 配置失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置歌曲的自定义音源配置
|
||||
*/
|
||||
static setConfig(
|
||||
songId: number | string,
|
||||
sources: Platform[],
|
||||
type: 'manual' | 'auto' = 'manual'
|
||||
): void {
|
||||
const id = String(songId);
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
this.clearConfig(songId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_KEY_PREFIX}${id}`, JSON.stringify(sources));
|
||||
localStorage.setItem(`${STORAGE_TYPE_KEY_PREFIX}${id}`, type);
|
||||
console.log(`[SongSourceConfigManager] 设置歌曲 ${id} 音源: ${sources.join(', ')} (${type})`);
|
||||
} catch (error) {
|
||||
console.error(`[SongSourceConfigManager] 保存歌曲 ${id} 配置失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除歌曲的自定义配置
|
||||
*/
|
||||
static clearConfig(songId: number | string): void {
|
||||
const id = String(songId);
|
||||
localStorage.removeItem(`${STORAGE_KEY_PREFIX}${id}`);
|
||||
localStorage.removeItem(`${STORAGE_TYPE_KEY_PREFIX}${id}`);
|
||||
// 同时清除内存中的已尝试音源
|
||||
this.clearTriedSources(songId);
|
||||
console.log(`[SongSourceConfigManager] 清除歌曲 ${id} 配置`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查歌曲是否有自定义配置
|
||||
*/
|
||||
static hasConfig(songId: number | string): boolean {
|
||||
return this.getConfig(songId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配置类型是否为手动设置
|
||||
*/
|
||||
static isManualConfig(songId: number | string): boolean {
|
||||
const config = this.getConfig(songId);
|
||||
return config?.type === 'manual';
|
||||
}
|
||||
|
||||
// ==================== 已尝试音源管理 ====================
|
||||
|
||||
/**
|
||||
* 获取歌曲已尝试的音源列表
|
||||
*/
|
||||
static getTriedSources(songId: number | string): Set<string> {
|
||||
const id = String(songId);
|
||||
if (!triedSourcesMap.has(id)) {
|
||||
triedSourcesMap.set(id, new Set());
|
||||
}
|
||||
return triedSourcesMap.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加已尝试的音源
|
||||
*/
|
||||
static addTriedSource(songId: number | string, source: string): void {
|
||||
const id = String(songId);
|
||||
const tried = this.getTriedSources(id);
|
||||
tried.add(source);
|
||||
console.log(`[SongSourceConfigManager] 歌曲 ${id} 添加已尝试音源: ${source}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除歌曲的已尝试音源
|
||||
*/
|
||||
static clearTriedSources(songId: number | string): void {
|
||||
const id = String(songId);
|
||||
triedSourcesMap.delete(id);
|
||||
triedSourceDiffsMap.delete(id);
|
||||
console.log(`[SongSourceConfigManager] 清除歌曲 ${id} 已尝试音源`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌曲已尝试音源的时长差异
|
||||
*/
|
||||
static getTriedSourceDiffs(songId: number | string): Map<string, number> {
|
||||
const id = String(songId);
|
||||
if (!triedSourceDiffsMap.has(id)) {
|
||||
triedSourceDiffsMap.set(id, new Map());
|
||||
}
|
||||
return triedSourceDiffsMap.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音源的时长差异
|
||||
*/
|
||||
static setTriedSourceDiff(songId: number | string, source: string, diff: number): void {
|
||||
const id = String(songId);
|
||||
const diffs = this.getTriedSourceDiffs(id);
|
||||
diffs.set(source, diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找最佳匹配的音源(时长差异最小)
|
||||
*/
|
||||
static findBestMatchingSource(songId: number | string): { source: string; diff: number } | null {
|
||||
const diffs = this.getTriedSourceDiffs(songId);
|
||||
if (diffs.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestSource = '';
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const [source, diff] of diffs.entries()) {
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
bestSource = source;
|
||||
}
|
||||
}
|
||||
|
||||
return bestSource ? { source: bestSource, diff: minDiff } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有内存缓存(用于测试或重置)
|
||||
*/
|
||||
static clearAllMemoryCache(): void {
|
||||
triedSourcesMap.clear();
|
||||
triedSourceDiffsMap.clear();
|
||||
console.log('[SongSourceConfigManager] 清除所有内存缓存');
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例方便使用
|
||||
export const songSourceConfig = SongSourceConfigManager;
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Howl } from 'howler';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { getParsingMusicUrl } from '@/api/music';
|
||||
import type { SongResult } from '@/types/music';
|
||||
|
||||
class PreloadService {
|
||||
@@ -60,167 +58,22 @@ class PreloadService {
|
||||
}
|
||||
|
||||
// 创建初始音频实例
|
||||
let sound = await this._createSound(song.playMusicUrl);
|
||||
const sound = await this._createSound(song.playMusicUrl);
|
||||
|
||||
// 检查时长
|
||||
const duration = sound.duration();
|
||||
const expectedDuration = (song.dt || 0) / 1000;
|
||||
|
||||
// 如果时长差异超过5秒,且不是B站视频,且预期时长大于0
|
||||
// 时长差异只记录警告,不自动触发重新解析
|
||||
// 用户可以通过 ReparsePopover 手动选择正确的音源
|
||||
if (
|
||||
expectedDuration > 0 &&
|
||||
Math.abs(duration - expectedDuration) > 5 &&
|
||||
song.source !== 'bilibili'
|
||||
) {
|
||||
const songId = String(song.id);
|
||||
const sourceType = localStorage.getItem(`song_source_type_${songId}`);
|
||||
|
||||
// 如果不是用户手动锁定的音源,尝试自动重新解析
|
||||
if (sourceType !== 'manual') {
|
||||
console.warn(
|
||||
`[PreloadService] 时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试智能解析`
|
||||
);
|
||||
|
||||
// 动态导入 store
|
||||
const { useSettingsStore } = await import('@/store/modules/settings');
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
const settingsStore = useSettingsStore();
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
const enabledSources = settingsStore.setData.enabledMusicSources || [
|
||||
'migu',
|
||||
'kugou',
|
||||
'pyncmd',
|
||||
'gdmusic'
|
||||
];
|
||||
const availableSources = enabledSources.filter((s: string) => s !== 'bilibili');
|
||||
|
||||
const triedSources = new Set<string>();
|
||||
const triedSourceDiffs = new Map<string, number>();
|
||||
|
||||
// 记录当前音源
|
||||
let currentSource = 'unknown';
|
||||
const currentSavedSource = localStorage.getItem(`song_source_${songId}`);
|
||||
if (currentSavedSource) {
|
||||
try {
|
||||
const sources = JSON.parse(currentSavedSource);
|
||||
if (Array.isArray(sources) && sources.length > 0) {
|
||||
currentSource = sources[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`[PreloadService] 时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试智能解析`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
triedSources.add(currentSource);
|
||||
triedSourceDiffs.set(currentSource, Math.abs(duration - expectedDuration));
|
||||
|
||||
// 卸载当前不匹配的音频
|
||||
sound.unload();
|
||||
|
||||
// 尝试其他音源
|
||||
for (const source of availableSources) {
|
||||
if (triedSources.has(source)) continue;
|
||||
|
||||
console.log(`[PreloadService] 尝试音源: ${source}`);
|
||||
triedSources.add(source);
|
||||
|
||||
try {
|
||||
const songData = cloneDeep(song);
|
||||
// 临时保存设置以便 getParsingMusicUrl 使用
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([source]));
|
||||
|
||||
const res = await getParsingMusicUrl(
|
||||
typeof song.id === 'string' ? parseInt(song.id) : song.id,
|
||||
songData
|
||||
);
|
||||
|
||||
if (res && res.data && res.data.data && res.data.data.url) {
|
||||
const newUrl = res.data.data.url;
|
||||
const tempSound = await this._createSound(newUrl);
|
||||
const newDuration = tempSound.duration();
|
||||
const diff = Math.abs(newDuration - expectedDuration);
|
||||
|
||||
triedSourceDiffs.set(source, diff);
|
||||
|
||||
if (diff <= 5) {
|
||||
console.log(`[PreloadService] 找到匹配音源: ${source}, 更新歌曲信息`);
|
||||
|
||||
// 更新歌曲信息
|
||||
const updatedSong = {
|
||||
...song,
|
||||
playMusicUrl: newUrl,
|
||||
expiredAt: Date.now() + 1800000
|
||||
};
|
||||
|
||||
// 更新 store
|
||||
playlistStore.updateSong(updatedSong);
|
||||
|
||||
// 记录新的音源设置
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([source]));
|
||||
localStorage.setItem(`song_source_type_${songId}`, 'auto');
|
||||
|
||||
return tempSound;
|
||||
} else {
|
||||
tempSound.unload();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[PreloadService] 尝试音源 ${source} 失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到完美匹配,使用最佳匹配
|
||||
console.warn('[PreloadService] 未找到完美匹配,寻找最佳匹配');
|
||||
let bestSource = '';
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const [source, diff] of triedSourceDiffs.entries()) {
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
bestSource = source;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSource && bestSource !== currentSource) {
|
||||
console.log(`[PreloadService] 使用最佳匹配音源: ${bestSource} (差异: ${minDiff}s)`);
|
||||
try {
|
||||
const songData = cloneDeep(song);
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([bestSource]));
|
||||
|
||||
const res = await getParsingMusicUrl(
|
||||
typeof song.id === 'string' ? parseInt(song.id) : song.id,
|
||||
songData
|
||||
);
|
||||
|
||||
if (res && res.data && res.data.data && res.data.data.url) {
|
||||
const newUrl = res.data.data.url;
|
||||
const bestSound = await this._createSound(newUrl);
|
||||
|
||||
const updatedSong = {
|
||||
...song,
|
||||
playMusicUrl: newUrl,
|
||||
expiredAt: Date.now() + 1800000
|
||||
};
|
||||
|
||||
playlistStore.updateSong(updatedSong);
|
||||
localStorage.setItem(`song_source_type_${songId}`, 'auto');
|
||||
|
||||
return bestSound;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[PreloadService] 获取最佳匹配音源失败:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不需要修复或修复失败,重新加载原始音频(因为上面可能unload了)
|
||||
if (sound.state() === 'unloaded') {
|
||||
sound = await this._createSound(song.playMusicUrl);
|
||||
console.warn(
|
||||
`[PreloadService] 时长差异警告:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name})`
|
||||
);
|
||||
}
|
||||
|
||||
return sound;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||
import { preloadService } from '@/services/preloadService';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import type { Platform, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
@@ -30,8 +31,6 @@ export const usePlayerCoreStore = defineStore(
|
||||
const isPlay = ref(false);
|
||||
const playMusic = ref<SongResult>({} as SongResult);
|
||||
const playMusicUrl = ref('');
|
||||
const triedSources = ref<Set<string>>(new Set());
|
||||
const triedSourceDiffs = ref<Map<string, number>>(new Map());
|
||||
const musicFull = ref(false);
|
||||
const playbackRate = ref(1.0);
|
||||
const volume = ref(1);
|
||||
@@ -165,10 +164,9 @@ export const usePlayerCoreStore = defineStore(
|
||||
* 核心播放处理函数
|
||||
*/
|
||||
const handlePlayMusic = async (music: SongResult, isPlay: boolean = true) => {
|
||||
// 如果是新歌曲,重置已尝试的音源
|
||||
// 如果是新歌曲,重置已尝试的音源(使用 SongSourceConfigManager 按歌曲隔离)
|
||||
if (music.id !== playMusic.value.id) {
|
||||
triedSources.value.clear();
|
||||
triedSourceDiffs.value.clear();
|
||||
SongSourceConfigManager.clearTriedSources(music.id);
|
||||
}
|
||||
|
||||
// 创建新的播放请求并取消之前的所有请求
|
||||
@@ -427,110 +425,7 @@ export const usePlayerCoreStore = defineStore(
|
||||
new CustomEvent('audio-ready', { detail: { sound: newSound, shouldPlay } })
|
||||
);
|
||||
|
||||
// 检查时长是否匹配,如果不匹配则尝试自动重新解析
|
||||
const duration = newSound.duration();
|
||||
const expectedDuration = (playMusic.value.dt || 0) / 1000;
|
||||
|
||||
// 如果时长差异超过5秒,且不是B站视频,且预期时长大于0
|
||||
if (
|
||||
expectedDuration > 0 &&
|
||||
Math.abs(duration - expectedDuration) > 5 &&
|
||||
playMusic.value.source !== 'bilibili' &&
|
||||
playMusic.value.id
|
||||
) {
|
||||
const songId = String(playMusic.value.id);
|
||||
const sourceType = localStorage.getItem(`song_source_type_${songId}`);
|
||||
|
||||
// 如果不是用户手动锁定的音源
|
||||
if (sourceType !== 'manual') {
|
||||
console.warn(
|
||||
`时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试自动切换音源`
|
||||
);
|
||||
|
||||
// 记录当前失败的音源
|
||||
// 注意:这里假设当前使用的音源是 playMusic.value.source,或者是刚刚解析出来的
|
||||
// 但实际上我们需要知道当前具体是用哪个平台解析成功的,这可能需要从 getSongUrl 的结果中获取
|
||||
// 暂时简单处理,将当前配置的来源加入已尝试列表
|
||||
|
||||
// 获取所有可用音源
|
||||
const { useSettingsStore } = await import('./settings');
|
||||
const settingsStore = useSettingsStore();
|
||||
const enabledSources = settingsStore.setData.enabledMusicSources || [
|
||||
'migu',
|
||||
'kugou',
|
||||
'pyncmd',
|
||||
'gdmusic'
|
||||
];
|
||||
const availableSources: Platform[] = enabledSources.filter(
|
||||
(s: string) => s !== 'bilibili'
|
||||
);
|
||||
|
||||
// 将当前正在使用的音源加入已尝试列表
|
||||
let currentSource = 'unknown';
|
||||
const currentSavedSource = localStorage.getItem(`song_source_${songId}`);
|
||||
if (currentSavedSource) {
|
||||
try {
|
||||
const sources = JSON.parse(currentSavedSource);
|
||||
if (Array.isArray(sources) && sources.length > 0) {
|
||||
currentSource = sources[0];
|
||||
triedSources.value.add(currentSource);
|
||||
}
|
||||
} catch {
|
||||
console.error(`解析当前音源失败: ${currentSource}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 找到下一个未尝试的音源
|
||||
const nextSource = availableSources.find((s) => !triedSources.value.has(s));
|
||||
|
||||
// 记录当前音源的时间差
|
||||
if (currentSource !== 'unknown') {
|
||||
triedSourceDiffs.value.set(currentSource, Math.abs(duration - expectedDuration));
|
||||
}
|
||||
|
||||
if (nextSource) {
|
||||
console.log(`自动切换到音源: ${nextSource}`);
|
||||
newSound.stop();
|
||||
newSound.unload();
|
||||
|
||||
// 递归调用 reparseCurrentSong
|
||||
// 注意:这里是异步调用,不会阻塞当前函数返回,但我们已经停止了播放
|
||||
const success = await reparseCurrentSong(nextSource, true);
|
||||
if (success) {
|
||||
return audioService.getCurrentSound();
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
console.warn('所有音源都已尝试,寻找最接近时长的版本');
|
||||
|
||||
// 找出时间差最小的音源
|
||||
let bestSource = '';
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const [source, diff] of triedSourceDiffs.value.entries()) {
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
bestSource = source;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了最佳音源,且不是当前正在播放的音源
|
||||
if (bestSource && bestSource !== currentSource) {
|
||||
console.log(`切换到最佳匹配音源: ${bestSource} (差异: ${minDiff}s)`);
|
||||
newSound.stop();
|
||||
newSound.unload();
|
||||
|
||||
const success = await reparseCurrentSong(bestSource as Platform, true);
|
||||
if (success) {
|
||||
return audioService.getCurrentSound();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`当前音源 ${currentSource} 已经是最佳匹配 (差异: ${minDiff}s),保留播放`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 时长检查已在 preloadService.ts 中完成
|
||||
|
||||
return newSound;
|
||||
} catch (error) {
|
||||
@@ -630,11 +525,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
return false;
|
||||
}
|
||||
|
||||
const songId = String(currentSong.id);
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([sourcePlatform]));
|
||||
|
||||
// 记录音源设置类型(自动/手动)
|
||||
localStorage.setItem(`song_source_type_${songId}`, isAuto ? 'auto' : 'manual');
|
||||
// 使用 SongSourceConfigManager 保存配置
|
||||
SongSourceConfigManager.setConfig(
|
||||
currentSong.id,
|
||||
[sourcePlatform],
|
||||
isAuto ? 'auto' : 'manual'
|
||||
);
|
||||
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
|
||||
Reference in New Issue
Block a user