diff --git a/js/api.js b/js/api.js index 2cecebc..b5b95a6 100644 --- a/js/api.js +++ b/js/api.js @@ -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} 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') { diff --git a/js/bulk-download-writer.ts b/js/bulk-download-writer.ts index 24a2367..581eaa2 100644 --- a/js/bulk-download-writer.ts +++ b/js/bulk-download-writer.ts @@ -41,6 +41,35 @@ export interface IBulkDownloadWriter { write(files: AsyncIterable): Promise; } +/** + * Triggers individual downloads for each file entry, one after another. + */ +export class SequentialFileWriter implements IBulkDownloadWriter { + constructor() {} + + async write(files: AsyncIterable): Promise { + 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. diff --git a/js/dash-downloader.js b/js/dash-downloader.ts similarity index 64% rename from js/dash-downloader.js rename to js/dash-downloader.ts index a67fa8b..21391ce 100644 --- a/js/dash-downloader.js +++ b/js/dash-downloader.ts @@ -1,8 +1,54 @@ +import { AbortError } from './errorTypes'; +import { SegmentedDownloadProgress } from './progressEvents'; + +export interface DashDownloadOptions { + onProgress?: MonochromeProgressListener; + 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 { + 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 { + 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)); diff --git a/js/downloads.js b/js/downloads.js index 5b178de..0ef08d1 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -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); diff --git a/js/errorTypes.ts b/js/errorTypes.ts new file mode 100644 index 0000000..8ae9cdb --- /dev/null +++ b/js/errorTypes.ts @@ -0,0 +1,6 @@ +export class AbortError extends Error { + constructor(cause: string = 'The task was aborted.') { + super(cause); + this.name = 'AbortError'; + } +} diff --git a/js/ffmpeg.js b/js/ffmpeg.js index 28193fd..90be0b9 100644 --- a/js/ffmpeg.js +++ b/js/ffmpeg.js @@ -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} 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} Encoded audio blob + * @throws {FfmpegError} If Web Workers are not available + * @throws {Error} If FFmpeg encoding fails + */ export async function ffmpeg( audioBlob, args = [], diff --git a/js/ffmpeg.types.ts b/js/ffmpeg.types.ts new file mode 100644 index 0000000..43ffa38 --- /dev/null +++ b/js/ffmpeg.types.ts @@ -0,0 +1,7 @@ +export class FfmpegProgress implements MonochromeProgress { + constructor( + public readonly stage: 'loading' | 'encoding' | 'finalizing', + public readonly progress: number, + public readonly message?: string + ) {} +} diff --git a/js/hls-downloader.js b/js/hls-downloader.js index a05aa1e..19ab45f 100644 --- a/js/hls-downloader.js +++ b/js/hls-downloader.js @@ -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'; diff --git a/js/mp3-encoder.js b/js/mp3-encoder.js index 6664fb0..652c794 100644 --- a/js/mp3-encoder.js +++ b/js/mp3-encoder.js @@ -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} Encoded MP3 audio blob + */ export async function encodeToMp3(audioBlob, onProgress = null, signal = null) { try { // Use Web Worker for non-blocking FFmpeg encoding diff --git a/js/progressEvents.ts b/js/progressEvents.ts new file mode 100644 index 0000000..d3618b0 --- /dev/null +++ b/js/progressEvents.ts @@ -0,0 +1,43 @@ +declare global { + type MonochromeProgress = { + stage: string; + } & T; + + type MonochromeProgressMessage = { + message: string; + }; + + type MonochromeProgressListener = (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); + } +} diff --git a/js/taglib.ts b/js/taglib.ts index d4dbad8..f7ee443 100644 --- a/js/taglib.ts +++ b/js/taglib.ts @@ -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((resolve, reject) => { diff --git a/js/taglib.worker.ts b/js/taglib.worker.ts index 44ec706..e7841a5 100644 --- a/js/taglib.worker.ts +++ b/js/taglib.worker.ts @@ -261,9 +261,14 @@ self.onmessage = async (event: MessageEvent) => { 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,