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:
parent
497d42b9fd
commit
efa3521aff
8 changed files with 692 additions and 134 deletions
5
bun.lock
5
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=="],
|
||||
|
|
|
|||
195
js/BaseCodec.ts
Normal file
195
js/BaseCodec.ts
Normal 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
26
js/doTimed.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
188
js/metadata.js
188
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<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);
|
||||
|
|
|
|||
73
js/taglib.ts
73
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<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
334
js/taglib.worker.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue