Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: excessive requests when using HDCP fallback with LDL #225

Merged
merged 15 commits into from
Aug 27, 2024
Merged
81 changes: 52 additions & 29 deletions src/eme.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,43 @@ export const makeNewRequest = (player, requestOptions) => {
keySystem
} = requestOptions;

let timeElapsed = 0;
let pauseTimer;

player.on('pause', () => {
if (options.limitRenewalsOnPauseTime && typeof options.limitRenewalsOnPauseTime === 'number') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to document the limitRenewalsBeforePlay and limitRenewalsOnPauseTime in the README?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated README in the latest commit!


pauseTimer = setInterval(() => {
timeElapsed++;
if (timeElapsed >= options.limitRenewalsOnPauseTime) {
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 = () => {
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 +170,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 +192,19 @@ export const makeNewRequest = (player, requestOptions) => {
return;
}

if (event.messageType === 'license-renewal') {
const limitRenewalsBeforePlay = options.limitRenewalsBeforePlay;
const limitRenewalsOnPauseTime = options.limitRenewalsOnPauseTime;

if (!player.hasStarted() && limitRenewalsBeforePlay ||
(player.paused() && typeof limitRenewalsOnPauseTime === 'number' && timeElapsed >= limitRenewalsOnPauseTime) ||
player.ended()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would split it into several consts eg:
ignoreLinceRenewal (before playback, max pause duration reached, ended)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!


closeAndRemoveSession();
return;
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an attempt to reduce the number of license renewals made when the player is idle (before playback begins) or when the player is paused for too long.

A more ideal solution to this would probably be closing and removing the sessions if the player is abandoned in a paused state and then making a new request when unpaused. Maybe such a behavior should be behind an option where the amount of time to wait (before closing and removing the session) is specified ? But this might introduce some complications.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the paused check could cause a license to expire mid stream. Has this been tested? Curious if the player can recover the session after playback is resumed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To provide further context to what I'm asking here, I'm curious if the player sits either paused or before starting playback for the duration of a license and it expires, will we request a new license and recover upon resuming playback?

Copy link
Contributor Author

@harisha-swaminathan harisha-swaminathan Aug 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made some changes to the previous logic. I've added an option limitRenewalsOnPauseTime (which probably needs a better name) to define the number of seconds to wait in paused state before rejecting renewals and closing the session.

The player will request new license when playback resumes.

Session is closed when license-renewal is attempted after playback ends. New requests will be made on replay or seek-back

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it should be something like: limitRenewalsMaxPauseDuration


getLicense(options, event.message, contentId)
.then((license) => {
resolve(keySession.update(license).then(() => {
Expand Down Expand Up @@ -231,29 +276,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.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We removed this debug statement and didn't add it back in closeAndRemoveSession .. should we add something similar?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Added it back in closeAndRemoveSession

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
37 changes: 33 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,37 @@ 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a comment describing why we are doing this only on Firefox be useful? I guess at first glance it isn't obvious why we are only sending the mock encrypted event in Firefox

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment!

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', () => {
if (player.eme.sessions.length === 0) {
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 +336,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
Loading