feat: 添加本地音乐扫描播放功能

This commit is contained in:
alger
2026-02-06 17:49:14 +08:00
parent 292751643f
commit 0e47c127fe
23 changed files with 1363 additions and 51 deletions

View File

@@ -268,5 +268,6 @@ export default {
mv: 'MV',
home: 'Home',
search: 'Search',
album: 'Album'
album: 'Album',
localMusic: 'Local Music'
};

View File

@@ -0,0 +1,13 @@
export default {
title: 'Local Music',
scanFolder: 'Scan Folder',
removeFolder: 'Remove Folder',
scanning: 'Scanning...',
scanComplete: 'Scan Complete',
playAll: 'Play All',
search: 'Search local music',
emptyState: 'No local music found. Please select a folder to scan.',
fileNotFound: 'File not found or has been moved',
rescan: 'Rescan',
songCount: '{count} songs'
};

View File

@@ -268,5 +268,6 @@ export default {
mv: 'MV',
home: 'ホーム',
search: '検索',
album: 'アルバム'
album: 'アルバム',
localMusic: 'ローカル音楽'
};

View File

@@ -0,0 +1,13 @@
export default {
title: 'ローカル音楽',
scanFolder: 'フォルダをスキャン',
removeFolder: 'フォルダを削除',
scanning: 'スキャン中...',
scanComplete: 'スキャン完了',
playAll: 'すべて再生',
search: 'ローカル音楽を検索',
emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。',
fileNotFound: 'ファイルが見つからないか、移動されました',
rescan: '再スキャン',
songCount: '{count} 曲'
};

View File

@@ -267,5 +267,6 @@ export default {
mv: 'MV',
home: '홈',
search: '검색',
album: '앨범'
album: '앨범',
localMusic: '로컬 음악'
};

View File

@@ -0,0 +1,13 @@
export default {
title: '로컬 음악',
scanFolder: '폴더 스캔',
removeFolder: '폴더 제거',
scanning: '스캔 중...',
scanComplete: '스캔 완료',
playAll: '모두 재생',
search: '로컬 음악 검색',
emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.',
fileNotFound: '파일을 찾을 수 없거나 이동되었습니다',
rescan: '다시 스캔',
songCount: '{count}곡'
};

View File

@@ -261,5 +261,6 @@ export default {
mv: 'MV',
home: '首页',
search: '搜索',
album: '专辑'
album: '专辑',
localMusic: '本地音乐'
};

View File

@@ -0,0 +1,13 @@
export default {
title: '本地音乐',
scanFolder: '扫描文件夹',
removeFolder: '移除文件夹',
scanning: '正在扫描...',
scanComplete: '扫描完成',
playAll: '播放全部',
search: '搜索本地音乐',
emptyState: '暂无本地音乐,请先选择文件夹进行扫描',
fileNotFound: '文件不存在或已被移动',
rescan: '重新扫描',
songCount: '{count} 首歌曲'
};

View File

@@ -261,5 +261,6 @@ export default {
mv: 'MV',
home: '首頁',
search: '搜尋',
album: '專輯'
album: '專輯',
localMusic: '本地音樂'
};

View File

@@ -0,0 +1,13 @@
export default {
title: '本地音樂',
scanFolder: '掃描資料夾',
removeFolder: '移除資料夾',
scanning: '正在掃描...',
scanComplete: '掃描完成',
playAll: '播放全部',
search: '搜尋本地音樂',
emptyState: '暫無本地音樂,請先選擇資料夾進行掃描',
fileNotFound: '檔案不存在或已被移動',
rescan: '重新掃描',
songCount: '{count} 首歌曲'
};

View File

