Skip to content

Commit

Permalink
fix: excessive requests when using HDCP fallback with LDL (#225)
Browse files Browse the repository at this point in the history
  • Loading branch information
harisha-swaminathan authored Aug 27, 2024
1 parent 6b4dabd commit 348935e
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 37 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Maintenance Status: Stable
- [`keySystems`](#keysystems)
- [`emeHeaders`](#emeheaders)
- [`firstWebkitneedkeyTimeout`](#firstwebkitneedkeytimeout)
- [`limitRenewalsMaxPauseDuration`](#limitrenewalsmaxpauseduration)
- [`limitRenewalsBeforePlay`](#limitrenewalsbeforeplay)
- [Setting Options per Source](#setting-options-per-source)
- [Setting Options for All Sources](#setting-options-for-all-sources)
- [Header Hierarchy and Removal](#header-hierarchy-and-removal)
Expand Down Expand Up @@ -368,6 +370,15 @@ emeHeaders: {
The amount of time in milliseconds to wait on the first `webkitneedkey` event before making the key request. This was implemented due to a bug in Safari where rendition switches at the start of playback can cause `webkitneedkey` to fire multiple times, with only the last one being valid.

#### `limitRenewalsMaxPauseDuration`

The duration, in seconds, to wait in paused state before license-renewals are rejected and session is closed. This option limits excess license requests when using limited-duration licenses.

#### `limitRenewalsBeforePlay`
> Boolean
If set to true, license renewal is rejected if license expires before play when player is idle. This option limits excess license requests when using limited-duration licenses.

### Setting Options per Source

This is the recommended way of setting most options. Each source may have a different set of requirements; so, it is best to define options on a per source basis.
Expand Down Expand Up @@ -619,7 +630,7 @@ When the key session is created, an event of type `keysessioncreated` will be tr
```
player.tech().on('keysessioncreated', function(keySession) {
// Event data:
// keySession: the mediaKeySession object
// keySession: the mediaKeySession object
// https://www.w3.org/TR/encrypted-media/#mediakeysession-interface
});
```
Expand Down
83 changes: 54 additions & 29 deletions src/eme.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,44 @@ export const makeNewRequest = (player, requestOptions) => {
keySystem
} = requestOptions;

let timeElapsed = 0;
let pauseTimer;

player.on('pause', () => {
if (options.limitRenewalsMaxPauseDuration && typeof options.limitRenewalsMaxPauseDuration === 'number') {

pauseTimer = setInterval(() => {
timeElapsed++;
if (timeElapsed >= options.limitRenewalsMaxPauseDuration) {
clearInterval(pauseTimer);
}
}, 1000);

player.on('play', () => {
clearInterval(pauseTimer);
timeElapsed = 0;
});
}
});

try {
const keySession = mediaKeys.createSession();

safeTriggerOnEventBus(eventBus, {
type: 'keysessioncreated',
keySession
});

player.on('dispose', () => {
const closeAndRemoveSession = () => {
videojs.log.debug('Session expired, closing the session.');
keySession.close().then(() => {

// Because close() is async, this promise could resolve after the
// player has been disposed.
if (eventBus.isDisposed()) {
return;
}

safeTriggerOnEventBus(eventBus, {
type: 'keysessionclosed',
keySession
});
removeSession(initData);
}).catch((error) => {
const metadata = {
errorType: EmeError.EMEFailedToCloseSession,
Expand All @@ -147,6 +171,15 @@ export const makeNewRequest = (player, requestOptions) => {

emeError(error, metadata);
});
};

safeTriggerOnEventBus(eventBus, {
type: 'keysessioncreated',
keySession
});

player.on('dispose', () => {
closeAndRemoveSession();
});

return new Promise((resolve, reject) => {
Expand All @@ -160,6 +193,20 @@ export const makeNewRequest = (player, requestOptions) => {
return;
}

if (event.messageType === 'license-renewal') {
const limitRenewalsBeforePlay = options.limitRenewalsBeforePlay;
const limitRenewalsMaxPauseDuration = options.limitRenewalsMaxPauseDuration;
const validLimitRenewalsMaxPauseDuration = typeof limitRenewalsMaxPauseDuration === 'number';
const renewingBeforePlayback = !player.hasStarted() && limitRenewalsBeforePlay;
const maxPauseDurationReached = player.paused() && validLimitRenewalsMaxPauseDuration && timeElapsed >= limitRenewalsMaxPauseDuration;
const ended = player.ended();

if (renewingBeforePlayback || maxPauseDurationReached || ended) {
closeAndRemoveSession();
return;
}
}

getLicense(options, event.message, contentId)
.then((license) => {
resolve(keySession.update(license).then(() => {
Expand Down Expand Up @@ -231,29 +278,7 @@ export const makeNewRequest = (player, requestOptions) => {
if (expired) {
// Close session and remove it from the session list to ensure that a new
// session can be created.
videojs.log.debug('Session expired, closing the session.');
keySession.close().then(() => {

// Because close() is async, this promise could resolve after the
// player has been disposed.
if (eventBus.isDisposed()) {
return;
}

safeTriggerOnEventBus(eventBus, {
type: 'keysessionclosed',
keySession
});
removeSession(initData);
makeNewRequest(player, requestOptions);
}).catch((error) => {
const metadata = {
errorType: EmeError.EMEFailedToCloseSession,
keySystem
};

emeError(error, metadata);
});
closeAndRemoveSession();
}
}, false);

Expand Down
44 changes: 40 additions & 4 deletions src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const removeSession = (sessions, initData) => {
}
};

export const handleEncryptedEvent = (player, event, options, sessions, eventBus, emeError) => {
export function handleEncryptedEvent(player, event, options, sessions, eventBus, emeError) {
if (!options || !options.keySystems) {
// return silently since it may be handled by a different system
return Promise.resolve();
Expand Down Expand Up @@ -89,7 +89,6 @@ export const handleEncryptedEvent = (player, event, options, sessions, eventBus,
}

sessions.push({ initData });

return standard5July2016({
player,
video: event.target,
Expand All @@ -109,7 +108,7 @@ export const handleEncryptedEvent = (player, event, options, sessions, eventBus,

emeError(error, metadata);
});
};
}

export const handleWebKitNeedKeyEvent = (event, options, eventBus, emeError) => {
if (!options.keySystems || !options.keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] || !event.initData) {
Expand Down Expand Up @@ -256,6 +255,44 @@ const onPlayerReady = (player, emeError) => {
setupSessions(player);

if (window.MediaKeys) {
const sendMockEncryptedEvent = () => {
const mockEncryptedEvent = {
initDataType: 'cenc',
initData: null,
target: player.tech_.el_
};

setupSessions(player);
handleEncryptedEvent(player, mockEncryptedEvent, getOptions(player), player.eme.sessions, player.tech_, emeError);
};

if (videojs.browser.IS_FIREFOX) {
// Unlike Chrome, Firefox doesn't receive an `encrypted` event on
// replay and seek-back after content ends and `handleEncryptedEvent` is never called.
// So a fake encrypted event is necessary here.

let handled;

player.on('ended', () =>{
handled = false;
player.one(['seek', 'play'], (e) => {
if (!handled && player.eme.sessions.length === 0) {
sendMockEncryptedEvent();
handled = true;
}
});
});
player.on('play', () => {
const options = player.eme.options;
const limitRenewalsMaxPauseDuration = options.limitRenewalsMaxPauseDuration;

if (player.eme.sessions.length === 0 && typeof limitRenewalsMaxPauseDuration === 'number') {
handled = true;
sendMockEncryptedEvent();
}
});
}

// Support EME 05 July 2016
// Chrome 42+, Firefox 47+, Edge, Safari 12.1+ on macOS 10.14+
player.tech_.el_.addEventListener('encrypted', (event) => {
Expand Down Expand Up @@ -306,7 +343,6 @@ const onPlayerReady = (player, emeError) => {
*/
const eme = function(options = {}) {
const player = this;

const emeError = emeErrorHandler(player);

player.ready(() => onPlayerReady(player, emeError));
Expand Down
70 changes: 68 additions & 2 deletions test/eme.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ QUnit.test('keystatuseschange triggers keystatuschange on eventBus for each key'

});

QUnit.test('keystatuseschange with expired key closes and recreates session', function(assert) {
QUnit.test('keystatuseschange with expired key closes', function(assert) {
const removeSessionCalls = [];
// once the eme module gets the removeSession function, the session argument is already
// bound to the function (note that it's a custom session maintained by the plugin, not
Expand Down Expand Up @@ -260,7 +260,73 @@ QUnit.test('keystatuseschange with expired key closes and recreates session', fu
assert.equal(removeSessionCalls.length, 1, 'called remove session');
assert.equal(removeSessionCalls[0], initData, 'called to remove session with initData');

assert.equal(creates, 2, 'created another session');
assert.equal(creates, 1, 'no new session created');
});

QUnit.test('sessions are closed and removed on `ended` after expiry', function(assert) {
const done = assert.async();
let getLicenseCalls = 0;
const options = {
keySystems: {
'com.widevine.alpha': {
url: 'some-url',
getLicense(emeOptions, keyMessage, callback) {
getLicenseCalls++;
}
}
}
};
const removeSessionCalls = [];
// once the eme module gets the removeSession function, the session argument is already
// bound to the function (note that it's a custom session maintained by the plugin, not
// the native session), so only initData is passed
const removeSession = (initData) => removeSessionCalls.push(initData);

const keySystemAccess = {
keySystem: 'com.widevine.alpha',
createMediaKeys: () => {
return Promise.resolve({
setServerCertificate: () => Promise.resolve(),
createSession: () => {
return {
addEventListener: (event, callback) => {
if (event === 'message') {
setTimeout(() => {
callback({message: 'whatever', messageType: 'license-renewal'});
assert.equal(getLicenseCalls, 0, 'did not call getLicense');
assert.equal(removeSessionCalls.length, 1, 'session is removed');
done();
});
}
},
keyStatuses: [],
generateRequest: () => Promise.resolve(),
close: () => {
return {
then: (nextCall) => {
nextCall();
return Promise.resolve();
}
};
}
};
}
});
}
};
const video = {
setMediaKeys: () => Promise.resolve()
};

this.player.ended = () => true;
standard5July2016({
player: this.player,
video,
keySystemAccess,
options,
eventBus: getMockEventBus(),
removeSession
});
});

QUnit.test('keystatuseschange with internal-error logs a warning', function(assert) {
Expand Down
42 changes: 41 additions & 1 deletion test/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import QUnit from 'qunit';
import sinon from 'sinon';
import videojs from 'video.js';
import window from 'global/window';

import * as plug from '../src/plugin';
import {
default as plugin,
hasSession,
Expand Down Expand Up @@ -518,6 +518,46 @@ QUnit.test('handleEncryptedEvent uses predefined init data', function(assert) {
});
});

QUnit.test('handleEncryptedEvent called explicitly on replay or seekback after `ended` if browser is Firefox ', function(assert) {
const done = assert.async();

this.clock = sinon.useFakeTimers();

videojs.browser = {
IS_FIREFOX: true
};
this.player.eme();

this.player.trigger('ready');
this.player.trigger('play');

plug.handleEncryptedEvent = sinon.spy();

this.clock.tick(1);
this.player.trigger('ended');
this.clock.tick(1);
this.player.trigger('play');
assert.ok(plug.handleEncryptedEvent.calledOnce, 'HandleEncryptedEvent called if play fires after ended');
plug.handleEncryptedEvent.resetHistory();

this.player.trigger('ended');
this.player.trigger('seek');
assert.ok(plug.handleEncryptedEvent.calledOnce, 'HandleEncryptedEvent called if seek fires after ended');
plug.handleEncryptedEvent.resetHistory();

this.player.trigger('ended');
this.player.trigger('seek');

this.player.eme.sessions.push({});

this.player.trigger('play');
assert.ok(plug.handleEncryptedEvent.calledOnce, 'HandleEncryptedEvent only called once if seek and play both fire after ended');
plug.handleEncryptedEvent.resetHistory();

sinon.restore();
done();
});

QUnit.test('handleMsNeedKeyEvent uses predefined init data', function(assert) {
const options = {
keySystems: {
Expand Down

0 comments on commit 348935e

Please sign in to comment.