fix(local-music): 封面落盘 + URL 编码统一,修复持久化配额与编码边界

- 新增 src/shared/localUrl.ts 共用 local:// 编码:按路径段 encodeURIComponent,
  避免整体编码把 / 转成 %2F 引发 Chromium 解析边界差异,同时正确处理
  空格/中文/# 等特殊字符(封面落到含空格目录时 Image loader 会加载失败)
- 封面从内嵌 base64 Data URL 改为 userData/AudioCovers/<sha256>.<ext> 落盘,
  MAX_COVER_BYTES 1MB→8MB;老条目(无 coverPath 字段)扫描时一次性自愈
- playlist minify 剥离 base64 picUrl 并仅持久化 local:// 永不过期的 playMusicUrl,
  防止单张 base64 封面撑爆 localStorage 5MB 配额导致整个 playList 写入失败;
  localStorage 写入加 try/catch 兜底,避免配额超限时直接抛异常
This commit is contained in:
chengww
2026-05-17 21:36:49 +08:00
parent ee98eb0266
commit 15258f28fd
8 changed files with 129 additions and 40 deletions
+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);
}
}
+27 -6
View File
@@ -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<string, string>();
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);
+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;
/** 文件大小(字节) */
+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}`;
}
/**
* 按关键词搜索过滤本地音乐列表
* 不区分大小写,匹配歌曲标题或艺术家名称
+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) => {