taglib-wasm
Version:
TagLib for TypeScript platforms: Deno, Node.js, Bun, Electron, browsers, and Cloudflare Workers
675 lines (674 loc) • 20 kB
JavaScript
import {
InvalidFormatError,
MetadataError,
TagLibInitializationError,
UnsupportedFormatError
} from "./errors.js";
import {
getFileSize,
readFileData,
readPartialFileData
} from "./utils/file.js";
import { writeFileData } from "./utils/write.js";
import { getGlobalWorkerPool } from "./worker-pool.js";
class AudioFileImpl {
constructor(module, fileHandle, sourcePath, originalSource, isPartiallyLoaded = false, partialLoadOptions) {
this.module = module;
this.cachedTag = null;
this.cachedAudioProperties = null;
this.isPartiallyLoaded = false;
this.fileHandle = fileHandle;
this.sourcePath = sourcePath;
this.originalSource = originalSource;
this.isPartiallyLoaded = isPartiallyLoaded;
this.partialLoadOptions = partialLoadOptions;
}
/** @inheritdoc */
getFormat() {
return this.fileHandle.getFormat();
}
/** @inheritdoc */
tag() {
const tagWrapper = this.fileHandle.getTag();
if (!tagWrapper) {
throw new MetadataError(
"read",
"Tag may be corrupted or format not fully supported"
);
}
return {
title: tagWrapper.title(),
artist: tagWrapper.artist(),
album: tagWrapper.album(),
comment: tagWrapper.comment(),
genre: tagWrapper.genre(),
year: tagWrapper.year(),
track: tagWrapper.track(),
setTitle: (value) => tagWrapper.setTitle(value),
setArtist: (value) => tagWrapper.setArtist(value),
setAlbum: (value) => tagWrapper.setAlbum(value),
setComment: (value) => tagWrapper.setComment(value),
setGenre: (value) => tagWrapper.setGenre(value),
setYear: (value) => tagWrapper.setYear(value),
setTrack: (value) => tagWrapper.setTrack(value)
};
}
/** @inheritdoc */
audioProperties() {
if (!this.cachedAudioProperties) {
const propsWrapper = this.fileHandle.getAudioProperties();
if (!propsWrapper) {
return null;
}
this.cachedAudioProperties = {
length: propsWrapper.lengthInSeconds(),
bitrate: propsWrapper.bitrate(),
sampleRate: propsWrapper.sampleRate(),
channels: propsWrapper.channels(),
bitsPerSample: propsWrapper.bitsPerSample(),
codec: propsWrapper.codec(),
containerFormat: propsWrapper.containerFormat(),
isLossless: propsWrapper.isLossless()
};
}
return this.cachedAudioProperties;
}
/** @inheritdoc */
properties() {
const jsObj = this.fileHandle.getProperties();
const result = {};
const keys = Object.keys(jsObj);
for (const key of keys) {
result[key] = jsObj[key];
}
return result;
}
/** @inheritdoc */
setProperties(properties) {
this.fileHandle.setProperties(properties);
}
/** @inheritdoc */
getProperty(key) {
const value = this.fileHandle.getProperty(key);
return value === "" ? void 0 : value;
}
/** @inheritdoc */
setProperty(key, value) {
this.fileHandle.setProperty(key, value);
}
/** @inheritdoc */
isMP4() {
return this.fileHandle.isMP4();
}
/** @inheritdoc */
getMP4Item(key) {
if (!this.isMP4()) {
const format = this.getFormat();
throw new UnsupportedFormatError(
format,
["MP4", "M4A"]
);
}
const value = this.fileHandle.getMP4Item(key);
return value === "" ? void 0 : value;
}
/** @inheritdoc */
setMP4Item(key, value) {
if (!this.isMP4()) {
const format = this.getFormat();
throw new UnsupportedFormatError(
format,
["MP4", "M4A"]
);
}
this.fileHandle.setMP4Item(key, value);
}
/** @inheritdoc */
removeMP4Item(key) {
if (!this.isMP4()) {
const format = this.getFormat();
throw new UnsupportedFormatError(
format,
["MP4", "M4A"]
);
}
this.fileHandle.removeMP4Item(key);
}
/** @inheritdoc */
save() {
if (this.isPartiallyLoaded && this.originalSource) {
throw new Error(
"Cannot save partially loaded file directly. Use saveToFile() instead, which will automatically load the full file."
);
}
this.cachedTag = null;
this.cachedAudioProperties = null;
return this.fileHandle.save();
}
/** @inheritdoc */
getFileBuffer() {
const buffer = this.fileHandle.getBuffer();
if (!buffer) {
return new Uint8Array(0);
}
return buffer;
}
/** @inheritdoc */
async saveToFile(path) {
const targetPath = path || this.sourcePath;
if (!targetPath) {
throw new Error(
"No file path available. Either provide a path or open the file from a path."
);
}
if (this.isPartiallyLoaded && this.originalSource) {
const fullData = await readFileData(this.originalSource);
const fullFileHandle = this.module.createFileHandle();
const success = fullFileHandle.loadFromBuffer(fullData);
if (!success) {
throw new InvalidFormatError(
"Failed to load full audio file for saving",
fullData.byteLength
);
}
const partialTag = this.fileHandle.getTag();
const fullTag = fullFileHandle.getTag();
if (partialTag && fullTag) {
fullTag.setTitle(partialTag.title());
fullTag.setArtist(partialTag.artist());
fullTag.setAlbum(partialTag.album());
fullTag.setComment(partialTag.comment());
fullTag.setGenre(partialTag.genre());
fullTag.setYear(partialTag.year());
fullTag.setTrack(partialTag.track());
}
const properties = this.fileHandle.getProperties();
fullFileHandle.setProperties(properties);
const pictures = this.fileHandle.getPictures();
fullFileHandle.setPictures(pictures);
if (!fullFileHandle.save()) {
fullFileHandle.destroy();
throw new Error("Failed to save changes to full file");
}
const buffer = fullFileHandle.getBuffer();
fullFileHandle.destroy();
await writeFileData(targetPath, buffer);
this.isPartiallyLoaded = false;
this.originalSource = void 0;
} else {
if (!this.save()) {
throw new Error("Failed to save changes to in-memory buffer");
}
const buffer = this.getFileBuffer();
await writeFileData(targetPath, buffer);
}
}
/** @inheritdoc */
isValid() {
return this.fileHandle.isValid();
}
/** @inheritdoc */
getPictures() {
const picturesArray = this.fileHandle.getPictures();
const pictures = [];
for (let i = 0; i < picturesArray.length; i++) {
const pic = picturesArray[i];
pictures.push({
mimeType: pic.mimeType,
data: pic.data,
type: pic.type,
description: pic.description
});
}
return pictures;
}
/** @inheritdoc */
setPictures(pictures) {
const picturesArray = pictures.map((pic) => ({
mimeType: pic.mimeType,
data: pic.data,
type: pic.type,
description: pic.description || ""
}));
this.fileHandle.setPictures(picturesArray);
}
/** @inheritdoc */
addPicture(picture) {
const pic = {
mimeType: picture.mimeType,
data: picture.data,
type: picture.type,
description: picture.description || ""
};
this.fileHandle.addPicture(pic);
}
/** @inheritdoc */
removePictures() {
this.fileHandle.removePictures();
}
/** @inheritdoc */
dispose() {
if (this.fileHandle) {
if (typeof this.fileHandle.destroy === "function") {
this.fileHandle.destroy();
}
this.fileHandle = null;
this.cachedTag = null;
this.cachedAudioProperties = null;
}
}
// Extended metadata implementations
/** @inheritdoc */
getMusicBrainzTrackId() {
const value = this.getProperty("MUSICBRAINZ_TRACKID");
return value || void 0;
}
/** @inheritdoc */
setMusicBrainzTrackId(id) {
this.setProperty("MUSICBRAINZ_TRACKID", id);
}
/** @inheritdoc */
getMusicBrainzReleaseId() {
const value = this.getProperty("MUSICBRAINZ_ALBUMID");
return value || void 0;
}
/** @inheritdoc */
setMusicBrainzReleaseId(id) {
this.setProperty("MUSICBRAINZ_ALBUMID", id);
}
/** @inheritdoc */
getMusicBrainzArtistId() {
const value = this.getProperty("MUSICBRAINZ_ARTISTID");
return value || void 0;
}
/** @inheritdoc */
setMusicBrainzArtistId(id) {
this.setProperty("MUSICBRAINZ_ARTISTID", id);
}
/** @inheritdoc */
getAcoustIdFingerprint() {
const value = this.getProperty("ACOUSTID_FINGERPRINT");
return value || void 0;
}
/** @inheritdoc */
setAcoustIdFingerprint(fingerprint) {
this.setProperty("ACOUSTID_FINGERPRINT", fingerprint);
}
/** @inheritdoc */
getAcoustIdId() {
const value = this.getProperty("ACOUSTID_ID");
return value || void 0;
}
/** @inheritdoc */
setAcoustIdId(id) {
this.setProperty("ACOUSTID_ID", id);
}
/** @inheritdoc */
getReplayGainTrackGain() {
const value = this.getProperty("REPLAYGAIN_TRACK_GAIN");
return value || void 0;
}
/** @inheritdoc */
setReplayGainTrackGain(gain) {
this.setProperty("REPLAYGAIN_TRACK_GAIN", gain);
}
/** @inheritdoc */
getReplayGainTrackPeak() {
const value = this.getProperty("REPLAYGAIN_TRACK_PEAK");
return value || void 0;
}
/** @inheritdoc */
setReplayGainTrackPeak(peak) {
this.setProperty("REPLAYGAIN_TRACK_PEAK", peak);
}
/** @inheritdoc */
getReplayGainAlbumGain() {
const value = this.getProperty("REPLAYGAIN_ALBUM_GAIN");
return value || void 0;
}
/** @inheritdoc */
setReplayGainAlbumGain(gain) {
this.setProperty("REPLAYGAIN_ALBUM_GAIN", gain);
}
/** @inheritdoc */
getReplayGainAlbumPeak() {
const value = this.getProperty("REPLAYGAIN_ALBUM_PEAK");
return value || void 0;
}
/** @inheritdoc */
setReplayGainAlbumPeak(peak) {
this.setProperty("REPLAYGAIN_ALBUM_PEAK", peak);
}
/** @inheritdoc */
getAppleSoundCheck() {
if (this.isMP4()) {
return this.getMP4Item("iTunNORM");
}
const value = this.getProperty("ITUNESOUNDCHECK");
return value || void 0;
}
/** @inheritdoc */
setAppleSoundCheck(data) {
if (this.isMP4()) {
this.setMP4Item("iTunNORM", data);
} else {
this.setProperty("ITUNESOUNDCHECK", data);
}
}
}
class TagLib {
constructor(module) {
this.module = module;
}
/**
* Initialize TagLib with optional configuration.
* This is the recommended way to create a TagLib instance.
*
* @param options - Optional configuration for loading the WASM module
* @returns Promise resolving to initialized TagLib instance
*
* @example
* ```typescript
* // Basic usage
* const taglib = await TagLib.initialize();
*
* // With pre-loaded WASM binary (for offline usage)
* const wasmBinary = await fetch("taglib.wasm").then(r => r.arrayBuffer());
* const taglib = await TagLib.initialize({ wasmBinary });
*
* // With custom WASM URL
* const taglib = await TagLib.initialize({ wasmUrl: "/assets/taglib.wasm" });
*
* // With worker pool enabled
* const taglib = await TagLib.initialize({ useWorkerPool: true });
* ```
*/
static async initialize(options) {
const { loadTagLibModule } = await import("../index.js");
const module = await loadTagLibModule(options);
const taglib = new TagLib(module);
if (options?.useWorkerPool) {
taglib.workerPool = getGlobalWorkerPool(options.workerPoolOptions);
}
return taglib;
}
/**
* Enable or disable worker pool for this TagLib instance
*/
setWorkerPool(pool) {
this.workerPool = pool || void 0;
}
/**
* Get the current worker pool instance
*/
getWorkerPool() {
return this.workerPool;
}
/**
* Open an audio file from various sources.
* Automatically detects the file format based on content.
*
* @param input - File path (string), ArrayBuffer, Uint8Array, or File object
* @returns Promise resolving to AudioFile instance
* @throws {Error} If the file format is invalid or unsupported
* @throws {Error} If the module is not properly initialized
*
* @example
* ```typescript
* // From file path
* const file = await taglib.open("song.mp3");
*
* // From ArrayBuffer
* const file = await taglib.open(arrayBuffer);
*
* // From Uint8Array
* const file = await taglib.open(uint8Array);
*
* // From File object (browser)
* const file = await taglib.open(fileObject);
*
* // Remember to dispose when done
* file.dispose();
* ```
*/
async open(input, options) {
if (!this.module.createFileHandle) {
throw new TagLibInitializationError(
"TagLib module not properly initialized: createFileHandle not found. Make sure the module is fully loaded before calling open."
);
}
const sourcePath = typeof input === "string" ? input : void 0;
const opts = {
partial: false,
maxHeaderSize: 1024 * 1024,
// 1MB
maxFooterSize: 128 * 1024,
// 128KB
...options
};
let audioData;
let isPartiallyLoaded = false;
if (opts.partial && typeof File !== "undefined" && input instanceof File) {
const headerSize = Math.min(opts.maxHeaderSize, input.size);
const footerSize = Math.min(opts.maxFooterSize, input.size);
if (input.size <= headerSize + footerSize) {
audioData = await readFileData(input);
} else {
const header = await input.slice(0, headerSize).arrayBuffer();
const footerStart = Math.max(0, input.size - footerSize);
const footer = await input.slice(footerStart).arrayBuffer();
const combined = new Uint8Array(header.byteLength + footer.byteLength);
combined.set(new Uint8Array(header), 0);
combined.set(new Uint8Array(footer), header.byteLength);
audioData = combined;
isPartiallyLoaded = true;
}
} else if (opts.partial && typeof input === "string") {
const fileSize = await getFileSize(input);
if (fileSize > opts.maxHeaderSize + opts.maxFooterSize) {
audioData = await readPartialFileData(
input,
opts.maxHeaderSize,
opts.maxFooterSize
);
isPartiallyLoaded = true;
} else {
audioData = await readFileData(input);
}
} else {
audioData = await readFileData(input);
}
const buffer = audioData.buffer.slice(
audioData.byteOffset,
audioData.byteOffset + audioData.byteLength
);
const uint8Array = new Uint8Array(buffer);
const fileHandle = this.module.createFileHandle();
const success = fileHandle.loadFromBuffer(uint8Array);
if (!success) {
throw new InvalidFormatError(
"Failed to load audio file. File may be corrupted or in an unsupported format",
buffer.byteLength
);
}
return new AudioFileImpl(
this.module,
fileHandle,
sourcePath,
input,
// Store original source for lazy loading
isPartiallyLoaded,
opts
);
}
/**
* Update tags in a file and save changes to disk in one operation.
* This is a convenience method that opens, modifies, saves, and closes the file.
*
* @param path - File path to update
* @param tags - Object containing tags to update
* @throws {Error} If file operations fail
*
* @example
* ```typescript
* await taglib.updateFile("song.mp3", {
* title: "New Title",
* artist: "New Artist"
* });
* ```
*/
async updateFile(path, tags) {
const file = await this.open(path);
try {
const tag = file.tag();
if (tags.title !== void 0) tag.setTitle(tags.title);
if (tags.artist !== void 0) tag.setArtist(tags.artist);
if (tags.album !== void 0) tag.setAlbum(tags.album);
if (tags.year !== void 0) tag.setYear(tags.year);
if (tags.track !== void 0) tag.setTrack(tags.track);
if (tags.genre !== void 0) tag.setGenre(tags.genre);
if (tags.comment !== void 0) tag.setComment(tags.comment);
await file.saveToFile();
} finally {
file.dispose();
}
}
/**
* Copy a file with new tags.
* Opens the source file, applies new tags, and saves to a new location.
*
* @param sourcePath - Source file path
* @param destPath - Destination file path
* @param tags - Object containing tags to apply
* @throws {Error} If file operations fail
*
* @example
* ```typescript
* await taglib.copyWithTags("original.mp3", "copy.mp3", {
* title: "Copy of Song",
* comment: "This is a copy"
* });
* ```
*/
async copyWithTags(sourcePath, destPath, tags) {
const file = await this.open(sourcePath);
try {
const tag = file.tag();
if (tags.title !== void 0) tag.setTitle(tags.title);
if (tags.artist !== void 0) tag.setArtist(tags.artist);
if (tags.album !== void 0) tag.setAlbum(tags.album);
if (tags.year !== void 0) tag.setYear(tags.year);
if (tags.track !== void 0) tag.setTrack(tags.track);
if (tags.genre !== void 0) tag.setGenre(tags.genre);
if (tags.comment !== void 0) tag.setComment(tags.comment);
await file.saveToFile(destPath);
} finally {
file.dispose();
}
}
/**
* Execute batch operations on a file using the worker pool.
* This method provides efficient batch processing using Web Workers.
*
* @param file - File path or Uint8Array
* @param operations - Array of operations to execute
* @returns Result of the batch operations
*
* @example
* ```typescript
* const result = await taglib.batchOperations("song.mp3", [
* { method: "setTitle", args: ["New Title"] },
* { method: "setArtist", args: ["New Artist"] },
* { method: "save" }
* ]);
* ```
*/
async batchOperations(file, operations) {
if (!this.workerPool) {
throw new Error(
"Worker pool not initialized. Enable it with TagLib.initialize({ useWorkerPool: true })"
);
}
return this.workerPool.batchOperations(file, operations);
}
/**
* Process multiple files in parallel using the worker pool.
*
* @param files - Array of file paths
* @param operation - Operation to perform on each file
* @returns Array of results
*
* @example
* ```typescript
* const tags = await taglib.processFiles(
* ["song1.mp3", "song2.mp3", "song3.mp3"],
* "readTags"
* );
* ```
*/
async processFiles(files, operation) {
if (!this.workerPool) {
throw new Error(
"Worker pool not initialized. Enable it with TagLib.initialize({ useWorkerPool: true })"
);
}
return Promise.all(
files.map((file) => {
if (operation === "readTags") {
return this.workerPool.readTags(file);
} else {
return this.workerPool.readProperties(file);
}
})
);
}
/**
* Get the TagLib version.
* @returns Version string (e.g., "2.1.0")
*/
version() {
return "2.1.0";
}
}
async function createTagLib(module) {
return new TagLib(module);
}
import {
EnvironmentError,
FileOperationError,
InvalidFormatError as InvalidFormatError2,
isEnvironmentError,
isFileOperationError,
isInvalidFormatError,
isMemoryError,
isMetadataError,
isTagLibError,
isUnsupportedFormatError,
MemoryError,
MetadataError as MetadataError2,
SUPPORTED_FORMATS,
TagLibError,
TagLibInitializationError as TagLibInitializationError2,
UnsupportedFormatError as UnsupportedFormatError2
} from "./errors.js";
export {
AudioFileImpl,
EnvironmentError,
FileOperationError,
InvalidFormatError2 as InvalidFormatError,
MemoryError,
MetadataError2 as MetadataError,
SUPPORTED_FORMATS,
TagLib,
TagLibError,
TagLibInitializationError2 as TagLibInitializationError,
UnsupportedFormatError2 as UnsupportedFormatError,
createTagLib,
isEnvironmentError,
isFileOperationError,
isInvalidFormatError,
isMemoryError,
isMetadataError,
isTagLibError,
isUnsupportedFormatError
};