/* 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 }; })();