mp3tag
Version:
A library for reading/writing mp3 tag data
414 lines (339 loc) • 13.4 kB
JavaScript
/** This module defines the decoder/encoder class, which gets set to the tagdata.decoder property and provides
* framedecoding and -encoding method based on the tags version.
*/
const util = require('util');
const cp = require('./cp');
const _ = require('lodash');
const Data = require('./data');
/**
* @typedef {{language:string,short:string,long:string}} Comment
*/
/**
* @typedef {{email:string,rating:number,playCount:number}} Popularity
*/
/**
* @typedef {{mimeType:string, pictureType:integer, description:string, pictureData:Data}} Picture
*/
class Encoding {
/**
* @param {string} name the name of the encoding
* @param {number[]} bom array of BOM bytes
* @param {boolean} dbe true if double-byte encoding (terminated wit 0x00 0x00)
* @param {number?} encodingByte optional encoding byte used for identification
*/
constructor(name, bom, dbe, encodingByte) {
this.name = name;
this.bom = bom;
this.dbe = dbe;
this.encodingByte = encodingByte;
}
}
Encoding['ISO-8895-1'] = new Encoding('ISO-8895-1', [], false, 0x00);
Encoding['UTF-16LE-BOM'] = new Encoding('UTF-16LE', [0xFF, 0xFE], true, 0x01);
Encoding['UTF-16BE-BOM'] = new Encoding('UTF-16BE', [0xFE, 0xFF], true);
Encoding['UTF-8-BOM'] = new Encoding('UTF-8', [0xEF, 0xBB, 0xBF], false);
Encoding['UTF-8'] = new Encoding('UTF-8', [], false, 0x03);
/** List of unicode encodings supported as text encoding
*/
const UCEncodings = [
Encoding['UTF-16LE-BOM'],
Encoding['UTF-16BE-BOM'],
Encoding['UTF-8-BOM'],
Encoding['UTF-8'] // Empty bom sets the default codepage
];
/** Defines the default encoding for the encodeString method
*
* @type {{[version:number]:Encoding}}
*/
const DEFAULT_ENCODINGS = {
3: Encoding['UTF-16LE-BOM'],
4: Encoding['UTF-8']
};
/** Decoder class, used to de- and encode frames
*/
class Decoder {
/** Creates a new decoder for the given tag version
*/
constructor(version) {
this.version = version.major;
}
/** This method decodes the given buffer into a js string.
*
* @param {Buffer} buffer the buffer to decode (the first byte is the encoding byte)
*
* @return {string} the decoded string
*/
decodeString(buffer) {
if (!(buffer instanceof Buffer)) {
throw new Error(`Expected buffer, got: ${typeof(buffer)} - ${util.inspect(buffer, {showHidden:true})}`);
}
var data = buffer.slice(1);
try {
return internal.decodeString(this, data, buffer[0]);
} catch(e) {
throw new Error(`Unsupported buffer encoding: ${buffer.inspect()}`);
}
}
/** Decodes a comment frame buffer into a structure of {language,short,long}
*
* @param {Buffer} buffer the buffer of the comment frame to decode
*
* @return {Comment} the decoded comment object
*/
decodeComment(buffer) {
if (!(buffer instanceof Buffer)) {
throw new Error(`Expected buffer, got: ${typeof(buffer)} - ${util.inspect(buffer, {showHidden:true})}`);
}
const encodingByte = buffer[0];
const language = buffer.toString('ascii', 1, 4); // the language code has acutally 3 characters!
const data = buffer.slice(4);
try {
this.getBufferEncoding(data, encodingByte);
} catch (e) {
throw new Error(`Unsupported buffer encoding: ${buffer.inspect()}`);
}
const cData = internal.decodeCString(this, data, encodingByte); //TODO try catch other error
const shortComment = cData.string;
const longComment = internal.decodeString(this, data.slice(cData.pastNullPos));
return {
language:language,
short:shortComment,
long:longComment
};
}
/** Encodes the given comment object back into a buffer
*
* @param {Comment} comment the comment object to encode
*
* @return {Buffer} the encoded buffer
*/
encodeComment(comment) {
const encoding = DEFAULT_ENCODINGS[this.version];
const shortComment = internal.encodeCString(comment.short, encoding);
const longComment = internal.encodeString(comment.long, encoding);
const result = Buffer.alloc(4 + shortComment.length + longComment.length);
result[0] = encoding.encodingByte; // set appropriate encoding byte
result.write(comment.language.substr(0,3).padEnd(3), 1, 3, 'ascii');
shortComment.copy(result, 4);
longComment.copy(result, 4+shortComment.length);
return result;
}
/** Decodes the popularity object from the given frame buffer.
*
* @param {Buffer} buffer the buffer to decode the popularity from
*
* @return {Popularity} the decoded popularity
*/
decodePopularity(buffer) {
//v-- no unicode support for email
const email = internal.decodeCString(this, buffer, 0x00);
const rating = buffer[email.pastNullPos];
const offset = email.pastNullPos + 1;
const played = internal.decodeNumberBE(buffer, offset, buffer.length - offset);
return {
email:email.string,
rating:rating,
playCount:played
}
}
/**
* @param {Buffer} buffer the buffer to decode the buffer from
*
* @return {Picture} the pictue object
*/
decodePicture(buffer) {
if (!(buffer instanceof Buffer)) {
throw new Error(`Expected buffer, got: ${typeof(buffer)} - ${util.inspect(buffer, {showHidden:true})}`);
}
const encodingByte = buffer[0];
const dataBuffer = buffer.subarray(1); //v-- MIME will always be ISO-8895-1
const mimeData = internal.decodeCString(this, dataBuffer, 0x00);
const mimeType = mimeData.string;
const pictureType = dataBuffer[mimeData.pastNullPos];
const descriptionBuffer = dataBuffer.subarray(mimeData.pastNullPos + 1);
const descData = internal.decodeCString(this, descriptionBuffer, encodingByte);
const description = descData.string;
const pictureData = descriptionBuffer.subarray(descData.pastNullPos);
return {
mimeType: mimeType, //String
pictureType: pictureType,//Integer
description: description,//String
pictureData: new Data(pictureData) //BufferData
};
}
/** Encodes the given picture data into a Buffer
*
* @param {Picture} picture the picture information in the same format as returned by decodePicture
*
* @return {Buffer} encoded picture data
*/
encodePicture(picture) {
const defaultEncoding = DEFAULT_ENCODINGS[this.version];
const frameBuffers = [
Buffer.alloc(1, defaultEncoding.encodingByte),
internal.encodeCString(picture.mimeType, Encoding['ISO-8895-1']), // mime is always encoded in ISO-8895-1
Buffer.alloc(1, picture.pictureType),
internal.encodeCString(picture.description, defaultEncoding),
picture.pictureData.toBuffer()
];
// Return the resulting buffer
return Buffer.concat(frameBuffers);
}
/** Returns the encoding for the given buffer and encodingByte
* If no encodingByte is passed the encoding is being guessed by searching the buffer
* for a BOM.
*
* @param {Buffer} buffer the buffer (not containing the encodingByte)
* @param {number} encodingByte optional encoding byte, which specifies the buffer's encoding
*
* @return {Encoding} an encoding, which describes the detected
* buffer encoding
*/
getBufferEncoding(buffer, encodingByte) {
if (encodingByte === undefined) {
encodingByte = 0x01; // Default is unicode if not passed
}
if (encodingByte === 0x00) { // ISO-8895-1 encoding
return Encoding['ISO-8895-1'];
}
if (encodingByte === 0x01) { // UC (search for BOM and look up)
return _.find(UCEncodings, (encoding) => {
for (let c = 0; c < encoding.bom.length; ++c) {
if (buffer[c] !== encoding.bom[c]) {
return false;
}
}
return true;
});
}
if (this.version >= 4) { //New encodings added with 2.4
if (encodingByte === 0x02) {
return new Encoding('UTF-16BE', [], true); // UTF-16BE without BOM
}
if (encodingByte === 0x03) {
return new Encoding('UTF-8', [], false);
}
}
throw new Error(`Unknown encoding byte: '${encodingByte}'`);
}
/** Encodes the given string into a buffer with the default encoding, which is
* UTF-16LE for v2.3 and UTF-8 for v2.4.
*
* @param {string} string the string to encode
*
* @return {Buffer} the encoded buffer
*/
encodeString(string) {
if (typeof(string) !== "string") {
throw new Error(`Expected string, got: ${typeof(string)} - ${util.inspect(string, {showHidden:true})}`);
}
const encoding = DEFAULT_ENCODINGS[this.version];
const bomBuf = internal.encodeString(string, encoding);
const result = Buffer.alloc(bomBuf.length+1);
result[0] = encoding.encodingByte; //set appropriate encoding byte
bomBuf.copy(result, 1);
return result;
}
}
////
// INTERNAL HELPER FUNCTIONS
//
// not implemented as method to not overload the decoder's interface
////
const internal = {};
/** Same as decodeCString, but this string isn't zero terminated. The whole
* buffer is treated as string.
*
* @param {Decoder} decoder the decoder to use for the decoding operation
* @param {Buffer} buffer the buffer to read from
* @param {number} encodingByte the byte which specifies, which encoding to use
*
* @return {string} decoded string
*/
internal.decodeString = function(decoder, buffer, encodingByte) {
const encoding = decoder.getBufferEncoding(buffer, encodingByte);
return cp.fromBuffer(buffer.slice(encoding.bom.length), encoding.name);
};
/** Receives a zero terminated buffer to decode from and the encoding byte
*
* @param {Decoder} decoder the decoder to use for this operation
* @param {Buffer} buffer the buffer to read the string from
* @param {number} encodingByte the byte, which specifies encoding
*
* @return {{string:string,nullPos:number,pastNullPos:number}} the decoded string object
*/
internal.decodeCString = function(decoder, buffer, encodingByte) {
const encoding = decoder.getBufferEncoding(buffer, encodingByte);
const result = {};
result.nullPos = internal.getStringEndPos(buffer, encoding.dbe);
if (result.nullPos === -1) {
throw new Error("Expected NULL terminated string, missing NULL byte(s), with encoding: " + encoding.name);
}
result.pastNullPos = result.nullPos + (encoding.dbe ? 2 : 1);
const contentSlice = buffer.slice(encoding.bom.length, result.nullPos);
result.string = cp.fromBuffer(contentSlice, encoding.name);
return result;
};
/** Encodes the string into a buffer with the encoding's BOM.
* The encodingByte won't be written into the buffer as this is format specific.
*
* @param {string} string the string to encode
* @param {Encoding} encoding the encoding object, which represents the encoding to use
*
* @return {Buffer} the buffer with the encoded string
*/
internal.encodeString = function(string, encoding) {
const strBuffer = cp.fromString(string, encoding.name);
const result = Buffer.alloc(strBuffer.length+encoding.bom.length);
for (let c = 0; c < encoding.bom.length; ++c) {
result[c] = encoding.bom[c];
}
strBuffer.copy(result, encoding.bom.length);
return result;
};
/** Encodes the string into a buffer with the encoding's BOM and terminates the string with a NULL character.
* The encodingByte won't be written into the buffer as this is format specific.
*
* @param {string} string the string to encode
* @param {Encoding} encoding the encoding object, which represents the encoding to use
*
* @return {Buffer} the buffer with the encoded string
*/
internal.encodeCString = function(string, encoding) {
return internal.encodeString(string+'\0', encoding);
};
/** Returns the offset of the NULL (double-)byte inside a string buffer
*
* @param {Buffer} buffer the buffer to search
* @param {boolean} isDoubleNull if true, the function treats the byte buffer like a two byte encoding
*
* @return {number} returns the endpos
*/
internal.getStringEndPos = function(buffer, isDoubleNull) {
for (let c = 0; c < buffer.length; ++c) {
if (buffer[c] == 0x00 && !isDoubleNull) {
return c;
} else if (buffer[c] == 0x00 && buffer[c+1] == 0x00 && isDoubleNull) {
return c;
}
if (isDoubleNull) {
++c; // Additional increment if double byte NULL is expected
}
}
return -1; //Not found
};
/**
* @param {Buffer} buffer the buffer to decode the number from
* @param {number} offset the offset to start decoding the number from
* @param {number} bytes the number of bytes which encode the number
*
* @return {number} the decoded number
*/
internal.decodeNumberBE = function(buffer, offset, bytes) {
let result = 0;
for (let c = offset; c < offset + bytes; ++c) {
result = ((result << 8) | buffer[c]); // Decode big endian number
}
return result;
};
module.exports = Decoder;