Files
AlgerMusicPlayer/src/main/modules/downloadManager.ts
alger bc46024499 refactor(download): 重构下载系统,支持暂停/恢复/取消,修复歌词加载
- 新建 DownloadManager 类(主进程),每个任务独立 AbortController 控制
- 新建 Pinia useDownloadStore 作为渲染进程单一数据源
- 支持暂停/恢复/取消下载,支持断点续传(Range header)
- 批量下载全部完成后发送汇总系统通知,单首不重复通知
- 并发数可配置(1-5),队列持久化(重启后恢复)
- 修复下载列表不全、封面加载失败、通知重复等 bug
- 修复本地/下载歌曲歌词加载:优先从 ID3/FLAC 元数据提取,API 作为 fallback
- 删除 useDownloadStatus.ts,统一状态管理
- DownloadDrawer/DownloadPage 全面重写,移除 @apply 违规
- 新增 5 语言 i18n 键值(暂停/恢复/取消/排队中等)
2026-03-27 23:02:08 +08:00

1044 lines
33 KiB
TypeScript

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<string, string> {
const lyricMap = new Map<string, string>();
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<string, string>,
translatedLyrics: Map<string, string>
): 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<string, DownloadTask> = new Map();
private abortControllers: Map<string, AbortController> = new Map();
private activeCount = 0;
private maxConcurrent = 3;
private persistStore: Store<DownloadQueueStore>;
private batchTracker: Map<string, BatchEntry> = new Map();
private mainWindow: BrowserWindow | null = null;
private progressThrottles: Map<string, number> = new Map();
private persistTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.persistStore = new Store<DownloadQueueStore>({
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<any[]> {
try {
const configStore = getStore();
const songInfos = (configStore.get('downloadedSongs') || {}) as Record<string, any>;
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<string, any>, song: any) => {
if (song && song.path) {
acc[song.path] = song;
}
return acc;
},
{} as Record<string, any>
);
configStore.set('downloadedSongs', newSongInfos);
return validSongs;
} catch (error) {
console.error('Error getting downloaded music:', error);
return [];
}
}
private async deleteCompleted(filePath: string): Promise<boolean> {
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<string, any>;
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<string | null> {
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<void> {
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<string, string> = {};
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<void>((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<void> {
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<string, string[]> = {
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<string, any>;
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);
}
}