Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
231 lines
9.1 KiB
JavaScript
231 lines
9.1 KiB
JavaScript
/* exported AlbumArt */
|
|
'use strict';
|
|
|
|
/*
|
|
* Metadata describing the cover art for a track. Cover art comes in one of
|
|
* three flavors: embedded (stored in the audio track's metadata), unsynced
|
|
* (like embedded, but stored in an unsynchronized block of ID3 data; we
|
|
* ultimately save it in an external file), or external (stored as a separate
|
|
* file in the same directory as the audio track).
|
|
*
|
|
* @typedef {Object} Picture
|
|
* @property {String} flavor How the art was stored; one of "embedded",
|
|
* "unsynced", or "external".
|
|
* @property {Number} [start] The offset in bytes to where the picture is
|
|
* stored in the audio file; only applies when flavor="embedded".
|
|
* @property {Number} [end] The offset in bytes to the end of where the
|
|
* picture is stored in the audio file; only applies when flavor="embedded".
|
|
* @property {Number} [type] The mimetype of the picture; only applies when
|
|
* flavor="embedded".
|
|
* @property {String} [filename] The path on the filesystem to the original
|
|
* (full-size) picture; only applies when flavor="external" or "unsynced".
|
|
*/
|
|
|
|
var AlbumArt = (function() {
|
|
var pictureStorage = navigator.getDeviceStorage('pictures');
|
|
|
|
// A cache of metadata Picture objects for albums with external cover art. The
|
|
// key is the directory name for the song in question.
|
|
var externalCoverCache = {};
|
|
|
|
// A cache of filenames for saved cover art (generated from unsynced ID3
|
|
// tags). The value is the absolute path name.
|
|
var savedCoverCache = new Set();
|
|
|
|
/**
|
|
* Perform additional processing for cover art, such as checking for external
|
|
* artwork and saving temporary image blobs (for unsynchronized ID3 data).
|
|
*
|
|
* @param {Blob} blob The audio file.
|
|
* @param {Metadata} metadata The metadata for the file.
|
|
* @return {Promise} A Promise resolving to the metadata object with any
|
|
* additional cover art fields added as necessary.
|
|
*/
|
|
function process(blob, metadata) {
|
|
// If a blob isn't backed by an actual file, we can't do anything here
|
|
// except return what we have.
|
|
var filename = blob.name;
|
|
if (!filename) {
|
|
return Promise.resolve(metadata);
|
|
}
|
|
|
|
if (!metadata.picture) {
|
|
// We don't have any embedded album art; see if there's any external album
|
|
// art in the same directory.
|
|
var lastSlash = filename.lastIndexOf('/');
|
|
var dirName = filename.substring(0, lastSlash + 1);
|
|
|
|
// First, check if we've recently fetched this cover; if so, just use the
|
|
// cached results so we're not going out to the disk more than necessary.
|
|
if (dirName in externalCoverCache) {
|
|
metadata.picture = externalCoverCache[dirName];
|
|
return Promise.resolve(metadata);
|
|
}
|
|
|
|
// Try to find external album art using a handful of common names. The
|
|
// possibilities listed here appear to be the most common, based on a
|
|
// brief survey of what people use in the wild.
|
|
return new Promise(function(resolve, reject) {
|
|
var possibleFilenames = ['folder.jpg', 'cover.jpg', 'front.jpg'];
|
|
var tryFetchExternalCover = function(index) {
|
|
if (index === possibleFilenames.length) {
|
|
// We couldn't find any external album art.
|
|
externalCoverCache[dirName] = null;
|
|
resolve(metadata);
|
|
return;
|
|
}
|
|
|
|
var externalCoverFilename = dirName + possibleFilenames[index];
|
|
var getcoverrequest = pictureStorage.get(externalCoverFilename);
|
|
getcoverrequest.onsuccess = function() {
|
|
metadata.picture = { flavor: 'external',
|
|
filename: externalCoverFilename };
|
|
// Cache the picture object we generated to make things faster.
|
|
externalCoverCache[dirName] = metadata.picture;
|
|
resolve(metadata);
|
|
};
|
|
getcoverrequest.onerror = function() {
|
|
tryFetchExternalCover(index + 1);
|
|
};
|
|
};
|
|
tryFetchExternalCover(0);
|
|
});
|
|
} else if (metadata.picture.blob) {
|
|
// We have album art in a separate blob that we need to save somewhere.
|
|
// Two reasons :
|
|
// - we had an unsynced ID3 frame with the art and needed to de-unsync
|
|
// it.
|
|
// - we had album art from a VorbisComment.
|
|
//
|
|
// First, try to make a unique filename for the image based on the artist,
|
|
// album, and file size. The file size is essentially a poor man's
|
|
// checksum to make sure we aren't reusing an inappropriate image, such as
|
|
// if the user updated the embedded art.
|
|
var albumKey;
|
|
if (metadata.artist || metadata.album) {
|
|
// Don't let the artist or album strings be too long; VFAT has a maximum
|
|
// of 255 characters for the path, and we need some room for the rest of
|
|
// it!
|
|
var artist = (metadata.artist || '').substr(0, 64);
|
|
var album = (metadata.album || '').substr(0, 64);
|
|
albumKey = artist + '.' + album;
|
|
} else {
|
|
// If there's no album or artist, use the path to the file instead; we
|
|
// truncate from the end to help ensure uniqueness.
|
|
albumKey = filename.substr(-128);
|
|
}
|
|
|
|
var coverBlob = metadata.picture.blob;
|
|
delete metadata.picture.blob;
|
|
|
|
// coverBlob is always a JPEG or PNG.
|
|
var extension = coverBlob.type === 'image/jpeg' ? '.jpg' : '.png';
|
|
var imageFilename = vfatEscape(albumKey) + '.' + coverBlob.size +
|
|
extension;
|
|
var storageName = getStorageName(filename);
|
|
return checkSaveCover(coverBlob, imageFilename, storageName).then(
|
|
function(savedFile) {
|
|
metadata.picture.filename = savedFile;
|
|
return metadata;
|
|
}, function(err) {
|
|
delete metadata.picture;
|
|
return metadata;
|
|
}
|
|
);
|
|
} else {
|
|
// We have embedded album art! All the processing is already done, so we
|
|
// can just return.
|
|
return Promise.resolve(metadata);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escape any characters that are illegal on the VFAT filesystem.
|
|
*
|
|
* @param {string} str The string to escape.
|
|
* @return {string} The escaped string.
|
|
*/
|
|
function vfatEscape(str) {
|
|
return str.replace(/["*\/:<>?\|]/g, '_');
|
|
}
|
|
|
|
/**
|
|
* Get the name of the storage area for an absolute filename, with leading
|
|
* and trailing slashes (e.g. '/sdcard/'). If the filename is relative,
|
|
* return the empty string (i.e. use the default storage area.)
|
|
*
|
|
* @param {string} filename The path to the file.
|
|
* @return {string} The file's storage area, or the empty string if the path
|
|
* was relative.
|
|
*/
|
|
function getStorageName(filename) {
|
|
// `filename` is usually a fully qualified name (perhaps something like
|
|
// /sdcard/Music/file.mp3). On desktop, it's a relative name, but desktop
|
|
// only has one storage area anyway.
|
|
if (filename[0] === '/') {
|
|
var slashIndex = filename.indexOf('/', 1);
|
|
if (slashIndex < 0) {
|
|
var err = Error('handleCoverArt: Bad filename: "' + filename + '"');
|
|
console.error(err);
|
|
return Promise.reject(err);
|
|
}
|
|
|
|
// Get the storage name, e.g. /sdcard/
|
|
return filename.substring(0, slashIndex + 1);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Check for the existence of an image on the audio file's storage area,
|
|
* and if it's not present, try to save it.
|
|
*
|
|
* @param {Blob} coverBlob The blob for the full-size cover art.
|
|
* @param {string} imageFilename A relative filename for the image.
|
|
* @param {string} storageName The name of the storage area too use for
|
|
* saving the image. May be an empty string or a path with trailing '/'.
|
|
* For instance, to store on the SD card, pass '/sdcard/'.
|
|
* @return {Promise} A Promise that resolves when finished, providing the
|
|
* filename of the saved image.
|
|
*/
|
|
function checkSaveCover(coverBlob, imageFilename, storageName) {
|
|
// We want to put the image in the same storage area as the audio track it's
|
|
// for. Since the audio track could be in any storage area, we examine its
|
|
// filename to get the storage name; the storage name is always the first
|
|
// part of the (absolute) filename, so we grab that and build an absolute
|
|
// path for the image. This will ensure that the generic deviceStorage we
|
|
// use for pictures ("pictureStorage") puts the image where we want it.
|
|
var imageAbsPath = storageName + '.music/covers/' + imageFilename;
|
|
if (savedCoverCache.has(imageAbsPath)) {
|
|
return Promise.resolve(imageAbsPath);
|
|
}
|
|
|
|
return new Promise(function(resolve, reject) {
|
|
var getrequest = pictureStorage.get(imageAbsPath);
|
|
|
|
// We already have the image. We're done!
|
|
getrequest.onsuccess = function() {
|
|
savedCoverCache.add(imageAbsPath);
|
|
resolve(imageAbsPath);
|
|
};
|
|
|
|
// We don't have the image yet. Let's save it.
|
|
getrequest.onerror = function() {
|
|
var saverequest = pictureStorage.addNamed(coverBlob, imageAbsPath);
|
|
saverequest.onerror = function() {
|
|
console.error('Could not save cover image', imageFilename);
|
|
};
|
|
|
|
// Don't bother waiting for saving to finish. Just return.
|
|
savedCoverCache.add(imageAbsPath);
|
|
resolve(imageAbsPath);
|
|
};
|
|
});
|
|
}
|
|
|
|
return {
|
|
process: process,
|
|
pictureStorage: pictureStorage
|
|
};
|
|
})();
|