node-id3
Version:
Pure JavaScript ID3v2 Tag writer and reader
587 lines (530 loc) • 20.4 kB
JavaScript
const fs = require('fs')
const ID3FrameBuilder = require("./ID3FrameBuilder")
const ID3FrameReader = require("./ID3FrameReader")
const ID3Definitions = require("./ID3Definitions")
const ID3Util = require("./ID3Util")
const ID3Helpers = require('./ID3Helpers')
const { isString } = require('./util')
module.exports.GENERIC_TEXT = {
create: (frameIdentifier, data) => {
if(!frameIdentifier || !data) {
return null
}
return new ID3FrameBuilder(frameIdentifier)
.appendStaticNumber(0x01, 0x01)
.appendStaticValue(data, null, 0x01)
.getBuffer()
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer, 0)
return reader.consumeStaticValue('string')
}
}
module.exports.GENERIC_URL = {
create: (frameIdentifier, data) => {
if(!frameIdentifier || !data) {
return null
}
return new ID3FrameBuilder(frameIdentifier)
.appendStaticValue(data)
.getBuffer()
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer)
return reader.consumeStaticValue('string')
}
}
module.exports.APIC = {
create: (data) => {
try {
if (data instanceof Buffer) {
data = {
imageBuffer: Buffer.from(data)
}
} else if (isString(data)) {
data = {
imageBuffer: fs.readFileSync(data)
}
} else if (!data.imageBuffer) {
return Buffer.alloc(0)
}
let mime_type = data.mime
if(!mime_type) {
mime_type = ID3Util.getPictureMimeTypeFromBuffer(data.imageBuffer)
}
const TagConstants = ID3Definitions.TagConstants.AttachedPicture
const pictureType = data.type || {}
const pictureTypeId = pictureType.id === undefined
? TagConstants.PictureType.FRONT_COVER : pictureType.id
/*
* Fix a bug in iTunes where the artwork is not recognized when the description is empty using UTF-16.
* Instead, if the description is empty, use encoding 0x00 (ISO-8859-1).
*/
const { description = '' } = data
const encoding = description ? 0x01 : 0x00
return new ID3FrameBuilder('APIC')
.appendStaticNumber(encoding, 1)
.appendNullTerminatedValue(mime_type)
.appendStaticNumber(pictureTypeId, 1)
.appendNullTerminatedValue(description, encoding)
.appendStaticValue(data.imageBuffer)
.getBuffer()
} catch(error) {
return error
}
},
read: (buffer, version) => {
const reader = new ID3FrameReader(buffer, 0)
let mime
if(version === 2) {
mime = reader.consumeStaticValue('string', 3, 0x00)
} else {
mime = reader.consumeNullTerminatedValue('string', 0x00)
}
const typeId = reader.consumeStaticValue('number', 1)
const description = reader.consumeNullTerminatedValue('string')
const imageBuffer = reader.consumeStaticValue()
return {
mime: mime,
type: {
id: typeId,
name: ID3Definitions.APIC_TYPES[typeId]
},
description: description,
imageBuffer: imageBuffer
}
}
}
module.exports.COMM = {
create: (data) => {
data = data || {}
if(!data.text) {
return null
}
return new ID3FrameBuilder("COMM")
.appendStaticNumber(0x01, 1)
.appendStaticValue(data.language)
.appendNullTerminatedValue(data.shortText, 0x01)
.appendStaticValue(data.text, null, 0x01)
.getBuffer()
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer, 0)
return {
language: reader.consumeStaticValue('string', 3, 0x00),
shortText: reader.consumeNullTerminatedValue('string'),
text: reader.consumeStaticValue('string', null)
}
}
}
module.exports.USLT = {
create: (data) => {
data = data || {}
if(isString(data)) {
data = {
text: data
}
}
if(!data.text) {
return null
}
return new ID3FrameBuilder("USLT")
.appendStaticNumber(0x01, 1)
.appendStaticValue(data.language)
.appendNullTerminatedValue(data.shortText, 0x01)
.appendStaticValue(data.text, null, 0x01)
.getBuffer()
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer, 0)
return {
language: reader.consumeStaticValue('string', 3, 0x00),
shortText: reader.consumeNullTerminatedValue('string'),
text: reader.consumeStaticValue('string', null)
}
}
}
module.exports.SYLT = {
create: (data) => {
if(!(data instanceof Array)) {
data = [data]
}
const encoding = 1 // 16 bit unicode
return Buffer.concat(data.map(lycics => {
const frameBuilder = new ID3FrameBuilder("SYLT")
.appendStaticNumber(encoding, 1)
.appendStaticValue(lycics.language, 3)
.appendStaticNumber(lycics.timeStampFormat, 1)
.appendStaticNumber(lycics.contentType, 1)
.appendNullTerminatedValue(lycics.shortText, encoding)
lycics.synchronisedText.forEach(part => {
frameBuilder.appendNullTerminatedValue(part.text, encoding)
frameBuilder.appendStaticNumber(part.timeStamp, 4)
})
return frameBuilder.getBuffer()
}))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer, 0)
return {
language: reader.consumeStaticValue('string', 3, 0x00),
timeStampFormat: reader.consumeStaticValue('number', 1),
contentType: reader.consumeStaticValue('number', 1),
shortText: reader.consumeNullTerminatedValue('string'),
synchronisedText: Array.from((function*() {
while(true) {
const text = reader.consumeNullTerminatedValue('string')
const timeStamp = reader.consumeStaticValue('number', 4)
if (text === undefined || timeStamp === undefined) {
break
}
yield {text, timeStamp}
}
})())
}
}
}
module.exports.TXXX = {
create: (data) => {
if(!(data instanceof Array)) {
data = [data]
}
return Buffer.concat(data.map(udt => new ID3FrameBuilder("TXXX")
.appendStaticNumber(0x01, 1)
.appendNullTerminatedValue(udt.description, 0x01)
.appendStaticValue(udt.value, null, 0x01)
.getBuffer()))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer, 0)
return {
description: reader.consumeNullTerminatedValue('string'),
value: reader.consumeStaticValue('string')
}
}
}
module.exports.POPM = {
create: (data) => {
const email = data.email
let rating = Math.trunc(data.rating)
let counter = Math.trunc(data.counter)
if(!email) {
return null
}
if(isNaN(rating) || rating < 0 || rating > 255) {
rating = 0
}
if(isNaN(counter) || counter < 0) {
counter = 0
}
return new ID3FrameBuilder("POPM")
.appendNullTerminatedValue(email)
.appendStaticNumber(rating, 1)
.appendStaticNumber(counter, 4)
.getBuffer()
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer)
return {
email: reader.consumeNullTerminatedValue('string'),
rating: reader.consumeStaticValue('number', 1),
counter: reader.consumeStaticValue('number')
}
}
}
module.exports.PRIV = {
create: (data) => {
if(!(data instanceof Array)) {
data = [data]
}
return Buffer.concat(data.map(priv => new ID3FrameBuilder("PRIV")
.appendNullTerminatedValue(priv.ownerIdentifier)
.appendStaticValue(priv.data instanceof Buffer ? priv.data : Buffer.from(priv.data, "utf8"))
.getBuffer()))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer)
return {
ownerIdentifier: reader.consumeNullTerminatedValue('string'),
data: reader.consumeStaticValue()
}
}
}
module.exports.UFID = {
create: (data) => {
if (!(data instanceof Array)) {
data = [data]
}
return Buffer.concat(data.map(ufid => new ID3FrameBuilder("UFID")
.appendNullTerminatedValue(ufid.ownerIdentifier)
.appendStaticValue(
ufid.identifier instanceof Buffer ?
ufid.identifier : Buffer.from(ufid.identifier, "utf8")
)
.getBuffer()))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer)
return {
ownerIdentifier: reader.consumeNullTerminatedValue('string'),
identifier: reader.consumeStaticValue()
}
}
}
module.exports.CHAP = {
create: (data) => {
if (!(data instanceof Array)) {
data = [data]
}
return Buffer.concat(data.map(chap => {
if (!chap || !chap.elementID || typeof chap.startTimeMs === "undefined" || !chap.endTimeMs) {
return null
}
return new ID3FrameBuilder("CHAP")
.appendNullTerminatedValue(chap.elementID)
.appendStaticNumber(chap.startTimeMs, 4)
.appendStaticNumber(chap.endTimeMs, 4)
.appendStaticNumber(chap.startOffsetBytes ? chap.startOffsetBytes : 0xFFFFFFFF, 4)
.appendStaticNumber(chap.endOffsetBytes ? chap.endOffsetBytes : 0xFFFFFFFF, 4)
.appendStaticValue(ID3Helpers.createBufferFromTags(chap.tags))
.getBuffer()
}).filter(chap => chap instanceof Buffer))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer)
const chap = {
elementID: reader.consumeNullTerminatedValue('string'),
startTimeMs: reader.consumeStaticValue('number', 4),
endTimeMs: reader.consumeStaticValue('number', 4),
startOffsetBytes: reader.consumeStaticValue('number', 4),
endOffsetBytes: reader.consumeStaticValue('number', 4),
tags: ID3Helpers.getTagsFromID3Body(reader.consumeStaticValue())
}
if(chap.startOffsetBytes === 0xFFFFFFFF) {
delete chap.startOffsetBytes
}
if(chap.endOffsetBytes === 0xFFFFFFFF) {
delete chap.endOffsetBytes
}
return chap
}
}
module.exports.CTOC = {
create: (data) => {
if(!(data instanceof Array)) {
data = [data]
}
return Buffer.concat(data.map((toc, index) => {
if(!toc || !toc.elementID) {
return null
}
if(!(toc.elements instanceof Array)) {
toc.elements = []
}
const ctocFlags = Buffer.alloc(1, 0)
if(index === 0) {
ctocFlags[0] += 2
}
if(toc.isOrdered) {
ctocFlags[0] += 1
}
const builder = new ID3FrameBuilder("CTOC")
.appendNullTerminatedValue(toc.elementID)
.appendStaticValue(ctocFlags, 1)
.appendStaticNumber(toc.elements.length, 1)
toc.elements.forEach((el) => {
builder.appendNullTerminatedValue(el)
})
if(toc.tags) {
builder.appendStaticValue(ID3Helpers.createBufferFromTags(toc.tags))
}
return builder.getBuffer()
}).filter((toc) => toc instanceof Buffer))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer)
const elementID = reader.consumeNullTerminatedValue('string')
const flags = reader.consumeStaticValue('number', 1)
const entries = reader.consumeStaticValue('number', 1)
const elements = []
for(let i = 0; i < entries; i++) {
elements.push(reader.consumeNullTerminatedValue('string'))
}
const tags = ID3Helpers.getTagsFromID3Body(reader.consumeStaticValue())
return {
elementID,
isOrdered: !!(flags & 0x01 === 0x01),
elements,
tags
}
}
}
module.exports.WXXX = {
create: (data) => {
if(!(data instanceof Array)) {
data = [data]
}
return Buffer.concat(data.map((udu) => {
return new ID3FrameBuilder("WXXX")
.appendStaticNumber(0x01, 1)
.appendNullTerminatedValue(udu.description, 0x01)
.appendStaticValue(udu.url, null)
.getBuffer()
}))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer, 0)
return {
description: reader.consumeNullTerminatedValue('string'),
url: reader.consumeStaticValue('string', null, 0x00)
}
}
}
module.exports.ETCO = {
create: (data) => {
const builder = new ID3FrameBuilder("ETCO")
.appendStaticNumber(data.timeStampFormat, 1)
data.keyEvents.forEach((keyEvent) => {
builder
.appendStaticNumber(keyEvent.type, 1)
.appendStaticNumber(keyEvent.timeStamp, 4)
})
return builder.getBuffer()
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer)
return {
timeStampFormat: reader.consumeStaticValue('number', 1),
keyEvents: Array.from((function*() {
while(true) {
const type = reader.consumeStaticValue('number', 1)
const timeStamp = reader.consumeStaticValue('number', 4)
if (type === undefined || timeStamp === undefined) {
break
}
yield {type, timeStamp}
}
})())
}
}
}
module.exports.COMR = {
create: (data) => {
if(!(data instanceof Array)) {
data = [data]
}
return Buffer.concat(data.map(comr => {
const prices = comr.prices || {}
const builder = new ID3FrameBuilder("COMR")
// Text encoding
builder.appendStaticNumber(0x01, 1)
// Price string
const priceString = Object.entries(prices).map((price) => {
return price[0].substring(0, 3) + price[1].toString()
}).join('/')
builder.appendNullTerminatedValue(priceString, 0x00)
// Valid until
builder.appendStaticValue(
comr.validUntil.year.toString().padStart(4, '0').substring(0, 4) +
comr.validUntil.month.toString().padStart(2, '0').substring(0, 2) +
comr.validUntil.day.toString().padStart(2, '0').substring(0, 2),
8, 0x00
)
// Contact URL
builder.appendNullTerminatedValue(comr.contactUrl, 0x00)
// Received as
builder.appendStaticNumber(comr.receivedAs, 1)
// Name of seller
builder.appendNullTerminatedValue(comr.nameOfSeller, 0x01)
// Description
builder.appendNullTerminatedValue(comr.description, 0x01)
// Seller logo
if(comr.sellerLogo) {
let picture = comr.sellerLogo.picture
if(typeof comr.sellerLogo.picture === 'string' || comr.sellerLogo.picture instanceof String) {
picture = fs.readFileSync(comr.sellerLogo.picture)
}
let mimeType = comr.sellerLogo.mimeType || ID3Util.getPictureMimeTypeFromBuffer(picture)
// Only image/png and image/jpeg allowed
if(mimeType !== 'image/png' && 'image/jpeg') {
mimeType = 'image/'
}
builder.appendNullTerminatedValue(mimeType ? mimeType : '', 0x00)
builder.appendStaticValue(picture)
}
return builder.getBuffer()
}))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer, 0)
const tag = {}
// Price string
const priceStrings = reader.consumeNullTerminatedValue('string', 0x00)
.split('/')
.filter((price) => price.length > 3)
tag.prices = {}
for(const price of priceStrings) {
tag.prices[price.substring(0, 3)] = price.substring(3)
}
// Valid until
const validUntilString = reader.consumeStaticValue('string', 8, 0x00)
tag.validUntil = { year: 0, month: 0, day: 0 }
if(/^\d+$/.test(validUntilString)) {
tag.validUntil.year = parseInt(validUntilString.substring(0, 4))
tag.validUntil.month = parseInt(validUntilString.substring(4, 6))
tag.validUntil.day = parseInt(validUntilString.substring(6))
}
// Contact URL
tag.contactUrl = reader.consumeNullTerminatedValue('string', 0x00)
// Received as
tag.receivedAs = reader.consumeStaticValue('number', 1)
// Name of seller
tag.nameOfSeller = reader.consumeNullTerminatedValue('string')
// Description
tag.description = reader.consumeNullTerminatedValue('string')
// Seller logo
const mimeType = reader.consumeNullTerminatedValue('string', 0x00)
const picture = reader.consumeStaticValue('buffer')
if(picture && picture.length > 0) {
tag.sellerLogo = {
mimeType,
picture
}
}
return tag
}
}
module.exports.GEOB = {
create: (data) => {
if (!(data instanceof Array)) {
data = [data]
}
data.forEach(item => {
if(item.encapsulatedObject == null) {
throw new Error("encapsulatedObject is required in GEOB frames")
}
if(item.contentDescription == null) {
throw new Error("contentDescription is required for GEOB frames")
}
})
const encoding = 0x01 // UTF-16 BOM
return Buffer.concat(data.map((geob) => {
return new ID3FrameBuilder("GEOB")
.appendStaticNumber(encoding)
.appendNullTerminatedValue(geob.mimeType ? geob.mimeType : '')
.appendNullTerminatedValue(geob.filename ? geob.filename : '', encoding)
.appendNullTerminatedValue(geob.contentDescription, encoding)
.appendStaticValue(geob.encapsulatedObject)
.getBuffer()
}))
},
read: (buffer) => {
const reader = new ID3FrameReader(buffer, 0)
return {
mimeType: reader.consumeNullTerminatedValue('string', 0),
filename: reader.consumeNullTerminatedValue('string'),
contentDescription: reader.consumeNullTerminatedValue('string'),
encapsulatedObject: reader.consumeStaticValue('buffer')
}
}
}