527 lines
17 KiB
JavaScript
527 lines
17 KiB
JavaScript
import { getExtensionForQuality, getCoverBlob } from './utils.js';
|
|
|
|
const VENDOR_STRING = 'Monochrome';
|
|
const DEFAULT_TITLE = 'Unknown Title';
|
|
const DEFAULT_ARTIST = 'Unknown Artist';
|
|
const DEFAULT_ALBUM = 'Unknown Album';
|
|
|
|
/**
|
|
* Adds metadata tags to audio files (FLAC or M4A)
|
|
* @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
|
|
* @returns {Promise<Blob>} - Audio blob with embedded metadata
|
|
*/
|
|
export async function addMetadataToAudio(audioBlob, track, api, quality) {
|
|
const extension = getExtensionForQuality(quality);
|
|
|
|
if (extension === 'flac') {
|
|
return await addFlacMetadata(audioBlob, track, api);
|
|
} else if (extension === 'm4a') {
|
|
return await addM4aMetadata(audioBlob, track, api);
|
|
}
|
|
|
|
// If unsupported format, return original blob
|
|
return audioBlob;
|
|
}
|
|
|
|
/**
|
|
* Adds Vorbis comment metadata to FLAC files
|
|
*/
|
|
async function addFlacMetadata(flacBlob, track, api) {
|
|
try {
|
|
const arrayBuffer = await flacBlob.arrayBuffer();
|
|
const dataView = new DataView(arrayBuffer);
|
|
|
|
// Verify FLAC signature
|
|
if (!isFlacFile(dataView)) {
|
|
console.warn('Not a valid FLAC file, returning original');
|
|
return flacBlob;
|
|
}
|
|
|
|
// Parse FLAC structure
|
|
const blocks = parseFlacBlocks(dataView);
|
|
|
|
// Create or update Vorbis comment block
|
|
const vorbisCommentBlock = createVorbisCommentBlock(track);
|
|
|
|
// Fetch album artwork if available
|
|
let pictureBlock = null;
|
|
if (track.album?.cover) {
|
|
try {
|
|
pictureBlock = await createFlacPictureBlock(track.album.cover, api);
|
|
} catch (error) {
|
|
console.warn('Failed to embed album art:', error);
|
|
}
|
|
}
|
|
|
|
// Rebuild FLAC file with new metadata
|
|
const newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock);
|
|
|
|
return new Blob([newFlacData], { type: 'audio/flac' });
|
|
} catch (error) {
|
|
console.error('Failed to add FLAC metadata:', error);
|
|
return flacBlob;
|
|
}
|
|
}
|
|
|
|
function isFlacFile(dataView) {
|
|
// Check for "fLaC" signature at the beginning
|
|
return dataView.byteLength >= 4 &&
|
|
dataView.getUint8(0) === 0x66 && // 'f'
|
|
dataView.getUint8(1) === 0x4C && // 'L'
|
|
dataView.getUint8(2) === 0x61 && // 'a'
|
|
dataView.getUint8(3) === 0x43; // 'C'
|
|
}
|
|
|
|
function parseFlacBlocks(dataView) {
|
|
const blocks = [];
|
|
let offset = 4; // Skip "fLaC" signature
|
|
|
|
while (offset + 4 <= dataView.byteLength) {
|
|
const header = dataView.getUint8(offset);
|
|
const isLast = (header & 0x80) !== 0;
|
|
const blockType = header & 0x7F;
|
|
|
|
const blockSize = (dataView.getUint8(offset + 1) << 16) |
|
|
(dataView.getUint8(offset + 2) << 8) |
|
|
dataView.getUint8(offset + 3);
|
|
|
|
// Validate block size
|
|
if (offset + 4 + blockSize > dataView.byteLength) {
|
|
console.warn('Invalid block size detected, stopping parse');
|
|
break;
|
|
}
|
|
|
|
blocks.push({
|
|
type: blockType,
|
|
isLast: isLast,
|
|
size: blockSize,
|
|
offset: offset + 4,
|
|
headerOffset: offset
|
|
});
|
|
|
|
offset += 4 + blockSize;
|
|
|
|
if (isLast) {
|
|
// Save the audio data offset
|
|
blocks.audioDataOffset = offset;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
function createVorbisCommentBlock(track) {
|
|
// Vorbis comment structure
|
|
const comments = [];
|
|
|
|
// Add standard tags
|
|
if (track.title) {
|
|
comments.push(['TITLE', track.title]);
|
|
}
|
|
if (track.artist?.name) {
|
|
comments.push(['ARTIST', track.artist.name]);
|
|
}
|
|
if (track.album?.title) {
|
|
comments.push(['ALBUM', track.album.title]);
|
|
}
|
|
if (track.album?.artist?.name) {
|
|
comments.push(['ALBUMARTIST', track.album.artist.name]);
|
|
}
|
|
if (track.trackNumber) {
|
|
comments.push(['TRACKNUMBER', String(track.trackNumber)]);
|
|
}
|
|
if (track.album?.numberOfTracks) {
|
|
comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]);
|
|
}
|
|
|
|
const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
|
|
if (releaseDateStr) {
|
|
try {
|
|
const year = new Date(releaseDateStr).getFullYear();
|
|
if (!isNaN(year)) {
|
|
comments.push(['DATE', String(year)]);
|
|
}
|
|
} catch (error) {
|
|
// Invalid date, skip
|
|
}
|
|
}
|
|
|
|
if (track.copyright) {
|
|
comments.push(['COPYRIGHT', track.copyright]);
|
|
}
|
|
if (track.isrc) {
|
|
comments.push(['ISRC', track.isrc]);
|
|
}
|
|
|
|
// Calculate total size
|
|
const vendor = VENDOR_STRING;
|
|
const vendorBytes = new TextEncoder().encode(vendor);
|
|
|
|
let totalSize = 4 + vendorBytes.length + 4; // vendor length + vendor + comment count
|
|
|
|
const encodedComments = comments.map(([key, value]) => {
|
|
const text = `${key}=${value}`;
|
|
const bytes = new TextEncoder().encode(text);
|
|
totalSize += 4 + bytes.length;
|
|
return bytes;
|
|
});
|
|
|
|
// Create buffer
|
|
const buffer = new ArrayBuffer(totalSize);
|
|
const view = new DataView(buffer);
|
|
const uint8Array = new Uint8Array(buffer);
|
|
|
|
let offset = 0;
|
|
|
|
// Vendor length (little-endian)
|
|
view.setUint32(offset, vendorBytes.length, true);
|
|
offset += 4;
|
|
|
|
// Vendor string
|
|
uint8Array.set(vendorBytes, offset);
|
|
offset += vendorBytes.length;
|
|
|
|
// Comment count (little-endian)
|
|
view.setUint32(offset, comments.length, true);
|
|
offset += 4;
|
|
|
|
// Comments
|
|
for (const commentBytes of encodedComments) {
|
|
view.setUint32(offset, commentBytes.length, true);
|
|
offset += 4;
|
|
uint8Array.set(commentBytes, offset);
|
|
offset += commentBytes.length;
|
|
}
|
|
|
|
return uint8Array;
|
|
}
|
|
|
|
async function createFlacPictureBlock(coverId, api) {
|
|
try {
|
|
// Fetch album art
|
|
const imageBlob = await getCoverBlob(api, coverId);
|
|
if (!imageBlob) {
|
|
throw new Error('Failed to fetch album art');
|
|
}
|
|
|
|
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
|
|
|
|
// Detect MIME type from blob or use default
|
|
const mimeType = imageBlob.type || 'image/jpeg';
|
|
const mimeBytes = new TextEncoder().encode(mimeType);
|
|
const description = '';
|
|
const descBytes = new TextEncoder().encode(description);
|
|
|
|
// Calculate total size
|
|
const totalSize = 4 + // picture type
|
|
4 + mimeBytes.length + // mime length + mime
|
|
4 + descBytes.length + // desc length + desc
|
|
4 + // width
|
|
4 + // height
|
|
4 + // color depth
|
|
4 + // indexed colors
|
|
4 + imageBytes.length; // image length + image
|
|
|
|
const buffer = new ArrayBuffer(totalSize);
|
|
const view = new DataView(buffer);
|
|
const uint8Array = new Uint8Array(buffer);
|
|
|
|
let offset = 0;
|
|
|
|
// Picture type (3 = front cover)
|
|
view.setUint32(offset, 3, false);
|
|
offset += 4;
|
|
|
|
// MIME type length
|
|
view.setUint32(offset, mimeBytes.length, false);
|
|
offset += 4;
|
|
|
|
// MIME type
|
|
uint8Array.set(mimeBytes, offset);
|
|
offset += mimeBytes.length;
|
|
|
|
// Description length
|
|
view.setUint32(offset, descBytes.length, false);
|
|
offset += 4;
|
|
|
|
// Description (empty)
|
|
if (descBytes.length > 0) {
|
|
uint8Array.set(descBytes, offset);
|
|
offset += descBytes.length;
|
|
}
|
|
|
|
// Width (0 = unknown)
|
|
view.setUint32(offset, 0, false);
|
|
offset += 4;
|
|
|
|
// Height (0 = unknown)
|
|
view.setUint32(offset, 0, false);
|
|
offset += 4;
|
|
|
|
// Color depth (0 = unknown)
|
|
view.setUint32(offset, 0, false);
|
|
offset += 4;
|
|
|
|
// Indexed colors (0 = not indexed)
|
|
view.setUint32(offset, 0, false);
|
|
offset += 4;
|
|
|
|
// Image data length
|
|
view.setUint32(offset, imageBytes.length, false);
|
|
offset += 4;
|
|
|
|
// Image data
|
|
uint8Array.set(imageBytes, offset);
|
|
|
|
return uint8Array;
|
|
} catch (error) {
|
|
console.error('Failed to create FLAC picture block:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock) {
|
|
const originalArray = new Uint8Array(dataView.buffer);
|
|
|
|
// Remove old Vorbis comment and picture blocks
|
|
const filteredBlocks = blocks.filter(b => b.type !== 4 && b.type !== 6); // 4 = Vorbis, 6 = Picture
|
|
|
|
// Calculate new file size
|
|
let newSize = 4; // "fLaC" signature
|
|
|
|
// Add STREAMINFO and other essential blocks
|
|
for (const block of filteredBlocks) {
|
|
newSize += 4 + block.size; // header + data
|
|
}
|
|
|
|
// Add new Vorbis comment block
|
|
newSize += 4 + vorbisCommentBlock.length;
|
|
|
|
// Add picture block if available
|
|
if (pictureBlock) {
|
|
newSize += 4 + pictureBlock.length;
|
|
}
|
|
|
|
// Add audio data
|
|
const audioDataOffset = blocks.audioDataOffset;
|
|
if (audioDataOffset === undefined) {
|
|
throw new Error('Invalid FLAC file structure: unable to locate audio data stream');
|
|
}
|
|
const audioDataSize = dataView.byteLength - audioDataOffset;
|
|
newSize += audioDataSize;
|
|
|
|
// Build new file
|
|
const newFile = new Uint8Array(newSize);
|
|
let offset = 0;
|
|
|
|
// Write "fLaC" signature
|
|
newFile[offset++] = 0x66; // 'f'
|
|
newFile[offset++] = 0x4C; // 'L'
|
|
newFile[offset++] = 0x61; // 'a'
|
|
newFile[offset++] = 0x43; // 'C'
|
|
|
|
// Write existing blocks (except Vorbis and Picture)
|
|
for (let i = 0; i < filteredBlocks.length; i++) {
|
|
const block = filteredBlocks[i];
|
|
const isLast = false; // We'll add more blocks
|
|
|
|
// Write block header
|
|
const header = (isLast ? 0x80 : 0x00) | block.type;
|
|
newFile[offset++] = header;
|
|
newFile[offset++] = (block.size >> 16) & 0xFF;
|
|
newFile[offset++] = (block.size >> 8) & 0xFF;
|
|
newFile[offset++] = block.size & 0xFF;
|
|
|
|
// Write block data
|
|
newFile.set(originalArray.subarray(block.offset, block.offset + block.size), offset);
|
|
offset += block.size;
|
|
}
|
|
|
|
// Write new Vorbis comment block
|
|
const vorbisHeaderOffset = offset;
|
|
const vorbisHeader = 0x04; // Vorbis comment type
|
|
newFile[offset++] = vorbisHeader;
|
|
newFile[offset++] = (vorbisCommentBlock.length >> 16) & 0xFF;
|
|
newFile[offset++] = (vorbisCommentBlock.length >> 8) & 0xFF;
|
|
newFile[offset++] = vorbisCommentBlock.length & 0xFF;
|
|
newFile.set(vorbisCommentBlock, offset);
|
|
offset += vorbisCommentBlock.length;
|
|
|
|
let lastBlockHeaderOffset = vorbisHeaderOffset;
|
|
|
|
// Write picture block if available
|
|
if (pictureBlock) {
|
|
const pictureHeaderOffset = offset;
|
|
const pictureHeader = 0x06; // Picture type
|
|
newFile[offset++] = pictureHeader;
|
|
newFile[offset++] = (pictureBlock.length >> 16) & 0xFF;
|
|
newFile[offset++] = (pictureBlock.length >> 8) & 0xFF;
|
|
newFile[offset++] = pictureBlock.length & 0xFF;
|
|
newFile.set(pictureBlock, offset);
|
|
offset += pictureBlock.length;
|
|
lastBlockHeaderOffset = pictureHeaderOffset;
|
|
}
|
|
|
|
// Mark the last metadata block with the 0x80 flag
|
|
newFile[lastBlockHeaderOffset] |= 0x80;
|
|
|
|
// Write audio data
|
|
if (audioDataSize > 0) {
|
|
newFile.set(originalArray.subarray(audioDataOffset, audioDataOffset + audioDataSize), offset);
|
|
}
|
|
|
|
return newFile;
|
|
}
|
|
|
|
/**
|
|
* Adds metadata to M4A files using MP4 atoms
|
|
*/
|
|
async function addM4aMetadata(m4aBlob, track, api) {
|
|
try {
|
|
const arrayBuffer = await m4aBlob.arrayBuffer();
|
|
const dataView = new DataView(arrayBuffer);
|
|
|
|
// Parse MP4 atoms
|
|
const atoms = parseMp4Atoms(dataView);
|
|
|
|
// Create metadata atoms
|
|
const metadataAtoms = createMp4MetadataAtoms(track);
|
|
|
|
// Fetch album artwork if available
|
|
if (track.album?.cover) {
|
|
try {
|
|
const coverData = await fetchAlbumArtForMp4(track.album.cover, api);
|
|
if (coverData) {
|
|
metadataAtoms.cover = coverData;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to embed album art in M4A:', error);
|
|
}
|
|
}
|
|
|
|
// Rebuild MP4 file with metadata
|
|
const newMp4Data = rebuildMp4WithMetadata(dataView, atoms, metadataAtoms);
|
|
|
|
return new Blob([newMp4Data], { type: 'audio/mp4' });
|
|
} catch (error) {
|
|
console.error('Failed to add M4A metadata:', error);
|
|
return m4aBlob;
|
|
}
|
|
}
|
|
|
|
function parseMp4Atoms(dataView) {
|
|
const atoms = [];
|
|
let offset = 0;
|
|
|
|
while (offset + 8 <= dataView.byteLength) {
|
|
// MP4 atoms use big-endian byte order
|
|
let size = dataView.getUint32(offset, false);
|
|
|
|
// Handle special size values
|
|
if (size === 0) {
|
|
// Size 0 means the atom extends to the end of the file
|
|
size = dataView.byteLength - offset;
|
|
} else if (size === 1) {
|
|
// Size 1 means 64-bit extended size follows (after the type field)
|
|
if (offset + 16 > dataView.byteLength) {
|
|
break;
|
|
}
|
|
// Read 64-bit size from offset+8 (big-endian)
|
|
const sizeHigh = dataView.getUint32(offset + 8, false);
|
|
const sizeLow = dataView.getUint32(offset + 12, false);
|
|
if (sizeHigh !== 0) {
|
|
console.warn('64-bit MP4 atoms larger than 4GB are not supported - file may be processed incompletely');
|
|
break;
|
|
}
|
|
size = sizeLow;
|
|
}
|
|
|
|
if (size < 8 || offset + size > dataView.byteLength) {
|
|
break;
|
|
}
|
|
|
|
const type = String.fromCharCode(
|
|
dataView.getUint8(offset + 4),
|
|
dataView.getUint8(offset + 5),
|
|
dataView.getUint8(offset + 6),
|
|
dataView.getUint8(offset + 7)
|
|
);
|
|
|
|
atoms.push({
|
|
type: type,
|
|
offset: offset,
|
|
size: size
|
|
});
|
|
|
|
offset += size;
|
|
}
|
|
|
|
return atoms;
|
|
}
|
|
|
|
function createMp4MetadataAtoms(track) {
|
|
// MP4 metadata atoms are more complex than FLAC
|
|
// We'll create basic iTunes-style metadata
|
|
|
|
const tags = {
|
|
'©nam': track.title || DEFAULT_TITLE,
|
|
'©ART': track.artist?.name || DEFAULT_ARTIST,
|
|
'©alb': track.album?.title || DEFAULT_ALBUM,
|
|
'aART': track.album?.artist?.name || DEFAULT_ARTIST,
|
|
};
|
|
|
|
if (track.trackNumber) {
|
|
tags['trkn'] = track.trackNumber;
|
|
}
|
|
|
|
const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
|
|
if (releaseDateStr) {
|
|
try {
|
|
const year = new Date(releaseDateStr).getFullYear();
|
|
if (!isNaN(year)) {
|
|
tags['©day'] = String(year);
|
|
}
|
|
} catch (error) {
|
|
// Invalid date, skip
|
|
}
|
|
}
|
|
|
|
return { tags };
|
|
}
|
|
|
|
async function fetchAlbumArtForMp4(coverId, api) {
|
|
try {
|
|
const imageBlob = await getCoverBlob(api, coverId);
|
|
if (!imageBlob) {
|
|
return null;
|
|
}
|
|
|
|
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
|
|
|
|
return {
|
|
type: 'covr',
|
|
data: imageBytes
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to fetch album art for MP4:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
|
|
// M4A metadata injection is complex and requires:
|
|
// 1. Finding the moov atom
|
|
// 2. Finding or creating the udta atom inside moov
|
|
// 3. Creating a meta atom with ilst containing all metadata
|
|
// 4. Rebuilding the file with updated atom sizes
|
|
|
|
// For now, return the original file to avoid potential corruption
|
|
// TODO: Implement full MP4 metadata injection
|
|
const originalArray = new Uint8Array(dataView.buffer);
|
|
console.warn('M4A metadata embedding is not yet supported - downloaded file will not contain metadata tags');
|
|
return originalArray;
|
|
}
|