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
This commit is contained in:
Julien Maille 2026-01-26 21:44:33 +01:00
parent 575e4590bc
commit 2e322ac8a6
4 changed files with 150 additions and 31 deletions

View file

@ -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;

View file

@ -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 = `
<div style="display: flex; align-items: start; gap: 0.75rem;">

View file

@ -14,28 +14,47 @@ const DEFAULT_ALBUM = 'Unknown Album';
* @returns {Promise<Blob>} - 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;
}

View file

@ -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<string>} - 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) => {