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
|
//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;
|
||||||
|
|
|
||||||
|
|
@ -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;">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 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) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue