Source: player/offline_video_source.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.player.OfflineVideoSource');

goog.require('goog.Uri');
goog.require('shaka.asserts');
goog.require('shaka.dash.MpdProcessor');
goog.require('shaka.dash.MpdRequest');
goog.require('shaka.dash.mpd');
goog.require('shaka.features');
goog.require('shaka.log');
goog.require('shaka.media.EmeManager');
goog.require('shaka.media.IAbrManager');
goog.require('shaka.media.OfflineSegmentIndexSource');
goog.require('shaka.media.SegmentIndex');
goog.require('shaka.media.SimpleAbrManager');
goog.require('shaka.media.StreamInfo');
goog.require('shaka.player.StreamVideoSource');
goog.require('shaka.util.ContentDatabaseReader');
goog.require('shaka.util.ContentDatabaseWriter');
goog.require('shaka.util.FailoverUri');
goog.require('shaka.util.IBandwidthEstimator');
goog.require('shaka.util.TypedBind');
goog.require('shaka.util.Uint8ArrayUtils');



/**
 * Creates an OfflineVideoSource.
 * @param {?number} groupId The unique ID of the group of streams
 *    in this source.
 * @param {shaka.util.IBandwidthEstimator} estimator
 * @param {shaka.media.IAbrManager} abrManager
 *
 * @struct
 * @constructor
 * @extends {shaka.player.StreamVideoSource}
 * @exportDoc
 */
shaka.player.OfflineVideoSource = function(groupId, estimator, abrManager) {
  if (!estimator) {
    // For backward compatibility, provide an instance of the default
    // implementation if none is provided.
    estimator = new shaka.util.EWMABandwidthEstimator();
  }
  if (!abrManager) {
    abrManager = new shaka.media.SimpleAbrManager();
  }

  shaka.player.StreamVideoSource.call(this, null, estimator, abrManager);

  /** @private {?number} */
  this.groupId_ = groupId;

  /** @private {!Array.<string>} */
  this.sessionIds_ = [];

  /**
   * The timeout, in milliseconds, for downloading and storing offline licenses
   * for encrypted content.
   * @type {number}
   * @expose
   */
  this.timeoutMs = 30000;

  /** @private {!Object.<string, *>} */
  this.config_ = {};

  /** @private {shaka.util.FailoverUri.NetworkCallback} */
  this.networkCallback_ = null;

  /** @private {shaka.player.DrmInfo.Config} */
  this.overrideConfig_ = null;
};
goog.inherits(shaka.player.OfflineVideoSource, shaka.player.StreamVideoSource);
if (shaka.features.Offline) {
  goog.exportSymbol('shaka.player.OfflineVideoSource',
                    shaka.player.OfflineVideoSource);
}


/**
 * A callback to the application to choose the tracks which will be stored
 * offline.  Returns a Promise to an array of track IDs.  This uses Promises
 * so that the application can, if it chooses, display some dialog to the user
 * to drive the choice of tracks.
 *
 * @typedef {function():!Promise.<!Array.<number>>}
 * @expose
 */
shaka.player.OfflineVideoSource.ChooseTracksCallback;


/**
 * Configures the OfflineVideoSource options.
 * Options are set via key-value pairs.
 *
 * The following configuration options are supported:
 *  licenseRequestTimeout: number
 *    Sets the license request timeout in seconds.
 *  mpdRequestTimeout: number
 *    Sets the MPD request timeout in seconds.
 *  segmentRequestTimeout: number
 *    Sets the segment request timeout in seconds.
 *
 * @example
 *     offlineVideoSouce.configure({'licenseRequestTimeout': 20});
 *
 * @param {!Object.<string, *>} config A configuration object, which contains
 *     the configuration options as key-value pairs. All fields should have
 *     already been validated.
 * @override
 */
shaka.player.OfflineVideoSource.prototype.configure = function(config) {
  if (config['licenseRequestTimeout'] != null) {
    this.config_['licenseRequestTimeout'] = config['licenseRequestTimeout'];
  }
  if (config['segmentRequestTimeout'] != null) {
    this.config_['segmentRequestTimeout'] = config['segmentRequestTimeout'];
  }
  var baseClassConfigure = shaka.player.StreamVideoSource.prototype.configure;
  baseClassConfigure.call(this, config);
};


/**
 * Retrieves an array of all stored group IDs.
 * @return {!Promise.<!Array.<number>>} The unique IDs of all of the
 *    stored groups.
 * @exportDoc
 */
