mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-18 19:47:29 +08:00
15258f28fd
- 新增 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 兜底,避免配额超限时直接抛异常
368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
// 本地音乐扫描模块
|
|
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
|
|
|
|
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';
|
|
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 = 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;
|
|
}
|
|
|
|
/**
|
|
* 主进程返回的原始音乐元数据
|
|
* 与渲染进程 LocalMusicMeta 类型保持一致
|
|
*/
|
|
type LocalMusicMeta = {
|
|
/** 文件绝对路径 */
|
|
filePath: string;
|
|
/** 歌曲标题 */
|
|
title: string;
|
|
/** 艺术家名称 */
|
|
artist: string;
|
|
/** 专辑名称 */
|
|
album: string;
|
|
/** 时长(毫秒) */
|
|
duration: number;
|
|
/** 封面图片缓存文件绝对路径,无封面时为 null */
|
|
coverPath: string | null;
|
|
/** LRC 格式歌词文本,无歌词时为 null */
|
|
lyrics: string | null;
|
|
/** 文件大小(字节) */
|
|
fileSize: number;
|
|
/** 文件修改时间戳 */
|
|
modifiedTime: number;
|
|
};
|
|
|
|
type ScannedMusicFile = {
|
|
path: string;
|
|
modifiedTime: number;
|
|
};
|
|
|
|
/**
|
|
* 判断文件扩展名是否为支持的音频格式
|
|
* @param ext 文件扩展名(含点号,如 .mp3)
|
|
* @returns 是否为支持的格式
|
|
*/
|
|
function isSupportedFormat(ext: string): boolean {
|
|
return (SUPPORTED_AUDIO_FORMATS as readonly string[]).includes(ext.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* 从文件路径中提取歌曲标题(去除目录和扩展名)
|
|
* @param filePath 文件路径
|
|
* @returns 歌曲标题
|
|
*/
|
|
function extractTitleFromFilename(filePath: string): string {
|
|
const basename = path.basename(filePath);
|
|
const dotIndex = basename.lastIndexOf('.');
|
|
if (dotIndex > 0) {
|
|
return basename.slice(0, dotIndex);
|
|
}
|
|
return basename;
|
|
}
|
|
|
|
/**
|
|
* 将封面图片落盘到 userData/AudioCovers/,返回绝对路径
|
|
* 文件名按 sourceFilePath 的 sha256 + 推断扩展名拼成,幂等可覆盖
|
|
* @param picture music-metadata 解析出的封面图片对象
|
|
* @param sourceFilePath 音乐源文件绝对路径,用于生成稳定的封面文件名
|
|
* @returns 封面文件绝对路径,无封面或写入失败返回 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 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);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 从 music-metadata 解析结果中提取歌词文本
|
|
* @param lyrics music-metadata 解析出的歌词数组
|
|
* @returns 歌词文本,提取失败返回 null
|
|
*/
|
|
function extractLyrics(lyrics: mm.ILyricsTag[] | undefined): string | null {
|
|
if (!lyrics || lyrics.length === 0) {
|
|
return null;
|
|
}
|
|
try {
|
|
// 优先取第一条歌词的文本内容
|
|
const firstLyric = lyrics[0];
|
|
return firstLyric?.text ?? null;
|
|
} catch (error) {
|
|
console.error('歌词提取失败:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 递归扫描指定文件夹,返回所有支持格式的音乐文件路径
|
|
* @param folderPath 要扫描的文件夹路径
|
|
* @returns 音乐文件绝对路径列表
|
|
*/
|
|
async function scanMusicFiles(folderPath: string): Promise<string[]> {
|
|
const results: string[] = [];
|
|
|
|
// 检查文件夹是否存在
|
|
if (!fs.existsSync(folderPath)) {
|
|
throw new Error(`文件夹不存在: ${folderPath}`);
|
|
}
|
|
|
|
// 检查是否为目录
|
|
const stat = await fs.promises.stat(folderPath);
|
|
if (!stat.isDirectory()) {
|
|
throw new Error(`路径不是文件夹: ${folderPath}`);
|
|
}
|
|
|
|
/**
|
|
* 递归遍历目录
|
|
* @param dirPath 当前目录路径
|
|
*/
|
|
async function walkDirectory(dirPath: string): Promise<void> {
|
|
try {
|
|
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dirPath, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
// 递归扫描子目录
|
|
await walkDirectory(fullPath);
|
|
} else if (entry.isFile()) {
|
|
// 检查文件扩展名是否为支持的音频格式
|
|
const ext = path.extname(entry.name);
|
|
if (isSupportedFormat(ext)) {
|
|
results.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// 单个目录读取失败不中断整体扫描,记录错误后继续
|
|
console.error(`扫描目录失败: ${dirPath}`, error);
|
|
}
|
|
}
|
|
|
|
await walkDirectory(folderPath);
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 递归扫描指定文件夹,返回包含修改时间的音乐文件信息
|
|
* @param folderPath 要扫描的文件夹路径
|
|
* @returns 音乐文件信息列表
|
|
*/
|
|
async function scanMusicFilesWithStats(folderPath: string): Promise<ScannedMusicFile[]> {
|
|
const results: ScannedMusicFile[] = [];
|
|
|
|
if (!fs.existsSync(folderPath)) {
|
|
throw new Error(`文件夹不存在: ${folderPath}`);
|
|
}
|
|
|
|
const stat = await fs.promises.stat(folderPath);
|
|
if (!stat.isDirectory()) {
|
|
throw new Error(`路径不是文件夹: ${folderPath}`);
|
|
}
|
|
|
|
async function walkDirectory(dirPath: string): Promise<void> {
|
|
try {
|
|
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dirPath, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
await walkDirectory(fullPath);
|
|
} else if (entry.isFile()) {
|
|
const ext = path.extname(entry.name);
|
|
if (!isSupportedFormat(ext)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const fileStat = await fs.promises.stat(fullPath);
|
|
results.push({
|
|
path: fullPath,
|
|
modifiedTime: fileStat.mtimeMs
|
|
});
|
|
} catch (error) {
|
|
console.error(`读取文件信息失败: ${fullPath}`, error);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`扫描目录失败: ${dirPath}`, error);
|
|
}
|
|
}
|
|
|
|
await walkDirectory(folderPath);
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 解析单个音乐文件的元数据
|
|
* 解析失败时使用 fallback 默认值(文件名作标题),不抛出异常
|
|
* @param filePath 音乐文件绝对路径
|
|
* @returns 音乐元数据对象
|
|
*/
|
|
async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
|
|
// 获取文件信息(大小和修改时间)
|
|
let fileSize = 0;
|
|
let modifiedTime = 0;
|
|
try {
|
|
const stat = await fs.promises.stat(filePath);
|
|
fileSize = stat.size;
|
|
modifiedTime = stat.mtimeMs;
|
|
} catch (error) {
|
|
console.error(`获取文件信息失败: ${filePath}`, error);
|
|
}
|
|
|
|
// 构建 fallback 默认值
|
|
const fallback: LocalMusicMeta = {
|
|
filePath,
|
|
title: extractTitleFromFilename(filePath),
|
|
artist: '未知艺术家',
|
|
album: '未知专辑',
|
|
duration: 0,
|
|
coverPath: null,
|
|
lyrics: null,
|
|
fileSize,
|
|
modifiedTime
|
|
};
|
|
|
|
try {
|
|
const metadata = await mm.parseFile(filePath);
|
|
const { common, format } = metadata;
|
|
|
|
return {
|
|
filePath,
|
|
title: common.title || fallback.title,
|
|
artist: common.artist || fallback.artist,
|
|
album: common.album || fallback.album,
|
|
duration: format.duration ? Math.round(format.duration * 1000) : 0,
|
|
coverPath: await extractCoverToFile(common.picture?.[0], filePath),
|
|
lyrics: extractLyrics(common.lyrics),
|
|
fileSize,
|
|
modifiedTime
|
|
};
|
|
} catch (error) {
|
|
// 解析失败使用 fallback,不中断流程
|
|
console.error(`元数据解析失败,使用 fallback: ${filePath}`, error);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 批量解析音乐文件元数据
|
|
* 内部逐个调用 parseMetadata,单文件失败不影响其他文件
|
|
* @param filePaths 音乐文件路径列表
|
|
* @returns 元数据对象列表
|
|
*/
|
|
async function batchParseMetadata(filePaths: string[]): Promise<LocalMusicMeta[]> {
|
|
if (filePaths.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const results = new Array<LocalMusicMeta>(filePaths.length);
|
|
const workerCount = Math.min(METADATA_PARSE_CONCURRENCY, filePaths.length);
|
|
let index = 0;
|
|
|
|
const workers = Array.from({ length: workerCount }, async () => {
|
|
while (index < filePaths.length) {
|
|
const current = index;
|
|
index += 1;
|
|
results[current] = await parseMetadata(filePaths[current]);
|
|
}
|
|
});
|
|
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 初始化本地音乐扫描模块
|
|
* 注册 IPC handler,供渲染进程调用
|
|
*/
|
|
export function initializeLocalMusicScanner(): void {
|
|
// 扫描指定文件夹中的音乐文件
|
|
ipcMain.handle('scan-local-music', async (_, folderPath: string) => {
|
|
try {
|
|
const files = await scanMusicFiles(folderPath);
|
|
return { files, count: files.length };
|
|
} catch (error: any) {
|
|
console.error('扫描本地音乐失败:', error);
|
|
return { error: error.message || '扫描失败' };
|
|
}
|
|
});
|
|
|
|
// 扫描指定文件夹中的音乐文件(包含修改时间)
|
|
ipcMain.handle('scan-local-music-with-stats', async (_, folderPath: string) => {
|
|
try {
|
|
const files = await scanMusicFilesWithStats(folderPath);
|
|
return { files, count: files.length };
|
|
} catch (error: any) {
|
|
console.error('扫描本地音乐(含文件信息)失败:', error);
|
|
return { error: error.message || '扫描失败' };
|
|
}
|
|
});
|
|
|
|
// 批量解析音乐文件元数据
|
|
ipcMain.handle('parse-local-music-metadata', async (_, filePaths: string[]) => {
|
|
try {
|
|
const metadataList = await batchParseMetadata(filePaths);
|
|
return metadataList;
|
|
} catch (error: any) {
|
|
console.error('解析本地音乐元数据失败:', error);
|
|
return [];
|
|
}
|
|
});
|
|
}
|