Files
client/greyscale/src/downloads.js
T
2026-04-17 16:52:51 -04:00

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);
}
}