From efa3521aff2ae47ac6e195bdca384d90b9143a0f Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:09:44 +0000 Subject: [PATCH] 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 --- bun.lock | 5 +- js/BaseCodec.ts | 195 ++++++++++++++++++++++++++ js/doTimed.ts | 26 ++++ js/metadata.js | 188 +++++++++---------------- js/taglib.ts | 73 ++++++++-- js/taglib.worker.ts | 334 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- tsconfig.json | 2 +- 8 files changed, 692 insertions(+), 134 deletions(-) create mode 100644 js/BaseCodec.ts create mode 100644 js/doTimed.ts create mode 100644 js/taglib.worker.ts diff --git a/bun.lock b/bun.lock index fdf2fb4..9b8e910 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/js/BaseCodec.ts b/js/BaseCodec.ts new file mode 100644 index 0000000..3ea92fe --- /dev/null +++ b/js/BaseCodec.ts @@ -0,0 +1,195 @@ +class BaseCodec { + private readonly dictionary: string[]; + private readonly base: number; + private readonly dictionarySet: Set; + + 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 = 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 = {}; + +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; diff --git a/js/doTimed.ts b/js/doTimed.ts new file mode 100644 index 0000000..812ddb9 --- /dev/null +++ b/js/doTimed.ts @@ -0,0 +1,26 @@ +import { InvisibleDictionary, baseCodecFrom } from './BaseCodec'; +import { v7 } from 'uuid'; + +export const InvisibleCodec = baseCodecFrom(InvisibleDictionary); + +export function doTimed(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(message: string, callback: () => T): Promise> { + const hiddenId = InvisibleCodec.encode(v7()); + console.time(message + hiddenId); + try { + const output = await callback(); + return output; + } finally { + console.timeEnd(message + hiddenId); + } +} diff --git a/js/metadata.js b/js/metadata.js index 42200a9..e09320a 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -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} - 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); diff --git a/js/taglib.ts b/js/taglib.ts index fea89f8..63373ba 100644 --- a/js/taglib.ts +++ b/js/taglib.ts @@ -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 | null = null; -export async function initTagLib(): Promise { - 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 { + return fetchTagLib.blobUrl || (fetchTagLib.blobUrl = await fetchBlobURL(_TagLibWasm)); +} + +namespace fetchTagLib { + export let blobUrl = ''; +} + +export { fetchTagLib }; + +export async function addMetadataWithTagLib( + audioData: Uint8Array, + data: Omit +) { + const worker = new Worker(new URL(TagLibWorker, import.meta.url), { type: 'module' }); + const wasmUrl = await fetchTagLib(); + + return new Promise((resolve, reject) => { + worker.onmessage = (e: MessageEvent) => { + 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((resolve, reject) => { + worker.onmessage = (e: MessageEvent) => { + 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 }); + }); } diff --git a/js/taglib.worker.ts b/js/taglib.worker.ts new file mode 100644 index 0000000..391cc9c --- /dev/null +++ b/js/taglib.worker.ts @@ -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 { + 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; +export type TagLibMetadataResponse = TagLibWorkerResponse; + +export type AddMetadataMessage = TagLibWorkerMessage & { + type: 'Add'; +} & TagLibMetadata; + +export type GetMetadataMessage = TagLibWorkerMessage & { + type: 'Get'; +}; + +async function addMetadataToAudio(message: AddMetadataMessage): Promise { + 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 { + 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) => { + 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); + } + 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); + } + break; + } +}; diff --git a/package.json b/package.json index 9fe206f..e4fed9e 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/tsconfig.json b/tsconfig.json index 71dc5e1..18c5a77 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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": {