fix(covers): embed album art for single track downloads

This commit is contained in:
Samidy 2026-03-10 10:31:04 +03:00
parent 04f04ca03a
commit ad615f52f8
4 changed files with 189 additions and 38 deletions

View file

@ -305,6 +305,21 @@ export class LosslessAPI {
decoded = manifest;
}
} else if (typeof manifest === 'object') {
if (manifest.urls && Array.isArray(manifest.urls)) {
const priorityKeywords = ['flac', 'lossless', 'hi-res', 'high'];
const sortedUrls = [...manifest.urls].sort((a, b) => {
const aLow = a.toLowerCase();
const bLow = b.toLowerCase();
const aScore = priorityKeywords.findIndex((k) => aLow.includes(k));
const bScore = priorityKeywords.findIndex((k) => bLow.includes(k));
const finalAScore = aScore === -1 ? 999 : aScore;
const finalBScore = bScore === -1 ? 999 : bScore;
return finalAScore - finalBScore;
});
return sortedUrls[0];
}
if (manifest.urls?.[0]) return manifest.urls[0];
return null;
} else {
@ -319,6 +334,19 @@ export class LosslessAPI {
try {
const parsed = JSON.parse(decoded);
if (parsed?.urls && Array.isArray(parsed.urls)) {
const priorityKeywords = ['flac', 'lossless', 'hi-res', 'high'];
const sortedUrls = [...parsed.urls].sort((a, b) => {
const aLow = a.toLowerCase();
const bLow = b.toLowerCase();
const aScore = priorityKeywords.findIndex((k) => aLow.includes(k));
const bScore = priorityKeywords.findIndex((k) => bLow.includes(k));
const finalAScore = aScore === -1 ? 999 : aScore;
const finalBScore = bScore === -1 ? 999 : bScore;
return finalAScore - finalBScore;
});
return sortedUrls[0];
}
if (parsed?.urls?.[0]) {
return parsed.urls[0];
}
@ -1451,7 +1479,7 @@ export class LosslessAPI {
};
}
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality);
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, options.coverBlob);
}
}

View file

