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 //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 { APICache } from './cache.js';
import { addMetadataToAudio } from './metadata.js'; import { addMetadataToAudio } from './metadata.js';
import { DashDownloader } from './dash-downloader.js'; import { DashDownloader } from './dash-downloader.js';
@ -987,7 +987,17 @@ export class LosslessAPI {
blob = await addMetadataToAudio(blob, track, this, quality); 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) { } catch (error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
throw error; throw error;

View file

@ -8,6 +8,7 @@ import {
formatTemplate, formatTemplate,
SVG_CLOSE, SVG_CLOSE,
getCoverBlob, getCoverBlob,
getExtensionFromBlob,
} from './utils.js'; } from './utils.js';
import { lyricsSettings, bulkDownloadSettings } from './storage.js'; import { lyricsSettings, bulkDownloadSettings } from './storage.js';
import { addMetadataToAudio } from './metadata.js'; import { addMetadataToAudio } from './metadata.js';
@ -236,10 +237,13 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
blob = await response.blob(); blob = await response.blob();
} }
// Detect actual format from blob signature BEFORE adding metadata
const extension = await getExtensionFromBlob(blob);
// Add metadata to the blob // Add metadata to the blob
blob = await addMetadataToAudio(blob, enrichedTrack, api, quality); blob = await addMetadataToAudio(blob, enrichedTrack, api, quality);
return blob; return { blob, extension };
} }
function triggerDownload(blob, filename) { function triggerDownload(blob, filename) {
@ -261,12 +265,12 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not
if (signal.aborted) break; if (signal.aborted) break;
const track = tracks[i]; const track = tracks[i];
const trackTitle = getTrackTitle(track); const trackTitle = getTrackTitle(track);
const filename = buildTrackFilename(track, quality);
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try { 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); triggerDownload(blob, filename);
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
@ -316,12 +320,12 @@ async function bulkDownloadToZipStream(
if (signal.aborted) break; if (signal.aborted) break;
const track = tracks[i]; const track = tracks[i];
const trackTitle = getTrackTitle(track); const trackTitle = getTrackTitle(track);
const filename = buildTrackFilename(track, quality);
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try { 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 }; yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
@ -479,9 +483,9 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
for (const track of tracks) { for (const track of tracks) {
if (signal.aborted) break; if (signal.aborted) break;
const filename = buildTrackFilename(track, quality);
try { 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 }; yield { name: `${fullFolderPath}/${filename}`, lastModified: new Date(), input: blob };
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
@ -549,12 +553,12 @@ function createBulkDownloadNotification(type, name, _totalItems) {
type === 'album' type === 'album'
? 'Album' ? 'Album'
: type === 'playlist' : type === 'playlist'
? 'Playlist' ? 'Playlist'
: type === 'liked' : type === 'liked'
? 'Liked Tracks' ? 'Liked Tracks'
: type === 'queue' : type === 'queue'
? 'Queue' ? 'Queue'
: 'Discography'; : 'Discography';
notifEl.innerHTML = ` notifEl.innerHTML = `
<div style="display: flex; align-items: start; gap: 0.75rem;"> <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 * @returns {Promise<Blob>} - Audio blob with embedded metadata
*/ */
export async function addMetadataToAudio(audioBlob, track, api, quality) { export async function addMetadataToAudio(audioBlob, track, api, quality) {
if (quality === 'HI_RES_LOSSLESS') { // Always check actual file signature, not just quality setting
return await addFlacMetadata(audioBlob, track, api); // DASH Hi-Res streams may return fragmented MP4 instead of raw FLAC
} const buffer = await audioBlob.slice(0, 12).arrayBuffer();
const buffer = await audioBlob.slice(0, 4).arrayBuffer();
const view = new DataView(buffer); const view = new DataView(buffer);
// Check for FLAC signature: "fLaC" (0x66 0x4C 0x61 0x43)
const isFlac = const isFlac =
view.byteLength >= 4 && view.byteLength >= 4 &&
view.getUint8(0) === 0x66 && // f view.getUint8(0) === 0x66 && // f
view.getUint8(1) === 0x4c && // L view.getUint8(1) === 0x4c && // L
view.getUint8(2) === 0x61 && // a view.getUint8(2) === 0x61 && // a
view.getUint8(3) === 0x43; // C view.getUint8(3) === 0x43; // C
const mime = audioBlob.type; if (isFlac) {
if (mime === 'audio/flac') {
return await addFlacMetadata(audioBlob, track, api); 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); 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 // Parse FLAC structure
const blocks = parseFlacBlocks(dataView); 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 // Create or update Vorbis comment block
const vorbisCommentBlock = createVorbisCommentBlock(track); const vorbisCommentBlock = createVorbisCommentBlock(track);
@ -321,7 +352,27 @@ async function addFlacMetadata(flacBlob, track, api) {
} }
// Rebuild FLAC file with new metadata // 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' }); return new Blob([newFlacData], { type: 'audio/flac' });
} catch (error) { } catch (error) {
@ -350,14 +401,21 @@ function parseFlacBlocks(dataView) {
const isLast = (header & 0x80) !== 0; const isLast = (header & 0x80) !== 0;
const blockType = header & 0x7f; 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 = const blockSize =
(dataView.getUint8(offset + 1) << 16) | (dataView.getUint8(offset + 1) << 16) |
(dataView.getUint8(offset + 2) << 8) | (dataView.getUint8(offset + 2) << 8) |
dataView.getUint8(offset + 3); dataView.getUint8(offset + 3);
// Validate block size // Validate block size
if (offset + 4 + blockSize > dataView.byteLength) { if (blockSize < 0 || offset + 4 + blockSize > dataView.byteLength) {
console.warn('Invalid block size detected, stopping parse'); console.warn(`Invalid block size ${blockSize} at offset ${offset}, stopping parse`);
break; 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; 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 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'; const artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist';
@ -103,7 +143,7 @@ export const buildTrackFilename = (track, quality) => {
album: track.album?.title, album: track.album?.title,
}; };
return formatTemplate(template, data) + '.' + extension; return formatTemplate(template, data) + '.' + ext;
}; };
const sanitizeToken = (value) => { const sanitizeToken = (value) => {