mp3tag
Version:
A library for reading/writing mp3 tag data
438 lines (437 loc) • 17.9 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TagData = void 0;
/**
* module which contains the TagData-class
*
* This contains all relevant information about the file's id3 tag.
* This class makes the assumption, that each tag id is actually unique for the whole tag.
* This assumtion affects the methods getFrameBuffer/getFrameData/setFrameBuffer
*/
const _ = __importStar(require("lodash"));
const encoding = __importStar(require("./encoding"));
const dataSource_1 = require("./dataSource");
const file_1 = require("./file");
const frame_1 = require("./frame");
const decoder_1 = require("./decoder");
class TagData {
/** File read from.
*/
file;
/** the version tuple {'major','minor'}
*/
version;
/** flags field from the header
*/
flags;
/** header size with frames and padding/footer = starting offset of audiodata
*/
size;
/** list of filtered frames (no zero size frames and no padding frames)
*/
frames;
/** padding bytes {'offset', 'size'}
*/
padding;
/** location of the audio data in this mp3 file
*/
audioData;
/** true, if adding new frames has depleted all padding and file must be rewritten
*/
rewrite = false;
/** true if any of the tag data has been changed since creation if the file isn't dirty saving into the same file is a no
*/
dirty = false;
decoder;
hasFooter;
// Constant size of ID3Tag header/footer size
static TAG_HEADER_SIZE = 10;
static TAG_FOOTER_SIZE = 10;
/**
* @param file the file from which this tagdata has been read
* @param version the minor,major version tuple read from the header
* @param flags the flags field from the header
* @param size frame size with padding/footer = starting offset of audtio
* @param frames list of filtered frames in this mp3 file (no zero size frames and no padding frames)
* @param padding offset and size of padding in mp3 file
* @param audioData location of the audio data in this mp3 file
*/
constructor(file, version, flags, size, frames, padding, audioData) {
this.file = file;
this.version = version;
this.flags = flags;
this.size = size;
this.frames = frames;
this.padding = padding;
this.audioData = audioData ?? new dataSource_1.DataSource(file, size);
const { major } = version;
if (major === 3 || major === 4) {
this.decoder = new decoder_1.Decoder(major);
}
else {
throw new Error(`Unsupported major id3 version: ${version}`);
}
this.hasFooter = version.major >= 4 && (flags & 0x10) != 0;
}
/** This function creates an empty tag with no frames and the audio data references the whole file.
*
* @param audioFile the file containing only the audio data
*
* @return the empty header object
*/
static noHeader(audioFile) {
// We must set the size to the TAG_HEADER_SIZE and padding start correctly or else writing this empty
// header into a new file will fail.
return new TagData(audioFile, { major: 3, minor: 0 }, 0 /*flags*/, TagData.TAG_HEADER_SIZE /*size*/, [], { offset: TagData.TAG_HEADER_SIZE, size: 0 } /*no padding*/, new dataSource_1.DataSource(audioFile));
}
/** This simple getter searches the frames array for the first frame with the given id.
*
* @param {string} id the frame's id
*
* @return {Frame} either the frame or undefined if not found
*/
getFrame(id) {
return _.find(this.frames, frame => frame.id == id);
}
/** This simple getter returns all frames for the given id or an empty array
* if no such frame exists. There are cases where the frame id isn't actually
* a unique Identifier and multiple frames with the same id exist.
*
* @param id the frame's id
*
* @return the list of frames with that id
*/
getFrames(id) {
return _.filter(this.frames, frame => frame.id == id);
}
/** Returns the frame buffer of the frame, which identified by the given id.
* If no such frame is available, undefined is returned. The padding frame is not part of
* of the frames, and can't be retrieved by this method.
*
* @param id the frame's id to get the buffer for.
*
* @return the frame's buffer, or undefined if the frame does not exist.
*/
getFrameBuffer(id) {
const frame = this.getFrame(id);
return frame?.data.toBuffer();
}
/** Returns the frame buffer of the frame, which identified by the given id.
* Since there are cases in which a file contains multiple frames with the same id,
* (in most cases custom frames) this method can be used to retrieve all buffers
* for these frames. The buffers are returned as array.
*
* @param id the frame's id to get the buffer for.
*
* @return an array of buffers, on for each frame found with the given id
*/
getFrameBuffers(id) {
return _.map(this.getFrames(id), frame => frame.data.toBuffer());
}
/** Inverse to getFrameBuffer.
* The buffer is only assigned, not copied, so don't modify it afterwards.
* If the frame does not exist, it will be created, otherwise the frame buffer just
* gets replaced. The padding of TagData is adapted accordingly.
*
* @param id the frame's id
* @param buffer the buffer to set
*/
setFrameBuffer(id, buffer) {
this.reallocateFrame(id, buffer);
}
/** Completely removes the given frame(s).
* If the frame doesn't exist, it does nothing.
* The method doesn't prevent the deletion of required frames.
*
* @param id the id of the frame to delete
*/
removeFrame(id) {
_.remove(this.frames, (frame) => { return frame.id == id; });
this.realignFrames(); // Realign all frames after a frame got deleted
}
/** This method will return the frame with the given id, and change it's size
* to fit the passed buffer. If the frame doesn't exist, it will be created.
* The passed buffer will be set as new data.
*
* @param id the frame's id
* @param buffer the buffer to set for the frame
*
* @return the reallocated frame
*/
reallocateFrame(id, buffer) {
const frame = this.getFrame(id);
// Allocate if frame doesn't exist
if (!frame) {
return this.allocateFrame(id, buffer);
}
// No change in data necessary, setting same content
if (frame.data.toBuffer().equals(buffer)) {
return frame;
}
// Maybe no resizing necessary?
const size = frame.size;
frame.setBuffer(buffer);
if (frame.size != size) {
// Take care of realigning frames, adjusting padding and the tag's size
this.realignFrames();
}
this.dirty = true; // Change in data happened
return frame;
}
/** This method will iterate through all frames and adjust their `pos` according to
* their `size` and position in the frames array. The padding and tag size will be
* adjusted if necessary.
*/
realignFrames() {
let pos = TagData.TAG_HEADER_SIZE; // Starting position
for (const frame of this.frames) {
pos += frame_1.Frame.HEADER_SIZE; // move position to start of data
// Move frame to correct position
frame.pos = pos;
pos += frame.size;
}
// Padding start position may have changed
const paddingDelta = pos - this.padding.offset;
this.dirty = true; // data has probably changed (new frame or removed a frame)
// Move resize padding accordingly
this.padding.offset += paddingDelta;
this.padding.size -= paddingDelta;
if (this.padding.size < 0) { // No padding left
this.size += this.padding.size * (-1); // Tag's size has just increased
this.rewrite = true; // Audiodata must be moved
this.padding.size = 0;
}
}
/** This method will create a new frame structure and append it to frames.
* The padding will be decreased accordingly. It does not check whether
* a frame with this id already exists. The frame will be allocated with
* enough size to hold the passed buffer.
*
* @param id the frame's id
* @param buffer the buffer to set as data.
*
* @return the newly allocated frame
*/
allocateFrame(id, buffer) {
const frame = frame_1.Frame.allocate(id, buffer);
// Insert as last frame
this.frames.push(frame);
// Realign frames will take care of setting the correct position and adjusting the padding
this.realignFrames();
return frame;
}
/** This method will just write back the updated frame data into the file itself.
* If possible it will try to use the padding area for that purpose. If the updated
* header fits inside the old header+padding then only the header part of the file will
* get updated and not the whole file.
* If the tag data hasn't been changed, then no write operation takes place.
*
* @return A promise that gets resolved once the file has been written.
*/
async save() {
return this.writeToFile(this.file.name);
}
/** Checks whether the footer should better be disabled based on a used padding
*/
checkFooter() {
// Footer processing (we don't want to write the footer if we have included padding)
if (this.hasFooter && this.padding.size > 0) {
// remove footer, add it's size to the padding and disable the flag
this.hasFooter = false;
this.padding.size += TagData.TAG_FOOTER_SIZE;
this.flags &= ~0x10;
}
}
/** Returns the size of the tag's content not counting header and footer, but padding is included.
* This value is equal to the size value encoded in the tag header/footer.
*
* @return the content size in bytes
*/
getContentSize() {
let size = this.size - TagData.TAG_HEADER_SIZE;
if (this.hasFooter) {
size -= TagData.TAG_FOOTER_SIZE;
}
return size;
}
/** This method will write the id3 tag header into the given file. At the correct offset.
* This will not write the frames.
*
* @param file a File object
*
* @return a promise, which resolves to the number of written bytes upon success.
* This should be equal to TagData.TAG_HEADER_SIZE
*/
async writeTagHeader(file) {
// check whether we have to disable the footer to set the correct flags
this.checkFooter();
const header = Buffer.alloc(TagData.TAG_HEADER_SIZE);
header.write("ID3"); // Write marker
header.writeUInt8(this.version.major, 3);
header.writeUInt8(this.version.minor, 4);
header.writeUInt8(this.flags, 5);
// TagHeader size must be subtracted from the actual size
header.writeUInt32BE(encoding.encodeUInt7Bit(this.getContentSize()), 6);
// Now write the header
return file.write(header);
}
/** This method will write the id3 tag footer if the file still has a footer
*
* @param file the File object to write into
*
* @return a promise, which resolves to the number of written bytes.
* This will equal TagData.TAG_FOOTER_SIZE if the file has a footer
* or 0 if the file doesn't need / cannot have a footer (due to padding)
*/
async writeTagFooter(file) {
this.checkFooter();
if (!this.hasFooter) {
// so 0 bytes have been written, but this is not an error
return 0;
}
const footer = Buffer.alloc(TagData.TAG_FOOTER_SIZE);
footer.write("3DI"); // Write footer marker (basically ID3 reversed)
footer.writeUInt8(this.version.major, 3);
footer.writeUInt8(this.version.minor, 4);
footer.writeUInt8(this.flags, 5);
// TagHeader size must be subtracted from the actual size
footer.writeUInt32BE(encoding.encodeUInt7Bit(this.getContentSize()), 6);
// Now write the footer
return file.write(footer);
}
/** Basically a getter for the decoder property
*
* @return the used decoder instance for this tag
*/
getDecoder() {
return this.decoder;
}
/** Reads the audio data from the file into a buffer and returns it.
*
* @return a promise, which resolves to the buffer containing the file's audio data
*/
async getAudioBuffer() {
const data = await this.audioData.toData();
return data.toBuffer();
}
/** This method will write the content into a file (overwriting existing files) and
* will place a padding area of 1024 bytes after the header. If the file to write into
* equals the source file for this tag's audiodata, then it tries to only update the tags itself.
* If a new file is written, a default padding of 1024 is added.
*
* If the destination file is a .mp3 file, it will get completely overwritten.
* It won't be first parsed to preserve it's audio data.
* If the target file equals this tagData's source file and the tagData hasn't changed since last load
* or save then no file access occurrs. If the data has changed, then this operation will
* reset this state.
*
* @param path the file's path to write into
*
* @return a promise, which gets resolved if the file has been successfully written.
*/
async writeToFile(path) {
const sameFile = (this.audioData.source.name === path);
// No change & same file -> no write necessary
if (sameFile && !this.dirty) {
return;
}
/** This helper function will load the audio data into a buffer iff rewriting the file
* is necessary to prevent overwriting the audio data when writing out the tag frames.
* AudioData.writeToFile() should be called on the returned object to write the buffered
* audio data back into the file.
*
* @return {Promise<AudioData>} the object holding the optionally buffered audio data.
*/
const getAudioData = async () => {
// We don't have to write audio if a rewrite isn't necessary and we write into the same file
if (sameFile && !this.rewrite) {
// AudioData.writeToFile() will be a NOP
return new AudioData();
}
else {
// Otherwise, writing audio is necessary
// We have to load the audioData into a buffer, before writing any frames,
// or else we will overwrite it.
return new AudioData(await this.audioData.toData());
}
};
// load audio data if necessary
const audioData = await getAudioData();
// Open target file for writing
let fmode = "w";
if (sameFile && !this.rewrite) {
// Don't clear file on open
fmode = "a";
}
const file = await file_1.File.open(path, fmode);
// Write the file parts in order
await this.writeTagHeader(file);
// Write frames
for (const frame of this.frames) {
await frame.write(file);
}
// Write padding (generate a buffer filled with 0x00)
const padding = Buffer.alloc(this.padding.size, 0x00);
await file.write(padding);
// Write footer (if any)
await this.writeTagFooter(file);
// Now write the audio data (if needed) and close the file
await audioData.writeToFile(file);
file.close();
if (sameFile) {
// Clear dirty flag if we have just updated the source file
this.dirty = false;
}
return;
}
}
exports.TagData = TagData;
/** Helper class for writing back the mp3's audio data
*/
class AudioData {
data;
/**
* @param data the loaded audio data to write back. May be undefined if
* writing back the audio isn't necessary.
*/
constructor(data) {
this.data = data;
}
/**
* @param file the file to write into
*
* @return a promise, which resolves to the number of bytes written
*/
async writeToFile(file) {
if (this.data !== undefined) {
return file.write(this.data.toBuffer());
}
else {
// no bytes written... wasn't necessary
return 0;
}
}
}