shaka.player.OfflineVideoSource.retrieveGroupIds = function() {
  var contentDatabase = new shaka.util.ContentDatabaseReader();

  var p = contentDatabase.setUpDatabase().then(
      function() {
        return contentDatabase.retrieveGroupIds();
      });

  p.then(
      function() {
        contentDatabase.closeDatabaseConnection();
      }
  ).catch(
      function() {
        contentDatabase.closeDatabaseConnection();
      }
  );

  return p;
};
if (shaka.features.Offline) {
  goog.exportSymbol('shaka.player.OfflineVideoSource.retrieveGroupIds',
                    shaka.player.OfflineVideoSource.retrieveGroupIds);
}


/**
 * Stores the content described by the MPD for offline playback.
 * @param {string} mpdUrl The MPD URL.
 * @param {string} preferredLanguage The user's preferred language tag.
 * @param {?shaka.player.DashVideoSource.ContentProtectionCallback}
 *     interpretContentProtection A callback to interpret the ContentProtection
 *     elements in the MPD.
 * @param {shaka.player.OfflineVideoSource.ChooseTracksCallback} chooseTracks
 * @return {!Promise.<number>} The group ID of the stored content.
 * @exportDoc
 */
shaka.player.OfflineVideoSource.prototype.store = function(
    mpdUrl, preferredLanguage, interpretContentProtection, chooseTracks) {
  var emeManager;
  var error = null;

  /** @type {!Object.<number, !shaka.media.StreamInfo>} */
  var streamIdMap = {};

  /** @type {!Array.<!shaka.media.StreamInfo>} */
  var selectedStreams = [];

  var failover = new shaka.util.FailoverUri(this.networkCallback_,
                                            [new goog.Uri(mpdUrl)]);
  var mpdRequest = new shaka.dash.MpdRequest(failover, this.mpdRequestTimeout);

  return mpdRequest.send().then(shaka.util.TypedBind(this,
      /** @param {!shaka.dash.mpd.Mpd} mpd */
      function(mpd) {
        var mpdProcessor =
            new shaka.dash.MpdProcessor(interpretContentProtection);
        this.manifestInfo = mpdProcessor.process(mpd, this.networkCallback_);

        if (this.manifestInfo.live) {
          var error = new Error('Unable to store live streams offline.');
          error.type = 'app';
          return Promise.reject(error);
        }

        this.configure({'preferredLanguage': preferredLanguage});
        var baseClassLoad = shaka.player.StreamVideoSource.prototype.load;
        return baseClassLoad.call(this);
      })
  ).then(shaka.util.TypedBind(this,
      function() {
        var fakeVideoElement = /** @type {!HTMLVideoElement} */ (
            document.createElement('video'));
        fakeVideoElement.src = window.URL.createObjectURL(this.mediaSource);

        emeManager =
            new shaka.media.EmeManager(null, fakeVideoElement, this);
        if (this.config_['licenseRequestTimeout'] != null) {
          emeManager.setLicenseRequestTimeout(
              Number(this.config_['licenseRequestTimeout']));
        }
        this.eventManager.listen(
            emeManager, 'sessionReady', this.onSessionReady_.bind(this));
        this.eventManager.listen(emeManager, 'error', function(e) {
          error = e;
        });
        return emeManager.initialize();
      })
  ).then(shaka.util.TypedBind(this,
      function() {
        // Build a map of stream IDs.
        var streamSetInfos = this.streamSetsByType.getAll();
        for (var i = 0; i < streamSetInfos.length; ++i) {
          var streamSetInfo = streamSetInfos[i];
          for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) {
            var streamInfo = streamSetInfo.streamInfos[j];
            streamIdMap[streamInfo.uniqueId] = streamInfo;
          }
        }

        // Ask the application to choose streams.
        return chooseTracks();
      })
  ).then(shaka.util.TypedBind(this,
      /** @param {!Array.<number>} trackIds */
      function(trackIds) {
        // Map the track IDs back to streams.
        for (var i = 0; i < trackIds.length; ++i) {
          var id = trackIds[i];
          var selectedStream = streamIdMap[id];
          if (selectedStream) {
            selectedStreams.push(selectedStream);
          } else {
            return Promise.reject(new Error('Invalid stream ID chosen: ' + id));
          }
        }

        // Only keep those which are supported types.
        // TODO(natalieharris): Add text support.
        var supportedTypes = ['audio', 'video'];
        selectedStreams = selectedStreams.filter(function(streamInfo) {
          if (supportedTypes.indexOf(streamInfo.getContentType()) < 0) {
            shaka.log.warning('Ignoring track ID ' + streamInfo.uniqueId +
                              ' due to unsupported type: ' +
                              streamInfo.getContentType());
            return false;
          }
          return true;
        });

        var async = selectedStreams.map(
            function(streamInfo) {
              return streamInfo.segmentInitSource.create();
            });
        return Promise.all(async);
      })
  ).then(shaka.util.TypedBind(this,
      /** @param {!Array.<ArrayBuffer>} initDatas */
      function(initDatas) {
        return this.initializeStreams_(selectedStreams, initDatas);
      })
  ).then(shaka.util.TypedBind(this,
      function() {
        return emeManager.allSessionsReady(this.timeoutMs);
      })
  ).then(shaka.util.TypedBind(this,
      function() {
        if (error) {
          return Promise.reject(error);
        }

        var drmInfo = emeManager.getDrmInfo();
        // TODO(story 1890046): Support multiple periods.
        var duration = this.manifestInfo.periodInfos[0].duration;
        if (!duration) {
          shaka.log.warning('The duration of the stream being stored is null.');
        }
        shaka.asserts.assert(duration != Number.POSITIVE_INFINITY);

        return this.insertGroup_(selectedStreams, drmInfo, duration);
      })
  ).then(shaka.util.TypedBind(this,
      /** @param {number} i */
      function(i) {
        this.groupId_ = i;
        if (error) {
          this.deleteGroupContent_();
          return Promise.reject(error);
        }

        return Promise.resolve(i);
      })
  );
};
if (shaka.features.Offline && shaka.features.Dash) {
  goog.exportSymbol('shaka.player.OfflineVideoSource.prototype.store',
                    shaka.player.OfflineVideoSource.prototype.store);
}


