feat(taglib): refactor and improve metadata handling, worker integration, and code quality

- Refactor metadata handling to use fetchTagLib and addMetadataWithTagLib for improved loading and worker-based processing
- Update prefetchMetadataObjects and addMetadataToAudio for simplified and more robust metadata extraction
- Add taglib.worker.ts for audio metadata processing in a worker
- Implement getMetadataWithTagLib function
- Auto-fix linting issues and remove unnecessary debugger statements
This commit is contained in:
Daniel 2026-03-08 16:09:44 +00:00 committed by GitHub
parent 497d42b9fd
commit efa3521aff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 692 additions and 134 deletions

View file

@ -20,6 +20,7 @@
"npm": "^11.11.0",
"pocketbase": "^0.26.8",
"taglib-wasm": "^1.0.5",
"uuid": "^13.0.0",
},
"devDependencies": {
"@neutralinojs/neu": "^11.7.0",
@ -1345,7 +1346,7 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
@ -1465,6 +1466,8 @@
"@keyv/bigmap/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"@neutralinojs/neu/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@rollup/plugin-babel/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="],

195
js/BaseCodec.ts Normal file
View file

@ -0,0 +1,195 @@
class BaseCodec {
private readonly dictionary: string[];
private readonly base: number;
private readonly dictionarySet: Set<string>;
constructor(dictionary: string) {
if (new Set(dictionary).size !== dictionary.length) {
throw new Error('Dictionary must not contain duplicate characters.');
}
if (dictionary.length < 2) {
throw new Error('Dictionary must contain at least 2 symbols.');
}
this.dictionary = [...dictionary];
this.dictionarySet = new Set(dictionary);
this.base = dictionary.length;
}
/**
* Encode overloads:
* - number sync encoding (base-N)
* - string | Uint8Array | Blob byte-level encoding
*/
encode(input: number): string;
encode(input: string | Uint8Array): string;
encode(input: number | string | Uint8Array): string {
if (typeof input === 'number') {
return this.encodeNumber(input);
}
return this.encodeBytes(input);
}
/**
* Converts a number to a base-N string using the provided dictionary.
* Rounds the number first; prefixes '-' if negative.
*/
encodeNumber(num: number): string {
if (!Number.isFinite(num)) {
throw new Error('Input must be a finite number.');
}
const negative = num < 0;
num = Math.round(Math.abs(num));
if (num === 0) {
return this.dictionary[0];
}
let encoded = '';
while (num > 0) {
encoded = this.dictionary[num % this.base] + encoded;
num = Math.floor(num / this.base);
}
return negative ? '-' + encoded : encoded;
}
/**
* Asynchronously encodes binary input (string, bytes, or Blob) using base-N byte-level logic.
*/
encodeBytes(input: string | Uint8Array | ArrayBuffer): string {
let bytes: Uint8Array;
if (typeof input === 'string') {
bytes = new TextEncoder().encode(input);
} else if (input instanceof Uint8Array) {
bytes = input;
} else if (input instanceof ArrayBuffer) {
bytes = new Uint8Array(input);
} else if (Array.isArray(input)) {
bytes = new Uint8Array(input);
} else {
throw new Error('Unsupported input type for encode');
}
// Count leading zeros
let zeroCount = 0;
while (zeroCount < bytes.length && bytes[zeroCount] === 0) zeroCount++;
const digits: string[] = [];
let inputArray = Array.from(bytes);
while (inputArray.length > 0 && !(inputArray.length === 1 && inputArray[0] === 0)) {
const newInput: number[] = [];
let remainder = 0;
for (const byte of inputArray) {
const acc = (remainder << 8) + byte;
const digit = Math.floor(acc / this.base);
remainder = acc % this.base;
if (newInput.length > 0 || digit !== 0) newInput.push(digit);
}
digits.push(this.dictionary[remainder]);
inputArray = newInput;
}
for (let i = 0; i < zeroCount; i++) digits.push(this.dictionary[0]);
return digits.reverse().join('');
}
/**
* Decodes a base-N string back to a number. Handles optional '-' prefix.
*/
decodeNumber(str: string): number {
if (typeof str !== 'string' || str.length === 0) {
throw new Error('Input must be a non-empty string.');
}
const negative = str[0] === '-';
if (negative) str = str.slice(1);
let num = 0;
if (new Set(str).isSubsetOf(this.dictionarySet) === false) {
throw new Error('Input contains invalid characters.');
}
for (let i = 0; i < str.length; i++) {
const val = this.dictionary.indexOf(str[i]);
num = num * this.base + val;
}
return negative ? -num : num;
}
/**
* Decodes a string or binary representation back to a Uint8Array.
*/
decodeBytes(input: string): Uint8Array {
if (input.length === 0) return new Uint8Array();
let zeroCount = 0;
while (zeroCount < input.length && input[zeroCount] === this.dictionary[0]) zeroCount++;
const charToValue: Record<string, number> = Object.fromEntries(this.dictionary.map((c, i) => [c, i]));
const bytes: number[] = [];
let inputArray = Array.from(input, (c) => {
const v = charToValue[c];
if (v === undefined) throw new Error(`Invalid character: ${c}`);
return v;
});
while (inputArray.length > 0 && !(inputArray.length === 1 && inputArray[0] === 0)) {
const newInput: number[] = [];
let remainder = 0;
for (const digit of inputArray) {
const acc = remainder * this.base + digit;
const byte = Math.floor(acc / 256);
remainder = acc % 256;
if (newInput.length > 0 || byte !== 0) newInput.push(byte);
}
bytes.push(remainder);
inputArray = newInput;
}
for (let i = 0; i < zeroCount; i++) bytes.push(0);
return new Uint8Array(bytes.reverse());
}
}
const dictionaries: Record<string, BaseCodec> = {};
export const Base64Dictionary = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export const InvisibleDictionary = '\u200B\u200C\u200D\uFEFF';
export function baseCodecFrom(dictionary: string): BaseCodec {
return dictionaries[dictionary] || (dictionaries[dictionary] = new BaseCodec(dictionary));
}
namespace BaseCodec {
/**
* Converts a number to a Base64 string.
* Rounds the number first; prefixes '-' if negative.
* @param {number} num - The number to convert.
* @returns {string} The Base64-encoded representation.
*/
export const encode = (num: number) => baseCodecFrom(Base64Dictionary).encodeNumber(num);
/**
* Decodes a Base64 string back to a number.
* Handles optional '-' prefix.
* @param {string} str - The Base64-encoded string.
* @returns {number} The decoded number.
*/
export const decode = (str: string) => baseCodecFrom(Base64Dictionary).decodeNumber(str);
}
export default BaseCodec;

26
js/doTimed.ts Normal file
View file

@ -0,0 +1,26 @@
import { InvisibleDictionary, baseCodecFrom } from './BaseCodec';
import { v7 } from 'uuid';
export const InvisibleCodec = baseCodecFrom(InvisibleDictionary);
export function doTimed<T>(message: string, callback: () => T): T {
const hiddenId = InvisibleCodec.encode(v7());
console.time(message + hiddenId);
try {
const output = callback();
return output;
} finally {
console.timeEnd(message + hiddenId);
}
}
export async function doTimedAsync<T>(message: string, callback: () => T): Promise<Awaited<T>> {
const hiddenId = InvisibleCodec.encode(v7());
console.time(message + hiddenId);
try {
const output = await callback();
return output;
} finally {
console.timeEnd(message + hiddenId);
}
}

View file

@ -1,6 +1,6 @@
import { getCoverBlob, getTrackTitle } from './utils.js';
import { initTagLib } from './taglib.js';
import { PICTURE_TYPE_VALUES } from 'taglib-wasm';
import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
import { doTimed, doTimedAsync } from './doTimed.ts';
import { managers } from './app.js';
const VENDOR_STRING = 'Monochrome';
@ -43,7 +43,7 @@ function getFullArtistString(track) {
}
export function prefetchMetadataObjects(track, api) {
const _tagLib = initTagLib().catch(console.error);
const _tagLib = fetchTagLib().catch(console.error);
const coverFetch = track?.album?.cover
? getCoverBlob(api, track.album.cover).catch(console.error)
: Promise.resolve(null);
@ -61,109 +61,56 @@ export function prefetchMetadataObjects(track, api) {
* @returns {Promise<Blob>} - Audio blob with embedded metadata
*/
export async function addMetadataToAudio(audioBlob, track, api, _quality, prefetchPromises) {
const { _tagLib, coverFetch, lyricsFetch } = prefetchPromises;
const { coverFetch, lyricsFetch } = prefetchPromises;
console.time('Get audio array buffer');
const audioBuffer = await audioBlob.arrayBuffer();
console.timeEnd('Get audio array buffer');
/**
* @type {import("./taglib.worker.ts").TagLibMetadata}
*/
const data = {};
console.time('Open file with taglib');
const tagLib = await _tagLib;
const file = await tagLib.open(audioBuffer);
console.timeEnd('Open file with taglib');
const audioBuffer = await doTimedAsync('Get audio array buffer', () => audioBlob.arrayBuffer());
console.time('Tagging file');
try {
const isMp4 = file.isMP4();
const discNumber = track.volumeNumber ?? track.discNumber;
// Add standard tags
if (track.title) {
file.setProperty('TITLE', getTrackTitle(track));
}
const artistStr = getFullArtistString(track);
if (artistStr) {
file.setProperty('ARTIST', artistStr);
}
if (track.album?.title) {
file.setProperty('ALBUM', track.album.title);
}
const albumArtist = track.album?.artist?.name || track.artist?.name;
if (albumArtist) {
file.setProperty('ALBUMARTIST', albumArtist);
}
if (track.trackNumber) {
let trackString = String(track.trackNumber);
if (isMp4 && track.trackNumber && track.album?.numberOfTracks) {
trackString = `${track.trackNumber}/${track.album.numberOfTracks}`;
}
if (isMp4) {
file.setProperty('TRACKNUMBER', trackString);
} else {
file.setProperty('TRACKNUMBER', String(track.trackNumber));
}
}
if (!isMp4 && track.album?.numberOfTracks) {
file.setProperty('TRACKTOTAL', String(track.album.numberOfTracks));
}
if (discNumber) {
file.setProperty('DISCNUMBER', String(discNumber));
}
data.title = getTrackTitle(track);
data.artist = getFullArtistString(track);
data.albumTitle = track.album.title;
data.albumArtist = track.album?.artist?.name || track.artist?.name;
data.trackNumber = track.trackNumber;
data.discNumber = track.volumeNumber ?? track.discNumber;
data.totalTracks = track.album.numberOfTracks;
data.copyright = track.copyright;
data.isrc = track.isrc;
data.explicit = Boolean(track.explicit);
if (track.bpm != null) {
const bpm = Number(track.bpm);
if (Number.isFinite(bpm)) {
file.setProperty('BPM', String(Math.round(bpm)));
data.bpm = Math.round(bpm);
}
}
if (track.replayGain) {
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.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));
data.replayGain = {
albumReplayGain: `${Number(albumReplayGain)} dB`,
trackReplayGain: `${Number(trackReplayGain)} dB`,
albumPeakAmplitude: albumPeakAmplitude ? Number(albumPeakAmplitude) : undefined,
trackPeakAmplitude: trackPeakAmplitude ? Number(trackPeakAmplitude) : undefined,
};
}
const releaseDateStr =
track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
track.album?.releaseDate?.trim() || track?.streamStartDate?.split('T')?.[0]?.trim() || undefined;
if (releaseDateStr) {
try {
const year = new Date(releaseDateStr).getFullYear();
const year = Number(releaseDateStr.split('-')[0]);
if (!isNaN(year)) {
file.setProperty('DATE', String(year));
data.releaseDate = String(releaseDateStr);
}
} catch {
// Invalid date, skip
}
}
if (track.copyright) {
file.setProperty('COPYRIGHT', track.copyright);
}
if (track.isrc) {
file.setProperty('ISRC', track.isrc);
if (isMp4) {
file.setMP4Item('xid ', `:isrc:${track.isrc}`);
}
}
if (track.explicit) {
if (isMp4) {
file.setMP4Item('rtng', '1');
} else {
file.setProperty('ITUNESADVISORY', '1');
console.warn('Invalid date', releaseDateStr);
}
}
@ -173,14 +120,10 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
const coverBuffer = new Uint8Array(await coverBlob.arrayBuffer());
if (coverBlob) {
file.setPictures([
{
mimeType: coverBlob.type,
data: coverBuffer,
type: PICTURE_TYPE_VALUES.FrontCover,
description: 'Cover Art',
},
]);
data.cover = {
data: coverBuffer,
type: getMimeType(coverBuffer),
};
}
}
} catch (e) {
@ -189,35 +132,24 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
try {
const lyrics = await lyricsFetch;
const lyricsString = lyrics?.subtitles || lyrics?.plainLyrics;
if (lyricsString) {
//if (isMp4) {
// file.setMP4Item('@lyr', String(lyricsString));
//} else {
file.setProperty('LYRICS', String(lyricsString).replace(/\r/g, '').replace(/\n/g, '\r\n'));
//}
}
data.lyrics = lyrics?.subtitles || lyrics?.plainLyrics;
} catch (e) {
console.warn('Error setting lyrics metadata', track, e);
}
console.timeEnd('Tagging file');
const newAudioBuffer = await addMetadataWithTagLib(audioBuffer, {
...data,
});
console.time('Saving in-memory buffer');
await file.save();
console.timeEnd('Saving in-memory buffer');
console.time('Saving blob');
const blob = new Blob([file.getFileBuffer()], { type: audioBlob.type, name: audioBlob.name });
console.timeEnd('Saving blob');
return blob;
return doTimed(
'Create new audio blob',
() =>
new Blob([newAudioBuffer], {
type: audioBlob.type,
})
);
} catch (err) {
console.error(err);
} finally {
// Always dispose, even if there was an error.
file.dispose();
}
return audioBlob;
@ -237,18 +169,36 @@ export async function readTrackMetadata(file, siblings = []) {
duration: 0,
isrc: null,
copyright: null,
explicit: false,
isLocal: true,
file: file,
id: `local-${file.name}-${file.lastModified}`,
};
try {
if (file.type === 'audio/flac' || file.name.endsWith('.flac')) {
await readFlacMetadata(file, metadata);
} else if (file.type === 'audio/mp4' || file.name.endsWith('.m4a')) {
await readM4aMetadata(file, metadata);
} else if (file.type === 'audio/mpeg' || file.name.endsWith('.mp3')) {
await readMp3Metadata(file, metadata);
const data = await getMetadataWithTagLib(await file.arrayBuffer());
if (data) {
metadata.title = data.title || metadata.title;
metadata.artists.push(
...(data.artist || '')
.split(';')
.map((a) => a.trim())
.filter((a) => a)
);
metadata.artist = data.artist || metadata.artist;
metadata.album.title = data.albumTitle || metadata.album.title;
metadata.album.releaseDate = data.releaseDate || metadata.album.releaseDate;
if (data.cover) {
const blob = new Blob([data.cover.data], { type: data.cover.type });
metadata.album.cover = URL.createObjectURL(blob);
}
metadata.duration = data.duration;
metadata.isrc = data.isrc || metadata.isrc;
metadata.copyright = data.copyright || metadata.copyright;
metadata.explicit = !!data.explicit;
}
} catch (e) {
console.warn('Error reading metadata for', file.name, e);

View file

@ -1,19 +1,68 @@
import { TagLib } from 'taglib-wasm';
import { fetchBlobURL } from './utils';
import _TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?url';
import type {
TagLibWorkerMessageType,
AddMetadataMessage,
GetMetadataMessage,
TagLibFileResponse,
TagLibMetadataResponse,
TagLibMetadata,
TagLibReadMetadata,
} from './taglib.worker';
import TagLibWorker from './taglib.worker.ts?url';
let tagLib: Promise<TagLib> | null = null;
export async function initTagLib(): Promise<TagLib> {
if (tagLib) return await tagLib;
const TagLibWasm = await fetchBlobURL(_TagLibWasm);
tagLib = TagLib.initialize({
wasmUrl: TagLibWasm,
});
console.log('TagLib initialized', { tagLib: await tagLib, TagLibWasm });
return await tagLib;
async function fetchTagLib(): Promise<string> {
return fetchTagLib.blobUrl || (fetchTagLib.blobUrl = await fetchBlobURL(_TagLibWasm));
}
namespace fetchTagLib {
export let blobUrl = '';
}
export { fetchTagLib };
export async function addMetadataWithTagLib(
audioData: Uint8Array,
data: Omit<AddMetadataMessage, 'type' | 'wasmUrl' | 'audioData'>
) {
const worker = new Worker(new URL(TagLibWorker, import.meta.url), { type: 'module' });
const wasmUrl = await fetchTagLib();
return new Promise<Uint8Array>((resolve, reject) => {
worker.onmessage = (e: MessageEvent<TagLibFileResponse>) => {
const { data, error } = e.data;
if (error) {
reject(new Error(error));
} else {
resolve(data!);
}
};
worker.onerror = reject;
worker.onmessageerror = reject;
worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData });
});
}
export async function getMetadataWithTagLib(audioData: Uint8Array) {
const worker = new Worker(new URL(TagLibWorker, import.meta.url), { type: 'module' });
const wasmUrl = await fetchTagLib();
return new Promise<TagLibReadMetadata>((resolve, reject) => {
worker.onmessage = (e: MessageEvent<TagLibMetadataResponse>) => {
const { data, error } = e.data;
if (error) {
reject(new Error(error));
} else {
resolve(data!);
}
};
worker.onerror = reject;
worker.onmessageerror = reject;
worker.postMessage({ type: 'Get', wasmUrl, audioData });
});
}

334
js/taglib.worker.ts Normal file
View file

@ -0,0 +1,334 @@
// filepath: /workspaces/monochrome/js/taglib.worker.ts
import { TagLib, type PictureType } from 'taglib-wasm';
import { doTimed, doTimedAsync } from './doTimed';
const PICTURE_TYPE_VALUES = {
FrontCover: 3,
};
export type TagLibWorkerMessageType = 'Add' | 'Get';
export interface TagLibWorkerMessage {
type: TagLibWorkerMessageType;
wasmUrl: string;
audioData: Uint8Array;
}
interface TagLibWorkerResponse<T> {
type: TagLibWorkerMessageType;
data?: T;
error?: string;
}
export interface TagLibMetadata {
title?: string;
artist?: string;
albumTitle?: string;
albumArtist?: string;
trackNumber?: number;
totalTracks?: number;
discNumber?: number;
totalDiscs?: number;
bpm?: number;
replayGain?: {
albumReplayGain?: string;
albumPeakAmplitude?: number;
trackReplayGain?: string;
trackPeakAmplitude?: number;
};
cover?: {
data: Uint8Array;
type: string;
};
releaseDate?: string;
copyright?: string;
isrc?: string;
explicit?: boolean;
lyrics?: string;
}
export interface TagLibReadMetadata extends TagLibMetadata {
duration: number;
}
export type TagLibFileResponse = TagLibWorkerResponse<Uint8Array>;
export type TagLibMetadataResponse = TagLibWorkerResponse<TagLibReadMetadata>;
export type AddMetadataMessage = TagLibWorkerMessage & {
type: 'Add';
} & TagLibMetadata;
export type GetMetadataMessage = TagLibWorkerMessage & {
type: 'Get';
};
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();
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 (isMp4 && trackNumber && totalTracks) {
trackString = `${trackNumber}/${totalTracks}`;
}
if (isMp4) {
file.setProperty('TRACKNUMBER', trackString);
} else {
file.setProperty('TRACKNUMBER', String(trackNumber));
}
}
if (!isMp4 && totalTracks) {
file.setProperty('TRACKTOTAL', String(totalTracks));
}
if (discNumber) {
let discString = String(discNumber);
if (isMp4 && discNumber && totalDiscs) {
discString = `${discNumber}/${totalDiscs}`;
}
if (isMp4) {
file.setProperty('DISCNUMBER', discString);
} else {
file.setProperty('DISCNUMBER', String(discNumber));
}
}
if (!isMp4 && 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>) => {
switch (event.data.type) {
case 'Add':
try {
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
self.postMessage({
type: event.data.type,
data: result,
} satisfies TagLibFileResponse);
} catch (error) {
self.postMessage({
type: event.data.type,
error: error instanceof Error ? error.message : String(error),
} satisfies TagLibWorkerResponse<undefined>);
}
break;
case 'Get':
try {
const result = await getMetadataFromAudio(event.data as GetMetadataMessage);
self.postMessage({
type: event.data.type,
data: result,
} satisfies TagLibMetadataResponse);
} catch (error) {
self.postMessage({
type: event.data.type,
error: error instanceof Error ? error.message : String(error),
} satisfies TagLibWorkerResponse<undefined>);
}
break;
}
};

View file

@ -64,6 +64,7 @@
"jose": "^6.2.0",
"npm": "^11.11.0",
"pocketbase": "^0.26.8",
"taglib-wasm": "^1.0.5"
"taglib-wasm": "^1.0.5",
"uuid": "^13.0.0"
}
}

View file

@ -3,7 +3,7 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {