mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-19 03:57:28 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
/** 文件大小(字节) */
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索过滤本地音乐列表
|
||||
* 不区分大小写,匹配歌曲标题或艺术家名称
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user