Source: util/content_database_writer.js

/**
 * @license
 * Copyright 2015 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

goog.provide('shaka.util.ContentDatabaseWriter');

goog.require('shaka.asserts');
goog.require('shaka.media.SegmentIndex');
goog.require('shaka.media.SegmentReference');
goog.require('shaka.media.StreamInfo');
goog.require('shaka.player.Defaults');
goog.require('shaka.player.DrmInfo');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.ContentDatabase');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.IBandwidthEstimator');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.TypedBind');


/**
 * @event shaka.util.ContentDatabaseWriter.ProgressEvent
 * @description Fired to indicate progess while downloading and storing streams.
 * @property {string} type 'progress'
 * @property {number} detail Percentage of group references already stored.
 * @property {boolean} bubbles True
 */



/**
 * Creates a new ContentDatabaseWriter.
 *
 * @param {shaka.util.IBandwidthEstimator} estimator
 * @param {shaka.util.FakeEventTarget} parent
 *
 * @fires shaka.util.ContentDatabaseWriter.ProgressEvent
 *
 * @constructor
 * @struct
 * @extends {shaka.util.ContentDatabase}
 */
shaka.util.ContentDatabaseWriter = function(estimator, parent) {
  shaka.util.ContentDatabase.call(this, 'readwrite', parent);

  /** @private {shaka.util.IBandwidthEstimator} */
  this.estimator_ = estimator;

  /** @private {number} */
  this.segmentRequestTimeout_ = shaka.player.Defaults.SEGMENT_REQUEST_TIMEOUT;
};
goog.inherits(shaka.util.ContentDatabaseWriter, shaka.util.ContentDatabase);


/**
 * The target size of each chunk in bytes of content stored in the database.
 *
 * @private {number}
 * @const
 */
shaka.util.ContentDatabaseWriter.TARGET_SEGMENT_SIZE_ = 1 * 1024 * 1024;


/**
 * @typedef {{
 *    streamId: number,
 *    segment: ArrayBuffer,
 *    segmentId: number,
 *    references: !Array.<shaka.util.ContentDatabase.SegmentInformation>,
 *    firstReference: shaka.media.SegmentReference,
 *    totalReferences: number,
 *    referencesInserted: number
 *  }}
 */
shaka.util.ContentDatabaseWriter.InsertStreamState;


/**
 * Sets the segment request timeout in seconds.
 *
 * @param {number} timeout
 */
shaka.util.ContentDatabaseWriter.prototype.setSegmentRequestTimeout =
    function(timeout) {
  shaka.asserts.assert(!isNaN(timeout));
  this.segmentRequestTimeout_ = timeout;
};


/**
 * Inserts a group of streams into the database.
 * @param {!Array.<!shaka.media.StreamInfo>} streamInfos The streams to insert
 *    as a group.
 * @param {!Array.<string>} sessionIds The IDs of the MediaKeySessions for
 *    this group.
 * @param {?number} duration The max stream's entire duration in the group.
 * @param {shaka.player.DrmInfo} drmInfo The group's DrmInfo.
 * @return {!Promise.<number>} The unique id assigned to the group.
 */
shaka.util.ContentDatabaseWriter.prototype.insertGroup = function(
    streamInfos, sessionIds, duration, drmInfo) {
  /** @type {!Array.<!shaka.media.SegmentIndex>} */
  var segmentIndexes = [];

  /** @type {!Array.<ArrayBuffer>} */
  var initDatas = [];

  var totalReferences = 0;
  var referencesInserted = 0;
  var streamIds = [];

  // Create SegmentIndexes.
  var async1 = streamInfos.map(
      function(streamInfo) {
        return streamInfo.segmentIndexSource.create();
      });
  var promise1 = Promise.all(async1);

  // Create initialization datas.
  var async2 = streamInfos.map(
      function(streamInfo) {
        return streamInfo.segmentInitSource.create();
      });
  var promise2 = Promise.all(async2);

  var p = Promise.all([promise1, promise2]).then(
      /** @param {!Array} results */
      function(results) {
        segmentIndexes = results[0];
        initDatas = results[1];
        totalReferences = segmentIndexes.reduce(
            function(sum, index) {
              return sum + index.length();
            }, 0);
      });

  // Insert each stream into the database.
  for (var i = 0; i < streamInfos.length; ++i) {
    p = p.then(
        function(index) {
          return this.insertStream_(streamInfos[index],
                                    segmentIndexes[index],
                                    initDatas[index],
                                    totalReferences,
                                    referencesInserted);
        }.bind(this, i));

    p = p.then(
        function(index, streamId) {
          referencesInserted += segmentIndexes[index].length();
          streamIds.push(streamId);
        }.bind(this, i));
  }

  return p.then(shaka.util.TypedBind(this,
      function() {
        return this.getNextId_(this.getGroupStore());
      })
  ).then(shaka.util.TypedBind(this,
      /** @param {number} groupId */
      function(groupId) {
        var groupPromise = new shaka.util.PublicPromise();

        sessionIds = shaka.util.ArrayUtils.removeDuplicates(sessionIds);
        var groupInfo = {
          'group_id': groupId,
          'stream_ids': streamIds,
          'session_ids': sessionIds,
          'duration': duration,
          'key_system': drmInfo.keySystem,
          'license_server': drmInfo.licenseServerUrl,
          'with_credentials': drmInfo.withCredentials,
          'distinctive_identifier': drmInfo.distinctiveIdentifierRequired,
          'audio_robustness': drmInfo.audioRobustness,
          'video_robustness': drmInfo.videoRobustness
        };
        var request = this.getGroupStore().put(groupInfo);

        request.onsuccess = function() { groupPromise.resolve(groupId); };
        request.onerror = function(e) { groupPromise.reject(request.error); };

        return groupPromise;
      }));
};


