From 2e322ac8a66ba17a99a0ff2d39cfcb5c0a38b66c Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Mon, 26 Jan 2026 21:44:33 +0100 Subject: [PATCH] fix(downloads): detect actual format for all download paths Fixes #117 - Add getExtensionFromBlob() to detect format from blob signature - DASH Hi-Res streams are MP4 containers, not raw FLAC - Fix api.downloadTrack to detect and correct filename extension - Fix bulk download functions to use detected extension - Fallback to mime type if signature detection fails --- js/api.js | 14 ++++++-- js/downloads.js | 30 +++++++++------- js/metadata.js | 91 ++++++++++++++++++++++++++++++++++++++++++------- js/utils.js | 46 +++++++++++++++++++++++-- 4 files changed, 150 insertions(+), 31 deletions(-) diff --git a/js/api.js b/js/api.js index 8b6d7d0..4049f0d 100644 --- a/js/api.js +++ b/js/api.js @@ -1,5 +1,5 @@ //js/api.js -import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay, isTrackUnavailable } from './utils.js'; +import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay, isTrackUnavailable, getExtensionFromBlob } from './utils.js'; import { APICache } from './cache.js'; import { addMetadataToAudio } from './metadata.js'; import { DashDownloader } from './dash-downloader.js'; @@ -987,7 +987,17 @@ export class LosslessAPI { blob = await addMetadataToAudio(blob, track, this, quality); } - this.triggerDownload(blob, filename); + // 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}`); + } + + this.triggerDownload(blob, finalFilename); } catch (error) { if (error.name === 'AbortError') { throw error; diff --git a/js/downloads.js b/js/downloads.js index be3731f..8ac563c 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -8,6 +8,7 @@ import { formatTemplate, SVG_CLOSE, getCoverBlob, + getExtensionFromBlob, } from './utils.js'; import { lyricsSettings, bulkDownloadSettings } from './storage.js'; import { addMetadataToAudio } from './metadata.js'; @@ -236,10 +237,13 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign blob = await response.blob(); } + // 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); - return blob; + return { blob, extension }; } function triggerDownload(blob, filename) { @@ -261,12 +265,12 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not if (signal.aborted) break; const track = tracks[i]; const trackTitle = getTrackTitle(track); - const filename = buildTrackFilename(track, quality); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); try { - const blob = await downloadTrackBlob(track, quality, api, null, signal); + const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); + const filename = buildTrackFilename(track, quality, extension); triggerDownload(blob, filename); if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { @@ -316,12 +320,12 @@ async function bulkDownloadToZipStream( if (signal.aborted) break; const track = tracks[i]; const trackTitle = getTrackTitle(track); - const filename = buildTrackFilename(track, quality); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); try { - const blob = await downloadTrackBlob(track, quality, api, null, signal); + const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); + const filename = buildTrackFilename(track, quality, extension); yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob }; if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { @@ -479,9 +483,9 @@ export async function downloadDiscography(artist, selectedReleases, api, quality for (const track of tracks) { if (signal.aborted) break; - const filename = buildTrackFilename(track, quality); try { - const blob = await downloadTrackBlob(track, quality, api, null, signal); + const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); + const filename = buildTrackFilename(track, quality, extension); yield { name: `${fullFolderPath}/${filename}`, lastModified: new Date(), input: blob }; if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { @@ -549,12 +553,12 @@ function createBulkDownloadNotification(type, name, _totalItems) { type === 'album' ? 'Album' : type === 'playlist' - ? 'Playlist' - : type === 'liked' - ? 'Liked Tracks' - : type === 'queue' - ? 'Queue' - : 'Discography'; + ? 'Playlist' + : type === 'liked' + ? 'Liked Tracks' + : type === 'queue' + ? 'Queue' + : 'Discography'; notifEl.innerHTML = `
diff --git a/js/metadata.js b/js/metadata.js index d075643..c33883f 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -14,28 +14,47 @@ const DEFAULT_ALBUM = 'Unknown Album'; * @returns {Promise} - Audio blob with embedded metadata */ export async function addMetadataToAudio(audioBlob, track, api, quality) { - if (quality === 'HI_RES_LOSSLESS') { - return await addFlacMetadata(audioBlob, track, api); - } - - const buffer = await audioBlob.slice(0, 4).arrayBuffer(); + // Always check actual file signature, not just quality setting + // DASH Hi-Res streams may return fragmented MP4 instead of raw FLAC + const buffer = await audioBlob.slice(0, 12).arrayBuffer(); const view = new DataView(buffer); + + // Check for FLAC signature: "fLaC" (0x66 0x4C 0x61 0x43) const isFlac = view.byteLength >= 4 && view.getUint8(0) === 0x66 && // f view.getUint8(1) === 0x4c && // L view.getUint8(2) === 0x61 && // a - view.getUint8(3) === 0x43; // C + view.getUint8(3) === 0x43; // C - const mime = audioBlob.type; - - if (mime === 'audio/flac') { + if (isFlac) { return await addFlacMetadata(audioBlob, track, api); } - if (mime === 'audio/mp4') { + // Check for MP4/M4A signature: "ftyp" at offset 4 + const isMp4 = + view.byteLength >= 8 && + view.getUint8(4) === 0x66 && // f + view.getUint8(5) === 0x74 && // t + view.getUint8(6) === 0x79 && // y + view.getUint8(7) === 0x70; // p + + if (isMp4) { return await addM4aMetadata(audioBlob, track, api); } + + // Fallback: check MIME type from blob + const mime = audioBlob.type; + if (mime === 'audio/flac') { + return await addFlacMetadata(audioBlob, track, api); + } + if (mime === 'audio/mp4' || mime === 'audio/x-m4a') { + return await addM4aMetadata(audioBlob, track, api); + } + + // Unknown format - return original without modification + console.warn(`Unknown audio format (mime: ${mime}), returning original blob`); + return audioBlob; } /** @@ -307,6 +326,18 @@ async function addFlacMetadata(flacBlob, track, api) { // Parse FLAC structure const blocks = parseFlacBlocks(dataView); + // If parsing failed or no audio data found, return original + if (!blocks || blocks.length === 0 || blocks.audioDataOffset === undefined) { + console.warn('Failed to parse FLAC blocks, returning original'); + return flacBlob; + } + + // Check for STREAMINFO block (must be first, type 0) + if (blocks[0].type !== 0) { + console.warn('FLAC file missing STREAMINFO block, returning original'); + return flacBlob; + } + // Create or update Vorbis comment block const vorbisCommentBlock = createVorbisCommentBlock(track); @@ -321,7 +352,27 @@ async function addFlacMetadata(flacBlob, track, api) { } // Rebuild FLAC file with new metadata - const newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock); + let newFlacData; + try { + newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock); + } catch (rebuildError) { + console.error('Failed to rebuild FLAC structure:', rebuildError); + return flacBlob; + } + + // Validate the rebuilt file + const validationView = new DataView(newFlacData.buffer); + if (!isFlacFile(validationView)) { + console.error('Rebuilt FLAC has invalid signature, returning original'); + return flacBlob; + } + + // Validate new file has proper block structure + const newBlocks = parseFlacBlocks(validationView); + if (!newBlocks || newBlocks.length === 0 || newBlocks.audioDataOffset === undefined) { + console.error('Rebuilt FLAC has invalid block structure, returning original'); + return flacBlob; + } return new Blob([newFlacData], { type: 'audio/flac' }); } catch (error) { @@ -350,14 +401,21 @@ function parseFlacBlocks(dataView) { const isLast = (header & 0x80) !== 0; const blockType = header & 0x7f; + // Block type 127 is invalid, types > 6 are reserved (except 127) + // Valid types: 0=STREAMINFO, 1=PADDING, 2=APPLICATION, 3=SEEKTABLE, 4=VORBIS_COMMENT, 5=CUESHEET, 6=PICTURE + if (blockType === 127) { + console.warn('Encountered invalid block type 127, stopping parse'); + break; + } + const blockSize = (dataView.getUint8(offset + 1) << 16) | (dataView.getUint8(offset + 2) << 8) | dataView.getUint8(offset + 3); // Validate block size - if (offset + 4 + blockSize > dataView.byteLength) { - console.warn('Invalid block size detected, stopping parse'); + if (blockSize < 0 || offset + 4 + blockSize > dataView.byteLength) { + console.warn(`Invalid block size ${blockSize} at offset ${offset}, stopping parse`); break; } @@ -378,6 +436,13 @@ function parseFlacBlocks(dataView) { } } + // If we didn't find the last block marker, estimate audio offset + if (blocks.audioDataOffset === undefined && blocks.length > 0) { + const lastBlock = blocks[blocks.length - 1]; + blocks.audioDataOffset = lastBlock.headerOffset + 4 + lastBlock.size; + console.warn('No last-block marker found, estimated audio offset:', blocks.audioDataOffset); + } + return blocks; } diff --git a/js/utils.js b/js/utils.js index 3d9afea..208823a 100644 --- a/js/utils.js +++ b/js/utils.js @@ -90,9 +90,49 @@ export const getExtensionForQuality = (quality) => { } }; -export const buildTrackFilename = (track, quality) => { +/** + * Detects actual audio format from blob signature + * @param {Blob} blob - Audio blob to analyze + * @returns {Promise} - Extension: 'flac', 'm4a', or fallback based on mime + */ +export const getExtensionFromBlob = async (blob) => { + const buffer = await blob.slice(0, 12).arrayBuffer(); + const view = new DataView(buffer); + + // Check for FLAC signature: "fLaC" (0x66 0x4C 0x61 0x43) + if ( + view.byteLength >= 4 && + view.getUint8(0) === 0x66 && // f + view.getUint8(1) === 0x4c && // L + view.getUint8(2) === 0x61 && // a + view.getUint8(3) === 0x43 // C + ) { + return 'flac'; + } + + // Check for MP4/M4A signature: "ftyp" at offset 4 + if ( + view.byteLength >= 8 && + view.getUint8(4) === 0x66 && // f + view.getUint8(5) === 0x74 && // t + view.getUint8(6) === 0x79 && // y + view.getUint8(7) === 0x70 // p + ) { + return 'm4a'; + } + + // Fallback to MIME type + const mime = blob.type; + if (mime === 'audio/flac') return 'flac'; + if (mime === 'audio/mp4' || mime === 'audio/x-m4a') return 'm4a'; + + // Default fallback + return 'flac'; +}; + +export const buildTrackFilename = (track, quality, extension = null) => { const template = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}'; - const extension = getExtensionForQuality(quality); + const ext = extension || getExtensionForQuality(quality); const artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist'; @@ -103,7 +143,7 @@ export const buildTrackFilename = (track, quality) => { album: track.album?.title, }; - return formatTemplate(template, data) + '.' + extension; + return formatTemplate(template, data) + '.' + ext; }; const sanitizeToken = (value) => {