feat(metadata): replace taglib-wasm with @dantheman827/taglib-ts

- feat(taglib): updated audio buffer handling in metadata.js to use Uint8Array.
- feat(taglib): refactored addMetadataToAudio to support return type as Blob or Uint8Array

- feat(taglib): add timeout functionality to metadata functions
  - Introduced `withTimeout` utility function to handle operation timeouts.
  - Updated `addMetadataWithTagLib` to use `withTimeout` for promise resolution.
  - Updated `getMetadataWithTagLib` to use `withTimeout` for promise resolution.
  - Added default timeout parameter to both metadata functions.

- feat(taglib): improve metadata handling with ChunkedByteVectorStream
  - Enhanced metadata handling in taglib.ts and taglib.worker.ts to utilize ChunkedByteVectorStream.

- fix(taglib): handle metadata addition failure gracefully
  - Updated `addMetadataWithTagLib` to catch errors and return original audio data if metadata addition fails.

fix(downloads): return original blob if metadata addition fails
 - Wrap addMetadataToAudio call in try-catch to handle errors.

feat(taglib): add direct calling of taglib methods
  - Introduced `direct` parameter to `addMetadataWithTagLib` and `getMetadataWithTagLib` functions for direct processing in the current thread.
  - Exported taglib worker functions.
This commit is contained in:
Daniel 2026-03-19 13:57:54 -05:00
parent 1ba27308d1
commit 895d5dd20f
11 changed files with 513 additions and 386 deletions

0
.gitmodules vendored Normal file
View file

View file

@ -5,6 +5,7 @@
"": {
"name": "monochrome",
"dependencies": {
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
@ -23,7 +24,6 @@
"mime": "^4.1.0",
"npm": "^11.11.1",
"pocketbase": "^0.26.8",
"taglib-wasm": "^1.0.5",
"uuid": "^13.0.0",
},
"devDependencies": {
@ -260,6 +260,8 @@
"@csstools/selector-specificity": ["@csstools/selector-specificity@5.0.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw=="],
"@dantheman827/taglib-ts": ["@dantheman827/taglib-ts@https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz", {}, "sha512-rvQOn9GDEj2sH4yV6oUTMMG9+rJbFG7tQkiP6/bhGJARg1Vmdy283j4YFCl+ubkqsMQ+UfAhEWSw5d5lfPVfwQ=="],
"@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@ -426,8 +428,6 @@
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
"@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="],
"@neutralinojs/lib": ["@neutralinojs/lib@6.5.0", "", { "optionalDependencies": { "@rollup/rollup-darwin-x64": "*", "@rollup/rollup-linux-x64-gnu": "*" } }, "sha512-ECgYh+CXAfMR1JVTvDw/kHhjL6LzNNcjk8Va1DZUSBkUwROqFTQ7zseFeuFtwGvutqvlWiwpGmU3s11rg/bdvA=="],
"@neutralinojs/neu": ["@neutralinojs/neu@11.7.0", "", { "dependencies": { "@electron/asar": "^3.0.3", "chalk": "^4.1.0", "chokidar": "^4.0.3", "commander": "^7.2.0", "configstore": "^5.0.1", "edit-json-file": "^1.6.2", "follow-redirects": "^1.13.1", "fs-extra": "^9.0.1", "pe-library": "^1.0.1", "png2icons": "^2.0.1", "postject": "1.0.0-alpha.6", "recursive-readdir": "^2.2.2", "resedit": "^2.0.2", "spawn-command": "^1.0.0", "tcp-port-used": "^1.0.2", "uuid": "^8.3.2", "websocket": "^1.0.35", "zip-lib": "^1.0.4" }, "bin": { "neu": "bin/neu.js" } }, "sha512-fUqvR70a+BpKI9mrD92ldZkVC24Rs8XL/9m7zmOCLgCRys3yuWy7vEsxpHzKMzqTiQJkTYIsLmcR8VMzNIjuSw=="],
@ -1312,8 +1312,6 @@
"table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="],
"taglib-wasm": ["taglib-wasm@1.0.5", "", { "dependencies": { "@msgpack/msgpack": "^3.1.3" }, "peerDependencies": { "typescript": ">=4.5.0" }, "optionalPeers": ["typescript"] }, "sha512-kuDHX78FbjLOqldWxBBkEgjyyDagYRGcYqr4g6ObkmEMO203Sp1R0KxkELoH9VkZxsOVR8yoSWaSaG9f5oTqyQ=="],
"tcp-port-used": ["tcp-port-used@1.0.2", "", { "dependencies": { "debug": "4.3.1", "is2": "^2.0.6" } }, "sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA=="],
"temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],

