VulpesOS/assets/webapps/music.gaiamobile.org/js/metadata/metadata_scripts.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

725 lines
23 KiB
JavaScript

/* exported BlobView */
'use strict';
var BlobView = (function() {
function fail(msg) {
throw Error(msg);
}
var decoderCache = {};
function getDecoder(encoding) {
if (encoding in decoderCache) {
return decoderCache[encoding];
}
var decoder = decoderCache[encoding] = new TextDecoder(encoding);
return decoder;
}
// This constructor is for internal use only.
// Use the BlobView.get() factory function or the getMore instance method
// to obtain a BlobView object.
function BlobView(blob, sliceOffset, sliceLength, slice,
viewOffset, viewLength, littleEndian)
{
this.blob = blob; // The parent blob that the data is from
this.sliceOffset = sliceOffset; // The start address within the blob
this.sliceLength = sliceLength; // How long the slice is
this.slice = slice; // The ArrayBuffer of slice data
this.viewOffset = viewOffset; // The start of the view within the slice
this.viewLength = viewLength; // The length of the view
this.littleEndian = littleEndian; // Read little endian by default?
// DataView wrapper around the ArrayBuffer
this.view = new DataView(slice, viewOffset, viewLength);
// These fields mirror those of DataView
this.buffer = slice;
this.byteLength = viewLength;
this.byteOffset = viewOffset;
this.index = 0; // The read methods keep track of the read position
}
// Async factory function
BlobView.get = function(blob, offset, length, callback, littleEndian) {
if (offset < 0) {
fail('negative offset');
}
if (length < 0) {
fail('negative length');
}
if (offset > blob.size) {
fail('offset larger than blob size');
}
// Don't fail if the length is too big; just reduce the length
if (offset + length > blob.size) {
length = blob.size - offset;
}
var slice = blob.slice(offset, offset + length);
var reader = new FileReader();
reader.readAsArrayBuffer(slice);
reader.onloadend = function() {
var result = null;
if (reader.result) {
result = new BlobView(blob, offset, length, reader.result,
0, length, littleEndian || false);
}
callback(result, reader.error);
};
};
// Synchronous factory function for use when you have an array buffer and want
// to treat it as a BlobView (e.g. to use the readXYZ functions). We need
// this for the music app, which uses an array buffer to hold
// de-unsynchronized ID3 frames.
BlobView.getFromArrayBuffer = function(buffer, offset, length, littleEndian) {
return new BlobView(null, offset, length, buffer, offset, length,
littleEndian);
};
BlobView.prototype = {
constructor: BlobView,
// This instance method is like the BlobView.get() factory method,
// but it is here because if the current buffer includes the requested
// range of bytes, they can be passed directly to the callback without
// going back to the blob to read them
getMore: function(offset, length, callback) {
// If we made this BlobView from an array buffer, there's no blob backing
// it, and so it's impossible to get more data.
if (!this.blob) {
fail('no blob backing this BlobView');
}
if (offset >= this.sliceOffset &&
offset + length <= this.sliceOffset + this.sliceLength) {
// The quick case: we already have that region of the blob
callback(new BlobView(this.blob,
this.sliceOffset, this.sliceLength, this.slice,
offset - this.sliceOffset, length,
this.littleEndian));
}
else {
// Otherwise, we have to do an async read to get more bytes
BlobView.get(this.blob, offset, length, callback, this.littleEndian);
}
},
// Set the default endianness for the other methods
littleEndian: function() {
this.littleEndian = true;
},
bigEndian: function() {
this.littleEndian = false;
},
// These "get" methods are just copies of the DataView methods, except
// that they honor the default endianness
getUint8: function(offset) {
return this.view.getUint8(offset);
},
getInt8: function(offset) {
return this.view.getInt8(offset);
},
getUint16: function(offset, le) {
return this.view.getUint16(offset,
le !== undefined ? le : this.littleEndian);
},
getInt16: function(offset, le) {
return this.view.getInt16(offset,
le !== undefined ? le : this.littleEndian);
},
getUint32: function(offset, le) {
return this.view.getUint32(offset,
le !== undefined ? le : this.littleEndian);
},
getInt32: function(offset, le) {
return this.view.getInt32(offset,
le !== undefined ? le : this.littleEndian);
},
getFloat32: function(offset, le) {
return this.view.getFloat32(offset,
le !== undefined ? le : this.littleEndian);
},
getFloat64: function(offset, le) {
return this.view.getFloat64(offset,
le !== undefined ? le : this.littleEndian);
},
// These "read" methods read from the current position in the view and
// update that position accordingly
readByte: function() {
return this.view.getInt8(this.index++);
},
readUnsignedByte: function() {
return this.view.getUint8(this.index++);
},
readShort: function(le) {
var val = this.view.getInt16(this.index,
le !== undefined ? le : this.littleEndian);
this.index += 2;
return val;
},
readUnsignedShort: function(le) {
var val = this.view.getUint16(this.index,
le !== undefined ? le : this.littleEndian);
this.index += 2;
return val;
},
readInt: function(le) {
var val = this.view.getInt32(this.index,
le !== undefined ? le : this.littleEndian);
this.index += 4;
return val;
},
readUnsignedInt: function(le) {
var val = this.view.getUint32(this.index,
le !== undefined ? le : this.littleEndian);
this.index += 4;
return val;
},
readFloat: function(le) {
var val = this.view.getFloat32(this.index,
le !== undefined ? le : this.littleEndian);
this.index += 4;
return val;
},
readDouble: function(le) {
var val = this.view.getFloat64(this.index,
le !== undefined ? le : this.littleEndian);
this.index += 8;
return val;
},
// Methods to get and set the current position
tell: function() {
return this.index;
},
remaining: function() {
return this.byteLength - this.index;
},
seek: function(index) {
if (index < 0) {
fail('negative index');
}
if (index > this.byteLength) {
fail('index greater than buffer size');
}
this.index = index;
},
advance: function(n) {
var index = this.index + n;
if (index < 0) {
fail('advance past beginning of buffer');
}
// It's usual that when we finished reading one target view,
// the index is advanced to the start(previous end + 1) of next view,
// and the new index will be equal to byte length(the last index + 1),
// we will not fail on it because it means the reading is finished,
// or do we have to warn here?
if (index > this.byteLength) {
fail('advance past end of buffer');
}
this.index = index;
},
// Additional methods to read other useful things
getUnsignedByteArray: function(offset, n) {
return new Uint8Array(this.buffer, offset + this.viewOffset, n);
},
// Additional methods to read other useful things
readUnsignedByteArray: function(n) {
var val = new Uint8Array(this.buffer, this.index + this.viewOffset, n);
this.index += n;
return val;
},
getBit: function(offset, bit) {
var byte = this.view.getUint8(offset);
return (byte & (1 << bit)) !== 0;
},
getUint24: function(offset, le) {
var b1, b2, b3;
if (le !== undefined ? le : this.littleEndian) {
b1 = this.view.getUint8(offset);
b2 = this.view.getUint8(offset + 1);
b3 = this.view.getUint8(offset + 2);
}
else { // big end first
b3 = this.view.getUint8(offset);
b2 = this.view.getUint8(offset + 1);
b1 = this.view.getUint8(offset + 2);
}
return (b3 << 16) + (b2 << 8) + b1;
},
readUint24: function(le) {
var value = this.getUint24(this.index, le);
this.index += 3;
return value;
},
// There are lots of ways to read strings. We support binary (raw 8-bit
// codepoints), Latin-1, UTF-8, and UTF-16. For the latter three, we also
// support null-terminated versions.
getBinaryText: function(offset, len) {
var bytes = new Uint8Array(this.buffer, offset + this.viewOffset, len);
return String.fromCharCode.apply(String, bytes);
},
readBinaryText: function(len) {
var s = this.getBinaryText(this.index, len);
this.index += len;
return s;
},
getLatin1Text: function(offset, len) {
var bytes = new Uint8Array(this.buffer, offset + this.viewOffset, len);
return getDecoder('latin1').decode(bytes);
},
readLatin1Text: function(len) {
var s = this.getLatin1Text(this.index, len);
this.index += len;
return s;
},
getUTF8Text: function(offset, len) {
var bytes = new Uint8Array(this.buffer, offset + this.viewOffset, len);
return getDecoder('utf-8').decode(bytes);
},
readUTF8Text: function(len) {
var s = this.getUTF8Text(this.index, len);
this.index += len;
return s;
},
// Get UTF16 text. If le is not specified, expect a BOM to define
// endianness. If le is true, read UTF16LE, if false, UTF16BE.
getUTF16Text: function(offset, len, le) {
if (len % 2) {
fail('len must be a multiple of two');
}
var bytes = new Uint8Array(this.buffer, offset + this.viewOffset, len);
if (le === null || le === undefined) {
var BOM = (bytes[0] << 8) + bytes[1];
if (BOM === 0xFEFF) {
bytes = bytes.subarray(2);
le = false;
} else if (BOM === 0xFFFE) {
bytes = bytes.subarray(2);
le = true;
} else {
le = true;
}
}
var encoding = le ? 'utf-16le' : 'utf-16be';
return getDecoder(encoding).decode(bytes);
},
readUTF16Text: function(len, le) {
var s = this.getUTF16Text(this.index, len, le);
this.index += len;
return s;
},
// Read 4 bytes, ignore the high bit and combine them into a 28-bit
// big-endian unsigned integer.
// This format is used by the ID3v2 metadata.
getID3Uint28BE: function(offset) {
var b1 = this.view.getUint8(offset) & 0x7f;
var b2 = this.view.getUint8(offset + 1) & 0x7f;
var b3 = this.view.getUint8(offset + 2) & 0x7f;
var b4 = this.view.getUint8(offset + 3) & 0x7f;
return (b1 << 21) | (b2 << 14) | (b3 << 7) | b4;
},
readID3Uint28BE: function() {
var value = this.getID3Uint28BE(this.index);
this.index += 4;
return value;
},
// Read bytes up to and including a null terminator, but never
// more than size bytes. And return as a Latin1 string. If advance_by_size
// is true, this will always seek ahead by `size` bytes, even if a null
// character was found earlier.
readNullTerminatedLatin1Text: function(size, advance_by_size = false) {
var bytes = new Uint8Array(this.buffer, this.viewOffset + this.index,
size);
var nil = bytes.indexOf(0);
if (nil !== -1) {
bytes = bytes.subarray(0, nil);
}
var s = getDecoder('latin1').decode(bytes);
if (nil === -1 || advance_by_size) {
this.index += size;
} else {
this.index += nil + 1;
}
return s;
},
// Read bytes up to and including a null terminator, but never
// more than size bytes. And return as a UTF8 string. If advance_by_size
// is true, this will always seek ahead by `size` bytes, even if a null
// character was found earlier.
readNullTerminatedUTF8Text: function(size, advance_by_size = false) {
var bytes = new Uint8Array(this.buffer, this.viewOffset + this.index,
size);
var nil = bytes.indexOf(0);
if (nil !== -1) {
bytes = bytes.subarray(0, nil);
}
var s = getDecoder('utf-8').decode(bytes);
if (nil === -1 || advance_by_size) {
this.index += size;
} else {
this.index += nil + 1;
}
return s;
},
// Read UTF16 text. If le is not specified, expect a BOM to define
// endianness. If le is true, read UTF16LE, if false, UTF16BE
// Read until we find a null-terminator, but never more than size bytes.
// If advance_by_size is true, this will always seek ahead by `size` bytes,
// even if a null character was found earlier.
readNullTerminatedUTF16Text: function(size, le, advance_by_size = false) {
if (size % 2) {
fail('size must be a multiple of two');
}
for (var len = 0; len < size; len += 2) {
if (this.getUint16(this.index + len, le) === 0) {
break;
}
}
var s = this.getUTF16Text(this.index, len, le);
if (len === size || advance_by_size) {
this.index += size;
} else {
this.index += len + 2;
}
return s;
}
};
// We don't want users of this library to accidentally call the constructor
// instead of using one of the factory functions, so we return a dummy object
// instead of the real constructor. If someone really needs to get at the
// real constructor, the contructor property of the prototype refers to it.
return {
get: BlobView.get,
getFromArrayBuffer: BlobView.getFromArrayBuffer
};
}());
/* global FLACMetadata, ForwardLockMetadata, ID3v1Metadata, ID3v2Metadata,
LazyLoader, MP4Metadata, OggMetadata */
/* exported MetadataFormats */
'use strict';
/**
* Delegates metadata parsing to the appropriate parser based on magic header
* values.
*/
var MetadataFormats = (function() {
/*
* This is the list of formats that we know how to parse. Each format has
* three properties:
*
* @property {String} file The path to the file for parsing this metadata
* format.
* @property {Object} module A getter that returns the module object for this
* parser. Note: It *must* be a getter because it needs to be evaluated
* *after* the file is loaded.
* @property {Function} match A function that takes a BlobView of the file
* and returns true if the file uses this metadata format.
*/
var formats = [
{
file: 'js/metadata/forward_lock.js',
get module() { return ForwardLockMetadata; },
match: function(header) {
return header.getBinaryText(0, 9) === 'LOCKED 1 ';
}
},
{
file: 'js/metadata/id3v2.js',
get module() { return ID3v2Metadata; },
match: function(header) {
return header.getBinaryText(0, 3) === 'ID3';
}
},
{
file: 'js/metadata/ogg.js',
get module() { return OggMetadata; },
match: function(header) {
return header.getBinaryText(0, 4) === 'OggS';
}
},
{
file: 'js/metadata/flac.js',
get module() { return FLACMetadata; },
match: function(header) {
return header.getBinaryText(0, 4) === 'fLaC';
}
},
{
file: 'js/metadata/mp4.js',
get module() { return MP4Metadata; },
match: function(header) {
return header.getBinaryText(4, 4) === 'ftyp';
}
},
{
file: 'js/metadata/id3v1.js',
get module() { return ID3v1Metadata; },
match: function(header) {
return (header.getUint16(0, false) & 0xFFFE) === 0xFFFA;
}
}
];
/**
* Create a new metadata parser for a particular file format.
*
* @param {Object} formatInfo A description of the file format containing a
* `file` attribute for the file to load, and a `module` attribute returning
* the module that contains the parse() method we should call.
*/
function MetadataParser(formatInfo) {
this._formatInfo = formatInfo;
}
MetadataParser.prototype = {
/**
* Parse a file and return a Promise with the metadata.
*
* @param {BlobView} header The file in question.
* @return {Promise} A Promise that resolves with the completed metadata
* object.
*/
parse: function(header) {
var info = this._formatInfo;
return LazyLoader.load(info.file).then(() => {
return info.module.parse(header);
});
}
};
/**
* Find the appropriate metadata parser for a given file.
*
* @param {BlobView} header The file in question.
* @return {MetadataParser} The metadata parser to use for this file.
*/
function findParser(header) {
for (var i = 0; i < formats.length; i++) {
if (formats[i].match(header)) {
return new MetadataParser(formats[i]);
}
}
return null;
}
return {
findParser: findParser
};
})();
/* global BlobView, MetadataFormats */
/* exported AudioMetadata */
'use strict';
// XXX We're hiding the fact that these are JSdoc comments because our linter
// refuses to accept "property" as a valid tag. Grumble grumble.
/*
* Metadata for a track.
*
* @typedef {Object} Metadata
* @property {String} tag_format The format of the tag (e.g. id3v2.4).
* @property {String} artist The track's artist.
* @property {String} album The track's album.
* @property {String} title The track's title.
* @property {Number} [tracknum] The number of the track on the album.
* @property {Number} [trackcount] The total number of tracks in the album.
* @property {Number} [discnum] The number of the disc on the album.
* @property {Number} [disccount] The total number of discs in the album.
* @property {Picture} [picture] The cover art, if any.
* @property {Number} rated The track's rating; starts at 0.
* @property {Number} played The track's play count; starts at 0.
*/
/**
* Parse the metadata for an audio file.
*/
var AudioMetadata = (function() {
/**
* Parse the specified blob and return a Promise with the metadata.
*
* @param {Blob} blob The audio file to parse.
* @param {String} [filename] The name of the file, used as a fallback if
* the metadata has no title field.
* @return {Promise} A Promise returning the parsed metadata object.
*/
function parse(blob, filename = null) {
if (!filename) {
filename = blob.name;
}
// If blob.name exists, it should be an audio file from system
// otherwise it should be an audio blob that probably from network/process
// we can still parse it but we don't need to care about the filename
if (filename) {
// If the file is in the DCIM/ directory and has a .3gp extension
// then it is a video, not a music file and we ignore it
if (filename.slice(0, 5) === 'DCIM/' &&
filename.slice(-4).toLowerCase() === '.3gp') {
return Promise.reject('skipping 3gp video file');
}
}
// If the file is too small to be a music file then ignore it
if (blob.size < 128) {
return Promise.reject('file is empty or too small');
}
// Read the start of the file, figure out what kind it is, and call
// the appropriate parser. Start off with an 64kb chunk of data.
// If the metadata is in that initial chunk we won't have to read again.
return new Promise(function(resolve, reject) {
var headersize = Math.min(64 * 1024, blob.size);
BlobView.get(blob, 0, headersize, function(header, error) {
if (error) {
reject(error);
return;
}
try {
var parser = MetadataFormats.findParser(header);
var promise;
if (parser) {
promise = parser.parse(header);
} else {
// This is some kind of file that we don't know about.
// Let's see if we can play it.
promise = checkPlayability(blob);
}
resolve(promise.then(function(metadata) {
return addDefaultMetadata(metadata || {}, filename);
}));
} catch (e) {
console.error('AudioMetadata.parse:', e, e.stack);
reject(e);
}
});
});
}
/**
* Fill in any default metadata fields, such as a fallback for the title, and
* the rating/playcount.
*
* @param {Object} metadata The metadata from one of our parsers.
* @param {String} filename The name of the underlying file, if any.
* @return {Object} The updated metdata object.
*/
function addDefaultMetadata(metadata, filename) {
if (!metadata.artist) {
metadata.artist = '';
}
if (!metadata.album) {
metadata.album = '';
}
if (!metadata.title) {
// If the blob has a name, use that as a default title in case
// we can't find one in the file
if (filename) {
var p1 = filename.lastIndexOf('/');
var p2 = filename.lastIndexOf('.');
if (p2 <= p1) {
p2 = filename.length;
}
metadata.title = filename.substring(p1 + 1, p2);
} else {
metadata.title = '';
}
}
metadata.rated = metadata.played = 0;
return metadata;
}
/**
* Check if a blob can be played as an audio file.
*
* @param {Blob} blob The file to test.
* @return {Promise} A promise that resolves successfully if the file is
* playable.
*/
function checkPlayability(blob) {
var player = new Audio();
player.mozAudioChannelType = 'content';
var canplay = blob.type && player.canPlayType(blob.type);
if (canplay === 'probably') {
return Promise.resolve();
} else {
return new Promise(function(resolve, reject) {
var url = URL.createObjectURL(blob);
player.src = url;
// XXX: There seems to have been a gecko regression, and error events
// are no longer being fired when we try to play some invalid audio
// files. To work around this, we use a timeout to assume the file is
// unplayable if we nave not gotten any events within a reasonable
// amount of time. See also bugs 1208331 and 1198169.
const CANPLAY_TIMEOUT = 3000;
var timeoutId = setTimeout(() => {
console.error('No oncanplay or error events seen yet.',
'Assuming file is corrupt:', blob.name);
player.onerror();
}, CANPLAY_TIMEOUT);
player.onerror = function() {
clearTimeout(timeoutId);
URL.revokeObjectURL(url);
player.removeAttribute('src');
player.load();
reject('Unplayable music file');
};
player.oncanplay = function() {
clearTimeout(timeoutId);
URL.revokeObjectURL(url);
player.removeAttribute('src');
player.load();
resolve();
};
});
}
}
return {
parse: parse
};
})();