124 lines
3.6 KiB
JavaScript
124 lines
3.6 KiB
JavaScript
import { buildTrackFilename } from './utils.js';
|
|
|
|
export class DownloadManager {
|
|
constructor(api) {
|
|
this.api = api;
|
|
this.tasks = new Map();
|
|
this.counter = 0;
|
|
this.onChange = null;
|
|
}
|
|
|
|
setOnChange(cb) {
|
|
this.onChange = cb;
|
|
}
|
|
|
|
emit() {
|
|
if (typeof this.onChange === 'function') {
|
|
this.onChange(this.getTasks());
|
|
}
|
|
}
|
|
|
|
getTasks() {
|
|
return [...this.tasks.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
}
|
|
|
|
clearFinished() {
|
|
for (const [id, task] of this.tasks.entries()) {
|
|
if (task.status === 'done' || task.status === 'error' || task.status === 'cancelled') {
|
|
this.tasks.delete(id);
|
|
}
|
|
}
|
|
this.emit();
|
|
}
|
|
|
|
cancel(taskId) {
|
|
const task = this.tasks.get(taskId);
|
|
if (!task || !task.abortController) return;
|
|
task.abortController.abort();
|
|
task.status = 'cancelled';
|
|
task.message = 'Cancelled';
|
|
this.emit();
|
|
}
|
|
|
|
async downloadTrack(track, quality = 'LOSSLESS') {
|
|
const taskId = `dl_${Date.now()}_${this.counter++}`;
|
|
const abortController = new AbortController();
|
|
|
|
const task = {
|
|
id: taskId,
|
|
type: 'track',
|
|
status: 'queued',
|
|
title: track?.title || `Track ${track?.id || ''}`,
|
|
percent: 0,
|
|
message: 'Queued',
|
|
createdAt: Date.now(),
|
|
abortController,
|
|
};
|
|
|
|
this.tasks.set(taskId, task);
|
|
this.emit();
|
|
|
|
try {
|
|
task.status = 'downloading';
|
|
task.message = 'Resolving stream...';
|
|
this.emit();
|
|
|
|
const result = await this.api.downloadTrackBlob(track.id, quality, ({ receivedBytes, totalBytes }) => {
|
|
if (!totalBytes) {
|
|
task.message = `Downloaded ${(receivedBytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
this.emit();
|
|
return;
|
|
}
|
|
|
|
task.percent = Math.max(0, Math.min(100, (receivedBytes / totalBytes) * 100));
|
|
task.message = `${Math.round(task.percent)}%`;
|
|
this.emit();
|
|
});
|
|
|
|
const ext = quality.includes('LOSSLESS') ? 'flac' : 'm4a';
|
|
const filename = buildTrackFilename(result.track || track, ext);
|
|
this.triggerDownload(result.blob, filename);
|
|
|
|
task.status = 'done';
|
|
task.percent = 100;
|
|
task.message = 'Saved';
|
|
this.emit();
|
|
return taskId;
|
|
} catch (error) {
|
|
if (error?.name === 'AbortError') {
|
|
task.status = 'cancelled';
|
|
task.message = 'Cancelled';
|
|
} else {
|
|
task.status = 'error';
|
|
task.message = error?.message || 'Download failed';
|
|
}
|
|
this.emit();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async downloadAlbum(album, tracks, quality = 'LOSSLESS') {
|
|
if (!Array.isArray(tracks) || tracks.length === 0) return;
|
|
|
|
for (let i = 0; i < tracks.length; i += 1) {
|
|
const track = tracks[i];
|
|
try {
|
|
await this.downloadTrack(track, quality);
|
|
} catch {
|
|
// continue with next track
|
|
}
|
|
}
|
|
}
|
|
|
|
triggerDownload(blob, filename) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|