Skip to content

Commit

Permalink
Add support for storing protected content offline.
Browse files Browse the repository at this point in the history
Now the Storage class can store protected content and play it back.
When deleting it, the offline EME sessions will be removed.  Also
now offline support appears in Player.support().

Closes #343

Change-Id: Ic5b5a0e0854d80f7821e04e751275abf40ee6eb6
  • Loading branch information
TheModMaker committed Jun 16, 2016
1 parent e36f009 commit 6cc9613
Show file tree
Hide file tree
Showing 12 changed files with 368 additions and 52 deletions.
20 changes: 20 additions & 0 deletions demo/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,26 @@ shakaAssets.testAssets = [
shakaAssets.Feature.WEBVTT
]
},
{
name: 'Angel One (multicodec, multilingual, Widevine)',
manifestUri: '//storage.googleapis.com/shaka-demo-assets/angel-one-widevine/dash.mpd', // gjslint: disable=110

encoder: shakaAssets.Encoder.SHAKA_PACKAGER,
source: shakaAssets.Source.SHAKA,
drm: [shakaAssets.KeySystem.WIDEVINE],
features: [
shakaAssets.Feature.MP4,
shakaAssets.Feature.MULTIPLE_LANGUAGES,
shakaAssets.Feature.SEGMENT_BASE,
shakaAssets.Feature.SUBTITLES,
shakaAssets.Feature.WEBM,
shakaAssets.Feature.WEBVTT
],

licenseServers: {
'com.widevine.alpha': '//widevine-proxy.appspot.com/proxy'
}
},
{
name: 'Sintel 4k (multicodec)',
manifestUri: '//storage.googleapis.com/shaka-demo-assets/sintel/dash.mpd', // gjslint: disable=110
Expand Down
14 changes: 10 additions & 4 deletions demo/offline_section.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,18 @@ shakaDemo.updateButtons_ = function() {

var option = assetList.options[assetList.selectedIndex];
var storedContent = option.storedContent;
var hasDrm = option.asset && option.asset.drm && option.asset.drm.length;
// True if there is no DRM or if the browser supports persistent licenses for
// any given DRM system.
var supportsDrm = !option.asset || !option.asset.drm ||
!option.asset.drm.length || option.asset.drm.some(function(drm) {
return shakaDemo.support_.drm[drm] &&
shakaDemo.support_.drm[drm].persistentState;
});

var storeBtn = document.getElementById('storeOffline');
storeBtn.disabled = (hasDrm || storedContent != null);
storeBtn.title = hasDrm ?
'Storing protected content is not supported' :
storeBtn.disabled = (!supportsDrm || storedContent != null);
storeBtn.title = !supportsDrm ?
'This browser does not support persistent licenses' :
(storeBtn.disabled ? 'Selected asset is already stored offline' : '');
var deleteBtn = document.getElementById('deleteOffline');
deleteBtn.disabled = (storedContent == null);
Expand Down
4 changes: 4 additions & 0 deletions externs/shaka/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* @typedef {{
* presentationTimeline: !shaka.media.PresentationTimeline,
* periods: !Array.<!shakaExtern.Period>,
* offlineSessionIds: !Array.<string>,
* minBufferTime: number
* }}
*
Expand Down Expand Up @@ -66,6 +67,9 @@
* @property {!Array.<!shakaExtern.Period>} periods
* <i>Required.</i> <br>
* The presentation's Periods. There must be at least one Period.
* @property {!Array.<string>} offlineSessionIds
* <i>Defaults to [].</i> <br>
* An array of EME sessions to load for offline playback.
* @property {number} minBufferTime
* <i>Defaults to 0.</i> <br>
* The minimum number of seconds of content that must be buffered before
Expand Down
1 change: 1 addition & 0 deletions lib/dash/dash_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ shaka.dash.DashParser.prototype.parseManifest_ =
this.manifest_ = {
presentationTimeline: presentationTimeline,
periods: periods,
offlineSessionIds: [],
minBufferTime: minBufferTime || 0
};
}.bind(this));
Expand Down
165 changes: 146 additions & 19 deletions lib/media/drm_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,35 +61,50 @@ shaka.media.DrmEngine = function(networkingEngine, onError, onKeyStatus) {
/** @private {!Array.<shaka.media.DrmEngine.ActiveSession>} */
this.activeSessions_ = [];

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

/** @private {!shaka.util.PublicPromise} */
this.allSessionsLoaded_ = new shaka.util.PublicPromise();

/** @private {shaka.net.NetworkingEngine} */
this.networkingEngine_ = networkingEngine;

/** @private {?shakaExtern.DrmConfiguration} */
this.config_ = null;

/** @private {?function(!shaka.util.Error)} */
this.onError_ = onError;
this.onError_ = (function(err) {
this.allSessionsLoaded_.reject(err);
onError(err);
}.bind(this));

/** @private {?function(!Object.<string, string>)} */
this.onKeyStatus_ = onKeyStatus;

/** @private {boolean} */
this.destroyed_ = false;

/** @private {boolean} */
this.isOffline_ = false;

// Add a catch to the Promise to avoid console logs about uncaught errors.
this.allSessionsLoaded_.catch(function() {});
};


