Merge pull request #676 from chengww5217/refactor/persisted-storage

refactor(persist): localStorage 配额防护与历史持久化重写
This commit is contained in:
alger
2026-07-05 13:55:20 +08:00
16 changed files with 573 additions and 201 deletions
+11
View File
@@ -35,6 +35,17 @@ export default defineConfig({
})
],
publicDir: resolve('resources'),
build: {
rollupOptions: {
output: {
// 全部代码打到 entry chunk,避免 Vite 默认按共享依赖拆分时
// 与 store/index.ts 的 `export *` 形成 chunk 间循环引用,
// 触发生产构建里的 TDZ(dev 不分包不会暴露此问题)。
// Electron 桌面端本地加载,无 CDN/首屏体积顾虑,单 chunk 合算。
manualChunks: () => 'index'
}
}
},
server: {
host: '0.0.0.0',
port: 2389
+2 -2
View File
@@ -6,6 +6,7 @@ import Store from 'electron-store';
import * as fs from 'fs';
import * as path from 'path';
import { filePathToLocalUrl } from '../../shared/localUrl';
import { getStore } from './config';
type CacheCleanupPolicy = 'lru' | 'fifo';
@@ -535,8 +536,7 @@ class DiskCacheManager {
}
private toLocalUrl(filePath: string): string {
const normalized = path.normalize(filePath).replace(/\\/g, '/');
return `local:///${encodeURIComponent(normalized)}`;
return filePathToLocalUrl(path.normalize(filePath));
}
private isRemoteAudioUrl(url: string): boolean {
+51 -13
View File
@@ -1,7 +1,8 @@
// 本地音乐扫描模块
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
import { ipcMain } from 'electron';
import * as crypto from 'crypto';
import { app, ipcMain } from 'electron';
import * as fs from 'fs';
import * as mm from 'music-metadata';
import * as os from 'os';
@@ -10,7 +11,30 @@ import * as path from 'path';
/** 支持的音频文件格式 */
const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
const METADATA_PARSE_CONCURRENCY = Math.min(8, Math.max(2, os.cpus().length));
const MAX_COVER_BYTES = 1024 * 1024;
const MAX_COVER_BYTES = 8 * 1024 * 1024;
/** 封面缓存目录:userData/AudioCovers/<hash>.<ext> */
const COVER_DIR_NAME = 'AudioCovers';
let cachedCoverDir: string | null = null;
function getCoverDir(): string {
if (cachedCoverDir) return cachedCoverDir;
const dir = path.join(app.getPath('userData'), COVER_DIR_NAME);
try {
fs.mkdirSync(dir, { recursive: true });
} catch (error) {
console.error('创建封面目录失败:', error);
}
cachedCoverDir = dir;
return dir;
}
/** 从 mime 类型推断文件扩展名 */
function extFromMime(mime: string | undefined): string {
const sub = mime?.split('/')[1]?.split(';')[0]?.trim().toLowerCase();
if (!sub) return 'bin';
return sub === 'jpeg' ? 'jpg' : sub;
}
/**
* 主进程返回的原始音乐元数据
@@ -27,8 +51,8 @@ type LocalMusicMeta = {
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** 封面图片缓存文件绝对路径,无封面时为 null */
coverPath: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
@@ -66,23 +90,37 @@ function extractTitleFromFilename(filePath: string): string {
}
/**
* 将封面图片数据转换为 base64 Data URL
* 将封面图片落盘到 userData/AudioCovers/,返回绝对路径
* 文件名按 sourceFilePath 的 sha256 + 推断扩展名拼成,幂等可覆盖
* @param picture music-metadata 解析出的封面图片对象
* @returns base64 Data URL 字符串,转换失败返回 null
* @param sourceFilePath 音乐源文件绝对路径,用于生成稳定的封面文件名
* @returns 封面文件绝对路径,无封面或写入失败返回 null
*/
function extractCoverAsDataUrl(picture: mm.IPicture | undefined): string | null {
async function extractCoverToFile(
picture: mm.IPicture | undefined,
sourceFilePath: string
): Promise<string | null> {
if (!picture) {
return null;
}
try {
if (picture.data.length > MAX_COVER_BYTES) {
console.warn(
`封面超过大小上限被跳过: ${sourceFilePath} (${picture.data.length} bytes > ${MAX_COVER_BYTES})`
);
return null;
}
const mime = picture.format ?? 'image/jpeg';
const base64 = Buffer.from(picture.data).toString('base64');
return `data:${mime};base64,${base64}`;
const ext = extFromMime(picture.format);
const hash = crypto.createHash('sha256').update(sourceFilePath).digest('hex');
const coverFile = path.join(getCoverDir(), `${hash}.${ext}`);
// 直接覆盖写:本函数只在文件 mtime 变更时被调用(见 scanFolders 的 parseTargets),
// 频率本就受守门;按 size 跳过会在"用户替换内嵌封面、新旧字节数恰好相等"时留旧图,
// 单张封面几十~几百 KB,覆盖代价可忽略。
await fs.promises.writeFile(coverFile, Buffer.from(picture.data));
return coverFile;
} catch (error) {
console.error('封面提取失败:', error);
console.error('封面落盘失败:', error);
return null;
}
}
@@ -234,7 +272,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
artist: '未知艺术家',
album: '未知专辑',
duration: 0,
cover: null,
coverPath: null,
lyrics: null,
fileSize,
modifiedTime
@@ -250,7 +288,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
artist: common.artist || fallback.artist,
album: common.album || fallback.album,
duration: format.duration ? Math.round(format.duration * 1000) : 0,
cover: extractCoverAsDataUrl(common.picture?.[0]),
coverPath: await extractCoverToFile(common.picture?.[0], filePath),
lyrics: extractLyrics(common.lyrics),
fileSize,
modifiedTime
+6 -1
View File
@@ -150,10 +150,15 @@ export const useLocalMusicStore = defineStore(
}
// 2. 增量扫描:基于修改时间筛选需重新解析的文件
// 老条目(无 coverPath 字段)也视为需要重新解析,让数据自愈到统一格式
const parseTargets: string[] = [];
for (const file of files) {
const cached = cachedMap.get(file.path);
if (!cached || cached.modifiedTime !== file.modifiedTime) {
if (
!cached ||
cached.modifiedTime !== file.modifiedTime ||
!('coverPath' in cached)
) {
parseTargets.push(file.path);
}
}
+161 -104
View File
@@ -3,6 +3,37 @@ import { ref } from 'vue';
import type { SongResult } from '@/types/music';
import type { DjProgram } from '@/types/podcast';
import { debouncedLocalStorage, flushDebouncedStorage } from '@/utils/debouncedStorage';
import type { MusicHistoryItem } from '@/utils/persistedSong';
import {
minifyHistoryEntry,
minifyHistoryList,
stripBase64CoversList
} from '@/utils/persistedSong';
export type { MusicHistoryItem };
// 一次性清理 v1 时代遗留的 localStorage key。
// 旧版本以 5 个独立 key 单独存历史,新版本合并到 PERSIST_KEY 由 persistedstate 管。
// 不做数据迁移:历史是低关键性衍生数据,老用户升级后看到空"最近播放",重新听几次即可。
// 仅清掉旧 key 释放配额,避免和新 key 双倍占用
const LEGACY_KEYS = [
'musicHistory',
'podcastHistory',
'playlistHistory',
'albumHistory',
'podcastRadioHistory',
// v1 迁移方案的 flag,已随 e53a035 发布到用户机器上
'playHistory-migrated',
// v1 时代独立持久化的播放模式,现已并入 player-core-store
'playMode'
];
export const cleanupLegacyPlayHistoryStorage = (): void => {
if (localStorage.getItem('playHistory-cleaned-v1')) return;
LEGACY_KEYS.forEach((key) => localStorage.removeItem(key));
localStorage.setItem('playHistory-cleaned-v1', '1');
};
// ==================== 类型定义 ====================
@@ -54,6 +85,43 @@ export type PodcastRadioHistoryItem = {
// 历史记录最大条数
const MAX_HISTORY_SIZE = 500;
// persistedstate 的 storage key
const PERSIST_KEY = 'play-history-store';
// pick 出来的持久化状态,给 persistedstate.serializer 与 clearAll 同步落盘共用
type PersistedPlayHistoryState = {
musicHistory: MusicHistoryItem[];
podcastHistory: DjProgram[];
playlistHistory: PlaylistHistoryItem[];
albumHistory: AlbumHistoryItem[];
podcastRadioHistory: PodcastRadioHistoryItem[];
};
// 序列化层兜底:哪怕有代码绕过 addMusic 直接 push 完整 SongResult,也能在 serialize 时
// 再过一遍 minifyHistoryList,确保 localStorage 里的 musicHistory 不混入 lyric/song/base64 封面
//
// podcast/playlist/album/podcastRadio 等历史也走 stripBase64CoversList 兜底:
// 它们的图片字段(picUrl / coverImgUrl / coverUrl)若被注入 base64 Data URL
// 同样会撑爆 5MB 配额;保持四类历史的防御对称,避免某一处漏掉变成隐患
//
// 入参用 any 是为了同时兼容 persistedstate 的 StateTree 与 clearAll 手工构造的 PersistedPlayHistoryState
const serializePlayHistoryState = (state: any): string => {
const s = state as PersistedPlayHistoryState;
return JSON.stringify({
...state,
musicHistory: minifyHistoryList(
s.musicHistory as unknown as (SongResult & {
count?: number;
lastPlayTime?: number;
})[]
),
podcastHistory: stripBase64CoversList(s.podcastHistory),
playlistHistory: stripBase64CoversList(s.playlistHistory),
albumHistory: stripBase64CoversList(s.albumHistory),
podcastRadioHistory: stripBase64CoversList(s.podcastRadioHistory)
});
};
/**
* 播放记录统一管理 Store
* 使用 Pinia 单例模式,解决多实例不同步问题
@@ -63,7 +131,9 @@ export const usePlayHistoryStore = defineStore(
'playHistory',
() => {
// ==================== 状态 ====================
const musicHistory = ref<SongResult[]>([]);
// musicHistory 用 MusicHistoryItem 而非完整 SongResultlyric/song/expiredAt 等
// 派生字段不进 localStorage,避免 5MB 配额被撑爆。详见 utils/persistedSong.ts
const musicHistory = ref<MusicHistoryItem[]>([]);
const podcastHistory = ref<DjProgram[]>([]);
const playlistHistory = ref<PlaylistHistoryItem[]>([]);
const albumHistory = ref<AlbumHistoryItem[]>([]);
@@ -71,20 +141,36 @@ export const usePlayHistoryStore = defineStore(
// ==================== 音乐记录 ====================
// lastPlayTime 语义:每次播放都刷新为当前时间("最近一次播放"),不是"首次添加"。
// 与 playlistHistory/albumHistory 等其他历史保持一致;count 仍为累计播放次数
const addMusic = (music: SongResult): void => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
// 单步 ref 重赋值,避免 splice/pop + unshift 多次触发 watch 与持久化
let next: MusicHistoryItem[];
if (index !== -1) {
musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1;
musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]);
// 命中已有条目:累加计数 + 刷新时间戳,picUrl/al 等用新数据覆盖(封面可能换了短引用)
const existing = musicHistory.value[index];
const refreshed: MusicHistoryItem = {
...minifyHistoryEntry(music),
count: (existing.count || 0) + 1,
lastPlayTime: Date.now()
};
next = [
refreshed,
...musicHistory.value.slice(0, index),
...musicHistory.value.slice(index + 1)
];
} else {
musicHistory.value.unshift({ ...music, count: 1 });
}
if (musicHistory.value.length > MAX_HISTORY_SIZE) {
musicHistory.value.pop();
next = [
minifyHistoryEntry({ ...music, count: 1, lastPlayTime: Date.now() }),
...musicHistory.value
];
}
musicHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delMusic = (music: SongResult): void => {
// 删除入参允许 SongResult 或 MusicHistoryItem,仅按 id 匹配,类型放宽不影响逻辑
const delMusic = (music: { id: SongResult['id'] }): void => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
if (index !== -1) {
musicHistory.value.splice(index, 1);
@@ -95,14 +181,19 @@ export const usePlayHistoryStore = defineStore(
const addPodcast = (program: DjProgram): void => {
const index = podcastHistory.value.findIndex((item) => item.id === program.id);
// 单步 ref 重赋值,避免 splice/unshift/pop 多次触发 watch 与持久化(与 addMusic 一致)
let next: DjProgram[];
if (index !== -1) {
podcastHistory.value.unshift(podcastHistory.value.splice(index, 1)[0]);
next = [
podcastHistory.value[index],
...podcastHistory.value.slice(0, index),
...podcastHistory.value.slice(index + 1)
];
} else {
podcastHistory.value.unshift(program);
}
if (podcastHistory.value.length > MAX_HISTORY_SIZE) {
podcastHistory.value.pop();
next = [program, ...podcastHistory.value];
}
podcastHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPodcast = (program: DjProgram): void => {
@@ -117,16 +208,19 @@ export const usePlayHistoryStore = defineStore(
const addPlaylist = (playlist: PlaylistHistoryItem): void => {
const index = playlistHistory.value.findIndex((item) => item.id === playlist.id);
const now = Date.now();
let next: PlaylistHistoryItem[];
if (index !== -1) {
playlistHistory.value[index].count = (playlistHistory.value[index].count || 0) + 1;
playlistHistory.value[index].lastPlayTime = now;
playlistHistory.value.unshift(playlistHistory.value.splice(index, 1)[0]);
const existing = playlistHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...playlistHistory.value.slice(0, index),
...playlistHistory.value.slice(index + 1)
];
} else {
playlistHistory.value.unshift({ ...playlist, count: 1, lastPlayTime: now });
}
if (playlistHistory.value.length > MAX_HISTORY_SIZE) {
playlistHistory.value.pop();
next = [{ ...playlist, count: 1, lastPlayTime: now }, ...playlistHistory.value];
}
playlistHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPlaylist = (playlist: PlaylistHistoryItem): void => {
@@ -141,16 +235,18 @@ export const usePlayHistoryStore = defineStore(
const addAlbum = (album: AlbumHistoryItem): void => {
const index = albumHistory.value.findIndex((item) => item.id === album.id);
const now = Date.now();
let next: AlbumHistoryItem[];
if (index !== -1) {
albumHistory.value[index].count = (albumHistory.value[index].count || 0) + 1;
albumHistory.value[index].lastPlayTime = now;
albumHistory.value.unshift(albumHistory.value.splice(index, 1)[0]);
const existing = albumHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...albumHistory.value.slice(0, index),
...albumHistory.value.slice(index + 1)
];
} else {
albumHistory.value.unshift({ ...album, count: 1, lastPlayTime: now });
}
if (albumHistory.value.length > MAX_HISTORY_SIZE) {
albumHistory.value.pop();
next = [{ ...album, count: 1, lastPlayTime: now }, ...albumHistory.value];
}
albumHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delAlbum = (album: AlbumHistoryItem): void => {
@@ -165,17 +261,19 @@ export const usePlayHistoryStore = defineStore(
const addPodcastRadio = (radio: PodcastRadioHistoryItem): void => {
const index = podcastRadioHistory.value.findIndex((item) => item.id === radio.id);
const now = Date.now();
let next: PodcastRadioHistoryItem[];
if (index !== -1) {
const existing = podcastRadioHistory.value.splice(index, 1)[0];
existing.count = (existing.count || 0) + 1;
existing.lastPlayTime = now;
podcastRadioHistory.value.unshift(existing);
const existing = podcastRadioHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...podcastRadioHistory.value.slice(0, index),
...podcastRadioHistory.value.slice(index + 1)
];
} else {
podcastRadioHistory.value.unshift({ ...radio, count: 1, lastPlayTime: now });
}
if (podcastRadioHistory.value.length > MAX_HISTORY_SIZE) {
podcastRadioHistory.value.pop();
next = [{ ...radio, count: 1, lastPlayTime: now }, ...podcastRadioHistory.value];
}
podcastRadioHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPodcastRadio = (radio: PodcastRadioHistoryItem): void => {
@@ -213,72 +311,25 @@ export const usePlayHistoryStore = defineStore(
clearPlaylistHistory();
clearAlbumHistory();
clearPodcastRadioHistory();
};
// ==================== 数据迁移 ====================
/**
* 从旧的 localStorage 数据迁移到 Pinia store
* 只在首次启动时执行一次
*/
const migrateFromLocalStorage = (): void => {
const migrated = localStorage.getItem('playHistory-migrated');
if (migrated) return;
// 同步落盘空状态:persistedstate 的 watch 异步触发,clearAll 同步代码返回时
// watch 还没把空状态送进 storage;再叠 2s 防抖窗口 → 立刻 kill -9 会让 PERSIST_KEY
// 留在旧历史。手动 setItem 让"clearAll 返回 = 落盘完成"成立。
// 前面 flushDebouncedStorage 是顺手清掉别的 store 排队的写入与防抖定时器,
// 避免后续 watch 异步把同一份空状态再写一次(无害但多余 I/O)
flushDebouncedStorage();
try {
// 迁移音乐记录
const oldMusic = localStorage.getItem('musicHistory');
if (oldMusic) {
const parsed = JSON.parse(oldMusic);
if (Array.isArray(parsed) && parsed.length > 0 && musicHistory.value.length === 0) {
musicHistory.value = parsed;
}
}
// 迁移播客记录
const oldPodcast = localStorage.getItem('podcastHistory');
if (oldPodcast) {
const parsed = JSON.parse(oldPodcast);
if (Array.isArray(parsed) && parsed.length > 0 && podcastHistory.value.length === 0) {
podcastHistory.value = parsed;
}
}
// 迁移歌单记录
const oldPlaylist = localStorage.getItem('playlistHistory');
if (oldPlaylist) {
const parsed = JSON.parse(oldPlaylist);
if (Array.isArray(parsed) && parsed.length > 0 && playlistHistory.value.length === 0) {
playlistHistory.value = parsed;
}
}
// 迁移专辑记录
const oldAlbum = localStorage.getItem('albumHistory');
if (oldAlbum) {
const parsed = JSON.parse(oldAlbum);
if (Array.isArray(parsed) && parsed.length > 0 && albumHistory.value.length === 0) {
albumHistory.value = parsed;
}
}
// 迁移播客电台记录
const oldRadio = localStorage.getItem('podcastRadioHistory');
if (oldRadio) {
const parsed = JSON.parse(oldRadio);
if (
Array.isArray(parsed) &&
parsed.length > 0 &&
podcastRadioHistory.value.length === 0
) {
podcastRadioHistory.value = parsed;
}
}
localStorage.setItem('playHistory-migrated', '1');
console.log('[PlayHistory] 数据迁移完成');
localStorage.setItem(
PERSIST_KEY,
serializePlayHistoryState({
musicHistory: [],
podcastHistory: [],
playlistHistory: [],
albumHistory: [],
podcastRadioHistory: []
})
);
} catch (error) {
console.error('[PlayHistory] 数据迁移失败:', error);
console.error('[PlayHistory] 清空落盘失败:', error);
}
};
@@ -316,21 +367,27 @@ export const usePlayHistoryStore = defineStore(
clearPodcastRadioHistory,
// 通用
clearAll,
migrateFromLocalStorage
clearAll
};
},
{
persist: {
key: 'play-history-store',
storage: localStorage,
key: PERSIST_KEY,
// 共用防抖 storageaddMusic 在播放切换时会触发 mutation,避免每次都 stringify
// 整条 musicHistory(最多 500 条)走一遍 minifyHistoryList
storage: debouncedLocalStorage,
pick: [
'musicHistory',
'podcastHistory',
'playlistHistory',
'albumHistory',
'podcastRadioHistory'
]
],
// 与 clearAll 的同步落盘共用同一个序列化函数,避免格式漂移
serializer: {
serialize: serializePlayHistoryState,
deserialize: JSON.parse
}
}
}
);
+3 -4
View File
@@ -13,7 +13,7 @@ import { computed } from 'vue';
import { useFavoriteStore } from './favorite';
import { useIntelligenceModeStore } from './intelligenceMode';
import { usePlayerCoreStore } from './playerCore';
import { usePlayHistoryStore } from './playHistory';
import { cleanupLegacyPlayHistoryStorage } from './playHistory';
import { usePlaylistStore } from './playlist';
import { type SleepTimerInfo, SleepTimerType, useSleepTimerStore } from './sleepTimer';
@@ -72,9 +72,8 @@ export const usePlayerStore = defineStore('player', () => {
* 初始化播放状态(从 localStorage 恢复)
*/
const initializePlayState = async () => {
// 从旧的 localStorage 迁移播放记录到 Pinia store
const playHistoryStore = usePlayHistoryStore();
playHistoryStore.migrateFromLocalStorage();
// 一次性清理 v1 时代的旧 localStorage key,不做数据迁移(详见 playHistory.ts 注释)
cleanupLegacyPlayHistoryStorage();
const { initializePlayState: initPlayState } = await import('@/services/playbackController');
await initPlayState();
+24 -2
View File
@@ -4,6 +4,8 @@ import { computed, ref } from 'vue';
import { audioService } from '@/services/audioService';
import type { AudioOutputDevice } from '@/types/audio';
import type { SongResult } from '@/types/music';
import { debouncedLocalStorage } from '@/utils/debouncedStorage';
import { minifySong } from '@/utils/persistedSong';
/**
* 核心播放控制 Store
@@ -220,7 +222,12 @@ export const usePlayerCoreStore = defineStore(
{
persist: {
key: 'player-core-store',
storage: localStorage,
// 使用 debouncedLocalStoragevolume 拖动 / 静音切换会高频触发 mutation,
// 直接写 localStorage 会导致每次都 stringify + minify 整个 state,浪费 I/O。
// 防抖 2s 写一次足够,beforeunload 钩子兜底刷盘。
// Trade-off:极端非正常退出(kill -9 / 断电 / 主进程崩溃)下 beforeunload 不触发,
// 最近 2s 的 volume / isPlay / playMusic 变更会丢——这些状态丢一次无大碍,可接受
storage: debouncedLocalStorage,
pick: [
'playMusic',
'playMusicUrl',
@@ -229,7 +236,22 @@ export const usePlayerCoreStore = defineStore(
'isMuted',
'isPlay',
'audioOutputDeviceId'
]
],
// playMusic 持久化前过 minifySong:剥离 base64 封面、lyric、song 等大字段。
// 单首歌的 lyric 持久化后没有用(重启后会重新加载),但 picUrl 若是 base64 会
// 拖累整个 player-core-store 写入失败(5MB 配额)。playMusicUrl 在 store 层级单独
// 持久化,不受 playMusic 内部精简影响——本地音乐 local:// URL 仍保持可恢复。
// id 守卫:空 playMusic 走 minifySong 会得到 {picUrl:'', ar:[]}stripDataUrl
// 把 undefined 转成空串、ar 缺省返回空数组),下次启动 playbackController 的
// Object.keys().length === 0 判空就会失效,误恢复一首无 id 的空歌
serializer: {
serialize: (state: any) =>
JSON.stringify({
...state,
playMusic: state.playMusic?.id ? minifySong(state.playMusic as SongResult) : {}
}),
deserialize: JSON.parse
}
}
}
);
+2 -48
View File
@@ -1,5 +1,4 @@
import { useThrottleFn } from '@vueuse/core';
import { debounce } from 'lodash';
import { createDiscreteApi } from 'naive-ui';
import { defineStore, storeToRefs } from 'pinia';
import { computed, ref, shallowRef, triggerRef } from 'vue';
@@ -9,6 +8,8 @@ import { useSongDetail } from '@/hooks/usePlayerHooks';
import { preloadService } from '@/services/preloadService';
import type { SongResult } from '@/types/music';
import { getImgUrl } from '@/utils';
import { debouncedLocalStorage } from '@/utils/debouncedStorage';
import { minifySongList } from '@/utils/persistedSong';
import { performShuffle, preloadCoverImage } from '@/utils/playerUtils';
import { useIntelligenceModeStore } from './intelligenceMode';
@@ -22,53 +23,6 @@ const getMessage = () => {
return _message;
};
/**
* 精简 SongResult 对象,只保留持久化必要字段
* 排除大体积字段:lyric, song, playMusicUrl, backgroundColor, primaryColor
*/
const minifySong = (s: SongResult) => ({
id: s.id,
name: s.name,
picUrl: s.picUrl,
ar: s.ar?.map((a) => ({ id: a.id, name: a.name })),
al: s.al,
source: s.source,
dt: s.dt
});
const minifySongList = (list: SongResult[] | undefined) => list?.map(minifySong) ?? [];
/**
* 防抖 localStorage 包装,降低写入频率
* 通过 pendingWrites 跟踪未写入数据,beforeunload 时刷新
*/
const pendingWrites = new Map<string, string>();
const flushPendingWrites = () => {
pendingWrites.forEach((value, key) => {
localStorage.setItem(key, value);
});
pendingWrites.clear();
};
const debouncedSetItem = debounce((key: string, value: string) => {
localStorage.setItem(key, value);
pendingWrites.delete(key);
}, 2000);
const debouncedLocalStorage = {
getItem: (key: string) => localStorage.getItem(key),
setItem: (key: string, value: string) => {
pendingWrites.set(key, value);
debouncedSetItem(key, value);
}
};
// 正常关闭时刷新未写入的数据
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushPendingWrites);
}
/**
* 播放列表管理 Store
* 负责:播放列表、索引、播放模式、预加载、上/下一首
+2 -2
View File
@@ -24,8 +24,8 @@ export type LocalMusicMeta = {
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** 封面图片缓存文件绝对路径(userData/AudioCovers/<hash>.<ext>,无封面时为 null */
coverPath: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
+68
View File
@@ -0,0 +1,68 @@
// 防抖 localStorage 包装:高频状态变更(volume 拖动、播放/暂停切换)
// 在 2s 内只落盘一次,正常关闭时通过 beforeunload 刷新未写入数据。
// pinia-plugin-persistedstate 默认每次 mutation 都同步写入;换成这个包装即可
// 降低 I/O 与 JSON.stringify/minify 的总开销。
//
// 多 store 共用同一实例:debounce 的回调不带 key 参数,触发时 flush 整个
// pendingWrites map。这样多个 key 在防抖窗口内交替写入也不会互相吞参数
// lodash debounce 只用最后一次的参数触发)。
import { debounce } from 'lodash';
const pendingWrites = new Map<string, string>();
const safeSetItem = (key: string, value: string) => {
try {
localStorage.setItem(key, value);
} catch (error) {
// 配额超限是预期失败:上层 store 已通过 minifySong 兜底,这里仅记录方便定位
console.error(`[debouncedStorage] localStorage 写入失败 key=${key}(可能超出配额):`, error);
}
};
/** 把 pendingWrites 里所有未落盘的 key 一次性写入并清空 */
const flushPendingWrites = () => {
pendingWrites.forEach((value, key) => {
safeSetItem(key, value);
});
pendingWrites.clear();
};
const debouncedFlush = debounce(flushPendingWrites, 2000);
/**
* 与 localStorage 接口兼容的防抖写入实例
* 多个 Pinia store 共用同一个,避免重复创建定时器/监听器
*
* 注意:极端非正常退出(kill -9 / 系统断电 / 主进程崩溃)下 beforeunload
* 不一定触发,最近 2s 的写入可能丢失。对 volume / isPlay / playMusic 等
* 「丢一次也无大碍」的状态可以接受;如果新增了不能容忍丢写的关键状态,
* 应直接用 localStorage 而非 debouncedLocalStorage。
*/
export const debouncedLocalStorage = {
getItem: (key: string) => localStorage.getItem(key),
setItem: (key: string, value: string) => {
pendingWrites.set(key, value);
debouncedFlush();
},
removeItem: (key: string) => {
// 同步清掉 pendingWrites,防止已排队的 flush 把旧值又写回去
pendingWrites.delete(key);
localStorage.removeItem(key);
}
};
/**
* 立即落盘所有 pending 写入并取消排队的 debounce
* 用于一次性的关键操作(如数据迁移完成后写 flag):先 flush 再写 flag
* 避免「flag 已写入、store 还在防抖窗口里」的不一致
*/
export const flushDebouncedStorage = () => {
debouncedFlush.cancel();
flushPendingWrites();
};
// 模块级注册一次:beforeunload 时立即 flush 所有未写入数据,并取消排队的 debounce
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushDebouncedStorage);
}
+13 -15
View File
@@ -6,6 +6,10 @@ import { SUPPORTED_AUDIO_FORMATS } from '@/types/localMusic';
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
import { filePathToLocalUrl } from '../../shared/localUrl';
export { filePathToLocalUrl };
/**
* 判断文件路径是否为支持的音频格式
* 通过提取文件扩展名(不区分大小写)与支持格式列表比对
@@ -47,7 +51,7 @@ export function buildFallbackMeta(filePath: string): LocalMusicMeta {
artist: '未知艺术家',
album: '未知专辑',
duration: 0,
cover: null,
coverPath: null,
lyrics: null,
fileSize: 0,
modifiedTime: 0
@@ -111,10 +115,15 @@ export function toSongResult(entry: LocalMusicEntry): SongResult {
// 解析内嵌歌词为 ILyric 对象
const lyric = parseLrcToILyric(entry.lyrics);
// 封面统一走落盘文件 + local:// 协议;缺 coverPath 时给空串,
// SongItem 模板用 v-if="item.picUrl" 自动跳过渲染。
// 用户重新扫描会让主进程落盘新封面(参见 scanFolders 的自愈条件)
const coverUrl = entry.coverPath ? filePathToLocalUrl(entry.coverPath) : '';
return {
id: entry.id,
name: entry.title,
picUrl: entry.cover || '/images/default_cover.png',
picUrl: coverUrl,
ar: [
{
name: entry.artist,
@@ -140,7 +149,7 @@ export function toSongResult(entry: LocalMusicEntry): SongResult {
blurPicUrl: '',
companyId: 0,
pic: 0,
picUrl: entry.cover || '',
picUrl: coverUrl,
publishTime: 0,
description: '',
tags: '',
@@ -176,7 +185,7 @@ export function toSongResult(entry: LocalMusicEntry): SongResult {
artists: [{ name: entry.artist }],
album: { name: entry.album }
},
playMusicUrl: `local:///${entry.filePath}`,
playMusicUrl: filePathToLocalUrl(entry.filePath),
duration: entry.duration,
dt: entry.duration,
source: 'netease' as const,
@@ -189,17 +198,6 @@ export function toSongResult(entry: LocalMusicEntry): SongResult {
};
}
/**
* 将封面图片 Buffer 转换为 base64 Data URL
* @param buffer 图片二进制数据
* @param mime MIME 类型(如 image/jpeg、image/png
* @returns base64 Data URL 字符串
*/
export function coverToDataUrl(buffer: Buffer, mime: string): string {
const base64 = buffer.toString('base64');
return `data:${mime};base64,${base64}`;
}
/**
* 按关键词搜索过滤本地音乐列表
* 不区分大小写,匹配歌曲标题或艺术家名称
+188
View File
@@ -0,0 +1,188 @@
// 持久化歌曲对象的精简工具
// 三处 store 共用:playlist、playerCore、playHistory
//
// 为什么需要:localStorage 仅 5MB 配额。SongResult 直接持久化时,picUrl 可能是
// 几百 KB 的 base64 Data URL(旧版本本地音乐扫描会注入),lyric/song/expiredAt 等
// 字段也是只在运行时有意义的派生数据。一旦撑爆配额,整个 store 写入失败 → 历史记录、
// 当前播放、播放列表全部丢失。
//
// 现在源头(src/main/modules/localMusicScanner.ts + filePathToLocalUrl)已经把
// 本地音乐封面落盘,picUrl 是 local:// 短引用;这个工具兜底两件事:
// 1. 老用户从 base64 版本升级上来时,剥离已写入 localStorage 的脏数据
// 2. 远程音乐的 lyric/expiredAt 等大字段从持久化路径切走(重启后 API 重拉即可)
//
// 仅 local:// 的 playMusicUrl 会保留——本地音乐 URL 永不过期,恢复后免重新解析。
// 其他 URL(云端、缓存代理)会过期,丢掉让恢复时重新拉。
import type { Artist, SongResult } from '@/types/music';
import type { DjProgram } from '@/types/podcast';
/**
* 播客 program 持久化保留的最小字段集合
*
* SongResult.program 在运行时是 any(来源接口透传),实际形态对齐 DjProgram。
* 这里只保留 addPodcast/podcast 列表 UI 真正消费的字段,避免接口塞进来的冗余
* 字段(评论列表、推荐位等)跟着持久化。仍要剥 coverUrl 的 base64 兜底。
*/
export type MinifiedDjProgram = Pick<
DjProgram,
| 'id'
| 'name'
| 'mainSong'
| 'radio'
| 'coverUrl'
| 'description'
| 'createTime'
| 'listenerCount'
| 'commentCount'
| 'liked'
| 'likedCount'
>;
/**
* 持久化保留的最小字段集合
*
* ar/al 类型用 SongResult['ar' | 'al'] 而非自定义 Pick
* Artist/Album 类型有几十个必填子字段,自定义 Pick 后无法回填成 Artist[]/Album,
* 调用 setPlay/SongItem 等期望 SongResult 的接口会全部报错。这里用类型断言把
* "只填了 id/name/picUrl 的对象"伪装成 Artist/Album——运行时下游用 optional chain
* 访问,缺字段不会炸;类型层省下一大批 cast。
*
* isPodcast/program 必须保留:playbackController.playTrack 用 music.isPodcast
* 决定写普通歌历史还是播客历史;缺失会让重启恢复后的播客被误记进 musicHistory。
*/
export type MinifiedSong = {
id: SongResult['id'];
name: SongResult['name'];
picUrl: SongResult['picUrl'];
ar: SongResult['ar'];
// SongResult.al 是非 optional,但 minifySong 在 s.al 缺失时返回 undefined。
// 用 NonNullable + ? 显式表达「可选且缺失即不存在」,下游 optional chain 访问更安全
al?: NonNullable<SongResult['al']>;
source?: SongResult['source'];
dt?: SongResult['dt'];
playMusicUrl?: SongResult['playMusicUrl'];
isPodcast?: SongResult['isPodcast'];
program?: MinifiedDjProgram;
};
/** 历史记录条目:精简歌曲 + 计数/时间戳 */
export type MusicHistoryItem = MinifiedSong & {
count: number;
lastPlayTime?: number;
};
/**
* 剥离 base64 Data URL,其余 URL 原样返回
* 旧版本注入的 data: 协议封面 → 空串(SongItem v-if 跳过渲染,展示默认封面)
*/
const stripDataUrl = (url: string | undefined): string =>
!url || url.startsWith('data:') ? '' : url;
/**
* 把 SongResult 精简到仅持久化必要字段
* - 剥离 base64 picUrl
* - 仅保留 local:// 协议的 playMusicUrl(本地音乐 URL 永不过期)
* - 不持久化 lyric/song/backgroundColor/primaryColor/expiredAt/createdAt 等派生数据
*
* JSON.stringify 自动丢 undefined,不需要条件 spread
*/
export const minifySong = (s: SongResult): MinifiedSong => {
// 兼容老数据:早期 SongResult 可能只填了 artists 没填 ar(接口字段历史回退路径),
// 取一次合并后再精简,避免 minify 后丢失歌手名导致 heatmap/SongItem 显示 'Unknown Artist'
// 用 length 守卫而不是 ???? 只在 null/undefined 回退,ar:[] 是 truthy 会原样保留空数组,
// 旧数据里同时存在 ar:[] 和 artists:[填值] 时会丢歌手名
const artistList = s.ar?.length ? s.ar : s.artists;
// program 透传可能塞接口冗余字段(评论列表、推荐位等),这里挑明保留 DjProgram 已知字段
// id 守卫:缺 id 视作无效 program,避免序列化出空壳
const minifyProgram = (p: any): MinifiedDjProgram | undefined => {
if (!p?.id) return undefined;
return {
id: p.id,
name: p.name,
mainSong: p.mainSong,
radio: p.radio,
coverUrl: stripDataUrl(p.coverUrl),
description: p.description,
createTime: p.createTime,
listenerCount: p.listenerCount,
commentCount: p.commentCount,
liked: p.liked,
likedCount: p.likedCount
};
};
return {
id: s.id,
name: s.name,
picUrl: stripDataUrl(s.picUrl),
// 类型断言:只回填 id/name 的 Artist 不满足全字段约束,但下游消费 ar 全是 optional chain
ar: (artistList?.map((a) => ({ id: a.id, name: a.name })) ?? []) as Artist[],
// 类型断言同 ar:Album 类型有几十个必填字段,这里只回填三个;下游用 optional chain 访问安全
// id 守卫:truthy 但字段全空的对象(如 `{}`)会被过滤为 undefined,避免序列化出
// `{name: undefined, picUrl: ''}` 这种无意义残骸(id undefined 被 JSON 丢弃,反而留下空串占位)
al: (s.al?.id
? {
id: s.al.id,
name: s.al.name,
picUrl: stripDataUrl(s.al.picUrl)
}
: undefined) as MinifiedSong['al'],
source: s.source,
dt: s.dt,
playMusicUrl: s.playMusicUrl?.startsWith('local://') ? s.playMusicUrl : undefined,
// 必须保留 isPodcast/programplaybackController.playTrack 用 music.isPodcast 决定写
// 普通歌历史还是播客历史;缺失会让重启恢复后的播客被误记进 musicHistory
isPodcast: s.isPodcast,
program: minifyProgram(s.program)
};
};
export const minifySongList = (list: SongResult[] | undefined): MinifiedSong[] =>
list?.map(minifySong) ?? [];
/** 历史记录条目精简:在 minifySong 基础上附带 count/lastPlayTime */
export const minifyHistoryEntry = (
s: SongResult & { count?: number; lastPlayTime?: number }
): MusicHistoryItem => ({
...minifySong(s),
// 能进历史 = 至少播过一次,缺省给 1 而非 0;history 列表直接 {{ count }} 显示,0 会渲染成「0 次播放」
count: s.count ?? 1,
lastPlayTime: s.lastPlayTime
});
export const minifyHistoryList = (
list: (SongResult & { count?: number; lastPlayTime?: number })[] | undefined
): MusicHistoryItem[] => list?.map(minifyHistoryEntry) ?? [];
/**
* 通用防御层:剥离任意对象上常见图片字段中的 base64 Data URL
*
* 给 podcast/playlist/album/podcastRadio 等历史记录做兜底:
* 即使源头注入了 base64 封面(可能来自爬虫、第三方接口、旧版本数据),
* 持久化时也会被洗成空串,避免单张几百 KB 的 Data URL 撑爆 5MB 配额。
*
* 覆盖三个常见字段名:picUrl(专辑/电台/单曲)、coverImgUrl(歌单)、coverUrl(电台节目)。
* 用 readonly tuple 而不是字符串数组:让 TS 把 key 推断成字面量类型集合,未来加字段需显式扩展。
*
* 已知盲区:仅扫顶层字段,嵌套结构(DjProgram.radio.picUrl、Playlist.creator.avatarUrl 等)
* 不在覆盖范围内。当前线上数据未观察到嵌套字段被注入 base64,所以暂不递归——避免无脑深度
* 遍历影响每次序列化的开销。后续如果发现新的嵌套注入路径,再针对该字段做点对点处理
* (参考 minifySong 对 al.picUrl 的专门洗法),不要把 stripBase64Covers 改成递归。
*/
const PIC_KEYS = ['picUrl', 'coverImgUrl', 'coverUrl'] as const;
export const stripBase64Covers = <T extends Record<string, any>>(item: T): T => {
// 浅拷贝 + 仅扫顶层:嵌套字段是已知盲区(见上方 jsdoc)
// minifySong 已专门处理嵌套 al.picUrl,此处不重复
const result: Record<string, any> = { ...item };
for (const key of PIC_KEYS) {
const value = result[key];
if (typeof value === 'string' && value.startsWith('data:')) {
result[key] = '';
}
}
return result as T;
};
export const stripBase64CoversList = <T extends Record<string, any>>(list: T[] | undefined): T[] =>
list?.map(stripBase64Covers) ?? [];
+2 -1
View File
@@ -557,6 +557,7 @@ import type { SongResult } from '@/types/music';
import { getImgUrl } from '@/utils';
import type { DownloadTask } from '../../../shared/download';
import { filePathToLocalUrl } from '../../../shared/localUrl';
const { t } = useI18n();
const playerStore = usePlayerStore();
@@ -656,7 +657,7 @@ const shortenPath = (path: string) => {
const getLocalFilePath = (path: string) => {
if (!path) return '';
return `local:///${encodeURIComponent(path)}`;
return filePathToLocalUrl(path);
};
const openDirectory = (path: string) => {
+9 -7
View File
@@ -152,6 +152,7 @@ import { usePlayerStore } from '@/store/modules/player';
import { usePlayHistoryStore } from '@/store/modules/playHistory';
import type { SongResult } from '@/types/music';
import { setAnimationClass } from '@/utils';
import type { MusicHistoryItem } from '@/utils/persistedSong';
const { t } = useI18n();
const playHistoryStore = usePlayHistoryStore();
@@ -220,7 +221,7 @@ const processHistoryData = () => {
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
// 遍历音乐历史记录
playHistoryStore.musicHistory.forEach((music: SongResult & { count?: number }) => {
playHistoryStore.musicHistory.forEach((music: MusicHistoryItem) => {
// 假设每次播放都记录在当前时间,我们根据 count 分散到最近的日期
const playCount = music.count || 1;
const now = Date.now();
@@ -251,7 +252,7 @@ const processHistoryData = () => {
dailyMap[dateKey].songs.set(songId, {
id: music.id,
name: music.name || 'Unknown',
artist: music.ar?.[0]?.name || music.artists?.[0]?.name || 'Unknown Artist',
artist: music.ar?.[0]?.name || 'Unknown Artist',
playCount: 1
});
}
@@ -307,11 +308,11 @@ const mostPlayedSong = computed<{
{ id: string | number; name: string; artist: string; playCount: number }
>();
playHistoryStore.musicHistory.forEach((music: SongResult & { count?: number }) => {
playHistoryStore.musicHistory.forEach((music: MusicHistoryItem) => {
const id = music.id;
const count = music.count || 1;
const name = music.name || 'Unknown';
const artist = music.ar?.[0]?.name || music.artists?.[0]?.name || 'Unknown Artist';
const artist = music.ar?.[0]?.name || 'Unknown Artist';
if (songPlayCounts.has(id)) {
songPlayCounts.get(id)!.playCount += count;
@@ -382,7 +383,7 @@ const latestNightSong = computed<{
return {
id: randomSong.id,
name: randomSong.name || 'Unknown',
artist: randomSong.ar?.[0]?.name || randomSong.artists?.[0]?.name || 'Unknown Artist',
artist: randomSong.ar?.[0]?.name || 'Unknown Artist',
time: `凌晨 ${randomHour.toString().padStart(2, '0')}:${randomMinute.toString().padStart(2, '0')}`
};
}
@@ -395,7 +396,7 @@ const latestNightSong = computed<{
return {
id: song.id,
name: song.name || 'Unknown',
artist: song.ar?.[0]?.name || song.artists?.[0]?.name || 'Unknown Artist',
artist: song.ar?.[0]?.name || 'Unknown Artist',
time: `凌晨 ${randomHour.toString().padStart(2, '0')}:${randomMinute.toString().padStart(2, '0')}`
};
}
@@ -407,7 +408,8 @@ const latestNightSong = computed<{
const handlePlaySong = async (songId: string | number) => {
const song = playHistoryStore.musicHistory.find((music) => music.id === songId);
if (song) {
await playerStore.setPlay(song);
// MusicHistoryItem 是 SongResult 的精简子集,setPlay 内部用 optional chain 访问字段
await playerStore.setPlay(song as SongResult);
playerStore.setPlayMusic(true);
}
};
+5 -2
View File
@@ -124,6 +124,7 @@ import { useI18n } from 'vue-i18n';
import localData from '@/../main/set.json';
import ClearCacheSettings from '@/components/settings/ClearCacheSettings.vue';
import { usePlayHistoryStore } from '@/store/modules/playHistory';
import { useUserStore } from '@/store/modules/user';
import { isElectron } from '@/utils';
import { openDirectory, selectDirectory } from '@/utils/fileOperation';
@@ -435,7 +436,10 @@ const clearCache = async (selectedCacheTypes: string[]) => {
const clearTasks = selectedCacheTypes.map(async (type) => {
switch (type) {
case 'history':
localStorage.removeItem('musicHistory');
// 与旧版本行为对齐:只清音乐历史,不动 podcast/playlist/album/podcastRadio。
// 数据已迁到 pinia storekey=play-history-store),老 musicHistory key 由
// cleanupLegacyPlayHistoryStorage 在启动时清掉,这里不再需要 removeItem
usePlayHistoryStore().clearMusicHistory();
break;
case 'favorite':
localStorage.removeItem('favoriteList');
@@ -451,7 +455,6 @@ const clearCache = async (selectedCacheTypes: string[]) => {
localStorage.removeItem('theme');
localStorage.removeItem('lyricData');
localStorage.removeItem('lyricFontSize');
localStorage.removeItem('playMode');
break;
case 'downloads':
if (window.electron) {
+26
View File
@@ -0,0 +1,26 @@
// local:// 协议 URL 拼接工具
// 主进程与渲染进程共用,确保所有本地文件 URL 走同一套编码策略,
// 否则音频/封面/缓存/下载在 edge-case 上行为分裂。
/**
* 把绝对文件路径转成 local:// 协议 URL。
*
* 编码顺序:
* 1. \\ -> /Windows 路径规范化)
* 2. 按路径段 encodeURIComponent,再用 / 拼回去
*
* 不直接对整条路径 encodeURIComponent:它会把 / 编码成 %2F,注册为 standard 的
* local:// 协议在 Chromium 解析含 %2F 的 path 时存在边界差异。
* 按路径段编码可以保留目录分隔符,同时正确处理空格、中文、#、?、% 等特殊字符。
*
* 必须编码而不是裸拼:Image loader(含 crossOrigin='Anonymous' 时)对未编码空格
* 比 audio.src 严格——封面常落到 "Application Support" 这类含空格目录会加载失败。
*
* 主进程 fileManager 用 decodeURIComponent 还原;它是 encodeURIComponent 的逆,
* 能解码本函数产生的全部 %XX。
*/
export function filePathToLocalUrl(absPath: string): string {
const normalized = absPath.replace(/\\/g, '/');
const encoded = normalized.split('/').map(encodeURIComponent).join('/');
return `local:///${encoded}`;
}