VulpesOS/gaia/shared/js/mediadb.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

2150 lines
81 KiB
JavaScript
Executable file

/* globals indexedDB */
/* exported MediaDB */
'use strict';
/**
* MediaDB.js: a simple interface to DeviceStorage and IndexedDB that serves
* as a model of the filesystem and provides easy access to the
* user's media files and their metadata.
*
* Gaia's media apps (Gallery, Music, Videos) read media files from the phone
* using the DeviceStorage API. They need to keep track of the complete list of
* media files, as well as the metadata (image thumbnails, song titles, etc.)
* they have extracted from those files. It would be much too slow to scan the
* filesystem and read all the metadata from all files each time the apps starts
* up, so the apps need to store the list of files and metadata in an IndexedDB
* database. This library integrates both DeviceStorage and IndexedDB into a
* single API. It keeps the database in sync with the filesystem and provides
* notifications when files are added or deleted.
*
* CONSTRUCTOR
*
* Create a MediaDB object with the MediaDB() constructor. It takes three
* arguments:
*
* mediaType:
* one of the DeviceStorage media types: "pictures", "movies" or "music".
*
* metadataParser:
* your metadata parser function. This function should expect three
* arguments. It will be called with a file to parse and two callback
* functions. It should read metadata from the file and then pass an object
* of metadata to the first callback. If parsing fails it should pass an
* Error object or error message to the second callback. If you omit this
* argument or pass null, a dummy parser that invokes the callback with an
* empty object will be used instead.
*
* options:
* An optional object containing additional MediaDB options.
* Supported options are:
*
* mimeTypes:
* an array of MIME types that specifies the kind of files you are
* interested in and that your metadata parser function knows how to
* handle. DeviceStorage infers MIME type from filename extension and
* filters the files it returns based on their extension. Use this
* property if you want to restrict the set of mime types further.
*
* indexes:
* an array of IndexedDB key path specifications that specify which
* properties of each media record should be indexed. If you want to
* search or sort on anything other than the file name and date you
* should set this property. "size", and "type" are valid keypaths as
* is "metadata.x" where x is any metadata property returned by your
* metadata parser.
*
* version:
* The version of your IndexedDB database. The default value is 1
* Setting it to a larger value will delete all data in the database
* and rebuild it from scratch. If you ever change your metadata parser
* function or alter the array of indexes.
*
* autoscan:
* Whether MediaDB should automatically scan every time it becomes
* ready. The default is true. If you set this to false you are
* responsible for calling scan() in response to the 'ready' event.
*
* batchHoldTime:
* How long (in ms) to wait after finding a new file during a scan
* before reporting it. Longer hold times allow more batching of
* changes. The default is 100ms.
*
* batchSize:
* When batching changes, don't allow the batches to exceed this
* amount. The default is 0 which means no maximum batch size.
*
* updateRecord:
* When upgrading database, MediaDB uses this function to ask client
* app to update the metadata record of specified file. The return
* value of this function is the updated metadata. If client app does
* not update any metadata, client app still needs to return
* file.metadata. The client may also set file.needsReparse to indicate
* that the source file should be reparsed.
*
* reparsedRecord:
* If in updateRecord, a record has been marked with file.needsReparse,
* this function is called after the file has been reparsed to allow
* the client app to merge any old metadata (e.g. metadata set by the
* user). This function takes two arguments: the old metadata and the
* new metadata, and should return the merged metadata.
*
* excludeFilter:
* excludeFilter is used when client app wants MediaDB to filter out
* additional media files. It must be a regular expression object. The
* matched files are filtered out. The original filtering behavior of
* MediaDB will not be change even if excludeFilter is supplied.
*
* MediaDB STATE
*
* A MediaDB object must asynchronously open a connection to its database, and
* asynchronously check on the availability of device storage, which means that
* it is not ready for use when first created. After calling the MediaDB()
* constructor, register an event listener for 'ready' events with
* addEventListener() or by setting the onready property. You must not use
* the MediaDB object until the ready event has been delivered or until
* the state property is set to MediaDB.READY.
*
* The DeviceStorage API is not always available, and MediaDB is not usable if
* DeviceStorage is not usable. If the user removes the SD card from their
* phone, then DeviceStorage will not be able to read or write files,
* obviously. Also, when a USB Mass Storage session is in progress,
* DeviceStorage is not available either. If DeviceStorage is not available
* when a MediaDB object is created, an 'unavailable' event will be fired
* instead of a 'ready' event. Subsequently, a 'ready' event will be fired
* whenever DeviceStorage becomes available, and 'unavailable' will be fired
* whenever DeviceStorage becomes unavailable. Media apps can handle the
* unavailable case by displaying an informative message in an overlay that
* prevents all user interaction with the app.
*
* The 'ready' and 'unavailable' events signal changes to the state of a
* MediaDB object. The state is also available in the state property of the
* object. The possible values of this property are the following:
*
* Value Constant Meaning
* ----------------------------------------------------------------------
* 'opening' MediaDB.OPENING MediaDB is initializing itself
* 'upgrading' MediaDB.UPGRADING MediaDB is upgrading database
* 'enumerable' MediaDB.ENUMERABLE Not fully ready but can be enumerated
* 'ready' MediaDB.READY MediaDB is available and ready for use
* 'nocard' MediaDB.NOCARD Unavailable because there is no sd card
* 'unmounted' MediaDB.UNMOUNTED Unavailable because the card is unmounted
* 'closed' MediaDB.CLOSED Unavailable because close() was called
*
* When an 'unavailable' event is fired, the detail property of the event
* object specifies the reason that the MediaDB is unavailable. It is one of
* the state values 'nocard', 'unmounted' or 'closed'.
*
* The 'nocard' state occurs when device storage is not available because
* there is no SD card in the device. This is typically a permanent failure
* state, and media apps cannot run without an SD card. It can occur
* transiently, however, if the user is swapping SD cards while a media app is
* open.
*
* The 'unmounted' state occurs when the device's SD card is unmounted. This
* is generally a temporary condition that occurs when the user starts a USB
* Mass Storage transfer session by plugging their device into a computer. In
* this case, MediaDB will become available again as soon as the device is
* unplugged (it may have different files on it, though: see the SCANNING
* section below).
*
* DATABASE RECORDS
*
* MediaDB stores a record in its IndexedDB database for each DeviceStorage
* file of the appropriate media type and mime type. The records
* are objects of this form:
*
* {
* name: // the filename (relative to the DeviceStorage root)
* type: // the file MIME type (extension-based, from DeviceStorage)
* size: // the file size in bytes
* date: // file modification time (as ms since the epoch)
* metadata: // whatever object the metadata parser returned
* }
*
* Note that the database records do not include the file itself, but only its
* name. Use the getFile() method to get a File object (a Blob) by name.
*
* ENUMERATING FILES
*
* Typically, the first thing an app will do with a MediaDB object after the
* ready event is triggered is call the enumerate() method to obtain the list
* of files that MediaDB already knows about from previous app invocations.
* enumerate() gets records from the database and passes them to the specified
* callback. Each record that is passed to the callback is an object in the
* form shown above.
*
* If you pass only a callback to enumerate(), it calls the callback once for
* each entry in the database and then calls the callback with an argument of
* null to indicate that it is done.
*
* By default, entries are returned in alphabetical order by filename and all
* entries in the database are returned. You can specify other arguments to
* enumerate() to change the set of entries that are returned and the order that
* they are enumerated in. The full set of arguments are:
*
* key:
* A keypath specification that specifies what field to sort on. If you
* specify this argument, it must be 'name', 'date', or one of the values
* in the options.indexes array passed to the MediaDB() constructor. This
* argument is optional. If omitted, the default is to use the file name
* as the key.
*
* range:
* An IDBKeyRange object that optionally specifies upper and lower bounds on
* the specified key. This argument is optional. If omitted, all entries in
* the database are enumerated. See IndexedDB documentation for more on
* key ranges.
*
* direction:
* One of the IndexedDB direction string "next", "nextunique", "prev" or
* "prevunique". This argument is optional. If omitted, the default is
* "next", which enumerates entries in ascending order.
*
* callback:
* The function that database entries should be passed to. This argument is
* not optional, and is always passed as the last argument to enumerate().
*
* The enumerate() method returns database entries. These include file names,
* but not the files themselves. enumerate() interacts solely with the
* IndexedDB; it does not use DeviceStorage. If you want to use a media file
* (to play a song or display a photo, for example) call the getFile() method.
*
* enumerate() returns an object with a 'state' property that starts out as
* 'enumerating' and switches to 'complete' when the enumeration is done. You
* can cancel a pending enumeration by passing this object to the
* cancelEnumeration() method. This switches the state to 'cancelling' and then
* it switches to 'cancelled' when the cancellation is complete. If you call
* cancelEnumeration(), the callback function you passed to enumerate() is
* guaranteed not to be called again.
*
* In addition to enumerate(), there are two other methods you can use
* to enumerate database entries:
*
* enumerateAll() takes the same arguments and returns the same values
* as enumerate(), but it batches the results and passes them in an
* array to the callback function.
*
* getAll() takes a callback argument and passes it an array of all
* entries in the database, sorted by filename. It does not allow you
* to specify a key, range, or direction, but if you need all entries
* from the database, this method is is much faster than enumerating
* entries individually.
*
* FILESYSTEM CHANGES
*
* When media files are added or removed, MediaDB reports this by triggering
* 'created' and 'deleted' events.
*
* When a 'created' event is fired, the detail property of the event is an
* array of database record objects. When a single file is created (for
* example when the user takes a picture with the Camera app) this array has
* only a single element. But when MediaDB scans for new files (see SCANNING
* below) it may batch multiple records into a single created event. If a
* 'created' event has many records, apps may choose to simply rebuild their
* UI from scratch with a new call to enumerate() instead of handling the new
* files one at a time.
*
* When a 'deleted' event is fired, the detail property of the event is an
* array of the names of the files that have been deleted. As with 'created'
* events, the array may have a single element or may have many batched
* elements.
*
* If MediaDB detects that a file has been modified in place (because its
* size or date changes) it treats this as a deletion of the old version and
* the creation of a new version, and will fire a deleted event followed by
* a created event.
*
* The created and deleted events are not triggered until the corresponding
* files have actually been created and deleted and their database records
* have been updated.
*
* SCANNING
*
* MediaDB automatically scans for new and deleted files every time it enters
* the MediaDB.READY state. This happens when the MediaDB object is first
* created, and also when an SD card is removed and reinserted or when the
* user finishes a USB Mass Storage session. If the scan finds new files, it
* reports them with one or more 'created' events. If the scan finds that
* files have been deleted, it reports them with one or more 'deleted' events.
*
* MediaDB fires a 'scanstart' event when a scan begins and fires a 'scanend'
* event when the scan is complete. Apps can use these events to let the user
* know that a scan is in progress.
*
* The scan algorithm attempts to quickly look for new files and reports those
* first. It then begins a slower full scan phase where it checks that each of
* the files it already knows about is still present.
*
* EVENTS
*
* As described above, MediaDB sends events to communicate with the apps
* that use it. The event types and their meanings are:
*
* Event Meaning
* --------------------------------------------------------------------------
* ready MediaDB is ready for use. Also fired when new volumes added.
* enumerable Fired when DB can be enumerated but before fully ready
* unavailable MediaDB is unavailable (often because of USB file transfer)
* created One or more files were created
* deleted One or more files were deleted
* scanstart MediaDB is scanning
* scanend MediaDB has finished scanning
* cardremoved A volume became permanently unavailable.
* Only fired on devices that have internal storage and a card
*
* Because MediaDB is a JavaScript library, these are not real DOM events, but
* simulations.
*
* MediaDB defines two-argument versions of addEventListener() and
* removeEventListener() and also allows you to define event handlers by
* setting 'on' properties like 'onready' and 'onscanstart'.
*
* The objects passed on MediaDB event handlers are not true Event objects but
* simulate a CustomEvent by defining type, target, currentTarget, timestamp
* and detail properties. For MediaDB events, it is the detail property that
* always holds the useful information. These simulated event objects do not
* have preventDefault(), stopPropagation() or stopImmediatePropagation()
* methods.
*
* MediaDB events do not bubble and cannot be captured.
*
* INTERNAL AND EXTERNAL STORAGE
*
* Some devices have internal storage and also external (sdcard) storage.
* MediaDB hides this from apps, for the most part. There are some
* idiosyncracies to be aware of, however. With more than one device storage
* area available, MediaDB can remain available even when an sdcard is
* removed. When this happens we send a 'cardremoved' event instead of
* an 'unavailable' event. And if there were files on that card, the
* cardremoved event is followed by a series of deleted events for all of
* the files.
*
* If a card is inserted, we just send a 'ready' event, and start a new scan,
* even if we were already in the MediaDB.READY state.
*
* The situation is slightly different when one of the two storage areas is
* unmounted so it can be shared by USB, however. In this case, the files
* are only temporarily unavailable, and it doesn't make sense to delete
* them and then rescan everything when the volume is mounted again. So instead,
* when either of the storage areas is shared via USB, MediaDB sends its
* 'unavailable' event.
*
* METHODS
*
* MediaDB defines the following methods:
*
* - addEventListener(): register a function to call when an event is fired
*
* - removeEventListener(): unregister an event listener function
*
* - enumerate(): for each file that MediaDB knows about, pass its database
* record object to the specified callback. By default, records are returned
* in alphabetical order by name, but optional arguments allow you to
* specify a database index, a key range, and a sort direction.
*
* - cancelEnumeration(): stops an enumeration in progress. Pass the object
* returned by enumerate().
*
* - getFile(): given a filename and a callback, this method looks up the
* named file in DeviceStorage and passes it (a Blob) to the callback.
* An error callback is available as an optional third argument.
*
* - getFileInfo(): given a filename and a callback, this method looks up
* the database record for that file and passes it to the callback. If
* no such record exists and an error callback was passed as the 3rd
* argument, then an error message is passed to that error callback.
* Note that unlike getFile() this method does not return file content.
*
* - count(): count the number of records in the database and pass the value
* to the specified callback. Like enumerate(), this method allows you
* to specify the name of an index and a key range if you only want to
* count some of the records.
*
* - updateMetadata(): updates the metadata associated with a named file
*
* - addFile(): given a filename and a blob this method saves the blob as a
* named file to device storage.
*
* - deleteFile(): deletes the named file from device storage and the database
*
* - close(): closes the IndexedDB connections and stops listening to
* DeviceStorage events. This permanently puts the MediaDB object into
* the MediaDB.CLOSED state in which it is unusable.
*
* - freeSpace(): call the DeviceStorage freeSpace() method and pass the
* result to the specified callback
*/
var MediaDB = (function() {
function MediaDB(mediaType, metadataParser, options) {
this.mediaType = mediaType;
this.metadataParser = metadataParser;
if (!options) {
options = {};
}
this.indexes = options.indexes || [];
this.version = options.version || 1;
this.mimeTypes = options.mimeTypes;
this.autoscan = (options.autoscan !== undefined) ? options.autoscan : true;
this.state = MediaDB.OPENING;
this.scanning = false; // becomes true while scanning
this.initialScanComplete = false; // becomes true once endscan is called
this.parsingBigFiles = false;
// These are for data upgrade from the client.
this.updateRecord = options.updateRecord;
this.reparsedRecord = options.reparsedRecord;
if (options.excludeFilter && (options.excludeFilter instanceof RegExp)) {
// only regular expression object is accepted.
this.clientExcludeFilter = options.excludeFilter;
}
// While scanning, we attempt to send change events in batches.
// After finding a new or deleted file, we'll wait this long before
// sending events in case we find another new or deleted file right away.
this.batchHoldTime = options.batchHoldTime || 100;
// But we'll send a batch of changes right away if it gets this big
// A batch size of 0 means no maximum batch size
this.batchSize = options.batchSize || 0;
this.dbname = 'MediaDB/' + this.mediaType + '/';
var media = this; // for the nested functions below
// Private implementation details in this object
this.details = {
// This maps event type -> array of listeners
// See addEventListener and removeEventListener
eventListeners: {},
// Properties for queuing up db insertions and deletions and also
// for queueing up notifications to be sent
pendingInsertions: [], // Array of filenames to insert
pendingDeletions: [], // Array of filenames to remove
whenDoneProcessing: [], // Functions to run when queue is empty
pendingCreateNotifications: [], // Array of fileinfo objects
pendingDeleteNotifications: [], // Ditto
pendingNotificationTimer: null,
// This property holds the modification date of the newest file we have.
// We need to know the newest file in order to look for newer ones during
// scanning. We initialize newestFileModTime during initialization by
// actually checking the database (using the date index). We also update
// this property every time a new record is added to the database.
newestFileModTime: 0
};
// Define a dummy metadata parser if we're not given one
if (!this.metadataParser) {
this.metadataParser = function(file, callback) {
setTimeout(function() { callback({}); }, 0);
};
}
// Open the database
// Note that the user can upgrade the version and we can upgrade the version
// DB Version is a 32bits unsigned short: upper 16bits is client app db
// number, lower 16bits is MediaDB version number.
var dbVersion = (0xFFFF & this.version) << 16 | (0xFFFF & MediaDB.VERSION);
var openRequest = indexedDB.open(this.dbname,
dbVersion);
// This should never happen for Gaia apps
openRequest.onerror = function(e) {
console.error('MediaDB():', openRequest.error.name);
};
// This should never happen for Gaia apps
openRequest.onblocked = function(e) {
console.error('indexedDB.open() is blocked in MediaDB()');
};
// This is where we create (or delete and recreate) the database
openRequest.onupgradeneeded = function(e) {
var db = openRequest.result;
// read transaction from event for data manipulation (read/write).
var transaction = e.target.transaction;
var oldVersion = e.oldVersion;
// translate to db version and client version.
var oldDbVersion = 0xFFFF & oldVersion;
var oldClientVersion = 0xFFFF & (oldVersion >> 16);
// if client version is 0, oldVersion is the version number prior to
// bug 891797. The MediaDB.VERSION may be 2, and other parts is client
// version.
if (oldClientVersion === 0) {
oldDbVersion = 2;
oldClientVersion = oldVersion / oldDbVersion;
}
if (0 === db.objectStoreNames.length) {
// No objectstore found. It is the first time use MediaDB, we need to
// create it.
createObjectStores(db);
} else {
// ObjectStore found, we need to upgrade data for both client upgrade
// and mediadb upgrade.
handleUpgrade(db, transaction, oldDbVersion, oldClientVersion);
}
};
// This is called when we've got the database open and ready.
openRequest.onsuccess = function(e) {
media.db = openRequest.result;
// Log any errors that propagate up to here
media.db.onerror = function(event) {
console.error('MediaDB: ',
event.target.error && event.target.error.name);
};
// At this point, the db is open and can be queried. We have not
// checked device storage yet, so we are not fully ready, but apps
// that need to enumerate existing files before scanning for new files
// can start that enumeration now.
changeState(media, MediaDB.ENUMERABLE);
// Query the db to find the modification time of the newest file
var cursorRequest =
media.db.transaction('files', 'readonly')
.objectStore('files')
.index('date')
.openCursor(null, 'prev');
cursorRequest.onerror = function() {
// If anything goes wrong just display an error.
// If this fails, don't even attempt error recovery
console.error('MediaDB initialization error', cursorRequest.error);
};
cursorRequest.onsuccess = function() {
var cursor = cursorRequest.result;
if (cursor) {
media.details.newestFileModTime = cursor.value.date;
}
else {
// No files in the db yet, so use a really old time
media.details.newestFileModTime = 0;
}
// The DB is initialized, and we've got our mod time
// so move on and initialize device storage
initDeviceStorage();
};
};
// helper function to create all indexes
function createObjectStores(db) {
// Now build the database
var filestore = db.createObjectStore('files', { keyPath: 'name' });
// Always index the files by modification date
filestore.createIndex('date', 'date');
// And index them by any other file properties or metadata properties
// passed to the constructor
media.indexes.forEach(function(indexName) {
// Don't recreate indexes we've already got
if (indexName === 'name' || indexName === 'date') {
return;
}
// the index name is also the keypath
filestore.createIndex(indexName, indexName);
});
}
// helper function to list all files and invoke callback with db, trans,
// dbfiles, db version, client version as arguments.
function enumerateOldFiles(store, callback) {
var openCursorReq = store.openCursor();
openCursorReq.onsuccess = function() {
var cursor = openCursorReq.result;
if (cursor) {
callback(cursor.value);
cursor.continue();
}
};
}
function handleUpgrade(db, trans, oldDbVersion, oldClientVersion) {
// change state to upgrading that client apps may use it.
media.state = MediaDB.UPGRADING;
var evtDetail = {'oldMediaDBVersion': oldDbVersion,
'oldClientVersion': oldClientVersion,
'newMediaDBVersion': MediaDB.VERSION,
'newClientVersion': media.version};
// send upgrading event
dispatchEvent(media, 'upgrading', evtDetail);
// The upgrade contains upgrading indexes and upgrading data.
var store = trans.objectStore('files');
// Part 1: upgrading indexes
if (media.version != oldClientVersion) {
// upgrade indexes changes from client app.
upgradeIndexesChanges(store);
}
var clientUpgradeNeeded = (media.version != oldClientVersion) &&
media.updateRecord;
// checking if we need to enumerate all files. This may improve the
// performance of only changing indexes. If client app changes indexes,
// they may not need to update records. In this case, we don't need to
// enumerate all files.
if ((2 != oldDbVersion || 3 != MediaDB.VERSION) && !clientUpgradeNeeded) {
return;
}
// Part 2: upgrading data
enumerateOldFiles(store, function doUpgrade(dbfile) {
// handle mediadb upgrade from 2 to 3
if (2 == oldDbVersion && 3 == MediaDB.VERSION) {
upgradeDBVer2to3(store, dbfile);
}
// handle client upgrade
if (clientUpgradeNeeded) {
handleClientUpgrade(store, dbfile, oldClientVersion);
}
});
}
function upgradeIndexesChanges(store) {
var dbIndexes = store.indexNames; // note: it is DOMStringList not array.
var clientIndexes = media.indexes;
for (var i = 0; i < dbIndexes.length; i++) {
// indexes provided by mediadb, can't remove it.
if ('name' === dbIndexes[i] || 'date' === dbIndexes[i]) {
continue;
}
if (clientIndexes.indexOf(dbIndexes[i]) < 0) {
store.deleteIndex(dbIndexes[i]);
}
}
for (i = 0; i < clientIndexes.length; i++) {
if (!dbIndexes.contains(clientIndexes[i])) {
store.createIndex(clientIndexes[i], clientIndexes[i]);
}
}
}
function upgradeDBVer2to3(store, dbfile) {
// if record is already starting with '/', don't update them.
if (dbfile.name[0] === '/') {
return;
}
store.delete(dbfile.name);
dbfile.name = '/sdcard/' + dbfile.name;
store.add(dbfile);
}
function handleClientUpgrade(store, dbfile, oldClientVersion) {
try {
dbfile.metadata = media.updateRecord(dbfile, oldClientVersion,
media.version);
if (dbfile.needsReparse && !media.reparsedRecord) {
console.warn(
'client app requested reparse, but no reparsedRecord was set'
);
delete dbfile.needsReparse;
}
store.put(dbfile);
} catch (ex) {
// discard client upgrade error, client app should handle it.
console.warn('client app updates record, ' + dbfile.name +
', failed: ' + ex.message);
}
}
function initDeviceStorage() {
var details = media.details;
// Get the individual device storage objects, so that we can listen
// for events on the different volumes separately.
details.storages = navigator.getDeviceStorages(mediaType);
details.availability = {};
// Start off by getting the initial availablility of the storage areas
// This is an async function that will call the next initialization
// step when it is done.
getStorageAvailability();
// This is an asynchronous step in the db initialization process
function getStorageAvailability() {
var next = 0;
getNextAvailability();
function getNextAvailability() {
if (next >= details.storages.length) {
// We've gotten the availability of all storage areas, so
// move on to the next step.
setupHandlers();
return;
}
var s = details.storages[next++];
var name = s.storageName;
var req = s.available();
req.onsuccess = function(e) {
details.availability[name] = req.result;
getNextAvailability();
};
req.onerror = function(e) {
details.availability[name] = 'unavailable';
getNextAvailability();
};
}
}
function setupHandlers() {
// Now that we know the state of all of the storage areas, register
// an event listener to monitor changes to that state.
for (var i = 0; i < details.storages.length; i++) {
details.storages[i].addEventListener('change', changeHandler);
}
// Remember the listener so we can remove it in stop()
details.dsEventListener = changeHandler;
// Move on to the next step
sendInitialEvent();
}
function sendInitialEvent() {
// Get our current state based on the device storage availability
var state = getState(details.availability);
// Switch to that state and send an appropriate event
changeState(media, state);
// If the state is ready, and we're auto scanning then start a scan
if (media.autoscan) {
scan(media);
}
}
// Given a storage name -> availability map figure out what state
// the mediadb object should be in
function getState(availability) {
var n = 0; // total number of storages
var a = 0; // # that are available
var u = 0; // # that are unavailable
var s = 0; // # that are shared
for (var name in availability) {
n++;
switch (availability[name]) {
case 'available':
a++;
break;
case 'unavailable':
u++;
break;
case 'shared':
s++;
break;
}
}
// If any volume is shared, then behave as if they are all shared
// and make the entire MediaDB shared. This is because shared volumes
// are generally shared transiently and most files on the volume will
// probably still be there when the volume comes back. If we want to
// keep the MediaDB available while one volume is shared we have to
// rescan and discard all the files on the shared volume, which means
// it will take much longer to recover when the volume comes back. So
// it is better to just act as if all volumes are shared together
if (s > 0) {
return MediaDB.UNMOUNTED;
}
// If all volumes are unavailable, then MediaDB is unavailable
if (u === n) {
return MediaDB.NOCARD;
}
// Otherwise, there is at least one available volume, so MediaDB
// is available.
return MediaDB.READY;
}
function changeHandler(e) {
switch (e.reason) {
case 'modified':
case 'deleted':
fileChangeHandler(e);
return;
case 'available':
case 'unavailable':
case 'shared':
volumeChangeHandler(e);
return;
default: // we ignore created events and handle modified instead.
return;
}
}
function volumeChangeHandler(e) {
var storageName = e.target.storageName;
// If nothing changed, ignore this event
if (details.availability[storageName] === e.reason) {
return;
}
var oldState = media.state;
// Record the new availability of the volume that changed.
details.availability[storageName] = e.reason;
// And figure out what our new state is
var newState = getState(details.availability);
// If the state changed, send out an event about it
if (newState !== oldState) {
changeState(media, newState);
// Start scanning if we're available, and cancel scanning otherwise
if (newState === MediaDB.READY) {
if (media.autoscan) {
scan(media);
}
}
else {
endscan(media);
}
}
else if (newState === MediaDB.READY) {
// In this case, the state did not change. But we may still need to
// send out an event. If both states are READY, then the user
// just inserted or removed an sdcard. If the user just added a
// card, then we want to send another available event and start a
// scan. If the user just removed a card then we need to immediately
// tell the client that happened so the music app (for example) can
// stop playing. If it is playing a file that just disappeared it is
// in danger of crashing. Also, in this case we must delete the
// records (and send events) for all of the files on that card.
if (e.reason === 'available') {
// An SD card was just inserted, so send another ready event.
dispatchEvent(media, 'ready');
// And if we're automatically scanning, start the scan now.
// It would be more efficient if the scan() function could scan
// just one storage area at a time. But SD card insertion should
// be rare enough that efficiency is not so important.
if (media.autoscan) {
scan(media);
}
}
else if (e.reason === 'unavailable') {
// An SD card was just removed. First send an event.
dispatchEvent(media, 'cardremoved');
// Now figure out all the files we know about that were on that
// volume and remove their records from the database and send events
// to the client.
deleteAllFiles(storageName);
}
}
}
function fileChangeHandler(e) {
var filename = e.path;
if (ignoreName(media, filename)) {
return;
}
// insertRecord and deleteRecord will send events to the client once
// the db has been updated.
if (e.reason === 'modified') {
insertRecord(media, filename);
} else {
deleteRecord(media, filename);
}
}
// Enumerate all entries in the DB and call deleteRecord for any whose
// filename begins with the specified storageName
function deleteAllFiles(storageName) {
var storagePrefix = storageName ? '/' + storageName + '/' : '';
var store = media.db.transaction('files').objectStore('files');
var cursorRequest = store.openCursor();
cursorRequest.onsuccess = function() {
var cursor = cursorRequest.result;
if (cursor) {
if (cursor.value.name.startsWith(storagePrefix)) {
// This will generate an event to notify the client that the
// file is now gone.
deleteRecord(media, cursor.value.name);
}
cursor.continue();
}
};
}
}
}
MediaDB.prototype = {
close: function close() {
// Close the database
this.db.close();
// There is no way to close device storage, but we at least want
// to stop receiving events from it.
for (var i = 0; i < this.details.storages.length; i++) {
var s = this.details.storages[i];
s.removeEventListener('change', this.details.dsEventListener);
}
// Change state and send out an event
changeState(this, MediaDB.CLOSED);
},
addEventListener: function addEventListener(type, listener) {
if (!this.details.eventListeners.hasOwnProperty(type)) {
this.details.eventListeners[type] = [];
}
var listeners = this.details.eventListeners[type];
if (listeners.indexOf(listener) !== -1) {
return;
}
listeners.push(listener);
},
removeEventListener: function removeEventListener(type, listener) {
if (!this.details.eventListeners.hasOwnProperty(type)) {
return;
}
var listeners = this.details.eventListeners[type];
var position = listeners.indexOf(listener);
if (position === -1) {
return;
}
listeners.splice(position, 1);
},
// Look up the database record for the specfied filename and pass it
// to the specified callback.
getFileInfo: function getFile(filename, callback, errback) {
if (this.state === MediaDB.OPENING) {
throw Error('MediaDB is not ready. State: ' + this.state);
}
var media = this;
// First, look up the fileinfo record in the db
var read = media.db.transaction('files', 'readonly')
.objectStore('files')
.get(filename);
read.onerror = function() {
var msg = 'MediaDB.getFileInfo: unknown filename: ' + filename;
if (errback) {
errback(msg);
} else {
console.error(msg);
}
};
read.onsuccess = function() {
if (callback) {
callback(read.result);
}
};
},
// Look up the specified filename in DeviceStorage and pass the
// resulting File object to the specified callback.
getFile: function getFile(filename, callback, errback) {
if (this.state !== MediaDB.READY) {
throw Error('MediaDB is not ready. State: ' + this.state);
}
var storage = navigator.getDeviceStorage(this.mediaType);
var getRequest = storage.get(filename);
getRequest.onsuccess = function() {
callback(getRequest.result);
};
getRequest.onerror = function() {
var errmsg = getRequest.error && getRequest.error.name;
if (errback) {
errback(errmsg);
} else {
console.error('MediaDB.getFile:', errmsg);
}
};
},
// Delete the named file from device storage.
// This will cause a device storage change event, which will cause
// mediadb to remove the file from the database and send out a
// mediadb change event, which will notify the application UI.
deleteFile: function deleteFile(filename) {
if (this.state !== MediaDB.READY) {
throw Error('MediaDB is not ready. State: ' + this.state);
}
var storage = navigator.getDeviceStorage(this.mediaType);
storage.delete(filename).onerror = function(e) {
console.error('MediaDB.deleteFile(): Failed to delete', filename,
'from DeviceStorage:', e.target.error);
};
},
//
// Save the specified blob to device storage, using the specified filename.
// This will cause device storage to send us an event, and that event
// will cause mediadb to add the file to its database, and that will
// send out a mediadb event to the application UI.
//
addFile: function addFile(filename, file) {
if (this.state !== MediaDB.READY) {
throw Error('MediaDB is not ready. State: ' + this.state);
}
var media = this;
// Refetch the default storage area, since the user can change it
// in the settings app.
var storage = navigator.getDeviceStorage(media.mediaType);
// Delete any existing file by this name, then save the file.
var deletereq = storage.delete(filename);
deletereq.onsuccess = deletereq.onerror = save;
function save() {
var request = storage.addNamed(file, filename);
request.onerror = function() {
console.error('MediaDB: Failed to store', filename,
'in DeviceStorage:', request.error);
};
}
},
// Look up the database record for the named file, and copy the properties
// of the metadata object into the file's metadata, and then write the
// updated record back to the database. The third argument is optional. If
// you pass a function, it will be called when the metadata is written.
updateMetadata: function(filename, metadata, callback) {
if (this.state === MediaDB.OPENING) {
throw Error('MediaDB is not ready. State: ' + this.state);
}
var media = this;
// First, look up the fileinfo record in the db
var read = media.db.transaction('files', 'readonly')
.objectStore('files')
.get(filename);
read.onerror = function() {
console.error('MediaDB.updateMetadata called with unknown filename');
};
read.onsuccess = function() {
var fileinfo = read.result;
// Update the fileinfo metadata
Object.keys(metadata).forEach(function(key) {
fileinfo.metadata[key] = metadata[key];
});
// And write it back into the database.
var write = media.db.transaction('files', 'readwrite')
.objectStore('files')
.put(fileinfo);
write.onerror = function() {
console.error('MediaDB.updateMetadata: database write failed',
write.error && write.error.name);
};
if (callback) {
write.onsuccess = function() {
callback();
};
}
};
},
// Count the number of records in the database and pass that number to the
// specified callback. key is 'name', 'date' or one of the index names
// passed to the constructor. range is be an IDBKeyRange that defines a
// the range of key values to count. key and range are optional
// arguments. If one argument is passed, it is the callback. If two
// arguments are passed, they are assumed to be the range and callback.
count: function(key, range, callback) {
if (this.state !== MediaDB.READY && this.state !== MediaDB.ENUMERABLE) {
throw Error('MediaDB is not ready or enumerable. State: ' + this.state);
}
// range is an optional argument
if (arguments.length === 1) {
callback = key;
range = undefined;
key = undefined;
}
else if (arguments.length === 2) {
callback = range;
range = key;
key = undefined;
}
var store = this.db.transaction('files').objectStore('files');
if (key && key !== 'name') {
store = store.index(key);
}
var countRequest = store.count(range || null);
countRequest.onerror = function() {
console.error('MediaDB.count() failed with', countRequest.error);
};
countRequest.onsuccess = function(e) {
callback(e.target.result);
};
},
// Enumerate all files in the filesystem, sorting by the specified
// property (which must be one of the indexes, or null for the filename).
// Direction is ascending or descending. Use whatever string
// constant IndexedDB uses. f is the function to pass each record to.
//
// Each record is an object like this:
//
// {
// // The basic fields are all from the File object
// name: // the filename
// type: // the file type
// size: // the file size
// date: // file mod time
// metadata: // whatever object the metadata parser returns
// }
//
// This method returns an object that you can pass to cancelEnumeration()
// to cancel an enumeration in progress. You can use the state property
// of the returned object to find out the state of the enumeration. It
// should be one of the strings 'enumerating', 'complete', 'cancelling'
// 'cancelled', or 'error'
//
enumerate: function enumerate(key, range, direction, callback) {
if (this.state !== MediaDB.READY && this.state !== MediaDB.ENUMERABLE) {
throw Error('MediaDB is not ready or enumerable. State: ' + this.state);
}
var handle = { state: 'enumerating' };
// The first three arguments are optional, but the callback
// is required, and we don't want to have to pass three nulls
if (arguments.length === 1) {
callback = key;
key = undefined;
}
else if (arguments.length === 2) {
callback = range;
range = undefined;
}
else if (arguments.length === 3) {
callback = direction;
direction = undefined;
}
var store = this.db.transaction('files').objectStore('files');
// If a key other than "name" is specified, then use the index for that
// key instead of the store.
if (key && key !== 'name') {
store = store.index(key);
}
// Now create a cursor for the store or index.
var cursorRequest = store.openCursor(range || null, direction || 'next');
cursorRequest.onerror = function() {
console.error('MediaDB.enumerate() failed with', cursorRequest.error);
handle.state = 'error';
};
cursorRequest.onsuccess = function() {
// If the enumeration has been cancelled, return without
// calling the callback and without calling cursor.continue();
if (handle.state === 'cancelling') {
handle.state = 'cancelled';
return;
}
var cursor = cursorRequest.result;
if (cursor) {
try {
// If metadata parsing succeeded and the file doesn't need
// reparsing.
var fileinfo = cursor.value;
if (!fileinfo.fail) {
callback(fileinfo);
}
}
catch (e) {
console.warn('MediaDB.enumerate(): callback threw', e, e.stack);
}
cursor.continue();
}
else {
// Final time, tell the callback that there are no more.
handle.state = 'complete';
callback(null);
}
};
return handle;
},
// Basically this function is a variation of enumerate(), since retrieving
// a large number of records from indexedDB takes some time and if the
// enumeration is cancelled, people can use this function to resume getting
// the rest records by providing an index where it was stopped.
// Also, if you want to get just one record, just give the target index and
// the first returned record is the record you want, remember to call
// cancelEnumeration() immediately after you got the record.
// All the arguments are required because this function is for advancing
// enumeration, people who use this function should already have all the
// arguments, and pass them again to get the target records from the index.
advancedEnumerate: function(key, range, direction, index, callback) {
if (this.state !== MediaDB.READY) {
throw Error('MediaDB is not ready. State: ' + this.state);
}
var handle = { state: 'enumerating' };
var store = this.db.transaction('files').objectStore('files');
// If a key other than "name" is specified, then use the index for that
// key instead of the store.
if (key && key !== 'name') {
store = store.index(key);
}
// Now create a cursor for the store or index.
var cursorRequest = store.openCursor(range || null, direction || 'next');
var isTarget = false;
cursorRequest.onerror = function() {
console.error('MediaDB.enumerate() failed with', cursorRequest.error);
handle.state = 'error';
};
cursorRequest.onsuccess = function() {
// If the enumeration has been cancelled, return without
// calling the callback and without calling cursor.continue();
if (handle.state === 'cancelling') {
handle.state = 'cancelled';
return;
}
var cursor = cursorRequest.result;
if (cursor) {
try {
// when the index is 0, we should just return the first
// record and does not need to advance the cursor.
if (index === 0) {
isTarget = true;
}
// if metadata parsing succeeded and is the target record
var fileinfo = cursor.value;
if (!fileinfo.fail && isTarget) {
callback(cursor.value);
cursor.continue();
}
else {
// cursor.advance() should be given with index values
// greater than or equal to 1.
cursor.advance(index);
isTarget = true;
}
}
catch (e) {
console.warn('MediaDB.enumerate(): callback threw', e, e.stack);
}
}
else {
// Final time, tell the callback that there are no more.
handle.state = 'complete';
callback(null);
}
};
return handle;
},
// This method takes the same arguments as enumerate(), but batches
// the results into an array and passes them to the callback all at
// once when the enumeration is complete. It uses enumerate() so it
// is no faster than that method, but may be more convenient.
enumerateAll: function enumerateAll(key, range, direction, callback) {
var batch = [];
// The first three arguments are optional, but the callback
// is required, and we don't want to have to pass three nulls
if (arguments.length === 1) {
callback = key;
key = undefined;
}
else if (arguments.length === 2) {
callback = range;
range = undefined;
}
else if (arguments.length === 3) {
callback = direction;
direction = undefined;
}
return this.enumerate(key, range, direction, function(fileinfo) {
if (fileinfo !== null) {
batch.push(fileinfo);
} else {
callback(batch);
}
});
},
// Cancel a pending enumeration. After calling this the callback for
// the specified enumeration will not be invoked again.
cancelEnumeration: function cancelEnumeration(handle) {
if (handle.state === 'enumerating') {
handle.state = 'cancelling';
}
},
// Use the non-standard mozGetAll() function to return all of the
// records in the database in one big batch. The records will be
// sorted by filename
getAll: function getAll(callback) {
if (this.state !== MediaDB.READY && this.state !== MediaDB.ENUMERABLE) {
throw Error('MediaDB is not ready or enumerable. State: ' + this.state);
}
var store = this.db.transaction('files').objectStore('files');
var request = store.mozGetAll();
request.onerror = function() {
console.error('MediaDB.getAll() failed with', request.error);
};
request.onsuccess = function() {
var all = request.result; // All records in the object store
// Filter out files that failed metadata parsing
var good = all.filter(function(fileinfo) { return !fileinfo.fail; });
callback(good);
};
},
// Scan for new or deleted files.
// This is only necessary if you have explicitly disabled automatic
// scanning by setting autoscan:false in the options object.
scan: function() {
scan(this);
},
// Use the device storage freeSpace() method and pass the returned
// value to the callback.
freeSpace: function freeSpace(callback) {
if (this.state !== MediaDB.READY) {
throw Error('MediaDB is not ready. State: ' + this.state);
}
var storage = navigator.getDeviceStorage(this.mediaType);
var freereq = storage.freeSpace();
freereq.onsuccess = function() {
callback(freereq.result);
};
}
};
// This is the version number of the MediaDB schema. If we change this
// number it will cause existing data stores to be deleted and rebuilt,
// which is useful when the schema changes. Note that the user can also
// upgrade the version number with an option to the MediaDB constructor.
// The final indexedDB version number we use is the product of our version
// and the user's version.
// Version 2: We modified the default schema to include an index for file
// modification date.
// Version 3: DeviceStorage had changed the file path from relative path(v1)
// to full qualified name(v1.1). We changed the code to handle the
// full qualified name and the upgrade from relative path to full
// qualified name.
MediaDB.VERSION = 3;
// These are the values of the state property of a MediaDB object
// The NOCARD, UNMOUNTED, and CLOSED values are also used as the detail
// property of 'unavailable' events
MediaDB.OPENING = 'opening'; // MediaDB is initializing itself
MediaDB.UPGRADING = 'upgrading'; // MediaDB is upgrading database
MediaDB.ENUMERABLE = 'enumerable'; // Not fully ready, but can be enumerated
MediaDB.READY = 'ready'; // MediaDB is available and ready for use
MediaDB.NOCARD = 'nocard'; // Unavailable because there is no sd card
MediaDB.UNMOUNTED = 'unmounted'; // Unavailable because card unmounted
MediaDB.CLOSED = 'closed'; // Unavailable because MediaDB has closed
/* Details of helper functions follow */
//
// Return true if media db should ignore this file.
//
// If any components of the path begin with a . we'll ignore the file.
// The '.' prefix indicates hidden files and directories on Unix and
// when files are "moved to trash" during a USB Mass Storage session they
// are sometimes not actually deleted, but moved to a hidden directory.
//
// If an array of media types was specified when the MediaDB was created
// and the type of this file is not a member of that list, then ignore it.
//
function ignore(media, file) {
if (ignoreName(media, file.name)) {
return true;
}
if (media.mimeTypes && media.mimeTypes.indexOf(file.type) === -1) {
return true;
}
return false;
}
// Test whether this filename is one we ignore.
// This is a separate function because device storage change events
// give us a name only, not the file object.
function ignoreName(media, filename) {
if (media.clientExcludeFilter && media.clientExcludeFilter.test(filename)) {
return true;
} else {
// Ignore files beginning with "." and files in directories beginning
// with ".".
return (filename[0] === '.' || filename.indexOf('/.') !== -1);
}
}
// Tell the db to start a manual scan. I think we don't do
// this automatically from the constructor, but most apps will start
// a scan right after calling the constructor and then will proceed to
// enumerate what is already in the db. If scan performance is bad
// for large media collections, apps can just have the user specify
// when to rescan rather than doing it automatically. Until we have
// change event notifications, gaia apps might want to do a scan
// every time they are made visible.
//
// Filesystem changes discovered by a scan are generally
// batched. If a scan discovers 10 new files, the information
// about those files will generally be passed as an array to a the
// onchange handler rather than calling that handler once for each
// newly discovered file. Apps can decide whether to handle
// batches by processing each element individually or by just starting
// fresh with a new call to enumerate().
//
// Scan details are not tightly specified, but the goal is to be
// as efficient as possible. We'll try to do a quick date-based
// scan to look for new files and report those first. Following
// that, a full scan will be compared with a full dump of the DB
// to see if any files have been deleted.
//
function scan(media) {
media.scanning = true;
dispatchEvent(media, 'scanstart');
// First, scan for new files since the last scan, if there was one
// When the quickScan is done it will begin a full scan. If we don't
// have a last scan date, then the database is empty and we don't
// have to do a full scan, since there will be no changes or deletions.
quickScan(media.details.newestFileModTime);
// Do a quick scan and then follow with a full scan
function quickScan(timestamp) {
var options;
if (timestamp > 0) {
media.details.firstscan = false;
options = {
// add 1 so we don't find the same newest file again
since: new Date(timestamp + 1)
};
}
else {
// If there is no timestamp then this is the first time we've
// scanned and we don't have any files in the database, which
// allows important optimizations during the scanning process
media.details.firstscan = true;
media.details.records = [];
options = {};
}
scanDS(media.details.storages, null, options)
.then(processFiles)
.catch(handleScanError);
function processFiles(files) {
if (!media.scanning) { // Abort if scanning has been cancelled
return;
}
// Sort them by date from newest to oldest
files = files.sort(function(a, b) {
return b.lastModifiedDate - a.lastModifiedDate;
});
// And insert them all
for (var i = 0; i < files.length; i++) {
insertRecord(media, files[i]);
}
// Quick scan is done. When the queue is empty, force out
// any batched created events and move on to the slower
// more thorough full scan.
whenDoneProcessing(media, function() {
sendNotifications(media);
if (media.details.firstscan) {
// If this was the first scan, then we're done
endscan(media);
}
else {
// If this was not the first scan, then we need to go
// ensure that all of the old files we know about are still there
fullScan();
}
});
}
function handleScanError(error) {
// We can't scan if we can't read device storage.
// Perhaps the card was unmounted or pulled out
console.warn('Error while scanning', error);
endscan(media);
}
}
// Get a complete list of files from DeviceStorage
// Get a complete list of files from IndexedDB.
// Sort them both (the indexedDB list will already be sorted)
// Step through the lists noting deleted files and created files.
// Pay attention to files whose size or date has changed and
// treat those as deletions followed by insertions.
// Sync up the database while stepping through the lists.
function fullScan() {
if (media.state !== MediaDB.READY) {
endscan(media);
return;
}
// The db may be busy right about now, processing files that
// were found during the quick scan. So we'll start off by
// enumerating all files in device storage
var dsfiles;
scanDS(media.details.storages)
.then(function(files) { // When we get the files from device storage
dsfiles = files; // remember them
getDBFiles(); // and then move on to get the database entries
})
.catch(function(error) {
// We can't scan if we can't read device storage.
// Perhaps the card was unmounted or pulled out
console.warn('Error while scanning', error);
endscan(media);
});
function getDBFiles() {
var store = media.db.transaction('files').objectStore('files');
var getAllRequest = store.mozGetAll();
getAllRequest.onsuccess = function() {
if (!media.scanning) { // Abort if scanning has been cancelled
return;
}
var dbfiles = getAllRequest.result; // Should already be sorted
compareLists(dbfiles, dsfiles);
};
}
function compareLists(dbfiles, dsfiles) {
// The dbfiles are sorted when we get them from the db.
// But the ds files are not sorted
dsfiles.sort(function(a, b) {
if (a.name < b.name) {
return -1;
} else {
return 1;
}
});
// Loop through both the dsfiles and dbfiles lists
var dsindex = 0, dbindex = 0;
while (true) {
// Get the next DeviceStorage file or null
var dsfile;
if (dsindex < dsfiles.length) {
dsfile = dsfiles[dsindex];
} else {
dsfile = null;
}
// Get the next DB file or null
var dbfile;
if (dbindex < dbfiles.length) {
dbfile = dbfiles[dbindex];
} else {
dbfile = null;
}
// Case 1: both files are null. If so, we're done.
if (dsfile === null && dbfile === null) {
break;
}
// Case 2: no more files in the db. This means that
// the file from ds is a new one
if (dbfile === null) {
insertRecord(media, dsfile);
dsindex++;
continue;
}
// Case 3: no more files in ds. This means that the db file
// has been deleted
if (dsfile === null) {
deleteRecord(media, dbfile.name);
dbindex++;
continue;
}
// Case 4: two files with the same name.
// 4a: date or size has changed: it is both a deletion and a creation
// 4b: dbfile needs reparse: it is both a deletion and an update
// 4c: date and size are the same for both, and no reparse needed:
// do nothing
if (dsfile.name === dbfile.name) {
// In release 1.3 and before files reported local times, and in 1.4
// and later they report UTC times. If the user has upgraded from
// 1.3 to 1.4 they may have files in the db whose times are in a
// local timezone. We want to recognize those files as matching
// existing files so we consider two files to have the same time if
// they are within +/- 12 hours of each other and if the difference
// in times is an exactly multiple of 10 minutes. (This assumes all
// world timezones are exact multiples of 10 minutes.)
var lastModified = dsfile.lastModifiedDate;
var timeDifference = lastModified.getTime() - dbfile.date;
var sameTime = (timeDifference === 0 ||
((Math.abs(timeDifference) <= 12 * 60 * 60 * 1000) &&
(timeDifference % 10 * 60 * 1000 === 0)));
var sameSize = dsfile.size === dbfile.size;
if (!sameTime || !sameSize) {
deleteRecord(media, dbfile.name);
insertRecord(media, dsfile);
} else if (dbfile.needsReparse) {
deleteRecord(media, dbfile.name);
insertRecord(media, dsfile, dbfile.metadata);
}
dsindex++;
dbindex++;
continue;
}
// Case 5: the dsfile name is less than the dbfile name.
// This means that the dsfile is new. Like case 2
if (dsfile.name < dbfile.name) {
insertRecord(media, dsfile);
dsindex++;
continue;
}
// Case 6: the dsfile name is greater than the dbfile name.
// this means that the dbfile no longer exists on disk
if (dsfile.name > dbfile.name) {
deleteRecord(media, dbfile.name);
dbindex++;
continue;
}
// That should be an exhaustive set of possiblities
// and we should never reach this point.
console.error('Assertion failed');
}
// Push a special value onto the queue so that when it is
// processed we can trigger a 'scanend' event
insertRecord(media, null);
}
}
// A utility function that returns a Promise that resolves to an array
// of non-ignored File objects on the specified DeviceStorage.
function scanDS(storage, directory, options) {
// If the first argument is an array then recursively call this
// function on each element of the array, and combine the results.
if (Array.isArray(storage)) {
return Promise.all(storage.map((s) => scanDS(s, directory, options)))
.then(function(arrays) {
return Array.prototype.concat.apply([], arrays);
});
}
// Otherwise, it should be a single DeviceStorage object to enumerate.
return new Promise(function(resolve, reject) {
var files = [];
var cursor = storage.enumerate(directory || '', options || {});
cursor.onerror = function() {
// If a storage area is unmounted, don't fail, just return no files
if (cursor.error.name === 'NotFoundError') {
resolve(files);
}
else {
reject(cursor.error);
}
};
cursor.onsuccess = function() {
var result = cursor.result;
if (result) {
if (!ignore(media, result)) {
files.push(result);
}
cursor.continue();
}
else {
resolve(files);
}
};
});
}
}
// Called to send out a scanend event when scanning is done.
// This event is sent on normal scan termination and also
// when something goes wrong, such as the device storage being
// unmounted during a scan.
function endscan(media) {
if (media.scanning) {
media.initialScanComplete = true;
media.scanning = false;
media.parsingBigFiles = false;
dispatchEvent(media, 'scanend');
}
}
// Pass in a file, or a filename. The function queues it up for
// metadata parsing and insertion into the database, and will send a
// mediadb change event (possibly batched with other changes).
// Ensures that only one file is being parsed at a time, but tries
// to make as many db changes in one transaction as possible. The
// special value null indicates that scanning is complete.
function insertRecord(media, fileOrName, oldMetadata) {
var details = media.details;
// Add this file to the queue of files to process
details.pendingInsertions.push([fileOrName, oldMetadata]);
// If the queue is already being processed, just return
if (details.processingQueue) {
return;
}
// Otherwise, start processing the queue.
processQueue(media);
}
// Delete the database record associated with filename.
function deleteRecord(media, filename) {
var details = media.details;
// Add this file to the queue of files to process
details.pendingDeletions.push(filename);
// If there is already a transaction in progress return now.
if (details.processingQueue) {
return;
}
// Otherwise, start processing the queue
processQueue(media);
}
function whenDoneProcessing(media, f) {
var details = media.details;
if (details.processingQueue) {
details.whenDoneProcessing.push(f);
} else {
f();
}
}
function processQueue(media) {
var details = media.details;
details.processingQueue = true;
// Now get one filename off a queue and store it
next();
// Take an item from a queue and process it.
// Deletions are always processed before insertions because we want
// to clear away non-functional parts of the UI ASAP.
function next() {
if (details.pendingDeletions.length > 0) {
deleteFiles();
}
else if (details.pendingInsertions.length > 0) {
insertFile(...details.pendingInsertions.shift());
}
else {
details.processingQueue = false;
if (details.whenDoneProcessing.length > 0) {
var functions = details.whenDoneProcessing;
details.whenDoneProcessing = [];
functions.forEach(function(f) { f(); });
}
}
}
// Delete all of the pending files in a single transaction
function deleteFiles() {
var transaction = media.db.transaction('files', 'readwrite');
var store = transaction.objectStore('files');
deleteNextFile();
function deleteNextFile() {
if (details.pendingDeletions.length === 0) {
next();
return;
}
var filename = details.pendingDeletions.shift();
var request = store.delete(filename);
request.onerror = function() {
// This probably means that the file wasn't in the db yet
console.warn('MediaDB: Unknown file in deleteRecord:',
filename, request.error);
deleteNextFile();
};
request.onsuccess = function() {
// We succeeded, so remember to send out an event about it.
queueDeleteNotification(media, filename);
deleteNextFile();
};
}
}
// Insert a file into the db. One transaction per insertion.
// The argument f might be a filename or a File object.
// oldMetadata is the metadata of this file before it was reparsed (or
// undefined).
function insertFile(f, oldMetadata) {
// null is a special value pushed on to the queue when a scan()
// is complete. We use it to trigger a scanend event
// after all the change events from the scan are delivered
if (f === null) {
sendNotifications(media);
endscan(media);
next();
return;
}
// If we got a filename, look up the file in device storage
if (typeof f === 'string') {
// Note: Even though we're using the default storage area, if the
// filename is fully qualified, it will get redirected to the
// appropriate storage area.
var storage = navigator.getDeviceStorage(media.mediaType);
var getreq = storage.get(f);
getreq.onerror = function() {
console.warn('MediaDB: Unknown file in insertRecord:',
f, getreq.error);
next();
};
getreq.onsuccess = function() {
// We got the filename from a device storage change event and
// verified that the filename was not one that we wanted to ignore.
// But until now, we haven't had the file and its type to check
// against the mimeTypes array. So if necessary we check again.
// If the file is not one of the types we're interested in we skip
// it. Otherwise, parse its metadata.
if (media.mimeTypes && ignore(media, getreq.result)) {
next();
} else {
parseMetadata(getreq.result, f, oldMetadata);
}
};
}
else {
// otherwise f is the file we want
parseMetadata(f, f.name, oldMetadata);
}
}
function parseMetadata(file, filename, oldMetadata) {
if (!file.lastModifiedDate) {
console.warn('MediaDB: parseMetadata: no lastModifiedDate for',
filename,
'using Date.now() until #793955 is fixed');
}
// Basic information about the file
var fileinfo = {
name: filename, // we can't trust file.name
type: file.type,
size: file.size,
date: file.lastModifiedDate ?
file.lastModifiedDate.getTime() :
Date.now()
};
if (fileinfo.date > details.newestFileModTime) {
details.newestFileModTime = fileinfo.date;
}
// Get metadata about the file
media.metadataParser(file, gotMetadata, metadataError, parsingBigFile);
function parsingBigFile() {
media.parsingBigFiles = true;
}
function metadataError(e) {
console.warn('MediaDB: error parsing metadata for',
filename, ':', e);
// If we get an error parsing the metadata, assume it is invalid
// and make a note in the fileinfo record that we store in the database
// If we don't store it in the database, we'll keep finding it
// on every scan. But we make sure never to return the invalid file
// on an enumerate call.
fileinfo.fail = true;
storeRecord(fileinfo);
}
function gotMetadata(metadata) {
fileinfo.metadata = metadata;
if (oldMetadata && media.reparsedRecord) {
fileinfo.metadata = media.reparsedRecord(oldMetadata, metadata);
}
storeRecord(fileinfo);
if (!media.scanning) {
// single file parsing.
media.parsingBigFiles = false;
}
}
}
function storeRecord(fileinfo) {
if (media.details.firstscan) {
// If this is the first scan then we know this is a new file and
// we can assume that adding it to the db will succeed.
// So we can just queue a notification about the new file without
// waiting for a db operation.
media.details.records.push(fileinfo);
if (!fileinfo.fail) {
queueCreateNotification(media, fileinfo);
}
// And go on to the next
next();
}
else {
// If this is not the first scan, then we may already have a db
// record for this new file. In that case, the call to add() above
// is going to fail. We need to handle that case, so we can't send
// out the new file notification until we get a response to the add().
var transaction = media.db.transaction('files', 'readwrite');
var store = transaction.objectStore('files');
var request = store.add(fileinfo);
request.onsuccess = function() {
// Remember to send an event about this new file
if (!fileinfo.fail) {
queueCreateNotification(media, fileinfo);
}
// And go on to the next
next();
};
request.onerror = function(event) {
// If the error name is 'ConstraintError' it means that the
// file already exists in the database. So try again, using put()
// instead of add(). If that succeeds, then queue a delete
// notification along with the insert notification. If the
// second try fails, or if the error was something different
// then issue a warning and continue with the next.
if (request.error.name === 'ConstraintError') {
// Don't let the higher-level DB error handler report the error
event.stopPropagation();
// And don't spew a default error message to the console either
event.preventDefault();
var putrequest = store.put(fileinfo);
putrequest.onsuccess = function() {
queueDeleteNotification(media, fileinfo.name);
if (!fileinfo.fail) {
queueCreateNotification(media, fileinfo);
}
next();
};
putrequest.onerror = function() {
// Report and move on
console.error('MediaDB: unexpected ConstraintError',
'in insertRecord for file:', fileinfo.name);
next();
};
}
else {
// Something unexpected happened!
// All we can do is report it and move on
console.error('MediaDB: unexpected error in insertRecord:',
request.error, 'for file:', fileinfo.name);
next();
}
};
}
}
}
// Don't send out notification events right away. Wait a short time to
// see if others arrive that we can batch up. This is common for scanning
function queueCreateNotification(media, fileinfo) {
var creates = media.details.pendingCreateNotifications;
creates.push(fileinfo);
if (media.batchSize && creates.length >= media.batchSize) {
sendNotifications(media);
} else {
resetNotificationTimer(media);
}
}
function queueDeleteNotification(media, filename) {
var deletes = media.details.pendingDeleteNotifications;
deletes.push(filename);
if (media.batchSize && deletes.length >= media.batchSize) {
sendNotifications(media);
} else {
resetNotificationTimer(media);
}
}
function resetNotificationTimer(media) {
var details = media.details;
if (details.pendingNotificationTimer) {
clearTimeout(details.pendingNotificationTimer);
}
details.pendingNotificationTimer =
setTimeout(function() { sendNotifications(media); },
media.scanning ? media.batchHoldTime : 100);
}
// Send out notifications for creations and deletions
function sendNotifications(media) {
var details = media.details;
if (details.pendingNotificationTimer) {
clearTimeout(details.pendingNotificationTimer);
details.pendingNotificationTimer = null;
}
if (details.pendingDeleteNotifications.length > 0) {
var deletions = details.pendingDeleteNotifications;
details.pendingDeleteNotifications = [];
dispatchEvent(media, 'deleted', deletions);
}
if (details.pendingCreateNotifications.length > 0) {
var creations = details.pendingCreateNotifications;
details.pendingCreateNotifications = [];
// If this is a first scan, and we have records that are not
// in the db yet, write them to the db now
if (details.firstscan && details.records.length > 0) {
var transaction = media.db.transaction('files', 'readwrite');
var store = transaction.objectStore('files');
for (var i = 0; i < details.records.length; i++) {
store.add(details.records[i]);
}
details.records.length = 0;
// One of the original points of this firstscan optimization was that
// we could dispatch the created events without waiting for the
// database writes to complete. It turns out (see bug 963917) that
// we can't do that because the Gallery app needs to read records
// from the db in order to be sure it is holding file-based blobs
// instead of memory-based blobs. So we wait for the transaction to
// complete before sending the notifications.
transaction.oncomplete = function() {
dispatchEvent(media, 'created', creations);
};
}
else {
dispatchEvent(media, 'created', creations);
}
}
}
function dispatchEvent(media, type, detail) {
var handler = media['on' + type];
var listeners = media.details.eventListeners[type];
// Return if there is nothing to handle the event
if (!handler && (!listeners || listeners.length === 0)) {
return;
}
// We use a fake event object
var event = {
type: type,
target: media,
currentTarget: media,
timestamp: Date.now(),
detail: detail
};
// Call the 'on' handler property if there is one
if (typeof handler === 'function') {
try {
handler.call(media, event);
}
catch (e) {
console.warn('MediaDB: ', 'on' + type,
'event handler threw', e, e.stack);
}
}
// Now call the listeners if there are any
if (!listeners) {
return;
}
for (var i = 0; i < listeners.length; i++) {
try {
var listener = listeners[i];
if (typeof listener === 'function') {
listener.call(media, event);
}
else {
listener.handleEvent(event);
}
}
catch (e) {
console.warn('MediaDB: ', type, 'event listener threw', e, e.stack);
}
}
}
function changeState(media, state) {
if (media.state !== state) {
media.state = state;
switch (state) {
case MediaDB.READY:
case MediaDB.ENUMERABLE:
dispatchEvent(media, state);
break;
default:
dispatchEvent(media, 'unavailable', state);
break;
}
}
}
return MediaDB;
}());