/**
* @typedef {{
* loaded: boolean,
* initData: !Uint8Array,
* initData: Uint8Array,
* session: !MediaKeySession
* }}
*
* @description A record to track sessions and suppress duplicate init data.
* @property {boolean} loaded
* True once the key status has been updated (to a non-pending state). This
* does not mean the session is 'usable'.
* @property {!Uint8Array} initData
* @property {Uint8Array} initData
* The init data used to create the session.
* @property {!MediaKeySession} session
* The session object.
Expand All @@ -102,14 +117,16 @@ shaka.media.DrmEngine.prototype.destroy = function() {
var Functional = shaka.util.Functional;
this.destroyed_ = true;

this.activeSessions_.forEach(function(activeSession) {
var async = this.activeSessions_.map(function(activeSession) {
// Ignore any errors when closing the sessions. One such error would be
// an invalid state error triggered by closing a session which has not
// generated any key requests.
activeSession.session.close().catch(Functional.noop);
goog.asserts.assert(activeSession.session.closed, 'Bad EME implementation');
return activeSession.session.closed;
});
this.allSessionsLoaded_.reject();

var async = [];
if (this.eventManager_)
async.push(this.eventManager_.destroy());

Expand All @@ -124,6 +141,7 @@ shaka.media.DrmEngine.prototype.destroy = function() {
this.video_ = null;
this.eventManager_ = null;
this.activeSessions_ = [];
this.offlineSessionIds_ = [];
this.networkingEngine_ = null; // We don't own it, don't destroy() it.
this.config_ = null;
this.onError_ = null;
Expand Down Expand Up @@ -161,8 +179,14 @@ shaka.media.DrmEngine.prototype.init = function(manifest, offline) {
/** @type {!Array.<string>} */
var keySystemsInOrder = [];

this.prepareMediaKeyConfigs_(manifest, offline,
configsByKeySystem, keySystemsInOrder);
// |isOffline_| determines what kind of session to create. The argument to
// |prepareMediaKeyConfigs_| determines the kind of CDM to query for. So
// we still need persistent state when we are loading offline sessions.
this.isOffline_ = offline;
this.offlineSessionIds_ = manifest.offlineSessionIds;
this.prepareMediaKeyConfigs_(
manifest, offline || manifest.offlineSessionIds.length > 0,
configsByKeySystem, keySystemsInOrder);

