Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
454 lines
14 KiB
JavaScript
Executable file
454 lines
14 KiB
JavaScript
Executable file
/* 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
|
|
};
|
|
|
|
})();
|