View file

@ -12,7 +12,6 @@ import {
} from './utils.js';
import { trackDateSettings } from './storage.js';
import { APICache } from './cache.js';
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.ts';
import { HlsDownloader } from './hls-downloader.js';
import { MP3EncodingError } from './mp3-encoder.js';
@ -1320,6 +1319,8 @@ export class LosslessAPI {
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
// Load ffmpeg in the background.
loadFfmpeg().catch(console.error);
const metadataModule = await import('./metadata.js');
const { prefetchMetadataObjects, addMetadataToAudio } = metadataModule;
const { onProgress, track, calculateDashBytes = true } = options;
const prefetchPromises = prefetchMetadataObjects(track, this);
@ -1504,7 +1505,11 @@ export class LosslessAPI {
}
onProgress?.(new DownloadProgress('Adding metadata'));
try {
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
} catch (err) {
console.error(err);
}
}
}

View file

@ -15,7 +15,6 @@ import {
import { AbortError } from './errorTypes.ts';
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
import { triggerDownload } from './download-utils.ts';
import {
ZipStreamWriter,
ZipBlobWriter,

View file

@ -1,13 +1,5 @@
import {
getCoverBlob,
getTrackTitle,
getFullArtistString,
getMimeType,
getTrackCoverId,
getTrackDiscNumber,
getExtensionFromBlob,
} from './utils.js';
import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js';
import { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
import { doTimed, doTimedAsync } from './doTimed.ts';
import { managers } from './app.js';
@ -19,7 +11,6 @@ export const METADATA_STRINGS = {
};
export function prefetchMetadataObjects(track, api, coverBlob = null) {
const _tagLib = fetchTagLib().catch(console.error);
const coverId = getTrackCoverId(track);
const coverFetch = coverBlob
? Promise.resolve(coverBlob)
@ -28,7 +19,7 @@ export function prefetchMetadataObjects(track, api, coverBlob = null) {
: Promise.resolve(null);
const lyricsFetch = managers?.lyricsManager?.fetchLyrics?.(track.id, track)?.catch(console.error);
return { _tagLib, coverFetch, lyricsFetch };
return { coverFetch, lyricsFetch };
}
/**
@ -47,8 +38,6 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
*/
const data = {};
const audioBuffer = await doTimedAsync('Get audio array buffer', () => audioBlob.arrayBuffer());
try {
data.title = getTrackTitle(track);
data.artist = getFullArtistString(track);
@ -117,16 +106,14 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
console.warn('Error setting lyrics metadata', track, e);
}
const newAudioBuffer = await addMetadataWithTagLib(audioBuffer, {
return await addMetadataWithTagLib(
audioBlob,
{
...data,
});
return doTimed(
'Create new audio blob',
() =>
new Blob([newAudioBuffer], {
type: audioBlob.type,
})
},
undefined,
true,
true
);
} catch (err) {
console.error(err);
@ -137,12 +124,12 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
/**
* Reads metadata from a file
* @param {File} file
* @param {Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry} file
* @returns {Promise<Object>} Track metadata
*/
export async function readTrackMetadata(file, siblings = []) {
export async function readTrackMetadata(file, { filename = file?.name || 'Unknown Title', siblings } = {}) {
const metadata = {
title: file.name.replace(/\.[^/.]+$/, ''),
title: filename?.replace(/\.[^/.]+$/, ''),
artists: [],
artist: { name: 'Unknown Artist' }, // For fallback/compatibility
album: { title: 'Unknown Album', cover: 'assets/appicon.png', releaseDate: null },
@ -152,16 +139,16 @@ export async function readTrackMetadata(file, siblings = []) {
explicit: false,
isLocal: true,
file: file,
id: `local-${file.name}-${file.lastModified}`,
id: `local-${filename}-${file.lastModified}`,
};
try {
const data = await getMetadataWithTagLib(await file.arrayBuffer());
const data = await getMetadataWithTagLib(file, filename, true);
if (data) {
metadata.title = data.title || metadata.title;
const artistNames = (data.artist || "")
.split(";")
const artistNames = (data.artist || '')
.split(';')
.map((a) => a.trim())
.filter((a) => a);
@ -175,7 +162,7 @@ export async function readTrackMetadata(file, siblings = []) {
if (data.albumArtist) {
metadata.album.artist = { name: data.albumArtist };
} else if (metadata.artist.name !== "Unknown Artist") {
} else if (metadata.artist.name !== 'Unknown Artist') {
metadata.album.artist = { name: metadata.artist.name };
}
@ -190,11 +177,11 @@ export async function readTrackMetadata(file, siblings = []) {
metadata.explicit = !!data.explicit;
}
} catch (e) {
console.warn('Error reading metadata for', file.name, e);
console.warn('Error reading metadata for', filename, e);
}
if (metadata.album.cover === 'assets/appicon.png' && siblings.length > 0) {
const baseName = file.name.substring(0, file.name.lastIndexOf('.'));
const baseName = filename.substring(0, filename.lastIndexOf('.'));
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
const coverFile = siblings.find((f) => {
const fName = f.name;

View file

@ -1,38 +1,113 @@
import { TagLib } from 'taglib-wasm';
import { fetchBlobURL } from './utils';
import _TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?blob-url';
import { doTimed, doTimedAsync } from './doTimed';
import type {
AddMetadataMessage,
TagLibFileResponse,
TagLibMetadataResponse,
TagLibReadMetadata,
TagLibReadTypes,
TagLibWriteTypes,
} from './taglib.types';
import TagLibWorker from './taglib.worker?worker';
let tagLib: Promise<TagLib> | null = null;
export async function withTimeout<T>(callback: () => Promise<T>, timeout: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Operation timed out after ${timeout} ms`));
}, timeout);
async function fetchTagLib(): Promise<string> {
return fetchTagLib.blobUrl || (fetchTagLib.blobUrl = await _TagLibWasm());
callback()
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
namespace fetchTagLib {
export let blobUrl = '';
}
export { fetchTagLib };
export async function addMetadataWithTagLib(
audioData: Uint8Array,
data: Omit<AddMetadataMessage, 'type' | 'wasmUrl' | 'audioData'>
) {
if (!(audioData instanceof Uint8Array)) {
audioData = new Uint8Array(audioData);
function toUint8Array(audioData: ArrayBufferLike | Uint8Array) {
if (audioData instanceof Uint8Array) {
return audioData;
}
const worker = new TagLibWorker();
const wasmUrl = await fetchTagLib();
return doTimed(
`Converting audio data (${(audioData as any)?.constructor?.name}) to Uint8Array`,
() => new Uint8Array(audioData)
);
}
return new Promise<Uint8Array>((resolve, reject) => {
async function convertInputToTaglib<R = TagLibReadTypes>(
audioData: TagLibReadTypes | TagLibWriteTypes,
direct: boolean = false
): Promise<R> {
if ('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) {
audioData = await doTimedAsync('Getting File from FileSystemFileEntry', async () => {
const file = await new Promise<File>((resolve) =>
(audioData as FileSystemFileEntry).file((f) => resolve(f))
);
return toUint8Array(new Uint8Array(await file.arrayBuffer()));
});
}
if ((audioData instanceof Blob || audioData instanceof File) && !direct) {
return (await doTimedAsync(
`Reading ${audioData instanceof File ? 'File' : 'Blob'} as Uint8Array`,
async () => new Uint8Array(await audioData.arrayBuffer())
)) as R;
} else if ('FileSystemFileHandle' in globalThis && audioData instanceof FileSystemFileHandle && !direct) {
return (await doTimedAsync('Reading File from FileSystemHandle as Uint8Array', async () => {
const file = await audioData.getFile();
const arrayBuffer = await file.arrayBuffer();
return await toUint8Array(arrayBuffer);
})) as R;
} else if (
!(audioData instanceof Uint8Array) &&
!(audioData instanceof Blob) &&
!(audioData instanceof File) &&
!('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) &&
!('FileSystemFileHandle' in globalThis && audioData instanceof FileSystemFileHandle)
) {
return toUint8Array(audioData as any) as R;
}
return audioData as R;
}
const workerModule = import('./taglib.worker.js');
export async function addMetadataWithTagLib(
audioData: TagLibWriteTypes,
data: Omit<AddMetadataMessage, 'type' | 'audioData'>,
filename?: string,
direct: boolean = false,
returnBlob: boolean = false,
timeout: number = 10000
) {
audioData = await convertInputToTaglib(audioData, direct);
if (direct) {
const { addMetadataToAudio } = await workerModule;
return await doTimedAsync('Adding metadata with taglib-ts (direct)', () =>
addMetadataToAudio({
...data,
filename,
audioData,
returnType: returnBlob && direct ? 'blob' : 'uint8array',
})
);
} else {
const worker = new TagLibWorker();
try {
return await doTimedAsync(
'Adding metadata with taglib-ts (worker)',
async () =>
await withTimeout(
() =>
new Promise<Uint8Array>((resolve, reject) => {
worker.onmessage = (e: MessageEvent<TagLibFileResponse>) => {
const { data, error } = e.data;
@ -45,24 +120,48 @@ export async function addMetadataWithTagLib(
worker.onerror = reject;
worker.onmessageerror = reject;
const transferables: Transferable[] = [audioData.buffer];
const transferables: Transferable[] = [];
if ((audioData as any)?.buffer instanceof ArrayBuffer) {
transferables.push((audioData as any).buffer);
}
if ((data as any).cover?.data?.buffer instanceof ArrayBuffer) {
transferables.push((data as any).cover.data.buffer);
}
worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, transferables);
});
worker.postMessage({ ...data, type: 'Add', audioData, filename }, transferables);
}),
timeout
)
);
} finally {
worker.terminate();
}
}
}
export async function getMetadataWithTagLib(audioData: Uint8Array) {
if (!(audioData instanceof Uint8Array)) {
audioData = new Uint8Array(audioData);
}
export async function getMetadataWithTagLib(
audioData: TagLibReadTypes,
filename?: string,
direct: boolean = false,
timeout: number = 10000
) {
audioData = await convertInputToTaglib<TagLibReadTypes>(audioData, direct);
if (direct) {
const { getMetadataFromAudio } = await workerModule;
return await doTimedAsync('Getting metadata with taglib-ts (direct)', () =>
getMetadataFromAudio({ filename, audioData })
);
} else {
const worker = new TagLibWorker();
const wasmUrl = await fetchTagLib();
return new Promise<TagLibReadMetadata>((resolve, reject) => {
try {
return await doTimedAsync('Getting metadata with taglib-ts (worker)', () =>
withTimeout(
() =>
new Promise<TagLibReadMetadata>((resolve, reject) => {
worker.onmessage = (e: MessageEvent<TagLibMetadataResponse>) => {
const { data, error } = e.data;
@ -74,6 +173,18 @@ export async function getMetadataWithTagLib(audioData: Uint8Array) {
};
worker.onerror = reject;
worker.onmessageerror = reject;
worker.postMessage({ type: 'Get', wasmUrl, audioData }, [audioData.buffer]);
});
const transferables: Transferable[] = [];
if ((audioData as any)?.buffer instanceof ArrayBuffer) {
transferables.push((audioData as any).buffer);
}
worker.postMessage({ type: 'Get', audioData, filename }, transferables);
}),
timeout
)
);
} finally {
worker.terminate();
}
}
}

View file

@ -1,9 +1,11 @@
import type { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef';
export type TagLibWorkerMessageType = 'Add' | 'Get';
export interface TagLibWorkerMessage {
export interface TagLibWorkerMessage<T = Uint8Array> {
type: TagLibWorkerMessageType;
wasmUrl: string;
audioData: Uint8Array;
audioData: T;
filename?: string;
}
export interface TagLibWorkerResponse<T> {
@ -50,6 +52,19 @@ export type AddMetadataMessage = TagLibWorkerMessage & {
type: 'Add';
} & TagLibMetadata;
export type GetMetadataMessage = TagLibWorkerMessage & {
export type GetMetadataMessage = TagLibWorkerMessage<TagLibReadTypes> & {
type: 'Get';
};
export type TagLibReadTypes = Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry;
export type TagLibWriteTypes = Uint8Array;
export type _AddMetadataMessage = Omit<AddMetadataMessage, 'audioData' | 'type'> & {
audioRef?: FileRef | null;
audioData?: Uint8Array;
returnType?: 'blob' | 'uint8array';
};
export type _GetMetadataMessage = Omit<GetMetadataMessage, 'audioData' | 'type'> & {
audioRef?: FileRef | null;
audioData?: TagLibReadTypes;
};

View file

@ -1,9 +1,13 @@
// filepath: /workspaces/monochrome/js/taglib.worker.ts
declare var self: DedicatedWorkerGlobalScope;
import { TagLib, type PictureType } from 'taglib-wasm';
import { ByteVector } from '!/@dantheman827/taglib-ts/src/byteVector.js';
import { Mp4Tag, Mp4Item } from '!/@dantheman827/taglib-ts/src/mp4/mp4Tag.js';
import { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js';
import { doTimed, doTimedAsync } from './doTimed';
import type {
_AddMetadataMessage,
_GetMetadataMessage,
AddMetadataMessage,
GetMetadataMessage,
TagLibFileResponse,
@ -13,15 +17,27 @@ import type {
TagLibWorkerMessage,
TagLibWorkerResponse,
} from './taglib.types';
import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js';
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
import { ChunkedByteVectorStream } from '!/@dantheman827/taglib-ts/src/toolkit/chunkedByteVectorStream.js';
import { ReadStyle } from '!/@dantheman827/taglib-ts/src/toolkit/types';
import { BlobStream } from '!/@dantheman827/taglib-ts/src/toolkit/blobStream.js';
import { FileSystemFileHandleStream } from '!/@dantheman827/taglib-ts/src/toolkit/fileSystemFileHandleStream.js';
const PICTURE_TYPE_VALUES = {
FrontCover: 3,
};
// Imported to ensure support is bundled in this chunk, even if not directly used
import { FlacFile } from '!/@dantheman827/taglib-ts/src/flac/flacFile.js';
import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js';
import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js';
import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js';
import { OggVorbisFile } from '!/@dantheman827/taglib-ts/src/ogg/vorbis/vorbisFile.js';
async function addMetadataToAudio(message: AddMetadataMessage): Promise<Uint8Array> {
export const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<Uint8Array | Blob> {
const {
wasmUrl,
audioData,
audioRef,
filename,
title,
artist,
albumTitle,
@ -38,226 +54,233 @@ async function addMetadataToAudio(message: AddMetadataMessage): Promise<Uint8Arr
isrc,
explicit,
lyrics,
returnType = 'uint8array',
} = message;
const file = await doTimedAsync('Open file with taglib', async () => {
const tagLib = await TagLib.initialize({
wasmUrl: wasmUrl,
});
return await tagLib.open(audioData);
});
const ref =
audioRef ??
(await doTimedAsync(
`Opening file (${audioData.constructor.name})`,
async () => await getFileRefFromAudioData(audioData)
));
if (!ref || !ref.isValid) {
console.warn('taglib-ts: failed to open file');
return audioData;
}
const underlying = ref.file();
const isMp4 = underlying instanceof Mp4File;
const isMpeg = underlying instanceof MpegFile;
const needsCombinedTrackDisc = isMp4 || isMpeg;
try {
doTimed('Tagging file', () => {
const isMp4 = file.isMP4();
const media = file.audioProperties();
const needsCombinedTrackDisc = isMp4 || media.containerFormat.toLowerCase() === 'mp3';
const props = ref.properties();
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 (title) props.replace('TITLE', [title]);
if (artist) props.replace('ARTIST', [artist]);
if (albumTitle) props.replace('ALBUM', [albumTitle]);
if (albumArtist || artist) props.replace('ALBUMARTIST', [albumArtist || artist!]);
if (trackNumber) {
let trackString = String(trackNumber);
if (needsCombinedTrackDisc && trackNumber && totalTracks) {
trackString = `${trackNumber}/${totalTracks}`;
const trackStr =
needsCombinedTrackDisc && totalTracks ? `${trackNumber}/${totalTracks}` : String(trackNumber);
props.replace('TRACKNUMBER', [trackStr]);
}
if (needsCombinedTrackDisc) {
file.setProperty('TRACKNUMBER', trackString);
} else {
file.setProperty('TRACKNUMBER', String(trackNumber));
}
}
if (!needsCombinedTrackDisc && totalTracks) {
file.setProperty('TRACKTOTAL', String(totalTracks));
props.replace('TRACKTOTAL', [String(totalTracks)]);
}
if (discNumber) {
let discString = String(discNumber);
if (needsCombinedTrackDisc && discNumber && totalDiscs) {
discString = `${discNumber}/${totalDiscs}`;
const discStr = needsCombinedTrackDisc && totalDiscs ? `${discNumber}/${totalDiscs}` : String(discNumber);
props.replace('DISCNUMBER', [discStr]);
}
if (needsCombinedTrackDisc) {
file.setProperty('DISCNUMBER', discString);
} else {
file.setProperty('DISCNUMBER', String(discNumber));
}
}
if (!needsCombinedTrackDisc && totalDiscs) {
file.setProperty('DISCTOTAL', String(totalDiscs));
props.replace('DISCTOTAL', [String(totalDiscs)]);
}
if (bpm != null && Number.isFinite(bpm)) {
file.setProperty('BPM', String(Math.round(bpm)));
props.replace('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 (albumReplayGain != null) props.replace('REPLAYGAIN_ALBUM_GAIN', [String(albumReplayGain)]);
if (albumPeakAmplitude != null) props.replace('REPLAYGAIN_ALBUM_PEAK', [String(albumPeakAmplitude)]);
if (trackReplayGain != null) props.replace('REPLAYGAIN_TRACK_GAIN', [String(trackReplayGain)]);
if (trackPeakAmplitude != null) props.replace('REPLAYGAIN_TRACK_PEAK', [String(trackPeakAmplitude)]);
}
if (releaseDate) {
try {
const year = Number(releaseDate.split('-')[0]);
if (!isNaN(year)) {
file.setProperty('DATE', String(year));
}
if (!isNaN(year)) props.replace('DATE', [String(year)]);
} catch {
// Invalid date, skip
}
}
if (copyright) {
file.setProperty('COPYRIGHT', copyright);
}
if (isrc) {
file.setProperty('ISRC', isrc);
if (copyright) props.replace('COPYRIGHT', [copyright]);
if (isrc) props.replace('ISRC', [isrc]);
if (lyrics) props.replace('LYRICS', [lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n')]);
if (explicit !== undefined) {
if (isMp4) {
file.setMP4Item('xid ', `:isrc:${isrc}`);
}
}
if (explicit) {
if (isMp4) {
file.setMP4Item('rtng', '1');
// rtng is a byte item — must be set directly on the Mp4Tag
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0));
} else {
file.setProperty('ITUNESADVISORY', '1');
props.replace('ITUNESADVISORY', [explicit ? '1' : '0']);
}
}
if (lyrics) {
file.setProperty('LYRICS', lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n'));
}
ref.setProperties(props);
if (cover) {
file.setPictures([
{
mimeType: cover.type,
data: cover.data,
type: 'FrontCover',
description: 'Cover Art',
},
]);
const pictureMap = new Map<string, Variant>();
pictureMap.set('data', Variant.fromByteVector(ByteVector.fromByteArray(cover.data)));
pictureMap.set('mimeType', Variant.fromString(cover.type));
pictureMap.set('pictureType', Variant.fromInt(3)); // FrontCover
ref.setComplexProperties('PICTURE', [pictureMap]);
}
});
await doTimedAsync('Saving in-memory buffer', () => file.save());
await doTimedAsync('Saving in-memory buffer', async () => {
await ref.save();
});
return file.getFileBuffer();
} catch (err) {
console.error(err);
} finally {
file.dispose();
const file = ref.file() as TagLibFile;
if (!file) return audioData;
const stream = file.stream();
if (stream instanceof ChunkedByteVectorStream) {
const data = doTimed(
'Converting saved file to ' + (returnType == 'blob' ? 'Blob' : 'Uint8Array'),
() => stream.data().data
);
if (returnType === 'blob') {
const blob = new Blob([data as BlobPart], { type: 'application/octet-stream' });
return blob;
}
return data;
} else if (stream instanceof BlobStream) {
const blob = doTimed('Converting saved file to ' + (returnType == 'blob' ? 'Blob' : 'Uint8Array'), () =>
stream.toBlob()
);
if (returnType === 'blob') {
return blob;
}
const arrayBuffer = await doTimed('Reading Blob as ArrayBuffer', async () => await blob.arrayBuffer());
return new Uint8Array(arrayBuffer);
}
console.warn('taglib-ts: unexpected stream type after saving file', stream);
return audioData;
}
async function getMetadataFromAudio(message: GetMetadataMessage): Promise<TagLibReadMetadata> {
const { wasmUrl, audioData } = message;
const data: TagLibReadMetadata = {
duration: 0,
};
export async function getMetadataFromAudio(message: _GetMetadataMessage): Promise<TagLibReadMetadata> {
const { audioData, audioRef, filename } = 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);
});
const ref =
audioRef ??
(await doTimedAsync(
`Opening file (${audioData.constructor.name})`,
async () => await getFileRefFromAudioData(audioData)
));
try {
const pictures = file.getPictures();
const isMp4 = file.isMP4();
const media = file.audioProperties();
if (!ref || !ref.isValid) return data;
data.duration = media.duration;
const underlying = ref.file();
const isMp4 = underlying instanceof Mp4File;
const ap = ref.audioProperties();
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;
if (ap) data.duration = ap.lengthInSeconds;
const [discNumber, discTotal] = file
.getProperty('DISCNUMBER')
?.split('/')
.map((t) => Number(t.trim() || 0) || undefined);
data.discNumber = Number(file.getProperty('DISCNUMBER') || 0) || undefined;
const props = ref.properties();
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;
data.title = props.get('TITLE')?.[0] || undefined;
data.artist = props.get('ARTIST')?.[0] || undefined;
data.albumTitle = props.get('ALBUM')?.[0] || undefined;
data.albumArtist = props.get('ALBUMARTIST')?.[0] || 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 trackStr = props.get('TRACKNUMBER')?.[0] ?? '';
const [trackNum, trackTotal] = trackStr.split('/').map((t) => Number(t.trim() || 0) || undefined);
data.trackNumber = trackNum || undefined;
data.totalTracks = trackTotal ?? (Number(props.get('TRACKTOTAL')?.[0] || 0) || undefined);
const discStr = props.get('DISCNUMBER')?.[0] ?? '';
const [discNum, discTotal] = discStr.split('/').map((t) => Number(t.trim() || 0) || undefined);
data.discNumber = discNum || undefined;
if (!data.totalDiscs) {
data.totalDiscs = discTotal ?? (Number(props.get('DISCTOTAL')?.[0] || 0) || undefined);
}
data.bpm = Number(props.get('BPM')?.[0] || 0) || undefined;
data.copyright = props.get('COPYRIGHT')?.[0] || undefined;
data.lyrics = props.get('LYRICS')?.[0] || undefined;
data.releaseDate = props.get('DATE')?.[0] || undefined;
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;
const albumGain = props.get('REPLAYGAIN_ALBUM_GAIN')?.[0];
const albumPeak = props.get('REPLAYGAIN_ALBUM_PEAK')?.[0];
const trackGain = props.get('REPLAYGAIN_TRACK_GAIN')?.[0];
const trackPeak = props.get('REPLAYGAIN_TRACK_PEAK')?.[0];
if (albumGain) replayGain.albumReplayGain = albumGain;
if (albumPeak) replayGain.albumPeakAmplitude = Number(albumPeak);
if (trackGain) replayGain.trackReplayGain = trackGain;
if (trackPeak) replayGain.trackPeakAmplitude = Number(trackPeak);
if (Object.keys(replayGain).length > 0) data.replayGain = replayGain;
data.isrc = props.get('ISRC')?.[0] || undefined;
if (isMp4) {
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
data.explicit = mp4Tag.item('rtng')?.toByte() === 1;
} else {
data.explicit = props.get('ITUNESADVISORY')?.[0] === '1';
}
data.isrc = (isMp4 && file.getMP4Item('xid ')?.split(':').at(-1)) || file.getProperty('ISRC') || undefined;
data.explicit = (isMp4 && file.getMP4Item('rtng') === '1') || file.getProperty('ITUNESADVISORY') === '1';
const pictures = ref.complexProperties('PICTURE');
if (pictures.length > 0) {
const picture = pictures.filter((p) => p.type === 'FrontCover')[0];
if (picture) {
data.cover = {
data: picture.data,
type: picture.mimeType,
};
const pic = pictures[0];
const picData = pic.get('data')?.toByteVector();
const mimeType = pic.get('mimeType')?.toString() ?? '';
if (picData && picData.length > 0) {
data.cover = { data: picData.data, type: mimeType };
}
}
} catch (err) {
console.error(err);
} finally {
file.dispose();
}
return data;
}
self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
const transfer: Transferable[] = [event.data.audioData.buffer];
async function getFileRefFromAudioData(
audioData: Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry
): Promise<FileRef | null> {
if (audioData instanceof Blob || audioData instanceof File) {
const stream = new BlobStream(audioData);
return await FileRef.open(stream, true, ReadStyle.Average);
} else if (audioData instanceof FileSystemFileHandle) {
const stream = await FileSystemFileHandleStream.open(audioData, true);
return await FileRef.open(stream, true, ReadStyle.Average);
} else if ('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) {
const file = await new Promise<File>((resolve) => audioData.file((f) => resolve(f)));
const stream = new BlobStream(file);
return await FileRef.open(stream, true, ReadStyle.Average);
} else if (audioData instanceof Uint8Array) {
const stream = new ChunkedByteVectorStream(audioData);
return await FileRef.open(stream, true, ReadStyle.Average);
}
throw new Error('Unsupported audio data type');
}
if (isWorker) {
self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
const transfer: Transferable[] = [];
if (event.data.audioData?.buffer instanceof ArrayBuffer) {
transfer.push(event.data.audioData.buffer);
}
switch (event.data.type) {
case 'Add':
@ -266,8 +289,14 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
}
try {
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
const result = (await addMetadataToAudio({
...event.data,
returnType: 'uint8array',
} as _AddMetadataMessage)) as Uint8Array;
if (result.buffer !== event.data.audioData.buffer) {
transfer.push(result.buffer);
}
self.postMessage(
{
@ -289,7 +318,9 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
case 'Get':
try {
const result = await getMetadataFromAudio(event.data as GetMetadataMessage);
const result = await getMetadataFromAudio({
...event.data,
} as _GetMetadataMessage);
self.postMessage(
{
type: event.data.type,
@ -308,4 +339,5 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
}
break;
}
};
};
}

35
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "2.5.0",
"license": "ISC",
"dependencies": {
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
@ -27,7 +28,6 @@
"mime": "^4.1.0",
"npm": "^11.11.1",
"pocketbase": "^0.26.8",
"taglib-wasm": "^1.0.5",
"uuid": "^13.0.0"
},
"devDependencies": {
@ -1676,6 +1676,12 @@
"postcss-selector-parser": "^7.0.0"
}
},
"node_modules/@dantheman827/taglib-ts": {
"version": "0.1.4",
"resolved": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
"integrity": "sha512-rvQOn9GDEj2sH4yV6oUTMMG9+rJbFG7tQkiP6/bhGJARg1Vmdy283j4YFCl+ubkqsMQ+UfAhEWSw5d5lfPVfwQ==",
"license": "LGPL-2.1-or-later"
},
"node_modules/@dual-bundle/import-meta-resolve": {
"version": "4.2.1",
"dev": true,
@ -3012,13 +3018,6 @@
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"license": "ISC",
"engines": {
"node": ">= 18"
}
},
"node_modules/@neutralinojs/lib": {
"version": "6.5.0",
"license": "MIT",
@ -10307,24 +10306,6 @@
"node": ">=10.0.0"
}
},
"node_modules/taglib-wasm": {
"version": "1.0.5",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3"
},
"engines": {
"node": ">=22.6.0"
},
"peerDependencies": {
"typescript": ">=4.5.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/tcp-port-used": {
"version": "1.0.2",
"dev": true,
@ -10550,7 +10531,7 @@
},
"node_modules/typescript": {
"version": "5.9.3",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View file

@ -51,6 +51,7 @@
"serialize-javascript": "^7.0.3"
},
"dependencies": {
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
@ -69,7 +70,6 @@
"mime": "^4.1.0",
"npm": "^11.11.1",
"pocketbase": "^0.26.8",
"taglib-wasm": "^1.0.5",
"uuid": "^13.0.0"
}
}

View file

@ -21,8 +21,7 @@ export default defineConfig(({ mode }) => {
},
},
optimizeDeps: {
exclude: ['pocketbase', '@ffmpeg/ffmpeg', '@ffmpeg/util', 'taglib-wasm'],
external: ['taglib-wasm'],
exclude: ['pocketbase', '@ffmpeg/ffmpeg', '@ffmpeg/util'],
},
server: {
fs: {