From 15258f28fdcc5b31cbd4a1481108ffffd2d11546 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 21:36:49 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(local-music):=20=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E8=90=BD=E7=9B=98=20+=20URL=20=E7=BC=96=E7=A0=81=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E9=85=8D=E9=A2=9D=E4=B8=8E=E7=BC=96=E7=A0=81=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/shared/localUrl.ts 共用 local:// 编码:按路径段 encodeURIComponent, 避免整体编码把 / 转成 %2F 引发 Chromium 解析边界差异,同时正确处理 空格/中文/# 等特殊字符(封面落到含空格目录时 Image loader 会加载失败) - 封面从内嵌 base64 Data URL 改为 userData/AudioCovers/. 落盘, MAX_COVER_BYTES 1MB→8MB;老条目(无 coverPath 字段)扫描时一次性自愈 - playlist minify 剥离 base64 picUrl 并仅持久化 local:// 永不过期的 playMusicUrl, 防止单张 base64 封面撑爆 localStorage 5MB 配额导致整个 playList 写入失败; localStorage 写入加 try/catch 兜底,避免配额超限时直接抛异常 --- src/main/modules/cache.ts | 4 +- src/main/modules/localMusicScanner.ts | 64 ++++++++++++++++---- src/renderer/store/modules/localMusic.ts | 7 ++- src/renderer/store/modules/playlist.ts | 33 ++++++++-- src/renderer/types/localMusic.ts | 4 +- src/renderer/utils/localMusicUtils.ts | 28 ++++----- src/renderer/views/download/DownloadPage.vue | 3 +- src/shared/localUrl.ts | 26 ++++++++ 8 files changed, 129 insertions(+), 40 deletions(-) create mode 100644 src/shared/localUrl.ts diff --git a/src/main/modules/cache.ts b/src/main/modules/cache.ts index 8e017e5..f45a07c 100644 --- a/src/main/modules/cache.ts +++ b/src/main/modules/cache.ts @@ -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 { diff --git a/src/main/modules/localMusicScanner.ts b/src/main/modules/localMusicScanner.ts index b44006e..330dfe9 100644 --- a/src/main/modules/localMusicScanner.ts +++ b/src/main/modules/localMusicScanner.ts @@ -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/. */ +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 { 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 { artist: '未知艺术家', album: '未知专辑', duration: 0, - cover: null, + coverPath: null, lyrics: null, fileSize, modifiedTime @@ -250,7 +288,7 @@ async function parseMetadata(filePath: string): Promise { 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 diff --git a/src/renderer/store/modules/localMusic.ts b/src/renderer/store/modules/localMusic.ts index 8ee535e..a4d5ba6 100644 --- a/src/renderer/store/modules/localMusic.ts +++ b/src/renderer/store/modules/localMusic.ts @@ -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); } } diff --git a/src/renderer/store/modules/playlist.ts b/src/renderer/store/modules/playlist.ts index 4b34422..4b0a5f0 100644 --- a/src/renderer/store/modules/playlist.ts +++ b/src/renderer/store/modules/playlist.ts @@ -24,16 +24,29 @@ const getMessage = () => { /** * 精简 SongResult 对象,只保留持久化必要字段 - * 排除大体积字段:lyric, song, playMusicUrl, backgroundColor, primaryColor + * 排除大体积字段:lyric, song, backgroundColor, primaryColor + * + * picUrl/al.picUrl 若为 base64 Data URL 一律剥离:localStorage 仅 5MB 配额, + * 单张 base64 封面动辄几百 KB,几首就能撑爆导致整个 playList 写入失败。 + * 剥离后恢复时展示默认封面图,picUrl 仍是 http(s):// 或 local:// 短引用时原样保留。 + * + * 仅 local:// 的 playMusicUrl(永不过期)会被持久化,让本地音乐恢复后免重新解析; + * expiredAt 不持久化——本地音乐每次走 toSongResult 会重新生成,远程歌曲恢复后重新拉详情即可。 */ +const stripDataUrl = (url: string | undefined): string => + !url || url.startsWith('data:') ? '' : url; + const minifySong = (s: SongResult) => ({ id: s.id, name: s.name, - picUrl: s.picUrl, + picUrl: stripDataUrl(s.picUrl), ar: s.ar?.map((a) => ({ id: a.id, name: a.name })), - al: s.al, + al: s.al && { id: s.al.id, name: s.al.name, picUrl: stripDataUrl(s.al.picUrl) }, source: s.source, - dt: s.dt + dt: s.dt, + // 仅 local:// 永不过期,保留给本地音乐恢复后免重新解析;其他 URL 会过期,丢掉让恢复时重新拉 + // JSON.stringify 自动丢 undefined,无需条件 spread + playMusicUrl: s.playMusicUrl?.startsWith('local://') ? s.playMusicUrl : undefined }); const minifySongList = (list: SongResult[] | undefined) => list?.map(minifySong) ?? []; @@ -44,15 +57,23 @@ const minifySongList = (list: SongResult[] | undefined) => list?.map(minifySong) */ const pendingWrites = new Map(); +const safeSetItem = (key: string, value: string) => { + try { + localStorage.setItem(key, value); + } catch (error) { + console.error('[playlist] localStorage 写入失败(可能超出配额):', error); + } +}; + const flushPendingWrites = () => { pendingWrites.forEach((value, key) => { - localStorage.setItem(key, value); + safeSetItem(key, value); }); pendingWrites.clear(); }; const debouncedSetItem = debounce((key: string, value: string) => { - localStorage.setItem(key, value); + safeSetItem(key, value); pendingWrites.delete(key); }, 2000); diff --git a/src/renderer/types/localMusic.ts b/src/renderer/types/localMusic.ts index 8fa02bd..1fefab0 100644 --- a/src/renderer/types/localMusic.ts +++ b/src/renderer/types/localMusic.ts @@ -24,8 +24,8 @@ export type LocalMusicMeta = { album: string; /** 时长(毫秒) */ duration: number; - /** base64 Data URL 格式的封面图片,无封面时为 null */ - cover: string | null; + /** 封面图片缓存文件绝对路径(userData/AudioCovers/.),无封面时为 null */ + coverPath: string | null; /** LRC 格式歌词文本,无歌词时为 null */ lyrics: string | null; /** 文件大小(字节) */ diff --git a/src/renderer/utils/localMusicUtils.ts b/src/renderer/utils/localMusicUtils.ts index 4855285..3a317a7 100644 --- a/src/renderer/utils/localMusicUtils.ts +++ b/src/renderer/utils/localMusicUtils.ts @@ -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}`; -} - /** * 按关键词搜索过滤本地音乐列表 * 不区分大小写,匹配歌曲标题或艺术家名称 diff --git a/src/renderer/views/download/DownloadPage.vue b/src/renderer/views/download/DownloadPage.vue index 7506ef1..141c89f 100644 --- a/src/renderer/views/download/DownloadPage.vue +++ b/src/renderer/views/download/DownloadPage.vue @@ -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) => { diff --git a/src/shared/localUrl.ts b/src/shared/localUrl.ts new file mode 100644 index 0000000..4d00982 --- /dev/null +++ b/src/shared/localUrl.ts @@ -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}`; +} From 537e280fdd36caef0a4ed0fa5353ce12a813e66d Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 23:07:58 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(persist):=20=E6=8A=BD=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E9=98=B2=E6=8A=96=20storage=20=E4=B8=8E=20minifySong?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=EF=BC=8Cplaylist/playerCore=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 9caaec3 在 playlist 内联的 minifySong + debouncedLocalStorage 抽到 utils 共用,playerCore 同步接入,为后续推广到 playHistory 做准备。 - 新增 utils/debouncedStorage.ts:高频状态变更(volume 拖动、isPlay 切换)2s 防抖落盘,beforeunload 钩子兜底刷盘;多 store 共用同一 pendingWrites map,flush 时一次写完所有 pending key - 新增 utils/persistedSong.ts:minifySong 剥离 base64 picUrl、仅保留 local:// 永不过期的 playMusicUrl;ar.length 守卫避免空数组吞掉 s.artists 兜底;al.id 守卫避免序列化出无 id 空壳 - playlist.ts 删除内联实现改用 import,行为不变 - playerCore.ts 切到防抖落盘并 minify playMusic;id 守卫避免空 playMusic 走 minify 后失去 Object.keys().length===0 判空能力, 下次启动会误恢复一首无 id 的空歌 Trade-off:极端非正常退出(kill -9 / 断电)下最近 2s 的 volume / isPlay / playMusic 变更会丢——这些状态丢一次无大碍,可接受 --- src/renderer/store/modules/playerCore.ts | 26 +++- src/renderer/store/modules/playlist.ts | 71 +-------- src/renderer/utils/debouncedStorage.ts | 68 ++++++++ src/renderer/utils/persistedSong.ts | 188 +++++++++++++++++++++++ 4 files changed, 282 insertions(+), 71 deletions(-) create mode 100644 src/renderer/utils/debouncedStorage.ts create mode 100644 src/renderer/utils/persistedSong.ts diff --git a/src/renderer/store/modules/playerCore.ts b/src/renderer/store/modules/playerCore.ts index 3c0ac05..21efd7f 100644 --- a/src/renderer/store/modules/playerCore.ts +++ b/src/renderer/store/modules/playerCore.ts @@ -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, + // 使用 debouncedLocalStorage:volume 拖动 / 静音切换会高频触发 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 + } } } ); diff --git a/src/renderer/store/modules/playlist.ts b/src/renderer/store/modules/playlist.ts index 4b0a5f0..d0e5144 100644 --- a/src/renderer/store/modules/playlist.ts +++ b/src/renderer/store/modules/playlist.ts @@ -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,74 +23,6 @@ const getMessage = () => { return _message; }; -/** - * 精简 SongResult 对象,只保留持久化必要字段 - * 排除大体积字段:lyric, song, backgroundColor, primaryColor - * - * picUrl/al.picUrl 若为 base64 Data URL 一律剥离:localStorage 仅 5MB 配额, - * 单张 base64 封面动辄几百 KB,几首就能撑爆导致整个 playList 写入失败。 - * 剥离后恢复时展示默认封面图,picUrl 仍是 http(s):// 或 local:// 短引用时原样保留。 - * - * 仅 local:// 的 playMusicUrl(永不过期)会被持久化,让本地音乐恢复后免重新解析; - * expiredAt 不持久化——本地音乐每次走 toSongResult 会重新生成,远程歌曲恢复后重新拉详情即可。 - */ -const stripDataUrl = (url: string | undefined): string => - !url || url.startsWith('data:') ? '' : url; - -const minifySong = (s: SongResult) => ({ - id: s.id, - name: s.name, - picUrl: stripDataUrl(s.picUrl), - ar: s.ar?.map((a) => ({ id: a.id, name: a.name })), - al: s.al && { id: s.al.id, name: s.al.name, picUrl: stripDataUrl(s.al.picUrl) }, - source: s.source, - dt: s.dt, - // 仅 local:// 永不过期,保留给本地音乐恢复后免重新解析;其他 URL 会过期,丢掉让恢复时重新拉 - // JSON.stringify 自动丢 undefined,无需条件 spread - playMusicUrl: s.playMusicUrl?.startsWith('local://') ? s.playMusicUrl : undefined -}); - -const minifySongList = (list: SongResult[] | undefined) => list?.map(minifySong) ?? []; - -/** - * 防抖 localStorage 包装,降低写入频率 - * 通过 pendingWrites 跟踪未写入数据,beforeunload 时刷新 - */ -const pendingWrites = new Map(); - -const safeSetItem = (key: string, value: string) => { - try { - localStorage.setItem(key, value); - } catch (error) { - console.error('[playlist] localStorage 写入失败(可能超出配额):', error); - } -}; - -const flushPendingWrites = () => { - pendingWrites.forEach((value, key) => { - safeSetItem(key, value); - }); - pendingWrites.clear(); -}; - -const debouncedSetItem = debounce((key: string, value: string) => { - safeSetItem(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 * 负责:播放列表、索引、播放模式、预加载、上/下一首 diff --git a/src/renderer/utils/debouncedStorage.ts b/src/renderer/utils/debouncedStorage.ts new file mode 100644 index 0000000..2b24139 --- /dev/null +++ b/src/renderer/utils/debouncedStorage.ts @@ -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(); + +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); +} diff --git a/src/renderer/utils/persistedSong.ts b/src/renderer/utils/persistedSong.ts new file mode 100644 index 0000000..de6f588 --- /dev/null +++ b/src/renderer/utils/persistedSong.ts @@ -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; + 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/program:playbackController.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 = >(item: T): T => { + // 浅拷贝 + 仅扫顶层:嵌套字段是已知盲区(见上方 jsdoc) + // minifySong 已专门处理嵌套 al.picUrl,此处不重复 + const result: Record = { ...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 = >(list: T[] | undefined): T[] => + list?.map(stripBase64Covers) ?? []; From 761884f23a5a84e8f84f50b91833e0dcad5237a8 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 23:08:22 +0800 Subject: [PATCH 3/5] =?UTF-8?q?refactor(playHistory):=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E9=87=8D=E5=86=99=EF=BC=8C=E7=BB=9F=E4=B8=80=E9=98=B2?= =?UTF-8?q?=E6=8A=96=E8=90=BD=E7=9B=98=E4=B8=8E=E5=BA=8F=E5=88=97=E5=8C=96?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 playHistory 接入 utils/debouncedStorage 与 utils/persistedSong, 配合 add* 方法重构与 clearAll 同步落盘,闭合 localStorage 配额防护。 - musicHistory 类型从 SongResult 收敛到 MusicHistoryItem(精简子集), 导出 MinifiedDjProgram、stripBase64Covers,给 podcast/playlist/album/ podcastRadio 历史也做顶层 picUrl/coverImgUrl/coverUrl 的 base64 兜底 - serializePlayHistoryState 提取为模块级函数,给 persistedstate.serializer 与 clearAll 同步落盘共用,避免格式漂移;isPodcast/program 字段必须 保留——playbackController.playTrack 用 isPodcast 决定写哪条历史 - 5 个 add* 全部重写成单步 ref 重赋值,避免 splice/pop/unshift 多次 触发 watch 与持久化;命中已有条目时累加 count + 刷新 lastPlayTime, picUrl/al 用新数据覆盖(封面可能换了短引用) - clearAll 增加 flushDebouncedStorage + 同步 setItem 空状态,防止 kill -9 落在 2s 防抖窗口里导致旧历史残留 - heatmap/index.vue 类型切到 MusicHistoryItem,移除 music.artists 兜底(minifySong 已合并 ar/artists,只剩 ar) --- src/renderer/store/modules/playHistory.ts | 265 +++++++++++++--------- src/renderer/views/heatmap/index.vue | 16 +- 2 files changed, 170 insertions(+), 111 deletions(-) diff --git a/src/renderer/store/modules/playHistory.ts b/src/renderer/store/modules/playHistory.ts index 397a56a..53f7756 100644 --- a/src/renderer/store/modules/playHistory.ts +++ b/src/renderer/store/modules/playHistory.ts @@ -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([]); + // musicHistory 用 MusicHistoryItem 而非完整 SongResult:lyric/song/expiredAt 等 + // 派生字段不进 localStorage,避免 5MB 配额被撑爆。详见 utils/persistedSong.ts + const musicHistory = ref([]); const podcastHistory = ref([]); const playlistHistory = ref([]); const albumHistory = ref([]); @@ -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, + // 共用防抖 storage:addMusic 在播放切换时会触发 mutation,避免每次都 stringify + // 整条 musicHistory(最多 500 条)走一遍 minifyHistoryList + storage: debouncedLocalStorage, pick: [ 'musicHistory', 'podcastHistory', 'playlistHistory', 'albumHistory', 'podcastRadioHistory' - ] + ], + // 与 clearAll 的同步落盘共用同一个序列化函数,避免格式漂移 + serializer: { + serialize: serializePlayHistoryState, + deserialize: JSON.parse + } } } ); diff --git a/src/renderer/views/heatmap/index.vue b/src/renderer/views/heatmap/index.vue index 3d9b130..ed477af 100644 --- a/src/renderer/views/heatmap/index.vue +++ b/src/renderer/views/heatmap/index.vue @@ -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); } }; From 405b144e66e044e4320662e41138f71fba014eb3 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 23:08:52 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor(playHistory):=20v1=20=E6=97=A7=20l?= =?UTF-8?q?ocalStorage=20key=20=E6=B8=85=E7=90=86=E4=B8=8E=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 老用户升级后清掉 v1 时代独立 key 释放配额,调用方切到 store API。 - player.ts initializePlayState 调用从 migrateFromLocalStorage 切到 cleanupLegacyPlayHistoryStorage:不再做数据迁移(历史是低关键性 衍生数据,老用户重新听几次即可),仅清掉旧 musicHistory/podcastHistory/ playlistHistory/albumHistory/podcastRadioHistory/playHistory-migrated/ playMode 这 7 个旧 key,避免和新 play-history-store key 双倍占用 - SystemTab.vue 清缓存的 case 'history' 改走 usePlayHistoryStore(). clearMusicHistory(),与 v1 时代行为对齐(只清音乐历史,不动 podcast/ playlist/album/podcastRadio);移除 case 'settings' 里的 removeItem('playMode')——已并入 player-core-store --- src/renderer/store/modules/player.ts | 7 +++---- src/renderer/views/set/tabs/SystemTab.vue | 7 +++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/renderer/store/modules/player.ts b/src/renderer/store/modules/player.ts index 33c8639..77b26ce 100644 --- a/src/renderer/store/modules/player.ts +++ b/src/renderer/store/modules/player.ts @@ -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(); diff --git a/src/renderer/views/set/tabs/SystemTab.vue b/src/renderer/views/set/tabs/SystemTab.vue index c56d1e9..4ce4cdc 100644 --- a/src/renderer/views/set/tabs/SystemTab.vue +++ b/src/renderer/views/set/tabs/SystemTab.vue @@ -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 store(key=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) { From a078e37e2c470ac7ac67d932d6021971c688ebe8 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 23:38:18 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(build):=20renderer=20=E5=8D=95=20chunk?= =?UTF-8?q?=20=E6=89=93=E5=8C=85=EF=BC=8C=E8=A7=84=E9=81=BF=20chunk=20?= =?UTF-8?q?=E9=97=B4=E5=BE=AA=E7=8E=AF=E4=BE=9D=E8=B5=96=20TDZ=20=E7=99=BD?= =?UTF-8?q?=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store/index.ts 用 `export *` 聚合所有 store + 抽出的共享工具(如 debouncedStorage)被多 store 引用,Vite 6 默认拆分会让 utils 合并到 entry chunk,再与 store chunk 形成 index ↔ store 循环;当循环 chunk 顶层同步访问对方导出的 const(如 `persist: { storage: debouncedLocalStorage }`), 生产构建会触发 TDZ 报错 `Cannot access 'debouncedLocalStorage' before initialization`,表现为打包后白屏(dev 不分包不暴露)。 Electron 桌面端无 CDN/首屏体积顾虑,单 chunk 是最简洁的兜底。 --- electron.vite.config.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 32424c3..464e318 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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