mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-15 07:20:49 +08:00
- 新建 DownloadManager 类(主进程),每个任务独立 AbortController 控制 - 新建 Pinia useDownloadStore 作为渲染进程单一数据源 - 支持暂停/恢复/取消下载,支持断点续传(Range header) - 批量下载全部完成后发送汇总系统通知,单首不重复通知 - 并发数可配置(1-5),队列持久化(重启后恢复) - 修复下载列表不全、封面加载失败、通知重复等 bug - 修复本地/下载歌曲歌词加载:优先从 ID3/FLAC 元数据提取,API 作为 fallback - 删除 useDownloadStatus.ts,统一状态管理 - DownloadDrawer/DownloadPage 全面重写,移除 @apply 违规 - 新增 5 语言 i18n 键值(暂停/恢复/取消/排队中等)
1044 lines
33 KiB
TypeScript
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);
|
|
}
|
|
}
|