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