Source: lib/offline/download_manager.js

/**
 * @license
 * Copyright 2016 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.offline.DownloadManager');

goog.require('goog.asserts');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.offline.OfflineUtils');
goog.require('shaka.util.Error');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.MapUtils');



/**
 * This manages downloading segments and notifying the app of progress.
 *
 * @param {shaka.offline.IStorageEngine} storageEngine
 * @param {!shaka.net.NetworkingEngine} netEngine
 * @param {shakaExtern.RetryParameters} retryParams
 * @param {shakaExtern.OfflineConfiguration} config
 *
 * @struct
 * @constructor
 * @implements {shaka.util.IDestroyable}
 */
shaka.offline.DownloadManager = function(
    storageEngine, netEngine, retryParams, config) {
  /**
   * @private {!Object.<
   *     string, !Array.<shaka.offline.DownloadManager.Segment>>}
   */
  this.segments_ = {};

  /**
   * The IDs of the segments that have been stored for an in-progress
   * download().  This is used to cleanup in destroy().
   * @private {!Array.<number>}
   */
  this.storedSegments_ = [];

  /** @private {?shakaExtern.OfflineConfiguration} */
  this.config_ = config;

  /** @private {shaka.offline.IStorageEngine} */
  this.storageEngine_ = storageEngine;

  /** @private {shaka.net.NetworkingEngine} */
  this.netEngine_ = netEngine;

  /** @private {?shakaExtern.RetryParameters} */
  this.retryParams_ = retryParams;

  /** @private {?shakaExtern.ManifestDB} */
  this.manifest_ = null;

  /** @private {Promise} */
  this.promise_ = null;

  /**
   * The total number of bytes for segments that include a byte range.
   * @private {number}
   */
  this.givenBytesTotal_ = 0;

  /**
   * The number of bytes downloaded for segments that include a byte range.
   * @private {number}
   */
  this.givenBytesDownloaded_ = 0;

  /**
   * The total number of bytes estimated based on bandwidth for segments that
   * do not include a byte range.
   * @private {number}
   */
  this.bandwidthBytesTotal_ = 0;

  /**
   * The estimated number of bytes downloaded for segments that do not have
   * a byte range.
   * @private {number}
   */
  this.bandwidthBytesDownloaded_ = 0;
};


/**
 * @typedef {{
 *   uris: !Array.<string>,
 *   startByte: number,
 *   endByte: ?number,
 *   bandwidthSize: number,
 *   segmentDb: shakaExtern.SegmentDataDB
 * }}
 *
 * @property {!Array.<string>} uris
 *   The URIs to download the segment.
 * @property {number} startByte
 *   The byte index the segment starts at.
 * @property {?number} endByte
 *   The byte index the segment ends at, if present.
 * @property {number} bandwidthSize
 *   The size of the segment as estimated by the bandwidth and segment duration.
 * @property {shakaExtern.SegmentDataDB} segmentDb
 *   The data to store in the database.
 */
shaka.offline.DownloadManager.Segment;


/** @override */
shaka.offline.DownloadManager.prototype.destroy = function() {
  var storage = this.storageEngine_;
  var segments = this.storedSegments_;
  var p = this.promise_ || Promise.resolve();

  // Don't try to remove segments if there are none.  That may trigger an error
  // in storage if the DB connection was never created.
  if (segments.length) {
    p = p.then(function() { return storage.removeKeys('segment', segments); });
  }

  // Don't destroy() storageEngine since it is owned by Storage.

  this.segments_ = {};
  this.storedSegments_ = [];
  this.config_ = null;
  this.storageEngine_ = null;
  this.netEngine_ = null;
  this.retryParams_ = null;
  this.manifest_ = null;
  this.promise_ = null;
  return p;
};


/**
 * Adds a segment to the list to be downloaded.
 *
 * @param {string} type
 * @param {!shaka.media.SegmentReference|!shaka.media.InitSegmentReference} ref
 * @param {number} bandwidthSize
 * @param {shakaExtern.SegmentDataDB} segmentDb
 *   The data to store in the database with the data.  The |data| field of this
 *   object will contain the downloaded data.
 */
shaka.offline.DownloadManager.prototype.addSegment = function(
    type, ref, bandwidthSize, segmentDb) {
  this.segments_[type] = this.segments_[type] || [];
  this.segments_[type].push({
    uris: ref.getUris(),
    startByte: ref.startByte,
    endByte: ref.endByte,
    bandwidthSize: bandwidthSize,
    segmentDb: segmentDb
  });
};


/**
 * Downloads all the segments, stores them in the database, and stores the given
 * manifest object.
 *
 * @param {shakaExtern.ManifestDB} manifest
 * @return {!Promise}
 */
