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:
parent
575e4590bc
commit
2e322ac8a6
4 changed files with 150 additions and 31 deletions
14
js/api.js
14
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;
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
46
js/utils.js
46
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<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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue