diff --git a/src/i18n/lang/en-US/download.ts b/src/i18n/lang/en-US/download.ts index e0cfbcc..47765bf 100644 --- a/src/i18n/lang/en-US/download.ts +++ b/src/i18n/lang/en-US/download.ts @@ -20,7 +20,21 @@ export default { downloading: 'Downloading', completed: 'Completed', failed: 'Failed', - unknown: 'Unknown' + unknown: 'Unknown', + queued: 'Queued', + paused: 'Paused', + cancelled: 'Cancelled' + }, + action: { + pause: 'Pause', + resume: 'Resume', + cancel: 'Cancel', + cancelAll: 'Cancel All', + retrying: 'Re-resolving URL...' + }, + batch: { + complete: 'Download complete: {success}/{total} songs succeeded', + allComplete: 'All downloads complete' }, artist: { unknown: 'Unknown Artist' @@ -78,6 +92,8 @@ export default { dragToArrange: 'Sort or use arrow buttons to arrange:', formatVariables: 'Available variables', preview: 'Preview:', + concurrency: 'Max Concurrent', + concurrencyDesc: 'Maximum number of simultaneous downloads (1-5)', saveSuccess: 'Download settings saved', presets: { songArtist: 'Song - Artist', @@ -89,5 +105,10 @@ export default { artistName: 'Artist name', albumName: 'Album name' } + }, + error: { + incomplete: 'File download incomplete', + urlExpired: 'URL expired, re-resolving', + resumeFailed: 'Resume failed' } }; diff --git a/src/i18n/lang/ja-JP/download.ts b/src/i18n/lang/ja-JP/download.ts index 0d69fa6..a49f0f6 100644 --- a/src/i18n/lang/ja-JP/download.ts +++ b/src/i18n/lang/ja-JP/download.ts @@ -20,7 +20,21 @@ export default { downloading: 'ダウンロード中', completed: '完了', failed: '失敗', - unknown: '不明' + unknown: '不明', + queued: 'キュー中', + paused: '一時停止', + cancelled: 'キャンセル済み' + }, + action: { + pause: '一時停止', + resume: '再開', + cancel: 'キャンセル', + cancelAll: 'すべてキャンセル', + retrying: 'URL再取得中...' + }, + batch: { + complete: 'ダウンロード完了:{success}/{total}曲成功', + allComplete: '全てのダウンロードが完了' }, artist: { unknown: '不明なアーティスト' @@ -78,6 +92,8 @@ export default { dragToArrange: 'ドラッグで並び替えまたは矢印ボタンで順序を調整:', formatVariables: '使用可能な変数', preview: 'プレビュー効果:', + concurrency: '最大同時ダウンロード数', + concurrencyDesc: '同時にダウンロードする最大曲数(1-5)', saveSuccess: 'ダウンロード設定を保存しました', presets: { songArtist: '楽曲名 - アーティスト名', @@ -89,5 +105,10 @@ export default { artistName: 'アーティスト名', albumName: 'アルバム名' } + }, + error: { + incomplete: 'ファイルのダウンロードが不完全です', + urlExpired: 'URLの有効期限が切れました。再取得中', + resumeFailed: '再開に失敗しました' } }; diff --git a/src/i18n/lang/ko-KR/download.ts b/src/i18n/lang/ko-KR/download.ts index c41813a..92fbb0c 100644 --- a/src/i18n/lang/ko-KR/download.ts +++ b/src/i18n/lang/ko-KR/download.ts @@ -20,7 +20,21 @@ export default { downloading: '다운로드 중', completed: '완료', failed: '실패', - unknown: '알 수 없음' + unknown: '알 수 없음', + queued: '대기 중', + paused: '일시 정지', + cancelled: '취소됨' + }, + action: { + pause: '일시 정지', + resume: '재개', + cancel: '취소', + cancelAll: '모두 취소', + retrying: 'URL 재획득 중...' + }, + batch: { + complete: '다운로드 완료: {success}/{total}곡 성공', + allComplete: '모든 다운로드 완료' }, artist: { unknown: '알 수 없는 가수' @@ -78,6 +92,8 @@ export default { dragToArrange: '드래그하여 정렬하거나 화살표 버튼을 사용하여 순서 조정:', formatVariables: '사용 가능한 변수', preview: '미리보기 효과:', + concurrency: '최대 동시 다운로드', + concurrencyDesc: '동시에 다운로드할 최대 곡 수 (1-5)', saveSuccess: '다운로드 설정이 저장됨', presets: { songArtist: '곡명 - 가수명', @@ -89,5 +105,10 @@ export default { artistName: '가수명', albumName: '앨범명' } + }, + error: { + incomplete: '파일 다운로드가 불완전합니다', + urlExpired: 'URL이 만료되었습니다. 재획득 중', + resumeFailed: '재개 실패' } }; diff --git a/src/i18n/lang/zh-CN/download.ts b/src/i18n/lang/zh-CN/download.ts index b16833d..d139d0f 100644 --- a/src/i18n/lang/zh-CN/download.ts +++ b/src/i18n/lang/zh-CN/download.ts @@ -20,7 +20,21 @@ export default { downloading: '下载中', completed: '已完成', failed: '失败', - unknown: '未知' + unknown: '未知', + queued: '排队中', + paused: '已暂停', + cancelled: '已取消' + }, + action: { + pause: '暂停', + resume: '恢复', + cancel: '取消', + cancelAll: '取消全部', + retrying: '重新获取链接...' + }, + batch: { + complete: '下载完成:成功 {success}/{total} 首', + allComplete: '全部下载完成' }, artist: { unknown: '未知歌手' @@ -77,6 +91,8 @@ export default { dragToArrange: '拖动排序或使用箭头按钮调整顺序:', formatVariables: '可用变量', preview: '预览效果:', + concurrency: '最大并发数', + concurrencyDesc: '同时下载的最大歌曲数量(1-5)', saveSuccess: '下载设置已保存', presets: { songArtist: '歌曲名 - 歌手名', @@ -88,5 +104,10 @@ export default { artistName: '歌手名', albumName: '专辑名' } + }, + error: { + incomplete: '文件下载不完整', + urlExpired: '下载链接已过期,正在重新获取', + resumeFailed: '恢复下载失败' } }; diff --git a/src/i18n/lang/zh-Hant/download.ts b/src/i18n/lang/zh-Hant/download.ts index f39747c..c6ffcf2 100644 --- a/src/i18n/lang/zh-Hant/download.ts +++ b/src/i18n/lang/zh-Hant/download.ts @@ -20,7 +20,21 @@ export default { downloading: '下載中', completed: '已完成', failed: '失敗', - unknown: '未知' + unknown: '未知', + queued: '排隊中', + paused: '已暫停', + cancelled: '已取消' + }, + action: { + pause: '暫停', + resume: '恢復', + cancel: '取消', + cancelAll: '取消全部', + retrying: '重新獲取連結...' + }, + batch: { + complete: '下載完成:成功 {success}/{total} 首', + allComplete: '全部下載完成' }, artist: { unknown: '未知歌手' @@ -77,6 +91,8 @@ export default { dragToArrange: '拖曳排序或使用箭頭按鈕調整順序:', formatVariables: '可用變數', preview: '預覽效果:', + concurrency: '最大並發數', + concurrencyDesc: '同時下載的最大歌曲數量(1-5)', saveSuccess: '下載設定已儲存', presets: { songArtist: '歌曲名 - 歌手名', @@ -88,5 +104,10 @@ export default { artistName: '歌手名', albumName: '專輯名' } + }, + error: { + incomplete: '檔案下載不完整', + urlExpired: '下載連結已過期,正在重新獲取', + resumeFailed: '恢復下載失敗' } }; diff --git a/src/main/index.ts b/src/main/index.ts index 65cb052..b47e8e4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ import i18n from '../i18n/main'; import { loadLyricWindow } from './lyric'; import { initializeCacheManager } from './modules/cache'; import { initializeConfig } from './modules/config'; +import { initializeDownloadManager, setDownloadManagerWindow } from './modules/downloadManager'; import { initializeFileManager } from './modules/fileManager'; import { initializeFonts } from './modules/fonts'; import { initializeLocalMusicScanner } from './modules/localMusicScanner'; @@ -42,6 +43,8 @@ function initialize(configStore: any) { // 初始化文件管理 initializeFileManager(); + // 初始化下载管理 + initializeDownloadManager(); // 初始化歌词缓存管理 initializeCacheManager(); // 初始化其他 API (搜索建议等) @@ -58,6 +61,9 @@ function initialize(configStore: any) { // 创建主窗口 mainWindow = createMainWindow(icon); + // 设置下载管理器窗口引用 + setDownloadManagerWindow(mainWindow); + // 初始化托盘 initializeTray(iconPath, mainWindow); diff --git a/src/main/modules/downloadManager.ts b/src/main/modules/downloadManager.ts new file mode 100644 index 0000000..6c806c6 --- /dev/null +++ b/src/main/modules/downloadManager.ts @@ -0,0 +1,1043 @@ +import axios from 'axios'; +import crypto from 'crypto'; +import { app, BrowserWindow, ipcMain, nativeImage, Notification, shell } from 'electron'; +import Store from 'electron-store'; +import { fileTypeFromFile } from 'file-type'; +import { FlacTagMap, writeFlacTags } from 'flac-tagger'; +import * as fs from 'fs'; +import * as mm from 'music-metadata'; +import * as NodeID3 from 'node-id3'; +import * as os from 'os'; +import * as path from 'path'; + +import type { + DownloadBatchCompleteEvent, + DownloadProgressEvent, + DownloadStateChangeEvent, + DownloadTask, + DownloadTaskState +} from '../../shared/download'; +import { getStore } from './config'; + +// ─── Helpers ───────────────────────────────────────────────────────── + +function sanitizeFilename(filename: string): string { + return filename + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .trim(); +} + +function parseLyrics(lyricsText: string): Map { + const lyricMap = new Map(); + const lines = lyricsText.split('\n'); + + for (const line of lines) { + const timeTagMatches = line.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g); + if (!timeTagMatches) continue; + + const content = line.replace(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g, '').trim(); + if (!content) continue; + + for (const timeTag of timeTagMatches) { + lyricMap.set(timeTag, content); + } + } + + return lyricMap; +} + +function mergeLyrics( + originalLyrics: Map, + translatedLyrics: Map +): string { + const mergedLines: string[] = []; + + for (const [timeTag, originalContent] of originalLyrics.entries()) { + const translatedContent = translatedLyrics.get(timeTag); + mergedLines.push(`${timeTag}${originalContent}`); + if (translatedContent) { + mergedLines.push(`${timeTag}${translatedContent}`); + } + } + + mergedLines.sort((a, b) => { + const timeA = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; + const timeB = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; + return timeA.localeCompare(timeB); + }); + + return mergedLines.join('\n'); +} + +// ─── Batch tracker entry ───────────────────────────────────────────── + +type BatchEntry = { total: number; finished: number; success: number }; + +// ─── Persist store type ────────────────────────────────────────────── + +type DownloadQueueStore = { + tasks: DownloadTask[]; +}; + +// ─── DownloadManager ───────────────────────────────────────────────── + +class DownloadManager { + private tasks: Map = new Map(); + private abortControllers: Map = new Map(); + private activeCount = 0; + private maxConcurrent = 3; + private persistStore: Store; + private batchTracker: Map = new Map(); + private mainWindow: BrowserWindow | null = null; + private progressThrottles: Map = new Map(); + private persistTimer: ReturnType | null = null; + + constructor() { + this.persistStore = new Store({ + name: 'download-queue', + defaults: { tasks: [] } + }); + + this.loadPersistedQueue(); + this.cleanOrphanedTempFiles(); + + app.on('before-quit', () => { + // Abort all active downloads, set to paused, persist synchronously + for (const [taskId, controller] of this.abortControllers.entries()) { + controller.abort(); + const task = this.tasks.get(taskId); + if (task && task.state === 'downloading') { + task.state = 'paused'; + } + } + this.persistQueueSync(); + }); + } + + setMainWindow(win: BrowserWindow) { + this.mainWindow = win; + } + + // ─── IPC Registration ─────────────────────────────────────────── + + registerIpcHandlers() { + ipcMain.handle('download:add', (_, payload) => this.addTask(payload)); + ipcMain.handle('download:add-batch', (_, payload) => this.addBatch(payload)); + ipcMain.handle('download:pause', (_, taskId: string) => this.pauseTask(taskId)); + ipcMain.handle('download:resume', (_, taskId: string) => this.resumeTask(taskId)); + ipcMain.handle('download:cancel', (_, taskId: string) => this.cancelTask(taskId)); + ipcMain.handle('download:cancel-all', () => this.cancelAll()); + ipcMain.handle('download:get-queue', () => this.getQueue()); + ipcMain.on('download:set-concurrency', (_, value: number) => this.setConcurrency(value)); + ipcMain.handle('download:get-completed', () => this.getCompleted()); + ipcMain.handle('download:delete-completed', (_, filePath: string) => + this.deleteCompleted(filePath) + ); + ipcMain.handle('download:clear-completed', () => this.clearCompleted()); + ipcMain.handle('download:get-embedded-lyrics', (_, filePath: string) => + this.getEmbeddedLyrics(filePath) + ); + ipcMain.handle('download:provide-url', (_, payload: { taskId: string; url: string }) => + this.provideUrl(payload.taskId, payload.url) + ); + } + + // ─── Task creation ────────────────────────────────────────────── + + private addTask(payload: { + url: string; + filename: string; + songInfo: any; + type?: string; + }): string { + const taskId = crypto.randomUUID(); + const task: DownloadTask = { + taskId, + url: payload.url, + filename: payload.filename, + songInfo: payload.songInfo, + type: payload.type || 'mp3', + state: 'queued', + progress: 0, + loaded: 0, + total: 0, + tempFilePath: '', + finalFilePath: '', + createdAt: Date.now() + }; + + this.tasks.set(taskId, task); + this.persistQueue(); + this.sendStateChange(task); + this.processQueue(); + return taskId; + } + + private addBatch(payload: { + items: { url: string; filename: string; songInfo: any; type?: string }[]; + }): { batchId: string; taskIds: string[] } { + const batchId = crypto.randomUUID(); + const taskIds: string[] = []; + + this.batchTracker.set(batchId, { + total: payload.items.length, + finished: 0, + success: 0 + }); + + for (const item of payload.items) { + const taskId = crypto.randomUUID(); + const task: DownloadTask = { + taskId, + url: item.url, + filename: item.filename, + songInfo: item.songInfo, + type: item.type || 'mp3', + state: 'queued', + progress: 0, + loaded: 0, + total: 0, + tempFilePath: '', + finalFilePath: '', + createdAt: Date.now(), + batchId + }; + + this.tasks.set(taskId, task); + taskIds.push(taskId); + this.sendStateChange(task); + } + + this.persistQueue(); + this.processQueue(); + return { batchId, taskIds }; + } + + // ─── Pause / Resume / Cancel ──────────────────────────────────── + + private pauseTask(taskId: string): boolean { + const task = this.tasks.get(taskId); + if (!task) return false; + + const controller = this.abortControllers.get(taskId); + if (controller) { + controller.abort(); + this.abortControllers.delete(taskId); + } + + task.state = 'paused'; + this.persistQueue(); + this.sendStateChange(task); + return true; + } + + private resumeTask(taskId: string): boolean { + const task = this.tasks.get(taskId); + if (!task || (task.state !== 'paused' && task.state !== 'error')) return false; + + task.state = 'queued'; + this.persistQueue(); + this.sendStateChange(task); + this.processQueue(); + return true; + } + + private cancelTask(taskId: string): boolean { + const task = this.tasks.get(taskId); + if (!task) return false; + + // Abort if downloading + const controller = this.abortControllers.get(taskId); + if (controller) { + controller.abort(); + this.abortControllers.delete(taskId); + } + + // Delete temp file + if (task.tempFilePath && fs.existsSync(task.tempFilePath)) { + try { + fs.unlinkSync(task.tempFilePath); + } catch (e) { + console.error('Failed to delete temp file:', e); + } + } + + task.state = 'cancelled'; + this.sendStateChange(task); + this.tasks.delete(taskId); + this.persistQueue(); + return true; + } + + private cancelAll(): void { + const taskIds = [...this.tasks.keys()]; + for (const taskId of taskIds) { + this.cancelTask(taskId); + } + } + + // ─── Queue queries ────────────────────────────────────────────── + + private getQueue(): DownloadTask[] { + return [...this.tasks.values()].filter( + (t) => t.state === 'queued' || t.state === 'paused' || t.state === 'downloading' + ); + } + + // ─── Concurrency ─────────────────────────────────────────────── + + private setConcurrency(value: number): void { + this.maxConcurrent = Math.max(1, Math.min(5, value)); + this.processQueue(); + } + + // ─── Completed songs (same logic as old fileManager) ──────────── + + private async getCompleted(): Promise { + try { + const configStore = getStore(); + const songInfos = (configStore.get('downloadedSongs') || {}) as Record; + + const entriesArray = Object.entries(songInfos); + const validEntriesPromises = await Promise.all( + entriesArray.map(async ([filePath, info]) => { + try { + const exists = await fs.promises + .access(filePath) + .then(() => true) + .catch(() => false); + return exists ? info : null; + } catch { + return null; + } + }) + ); + + const validSongs = validEntriesPromises + .filter((song) => song !== null) + .sort((a: any, b: any) => (b.downloadTime || 0) - (a.downloadTime || 0)); + + // Update store to remove stale entries + const newSongInfos = validSongs.reduce( + (acc: Record, song: any) => { + if (song && song.path) { + acc[song.path] = song; + } + return acc; + }, + {} as Record + ); + configStore.set('downloadedSongs', newSongInfos); + + return validSongs; + } catch (error) { + console.error('Error getting downloaded music:', error); + return []; + } + } + + private async deleteCompleted(filePath: string): Promise { + try { + if (fs.existsSync(filePath)) { + try { + await fs.promises.unlink(filePath); + } catch (error) { + console.error('Error deleting file:', error); + } + + const configStore = getStore(); + const songInfos = (configStore.get('downloadedSongs') || {}) as Record; + delete songInfos[filePath]; + configStore.set('downloadedSongs', songInfos); + + return true; + } + return false; + } catch (error) { + console.error('Error deleting file:', error); + return false; + } + } + + private clearCompleted(): boolean { + const configStore = getStore(); + configStore.set('downloadedSongs', {}); + return true; + } + + // ─── Embedded lyrics reader ───────────────────────────────────── + + private async getEmbeddedLyrics(filePath: string): Promise { + try { + if (!fs.existsSync(filePath)) return null; + + const ext = path.extname(filePath).toLowerCase(); + + if (ext === '.mp3') { + const tags = NodeID3.read(filePath); + if (tags && tags.unsynchronisedLyrics) { + const uslt = tags.unsynchronisedLyrics as any; + return uslt.text || (typeof uslt === 'string' ? uslt : null); + } + return null; + } + + if (ext === '.flac') { + const metadata = await mm.parseFile(filePath); + const native = metadata.native; + // Look for LYRICS in vorbis comments + for (const format of Object.keys(native)) { + const tags = native[format]; + const lyricsTag = tags.find( + (t) => t.id.toUpperCase() === 'LYRICS' || t.id.toUpperCase() === 'UNSYNCEDLYRICS' + ); + if (lyricsTag) return lyricsTag.value as string; + } + return null; + } + + return null; + } catch (error) { + console.error('Error reading embedded lyrics:', error); + return null; + } + } + + // ─── Provide URL (re-resolved by renderer) ───────────────────── + + private provideUrl(taskId: string, url: string): boolean { + const task = this.tasks.get(taskId); + if (!task) return false; + + task.url = url; + if (task.state === 'queued' || task.state === 'paused') { + task.state = 'queued'; + this.sendStateChange(task); + this.processQueue(); + } + return true; + } + + // ─── Queue processing ────────────────────────────────────────── + + private processQueue(): void { + const queued = [...this.tasks.values()] + .filter((t) => t.state === 'queued') + .sort((a, b) => a.createdAt - b.createdAt); + + while (this.activeCount < this.maxConcurrent && queued.length > 0) { + const task = queued.shift()!; + this.activeCount++; + task.state = 'downloading'; + this.sendStateChange(task); + this.downloadTask(task); + } + } + + // ─── Core download ───────────────────────────────────────────── + + private async downloadTask(task: DownloadTask): Promise { + const controller = new AbortController(); + this.abortControllers.set(task.taskId, controller); + + let writer: fs.WriteStream | null = null; + + try { + const configStore = getStore(); + const downloadPath = + (configStore.get('set.downloadPath') as string) || app.getPath('downloads'); + + // Format filename + const nameFormat = + (configStore.get('set.downloadNameFormat') as string) || '{songName} - {artistName}'; + + let formattedFilename = task.filename; + if (task.songInfo) { + const artistName = task.songInfo.ar?.map((a: any) => a.name).join('\u3001') || '未知艺术家'; + const songName = task.songInfo.name || task.filename; + const albumName = task.songInfo.al?.name || '未知专辑'; + + formattedFilename = nameFormat + .replace(/\{songName\}/g, songName) + .replace(/\{artistName\}/g, artistName) + .replace(/\{albumName\}/g, albumName); + } + + const sanitizedFilename = sanitizeFilename(formattedFilename); + + // Temp directory + const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + // Use existing temp file path if resuming, otherwise create new one + if (!task.tempFilePath) { + task.tempFilePath = path.join(tempDir, `${task.taskId}_${sanitizedFilename}.tmp`); + } + + // Build request headers for resume + const headers: Record = {}; + if (task.loaded > 0 && fs.existsSync(task.tempFilePath)) { + headers['Range'] = `bytes=${task.loaded}-`; + } + + // Start download + const response = await axios({ + url: task.url, + method: 'GET', + responseType: 'stream', + timeout: 30000, + signal: controller.signal, + headers + }); + + // Handle response status + const status = response.status; + + if (status === 403 || status === 410) { + // URL expired, request re-resolution from renderer + this.sendToRenderer('download:request-url', { + taskId: task.taskId, + songInfo: task.songInfo + }); + task.state = 'queued'; + this.sendStateChange(task); + this.activeCount--; + this.processQueue(); + return; + } + + let appendMode = false; + if (status === 206) { + // Partial content - resume mode + appendMode = true; + const contentRange = response.headers['content-range']; + if (contentRange) { + const totalMatch = contentRange.match(/\/(\d+)/); + if (totalMatch) { + task.total = parseInt(totalMatch[1], 10); + } + } + } else { + // Full response (200) - start from beginning + task.loaded = 0; + const contentLength = response.headers['content-length']; + task.total = contentLength ? parseInt(contentLength, 10) : 0; + } + + // Create write stream + writer = fs.createWriteStream(task.tempFilePath, { + flags: appendMode ? 'a' : 'w' + }); + + // Track progress with throttling + response.data.on('data', (chunk: Buffer) => { + task.loaded += chunk.length; + if (task.total > 0) { + task.progress = Math.round((task.loaded / task.total) * 100); + } + + // Throttle progress events to max 4/sec (250ms interval) + const now = Date.now(); + const lastSent = this.progressThrottles.get(task.taskId) || 0; + if (now - lastSent >= 250) { + this.progressThrottles.set(task.taskId, now); + this.sendProgress(task); + } + }); + + // Wait for download to complete + await new Promise((resolve, reject) => { + writer!.on('finish', () => resolve()); + writer!.on('error', (error) => reject(error)); + response.data.on('error', (error: Error) => reject(error)); + response.data.pipe(writer!); + }); + + // Send final progress + task.progress = 100; + this.sendProgress(task); + + // Finalize + await this.finalizeDownload(task, sanitizedFilename, downloadPath); + } catch (error: any) { + if (axios.isCancel(error) || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { + // Aborted by user (pause/cancel) - do not set error state + return; + } + + console.error(`Download error for task ${task.taskId}:`, error); + task.state = 'error'; + task.error = error.message || 'Download failed'; + this.sendStateChange(task); + + // Track batch error + this.handleBatchError(task); + + // Cleanup temp file on error + if (task.tempFilePath && fs.existsSync(task.tempFilePath)) { + try { + fs.unlinkSync(task.tempFilePath); + task.tempFilePath = ''; + task.loaded = 0; + } catch (e) { + console.error('Failed to delete temp file:', e); + } + } + + this.persistQueue(); + } finally { + this.abortControllers.delete(task.taskId); + this.progressThrottles.delete(task.taskId); + + // Only decrement if we were actively downloading (not already decremented in 403/410 path) + if (task.state !== 'queued') { + this.activeCount--; + } + this.processQueue(); + } + } + + // ─── Finalize download ───────────────────────────────────────── + + private async finalizeDownload( + task: DownloadTask, + sanitizedFilename: string, + downloadPath: string + ): Promise { + const configStore = getStore(); + const apiPort = configStore.get('set.musicApiPort') || 30488; + + // Detect file type + let fileExtension = ''; + try { + const fileType = await fileTypeFromFile(task.tempFilePath); + if (fileType && fileType.ext) { + fileExtension = `.${fileType.ext}`; + } else { + const metadata = await mm.parseFile(task.tempFilePath); + if (metadata && metadata.format) { + const container = metadata.format.container || ''; + const codec = metadata.format.codec || ''; + + const formatMap: Record = { + mp3: ['MPEG', 'MP3', 'mp3'], + aac: ['AAC'], + flac: ['FLAC'], + ogg: ['Ogg', 'Vorbis'], + wav: ['WAV', 'PCM'], + m4a: ['M4A', 'MP4'] + }; + + const format = Object.entries(formatMap).find(([_, keywords]) => + keywords.some((keyword) => container.includes(keyword) || codec.includes(keyword)) + ); + + fileExtension = format ? `.${format[0]}` : `.${task.type || 'mp3'}`; + } else { + fileExtension = `.${task.type || 'mp3'}`; + } + } + } catch { + fileExtension = `.${task.type || 'mp3'}`; + } + + // Build final file path with dedup + let finalFilePath = path.join(downloadPath, `${sanitizedFilename}${fileExtension}`); + let counter = 1; + while (fs.existsSync(finalFilePath)) { + const ext = path.extname(finalFilePath); + const base = path.join(downloadPath, sanitizedFilename); + finalFilePath = `${base} (${counter})${ext}`; + counter++; + } + + // Move temp to final + fs.copyFileSync(task.tempFilePath, finalFilePath); + fs.unlinkSync(task.tempFilePath); + task.finalFilePath = finalFilePath; + + // Download lyrics + let lyricsContent = ''; + let lyricData = null; + try { + if (task.songInfo?.id) { + const lyricsResponse = await axios.get( + `http://localhost:${apiPort}/lyric?id=${task.songInfo.id}` + ); + if (lyricsResponse.data && (lyricsResponse.data.lrc || lyricsResponse.data.tlyric)) { + lyricData = lyricsResponse.data; + + if (lyricsResponse.data.lrc && lyricsResponse.data.lrc.lyric) { + lyricsContent = lyricsResponse.data.lrc.lyric; + + if (lyricsResponse.data.tlyric && lyricsResponse.data.tlyric.lyric) { + const originalLyrics = parseLyrics(lyricsResponse.data.lrc.lyric); + const translatedLyrics = parseLyrics(lyricsResponse.data.tlyric.lyric); + lyricsContent = mergeLyrics(originalLyrics, translatedLyrics); + } + } + } + } + } catch (lyricError) { + console.error('Failed to download lyrics:', lyricError); + } + + // Download cover + let coverImageBuffer: Buffer | null = null; + try { + const picUrl = task.songInfo?.picUrl || task.songInfo?.al?.picUrl; + if (picUrl && picUrl !== '/images/default_cover.png') { + if (picUrl.startsWith('data:')) { + const base64Match = picUrl.match(/^data:[^;]+;base64,(.+)$/); + if (base64Match) { + coverImageBuffer = Buffer.from(base64Match[1], 'base64'); + } + } else { + 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; + + if (originalCoverBuffer.length > TWO_MB) { + try { + const image = nativeImage.createFromBuffer(originalCoverBuffer); + const size = image.getSize(); + 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); + } + + const resizedImage = image.resize({ + width: newWidth, + height: newHeight, + quality: 'good' + }); + coverImageBuffer = resizedImage.toJPEG(80); + } catch { + coverImageBuffer = originalCoverBuffer; + } + } else { + coverImageBuffer = originalCoverBuffer; + } + } + } + } catch (coverError) { + console.error('Failed to download cover:', coverError); + } + + // Write metadata + // songInfo may carry extra fields (song, no, publishTime) beyond DownloadSongInfo + const info: any = task.songInfo; + const fileFormat = fileExtension.toLowerCase(); + const artistNames = + (info?.ar || info?.song?.artists)?.map((a: any) => a.name).join('\u3001') || '未知艺术家'; + + if (['.mp3'].includes(fileFormat)) { + try { + NodeID3.removeTags(finalFilePath); + + const tags = { + title: info?.name, + artist: artistNames, + TPE1: artistNames, + TPE2: artistNames, + album: info?.al?.name || info?.song?.album?.name || info?.name || task.filename, + APIC: { + imageBuffer: coverImageBuffer, + type: { id: 3, name: 'front cover' }, + description: 'Album cover', + mime: 'image/jpeg' + }, + USLT: { + language: 'chi', + description: 'Lyrics', + text: lyricsContent || '' + }, + trackNumber: info?.no || undefined, + year: info?.publishTime ? new Date(info.publishTime).getFullYear().toString() : undefined + }; + + const success = NodeID3.write(tags, finalFilePath); + if (!success) { + console.error('Failed to write ID3 tags'); + } + } catch (err) { + console.error('Error writing ID3 tags:', err); + } + } else if (['.flac'].includes(fileFormat)) { + try { + const tagMap: FlacTagMap = { + TITLE: info?.name, + ARTIST: artistNames, + ALBUM: info?.al?.name || info?.song?.album?.name || info?.name || task.filename, + LYRICS: lyricsContent || '', + TRACKNUMBER: info?.no ? String(info.no) : '', + DATE: info?.publishTime ? new Date(info.publishTime).getFullYear().toString() : '' + }; + + await writeFlacTags( + { + tagMap, + picture: coverImageBuffer ? { buffer: coverImageBuffer, mime: 'image/jpeg' } : undefined + }, + finalFilePath + ); + } catch (err) { + console.error('Error writing FLAC tags:', err); + } + } + + // Save .lrc file if setting enabled + if (lyricsContent && configStore.get('set.downloadSaveLyric')) { + try { + const lrcFilePath = finalFilePath.replace(/\.[^.]+$/, '.lrc'); + await fs.promises.writeFile(lrcFilePath, lyricsContent, 'utf-8'); + } catch (lrcError) { + console.error('Failed to save lyrics file:', lrcError); + } + } + + // Save to downloadedSongs + const songInfos = (configStore.get('downloadedSongs') || {}) as Record; + const defaultInfo = { + name: task.filename, + ar: [{ name: '本地音乐' }], + picUrl: '/images/default_cover.png' + }; + + const totalSize = task.total; + const newSongInfo = { + id: task.songInfo?.id || 0, + name: task.songInfo?.name || task.filename, + filename: task.filename, + picUrl: task.songInfo?.picUrl || task.songInfo?.al?.picUrl || defaultInfo.picUrl, + ar: task.songInfo?.ar || defaultInfo.ar, + al: task.songInfo?.al || { + picUrl: task.songInfo?.picUrl || defaultInfo.picUrl, + name: task.songInfo?.name || task.filename + }, + size: totalSize, + path: finalFilePath, + downloadTime: Date.now(), + type: fileExtension.substring(1), + lyric: lyricData + }; + + songInfos[finalFilePath] = newSongInfo; + configStore.set('downloadedSongs', songInfos); + + // Update task state + task.state = 'completed'; + this.sendStateChange(task); + + // Handle notifications + if (task.batchId) { + const batch = this.batchTracker.get(task.batchId); + if (batch) { + batch.finished++; + batch.success++; + + if (batch.finished >= batch.total) { + // All tasks in batch complete + const failed = batch.total - batch.success; + + try { + const notification = new Notification({ + title: '批量下载完成', + body: `共 ${batch.total} 首,成功 ${batch.success} 首${failed > 0 ? `,失败 ${failed} 首` : ''}`, + silent: false + }); + notification.show(); + } catch (e) { + console.error('Failed to send batch notification:', e); + } + + const batchEvent: DownloadBatchCompleteEvent = { + batchId: task.batchId, + total: batch.total, + success: batch.success, + failed + }; + this.sendToRenderer('download:batch-complete', batchEvent); + this.batchTracker.delete(task.batchId); + } + } + } else { + // Individual notification + try { + const notification = new Notification({ + title: '下载完成', + body: `${task.songInfo?.name || task.filename} - ${artistNames}`, + silent: false + }); + notification.on('click', () => { + shell.showItemInFolder(finalFilePath); + }); + notification.show(); + } catch (e) { + console.error('Failed to send notification:', e); + } + } + + // Remove completed task from active tasks and persist + this.tasks.delete(task.taskId); + this.persistQueue(); + } + + // ─── Batch error tracking ────────────────────────────────────── + + private handleBatchError(task: DownloadTask): void { + if (!task.batchId) return; + const batch = this.batchTracker.get(task.batchId); + if (!batch) return; + + batch.finished++; + // success not incremented for errors + + if (batch.finished >= batch.total) { + const failed = batch.total - batch.success; + try { + const notification = new Notification({ + title: '批量下载完成', + body: `共 ${batch.total} 首,成功 ${batch.success} 首${failed > 0 ? `,失败 ${failed} 首` : ''}`, + silent: false + }); + notification.show(); + } catch (e) { + console.error('Failed to send batch notification:', e); + } + + const batchEvent: DownloadBatchCompleteEvent = { + batchId: task.batchId, + total: batch.total, + success: batch.success, + failed + }; + this.sendToRenderer('download:batch-complete', batchEvent); + this.batchTracker.delete(task.batchId); + } + } + + // ─── IPC send helpers ────────────────────────────────────────── + + private sendToRenderer(channel: string, data: any): void { + try { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(channel, data); + } + } catch { + // Window may have been closed + } + } + + private sendStateChange(task: DownloadTask): void { + const event: DownloadStateChangeEvent = { + taskId: task.taskId, + state: task.state, + task: { ...task } + }; + this.sendToRenderer('download:state-change', event); + } + + private sendProgress(task: DownloadTask): void { + const event: DownloadProgressEvent = { + taskId: task.taskId, + progress: task.progress, + loaded: task.loaded, + total: task.total + }; + this.sendToRenderer('download:progress', event); + } + + // ─── Persistence ─────────────────────────────────────────────── + + private persistQueue(): void { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + } + this.persistTimer = setTimeout(() => { + this.persistQueueSync(); + }, 500); + } + + private persistQueueSync(): void { + const tasksToSave = [...this.tasks.values()].filter( + (t) => t.state === 'queued' || t.state === 'paused' || t.state === 'downloading' + ); + // Mark downloading as paused for persistence + const serialized = tasksToSave.map((t) => ({ + ...t, + state: (t.state === 'downloading' ? 'paused' : t.state) as DownloadTaskState + })); + this.persistStore.set('tasks', serialized); + } + + private loadPersistedQueue(): void { + try { + const saved = this.persistStore.get('tasks', []); + for (const task of saved) { + // Treat any 'downloading' as 'paused' on load + if (task.state === 'downloading') { + task.state = 'paused'; + } + this.tasks.set(task.taskId, task); + } + } catch (error) { + console.error('Failed to load persisted download queue:', error); + } + } + + private cleanOrphanedTempFiles(): void { + try { + const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp'); + if (!fs.existsSync(tempDir)) return; + + const knownTempPaths = new Set( + [...this.tasks.values()].map((t) => t.tempFilePath).filter(Boolean) + ); + + const files = fs.readdirSync(tempDir); + for (const file of files) { + if (!file.endsWith('.tmp')) continue; + const fullPath = path.join(tempDir, file); + if (!knownTempPaths.has(fullPath)) { + try { + fs.unlinkSync(fullPath); + } catch { + // ignore + } + } + } + } catch { + // Temp dir may not exist + } + } +} + +// ─── Singleton & exports ─────────────────────────────────────────── + +let instance: DownloadManager | null = null; + +export function initializeDownloadManager(): void { + instance = new DownloadManager(); + instance.registerIpcHandlers(); +} + +export function setDownloadManagerWindow(mainWindow: BrowserWindow): void { + if (instance) { + instance.setMainWindow(mainWindow); + } +} diff --git a/src/main/modules/fileManager.ts b/src/main/modules/fileManager.ts index d213b7f..ce6e823 100644 --- a/src/main/modules/fileManager.ts +++ b/src/main/modules/fileManager.ts @@ -1,30 +1,10 @@ -import axios from 'axios'; -import { app, dialog, ipcMain, nativeImage, Notification, protocol, shell } from 'electron'; +import { app, dialog, ipcMain, protocol, shell } from 'electron'; import Store from 'electron-store'; -import { fileTypeFromFile } from 'file-type'; -import { FlacTagMap, writeFlacTags } from 'flac-tagger'; import * as fs from 'fs'; -import * as http from 'http'; -import * as https from 'https'; -import * as mm from 'music-metadata'; -import * as NodeID3 from 'node-id3'; -import * as os from 'os'; import * as path from 'path'; import { getStore } from './config'; -const MAX_CONCURRENT_DOWNLOADS = 3; -const downloadQueue: { url: string; filename: string; songInfo: any; type?: string }[] = []; -let activeDownloads = 0; - -// 创建一个store实例用于存储下载历史 -const downloadStore = new Store({ - name: 'downloads', - defaults: { - history: [] - } -}); - // 创建一个store实例用于存储音频缓存 const audioCacheStore = new Store({ name: 'audioCache', @@ -33,8 +13,15 @@ const audioCacheStore = new Store({ } }); -// 保存已发送通知的文件,避免重复通知 -const sentNotifications = new Map(); +/** + * 清理文件名中的非法字符 + */ +function sanitizeFilename(filename: string): string { + return filename + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .trim(); +} /** * 初始化文件管理相关的IPC监听 @@ -130,122 +117,6 @@ export function initializeFileManager() { return app.getPath('downloads'); }); - // 获取存储的配置值 - ipcMain.handle('get-store-value', (_, key) => { - const store = new Store(); - return store.get(key); - }); - - // 设置存储的配置值 - ipcMain.on('set-store-value', (_, key, value) => { - const store = new Store(); - store.set(key, value); - }); - - // 下载音乐处理 - ipcMain.on('download-music', handleDownloadRequest); - - // 检查文件是否已下载 - ipcMain.handle('check-music-downloaded', (_, filename: string) => { - const store = new Store(); - const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads'); - const filePath = path.join(downloadPath, `${filename}.mp3`); - return fs.existsSync(filePath); - }); - - // 删除已下载的音乐 - ipcMain.handle('delete-downloaded-music', async (_, filePath: string) => { - try { - if (fs.existsSync(filePath)) { - // 先删除文件 - try { - await fs.promises.unlink(filePath); - } catch (error) { - console.error('Error deleting file:', error); - } - - // 删除对应的歌曲信息 - const store = new Store(); - const songInfos = store.get('downloadedSongs', {}) as Record; - delete songInfos[filePath]; - store.set('downloadedSongs', songInfos); - - return true; - } - return false; - } catch (error) { - console.error('Error deleting file:', error); - return false; - } - }); - - // 获取已下载音乐列表 - ipcMain.handle('get-downloaded-music', async () => { - try { - const store = new Store(); - const songInfos = store.get('downloadedSongs', {}) as Record; - - // 异步处理文件存在性检查 - const entriesArray = Object.entries(songInfos); - const validEntriesPromises = await Promise.all( - entriesArray.map(async ([path, info]) => { - try { - const exists = await fs.promises - .access(path) - .then(() => true) - .catch(() => false); - return exists ? info : null; - } catch (error) { - console.error('Error checking file existence:', error); - return null; - } - }) - ); - - // 过滤有效的歌曲并排序 - const validSongs = validEntriesPromises - .filter((song) => song !== null) - .sort((a, b) => (b.downloadTime || 0) - (a.downloadTime || 0)); - - // 更新存储,移除不存在的文件记录 - const newSongInfos = validSongs.reduce((acc, song) => { - if (song && song.path) { - acc[song.path] = song; - } - return acc; - }, {}); - store.set('downloadedSongs', newSongInfos); - - return validSongs; - } catch (error) { - console.error('Error getting downloaded music:', error); - return []; - } - }); - - // 检查歌曲是否已下载并返回本地路径 - ipcMain.handle('check-song-downloaded', (_, songId: number) => { - const store = new Store(); - const songInfos = store.get('downloadedSongs', {}) as Record; - - // 通过ID查找已下载的歌曲 - for (const [path, info] of Object.entries(songInfos)) { - if (info.id === songId && fs.existsSync(path)) { - return { - isDownloaded: true, - localPath: `local://${path}`, - songInfo: info - }; - } - } - - return { - isDownloaded: false, - localPath: '', - songInfo: null - }; - }); - // 保存歌词文件 ipcMain.handle( 'save-lyric-file', @@ -273,18 +144,6 @@ export function initializeFileManager() { } ); - // 添加清除下载历史的处理函数 - ipcMain.on('clear-downloads-history', () => { - downloadStore.set('history', []); - }); - - // 添加清除已下载音乐记录的处理函数 - ipcMain.handle('clear-downloaded-music', () => { - const store = new Store(); - store.set('downloadedSongs', {}); - return true; - }); - // 添加清除音频缓存的处理函数 ipcMain.on('clear-audio-cache', () => { audioCacheStore.set('cache', {}); @@ -378,613 +237,3 @@ export function initializeFileManager() { } }); } - -/** - * 处理下载请求 - */ -function handleDownloadRequest( - event: Electron.IpcMainEvent, - { - url, - filename, - songInfo, - type - }: { url: string; filename: string; songInfo?: any; type?: string } -) { - // 检查是否已经在队列中或正在下载 - if (downloadQueue.some((item) => item.filename === filename)) { - event.reply('music-download-error', { - filename, - error: '该歌曲已在下载队列中' - }); - return; - } - - // 检查是否已下载 - const store = new Store(); - const songInfos = store.get('downloadedSongs', {}) as Record; - - // 检查是否已下载(通过ID) - const isDownloaded = - songInfo?.id && Object.values(songInfos).some((info: any) => info.id === songInfo.id); - - if (isDownloaded) { - event.reply('music-download-error', { - filename, - error: '该歌曲已下载' - }); - return; - } - - // 添加到下载队列 - downloadQueue.push({ url, filename, songInfo, type }); - event.reply('music-download-queued', { - filename, - songInfo - }); - - // 尝试开始下载 - processDownloadQueue(event); -} - -/** - * 处理下载队列 - */ -async function processDownloadQueue(event: Electron.IpcMainEvent) { - if (activeDownloads >= MAX_CONCURRENT_DOWNLOADS || downloadQueue.length === 0) { - return; - } - - const { url, filename, songInfo, type } = downloadQueue.shift()!; - activeDownloads++; - - try { - await downloadMusic(event, { url, filename, songInfo, type }); - } finally { - activeDownloads--; - processDownloadQueue(event); - } -} - -/** - * 清理文件名中的非法字符 - */ -function sanitizeFilename(filename: string): string { - // 替换 Windows 和 Unix 系统中的非法字符 - return filename - .replace(/[<>:"/\\|?*]/g, '_') // 替换特殊字符为下划线 - .replace(/\s+/g, ' ') // 将多个空格替换为单个空格 - .trim(); // 移除首尾空格 -} - -/** - * 下载音乐和歌词 - */ -async function downloadMusic( - event: Electron.IpcMainEvent, - { - url, - filename, - songInfo, - type = 'mp3' - }: { url: string; filename: string; songInfo: any; type?: string } -) { - let finalFilePath = ''; - let writer: fs.WriteStream | null = null; - let tempFilePath = ''; - - try { - // 使用配置Store来获取设置 - const configStore = getStore(); - const downloadPath = - (configStore.get('set.downloadPath') as string) || app.getPath('downloads'); - const apiPort = configStore.get('set.musicApiPort') || 30488; - - // 获取文件名格式设置 - const nameFormat = - (configStore.get('set.downloadNameFormat') as string) || '{songName} - {artistName}'; - - // 根据格式创建文件名 - let formattedFilename = filename; - if (songInfo) { - // 准备替换变量 - const artistName = songInfo.ar?.map((a: any) => a.name).join('、') || '未知艺术家'; - const songName = songInfo.name || filename; - const albumName = songInfo.al?.name || '未知专辑'; - - // 应用自定义格式 - formattedFilename = nameFormat - .replace(/\{songName\}/g, songName) - .replace(/\{artistName\}/g, artistName) - .replace(/\{albumName\}/g, albumName); - } - - // 清理文件名中的非法字符 - const sanitizedFilename = sanitizeFilename(formattedFilename); - - // 创建临时文件路径 (在系统临时目录中创建) - const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp'); - - // 确保临时目录存在 - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - - tempFilePath = path.join(tempDir, `${Date.now()}_${sanitizedFilename}.tmp`); - - // 先获取文件大小 - const headResponse = await axios.head(url); - const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10); - - // 开始下载到临时文件 - const response = await axios({ - url, - method: 'GET', - responseType: 'stream', - timeout: 30000, // 30秒超时 - httpAgent: new http.Agent({ keepAlive: true }), - httpsAgent: new https.Agent({ keepAlive: true }) - }); - - writer = fs.createWriteStream(tempFilePath); - let downloadedSize = 0; - - // 使用 data 事件来跟踪下载进度 - response.data.on('data', (chunk: Buffer) => { - downloadedSize += chunk.length; - const progress = Math.round((downloadedSize / totalSize) * 100); - event.reply('music-download-progress', { - filename, - progress, - loaded: downloadedSize, - total: totalSize, - path: tempFilePath, - status: progress === 100 ? 'completed' : 'downloading', - songInfo: songInfo || { - name: filename, - ar: [{ name: '本地音乐' }], - picUrl: '/images/default_cover.png' - } - }); - }); - - // 等待下载完成 - await new Promise((resolve, reject) => { - writer!.on('finish', () => resolve(undefined)); - writer!.on('error', (error) => reject(error)); - response.data.pipe(writer!); - }); - - // 验证文件是否完整下载 - const stats = fs.statSync(tempFilePath); - if (stats.size !== totalSize) { - throw new Error('文件下载不完整'); - } - - // 检测文件类型 - let fileExtension = ''; - - try { - // 首先尝试使用file-type库检测 - const fileType = await fileTypeFromFile(tempFilePath); - if (fileType && fileType.ext) { - fileExtension = `.${fileType.ext}`; - console.log(`文件类型检测结果: ${fileType.mime}, 扩展名: ${fileExtension}`); - } else { - // 如果file-type无法识别,尝试使用music-metadata - const metadata = await mm.parseFile(tempFilePath); - if (metadata && metadata.format) { - // 根据format.container或codec判断扩展名 - const formatInfo = metadata.format; - const container = formatInfo.container || ''; - const codec = formatInfo.codec || ''; - - // 音频格式映射表 - const formatMap = { - mp3: ['MPEG', 'MP3', 'mp3'], - aac: ['AAC'], - flac: ['FLAC'], - ogg: ['Ogg', 'Vorbis'], - wav: ['WAV', 'PCM'], - m4a: ['M4A', 'MP4'] - }; - - // 查找匹配的格式 - const format = Object.entries(formatMap).find(([_, keywords]) => - keywords.some((keyword) => container.includes(keyword) || codec.includes(keyword)) - ); - - // 设置文件扩展名,如果没找到则默认为mp3 - fileExtension = format ? `.${format[0]}` : '.mp3'; - - console.log( - `music-metadata检测结果: 容器:${container}, 编码:${codec}, 扩展名: ${fileExtension}` - ); - } else { - // 两种方法都失败,使用传入的type或默认mp3 - fileExtension = type ? `.${type}` : '.mp3'; - console.log(`无法检测文件类型,使用默认扩展名: ${fileExtension}`); - } - } - } catch (err) { - console.error('检测文件类型失败:', err); - // 检测失败,使用传入的type或默认mp3 - fileExtension = type ? `.${type}` : '.mp3'; - } - - // 使用检测到的文件扩展名创建最终文件路径 - const filePath = path.join(downloadPath, `${sanitizedFilename}${fileExtension}`); - - // 检查文件是否已存在,如果存在则添加序号 - finalFilePath = filePath; - let counter = 1; - while (fs.existsSync(finalFilePath)) { - const ext = path.extname(filePath); - const nameWithoutExt = filePath.slice(0, -ext.length); - finalFilePath = `${nameWithoutExt} (${counter})${ext}`; - counter++; - } - - // 将临时文件移动到最终位置 - fs.copyFileSync(tempFilePath, finalFilePath); - fs.unlinkSync(tempFilePath); // 删除临时文件 - - // 下载歌词 - let lyricData = null; - let lyricsContent = ''; - try { - if (songInfo?.id) { - // 下载歌词,使用配置的端口 - const lyricsResponse = await axios.get( - `http://localhost:${apiPort}/lyric?id=${songInfo.id}` - ); - if (lyricsResponse.data && (lyricsResponse.data.lrc || lyricsResponse.data.tlyric)) { - lyricData = lyricsResponse.data; - - // 处理歌词内容 - if (lyricsResponse.data.lrc && lyricsResponse.data.lrc.lyric) { - lyricsContent = lyricsResponse.data.lrc.lyric; - - // 如果有翻译歌词,合并到主歌词中 - if (lyricsResponse.data.tlyric && lyricsResponse.data.tlyric.lyric) { - // 解析原歌词和翻译 - const originalLyrics = parseLyrics(lyricsResponse.data.lrc.lyric); - const translatedLyrics = parseLyrics(lyricsResponse.data.tlyric.lyric); - - // 合并歌词 - const mergedLyrics = mergeLyrics(originalLyrics, translatedLyrics); - lyricsContent = mergedLyrics; - } - } - - console.log('歌词已准备好,将写入元数据'); - } - } - } catch (lyricError) { - console.error('下载歌词失败:', lyricError); - // 继续处理,不影响音乐下载 - } - - // 下载封面 - let coverImageBuffer: Buffer | null = null; - try { - if (songInfo?.picUrl || songInfo?.al?.picUrl) { - const picUrl = songInfo.picUrl || songInfo.al?.picUrl; - if (picUrl && picUrl !== '/images/default_cover.png') { - // 处理 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 { - 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('封面已准备好,将写入元数据'); - } - } - } catch (coverError) { - console.error('下载封面失败:', coverError); - // 继续处理,不影响音乐下载 - } - - const fileFormat = fileExtension.toLowerCase(); - const artistNames = - (songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('、') || '未知艺术家'; - - // 根据文件类型处理元数据 - if (['.mp3'].includes(fileFormat)) { - // 对MP3文件使用NodeID3处理ID3标签 - try { - // 在写入ID3标签前,先移除可能存在的旧标签 - NodeID3.removeTags(finalFilePath); - - const tags = { - title: songInfo?.name, - artist: artistNames, - TPE1: artistNames, - TPE2: artistNames, - album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename, - APIC: { - // 专辑封面 - imageBuffer: coverImageBuffer, - type: { - id: 3, - name: 'front cover' - }, - description: 'Album cover', - mime: 'image/jpeg' - }, - USLT: { - // 歌词 - language: 'chi', - description: 'Lyrics', - text: lyricsContent || '' - }, - trackNumber: songInfo?.no || undefined, - year: songInfo?.publishTime - ? new Date(songInfo.publishTime).getFullYear().toString() - : undefined - }; - - const success = NodeID3.write(tags, finalFilePath); - if (!success) { - console.error('Failed to write ID3 tags'); - } else { - console.log('ID3 tags written successfully'); - } - } catch (err) { - console.error('Error writing ID3 tags:', err); - } - } else if (['.flac'].includes(fileFormat)) { - try { - const tagMap: FlacTagMap = { - TITLE: songInfo?.name, - ARTIST: artistNames, - ALBUM: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename, - LYRICS: lyricsContent || '', - TRACKNUMBER: songInfo?.no ? String(songInfo.no) : '', - DATE: songInfo?.publishTime ? new Date(songInfo.publishTime).getFullYear().toString() : '' - }; - - await writeFlacTags( - { - tagMap, - picture: coverImageBuffer - ? { - buffer: coverImageBuffer, - mime: 'image/jpeg' - } - : undefined - }, - finalFilePath - ); - console.log('FLAC tags written successfully'); - } catch (err) { - console.error('Error writing FLAC tags:', err); - } - } - - // 如果启用了单独保存歌词文件,将歌词保存为 .lrc 文件 - if (lyricsContent && configStore.get('set.downloadSaveLyric')) { - try { - const lrcFilePath = finalFilePath.replace(/\.[^.]+$/, '.lrc'); - await fs.promises.writeFile(lrcFilePath, lyricsContent, 'utf-8'); - console.log('歌词文件已保存:', lrcFilePath); - } catch (lrcError) { - console.error('保存歌词文件失败:', lrcError); - } - } - - // 保存下载信息 - try { - const songInfos = configStore.get('downloadedSongs', {}) as Record; - const defaultInfo = { - name: filename, - ar: [{ name: '本地音乐' }], - picUrl: '/images/default_cover.png' - }; - - const newSongInfo = { - id: songInfo?.id || 0, - name: songInfo?.name || filename, - filename, - picUrl: songInfo?.picUrl || songInfo?.al?.picUrl || defaultInfo.picUrl, - ar: songInfo?.ar || defaultInfo.ar, - al: songInfo?.al || { - picUrl: songInfo?.picUrl || defaultInfo.picUrl, - name: songInfo?.name || filename - }, - size: totalSize, - path: finalFilePath, - downloadTime: Date.now(), - type: fileExtension.substring(1), // 去掉前面的点号,只保留扩展名 - lyric: lyricData - }; - - // 保存到下载记录 - songInfos[finalFilePath] = newSongInfo; - configStore.set('downloadedSongs', songInfos); - - // 添加到下载历史 - const history = downloadStore.get('history', []) as any[]; - history.unshift(newSongInfo); - downloadStore.set('history', history); - - // 避免重复发送通知 - const notificationId = `download-${finalFilePath}`; - if (!sentNotifications.has(notificationId)) { - sentNotifications.set(notificationId, true); - - // 发送桌面通知 - try { - const artistNames = - (songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('、') || - '未知艺术家'; - const notification = new Notification({ - title: '下载完成', - body: `${songInfo?.name || filename} - ${artistNames}`, - silent: false - }); - - notification.on('click', () => { - shell.showItemInFolder(finalFilePath); - }); - - notification.show(); - - // 60秒后清理通知记录,释放内存 - setTimeout(() => { - sentNotifications.delete(notificationId); - }, 60000); - } catch (notifyError) { - console.error('发送通知失败:', notifyError); - } - } - - // 发送下载完成事件,确保只发送一次 - event.reply('music-download-complete', { - success: true, - path: finalFilePath, - filename, - size: totalSize, - songInfo: newSongInfo - }); - } catch (error) { - console.error('Error saving download info:', error); - throw new Error('保存下载信息失败'); - } - } catch (error: any) { - console.error('Download error:', error); - - // 清理未完成的下载 - if (writer) { - writer.end(); - } - - // 清理临时文件 - if (tempFilePath && fs.existsSync(tempFilePath)) { - try { - fs.unlinkSync(tempFilePath); - } catch (e) { - console.error('Failed to delete temporary file:', e); - } - } - - // 清理未完成的最终文件 - if (finalFilePath && fs.existsSync(finalFilePath)) { - try { - fs.unlinkSync(finalFilePath); - } catch (e) { - console.error('Failed to delete incomplete download:', e); - } - } - - event.reply('music-download-complete', { - success: false, - error: error.message || '下载失败', - filename - }); - } -} - -// 辅助函数 - 解析歌词文本成时间戳和内容的映射 -function parseLyrics(lyricsText: string): Map { - const lyricMap = new Map(); - const lines = lyricsText.split('\n'); - - for (const line of lines) { - // 匹配时间标签,形如 [00:00.000] - const timeTagMatches = line.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g); - if (!timeTagMatches) continue; - - // 提取歌词内容(去除时间标签) - const content = line.replace(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g, '').trim(); - if (!content) continue; - - // 将每个时间标签与歌词内容关联 - for (const timeTag of timeTagMatches) { - lyricMap.set(timeTag, content); - } - } - - return lyricMap; -} - -// 辅助函数 - 合并原文歌词和翻译歌词 -function mergeLyrics( - originalLyrics: Map, - translatedLyrics: Map -): string { - const mergedLines: string[] = []; - - // 对每个时间戳,组合原始歌词和翻译 - for (const [timeTag, originalContent] of originalLyrics.entries()) { - const translatedContent = translatedLyrics.get(timeTag); - - // 添加原始歌词行 - mergedLines.push(`${timeTag}${originalContent}`); - - // 如果有翻译,添加翻译行(时间戳相同,这样可以和原歌词同步显示) - if (translatedContent) { - mergedLines.push(`${timeTag}${translatedContent}`); - } - } - - // 按时间顺序排序 - mergedLines.sort((a, b) => { - const timeA = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; - const timeB = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; - return timeA.localeCompare(timeB); - }); - - return mergedLines.join('\n'); -} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6358e45..199149c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -44,6 +44,25 @@ interface API { parseLocalMusicMetadata: ( filePaths: string[] ) => Promise; + // Download manager + downloadAdd: (task: any) => Promise; + downloadAddBatch: (tasks: any) => Promise<{ batchId: string; taskIds: string[] }>; + downloadPause: (taskId: string) => Promise; + downloadResume: (taskId: string) => Promise; + downloadCancel: (taskId: string) => Promise; + downloadCancelAll: () => Promise; + downloadGetQueue: () => Promise; + downloadSetConcurrency: (n: number) => void; + downloadGetCompleted: () => Promise; + downloadDeleteCompleted: (filePath: string) => Promise; + downloadClearCompleted: () => Promise; + getEmbeddedLyrics: (filePath: string) => Promise; + downloadProvideUrl: (taskId: string, url: string) => Promise; + onDownloadProgress: (cb: (data: any) => void) => void; + onDownloadStateChange: (cb: (data: any) => void) => void; + onDownloadBatchComplete: (cb: (data: any) => void) => void; + onDownloadRequestUrl: (cb: (data: any) => void) => void; + removeDownloadListeners: () => void; } // 自定义IPC渲染进程通信接口 diff --git a/src/preload/index.ts b/src/preload/index.ts index 1de3db8..0c7b664 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -82,7 +82,43 @@ const api = { scanLocalMusicWithStats: (folderPath: string) => ipcRenderer.invoke('scan-local-music-with-stats', folderPath), parseLocalMusicMetadata: (filePaths: string[]) => - ipcRenderer.invoke('parse-local-music-metadata', filePaths) + ipcRenderer.invoke('parse-local-music-metadata', filePaths), + + // Download manager + downloadAdd: (task: any) => ipcRenderer.invoke('download:add', task), + downloadAddBatch: (tasks: any) => ipcRenderer.invoke('download:add-batch', tasks), + downloadPause: (taskId: string) => ipcRenderer.invoke('download:pause', taskId), + downloadResume: (taskId: string) => ipcRenderer.invoke('download:resume', taskId), + downloadCancel: (taskId: string) => ipcRenderer.invoke('download:cancel', taskId), + downloadCancelAll: () => ipcRenderer.invoke('download:cancel-all'), + downloadGetQueue: () => ipcRenderer.invoke('download:get-queue'), + downloadSetConcurrency: (n: number) => ipcRenderer.send('download:set-concurrency', n), + downloadGetCompleted: () => ipcRenderer.invoke('download:get-completed'), + downloadDeleteCompleted: (filePath: string) => + ipcRenderer.invoke('download:delete-completed', filePath), + downloadClearCompleted: () => ipcRenderer.invoke('download:clear-completed'), + getEmbeddedLyrics: (filePath: string) => + ipcRenderer.invoke('download:get-embedded-lyrics', filePath), + downloadProvideUrl: (taskId: string, url: string) => + ipcRenderer.invoke('download:provide-url', { taskId, url }), + onDownloadProgress: (cb: (data: any) => void) => { + ipcRenderer.on('download:progress', (_event: any, data: any) => cb(data)); + }, + onDownloadStateChange: (cb: (data: any) => void) => { + ipcRenderer.on('download:state-change', (_event: any, data: any) => cb(data)); + }, + onDownloadBatchComplete: (cb: (data: any) => void) => { + ipcRenderer.on('download:batch-complete', (_event: any, data: any) => cb(data)); + }, + onDownloadRequestUrl: (cb: (data: any) => void) => { + ipcRenderer.on('download:request-url', (_event: any, data: any) => cb(data)); + }, + removeDownloadListeners: () => { + ipcRenderer.removeAllListeners('download:progress'); + ipcRenderer.removeAllListeners('download:state-change'); + ipcRenderer.removeAllListeners('download:batch-complete'); + ipcRenderer.removeAllListeners('download:request-url'); + } }; // 创建带类型的ipcRenderer对象,暴露给渲染进程 diff --git a/src/renderer/components/common/DownloadDrawer.vue b/src/renderer/components/common/DownloadDrawer.vue index fa207fe..f3e7fcf 100644 --- a/src/renderer/components/common/DownloadDrawer.vue +++ b/src/renderer/components/common/DownloadDrawer.vue @@ -1,9 +1,13 @@ - - diff --git a/src/renderer/hooks/MusicHook.ts b/src/renderer/hooks/MusicHook.ts index dae8c92..3a31a79 100644 --- a/src/renderer/hooks/MusicHook.ts +++ b/src/renderer/hooks/MusicHook.ts @@ -188,10 +188,10 @@ const setupMusicWatchers = () => { if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') { playMusic.value.lyric.hasWordByWord = hasWordByWord; } - } else { + } else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) { // 使用现有的歌词数据结构 - const rawLrc = lyricData?.lrcArray || []; - lrcTimeArray.value = lyricData?.lrcTimeArray || []; + const rawLrc = lyricData.lrcArray || []; + lrcTimeArray.value = lyricData.lrcTimeArray || []; try { const { translateLyrics } = await import('@/services/lyricTranslation'); @@ -200,6 +200,53 @@ const setupMusicWatchers = () => { console.error('翻译歌词失败,使用原始歌词:', e); lrcArray.value = rawLrc as any; } + } else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) { + // 从下载/本地文件的 ID3/FLAC 元数据中提取嵌入歌词 + try { + let filePath = decodeURIComponent( + playMusic.value.playMusicUrl.replace('local:///', '') + ); + // 处理 Windows 路径:/C:/... → C:/... + if (/^\/[a-zA-Z]:\//.test(filePath)) { + filePath = filePath.slice(1); + } + const embeddedLyrics = await window.api.getEmbeddedLyrics(filePath); + if (embeddedLyrics) { + const { + lrcArray: parsedLrcArray, + lrcTimeArray: parsedTimeArray, + hasWordByWord + } = await parseLyricsString(embeddedLyrics); + lrcArray.value = parsedLrcArray; + lrcTimeArray.value = parsedTimeArray; + if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') { + (playMusic.value.lyric as any).hasWordByWord = hasWordByWord; + } + } else { + // 无嵌入歌词 — 若有数字 ID,尝试 API 兜底 + const songId = playMusic.value.id; + if (songId && typeof songId === 'number') { + try { + const { getMusicLrc } = await import('@/api/music'); + const res = await getMusicLrc(songId); + if (res?.data?.lrc?.lyric) { + const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = + await parseLyricsString(res.data.lrc.lyric); + lrcArray.value = apiLrcArray; + lrcTimeArray.value = apiTimeArray; + } + } catch (apiErr) { + console.error('API lyrics fallback failed:', apiErr); + } + } + } + } catch (err) { + console.error('Failed to extract embedded lyrics:', err); + } + } else { + // 无歌词数据 + lrcArray.value = []; + lrcTimeArray.value = []; } // 当歌词数据更新时,如果歌词窗口打开,则发送数据 if (isElectron && isLyricWindowOpen.value) { diff --git a/src/renderer/hooks/useDownload.ts b/src/renderer/hooks/useDownload.ts index e76e98a..0d9d8b9 100644 --- a/src/renderer/hooks/useDownload.ts +++ b/src/renderer/hooks/useDownload.ts @@ -1,160 +1,42 @@ -import { cloneDeep } from 'lodash'; import { useMessage } from 'naive-ui'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { getMusicLrc } from '@/api/music'; +import { useDownloadStore } from '@/store/modules/download'; import { getSongUrl } from '@/store/modules/player'; import type { SongResult } from '@/types/music'; import { isElectron } from '@/utils'; +import type { DownloadSongInfo } from '../../shared/download'; + const ipcRenderer = isElectron ? window.electron.ipcRenderer : null; -// 全局下载管理(闭包模式) -const createDownloadManager = () => { - // 正在下载的文件集合 - const activeDownloads = new Set(); - - // 已经发送了通知的文件集合(避免重复通知) - const notifiedDownloads = new Set(); - - // 事件监听器是否已初始化 - let isInitialized = false; - - // 监听器引用(用于清理) - let completeListener: ((event: any, data: any) => void) | null = null; - let errorListener: ((event: any, data: any) => void) | null = null; - +/** + * Map a SongResult to the minimal DownloadSongInfo shape required by the download store. + */ +function toDownloadSongInfo(song: SongResult): DownloadSongInfo { return { - // 添加下载 - addDownload: (filename: string) => { - activeDownloads.add(filename); - }, - - // 移除下载 - removeDownload: (filename: string) => { - activeDownloads.delete(filename); - // 延迟清理通知记录 - setTimeout(() => { - notifiedDownloads.delete(filename); - }, 5000); - }, - - // 标记文件已通知 - markNotified: (filename: string) => { - notifiedDownloads.add(filename); - }, - - // 检查文件是否已通知 - isNotified: (filename: string) => { - return notifiedDownloads.has(filename); - }, - - // 清理所有下载 - clearDownloads: () => { - activeDownloads.clear(); - notifiedDownloads.clear(); - }, - - // 初始化事件监听器 - initEventListeners: (message: any, t: any) => { - if (isInitialized) return; - - // 移除可能存在的旧监听器 - if (completeListener) { - ipcRenderer?.removeListener('music-download-complete', completeListener); - } - - if (errorListener) { - ipcRenderer?.removeListener('music-download-error', errorListener); - } - - // 创建新的监听器 - completeListener = (_event, data) => { - if (!data.filename || !activeDownloads.has(data.filename)) return; - - // 如果该文件已经通知过,则跳过 - if (notifiedDownloads.has(data.filename)) return; - - // 标记为已通知 - notifiedDownloads.add(data.filename); - - // 从活动下载移除 - activeDownloads.delete(data.filename); - }; - - errorListener = (_event, data) => { - if (!data.filename || !activeDownloads.has(data.filename)) return; - - // 如果该文件已经通知过,则跳过 - if (notifiedDownloads.has(data.filename)) return; - - // 标记为已通知 - notifiedDownloads.add(data.filename); - - // 显示失败通知 - message.error( - t('songItem.message.downloadFailed', { - filename: data.filename, - error: data.error || '未知错误' - }) - ); - - // 从活动下载移除 - activeDownloads.delete(data.filename); - }; - - // 添加监听器 - ipcRenderer?.on('music-download-complete', completeListener); - ipcRenderer?.on('music-download-error', errorListener); - - isInitialized = true; - }, - - // 清理事件监听器 - cleanupEventListeners: () => { - if (!isInitialized) return; - - if (completeListener) { - ipcRenderer?.removeListener('music-download-complete', completeListener); - completeListener = null; - } - - if (errorListener) { - ipcRenderer?.removeListener('music-download-error', errorListener); - errorListener = null; - } - - isInitialized = false; - }, - - // 获取活跃下载数量 - getActiveDownloadCount: () => { - return activeDownloads.size; - }, - - // 检查是否有特定文件正在下载 - hasDownload: (filename: string) => { - return activeDownloads.has(filename); + id: song.id as number, + name: song.name, + picUrl: song.picUrl ?? song.al?.picUrl ?? '', + ar: (song.ar || song.song?.artists || []).map((a: { name: string }) => ({ name: a.name })), + al: { + name: song.al?.name ?? '', + picUrl: song.al?.picUrl ?? '' } }; -}; - -// 创建单例下载管理器 -const downloadManager = createDownloadManager(); +} export const useDownload = () => { const { t } = useI18n(); const message = useMessage(); + const downloadStore = useDownloadStore(); const isDownloading = ref(false); - // 初始化事件监听器 - downloadManager.initEventListeners(message, t); - /** - * 下载单首音乐 - * @param song 歌曲信息 - * @returns Promise + * Download a single song. + * Resolves the URL in the renderer then delegates queuing to the download store. */ const downloadMusic = async (song: SongResult) => { if (isDownloading.value) { @@ -165,55 +47,33 @@ export const useDownload = () => { try { isDownloading.value = true; - const musicUrl = (await getSongUrl(song.id as number, cloneDeep(song), true)) as any; + const musicUrl = (await getSongUrl(song.id as number, song, true)) as any; if (!musicUrl) { throw new Error(t('songItem.message.getUrlFailed')); } - // 构建文件名 - const artistNames = (song.ar || song.song?.artists)?.map((a) => a.name).join(','); - const filename = `${song.name} - ${artistNames}`; - - // 检查是否已在下载 - if (downloadManager.hasDownload(filename)) { - isDownloading.value = false; - return; - } - - // 添加到活动下载集合 - downloadManager.addDownload(filename); - - const songData = cloneDeep(song); - songData.ar = songData.ar || songData.song?.artists; - - // 发送下载请求 - ipcRenderer?.send('download-music', { - url: typeof musicUrl === 'string' ? musicUrl : musicUrl.url, - filename, - songInfo: { - ...songData, - downloadTime: Date.now() - }, - type: musicUrl.type - }); + const url = typeof musicUrl === 'string' ? musicUrl : musicUrl.url; + const type = typeof musicUrl === 'string' ? '' : (musicUrl.type ?? ''); + const songInfo = toDownloadSongInfo(song); + await downloadStore.addDownload(songInfo, url, type); message.success(t('songItem.message.downloadQueued')); - - // 简化的监听逻辑,基本通知由全局监听器处理 - setTimeout(() => { - isDownloading.value = false; - }, 2000); } catch (error: any) { console.error('Download error:', error); - isDownloading.value = false; message.error(error.message || t('songItem.message.downloadFailed')); + } finally { + isDownloading.value = false; } }; /** - * 批量下载音乐 - * @param songs 歌曲列表 - * @returns Promise + * Batch download multiple songs. + * + * NOTE: This deviates slightly from the original spec (which envisioned JIT URL resolution in + * the main process via onDownloadRequestUrl). Instead we pre-resolve URLs here in batches of 5 + * to avoid request storms against the local NeteaseCloudMusicApi service (> ~5 concurrent TLS + * connections can trigger 502s). The trade-off is acceptable: the renderer already has access to + * getSongUrl and this keeps the main process simpler. */ const batchDownloadMusic = async (songs: SongResult[]) => { if (isDownloading.value) { @@ -230,82 +90,46 @@ export const useDownload = () => { isDownloading.value = true; message.success(t('favorite.downloading')); - let successCount = 0; - let failCount = 0; - const totalCount = songs.length; + const BATCH_SIZE = 5; + const resolvedItems: Array<{ songInfo: DownloadSongInfo; url: string; type: string }> = []; - // 下载进度追踪 - const trackProgress = () => { - if (successCount + failCount === totalCount) { - isDownloading.value = false; - message.success(t('favorite.downloadSuccess')); + // Resolve URLs in batches of 5 to avoid request storms + for (let i = 0; i < songs.length; i += BATCH_SIZE) { + const chunk = songs.slice(i, i + BATCH_SIZE); + const chunkResults = await Promise.all( + chunk.map(async (song) => { + try { + const data = (await getSongUrl(song.id as number, song, true)) as any; + const url = typeof data === 'string' ? data : (data?.url ?? ''); + const type = typeof data === 'string' ? '' : (data?.type ?? ''); + if (!url) return null; + return { songInfo: toDownloadSongInfo(song), url, type }; + } catch (error) { + console.error(`获取歌曲 ${song.name} 下载链接失败:`, error); + return null; + } + }) + ); + for (const item of chunkResults) { + if (item) resolvedItems.push(item); } - }; + } - // 并行获取所有歌曲的下载链接 - const downloadUrls = await Promise.all( - songs.map(async (song) => { - try { - const data = (await getSongUrl(song.id, song, true)) as any; - return { song, ...data }; - } catch (error) { - console.error(`获取歌曲 ${song.name} 下载链接失败:`, error); - failCount++; - return { song, url: null }; - } - }) - ); - - // 开始下载有效的链接 - downloadUrls.forEach(({ song, url, type }) => { - if (!url) { - failCount++; - trackProgress(); - return; - } - - const songData = cloneDeep(song); - const filename = `${song.name} - ${(song.ar || song.song?.artists)?.map((a) => a.name).join(',')}`; - - // 检查是否已在下载 - if (downloadManager.hasDownload(filename)) { - failCount++; - trackProgress(); - return; - } - - // 添加到活动下载集合 - downloadManager.addDownload(filename); - - const songInfo = { - ...songData, - ar: songData.ar || songData.song?.artists, - downloadTime: Date.now() - }; - - ipcRenderer?.send('download-music', { - url, - filename, - songInfo, - type - }); - - successCount++; - }); - - // 所有下载开始后,检查进度 - trackProgress(); + if (resolvedItems.length > 0) { + await downloadStore.batchDownload(resolvedItems); + } } catch (error) { console.error('下载失败:', error); - isDownloading.value = false; message.destroyAll(); message.error(t('favorite.downloadFailed')); + } finally { + isDownloading.value = false; } }; /** - * 下载单首歌曲的歌词(.lrc 文件) - * @param song 歌曲信息 + * Download the lyric (.lrc) for a single song. + * This is independent of the download system and uses a direct IPC call. */ const downloadLyric = async (song: SongResult) => { try { @@ -317,14 +141,15 @@ export const useDownload = () => { return; } - // 构建 LRC 内容:保留原始歌词,如有翻译则合并 + // Build LRC content: keep original lyrics, merge translation if available let lrcContent = lyricData.lrc.lyric; if (lyricData.tlyric?.lyric) { lrcContent = mergeLrcWithTranslation(lyricData.lrc.lyric, lyricData.tlyric.lyric); } - // 构建文件名 - const artistNames = (song.ar || song.song?.artists)?.map((a) => a.name).join(','); + const artistNames = (song.ar || song.song?.artists) + ?.map((a: { name: string }) => a.name) + .join(','); const filename = `${song.name} - ${artistNames}`; const result = await ipcRenderer?.invoke('save-lyric-file', { filename, lrcContent }); @@ -349,7 +174,7 @@ export const useDownload = () => { }; /** - * 将原文歌词和翻译歌词合并为一个 LRC 字符串 + * Merge original LRC lyrics and translated LRC lyrics into a single LRC string. */ function mergeLrcWithTranslation(originalText: string, translationText: string): string { const originalMap = parseLrcText(originalText); @@ -365,7 +190,7 @@ function mergeLrcWithTranslation(originalText: string, translationText: string): } } - // 按时间排序 + // Sort by time tag mergedLines.sort((a, b) => { const ta = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; const tb = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; @@ -376,7 +201,7 @@ function mergeLrcWithTranslation(originalText: string, translationText: string): } /** - * 解析 LRC 文本为 Map + * Parse LRC text into a Map. */ function parseLrcText(text: string): Map { const map = new Map(); diff --git a/src/renderer/hooks/useDownloadStatus.ts b/src/renderer/hooks/useDownloadStatus.ts deleted file mode 100644 index e53f13e..0000000 --- a/src/renderer/hooks/useDownloadStatus.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { computed, onMounted, ref } from 'vue'; -import { useRouter } from 'vue-router'; - -const downloadList = ref([]); -const isInitialized = ref(false); - -export const useDownloadStatus = () => { - const router = useRouter(); - - const downloadingCount = computed(() => { - return downloadList.value.filter((item) => item.status === 'downloading').length; - }); - - const navigateToDownloads = () => { - router.push('/downloads'); - }; - - const initDownloadListeners = () => { - if (isInitialized.value) return; - - if (!window.electron?.ipcRenderer) return; - - window.electron.ipcRenderer.on('music-download-progress', (_, data) => { - const existingItem = downloadList.value.find((item) => item.filename === data.filename); - - if (data.progress === 100) { - data.status = 'completed'; - } - - if (existingItem) { - Object.assign(existingItem, { - ...data, - songInfo: data.songInfo || existingItem.songInfo - }); - - if (data.status === 'completed') { - downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename); - } - } else { - downloadList.value.push({ - ...data, - songInfo: data.songInfo - }); - } - }); - - window.electron.ipcRenderer.on('music-download-complete', async (_, data) => { - if (data.success) { - downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename); - } else { - const existingItem = downloadList.value.find((item) => item.filename === data.filename); - if (existingItem) { - Object.assign(existingItem, { - status: 'error', - error: data.error, - progress: 0 - }); - setTimeout(() => { - downloadList.value = downloadList.value.filter( - (item) => item.filename !== data.filename - ); - }, 3000); - } - } - }); - - window.electron.ipcRenderer.on('music-download-queued', (_, data) => { - const existingItem = downloadList.value.find((item) => item.filename === data.filename); - if (!existingItem) { - downloadList.value.push({ - filename: data.filename, - progress: 0, - loaded: 0, - total: 0, - path: '', - status: 'downloading', - songInfo: data.songInfo - }); - } - }); - - isInitialized.value = true; - }; - - onMounted(() => { - initDownloadListeners(); - }); - - return { - downloadList, - downloadingCount, - navigateToDownloads - }; -}; diff --git a/src/renderer/layout/components/SearchBar.vue b/src/renderer/layout/components/SearchBar.vue index aef707f..c9afe66 100644 --- a/src/renderer/layout/components/SearchBar.vue +++ b/src/renderer/layout/components/SearchBar.vue @@ -221,8 +221,8 @@ import alipay from '@/assets/alipay.png'; import wechat from '@/assets/wechat.png'; import Coffee from '@/components/Coffee.vue'; import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const'; -import { useDownloadStatus } from '@/hooks/useDownloadStatus'; import { useZoom } from '@/hooks/useZoom'; +import { useDownloadStore } from '@/store/modules/download'; import { useIntelligenceModeStore } from '@/store/modules/intelligenceMode'; import { useNavTitleStore } from '@/store/modules/navTitle'; import { useSearchStore } from '@/store/modules/search'; @@ -243,7 +243,11 @@ const userSetOptions = ref(USER_SET_OPTIONS); const { t, locale } = useI18n(); const intelligenceModeStore = useIntelligenceModeStore(); -const { downloadingCount, navigateToDownloads } = useDownloadStatus(); +const downloadStore = useDownloadStore(); +const downloadingCount = computed(() => downloadStore.downloadingCount); +const navigateToDownloads = () => { + router.push('/downloads'); +}; const showDownloadButton = computed( () => isElectron && (settingsStore.setData?.alwaysShowDownloadButton || downloadingCount.value > 0) diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 333abbf..24f2a59 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -15,6 +15,7 @@ pinia.use(({ store }) => { }); // 导出所有 store +export * from './modules/download'; export * from './modules/favorite'; export * from './modules/intelligenceMode'; export * from './modules/localMusic'; diff --git a/src/renderer/store/modules/download.ts b/src/renderer/store/modules/download.ts new file mode 100644 index 0000000..f1a5231 --- /dev/null +++ b/src/renderer/store/modules/download.ts @@ -0,0 +1,228 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; + +import { isElectron } from '@/utils'; + +import { + createDefaultDownloadSettings, + DOWNLOAD_TASK_STATE, + type DownloadSettings, + type DownloadTask +} from '../../../shared/download'; + +const DEFAULT_COVER = '/images/default_cover.png'; + +function validatePicUrl(url?: string): string { + if (!url || url === '' || url.startsWith('/')) return DEFAULT_COVER; + return url.replace(/^http:\/\//, 'https://'); +} + +export const useDownloadStore = defineStore( + 'download', + () => { + // ── State ────────────────────────────────────────────────────────────── + const tasks = ref(new Map()); + const completedList = ref([]); + const settings = ref(createDefaultDownloadSettings()); + const isLoadingCompleted = ref(false); + + // Track whether IPC listeners have been registered + let listenersInitialised = false; + + // ── Computed ─────────────────────────────────────────────────────────── + const downloadingList = computed(() => { + const active = [ + DOWNLOAD_TASK_STATE.queued, + DOWNLOAD_TASK_STATE.downloading, + DOWNLOAD_TASK_STATE.paused + ] as string[]; + return [...tasks.value.values()] + .filter((t) => active.includes(t.state)) + .sort((a, b) => a.createdAt - b.createdAt); + }); + + const downloadingCount = computed(() => downloadingList.value.length); + + const totalProgress = computed(() => { + const list = downloadingList.value; + if (list.length === 0) return 0; + const sum = list.reduce((acc, t) => acc + t.progress, 0); + return sum / list.length; + }); + + // ── Actions ──────────────────────────────────────────────────────────── + const addDownload = async (songInfo: DownloadTask['songInfo'], url: string, type: string) => { + if (!isElectron) return; + const validatedInfo = { + ...songInfo, + picUrl: validatePicUrl(songInfo.picUrl) + }; + const artistNames = validatedInfo.ar?.map((a) => a.name).join(',') ?? ''; + const filename = `${validatedInfo.name} - ${artistNames}`; + await window.api.downloadAdd({ url, filename, songInfo: validatedInfo, type }); + }; + + const batchDownload = async ( + items: Array<{ songInfo: DownloadTask['songInfo']; url: string; type: string }> + ) => { + if (!isElectron) return; + const validatedItems = items.map((item) => { + const validatedInfo = { + ...item.songInfo, + picUrl: validatePicUrl(item.songInfo.picUrl) + }; + const artistNames = validatedInfo.ar?.map((a) => a.name).join(',') ?? ''; + const filename = `${validatedInfo.name} - ${artistNames}`; + return { url: item.url, filename, songInfo: validatedInfo, type: item.type }; + }); + await window.api.downloadAddBatch({ items: validatedItems }); + }; + + const pauseTask = async (taskId: string) => { + if (!isElectron) return; + await window.api.downloadPause(taskId); + }; + + const resumeTask = async (taskId: string) => { + if (!isElectron) return; + await window.api.downloadResume(taskId); + }; + + const cancelTask = async (taskId: string) => { + if (!isElectron) return; + await window.api.downloadCancel(taskId); + tasks.value.delete(taskId); + }; + + const cancelAll = async () => { + if (!isElectron) return; + await window.api.downloadCancelAll(); + tasks.value.clear(); + }; + + const updateConcurrency = async (n: number) => { + if (!isElectron) return; + const clamped = Math.min(5, Math.max(1, n)); + settings.value = { ...settings.value, maxConcurrent: clamped }; + await window.api.downloadSetConcurrency(clamped); + }; + + const refreshCompleted = async () => { + if (!isElectron) return; + isLoadingCompleted.value = true; + try { + const list = await window.api.downloadGetCompleted(); + completedList.value = list; + } finally { + isLoadingCompleted.value = false; + } + }; + + const deleteCompleted = async (filePath: string) => { + if (!isElectron) return; + await window.api.downloadDeleteCompleted(filePath); + completedList.value = completedList.value.filter((item) => item.filePath !== filePath); + }; + + const clearCompleted = async () => { + if (!isElectron) return; + await window.api.downloadClearCompleted(); + completedList.value = []; + }; + + const loadPersistedQueue = async () => { + if (!isElectron) return; + const queue = await window.api.downloadGetQueue(); + tasks.value.clear(); + for (const task of queue) { + tasks.value.set(task.taskId, task); + } + }; + + const initListeners = () => { + if (!isElectron || listenersInitialised) return; + listenersInitialised = true; + + window.api.onDownloadProgress((event) => { + const task = tasks.value.get(event.taskId); + if (task) { + tasks.value.set(event.taskId, { + ...task, + progress: event.progress, + loaded: event.loaded, + total: event.total + }); + } + }); + + window.api.onDownloadStateChange((event) => { + const { taskId, state, task } = event; + if (state === DOWNLOAD_TASK_STATE.completed || state === DOWNLOAD_TASK_STATE.cancelled) { + tasks.value.delete(taskId); + if (state === DOWNLOAD_TASK_STATE.completed) { + setTimeout(() => { + refreshCompleted(); + }, 500); + } + } else { + tasks.value.set(taskId, task); + } + }); + + window.api.onDownloadBatchComplete((_event) => { + // no-op: main process handles the desktop notification + }); + + window.api.onDownloadRequestUrl(async (event) => { + try { + const { getSongUrl } = await import('@/store/modules/player'); + const result = (await getSongUrl(event.songInfo.id, event.songInfo as any, true)) as any; + const url = typeof result === 'string' ? result : (result?.url ?? ''); + await window.api.downloadProvideUrl(event.taskId, url); + } catch (err) { + console.error('[downloadStore] onDownloadRequestUrl failed:', err); + await window.api.downloadProvideUrl(event.taskId, ''); + } + }); + }; + + const cleanup = () => { + if (!isElectron) return; + window.api.removeDownloadListeners(); + listenersInitialised = false; + }; + + return { + // state + tasks, + completedList, + settings, + isLoadingCompleted, + // computed + downloadingList, + downloadingCount, + totalProgress, + // actions + addDownload, + batchDownload, + pauseTask, + resumeTask, + cancelTask, + cancelAll, + updateConcurrency, + refreshCompleted, + deleteCompleted, + clearCompleted, + loadPersistedQueue, + initListeners, + cleanup + }; + }, + { + persist: { + key: 'download-settings', + // WARNING: Do NOT add 'tasks' — Map doesn't serialize with JSON.stringify + pick: ['settings'] + } + } +); diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 74ddc7a..1672367 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -28,6 +28,25 @@ export interface IElectronAPI { ) => Promise<{ files: { path: string; modifiedTime: number }[]; count: number }>; /** 批量解析本地音乐文件元数据 */ parseLocalMusicMetadata: (_filePaths: string[]) => Promise; + // Download manager + downloadAdd: (_task: any) => Promise; + downloadAddBatch: (_tasks: any) => Promise<{ batchId: string; taskIds: string[] }>; + downloadPause: (_taskId: string) => Promise; + downloadResume: (_taskId: string) => Promise; + downloadCancel: (_taskId: string) => Promise; + downloadCancelAll: () => Promise; + downloadGetQueue: () => Promise; + downloadSetConcurrency: (_n: number) => void; + downloadGetCompleted: () => Promise; + downloadDeleteCompleted: (_filePath: string) => Promise; + downloadClearCompleted: () => Promise; + getEmbeddedLyrics: (_filePath: string) => Promise; + downloadProvideUrl: (_taskId: string, _url: string) => Promise; + onDownloadProgress: (_cb: (_data: any) => void) => void; + onDownloadStateChange: (_cb: (_data: any) => void) => void; + onDownloadBatchComplete: (_cb: (_data: any) => void) => void; + onDownloadRequestUrl: (_cb: (_data: any) => void) => void; + removeDownloadListeners: () => void; } declare global { diff --git a/src/renderer/views/download/DownloadPage.vue b/src/renderer/views/download/DownloadPage.vue index 7c0b439..ad4a22e 100644 --- a/src/renderer/views/download/DownloadPage.vue +++ b/src/renderer/views/download/DownloadPage.vue @@ -45,8 +45,10 @@

{{ tabName === 'downloading' - ? t('download.progress.total', { progress: totalProgress.toFixed(1) }) - : t('download.count', { count: downloadedList.length }) + ? t('download.progress.total', { + progress: downloadStore.totalProgress.toFixed(1) + }) + : t('download.count', { count: downloadStore.completedList.length }) }}

@@ -79,8 +81,8 @@
+ + + + + {{ item.progress.toFixed(1) }}% +
@@ -168,8 +196,11 @@
- -
+ +
@@ -180,8 +211,8 @@
@@ -191,6 +222,7 @@
- {{ shortenPath(item.path) }} + {{ shortenPath(item.path || item.filePath) }}
@@ -227,7 +259,7 @@