/**
 * Deletes a group of streams from the database.
 * @param {number} groupId The unique id of the group to delete.
 * @return {!Promise}
 */
shaka.util.ContentDatabaseWriter.prototype.deleteGroup = function(groupId) {
  var p = this.retrieveItem(this.getGroupStore(), groupId);
  return p.then(shaka.util.TypedBind(this,
      /** @param {shaka.util.ContentDatabase.GroupInformation} groupInfo */
      function(groupInfo) {
        var async = [];
        for (var id in groupInfo['stream_ids']) {
          async.push(this.deleteStream_(groupInfo['stream_ids'][id]));
        }
        var groupStore = this.getGroupStore();
        async.push(groupStore.delete(groupId));
        return Promise.all(async);
      }));
};


/**
 * Inserts a stream into the database.
 * @param {!shaka.media.StreamInfo} streamInfo
 * @param {!shaka.media.SegmentIndex} segmentIndex
 * @param {ArrayBuffer} initData
 * @param {number} totalReferences Number of references in this streams group.
 * @param {number} referencesInserted Number of references already inserted.
 * @return {!Promise.<number>} The unique id assigned to the stream.
 * @private
 */
shaka.util.ContentDatabaseWriter.prototype.insertStream_ = function(
    streamInfo, segmentIndex, initData, totalReferences, referencesInserted) {
  var async = [
    this.getNextId_(this.getIndexStore()),
    this.getNextId_(this.getContentStore().index('stream'))
  ];

  var p = Promise.all(async).then(shaka.util.TypedBind(this, function(results) {
    /** @type {!shaka.util.ContentDatabaseWriter.InsertStreamState} */
    var state = {
      streamId: Math.max(results[0], results[1]),
      segment: new ArrayBuffer(0),
      segmentId: 0,
      references: [],
      firstReference: null,
      totalReferences: totalReferences,
      referencesInserted: referencesInserted
    };
    return state;
  }));
  p = p.then(this.insertStreamContent_.bind(this, segmentIndex));
  p = p.then(this.insertStreamIndex_.bind(this, streamInfo, initData));
  return p;
};


/**
 * Gets the next id to be used in the given store. If no entries currently exist
 * 0 will be returned.
 * @param {!IDBObjectStore|!IDBIndex} store The store or store's index whose
 *    next id will be retrieved.
 * @return {!Promise.<number>} The next id or 0.
 * @private
 */
shaka.util.ContentDatabaseWriter.prototype.getNextId_ = function(store) {
  var p = new shaka.util.PublicPromise();
  var request = store.openCursor(null, 'prev');
  request.onsuccess = function(e) {
    if (e.target.result) {
      var nextId = e.target.result.key + 1;
      p.resolve(nextId);
    } else {
      p.resolve(0);
    }
  };
  request.onerror = function(e) { p.reject(request.error); };
  return p;
};


/**
 * Inserts a stream index into the stream index store.
 * @param {!shaka.media.StreamInfo} streamInfo
 * @param {ArrayBuffer} initData
 * @param {!shaka.util.ContentDatabaseWriter.InsertStreamState} state
 *     The stream's state information.
 * @return {!Promise.<number>} The unique id assigned to the stream.
 * @private
 */
shaka.util.ContentDatabaseWriter.prototype.insertStreamIndex_ = function(
    streamInfo, initData, state) {
  var p = new shaka.util.PublicPromise();
  var streamIndex = {
    'stream_id': state.streamId,
    'mime_type': streamInfo.mimeType,
    'codecs': streamInfo.codecs,
    'init_segment': initData,
    'references': state.references
  };
  var indexStore = this.getIndexStore();
  var request = indexStore.put(streamIndex);

  request.onsuccess = function() { p.resolve(state.streamId); };
  request.onerror = function(e) { p.reject(request.error); };

  return p;
};


/**
 * Inserts stream content into the stream content store.
 * @param {!shaka.media.SegmentIndex} segmentIndex
 * @param {!shaka.util.ContentDatabaseWriter.InsertStreamState} state The
 *     stream's state information.
 * @return {!Promise.<shaka.util.ContentDatabaseWriter.InsertStreamState>}
 * @private
 */
