mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-14 15:56:38 +03:00
4a4050d223
Changed download manager to take |shakaExtern.Request| as input instead of |shaka.media.SegmentReference| and |shaka.media.InitSegmentReference| as it really does not need to know about those types. Issue #1248 Change-Id: I797c437f4339cf670b5eddad14952b0526b72ea5
1163 lines
36 KiB
JavaScript
1163 lines
36 KiB
JavaScript
/**
|
|
* @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('goog.asserts');
|
|
goog.require('shaka.Player');
|
|
goog.require('shaka.log');
|
|
goog.require('shaka.media.DrmEngine');
|
|
goog.require('shaka.media.ManifestParser');
|
|
goog.require('shaka.net.NetworkingEngine');
|
|
goog.require('shaka.offline.DownloadManager');
|
|
goog.require('shaka.offline.IStorageEngine');
|
|
goog.require('shaka.offline.ManifestConverter');
|
|
goog.require('shaka.offline.OfflineUri');
|
|
goog.require('shaka.offline.StorageEngineFactory');
|
|
goog.require('shaka.offline.StoredContentUtils');
|
|
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.ManifestParserUtils');
|
|
goog.require('shaka.util.MapUtils');
|
|
goog.require('shaka.util.StreamUtils');
|
|
|
|
|
|
|
|
/**
|
|
* This manages persistent offline data including storage, listing, and deleting
|
|
* stored manifests. Playback of offline manifests are done through the Player
|
|
* using a special URI (see shaka.offline.OfflineUri).
|
|
*
|
|
* First, check support() to see if offline is supported by the platform.
|
|
* Second, configure() the storage object with callbacks to your application.
|
|
* Third, call store(), remove(), or list() as needed.
|
|
* When done, call destroy().
|
|
*
|
|
* @param {shaka.Player} player
|
|
* The player instance to pull configuration data from.
|
|
*
|
|
* @struct
|
|
* @constructor
|
|
* @implements {shaka.util.IDestroyable}
|
|
* @export
|
|
*/
|
|
shaka.offline.Storage = function(player) {
|
|
// It is an easy mistake to make to pass a Player proxy from CastProxy.
|
|
// Rather than throw a vague exception later, throw an explicit and clear one
|
|
// now.
|
|
if (!player || player.constructor != shaka.Player) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.STORAGE,
|
|
shaka.util.Error.Code.LOCAL_PLAYER_INSTANCE_REQUIRED);
|
|
}
|
|
|
|
/** @private {shaka.offline.IStorageEngine} */
|
|
this.storageEngine_ = null;
|
|
|
|
/** @private {shaka.Player} */
|
|
this.player_ = player;
|
|
|
|
/** @private {?shakaExtern.OfflineConfiguration} */
|
|
this.config_ = this.defaultConfig_();
|
|
|
|
/** @private {boolean} */
|
|
this.storeInProgress_ = false;
|
|
|
|
/** @private {Array.<shakaExtern.Track>} */
|
|
this.firstPeriodTracks_ = null;
|
|
|
|
/**
|
|
* The stored content for the manifest that storage is currently downloading.
|
|
* If this is null, it means that storage is not downloading a manifest.
|
|
* @private {?shakaExtern.StoredContent}
|
|
*/
|
|
this.pendingContent_ = null;
|
|
|
|
/** @private {shaka.offline.DownloadManager} */
|
|
this.downloadManager_ = null;
|
|
};
|
|
|
|
|
|
/**
|
|
* Gets whether offline storage is supported. Returns true if offline storage
|
|
* is supported for clear content. Support for offline storage of encrypted
|
|
* content will not be determined until storage is attempted.
|
|
*
|
|
* @return {boolean}
|
|
* @export
|
|
*/
|
|
shaka.offline.Storage.support = function() {
|
|
return shaka.offline.StorageEngineFactory.isSupported();
|
|
};
|
|
|
|
|
|
/**
|
|
* @override
|
|
* @export
|
|
*/
|
|
shaka.offline.Storage.prototype.destroy = function() {
|
|
let storageEngine = this.storageEngine_;
|
|
// Destroy the download manager first since it needs the StorageEngine to
|
|
// clean up old segments.
|
|
let ret = !this.downloadManager_ ?
|
|
Promise.resolve() :
|
|
this.downloadManager_.destroy()
|
|
.catch(function() {})
|
|
.then(function() {
|
|
if (storageEngine) return storageEngine.destroy();
|
|
});
|
|
|
|
this.storageEngine_ = 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.
|
|
*
|
|
* There are two important callbacks configured here: one for download progress,
|
|
* and one to decide which tracks to store.
|
|
*
|
|
* The default track selection callback will store the largest SD video track.
|
|
* Provide your own callback to choose the tracks you want to store.
|
|
*
|
|
* @param {!Object} config This should follow the form of
|
|
* {@link shakaExtern.OfflineConfiguration}, but you may omit any field you do
|
|
* not wish to change.
|
|
* @export
|
|
*/
|
|
shaka.offline.Storage.prototype.configure = function(config) {
|
|
goog.asserts.assert(this.config_, 'Storage must not be destroyed');
|
|
shaka.util.ConfigUtils.mergeConfigObjects(
|
|
this.config_, config, this.defaultConfig_(), {}, '');
|
|
};
|
|
|
|
|
|
/**
|
|
* Stores the given manifest. If the content is encrypted, and encrypted
|
|
* content cannot be stored on this platform, the Promise will be rejected with
|
|
* error code 6001, REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE.
|
|
*
|
|
* @param {string} manifestUri The URI of the manifest to store.
|
|
* @param {!Object=} opt_appMetadata An arbitrary object from the application
|
|
* that will be stored along-side the offline content. Use this for any
|
|
* application-specific metadata you need associated with the stored content.
|
|
* For details on the data types that can be stored here, please refer to
|
|
* {@link https://goo.gl/h62coS}
|
|
* @param {!shakaExtern.ManifestParser.Factory=} opt_manifestParserFactory
|
|
* @return {!Promise.<shakaExtern.StoredContent>} A Promise to a structure
|
|
* representing what was stored. The "offlineUri" member is the URI that
|
|
* should be given to Player.load() to play this piece of content offline.
|
|
* The "appMetadata" member is the appMetadata argument you passed to store().
|
|
* @export
|
|
*/
|
|
shaka.offline.Storage.prototype.store = function(
|
|
manifestUri, opt_appMetadata, opt_manifestParserFactory) {
|
|
// TODO: Create a way for a download to be canceled while being downloaded.
|
|
if (this.storeInProgress_) {
|
|
return Promise.reject(new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.STORAGE,
|
|
shaka.util.Error.Code.STORE_ALREADY_IN_PROGRESS));
|
|
}
|
|
this.storeInProgress_ = true;
|
|
|
|
/** @type {!Object} */
|
|
let appMetadata = opt_appMetadata || {};
|
|
|
|
let error = null;
|
|
|
|
return this.initIfNeeded_().then(() => {
|
|
this.checkDestroyed_();
|
|
return this.loadInternal(
|
|
manifestUri, (e) => { error = e; }, opt_manifestParserFactory);
|
|
}).then((data) => {
|
|
this.checkDestroyed_();
|
|
if (error) { throw error; }
|
|
|
|
return shaka.util.IDestroyable.with(data.drmEngine, () => {
|
|
return this.downloadAndStoreManifest_(
|
|
manifestUri, data.manifest, appMetadata, data.drmEngine);
|
|
});
|
|
}).then(
|
|
(content) => {
|
|
this.checkDestroyed_();
|
|
|
|
this.storeInProgress_ = false;
|
|
this.firstPeriodTracks_ = null;
|
|
|
|
return content;
|
|
},
|
|
(err) => {
|
|
this.storeInProgress_ = false;
|
|
this.firstPeriodTracks_ = null;
|
|
|
|
// If we already had an error, ignore this error to avoid hiding
|
|
// the original error.
|
|
throw error || err;
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {string} manifestUri
|
|
* @param {shakaExtern.Manifest} manifest
|
|
* @param {!Object} appMetadata
|
|
* @param {!shaka.media.DrmEngine} drmEngine
|
|
* @return {!Promise.<shakaExtern.StoredContent>}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.downloadAndStoreManifest_ = function(
|
|
manifestUri, manifest, appMetadata, drmEngine) {
|
|
if (manifest.presentationTimeline.isLive() ||
|
|
manifest.presentationTimeline.isInProgress()) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.STORAGE,
|
|
shaka.util.Error.Code.CANNOT_STORE_LIVE_OFFLINE,
|
|
manifestUri);
|
|
}
|
|
|
|
// Re-filter now that DrmEngine is initialized.
|
|
this.filterAllPeriods_(drmEngine, manifest.periods);
|
|
|
|
this.pendingContent_ = shaka.offline.StoredContentUtils.fromManifest(
|
|
manifestUri,
|
|
manifest,
|
|
0, // Start with a size of 0.
|
|
appMetadata);
|
|
|
|
/** @type {shakaExtern.ManifestDB} */
|
|
let manifestDB = this.createOfflineManifest_(
|
|
drmEngine, manifest, manifestUri, appMetadata);
|
|
|
|
return this.downloadManager_.downloadAndStore(manifestDB)
|
|
.then(function(id) {
|
|
/** @type {!shaka.offline.OfflineUri} */
|
|
let uri = shaka.offline.OfflineUri.manifest(
|
|
'mechanism', 'cell', id);
|
|
return shaka.offline.StoredContentUtils.fromManifestDB(
|
|
uri, manifestDB);
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* Removes the given stored content.
|
|
*
|
|
* @param {string} contentUri
|
|
* @return {!Promise}
|
|
* @export
|
|
*/
|
|
shaka.offline.Storage.prototype.remove = function(contentUri) {
|
|
let nullableUri = shaka.offline.OfflineUri.parse(contentUri);
|
|
if (nullableUri == null || !nullableUri.isManifest()) {
|
|
return Promise.reject(new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.STORAGE,
|
|
shaka.util.Error.Code.MALFORMED_OFFLINE_URI,
|
|
contentUri));
|
|
}
|
|
|
|
let uri = /** @type {!shaka.offline.OfflineUri} */ (nullableUri);
|
|
|
|
return shaka.offline.Storage.withStorageEngine_((storage) => {
|
|
return storage.getManifest(uri.key()).then((manifestDB) => {
|
|
if (!manifestDB) {
|
|
return Promise.reject(new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.STORAGE,
|
|
shaka.util.Error.Code.REQUESTED_ITEM_NOT_FOUND,
|
|
contentUri));
|
|
}
|
|
|
|
return Promise.all([
|
|
this.removeFromDRM_(uri, manifestDB),
|
|
this.removeFromStorage_(storage, uri, manifestDB)
|
|
]);
|
|
});
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {!shaka.offline.OfflineUri} uri
|
|
* @param {shakaExtern.ManifestDB} manifestDB
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.removeFromDRM_ = function(uri, manifestDB) {
|
|
let netEngine = this.getNetEngine_();
|
|
|
|
let error;
|
|
let onError = (e) => {
|
|
// Ignore errors if the session was already removed.
|
|
if (e.code != shaka.util.Error.Code.OFFLINE_SESSION_REMOVED) {
|
|
error = e;
|
|
}
|
|
};
|
|
|
|
let drmEngine = new shaka.media.DrmEngine({
|
|
netEngine: netEngine,
|
|
onError: onError,
|
|
onKeyStatus: () => {},
|
|
onExpirationUpdated: () => {},
|
|
onEvent: () => {}
|
|
});
|
|
|
|
drmEngine.configure(this.player_.getConfiguration().drm);
|
|
|
|
let converter = new shaka.offline.ManifestConverter(
|
|
uri.mechanism(), uri.cell());
|
|
let manifest = converter.fromManifestDB(manifestDB);
|
|
|
|
return shaka.util.IDestroyable.with(drmEngine, () => {
|
|
return Promise.resolve()
|
|
.then(() => drmEngine.init(manifest, this.config_.usePersistentLicense))
|
|
.then(() => drmEngine.removeSessions(manifestDB.sessionIds));
|
|
}).then(() => { if (error) { throw error; } });
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {!shaka.offline.IStorageEngine} storage
|
|
* @param {!shaka.offline.OfflineUri} uri
|
|
* @param {shakaExtern.ManifestDB} manifest
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.removeFromStorage_ = function(
|
|
storage, uri, manifest) {
|
|
/** @type {function(shakaExtern.StoredContent, number)} */
|
|
let callback = this.config_.progressCallback;
|
|
|
|
/** @type {shakaExtern.StoredContent} */
|
|
let content = shaka.offline.StoredContentUtils.fromManifestDB(
|
|
uri, manifest);
|
|
|
|
/** @type {!Array.<number>} */
|
|
let segmentIds = shaka.offline.Storage.getAllSegmentIds_(manifest);
|
|
|
|
/** @type {number} */
|
|
let thingsRemoved = 0;
|
|
/** @type {number} */
|
|
let thingsToRemove = segmentIds.length + 1; // The "+ 1" is the manifest.
|
|
|
|
/** @type {function()} */
|
|
let onThingRemoved = () => {
|
|
thingsRemoved++;
|
|
callback(content, thingsRemoved / thingsToRemove);
|
|
};
|
|
|
|
return Promise.all([
|
|
storage.removeSegments(segmentIds, onThingRemoved),
|
|
storage.removeManifests([uri.key()], onThingRemoved)
|
|
]);
|
|
};
|
|
|
|
|
|
/**
|
|
* Lists all the stored content available.
|
|
*
|
|
* @return {!Promise.<!Array.<shakaExtern.StoredContent>>} A Promise to an
|
|
* array of structures representing all stored content. The "offlineUri"
|
|
* member of the structure is the URI that should be given to Player.load()
|
|
* to play this piece of content offline. The "appMetadata" member is the
|
|
* appMetadata argument you passed to store().
|
|
* @export
|
|
*/
|
|
shaka.offline.Storage.prototype.list = function() {
|
|
/** @type {!Array.<!shakaExtern.StoredContent>} */
|
|
let result = [];
|
|
|
|
return shaka.offline.Storage.withStorageEngine_((storage) => {
|
|
return storage.forEachManifest((id, manifest) => {
|
|
let uri =
|
|
shaka.offline.OfflineUri.manifest('mechanism', 'cell', id);
|
|
let content =
|
|
shaka.offline.StoredContentUtils.fromManifestDB(uri, manifest);
|
|
|
|
result.push(content);
|
|
});
|
|
}).then(() => result);
|
|
};
|
|
|
|
|
|
/**
|
|
* 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) {
|
|
|
|
let netEngine = this.getNetEngine_();
|
|
let config = this.player_.getConfiguration();
|
|
|
|
/** @type {shakaExtern.Manifest} */
|
|
let manifest;
|
|
/** @type {!shaka.media.DrmEngine} */
|
|
let drmEngine;
|
|
/** @type {!shakaExtern.ManifestParser} */
|
|
let manifestParser;
|
|
|
|
let onKeyStatusChange = function() {};
|
|
return shaka.media.ManifestParser
|
|
.getFactory(
|
|
manifestUri, netEngine, config.manifest.retryParameters,
|
|
opt_manifestParserFactory)
|
|
.then(function(factory) {
|
|
this.checkDestroyed_();
|
|
|
|
drmEngine = new shaka.media.DrmEngine({
|
|
netEngine: netEngine,
|
|
onError: onError,
|
|
onKeyStatus: onKeyStatusChange,
|
|
onExpirationUpdated: () => {},
|
|
onEvent: () => {}
|
|
});
|
|
|
|
drmEngine.configure(config.drm);
|
|
|
|
let playerInterface = {
|
|
networkingEngine: netEngine,
|
|
filterAllPeriods: (periods) => {
|
|
this.filterAllPeriods_(drmEngine, periods);
|
|
},
|
|
filterNewPeriod: (period) => {
|
|
this.filterPeriod_(drmEngine, period);
|
|
},
|
|
onTimelineRegionAdded: function() {},
|
|
onEvent: function() {},
|
|
onError: onError
|
|
};
|
|
|
|
manifestParser = new factory();
|
|
manifestParser.configure(config.manifest);
|
|
return manifestParser.start(manifestUri, playerInterface);
|
|
}.bind(this))
|
|
.then(function(data) {
|
|
this.checkDestroyed_();
|
|
manifest = data;
|
|
return drmEngine.init(manifest, this.config_.usePersistentLicense);
|
|
}.bind(this))
|
|
.then(function() {
|
|
this.checkDestroyed_();
|
|
return this.createSegmentIndex_(manifest);
|
|
}.bind(this))
|
|
.then(function() {
|
|
this.checkDestroyed_();
|
|
return drmEngine.createOrLoad();
|
|
}.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.<shakaExtern.Track>} tracks
|
|
* @return {!Array.<shakaExtern.Track>}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.defaultTrackSelect_ = function(tracks) {
|
|
const LanguageUtils = shaka.util.LanguageUtils;
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
|
|
let selectedTracks = [];
|
|
|
|
// Select variants with best language match.
|
|
let audioLangPref = LanguageUtils.normalize(
|
|
this.player_.getConfiguration().preferredAudioLanguage);
|
|
let matchTypes = [
|
|
LanguageUtils.MatchType.EXACT,
|
|
LanguageUtils.MatchType.BASE_LANGUAGE_OKAY,
|
|
LanguageUtils.MatchType.OTHER_SUB_LANGUAGE_OKAY
|
|
];
|
|
let allVariantTracks =
|
|
tracks.filter(function(track) { return track.type == 'variant'; });
|
|
// For each match type, get the tracks that match the audio preference for
|
|
// that match type.
|
|
let tracksByMatchType = matchTypes.map(function(match) {
|
|
return allVariantTracks.filter(function(track) {
|
|
let lang = LanguageUtils.normalize(track.language);
|
|
return LanguageUtils.match(match, audioLangPref, lang);
|
|
});
|
|
});
|
|
|
|
// Find the best match type that has any matches.
|
|
let variantTracks;
|
|
for (let i = 0; i < tracksByMatchType.length; i++) {
|
|
if (tracksByMatchType[i].length) {
|
|
variantTracks = tracksByMatchType[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fall back to "primary" audio tracks, if present.
|
|
if (!variantTracks) {
|
|
let primaryTracks = allVariantTracks.filter(function(track) {
|
|
return track.primary;
|
|
});
|
|
if (primaryTracks.length) {
|
|
variantTracks = primaryTracks;
|
|
}
|
|
}
|
|
|
|
// Otherwise, there is no good way to choose the language, so we don't choose
|
|
// a language at all.
|
|
if (!variantTracks) {
|
|
variantTracks = allVariantTracks;
|
|
// Issue a warning, but only if the content has multiple languages.
|
|
// Otherwise, this warning would just be noise.
|
|
let languages = allVariantTracks
|
|
.map(function(track) { return track.language; })
|
|
.filter(shaka.util.Functional.isNotDuplicate);
|
|
if (languages.length > 1) {
|
|
shaka.log.warning('Could not choose a good audio track based on ' +
|
|
'language preferences or primary tracks. An ' +
|
|
'arbitrary language will be stored!');
|
|
}
|
|
}
|
|
|
|
// From previously selected variants, choose the SD ones (height <= 480).
|
|
let tracksByHeight = variantTracks.filter(function(track) {
|
|
return track.height && track.height <= 480;
|
|
});
|
|
|
|
// If variants don't have video or no video with height <= 480 was
|
|
// found, proceed with the previously selected tracks.
|
|
if (tracksByHeight.length) {
|
|
// Sort by resolution, then select all variants which match the height
|
|
// of the highest SD res. There may be multiple audio bitrates for the
|
|
// same video resolution.
|
|
tracksByHeight.sort(function(a, b) { return b.height - a.height; });
|
|
variantTracks = tracksByHeight.filter(function(track) {
|
|
return track.height == tracksByHeight[0].height;
|
|
});
|
|
}
|
|
|
|
// Now sort by bandwidth.
|
|
variantTracks.sort(function(a, b) { return a.bandwidth - b.bandwidth; });
|
|
|
|
// If there are multiple matches at different audio bitrates, select the
|
|
// middle bandwidth one.
|
|
if (variantTracks.length) {
|
|
selectedTracks.push(variantTracks[Math.floor(variantTracks.length / 2)]);
|
|
}
|
|
|
|
// Since this default callback is used primarily by our own demo app and by
|
|
// app developers who haven't thought about which tracks they want, we should
|
|
// select all text tracks, regardless of language. This makes for a better
|
|
// demo for us, and does not rely on user preferences for the unconfigured
|
|
// app.
|
|
selectedTracks.push.apply(selectedTracks, tracks.filter(function(track) {
|
|
return track.type == ContentType.TEXT;
|
|
}));
|
|
|
|
return selectedTracks;
|
|
};
|
|
|
|
|
|
/**
|
|
* @return {shakaExtern.OfflineConfiguration}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.defaultConfig_ = function() {
|
|
return {
|
|
trackSelectionCallback: this.defaultTrackSelect_.bind(this),
|
|
progressCallback: function(storedContent, percent) {
|
|
// Reference arguments to keep closure from removing them.
|
|
// If the arguments are removed, it breaks our function length check
|
|
// in mergeConfigObjects_().
|
|
// NOTE: Chrome App Content Security Policy prohibits usage of new
|
|
// Function().
|
|
if (storedContent || percent) return null;
|
|
},
|
|
usePersistentLicense: true
|
|
};
|
|
};
|
|
|
|
|
|
/**
|
|
* Initializes the IStorageEngine if it is not initialized already.
|
|
*
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.initIfNeeded_ = function() {
|
|
if (!shaka.offline.Storage.support()) {
|
|
return Promise.reject(new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.STORAGE,
|
|
shaka.util.Error.Code.STORAGE_NOT_SUPPORTED));
|
|
}
|
|
|
|
if (this.storageEngine_) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
goog.asserts.assert(this.player_, 'Player must be initialized');
|
|
/** @type {shaka.net.NetworkingEngine} */
|
|
let netEngine = this.getNetEngine_();
|
|
|
|
return shaka.offline.StorageEngineFactory.createStorageEngine()
|
|
.then(function(storageEngine) {
|
|
goog.asserts.assert(netEngine, 'Need valid networking engine.');
|
|
goog.asserts.assert(storageEngine, 'Need valid storage engine.');
|
|
|
|
// Save this instance for later use in other methods.
|
|
this.storageEngine_ = storageEngine;
|
|
|
|
this.downloadManager_ = new shaka.offline.DownloadManager(
|
|
storageEngine,
|
|
netEngine);
|
|
this.downloadManager_.followProgress(function(progress, size) {
|
|
/** @type {?shakaExtern.StoredContent} */
|
|
let content = this.pendingContent_;
|
|
|
|
goog.asserts.assert(
|
|
content,
|
|
'Need stored content to be set when updating download progress.');
|
|
|
|
// Update the size of the stored content before issuing a
|
|
// progress update.
|
|
content.size = size;
|
|
|
|
this.config_.progressCallback(content, progress);
|
|
}.bind(this));
|
|
}.bind(this));
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {!shaka.media.DrmEngine} drmEngine
|
|
* @param {!Array.<shakaExtern.Period>} periods
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.filterAllPeriods_ = function(
|
|
drmEngine, periods) {
|
|
periods.forEach((period) => this.filterPeriod_(drmEngine, period));
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {!shaka.media.DrmEngine} drmEngine
|
|
* @param {shakaExtern.Period} period
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.filterPeriod_ = function(drmEngine, period) {
|
|
const StreamUtils = shaka.util.StreamUtils;
|
|
|
|
const maxHwRes = {width: Infinity, height: Infinity};
|
|
|
|
/** @type {?shakaExtern.Variant} */
|
|
let variant = null;
|
|
|
|
if (this.firstPeriodTracks_) {
|
|
let variantTrack = this.firstPeriodTracks_.filter(function(track) {
|
|
return track.type == 'variant';
|
|
})[0];
|
|
|
|
if (variantTrack) {
|
|
variant = StreamUtils.findVariantForTrack(period, variantTrack);
|
|
}
|
|
}
|
|
|
|
/** @type {?shakaExtern.Stream} */
|
|
let activeAudio = null;
|
|
/** @type {?shakaExtern.Stream} */
|
|
let activeVideo = null;
|
|
|
|
if (variant) {
|
|
// Use the first variant as the container of "active streams". This
|
|
// is then used to filter out the streams that are not compatible with it.
|
|
// This ensures that in multi-Period content, all Periods have streams
|
|
// with compatible MIME types.
|
|
if (variant.audio) activeAudio = variant.audio;
|
|
if (variant.video) activeVideo = variant.video;
|
|
}
|
|
|
|
StreamUtils.filterNewPeriod(
|
|
drmEngine, activeAudio, activeVideo, period);
|
|
StreamUtils.applyRestrictions(
|
|
period, this.player_.getConfiguration().restrictions, maxHwRes);
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
* Calls createSegmentIndex for all streams in the manifest.
|
|
*
|
|
* @param {shakaExtern.Manifest} manifest
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.createSegmentIndex_ = function(manifest) {
|
|
const Functional = shaka.util.Functional;
|
|
let streams = manifest.periods
|
|
.map(function(period) { return period.variants; })
|
|
.reduce(Functional.collapseArrays, [])
|
|
.map(function(variant) {
|
|
let variantStreams = [];
|
|
if (variant.audio) variantStreams.push(variant.audio);
|
|
if (variant.video) variantStreams.push(variant.video);
|
|
return variantStreams;
|
|
})
|
|
.reduce(Functional.collapseArrays, [])
|
|
.filter(Functional.isNotDuplicate);
|
|
|
|
let textStreams = manifest.periods
|
|
.map(function(period) { return period.textStreams; })
|
|
.reduce(Functional.collapseArrays, []);
|
|
|
|
streams.push.apply(streams, textStreams);
|
|
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 {!shaka.media.DrmEngine} drmEngine
|
|
* @param {shakaExtern.Manifest} manifest
|
|
* @param {string} originalManifestUri
|
|
* @param {!Object} metadata
|
|
* @return {shakaExtern.ManifestDB}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.createOfflineManifest_ = function(
|
|
drmEngine, manifest, originalManifestUri, metadata) {
|
|
let periods = manifest.periods.map((period) => {
|
|
return this.createPeriod_(drmEngine, manifest, period);
|
|
});
|
|
|
|
let drmInfo = drmEngine.getDrmInfo();
|
|
let sessions = drmEngine.getSessionIds();
|
|
|
|
if (drmInfo && this.config_.usePersistentLicense) {
|
|
if (!sessions.length) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.STORAGE,
|
|
shaka.util.Error.Code.NO_INIT_DATA_FOR_OFFLINE, originalManifestUri);
|
|
}
|
|
// Don't store init data, since we have stored sessions.
|
|
drmInfo.initData = [];
|
|
}
|
|
|
|
return {
|
|
originalManifestUri: originalManifestUri,
|
|
duration: manifest.presentationTimeline.getDuration(),
|
|
size: 0,
|
|
expiration: drmEngine.getExpiration(),
|
|
periods: periods,
|
|
sessionIds: this.config_.usePersistentLicense ? sessions : [],
|
|
drmInfo: drmInfo,
|
|
appMetadata: metadata
|
|
};
|
|
};
|
|
|
|
|
|
/**
|
|
* 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 {!shaka.media.DrmEngine} drmEngine
|
|
* @param {shakaExtern.Manifest} manifest
|
|
* @param {shakaExtern.Period} period
|
|
* @return {shakaExtern.PeriodDB}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.createPeriod_ = function(
|
|
drmEngine, manifest, period) {
|
|
const StreamUtils = shaka.util.StreamUtils;
|
|
|
|
let variantTracks = StreamUtils.getVariantTracks(period, null, null);
|
|
let textTracks = StreamUtils.getTextTracks(period, null);
|
|
let allTracks = variantTracks.concat(textTracks);
|
|
|
|
let chosenTracks = this.config_.trackSelectionCallback(allTracks);
|
|
|
|
if (this.firstPeriodTracks_ == null) {
|
|
this.firstPeriodTracks_ = chosenTracks;
|
|
// Now that the first tracks are chosen, filter again. This ensures all
|
|
// Periods have compatible content types.
|
|
this.filterAllPeriods_(drmEngine, manifest.periods);
|
|
}
|
|
|
|
// Check for any similar tracks.
|
|
if (shaka.offline.Storage.lookForSimilarTracks_(chosenTracks)) {
|
|
shaka.log.warning(
|
|
'Multiple tracks of the same type/kind/language given.');
|
|
}
|
|
|
|
let bandwidth = {};
|
|
chosenTracks.forEach((track) => {
|
|
if (track.type == 'text') {
|
|
// We assume that text has no bandwidth.
|
|
bandwidth[track.id] = 0;
|
|
}
|
|
|
|
if (track.type == 'variant') {
|
|
let variant = StreamUtils.findVariantForTrack(period, track);
|
|
let streams = [variant.audio, variant.video].filter((stream) => !!stream);
|
|
|
|
let bandwidthPerStream = variant.bandwidth / streams.length;
|
|
streams.forEach((stream) => {
|
|
bandwidth[stream.id] = bandwidth[stream.id] || bandwidthPerStream;
|
|
});
|
|
}
|
|
});
|
|
|
|
// Need a way to look up which streams should be downloaded. Use a map so
|
|
// that we can easily lookup if a stream should be downloaded just by
|
|
// checking if its id is in the map.
|
|
let idMap = {};
|
|
chosenTracks.forEach((track) => {
|
|
if (track.type == 'variant' && track.audioId != null) {
|
|
idMap[track.audioId] = true;
|
|
}
|
|
if (track.type == 'variant' && track.videoId != null) {
|
|
idMap[track.videoId] = true;
|
|
}
|
|
if (track.type == 'text') {
|
|
idMap[track.id] = true;
|
|
}
|
|
});
|
|
|
|
// Find the streams we want to download and create a stream db instance
|
|
// for each of them.
|
|
let streamDBs = {};
|
|
shaka.offline.Storage.getStreamSet_(manifest)
|
|
.filter((stream) => !!idMap[stream.id])
|
|
.forEach((stream) => {
|
|
streamDBs[stream.id] = this.createStream_(
|
|
manifest,
|
|
period,
|
|
stream,
|
|
bandwidth[stream.id]);
|
|
});
|
|
|
|
// Connect streams and variants together.
|
|
chosenTracks.forEach((track) => {
|
|
if (track.type == 'variant' && track.audioId != null) {
|
|
streamDBs[track.audioId].variantIds.push(track.id);
|
|
}
|
|
if (track.type == 'variant' && track.videoId != null) {
|
|
streamDBs[track.videoId].variantIds.push(track.id);
|
|
}
|
|
});
|
|
|
|
return {
|
|
startTime: period.startTime,
|
|
streams: shaka.util.MapUtils.values(streamDBs)
|
|
};
|
|
};
|
|
|
|
|
|
/**
|
|
* 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.Manifest} manifest
|
|
* @param {shakaExtern.Period} period
|
|
* @param {shakaExtern.Stream} stream
|
|
* @param {number} estimatedStreamBandwidth
|
|
* @param {number=} opt_variantId
|
|
* @return {shakaExtern.StreamDB}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.createStream_ = function(
|
|
manifest, period, stream, estimatedStreamBandwidth, opt_variantId) {
|
|
/** @type {shakaExtern.StreamDB} */
|
|
let streamDb = {
|
|
id: stream.id,
|
|
primary: stream.primary,
|
|
presentationTimeOffset: stream.presentationTimeOffset || 0,
|
|
contentType: stream.type,
|
|
mimeType: stream.mimeType,
|
|
codecs: stream.codecs,
|
|
frameRate: stream.frameRate,
|
|
kind: stream.kind,
|
|
language: stream.language,
|
|
label: stream.label,
|
|
width: stream.width || null,
|
|
height: stream.height || null,
|
|
initSegmentKey: null,
|
|
encrypted: stream.encrypted,
|
|
keyId: stream.keyId,
|
|
segments: [],
|
|
variantIds: []
|
|
};
|
|
|
|
if (opt_variantId != null) {
|
|
streamDb.variantIds.push(opt_variantId);
|
|
}
|
|
|
|
/** @type {number} */
|
|
let startTime =
|
|
manifest.presentationTimeline.getSegmentAvailabilityStart();
|
|
|
|
// Download each stream in parallel.
|
|
let downloadGroup = stream.id;
|
|
|
|
shaka.offline.Storage.forEachSegment_(stream, startTime, function(segment) {
|
|
/** @type {number} */
|
|
let startTime = segment.startTime;
|
|
/** @type {number} */
|
|
let endTime = segment.endTime;
|
|
/** @type {number} */
|
|
let duration = endTime - startTime;
|
|
|
|
/** @type {number} */
|
|
let estimatedSize = segment.endByte ?
|
|
(segment.endByte - segment.startByte) :
|
|
(duration * estimatedStreamBandwidth / 8);
|
|
|
|
let request = this.createRequest_(segment);
|
|
this.downloadManager_.queue(
|
|
downloadGroup,
|
|
request,
|
|
estimatedSize,
|
|
function(id) {
|
|
/** @type {shakaExtern.SegmentDB} */
|
|
let segmentDb = {
|
|
startTime: startTime,
|
|
endTime: endTime,
|
|
dataKey: id
|
|
};
|
|
|
|
streamDb.segments.push(segmentDb);
|
|
});
|
|
}.bind(this));
|
|
|
|
let initSegment = stream.initSegmentReference;
|
|
if (initSegment) {
|
|
const noBandwidth = 0;
|
|
let request = this.createRequest_(initSegment);
|
|
|
|
this.downloadManager_.queue(
|
|
downloadGroup,
|
|
request,
|
|
noBandwidth,
|
|
function(id) {
|
|
streamDb.initSegmentKey = id;
|
|
});
|
|
}
|
|
|
|
return streamDb;
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {shakaExtern.Stream} stream
|
|
* @param {number} startTime
|
|
* @param {!function(shaka.media.SegmentReference)} callback
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.forEachSegment_ = function(stream, startTime, callback) {
|
|
/** @type {?number} */
|
|
let i = stream.findSegmentPosition(startTime);
|
|
/** @type {?shaka.media.SegmentReference} */
|
|
let ref = i == null ? null : stream.getSegmentReference(i);
|
|
|
|
while (ref) {
|
|
callback(ref);
|
|
ref = stream.getSegmentReference(++i);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* 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.Severity.CRITICAL,
|
|
shaka.util.Error.Category.STORAGE,
|
|
shaka.util.Error.Code.OPERATION_ABORTED);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* @return {!shaka.net.NetworkingEngine}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.getNetEngine_ = function() {
|
|
let net = this.player_.getNetworkingEngine();
|
|
goog.asserts.assert(net, 'Player must not be destroyed');
|
|
return net;
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {!shaka.media.SegmentReference|
|
|
* !shaka.media.InitSegmentReference} segment
|
|
* @return {shakaExtern.Request}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.prototype.createRequest_ = function(segment) {
|
|
let retryParams = this.player_.getConfiguration().streaming.retryParameters;
|
|
let request = shaka.net.NetworkingEngine.makeRequest(
|
|
segment.getUris(), retryParams);
|
|
|
|
if (segment.startByte != 0 || segment.endByte != null) {
|
|
let end = segment.endByte == null ? '' : segment.endByte;
|
|
request.headers['Range'] = 'bytes=' + segment.startByte + '-' + end;
|
|
}
|
|
|
|
return request;
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {shakaExtern.ManifestDB} manifest
|
|
* @return {!Array.<number>}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.getAllSegmentIds_ = function(manifest) {
|
|
/** @type {!Array.<number>} */
|
|
let ids = [];
|
|
|
|
// Get every segment for every stream in the manifest.
|
|
manifest.periods.forEach(function(period) {
|
|
period.streams.forEach(function(stream) {
|
|
if (stream.initSegmentKey != null) {
|
|
ids.push(stream.initSegmentKey);
|
|
}
|
|
|
|
stream.segments.forEach(function(segment) {
|
|
ids.push(segment.dataKey);
|
|
});
|
|
});
|
|
});
|
|
|
|
return ids;
|
|
};
|
|
|
|
|
|
/**
|
|
* Execute operations with an instance of storage engine and clean-up the
|
|
* storage engine after the call completes (regardless of success).
|
|
*
|
|
* @param {function(!shaka.offline.IStorageEngine):!Promise<T>} callback
|
|
* @return {!Promise.<T>}
|
|
* @template T
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.withStorageEngine_ = function(callback) {
|
|
const StorageEngineFactory = shaka.offline.StorageEngineFactory;
|
|
|
|
return StorageEngineFactory.createStorageEngine().then((storage) => {
|
|
return shaka.util.IDestroyable.with(storage, () => callback(storage));
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* Delete the on-disk storage and all the content it contains. This should not
|
|
* be done regularly; only do it when storage is rendered unusable.
|
|
*
|
|
* @return {!Promise}
|
|
* @export
|
|
*/
|
|
shaka.offline.Storage.deleteAll = function() {
|
|
return shaka.offline.StorageEngineFactory.deleteStorage();
|
|
};
|
|
|
|
|
|
/**
|
|
* Look to see if there are any tracks that are "too" similar to each other.
|
|
*
|
|
* @param {!Array.<shakaExtern.Track>} tracks
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.lookForSimilarTracks_ = function(tracks) {
|
|
return tracks.some((t0) => {
|
|
return tracks.some((t1) => {
|
|
return t0 != t1 &&
|
|
t0.type == t1.type &&
|
|
t0.kind == t1.kind &&
|
|
t0.language == t1.language;
|
|
});
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* Get a collection of streams that are in the manifest. This collection will
|
|
* only have one instance of each stream (similar to a set).
|
|
*
|
|
* @param {shakaExtern.Manifest} manifest
|
|
* @return {!Array.<shakaExtern.Stream>}
|
|
* @private
|
|
*/
|
|
shaka.offline.Storage.getStreamSet_ = function(manifest) {
|
|
// Use a map so that we don't store duplicates. Since a stream's id should
|
|
// be unique within the manifest, we can use that as the key.
|
|
let map = {};
|
|
|
|
manifest.periods.forEach((period) => {
|
|
period.textStreams.forEach((text) => { map[text.id] = text; });
|
|
period.variants.forEach((variant) => {
|
|
if (variant.audio) { map[variant.audio.id] = variant.audio; }
|
|
if (variant.video) { map[variant.video.id] = variant.video; }
|
|
});
|
|
});
|
|
|
|
return shaka.util.MapUtils.values(map);
|
|
};
|
|
|
|
|
|
shaka.Player.registerSupportPlugin('offline', shaka.offline.Storage.support);
|