if (!keySystemsInOrder.length) {
// Unencrypted.
Expand Down Expand Up @@ -223,17 +247,11 @@ shaka.media.DrmEngine.prototype.attach = function(video) {
return Promise.all([setMediaKeys, setServerCertificate]).then(function() {
if (this.destroyed_) return Promise.reject();

// TODO(modmaker): load stored sessions?

var initDatas = this.currentDrmInfo_.initData;
if (initDatas.length) {
// Explicit init data for any one stream is sufficient to suppress
// 'encrypted' events for all streams.
initDatas.forEach(function(initDataOverride) {
this.createTemporarySession_(initDataOverride.initDataType,
initDataOverride.initData);
}.bind(this));
} else {
this.createOrLoad();
if (!this.currentDrmInfo_.initData.length &&
!this.offlineSessionIds_.length) {
// Explicit init data for any one stream or an offline session is
// sufficient to suppress 'encrypted' events for all streams.
var onEncrypted = /** @type {shaka.util.EventManager.ListenerType} */(
this.onEncrypted_.bind(this));
this.eventManager_.listen(this.video_, 'encrypted', onEncrypted);
Expand All @@ -245,6 +263,47 @@ shaka.media.DrmEngine.prototype.attach = function(video) {
};


/**
* Removes the given offline sessions and deletes their data. Must call init()
* before this.
*
* @param {!Array.<string>} sessions
* @return {!Promise}
*/
shaka.media.DrmEngine.prototype.removeSessions = function(sessions) {
goog.asserts.assert(this.mediaKeys_ || !sessions.length,
'Must call init() before removeSessions');
return Promise.all(sessions.map(function(sessionId) {
return this.loadOfflineSession_(sessionId).then(function(session) {
// This will be null on error, such as session not found.
if (session)
return session.remove();
});
}.bind(this)));
};


/**
* Creates the sessions for the init data and waits for them to become ready.
*
* @return {!Promise}
*/
shaka.media.DrmEngine.prototype.createOrLoad = function() {
var initDatas = this.currentDrmInfo_ ? this.currentDrmInfo_.initData : [];
initDatas.forEach(function(initDataOverride) {
this.createTemporarySession_(
initDataOverride.initDataType, initDataOverride.initData);
}.bind(this));
this.offlineSessionIds_.forEach(function(sessionId) {
this.loadOfflineSession_(sessionId);
}.bind(this));

if (!initDatas.length && !this.offlineSessionIds_.length)
this.allSessionsLoaded_.resolve();
return this.allSessionsLoaded_;
};


/** @return {boolean} */
shaka.media.DrmEngine.prototype.initialized = function() {
return this.initialized_;
Expand Down Expand Up @@ -724,6 +783,68 @@ shaka.media.DrmEngine.prototype.onEncrypted_ = function(event) {
};


/**
* @param {string} sessionId
* @return {!Promise.<MediaKeySession>}
* @private
*/
shaka.media.DrmEngine.prototype.loadOfflineSession_ = function(sessionId) {
var session;
try {
session = this.mediaKeys_.createSession('persistent-license');
} catch (exception) {
var error = new shaka.util.Error(
shaka.util.Error.Category.DRM,
shaka.util.Error.Code.FAILED_TO_CREATE_SESSION,
exception.message);
this.onError_(error);
return Promise.reject(error);
}

this.eventManager_.listen(session, 'message',
/** @type {shaka.util.EventManager.ListenerType} */(
this.onSessionMessage_.bind(this)));
this.eventManager_.listen(session, 'keystatuseschange',
this.onKeyStatusesChange_.bind(this));

var activeSession = {initData: null, session: session, loaded: false};
this.activeSessions_.push(activeSession);

return session.load(sessionId).then(function(present) {
if (this.destroyed_) return;

if (!present) {
var i = this.activeSessions_.indexOf(activeSession);
goog.asserts.assert(i >= 0, 'Session must be in the array');
this.activeSessions_.splice(i, 1);

this.onError_(new shaka.util.Error(
shaka.util.Error.Category.DRM,
shaka.util.Error.Code.OFFLINE_SESSION_REMOVED));
return;
}

// TODO: We should get a key status change event. Remove once Chrome CDM
// is fixed.
activeSession.loaded = true;
if (this.activeSessions_.every(function(s) { return s.loaded; }))
this.allSessionsLoaded_.resolve();
return session;
}.bind(this), function(error) {
if (this.destroyed_) return;

var i = this.activeSessions_.indexOf(activeSession);
goog.asserts.assert(i >= 0, 'Session must be in the array');
this.activeSessions_.splice(i, 1);

this.onError_(new shaka.util.Error(
shaka.util.Error.Category.DRM,
shaka.util.Error.Code.FAILED_TO_CREATE_SESSION,
error.message));
}.bind(this));
};


/**
* @param {string} initDataType
* @param {!Uint8Array} initData
Expand All @@ -733,7 +854,11 @@ shaka.media.DrmEngine.prototype.createTemporarySession_ =
function(initDataType, initData) {
var session;
try {
session = this.mediaKeys_.createSession();
if (this.isOffline_) {
session = this.mediaKeys_.createSession('persistent-license');
} else {
session = this.mediaKeys_.createSession();
}
} catch (exception) {
this.onError_(new shaka.util.Error(
shaka.util.Error.Category.DRM,
Expand Down Expand Up @@ -942,6 +1067,8 @@ shaka.media.DrmEngine.prototype.onKeyStatusesChange_ = function(event) {
goog.asserts.assert(activeSession != null,
'Unexpected session in key status map');
activeSession.loaded = true;
if (this.activeSessions_.every(function(s) { return s.loaded; }))
this.allSessionsLoaded_.resolve();
}

var keyIdHex = shaka.util.Uint8ArrayUtils.toHex(new Uint8Array(keyId));
Expand Down
9 changes: 5 additions & 4 deletions lib/offline/offline_manifest_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ shaka.offline.OfflineManifestParser.prototype.start =
shaka.util.Error.Code.REQUESTED_ITEM_NOT_FOUND, manifestId);
}

return this.reconstructManifest_(manifest);
}.bind(this))
var OfflineManifestParser = shaka.offline.OfflineManifestParser;
return OfflineManifestParser.reconstructManifest(manifest);
})
.then(
function(ret) {
return dbEngine.destroy().then(function() { return ret; });
Expand All @@ -87,16 +88,16 @@ shaka.offline.OfflineManifestParser.prototype.stop = function() {
*
* @param {shakaExtern.ManifestDB} manifest
* @return {shakaExtern.Manifest}
* @private
*/
shaka.offline.OfflineManifestParser.prototype.reconstructManifest_ = function(
shaka.offline.OfflineManifestParser.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,
offlineSessionIds: manifest.sessionIds,
periods: manifest.periods.map(function(period) {
return {
startTime: period.startTime,
Expand Down
Loading

0 comments on commit 6cc9613

Please sign in to comment.