diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e69de29 diff --git a/bun.lock b/bun.lock index 34ebc1d..fdf49cc 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/js/api.js b/js/api.js index 725b066..6a97d67 100644 --- a/js/api.js +++ b/js/api.js @@ -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')); - blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises); + try { + blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises); + } catch (err) { + console.error(err); + } } } diff --git a/js/downloads.js b/js/downloads.js index 0ef08d1..169f0a3 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -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, diff --git a/js/metadata.js b/js/metadata.js index 0cb2aba..f4fd9b8 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -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, { - ...data, - }); - - return doTimed( - 'Create new audio blob', - () => - new Blob([newAudioBuffer], { - type: audioBlob.type, - }) + return await addMetadataWithTagLib( + audioBlob, + { + ...data, + }, + 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} 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; diff --git a/js/taglib.ts b/js/taglib.ts index a8b63f4..8866aa2 100644 --- a/js/taglib.ts +++ b/js/taglib.ts @@ -1,79 +1,190 @@ -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 | null = null; +export async function withTimeout(callback: () => Promise, timeout: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Operation timed out after ${timeout} ms`)); + }, timeout); -async function fetchTagLib(): Promise { - 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 = ''; +function toUint8Array(audioData: ArrayBufferLike | Uint8Array) { + if (audioData instanceof Uint8Array) { + return audioData; + } + + return doTimed( + `Converting audio data (${(audioData as any)?.constructor?.name}) to Uint8Array`, + () => new Uint8Array(audioData) + ); } -export { fetchTagLib }; +async function convertInputToTaglib( + audioData: TagLibReadTypes | TagLibWriteTypes, + direct: boolean = false +): Promise { + if ('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) { + audioData = await doTimedAsync('Getting File from FileSystemFileEntry', async () => { + const file = await new Promise((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: Uint8Array, - data: Omit + audioData: TagLibWriteTypes, + data: Omit, + filename?: string, + direct: boolean = false, + returnBlob: boolean = false, + timeout: number = 10000 ) { - if (!(audioData instanceof Uint8Array)) { - audioData = new Uint8Array(audioData); - } + audioData = await convertInputToTaglib(audioData, direct); - const worker = new TagLibWorker(); - const wasmUrl = await fetchTagLib(); + if (direct) { + const { addMetadataToAudio } = await workerModule; - return new Promise((resolve, reject) => { - worker.onmessage = (e: MessageEvent) => { - const { data, error } = e.data; + return await doTimedAsync('Adding metadata with taglib-ts (direct)', () => + addMetadataToAudio({ + ...data, + filename, + audioData, + returnType: returnBlob && direct ? 'blob' : 'uint8array', + }) + ); + } else { + const worker = new TagLibWorker(); - if (error) { - reject(new Error(error)); - } else { - resolve(data!); - } - }; - worker.onerror = reject; - worker.onmessageerror = reject; + try { + return await doTimedAsync( + 'Adding metadata with taglib-ts (worker)', + async () => + await withTimeout( + () => + new Promise((resolve, reject) => { + worker.onmessage = (e: MessageEvent) => { + const { data, error } = e.data; - const transferables: Transferable[] = [audioData.buffer]; - if ((data as any).cover?.data?.buffer instanceof ArrayBuffer) { - transferables.push((data as any).cover.data.buffer); + if (error) { + reject(new Error(error)); + } else { + resolve(data!); + } + }; + worker.onerror = reject; + worker.onmessageerror = reject; + + 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', audioData, filename }, transferables); + }), + timeout + ) + ); + } finally { + worker.terminate(); + } + } +} + +export async function getMetadataWithTagLib( + audioData: TagLibReadTypes, + filename?: string, + direct: boolean = false, + timeout: number = 10000 +) { + audioData = await convertInputToTaglib(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(); + + try { + return await doTimedAsync('Getting metadata with taglib-ts (worker)', () => + withTimeout( + () => + 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; + + 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(); } - - worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, transferables); - }); -} - -export async function getMetadataWithTagLib(audioData: Uint8Array) { - if (!(audioData instanceof Uint8Array)) { - audioData = new Uint8Array(audioData); } - - const worker = new TagLibWorker(); - 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 }, [audioData.buffer]); - }); } diff --git a/js/taglib.types.ts b/js/taglib.types.ts index 5af538d..08da71f 100644 --- a/js/taglib.types.ts +++ b/js/taglib.types.ts @@ -1,9 +1,11 @@ +import type { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef'; + export type TagLibWorkerMessageType = 'Add' | 'Get'; -export interface TagLibWorkerMessage { +export interface TagLibWorkerMessage { type: TagLibWorkerMessageType; - wasmUrl: string; - audioData: Uint8Array; + audioData: T; + filename?: string; } export interface TagLibWorkerResponse { @@ -50,6 +52,19 @@ export type AddMetadataMessage = TagLibWorkerMessage & { type: 'Add'; } & TagLibMetadata; -export type GetMetadataMessage = TagLibWorkerMessage & { +export type GetMetadataMessage = TagLibWorkerMessage & { type: 'Get'; }; + +export type TagLibReadTypes = Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry; +export type TagLibWriteTypes = Uint8Array; + +export type _AddMetadataMessage = Omit & { + audioRef?: FileRef | null; + audioData?: Uint8Array; + returnType?: 'blob' | 'uint8array'; +}; +export type _GetMetadataMessage = Omit & { + audioRef?: FileRef | null; + audioData?: TagLibReadTypes; +}; diff --git a/js/taglib.worker.ts b/js/taglib.worker.ts index e7841a5..5f0465f 100644 --- a/js/taglib.worker.ts +++ b/js/taglib.worker.ts @@ -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 { +export const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; + +export async function addMetadataToAudio(message: _AddMetadataMessage): Promise { const { - wasmUrl, audioData, + audioRef, + filename, title, artist, albumTitle, @@ -38,274 +54,290 @@ async function addMetadataToAudio(message: AddMetadataMessage): Promise { - 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 { - doTimed('Tagging file', () => { - const isMp4 = file.isMP4(); - const media = file.audioProperties(); - const needsCombinedTrackDisc = isMp4 || media.containerFormat.toLowerCase() === 'mp3'; - - if (title) { - file.setProperty('TITLE', title); - } - - if (artist) { - file.setProperty('ARTIST', artist); - } - - if (albumTitle) { - file.setProperty('ALBUM', albumTitle); - } - - const _albumArtist = albumArtist || artist; - if (_albumArtist) { - file.setProperty('ALBUMARTIST', _albumArtist); - } - - if (trackNumber) { - let trackString = String(trackNumber); - - if (needsCombinedTrackDisc && trackNumber && totalTracks) { - trackString = `${trackNumber}/${totalTracks}`; - } - - if (needsCombinedTrackDisc) { - file.setProperty('TRACKNUMBER', trackString); - } else { - file.setProperty('TRACKNUMBER', String(trackNumber)); - } - } - - if (!needsCombinedTrackDisc && totalTracks) { - file.setProperty('TRACKTOTAL', String(totalTracks)); - } - - if (discNumber) { - let discString = String(discNumber); - - if (needsCombinedTrackDisc && discNumber && totalDiscs) { - discString = `${discNumber}/${totalDiscs}`; - } - - if (needsCombinedTrackDisc) { - file.setProperty('DISCNUMBER', discString); - } else { - file.setProperty('DISCNUMBER', String(discNumber)); - } - } - - if (!needsCombinedTrackDisc && totalDiscs) { - file.setProperty('DISCTOTAL', String(totalDiscs)); - } - - if (bpm != null && Number.isFinite(bpm)) { - file.setProperty('BPM', String(Math.round(bpm))); - } - - if (replayGain) { - const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = replayGain; - if (albumReplayGain) file.setProperty('REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain)); - if (albumPeakAmplitude) file.setProperty('REPLAYGAIN_ALBUM_PEAK', String(albumPeakAmplitude)); - if (trackReplayGain) file.setProperty('REPLAYGAIN_TRACK_GAIN', String(trackReplayGain)); - if (trackPeakAmplitude) file.setProperty('REPLAYGAIN_TRACK_PEAK', String(trackPeakAmplitude)); - } - - if (releaseDate) { - try { - const year = Number(releaseDate.split('-')[0]); - if (!isNaN(year)) { - file.setProperty('DATE', String(year)); - } - } catch { - // Invalid date, skip - } - } - - if (copyright) { - file.setProperty('COPYRIGHT', copyright); - } - - if (isrc) { - file.setProperty('ISRC', isrc); - - if (isMp4) { - file.setMP4Item('xid ', `:isrc:${isrc}`); - } - } - - if (explicit) { - if (isMp4) { - file.setMP4Item('rtng', '1'); - } else { - file.setProperty('ITUNESADVISORY', '1'); - } - } - - if (lyrics) { - file.setProperty('LYRICS', lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n')); - } - - if (cover) { - file.setPictures([ - { - mimeType: cover.type, - data: cover.data, - type: 'FrontCover', - description: 'Cover Art', - }, - ]); - } - }); - - await doTimedAsync('Saving in-memory buffer', () => file.save()); - - return file.getFileBuffer(); - } catch (err) { - console.error(err); - } finally { - file.dispose(); + 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; + + doTimed('Tagging file', () => { + const props = ref.properties(); + + 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) { + const trackStr = + needsCombinedTrackDisc && totalTracks ? `${trackNumber}/${totalTracks}` : String(trackNumber); + props.replace('TRACKNUMBER', [trackStr]); + } + if (!needsCombinedTrackDisc && totalTracks) { + props.replace('TRACKTOTAL', [String(totalTracks)]); + } + + if (discNumber) { + const discStr = needsCombinedTrackDisc && totalDiscs ? `${discNumber}/${totalDiscs}` : String(discNumber); + props.replace('DISCNUMBER', [discStr]); + } + if (!needsCombinedTrackDisc && totalDiscs) { + props.replace('DISCTOTAL', [String(totalDiscs)]); + } + + if (bpm != null && Number.isFinite(bpm)) { + props.replace('BPM', [String(Math.round(bpm))]); + } + + if (replayGain) { + const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = replayGain; + 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)) props.replace('DATE', [String(year)]); + } catch { + // Invalid date, skip + } + } + + 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) { + // 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 { + props.replace('ITUNESADVISORY', [explicit ? '1' : '0']); + } + } + + ref.setProperties(props); + + if (cover) { + const pictureMap = new Map(); + 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', async () => { + await ref.save(); + }); + + 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 { - const { wasmUrl, audioData } = message; - const data: TagLibReadMetadata = { - duration: 0, - }; +export async function getMetadataFromAudio(message: _GetMetadataMessage): Promise { + 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 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 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'] = {}; + 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'; + } + + const pictures = ref.complexProperties('PICTURE'); + if (pictures.length > 0) { + 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 }; } - - 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) => { - const transfer: Transferable[] = [event.data.audioData.buffer]; - - switch (event.data.type) { - case 'Add': - if ((event.data as AddMetadataMessage).cover?.data?.buffer instanceof ArrayBuffer) { - transfer.push((event.data as AddMetadataMessage).cover.data.buffer); - } - - try { - const result = await addMetadataToAudio(event.data as AddMetadataMessage); - transfer.push(result.buffer); - - self.postMessage( - { - type: event.data.type, - data: result, - } satisfies TagLibFileResponse, - transfer - ); - } catch (error) { - self.postMessage( - { - type: event.data.type, - error: error instanceof Error ? error.message : String(error), - } satisfies TagLibWorkerResponse, - transfer - ); - } - break; - - case 'Get': - try { - const result = await getMetadataFromAudio(event.data as GetMetadataMessage); - self.postMessage( - { - type: event.data.type, - data: result, - } satisfies TagLibMetadataResponse, - transfer - ); - } catch (error) { - self.postMessage( - { - type: event.data.type, - error: error instanceof Error ? error.message : String(error), - } satisfies TagLibWorkerResponse, - transfer - ); - } - break; +async function getFileRefFromAudioData( + audioData: Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry +): Promise { + 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((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) => { + const transfer: Transferable[] = []; + if (event.data.audioData?.buffer instanceof ArrayBuffer) { + transfer.push(event.data.audioData.buffer); + } + + switch (event.data.type) { + case 'Add': + if ((event.data as AddMetadataMessage).cover?.data?.buffer instanceof ArrayBuffer) { + transfer.push((event.data as AddMetadataMessage).cover.data.buffer); + } + + try { + const result = (await addMetadataToAudio({ + ...event.data, + returnType: 'uint8array', + } as _AddMetadataMessage)) as Uint8Array; + + if (result.buffer !== event.data.audioData.buffer) { + transfer.push(result.buffer); + } + + self.postMessage( + { + type: event.data.type, + data: result, + } satisfies TagLibFileResponse, + transfer + ); + } catch (error) { + self.postMessage( + { + type: event.data.type, + error: error instanceof Error ? error.message : String(error), + } satisfies TagLibWorkerResponse, + transfer + ); + } + break; + + case 'Get': + try { + const result = await getMetadataFromAudio({ + ...event.data, + } as _GetMetadataMessage); + self.postMessage( + { + type: event.data.type, + data: result, + } satisfies TagLibMetadataResponse, + transfer + ); + } catch (error) { + self.postMessage( + { + type: event.data.type, + error: error instanceof Error ? error.message : String(error), + } satisfies TagLibWorkerResponse, + transfer + ); + } + break; + } + }; +} diff --git a/package-lock.json b/package-lock.json index 87e059e..ffdbcf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5ea8e15..9de2ff0 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/vite.config.ts b/vite.config.ts index 8b2475f..c3ab6fe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {