From ad615f52f856b33837708fb517ad590b4f8a22f9 Mon Sep 17 00:00:00 2001 From: Samidy Date: Tue, 10 Mar 2026 10:31:04 +0300 Subject: [PATCH] fix(covers): embed album art for single track downloads --- js/api.js | 30 +++++++++- js/downloads.js | 16 ++--- js/id3-writer.js | 29 ++++++--- js/metadata.js | 152 ++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 189 insertions(+), 38 deletions(-) diff --git a/js/api.js b/js/api.js index bcbad22..c614477 100644 --- a/js/api.js +++ b/js/api.js @@ -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); } } diff --git a/js/downloads.js b/js/downloads.js index 00edae2..1a5abad 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -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 { diff --git a/js/id3-writer.js b/js/id3-writer.js index 47a841b..df167fa 100644 --- a/js/id3-writer.js +++ b/js/id3-writer.js @@ -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); + } } } diff --git a/js/metadata.js b/js/metadata.js index 1cb84af..f49e16c 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -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} - 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); + } } }