/**
 * Sets the callback used to intercept the URL in network requests.
 *
 * @param {!shaka.util.FailoverUri.NetworkCallback} callback
 * @export
 */
shaka.player.OfflineVideoSource.prototype.setNetworkCallback =
    function(callback) {
  this.networkCallback_ = callback;
};


/**
 * Creates sourceBuffers and appends init data for each of the given streams.
 * This should trigger encrypted events for any encrypted streams.
 * @param {!Array.<!shaka.media.StreamInfo>} streamInfos The streams to
 *    initialize.
 * @param {!Array.<ArrayBuffer>} initDatas |streamInfos| corresponding
 *     initialization data.
 * @return {!Promise}
 * @private
 */
shaka.player.OfflineVideoSource.prototype.initializeStreams_ =
    function(streamInfos, initDatas) {
  shaka.asserts.assert(streamInfos.length == initDatas.length);

  var sourceBuffers = [];
  for (var i = 0; i < streamInfos.length; ++i) {
    try {
      var fullMimeType = streamInfos[i].getFullMimeType();
      sourceBuffers[i] = this.mediaSource.addSourceBuffer(fullMimeType);
    } catch (exception) {
      shaka.log.error('addSourceBuffer() failed', exception);
    }
  }

  if (streamInfos.length != sourceBuffers.length) {
    var error = new Error('Error initializing streams.');
    error.type = 'storage';
    return Promise.reject(error);
  }

  for (var i = 0; i < initDatas.length; ++i) {
    var initData = initDatas[i];
    if (initData) {
      sourceBuffers[i].appendBuffer(initData);
    }
  }

  return Promise.resolve();
};


/**
 * Event handler for sessionReady events.
 * @param {Event} event A sessionReady event.
 * @private
 */
shaka.player.OfflineVideoSource.prototype.onSessionReady_ = function(event) {
  var session = /** @type {MediaKeySession} */ (event.detail);
  this.sessionIds_.push(session.sessionId);
};


/**
 * Inserts a group of streams into the database.
 * @param {!Array.<!shaka.media.StreamInfo>} selectedStreams The streams to
 *    insert.
 * @param {shaka.player.DrmInfo} drmInfo
 * @param {?number} duration The duration of the entire stream.
 * @return {!Promise.<number>} The unique id assigned to the group.
 * @private
 */
