From 0e47c127fea312e9d71d60361dc7a272294f8e8f Mon Sep 17 00:00:00 2001 From: alger Date: Fri, 6 Feb 2026 17:49:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E9=9F=B3=E4=B9=90=E6=89=AB=E6=8F=8F=E6=92=AD=E6=94=BE=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/lang/en-US/comp.ts | 3 +- src/i18n/lang/en-US/localMusic.ts | 13 + src/i18n/lang/ja-JP/comp.ts | 3 +- src/i18n/lang/ja-JP/localMusic.ts | 13 + src/i18n/lang/ko-KR/comp.ts | 3 +- src/i18n/lang/ko-KR/localMusic.ts | 13 + src/i18n/lang/zh-CN/comp.ts | 3 +- src/i18n/lang/zh-CN/localMusic.ts | 13 + src/i18n/lang/zh-Hant/comp.ts | 3 +- src/i18n/lang/zh-Hant/localMusic.ts | 13 + src/main/index.ts | 3 + src/main/modules/fileManager.ts | 97 +++--- src/main/modules/localMusicScanner.ts | 245 ++++++++++++++++ src/preload/index.d.ts | 6 + src/preload/index.ts | 11 +- src/renderer/router/home.ts | 11 + src/renderer/store/index.ts | 1 + src/renderer/store/modules/localMusic.ts | 288 ++++++++++++++++++ src/renderer/types/electron.d.ts | 6 + src/renderer/types/localMusic.ts | 44 +++ src/renderer/utils/index.ts | 3 + src/renderer/utils/localMusicUtils.ts | 261 +++++++++++++++++ src/renderer/views/local-music/index.vue | 358 +++++++++++++++++++++++ 23 files changed, 1363 insertions(+), 51 deletions(-) create mode 100644 src/i18n/lang/en-US/localMusic.ts create mode 100644 src/i18n/lang/ja-JP/localMusic.ts create mode 100644 src/i18n/lang/ko-KR/localMusic.ts create mode 100644 src/i18n/lang/zh-CN/localMusic.ts create mode 100644 src/i18n/lang/zh-Hant/localMusic.ts create mode 100644 src/main/modules/localMusicScanner.ts create mode 100644 src/renderer/store/modules/localMusic.ts create mode 100644 src/renderer/types/localMusic.ts create mode 100644 src/renderer/utils/localMusicUtils.ts create mode 100644 src/renderer/views/local-music/index.vue diff --git a/src/i18n/lang/en-US/comp.ts b/src/i18n/lang/en-US/comp.ts index d49f7cb..d7e213f 100644 --- a/src/i18n/lang/en-US/comp.ts +++ b/src/i18n/lang/en-US/comp.ts @@ -268,5 +268,6 @@ export default { mv: 'MV', home: 'Home', search: 'Search', - album: 'Album' + album: 'Album', + localMusic: 'Local Music' }; diff --git a/src/i18n/lang/en-US/localMusic.ts b/src/i18n/lang/en-US/localMusic.ts new file mode 100644 index 0000000..6ee8073 --- /dev/null +++ b/src/i18n/lang/en-US/localMusic.ts @@ -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' +}; diff --git a/src/i18n/lang/ja-JP/comp.ts b/src/i18n/lang/ja-JP/comp.ts index 82923a0..53a41f5 100644 --- a/src/i18n/lang/ja-JP/comp.ts +++ b/src/i18n/lang/ja-JP/comp.ts @@ -268,5 +268,6 @@ export default { mv: 'MV', home: 'ホーム', search: '検索', - album: 'アルバム' + album: 'アルバム', + localMusic: 'ローカル音楽' }; diff --git a/src/i18n/lang/ja-JP/localMusic.ts b/src/i18n/lang/ja-JP/localMusic.ts new file mode 100644 index 0000000..3291923 --- /dev/null +++ b/src/i18n/lang/ja-JP/localMusic.ts @@ -0,0 +1,13 @@ +export default { + title: 'ローカル音楽', + scanFolder: 'フォルダをスキャン', + removeFolder: 'フォルダを削除', + scanning: 'スキャン中...', + scanComplete: 'スキャン完了', + playAll: 'すべて再生', + search: 'ローカル音楽を検索', + emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。', + fileNotFound: 'ファイルが見つからないか、移動されました', + rescan: '再スキャン', + songCount: '{count} 曲' +}; diff --git a/src/i18n/lang/ko-KR/comp.ts b/src/i18n/lang/ko-KR/comp.ts index b937289..8286dd6 100644 --- a/src/i18n/lang/ko-KR/comp.ts +++ b/src/i18n/lang/ko-KR/comp.ts @@ -267,5 +267,6 @@ export default { mv: 'MV', home: '홈', search: '검색', - album: '앨범' + album: '앨범', + localMusic: '로컬 음악' }; diff --git a/src/i18n/lang/ko-KR/localMusic.ts b/src/i18n/lang/ko-KR/localMusic.ts new file mode 100644 index 0000000..0155a92 --- /dev/null +++ b/src/i18n/lang/ko-KR/localMusic.ts @@ -0,0 +1,13 @@ +export default { + title: '로컬 음악', + scanFolder: '폴더 스캔', + removeFolder: '폴더 제거', + scanning: '스캔 중...', + scanComplete: '스캔 완료', + playAll: '모두 재생', + search: '로컬 음악 검색', + emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.', + fileNotFound: '파일을 찾을 수 없거나 이동되었습니다', + rescan: '다시 스캔', + songCount: '{count}곡' +}; diff --git a/src/i18n/lang/zh-CN/comp.ts b/src/i18n/lang/zh-CN/comp.ts index 9e703f1..ba32103 100644 --- a/src/i18n/lang/zh-CN/comp.ts +++ b/src/i18n/lang/zh-CN/comp.ts @@ -261,5 +261,6 @@ export default { mv: 'MV', home: '首页', search: '搜索', - album: '专辑' + album: '专辑', + localMusic: '本地音乐' }; diff --git a/src/i18n/lang/zh-CN/localMusic.ts b/src/i18n/lang/zh-CN/localMusic.ts new file mode 100644 index 0000000..eafaa8c --- /dev/null +++ b/src/i18n/lang/zh-CN/localMusic.ts @@ -0,0 +1,13 @@ +export default { + title: '本地音乐', + scanFolder: '扫描文件夹', + removeFolder: '移除文件夹', + scanning: '正在扫描...', + scanComplete: '扫描完成', + playAll: '播放全部', + search: '搜索本地音乐', + emptyState: '暂无本地音乐,请先选择文件夹进行扫描', + fileNotFound: '文件不存在或已被移动', + rescan: '重新扫描', + songCount: '{count} 首歌曲' +}; diff --git a/src/i18n/lang/zh-Hant/comp.ts b/src/i18n/lang/zh-Hant/comp.ts index 762d587..433181d 100644 --- a/src/i18n/lang/zh-Hant/comp.ts +++ b/src/i18n/lang/zh-Hant/comp.ts @@ -261,5 +261,6 @@ export default { mv: 'MV', home: '首頁', search: '搜尋', - album: '專輯' + album: '專輯', + localMusic: '本地音樂' }; diff --git a/src/i18n/lang/zh-Hant/localMusic.ts b/src/i18n/lang/zh-Hant/localMusic.ts new file mode 100644 index 0000000..0d155ed --- /dev/null +++ b/src/i18n/lang/zh-Hant/localMusic.ts @@ -0,0 +1,13 @@ +export default { + title: '本地音樂', + scanFolder: '掃描資料夾', + removeFolder: '移除資料夾', + scanning: '正在掃描...', + scanComplete: '掃描完成', + playAll: '播放全部', + search: '搜尋本地音樂', + emptyState: '暫無本地音樂,請先選擇資料夾進行掃描', + fileNotFound: '檔案不存在或已被移動', + rescan: '重新掃描', + songCount: '{count} 首歌曲' +}; diff --git a/src/main/index.ts b/src/main/index.ts index 59c0c6d..8a88cff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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); diff --git a/src/main/modules/fileManager.ts b/src/main/modules/fileManager.ts index 9b2f9b5..6ff35c7 100644 --- a/src/main/modules/fileManager.ts +++ b/src/main/modules/fileManager.ts @@ -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('封面已准备好,将写入元数据'); diff --git a/src/main/modules/localMusicScanner.ts b/src/main/modules/localMusicScanner.ts new file mode 100644 index 0000000..a013758 --- /dev/null +++ b/src/main/modules/localMusicScanner.ts @@ -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 { + 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 { + 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 { + // 获取文件信息(大小和修改时间) + 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 { + 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 []; + } + }); +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 3f86bcd..fdada61 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -28,6 +28,12 @@ interface API { getSearchSuggestions: (keyword: string) => Promise; lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) => Promise; lxMusicHttpCancel: (requestId: string) => Promise; + /** 扫描指定文件夹中的本地音乐文件 */ + scanLocalMusic: (folderPath: string) => Promise<{ files: string[]; count: number }>; + /** 批量解析本地音乐文件元数据 */ + parseLocalMusicMetadata: ( + filePaths: string[] + ) => Promise; } // 自定义IPC渲染进程通信接口 diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f3e7de..0f559ee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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对象,暴露给渲染进程 diff --git a/src/renderer/router/home.ts b/src/renderer/router/home.ts index 0d9d5c9..50a0b3d 100644 --- a/src/renderer/router/home.ts +++ b/src/renderer/router/home.ts @@ -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', diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index ae3e46b..7358311 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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'; diff --git a/src/renderer/store/modules/localMusic.ts b/src/renderer/store/modules/localMusic.ts new file mode 100644 index 0000000..2f0da7e --- /dev/null +++ b/src/renderer/store/modules/localMusic.ts @@ -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( + 'localMusicDB', + [{ name: LOCAL_MUSIC_STORE, keyPath: 'id' }], + 1 + ); +} + +/** + * 本地音乐管理 Store + * 负责:文件夹管理、音乐扫描、IndexedDB 缓存、增量更新 + */ +export const useLocalMusicStore = defineStore( + 'localMusic', + () => { + // ==================== 状态 ==================== + /** 已配置的文件夹路径列表 */ + const folderPaths = ref([]); + /** 本地音乐列表(从 IndexedDB 加载) */ + const musicList = ref([]); + /** 是否正在扫描 */ + const scanning = ref(false); + /** 已扫描文件数(用于显示进度) */ + const scanProgress = ref(0); + + /** IndexedDB 实例(延迟初始化) */ + let db: Awaited> | 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 { + 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(); + 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 { + try { + const localDB = await getDB(); + musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE); + } catch (error) { + console.error('从缓存加载本地音乐失败:', error); + // 降级:缓存加载失败时保持空列表,用户可手动触发扫描 + musicList.value = []; + } + } + + /** + * 清理缓存:检查文件存在性,移除已不存在的文件条目 + */ + async function clearCache(): Promise { + try { + const localDB = await getDB(); + const allEntries = await localDB.getAllData(LOCAL_MUSIC_STORE); + + if (allEntries.length === 0) { + return; + } + + // 构建文件存在性映射 + const existsMap: Record = {}; + 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'] + } + } +); diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 496c2b8..f06477a 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -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; delete: (_key: string) => Promise; }; + /** 扫描指定文件夹中的本地音乐文件 */ + scanLocalMusic: (_folderPath: string) => Promise<{ files: string[]; count: number }>; + /** 批量解析本地音乐文件元数据 */ + parseLocalMusicMetadata: (_filePaths: string[]) => Promise; } declare global { diff --git a/src/renderer/types/localMusic.ts b/src/renderer/types/localMusic.ts new file mode 100644 index 0000000..8fa02bd --- /dev/null +++ b/src/renderer/types/localMusic.ts @@ -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; +}; diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index ea7746a..4e7141b 100644 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -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}`); diff --git a/src/renderer/utils/localMusicUtils.ts b/src/renderer/utils/localMusicUtils.ts new file mode 100644 index 0000000..4855285 --- /dev/null +++ b/src/renderer/utils/localMusicUtils.ts @@ -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(); + 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 +): LocalMusicEntry[] { + return entries.filter((entry) => existsMap[entry.filePath] === true); +} diff --git a/src/renderer/views/local-music/index.vue b/src/renderer/views/local-music/index.vue new file mode 100644 index 0000000..16d9394 --- /dev/null +++ b/src/renderer/views/local-music/index.vue @@ -0,0 +1,358 @@ + + + + +