@ -278,7 +278,7 @@ function removeBulkDownloadTask(notifEl) {
}, 300);
}
async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null, onProgress = null) {
async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null, onProgress = null, coverBlob = null) {
let enrichedTrack = {
...track,
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
@ -353,7 +353,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
// Fallback
if (downloadQuality !== 'LOSSLESS') {
console.warn('Falling back to LOSSLESS (16-bit) download.');
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress);
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress, coverBlob);
}
throw dashError;
}
@ -427,7 +427,7 @@ function triggerDownload(blob, filename) {
URL.revokeObjectURL(url);
}
async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification) {
async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification, coverBlob = null) {
const { abortController } = bulkDownloadTasks.get(notification);
const signal = abortController.signal;
@ -439,7 +439,7 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, null, coverBlob);
const filename = buildTrackFilename(track, quality, extension);
triggerDownload(blob, filename);
@ -568,7 +568,7 @@ async function bulkDownloadToZipStream(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
});
}, coverBlob);
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
yield {
@ -712,7 +712,7 @@ async function bulkDownloadToZipBlob(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
});
}, coverBlob);
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
yield {
@ -857,7 +857,7 @@ async function bulkDownloadToZipNeutralino(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
});
}, coverBlob);
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
yield {
@ -1185,7 +1185,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
const track = tracks[i];
if (signal.aborted) break;
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, null, coverBlob);
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
yield {

View file

@ -136,15 +136,28 @@ function buildID3v2Tag(mp3Blob, frames) {
return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' });
}
export async function addMp3Metadata(mp3Blob, track, api) {
try {
let coverBlob = null;
function getTrackCoverId(track) {
return (
track.album?.cover ||
track.cover ||
track.image ||
track.album?.coverId ||
track.coverId ||
track.album?.image ||
null
);
}
if (track.album?.cover) {
try {
coverBlob = await getCoverBlob(api, track.album.cover);
} catch (error) {
console.warn('Failed to fetch album art for MP3:', error);
export async function addMp3Metadata(mp3Blob, track, api, coverBlob = null) {
try {
if (!coverBlob) {
const coverId = getTrackCoverId(track);
if (coverId) {
try {
coverBlob = await getCoverBlob(api, coverId);
} catch (error) {
console.warn('Failed to fetch album art for MP3:', error);
}
}
}

View file

@ -40,15 +40,33 @@ function getFullArtistString(track) {
return knownArtists.join('; ') || null;
}
/**
*
* @param {Object} track
* @returns {string|null}
*/
function getTrackCoverId(track) {
return (
track.album?.cover ||
track.cover ||
track.image ||
track.album?.coverId ||
track.coverId ||
track.album?.image ||
null
);
}
/**
* Adds metadata tags to audio files (FLAC, M4A or MP3)
* @param {Blob} audioBlob - The audio file blob
* @param {Object} track - Track metadata
* @param {Object} api - API instance for fetching album art
* @param {string} quality - Audio quality
* @param {Blob} [coverBlob] - Optional pre-fetched album art blob
* @returns {Promise<Blob>} - Audio blob with embedded metadata
*/
export async function addMetadataToAudio(audioBlob, track, api, _quality) {
export async function addMetadataToAudio(audioBlob, track, api, _quality, coverBlob = null) {
// 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();
@ -58,11 +76,11 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) {
switch (format) {
case 'flac':
return await addFlacMetadata(audioBlob, track, api);
return await addFlacMetadata(audioBlob, track, api, coverBlob);
case 'mp4':
return await addM4aMetadata(audioBlob, track, api);
return await addM4aMetadata(audioBlob, track, api, coverBlob);
case 'mp3':
return await addMp3Metadata(audioBlob, track, api);
return await addMp3Metadata(audioBlob, track, api, coverBlob);
default:
// Unknown format - return original without modification
console.warn(`Unknown audio format (mime: ${audioBlob.type}), returning original blob`);
@ -529,7 +547,7 @@ function getMimeType(data) {
/**
* Adds Vorbis comment metadata to FLAC files
*/
async function addFlacMetadata(flacBlob, track, api) {
async function addFlacMetadata(flacBlob, track, api, coverBlob = null) {
try {
const arrayBuffer = await flacBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer);
@ -558,13 +576,24 @@ async function addFlacMetadata(flacBlob, track, api) {
// Create or update Vorbis comment block
const vorbisCommentBlock = createVorbisCommentBlock(track);
// Fetch album artwork if available
let pictureBlock = null;
if (track.album?.cover) {
if (coverBlob) {
try {
pictureBlock = await createFlacPictureBlock(track.album.cover, api);
const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
pictureBlock = await createFlacPictureBlockFromBytes(imageBytes, coverBlob.type);
} catch (error) {
console.warn('Failed to embed album art:', error);
console.warn('Failed to embed provided album art:', error);
}
}
if (!pictureBlock) {
const coverId = getTrackCoverId(track);
if (coverId) {
try {
pictureBlock = await createFlacPictureBlock(coverId, api);
} catch (error) {
console.warn('Failed to embed album art:', error);
}
}
}
@ -953,10 +982,77 @@ function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBl
return newFile;
}
async function createFlacPictureBlockFromBytes(imageBytes, mimeType = 'image/jpeg') {
try {
const mimeBytes = new TextEncoder().encode(mimeType);
const description = '';
const descBytes = new TextEncoder().encode(description);
const totalSize =
4 +
4 +
mimeBytes.length +
4 +
descBytes.length +
4 +
4 +
4 +
4 +
4 +
imageBytes.length;
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const uint8Array = new Uint8Array(buffer);
let offset = 0;
view.setUint32(offset, 3, false);
offset += 4;
view.setUint32(offset, mimeBytes.length, false);
offset += 4;
uint8Array.set(mimeBytes, offset);
offset += mimeBytes.length;
view.setUint32(offset, descBytes.length, false);
offset += 4;
if (descBytes.length > 0) {
uint8Array.set(descBytes, offset);
offset += descBytes.length;
}
view.setUint32(offset, 0, false);
offset += 4;
view.setUint32(offset, 0, false);
offset += 4;
view.setUint32(offset, 0, false);
offset += 4;
view.setUint32(offset, 0, false);
offset += 4;
view.setUint32(offset, imageBytes.length, false);
offset += 4;
uint8Array.set(imageBytes, offset);
return uint8Array;
} catch (error) {
console.error('Failed to create FLAC picture block from bytes:', error);
return null;
}
}
/**
* Adds metadata to M4A files using MP4 atoms
*/
async function addM4aMetadata(m4aBlob, track, api) {
async function addM4aMetadata(m4aBlob, track, api, coverBlob = null) {
try {
const arrayBuffer = await m4aBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer);
@ -967,19 +1063,33 @@ async function addM4aMetadata(m4aBlob, track, api) {
// Create metadata atoms
const metadataAtoms = createMp4MetadataAtoms(track);
// Fetch album artwork if available
if (track.album?.cover) {
if (coverBlob) {
try {
const imageBlob = await getCoverBlob(api, track.album.cover);
if (imageBlob) {
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
metadataAtoms.cover = {
type: 'covr',
data: imageBytes,
};
}
const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
metadataAtoms.cover = {
type: 'covr',
data: imageBytes,
};
} catch (error) {
console.warn('Failed to embed album art in M4A:', error);
console.warn('Failed to embed provided album art in M4A:', error);
}
}
if (!metadataAtoms.cover) {
const coverId = getTrackCoverId(track);
if (coverId) {
try {
const imageBlob = await getCoverBlob(api, coverId);
if (imageBlob) {
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
metadataAtoms.cover = {
type: 'covr',
data: imageBytes,
};
}
} catch (error) {
console.warn('Failed to embed album art in M4A:', error);
}
}
}