UNPKG

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
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 };