From 387ed49e92bd17043c49bf584059da12e3897ce7 Mon Sep 17 00:00:00 2001 From: Jacob Trimble Date: Wed, 27 Apr 2016 16:31:32 -0700 Subject: [PATCH] Add offline storage manager and manifest parser. This contains two major parts: the Storage class, which manages storing, listing, and deleting the stored content, and the offline manifest parser, which loads the stored content into a manifest so the Player can play it. This does not include support for storing encrypted content. The EME sessions will not be stored properly and will fail to play. Issue #343 Change-Id: I7ecb3400391ec8100155aa972f9b09bb7ae24d9d --- build/types/offline | 4 + externs/shaka/offline.js | 217 ++++++++ lib/offline/download_manager.js | 241 +++++++++ lib/offline/offline_manifest_parser.js | 149 ++++++ lib/offline/offline_scheme.js | 70 +++ lib/offline/storage.js | 703 +++++++++++++++++++++++++ lib/util/error.js | 10 +- shaka-player.uncompiled.js | 4 +- test/offline_integration.js | 74 +++ test/player_unit.js | 28 - test/storage_unit.js | 681 ++++++++++++++++++++++++ test/util/simple_fakes.js | 30 ++ 12 files changed, 2180 insertions(+), 31 deletions(-) create mode 100644 externs/shaka/offline.js create mode 100644 lib/offline/download_manager.js create mode 100644 lib/offline/offline_manifest_parser.js create mode 100644 lib/offline/offline_scheme.js create mode 100644 lib/offline/storage.js create mode 100644 test/offline_integration.js create mode 100644 test/storage_unit.js diff --git a/build/types/offline b/build/types/offline index c3252b3bc6..bfd73118d9 100644 --- a/build/types/offline +++ b/build/types/offline @@ -1,3 +1,7 @@ # The offline storage system and manifest parser plugin. +../../lib/offline/db_engine.js ++../../lib/offline/download_manager.js ++../../lib/offline/offline_manifest_parser.js ++../../lib/offline/offline_scheme.js ++../../lib/offline/storage.js diff --git a/externs/shaka/offline.js b/externs/shaka/offline.js new file mode 100644 index 0000000000..2f88b3f576 --- /dev/null +++ b/externs/shaka/offline.js @@ -0,0 +1,217 @@ +/** + * @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. + */ + + +/** @externs */ + + +/** + * @typedef {{ + * basic: boolean, + * encrypted: !Object. + * }} + * + * @property {boolean} basic + * True if offline is usable at all. + * @property {!Object.} encrypted + * A map of key system name to whether it supports offline playback. + */ +shakaExtern.OfflineSupport; + + +/** + * @typedef {{ + * trackSelectionCallback: + * function(!Array.):!Array., + * progressCallback: function(shakaExtern.StoredContent,number) + * }} + * + * @property {function(!Array.):!Array.} + * trackSelectionCallback + * Called inside store() to determine which tracks to save from a manifest. + * It is passed an array of Tracks from the manifest and it should return + * an array of the tracks to store. This is called for each Period in the + * manifest (in order). + * @property {function(shakaExtern.StoredContent,number)} progressCallback + * Called inside store() to give progress info back to the app. It is given + * the current manifest being stored and the progress of it being stored. + */ +shakaExtern.OfflineConfiguration; + + +/** + * @typedef {{ + * offlineUri: string, + * originalManifestUri: string, + * duration: number, + * size: number, + * tracks: !Array., + * appMetadata: Object + * }} + * + * @property {string} offlineUri + * An offline URI to access the content. This can be passed directly to + * Player. + * @property {string} originalManifestUri + * The original manifest URI of the content stored. + * @property {number} duration + * The duration of the content, in seconds. + * @property {number} size + * The size of the content, in bytes. + * @property {!Array.} tracks + * The tracks that are stored. This only lists those found in the first + * Period. + * @property {Object} appMetadata + * The metadata passed to store(). + */ +shakaExtern.StoredContent; + + +/** + * @typedef {{ + * key: number, + * originalManifestUri: string, + * duration: number, + * size: number, + * periods: !Array., + * sessionIds: !Array., + * drmInfo: ?shakaExtern.DrmInfo, + * appMetadata: Object + * }} + * + * @property {number} key + * The key that uniquely identifies the manifest. + * @property {string} originalManifestUri + * The URI that the manifest was originally loaded from. + * @property {number} duration + * The total duration of the media, in seconds. + * @property {number} size + * The total size of all stored segments, in bytes. + * @property {!Array.} periods + * The Periods that are stored. + * @property {!Array.} sessionIds + * The DRM offline session IDs for the media. + * @property {?shakaExtern.DrmInfo} drmInfo + * The DRM info used to initialize EME. + * @property {Object} appMetadata + * A metadata object passed from the application. + */ +shakaExtern.ManifestDB; + + +/** + * @typedef {{ + * startTime: number, + * streams: !Array. + * }} + * + * @property {number} startTime + * The start time of the period, in seconds. + * @property {!Array.} streams + * The streams that define the Period. + */ +shakaExtern.PeriodDB; + + +/** + * @typedef {{ + * id: number, + * primary: boolean, + * presentationTimeOffset: number, + * contentType: string, + * mimeType: string, + * codecs: string, + * kind: (string|undefined), + * language: string, + * width: ?number, + * height: ?number, + * initSegmentUri: ?string, + * encrypted: boolean, + * keyId: ?string, + * segments: !Array. + * }} + * + * @property {number} id + * The unique id of the stream. + * @property {boolean} primary + * Whether the stream set was primary. + * @property {number} presentationTimeOffset + * The presentation time offset of the stream. + * @property {string} contentType + * The type of the stream, 'audio', 'text', or 'video'. + * @property {string} mimeType + * The MIME type of the stream. + * @property {string} codecs + * The codecs of the stream. + * @property {(string|undefined)} kind + * The kind of text stream; undefined for audio/video. + * @property {string} language + * The language of the stream; '' for video. + * @property {?number} width + * The width of the stream; null for audio/text. + * @property {?number} height + * The height of the stream; null for audio/text. + * @property {?string} initSegmentUri + * The offline URI where the init segment is found; null if no init segment. + * @property {boolean} encrypted + * Whether this stream is encrypted. + * @property {?string} keyId + * The key ID this stream is encrypted with. + * @property {!Array.} segments + * An array of segments that make up the stream + */ +shakaExtern.StreamDB; + + +/** + * @typedef {{ + * startTime: number, + * endTime: number, + * uri: string + * }} + * + * @property {number} startTime + * The start time of the segment, in seconds from the start of the Period. + * @property {number} endTime + * The end time of the segment, in seconds from the start of the Period. + * @property {string} uri + * The offline URI where the segment is found. + */ +shakaExtern.SegmentDB; + + +/** + * @typedef {{ + * key: number, + * data: !ArrayBuffer, + * manifestKey: number, + * streamNumber: number, + * segmentNumber: number + * }} + * + * @property {number} key + * A key that uniquely describes the segment. + * @property {!ArrayBuffer} data + * The data contents of the segment. + * @property {number} manifestKey + * The key of the manifest this belongs to. + * @property {number} streamNumber + * The index of the stream this belongs to. + * @property {number} segmentNumber + * The index of the segment within the stream. + */ +shakaExtern.SegmentDataDB; diff --git a/lib/offline/download_manager.js b/lib/offline/download_manager.js new file mode 100644 index 0000000000..d5fbb2d2ec --- /dev/null +++ b/lib/offline/download_manager.js @@ -0,0 +1,241 @@ +/** + * @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('shaka.net.NetworkingEngine'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.IDestroyable'); + + + +/** + * This manages downloading segments and notifying the app of progress. + * + * @param {!shaka.net.NetworkingEngine} netEngine + * @param {shakaExtern.RetryParameters} retryParams + * @param {shakaExtern.OfflineConfiguration} config + * + * @struct + * @constructor + * @implements {shaka.util.IDestroyable} + */ +shaka.offline.DownloadManager = function(netEngine, retryParams, config) { + /** @private {!Array.} */ + this.segments_ = []; + + /** @private {?shakaExtern.OfflineConfiguration} */ + this.config_ = config; + + /** @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., + * startByte: number, + * endByte: ?number, + * bandwidthSize: number, + * callback: function(!ArrayBuffer):!Promise + * }} + * + * @property {!Array.} 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 {function(!ArrayBuffer):!Promise} callback + * The callback to call once the segment is downloaded. + */ +shaka.offline.DownloadManager.Segment; + + +/** @override */ +shaka.offline.DownloadManager.prototype.destroy = function() { + var ret = this.promise_ || Promise.resolve(); + this.segments_ = []; + this.config_ = null; + this.netEngine_ = null; + this.retryParams_ = null; + this.manifest_ = null; + this.promise_ = null; + return ret; +}; + + +/** + * Adds a segment to the list to be downloaded. + * + * @param {!shaka.media.SegmentReference|!shaka.media.InitSegmentReference} ref + * @param {number} bandwidthSize + * @param {function(!ArrayBuffer):!Promise} callback + */ +shaka.offline.DownloadManager.prototype.addSegment = function( + ref, bandwidthSize, callback) { + this.segments_.push({ + uris: ref.uris, + startByte: ref.startByte, + endByte: ref.endByte, + bandwidthSize: bandwidthSize, + callback: callback + }); +}; + + +/** + * Downloads all the segments. + * + * @param {shakaExtern.ManifestDB} manifest + * @return {!Promise} + */ +shaka.offline.DownloadManager.prototype.download = function(manifest) { + // Calculate progress estimates. + this.givenBytesTotal_ = 0; + this.givenBytesDownloaded_ = 0; + this.bandwidthBytesTotal_ = 0; + this.bandwidthBytesDownloaded_ = 0; + this.segments_.forEach(function(segment) { + if (segment.endByte != null) + this.givenBytesTotal_ += (segment.endByte - segment.startByte + 1); + else + this.bandwidthBytesTotal_ += segment.bandwidthSize; + }.bind(this)); + + this.manifest_ = manifest; + // Will be updated as we download for segments without a byte-range. + this.manifest_.size = this.givenBytesTotal_; + + // TODO(modmaker): Download different stream types in parallel. + var segments = this.segments_; + this.segments_ = []; + var i = 0; + var downloadNext = (function() { + if (!this.config_) { + return Promise.reject(new shaka.util.Error( + 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 (this.promise_ = downloadNext()); +}; + + +/** + * 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_, '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.Category.STORAGE, + shaka.util.Error.Code.OPERATION_ABORTED)); + } + byteCount = response.data.byteLength; + return segment.callback(response.data); + }.bind(this)) + .then(function() { + if (!this.manifest_) { + return Promise.reject(new shaka.util.Error( + 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.Storage.getStoredContent(this.manifest_); + this.config_.progressCallback(manifest, progress); +}; diff --git a/lib/offline/offline_manifest_parser.js b/lib/offline/offline_manifest_parser.js new file mode 100644 index 0000000000..2757de7150 --- /dev/null +++ b/lib/offline/offline_manifest_parser.js @@ -0,0 +1,149 @@ +/** + * @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.OfflineManifestParser'); + +goog.require('shaka.media.InitSegmentReference'); +goog.require('shaka.media.ManifestParser'); +goog.require('shaka.media.PresentationTimeline'); +goog.require('shaka.media.SegmentIndex'); +goog.require('shaka.media.SegmentReference'); +goog.require('shaka.offline.DBEngine'); +goog.require('shaka.util.Error'); + + + +/** + * Creates a new offline manifest parser. + * @struct + * @constructor + * @implements {shakaExtern.ManifestParser} + */ +shaka.offline.OfflineManifestParser = function() { +}; + + +/** @override */ +shaka.offline.OfflineManifestParser.prototype.configure = function(config) { + // No-op +}; + + +/** @override */ +shaka.offline.OfflineManifestParser.prototype.start = + function(uri, networkingEngine, filterPeriod, onError) { + var parts = /^offline:([0-9]+)$/.exec(uri); + if (!parts) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Category.NETWORK, + shaka.util.Error.Code.MALFORMED_OFFLINE_URI, uri)); + } + var manifestId = Number(parts[1]); + var dbEngine = new shaka.offline.DBEngine(); + + return dbEngine.init(shaka.offline.Storage.DB_SCHEME) + .then(function() { return dbEngine.get('manifest', manifestId); }) + .then(function(manifest) { + if (!manifest) { + throw new shaka.util.Error( + shaka.util.Error.Category.STORAGE, + shaka.util.Error.Code.REQUESTED_ITEM_NOT_FOUND, manifestId); + } + + return this.reconstructManifest_(manifest); + }.bind(this)) + .then( + function(ret) { + return dbEngine.destroy().then(function() { return ret; }); + }, + function(err) { + return dbEngine.destroy().then(function() { throw err; }); + }); +}; + + +/** @override */ +shaka.offline.OfflineManifestParser.prototype.stop = function() { + return Promise.resolve(); +}; + + +/** + * Reconstructs a manifest object from the given database manifest. + * + * @param {shakaExtern.ManifestDB} manifest + * @return {shakaExtern.Manifest} + * @private + */ +shaka.offline.OfflineManifestParser.prototype.reconstructManifest_ = function( + manifest) { + var timeline = new shaka.media.PresentationTimeline(null); + timeline.setDuration(manifest.duration); + var drmInfos = manifest.drmInfo ? [manifest.drmInfo] : []; + return { + presentationTimeline: timeline, + minBufferTime: 10, + periods: manifest.periods.map(function(period) { + return { + startTime: period.startTime, + streamSets: period.streams.map(function(streamDb) { + var refs = streamDb.segments.map(function(segment, i) { + return new shaka.media.SegmentReference( + i, segment.startTime, segment.endTime, [segment.uri], 0, null); + }); + timeline.notifySegments(period.startTime, refs); + var segmentIndex = new shaka.media.SegmentIndex(refs); + + var initRef = streamDb.initSegmentUri ? + new shaka.media.InitSegmentReference( + [streamDb.initSegmentUri], 0, null) : + null; + var stream = { + id: streamDb.id, + createSegmentIndex: Promise.resolve.bind(Promise), + findSegmentPosition: segmentIndex.find.bind(segmentIndex), + getSegmentReference: segmentIndex.get.bind(segmentIndex), + initSegmentReference: initRef, + presentationTimeOffset: streamDb.presentationTimeOffset, + mimeType: streamDb.mimeType, + codecs: streamDb.codecs, + bandwidth: 0, + width: streamDb.width || undefined, + height: streamDb.height || undefined, + kind: streamDb.kind, + encrypted: streamDb.encrypted, + keyId: streamDb.keyId, + allowedByApplication: true, + allowedByKeySystem: true + }; + var streamSet = { + language: streamDb.language, + type: streamDb.contentType, + primary: streamDb.primary, + drmInfos: drmInfos, + streams: [stream] + }; + return streamSet; + }) + }; + }) + }; +}; + + +shaka.media.ManifestParser.registerParserByMime( + 'application/x-offline-manifest', shaka.offline.OfflineManifestParser); diff --git a/lib/offline/offline_scheme.js b/lib/offline/offline_scheme.js new file mode 100644 index 0000000000..e15a173a50 --- /dev/null +++ b/lib/offline/offline_scheme.js @@ -0,0 +1,70 @@ +/** + * @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.OfflineScheme'); + +goog.require('shaka.net.NetworkingEngine'); +goog.require('shaka.offline.DBEngine'); +goog.require('shaka.util.Error'); + + +/** + * A plugin that handles offline network requests. + * + * @param {string} uri + * @param {shakaExtern.Request} request + * @return {!Promise.} + */ +shaka.offline.OfflineScheme = function(uri, request) { + var manifestParts = /^offline:([0-9]+)$/.exec(uri); + if (manifestParts) { + /** @type {shakaExtern.Response} */ + var response = { + uri: uri, + data: new ArrayBuffer(0), + headers: {'content-type': 'application/x-offline-manifest'} + }; + return Promise.resolve(response); + } + + var segmentParts = /^offline:[0-9]+\/[0-9]+\/([0-9]+)$/.exec(uri); + if (segmentParts) { + var segmentId = Number(segmentParts[1]); + var scheme = shaka.offline.Storage.DB_SCHEME; + var dbEngine = new shaka.offline.DBEngine(); + return dbEngine.init(scheme) + .then(function() { return dbEngine.get('segment', segmentId); }) + .then(function(segment) { + return dbEngine.destroy().then(function() { + if (!segment) { + throw new shaka.util.Error( + shaka.util.Error.Category.STORAGE, + shaka.util.Error.Code.REQUESTED_ITEM_NOT_FOUND, segmentId); + } + return {uri: uri, data: segment.data, headers: {}}; + }); + }); + } + + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Category.NETWORK, + shaka.util.Error.Code.MALFORMED_OFFLINE_URI, uri)); +}; + + +shaka.net.NetworkingEngine.registerScheme( + 'offline', shaka.offline.OfflineScheme); diff --git a/lib/offline/storage.js b/lib/offline/storage.js new file mode 100644 index 0000000000..0df84a64d8 --- /dev/null +++ b/lib/offline/storage.js @@ -0,0 +1,703 @@ +/** + * @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.Storage'); + +goog.require('shaka.Player'); +goog.require('shaka.media.DrmEngine'); +goog.require('shaka.media.ManifestParser'); +goog.require('shaka.offline.DBEngine'); +goog.require('shaka.offline.DownloadManager'); +goog.require('shaka.util.ConfigUtils'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.Functional'); +goog.require('shaka.util.IDestroyable'); +goog.require('shaka.util.LanguageUtils'); +goog.require('shaka.util.StreamUtils'); + + + +/** + * This manages persistent offline data including storage, listing, and deleting + * stored manifests. Playback of offline manifests are done using Player + * using the special URI (e.g. 'offline:12'). + * + * @param {shaka.Player} player + * The player instance to pull configuration data from. + * + * @struct + * @constructor + * @implements {shaka.util.IDestroyable} + * @export + */ +shaka.offline.Storage = function(player) { + /** @private {shaka.offline.DBEngine} */ + this.dbEngine_ = new shaka.offline.DBEngine(); + + /** @private {shaka.Player} */ + this.player_ = player; + + /** @private {?shakaExtern.OfflineConfiguration} */ + this.config_ = this.defaultConfig_(); + + /** @private {shaka.media.DrmEngine} */ + this.drmEngine_ = null; + + /** @private {boolean} */ + this.storeInProgress_ = false; + + /** + * The IDs of the segments that have been stored for an in-progress store(). + * This is used to cleanup in destroy(). + * @private {!Array.} + */ + this.inProgressSegmentIds_ = []; + + /** @private {number} */ + this.manifestId_ = -1; + + /** @private {number} */ + this.duration_ = 0; + + /** @private {?shakaExtern.Manifest} */ + this.manifest_ = null; + + var netEngine = player.getNetworkingEngine(); + goog.asserts.assert(netEngine, 'Player must not be destroyed'); + + /** @private {shaka.offline.DownloadManager} */ + this.downloadManager_ = new shaka.offline.DownloadManager( + netEngine, player.getConfiguration().streaming.retryParameters, + this.config_); +}; + + +/** @const {!Object.} */ +shaka.offline.Storage.DB_SCHEME = {'manifest': 'key', 'segment': 'key'}; + + +/** + * Gets whether offline storage is supported. + * + * @return {boolean} + * @export + */ +shaka.offline.Storage.support = function() { + return shaka.offline.DBEngine.isSupported(); +}; + + +/** + * Converts the given database manifest to a storedContent structure. + * + * @param {shakaExtern.ManifestDB} manifest + * @return {shakaExtern.StoredContent} + */ +shaka.offline.Storage.getStoredContent = function(manifest) { + goog.asserts.assert(manifest.periods.length > 0, + 'Must be at least one Period.'); + return { + offlineUri: 'offline:' + manifest.key, + originalManifestUri: manifest.originalManifestUri, + duration: manifest.duration, + size: manifest.size, + tracks: manifest.periods[0].streams.map(function(stream) { + return { + id: stream.id, + active: false, + type: stream.contentType, + bandwidth: 0, + language: stream.language, + kind: stream.kind || null, + width: stream.width, + height: stream.height, + hasOutputRestrictions: false + }; + }), + appMetadata: manifest.appMetadata + }; +}; + + +/** + * Sets the DBEngine instance to use. This is used for testing. + * + * @param {!shaka.offline.DBEngine} engine + */ +shaka.offline.Storage.prototype.setDbEngine = function(engine) { + goog.asserts.assert(!this.dbEngine_.initialized(), + 'Should not be initialized yet'); + this.dbEngine_ = engine; +}; + + +/** @override */ +shaka.offline.Storage.prototype.destroy = function() { + var segments = this.inProgressSegmentIds_; + var dbEngine = this.dbEngine_; + // Destroy the download manager first to ensure segments are not added while + // we delete old ones. + var ret = !this.downloadManager_ ? + Promise.resolve() : + this.downloadManager_.destroy() + .catch(function() {}) + .then(function() { + return Promise.all(segments.map(function(id) { + return dbEngine.remove('segment', id); + })); + }) + .then(function() { return dbEngine.destroy(); }); + + this.dbEngine_ = null; + this.downloadManager_ = null; + this.player_ = null; + this.config_ = null; + return ret; +}; + + +/** + * Sets configuration values for Storage. This is not associated with + * Player.configure and will not change Player. + * + * @param {shakaExtern.OfflineConfiguration} config + * @export + */ +shaka.offline.Storage.prototype.configure = function(config) { + goog.asserts.assert(this.config_, 'Must not be destroyed'); + shaka.util.ConfigUtils.mergeConfigObjects( + this.config_, config, this.defaultConfig_(), {}, ''); +}; + + +/** + * Stores the given manifest. + * + * @param {string} manifestUri + * @param {!Object} appMetadata + * @param {!shakaExtern.ManifestParser.Factory=} opt_manifestParserFactory + * @return {!Promise.} + * @export + */ +shaka.offline.Storage.prototype.store = function( + manifestUri, appMetadata, opt_manifestParserFactory) { + if (this.storeInProgress_) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Category.STORAGE, + shaka.util.Error.Code.STORE_ALREADY_IN_PROGRESS)); + } + this.storeInProgress_ = true; + + /** @type {shakaExtern.ManifestDB} */ + var manifestDb; + + var error = null; + var onError = function(e) { error = e; }; + return this.initIfNeeded_() + .then(function() { + this.checkDestroyed_(); + return this.loadInternal( + manifestUri, onError, opt_manifestParserFactory); + }.bind(this)) + .then(function(data) { + this.checkDestroyed_(); + this.manifest_ = data.manifest; + this.drmEngine_ = data.drmEngine; + + if (this.manifest_.presentationTimeline.isLive()) { + throw new shaka.util.Error( + shaka.util.Error.Category.STORAGE, + shaka.util.Error.Code.CANNOT_STORE_LIVE_OFFLINE, manifestUri); + } + + // Re-filter now that DrmEngine is initialized. + this.manifest_.periods.forEach(this.filterPeriod_.bind(this)); + + this.manifestId_ = this.dbEngine_.reserveId('manifest'); + this.duration_ = 0; + manifestDb = this.createOfflineManifest_(manifestUri, appMetadata); + return this.downloadManager_.download(manifestDb); + }.bind(this)) + .then(function() { + this.checkDestroyed_(); + // Throw any errors from the manifest parser or DrmEngine. + if (error) + throw error; + + return this.dbEngine_.insert('manifest', manifestDb); + }.bind(this)) + .then(function() { + return this.cleanup_(); + }.bind(this)) + .then(function() { + return shaka.offline.Storage.getStoredContent(manifestDb); + }.bind(this)) + .catch(function(err) { + var Functional = shaka.util.Functional; + return this.cleanup_().catch(Functional.noop).then(function() { + throw err; + }); + }.bind(this)); +}; + + +/** + * Removes the given stored content. + * + * @param {shakaExtern.StoredContent} content + * @return {!Promise} + * @export + */ +shaka.offline.Storage.prototype.remove = function(content) { + var uri = content.offlineUri; + var parts = /^offline:([0-9]+)$/.exec(uri); + if (!parts) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Category.STORAGE, + shaka.util.Error.Code.MALFORMED_OFFLINE_URI, uri)); + } + + var manifestId = Number(parts[1]); + return this.initIfNeeded_().then(function() { + this.checkDestroyed_(); + return this.dbEngine_.get('manifest', manifestId); + }.bind(this)).then(function(/** ?shakaExtern.ManifestDB */ manifest) { + this.checkDestroyed_(); + var Functional = shaka.util.Functional; + if (!manifest) { + throw new shaka.util.Error( + shaka.util.Error.Category.STORAGE, + shaka.util.Error.Code.REQUESTED_ITEM_NOT_FOUND, uri); + } + + // TODO(modmaker): Delete offline EME sessions. + + // Get every segment for every stream in the manifest. + /** @type {!Array.} */ + var segments = manifest.periods.map(function(period) { + return period.streams.map(function(stream) { + var segments = stream.segments.map(function(segment) { + var parts = /^offline:[0-9]+\/[0-9]+\/([0-9]+)$/.exec(segment.uri); + goog.asserts.assert(parts, 'Invalid offline URI'); + return Number(parts[1]); + }); + if (stream.initSegmentUri) { + var parts = /^offline:[0-9]+\/[0-9]+\/([0-9]+)$/.exec( + stream.initSegmentUri); + goog.asserts.assert(parts, 'Invalid offline URI'); + segments.push(Number(parts[1])); + } + return segments; + }).reduce(Functional.collapseArrays, []); + }).reduce(Functional.collapseArrays, []); + + // Delete all the segments. + var deleteCount = 0; + var segmentCount = segments.length; + var callback = this.config_.progressCallback; + return this.dbEngine_.removeWhere('segment', function(segment) { + var i = segments.indexOf(segment.key); + if (i >= 0) { + callback(content, deleteCount / segmentCount); + deleteCount++; + } + return i >= 0; + }.bind(this)); + }.bind(this)).then(function() { + this.checkDestroyed_(); + this.config_.progressCallback(content, 1); + return this.dbEngine_.remove('manifest', manifestId); + }.bind(this)); +}; + + +/** + * Lists all the stored content available. + * + * @return {!Promise.>} + * @export + */ +shaka.offline.Storage.prototype.list = function() { + /** @type {!Array.} */ + var storedContents = []; + return this.initIfNeeded_() + .then(function() { + this.checkDestroyed_(); + return this.dbEngine_.forEach( + 'manifest', function(/** shakaExtern.ManifestDB */ manifest) { + storedContents.push( + shaka.offline.Storage.getStoredContent(manifest)); + }); + }.bind(this)) + .then(function() { return storedContents; }); +}; + + +/** + * Loads the given manifest, parses it, and constructs the DrmEngine. This + * stops the manifest parser. This may be replaced by tests. + * + * @param {string} manifestUri + * @param {function(*)} onError + * @param {!shakaExtern.ManifestParser.Factory=} opt_manifestParserFactory + * @return {!Promise.<{ + * manifest: shakaExtern.Manifest, + * drmEngine: !shaka.media.DrmEngine + * }>} + */ +shaka.offline.Storage.prototype.loadInternal = function( + manifestUri, onError, opt_manifestParserFactory) { + + var netEngine = /** @type {!shaka.net.NetworkingEngine} */ ( + this.player_.getNetworkingEngine()); + var config = this.player_.getConfiguration(); + + /** @type {shakaExtern.Manifest} */ + var manifest; + /** @type {!shaka.media.DrmEngine} */ + var drmEngine; + /** @type {!shakaExtern.ManifestParser} */ + var manifestParser; + + var onKeyStatusChange = function() {}; + return shaka.media.ManifestParser + .getFactory( + manifestUri, netEngine, config.manifest.retryParameters, + opt_manifestParserFactory) + .then(function(factory) { + this.checkDestroyed_(); + manifestParser = new factory(); + manifestParser.configure(config.manifest); + return manifestParser.start( + manifestUri, netEngine, this.filterPeriod_.bind(this), onError); + }.bind(this)) + .then(function(data) { + this.checkDestroyed_(); + manifest = data; + drmEngine = + new shaka.media.DrmEngine(netEngine, onError, onKeyStatusChange); + drmEngine.configure(config.drm); + return drmEngine.init(manifest, true /* isOffline */); + }.bind(this)) + .then(function() { + this.checkDestroyed_(); + return this.createSegmentIndex_(manifest); + }.bind(this)) + .then(function() { + this.checkDestroyed_(); + return manifestParser.stop(); + }.bind(this)) + .then(function() { + this.checkDestroyed_(); + return {manifest: manifest, drmEngine: drmEngine}; + }.bind(this)) + .catch(function(error) { + if (manifestParser) + return manifestParser.stop().then(function() { throw error; }); + else + throw error; + }); +}; + + +/** + * The default track selection function. + * + * @param {!Array.} tracks + * @return {!Array.} + * @private + */ +shaka.offline.Storage.prototype.defaultTrackSelect_ = function(tracks) { + var LanguageUtils = shaka.util.LanguageUtils; + + var selectedTracks = []; + + // Select the highest bandwidth video track with height <= 480. + var videoTracks = tracks.filter(function(t) { + return t.type == 'video' && t.height <= 480; + }); + videoTracks.sort(function(a, b) { return b.bandwidth - a.bandwidth; }); + if (videoTracks.length) + selectedTracks.push(videoTracks[0]); + + // Select middle bandwidth audio track with best audio pref language match. + var audioLangPref = LanguageUtils.normalize( + this.player_.getConfiguration().preferredAudioLanguage); + var matchTypes = [ + LanguageUtils.MatchType.EXACT, + LanguageUtils.MatchType.BASE_LANGUAGE_OKAY, + LanguageUtils.MatchType.OTHER_SUB_LANGUAGE_OKAY + ]; + var allAudioTracks = + tracks.filter(function(t) { return t.type == 'audio'; }); + // For each match type, get the tracks that match the audio preference for + // that match type. + var tracksByMatchType = matchTypes.map(function(match) { + return allAudioTracks.filter(function(track) { + var lang = LanguageUtils.normalize(track.language); + return LanguageUtils.match(match, audioLangPref, lang); + }); + }); + // Find the best match type that has any matches, defaulting to all tracks. + var audioTracks = allAudioTracks; + for (var i = 0; i < tracksByMatchType.length; i++) { + if (tracksByMatchType[i].length) { + audioTracks = tracksByMatchType[i]; + } + } + audioTracks.sort(function(a, b) { return a.bandwidth - b.bandwidth; }); + if (audioTracks.length) + selectedTracks.push(audioTracks[Math.floor(audioTracks.length / 2)]); + + // Select all text tracks with any text pref language match. + var textLangPref = LanguageUtils.normalize( + this.player_.getConfiguration().preferredTextLanguage); + var matchesTextPref = LanguageUtils.match.bind( + null, LanguageUtils.MatchType.OTHER_SUB_LANGUAGE_OKAY, textLangPref); + selectedTracks.push.apply(selectedTracks, tracks.filter(function(t) { + var language = LanguageUtils.normalize(t.language); + return t.type == 'text' && matchesTextPref(language); + })); + + return selectedTracks; +}; + + +/** + * @return {shakaExtern.OfflineConfiguration} + * @private + */ +shaka.offline.Storage.prototype.defaultConfig_ = function() { + return { + trackSelectionCallback: this.defaultTrackSelect_.bind(this), + progressCallback: function() {} + }; +}; + + +/** + * Initializes the DBEngine if it is not already. + * + * @return {!Promise} + * @private + */ +shaka.offline.Storage.prototype.initIfNeeded_ = function() { + var scheme = shaka.offline.Storage.DB_SCHEME; + return this.dbEngine_.initialized() ? Promise.resolve() : + this.dbEngine_.init(scheme); +}; + + +/** + * @param {shakaExtern.Period} period + * @private + */ +shaka.offline.Storage.prototype.filterPeriod_ = function(period) { + var StreamUtils = shaka.util.StreamUtils; + StreamUtils.filterPeriod(this.drmEngine_, /* activeStreams */ {}, period); + StreamUtils.applyRestrictions( + period, this.player_.getConfiguration().restrictions); +}; + + +/** + * Cleans up the current store and destroys any objects. This object is still + * usable after this. + * + * @return {!Promise} + * @private + */ +shaka.offline.Storage.prototype.cleanup_ = function() { + var ret = this.drmEngine_ ? this.drmEngine_.destroy() : Promise.resolve(); + this.drmEngine_ = null; + this.manifest_ = null; + this.storeInProgress_ = false; + this.inProgressSegmentIds_ = []; + this.manifestId_ = -1; + return ret; +}; + + +/** + * Calls createSegmentIndex for all streams in the manifest. + * + * @param {shakaExtern.Manifest} manifest + * @return {!Promise} + * @private + */ +shaka.offline.Storage.prototype.createSegmentIndex_ = function(manifest) { + var Functional = shaka.util.Functional; + var streams = manifest.periods + .map(function(period) { return period.streamSets; }) + .reduce(Functional.collapseArrays, []) + .map(function(streamSet) { return streamSet.streams; }) + .reduce(Functional.collapseArrays, []); + return Promise.all( + streams.map(function(stream) { return stream.createSegmentIndex(); })); +}; + + +/** + * Creates an offline 'manifest' for the real manifest. This does not store + * the segments yet, only adds them to the download manager through + * createPeriod_. + * + * @param {string} originalManifestUri + * @param {!Object} appMetadata + * @return {shakaExtern.ManifestDB} + * @private + */ +shaka.offline.Storage.prototype.createOfflineManifest_ = function( + originalManifestUri, appMetadata) { + var periods = this.manifest_.periods.map(this.createPeriod_.bind(this)); + return { + key: this.manifestId_, + originalManifestUri: originalManifestUri, + duration: this.duration_, + size: 0, + periods: periods, + sessionIds: this.drmEngine_.getSessionIds(), + drmInfo: this.drmEngine_.getDrmInfo(), + appMetadata: appMetadata + }; +}; + + +/** + * Converts a manifest Period to a database Period. This will use the current + * configuration to get the tracks to use, then it will search each segment + * index and add all the segments to the download manager through createStream_. + * + * @param {shakaExtern.Period} period + * @return {shakaExtern.PeriodDB} + * @private + */ +shaka.offline.Storage.prototype.createPeriod_ = function(period) { + var allTracks = shaka.util.StreamUtils.getTracks(period, null); + var tracks = this.config_.trackSelectionCallback(allTracks); + // TODO(modmaker): Issue a warning if multiple tracks of the same variety + // are selected (type, kind, and language). + + var streams = tracks.map(function(track) { + var data = shaka.util.StreamUtils.findStreamForTrack(period, track); + goog.asserts.assert(data, 'Could not find track with id ' + track.id); + return this.createStream_(data.streamSet, data.stream); + }.bind(this)); + + return { + startTime: period.startTime, + streams: streams + }; +}; + + +/** + * Converts a manifest stream to a database stream. This will search the + * segment index and add all the segments to the download manager. + * + * @param {shakaExtern.StreamSet} streamSet + * @param {shakaExtern.Stream} stream + * @return {shakaExtern.StreamDB} + * @private + */ +shaka.offline.Storage.prototype.createStream_ = function(streamSet, stream) { + /** @type {!Array.} */ + var segmentsDb = []; + var startTime = this.manifest_.presentationTimeline.getEarliestStart(); + var endTime = startTime; + var i = stream.findSegmentPosition(startTime); + var ref = (i != null ? stream.getSegmentReference(i) : null); + while (ref) { + var id = this.dbEngine_.reserveId('segment'); + var bandwidthSize = (ref.endTime - ref.startTime) * stream.bandwidth / 8; + this.downloadManager_.addSegment( + ref, bandwidthSize, function(id, pos, streamId, data) { + /** @type {shakaExtern.SegmentDataDB} */ + var dataDb = { + key: id, + data: data, + manifestKey: this.manifestId_, + streamNumber: streamId, + segmentNumber: pos + }; + this.inProgressSegmentIds_.push(id); + return this.dbEngine_.insert('segment', dataDb); + }.bind(this, id, ref.position, stream.id)); + + segmentsDb.push({ + startTime: ref.startTime, + endTime: ref.endTime, + uri: 'offline:' + this.manifestId_ + '/' + stream.id + '/' + id + }); + + endTime = ref.endTime; + ref = stream.getSegmentReference(++i); + } + + this.duration_ = Math.max(this.duration_, (endTime - startTime)); + var initUri = null; + if (stream.initSegmentReference) { + var id = this.dbEngine_.reserveId('segment'); + initUri = 'offline:' + this.manifestId_ + '/' + stream.id + '/' + id; + this.downloadManager_.addSegment(stream.initSegmentReference, 0, + function(streamId, data) { + /** @type {shakaExtern.SegmentDataDB} */ + var dataDb = { + key: id, + data: data, + manifestKey: this.manifestId_, + streamNumber: streamId, + segmentNumber: -1 + }; + this.inProgressSegmentIds_.push(id); + return this.dbEngine_.insert('segment', dataDb); + }.bind(this, stream.id)); + } + + return { + id: stream.id, + primary: streamSet.primary, + presentationTimeOffset: stream.presentationTimeOffset || 0, + contentType: streamSet.type, + mimeType: stream.mimeType, + codecs: stream.codecs, + kind: stream.kind, + language: streamSet.language, + width: stream.width || null, + height: stream.height || null, + initSegmentUri: initUri, + encrypted: stream.encrypted, + keyId: stream.keyId, + segments: segmentsDb + }; +}; + + +/** + * Throws an error if the object is destroyed. + * @private + */ +shaka.offline.Storage.prototype.checkDestroyed_ = function() { + if (!this.player_) { + throw new shaka.util.Error( + shaka.util.Error.Category.STORAGE, + shaka.util.Error.Code.OPERATION_ABORTED); + } +}; + + +shaka.Player.registerSupportPlugin('offline', shaka.offline.Storage.support); diff --git a/lib/util/error.js b/lib/util/error.js index c1adc86bab..43a5939d18 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -500,7 +500,7 @@ shaka.util.Error.Code = { * The specified item was not found in the IndexedDB. *
error.data[0] is the offline URI. */ - 'INDEXED_DB_NOT_FOUND': 9003, + 'REQUESTED_ITEM_NOT_FOUND': 9003, /** * A network request was made with a malformed offline URI. @@ -512,5 +512,11 @@ shaka.util.Error.Code = { * The specified manifest is live. Live manifests cannot be stored offline. *
error.data[0] is the URI. */ - 'CANNOT_STORE_LIVE_OFFLINE': 9005 + 'CANNOT_STORE_LIVE_OFFLINE': 9005, + + /** + * There is already a store operation in-progress, wait until it completes + * before starting another. + */ + 'STORE_ALREADY_IN_PROGRESS': 9006 }; diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index 9a6a47c585..d3c6db713d 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -37,4 +37,6 @@ goog.require('shaka.polyfill.MediaKeys'); goog.require('shaka.polyfill.Promise'); goog.require('shaka.polyfill.VideoPlaybackQuality'); -goog.require('shaka.offline.DBEngine'); +goog.require('shaka.offline.OfflineManifestParser'); +goog.require('shaka.offline.OfflineScheme'); +goog.require('shaka.offline.Storage'); diff --git a/test/offline_integration.js b/test/offline_integration.js new file mode 100644 index 0000000000..3bd1aa02a7 --- /dev/null +++ b/test/offline_integration.js @@ -0,0 +1,74 @@ +/** + * @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. + */ + +describe('Offline', function() { + var originalName; + var storage; + var player; + var video; + + beforeAll(/** @suppress {accessControls} */ function(done) { + video = /** @type {!HTMLVideoElement} */ (document.createElement('video')); + video.width = '600'; + video.height = '400'; + video.muted = true; + document.body.appendChild(video); + + originalName = shaka.offline.DBEngine.DB_NAME_; + shaka.offline.DBEngine.DB_NAME_ += '_test'; + // Ensure we start with a clean slate. + shaka.offline.DBEngine.deleteDatabase().catch(fail).then(done); + }); + + beforeEach(function() { + player = new shaka.Player(video); + storage = new shaka.offline.Storage(player); + }); + + afterEach(function(done) { + Promise.all([storage.destroy(), player.destroy()]).catch(fail).then(done); + }); + + afterAll(/** @suppress {accessControls} */ function() { + document.body.removeChild(video); + shaka.offline.DBEngine.DB_NAME_ = originalName; + }); + + it('stores and plays clear content', function(done) { + var uri = '//storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd'; + var storedContent; + storage.store(uri) + .then(function(content) { + storedContent = content; + return player.load(storedContent.offlineUri); + }) + .then(function() { + video.play(); + return shaka.test.Util.delay(5); + }) + .then(function() { + expect(video.currentTime).toBeGreaterThan(3); + expect(video.ended).toBe(false); + return player.unload(); + }) + .then(function() { + return storage.remove(storedContent); + }) + .catch(fail) + .then(done); + }, 30000); +}); diff --git a/test/player_unit.js b/test/player_unit.js index 2445e12891..574eda1dcf 100644 --- a/test/player_unit.js +++ b/test/player_unit.js @@ -1291,32 +1291,4 @@ describe('Player', function() { } }; } - - function createMockVideo() { - var video = { - src: '', - textTracks: [], - addTextTrack: jasmine.createSpy('addTextTrack'), - addEventListener: jasmine.createSpy('addEventListener'), - removeEventListener: jasmine.createSpy('removeEventListener'), - removeAttribute: jasmine.createSpy('removeAttribute'), - load: jasmine.createSpy('load'), - dispatchEvent: jasmine.createSpy('dispatchEvent'), - on: {} // event listeners - }; - video.addTextTrack.and.callFake(function(kind, id) { - var track = createMockTextTrack(); - video.textTracks.push(track); - return track; - }); - video.addEventListener.and.callFake(function(name, callback) { - video.on[name] = callback; - }); - return video; - } - - function createMockTextTrack() { - // TODO: mock TextTrack, if/when Player starts directly accessing it. - return {}; - } }); diff --git a/test/storage_unit.js b/test/storage_unit.js new file mode 100644 index 0000000000..d2f4c1a149 --- /dev/null +++ b/test/storage_unit.js @@ -0,0 +1,681 @@ +/** + * @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. + */ + +describe('Storage', function() { + var SegmentReference; + var fakeDbEngine; + var storage; + var player; + var netEngine; + + beforeEach(function(done) { + SegmentReference = shaka.media.SegmentReference; + fakeDbEngine = new shaka.test.MemoryDBEngine(); + netEngine = new shaka.test.FakeNetworkingEngine(); + + // Use a real Player since Storage only uses the configuration and + // networking engine. This allows us to use Player.configure in these + // tests. + player = new shaka.Player(createMockVideo(), function(player) { + player.createNetworkingEngine = function() { + return netEngine; + }; + }); + + storage = new shaka.offline.Storage(player); + storage.setDbEngine(fakeDbEngine); + + fakeDbEngine.init(shaka.offline.Storage.DB_SCHEME) + .catch(fail) + .then(done); + }); + + afterEach(function(done) { + storage.destroy().catch(fail).then(done); + }); + + it('lists stored manifests', function(done) { + var manifestDb1 = { + key: 0, + originalManifestUri: 'fake:foobar', + duration: 1337, + size: 65536, + periods: [{ + streams: [ + { + id: 0, + contentType: 'video', + kind: undefined, + language: '', + width: 1920, + height: 1080, + hasOutputRestrictions: false + }, + { + id: 1, + contentType: 'audio', + kind: undefined, + language: 'en', + width: null, + height: null, + hasOutputRestrictions: false + } + ] + }], + appMetadata: { + foo: 'bar', + drm: 'yes', + theAnswerToEverything: 42 + } + }; + var manifestDb2 = { + key: 1, + originalManifestUri: 'fake:another', + duration: 4181, + size: 6765, + periods: [{streams: []}], + appMetadata: { + something: 'else' + } + }; + var manifestDbs = [manifestDb1, manifestDb2]; + var expectedTracks = [ + { + id: 0, + active: false, + type: 'video', + bandwidth: 0, + language: '', + kind: null, + width: 1920, + height: 1080, + hasOutputRestrictions: false + }, + { + id: 1, + active: false, + type: 'audio', + bandwidth: 0, + language: 'en', + kind: null, + width: null, + height: null, + hasOutputRestrictions: false + } + ]; + Promise + .all([ + fakeDbEngine.insert('manifest', manifestDb1), + fakeDbEngine.insert('manifest', manifestDb2) + ]) + .then(function() { + return storage.list(); + }) + .then(function(data) { + expect(data).toBeTruthy(); + expect(data.length).toBe(2); + for (var i = 0; i < 2; i++) { + expect(data[i].offlineUri).toBe('offline:' + manifestDbs[i].key); + expect(data[i].originalManifestUri) + .toBe(manifestDbs[i].originalManifestUri); + expect(data[i].duration).toBe(manifestDbs[i].duration); + expect(data[i].size).toBe(manifestDbs[i].size); + expect(data[i].appMetadata).toEqual(manifestDbs[i].appMetadata); + } + expect(data[0].tracks).toEqual(expectedTracks); + expect(data[1].tracks).toEqual([]); + }) + .catch(fail) + .then(done); + }); + + describe('store', function() { + var manifest; + var tracks; + var drmEngine; + var stream1Index; + var stream2Index; + + beforeEach(function() { + drmEngine = new shaka.test.FakeDrmEngine(); + manifest = new shaka.test.ManifestGenerator() + .setPresentationDuration(20) + .addPeriod(0) + .addStreamSet('video') + .addStream(0).size(100, 200).bandwidth(0) + .addStreamSet('audio') + .language('en') + .addStream(1).bandwidth(0) + .build(); + var getTracks = shaka.util.StreamUtils.getTracks; + tracks = getTracks(manifest.periods[0], {}); + + storage.loadInternal = function() { + return Promise.resolve({ + manifest: manifest, + drmEngine: drmEngine + }); + }; + + player.configure({preferredAudioLanguage: 'en'}); + + stream1Index = new shaka.media.SegmentIndex([]); + stream2Index = new shaka.media.SegmentIndex([]); + + var stream1 = manifest.periods[0].streamSets[0].streams[0]; + stream1.findSegmentPosition = stream1Index.find.bind(stream1Index); + stream1.getSegmentReference = stream1Index.get.bind(stream1Index); + + var stream2 = manifest.periods[0].streamSets[1].streams[0]; + stream2.findSegmentPosition = stream2Index.find.bind(stream2Index); + stream2.getSegmentReference = stream2Index.get.bind(stream2Index); + }); + + it('stores basic manifests', function(done) { + var originalUri = 'fake://foobar'; + var appData = {tools: ['Google', 'StackOverflow'], volume: 11}; + storage.store(originalUri, appData) + .then(function(data) { + expect(data).toBeTruthy(); + // Since we are using a memory DB, it will always be the first one. + expect(data.offlineUri).toBe('offline:0'); + expect(data.originalManifestUri).toBe(originalUri); + expect(data.duration).toBe(0); // There are no segments. + expect(data.size).toEqual(0); + expect(data.tracks).toEqual(tracks); + expect(data.appMetadata).toEqual(appData); + }) + .catch(fail) + .then(done); + }); + + it('stores offline sessions', function(done) { + var sessions = ['lorem', 'ipsum']; + drmEngine.setSessionIds(sessions); + storage.store('') + .then(function(data) { + expect(data.offlineUri).toBe('offline:0'); + return fakeDbEngine.get('manifest', 0); + }) + .then(function(manifestDb) { + expect(manifestDb).toBeTruthy(); + expect(manifestDb.sessionIds).toEqual(sessions); + }) + .catch(fail) + .then(done); + }); + + it('stores DRM info', function(done) { + var drmInfo = { + keySystem: 'com.example.abc', + licenseServerUri: 'http://example.com', + persistentStateRequire: true, + audioRobustness: 'HARDY' + }; + drmEngine.setDrmInfo(drmInfo); + storage.store('') + .then(function(data) { + expect(data.offlineUri).toBe('offline:0'); + return fakeDbEngine.get('manifest', 0); + }) + .then(function(manifestDb) { + expect(manifestDb).toBeTruthy(); + expect(manifestDb.drmInfo).toEqual(drmInfo); + }) + .catch(fail) + .then(done); + }); + + describe('reports progress', function() { + it('when byte ranges given', function(done) { + netEngine.setResponseMap({ + 'fake:0': new ArrayBuffer(54), + 'fake:1': new ArrayBuffer(13), + 'fake:2': new ArrayBuffer(66), + 'fake:3': new ArrayBuffer(17) + }); + + stream1Index.merge([ + new SegmentReference(0, 0, 1, ['fake:0'], 0, 53), + new SegmentReference(1, 1, 2, ['fake:1'], 31, 43), + new SegmentReference(2, 2, 3, ['fake:2'], 291, 356), + new SegmentReference(3, 3, 4, ['fake:3'], 11, 27) + ]); + + var originalUri = 'fake:123'; + var progress = jasmine.createSpy('onProgress'); + progress.and.callFake(function(storedContent, percent) { + expect(storedContent).toEqual({ + offlineUri: 'offline:0', + originalManifestUri: originalUri, + duration: 4, + size: 150, + tracks: tracks, + appMetadata: undefined + }); + + switch (progress.calls.count()) { + case 1: + expect(percent).toBeCloseTo(54 / 150, 0.01); + break; + case 2: + expect(percent).toBeCloseTo(67 / 150, 0.01); + break; + case 3: + expect(percent).toBeCloseTo(133 / 150, 0.01); + break; + default: + expect(percent).toBeCloseTo(1, 0.01); + break; + } + }); + + storage.configure({progressCallback: progress}); + storage.store(originalUri) + .then(function() { + expect(progress.calls.count()).toBe(4); + }) + .catch(fail) + .then(done); + }); + + it('approximates when byte range not given', function(done) { + netEngine.setResponseMap({ + 'fake:0': new ArrayBuffer(54), + 'fake:1': new ArrayBuffer(13), + 'fake:2': new ArrayBuffer(66), + 'fake:3': new ArrayBuffer(17) + }); + + stream1Index.merge([ + new SegmentReference(0, 0, 1, ['fake:0'], 0, 53), + new SegmentReference(1, 1, 2, ['fake:1'], 0, null), // Estimate: 10 + new SegmentReference(2, 2, 4, ['fake:2'], 0, null), // Estimate: 20 + new SegmentReference(3, 4, 5, ['fake:3'], 11, 27) + ]); + + var originalUri = 'fake:123'; + var progress = jasmine.createSpy('onProgress'); + progress.and.callFake(function(storedContent, percent) { + expect(storedContent).toEqual({ + offlineUri: 'offline:0', + originalManifestUri: originalUri, + duration: 5, + size: jasmine.any(Number), + tracks: tracks, + appMetadata: undefined + }); + + switch (progress.calls.count()) { + case 1: + expect(percent).toBeCloseTo(54 / 101, 0.01); + expect(storedContent.size).toBe(71); + break; + case 2: + expect(percent).toBeCloseTo(64 / 101, 0.01); + expect(storedContent.size).toBe(84); + break; + case 3: + expect(percent).toBeCloseTo(84 / 101, 0.01); + expect(storedContent.size).toBe(150); + break; + default: + expect(percent).toBeCloseTo(1, 0.01); + expect(storedContent.size).toBe(150); + break; + } + }); + + storage.configure({progressCallback: progress}); + storage.store(originalUri) + .then(function() { + expect(progress.calls.count()).toBe(4); + }) + .catch(fail) + .then(done); + }); + }); + + describe('segments', function() { + it('stores media segments', function(done) { + netEngine.setResponseMap({ + 'fake:0': new ArrayBuffer(5), + 'fake:1': new ArrayBuffer(7) + }); + + stream1Index.merge([ + new SegmentReference(0, 0, 1, ['fake:0'], 0, null), + new SegmentReference(1, 1, 2, ['fake:0'], 0, null), + new SegmentReference(2, 2, 3, ['fake:1'], 0, null), + new SegmentReference(3, 3, 4, ['fake:0'], 0, null), + new SegmentReference(4, 4, 5, ['fake:1'], 0, null) + ]); + stream2Index.merge([ + new SegmentReference(0, 0, 1, ['fake:0'], 0, null) + ]); + + storage.store('') + .then(function(manifest) { + expect(manifest).toBeTruthy(); + expect(manifest.size).toBe(34); + expect(manifest.duration).toBe(5); + expect(netEngine.request.calls.count()).toBe(6); + return fakeDbEngine.get('manifest', 0); + }) + .then(function(manifest) { + var stream1 = manifest.periods[0].streams[0]; + expect(stream1.initSegmentUri).toBe(null); + expect(stream1.segments.length).toBe(5); + expect(stream1.segments[0]) + .toEqual({startTime: 0, endTime: 1, uri: 'offline:0/0/0'}); + expect(stream1.segments[3]) + .toEqual({startTime: 3, endTime: 4, uri: 'offline:0/0/3'}); + + var stream2 = manifest.periods[0].streams[1]; + expect(stream2.initSegmentUri).toBe(null); + expect(stream2.segments.length).toBe(1); + expect(stream2.segments[0]) + .toEqual({startTime: 0, endTime: 1, uri: 'offline:0/1/5'}); + return fakeDbEngine.get('segment', 3); + }) + .then(function(segment) { + expect(segment).toBeTruthy(); + expect(segment.data).toBeTruthy(); + expect(segment.data.byteLength).toBe(5); + }) + .catch(fail) + .then(done); + }); + + it('stores init segment', function(done) { + netEngine.setResponseMap({'fake:0': new ArrayBuffer(5)}); + + var stream = manifest.periods[0].streamSets[0].streams[0]; + stream.initSegmentReference = + new shaka.media.InitSegmentReference(['fake:0'], 0, null); + + storage.store('') + .then(function(manifest) { + expect(manifest).toBeTruthy(); + expect(manifest.size).toBe(5); + expect(manifest.duration).toBe(0); + expect(netEngine.request.calls.count()).toBe(1); + return fakeDbEngine.get('manifest', 0); + }) + .then(function(manifest) { + var stream = manifest.periods[0].streams[0]; + expect(stream.segments.length).toBe(0); + expect(stream.initSegmentUri).toBe('offline:0/0/0'); + return fakeDbEngine.get('segment', 0); + }) + .then(function(segment) { + expect(segment).toBeTruthy(); + expect(segment.data).toBeTruthy(); + expect(segment.data.byteLength).toBe(5); + }) + .catch(fail) + .then(done); + }); + + it('with non-0 start time', function(done) { + netEngine.setResponseMap({'fake:0': new ArrayBuffer(5)}); + + var refs = [ + new SegmentReference(0, 10, 11, ['fake:0'], 0, null), + new SegmentReference(1, 11, 12, ['fake:0'], 0, null), + new SegmentReference(2, 12, 13, ['fake:0'], 0, null) + ]; + stream1Index.merge(refs); + manifest.presentationTimeline.notifySegments(0, refs); + + storage.store('') + .then(function(manifest) { + expect(manifest).toBeTruthy(); + expect(manifest.size).toBe(15); + expect(manifest.duration).toBe(3); + expect(netEngine.request.calls.count()).toBe(3); + return fakeDbEngine.get('manifest', 0); + }) + .then(function(manifest) { + var stream = manifest.periods[0].streams[0]; + expect(stream.segments.length).toBe(3); + }) + .catch(fail) + .then(done); + }); + + it('stops for networking errors', function(done) { + netEngine.setResponseMap({'fake:0': new ArrayBuffer(5)}); + + stream1Index.merge([ + new SegmentReference(0, 0, 1, ['fake:0'], 0, null), + new SegmentReference(1, 1, 2, ['fake:0'], 0, null), + new SegmentReference(2, 2, 3, ['fake:0'], 0, null) + ]); + + var delay = netEngine.delayNextRequest(); + var expectedError = new shaka.util.Error( + shaka.util.Error.Category.NETWORK, + shaka.util.Error.Code.HTTP_ERROR); + delay.reject(expectedError); + storage.store('') + .then(fail, function(err) { + shaka.test.Util.expectToEqualError(err, expectedError); + }) + .catch(fail) + .then(done); + }); + }); + }); + + describe('remove', function() { + var segmentId; + + beforeEach(function() { + segmentId = 0; + }); + + it('will delete everything', function(done) { + var manifestId = 0; + createAndInsertSegments(manifestId, 5) + .then(function(refs) { + var manifest = {key: manifestId, periods: [{streams: []}]}; + manifest.periods[0].streams.push({segments: refs}); + return fakeDbEngine.insert('manifest', manifest); + }) + .then(function() { + expectDatabaseCount(1, 5); + return removeManifest(manifestId); + }) + .then(function() { expectDatabaseCount(0, 0); }) + .catch(fail) + .then(done); + }); + + it('will delete init segments', function(done) { + var manifestId = 1; + Promise + .all([ + createAndInsertSegments(manifestId, 5), + createAndInsertSegments(manifestId, 1) + ]) + .then(function(data) { + var manifest = {key: manifestId, periods: [{streams: []}]}; + manifest.periods[0].streams.push( + {initSegmentUri: data[1][0].uri, segments: data[0]}); + return fakeDbEngine.insert('manifest', manifest); + }) + .then(function() { + expectDatabaseCount(1, 6); + return removeManifest(manifestId); + }) + .then(function() { expectDatabaseCount(0, 0); }) + .catch(fail) + .then(done); + }); + + it('will delete multiple streams', function(done) { + var manifestId = 1; + Promise + .all([ + createAndInsertSegments(manifestId, 5), + createAndInsertSegments(manifestId, 3) + ]) + .then(function(data) { + var manifest = {key: manifestId, periods: [{streams: []}]}; + manifest.periods[0].streams.push({segments: data[0]}); + manifest.periods[0].streams.push({segments: data[1]}); + return fakeDbEngine.insert('manifest', manifest); + }) + .then(function() { + expectDatabaseCount(1, 8); + return removeManifest(manifestId); + }) + .then(function() { expectDatabaseCount(0, 0); }) + .catch(fail) + .then(done); + }); + + it('will delete multiple periods', function(done) { + var manifestId = 1; + Promise + .all([ + createAndInsertSegments(manifestId, 5), + createAndInsertSegments(manifestId, 3) + ]) + .then(function(data) { + var manifest = {key: manifestId, periods: []}; + manifest.periods.push({streams: [{segments: data[0]}]}); + manifest.periods.push({streams: [{segments: data[1]}]}); + return fakeDbEngine.insert('manifest', manifest); + }) + .then(function() { + expectDatabaseCount(1, 8); + return removeManifest(manifestId); + }) + .then(function() { expectDatabaseCount(0, 0); }) + .catch(fail) + .then(done); + }); + + it('will not delete other manifest\'s segments', function(done) { + var manifestId1 = 1; + var manifestId2 = 2; + Promise + .all([ + createAndInsertSegments(manifestId1, 5), + createAndInsertSegments(manifestId2, 3) + ]) + .then(function(data) { + var manifest1 = {key: manifestId1, periods: [{streams: []}]}; + manifest1.periods[0].streams.push({segments: data[0]}); + var manifest2 = {key: manifestId2, periods: [{streams: []}]}; + manifest2.periods[0].streams.push({segments: data[1]}); + return Promise.all([ + fakeDbEngine.insert('manifest', manifest1), + fakeDbEngine.insert('manifest', manifest2) + ]); + }) + .then(function() { + expectDatabaseCount(2, 8); + return removeManifest(manifestId1); + }) + .then(function() { + expectDatabaseCount(1, 3); + return fakeDbEngine.get('segment', segmentId - 1); + }) + .then(function(segment) { expect(segment).toBeTruthy(); }) + .catch(fail) + .then(done); + }); + + it('will not raise error on missing segments', function(done) { + var manifestId = 1; + createAndInsertSegments(manifestId, 5) + .then(function(data) { + var manifest = {key: manifestId, periods: [{streams: []}]}; + data[0].uri = 'offline:0/0/1253'; + manifest.periods[0].streams.push({segments: data}); + return fakeDbEngine.insert('manifest', manifest); + }) + .then(function() { + expectDatabaseCount(1, 5); + return removeManifest(manifestId); + }) + .then(function() { + // The segment that was changed above was not deleted. + expectDatabaseCount(0, 1); + }) + .catch(fail) + .then(done); + }); + + it('raises not found error', function(done) { + removeManifest(0) + .then(fail) + .catch(function(e) { + shaka.test.Util.expectToEqualError( + e, + new shaka.util.Error( + shaka.util.Error.Category.STORAGE, + shaka.util.Error.Code.REQUESTED_ITEM_NOT_FOUND, + 'offline:0')); + }) + .then(done); + }); + + /** + * @param {number} manifestCount + * @param {number} segmentCount + */ + function expectDatabaseCount(manifestCount, segmentCount) { + var manifests = fakeDbEngine.getAllData('manifest'); + expect(Object.keys(manifests).length).toBe(manifestCount); + var segments = fakeDbEngine.getAllData('segment'); + expect(Object.keys(segments).length).toBe(segmentCount); + } + + /** + * @param {number} manifestId + * @return {!Promise} + */ + function removeManifest(manifestId) { + return storage.remove({offlineUri: 'offline:' + manifestId}); + } + + /** + * @param {number} manifestId + * @param {number} count + * @return {!Promise.>} + */ + function createAndInsertSegments(manifestId, count) { + var ret = new Array(count); + for (var i = 0; i < count; i++) { + ret[i] = {key: segmentId++}; + } + return Promise + .all(ret.map(function(segment) { + return fakeDbEngine.insert('segment', segment); + })) + .then(function() { + return ret.map(function(segment) { + return {uri: 'offline:' + manifestId + '/0/' + segment.key}; + }); + }); + } + }); +}); diff --git a/test/util/simple_fakes.js b/test/util/simple_fakes.js index 474a8d6264..e1df9fe85e 100644 --- a/test/util/simple_fakes.js +++ b/test/util/simple_fakes.js @@ -207,3 +207,33 @@ shaka.test.FakeManifestParser.prototype.stop = function() { /** @override */ shaka.test.FakeManifestParser.prototype.configure = function() {}; + + +/** + * Creates a fake video element. + * @return {!HTMLVideoElement} + * @suppress {invalidCasts} + */ +function createMockVideo() { + var video = { + src: '', + textTracks: [], + addTextTrack: jasmine.createSpy('addTextTrack'), + addEventListener: jasmine.createSpy('addEventListener'), + removeEventListener: jasmine.createSpy('removeEventListener'), + removeAttribute: jasmine.createSpy('removeAttribute'), + load: jasmine.createSpy('load'), + dispatchEvent: jasmine.createSpy('dispatchEvent'), + on: {} // event listeners + }; + video.addTextTrack.and.callFake(function(kind, id) { + // TODO: mock TextTrack, if/when Player starts directly accessing it. + var track = {}; + video.textTracks.push(track); + return track; + }); + video.addEventListener.and.callFake(function(name, callback) { + video.on[name] = callback; + }); + return /** @type {!HTMLVideoElement} */ (video); +}