feat(metadata): re-add flac and m4a metadata code as separate files
This commit is contained in:
parent
de472c5891
commit
42101353ab
9 changed files with 1877 additions and 737 deletions
21
js/api.js
21
js/api.js
|
|
@ -13,6 +13,7 @@ import { DashDownloader } from './dash-downloader.js';
|
|||
import { HlsDownloader } from './hls-downloader.js';
|
||||
import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
|
||||
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
|
||||
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
||||
|
||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
||||
|
|
@ -1399,14 +1400,18 @@ export class LosslessAPI {
|
|||
try {
|
||||
switch (losslessContainerSettings.getContainer()) {
|
||||
case 'flac':
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
||||
'output.flac',
|
||||
'audio/flac',
|
||||
onProgress,
|
||||
options.signal
|
||||
);
|
||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
||||
'output.flac',
|
||||
'audio/flac',
|
||||
onProgress,
|
||||
options.signal
|
||||
);
|
||||
} else {
|
||||
blob = await rebuildFlacWithoutMetadata(blob);
|
||||
}
|
||||
break;
|
||||
case 'alac':
|
||||
blob = await ffmpeg(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from './utils.js';
|
||||
import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js';
|
||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
||||
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
||||
import { DashDownloader } from './dash-downloader.js';
|
||||
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
||||
import { encodeToMp3 } from './mp3-encoder.js';
|
||||
|
|
@ -379,14 +380,18 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
|||
try {
|
||||
switch (losslessContainerSettings.getContainer()) {
|
||||
case 'flac':
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
||||
'output.flac',
|
||||
'audio/flac',
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
||||
'output.flac',
|
||||
'audio/flac',
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
} else {
|
||||
blob = await rebuildFlacWithoutMetadata(blob);
|
||||
}
|
||||
break;
|
||||
case 'alac':
|
||||
blob = await ffmpeg(
|
||||
|
|
|
|||
156
js/id3-writer.js
156
js/id3-writer.js
|
|
@ -1,156 +0,0 @@
|
|||
import { getCoverBlob, getTrackTitle } from './utils.js';
|
||||
|
||||
async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) {
|
||||
const frames = [];
|
||||
|
||||
if (metadata.title) {
|
||||
frames.push(createTextFrame('TIT2', getTrackTitle(metadata)));
|
||||
}
|
||||
|
||||
const artistName = metadata.artist?.name || metadata.artists?.[0]?.name;
|
||||
if (artistName) {
|
||||
frames.push(createTextFrame('TPE1', artistName));
|
||||
}
|
||||
|
||||
if (metadata.album?.title) {
|
||||
frames.push(createTextFrame('TALB', metadata.album.title));
|
||||
}
|
||||
|
||||
const albumArtistName = metadata.album?.artist?.name || metadata.artist?.name || metadata.artists?.[0]?.name;
|
||||
if (albumArtistName) {
|
||||
frames.push(createTextFrame('TPE2', albumArtistName));
|
||||
}
|
||||
|
||||
if (metadata.trackNumber) {
|
||||
frames.push(createTextFrame('TRCK', metadata.trackNumber.toString()));
|
||||
}
|
||||
|
||||
if (metadata.album?.releaseDate) {
|
||||
const year = new Date(metadata.album.releaseDate).getFullYear();
|
||||
if (!Number.isNaN(year) && Number.isFinite(year)) {
|
||||
frames.push(createTextFrame('TYER', year.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.isrc) {
|
||||
frames.push(createTextFrame('TSRC', metadata.isrc));
|
||||
}
|
||||
|
||||
if (metadata.copyright) {
|
||||
frames.push(createTextFrame('TCOP', metadata.copyright));
|
||||
}
|
||||
|
||||
frames.push(createTextFrame('TENC', 'Monochrome'));
|
||||
|
||||
if (coverBlob) {
|
||||
frames.push(await createAPICFrame(coverBlob));
|
||||
}
|
||||
|
||||
return buildID3v2Tag(mp3Blob, frames);
|
||||
}
|
||||
|
||||
function createTextFrame(frameId, text) {
|
||||
// ID3v2.3 UTF-16 encoding with BOM
|
||||
const bom = new Uint8Array([0xff, 0xfe]); // UTF-16LE BOM
|
||||
const utf16Bytes = new Uint8Array(text.length * 2);
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
utf16Bytes[i * 2] = charCode & 0xff;
|
||||
utf16Bytes[i * 2 + 1] = (charCode >> 8) & 0xff;
|
||||
}
|
||||
|
||||
const frameSize = 1 + bom.length + utf16Bytes.length;
|
||||
const frame = new Uint8Array(10 + frameSize);
|
||||
const view = new DataView(frame.buffer);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
frame[i] = frameId.charCodeAt(i);
|
||||
}
|
||||
|
||||
view.setUint32(4, frameSize, false);
|
||||
|
||||
frame[10] = 0x01; // UTF-16 with BOM
|
||||
|
||||
frame.set(bom, 11);
|
||||
frame.set(utf16Bytes, 11 + bom.length);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
async function createAPICFrame(coverBlob) {
|
||||
const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
|
||||
const mimeType = coverBlob.type || 'image/jpeg';
|
||||
const mimeBytes = new TextEncoder().encode(mimeType);
|
||||
|
||||
const frameSize = 1 + mimeBytes.length + 1 + 1 + 1 + imageBytes.length;
|
||||
|
||||
const frame = new Uint8Array(10 + frameSize);
|
||||
const view = new DataView(frame.buffer);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
frame[i] = 'APIC'.charCodeAt(i);
|
||||
}
|
||||
|
||||
view.setUint32(4, frameSize, false);
|
||||
|
||||
let offset = 10;
|
||||
frame[offset++] = 0x00;
|
||||
|
||||
frame.set(mimeBytes, offset);
|
||||
offset += mimeBytes.length;
|
||||
frame[offset++] = 0x00;
|
||||
|
||||
frame[offset++] = 0x03;
|
||||
|
||||
frame[offset++] = 0x00;
|
||||
|
||||
frame.set(imageBytes, offset);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
function buildID3v2Tag(mp3Blob, frames) {
|
||||
const framesData = new Uint8Array(frames.reduce((acc, f) => acc + f.length, 0));
|
||||
let offset = 0;
|
||||
for (const frame of frames) {
|
||||
framesData.set(frame, offset);
|
||||
offset += frame.length;
|
||||
}
|
||||
|
||||
const tagSize = framesData.length;
|
||||
|
||||
const header = new Uint8Array(10);
|
||||
header[0] = 0x49;
|
||||
header[1] = 0x44;
|
||||
header[2] = 0x33;
|
||||
header[3] = 0x03;
|
||||
header[4] = 0x00;
|
||||
header[5] = 0x00;
|
||||
|
||||
header[6] = (tagSize >> 21) & 0x7f;
|
||||
header[7] = (tagSize >> 14) & 0x7f;
|
||||
header[8] = (tagSize >> 7) & 0x7f;
|
||||
header[9] = tagSize & 0x7f;
|
||||
|
||||
return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' });
|
||||
}
|
||||
|
||||
export async function addMp3Metadata(mp3Blob, track, api) {
|
||||
try {
|
||||
let coverBlob = 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);
|
||||
}
|
||||
}
|
||||
|
||||
return await writeID3v2Tag(mp3Blob, track, coverBlob);
|
||||
} catch (error) {
|
||||
console.error('Failed to add MP3 metadata:', error);
|
||||
return mp3Blob;
|
||||
}
|
||||
}
|
||||
588
js/metadata.flac.js
Normal file
588
js/metadata.flac.js
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
import { getCoverBlob, getTrackTitle } from './utils.js';
|
||||
import { getFullArtistString } from './utils.js';
|
||||
import { METADATA_STRINGS } from './metadata.js';
|
||||
|
||||
export const FLAC_MIME_TYPE = 'audio/flac';
|
||||
|
||||
export async function readFlacMetadata(file, metadata) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
|
||||
if (!isFlacFile(dataView)) return;
|
||||
|
||||
const blocks = parseFlacBlocks(dataView);
|
||||
const vorbisBlock = blocks.find((b) => b.type === 4);
|
||||
const pictureBlock = blocks.find((b) => b.type === 6);
|
||||
const streamInfo = blocks.find((b) => b.type === 0);
|
||||
|
||||
const artists = [];
|
||||
if (vorbisBlock) {
|
||||
const offset = vorbisBlock.offset;
|
||||
const vendorLen = dataView.getUint32(offset, true);
|
||||
let pos = offset + 4 + vendorLen;
|
||||
const commentListLen = dataView.getUint32(pos, true);
|
||||
pos += 4;
|
||||
|
||||
for (let i = 0; i < commentListLen; i++) {
|
||||
const len = dataView.getUint32(pos, true);
|
||||
pos += 4;
|
||||
const comment = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, len));
|
||||
pos += len;
|
||||
|
||||
const eqIdx = comment.indexOf('=');
|
||||
if (eqIdx > -1) {
|
||||
const key = comment.substring(0, eqIdx);
|
||||
const value = comment.substring(eqIdx + 1);
|
||||
const upperKey = key.toUpperCase();
|
||||
if (upperKey === 'TITLE') metadata.title = value;
|
||||
if (upperKey === 'ARTIST' || upperKey === 'ALBUMARTIST') {
|
||||
artists.push(value);
|
||||
}
|
||||
if (upperKey === 'ALBUM') metadata.album.title = value;
|
||||
if (upperKey === 'ISRC') metadata.isrc = value;
|
||||
if (upperKey === 'COPYRIGHT') metadata.copyright = value;
|
||||
if (upperKey === 'ITUNESADVISORY') metadata.explicit = value === '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamInfo) {
|
||||
const offset = streamInfo.offset;
|
||||
|
||||
// Sample Rate is 20 bits spanning bytes 10, 11, and the first 4 bits of 12
|
||||
const byte10 = dataView.getUint8(offset + 10);
|
||||
const byte11 = dataView.getUint8(offset + 11);
|
||||
const byte12 = dataView.getUint8(offset + 12);
|
||||
|
||||
// since data for some reason spans across multiple bytes, we need to combine them into one int
|
||||
const sampleRate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
|
||||
|
||||
const byte13 = dataView.getUint8(offset + 13);
|
||||
const tsHigh = byte13 & 0x0f;
|
||||
const tsLow = dataView.getUint32(offset + 14, false);
|
||||
|
||||
// same thing for total samples
|
||||
const totalSamples = tsHigh * 0x100000000 + tsLow;
|
||||
|
||||
if (sampleRate > 0) {
|
||||
// beatiful
|
||||
metadata.duration = totalSamples / sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
if (artists.length > 0) {
|
||||
metadata.artists = artists.flatMap((a) => a.split(/; |\/|\\/)).map((name) => ({ name: name.trim() }));
|
||||
}
|
||||
|
||||
if (pictureBlock) {
|
||||
try {
|
||||
let pos = pictureBlock.offset;
|
||||
pos += 4;
|
||||
const mimeLen = dataView.getUint32(pos, false);
|
||||
pos += 4;
|
||||
const mime = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, mimeLen));
|
||||
pos += mimeLen;
|
||||
const descLen = dataView.getUint32(pos, false);
|
||||
pos += 4;
|
||||
pos += descLen;
|
||||
pos += 16;
|
||||
const dataLen = dataView.getUint32(pos, false);
|
||||
pos += 4;
|
||||
const pictureData = new Uint8Array(arrayBuffer, pos, dataLen);
|
||||
const blob = new Blob([pictureData], { type: mime });
|
||||
metadata.album.cover = URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.warn('Error parsing FLAC picture:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds Vorbis comment metadata to FLAC files
|
||||
*/
|
||||
export 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);
|
||||
|
||||
// If parsing failed or no audio data found, return original
|
||||
if (!blocks || blocks.length === 0 || blocks.audioDataOffset === undefined) {
|
||||
console.warn('Failed to parse FLAC blocks, returning original');
|
||||
return flacBlob;
|
||||
}
|
||||
|
||||
// Check for STREAMINFO block (must be first, type 0)
|
||||
if (blocks[0].type !== 0) {
|
||||
console.warn('FLAC file missing STREAMINFO block, returning original');
|
||||
return flacBlob;
|
||||
}
|
||||
|
||||
// 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
|
||||
let newFlacData;
|
||||
try {
|
||||
newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock);
|
||||
} catch (rebuildError) {
|
||||
console.error('Failed to rebuild FLAC structure:', rebuildError);
|
||||
return flacBlob;
|
||||
}
|
||||
|
||||
// Validate the rebuilt file
|
||||
const validationView = new DataView(newFlacData.buffer);
|
||||
if (!isFlacFile(validationView)) {
|
||||
console.error('Rebuilt FLAC has invalid signature, returning original');
|
||||
return flacBlob;
|
||||
}
|
||||
|
||||
// Validate new file has proper block structure
|
||||
const newBlocks = parseFlacBlocks(validationView);
|
||||
if (!newBlocks || newBlocks.length === 0 || newBlocks.audioDataOffset === undefined) {
|
||||
console.error('Rebuilt FLAC has invalid block structure, returning original');
|
||||
return flacBlob;
|
||||
}
|
||||
|
||||
return new Blob([newFlacData], { type: 'audio/flac' });
|
||||
} catch (error) {
|
||||
console.error('Failed to add FLAC metadata:', error);
|
||||
return flacBlob;
|
||||
}
|
||||
}
|
||||
|
||||
export 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'
|
||||
}
|
||||
|
||||
export 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;
|
||||
|
||||
// Block type 127 is invalid, types > 6 are reserved (except 127)
|
||||
// Valid types: 0=STREAMINFO, 1=PADDING, 2=APPLICATION, 3=SEEKTABLE, 4=VORBIS_COMMENT, 5=CUESHEET, 6=PICTURE
|
||||
if (blockType === 127) {
|
||||
console.warn('Encountered invalid block type 127, stopping parse');
|
||||
break;
|
||||
}
|
||||
|
||||
const blockSize =
|
||||
(dataView.getUint8(offset + 1) << 16) |
|
||||
(dataView.getUint8(offset + 2) << 8) |
|
||||
dataView.getUint8(offset + 3);
|
||||
|
||||
// Validate block size
|
||||
if (blockSize < 0 || offset + 4 + blockSize > dataView.byteLength) {
|
||||
console.warn(`Invalid block size ${blockSize} at offset ${offset}, 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;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find the last block marker, estimate audio offset
|
||||
if (blocks.audioDataOffset === undefined && blocks.length > 0) {
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
blocks.audioDataOffset = lastBlock.headerOffset + 4 + lastBlock.size;
|
||||
console.warn('No last-block marker found, estimated audio offset:', blocks.audioDataOffset);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function createVorbisComments(track) {
|
||||
// Vorbis comment structure
|
||||
const comments = [];
|
||||
const discNumber = track.volumeNumber ?? track.discNumber;
|
||||
|
||||
// Add standard tags
|
||||
if (track.title) {
|
||||
comments.push(['TITLE', getTrackTitle(track)]);
|
||||
}
|
||||
const artistStr = getFullArtistString(track);
|
||||
if (artistStr) {
|
||||
comments.push(['ARTIST', artistStr]);
|
||||
}
|
||||
if (track.album?.title) {
|
||||
comments.push(['ALBUM', track.album.title]);
|
||||
}
|
||||
const albumArtist = track.album?.artist?.name || track.artist?.name;
|
||||
if (albumArtist) {
|
||||
comments.push(['ALBUMARTIST', albumArtist]);
|
||||
}
|
||||
if (track.trackNumber) {
|
||||
comments.push(['TRACKNUMBER', String(track.trackNumber)]);
|
||||
}
|
||||
if (discNumber) {
|
||||
comments.push(['DISCNUMBER', String(discNumber)]);
|
||||
}
|
||||
if (track.album?.numberOfTracks) {
|
||||
comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]);
|
||||
}
|
||||
if (track.bpm != null) {
|
||||
const bpm = Number(track.bpm);
|
||||
if (Number.isFinite(bpm)) {
|
||||
comments.push(['TEMPO', String(Math.round(bpm))]);
|
||||
}
|
||||
}
|
||||
if (track.replayGain) {
|
||||
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain;
|
||||
if (albumReplayGain) comments.push(['REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain)]);
|
||||
if (albumPeakAmplitude) comments.push(['REPLAYGAIN_ALBUM_PEAK', String(albumPeakAmplitude)]);
|
||||
if (trackReplayGain) comments.push(['REPLAYGAIN_TRACK_GAIN', String(trackReplayGain)]);
|
||||
if (trackPeakAmplitude) comments.push(['REPLAYGAIN_TRACK_PEAK', String(trackPeakAmplitude)]);
|
||||
}
|
||||
|
||||
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 {
|
||||
// Invalid date, skip
|
||||
}
|
||||
}
|
||||
|
||||
if (track.copyright) {
|
||||
comments.push(['COPYRIGHT', track.copyright]);
|
||||
}
|
||||
if (track.isrc) {
|
||||
comments.push(['ISRC', track.isrc]);
|
||||
}
|
||||
if (track.explicit) {
|
||||
comments.push(['ITUNESADVISORY', '1']);
|
||||
}
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
export function createVorbisCommentBlock(comments = []) {
|
||||
// Calculate total size
|
||||
const vendor = METADATA_STRINGS.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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function rebuildFlacWithMetadata(
|
||||
dataView,
|
||||
blocks,
|
||||
vorbisCommentBlock = createVorbisCommentBlock(),
|
||||
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
|
||||
}
|
||||
|
||||
if (vorbisCommentBlock) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
let lastBlockHeaderOffset = offset;
|
||||
|
||||
if (vorbisCommentBlock) {
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
|
||||
export function getFlacBlocks(dataView) {
|
||||
// Verify FLAC signature
|
||||
if (!isFlacFile(dataView)) {
|
||||
throw new Error('Not a valid FLAC file');
|
||||
}
|
||||
|
||||
// Parse FLAC structure
|
||||
const blocks = parseFlacBlocks(dataView);
|
||||
|
||||
// If parsing failed or no audio data found, return original
|
||||
if (!blocks || blocks.length === 0 || blocks.audioDataOffset === undefined) {
|
||||
throw new Error('Failed to parse FLAC blocks');
|
||||
}
|
||||
|
||||
// Check for STREAMINFO block (must be first, type 0)
|
||||
if (blocks[0].type !== 0) {
|
||||
throw new Error('FLAC file missing STREAMINFO block');
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all metadata from a FLAC file blob and returns the rebuilt FLAC data.
|
||||
*
|
||||
* @async
|
||||
* @param {Blob} flacBlob - The FLAC audio file as a Blob object
|
||||
* @returns {Promise<Blob>} A Promise that resolves to a new Blob containing the FLAC file without metadata,
|
||||
* or the original flacBlob if an error occurs during processing
|
||||
* @throws {Error} Logs errors to console but catches and returns original blob instead of throwing
|
||||
*
|
||||
* @example
|
||||
* const flacFile = new Blob([arrayBuffer], { type: 'audio/flac' });
|
||||
* const cleanFlac = await rebuildFlacWithoutMetadata(flacFile);
|
||||
*/
|
||||
export async function rebuildFlacWithoutMetadata(flacBlob) {
|
||||
try {
|
||||
const arrayBuffer = await flacBlob.arrayBuffer();
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
const blocks = getFlacBlocks(dataView);
|
||||
return new Blob([rebuildFlacWithMetadata(dataView, blocks, createVorbisCommentBlock(), null)], {
|
||||
type: FLAC_MIME_TYPE,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error rebuilding FLAC file:', err);
|
||||
return flacBlob;
|
||||
}
|
||||
}
|
||||
566
js/metadata.js
566
js/metadata.js
|
|
@ -1,46 +1,14 @@
|
|||
import { getCoverBlob, getTrackTitle } from './utils.js';
|
||||
import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType } from './utils.js';
|
||||
import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
|
||||
import { doTimed, doTimedAsync } from './doTimed.ts';
|
||||
import { managers } from './app.js';
|
||||
|
||||
const VENDOR_STRING = 'Monochrome';
|
||||
const DEFAULT_TITLE = 'Unknown Title';
|
||||
const DEFAULT_ARTIST = 'Unknown Artist';
|
||||
const DEFAULT_ALBUM = 'Unknown Album';
|
||||
|
||||
/**
|
||||
* Builds a full artist string by combining the track's listed artists
|
||||
* with any featured artists parsed from the title (feat./with).
|
||||
*/
|
||||
function getFullArtistString(track) {
|
||||
const knownArtists =
|
||||
Array.isArray(track.artists) && track.artists.length > 0
|
||||
? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean)
|
||||
: track.artist?.name
|
||||
? [track.artist.name]
|
||||
: [];
|
||||
|
||||
// Parse featured artists from title, e.g. "Song (feat. A, B & C)" or "(with X & Y)"
|
||||
// Note: splitting on '&' may incorrectly fragment compound artist names like "Simon & Garfunkel".
|
||||
const featPattern = /\(\s*(?:feat\.?|ft\.?|with)\s+(.+?)\s*\)/gi;
|
||||
const allFeatArtists = [...(track.title?.matchAll(featPattern) ?? [])].flatMap((m) =>
|
||||
m[1]
|
||||
.split(/\s*[,&]\s*/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (allFeatArtists.length > 0) {
|
||||
const knownLower = new Set(knownArtists.map((n) => n.toLowerCase()));
|
||||
for (const feat of allFeatArtists) {
|
||||
if (!knownLower.has(feat.toLowerCase())) {
|
||||
knownArtists.push(feat);
|
||||
knownLower.add(feat.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return knownArtists.join('; ') || null;
|
||||
}
|
||||
export const METADATA_STRINGS = {
|
||||
VENDOR_STRING: 'Monochrome',
|
||||
DEFAULT_TITLE: 'Unknown Title',
|
||||
DEFAULT_ARTIST: 'Unknown Artist',
|
||||
DEFAULT_ALBUM: 'Unknown Album',
|
||||
};
|
||||
|
||||
export function prefetchMetadataObjects(track, api) {
|
||||
const _tagLib = fetchTagLib().catch(console.error);
|
||||
|
|
@ -137,6 +105,10 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
|||
console.warn('Error setting lyrics metadata', track, e);
|
||||
}
|
||||
|
||||
if (!(audioBuffer instanceof Uint8Array)) {
|
||||
throw new Error('Audio buffer is not a Uint8Array');
|
||||
}
|
||||
|
||||
const newAudioBuffer = await addMetadataWithTagLib(audioBuffer, {
|
||||
...data,
|
||||
});
|
||||
|
|
@ -227,519 +199,3 @@ export async function readTrackMetadata(file, siblings = []) {
|
|||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function readFlacMetadata(file, metadata) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
|
||||
if (!isFlacFile(dataView)) return;
|
||||
|
||||
const blocks = parseFlacBlocks(dataView);
|
||||
const vorbisBlock = blocks.find((b) => b.type === 4);
|
||||
const pictureBlock = blocks.find((b) => b.type === 6);
|
||||
const streamInfo = blocks.find((b) => b.type === 0);
|
||||
|
||||
const artists = [];
|
||||
if (vorbisBlock) {
|
||||
const offset = vorbisBlock.offset;
|
||||
const vendorLen = dataView.getUint32(offset, true);
|
||||
let pos = offset + 4 + vendorLen;
|
||||
const commentListLen = dataView.getUint32(pos, true);
|
||||
pos += 4;
|
||||
|
||||
for (let i = 0; i < commentListLen; i++) {
|
||||
const len = dataView.getUint32(pos, true);
|
||||
pos += 4;
|
||||
const comment = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, len));
|
||||
pos += len;
|
||||
|
||||
const eqIdx = comment.indexOf('=');
|
||||
if (eqIdx > -1) {
|
||||
const key = comment.substring(0, eqIdx);
|
||||
const value = comment.substring(eqIdx + 1);
|
||||
const upperKey = key.toUpperCase();
|
||||
if (upperKey === 'TITLE') metadata.title = value;
|
||||
if (upperKey === 'ARTIST' || upperKey === 'ALBUMARTIST') {
|
||||
artists.push(value);
|
||||
}
|
||||
if (upperKey === 'ALBUM') metadata.album.title = value;
|
||||
if (upperKey === 'ISRC') metadata.isrc = value;
|
||||
if (upperKey === 'COPYRIGHT') metadata.copyright = value;
|
||||
if (upperKey === 'ITUNESADVISORY') metadata.explicit = value === '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamInfo) {
|
||||
const offset = streamInfo.offset;
|
||||
|
||||
// Sample Rate is 20 bits spanning bytes 10, 11, and the first 4 bits of 12
|
||||
const byte10 = dataView.getUint8(offset + 10);
|
||||
const byte11 = dataView.getUint8(offset + 11);
|
||||
const byte12 = dataView.getUint8(offset + 12);
|
||||
|
||||
// since data for some reason spans across multiple bytes, we need to combine them into one int
|
||||
const sampleRate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
|
||||
|
||||
const byte13 = dataView.getUint8(offset + 13);
|
||||
const tsHigh = byte13 & 0x0f;
|
||||
const tsLow = dataView.getUint32(offset + 14, false);
|
||||
|
||||
// same thing for total samples
|
||||
const totalSamples = tsHigh * 0x100000000 + tsLow;
|
||||
|
||||
if (sampleRate > 0) {
|
||||
// beatiful
|
||||
metadata.duration = totalSamples / sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
if (artists.length > 0) {
|
||||
metadata.artists = artists.flatMap((a) => a.split(/; |\/|\\/)).map((name) => ({ name: name.trim() }));
|
||||
}
|
||||
|
||||
if (pictureBlock) {
|
||||
try {
|
||||
let pos = pictureBlock.offset;
|
||||
pos += 4;
|
||||
const mimeLen = dataView.getUint32(pos, false);
|
||||
pos += 4;
|
||||
const mime = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, mimeLen));
|
||||
pos += mimeLen;
|
||||
const descLen = dataView.getUint32(pos, false);
|
||||
pos += 4;
|
||||
pos += descLen;
|
||||
pos += 16;
|
||||
const dataLen = dataView.getUint32(pos, false);
|
||||
pos += 4;
|
||||
const pictureData = new Uint8Array(arrayBuffer, pos, dataLen);
|
||||
const blob = new Blob([pictureData], { type: mime });
|
||||
metadata.album.cover = URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.warn('Error parsing FLAC picture:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readM4aMetadata(file, metadata) {
|
||||
try {
|
||||
const chunkSize = Math.min(file.size, 5 * 1024 * 1024);
|
||||
const buffer = await file.slice(0, chunkSize).arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const atoms = parseMp4Atoms(view);
|
||||
|
||||
const moov = atoms.find((a) => a.type === 'moov');
|
||||
if (!moov) return;
|
||||
|
||||
const moovStart = moov.offset + 8;
|
||||
const moovLen = moov.size - 8;
|
||||
const moovData = new DataView(view.buffer, moovStart, moovLen);
|
||||
const moovAtoms = parseMp4Atoms(moovData);
|
||||
|
||||
// mvhd metadata tag
|
||||
const mvhd = moovAtoms.find((a) => a.type === 'mvhd');
|
||||
if (mvhd) {
|
||||
const mvhdStart = moovStart + mvhd.offset + 8;
|
||||
const version = view.getUint8(mvhdStart);
|
||||
|
||||
// resolution and length, basically
|
||||
let timeScale, duration;
|
||||
|
||||
if (version === 0) {
|
||||
// 32-bit format
|
||||
timeScale = view.getUint32(mvhdStart + 12, false);
|
||||
duration = view.getUint32(mvhdStart + 16, false);
|
||||
} else if (version === 1) {
|
||||
// 64-bit format
|
||||
timeScale = view.getUint32(mvhdStart + 20, false);
|
||||
const durHigh = view.getUint32(mvhdStart + 24, false);
|
||||
const durLow = view.getUint32(mvhdStart + 28, false);
|
||||
duration = durHigh * 0x100000000 + durLow;
|
||||
}
|
||||
|
||||
if (timeScale > 0) {
|
||||
metadata.duration = duration / timeScale;
|
||||
}
|
||||
}
|
||||
|
||||
const udta = moovAtoms.find((a) => a.type === 'udta');
|
||||
if (!udta) return;
|
||||
|
||||
const udtaStart = moovStart + udta.offset + 8;
|
||||
const udtaLen = udta.size - 8;
|
||||
const udtaData = new DataView(view.buffer, udtaStart, udtaLen);
|
||||
const udtaAtoms = parseMp4Atoms(udtaData);
|
||||
|
||||
const meta = udtaAtoms.find((a) => a.type === 'meta');
|
||||
if (!meta) return;
|
||||
|
||||
const metaStart = udtaStart + meta.offset + 12;
|
||||
const metaLen = meta.size - 12;
|
||||
const metaData = new DataView(view.buffer, metaStart, metaLen);
|
||||
const metaAtoms = parseMp4Atoms(metaData);
|
||||
|
||||
const ilst = metaAtoms.find((a) => a.type === 'ilst');
|
||||
if (!ilst) return;
|
||||
|
||||
const ilstStart = metaStart + ilst.offset + 8;
|
||||
const ilstLen = ilst.size - 8;
|
||||
const ilstData = new DataView(view.buffer, ilstStart, ilstLen);
|
||||
const items = parseMp4Atoms(ilstData);
|
||||
|
||||
let artistStr = null;
|
||||
|
||||
for (const item of items) {
|
||||
const itemStart = ilstStart + item.offset + 8;
|
||||
const itemLen = item.size - 8;
|
||||
const itemData = new DataView(view.buffer, itemStart, itemLen);
|
||||
const dataAtom = parseMp4Atoms(itemData).find((a) => a.type === 'data');
|
||||
if (dataAtom) {
|
||||
const contentLen = dataAtom.size - 16;
|
||||
const contentOffset = itemStart + dataAtom.offset + 16;
|
||||
|
||||
if (item.type === '©nam') {
|
||||
metadata.title = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
||||
} else if (item.type === '©ART') {
|
||||
artistStr = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
||||
} else if (item.type === '©alb') {
|
||||
metadata.album.title = new TextDecoder().decode(
|
||||
new Uint8Array(view.buffer, contentOffset, contentLen)
|
||||
);
|
||||
} else if (item.type === 'ISRC') {
|
||||
metadata.isrc = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
||||
} else if (item.type === 'cprt') {
|
||||
metadata.copyright = new TextDecoder().decode(
|
||||
new Uint8Array(view.buffer, contentOffset, contentLen)
|
||||
);
|
||||
} else if (item.type === 'covr') {
|
||||
const pictureData = new Uint8Array(view.buffer, contentOffset, contentLen);
|
||||
const mime = getMimeType(pictureData);
|
||||
const blob = new Blob([pictureData], { type: mime });
|
||||
metadata.album.cover = URL.createObjectURL(blob);
|
||||
} else if (item.type === 'rtng') {
|
||||
metadata.explicit =
|
||||
contentLen > 0 && new Uint8Array(view.buffer, contentOffset, contentLen)[0] === 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artistStr) {
|
||||
metadata.artists = artistStr.split(/; |\/|\\/).map((name) => ({ name: name.trim() }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing M4A:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function readMp3Metadata(file, metadata) {
|
||||
let buffer = await file.slice(0, 10).arrayBuffer();
|
||||
let view = new DataView(buffer);
|
||||
|
||||
if (view.getUint8(0) === 0x49 && view.getUint8(1) === 0x44 && view.getUint8(2) === 0x33) {
|
||||
const majorVer = view.getUint8(3);
|
||||
const size = readSynchsafeInteger32(view, 6);
|
||||
const tagSize = size + 10;
|
||||
|
||||
buffer = await file.slice(0, tagSize).arrayBuffer();
|
||||
view = new DataView(buffer);
|
||||
|
||||
let offset = 10;
|
||||
if ((view.getUint8(5) & 0x40) !== 0) {
|
||||
const extSize = readSynchsafeInteger32(view, offset);
|
||||
offset += extSize;
|
||||
}
|
||||
|
||||
let tpe1 = null;
|
||||
let tpe2 = null;
|
||||
while (offset < view.byteLength) {
|
||||
let frameId, frameSize;
|
||||
|
||||
if (majorVer === 3) {
|
||||
frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4));
|
||||
frameSize = view.getUint32(offset + 4, false);
|
||||
offset += 10;
|
||||
} else if (majorVer === 4) {
|
||||
frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4));
|
||||
frameSize = readSynchsafeInteger32(view, offset + 4);
|
||||
offset += 10;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (frameId.charCodeAt(0) === 0) break;
|
||||
if (offset + frameSize > view.byteLength) break;
|
||||
|
||||
const frameData = new DataView(buffer, offset, frameSize);
|
||||
if (frameId === 'TIT2') metadata.title = readID3Text(frameData);
|
||||
if (frameId === 'TPE1') tpe1 = readID3Text(frameData);
|
||||
if (frameId === 'TPE2') tpe2 = readID3Text(frameData);
|
||||
if (frameId === 'TALB') metadata.album.title = readID3Text(frameData);
|
||||
if (frameId === 'TSRC') metadata.isrc = readID3Text(frameData);
|
||||
if (frameId === 'TCOP') metadata.copyright = readID3Text(frameData);
|
||||
if (frameId === 'TLEN') metadata.duration = parseInt(readID3Text(frameData)) / 1000; // usually not present
|
||||
if (frameId === 'TYER' || frameId === 'TDRC') {
|
||||
const year = readID3Text(frameData);
|
||||
if (year) metadata.album.releaseDate = year;
|
||||
}
|
||||
if (frameId === 'APIC') {
|
||||
try {
|
||||
const encoding = frameData.getUint8(0);
|
||||
let mimeType = '';
|
||||
let pos = 1;
|
||||
while (pos < frameData.byteLength && frameData.getUint8(pos) !== 0) {
|
||||
mimeType += String.fromCharCode(frameData.getUint8(pos));
|
||||
pos++;
|
||||
}
|
||||
pos++;
|
||||
pos++;
|
||||
let terminator = encoding === 1 || encoding === 2 ? 2 : 1;
|
||||
while (pos < frameData.byteLength) {
|
||||
if (frameData.getUint8(pos) === 0) {
|
||||
if (terminator === 1) {
|
||||
pos++;
|
||||
break;
|
||||
} else if (pos + 1 < frameData.byteLength && frameData.getUint8(pos + 1) === 0) {
|
||||
pos += 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
const pictureData = new Uint8Array(buffer, offset + pos, frameSize - pos);
|
||||
const blob = new Blob([pictureData], { type: mimeType || 'image/jpeg' });
|
||||
metadata.album.cover = URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.warn('Error parsing APIC:', e);
|
||||
}
|
||||
}
|
||||
|
||||
offset += frameSize;
|
||||
}
|
||||
|
||||
const artistStr = tpe1 || tpe2;
|
||||
if (artistStr) {
|
||||
metadata.artists = artistStr.split('/').map((name) => ({ name: name.trim() }));
|
||||
}
|
||||
|
||||
if (!metadata.duration || metadata.duration === 0) {
|
||||
metadata.duration = await calculateMp3Duration(file, tagSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (file.size > 128) {
|
||||
const tailBuffer = await file.slice(file.size - 128).arrayBuffer();
|
||||
const tag = new TextDecoder().decode(new Uint8Array(tailBuffer, 0, 3));
|
||||
if (tag === 'TAG') {
|
||||
const title = new TextDecoder()
|
||||
.decode(new Uint8Array(tailBuffer, 3, 30))
|
||||
.replace(/\0/g, '')
|
||||
.trim();
|
||||
const artist = new TextDecoder()
|
||||
.decode(new Uint8Array(tailBuffer, 33, 30))
|
||||
.replace(/\0/g, '')
|
||||
.trim();
|
||||
const album = new TextDecoder()
|
||||
.decode(new Uint8Array(tailBuffer, 63, 30))
|
||||
.replace(/\0/g, '')
|
||||
.trim();
|
||||
if (title) metadata.title = title;
|
||||
if (artist && metadata.artists.length === 0) {
|
||||
metadata.artists = [{ name: artist }];
|
||||
}
|
||||
if (album) metadata.album.title = album;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// since mp3 file don't have metadata about duration, estimating it
|
||||
// uses evil bitwise magic
|
||||
async function calculateMp3Duration(file, startOffset) {
|
||||
const buffer = await file.slice(startOffset, startOffset + 32768).arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
|
||||
let offset = 0;
|
||||
|
||||
// finding sync word
|
||||
while (offset < view.byteLength - 4 && !(uint8[offset] === 0xff && (uint8[offset + 1] & 0xe0) === 0xe0)) {
|
||||
offset++;
|
||||
}
|
||||
if (offset >= view.byteLength - 4) return 0;
|
||||
|
||||
const header = view.getUint32(offset, false);
|
||||
|
||||
// header info
|
||||
const mpegVer = (header >> 19) & 3;
|
||||
const brIdx = (header >> 12) & 15;
|
||||
const srIdx = (header >> 10) & 3;
|
||||
|
||||
// Reject invalid headers
|
||||
if (mpegVer === 1 || brIdx === 0 || brIdx === 15 || srIdx === 3) return 0;
|
||||
|
||||
const sampleRates = [[11025, 12000, 8000], null, [22050, 24000, 16000], [44100, 48000, 32000]];
|
||||
const brMpeg1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
|
||||
const brMpeg2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
|
||||
|
||||
const sampleRate = sampleRates[mpegVer][srIdx];
|
||||
const bitrate = mpegVer === 3 ? brMpeg1[brIdx] : brMpeg2[brIdx];
|
||||
|
||||
// this xing header is present in many mp3 files and contains total frame count, which allows for accurate duration calculation
|
||||
const channelMode = (header >> 6) & 3; // mono or stereo
|
||||
const xingOffset = offset + 4 + (mpegVer === 3 ? (channelMode === 3 ? 17 : 32) : channelMode === 3 ? 9 : 17); // the position of xing header
|
||||
|
||||
if (xingOffset + 8 <= view.byteLength) {
|
||||
const sig = view.getUint32(xingOffset, false);
|
||||
if ((sig === 0x58696e67 || sig === 0x496e666f) && view.getUint32(xingOffset + 4, false) & 1) {
|
||||
const frames = view.getUint32(xingOffset + 8, false);
|
||||
// basically, duration = frames * samples per frame / sample rate
|
||||
return (frames * (mpegVer === 3 ? 1152 : 576)) / sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
// if no Xing header, estimate duration from file size and bitrate
|
||||
return ((file.size - startOffset) * 8) / (bitrate * 1000);
|
||||
}
|
||||
|
||||
function readSynchsafeInteger32(view, offset) {
|
||||
return (
|
||||
((view.getUint8(offset) & 0x7f) << 21) |
|
||||
((view.getUint8(offset + 1) & 0x7f) << 14) |
|
||||
((view.getUint8(offset + 2) & 0x7f) << 7) |
|
||||
(view.getUint8(offset + 3) & 0x7f)
|
||||
);
|
||||
}
|
||||
|
||||
function readID3Text(view) {
|
||||
const encoding = view.getUint8(0);
|
||||
const buffer = view.buffer.slice(view.byteOffset + 1, view.byteOffset + view.byteLength);
|
||||
let decoder;
|
||||
if (encoding === 0) decoder = new TextDecoder('iso-8859-1');
|
||||
else if (encoding === 1) decoder = new TextDecoder('utf-16');
|
||||
else if (encoding === 2) decoder = new TextDecoder('utf-16be');
|
||||
else decoder = new TextDecoder('utf-8');
|
||||
|
||||
return decoder.decode(buffer).replace(/\0/g, '');
|
||||
}
|
||||
|
||||
function getMimeType(data) {
|
||||
if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg';
|
||||
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
||||
return 'image/png';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Block type 127 is invalid, types > 6 are reserved (except 127)
|
||||
// Valid types: 0=STREAMINFO, 1=PADDING, 2=APPLICATION, 3=SEEKTABLE, 4=VORBIS_COMMENT, 5=CUESHEET, 6=PICTURE
|
||||
if (blockType === 127) {
|
||||
console.warn('Encountered invalid block type 127, stopping parse');
|
||||
break;
|
||||
}
|
||||
|
||||
const blockSize =
|
||||
(dataView.getUint8(offset + 1) << 16) |
|
||||
(dataView.getUint8(offset + 2) << 8) |
|
||||
dataView.getUint8(offset + 3);
|
||||
|
||||
// Validate block size
|
||||
if (blockSize < 0 || offset + 4 + blockSize > dataView.byteLength) {
|
||||
console.warn(`Invalid block size ${blockSize} at offset ${offset}, 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;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find the last block marker, estimate audio offset
|
||||
if (blocks.audioDataOffset === undefined && blocks.length > 0) {
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
blocks.audioDataOffset = lastBlock.headerOffset + 4 + lastBlock.size;
|
||||
console.warn('No last-block marker found, estimated audio offset:', blocks.audioDataOffset);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
346
js/metadata.mp3.js
Normal file
346
js/metadata.mp3.js
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { getCoverBlob, getTrackTitle } from './utils.js';
|
||||
|
||||
export async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) {
|
||||
const frames = [];
|
||||
|
||||
if (metadata.title) {
|
||||
frames.push(createTextFrame('TIT2', getTrackTitle(metadata)));
|
||||
}
|
||||
|
||||
const artistName = metadata.artist?.name || metadata.artists?.[0]?.name;
|
||||
if (artistName) {
|
||||
frames.push(createTextFrame('TPE1', artistName));
|
||||
}
|
||||
|
||||
if (metadata.album?.title) {
|
||||
frames.push(createTextFrame('TALB', metadata.album.title));
|
||||
}
|
||||
|
||||
const albumArtistName = metadata.album?.artist?.name || metadata.artist?.name || metadata.artists?.[0]?.name;
|
||||
if (albumArtistName) {
|
||||
frames.push(createTextFrame('TPE2', albumArtistName));
|
||||
}
|
||||
|
||||
if (metadata.trackNumber) {
|
||||
frames.push(createTextFrame('TRCK', metadata.trackNumber.toString()));
|
||||
}
|
||||
|
||||
if (metadata.album?.releaseDate) {
|
||||
const year = new Date(metadata.album.releaseDate).getFullYear();
|
||||
if (!Number.isNaN(year) && Number.isFinite(year)) {
|
||||
frames.push(createTextFrame('TYER', year.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.isrc) {
|
||||
frames.push(createTextFrame('TSRC', metadata.isrc));
|
||||
}
|
||||
|
||||
if (metadata.copyright) {
|
||||
frames.push(createTextFrame('TCOP', metadata.copyright));
|
||||
}
|
||||
|
||||
frames.push(createTextFrame('TENC', 'Monochrome'));
|
||||
|
||||
if (coverBlob) {
|
||||
frames.push(await createAPICFrame(coverBlob));
|
||||
}
|
||||
|
||||
return buildID3v2Tag(mp3Blob, frames);
|
||||
}
|
||||
|
||||
export function createTextFrame(frameId, text) {
|
||||
// ID3v2.3 UTF-16 encoding with BOM
|
||||
const bom = new Uint8Array([0xff, 0xfe]); // UTF-16LE BOM
|
||||
const utf16Bytes = new Uint8Array(text.length * 2);
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
utf16Bytes[i * 2] = charCode & 0xff;
|
||||
utf16Bytes[i * 2 + 1] = (charCode >> 8) & 0xff;
|
||||
}
|
||||
|
||||
const frameSize = 1 + bom.length + utf16Bytes.length;
|
||||
const frame = new Uint8Array(10 + frameSize);
|
||||
const view = new DataView(frame.buffer);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
frame[i] = frameId.charCodeAt(i);
|
||||
}
|
||||
|
||||
view.setUint32(4, frameSize, false);
|
||||
|
||||
frame[10] = 0x01; // UTF-16 with BOM
|
||||
|
||||
frame.set(bom, 11);
|
||||
frame.set(utf16Bytes, 11 + bom.length);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
export async function createAPICFrame(coverBlob) {
|
||||
const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
|
||||
const mimeType = coverBlob.type || 'image/jpeg';
|
||||
const mimeBytes = new TextEncoder().encode(mimeType);
|
||||
|
||||
const frameSize = 1 + mimeBytes.length + 1 + 1 + 1 + imageBytes.length;
|
||||
|
||||
const frame = new Uint8Array(10 + frameSize);
|
||||
const view = new DataView(frame.buffer);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
frame[i] = 'APIC'.charCodeAt(i);
|
||||
}
|
||||
|
||||
view.setUint32(4, frameSize, false);
|
||||
|
||||
let offset = 10;
|
||||
frame[offset++] = 0x00;
|
||||
|
||||
frame.set(mimeBytes, offset);
|
||||
offset += mimeBytes.length;
|
||||
frame[offset++] = 0x00;
|
||||
|
||||
frame[offset++] = 0x03;
|
||||
|
||||
frame[offset++] = 0x00;
|
||||
|
||||
frame.set(imageBytes, offset);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function buildID3v2Tag(mp3Blob, frames) {
|
||||
const framesData = new Uint8Array(frames.reduce((acc, f) => acc + f.length, 0));
|
||||
let offset = 0;
|
||||
for (const frame of frames) {
|
||||
framesData.set(frame, offset);
|
||||
offset += frame.length;
|
||||
}
|
||||
|
||||
const tagSize = framesData.length;
|
||||
|
||||
const header = new Uint8Array(10);
|
||||
header[0] = 0x49;
|
||||
header[1] = 0x44;
|
||||
header[2] = 0x33;
|
||||
header[3] = 0x03;
|
||||
header[4] = 0x00;
|
||||
header[5] = 0x00;
|
||||
|
||||
header[6] = (tagSize >> 21) & 0x7f;
|
||||
header[7] = (tagSize >> 14) & 0x7f;
|
||||
header[8] = (tagSize >> 7) & 0x7f;
|
||||
header[9] = tagSize & 0x7f;
|
||||
|
||||
return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' });
|
||||
}
|
||||
|
||||
export async function addMp3Metadata(mp3Blob, track, api) {
|
||||
try {
|
||||
let coverBlob = 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);
|
||||
}
|
||||
}
|
||||
|
||||
return await writeID3v2Tag(mp3Blob, track, coverBlob);
|
||||
} catch (error) {
|
||||
console.error('Failed to add MP3 metadata:', error);
|
||||
return mp3Blob;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMp3Metadata(file, metadata) {
|
||||
let buffer = await file.slice(0, 10).arrayBuffer();
|
||||
let view = new DataView(buffer);
|
||||
|
||||
if (view.getUint8(0) === 0x49 && view.getUint8(1) === 0x44 && view.getUint8(2) === 0x33) {
|
||||
const majorVer = view.getUint8(3);
|
||||
const size = readSynchsafeInteger32(view, 6);
|
||||
const tagSize = size + 10;
|
||||
|
||||
buffer = await file.slice(0, tagSize).arrayBuffer();
|
||||
view = new DataView(buffer);
|
||||
|
||||
let offset = 10;
|
||||
if ((view.getUint8(5) & 0x40) !== 0) {
|
||||
const extSize = readSynchsafeInteger32(view, offset);
|
||||
offset += extSize;
|
||||
}
|
||||
|
||||
let tpe1 = null;
|
||||
let tpe2 = null;
|
||||
while (offset < view.byteLength) {
|
||||
let frameId, frameSize;
|
||||
|
||||
if (majorVer === 3) {
|
||||
frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4));
|
||||
frameSize = view.getUint32(offset + 4, false);
|
||||
offset += 10;
|
||||
} else if (majorVer === 4) {
|
||||
frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4));
|
||||
frameSize = readSynchsafeInteger32(view, offset + 4);
|
||||
offset += 10;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (frameId.charCodeAt(0) === 0) break;
|
||||
if (offset + frameSize > view.byteLength) break;
|
||||
|
||||
const frameData = new DataView(buffer, offset, frameSize);
|
||||
if (frameId === 'TIT2') metadata.title = readID3Text(frameData);
|
||||
if (frameId === 'TPE1') tpe1 = readID3Text(frameData);
|
||||
if (frameId === 'TPE2') tpe2 = readID3Text(frameData);
|
||||
if (frameId === 'TALB') metadata.album.title = readID3Text(frameData);
|
||||
if (frameId === 'TSRC') metadata.isrc = readID3Text(frameData);
|
||||
if (frameId === 'TCOP') metadata.copyright = readID3Text(frameData);
|
||||
if (frameId === 'TLEN') metadata.duration = parseInt(readID3Text(frameData)) / 1000; // usually not present
|
||||
if (frameId === 'TYER' || frameId === 'TDRC') {
|
||||
const year = readID3Text(frameData);
|
||||
if (year) metadata.album.releaseDate = year;
|
||||
}
|
||||
if (frameId === 'APIC') {
|
||||
try {
|
||||
const encoding = frameData.getUint8(0);
|
||||
let mimeType = '';
|
||||
let pos = 1;
|
||||
while (pos < frameData.byteLength && frameData.getUint8(pos) !== 0) {
|
||||
mimeType += String.fromCharCode(frameData.getUint8(pos));
|
||||
pos++;
|
||||
}
|
||||
pos++;
|
||||
pos++;
|
||||
let terminator = encoding === 1 || encoding === 2 ? 2 : 1;
|
||||
while (pos < frameData.byteLength) {
|
||||
if (frameData.getUint8(pos) === 0) {
|
||||
if (terminator === 1) {
|
||||
pos++;
|
||||
break;
|
||||
} else if (pos + 1 < frameData.byteLength && frameData.getUint8(pos + 1) === 0) {
|
||||
pos += 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
const pictureData = new Uint8Array(buffer, offset + pos, frameSize - pos);
|
||||
const blob = new Blob([pictureData], { type: mimeType || 'image/jpeg' });
|
||||
metadata.album.cover = URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.warn('Error parsing APIC:', e);
|
||||
}
|
||||
}
|
||||
|
||||
offset += frameSize;
|
||||
}
|
||||
|
||||
const artistStr = tpe1 || tpe2;
|
||||
if (artistStr) {
|
||||
metadata.artists = artistStr.split('/').map((name) => ({ name: name.trim() }));
|
||||
}
|
||||
|
||||
if (!metadata.duration || metadata.duration === 0) {
|
||||
metadata.duration = await calculateMp3Duration(file, tagSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (file.size > 128) {
|
||||
const tailBuffer = await file.slice(file.size - 128).arrayBuffer();
|
||||
const tag = new TextDecoder().decode(new Uint8Array(tailBuffer, 0, 3));
|
||||
if (tag === 'TAG') {
|
||||
const title = new TextDecoder()
|
||||
.decode(new Uint8Array(tailBuffer, 3, 30))
|
||||
.replace(/\0/g, '')
|
||||
.trim();
|
||||
const artist = new TextDecoder()
|
||||
.decode(new Uint8Array(tailBuffer, 33, 30))
|
||||
.replace(/\0/g, '')
|
||||
.trim();
|
||||
const album = new TextDecoder()
|
||||
.decode(new Uint8Array(tailBuffer, 63, 30))
|
||||
.replace(/\0/g, '')
|
||||
.trim();
|
||||
if (title) metadata.title = title;
|
||||
if (artist && metadata.artists.length === 0) {
|
||||
metadata.artists = [{ name: artist }];
|
||||
}
|
||||
if (album) metadata.album.title = album;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// since mp3 file don't have metadata about duration, estimating it
|
||||
// uses evil bitwise magic
|
||||
export async function calculateMp3Duration(file, startOffset) {
|
||||
const buffer = await file.slice(startOffset, startOffset + 32768).arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
|
||||
let offset = 0;
|
||||
|
||||
// finding sync word
|
||||
while (offset < view.byteLength - 4 && !(uint8[offset] === 0xff && (uint8[offset + 1] & 0xe0) === 0xe0)) {
|
||||
offset++;
|
||||
}
|
||||
if (offset >= view.byteLength - 4) return 0;
|
||||
|
||||
const header = view.getUint32(offset, false);
|
||||
|
||||
// header info
|
||||
const mpegVer = (header >> 19) & 3;
|
||||
const brIdx = (header >> 12) & 15;
|
||||
const srIdx = (header >> 10) & 3;
|
||||
|
||||
// Reject invalid headers
|
||||
if (mpegVer === 1 || brIdx === 0 || brIdx === 15 || srIdx === 3) return 0;
|
||||
|
||||
const sampleRates = [[11025, 12000, 8000], null, [22050, 24000, 16000], [44100, 48000, 32000]];
|
||||
const brMpeg1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
|
||||
const brMpeg2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
|
||||
|
||||
const sampleRate = sampleRates[mpegVer][srIdx];
|
||||
const bitrate = mpegVer === 3 ? brMpeg1[brIdx] : brMpeg2[brIdx];
|
||||
|
||||
// this xing header is present in many mp3 files and contains total frame count, which allows for accurate duration calculation
|
||||
const channelMode = (header >> 6) & 3; // mono or stereo
|
||||
const xingOffset = offset + 4 + (mpegVer === 3 ? (channelMode === 3 ? 17 : 32) : channelMode === 3 ? 9 : 17); // the position of xing header
|
||||
|
||||
if (xingOffset + 8 <= view.byteLength) {
|
||||
const sig = view.getUint32(xingOffset, false);
|
||||
if ((sig === 0x58696e67 || sig === 0x496e666f) && view.getUint32(xingOffset + 4, false) & 1) {
|
||||
const frames = view.getUint32(xingOffset + 8, false);
|
||||
// basically, duration = frames * samples per frame / sample rate
|
||||
return (frames * (mpegVer === 3 ? 1152 : 576)) / sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
// if no Xing header, estimate duration from file size and bitrate
|
||||
return ((file.size - startOffset) * 8) / (bitrate * 1000);
|
||||
}
|
||||
|
||||
export function readSynchsafeInteger32(view, offset) {
|
||||
return (
|
||||
((view.getUint8(offset) & 0x7f) << 21) |
|
||||
((view.getUint8(offset + 1) & 0x7f) << 14) |
|
||||
((view.getUint8(offset + 2) & 0x7f) << 7) |
|
||||
(view.getUint8(offset + 3) & 0x7f)
|
||||
);
|
||||
}
|
||||
|
||||
export function readID3Text(view) {
|
||||
const encoding = view.getUint8(0);
|
||||
const buffer = view.buffer.slice(view.byteOffset + 1, view.byteOffset + view.byteLength);
|
||||
let decoder;
|
||||
if (encoding === 0) decoder = new TextDecoder('iso-8859-1');
|
||||
else if (encoding === 1) decoder = new TextDecoder('utf-16');
|
||||
else if (encoding === 2) decoder = new TextDecoder('utf-16be');
|
||||
else decoder = new TextDecoder('utf-8');
|
||||
|
||||
return decoder.decode(buffer).replace(/\0/g, '');
|
||||
}
|
||||
846
js/metadata.mp4.js
Normal file
846
js/metadata.mp4.js
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
import { getCoverBlob, getTrackTitle, getMimeType, getFullArtistString } from './utils.js';
|
||||
import { METADATA_STRINGS } from './metadata.js';
|
||||
|
||||
const { DEFAULT_TITLE, DEFAULT_ARTIST, DEFAULT_ALBUM } = METADATA_STRINGS;
|
||||
|
||||
export async function readM4aMetadata(file, metadata) {
|
||||
try {
|
||||
const chunkSize = Math.min(file.size, 5 * 1024 * 1024);
|
||||
const buffer = await file.slice(0, chunkSize).arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const atoms = parseMp4Atoms(view);
|
||||
|
||||
const moov = atoms.find((a) => a.type === 'moov');
|
||||
if (!moov) return;
|
||||
|
||||
const moovStart = moov.offset + 8;
|
||||
const moovLen = moov.size - 8;
|
||||
const moovData = new DataView(view.buffer, moovStart, moovLen);
|
||||
const moovAtoms = parseMp4Atoms(moovData);
|
||||
|
||||
// mvhd metadata tag
|
||||
const mvhd = moovAtoms.find((a) => a.type === 'mvhd');
|
||||
if (mvhd) {
|
||||
const mvhdStart = moovStart + mvhd.offset + 8;
|
||||
const version = view.getUint8(mvhdStart);
|
||||
|
||||
// resolution and length, basically
|
||||
let timeScale, duration;
|
||||
|
||||
if (version === 0) {
|
||||
// 32-bit format
|
||||
timeScale = view.getUint32(mvhdStart + 12, false);
|
||||
duration = view.getUint32(mvhdStart + 16, false);
|
||||
} else if (version === 1) {
|
||||
// 64-bit format
|
||||
timeScale = view.getUint32(mvhdStart + 20, false);
|
||||
const durHigh = view.getUint32(mvhdStart + 24, false);
|
||||
const durLow = view.getUint32(mvhdStart + 28, false);
|
||||
duration = durHigh * 0x100000000 + durLow;
|
||||
}
|
||||
|
||||
if (timeScale > 0) {
|
||||
metadata.duration = duration / timeScale;
|
||||
}
|
||||
}
|
||||
|
||||
const udta = moovAtoms.find((a) => a.type === 'udta');
|
||||
if (!udta) return;
|
||||
|
||||
const udtaStart = moovStart + udta.offset + 8;
|
||||
const udtaLen = udta.size - 8;
|
||||
const udtaData = new DataView(view.buffer, udtaStart, udtaLen);
|
||||
const udtaAtoms = parseMp4Atoms(udtaData);
|
||||
|
||||
const meta = udtaAtoms.find((a) => a.type === 'meta');
|
||||
if (!meta) return;
|
||||
|
||||
const metaStart = udtaStart + meta.offset + 12;
|
||||
const metaLen = meta.size - 12;
|
||||
const metaData = new DataView(view.buffer, metaStart, metaLen);
|
||||
const metaAtoms = parseMp4Atoms(metaData);
|
||||
|
||||
const ilst = metaAtoms.find((a) => a.type === 'ilst');
|
||||
if (!ilst) return;
|
||||
|
||||
const ilstStart = metaStart + ilst.offset + 8;
|
||||
const ilstLen = ilst.size - 8;
|
||||
const ilstData = new DataView(view.buffer, ilstStart, ilstLen);
|
||||
const items = parseMp4Atoms(ilstData);
|
||||
|
||||
let artistStr = null;
|
||||
|
||||
for (const item of items) {
|
||||
const itemStart = ilstStart + item.offset + 8;
|
||||
const itemLen = item.size - 8;
|
||||
const itemData = new DataView(view.buffer, itemStart, itemLen);
|
||||
const dataAtom = parseMp4Atoms(itemData).find((a) => a.type === 'data');
|
||||
if (dataAtom) {
|
||||
const contentLen = dataAtom.size - 16;
|
||||
const contentOffset = itemStart + dataAtom.offset + 16;
|
||||
|
||||
if (item.type === '©nam') {
|
||||
metadata.title = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
||||
} else if (item.type === '©ART') {
|
||||
artistStr = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
||||
} else if (item.type === '©alb') {
|
||||
metadata.album.title = new TextDecoder().decode(
|
||||
new Uint8Array(view.buffer, contentOffset, contentLen)
|
||||
);
|
||||
} else if (item.type === 'ISRC') {
|
||||
metadata.isrc = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
||||
} else if (item.type === 'cprt') {
|
||||
metadata.copyright = new TextDecoder().decode(
|
||||
new Uint8Array(view.buffer, contentOffset, contentLen)
|
||||
);
|
||||
} else if (item.type === 'covr') {
|
||||
const pictureData = new Uint8Array(view.buffer, contentOffset, contentLen);
|
||||
const mime = getMimeType(pictureData);
|
||||
const blob = new Blob([pictureData], { type: mime });
|
||||
metadata.album.cover = URL.createObjectURL(blob);
|
||||
} else if (item.type === 'rtng') {
|
||||
metadata.explicit =
|
||||
contentLen > 0 && new Uint8Array(view.buffer, contentOffset, contentLen)[0] === 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artistStr) {
|
||||
metadata.artists = artistStr.split(/; |\/|\\/).map((name) => ({ name: name.trim() }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing M4A:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds metadata to M4A files using MP4 atoms
|
||||
*/
|
||||
export 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 imageBlob = await getCoverBlob(api, track.album.cover);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function createMp4MetadataAtoms(track) {
|
||||
// MP4 metadata atoms are more complex than FLAC
|
||||
// We'll create basic iTunes-style metadata
|
||||
|
||||
/**
|
||||
* Array of arrays: [namespace, name, value]
|
||||
*/
|
||||
const userTags = [];
|
||||
const tags = {
|
||||
'©nam': getTrackTitle(track) || DEFAULT_TITLE,
|
||||
'©ART': getFullArtistString(track) || DEFAULT_ARTIST,
|
||||
'©alb': track.album?.title || DEFAULT_ALBUM,
|
||||
aART: track.album?.artist?.name || track.artist?.name || DEFAULT_ARTIST,
|
||||
};
|
||||
|
||||
if (track.isrc) {
|
||||
tags['ISRC'] = track.isrc;
|
||||
tags['xid '] = ':isrc:' + track.isrc;
|
||||
}
|
||||
|
||||
if (track.copyright) {
|
||||
tags['cprt'] = track.copyright;
|
||||
}
|
||||
|
||||
if (track.trackNumber) {
|
||||
tags['trkn'] = {
|
||||
current: track.trackNumber,
|
||||
total: track.album?.numberOfTracks,
|
||||
};
|
||||
}
|
||||
if (track.explicit) {
|
||||
tags['rtng'] = 1; // 1 = Explicit, 2 = Clean, 0 = Unknown
|
||||
}
|
||||
|
||||
const discNumber = track.volumeNumber ?? track.discNumber;
|
||||
if (discNumber) {
|
||||
tags['disk'] = {
|
||||
current: discNumber,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (track.bpm) {
|
||||
tags['tmpo'] = Math.round(track.bpm);
|
||||
}
|
||||
|
||||
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 {
|
||||
// Invalid date, skip
|
||||
}
|
||||
}
|
||||
|
||||
if (track.replayGain) {
|
||||
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain;
|
||||
let trackPeakAmplitudeString = String(trackPeakAmplitude);
|
||||
let albumPeakAmplitudeString = String(albumPeakAmplitude);
|
||||
|
||||
if (trackPeakAmplitudeString.indexOf('.') === -1) {
|
||||
trackPeakAmplitudeString += '.000000';
|
||||
}
|
||||
if (albumPeakAmplitudeString.indexOf('.') === -1) {
|
||||
albumPeakAmplitudeString += '.000000';
|
||||
}
|
||||
|
||||
if (trackPeakAmplitude) userTags.push(['com.apple.iTunes', 'replaygain_track_peak', trackPeakAmplitudeString]);
|
||||
if (trackReplayGain) userTags.push(['com.apple.iTunes', 'replaygain_track_gain', `${trackReplayGain} dB`]);
|
||||
if (albumPeakAmplitude) userTags.push(['com.apple.iTunes', 'replaygain_album_peak', albumPeakAmplitudeString]);
|
||||
if (albumReplayGain) userTags.push(['com.apple.iTunes', 'replaygain_album_gain', `${albumReplayGain} dB`]);
|
||||
}
|
||||
|
||||
return { tags, userTags };
|
||||
}
|
||||
|
||||
export function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
|
||||
const originalArray = new Uint8Array(dataView.buffer);
|
||||
|
||||
// Find moov atom
|
||||
const moovAtom = atoms.find((a) => a.type === 'moov');
|
||||
if (!moovAtom) {
|
||||
console.warn('No moov atom found in M4A file');
|
||||
return originalArray;
|
||||
}
|
||||
|
||||
// Construct the new metadata block (udta -> meta -> ilst)
|
||||
const newMetadataBytes = createMetadataBlock(metadataAtoms);
|
||||
|
||||
// We need to insert this into the moov atom.
|
||||
// If udta exists, we merge/replace. For simplicity, we'll append/create.
|
||||
// Ideally, we should parse moov children to find udta.
|
||||
|
||||
// 1. Calculate new sizes
|
||||
// New file size = Original size + Metadata block size
|
||||
// Note: If we are replacing existing metadata, this calculation would be different,
|
||||
// but here we are assuming we are adding fresh or appending.
|
||||
// A robust implementation would parse moov children, remove existing udta, and add new one.
|
||||
|
||||
// Let's try to do it right: parse moov children
|
||||
const moovChildren = parseMp4Atoms(new DataView(originalArray.buffer, moovAtom.offset + 8, moovAtom.size - 8));
|
||||
|
||||
// Filter out existing udta to replace it
|
||||
const filteredMoovChildren = moovChildren.filter((a) => a.type !== 'udta');
|
||||
|
||||
// Calculate new moov size
|
||||
// Header (8) + Sum of other children sizes + New Metadata Block Size
|
||||
let newMoovSize = 8;
|
||||
for (const child of filteredMoovChildren) {
|
||||
newMoovSize += child.size;
|
||||
}
|
||||
newMoovSize += newMetadataBytes.length;
|
||||
|
||||
const sizeDiff = newMoovSize - moovAtom.size;
|
||||
const newFileSize = originalArray.length + sizeDiff;
|
||||
|
||||
const newFile = new Uint8Array(newFileSize);
|
||||
let offset = 0;
|
||||
let originalOffset = 0;
|
||||
|
||||
// Copy atoms before moov
|
||||
const atomsBeforeMoov = atoms.filter((a) => a.offset < moovAtom.offset);
|
||||
for (const atom of atomsBeforeMoov) {
|
||||
newFile.set(originalArray.subarray(atom.offset, atom.offset + atom.size), offset);
|
||||
offset += atom.size;
|
||||
originalOffset += atom.size;
|
||||
}
|
||||
|
||||
// Write new moov atom
|
||||
// Size
|
||||
newFile[offset++] = (newMoovSize >> 24) & 0xff;
|
||||
newFile[offset++] = (newMoovSize >> 16) & 0xff;
|
||||
newFile[offset++] = (newMoovSize >> 8) & 0xff;
|
||||
newFile[offset++] = newMoovSize & 0xff;
|
||||
|
||||
// Type 'moov'
|
||||
newFile[offset++] = 0x6d;
|
||||
newFile[offset++] = 0x6f;
|
||||
newFile[offset++] = 0x6f;
|
||||
newFile[offset++] = 0x76;
|
||||
|
||||
// Write preserved children of moov
|
||||
for (const child of filteredMoovChildren) {
|
||||
const absoluteChildStart = moovAtom.offset + 8 + child.offset;
|
||||
newFile.set(originalArray.subarray(absoluteChildStart, absoluteChildStart + child.size), offset);
|
||||
offset += child.size;
|
||||
}
|
||||
|
||||
// Write new metadata block (udta)
|
||||
newFile.set(newMetadataBytes, offset);
|
||||
offset += newMetadataBytes.length;
|
||||
|
||||
// Update originalOffset to skip old moov
|
||||
originalOffset = moovAtom.offset + moovAtom.size;
|
||||
|
||||
// Copy atoms after moov
|
||||
// Adjust offsets in stco/co64 atoms if necessary?
|
||||
// Changing the size of moov (or atoms before mdat) shifts the mdat offsets.
|
||||
// If moov comes before mdat, we MUST update the Chunk Offset Atom (stco or co64).
|
||||
// This is complex.
|
||||
|
||||
// Safe strategy: If moov is AFTER mdat, we don't need to update offsets.
|
||||
// If moov is BEFORE mdat, we need to shift offsets.
|
||||
// Most streaming optimized files have moov before mdat.
|
||||
|
||||
const mdatAtom = atoms.find((a) => a.type === 'mdat');
|
||||
const moovBeforeMdat = mdatAtom && moovAtom.offset < mdatAtom.offset;
|
||||
|
||||
if (moovBeforeMdat) {
|
||||
// We need to update stco/co64 atoms inside the copied moov children content in newFile.
|
||||
// This is getting very complicated for a simple "add metadata" feature without a proper library.
|
||||
// However, we can try to find 'stco' or 'co64' in the new buffer we just wrote and offset values.
|
||||
|
||||
// Let's assume we need to shift by sizeDiff.
|
||||
updateChunkOffsets(newFile, offset - newMoovSize, newMoovSize, sizeDiff);
|
||||
}
|
||||
|
||||
// Copy remaining data (mdat etc.)
|
||||
if (originalOffset < originalArray.length) {
|
||||
newFile.set(originalArray.subarray(originalOffset), offset);
|
||||
}
|
||||
|
||||
return newFile;
|
||||
}
|
||||
|
||||
export function createMetadataBlock(metadataAtoms) {
|
||||
const { tags, userTags, cover } = metadataAtoms;
|
||||
|
||||
const ilstChildren = [];
|
||||
|
||||
// Text tags
|
||||
for (const [key, value] of Object.entries(tags)) {
|
||||
if (key === 'trkn' || key === 'disk') {
|
||||
ilstChildren.push(createIntAtom(key, value));
|
||||
} else if (key === 'rtng') {
|
||||
ilstChildren.push(createUintAtom(key, value, 1));
|
||||
} else if (key === 'tmpo') {
|
||||
ilstChildren.push(createUintAtom(key, value, 2));
|
||||
} else {
|
||||
ilstChildren.push(createStringAtom(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
// User tags
|
||||
for (const [namespace, name, value] of userTags) {
|
||||
ilstChildren.push(createUserAtom(namespace, name, value));
|
||||
}
|
||||
|
||||
// Cover art
|
||||
if (cover) {
|
||||
ilstChildren.push(createCoverAtom(cover.data));
|
||||
}
|
||||
|
||||
// Construct ilst atom
|
||||
const ilstSize = 8 + ilstChildren.reduce((acc, buf) => acc + buf.length, 0);
|
||||
const ilst = new Uint8Array(ilstSize);
|
||||
let offset = 0;
|
||||
|
||||
writeAtomHeader(ilst, offset, ilstSize, 'ilst');
|
||||
offset += 8;
|
||||
|
||||
for (const child of ilstChildren) {
|
||||
ilst.set(child, offset);
|
||||
offset += child.length;
|
||||
}
|
||||
|
||||
// Construct meta atom (FullAtom, version+flags = 4 bytes)
|
||||
const metaSize = 12 + ilstSize;
|
||||
const meta = new Uint8Array(metaSize);
|
||||
offset = 0;
|
||||
|
||||
writeAtomHeader(meta, offset, metaSize, 'meta');
|
||||
offset += 8;
|
||||
|
||||
meta[offset++] = 0; // Version
|
||||
meta[offset++] = 0; // Flags
|
||||
meta[offset++] = 0;
|
||||
meta[offset++] = 0;
|
||||
|
||||
meta.set(ilst, offset);
|
||||
|
||||
// Construct hdlr atom (required for meta)
|
||||
// "mdir" subtype, "appl" manufacturer, 0 flags/masks, empty name
|
||||
// hdlr size: 4 (size) + 4 (type) + 4 (ver/flags) + 4 (pre_defined) + 4 (handler_type) + 12 (reserved) + name (string)
|
||||
// Minimal valid hdlr for iTunes metadata:
|
||||
const hdlrContent = new Uint8Array([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // Version/Flags
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // Pre-defined
|
||||
0x6d,
|
||||
0x64,
|
||||
0x69,
|
||||
0x72, // 'mdir'
|
||||
0x61,
|
||||
0x70,
|
||||
0x70,
|
||||
0x6c, // 'appl'
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // Reserved
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // Name (empty null-term) check spec? usually simple 0 is enough
|
||||
]);
|
||||
const hdlrSize = 8 + hdlrContent.length;
|
||||
const hdlr = new Uint8Array(hdlrSize);
|
||||
writeAtomHeader(hdlr, 0, hdlrSize, 'hdlr');
|
||||
hdlr.set(hdlrContent, 8);
|
||||
|
||||
// Construct udta atom
|
||||
// udta contains meta. meta usually should contain hdlr before ilst?
|
||||
// Actually, QuickTime spec says meta contains hdlr then ilst.
|
||||
|
||||
const finalMetaSize = 12 + hdlrSize + ilstSize;
|
||||
const finalMeta = new Uint8Array(finalMetaSize);
|
||||
offset = 0;
|
||||
writeAtomHeader(finalMeta, offset, finalMetaSize, 'meta');
|
||||
offset += 8;
|
||||
finalMeta[offset++] = 0; // Version
|
||||
finalMeta[offset++] = 0; // Flags
|
||||
finalMeta[offset++] = 0;
|
||||
finalMeta[offset++] = 0;
|
||||
|
||||
finalMeta.set(hdlr, offset);
|
||||
offset += hdlrSize;
|
||||
finalMeta.set(ilst, offset);
|
||||
|
||||
const udtaSize = 8 + finalMetaSize;
|
||||
const udta = new Uint8Array(udtaSize);
|
||||
writeAtomHeader(udta, 0, udtaSize, 'udta');
|
||||
udta.set(finalMeta, 8);
|
||||
|
||||
return udta;
|
||||
}
|
||||
|
||||
export function createStringAtom(type, value, truncateType = true) {
|
||||
const typeLength = truncateType ? 4 : type.length;
|
||||
const textBytes = new TextEncoder().encode(value);
|
||||
const dataSize = 16 + textBytes.length; // 8 (data atom header) + 8 (flags/null) + text
|
||||
const atomSize = 4 + typeLength + dataSize;
|
||||
|
||||
const buf = new Uint8Array(atomSize);
|
||||
let offset = 0;
|
||||
|
||||
// Wrapper atom (e.g., ©nam)
|
||||
writeAtomHeader(buf, offset, atomSize, type, truncateType);
|
||||
offset += 4 + typeLength;
|
||||
|
||||
// Data atom
|
||||
writeAtomHeader(buf, offset, dataSize, 'data');
|
||||
offset += 8;
|
||||
|
||||
// Data Type (1 = UTF-8 text) + Locale (0)
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 1; // Type 1
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
|
||||
buf.set(textBytes, offset);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function createUserAtom(namespace, name, value) {
|
||||
const encoder = new TextEncoder();
|
||||
const dashBytes = encoder.encode('----'); // User-defined atom type
|
||||
const namespaceBytes = encoder.encode(namespace);
|
||||
const meanBytes = encoder.encode('mean'); // Standard 'mean' atom for namespace
|
||||
const nameBytes = encoder.encode(name);
|
||||
const valueBytes = encoder.encode('\x00\x00\x00\x01\x00\x00\x00\x00' + value);
|
||||
|
||||
/**
|
||||
* Atom structure:
|
||||
* [----] (atom header)
|
||||
* [mean] (namespace)
|
||||
* [name] (name)
|
||||
* [data] (value)
|
||||
*/
|
||||
const atomSize = 8 + 12 + namespaceBytes.length + 12 + nameBytes.length + 8 + valueBytes.length;
|
||||
|
||||
const buf = new Uint8Array(atomSize);
|
||||
let offset = 0;
|
||||
writeAtomHeader(buf, offset, atomSize, '----');
|
||||
offset += 8; // Skip header
|
||||
writeAtomHeader(buf, offset, namespaceBytes.length + 12, 'mean');
|
||||
offset += 12;
|
||||
buf.set(namespaceBytes, offset);
|
||||
offset += namespaceBytes.length;
|
||||
writeAtomHeader(buf, offset, nameBytes.length + 12, 'name');
|
||||
offset += 12;
|
||||
buf.set(nameBytes, offset);
|
||||
offset += nameBytes.length;
|
||||
writeAtomHeader(buf, offset, valueBytes.length + 8, 'data');
|
||||
offset += 8;
|
||||
buf.set(valueBytes, offset);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a number or BigInt value to a big-endian byte array.
|
||||
* @param {number|BigInt|null} value - The value to convert to bytes. If null, returns null.
|
||||
* @param {number|null} [byteLength=null] - Optional fixed byte length. If provided, the result will be padded or truncated to this length. If not provided, returns the minimal byte representation.
|
||||
* @returns {Uint8Array} A Uint8Array representing the value in big-endian format, or null if value is null.
|
||||
* @throws {Error} If the value is a negative number.
|
||||
* @example
|
||||
* // Variable length (minimal bytes)
|
||||
* toBigEndianBytes(256); // Uint8Array [ 1, 0 ]
|
||||
* toBigEndianBytes(0); // Uint8Array [ 0 ]
|
||||
*
|
||||
* // Fixed length with padding
|
||||
* toBigEndianBytes(1, 4); // Uint8Array [ 0, 0, 0, 1 ]
|
||||
*
|
||||
* // With BigInt
|
||||
* toBigEndianBytes(0xDEADBEEFn, 4); // Uint8Array [ 222, 173, 190, 239 ]
|
||||
*/
|
||||
export function toBigEndianBytes(value, byteLength = null) {
|
||||
if (value == null) return new Uint8Array(0);
|
||||
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error('Value must be a non-negative safe integer.');
|
||||
}
|
||||
|
||||
// Fixed-length mode
|
||||
if (byteLength != null) {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
for (let i = byteLength - 1; i >= 0; i--) {
|
||||
bytes[i] = value & 0xff;
|
||||
value = Math.floor(value / 256);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Variable (minimal) mode
|
||||
if (value === 0) return new Uint8Array([0]);
|
||||
|
||||
const result = [];
|
||||
while (value > 0) {
|
||||
result.push(value & 0xff);
|
||||
value = Math.floor(value / 256);
|
||||
}
|
||||
|
||||
result.reverse();
|
||||
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
export function createUintAtom(key, value, intByteLength = 1) {
|
||||
const numberBytes = toBigEndianBytes(value, intByteLength);
|
||||
const dataSize = 16 + intByteLength; // Atom header (8) + number bytes
|
||||
const atomSize = 8 + dataSize;
|
||||
|
||||
const buf = new Uint8Array(atomSize);
|
||||
let offset = 0;
|
||||
|
||||
// Wrapper atom (e.g., ©nam)
|
||||
writeAtomHeader(buf, offset, atomSize, key);
|
||||
offset += 8;
|
||||
|
||||
// Data atom
|
||||
writeAtomHeader(buf, offset, dataSize, 'data');
|
||||
offset += 8;
|
||||
|
||||
// Data Type ((Big Endian Unsigned Integer) + Locale (0))
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 21; // Type 21
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf.set(numberBytes, offset++);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function createIntAtom(type, value) {
|
||||
// trkn/disk are special: data is 8 bytes.
|
||||
// reserved(2) + track(2) + total(2) + reserved(2)
|
||||
const dataSize = 16 + 8;
|
||||
const atomSize = 8 + dataSize;
|
||||
|
||||
const buf = new Uint8Array(atomSize);
|
||||
let offset = 0;
|
||||
|
||||
writeAtomHeader(buf, offset, atomSize, type);
|
||||
offset += 8;
|
||||
|
||||
writeAtomHeader(buf, offset, dataSize, 'data');
|
||||
offset += 8;
|
||||
|
||||
// Data Type (0 = implicit/int) + Locale
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0; // Type 0
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
|
||||
const current = typeof value === 'object' ? value.current : value;
|
||||
const total = typeof value === 'object' ? value.total : 0;
|
||||
|
||||
// Numbering payload (track/disc number + total)
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
const numberValue = parseInt(current, 10) || 0;
|
||||
buf[offset++] = (numberValue >> 8) & 0xff;
|
||||
buf[offset++] = numberValue & 0xff;
|
||||
const totalValue = parseInt(total, 10) || 0;
|
||||
buf[offset++] = (totalValue >> 8) & 0xff;
|
||||
buf[offset++] = totalValue & 0xff;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function createCoverAtom(imageBytes) {
|
||||
const dataSize = 16 + imageBytes.length;
|
||||
const atomSize = 8 + dataSize;
|
||||
|
||||
const buf = new Uint8Array(atomSize);
|
||||
let offset = 0;
|
||||
|
||||
writeAtomHeader(buf, offset, atomSize, 'covr');
|
||||
offset += 8;
|
||||
|
||||
writeAtomHeader(buf, offset, dataSize, 'data');
|
||||
offset += 8;
|
||||
|
||||
// Data Type (13 = JPEG, 14 = PNG)
|
||||
// We try to detect or default to JPEG (13)
|
||||
let type = 13;
|
||||
if (imageBytes[0] === 0x89 && imageBytes[1] === 0x50) {
|
||||
// PNG signature
|
||||
type = 14;
|
||||
}
|
||||
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = type;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
|
||||
buf.set(imageBytes, offset);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an atom header for MP4 metadata.
|
||||
* @param {number} size - The size of the atom in bytes.
|
||||
* @param {string} type - The 4-character atom type identifier.
|
||||
* @param {boolean} [truncate=false] - Whether to truncate the type to 4 characters or use full length.
|
||||
* @returns {Uint8Array} A byte array containing the atom header with size and type information.
|
||||
*/
|
||||
export function getAtomHeader(size, type, truncate = false) {
|
||||
const buf = new Uint8Array(4 + (truncate ? 4 : type.length));
|
||||
buf[0] = (size >> 24) & 0xff;
|
||||
buf[1] = (size >> 16) & 0xff;
|
||||
buf[2] = (size >> 8) & 0xff;
|
||||
buf[3] = size & 0xff;
|
||||
|
||||
for (let i = 0; i < (truncate ? 4 : type.length); i++) {
|
||||
buf[4 + i] = type.charCodeAt(i);
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an atom header to a buffer at the specified offset.
|
||||
* @param {Uint8Array} buf - The buffer to write the atom header to.
|
||||
* @param {number} offset - The offset in the buffer where the atom header should be written.
|
||||
* @param {number} size - The size of the atom.
|
||||
* @param {string} type - The type of the atom (typically a 4-character code).
|
||||
* @param {boolean} [truncate=true] - Whether to truncate the atom header. Defaults to true.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function writeAtomHeader(buf, offset, size, type, truncate = true) {
|
||||
buf.set(getAtomHeader(size, type, truncate), offset);
|
||||
}
|
||||
|
||||
export function updateChunkOffsets(buffer, moovOffset, moovSize, shift) {
|
||||
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
|
||||
// Scan moov for stco/co64
|
||||
// This is a naive recursive search restricted to the known moov range
|
||||
|
||||
// We parse atoms starting from moov content
|
||||
let offset = moovOffset + 8; // Skip moov header
|
||||
const end = moovOffset + moovSize;
|
||||
|
||||
findAndShiftOffsets(view, offset, end, shift);
|
||||
}
|
||||
|
||||
export function findAndShiftOffsets(view, start, end, shift) {
|
||||
let offset = start;
|
||||
|
||||
while (offset + 8 <= end) {
|
||||
const size = view.getUint32(offset, false);
|
||||
const type = String.fromCharCode(
|
||||
view.getUint8(offset + 4),
|
||||
view.getUint8(offset + 5),
|
||||
view.getUint8(offset + 6),
|
||||
view.getUint8(offset + 7)
|
||||
);
|
||||
|
||||
if (size < 8) break;
|
||||
|
||||
if (type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') {
|
||||
// Container atoms, recurse
|
||||
findAndShiftOffsets(view, offset + 8, offset + size, shift);
|
||||
} else if (type === 'stco') {
|
||||
// Chunk Offset Atom (32-bit)
|
||||
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 4)
|
||||
const count = view.getUint32(offset + 12, false);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entryOffset = offset + 16 + i * 4;
|
||||
const oldVal = view.getUint32(entryOffset, false);
|
||||
view.setUint32(entryOffset, oldVal + shift, false);
|
||||
}
|
||||
} else if (type === 'co64') {
|
||||
// Chunk Offset Atom (64-bit)
|
||||
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 8)
|
||||
const count = view.getUint32(offset + 12, false);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entryOffset = offset + 16 + i * 8;
|
||||
// Read 64-bit int
|
||||
const oldHigh = view.getUint32(entryOffset, false);
|
||||
const oldLow = view.getUint32(entryOffset + 4, false);
|
||||
|
||||
// Add shift (assuming shift is small enough not to overflow low 32 in a way that affects high simply?)
|
||||
// Shift is Javascript number (double), up to 9007199254740991.
|
||||
// 32-bit uint max is 4294967295.
|
||||
|
||||
// Proper 64-bit addition
|
||||
// Construct BigInt
|
||||
// Note: BigInt might not be available in all older environments, but modern browsers support it.
|
||||
// Fallback: simpler logic
|
||||
|
||||
let newLow = oldLow + shift;
|
||||
let carry = 0;
|
||||
if (newLow > 0xffffffff) {
|
||||
carry = Math.floor(newLow / 0x100000000);
|
||||
newLow = newLow >>> 0;
|
||||
}
|
||||
const newHigh = oldHigh + carry;
|
||||
|
||||
view.setUint32(entryOffset, newHigh, false);
|
||||
view.setUint32(entryOffset + 4, newLow, false);
|
||||
}
|
||||
}
|
||||
|
||||
offset += size;
|
||||
}
|
||||
}
|
||||
|
|
@ -303,22 +303,28 @@ async function getMetadataFromAudio(message: GetMetadataMessage): Promise<TagLib
|
|||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
||||
const transfer: Transferable[] = [event.data.audioData.buffer];
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'Add':
|
||||
try {
|
||||
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
|
||||
transfer.push(result.buffer);
|
||||
self.postMessage(
|
||||
{
|
||||
type: event.data.type,
|
||||
data: result,
|
||||
} satisfies TagLibFileResponse,
|
||||
[result.buffer, event.data.audioData.buffer]
|
||||
transfer
|
||||
);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: event.data.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} satisfies TagLibWorkerResponse<undefined>);
|
||||
self.postMessage(
|
||||
{
|
||||
type: event.data.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} satisfies TagLibWorkerResponse<undefined>,
|
||||
transfer
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -330,13 +336,16 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
|||
type: event.data.type,
|
||||
data: result,
|
||||
} satisfies TagLibMetadataResponse,
|
||||
[event.data.audioData.buffer]
|
||||
transfer
|
||||
);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: event.data.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} satisfies TagLibWorkerResponse<undefined>);
|
||||
self.postMessage(
|
||||
{
|
||||
type: event.data.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} satisfies TagLibWorkerResponse<undefined>,
|
||||
transfer
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
41
js/utils.js
41
js/utils.js
|
|
@ -557,6 +557,40 @@ export const getShareUrl = (path) => {
|
|||
return `${baseUrl}${safePath}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a full artist string by combining the track's listed artists
|
||||
* with any featured artists parsed from the title (feat./with).
|
||||
*/
|
||||
export function getFullArtistString(track) {
|
||||
const knownArtists =
|
||||
Array.isArray(track.artists) && track.artists.length > 0
|
||||
? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean)
|
||||
: track.artist?.name
|
||||
? [track.artist.name]
|
||||
: [];
|
||||
|
||||
// Parse featured artists from title, e.g. "Song (feat. A, B & C)" or "(with X & Y)"
|
||||
// Note: splitting on '&' may incorrectly fragment compound artist names like "Simon & Garfunkel".
|
||||
const featPattern = /\(\s*(?:feat\.?|ft\.?|with)\s+(.+?)\s*\)/gi;
|
||||
const allFeatArtists = [...(track.title?.matchAll(featPattern) ?? [])].flatMap((m) =>
|
||||
m[1]
|
||||
.split(/\s*[,&]\s*/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (allFeatArtists.length > 0) {
|
||||
const knownLower = new Set(knownArtists.map((n) => n.toLowerCase()));
|
||||
for (const feat of allFeatArtists) {
|
||||
if (!knownLower.has(feat.toLowerCase())) {
|
||||
knownArtists.push(feat);
|
||||
knownLower.add(feat.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return knownArtists.join('; ') || null;
|
||||
}
|
||||
|
||||
export function fetchBlob(url) {
|
||||
return fetch(url).then((d) => d.blob());
|
||||
}
|
||||
|
|
@ -564,3 +598,10 @@ export function fetchBlob(url) {
|
|||
export async function fetchBlobURL(url) {
|
||||
return await URL.createObjectURL(await fetchBlob(url));
|
||||
}
|
||||
|
||||
export function getMimeType(data) {
|
||||
if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg';
|
||||
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
||||
return 'image/png';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue