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) => {