shaka.player.OfflineVideoSource.prototype.insertGroup_ =
    function(selectedStreams, drmInfo, duration) {
  var contentDatabase =
      new shaka.util.ContentDatabaseWriter(this.estimator, this);
  if (this.config_['segmentRequestTimeout'] != null) {
    contentDatabase.setSegmentRequestTimeout(
        Number(this.config_['segmentRequestTimeout']));
  }

  // Insert the group of streams into the database and close the connection.
  return contentDatabase.setUpDatabase().then(shaka.util.TypedBind(this,
      function() {
        return contentDatabase.insertGroup(
            selectedStreams, this.sessionIds_, duration, drmInfo);
      })
  ).then(
      /** @param {number} groupId */
      function(groupId) {
        contentDatabase.closeDatabaseConnection();
        return Promise.resolve(groupId);
      }
  ).catch(
      /** @param {*} e */
      function(e) {
        contentDatabase.closeDatabaseConnection();
        return Promise.reject(e);
      }
  );
};


/** @override */
shaka.player.OfflineVideoSource.prototype.load = function() {
  shaka.asserts.assert(this.groupId_ >= 0);
  var contentDatabase = new shaka.util.ContentDatabaseReader();
  var duration, config;

  return contentDatabase.setUpDatabase().then(shaka.util.TypedBind(this,
      function() {
        return contentDatabase.retrieveGroup(
            /** @type {number} */(this.groupId_));
      })
  ).then(shaka.util.TypedBind(this,
      /** @param {shaka.util.ContentDatabase.GroupInformation} group */
      function(group) {
        var async = [];
        this.sessionIds_ = group['session_ids'];
        duration = group['duration'];
        config = {
          'keySystem': group['key_system'],
          'distinctiveIdentifierRequired': group['distinctive_identifier'],
          'persistentStorageRequired': true,
          'audioRobustness': group['audio_robustness'],
          'videoRobustness': group['video_robustness'],
          'withCredentials': group['with_credentials'],
          'licenseServerUrl': group['license_server']
        };
        for (var i = 0; i < group['stream_ids'].length; ++i) {
          var streamId = group['stream_ids'][i];
          async.push(contentDatabase.retrieveStreamIndex(streamId));
        }
        return Promise.all(async);
      })
  ).then(shaka.util.TypedBind(this,
      /** @param {!Array.<shaka.util.ContentDatabase.StreamIndex>} indexes */
      function(indexes) {
        this.manifestInfo =
            this.reconstructManifestInfo_(indexes, duration, config);

        var baseClassLoad = shaka.player.StreamVideoSource.prototype.load;
        return baseClassLoad.call(this);
      })
  ).then(
      function() {
        contentDatabase.closeDatabaseConnection();
        return Promise.resolve();
      }
  ).catch(
      /** @param {*} e */
      function(e) {
        contentDatabase.closeDatabaseConnection();
        return Promise.reject(e);
      }
  );
};


/**
 * Reconstructs a ManifestInfo object with data from storage.
 * @param {!Array.<shaka.util.ContentDatabase.StreamIndex>} indexes The indexes
 *    of the streams in this manifest.
 * @param {number} duration The max stream's entire duration in the group.
 * @param {shaka.player.DrmInfo.Config} config The config info loaded from
 *     storage.
 * @return {!shaka.media.ManifestInfo}
 * @private
 */
shaka.player.OfflineVideoSource.prototype.reconstructManifestInfo_ =
    function(indexes, duration, config) {
  var manifestInfo = new shaka.media.ManifestInfo();
  manifestInfo.minBufferTime = 5;
  // TODO(story 1890046): Support multiple periods.
  var periodInfo = new shaka.media.PeriodInfo();

  for (var i = 0; i < indexes.length; ++i) {
    var storedStreamInfo = indexes[i];

    // Will only have one streamInfo per streamSetInfo stored.
    var streamInfo = new shaka.media.StreamInfo();

    var segmentIndexSource = new shaka.media.OfflineSegmentIndexSource(
        storedStreamInfo['references']);

    var segmentInitSource = new shaka.media.SegmentInitSource(
        null, storedStreamInfo['init_segment']);

    streamInfo.segmentIndexSource = segmentIndexSource;
    streamInfo.segmentInitSource = segmentInitSource;
    streamInfo.mimeType = storedStreamInfo['mime_type'];
    streamInfo.codecs = storedStreamInfo['codecs'];
    streamInfo.allowedByKeySystem = true;

    if (this.overrideConfig_) {
      if (this.overrideConfig_['licenseServerUrl'] != null) {
        config['licenseServerUrl'] = this.overrideConfig_['licenseServerUrl'];
      }
      if (this.overrideConfig_['withCredentials'] != null) {
        config['withCredentials'] = this.overrideConfig_['withCredentials'];
      }
      config['licensePostProcessor'] =
          this.overrideConfig_['licensePostProcessor'];
      config['licensePreProcessor'] =
          this.overrideConfig_['licensePreProcessor'];
      config['serverCertificate'] = this.overrideConfig_['serverCertificate'];
    }

    var drmInfo = shaka.player.DrmInfo.createFromConfig(config);
    var streamSetInfo = new shaka.media.StreamSetInfo();
    streamSetInfo.streamInfos.push(streamInfo);
    streamSetInfo.drmInfos.push(drmInfo);
    streamSetInfo.contentType = streamInfo.mimeType.split('/')[0];
    periodInfo.streamSetInfos.push(streamSetInfo);
    periodInfo.duration = duration;
  }
  manifestInfo.periodInfos.push(periodInfo);
  return manifestInfo;
};


