From 348935ee34c7f732bddb836553eb72ce099b0705 Mon Sep 17 00:00:00 2001 From: Harisha Rajam Swaminathan <35213866+harisha-swaminathan@users.noreply.github.com> Date: Tue, 27 Aug 2024 09:45:49 -0400 Subject: [PATCH] fix: excessive requests when using HDCP fallback with LDL (#225) --- README.md | 13 ++++++- src/eme.js | 83 +++++++++++++++++++++++++++++---------------- src/plugin.js | 44 +++++++++++++++++++++--- test/eme.test.js | 70 ++++++++++++++++++++++++++++++++++++-- test/plugin.test.js | 42 ++++++++++++++++++++++- 5 files changed, 215 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1cd6979..6006625 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -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 }); ``` diff --git a/src/eme.js b/src/eme.js index ea7df6f..f1de599 100644 --- a/src/eme.js +++ b/src/eme.js @@ -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, @@ -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) => { @@ -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(() => { @@ -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); diff --git a/src/plugin.js b/src/plugin.js index a00f096..d3234a8 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -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(); @@ -89,7 +89,6 @@ export const handleEncryptedEvent = (player, event, options, sessions, eventBus, } sessions.push({ initData }); - return standard5July2016({ player, video: event.target, @@ -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) { @@ -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) => { @@ -306,7 +343,6 @@ const onPlayerReady = (player, emeError) => { */ const eme = function(options = {}) { const player = this; - const emeError = emeErrorHandler(player); player.ready(() => onPlayerReady(player, emeError)); diff --git a/test/eme.test.js b/test/eme.test.js index 36d076f..0156cd6 100644 --- a/test/eme.test.js +++ b/test/eme.test.js @@ -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 @@ -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) { diff --git a/test/plugin.test.js b/test/plugin.test.js index 3de47e0..d8055f5 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -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, @@ -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: {