@@ -8,6 +8,7 @@ import { loadLyricWindow } from './lyric';
import { initializeConfig } from './modules/config';
import { initializeFileManager } from './modules/fileManager';
import { initializeFonts } from './modules/fonts';
import { initializeLocalMusicScanner } from './modules/localMusicScanner';
import { initializeLoginWindow } from './modules/loginWindow';
import { initLxMusicHttp } from './modules/lxMusicHttp';
import { initializeOtherApi } from './modules/otherApi';
@@ -48,6 +49,8 @@ function initialize(configStore: any) {
initializeFonts();
// 初始化登录窗口
initializeLoginWindow();
// 初始化本地音乐扫描模块
initializeLocalMusicScanner();
// 创建主窗口
mainWindow = createMainWindow(icon);

View File

@@ -644,52 +644,61 @@ async function downloadMusic(
if (songInfo?.picUrl || songInfo?.al?.picUrl) {
const picUrl = songInfo.picUrl || songInfo.al?.picUrl;
if (picUrl && picUrl !== '/images/default_cover.png') {
const coverResponse = await axios({
url: picUrl.replace('http://', 'https://'),
method: 'GET',
responseType: 'arraybuffer',
timeout: 10000
});
const originalCoverBuffer = Buffer.from(coverResponse.data);
const TWO_MB = 2 * 1024 * 1024;
// 检查图片大小是否超过2MB
if (originalCoverBuffer.length > TWO_MB) {
const originalSizeMB = (originalCoverBuffer.length / (1024 * 1024)).toFixed(2);
console.log(`封面图大于2MB (${originalSizeMB} MB),开始压缩...`);
try {
// 使用 Electron nativeImage 进行压缩
const image = nativeImage.createFromBuffer(originalCoverBuffer);
const size = image.getSize();
// 计算新尺寸保持宽高比最大1600px
const maxSize = 1600;
let newWidth = size.width;
let newHeight = size.height;
if (size.width > maxSize || size.height > maxSize) {
const ratio = Math.min(maxSize / size.width, maxSize / size.height);
newWidth = Math.round(size.width * ratio);
newHeight = Math.round(size.height * ratio);
}
// 调整大小并转换为 JPEG 格式(质量 80
const resizedImage = image.resize({
width: newWidth,
height: newHeight,
quality: 'good'
});
coverImageBuffer = resizedImage.toJPEG(80);
const compressedSizeMB = (coverImageBuffer.length / (1024 * 1024)).toFixed(2);
console.log(`封面图压缩完成,新大小: ${compressedSizeMB} MB`);
} catch (compressionError) {
console.error('封面图压缩失败,将使用原图:', compressionError);
coverImageBuffer = originalCoverBuffer; // 如果压缩失败,则回退使用原始图片
// 处理 base64 Data URL本地音乐扫描提取的封面
if (picUrl.startsWith('data:')) {
const base64Match = picUrl.match(/^data:[^;]+;base64,(.+)$/);
if (base64Match) {
coverImageBuffer = Buffer.from(base64Match[1], 'base64');
console.log('从 base64 Data URL 提取封面');
}
} else {
// 如果图片不大于2MB直接使用原图
coverImageBuffer = originalCoverBuffer;
const coverResponse = await axios({
url: picUrl.replace('http://', 'https://'),
method: 'GET',
responseType: 'arraybuffer',
timeout: 10000
});
const originalCoverBuffer = Buffer.from(coverResponse.data);
const TWO_MB = 2 * 1024 * 1024;
// 检查图片大小是否超过2MB
if (originalCoverBuffer.length > TWO_MB) {
const originalSizeMB = (originalCoverBuffer.length / (1024 * 1024)).toFixed(2);
console.log(`封面图大于2MB (${originalSizeMB} MB),开始压缩...`);
try {
// 使用 Electron nativeImage 进行压缩
const image = nativeImage.createFromBuffer(originalCoverBuffer);
const size = image.getSize();
// 计算新尺寸保持宽高比最大1600px
const maxSize = 1600;
let newWidth = size.width;
let newHeight = size.height;
if (size.width > maxSize || size.height > maxSize) {
const ratio = Math.min(maxSize / size.width, maxSize / size.height);
newWidth = Math.round(size.width * ratio);
newHeight = Math.round(size.height * ratio);
}
// 调整大小并转换为 JPEG 格式(质量 80
const resizedImage = image.resize({
width: newWidth,
height: newHeight,
quality: 'good'
});
coverImageBuffer = resizedImage.toJPEG(80);
const compressedSizeMB = (coverImageBuffer.length / (1024 * 1024)).toFixed(2);
console.log(`封面图压缩完成,新大小: ${compressedSizeMB} MB`);
} catch (compressionError) {
console.error('封面图压缩失败,将使用原图:', compressionError);
coverImageBuffer = originalCoverBuffer; // 如果压缩失败,则回退使用原始图片
}
} else {
// 如果图片不大于2MB直接使用原图
coverImageBuffer = originalCoverBuffer;
}
}
console.log('封面已准备好,将写入元数据');

View File

@@ -0,0 +1,245 @@
// 本地音乐扫描模块
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
import { ipcMain } from 'electron';
import * as fs from 'fs';
import * as mm from 'music-metadata';
import * as path from 'path';
/** 支持的音频文件格式 */
const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
/**
* 主进程返回的原始音乐元数据
* 与渲染进程 LocalMusicMeta 类型保持一致
*/
type LocalMusicMeta = {
/** 文件绝对路径 */
filePath: string;
/** 歌曲标题 */
title: string;
/** 艺术家名称 */
artist: string;
/** 专辑名称 */
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
fileSize: number;
/** 文件修改时间戳 */
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;
}
/**
* 将封面图片数据转换为 base64 Data URL
* @param picture music-metadata 解析出的封面图片对象
* @returns base64 Data URL 字符串,转换失败返回 null
*/
function extractCoverAsDataUrl(picture: mm.IPicture | undefined): string | null {
if (!picture) {
return null;
}
try {
const mime = picture.format ?? 'image/jpeg';
const base64 = Buffer.from(picture.data).toString('base64');
return `data:${mime};base64,${base64}`;
} 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;
}
/**
* 解析单个音乐文件的元数据
* 解析失败时使用 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,
cover: 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,
cover: extractCoverAsDataUrl(common.picture?.[0]),
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[]> {
const results: LocalMusicMeta[] = [];
for (const filePath of filePaths) {
const meta = await parseMetadata(filePath);
results.push(meta);
}
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('parse-local-music-metadata', async (_, filePaths: string[]) => {
try {
const metadataList = await batchParseMetadata(filePaths);
return metadataList;
} catch (error: any) {
console.error('解析本地音乐元数据失败:', error);
return [];
}
});
}

View File

@@ -28,6 +28,12 @@ interface API {
getSearchSuggestions: (keyword: string) => Promise<any>;
lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) => Promise<any>;
lxMusicHttpCancel: (requestId: string) => Promise<void>;
/** 扫描指定文件夹中的本地音乐文件 */
scanLocalMusic: (folderPath: string) => Promise<{ files: string[]; count: number }>;
/** 批量解析本地音乐文件元数据 */
parseLocalMusicMetadata: (
filePaths: string[]
) => Promise<import('../renderer/types/localMusic').LocalMusicMeta[]>;
}
// 自定义IPC渲染进程通信接口

View File

@@ -51,7 +51,9 @@ const api = {
'get-system-fonts',
'get-cached-lyric',
'cache-lyric',
'clear-lyric-cache'
'clear-lyric-cache',
'scan-local-music',
'parse-local-music-metadata'
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
@@ -65,7 +67,12 @@ const api = {
lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) =>
ipcRenderer.invoke('lx-music-http-request', request),
lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId)
lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId),
// 本地音乐扫描相关
scanLocalMusic: (folderPath: string) => ipcRenderer.invoke('scan-local-music', folderPath),
parseLocalMusicMetadata: (filePaths: string[]) =>
ipcRenderer.invoke('parse-local-music-metadata', filePaths)
};
// 创建带类型的ipcRenderer对象暴露给渲染进程

View File

@@ -89,6 +89,17 @@ const layoutRouter = [
isMobile: true
}
},
{
path: '/local-music',
name: 'localMusic',
meta: {
title: 'comp.localMusic',
icon: 'ri-folder-music-fill',
keepAlive: true,
isMobile: false
},
component: () => import('@/views/local-music/index.vue')
},
{
path: '/user',
name: 'user',

View File

@@ -16,6 +16,7 @@ pinia.use(({ store }) => {
// 导出所有 store
export * from './modules/intelligenceMode';
export * from './modules/localMusic';
export * from './modules/lyric';
export * from './modules/menu';
export * from './modules/music';

View File

@@ -0,0 +1,288 @@
// 本地音乐 Pinia Store
// 管理本地音乐列表、扫描状态和文件夹配置
// 使用 IndexedDB 缓存音乐元数据localStorage 持久化文件夹路径
import { createDiscreteApi } from 'naive-ui';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import useIndexedDB from '@/hooks/IndexDBHook';
import type { LocalMusicEntry } from '@/types/localMusic';
import { getChangedFiles, removeStaleEntries } from '@/utils/localMusicUtils';
const { message } = createDiscreteApi(['message']);
/** IndexedDB store 名称 */
const LOCAL_MUSIC_STORE = 'local_music' as const;
/** IndexedDB 数据类型映射 */
type LocalMusicDBStores = {
local_music: LocalMusicEntry;
};
/**
* 使用 filePath 生成唯一 ID
* 采用简单的字符串 hash 算法,确保同一路径始终生成相同 ID
* @param filePath 文件绝对路径
* @returns hash 字符串作为唯一 ID
*/
function generateId(filePath: string): string {
let hash = 0;
for (let i = 0; i < filePath.length; i++) {
const char = filePath.charCodeAt(i);
hash = ((hash << 5) - hash + char) | 0;
}
// 转为正数的十六进制字符串
return (hash >>> 0).toString(16);
}
/**
* 初始化 IndexedDB 实例
* 使用 localMusicDB 数据库,包含 local_music 表
*/
async function initLocalMusicDB() {
return await useIndexedDB<typeof LOCAL_MUSIC_STORE, LocalMusicDBStores>(
'localMusicDB',
[{ name: LOCAL_MUSIC_STORE, keyPath: 'id' }],
1
);
}
/**
* 本地音乐管理 Store
* 负责文件夹管理、音乐扫描、IndexedDB 缓存、增量更新
*/
export const useLocalMusicStore = defineStore(
'localMusic',
() => {
// ==================== 状态 ====================
/** 已配置的文件夹路径列表 */
const folderPaths = ref<string[]>([]);
/** 本地音乐列表(从 IndexedDB 加载) */
const musicList = ref<LocalMusicEntry[]>([]);
/** 是否正在扫描 */
const scanning = ref(false);
/** 已扫描文件数(用于显示进度) */
const scanProgress = ref(0);
/** IndexedDB 实例(延迟初始化) */
let db: Awaited<ReturnType<typeof initLocalMusicDB>> | null = null;
/**
* 获取 IndexedDB 实例,首次调用时初始化
*/
async function getDB() {
if (!db) {
db = await initLocalMusicDB();
}
return db;
}
// ==================== 动作 ====================
/**
* 添加文件夹路径
* 如果路径已存在则忽略
* @param path 文件夹路径
*/
function addFolder(path: string): void {
if (!path || folderPaths.value.includes(path)) {
return;
}
folderPaths.value.push(path);
}
/**
* 移除文件夹路径
* @param path 要移除的文件夹路径
*/
function removeFolder(path: string): void {
const index = folderPaths.value.indexOf(path);
if (index !== -1) {
folderPaths.value.splice(index, 1);
}
}
/**
* 扫描所有已配置的文件夹
* 流程IPC 扫描文件 → 增量对比 → 解析变更文件元数据 → 存入 IndexedDB → 更新列表
*/
async function scanFolders(): Promise<void> {
if (scanning.value || folderPaths.value.length === 0) {
return;
}
scanning.value = true;
scanProgress.value = 0;
try {
const localDB = await getDB();
// 加载当前缓存数据用于增量对比
const cachedEntries = await localDB.getAllData(LOCAL_MUSIC_STORE);
// 遍历每个文件夹进行扫描
for (const folderPath of folderPaths.value) {
try {
// 1. 调用 IPC 扫描文件夹,获取文件路径列表
const result = await window.api.scanLocalMusic(folderPath);
// 检查是否返回错误
if ((result as any).error) {
console.error(`扫描文件夹失败: ${folderPath}`, (result as any).error);
message.error(`扫描失败: ${(result as any).error}`);
continue;
}
const { files } = result;
scanProgress.value += files.length;
// 2. 增量扫描:对比缓存,找出需要重新解析的文件
const cachedMap = new Map<string, LocalMusicEntry>();
for (const entry of cachedEntries) {
cachedMap.set(entry.filePath, entry);
}
// 缓存中不存在的新文件,一定需要解析
const newFiles = files.filter((f) => !cachedMap.has(f));
// 缓存中已存在的文件,需要检查修改时间是否变更
const existingFiles = files.filter((f) => cachedMap.has(f));
// 3. 解析新文件的元数据并存入 IndexedDB
if (newFiles.length > 0) {
const newMetas = await window.api.parseLocalMusicMetadata(newFiles);
for (const meta of newMetas) {
const entry: LocalMusicEntry = {
...meta,
id: generateId(meta.filePath)
};
await localDB.saveData(LOCAL_MUSIC_STORE, entry);
}
}
// 4. 对已有文件进行增量对比,仅重新解析修改时间变更的文件
if (existingFiles.length > 0) {
// 解析已有文件的元数据以获取最新修改时间
const existingMetas = await window.api.parseLocalMusicMetadata(existingFiles);
const existingWithTime = existingMetas.map((meta) => ({
path: meta.filePath,
modifiedTime: meta.modifiedTime
}));
// 使用 getChangedFiles 对比修改时间,找出变更文件
const changedFilePaths = getChangedFiles(existingWithTime, cachedEntries);
const changedSet = new Set(changedFilePaths);
// 对于修改时间变更的文件,直接使用已解析的元数据更新缓存(避免重复解析)
for (const meta of existingMetas) {
if (changedSet.has(meta.filePath)) {
const entry: LocalMusicEntry = {
...meta,
id: generateId(meta.filePath)
};
await localDB.saveData(LOCAL_MUSIC_STORE, entry);
}
}
}
} catch (error) {
console.error(`扫描文件夹出错: ${folderPath}`, error);
message.error(`扫描文件夹出错: ${folderPath}`);
}
}
// 5. 从 IndexedDB 重新加载完整列表
musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE);
} catch (error) {
console.error('扫描本地音乐失败:', error);
message.error('扫描本地音乐失败');
} finally {
scanning.value = false;
}
}
/**
* 从 IndexedDB 缓存加载音乐列表
* 应用启动时或进入本地音乐页面时调用
*/
async function loadFromCache(): Promise<void> {
try {
const localDB = await getDB();
musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE);
} catch (error) {
console.error('从缓存加载本地音乐失败:', error);
// 降级:缓存加载失败时保持空列表,用户可手动触发扫描
musicList.value = [];
}
}
/**
* 清理缓存:检查文件存在性,移除已不存在的文件条目
*/
async function clearCache(): Promise<void> {
try {
const localDB = await getDB();
const allEntries = await localDB.getAllData(LOCAL_MUSIC_STORE);
if (allEntries.length === 0) {
return;
}
// 构建文件存在性映射
const existsMap: Record<string, boolean> = {};
for (const entry of allEntries) {
try {
// 使用已有的 IPC 通道检查文件是否存在
const exists = await window.electron.ipcRenderer.invoke(
'check-file-exists',
entry.filePath
);
existsMap[entry.filePath] = exists !== false;
} catch {
// 检查失败时假设文件存在,避免误删
existsMap[entry.filePath] = true;
}
}
// 使用工具函数过滤出仍然存在的条目
const validEntries = removeStaleEntries(allEntries, existsMap);
const removedEntries = allEntries.filter(
(entry) => !validEntries.some((v) => v.id === entry.id)
);
// 从 IndexedDB 中删除不存在的条目
for (const entry of removedEntries) {
await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id);
}
// 更新内存中的列表
musicList.value = validEntries;
} catch (error) {
console.error('清理缓存失败:', error);
}
}
return {
// 状态
folderPaths,
musicList,
scanning,
scanProgress,
// 动作
addFolder,
removeFolder,
scanFolders,
loadFromCache,
clearCache
};
},
{
// 持久化配置:仅持久化文件夹路径到 localStorage
// 音乐列表存储在 IndexedDB 中,不需要 localStorage 持久化
persist: {
key: 'local-music-store',
storage: localStorage,
pick: ['folderPaths']
}
}
);

View File

@@ -1,3 +1,5 @@
import type { LocalMusicMeta } from './localMusic';
export interface IElectronAPI {
minimize: () => void;
maximize: () => void;
@@ -16,6 +18,10 @@ export interface IElectronAPI {
set: (_key: string, _value: any) => Promise<boolean>;
delete: (_key: string) => Promise<boolean>;
};
/** 扫描指定文件夹中的本地音乐文件 */
scanLocalMusic: (_folderPath: string) => Promise<{ files: string[]; count: number }>;
/** 批量解析本地音乐文件元数据 */
parseLocalMusicMetadata: (_filePaths: string[]) => Promise<LocalMusicMeta[]>;
}
declare global {

View File

@@ -0,0 +1,44 @@
// 本地音乐相关类型定义
/**
* 支持的音频文件格式
* 包含 mp3、flac、wav、ogg、m4a、aac 六种常见格式
*/
export const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
/** 支持的音频格式类型 */
export type SupportedAudioFormat = (typeof SUPPORTED_AUDIO_FORMATS)[number];
/**
* 主进程返回的原始音乐元数据
* 由主进程扫描模块解析音乐文件后生成
*/
export type LocalMusicMeta = {
/** 文件绝对路径 */
filePath: string;
/** 歌曲标题 */
title: string;
/** 艺术家名称 */
artist: string;
/** 专辑名称 */
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
fileSize: number;
/** 文件修改时间戳 */
modifiedTime: number;
};
/**
* IndexedDB 中存储的本地音乐条目
* 在 LocalMusicMeta 基础上增加唯一标识 id
*/
export type LocalMusicEntry = LocalMusicMeta & {
/** 使用 filePath 的 hash 作为唯一 ID */
id: string;
};

View File

@@ -76,6 +76,9 @@ export const formatNumber = (num: string | number) => {
export const getImgUrl = (url: string | undefined, size: string = '') => {
if (!url) return '';
// base64 Data URL 和本地文件路径不需要添加尺寸参数
if (url.startsWith('data:') || url.startsWith('local://')) return url;
if (url.includes('thumbnail')) {
// 只替换最后一个 thumbnail 参数的尺寸
return url.replace(/thumbnail=\d+y\d+(?!.*thumbnail)/, `thumbnail=${size}`);

View File

@@ -0,0 +1,261 @@
// 本地音乐工具函数
// 提供格式过滤、元数据 fallback、类型转换、搜索过滤、增量扫描等功能
import type { LocalMusicEntry, LocalMusicMeta } from '@/types/localMusic';
import { SUPPORTED_AUDIO_FORMATS } from '@/types/localMusic';
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
/**
* 判断文件路径是否为支持的音频格式
* 通过提取文件扩展名(不区分大小写)与支持格式列表比对
* @param filePath 文件路径
* @returns 是否为支持的音频格式
*/
export function isSupportedAudioFormat(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
return (SUPPORTED_AUDIO_FORMATS as readonly string[]).includes(ext);
}
/**
* 从文件路径中提取歌曲标题(去除目录和扩展名)
* @param filePath 文件路径
* @returns 歌曲标题
*/
export function extractTitleFromFilename(filePath: string): string {
// 兼容 Windows 和 Unix 路径分隔符
const separator = filePath.includes('\\') ? '\\' : '/';
const filename = filePath.split(separator).pop() || filePath;
// 去除扩展名
const dotIndex = filename.lastIndexOf('.');
if (dotIndex > 0) {
return filename.slice(0, dotIndex);
}
return filename;
}
/**
* 构建缺失元数据时的 fallback 元数据对象
* 使用文件名作为标题,"未知艺术家"和"未知专辑"作为默认值
* @param filePath 文件路径
* @returns 默认的 LocalMusicMeta 对象
*/
export function buildFallbackMeta(filePath: string): LocalMusicMeta {
return {
filePath,
title: extractTitleFromFilename(filePath),
artist: '未知艺术家',
album: '未知专辑',
duration: 0,
cover: null,
lyrics: null,
fileSize: 0,
modifiedTime: 0
};
}
/**
* 将 LRC 格式歌词字符串解析为 ILyric 对象
* 复用 yrcParser 解析能力,兼容标准 LRC 和 YRC 格式
* @param lrcString LRC 格式歌词文本
* @returns ILyric 对象,解析失败返回 null
*/
export function parseLrcToILyric(lrcString: string | null): ILyric | null {
if (!lrcString || typeof lrcString !== 'string') {
return null;
}
try {
const parseResult = parseYrcLyrics(lrcString);
if (!parseResult.success) {
return null;
}
const { lyrics: parsedLyrics } = parseResult.data;
const lrcArray: ILyricText[] = [];
const lrcTimeArray: number[] = [];
let hasWordByWord = false;
for (const line of parsedLyrics) {
const hasWords = line.words && line.words.length > 0;
if (hasWords) hasWordByWord = true;
lrcArray.push({
text: line.fullText,
trText: '',
words: hasWords ? (line.words as IWordData[]) : undefined,
hasWordByWord: hasWords,
startTime: line.startTime,
duration: line.duration
});
lrcTimeArray.push(line.startTime / 1000);
}
if (lrcArray.length === 0) {
return null;
}
return { lrcTimeArray, lrcArray, hasWordByWord };
} catch {
return null;
}
}
/**
* 将 LocalMusicEntry 转换为 SongResult以复用现有播放系统
* @param entry 本地音乐条目
* @returns 兼容播放系统的 SongResult 对象
*/
export function toSongResult(entry: LocalMusicEntry): SongResult {
// 解析内嵌歌词为 ILyric 对象
const lyric = parseLrcToILyric(entry.lyrics);
return {
id: entry.id,
name: entry.title,
picUrl: entry.cover || '/images/default_cover.png',
ar: [
{
name: entry.artist,
id: 0,
picId: 0,
img1v1Id: 0,
briefDesc: '',
picUrl: '',
img1v1Url: '',
albumSize: 0,
alias: [],
trans: '',
musicSize: 0,
topicPerson: 0
}
],
al: {
name: entry.album,
id: 0,
type: '',
size: 0,
picId: 0,
blurPicUrl: '',
companyId: 0,
pic: 0,
picUrl: entry.cover || '',
publishTime: 0,
description: '',
tags: '',
company: '',
briefDesc: '',
artist: {
name: entry.artist,
id: 0,
picId: 0,
img1v1Id: 0,
briefDesc: '',
picUrl: '',
img1v1Url: '',
albumSize: 0,
alias: [],
trans: '',
musicSize: 0,
topicPerson: 0
},
songs: [],
alias: [],
status: 0,
copyrightId: 0,
commentThreadId: '',
artists: [],
subType: '',
transName: null,
onSale: false,
mark: 0,
picId_str: ''
},
song: {
artists: [{ name: entry.artist }],
album: { name: entry.album }
},
playMusicUrl: `local:///${entry.filePath}`,
duration: entry.duration,
dt: entry.duration,
source: 'netease' as const,
count: 0,
// 内嵌歌词(如果有)
lyric: lyric ?? undefined,
// 本地音乐 URL 不会过期,设置一个极大的过期时间
createdAt: Date.now(),
expiredAt: Date.now() + 365 * 24 * 60 * 60 * 1000
};
}
/**
* 将封面图片 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}`;
}
/**
* 按关键词搜索过滤本地音乐列表
* 不区分大小写,匹配歌曲标题或艺术家名称
* 空关键词返回完整列表
* @param list 本地音乐列表
* @param keyword 搜索关键词
* @returns 过滤后的音乐列表
*/
export function filterByKeyword(list: LocalMusicEntry[], keyword: string): LocalMusicEntry[] {
if (!keyword || keyword.trim() === '') {
return list;
}
const lowerKeyword = keyword.toLowerCase();
return list.filter((entry) => {
return (
entry.title.toLowerCase().includes(lowerKeyword) ||
entry.artist.toLowerCase().includes(lowerKeyword)
);
});
}
/**
* 增量扫描对比:找出新增或修改时间变更的文件
* 对比扫描到的文件列表与缓存条目,返回需要重新解析的文件路径
* @param files 扫描到的文件列表(包含路径和修改时间)
* @param cached 已缓存的本地音乐条目
* @returns 需要重新解析的文件路径列表
*/
export function getChangedFiles(
files: { path: string; modifiedTime: number }[],
cached: LocalMusicEntry[]
): string[] {
// 构建缓存映射filePath -> modifiedTime
const cachedMap = new Map<string, number>();
for (const entry of cached) {
cachedMap.set(entry.filePath, entry.modifiedTime);
}
return files
.filter((file) => {
const cachedTime = cachedMap.get(file.path);
// 缓存中不存在(新文件)或修改时间不匹配(已变更)
return cachedTime === undefined || cachedTime !== file.modifiedTime;
})
.map((file) => file.path);
}
/**
* 缓存清理:移除文件已不存在的条目
* @param entries 缓存的本地音乐条目列表
* @param existsMap 文件存在性映射filePath -> 是否存在)
* @returns 清理后的条目列表(仅保留文件仍存在的条目)
*/
export function removeStaleEntries(
entries: LocalMusicEntry[],
existsMap: Record<string, boolean>
): LocalMusicEntry[] {
return entries.filter((entry) => existsMap[entry.filePath] === true);
}

View File

@@ -0,0 +1,358 @@
<template>
<div class="local-music-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<n-scrollbar class="h-full">
<div class="local-music-content pb-32">
<!-- Hero Section -->
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
<!-- 背景模糊效果 -->
<div class="hero-bg absolute inset-0 -top-20">
<div
class="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-primary/10 blur-3xl opacity-50 dark:opacity-30"
></div>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white dark:via-black/80 dark:to-black"
></div>
</div>
<!-- Hero 内容 -->
<div class="hero-content relative z-10 px-4 md:px-8 pt-10 pb-8">
<div class="flex flex-col md:flex-row gap-8 items-center md:items-end">
<div class="cover-wrapper relative group">
<div
class="cover-container relative w-32 h-32 md:w-40 md:h-40 rounded-2xl bg-primary/10 flex items-center justify-center shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50"
>
<i class="ri-folder-music-fill text-6xl text-primary opacity-80" />
</div>
</div>
<div class="info-content text-center md:text-left">
<div class="badge mb-3">
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 dark:bg-primary/20 text-primary text-xs font-semibold uppercase tracking-wider"
>
{{ t('localMusic.title') }}
</span>
</div>
<h1
class="text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight"
>
{{ t('localMusic.title') }}
</h1>
<p class="mt-4 text-sm md:text-base text-neutral-500 dark:text-neutral-400">
{{ t('localMusic.songCount', { count: localMusicStore.musicList.length }) }}
</p>
</div>
</div>
</div>
</section>
<!-- Action Bar (Sticky) -->
<section
class="action-bar sticky top-0 z-20 px-4 md:px-8 py-3 md:py-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50"
>
<div class="flex items-center justify-between gap-4">
<!-- 左侧搜索框 -->
<div class="flex-1 max-w-xs">
<n-input
v-model:value="searchKeyword"
:placeholder="t('localMusic.search')"
clearable
size="small"
round
>
<template #prefix>
<i class="ri-search-line text-neutral-400" />
</template>
</n-input>
</div>
<!-- 右侧操作按钮 -->
<div class="flex items-center gap-3">
<!-- 播放全部按钮 -->
<button
v-if="filteredList.length > 0"
class="action-btn-pill flex items-center gap-2 px-4 py-2 rounded-full font-semibold text-sm transition-all bg-primary text-white hover:bg-primary/90"
@click="handlePlayAll"
>
<i class="ri-play-fill text-lg" />
<span class="hidden md:inline">{{ t('localMusic.playAll') }}</span>
</button>
<!-- 扫描按钮 -->
<button
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
:disabled="localMusicStore.scanning"
@click="handleScan"
>
<i
class="ri-refresh-line text-lg"
:class="{ 'animate-spin': localMusicStore.scanning }"
/>
</button>
<!-- 添加文件夹按钮 -->
<button
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
@click="handleAddFolder"
>
<i class="ri-folder-add-line text-lg" />
</button>
<!-- 文件夹管理按钮 -->
<button
v-if="localMusicStore.folderPaths.length > 0"
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
@click="showFolderManager = true"
>
<i class="ri-folder-settings-line text-lg" />
</button>
</div>
</div>
</section>
<!-- 扫描进度提示 -->
<section v-if="localMusicStore.scanning" class="px-4 md:px-8 mt-6">
<div
class="flex items-center gap-4 p-4 rounded-2xl bg-primary/5 dark:bg-primary/10 border border-primary/20"
>
<n-spin size="small" />
<div>
<p class="text-sm font-medium text-neutral-900 dark:text-white">
{{ t('localMusic.scanning') }}
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{{ t('localMusic.songCount', { count: localMusicStore.scanProgress }) }}
</p>
</div>
</div>
</section>
<!-- 歌曲列表 -->
<section class="list-section px-4 md:px-8 mt-6">
<!-- 空状态 -->
<div
v-if="!localMusicStore.scanning && filteredList.length === 0"
class="empty-state py-20 text-center"
>
<i class="ri-folder-music-fill text-5xl mb-4 text-neutral-200 dark:text-neutral-800" />
<p class="text-neutral-400">{{ t('localMusic.emptyState') }}</p>
<button
class="mt-6 px-6 py-2 rounded-full bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-all"
@click="handleAddFolder"
>
<i class="ri-folder-add-line mr-2" />
{{ t('localMusic.scanFolder') }}
</button>
</div>
<!-- 虚拟列表 -->
<div v-else-if="filteredList.length > 0" class="song-list-container">
<n-virtual-list
class="song-virtual-list"
style="max-height: calc(100vh - 280px)"
:items="filteredSongResults"
:item-size="70"
item-resizable
key-field="id"
>
<template #default="{ item, index }">
<div>
<song-item :index="index" :item="item" @play="handlePlaySong" />
<!-- 列表末尾留白 -->
<div v-if="index === filteredSongResults.length - 1" class="h-36"></div>
</div>
</template>
</n-virtual-list>
</div>
</section>
</div>
</n-scrollbar>
<!-- 文件夹管理抽屉 -->
<n-drawer v-model:show="showFolderManager" :width="400" placement="right">
<n-drawer-content :title="t('localMusic.removeFolder')" closable>
<div class="space-y-3 py-4">
<div
v-for="folder in localMusicStore.folderPaths"
:key="folder"
class="flex items-center justify-between p-3 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-100 dark:border-neutral-800"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<i class="ri-folder-line text-lg text-primary flex-shrink-0" />
<span class="text-sm text-neutral-700 dark:text-neutral-300 truncate">{{
folder
}}</span>
</div>
<button
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-500/10 transition-all flex-shrink-0 ml-2"
@click="handleRemoveFolder(folder)"
>
<i class="ri-delete-bin-line" />
</button>
</div>
<!-- 空文件夹列表 -->
<div v-if="localMusicStore.folderPaths.length === 0" class="text-center py-8">
<i class="ri-folder-line text-4xl text-neutral-200 dark:text-neutral-800" />
<p class="text-sm text-neutral-400 mt-2">{{ t('localMusic.emptyState') }}</p>
</div>
</div>
<template #footer>
<n-button type="primary" block @click="handleAddFolder">
<template #icon>
<i class="ri-folder-add-line" />
</template>
{{ t('localMusic.scanFolder') }}
</n-button>
</template>
</n-drawer-content>
</n-drawer>
</div>
</template>
<script setup lang="ts">
import { createDiscreteApi } from 'naive-ui';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import SongItem from '@/components/common/SongItem.vue';
import { useLocalMusicStore } from '@/store/modules/localMusic';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/types/music';
import { filterByKeyword, toSongResult } from '@/utils/localMusicUtils';
// ==================== Stores ====================
const { t } = useI18n();
const { message } = createDiscreteApi(['message']);
const localMusicStore = useLocalMusicStore();
const playerStore = usePlayerStore();
// ==================== State ====================
/** 搜索关键词 */
const searchKeyword = ref('');
/** 文件夹管理抽屉是否显示 */
const showFolderManager = ref(false);
// ==================== Computed ====================
/** 根据搜索关键词过滤后的本地音乐列表 */
const filteredList = computed(() => {
return filterByKeyword(localMusicStore.musicList, searchKeyword.value);
});
/** 将过滤后的列表转换为 SongResult[] 供 SongItem 使用 */
const filteredSongResults = computed(() => {
return filteredList.value.map(toSongResult);
});
// ==================== Methods ====================
/**
* 选择并添加文件夹
* 调用系统文件夹选择对话框
* dialog.showOpenDialog 返回 { canceled: boolean, filePaths: string[] }
*/
async function handleAddFolder(): Promise<void> {
try {
const result = await window.electron.ipcRenderer.invoke('select-directory');
if (result && !result.canceled && result.filePaths?.length > 0) {
localMusicStore.addFolder(result.filePaths[0]);
// 添加文件夹后自动触发扫描
await localMusicStore.scanFolders();
}
} catch (error) {
console.error('选择文件夹失败:', error);
message.error(String(error));
}
}
/**
* 移除文件夹
* @param folder 要移除的文件夹路径
*/
function handleRemoveFolder(folder: string): void {
localMusicStore.removeFolder(folder);
}
/**
* 触发扫描
*/
async function handleScan(): Promise<void> {
if (localMusicStore.folderPaths.length === 0) {
// 没有配置文件夹时,引导用户先添加文件夹
await handleAddFolder();
return;
}
await localMusicStore.scanFolders();
}
/**
* 播放单曲
* SongItem 内部已通过 playMusicEvent 调用 playerStore.setPlay 触发播放
* 此处只需设置播放列表上下文,确保上下一首切换正常
* @param song SongItem 组件 emit 的 SongResult 对象
*/
async function handlePlaySong(_song: SongResult): Promise<void> {
try {
// 设置播放列表上下文,确保上下一首切换正常
playerStore.setPlayList(filteredSongResults.value);
} catch (error) {
console.error('播放本地音乐失败:', error);
}
}
/**
* 播放全部
* 将完整列表转换为 SongResult[] 后设置为播放列表并从第一首开始播放
*/
async function handlePlayAll(): Promise<void> {
if (filteredSongResults.value.length === 0) return;
try {
const firstSong = filteredSongResults.value[0];
const entry = filteredList.value[0];
// 检查第一首歌文件是否存在
const exists = await window.electron.ipcRenderer.invoke('check-file-exists', entry.filePath);
if (!exists) {
message.error(t('localMusic.fileNotFound'));
return;
}
// 设置播放列表并播放第一首
playerStore.setPlayList(filteredSongResults.value);
await playerStore.setPlay(firstSong);
} catch (error) {
console.error('播放全部失败:', error);
}
}
// ==================== Lifecycle ====================
onMounted(async () => {
// 进入页面时从 IndexedDB 缓存加载音乐列表
await localMusicStore.loadFromCache();
});
</script>
<style scoped>
/* 虚拟列表样式 */
.song-virtual-list {
@apply w-full;
}
.song-virtual-list :deep(.n-virtual-list__scroll) {
scrollbar-width: thin;
}
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar {
width: 6px;
}
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar-thumb {
@apply bg-neutral-300 dark:bg-neutral-700 rounded-full;
}
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar-track {
@apply bg-transparent;
}
</style>