shaka.offline.DownloadManager.prototype.downloadAndStore = function(manifest) {
  var MapUtils = shaka.util.MapUtils;
  // Calculate progress estimates.
  this.givenBytesTotal_ = 0;
  this.givenBytesDownloaded_ = 0;
  this.bandwidthBytesTotal_ = 0;
  this.bandwidthBytesDownloaded_ = 0;
  MapUtils.values(this.segments_).forEach(function(segments) {
    segments.forEach(function(segment) {
      if (segment.endByte != null)
        this.givenBytesTotal_ += (segment.endByte - segment.startByte + 1);
      else
        this.bandwidthBytesTotal_ += segment.bandwidthSize;
    }.bind(this));
  }.bind(this));

  this.manifest_ = manifest;
  // Will be updated as we download for segments without a byte-range.
  this.manifest_.size = this.givenBytesTotal_;

  // Create separate download chains for different content types.  This will
  // allow audio and video to be downloaded in parallel.
  var async = MapUtils.values(this.segments_).map(function(segments) {
    var i = 0;
    var downloadNext = (function() {
      if (!this.config_) {
        return Promise.reject(new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.STORAGE,
            shaka.util.Error.Code.OPERATION_ABORTED));
      }
      if (i >= segments.length) return Promise.resolve();
      var segment = segments[i++];
      return this.downloadSegment_(segment).then(downloadNext);
    }.bind(this));
    return downloadNext();
  }.bind(this));
  this.segments_ = {};

  this.promise_ = Promise.all(async).then(function() {
    return this.storageEngine_.insert('manifest', manifest);
  }.bind(this)).then(function() {
    this.storedSegments_ = [];
  }.bind(this));
  return this.promise_;
};


/**
 * Downloads the given segment and calls the callback.
 *
 * @param {shaka.offline.DownloadManager.Segment} segment
 * @return {!Promise}
 * @private
 */
shaka.offline.DownloadManager.prototype.downloadSegment_ = function(segment) {
  goog.asserts.assert(this.retryParams_,
                      'DownloadManager must not be destroyed');
  var type = shaka.net.NetworkingEngine.RequestType.SEGMENT;
  var request =
      shaka.net.NetworkingEngine.makeRequest(segment.uris, this.retryParams_);
  if (segment.startByte != 0 || segment.endByte != null) {
    var end = segment.endByte == null ? '' : segment.endByte;
    request.headers['Range'] = 'bytes=' + segment.startByte + '-' + end;
  }

  var byteCount;
  return this.netEngine_.request(type, request)
      .then(function(response) {
        if (!this.manifest_) {
          return Promise.reject(new shaka.util.Error(
              shaka.util.Error.Severity.CRITICAL,
              shaka.util.Error.Category.STORAGE,
              shaka.util.Error.Code.OPERATION_ABORTED));
        }
        byteCount = response.data.byteLength;

        this.storedSegments_.push(segment.segmentDb.key);
        segment.segmentDb.data = response.data;
        return this.storageEngine_.insert('segment', segment.segmentDb);
      }.bind(this))
      .then(function() {
        // Do not hold onto the segment data after it has been stored in the
        // database.  See https://github.com/google/shaka-player/issues/1167
        // Note that an automated regression test for this is not feasible, so
        // anyone refactoring this code must take care to manually test memory
        // consumption as detailed in the issue comments on github.
        segment.segmentDb.data = null;

        if (!this.manifest_) {
          return Promise.reject(new shaka.util.Error(
              shaka.util.Error.Severity.CRITICAL,
              shaka.util.Error.Category.STORAGE,
              shaka.util.Error.Code.OPERATION_ABORTED));
        }
        if (segment.endByte == null) {
          // We didn't know the size, so it was an estimate.
          this.manifest_.size += byteCount;
          this.bandwidthBytesDownloaded_ += segment.bandwidthSize;
        } else {
          goog.asserts.assert(
              byteCount == (segment.endByte - segment.startByte + 1),
              'Incorrect download size');
          this.givenBytesDownloaded_ += byteCount;
        }
        this.updateProgress_();
      }.bind(this));
};


/**
 * Calls the progress callback.
 * @private
 */
shaka.offline.DownloadManager.prototype.updateProgress_ = function() {
  var progress = (this.givenBytesDownloaded_ + this.bandwidthBytesDownloaded_) /
      (this.givenBytesTotal_ + this.bandwidthBytesTotal_);

  goog.asserts.assert(this.manifest_, 'Must not be destroyed');
  var manifest = shaka.offline.OfflineUtils.getStoredContent(this.manifest_);
  this.config_.progressCallback(manifest, progress);
};