From e0212324887286af14f2c450b9c03264861f18e3 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 26 Oct 2022 15:58:02 +0200 Subject: [PATCH] Re-create MediaKeys at each loadVideo on WebOS We noticed of an issue on WebOS (LG TV) 2021 and 2022 models where loading an already-loaded encrypted content would not succeed: it would load indefinitely. After investigation, nothing in the JavaScript code seemed to go wrong, licences were loaded, but nothing was playing. However, we found out that re-creating the `MediaKeys` instance at each zap completely fixed the issue, with the cost of potential longer loading time (not measured yet). This seems to be definitely an issue linked to LG's software and we will share to them what we found, but we still chose to preemptively do a work-around specifically for WebOS 2021 and 2022 models so it works even know. If the issue become fixed in the future, we may remove that work-around. The major part of this commit are added tests on the code handling content decryption, to ensure that a MediaKeys is only re-created in some very specific cases (and keep being re-created in those). --- .../__tests__/can_reuse_media_keys.test.ts | 54 ++++++ ...uld_renew_media_key_system_access.test.ts} | 12 +- src/compat/browser_detection.ts | 10 + src/compat/can_reuse_media_keys.ts | 19 ++ src/compat/index.ts | 6 +- ...> should_renew_media_key_system_access.ts} | 6 +- .../__tests__/__global__/media_keys.test.ts | 182 ++++++++++++++++++ .../decrypt/__tests__/__global__/utils.ts | 2 + src/core/decrypt/find_key_system.ts | 4 +- src/core/decrypt/get_media_keys.ts | 6 +- 10 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 src/compat/__tests__/can_reuse_media_keys.test.ts rename src/compat/__tests__/{should_renew_media_keys.test.ts => should_renew_media_key_system_access.test.ts} (77%) create mode 100644 src/compat/can_reuse_media_keys.ts rename src/compat/{should_renew_media_keys.ts => should_renew_media_key_system_access.ts} (79%) diff --git a/src/compat/__tests__/can_reuse_media_keys.test.ts b/src/compat/__tests__/can_reuse_media_keys.test.ts new file mode 100644 index 0000000000..6259c7e776 --- /dev/null +++ b/src/compat/__tests__/can_reuse_media_keys.test.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +describe("Compat - canReuseMediaKeys", () => { + afterEach(() => { + jest.resetModules(); + }); + + it("should return true on any browser but WebOS 2021 and 2022", () => { + jest.mock("../browser_detection", () => { + return { __esModule: true as const, + isWebOs2021: false, + isWebOs2022: false }; + }); + const canReuseMediaKeys = + jest.requireActual("../can_reuse_media_keys.ts"); + expect(canReuseMediaKeys.default()).toBe(true); + }); + + it("should return false on WebOs 2021", () => { + jest.mock("../browser_detection", () => { + return { __esModule: true as const, + isWebOs2021: true, + isWebOs2022: false }; + }); + const canReuseMediaKeys = + jest.requireActual("../can_reuse_media_keys.ts"); + expect(canReuseMediaKeys.default()).toBe(false); + }); + + it("should return false on WebOs 2022", () => { + jest.mock("../browser_detection", () => { + return { __esModule: true as const, + isWebOs2021: false, + isWebOs2022: true }; + }); + const canReuseMediaKeys = + jest.requireActual("../can_reuse_media_keys.ts"); + expect(canReuseMediaKeys.default()).toBe(false); + }); + + it("should return false in the improbable case of both WebOs 2021 and 2022", () => { + jest.mock("../browser_detection", () => { + return { __esModule: true as const, + isWebOs2021: true, + isWebOs2022: true }; + }); + const canReuseMediaKeys = + jest.requireActual("../can_reuse_media_keys.ts"); + expect(canReuseMediaKeys.default()).toBe(false); + }); +}); diff --git a/src/compat/__tests__/should_renew_media_keys.test.ts b/src/compat/__tests__/should_renew_media_key_system_access.test.ts similarity index 77% rename from src/compat/__tests__/should_renew_media_keys.test.ts rename to src/compat/__tests__/should_renew_media_key_system_access.test.ts index bd285109fc..b4d83aa517 100644 --- a/src/compat/__tests__/should_renew_media_keys.test.ts +++ b/src/compat/__tests__/should_renew_media_key_system_access.test.ts @@ -21,7 +21,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ -describe("compat - shouldRenewMediaKeys", () => { +describe("compat - shouldRenewMediaKeySystemAccess", () => { beforeEach(() => { jest.resetModules(); }); @@ -33,8 +33,9 @@ describe("compat - shouldRenewMediaKeys", () => { isIE11: false, }; }); - const shouldRenewMediaKeys = jest.requireActual("../should_renew_media_keys"); - expect(shouldRenewMediaKeys.default()).toBe(false); + const shouldRenewMediaKeySystemAccess = + jest.requireActual("../should_renew_media_key_system_access"); + expect(shouldRenewMediaKeySystemAccess.default()).toBe(false); }); it("should return true if we are on IE11", () => { @@ -44,8 +45,9 @@ describe("compat - shouldRenewMediaKeys", () => { isIE11: true, }; }); - const shouldRenewMediaKeys = jest.requireActual("../should_renew_media_keys"); - expect(shouldRenewMediaKeys.default()).toBe(true); + const shouldRenewMediaKeySystemAccess = + jest.requireActual("../should_renew_media_key_system_access"); + expect(shouldRenewMediaKeySystemAccess.default()).toBe(true); }); beforeEach(() => { jest.resetModules(); diff --git a/src/compat/browser_detection.ts b/src/compat/browser_detection.ts index 90ff022d41..a3ee70c4a0 100644 --- a/src/compat/browser_detection.ts +++ b/src/compat/browser_detection.ts @@ -50,6 +50,13 @@ const isSamsungBrowser : boolean = !isNode && const isTizen : boolean = !isNode && /Tizen/.test(navigator.userAgent); +const isWebOs : boolean = !isNode && + /Web0S/.test(navigator.userAgent); +const isWebOs2021 : boolean = !isNode && + /WebOS.TV-2021/.test(navigator.userAgent); +const isWebOs2022 : boolean = !isNode && + /WebOS.TV-2022/.test(navigator.userAgent); + interface ISafariWindowObject extends Window { safari? : { pushNotification? : { toString() : string } }; } @@ -76,4 +83,7 @@ export { isSafariMobile, isSamsungBrowser, isTizen, + isWebOs, + isWebOs2021, + isWebOs2022, }; diff --git a/src/compat/can_reuse_media_keys.ts b/src/compat/can_reuse_media_keys.ts new file mode 100644 index 0000000000..5056eac34a --- /dev/null +++ b/src/compat/can_reuse_media_keys.ts @@ -0,0 +1,19 @@ +import { + isWebOs2021, + isWebOs2022, +} from "./browser_detection"; + +/** + * Returns `true` if a `MediaKeys` instance (the `Encrypted Media Extension` + * concept) can be reused between contents. + * + * This should usually be the case but we found rare devices where this would + * cause problem: + * - (2022-10-26): WebOS (LG TVs) 2021 and 2022 just rebuffered indefinitely + * when loading a content already-loaded on the HTMLMediaElement. + * + * @returns {boolean} + */ +export default function canReuseMediaKeys() : boolean { + return !(isWebOs2021 || isWebOs2022); +} diff --git a/src/compat/index.ts b/src/compat/index.ts index e4a8a739af..b04c9bb3b6 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -22,6 +22,7 @@ import { MediaSource_, } from "./browser_compatibility_types"; import canPatchISOBMFFSegment from "./can_patch_isobmff"; +import canReuseMediaKeys from "./can_reuse_media_keys"; import tryToChangeSourceBufferType, { ICompatSourceBuffer, } from "./change_source_buffer_type"; @@ -58,7 +59,7 @@ import play from "./play"; import setElementSrc$ from "./set_element_src"; // eslint-disable-next-line max-len import shouldReloadMediaSourceOnDecipherabilityUpdate from "./should_reload_media_source_on_decipherability_update"; -import shouldRenewMediaKeys from "./should_renew_media_keys"; +import shouldRenewMediaKeySystemAccess from "./should_renew_media_key_system_access"; import shouldUnsetMediaKeys from "./should_unset_media_keys"; import shouldValidateMetadata from "./should_validate_metadata"; import shouldWaitForDataBeforeLoaded from "./should_wait_for_data_before_loaded"; @@ -73,6 +74,7 @@ export { addClassName, addTextTrack, canPatchISOBMFFSegment, + canReuseMediaKeys, clearElementSrc, closeSession, CustomMediaKeySystemAccess, @@ -104,7 +106,7 @@ export { setElementSrc$, setMediaKeys, shouldReloadMediaSourceOnDecipherabilityUpdate, - shouldRenewMediaKeys, + shouldRenewMediaKeySystemAccess, shouldUnsetMediaKeys, shouldValidateMetadata, shouldWaitForDataBeforeLoaded, diff --git a/src/compat/should_renew_media_keys.ts b/src/compat/should_renew_media_key_system_access.ts similarity index 79% rename from src/compat/should_renew_media_keys.ts rename to src/compat/should_renew_media_key_system_access.ts index b05fb22937..0be86cac63 100644 --- a/src/compat/should_renew_media_keys.ts +++ b/src/compat/should_renew_media_key_system_access.ts @@ -17,10 +17,10 @@ import { isIE11 } from "./browser_detection"; /** - * Returns true if the current target require the media keys to be renewed on - * each content. + * Returns true if the current target require the MediaKeySystemAccess to be + * renewed on each content. * @returns {Boolean} */ -export default function shouldRenewMediaKeys() : boolean { +export default function shouldRenewMediaKeySystemAccess() : boolean { return isIE11; } diff --git a/src/core/decrypt/__tests__/__global__/media_keys.test.ts b/src/core/decrypt/__tests__/__global__/media_keys.test.ts index 039c6cd693..49a1d38c7f 100644 --- a/src/core/decrypt/__tests__/__global__/media_keys.test.ts +++ b/src/core/decrypt/__tests__/__global__/media_keys.test.ts @@ -24,6 +24,7 @@ import { MediaKeysImpl, + MediaKeySystemAccessImpl, mockCompat, testContentDecryptorError, } from "./utils"; @@ -104,6 +105,8 @@ describe("core - decrypt - global tests - media key system access", () => { /* eslint-enable max-len */ return new Promise((res, rej) => { mockCompat({}); + const mockCreateMediaKeys = jest.spyOn(MediaKeySystemAccessImpl.prototype, + "createMediaKeys"); const { ContentDecryptorState } = jest.requireActual("../../content_decryptor"); const ContentDecryptor = jest.requireActual("../../content_decryptor").default; const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); @@ -112,6 +115,7 @@ describe("core - decrypt - global tests - media key system access", () => { receivedStateChange++; try { expect(newState).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); } catch (err) { rej(err); } setTimeout(() => { try { @@ -126,6 +130,184 @@ describe("core - decrypt - global tests - media key system access", () => { }); }); + /* eslint-disable max-len */ + it("should not call createMediaKeys again if previous one is compatible", () => { + /* eslint-enable max-len */ + return new Promise((res, rej) => { + mockCompat({}); + const mockCreateMediaKeys = jest.spyOn(MediaKeySystemAccessImpl.prototype, + "createMediaKeys"); + const { ContentDecryptorState } = jest.requireActual("../../content_decryptor"); + const ContentDecryptor = jest.requireActual("../../content_decryptor").default; + + const contentDecryptor1 = new ContentDecryptor(videoElt, ksConfig); + let receivedStateChange1 = 0; + contentDecryptor1.addEventListener("error", rej); + contentDecryptor1.addEventListener("stateChange", (state1: any) => { + receivedStateChange1++; + try { + if (receivedStateChange1 === 2) { + expect(state1).toEqual(ContentDecryptorState.ReadyForContent); + return; + } else if (receivedStateChange1 !== 1) { + throw new Error("Unexpected stateChange event."); + } + expect(state1).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); + contentDecryptor1.attach(); + } catch (err) { rej(err); } + + setTimeout(() => { + contentDecryptor1.dispose(); + const contentDecryptor2 = new ContentDecryptor(videoElt, ksConfig); + let receivedStateChange2 = 0; + contentDecryptor2.addEventListener("error", rej); + contentDecryptor2.addEventListener("stateChange", (state2: any) => { + receivedStateChange2++; + try { + if (receivedStateChange2 === 2) { + expect(state2).toEqual(ContentDecryptorState.ReadyForContent); + return; + } else if (receivedStateChange2 !== 1) { + throw new Error("Unexpected stateChange event."); + } + expect(state2).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); + contentDecryptor2.attach(); + setTimeout(() => { + try { + contentDecryptor2.dispose(); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); + res(); + } catch (err) { rej(err); } + }); + } catch (err) { rej(err); } + }); + }, 10); + }); + }); + }); + + /* eslint-disable max-len */ + it("should call createMediaKeys again if the platform needs re-creation of the MediaKeys", () => { + /* eslint-enable max-len */ + return new Promise((res, rej) => { + mockCompat({ + canReuseMediaKeys: jest.fn(() => false), + }); + const mockCreateMediaKeys = jest.spyOn(MediaKeySystemAccessImpl.prototype, + "createMediaKeys"); + const { ContentDecryptorState } = jest.requireActual("../../content_decryptor"); + const ContentDecryptor = jest.requireActual("../../content_decryptor").default; + + const contentDecryptor1 = new ContentDecryptor(videoElt, ksConfig); + let receivedStateChange1 = 0; + contentDecryptor1.addEventListener("error", rej); + contentDecryptor1.addEventListener("stateChange", (state1: any) => { + receivedStateChange1++; + try { + if (receivedStateChange1 === 2) { + expect(state1).toEqual(ContentDecryptorState.ReadyForContent); + return; + } else if (receivedStateChange1 !== 1) { + throw new Error("Unexpected stateChange event."); + } + expect(state1).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); + contentDecryptor1.attach(); + } catch (err) { rej(err); } + + setTimeout(() => { + contentDecryptor1.dispose(); + const contentDecryptor2 = new ContentDecryptor(videoElt, ksConfig); + let receivedStateChange2 = 0; + contentDecryptor2.addEventListener("error", rej); + contentDecryptor2.addEventListener("stateChange", (state2: any) => { + receivedStateChange2++; + try { + if (receivedStateChange2 === 2) { + expect(state2).toEqual(ContentDecryptorState.ReadyForContent); + return; + } else if (receivedStateChange2 !== 1) { + throw new Error("Unexpected stateChange event."); + } + expect(state2).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(2); + contentDecryptor2.attach(); + setTimeout(() => { + try { + contentDecryptor2.dispose(); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(2); + res(); + } catch (err) { rej(err); } + }); + } catch (err) { rej(err); } + }); + }, 10); + }); + }); + }); + + /* eslint-disable max-len */ + it("should not call createMediaKeys again if the platform needs MediaKeySystemAccess renewal", () => { + /* eslint-enable max-len */ + return new Promise((res, rej) => { + mockCompat({ + shouldRenewMediaKeySystemAccess: jest.fn(() => true), + }); + const mockCreateMediaKeys = jest.spyOn(MediaKeySystemAccessImpl.prototype, + "createMediaKeys"); + const { ContentDecryptorState } = jest.requireActual("../../content_decryptor"); + const ContentDecryptor = jest.requireActual("../../content_decryptor").default; + + const contentDecryptor1 = new ContentDecryptor(videoElt, ksConfig); + let receivedStateChange1 = 0; + contentDecryptor1.addEventListener("error", rej); + contentDecryptor1.addEventListener("stateChange", (state1: any) => { + receivedStateChange1++; + try { + if (receivedStateChange1 === 2) { + expect(state1).toEqual(ContentDecryptorState.ReadyForContent); + return; + } else if (receivedStateChange1 !== 1) { + throw new Error("Unexpected stateChange event."); + } + expect(state1).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); + contentDecryptor1.attach(); + } catch (err) { rej(err); } + + setTimeout(() => { + contentDecryptor1.dispose(); + const contentDecryptor2 = new ContentDecryptor(videoElt, ksConfig); + let receivedStateChange2 = 0; + contentDecryptor2.addEventListener("error", rej); + contentDecryptor2.addEventListener("stateChange", (state2: any) => { + receivedStateChange2++; + try { + if (receivedStateChange2 === 2) { + expect(state2).toEqual(ContentDecryptorState.ReadyForContent); + return; + } else if (receivedStateChange2 !== 1) { + throw new Error("Unexpected stateChange event."); + } + expect(state2).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(2); + contentDecryptor2.attach(); + setTimeout(() => { + try { + contentDecryptor2.dispose(); + expect(mockCreateMediaKeys).toHaveBeenCalledTimes(2); + res(); + } catch (err) { rej(err); } + }); + } catch (err) { rej(err); } + }); + }, 10); + }); + }); + }); + /* eslint-disable max-len */ it("should not create any session if no encrypted event was received", (done) => { /* eslint-enable max-len */ diff --git a/src/core/decrypt/__tests__/__global__/utils.ts b/src/core/decrypt/__tests__/__global__/utils.ts index d08f996e8d..d99a3e6094 100644 --- a/src/core/decrypt/__tests__/__global__/utils.ts +++ b/src/core/decrypt/__tests__/__global__/utils.ts @@ -286,6 +286,8 @@ export function mockCompat(exportedFunctions = {}) { setMediaKeys: mockSetMediaKeys, getInitData: mockGetInitData, generateKeyRequest: mockGenerateKeyRequest, + shouldRenewMediaKeySystemAccess: jest.fn(() => false), + canReuseMediaKeys: jest.fn(() => true), ...exportedFunctions })); return { mockEvents, diff --git a/src/core/decrypt/find_key_system.ts b/src/core/decrypt/find_key_system.ts index 6ea64f50b2..0157f360e5 100644 --- a/src/core/decrypt/find_key_system.ts +++ b/src/core/decrypt/find_key_system.ts @@ -17,7 +17,7 @@ import { ICustomMediaKeySystemAccess, requestMediaKeySystemAccess, - shouldRenewMediaKeys, + shouldRenewMediaKeySystemAccess, } from "../../compat"; import config from "../../config"; import { EncryptedMediaError } from "../../errors"; @@ -74,7 +74,7 @@ function checkCachedMediaKeySystemAccess( keySystemAccess: MediaKeySystemAccess|ICustomMediaKeySystemAccess; } { const mksConfiguration = currentKeySystemAccess.getConfiguration(); - if (shouldRenewMediaKeys() || mksConfiguration == null) { + if (shouldRenewMediaKeySystemAccess() || mksConfiguration == null) { return null; } diff --git a/src/core/decrypt/get_media_keys.ts b/src/core/decrypt/get_media_keys.ts index d653a2609d..d1af7cf17e 100644 --- a/src/core/decrypt/get_media_keys.ts +++ b/src/core/decrypt/get_media_keys.ts @@ -15,6 +15,7 @@ */ import { + canReuseMediaKeys, ICustomMediaKeys, ICustomMediaKeySystemAccess, } from "../../compat"; @@ -95,7 +96,10 @@ export default async function getMediaKeysInfos( const currentState = MediaKeysInfosStore.getState(mediaElement); const persistentSessionsStore = createPersistentSessionsStorage(options); - if (currentState !== null && evt.type === "reuse-media-key-system-access") { + if (canReuseMediaKeys() && + currentState !== null && + evt.type === "reuse-media-key-system-access") + { const { mediaKeys, loadedSessionsStore } = currentState; // We might just rely on the currently attached MediaKeys instance.