refactor: 重构音乐和歌词缓存逻辑 可配置缓存目录

This commit is contained in:
alger
2026-03-06 19:56:01 +08:00
parent b02ca859de
commit a62e6d256e
38 changed files with 1808 additions and 94 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ const getDescription = () => {
}
if (props.item.size !== undefined) {
parts.push(t('user.album.songCount', { count: props.item.size }));
parts.push(t('common.songCount', { count: props.item.size }));
}
return parts.join(' · ') || t('history.noDescription');
@@ -33,7 +33,7 @@
<span
class="px-3 py-1 text-xs font-medium text-white bg-gradient-to-r from-green-500 to-emerald-600 rounded-full"
>
{{ t('comp.update.newVersion') }}
{{ t('comp.update.title') }}
</span>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white truncate">
@@ -65,7 +65,7 @@
@click="handleLater"
class="flex-1 py-4 px-4 rounded-2xl text-base font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 active:scale-[0.98] transition-all duration-200"
>
{{ t('comp.update.later') }}
{{ t('comp.update.noThanks') }}
</button>
<button
@click="handleUpdate"
@@ -73,7 +73,7 @@
>
<span class="flex items-center justify-center gap-2">
<i class="ri-download-2-line text-lg"></i>
{{ t('comp.update.updateNow') }}
{{ t('comp.update.nowUpdate') }}
</span>
</button>
</div>
@@ -213,9 +213,9 @@ let timerInterval: number | null = null;
const hasTimerActive = computed(() => playerStore.hasSleepTimerActive);
const timerStatusText = computed(() => {
if (sleepTimer.value.type === 'time') return t('player.sleepTimer.activeTime');
if (sleepTimer.value.type === 'songs') return t('player.sleepTimer.activeSongs');
if (sleepTimer.value.type === 'end') return t('player.sleepTimer.activeEnd');
if (sleepTimer.value.type === 'time') return t('player.sleepTimer.timeMode');
if (sleepTimer.value.type === 'songs') return t('player.sleepTimer.songsMode');
if (sleepTimer.value.type === 'end') return t('player.sleepTimer.afterPlaylist');
return '';
});
@@ -396,7 +396,7 @@ const toggleSource = (sourceKey: string) => {
if (index > -1) {
// 至少保留一个音源
if (selectedSources.value.length <= 1) {
message.warning(t('settings.playback.musicSourcesMinWarning'));
message.warning(t('settings.playback.musicSourcesWarning'));
return;
}
selectedSources.value.splice(index, 1);
+79 -9
View File
@@ -6,12 +6,57 @@ 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 { getImgUrl, isElectron } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
const { message } = createDiscreteApi(['message']);
type DiskCacheResolveResult = {
url?: string;
cached?: boolean;
queued?: boolean;
};
const getSongArtistText = (songData: SongResult): string => {
if (songData?.ar?.length) {
return songData.ar.map((artist) => artist.name).join(' / ');
}
if (songData?.song?.artists?.length) {
return songData.song.artists.map((artist) => artist.name).join(' / ');
}
return '';
};
const resolveCachedPlaybackUrl = async (
url: string | null | undefined,
songData: SongResult
): Promise<string | null | undefined> => {
if (!url || !isElectron || !/^https?:\/\//i.test(url)) {
return url;
}
try {
const result = (await window.electron.ipcRenderer.invoke('resolve-cached-music-url', {
songId: Number(songData.id),
source: songData.source,
url,
title: songData.name,
artist: getSongArtistText(songData)
})) as DiskCacheResolveResult;
if (result?.url) {
return result.url;
}
} catch (error) {
console.warn('解析缓存播放地址失败,回退到在线地址:', error);
}
return url;
};
/**
* 获取歌曲播放URL(独立函数)
*/
@@ -35,7 +80,8 @@ export const getSongUrl = async (
}
if (songData.playMusicUrl) {
return songData.playMusicUrl;
if (isDownloaded) return songData.playMusicUrl;
return await resolveCachedPlaybackUrl(songData.playMusicUrl, songData);
}
// ==================== 自定义API最优先 ====================
@@ -70,7 +116,7 @@ export const getSongUrl = async (
) {
console.log('自定义API解析成功!');
if (isDownloaded) return customResult.data.data as any;
return customResult.data.data.url;
return await resolveCachedPlaybackUrl(customResult.data.data.url, songData);
} else {
console.log('自定义API解析失败,将使用默认降级流程...');
message.warning(i18n.global.t('player.reparse.customApiFailed'));
@@ -98,7 +144,7 @@ export const getSongUrl = async (
}
if (res && res.data && res.data.data && res.data.data.url) {
return res.data.data.url;
return await resolveCachedPlaybackUrl(res.data.data.url, songData);
}
console.warn('自定义音源解析失败,使用默认音源');
} catch (error) {
@@ -133,12 +179,13 @@ export const getSongUrl = async (
throw new Error('Request cancelled');
}
if (isDownloaded) return res?.data?.data as any;
return res?.data?.data?.url || null;
const parsedUrl = res?.data?.data?.url || null;
return await resolveCachedPlaybackUrl(parsedUrl, songData);
}
console.log('官方API解析成功!');
if (isDownloaded) return songDetail as any;
return songDetail.url;
return await resolveCachedPlaybackUrl(songDetail.url, songData);
}
console.log('官方API返回数据结构异常,进入内置备用解析...');
@@ -149,7 +196,8 @@ export const getSongUrl = async (
throw new Error('Request cancelled');
}
if (isDownloaded) return res?.data?.data as any;
return res?.data?.data?.url || null;
const parsedUrl = res?.data?.data?.url || null;
return await resolveCachedPlaybackUrl(parsedUrl, songData);
} catch (error) {
if ((error as Error).message === 'Request cancelled') {
throw error;
@@ -157,7 +205,8 @@ export const getSongUrl = async (
console.error('官方API请求失败,进入内置备用解析流程:', error);
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
if (isDownloaded) return res?.data?.data as any;
return res?.data?.data?.url || null;
const parsedUrl = res?.data?.data?.url || null;
return await resolveCachedPlaybackUrl(parsedUrl, songData);
}
};
@@ -218,7 +267,28 @@ const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: numbe
export const loadLrc = async (id: string | number): Promise<ILyric> => {
try {
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
const { data } = await getMusicLrc(numericId);
let lyricData: any;
if (isElectron) {
try {
lyricData = await window.electron.ipcRenderer.invoke('get-cached-lyric', numericId);
} catch (error) {
console.warn('读取磁盘歌词缓存失败:', error);
}
}
if (!lyricData) {
const { data } = await getMusicLrc(numericId);
lyricData = data;
if (isElectron && lyricData) {
void window.electron.ipcRenderer
.invoke('cache-lyric', numericId, lyricData)
.catch((error) => console.warn('写入磁盘歌词缓存失败:', error));
}
}
const data = lyricData ?? {};
const { lyrics, times } = parseLyrics(data?.yrc?.lyric || data?.lrc?.lyric);
// 检查是否有逐字歌词
@@ -16,7 +16,7 @@
@click="playAll"
>
<i class="iconfont icon-playfill text-sm" />
<span>{{ t('musicList.playAll') }}</span>
<span>{{ t('comp.musicList.playAll') }}</span>
</button>
</div>
</div>
@@ -67,7 +67,7 @@
<!-- 无结果 -->
<div v-else-if="!loading" class="empty-state">
<i class="ri-search-line"></i>
<span>{{ t('search.noResult') }}</span>
<span>{{ t('comp.musicList.noSearchResults') }}</span>
</div>
</div>
</div>
+1 -1
View File
@@ -342,7 +342,7 @@ const categoryList = computed(() => {
});
const currentCategoryName = computed(() => {
if (currentCategoryId.value === -1) return t('podcast.recommend');
if (currentCategoryId.value === -1) return t('podcast.recommended');
return categories.value.find((c) => c.id === currentCategoryId.value)?.name || '';
});
+1 -1
View File
@@ -207,7 +207,7 @@
class="flex flex-col items-center justify-center py-20 text-neutral-400"
>
<i class="ri-search-line text-6xl mb-4 opacity-20"></i>
<p>{{ t('search.noResults') }}</p>
<p>{{ t('comp.musicList.noSearchResults') }}</p>
</div>
<!-- Loading More / Footer -->
+423 -2
View File
@@ -473,6 +473,119 @@
<!-- 系统管理 -->
<div v-show="currentSection === 'system'" class="animate-fade-in">
<setting-section v-if="isElectron" :title="t('settings.sections.system')">
<!-- 磁盘缓存开关 -->
<setting-item
:title="t('settings.system.diskCache')"
:description="t('settings.system.diskCacheDesc')"
>
<n-switch v-model:value="setData.enableDiskCache">
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
</setting-item>
<!-- 缓存目录 -->
<setting-item
:title="t('settings.system.cacheDirectory')"
:description="
setData.diskCacheDir ||
diskCacheStats.directory ||
t('settings.system.cacheDirectoryDesc')
"
>
<template #action>
<div class="flex items-center gap-2 max-md:flex-wrap">
<n-button size="small" @click="selectCacheDirectory">
{{ t('settings.system.selectDirectory') }}
</n-button>
<n-button size="small" @click="openCacheDirectory">
{{ t('settings.system.openDirectory') }}
</n-button>
</div>
</template>
</setting-item>
<!-- 缓存上限 -->
<setting-item
:title="t('settings.system.cacheMaxSize')"
:description="t('settings.system.cacheMaxSizeDesc')"
>
<template #action>
<div class="flex items-center gap-2">
<n-input-number
v-model:value="setData.diskCacheMaxSizeMB"
:min="256"
:max="102400"
:step="256"
class="max-md:w-32"
/>
<span class="text-xs text-neutral-500">MB</span>
</div>
</template>
</setting-item>
<!-- 清理策略 -->
<setting-item
:title="t('settings.system.cleanupPolicy')"
:description="t('settings.system.cleanupPolicyDesc')"
>
<n-select
v-model:value="setData.diskCacheCleanupPolicy"
:options="cleanupPolicyOptions"
class="w-40"
/>
</setting-item>
<!-- 缓存状态 -->
<setting-item
:title="t('settings.system.cacheStatus')"
:description="
t('settings.system.cacheStatusDesc', {
used: formatBytes(diskCacheStats.totalSizeBytes),
limit: `${setData.diskCacheMaxSizeMB || diskCacheStats.maxSizeMB || 0} MB`
})
"
>
<template #action>
<div class="flex items-center gap-3 max-md:flex-wrap">
<div class="w-40 max-md:w-32">
<n-progress type="line" :percentage="diskCacheUsagePercent" />
</div>
<span class="text-xs text-neutral-500">
{{
t('settings.system.cacheStatusDetail', {
musicCount: diskCacheStats.musicFiles,
lyricCount: diskCacheStats.lyricFiles
})
}}
</span>
<n-button size="small" @click="refreshDiskCacheStats()">{{
t('common.refresh')
}}</n-button>
</div>
</template>
</setting-item>
<!-- 手动清理磁盘缓存 -->
<setting-item
:title="t('settings.system.manageDiskCache')"
:description="t('settings.system.manageDiskCacheDesc')"
>
<template #action>
<div class="flex items-center gap-2 max-md:flex-wrap">
<n-button size="small" @click="clearDiskCacheByScope('music')">
{{ t('settings.system.clearMusicCache') }}
</n-button>
<n-button size="small" @click="clearDiskCacheByScope('lyrics')">
{{ t('settings.system.clearLyricCache') }}
</n-button>
<n-button type="error" size="small" @click="clearDiskCacheByScope('all')">
{{ t('settings.system.clearAllCache') }}
</n-button>
</div>
</template>
</setting-item>
<!-- 清除缓存 -->
<setting-item
:title="t('settings.system.cache')"
@@ -567,7 +680,7 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core';
import { useMessage } from 'naive-ui';
import { useDialog, useMessage } from 'naive-ui';
import { computed, h, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
@@ -611,11 +724,40 @@ const fontPreviews = [
{ key: 'korean' }
];
type DiskCacheScope = 'all' | 'music' | 'lyrics';
type DiskCacheCleanupPolicy = 'lru' | 'fifo';
type CacheSwitchAction = 'migrate' | 'destroy' | 'keep';
type DiskCacheConfig = {
enabled: boolean;
directory: string;
maxSizeMB: number;
cleanupPolicy: DiskCacheCleanupPolicy;
};
type DiskCacheStats = DiskCacheConfig & {
totalSizeBytes: number;
musicSizeBytes: number;
lyricSizeBytes: number;
totalFiles: number;
musicFiles: number;
lyricFiles: number;
usage: number;
};
type SwitchCacheDirectoryResult = {
success: boolean;
config: DiskCacheConfig;
migratedFiles: number;
destroyedFiles: number;
};
// ==================== 平台和Store ====================
const platform = window.electron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const message = useMessage();
const dialog = useDialog();
const { t } = useI18n();
const router = useRouter();
@@ -764,6 +906,268 @@ const openDownloadPath = () => {
openDirectory(setData.value.downloadPath, message);
};
// ==================== 磁盘缓存设置 ====================
const diskCacheStats = ref<DiskCacheStats>({
enabled: true,
directory: '',
maxSizeMB: 4096,
cleanupPolicy: 'lru',
totalSizeBytes: 0,
musicSizeBytes: 0,
lyricSizeBytes: 0,
totalFiles: 0,
musicFiles: 0,
lyricFiles: 0,
usage: 0
});
const applyingDiskCacheConfig = ref(false);
const switchingCacheDirectory = ref(false);
const cleanupPolicyOptions = computed(() => [
{ label: t('settings.system.cleanupPolicyOptions.lru'), value: 'lru' },
{ label: t('settings.system.cleanupPolicyOptions.fifo'), value: 'fifo' }
]);
const diskCacheUsagePercent = computed(() =>
Math.min(100, Math.max(0, Math.round((diskCacheStats.value.usage || 0) * 100)))
);
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
};
const readDiskCacheConfigFromUI = (): DiskCacheConfig => {
const cleanupPolicy: DiskCacheCleanupPolicy =
setData.value.diskCacheCleanupPolicy === 'fifo' ? 'fifo' : 'lru';
const maxSizeMB = Math.max(256, Math.floor(Number(setData.value.diskCacheMaxSizeMB || 4096)));
return {
enabled: setData.value.enableDiskCache !== false,
directory: String(setData.value.diskCacheDir || ''),
maxSizeMB,
cleanupPolicy
};
};
const refreshDiskCacheStats = async (silent: boolean = true) => {
if (!window.electron) return;
try {
const stats = (await window.electron.ipcRenderer.invoke(
'get-disk-cache-stats'
)) as DiskCacheStats;
if (stats) {
diskCacheStats.value = stats;
}
} catch (error) {
console.error('读取磁盘缓存统计失败:', error);
if (!silent) {
message.error(t('settings.system.messages.diskCacheStatsLoadFailed'));
}
}
};
const loadDiskCacheConfig = async () => {
if (!window.electron) return;
try {
const config = (await window.electron.ipcRenderer.invoke(
'get-disk-cache-config'
)) as DiskCacheConfig;
if (config) {
setData.value = {
...setData.value,
enableDiskCache: config.enabled,
diskCacheDir: config.directory,
diskCacheMaxSizeMB: config.maxSizeMB,
diskCacheCleanupPolicy: config.cleanupPolicy
};
}
} catch (error) {
console.error('读取磁盘缓存配置失败:', error);
}
};
const applyDiskCacheConfig = async () => {
if (!window.electron || applyingDiskCacheConfig.value) return;
applyingDiskCacheConfig.value = true;
try {
const config = readDiskCacheConfigFromUI();
const updated = (await window.electron.ipcRenderer.invoke(
'set-disk-cache-config',
config
)) as DiskCacheConfig;
if (updated) {
setData.value = {
...setData.value,
enableDiskCache: updated.enabled,
diskCacheDir: updated.directory,
diskCacheMaxSizeMB: updated.maxSizeMB,
diskCacheCleanupPolicy: updated.cleanupPolicy
};
}
await refreshDiskCacheStats();
} catch (error) {
console.error('更新磁盘缓存配置失败:', error);
} finally {
applyingDiskCacheConfig.value = false;
}
};
const applyDiskCacheConfigDebounced = useDebounceFn(() => {
void applyDiskCacheConfig();
}, 500);
watch(
() => [
setData.value.enableDiskCache,
setData.value.diskCacheDir,
setData.value.diskCacheMaxSizeMB,
setData.value.diskCacheCleanupPolicy
],
() => {
if (!window.electron || applyingDiskCacheConfig.value || switchingCacheDirectory.value) return;
applyDiskCacheConfigDebounced();
}
);
const askCacheSwitchMigrate = (): Promise<boolean> => {
return new Promise((resolve) => {
let resolved = false;
const finish = (value: boolean) => {
if (resolved) return;
resolved = true;
resolve(value);
};
dialog.warning({
title: t('settings.system.switchDirectoryMigrateTitle'),
content: t('settings.system.switchDirectoryMigrateContent'),
positiveText: t('settings.system.switchDirectoryMigrateConfirm'),
negativeText: t('settings.system.switchDirectoryKeepOld'),
onPositiveClick: () => finish(true),
onNegativeClick: () => finish(false),
onClose: () => finish(false)
});
});
};
const askCacheSwitchDestroy = (): Promise<boolean> => {
return new Promise((resolve) => {
let resolved = false;
const finish = (value: boolean) => {
if (resolved) return;
resolved = true;
resolve(value);
};
dialog.warning({
title: t('settings.system.switchDirectoryDestroyTitle'),
content: t('settings.system.switchDirectoryDestroyContent'),
positiveText: t('settings.system.switchDirectoryDestroyConfirm'),
negativeText: t('settings.system.switchDirectoryKeepOld'),
onPositiveClick: () => finish(true),
onNegativeClick: () => finish(false),
onClose: () => finish(false)
});
});
};
const selectCacheDirectory = async () => {
if (!window.electron) return;
const selectedPath = await selectDirectory(message);
if (!selectedPath) return;
const currentDirectory = setData.value.diskCacheDir || diskCacheStats.value.directory;
if (currentDirectory && selectedPath === currentDirectory) {
return;
}
let action: CacheSwitchAction = 'keep';
if (currentDirectory && diskCacheStats.value.totalFiles > 0) {
const shouldMigrate = await askCacheSwitchMigrate();
if (shouldMigrate) {
action = 'migrate';
} else {
const shouldDestroy = await askCacheSwitchDestroy();
action = shouldDestroy ? 'destroy' : 'keep';
}
}
switchingCacheDirectory.value = true;
try {
const result = (await window.electron.ipcRenderer.invoke('switch-disk-cache-directory', {
directory: selectedPath,
action
})) as SwitchCacheDirectoryResult;
if (!result?.success) {
message.error(t('settings.system.messages.switchDirectoryFailed'));
return;
}
setData.value = {
...setData.value,
enableDiskCache: result.config.enabled,
diskCacheDir: result.config.directory,
diskCacheMaxSizeMB: result.config.maxSizeMB,
diskCacheCleanupPolicy: result.config.cleanupPolicy
};
await refreshDiskCacheStats();
if (action === 'migrate') {
message.success(
t('settings.system.messages.switchDirectoryMigrated', { count: result.migratedFiles })
);
return;
}
if (action === 'destroy') {
message.success(
t('settings.system.messages.switchDirectoryDestroyed', { count: result.destroyedFiles })
);
return;
}
message.success(t('settings.system.messages.switchDirectorySuccess'));
} catch (error) {
console.error('切换缓存目录失败:', error);
message.error(t('settings.system.messages.switchDirectoryFailed'));
} finally {
switchingCacheDirectory.value = false;
}
};
const openCacheDirectory = () => {
const targetPath = setData.value.diskCacheDir || diskCacheStats.value.directory;
openDirectory(targetPath, message);
};
const clearDiskCacheByScope = async (scope: DiskCacheScope) => {
if (!window.electron) return;
try {
const success = await window.electron.ipcRenderer.invoke('clear-disk-cache', scope);
if (success) {
await refreshDiskCacheStats();
message.success(t('settings.system.messages.diskCacheClearSuccess'));
return;
}
message.error(t('settings.system.messages.diskCacheClearFailed'));
} catch (error) {
console.error('手动清理磁盘缓存失败:', error);
message.error(t('settings.system.messages.diskCacheClearFailed'));
}
};
// ==================== 代理设置 ====================
const showProxyModal = ref(false);
const proxyForm = ref({ protocol: 'http', host: '127.0.0.1', port: 7890 });
@@ -883,6 +1287,7 @@ const clearCache = async (selectedCacheTypes: string[]) => {
case 'resources':
if (window.electron) {
window.electron.ipcRenderer.send('clear-audio-cache');
await window.electron.ipcRenderer.invoke('clear-disk-cache', 'music');
}
localStorage.removeItem('lyricCache');
localStorage.removeItem('musicUrlCache');
@@ -897,11 +1302,15 @@ const clearCache = async (selectedCacheTypes: string[]) => {
}
break;
case 'lyrics':
window.api.invoke('clear-lyrics-cache');
if (window.electron) {
await window.electron.ipcRenderer.invoke('clear-disk-cache', 'lyrics');
}
await window.api.invoke('clear-lyrics-cache');
break;
}
});
await Promise.all(clearTasks);
await refreshDiskCacheStats();
message.success(t('settings.system.messages.clearSuccess'));
};
@@ -997,6 +1406,18 @@ onMounted(async () => {
if (setData.value.enableRealIP === undefined) {
setData.value = { ...setData.value, enableRealIP: false };
}
if (setData.value.enableDiskCache === undefined) {
setData.value = { ...setData.value, enableDiskCache: true };
}
if (!setData.value.diskCacheMaxSizeMB) {
setData.value = { ...setData.value, diskCacheMaxSizeMB: 4096 };
}
if (!['lru', 'fifo'].includes(setData.value.diskCacheCleanupPolicy)) {
setData.value = { ...setData.value, diskCacheCleanupPolicy: 'lru' };
}
await loadDiskCacheConfig();
await refreshDiskCacheStats();
if (window.electron) {
window.electron.ipcRenderer.on('gpu-acceleration-updated', (_, enabled: boolean) => {
+1 -1
View File
@@ -204,7 +204,7 @@ const loadFollowerList = async () => {
hasMoreFollowers.value = newFollowers.length >= followerLimit.value;
} catch (error) {
console.error('加载粉丝列表失败:', error);
message.error(t('user.follower.loadFailed'));
message.error(t('common.loadFailed'));
} finally {
followerListLoading.value = false;
}
+1 -1
View File
@@ -206,7 +206,7 @@ const loadFollowList = async () => {
hasMoreFollows.value = newFollows.length >= followLimit.value;
} catch (error) {
console.error('加载关注列表失败:', error);
message.error(t('user.follow.loadFailed'));
message.error(t('common.loadFailed'));
} finally {
followListLoading.value = false;
}