Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
path: root/shared/js/mediadb.js
diff options
context:
space:
mode:
Diffstat (limited to 'shared/js/mediadb.js')
-rw-r--r--shared/js/mediadb.js1532
1 files changed, 1532 insertions, 0 deletions
diff --git a/shared/js/mediadb.js b/shared/js/mediadb.js
new file mode 100644
index 0000000..cfdab33
--- /dev/null
+++ b/shared/js/mediadb.js
@@ -0,0 +1,1532 @@
+/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil -*- */
+/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
+
+'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:
+ *
+ * directory:
+ * a subdirectory of the DeviceStorage directory. If you are only
+ * interested in images in the screenshots/ subdirectory for example,
+ * you can set this property to "screenshots/".
+ *
+ * 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.
+ *
+ * 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
+ * '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, directory 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
+ * 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
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * - 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.directory = options.directory || '';
+ this.mimeTypes = options.mimeTypes;
+ this.autoscan = (options.autoscan !== undefined) ? options.autoscan : true;
+ this.state = MediaDB.OPENING;
+ this.scanning = false; // becomes true while scanning
+
+ // 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;
+
+ if (this.directory &&
+ this.directory[this.directory.length - 1] !== '/')
+ this.directory += '/';
+
+ this.dbname = 'MediaDB/' + this.mediaType + '/' + this.directory;
+
+ 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
+ var openRequest = indexedDB.open(this.dbname,
+ this.version * MediaDB.VERSION);
+
+ // 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;
+
+ // If there are already existing object stores, delete them all
+ // If the version number changes we just want to start over.
+ var existingStoreNames = db.objectStoreNames;
+ for (var i = 0; i < existingStoreNames.length; i++) {
+ db.deleteObjectStore(existingStoreNames[i]);
+ }
+
+ // 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);
+ });
+ }
+
+ // 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);
+ }
+
+ // 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();
+ };
+ };
+
+ function initDeviceStorage() {
+ // Set up DeviceStorage
+ // If storage is null, then there is no sdcard installed and
+ // we have to abort.
+ media.storage = navigator.getDeviceStorage(mediaType);
+
+ // Handle change notifications from device storage
+ // We set this onchange property to null in the close() method
+ // so don't use addEventListener here
+ media.storage.addEventListener('change', deviceStorageChangeHandler);
+ media.details.dsEventListener = deviceStorageChangeHandler;
+
+ // Use available() to figure out if there is actually an sdcard there
+ // and emit a ready or unavailable event
+ var availreq = media.storage.available();
+ availreq.onsuccess = function(e) {
+ switch (e.target.result) {
+ case 'available':
+ changeState(media, MediaDB.READY);
+ if (media.autoscan)
+ scan(media); // Start scanning as soon as we're ready
+ break;
+ case 'unavailable':
+ changeState(media, MediaDB.NOCARD);
+ break;
+ case 'shared':
+ changeState(media, MediaDB.UNMOUNTED);
+ break;
+ }
+ };
+ availreq.onerror = function(e) {
+ console.error('available() failed',
+ availreq.error && availreq.error.name);
+ changeState(media, MediaDB.UNMOUNTED);
+ };
+ }
+
+ function deviceStorageChangeHandler(e) {
+ var filename;
+ switch (e.reason) {
+ case 'available':
+ changeState(media, MediaDB.READY);
+ if (media.autoscan)
+ scan(media); // automatically scan every time the card comes back
+ break;
+ case 'unavailable':
+ changeState(media, MediaDB.NOCARD);
+ endscan(media);
+ break;
+ case 'shared':
+ changeState(media, MediaDB.UNMOUNTED);
+ endscan(media);
+ break;
+ case 'modified':
+ case 'deleted':
+ filename = e.path;
+ if (ignoreName(filename))
+ break;
+ if (media.directory) {
+ // Ignore changes outside of our directory
+ if (filename.substring(0, media.directory.length) !==
+ media.directory)
+ break;
+ // And strip the directory from changes inside of it
+ filename = filename.substring(media.directory.length);
+ }
+ if (e.reason === 'modified')
+ insertRecord(media, filename);
+ else
+ deleteRecord(media, filename);
+ break;
+ }
+ }
+ }
+
+ 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.
+ this.storage.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 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 getRequest = this.storage.get(this.directory + 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);
+
+ this.storage.delete(this.directory + 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;
+
+ // Delete any existing file by this name, then save the file.
+ var deletereq = media.storage.delete(media.directory + filename);
+ deletereq.onsuccess = deletereq.onerror = save;
+
+ function save() {
+ var request = media.storage.addNamed(file, media.directory + filename);
+ request.onerror = function() {
+ console.error('MediaDB: Failed to store', filename,
+ 'in DeviceStorage:', storeRequest.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.READY)
+ 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)
+ throw Error('MediaDB is not ready. 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)
+ throw Error('MediaDB is not ready. 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 (!cursor.value.fail) // if metadata parsing succeeded
+ callback(cursor.value);
+ }
+ catch (e) {
+ console.warn('MediaDB.enumerate(): callback threw', e);
+ }
+ cursor.continue();
+ }
+ 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)
+ throw Error('MediaDB is not ready. 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 freereq = this.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.
+ // This is version 2 because we modified the default schema to include
+ // an index for file modification date.
+ MediaDB.VERSION = 2;
+
+ // 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.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'; // Unavailalbe 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(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(filename) {
+ 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 cursor;
+ if (timestamp > 0) {
+ media.details.firstscan = false;
+ cursor = media.storage.enumerate(media.directory, {
+ // 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 = [];
+ cursor = media.storage.enumerate(media.directory);
+ }
+
+ cursor.onsuccess = function() {
+ var file = cursor.result;
+ if (file) {
+ if (!ignore(media, file))
+ insertRecord(media, file);
+ cursor.continue();
+ }
+ else {
+ // 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();
+ }
+ });
+ }
+ };
+
+ cursor.onerror = function() {
+ // We can't scan if we can't read device storage.
+ // Perhaps the card was unmounted or pulled out
+ console.warning('Error while scanning', cursor.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 = [];
+ var cursor = media.storage.enumerate(media.directory);
+ cursor.onsuccess = function() {
+ var file = cursor.result;
+ if (file) {
+ if (!ignore(media, file)) {
+ dsfiles.push(file);
+ }
+ cursor.continue();
+ }
+ else {
+ // We're done enumerating device storage, so get all files from db
+ getDBFiles();
+ }
+ }
+
+ cursor.onerror = function() {
+ // We can't scan if we can't read device storage.
+ // Perhaps the card was unmounted or pulled out
+ console.warning('Error while scanning', cursor.error);
+ endscan(media);
+ };
+
+ function getDBFiles() {
+ var store = media.db.transaction('files').objectStore('files');
+ var getAllRequest = store.mozGetAll();
+
+ getAllRequest.onsuccess = function() {
+ 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 and size are the same for both: do nothing
+ // 4b: file has changed: it is both a deletion and a creation
+ if (dsfile.name === dbfile.name) {
+ var lastModified = dsfile.lastModifiedDate;
+ if ((lastModified && lastModified.getTime() !== dbfile.date) ||
+ dsfile.size !== dbfile.size) {
+ deleteRecord(media, dbfile.name);
+ insertRecord(media, dsfile);
+ }
+ 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);
+ }
+ }
+ }
+
+ // 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.scanning = 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. If the
+ // 2nd argument is a File, it should come from enumerate() so that
+ // the name property does not include the directory prefix. If it
+ // is a name, then the directory prefix must already have been
+ // stripped.
+ function insertRecord(media, fileOrName) {
+ var details = media.details;
+
+ // Add this file to the queue of files to process
+ details.pendingInsertions.push(fileOrName);
+
+ // 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.
+ // filename must not include the directory prefix.
+ 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, getreq.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 might be a filename or a File object
+ // If it is a File, then it came from enumerate and its name
+ // property already has the directory stripped off. If it is a
+ // filename, it came from a device storage change event and we
+ // stripped of the directory before calling insertRecord.
+ function insertFile(f) {
+ // 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') {
+ var getreq = media.storage.get(media.directory + f);
+ getreq.onerror = function() {
+ console.warn('MediaDB: Unknown file in insertRecord:',
+ media.directory + f, getreq.error);
+ next();
+ };
+ getreq.onsuccess = function() {
+ parseMetadata(getreq.result, f);
+ };
+ }
+ else {
+ // otherwise f is the file we want
+ parseMetadata(f, f.name);
+ }
+ }
+
+ function parseMetadata(file, filename) {
+ 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);
+ 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;
+ storeRecord(fileinfo);
+ }
+ }
+
+ 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.batchHoldTime);
+ }
+
+ // 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) {
+
+ // 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;
+ }
+
+ var creations = details.pendingCreateNotifications;
+ details.pendingCreateNotifications = [];
+ 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);
+ }
+ }
+
+ // 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);
+ }
+ }
+ }
+
+ function changeState(media, state) {
+ if (media.state !== state) {
+ media.state = state;
+ if (state === MediaDB.READY)
+ dispatchEvent(media, 'ready');
+ else
+ dispatchEvent(media, 'unavailable', state);
+ }
+ }
+
+ return MediaDB;
+
+}());