@catamphetamine/id3js
Version:
A modern ID3 parser written completely in JavaScript, making use of typed arrays and the HTML5 File API
363 lines (362 loc) • 10.6 kB
JavaScript
import genres from './genres.js';
import { getString, getUint24, getUint32Synch, getStringUtf16 } from './util.js';
export const types = new Map([
/*
* Textual frames
*/
['TALB', 'album'],
['TBPM', 'bpm'],
['TCOM', 'composer'],
['TCON', 'genre'],
['TCOP', 'copyright'],
['TDEN', 'encoding-time'],
['TDLY', 'playlist-delay'],
['TDOR', 'original-release-time'],
['TDRC', 'recording-time'],
['TDRL', 'release-time'],
['TDTG', 'tagging-time'],
['TENC', 'encoder'],
['TEXT', 'writer'],
['TFLT', 'file-type'],
['TIPL', 'involved-people'],
['TIT1', 'content-group'],
['TIT2', 'title'],
['TIT3', 'subtitle'],
['TKEY', 'initial-key'],
['TLAN', 'language'],
['TLEN', 'length'],
['TMCL', 'credits'],
['TMED', 'media-type'],
['TMOO', 'mood'],
['TOAL', 'original-album'],
['TOFN', 'original-filename'],
['TOLY', 'original-writer'],
['TOPE', 'original-artist'],
['TOWN', 'owner'],
['TPE1', 'artist'],
['TPE2', 'band'],
['TPE3', 'conductor'],
['TPE4', 'remixer'],
['TPOS', 'set-part'],
['TPRO', 'produced-notice'],
['TPUB', 'publisher'],
['TRCK', 'track'],
['TRSN', 'radio-name'],
['TRSO', 'radio-owner'],
['TSOA', 'album-sort'],
['TSOP', 'performer-sort'],
['TSOT', 'title-sort'],
['TSRC', 'isrc'],
['TSSE', 'encoder-settings'],
['TSST', 'set-subtitle'],
['TYER', 'year'],
/*
* Textual frames (<=2.2)
*/
['TAL', 'album'],
['TBP', 'bpm'],
['TCM', 'composer'],
['TCO', 'genre'],
['TCR', 'copyright'],
['TDY', 'playlist-delay'],
['TEN', 'encoder'],
['TFT', 'file-type'],
['TKE', 'initial-key'],
['TLA', 'language'],
['TLE', 'length'],
['TMT', 'media-type'],
['TOA', 'original-artist'],
['TOF', 'original-filename'],
['TOL', 'original-writer'],
['TOT', 'original-album'],
['TP1', 'artist'],
['TP2', 'band'],
['TP3', 'conductor'],
['TP4', 'remixer'],
['TPA', 'set-part'],
['TPB', 'publisher'],
['TRC', 'isrc'],
['TRK', 'track'],
['TSS', 'encoder-settings'],
['TT1', 'content-group'],
['TT2', 'title'],
['TT3', 'subtitle'],
['TXT', 'writer'],
['TYE', 'year'],
/*
* URL frames
*/
['WCOM', 'url-commercial'],
['WCOP', 'url-legal'],
['WOAF', 'url-file'],
['WOAR', 'url-artist'],
['WOAS', 'url-source'],
['WORS', 'url-radio'],
['WPAY', 'url-payment'],
['WPUB', 'url-publisher'],
/*
* URL frames (<=2.2)
*/
['WAF', 'url-file'],
['WAR', 'url-artist'],
['WAS', 'url-source'],
['WCM', 'url-commercial'],
['WCP', 'url-copyright'],
['WPB', 'url-publisher'],
/*
* Comment frame
*/
['COMM', 'comments'],
/*
* Image frame
*/
['APIC', 'image'],
['PIC', 'image'],
/*
* Private frames
*/
['PRIV', 'private']
]);
export const imageTypes = [
'other',
'file-icon',
'icon',
'cover-front',
'cover-back',
'leaflet',
'media',
'artist-lead',
'artist',
'conductor',
'band',
'composer',
'writer',
'location',
'during-recording',
'during-performance',
'screen',
'fish',
'illustration',
'logo-band',
'logo-publisher'
];
/**
* Parses legacy frames for ID3 v2.2 and earlier
* @param {ArrayBuffer} buffer Buffer to read
* @return {ID3Frame|null}
*/
export function parseLegacy(buffer) {
const result = {
id: null,
tag: null,
value: null
};
const dv = new DataView(buffer);
const header = {
id: getString(dv, 3),
type: getString(dv, 1),
size: getUint24(dv, 3)
};
const matchedType = types.get(header.id);
if (!matchedType) {
return null;
}
result.id = header.id;
result.tag = matchedType;
if (header.type === 'T') {
/*
* TODO: Implement UTF-8, UTF-16 and UTF-16 with BOM properly?
*/
let val = getString(dv, -7, 7);
if (header.id === 'TCO' && !!parseInt(val)) {
val = genres[parseInt(val)];
}
result.value = val;
}
else if (header.type === 'W') {
result.value = getString(dv, -7, 7);
}
else if (header.id === 'COM') {
/*
* TODO: Implement UTF-8, UTF-16 and UTF-16 with BOM properly?
*/
let val = getString(dv, -10, 10);
if (val.indexOf('\x00') !== -1) {
val = val.substr(val.indexOf('\x00') + 1);
}
result.value = val;
}
else if (header.id === 'PIC') {
const image = {
type: null,
mime: 'image/' + getString(dv, 3, 7).toLowerCase(),
description: null,
data: null
};
image.type = imageTypes[dv.getUint8(11)] || 'other';
const variableStart = 11;
let variableLength = 0;
for (let i = variableStart;; i++) {
if (dv.getUint8(i) === 0x00) {
variableLength = i - variableStart;
break;
}
}
image.description =
variableLength === 0
? null
: getString(dv, variableLength, variableStart);
image.data = buffer.slice(variableStart + 1);
result.value = image;
}
return result.tag ? result : null;
}
/**
* Parses a given buffer into an ID3 frame
* @param {ArrayBuffer} buffer Buffer to read data from
* @param {number} major Major version of ID3
* @param {number} minor Minor version of ID3
* @return {ID3Frame|null}
*/
export function parse(buffer, major, minor) {
minor = minor || 0;
major = major || 4;
const result = { id: null, tag: null, value: null };
const dv = new DataView(buffer);
if (major < 3) {
return parseLegacy(buffer);
}
const header = {
id: getString(dv, 4),
type: getString(dv, 1),
size: getUint32Synch(dv, 4),
flags: [dv.getUint8(8), dv.getUint8(9)]
};
/*
* No support for compressed, unsychronised, etc frames
*/
if (header.flags[1] !== 0) {
return null;
}
const matchedType = types.get(header.id);
if (!matchedType) {
return null;
}
result.tag = matchedType;
result.id = header.id;
if (header.type === 'T') {
const encoding = dv.getUint8(10);
let val = null;
/*
* TODO: Implement UTF-8, UTF-16 and UTF-16 with BOM properly?
*/
if (encoding === 0 || encoding === 3) {
val = getString(dv, -11, 11);
}
else if (encoding === 1) {
val = getStringUtf16(dv, -11, 11, true);
}
else if (encoding === 2) {
val = getStringUtf16(dv, -11, 11);
}
if (header.id === 'TCON' && val !== null && !!parseInt(val)) {
val = genres[parseInt(val)];
}
result.value = val;
}
else if (header.type === 'W') {
result.value = getString(dv, -10, 10);
}
else if (header.id === 'PRIV') {
const variableStart = 10;
let variableLength = 0;
for (let i = 0;; i++) {
if (dv.getUint8(i) === 0x00) {
variableLength = i - variableStart;
break;
}
}
result.value = {
identifier: variableLength === 0
? null
: getString(dv, variableLength, variableStart),
data: buffer.slice(variableLength + variableStart + 1)
};
}
else if (header.id === 'COMM') {
/*
* TODO: Implement UTF-8, UTF-16 and UTF-16 with BOM properly?
*/
const encoding = dv.getUint8(10);
let variableStart = 14;
/*
* Skip the comment description and retrieve only the comment its self
*/
for (let i = variableStart;; i++) {
if (encoding === 1 || encoding === 2) {
if (dv.getUint16(i) === 0x0000) {
variableStart = i + 2;
break;
}
i++;
}
else {
if (dv.getUint8(i) === 0x00) {
variableStart = i + 1;
break;
}
}
}
if (encoding === 0 || encoding === 3) {
result.value = getString(dv, -1 * variableStart, variableStart);
}
else if (encoding === 1) {
result.value = getStringUtf16(dv, -1 * variableStart, variableStart, true);
}
else if (encoding === 2) {
result.value = getStringUtf16(dv, -1 * variableStart, variableStart);
}
}
else if (header.id === 'APIC') {
const encoding = dv.getUint8(10);
const image = {
type: null,
mime: null,
description: null,
data: null
};
let variableStart = 11;
let variableLength = 0;
for (let i = variableStart;; i++) {
if (dv.getUint8(i) === 0x00) {
variableLength = i - variableStart;
break;
}
}
image.mime = getString(dv, variableLength, variableStart);
image.type =
imageTypes[dv.getUint8(variableStart + variableLength + 1)] || 'other';
variableStart += variableLength + 2;
variableLength = 0;
for (let i = variableStart;; i++) {
if (dv.getUint8(i) === 0x00) {
variableLength = i - variableStart;
break;
}
}
if (variableLength !== 0) {
if (encoding === 0 || encoding === 3) {
image.description = getString(dv, variableLength, variableStart);
}
else if (encoding === 1) {
image.description = getStringUtf16(dv, variableLength, variableStart, true);
}
else if (encoding === 2) {
image.description = getStringUtf16(dv, variableLength, variableStart);
}
}
image.data = buffer.slice(variableStart + 1);
result.value = image;
}
return result.tag ? result : null;
}