/**
 * Deletes a group of streams from storage.  This destroys the VideoSource.
 *
 * @param {shaka.player.DrmInfo.Config=} opt_config Optional config to override
 *   the values stored.  Can only change |licenseServerUrl|, |withCredentials|,
 *   |serverCertificate|, |licensePreProcessor|, and |licensePostProcessor|.
 * @param {boolean=} opt_forceDelete True to delete the content even if there
 *   is an error when deleting the persistent session.  The error is returned.
 * @return {!Promise.<Error>}
 * @export
 */
shaka.player.OfflineVideoSource.prototype.deleteGroup =
    function(opt_config, opt_forceDelete) {
  shaka.asserts.assert(this.groupId_ >= 0);

  if (opt_config) {
    this.overrideConfig_ = {
      'licenseServerUrl': opt_config['licenseServerUrl'],
      'withCredentials': opt_config['withCredentials'],
      'serverCertificate': opt_config['serverCertificate'],
      'licensePreProcessor': opt_config['licensePreProcessor'],
      'licensePostProcessor': opt_config['licensePostProcessor']
    };
  }

  var error = null;
  return this.deletePersistentSessions_().catch(function(e) {
    if (opt_forceDelete) {
      error = e;
      return Promise.resolve();
    }

    return Promise.reject(e);
  }).then(shaka.util.TypedBind(this, function() {
    return this.deleteGroupContent_();
  })).then(function() {
    return Promise.resolve(error);
  });
};


/** @override */
shaka.player.OfflineVideoSource.prototype.getSessionIds = function() {
  return this.sessionIds_;
};


/** @override */
shaka.player.OfflineVideoSource.prototype.isOffline = function() {
  return true;
};


/**
 * Deletes the offline content from the database for the given |group|.
 *
 * @return {!Promise}
 * @private
 */
shaka.player.OfflineVideoSource.prototype.deleteGroupContent_ = function() {
  var contentDatabase = new shaka.util.ContentDatabaseWriter(null, null);

  return contentDatabase.setUpDatabase().then(shaka.util.TypedBind(this,
      function() {
        return contentDatabase.deleteGroup(
            /** @type {number} */ (this.groupId_));
      })
  ).then(
      function() {
        contentDatabase.closeDatabaseConnection();
        return Promise.resolve();
      }
  ).catch(
      /** @param {*} e */
      function(e) {
        contentDatabase.closeDatabaseConnection();
        return Promise.reject(e);
      });
};


/**
 * Deletes any persistent sessions associated with the |groupId_|.
 *
 * @return {!Promise}
 * @private
 */
shaka.player.OfflineVideoSource.prototype.deletePersistentSessions_ =
    function() {
  var fakeVideoElement = /** @type {!HTMLVideoElement} */ (
      document.createElement('video'));
  fakeVideoElement.src = window.URL.createObjectURL(this.mediaSource);

  var emeManager = new shaka.media.EmeManager(null, fakeVideoElement, this);
  if (this.config_['licenseRequestTimeout'] != null) {
    emeManager.setLicenseRequestTimeout(
        Number(this.config_['licenseRequestTimeout']));
  }

  return this.load().then(function() {
    return emeManager.initialize();
  }).then(shaka.util.TypedBind(this, function() {
    return emeManager.allSessionsReady(this.timeoutMs);
  })).then(function() {
    return emeManager.deleteSessions();
  }).then(shaka.util.TypedBind(this,
      function() {
        emeManager.destroy();
        this.destroy();
        return Promise.resolve();
      })
  ).catch(shaka.util.TypedBind(this,
      /** @param {*} e */
      function(e) {
        emeManager.destroy();
        this.destroy();
        return Promise.reject(e);
      })
  );
};