Merge pull request #307 from DanTheMan827/progress-improvements

Fix taglib worker initialization and enhance download progress handling
This commit is contained in:
edideaur 2026-03-13 00:03:29 +02:00 committed by GitHub
commit 39b5090a67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 335 additions and 376 deletions

108
js/api.js
View file

@ -7,17 +7,19 @@ import {
getExtensionFromBlob,
getTrackTitle,
getFullArtistString,
getTrackDiscNumber,
getMimeType,
} from './utils.js';
import { trackDateSettings } from './storage.js';
import { APICache } from './cache.js';
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { DashDownloader } from './dash-downloader.ts';
import { HlsDownloader } from './hls-downloader.js';
import { MP3EncodingError } from './mp3-encoder.js';
import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
import { isCustomFormat } from './ffmpegFormats.ts';
import { DownloadProgress } from './progressEvents.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@ -1288,11 +1290,36 @@ export class LosslessAPI {
return streamUrl;
}
/**
* Downloads a track or video from TIDAL in the specified quality.
*
* Handles multiple stream types (DASH, HLS, and direct HTTP), applies post-processing
* for audio tracks, adds metadata, and optionally triggers a browser download.
*
* @async
* @param {string} id - The TIDAL track or video ID
* @param {string} [quality='HI_RES_LOSSLESS'] - The desired audio quality (e.g., 'HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'NORMAL').
* Custom FFMPEG formats are transcoded from LOSSLESS.
* @param {string} filename - The filename to save the downloaded content as
* @param {Object} [options={}] - Additional download options
* @param {Function} [options.onProgress] - Callback function for progress updates with signature:
* `(progressEvent) => void`
* @param {Object} [options.track] - Track metadata object to attach to the audio file
* @param {boolean} [options.calculateDashBytes=true] - Whether to calculate total bytes for DASH streams
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the download
* @param {boolean} [options.triggerDownload=true] - Whether to trigger browser download after completion
*
* @returns {Promise<Blob>} The downloaded content as a Blob object
*
* @throws {Error} If stream URL cannot be resolved, manifest is missing, or download fails
* @throws {AbortError} If the download is aborted via the signal
* @throws {MP3EncodingError|FfmpegError} If audio transcoding fails
*/
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
// Load ffmpeg in the background.
loadFfmpeg().catch(console.error);
const { onProgress, track } = options;
const { onProgress, track, calculateDashBytes = true } = options;
const prefetchPromises = prefetchMetadataObjects(track, this);
const isVideo = track?.type === 'video';
@ -1345,7 +1372,8 @@ export class LosslessAPI {
const downloader = new DashDownloader();
blob = await downloader.downloadDashStream(streamUrl, {
signal: options.signal,
onProgress: options.onProgress,
onProgress,
calculateDashBytes: calculateDashBytes ?? true,
});
} catch (dashError) {
console.error('DASH download failed:', dashError);
@ -1363,7 +1391,7 @@ export class LosslessAPI {
const downloader = new HlsDownloader();
blob = await downloader.downloadHlsStream(streamUrl, {
signal: options.signal,
onProgress: options.onProgress,
onProgress,
});
} catch (hlsError) {
console.error('HLS download failed:', hlsError);
@ -1384,7 +1412,7 @@ export class LosslessAPI {
let receivedBytes = 0;
if (response.body && onProgress) {
if (response.body) {
const reader = response.body.getReader();
const chunks = [];
@ -1396,25 +1424,16 @@ export class LosslessAPI {
chunks.push(value);
receivedBytes += value.byteLength;
onProgress({
stage: 'downloading',
receivedBytes,
totalBytes: totalBytes || undefined,
});
onProgress?.(new DownloadProgress(receivedBytes, totalBytes || undefined));
}
}
const defaultMime = isVideo ? 'video/mp4' : 'audio/flac';
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || defaultMime });
} else {
onProgress?.(new DownloadProgress(0, undefined));
blob = await response.blob();
if (onProgress) {
onProgress({
stage: 'downloading',
receivedBytes: blob.size,
totalBytes: blob.size,
});
}
onProgress?.(new DownloadProgress(blob.size, blob.size));
}
}
@ -1423,12 +1442,10 @@ export class LosslessAPI {
// Add metadata if track information is provided
if (track) {
if (onProgress) {
onProgress({
stage: 'processing',
message: 'Adding metadata...',
});
}
onProgress?.({
stage: 'processing',
message: 'Adding metadata...',
});
const enrichedTrack = { ...track };
if (lookup.info) {
@ -1445,38 +1462,17 @@ export class LosslessAPI {
(track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)
) {
try {
// Broad disc-field resolver — mirrors getExplicitTrackDiscNumber in downloads.js
const resolveDiscNumber = (t) => {
const candidates = [
t.volumeNumber,
t.discNumber,
t.mediaNumber,
t.media_number,
t.volume,
t.disc,
t.disc_no,
t.discNo,
t.disc_number,
t.mediaMetadata?.discNumber,
];
for (const c of candidates) {
const parsed = parseInt(c, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return 1;
};
const albumData = await this.getAlbum(track.album.id);
if (albumData.tracks?.length > 0) {
const discTrackCounts = new Map();
let maxDiscNumber = 0;
for (const t of albumData.tracks) {
const dn = resolveDiscNumber(t);
const dn = getTrackDiscNumber(t);
discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
if (dn > maxDiscNumber) maxDiscNumber = dn;
}
const totalDiscs = maxDiscNumber || 1;
const discNumber = resolveDiscNumber(track);
const discNumber = getTrackDiscNumber(track);
enrichedTrack.album = {
...(enrichedTrack.album || {}),
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
@ -1489,21 +1485,25 @@ export class LosslessAPI {
}
}
onProgress?.(new DownloadProgress('Adding metadata'));
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
}
}
// Detect actual format and fix filename extension if needed
const detectedExtension = await getExtensionFromBlob(blob);
let finalFilename = filename;
if (options.triggerDownload ?? true) {
// Detect actual format and fix filename extension if needed
const detectedExtension = await getExtensionFromBlob(blob);
let finalFilename = filename;
// Replace extension if it doesn't match detected format
const currentExtension = filename.split('.').pop()?.toLowerCase();
if (currentExtension && currentExtension !== detectedExtension) {
finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`);
// Replace extension if it doesn't match detected format
const currentExtension = filename.split('.').pop()?.toLowerCase();
if (currentExtension && currentExtension !== detectedExtension) {
finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`);
}
triggerDownload(blob, finalFilename);
}
triggerDownload(blob, finalFilename);
return blob;
} catch (error) {
if (error.name === 'AbortError') {

View file

@ -41,6 +41,35 @@ export interface IBulkDownloadWriter {
write(files: AsyncIterable<WriterEntry>): Promise<void>;
}
/**
* Triggers individual downloads for each file entry, one after another.
*/
export class SequentialFileWriter implements IBulkDownloadWriter {
constructor() {}
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
for await (const file of files) {
const name = file.name?.split('/').pop();
const ext = name?.split('.').pop().toLowerCase();
if (!name) {
console.warn('No name for file entry.', file);
continue;
}
if (['m3u', 'm3u8', 'cue', 'jpg', 'png', 'nfo', 'json'].includes(ext)) {
continue;
}
if (file.input instanceof Blob) {
triggerDownload(file.input, name);
} else {
triggerDownload(new Blob([file.input as BlobPart]), name);
}
}
}
}
/**
* Streams a ZIP archive to a file via the File System Access API.
* Prompts the user to choose a save location with showSaveFilePicker.

View file

@ -1,8 +1,54 @@
import { AbortError } from './errorTypes';
import { SegmentedDownloadProgress } from './progressEvents';
export interface DashDownloadOptions {
onProgress?: MonochromeProgressListener<SegmentedDownloadProgress>;
signal?: AbortSignal;
calculateDashBytes?: boolean;
}
interface DashSegment {
number: number;
time: number;
}
interface DashManifest {
baseUrl: string;
initialization: string | null;
media: string | null;
segments: DashSegment[];
repId: string | null;
mimeType: string | null;
}
export class DashDownloader {
constructor() {}
async downloadDashStream(manifestBlobUrl, options = {}) {
const { onProgress, signal } = options;
async getTotalSize(urls: string[], signal?: AbortSignal): Promise<number | undefined> {
try {
let totalSize = 0;
await Promise.all(
urls.map(async (url) => {
const result = await fetch(url, { method: 'HEAD', signal });
if (result.ok) {
const contentLength = result.headers.get('Content-Length');
if (contentLength) totalSize += parseInt(contentLength, 10);
} else {
throw new Error(`Failed to fetch segment HEAD: ${result.status}`);
}
})
);
return totalSize;
} catch {
return undefined;
}
}
async downloadDashStream(manifestBlobUrl: string, options: DashDownloadOptions = {}): Promise<Blob> {
const { onProgress, signal, calculateDashBytes = true } = options;
// 1. Fetch and Parse Manifest
const response = await fetch(manifestBlobUrl);
@ -18,24 +64,30 @@ export class DashDownloader {
const mimeType = manifest.mimeType || 'audio/mp4';
// 3. Download Segments
const chunks = [];
const chunks: ArrayBuffer[] = [];
let downloadedBytes = 0;
// Estimate total size? Hard to know exactly without Content-Length of each.
// We can just track progress by segment count.
const totalSegments = urls.length;
const totalSize = calculateDashBytes ? await this.getTotalSize(urls, signal) : undefined;
for (let i = 0; i < urls.length; i++) {
if (signal?.aborted) throw new Error('AbortError');
if (signal?.aborted) throw new AbortError();
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));
const url = urls[i];
const segmentResponse = await fetch(url, { signal });
if (!segmentResponse.ok) {
// Retry once?
console.warn(`Failed to fetch segment ${i}, retrying...`);
await new Promise((r) => setTimeout(r, 1000));
const retryResponse = await fetch(url, { signal });
if (!retryResponse.ok) throw new Error(`Failed to fetch segment ${i}: ${retryResponse.status}`);
if (!retryResponse.ok) {
throw new Error(`Failed to fetch segment ${i}: ${retryResponse.status}`);
}
const chunk = await retryResponse.arrayBuffer();
chunks.push(chunk);
downloadedBytes += chunk.byteLength;
@ -45,22 +97,14 @@ export class DashDownloader {
downloadedBytes += chunk.byteLength;
}
if (onProgress) {
onProgress({
stage: 'downloading',
receivedBytes: downloadedBytes, // accurate byte count
totalBytes: undefined, // Unknown total
currentSegment: i + 1,
totalSegments: totalSegments,
});
}
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i + 1, totalSegments));
}
// 4. Concatenate
return new Blob(chunks, { type: mimeType });
}
parseManifest(manifestText) {
parseManifest(manifestText: string): DashManifest {
const parser = new DOMParser();
const xml = parser.parseFromString(manifestText, 'text/xml');
@ -70,25 +114,22 @@ export class DashDownloader {
const period = mpd.querySelector('Period');
if (!period) throw new Error('Invalid DASH manifest: No Period tag');
// Prefer highest bandwidth audio adaptation set
const adaptationSets = Array.from(period.querySelectorAll('AdaptationSet'));
adaptationSets.sort((a, b) => {
const getMaxBandwidth = (set) => {
const getMaxBandwidth = (set: Element) => {
const reps = Array.from(set.querySelectorAll('Representation'));
return reps.length ? Math.max(...reps.map((r) => parseInt(r.getAttribute('bandwidth') || '0', 10))) : 0;
};
return getMaxBandwidth(b) - getMaxBandwidth(a);
});
let audioSet = adaptationSets.find((as) => as.getAttribute('mimeType')?.startsWith('audio'));
let audioSet = adaptationSets.find((as) => as.getAttribute('mimeType')?.startsWith('audio')) ?? null;
// Fallback: look for any adaptation set if mimeType is missing (rare)
if (!audioSet && adaptationSets.length > 0) audioSet = adaptationSets[0];
if (!audioSet) throw new Error('No AdaptationSet found');
// Find Representation
// Get all representations and sort by bandwidth descending
const representations = Array.from(audioSet.querySelectorAll('Representation')).sort((a, b) => {
const bwA = parseInt(a.getAttribute('bandwidth') || '0');
const bwB = parseInt(b.getAttribute('bandwidth') || '0');
@ -96,55 +137,47 @@ export class DashDownloader {
});
if (representations.length === 0) throw new Error('No Representation found');
const rep = representations[0];
const repId = rep.getAttribute('id');
// Find SegmentTemplate
// Can be in Representation or AdaptationSet
const segmentTemplate = rep.querySelector('SegmentTemplate') || audioSet.querySelector('SegmentTemplate');
if (!segmentTemplate) throw new Error('No SegmentTemplate found');
const initialization = segmentTemplate.getAttribute('initialization');
const media = segmentTemplate.getAttribute('media');
const startNumber = parseInt(segmentTemplate.getAttribute('startNumber') || '1', 10);
// BaseURL
// Can be at MPD, Period, AdaptationSet, or Representation level.
// We strictly need to find the "deepest" one or combine them?
// Usually simpler manifests have it at one level.
// Let's resolve closest BaseURL.
const baseUrlTag =
rep.querySelector('BaseURL') ||
audioSet.querySelector('BaseURL') ||
period.querySelector('BaseURL') ||
mpd.querySelector('BaseURL');
const baseUrl = baseUrlTag ? baseUrlTag.textContent.trim() : '';
// SegmentTimeline
const baseUrl = baseUrlTag?.textContent?.trim() || '';
const segmentTimeline = segmentTemplate.querySelector('SegmentTimeline');
const segments = [];
const segments: DashSegment[] = [];
if (segmentTimeline) {
const sElements = segmentTimeline.querySelectorAll('S');
let currentTime = 0;
let currentNumber = startNumber;
sElements.forEach((s) => {
// t is optional, defaults to previous end
const tAttr = s.getAttribute('t');
if (tAttr) currentTime = parseInt(tAttr, 10);
const d = parseInt(s.getAttribute('d'), 10);
const d = parseInt(s.getAttribute('d') || '0', 10);
const r = parseInt(s.getAttribute('r') || '0', 10);
// Initial segment
segments.push({ number: currentNumber, time: currentTime });
currentTime += d;
currentNumber++;
// Repeats
// r is the number of REPEATS (so total occurrences = 1 + r)
// If r is negative, it refers to open-ended? (Usually not in static manifests)
for (let i = 0; i < r; i++) {
segments.push({ number: currentNumber, time: currentTime });
currentTime += d;
@ -163,43 +196,40 @@ export class DashDownloader {
};
}
generateSegmentUrls(manifest) {
generateSegmentUrls(manifest: DashManifest): string[] {
const { baseUrl, initialization, media, segments, repId } = manifest;
const urls = [];
// Helper to resolve template strings
const resolveTemplate = (template, number, time) => {
const urls: string[] = [];
const resolveTemplate = (template: string, number: number, time: number): string => {
return template
.replace(/\$RepresentationID\$/g, repId)
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (match, width) => {
.replace(/\$RepresentationID\$/g, repId ?? '')
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (_, width) => {
if (width) {
return number.toString().padStart(parseInt(width), '0');
}
return number;
return number.toString();
})
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (match, width) => {
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (_, width) => {
if (width) {
return time.toString().padStart(parseInt(width), '0');
}
return time;
return time.toString();
});
};
// Helper to join paths handling slashes
const joinPath = (base, part) => {
const joinPath = (base: string, part: string): string => {
if (!base) return part;
if (part.startsWith('http')) return part; // Absolute path
if (part.startsWith('http')) return part;
return base.endsWith('/') ? base + part : base + '/' + part;
};
// 1. Initialization Segment
if (initialization) {
const initPath = resolveTemplate(initialization, 0, 0); // Init often doesn't use Number/Time but just in case
const initPath = resolveTemplate(initialization, 0, 0);
urls.push(joinPath(baseUrl, initPath));
}
// 2. Media Segments
if (segments && segments.length > 0) {
if (media && segments.length > 0) {
segments.forEach((seg) => {
const path = resolveTemplate(media, seg.number, seg.time);
urls.push(joinPath(baseUrl, path));

View file

@ -11,17 +11,20 @@ import {
getExtensionFromBlob,
escapeHtml,
getTrackDiscNumber,
getFullArtistString,
getMimeType,
} from './utils.js';
import { AbortError } from './errorTypes.ts';
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
import { loadFfmpeg } from './ffmpeg.js';
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
import { isCustomFormat } from './ffmpegFormats.ts';
import { ZipStreamWriter, ZipBlobWriter, ZipNeutralinoWriter, FolderPickerWriter } from './bulk-download-writer.ts';
import { triggerDownload } from './download-utils.ts';
import {
ZipStreamWriter,
ZipBlobWriter,
ZipNeutralinoWriter,
FolderPickerWriter,
SequentialFileWriter,
} from './bulk-download-writer.ts';
import { FfmpegProgress } from './ffmpeg.types.js';
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
const downloadTasks = new Map();
const bulkDownloadTasks = new Map();
@ -209,7 +212,7 @@ export function updateDownloadProgress(trackId, progress) {
const progressFill = taskEl.querySelector('.download-progress-fill');
const statusEl = taskEl.querySelector('.download-status');
if (progress.stage === 'downloading') {
if (progress instanceof DownloadProgress && progress.receivedBytes && progress.totalBytes) {
const percent = progress.totalBytes ? Math.round((progress.receivedBytes / progress.totalBytes) * 100) : 0;
progressFill.style.width = `${percent}%`;
@ -219,15 +222,25 @@ export function updateDownloadProgress(trackId, progress) {
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
} else if (progress.stage === 'encoding') {
} else if (progress instanceof SegmentedDownloadProgress && progress.currentSegment && progress.totalSegments) {
const percent = progress.totalBytes ? Math.round((progress.currentSegment / progress.totalSegments) * 100) : 0;
progressFill.style.width = `${percent}%`;
progressFill.style.background = 'var(--highlight)';
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
} else if (progress instanceof FfmpegProgress && progress.stage == 'encoding') {
const percent = progress.progress ? Math.round(progress.progress) : 0;
progressFill.style.width = `${percent}%`;
progressFill.style.background = '#3b82f6'; // Blue for encoding
statusEl.textContent = `Converting: ${percent}%`;
} else if (progress.stage === 'finalizing' || progress.stage === 'processing') {
} else if (progress instanceof ProgressMessage || progress.message) {
progressFill.style.width = '100%';
progressFill.style.background = '#3b82f6';
statusEl.textContent = progress.message || 'Processing...';
statusEl.textContent = progress.message;
}
}
@ -296,210 +309,22 @@ function removeBulkDownloadTask(notifEl) {
}, 300);
}
async function downloadTrackBlob(
track,
quality,
api,
lyricsManager = null,
signal = null,
onProgress = null,
coverBlob = null
) {
// Load ffmpeg in the background.
loadFfmpeg().catch(console.error);
const prefetchPromises = prefetchMetadataObjects(track, api, coverBlob);
let enrichedTrack = {
...track,
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
};
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
try {
const fullTrack = await api.getTrackMetadata(track.id);
if (fullTrack) {
enrichedTrack = {
...fullTrack,
...enrichedTrack,
artist: enrichedTrack.artist || fullTrack.artist,
album: {
...(fullTrack.album || {}),
...(enrichedTrack.album || {}),
},
// Preserve explicit disc fields from either source
discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
};
}
} catch {
// Non-fatal: continue with best available track payload
}
if (enrichedTrack.album?.id) {
try {
const albumData = await api.getAlbum(enrichedTrack.album.id);
if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) {
enrichedTrack.album = {
...enrichedTrack.album,
...albumData.album,
};
}
if (albumData.tracks?.length > 0) {
const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api);
const discNumber = getTrackDiscNumber(enrichedTrack) || 1;
enrichedTrack.album = {
...enrichedTrack.album,
totalDiscs,
numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
};
}
} catch (error) {
console.warn('Failed to fetch album data for metadata:', error);
}
}
const lookup = await api.getTrack(track.id, downloadQuality);
let streamUrl;
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
} else {
streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
if (!streamUrl) {
throw new Error('Could not resolve stream URL');
}
}
if (lookup.info) {
enrichedTrack.replayGain = {
trackReplayGain: lookup.info.trackReplayGain,
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
albumReplayGain: lookup.info.albumReplayGain,
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
};
}
// Handle DASH streams (blob URLs)
let blob;
if (streamUrl.startsWith('blob:')) {
try {
const downloader = new DashDownloader();
blob = await downloader.downloadDashStream(streamUrl, { signal });
} catch (dashError) {
console.error('DASH download failed:', dashError);
// Fallback
if (downloadQuality !== 'LOSSLESS') {
console.warn('Falling back to LOSSLESS (16-bit) download.');
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress, coverBlob);
}
throw dashError;
}
} else {
const response = await fetch(streamUrl, { signal });
if (!response.ok) {
throw new Error(`Failed to fetch track: ${response.status}`);
}
blob = await response.blob();
}
const coverBlobToEmbed = await prefetchPromises.coverFetch;
const extraFiles = [];
const ffmpegMetadataArgs = [];
if (coverBlobToEmbed) {
const coverBuffer = await coverBlobToEmbed.arrayBuffer();
const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg';
const coverName = `cover.${coverExt}`;
extraFiles.push({
name: coverName,
data: coverBuffer,
});
ffmpegMetadataArgs.push('-i', coverName);
}
if (enrichedTrack) {
ffmpegMetadataArgs.push(
'-metadata',
`title=${getTrackTitle(enrichedTrack)}`,
'-metadata',
`artist=${getFullArtistString(enrichedTrack)}`,
'-metadata',
`album=${enrichedTrack.album?.title || ''}`,
'-metadata',
`album_artist=${enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name || ''}`
);
const trackNum = enrichedTrack.trackNumber;
if (trackNum) {
const totalTracks = enrichedTrack.album?.numberOfTracks;
ffmpegMetadataArgs.push('-metadata', `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}`);
}
const discNum = enrichedTrack.volumeNumber || enrichedTrack.discNumber;
if (discNum) {
ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`);
}
const releaseDate = enrichedTrack.album?.releaseDate || enrichedTrack?.streamStartDate;
if (releaseDate) {
ffmpegMetadataArgs.push('-metadata', `date=${releaseDate.split('-')[0]}`);
}
}
// Apply audio post-processing (custom format transcoding + lossless container conversion)
blob = await applyAudioPostProcessing(blob, quality, onProgress, signal);
async function downloadTrackBlob(track, quality, api, signal = null, onProgress = null) {
const blob = await api.downloadTrack(track.id, quality, undefined, {
track,
signal,
onProgress,
triggerDownload: false,
calculateDashBytes: false,
});
// Detect actual format from blob signature BEFORE adding metadata
const extension = await getExtensionFromBlob(blob);
// Add metadata to the blob
blob = await addMetadataToAudio(blob, enrichedTrack, api, quality, prefetchPromises);
return { blob, extension };
}
async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification, coverBlob = null) {
const { abortController } = bulkDownloadTasks.get(notification);
const signal = abortController.signal;
for (let i = 0; i < tracks.length; i++) {
if (signal.aborted) break;
const track = tracks[i];
const trackTitle = getTrackTitle(track);
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, null, coverBlob);
const filename = buildTrackFilename(track, quality, extension);
triggerDownload(blob, filename);
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
if (lyricsData) {
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
const lrcBlob = new Blob([lrcContent], { type: 'text/plain' });
triggerDownload(lrcBlob, lrcFilename);
}
}
} catch {
// Silent fail for lyrics
}
}
} catch (err) {
if (err.name === 'AbortError') throw err;
console.error(`Failed to download track ${trackTitle}:`, err);
}
}
}
async function bulkDownloadToZip(
async function bulkDownload(
tracks,
folderName,
api,
@ -530,21 +355,21 @@ async function bulkDownloadToZip(
if (signal.aborted) break;
const track = tracks[i];
const trackTitle = getTrackTitle(track);
let fileFraction = 0;
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try {
const { blob, extension } = await downloadTrackBlob(
track,
quality,
api,
null,
signal,
(p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
},
coverBlob
);
const { blob, extension } = await downloadTrackBlob(track, quality, api, signal, (p) => {
if (p instanceof DownloadProgress && p.totalBytes && p.receivedBytes) {
fileFraction = p.receivedBytes / p.totalBytes;
} else if (p instanceof SegmentedDownloadProgress && p.currentSegment && p.totalSegments) {
fileFraction = p.currentSegment / p.totalSegments;
}
fileFraction = Math.min(fileFraction, 0.99); // Cap at 99% to avoid showing 100% before finalization
updateBulkDownloadProgress(notification, i + fileFraction, tracks.length, trackTitle, p);
});
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
@ -691,7 +516,7 @@ async function createBulkWriter(folderName) {
}
}
if (method === 'individual') {
return null;
return new SequentialFileWriter();
}
// method === 'zip' (or folder picker unavailable as fallback)
if (!forceZipBlob && hasFileSystemAccess) {
@ -717,7 +542,7 @@ async function startBulkDownload(
const writer = await createBulkWriter(defaultName);
if (writer) {
await bulkDownloadToZip(
await bulkDownload(
tracks,
defaultName,
api,
@ -729,9 +554,6 @@ async function startBulkDownload(
type,
metadata
);
} else {
// Individual sequential downloads
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
}
completeBulkDownload(notification, true);
@ -846,15 +668,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
const track = tracks[i];
if (signal.aborted) break;
try {
const { blob, extension } = await downloadTrackBlob(
track,
quality,
api,
null,
signal,
null,
coverBlob
);
const { blob, extension } = await downloadTrackBlob(track, quality, api, signal, null);
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
@ -954,16 +768,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
if (writer) {
await writer.write(yieldDiscography());
} else {
// Individual sequential downloads for discography
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
if (signal.aborted) break;
const album = selectedReleases[albumIndex];
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
const { tracks: rawTracks } = await api.getAlbum(album.id);
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
}
}
completeBulkDownload(notification, true);
@ -1026,22 +830,26 @@ function createBulkDownloadNotification(type, name, _totalItems) {
return notifEl;
}
function updateBulkDownloadProgress(notifEl, current, total, currentItem, ffmpegProgress = null) {
function updateBulkDownloadProgress(notifEl, current, total, currentItem, progress = null) {
const progressFill = notifEl.querySelector('.download-progress-fill');
const statusEl = notifEl.querySelector('.download-status');
if (ffmpegProgress && (ffmpegProgress.stage === 'encoding' || ffmpegProgress.stage === 'finalizing')) {
const percent = ffmpegProgress.progress ? Math.round(ffmpegProgress.progress) : 100;
if (progress instanceof FfmpegProgress) {
const percent = progress.progress || 0;
progressFill.style.width = `${percent}%`;
progressFill.style.background = '#3b82f6'; // Blue for encoding
statusEl.textContent = `Converting ${current}/${total}: ${percent}%`;
statusEl.textContent = `Converting ${Math.ceil(current)}/${total}: ${Math.round(percent)}%`;
return;
}
if (progress instanceof ProgressMessage) {
statusEl.textContent = progress.message;
}
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
progressFill.style.width = `${percent}%`;
progressFill.style.background = 'var(--highlight)';
statusEl.textContent = `${current}/${total} - ${currentItem}`;
statusEl.textContent = `${Math.floor(current)}/${total} - ${currentItem}`;
}
function completeBulkDownload(notifEl, success = true, message = null) {
@ -1143,6 +951,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
onProgress: (progress) => {
updateDownloadProgress(track.id, progress);
},
calculateDashBytes: true,
});
completeDownloadTask(track.id, true);

6
js/errorTypes.ts Normal file
View file

@ -0,0 +1,6 @@
export class AbortError extends Error {
constructor(cause: string = 'The task was aborted.') {
super(cause);
this.name = 'AbortError';
}
}

View file

@ -1,7 +1,10 @@
import { fetchBlobURL } from './utils';
import FfmpegWorker from './ffmpeg.worker.js?worker';
import coreJs from '!/@ffmpeg/core/dist/esm/ffmpeg-core.js?url';
import coreWasm from '!/@ffmpeg/core/dist/esm/ffmpeg-core.wasm?url';
import { FfmpegProgress } from './ffmpeg.types';
import { ProgressMessage } from './progressEvents';
const ffmpegBase = 'https://unpkg.com/@ffmpeg/core/dist/esm';
const coreJs = `${ffmpegBase}/ffmpeg-core.js`;
const coreWasm = `${ffmpegBase}/ffmpeg-core.wasm`;
class FfmpegError extends Error {
constructor(message) {
@ -25,6 +28,17 @@ export function loadFfmpeg() {
);
}
/**
*
* @param {Blob} audioBlob
* @param {string[]} args
* @param {string} outputName
* @param {string} outputMime
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} onProgress
* @param {AbortSignal|null} signal
* @param {Array<{name: string, data: ArrayBuffer | Uint8Array}>} extraFiles
* @returns {Promise<Blob>} Encoded audio blob
*/
async function ffmpegWorker(
audioBlob,
args = [],
@ -65,8 +79,10 @@ async function ffmpegWorker(
if (signal) signal.removeEventListener('abort', abortHandler);
worker.terminate();
reject(new FfmpegError(message));
} else if (type === 'progress' && onProgress) {
onProgress({ stage, message, progress });
} else if (type === 'progress' && message) {
onProgress?.(new FfmpegProgress(stage, progress || 0, message));
} else if (type === 'progress' && stage != 'loading' && progress !== null) {
onProgress?.(new FfmpegProgress(stage, progress || 0, message));
} else if (type === 'log') {
console.log('[FFmpeg]', message);
}
@ -106,6 +122,20 @@ async function ffmpegWorker(
});
}
/**
* Encodes audio using FFmpeg via Web Worker
* @async
* @param {Blob} audioBlob - The audio blob to encode
* @param {string[]} [args=[]] - FFmpeg command-line arguments
* @param {string} [outputName='output'] - Name of the output file
* @param {string} [outputMime='application/octet-stream'] - MIME type of the output
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} [onProgress=null] - Optional callback for progress updates
* @param {AbortSignal|null} [signal=null] - Optional abort signal to cancel encoding
* @param {Array} [extraFiles=[]] - Additional files to provide to FFmpeg
* @returns {Promise<Blob>} Encoded audio blob
* @throws {FfmpegError} If Web Workers are not available
* @throws {Error} If FFmpeg encoding fails
*/
export async function ffmpeg(
audioBlob,
args = [],

7
js/ffmpeg.types.ts Normal file
View file

@ -0,0 +1,7 @@
export class FfmpegProgress implements MonochromeProgress {
constructor(
public readonly stage: 'loading' | 'encoding' | 'finalizing',
public readonly progress: number,
public readonly message?: string
) {}
}

View file

@ -1,3 +1,5 @@
import { SegmentedDownloadProgress } from './progressEvents';
export class HlsDownloader {
constructor() {}
@ -24,6 +26,8 @@ export class HlsDownloader {
for (let i = 0; i < totalSegments; i++) {
if (signal?.aborted) throw new Error('AbortError');
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments));
const segmentUrl = segments[i];
const segmentResponse = await fetch(segmentUrl, { signal });
@ -35,15 +39,7 @@ export class HlsDownloader {
chunks.push(chunk);
downloadedBytes += chunk.byteLength;
if (onProgress) {
onProgress({
stage: 'downloading',
receivedBytes: downloadedBytes,
totalBytes: undefined,
currentSegment: i + 1,
totalSegments: totalSegments,
});
}
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i + 1, totalSegments));
}
const mimeType = segments[0].endsWith('.m4s') || segments[0].includes('mp4') ? 'video/mp4' : 'video/mp2t';

View file

@ -8,6 +8,13 @@ class MP3EncodingError extends Error {
}
}
/**
*
* @param {Blob} audioBlob
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} [onProgress=null]
* @param {AbortSignal|null} [signal=null]
* @returns {Promise<Blob>} Encoded MP3 audio blob
*/
export async function encodeToMp3(audioBlob, onProgress = null, signal = null) {
try {
// Use Web Worker for non-blocking FFmpeg encoding

43
js/progressEvents.ts Normal file
View file

@ -0,0 +1,43 @@
declare global {
type MonochromeProgress<T = {}> = {
stage: string;
} & T;
type MonochromeProgressMessage<T = MonochromeProgress> = {
message: string;
};
type MonochromeProgressListener<T = MonochromeProgress> = (progress: T) => void;
}
export class DownloadProgress implements MonochromeProgress {
public readonly stage = 'downloading';
constructor(
public readonly receivedBytes: number,
public readonly totalBytes: number | undefined
) {}
}
export class SegmentedDownloadProgress extends DownloadProgress {
public readonly stage = 'downloading';
constructor(
public readonly receivedBytes: number,
public readonly totalBytes: number | undefined,
public readonly currentSegment: number,
public readonly totalSegments: number
) {
super(receivedBytes, totalBytes);
}
}
export class ProgressMessage implements MonochromeProgressMessage {
constructor(public readonly message: string) {}
}
export class DownloadProgressMessage extends ProgressMessage {
constructor(message: string) {
super(message);
}
}

View file

@ -2,12 +2,9 @@ import { TagLib } from 'taglib-wasm';
import { fetchBlobURL } from './utils';
import _TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?url';
import type {
TagLibWorkerMessageType,
AddMetadataMessage,
GetMetadataMessage,
TagLibFileResponse,
TagLibMetadataResponse,
TagLibMetadata,
TagLibReadMetadata,
} from './taglib.types';
import TagLibWorker from './taglib.worker?worker';
@ -62,7 +59,7 @@ export async function getMetadataWithTagLib(audioData: Uint8Array) {
audioData = new Uint8Array(audioData);
}
const worker = new Worker(new URL(TagLibWorker, import.meta.url), { type: 'module' });
const worker = new TagLibWorker();
const wasmUrl = await fetchTagLib();
return new Promise<TagLibReadMetadata>((resolve, reject) => {

View file

@ -261,9 +261,14 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
switch (event.data.type) {
case 'Add':
if ((event.data as AddMetadataMessage).cover?.data?.buffer instanceof ArrayBuffer) {
transfer.push((event.data as AddMetadataMessage).cover.data.buffer);
}
try {
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
transfer.push(result.buffer);
self.postMessage(
{
type: event.data.type,