From 15258f28fdcc5b31cbd4a1481108ffffd2d11546 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 21:36:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(local-music):=20=E5=B0=81=E9=9D=A2=E8=90=BD?= =?UTF-8?q?=E7=9B=98=20+=20URL=20=E7=BC=96=E7=A0=81=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8C=81=E4=B9=85=E5=8C=96=E9=85=8D?= =?UTF-8?q?=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}`; +}