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; decoded = manifest;
} }
} else if (typeof manifest === 'object') { } 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]; if (manifest.urls?.[0]) return manifest.urls[0];
return null; return null;
} else { } else {
@ -319,6 +334,19 @@ export class LosslessAPI {
try { try {
const parsed = JSON.parse(decoded); 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]) { if (parsed?.urls?.[0]) {
return 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); }, 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 = { let enrichedTrack = {
...track, ...track,
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null), 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 // Fallback
if (downloadQuality !== 'LOSSLESS') { if (downloadQuality !== 'LOSSLESS') {
console.warn('Falling back to LOSSLESS (16-bit) download.'); 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; throw dashError;
} }
@ -427,7 +427,7 @@ function triggerDownload(blob, filename) {
URL.revokeObjectURL(url); 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 { abortController } = bulkDownloadTasks.get(notification);
const signal = abortController.signal; const signal = abortController.signal;
@ -439,7 +439,7 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try { 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 filename = buildTrackFilename(track, quality, extension);
triggerDownload(blob, filename); triggerDownload(blob, filename);
@ -568,7 +568,7 @@ async function bulkDownloadToZipStream(
try { try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => { const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
}); }, coverBlob);
const filename = buildTrackFilename(track, quality, extension); const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i); const discNumber = discLayout.resolveDiscNumber(i);
yield { yield {
@ -712,7 +712,7 @@ async function bulkDownloadToZipBlob(
try { try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => { const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
}); }, coverBlob);
const filename = buildTrackFilename(track, quality, extension); const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i); const discNumber = discLayout.resolveDiscNumber(i);
yield { yield {
@ -857,7 +857,7 @@ async function bulkDownloadToZipNeutralino(
try { try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => { const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
}); }, coverBlob);
const filename = buildTrackFilename(track, quality, extension); const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i); const discNumber = discLayout.resolveDiscNumber(i);
yield { yield {
@ -1185,7 +1185,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
const track = tracks[i]; const track = tracks[i];
if (signal.aborted) break; if (signal.aborted) break;
try { 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 filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i); const discNumber = discLayout.resolveDiscNumber(i);
yield { yield {

View file

@ -136,15 +136,28 @@ function buildID3v2Tag(mp3Blob, frames) {
return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' }); return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' });
} }
export async function addMp3Metadata(mp3Blob, track, api) { function getTrackCoverId(track) {
try { return (
let coverBlob = null; track.album?.cover ||
track.cover ||
track.image ||
track.album?.coverId ||
track.coverId ||
track.album?.image ||
null
);
}
if (track.album?.cover) { export async function addMp3Metadata(mp3Blob, track, api, coverBlob = null) {
try { try {
coverBlob = await getCoverBlob(api, track.album.cover); if (!coverBlob) {
} catch (error) { const coverId = getTrackCoverId(track);
console.warn('Failed to fetch album art for MP3:', error); 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; 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) * Adds metadata tags to audio files (FLAC, M4A or MP3)
* @param {Blob} audioBlob - The audio file blob * @param {Blob} audioBlob - The audio file blob
* @param {Object} track - Track metadata * @param {Object} track - Track metadata
* @param {Object} api - API instance for fetching album art * @param {Object} api - API instance for fetching album art
* @param {string} quality - Audio quality * @param {string} quality - Audio quality
* @param {Blob} [coverBlob] - Optional pre-fetched album art blob
* @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, coverBlob = null) {
// Always check actual file signature, not just quality setting // Always check actual file signature, not just quality setting
// DASH Hi-Res streams may return fragmented MP4 instead of raw FLAC // 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, 12).arrayBuffer();
@ -58,11 +76,11 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) {
switch (format) { switch (format) {
case 'flac': case 'flac':
return await addFlacMetadata(audioBlob, track, api); return await addFlacMetadata(audioBlob, track, api, coverBlob);
case 'mp4': case 'mp4':
return await addM4aMetadata(audioBlob, track, api); return await addM4aMetadata(audioBlob, track, api, coverBlob);
case 'mp3': case 'mp3':
return await addMp3Metadata(audioBlob, track, api); return await addMp3Metadata(audioBlob, track, api, coverBlob);
default: default:
// Unknown format - return original without modification // Unknown format - return original without modification
console.warn(`Unknown audio format (mime: ${audioBlob.type}), returning original blob`); 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 * Adds Vorbis comment metadata to FLAC files
*/ */
async function addFlacMetadata(flacBlob, track, api) { async function addFlacMetadata(flacBlob, track, api, coverBlob = null) {
try { try {
const arrayBuffer = await flacBlob.arrayBuffer(); const arrayBuffer = await flacBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer); const dataView = new DataView(arrayBuffer);
@ -558,13 +576,24 @@ async function addFlacMetadata(flacBlob, track, api) {
// Create or update Vorbis comment block // Create or update Vorbis comment block
const vorbisCommentBlock = createVorbisCommentBlock(track); const vorbisCommentBlock = createVorbisCommentBlock(track);
// Fetch album artwork if available
let pictureBlock = null; let pictureBlock = null;
if (track.album?.cover) { if (coverBlob) {
try { try {
pictureBlock = await createFlacPictureBlock(track.album.cover, api); const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
pictureBlock = await createFlacPictureBlockFromBytes(imageBytes, coverBlob.type);
} catch (error) { } 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; 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 * Adds metadata to M4A files using MP4 atoms
*/ */
async function addM4aMetadata(m4aBlob, track, api) { async function addM4aMetadata(m4aBlob, track, api, coverBlob = null) {
try { try {
const arrayBuffer = await m4aBlob.arrayBuffer(); const arrayBuffer = await m4aBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer); const dataView = new DataView(arrayBuffer);
@ -967,19 +1063,33 @@ async function addM4aMetadata(m4aBlob, track, api) {
// Create metadata atoms // Create metadata atoms
const metadataAtoms = createMp4MetadataAtoms(track); const metadataAtoms = createMp4MetadataAtoms(track);
// Fetch album artwork if available if (coverBlob) {
if (track.album?.cover) {
try { try {
const imageBlob = await getCoverBlob(api, track.album.cover); const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
if (imageBlob) { metadataAtoms.cover = {
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); type: 'covr',
metadataAtoms.cover = { data: imageBytes,
type: 'covr', };
data: imageBytes,
};
}
} catch (error) { } 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);
}
} }
} }