taglib-wasm
Version:
TagLib-Wasm is the universal tagging library for TypeScript/JavaScript platforms: Browsers, Node.js, Deno, Bun, Cloudflare Workers, and Electron apps
465 lines (464 loc) • 14.5 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { WasmerExecutionError } from "../wasmer-sdk-loader/types.js";
import { decodeTagData } from "../../msgpack/decoder.js";
import {
fromTagLibKey,
mp4AtomWireKey,
toTagLibKey
} from "../../constants/properties.js";
import {
DATE_MIRROR,
firstValueString,
isShadowedNumericMirror,
mirrorForRawKey,
readNumericMirror,
stageNumericWrite,
stageRawWrite,
TRACK_MIRROR
} from "../../utils/mirror-fields.js";
import {
readId3v2FramesFromWasm,
readTagsFromWasm,
readTagsFromWasmPath,
writeTagsToWasm,
writeTagsToWasmPath
} from "./wasm-io.js";
const AUDIO_KEYS = /* @__PURE__ */ new Set([
"bitrate",
"bitrateMode",
"bitsPerSample",
"channels",
"codec",
"containerFormat",
"formatVersion",
"isEncrypted",
"isLossless",
"duration",
"length",
"lengthMs",
"mpegLayer",
"mpegVersion",
"outputGainDb",
"sampleRate"
]);
const INTERNAL_KEYS = /* @__PURE__ */ new Set([
"pictures",
"ratings",
"lyrics",
"chapters",
"_mp4ChapterStyle",
"bextData",
"ixml",
// Exact MP4 atom names for the write path, not a readable property.
"_mp4ItemNames"
]);
const CONTAINER_TO_FORMAT = {
MP3: "MP3",
MP4: "MP4",
FLAC: "FLAC",
OGG: "OGG",
WAV: "WAV",
AIFF: "AIFF",
WavPack: "WV",
TTA: "TTA",
ASF: "ASF",
Matroska: "MATROSKA"
};
function mp4ItemPropertyKey(key) {
if (key.startsWith("----:")) {
return key.slice(key.lastIndexOf(":") + 1).toUpperCase();
}
return mp4AtomWireKey(key) ?? key;
}
class WasiFileHandle {
constructor(wasiModule) {
__publicField(this, "wasi");
__publicField(this, "fileData", null);
__publicField(this, "filePath", null);
__publicField(this, "tagData", null);
__publicField(this, "destroyed", false);
this.wasi = wasiModule;
}
checkNotDestroyed() {
if (this.destroyed) {
throw new WasmerExecutionError(
"FileHandle has been destroyed"
);
}
}
loadFromBuffer(buffer) {
this.checkNotDestroyed();
this.fileData = buffer;
const msgpackData = readTagsFromWasm(this.wasi, buffer);
this.tagData = decodeTagData(msgpackData);
return true;
}
loadFromPath(path) {
this.checkNotDestroyed();
this.filePath = path;
const msgpackData = readTagsFromWasmPath(this.wasi, path);
this.tagData = decodeTagData(msgpackData);
return true;
}
isValid() {
this.checkNotDestroyed();
return this.fileData !== null && this.fileData.length > 0 || this.filePath !== null && this.tagData !== null;
}
save() {
this.checkNotDestroyed();
if (!this.tagData) return false;
if (this.filePath) {
return writeTagsToWasmPath(
this.wasi,
this.filePath,
this.tagData
);
}
if (!this.fileData) return false;
const result = writeTagsToWasm(this.wasi, this.fileData, this.tagData);
if (result) {
this.fileData = result;
return true;
}
return false;
}
getTagData() {
this.checkNotDestroyed();
const d = this.tagData ?? {};
return {
title: firstValueString(d.title),
artist: firstValueString(d.artist),
album: firstValueString(d.album),
comment: firstValueString(d.comment),
genre: firstValueString(d.genre),
year: readNumericMirror(d, DATE_MIRROR),
track: readNumericMirror(d, TRACK_MIRROR)
};
}
setTagData(data) {
this.checkNotDestroyed();
const merged = { ...this.tagData, ...data };
if (data.year !== void 0) {
stageNumericWrite(merged, DATE_MIRROR, data.year);
}
if (data.track !== void 0) {
stageNumericWrite(merged, TRACK_MIRROR, data.track);
}
this.tagData = merged;
}
getAudioProperties() {
this.checkNotDestroyed();
if (!this.tagData || !("sampleRate" in this.tagData)) return null;
const d = this.tagData;
const containerFormat = d.containerFormat || "unknown";
const mpegVersion = d.mpegVersion ?? 0;
const formatVersion = d.formatVersion ?? 0;
return {
duration: d.length ?? 0,
durationMs: d.lengthMs ?? 0,
bitrate: d.bitrate ?? 0,
sampleRate: d.sampleRate ?? 0,
channels: d.channels ?? 0,
bitsPerSample: d.bitsPerSample ?? 0,
codec: d.codec || "unknown",
containerFormat,
isLossless: d.isLossless ?? false,
...mpegVersion > 0 ? { mpegVersion, mpegLayer: d.mpegLayer ?? 0 } : {},
...containerFormat === "MP4" || containerFormat === "ASF" ? { isEncrypted: d.isEncrypted ?? false } : {},
...formatVersion > 0 ? { formatVersion } : {},
...d.outputGainDb !== void 0 ? { outputGainDb: d.outputGainDb } : {}
};
}
getFormat() {
this.checkNotDestroyed();
const container = this.tagData?.containerFormat;
if (container) {
const codec = this.tagData?.codec;
if (container === "OGG" && codec === "Opus") return "OPUS";
if (CONTAINER_TO_FORMAT[container]) return CONTAINER_TO_FORMAT[container];
}
if (!this.fileData || this.fileData.length < 8) return "unknown";
const magic = this.fileData.slice(0, 4);
if (magic[0] === 255 && (magic[1] & 224) === 224) return "MP3";
if (magic[0] === 73 && magic[1] === 68 && magic[2] === 51) {
return "MP3";
}
if (magic[0] === 102 && magic[1] === 76 && magic[2] === 97 && magic[3] === 67) return "FLAC";
if (magic[0] === 79 && magic[1] === 103 && magic[2] === 103 && magic[3] === 83) return this.detectOggCodec();
if (magic[0] === 82 && magic[1] === 73 && magic[2] === 70 && magic[3] === 70) return "WAV";
if (magic[0] === 119 && magic[1] === 118 && magic[2] === 112 && magic[3] === 107) return "WV";
if (magic[0] === 84 && magic[1] === 84 && magic[2] === 65 && magic[3] === 49) return "TTA";
if (this.fileData.length >= 16 && magic[0] === 48 && magic[1] === 38 && magic[2] === 178 && magic[3] === 117) return "ASF";
if (magic[0] === 26 && magic[1] === 69 && magic[2] === 223 && magic[3] === 163) return "MATROSKA";
const ftyp = this.fileData.slice(4, 8);
if (ftyp[0] === 102 && ftyp[1] === 116 && ftyp[2] === 121 && ftyp[3] === 112) return "MP4";
return "unknown";
}
detectOggCodec() {
if (!this.fileData || this.fileData.length < 37) return "OGG";
const segCount = this.fileData[26];
if (segCount === void 0) return "OGG";
const payloadStart = 27 + segCount;
if (this.fileData.length < payloadStart + 8) return "OGG";
const sig = String.fromCharCode(
...this.fileData.slice(payloadStart, payloadStart + 8)
);
if (sig === "OpusHead") return "OPUS";
return "OGG";
}
getBuffer() {
this.checkNotDestroyed();
return this.fileData ?? new Uint8Array(0);
}
getProperties() {
this.checkNotDestroyed();
const result = {};
const data = this.tagData ?? {};
for (const [key, value] of Object.entries(data)) {
if (AUDIO_KEYS.has(key) || INTERNAL_KEYS.has(key)) continue;
if (key.startsWith("----:")) continue;
if (isShadowedNumericMirror(data, key)) continue;
if (value === void 0 || value === null) continue;
if (value === 0 || value === "") continue;
const propKey = toTagLibKey(key);
if (Array.isArray(value)) {
result[propKey] = value.map(String);
} else if (typeof value === "object") {
continue;
} else {
result[propKey] = [String(value)];
}
}
return result;
}
setProperties(props) {
this.checkNotDestroyed();
const next = { ...this.tagData };
for (const [key, values] of Object.entries(props)) {
const camelKey = fromTagLibKey(key);
if (camelKey === "_mp4ItemNames") {
const existing = next._mp4ItemNames ?? [];
next._mp4ItemNames = [.../* @__PURE__ */ new Set([...existing, ...values])];
continue;
}
const mirror = mirrorForRawKey(camelKey);
if (mirror !== void 0) {
stageRawWrite(next, mirror, values);
} else if (values.length === 0) {
delete next[camelKey];
} else {
next[camelKey] = values;
}
}
this.tagData = next;
}
getProperty(key) {
this.checkNotDestroyed();
const mappedKey = fromTagLibKey(key);
const stored = this.tagData?.[mappedKey];
return Array.isArray(stored) ? stored[0]?.toString() ?? "" : stored?.toString() ?? "";
}
setProperty(key, value) {
this.checkNotDestroyed();
const mappedKey = fromTagLibKey(key);
const mirror = mirrorForRawKey(mappedKey);
if (mirror !== void 0) {
const next = { ...this.tagData };
stageRawWrite(next, mirror, value === "" ? [] : [value]);
this.tagData = next;
} else {
this.tagData = { ...this.tagData, [mappedKey]: value };
}
}
isMP4() {
this.checkNotDestroyed();
if (!this.fileData) {
return this.tagData?.containerFormat === "MP4";
}
if (this.fileData.length < 8) return false;
const magic = this.fileData.slice(4, 8);
return magic[0] === 102 && magic[1] === 116 && magic[2] === 121 && magic[3] === 112;
}
getMP4Item(key) {
this.checkNotDestroyed();
if (key.startsWith("----:")) {
const foreign = this.tagData?.[key];
if (foreign !== void 0) {
return Array.isArray(foreign) ? foreign[0]?.toString() ?? "" : String(foreign);
}
}
return this.getProperty(mp4ItemPropertyKey(key));
}
setMP4Item(key, value) {
this.checkNotDestroyed();
this.setProperty(mp4ItemPropertyKey(key), value);
if (key.startsWith("----:")) {
this.registerMp4ItemName(key);
if (this.tagData?.[key] !== void 0) {
this.tagData = { ...this.tagData, [key]: value };
}
}
}
/** Record an exact atom name to be repaired after the PropertyMap write. */
registerMp4ItemName(name) {
const existing = this.tagData?._mp4ItemNames ?? [];
if (existing.includes(name)) return;
this.tagData = {
...this.tagData,
_mp4ItemNames: [...existing, name]
};
}
removeMP4Item(key) {
this.checkNotDestroyed();
if (this.tagData) {
const mappedKey = fromTagLibKey(mp4ItemPropertyKey(key));
delete this.tagData[mappedKey];
const mirror = mirrorForRawKey(mappedKey);
if (mirror !== void 0) delete this.tagData[mirror.numeric];
}
}
getPictures() {
this.checkNotDestroyed();
return this.tagData?.pictures ?? [];
}
setPictures(pictures) {
this.checkNotDestroyed();
this.tagData = { ...this.tagData, pictures };
}
addPicture(picture) {
this.checkNotDestroyed();
const pictures = this.getPictures();
pictures.push(picture);
this.setPictures(pictures);
}
removePictures() {
this.checkNotDestroyed();
this.tagData = { ...this.tagData, pictures: [] };
}
getChapters() {
this.checkNotDestroyed();
return this.tagData?.chapters ?? [];
}
setChapters(chapters, mp4ChapterStyle) {
this.checkNotDestroyed();
this.tagData = {
...this.tagData,
_mp4ChapterStyle: mp4ChapterStyle,
chapters
};
}
getBextData() {
this.checkNotDestroyed();
return this.tagData?.bextData ?? void 0;
}
setBextData(data) {
this.checkNotDestroyed();
this.tagData = { ...this.tagData, bextData: data };
}
getIxml() {
this.checkNotDestroyed();
const v = this.tagData?.ixml;
return typeof v === "string" && v.length > 0 ? v : void 0;
}
setIxml(data) {
this.checkNotDestroyed();
this.tagData = { ...this.tagData, ixml: data };
}
hasId3Tags() {
this.checkNotDestroyed();
const t = this.tagData?.id3Tags;
return { v1: t?.v1 ?? false, v2: t?.v2 ?? false };
}
stripId3Tags(opts) {
this.checkNotDestroyed();
const current = this.tagData?.id3Tags;
if (!current) return;
const prior = this.tagData?._stripId3;
const stripV1 = (prior?.v1 ?? false) || opts.v1;
const stripV2 = (prior?.v2 ?? false) || opts.v2;
this.tagData = {
...this.tagData,
_stripId3: { v1: stripV1, v2: stripV2 },
id3Tags: {
v1: (current.v1 ?? false) && !stripV1,
v2: (current.v2 ?? false) && !stripV2
}
};
}
getRatings() {
this.checkNotDestroyed();
return this.tagData?.ratings ?? [];
}
setRatings(ratings) {
this.checkNotDestroyed();
const normalizedRatings = ratings.map((r) => ({
rating: r.rating,
email: r.email ?? "",
counter: r.counter ?? 0
}));
this.tagData = {
...this.tagData,
ratings: normalizedRatings
};
}
getLyrics() {
this.checkNotDestroyed();
const value = this.tagData?.lyrics;
if (value === void 0 || value === null) return [];
const entries = Array.isArray(value) ? value : [value];
return entries.map(
(entry) => typeof entry === "string" ? { text: entry, description: "", language: "" } : {
text: entry?.text ?? "",
description: entry?.description ?? "",
language: entry?.language ?? ""
}
);
}
setLyrics(lyrics) {
this.checkNotDestroyed();
this.tagData = { ...this.tagData, lyrics };
}
getId3v2Frames(id) {
this.checkNotDestroyed();
const filter = id === "" ? void 0 : id;
const source = this.filePath ?? this.fileData;
let frames = [];
if (source) {
frames = readId3v2FramesFromWasm(this.wasi, source, filter);
}
const staged = this.getStagedId3v2Frames();
if (!staged) return frames;
const stagedIds = new Set(Object.keys(staged));
frames = frames.filter((f) => !stagedIds.has(f.id));
for (const [sid, bodies] of Object.entries(staged)) {
if (filter && sid !== filter) continue;
for (const data of bodies) frames.push({ id: sid, data: data.slice() });
}
return frames;
}
setId3v2Frames(id, data) {
this.checkNotDestroyed();
const staged = { ...this.getStagedId3v2Frames() ?? {} };
staged[id] = data.map((d) => new Uint8Array(d));
this.tagData = {
...this.tagData,
id3v2Frames: staged
};
}
removeId3v2Frames(id) {
this.setId3v2Frames(id, []);
}
getStagedId3v2Frames() {
return this.tagData?.id3v2Frames;
}
destroy() {
this.fileData = null;
this.tagData = null;
this.destroyed = true;
}
}
export {
WasiFileHandle
};