fix(covers): embed album art for single track downloads
This commit is contained in:
parent
04f04ca03a
commit
ad615f52f8
4 changed files with 189 additions and 38 deletions
30
js/api.js
30
js/api.js
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
152
js/metadata.js
152
js/metadata.js
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue