311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
// filepath: /workspaces/monochrome/js/taglib.worker.ts
|
|
declare var self: DedicatedWorkerGlobalScope;
|
|
|
|
import { TagLib, type PictureType } from 'taglib-wasm';
|
|
import { doTimed, doTimedAsync } from './doTimed';
|
|
import type {
|
|
AddMetadataMessage,
|
|
GetMetadataMessage,
|
|
TagLibFileResponse,
|
|
TagLibMetadata,
|
|
TagLibMetadataResponse,
|
|
TagLibReadMetadata,
|
|
TagLibWorkerMessage,
|
|
TagLibWorkerResponse,
|
|
} from './taglib.types';
|
|
|
|
const PICTURE_TYPE_VALUES = {
|
|
FrontCover: 3,
|
|
};
|
|
|
|
async function addMetadataToAudio(message: AddMetadataMessage): Promise<Uint8Array> {
|
|
const {
|
|
wasmUrl,
|
|
audioData,
|
|
title,
|
|
artist,
|
|
albumTitle,
|
|
albumArtist,
|
|
trackNumber,
|
|
totalTracks,
|
|
discNumber,
|
|
totalDiscs,
|
|
bpm,
|
|
replayGain,
|
|
cover,
|
|
releaseDate,
|
|
copyright,
|
|
isrc,
|
|
explicit,
|
|
lyrics,
|
|
} = message;
|
|
|
|
const file = await doTimedAsync('Open file with taglib', async () => {
|
|
const tagLib = await TagLib.initialize({
|
|
wasmUrl: wasmUrl,
|
|
});
|
|
return await tagLib.open(audioData);
|
|
});
|
|
|
|
try {
|
|
doTimed('Tagging file', () => {
|
|
const isMp4 = file.isMP4();
|
|
const media = file.audioProperties();
|
|
const needsCombinedTrackDisc = isMp4 || media.containerFormat.toLowerCase() === 'mp3';
|
|
|
|
if (title) {
|
|
file.setProperty('TITLE', title);
|
|
}
|
|
|
|
if (artist) {
|
|
file.setProperty('ARTIST', artist);
|
|
}
|
|
|
|
if (albumTitle) {
|
|
file.setProperty('ALBUM', albumTitle);
|
|
}
|
|
|
|
const _albumArtist = albumArtist || artist;
|
|
if (_albumArtist) {
|
|
file.setProperty('ALBUMARTIST', _albumArtist);
|
|
}
|
|
|
|
if (trackNumber) {
|
|
let trackString = String(trackNumber);
|
|
|
|
if (needsCombinedTrackDisc && trackNumber && totalTracks) {
|
|
trackString = `${trackNumber}/${totalTracks}`;
|
|
}
|
|
|
|
if (needsCombinedTrackDisc) {
|
|
file.setProperty('TRACKNUMBER', trackString);
|
|
} else {
|
|
file.setProperty('TRACKNUMBER', String(trackNumber));
|
|
}
|
|
}
|
|
|
|
if (!needsCombinedTrackDisc && totalTracks) {
|
|
file.setProperty('TRACKTOTAL', String(totalTracks));
|
|
}
|
|
|
|
if (discNumber) {
|
|
let discString = String(discNumber);
|
|
|
|
if (needsCombinedTrackDisc && discNumber && totalDiscs) {
|
|
discString = `${discNumber}/${totalDiscs}`;
|
|
}
|
|
|
|
if (needsCombinedTrackDisc) {
|
|
file.setProperty('DISCNUMBER', discString);
|
|
} else {
|
|
file.setProperty('DISCNUMBER', String(discNumber));
|
|
}
|
|
}
|
|
|
|
if (!needsCombinedTrackDisc && totalDiscs) {
|
|
file.setProperty('DISCTOTAL', String(totalDiscs));
|
|
}
|
|
|
|
if (bpm != null && Number.isFinite(bpm)) {
|
|
file.setProperty('BPM', String(Math.round(bpm)));
|
|
}
|
|
|
|
if (replayGain) {
|
|
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = replayGain;
|
|
if (albumReplayGain) file.setProperty('REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain));
|
|
if (albumPeakAmplitude) file.setProperty('REPLAYGAIN_ALBUM_PEAK', String(albumPeakAmplitude));
|
|
if (trackReplayGain) file.setProperty('REPLAYGAIN_TRACK_GAIN', String(trackReplayGain));
|
|
if (trackPeakAmplitude) file.setProperty('REPLAYGAIN_TRACK_PEAK', String(trackPeakAmplitude));
|
|
}
|
|
|
|
if (releaseDate) {
|
|
try {
|
|
const year = Number(releaseDate.split('-')[0]);
|
|
if (!isNaN(year)) {
|
|
file.setProperty('DATE', String(year));
|
|
}
|
|
} catch {
|
|
// Invalid date, skip
|
|
}
|
|
}
|
|
|
|
if (copyright) {
|
|
file.setProperty('COPYRIGHT', copyright);
|
|
}
|
|
|
|
if (isrc) {
|
|
file.setProperty('ISRC', isrc);
|
|
|
|
if (isMp4) {
|
|
file.setMP4Item('xid ', `:isrc:${isrc}`);
|
|
}
|
|
}
|
|
|
|
if (explicit) {
|
|
if (isMp4) {
|
|
file.setMP4Item('rtng', '1');
|
|
} else {
|
|
file.setProperty('ITUNESADVISORY', '1');
|
|
}
|
|
}
|
|
|
|
if (lyrics) {
|
|
file.setProperty('LYRICS', lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n'));
|
|
}
|
|
|
|
if (cover) {
|
|
file.setPictures([
|
|
{
|
|
mimeType: cover.type,
|
|
data: cover.data,
|
|
type: 'FrontCover',
|
|
description: 'Cover Art',
|
|
},
|
|
]);
|
|
}
|
|
});
|
|
|
|
await doTimedAsync('Saving in-memory buffer', () => file.save());
|
|
|
|
return file.getFileBuffer();
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
file.dispose();
|
|
}
|
|
|
|
return audioData;
|
|
}
|
|
|
|
async function getMetadataFromAudio(message: GetMetadataMessage): Promise<TagLibReadMetadata> {
|
|
const { wasmUrl, audioData } = message;
|
|
const data: TagLibReadMetadata = {
|
|
duration: 0,
|
|
};
|
|
|
|
const file = await doTimedAsync('Open file with taglib', async () => {
|
|
const tagLib = await TagLib.initialize({
|
|
wasmUrl: wasmUrl,
|
|
});
|
|
return await tagLib.open(audioData);
|
|
});
|
|
|
|
try {
|
|
const pictures = file.getPictures();
|
|
const isMp4 = file.isMP4();
|
|
const media = file.audioProperties();
|
|
|
|
data.duration = media.duration;
|
|
|
|
data.title = file.getProperty('TITLE') || undefined;
|
|
data.artist = file.getProperty('ARTIST') || undefined;
|
|
data.albumTitle = file.getProperty('ALBUM') || undefined;
|
|
data.albumArtist = file.getProperty('ALBUMARTIST') || undefined;
|
|
const [trackNumber, trackTotal] = file
|
|
.getProperty('TRACKNUMBER')
|
|
?.split('/')
|
|
.map((t) => Number(t.trim() || 0) || undefined);
|
|
data.trackNumber = trackNumber || undefined;
|
|
data.totalTracks = trackTotal ? trackTotal : Number(file.getProperty('TRACKTOTAL') || 0) || undefined;
|
|
|
|
const [discNumber, discTotal] = file
|
|
.getProperty('DISCNUMBER')
|
|
?.split('/')
|
|
.map((t) => Number(t.trim() || 0) || undefined);
|
|
data.discNumber = Number(file.getProperty('DISCNUMBER') || 0) || undefined;
|
|
|
|
data.bpm = Number(file.getProperty('BPM') || 0) || undefined;
|
|
data.copyright = file.getProperty('COPYRIGHT') || undefined;
|
|
data.lyrics = file.getProperty('LYRICS') || undefined;
|
|
data.releaseDate = file.getProperty('DATE') || undefined;
|
|
|
|
const [replayGainAlbumGain, replayGainAlbumPeak, replayGainTrackGain, replayGainTrackPeak] = [
|
|
file.getProperty('REPLAYGAIN_ALBUM_GAIN'),
|
|
file.getProperty('REPLAYGAIN_ALBUM_PEAK'),
|
|
file.getProperty('REPLAYGAIN_TRACK_GAIN'),
|
|
file.getProperty('REPLAYGAIN_TRACK_PEAK'),
|
|
];
|
|
|
|
const replayGain: TagLibMetadata['replayGain'] = {};
|
|
if (replayGainAlbumGain) replayGain.albumReplayGain = replayGainAlbumGain;
|
|
if (replayGainAlbumPeak) replayGain.albumPeakAmplitude = Number(replayGainAlbumPeak);
|
|
if (replayGainTrackGain) replayGain.trackReplayGain = replayGainTrackGain;
|
|
if (replayGainTrackPeak) replayGain.trackPeakAmplitude = Number(replayGainTrackPeak);
|
|
if (Object.keys(replayGain).length > 0) {
|
|
data.replayGain = replayGain;
|
|
}
|
|
|
|
data.isrc = (isMp4 && file.getMP4Item('xid ')?.split(':').at(-1)) || file.getProperty('ISRC') || undefined;
|
|
data.explicit = (isMp4 && file.getMP4Item('rtng') === '1') || file.getProperty('ITUNESADVISORY') === '1';
|
|
|
|
if (pictures.length > 0) {
|
|
const picture = pictures.filter((p) => p.type === 'FrontCover')[0];
|
|
if (picture) {
|
|
data.cover = {
|
|
data: picture.data,
|
|
type: picture.mimeType,
|
|
};
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
file.dispose();
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
|
const transfer: Transferable[] = [event.data.audioData.buffer];
|
|
|
|
switch (event.data.type) {
|
|
case 'Add':
|
|
if ((event.data as AddMetadataMessage).cover?.data?.buffer instanceof ArrayBuffer) {
|
|
transfer.push((event.data as AddMetadataMessage).cover.data.buffer);
|
|
}
|
|
|
|
try {
|
|
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
|
|
transfer.push(result.buffer);
|
|
|
|
self.postMessage(
|
|
{
|
|
type: event.data.type,
|
|
data: result,
|
|
} satisfies TagLibFileResponse,
|
|
transfer
|
|
);
|
|
} catch (error) {
|
|
self.postMessage(
|
|
{
|
|
type: event.data.type,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
} satisfies TagLibWorkerResponse<undefined>,
|
|
transfer
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'Get':
|
|
try {
|
|
const result = await getMetadataFromAudio(event.data as GetMetadataMessage);
|
|
self.postMessage(
|
|
{
|
|
type: event.data.type,
|
|
data: result,
|
|
} satisfies TagLibMetadataResponse,
|
|
transfer
|
|
);
|
|
} catch (error) {
|
|
self.postMessage(
|
|
{
|
|
type: event.data.type,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
} satisfies TagLibWorkerResponse<undefined>,
|
|
transfer
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
};
|