Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
454 lines
14 KiB
JavaScript
454 lines
14 KiB
JavaScript
/* global BlobView, ID3v1Metadata, LazyLoader */
|
|
/* exported ID3v2Metadata */
|
|
'use strict';
|
|
|
|
/**
|
|
* Parse files with ID3v2 metadata.
|
|
*
|
|
* Format information:
|
|
* http://id3.org/Developer%20Information
|
|
*/
|
|
var ID3v2Metadata = (function() {
|
|
// Map id3v2 frame ids to metadata property names. Each line has two mappings:
|
|
// one for id3v2.3+ and one for id3v2.2.
|
|
var ID3V2FRAMES = {
|
|
TPE1: 'artist', TP1: 'artist',
|
|
TALB: 'album', TAL: 'album',
|
|
TIT2: 'title', TT2: 'title',
|
|
TRCK: 'tracknum', TRK: 'tracknum',
|
|
TPOS: 'discnum', TPA: 'discnum',
|
|
APIC: 'picture', PIC: 'picture'
|
|
};
|
|
var ID3V2SECONDARYFRAMES = {
|
|
TRCK: 'trackcount', TRK: 'trackcount',
|
|
TPOS: 'disccount', TPA: 'disccount'
|
|
};
|
|
|
|
/**
|
|
* Parse a file and return a Promise with the metadata.
|
|
*
|
|
* @param {BlobView} blobview The audio file to parse.
|
|
* @return {Promise} A Promise returning the parsed metadata object.
|
|
*/
|
|
function parse(blobview) {
|
|
return new Promise((resolve, reject) => {
|
|
var header = parseHeader(blobview);
|
|
if (header.version > 4) {
|
|
console.warn('mp3 file with unknown metadata version');
|
|
reject();
|
|
return;
|
|
}
|
|
|
|
blobview.getMore(blobview.index, header.length, (moreview, err) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
resolve(parseFrames(header, moreview));
|
|
} catch(e) {
|
|
console.warn('Invalid ID3v2 data for', blobview.blob.name, ':', e);
|
|
reject(e);
|
|
}
|
|
});
|
|
}).catch((e) => {
|
|
return LazyLoader.load('js/metadata/id3v1.js').then(() => {
|
|
return ID3v1Metadata.parse(blobview);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse the header of an ID3 tag.
|
|
*
|
|
* @param {BlobView} blobview The audio file to parse.
|
|
* @return {Object} An object describing the header's values.
|
|
*/
|
|
function parseHeader(blobview) {
|
|
// First three bytes are "ID3" or we wouldn't be here
|
|
blobview.seek(3);
|
|
|
|
var header = {
|
|
get versionString() {
|
|
return 'id3v2.' + this.version + '.' + this.revision;
|
|
},
|
|
|
|
get tag_unsynchronized() {
|
|
return (this.flags & 0x80) !== 0;
|
|
},
|
|
|
|
get has_extended_header() {
|
|
return (this.flags & 0x40) !== 0;
|
|
}
|
|
};
|
|
|
|
header.version = blobview.readUnsignedByte();
|
|
header.revision = blobview.readUnsignedByte();
|
|
|
|
header.flags = blobview.readUnsignedByte();
|
|
header.length = blobview.readID3Uint28BE();
|
|
|
|
return header;
|
|
}
|
|
|
|
/**
|
|
* Parse all the ID3 frames in this tag.
|
|
*
|
|
* @param {Object} header The header object for this ID3 tag.
|
|
* @param {BlobView} blobview The audio file to parse.
|
|
* @return {Object} The metadata extracted from the ID3 tag.
|
|
*/
|
|
function parseFrames(header, blobview) {
|
|
var metadata = {};
|
|
metadata.tag_format = header.versionString;
|
|
|
|
// In id3v2.3, the "unsynchronized" flag in the tag header applies to
|
|
// the whole tag (excluding the tag header). In id3v2.4, the flag is just
|
|
// an indicator that we should expect to see the "unsynchronized" flag
|
|
// set on all the frames.
|
|
if (header.tag_unsynchronized && header.version === 3) {
|
|
blobview = deunsync(blobview, header.length);
|
|
}
|
|
|
|
// Skip the extended header, if there is one.
|
|
if (header.has_extended_header) {
|
|
var extended_header_size;
|
|
if (header.version === 4) {
|
|
// In id3v2.4, the size includes itself, i.e. the rest of the header
|
|
// is |extended_header_size - 4|.
|
|
extended_header_size = blobview.readID3Uint28BE() - 4;
|
|
} else { // id3version === 3
|
|
// In id3v2.3, the size *excludes* itself, i.e. the rest of the header
|
|
// is |extended_header_size|.
|
|
extended_header_size = blobview.readUnsignedInt();
|
|
}
|
|
blobview.advance(extended_header_size);
|
|
}
|
|
|
|
while (blobview.index < blobview.byteLength) {
|
|
// If there is a null byte here, then we've found padding and we're done.
|
|
if (blobview.getUint8(blobview.index) === 0) {
|
|
break;
|
|
}
|
|
parseFrame(header, blobview, metadata);
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
/**
|
|
* Parse an individual ID3 frame.
|
|
*
|
|
* @param {Object} header The header object for this ID3 tag.
|
|
* @param {BlobView} blobview The audio file being parsed.
|
|
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
|
*/
|
|
function parseFrame(header, blobview, metadata) {
|
|
var frameid, framesize, frame_format_flags, frame_unsynchronized = false,
|
|
data_len_indicator = false, unsupported_flags = false;
|
|
|
|
switch (header.version) {
|
|
case 2:
|
|
frameid = blobview.readBinaryText(3);
|
|
framesize = blobview.readUint24();
|
|
break;
|
|
case 3:
|
|
frameid = blobview.readBinaryText(4);
|
|
framesize = blobview.readUnsignedInt();
|
|
// Ignore the frame status flags, since they only apply when modifying the
|
|
// file/tag.
|
|
blobview.readUnsignedByte();
|
|
frame_format_flags = blobview.readUnsignedByte();
|
|
|
|
unsupported_flags = (frame_format_flags !== 0);
|
|
break;
|
|
case 4:
|
|
frameid = blobview.readBinaryText(4);
|
|
framesize = blobview.readID3Uint28BE();
|
|
blobview.readUnsignedByte(); // As above, ignore the frame status flags.
|
|
frame_format_flags = blobview.readUnsignedByte();
|
|
|
|
data_len_indicator = ((frame_format_flags & 0b00000001) !== 0);
|
|
frame_unsynchronized = ((frame_format_flags & 0b00000010) !== 0);
|
|
unsupported_flags = ((frame_format_flags & 0b11111100) !== 0);
|
|
break;
|
|
}
|
|
|
|
var nextframe = blobview.index + framesize;
|
|
var propname = ID3V2FRAMES[frameid];
|
|
|
|
// Skip frames we don't care about.
|
|
if (!propname) {
|
|
blobview.seek(nextframe);
|
|
return;
|
|
}
|
|
|
|
// Skip frames with flags we don't understand.
|
|
if (unsupported_flags) {
|
|
console.warn('Skipping', frameid, 'frame with unsupported format flags',
|
|
'0b' + frame_format_flags.toString(2));
|
|
blobview.seek(nextframe);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
var frameview, framevalue, data_len;
|
|
|
|
if (data_len_indicator) {
|
|
data_len = blobview.readID3Uint28BE();
|
|
framesize -= 4;
|
|
}
|
|
|
|
if (frame_unsynchronized) {
|
|
frameview = deunsync(blobview, framesize);
|
|
framesize = frameview.sliceLength;
|
|
if (data_len !== undefined && framesize != data_len) {
|
|
console.warn('de-unsynced frame size (' + framesize + ') doesn\'t' +
|
|
'match data length indicator (' + data_len + ')');
|
|
}
|
|
} else {
|
|
frameview = blobview;
|
|
}
|
|
|
|
switch (frameid) {
|
|
case 'TPE1': case 'TP1': // artist
|
|
case 'TALB': case 'TAL': // album
|
|
case 'TIT2': case 'TT2': // title
|
|
framevalue = readTextFrame(header, frameview, framesize);
|
|
break;
|
|
case 'TRCK': case 'TRK': // track
|
|
case 'TPOS': case 'TPA': // disc
|
|
framevalue = readNumericPairFrame(header, frameview, framesize);
|
|
if (framevalue[1] !== null) {
|
|
metadata[ID3V2SECONDARYFRAMES[frameid]] = framevalue[1];
|
|
}
|
|
framevalue = framevalue[0];
|
|
break;
|
|
case 'APIC': case 'PIC': // picture
|
|
framevalue = readPicFrame(header, frameview, framesize);
|
|
break;
|
|
default:
|
|
console.error('Should not have gotten here');
|
|
}
|
|
|
|
if (framevalue !== null) {
|
|
metadata[propname] = framevalue;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Error parsing mp3 metadata frame', frameid, ':', e);
|
|
}
|
|
|
|
// Make sure we're at the start of the next frame before continuing
|
|
blobview.seek(nextframe);
|
|
}
|
|
|
|
/**
|
|
* Reverse the ID3 unsynchronization so that we get the unescaped data. This
|
|
* will advance the view to the end of the unsynchronized block so that you
|
|
* can continue to use the original view once you're finished with the
|
|
* deunsynced view.
|
|
*
|
|
* @param {BlobView} view The original BlobView for the ID3 tag.
|
|
* @param {Number} framesize The size of the unsynchronized frame.
|
|
* @return {BlobView} A new BlobView with the deunsynced data.
|
|
*/
|
|
function deunsync(view, framesize) {
|
|
// To de-unsychronize a frame, we need to convert all instances of
|
|
// |0xff 00| to |0xff|.
|
|
var data = new Uint8Array(framesize), was0xff = false, dataIndex = 0;
|
|
for (var i = 0; i < framesize; i++) {
|
|
var b = view.readUnsignedByte();
|
|
if (was0xff && b === 0x00) {
|
|
continue;
|
|
}
|
|
was0xff = (b === 0xff);
|
|
data[dataIndex++] = b;
|
|
}
|
|
|
|
// Create a new BlobView with the de-unsynchronized data.
|
|
var deunsynced_view = new BlobView.getFromArrayBuffer(
|
|
data.buffer, 0, dataIndex, view.littleEndian
|
|
);
|
|
deunsynced_view.deunsynced = true;
|
|
return deunsynced_view;
|
|
}
|
|
|
|
/**
|
|
* Read the value from a textual ID3 frame (one whose name starts with "T",
|
|
* and isn't "TXXX").
|
|
*
|
|
* @param {Object} header The header object for this ID3 tag.
|
|
* @param {BlobView} view The audio file being parsed.
|
|
* @param {Number} size The size in bytes of this frame's value.
|
|
* @param {Number} encoding The encoding for this string (0: latin-1,
|
|
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
|
* the first byte of the value.
|
|
* @param {String} The value of this frame.
|
|
*/
|
|
function readTextFrame(header, view, size, encoding) {
|
|
if (encoding === undefined) {
|
|
encoding = view.readUnsignedByte();
|
|
size = size - 1;
|
|
}
|
|
if (header.version === 4) {
|
|
// In id3v2.4, all text frames can have multiple values, separated by
|
|
// the null character. For now, we join them together to make one big
|
|
// string.
|
|
return readEncodedTextArray(view, size, encoding);
|
|
} else {
|
|
// In id3v2.3, some text frames can have multiple values, separated by
|
|
// '/', so we don't need to do anything special here. In fact, even if
|
|
// we start treating multi-value text frames as an array, we probably
|
|
// shouldn't do anything, since '/' is a bad delimiter.
|
|
return readEncodedText(view, size, encoding);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the value from a numeric pair frame (track number or disc number).
|
|
*
|
|
* @param {Object} header The header object for this ID3 tag.
|
|
* @param {BlobView} view The audio file being parsed.
|
|
* @param {Number} size The size in bytes of this frame's value.
|
|
* @param {Number} encoding The encoding for this string (0: latin-1,
|
|
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
|
* the first byte of the value.
|
|
* @param {Array} The value of this frame, as two Numbers.
|
|
*/
|
|
function readNumericPairFrame(header, view, size, encoding) {
|
|
var text = readTextFrame(header, view, size, encoding);
|
|
var pair = text.split('/', 2).map(function(val) {
|
|
var i = parseInt(val, 10);
|
|
return isNaN(i) || !isFinite(i) ? null : i;
|
|
});
|
|
if (pair.length === 1) {
|
|
pair.push(null);
|
|
}
|
|
return pair;
|
|
}
|
|
|
|
/**
|
|
* Read the value from a picture frame.
|
|
*
|
|
* @param {Object} header The header object for this ID3 tag.
|
|
* @param {BlobView} view The audio file being parsed.
|
|
* @param {Number} size The size in bytes of this frame's value.
|
|
* @param {Picture} The value of this frame.
|
|
*/
|
|
function readPicFrame(header, view, size) {
|
|
var start = view.index;
|
|
var encoding = view.readUnsignedByte();
|
|
var mimetype;
|
|
// mimetype is different for old PIC frames and new APIC frames
|
|
if (header.version === 2) {
|
|
mimetype = view.readLatin1Text(3);
|
|
if (mimetype === 'JPG') {
|
|
mimetype = 'image/jpeg';
|
|
} else if (mimetype === 'PNG') {
|
|
mimetype = 'image/png';
|
|
}
|
|
} else {
|
|
mimetype = view.readNullTerminatedLatin1Text(size - 1);
|
|
if (mimetype === '-->') {
|
|
// XXX: We don't support URLs (the spec recommends against them anyway).
|
|
console.error('URL for cover art is not supported.');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// We ignore these next two fields.
|
|
view.readUnsignedByte(); // kind
|
|
readEncodedText(view, size - (view.index - start), encoding); // desc
|
|
|
|
var picstart = view.sliceOffset + view.viewOffset + view.index;
|
|
var picsize = size - (view.index - start);
|
|
var picend = picstart + picsize;
|
|
|
|
if (view.deunsynced) {
|
|
return {
|
|
flavor: 'unsynced',
|
|
blob: new Blob(
|
|
[view.buffer.slice(picstart, picend)],
|
|
{type: mimetype}
|
|
)
|
|
};
|
|
} else {
|
|
// Now return an object that specifies where to pull the image from
|
|
// The properties of this object can be passed to blob.slice()
|
|
return {
|
|
flavor: 'embedded',
|
|
start: picstart,
|
|
end: picend,
|
|
type: mimetype
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read an array of strings from a frame.
|
|
*
|
|
* @param {BlobView} view The audio file being parsed.
|
|
* @param {Number} size The size in bytes of this frame's value.
|
|
* @param {Number} encoding The encoding for this string (0: latin-1,
|
|
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
|
* the first byte of the value.
|
|
* @param {String} The strings, joined by " / ".
|
|
*/
|
|
function readEncodedTextArray(view, size, encoding) {
|
|
var text;
|
|
switch (encoding) {
|
|
case 0:
|
|
text = view.readLatin1Text(size);
|
|
break;
|
|
case 1:
|
|
text = view.readUTF16Text(size, undefined);
|
|
break;
|
|
case 2:
|
|
text = view.readUTF16Text(size, false);
|
|
break;
|
|
case 3:
|
|
text = view.readUTF8Text(size);
|
|
break;
|
|
default:
|
|
throw Error('unknown text encoding');
|
|
}
|
|
|
|
// Remove any trailing nulls and replace nulls in the middle with ' / '.
|
|
// XXX: When the database supports arrays of values for each field, we
|
|
// should split this into a proper array!
|
|
return text.replace(/\0+$/, '').replace('\0', ' / ');
|
|
}
|
|
|
|
/**
|
|
* Read a single string from a frame.
|
|
*
|
|
* @param {BlobView} view The audio file being parsed.
|
|
* @param {Number} size The size in bytes of this frame's value.
|
|
* @param {Number} encoding The encoding for this string (0: latin-1,
|
|
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
|
* the first byte of the value.
|
|
* @param {String} The string.
|
|
*/
|
|
function readEncodedText(view, size, encoding) {
|
|
switch (encoding) {
|
|
case 0:
|
|
return view.readNullTerminatedLatin1Text(size);
|
|
case 1:
|
|
// Round down to the nearest 16 bits.
|
|
return view.readNullTerminatedUTF16Text(size - (size % 2), undefined);
|
|
case 2:
|
|
// Round down to the nearest 16 bits.
|
|
return view.readNullTerminatedUTF16Text(size - (size % 2), false);
|
|
case 3:
|
|
return view.readNullTerminatedUTF8Text(size);
|
|
default:
|
|
throw Error('unknown text encoding');
|
|
}
|
|
}
|
|
|
|
return {
|
|
parse: parse
|
|
};
|
|
|
|
})();
|