shaka.util.ContentDatabaseWriter.prototype.insertStreamContent_ = function(
    segmentIndex, state) {
  // Initialize promise and stream insertion information to use in loop.
  var segmentPromise = Promise.resolve();

  for (var i = 0; i < segmentIndex.length(); ++i) {
    var reference = segmentIndex.get(i);
    var isLast = (i == segmentIndex.length() - 1);
    var requestSegment = this.requestSegment_.bind(this, reference);
    var appendSegment = this.appendSegment_.bind(this, reference, state,
                                                 isLast);
    segmentPromise = segmentPromise.then(requestSegment);
    segmentPromise = segmentPromise.then(appendSegment);
  }
  return segmentPromise.then(
      function() {
        return Promise.resolve(state);
      }
  ).catch(shaka.util.TypedBind(this,
      /** {Error} e */
      function(e) {
        this.deleteStream_(state.streamId);
        return Promise.reject(e);
      }));
};


/**
 * Appends |segment| to |segments| and adds |segments| array to the database
 * if over target segment size or is the last segment.
 * @param {shaka.media.SegmentReference} ref The SegmentReference describing the
 *   current segment.
 * @param {shaka.util.ContentDatabaseWriter.InsertStreamState} state The state
 *     of the current stream being inserted.
 * @param {boolean} isLast True for the last segment in a stream.
 * @param {!ArrayBuffer} segment The current segment of the stream.
 * @return {!Promise}
 * @private
 */
shaka.util.ContentDatabaseWriter.prototype.appendSegment_ = function(
    ref, state, isLast, segment) {
  var p = new shaka.util.PublicPromise();

  if (state.segment.byteLength == 0) {
    state.firstReference = ref;
  }
  state.segment = this.concatArrayBuffers_(state.segment, segment);
  state.referencesInserted++;
  var percent = (state.referencesInserted / state.totalReferences) * 100;
  var event = shaka.util.FakeEvent.create(
      { type: 'progress',
        detail: percent,
        bubbles: true });
  var size = state.segment.byteLength;
  if (size >= shaka.util.ContentDatabaseWriter.TARGET_SEGMENT_SIZE_ || isLast) {
    var data = {
      'stream_id': state.streamId,
      'segment_id': state.segmentId,
      'content': state.segment
    };
    var request = this.getContentStore().put(data);
    var segRef = {
      'start_time': state.firstReference.startTime,
      'start_byte' : state.firstReference.url.startByte,
      'end_time': ref.endTime,
      'url': 'idb://' + state.streamId + '/' + state.segmentId
    };
    state.references.push(segRef);
    state.segmentId++;
    state.segment = new ArrayBuffer(0);

    request.onerror = function(e) { p.reject(request.error); };
    request.onsuccess = shaka.util.TypedBind(this, function() {
      this.dispatchEvent(event);
      p.resolve();
    });
  } else {
    this.dispatchEvent(event);
    p.resolve();
  }
  return p;
};


/**
 * Concatenates two ArrayBuffer's.
 * @param {ArrayBuffer} bufferOne The first ArrayBuffer.
 * @param {ArrayBuffer} bufferTwo The second ArrayBuffer.
 * @return {!ArrayBuffer}
 * @private
 */
shaka.util.ContentDatabaseWriter.prototype.concatArrayBuffers_ =
    function(bufferOne, bufferTwo) {
  var view = new Uint8Array(bufferOne.byteLength + bufferTwo.byteLength);
  view.set(new Uint8Array(bufferOne), 0);
  view.set(new Uint8Array(bufferTwo), bufferOne.byteLength);
  return view.buffer;
};


/**
 * Requests the segment specified by |reference|.
 * @param {shaka.media.SegmentReference} reference
 * @return {!Promise.<!ArrayBuffer>}
 * @private
 */
shaka.util.ContentDatabaseWriter.prototype.requestSegment_ = function(
    reference) {
  var params = new shaka.util.AjaxRequest.Parameters();
  params.requestTimeoutMs = this.segmentRequestTimeout_ * 1000;
  return /** @type {!Promise.<!ArrayBuffer>} */ (
      reference.url.fetch(params, this.estimator_));
};


/**
 * Deletes a stream from the database.
 * @param {number} streamId The unique id of the stream to delete.
 * @return {!Promise}
 * @private
 */
shaka.util.ContentDatabaseWriter.prototype.deleteStream_ = function(streamId) {
  var p = new shaka.util.PublicPromise();
  var indexStore = this.getIndexStore();
  var request = indexStore.delete(streamId);
  request.onerror = function(e) { p.reject(request.error); };

  var store = this.getContentStore();
  store.index('stream').openKeyCursor(IDBKeyRange.only(streamId)).onsuccess =
      function(event) {
    /** @type {!IDBCursor} */
    var cursor = event.target.result;
    if (cursor) {
      store.delete(cursor.primaryKey);
      cursor.continue();
    }
  };
  store.transaction.oncomplete = function(e) { p.resolve(); };
  return p;
};