From 9dc575c2f56bd6a5d608b0cc3c40394ba7663174 Mon Sep 17 00:00:00 2001 From: wseymour Date: Wed, 15 Jan 2025 11:58:17 -0600 Subject: [PATCH 01/11] feat: eme controller --- packages/playback/src/lib/eme/eme-manager.ts | 15 +++ .../lib/types/configuration.declarations.ts | 111 ++++++++++++++++++ .../src/lib/types/eme-manager.declarations.ts | 1 + .../src/lib/types/source.declarations.ts | 12 ++ 4 files changed, 139 insertions(+) diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index 088d5b5..6beed64 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -66,6 +66,21 @@ export class EmeManager implements IEmeManager { // TODO: implement handling of initData } + // public initializeMediaKeys() { + // // Getting this from contrib eme as it seems to fix a bug in older chrome browsers: + // // https://bugs.chromium.org/p/chromium/issues/detail?id=895449 + // // It basically fakes the encrypted event to set up media keys. + // // I'm 80 percent we don't want to keep this + // } + + public getSupportedCDMs(): Array { + // Get's a list of supported CDMs if the user is interested. + // should check FairPlay, PlayReady, Widevine, and ClearKey. + return []; + } + + // Do we want a function to return the current mediaKeySession, licenses, etc.? + public handleWaitingForKey(): void { // TODO: check if we have pending request or init one if we have init data } diff --git a/packages/playback/src/lib/types/configuration.declarations.ts b/packages/playback/src/lib/types/configuration.declarations.ts index 2859f00..8a276aa 100644 --- a/packages/playback/src/lib/types/configuration.declarations.ts +++ b/packages/playback/src/lib/types/configuration.declarations.ts @@ -1,5 +1,6 @@ import type { CustomTagMap, TransformTagValue, TransformTagAttributes } from '@videojs/hls-parser'; import type { RequestType } from '../consts/request-type'; +import type { IKeySystemConfig } from './source.declarations'; export interface NetworkConfiguration { /** @@ -61,10 +62,120 @@ export interface PlayerHlsConfiguration { customTagMap: CustomTagMap; transformTagValue: TransformTagValue; transformTagAttributes: TransformTagAttributes; + +} + +/** Would be used if we allowed the user to pass in a persistent session */ +// If we need this, move it to another file +// export interface KeySessionMetadata { +// sessionId: string; +// initData: Uint8Array; +// initDataType: string; +// } + + +export interface PlayerEmeConfiguration { + /** + * A map of ClearKey key IDs to keys. + * These values should be encoded in hex or base64. + * Defaults to an empty object. + */ + clearKeys: Record; + + /** + * Key sessions metadata to load before starting playback. + * Defaults to an empty array. + */ + // initialKeySessions: Array; + + + // NOTE: We could make this order the priority that the user intends. + // If not, do we want a different config option that lists priority of key systems? + // Or maybe a `priority` value in the keySystems? + // Do we want to priortize different types of the same DRM (com.widevine.something vs com.widevine.alpha) + keySystems: Record; + + + /** + * The time in ms to check if the media key session is expired. + * Defaults to 1000. + */ + sessionExpirationInterval: number; + + /** + * The minimum version of HDCP to start EME streams. + * Defaults to ''. + */ + minHdcpVersion: string; + + // This will probably replace the firstWebkitneedkeyTimeout option in contrib eme + /** + * Option to ignore duplicate init data on the encrypted event or through pssh boxes. + * Defaults to true + */ + ignoreDuplicateInitData: boolean; + + + /** + * The amount of time in milliseconds to wait on the first `webkitneedkey` event before making the key request. + * is 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. + * Defaults to 1000. + */ + webkitneedkeyTimeout: number; + + // POLYFILLS + + // This would enable the legacy fairplay specific code, in a polyfill manner. + /** + * Build modern EME into browsers that use Apple's prefixed EME in Safari. + * Defaults to false + */ + enableLegacyFairplay: boolean; + + /** + * Adds the logic to fix setServerCertificate implementation on older platforms which claim to support modern EME. + * Defaults to false + */ + enableSetServerCertificate: boolean; + + /** + * Build modern EME into browsers that use legacy webkit EME API + * Shaka defaults this to true, I'd guess we would too. + */ + enableLegacyWebkit: boolean; + + /** + * Add support for EncryptionScheme queries in EME. + * https://wicg.github.io/encrypted-media-encryption-scheme/ + * defaults to false. + */ + enableEncryptionSchemes: boolean; + + // keySystemsByURI: Record; + // This maps something like 'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95': 'com.microsoft.playready.recommendation', + // I am guessing we want to do this manually. Should the user have this capability? + // They could just create a value in `keySystems`. + + // keySystemsMapping: similar to above for two strings, just create another key session. + + // delayLicenseRequestUntilPlayed?? - not sure how this would be useful + + // persistentSessionOnlinePlayback?? - try playback with given persistent session ids + // before requesting a license. Also prevents the session removal at playback + // stop, as-to be able to re-use it later.. + // DO THIS BY DEFAULT? + + // initDataTransform?? - do we want to allow the user to transform initData?? + + // parseInbandPsshEnabled?? - enabled by default I would guess + + // setMediaKeysNoOp?? - meant to stub out eme API for browsers without it. + // Shaka supports this but I do not know if we want this as an option. } export interface PlayerConfiguration { network: PlayerNetworkConfiguration; mse: PlayerMseConfiguration; hls: PlayerHlsConfiguration; + eme: PlayerEmeConfiguration; } diff --git a/packages/playback/src/lib/types/eme-manager.declarations.ts b/packages/playback/src/lib/types/eme-manager.declarations.ts index 97e084c..50718a1 100644 --- a/packages/playback/src/lib/types/eme-manager.declarations.ts +++ b/packages/playback/src/lib/types/eme-manager.declarations.ts @@ -11,6 +11,7 @@ export interface IEmeManager { attach(videoElement: HTMLVideoElement): void; detach(): void; dispose(): void; + getSupportedCDMs(): Array; stop(): void; setSource(source: IPlayerSource): void; handleWaitingForKey(): void; diff --git a/packages/playback/src/lib/types/source.declarations.ts b/packages/playback/src/lib/types/source.declarations.ts index 4552d84..1b86573 100644 --- a/packages/playback/src/lib/types/source.declarations.ts +++ b/packages/playback/src/lib/types/source.declarations.ts @@ -8,7 +8,19 @@ export interface IKeySystemConfig { audioRobustness?: string; sessionType?: MediaKeySessionType; sessionId?: string; + /** + * On 'individualization-request' events, this URI will be used for the license request. + * playready specific + * Defaults to ''. + */ + individualizationServerUri?: string; getContentId?: (contentId: string) => string; + // Rare cases when we want to leave it up to the user to get the license + getLicense?: (contentId: string, keyMessage: MediaKeyMessageEvent) => void; + // In contrib-eme we give the user to update the values in MediaCapability. I don't think we want to do this?? + // audioContentType: string; + // videoContentType: string; + // sessionType?? - we will get this from the request for configuration } export interface ILoadSource { From fc31141460684f312b6972494f6483c0eb18446e Mon Sep 17 00:00:00 2001 From: wseymour Date: Wed, 15 Jan 2025 16:17:35 -0600 Subject: [PATCH 02/11] fix: doc and api changes based on comments --- .../playback/src/lib/consts/eme-robustness.ts | 7 ++ packages/playback/src/lib/eme/eme-manager.ts | 15 --- .../src/lib/player/base/base-player.ts | 6 + .../lib/types/configuration.declarations.ts | 108 ++---------------- .../src/lib/types/eme-manager.declarations.ts | 1 - .../src/lib/types/source.declarations.ts | 13 ++- 6 files changed, 30 insertions(+), 120 deletions(-) create mode 100644 packages/playback/src/lib/consts/eme-robustness.ts diff --git a/packages/playback/src/lib/consts/eme-robustness.ts b/packages/playback/src/lib/consts/eme-robustness.ts new file mode 100644 index 0000000..545b10d --- /dev/null +++ b/packages/playback/src/lib/consts/eme-robustness.ts @@ -0,0 +1,7 @@ +export enum RobustnessLevel { + 'SW_SECURE_CRYPTO', + 'SW_SECURE_DECODE', + 'HW_SECURE_CRYPTO', + 'HW_SECURE_DECODE', + 'HW_SECURE_ALL', +} \ No newline at end of file diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index 6beed64..088d5b5 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -66,21 +66,6 @@ export class EmeManager implements IEmeManager { // TODO: implement handling of initData } - // public initializeMediaKeys() { - // // Getting this from contrib eme as it seems to fix a bug in older chrome browsers: - // // https://bugs.chromium.org/p/chromium/issues/detail?id=895449 - // // It basically fakes the encrypted event to set up media keys. - // // I'm 80 percent we don't want to keep this - // } - - public getSupportedCDMs(): Array { - // Get's a list of supported CDMs if the user is interested. - // should check FairPlay, PlayReady, Widevine, and ClearKey. - return []; - } - - // Do we want a function to return the current mediaKeySession, licenses, etc.? - public handleWaitingForKey(): void { // TODO: check if we have pending request or init one if we have init data } diff --git a/packages/playback/src/lib/player/base/base-player.ts b/packages/playback/src/lib/player/base/base-player.ts index 812a3b8..4c5848d 100644 --- a/packages/playback/src/lib/player/base/base-player.ts +++ b/packages/playback/src/lib/player/base/base-player.ts @@ -204,6 +204,12 @@ export abstract class BasePlayer { this.emeManager_ = null; } + // TODO: create adapter type + public registerEmeApiAdapter(adapter: any): void { + // TODO: implement functionality to register adapter + // For example, this will be used for legacy fairplay. + } + protected readonly networkRequestInterceptor_ = (requestInfo: INetworkRequestInfo): Promise => { return this.interceptorsStorage_.executeInterceptors(InterceptorType.NetworkRequest, requestInfo); }; diff --git a/packages/playback/src/lib/types/configuration.declarations.ts b/packages/playback/src/lib/types/configuration.declarations.ts index 8a276aa..87f4b48 100644 --- a/packages/playback/src/lib/types/configuration.declarations.ts +++ b/packages/playback/src/lib/types/configuration.declarations.ts @@ -1,6 +1,6 @@ import type { CustomTagMap, TransformTagValue, TransformTagAttributes } from '@videojs/hls-parser'; import type { RequestType } from '../consts/request-type'; -import type { IKeySystemConfig } from './source.declarations'; +import type { RobustnessLevel } from '../consts/eme-robustness'; export interface NetworkConfiguration { /** @@ -65,112 +65,24 @@ export interface PlayerHlsConfiguration { } -/** Would be used if we allowed the user to pass in a persistent session */ -// If we need this, move it to another file -// export interface KeySessionMetadata { -// sessionId: string; -// initData: Uint8Array; -// initDataType: string; -// } - - export interface PlayerEmeConfiguration { /** - * A map of ClearKey key IDs to keys. - * These values should be encoded in hex or base64. - * Defaults to an empty object. - */ - clearKeys: Record; - - /** - * Key sessions metadata to load before starting playback. - * Defaults to an empty array. - */ - // initialKeySessions: Array; - - - // NOTE: We could make this order the priority that the user intends. - // If not, do we want a different config option that lists priority of key systems? - // Or maybe a `priority` value in the keySystems? - // Do we want to priortize different types of the same DRM (com.widevine.something vs com.widevine.alpha) - keySystems: Record; - - - /** - * The time in ms to check if the media key session is expired. - * Defaults to 1000. + * Allows for using stored sessions. We store the session IDs internally. + * defaults to false */ - sessionExpirationInterval: number; + enablePersistentKeySessions: boolean; /** - * The minimum version of HDCP to start EME streams. - * Defaults to ''. + * The video robustness level associated with the content type. + * Defults to an empty string. */ - minHdcpVersion: string; + videoRobustness?: RobustnessLevel; - // This will probably replace the firstWebkitneedkeyTimeout option in contrib eme /** - * Option to ignore duplicate init data on the encrypted event or through pssh boxes. - * Defaults to true + * The audio robustness level associated with the content type. + * Defaults to an emptry string. */ - ignoreDuplicateInitData: boolean; - - - /** - * The amount of time in milliseconds to wait on the first `webkitneedkey` event before making the key request. - * is 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. - * Defaults to 1000. - */ - webkitneedkeyTimeout: number; - - // POLYFILLS - - // This would enable the legacy fairplay specific code, in a polyfill manner. - /** - * Build modern EME into browsers that use Apple's prefixed EME in Safari. - * Defaults to false - */ - enableLegacyFairplay: boolean; - - /** - * Adds the logic to fix setServerCertificate implementation on older platforms which claim to support modern EME. - * Defaults to false - */ - enableSetServerCertificate: boolean; - - /** - * Build modern EME into browsers that use legacy webkit EME API - * Shaka defaults this to true, I'd guess we would too. - */ - enableLegacyWebkit: boolean; - - /** - * Add support for EncryptionScheme queries in EME. - * https://wicg.github.io/encrypted-media-encryption-scheme/ - * defaults to false. - */ - enableEncryptionSchemes: boolean; - - // keySystemsByURI: Record; - // This maps something like 'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95': 'com.microsoft.playready.recommendation', - // I am guessing we want to do this manually. Should the user have this capability? - // They could just create a value in `keySystems`. - - // keySystemsMapping: similar to above for two strings, just create another key session. - - // delayLicenseRequestUntilPlayed?? - not sure how this would be useful - - // persistentSessionOnlinePlayback?? - try playback with given persistent session ids - // before requesting a license. Also prevents the session removal at playback - // stop, as-to be able to re-use it later.. - // DO THIS BY DEFAULT? - - // initDataTransform?? - do we want to allow the user to transform initData?? - - // parseInbandPsshEnabled?? - enabled by default I would guess - - // setMediaKeysNoOp?? - meant to stub out eme API for browsers without it. - // Shaka supports this but I do not know if we want this as an option. + audioRobustness?: RobustnessLevel; } export interface PlayerConfiguration { diff --git a/packages/playback/src/lib/types/eme-manager.declarations.ts b/packages/playback/src/lib/types/eme-manager.declarations.ts index 50718a1..97e084c 100644 --- a/packages/playback/src/lib/types/eme-manager.declarations.ts +++ b/packages/playback/src/lib/types/eme-manager.declarations.ts @@ -11,7 +11,6 @@ export interface IEmeManager { attach(videoElement: HTMLVideoElement): void; detach(): void; dispose(): void; - getSupportedCDMs(): Array; stop(): void; setSource(source: IPlayerSource): void; handleWaitingForKey(): void; diff --git a/packages/playback/src/lib/types/source.declarations.ts b/packages/playback/src/lib/types/source.declarations.ts index 1b86573..e6e546f 100644 --- a/packages/playback/src/lib/types/source.declarations.ts +++ b/packages/playback/src/lib/types/source.declarations.ts @@ -4,10 +4,15 @@ export interface IKeySystemConfig { serverCertificate?: Uint8Array; persistentState?: MediaKeysRequirement; distinctiveIdentifier?: MediaKeysRequirement; - videoRobustness?: string; - audioRobustness?: string; sessionType?: MediaKeySessionType; sessionId?: string; + priority?: number; + /** + * A map of ClearKey key IDs to keys. + * These values should be encoded in hex or base64. + * Defaults to an empty object. + */ + clearKeys?: Record; /** * On 'individualization-request' events, this URI will be used for the license request. * playready specific @@ -17,10 +22,6 @@ export interface IKeySystemConfig { getContentId?: (contentId: string) => string; // Rare cases when we want to leave it up to the user to get the license getLicense?: (contentId: string, keyMessage: MediaKeyMessageEvent) => void; - // In contrib-eme we give the user to update the values in MediaCapability. I don't think we want to do this?? - // audioContentType: string; - // videoContentType: string; - // sessionType?? - we will get this from the request for configuration } export interface ILoadSource { From 4b4f85296035808ce73525e6a35e4f477d3e0fec Mon Sep 17 00:00:00 2001 From: wseymour Date: Wed, 15 Jan 2025 16:57:23 -0600 Subject: [PATCH 03/11] fix: minor changes and configuration --- .../lib/configuration/configuration-defaults.ts | 8 ++++++++ .../player-configuration-node.ts | 2 ++ .../player-eme-configuration-node.ts | 9 +++++++++ packages/playback/src/lib/consts/eme-robustness.ts | 7 ------- .../playback/src/lib/player/base/base-player.ts | 5 +++-- .../src/lib/types/configuration.declarations.ts | 14 +++++++------- .../src/lib/types/eme-manager.declarations.ts | 5 +++++ 7 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 packages/playback/src/lib/configuration/configurationNodes/player-eme-configuration-node.ts delete mode 100644 packages/playback/src/lib/consts/eme-robustness.ts diff --git a/packages/playback/src/lib/configuration/configuration-defaults.ts b/packages/playback/src/lib/configuration/configuration-defaults.ts index 798c536..661c670 100644 --- a/packages/playback/src/lib/configuration/configuration-defaults.ts +++ b/packages/playback/src/lib/configuration/configuration-defaults.ts @@ -1,6 +1,7 @@ import type { NetworkConfiguration, PlayerConfiguration, + PlayerEmeConfiguration, PlayerHlsConfiguration, PlayerMseConfiguration, PlayerNetworkConfiguration, @@ -24,6 +25,12 @@ export const getPlayerNetworkConfigurationDefaults = (): PlayerNetworkConfigurat [RequestType.MediaSegment]: getNetworkConfigurationDefaults(), }); +export const getPlayerEmeConfigurationDefaults = (): PlayerEmeConfiguration => ({ + reusePersistentKeySessions: false, + audioRobustness: '', + videoRobustness: '', +}); + export const getPlayerMseConfigurationDefaults = (): PlayerMseConfiguration => ({ useManagedMediaSourceIfAvailable: true, requiredBufferDuration: 30, @@ -40,4 +47,5 @@ export const getPlayerConfigurationDefaults = (): PlayerConfiguration => ({ network: getPlayerNetworkConfigurationDefaults(), mse: getPlayerMseConfigurationDefaults(), hls: getPlayerHlsConfigurationDefaults(), + eme: getPlayerEmeConfigurationDefaults(), }); diff --git a/packages/playback/src/lib/configuration/configurationNodes/player-configuration-node.ts b/packages/playback/src/lib/configuration/configurationNodes/player-configuration-node.ts index 742d274..431da46 100644 --- a/packages/playback/src/lib/configuration/configurationNodes/player-configuration-node.ts +++ b/packages/playback/src/lib/configuration/configurationNodes/player-configuration-node.ts @@ -3,6 +3,7 @@ import { StoreNode } from '../../utils/store'; import type { PlayerConfiguration } from '../../types/configuration.declarations'; import PlayerMseConfigurationImpl from './player-mse-configuration-node'; import PlayerHlsConfigurationImpl from './player-hls-configuration-node'; +import PlayerEmeConfigurationImpl from './player-eme-configuration-node'; export default class PlayerConfigurationImpl extends StoreNode { public static default(): PlayerConfigurationImpl { @@ -10,6 +11,7 @@ export default class PlayerConfigurationImpl extends StoreNode { + public static default(): PlayerEmeConfigurationImpl { + return new PlayerEmeConfigurationImpl(getPlayerEmeConfigurationDefaults()); + } +} diff --git a/packages/playback/src/lib/consts/eme-robustness.ts b/packages/playback/src/lib/consts/eme-robustness.ts deleted file mode 100644 index 545b10d..0000000 --- a/packages/playback/src/lib/consts/eme-robustness.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum RobustnessLevel { - 'SW_SECURE_CRYPTO', - 'SW_SECURE_DECODE', - 'HW_SECURE_CRYPTO', - 'HW_SECURE_DECODE', - 'HW_SECURE_ALL', -} \ No newline at end of file diff --git a/packages/playback/src/lib/player/base/base-player.ts b/packages/playback/src/lib/player/base/base-player.ts index 4c5848d..4b617f3 100644 --- a/packages/playback/src/lib/player/base/base-player.ts +++ b/packages/playback/src/lib/player/base/base-player.ts @@ -46,7 +46,7 @@ import { NetworkRequestAttemptFailedEvent, NetworkRequestAttemptStartedEvent, } from '../../events/network-events'; -import type { IEmeManager, IEmeManagerDependencies } from '../../types/eme-manager.declarations'; +import type { IEmeManager, IEmeManagerDependencies, IEmeApiAdapter } from '../../types/eme-manager.declarations'; import { EncryptedEvent, WaitingForKeyEvent } from '../../events/eme-events'; import type { PipelineLoaderFactoryStorage } from './pipeline-loader-factory-storage'; @@ -205,7 +205,8 @@ export abstract class BasePlayer { } // TODO: create adapter type - public registerEmeApiAdapter(adapter: any): void { + // eslint-disable-next-line + public registerEmeApiAdapter(adapter: IEmeApiAdapter): void { // TODO: implement functionality to register adapter // For example, this will be used for legacy fairplay. } diff --git a/packages/playback/src/lib/types/configuration.declarations.ts b/packages/playback/src/lib/types/configuration.declarations.ts index 87f4b48..90fbc9d 100644 --- a/packages/playback/src/lib/types/configuration.declarations.ts +++ b/packages/playback/src/lib/types/configuration.declarations.ts @@ -1,6 +1,5 @@ import type { CustomTagMap, TransformTagValue, TransformTagAttributes } from '@videojs/hls-parser'; import type { RequestType } from '../consts/request-type'; -import type { RobustnessLevel } from '../consts/eme-robustness'; export interface NetworkConfiguration { /** @@ -62,27 +61,28 @@ export interface PlayerHlsConfiguration { customTagMap: CustomTagMap; transformTagValue: TransformTagValue; transformTagAttributes: TransformTagAttributes; - } export interface PlayerEmeConfiguration { /** - * Allows for using stored sessions. We store the session IDs internally. - * defaults to false + * When enabled, the player will save a mapping of the keyId to persistentSessionId + * to the localStorage. It will attempt to load it if encounters the same keyId and + * and the session is still valid. + * Defaults to false. */ - enablePersistentKeySessions: boolean; + reusePersistentKeySessions: boolean; /** * The video robustness level associated with the content type. * Defults to an empty string. */ - videoRobustness?: RobustnessLevel; + videoRobustness: string; /** * The audio robustness level associated with the content type. * Defaults to an emptry string. */ - audioRobustness?: RobustnessLevel; + audioRobustness: string; } export interface PlayerConfiguration { diff --git a/packages/playback/src/lib/types/eme-manager.declarations.ts b/packages/playback/src/lib/types/eme-manager.declarations.ts index 97e084c..d46d9eb 100644 --- a/packages/playback/src/lib/types/eme-manager.declarations.ts +++ b/packages/playback/src/lib/types/eme-manager.declarations.ts @@ -16,3 +16,8 @@ export interface IEmeManager { handleWaitingForKey(): void; setInitData(type: string, data: ArrayBuffer): void; } + +export interface IEmeApiAdapter { + // TODO: implement this adapter type + id: string; +} From 9a0b8ab2ea30361bb2ff7c783e57e8857ed405a2 Mon Sep 17 00:00:00 2001 From: wseymour Date: Fri, 17 Jan 2025 15:18:47 -0600 Subject: [PATCH 04/11] feat: private event handler and start of eme logic --- packages/playback/src/lib/consts/events.ts | 2 + packages/playback/src/lib/eme/eme-manager.ts | 196 +++++++++++++++++- .../playback/src/lib/events/parse-events.ts | 25 +++ .../src/lib/player/base/base-player.ts | 12 +- .../src/lib/types/eme-manager.declarations.ts | 13 ++ .../event-type-to-event-map.declarations.ts | 7 + .../src/lib/types/source.declarations.ts | 2 +- 7 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 packages/playback/src/lib/events/parse-events.ts diff --git a/packages/playback/src/lib/consts/events.ts b/packages/playback/src/lib/consts/events.ts index 0e6ca91..2c1f1a2 100644 --- a/packages/playback/src/lib/consts/events.ts +++ b/packages/playback/src/lib/consts/events.ts @@ -16,4 +16,6 @@ export enum PlayerEventType { NetworkRequestAttemptCompletedSuccessfully = 'NetworkRequestAttemptCompletedSuccessfully', NetworkRequestAttemptCompletedUnsuccessfully = 'NetworkRequestAttemptCompletedUnsuccessfully', NetworkRequestAttemptFailed = 'NetworkRequestAttemptFailed', + HlsPlaylistParsed = 'HlsPlaylistParsed', + DashManifestParsed = 'DashManifestParsed', } diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index 088d5b5..a655e4e 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -1,8 +1,14 @@ -import type { IEmeManager, IEmeManagerDependencies } from '../types/eme-manager.declarations'; +import type { + IEmeManager, + IEmeManagerDependencies, + IKeySystemConfiguration +} from '../types/eme-manager.declarations'; import type { INetworkManager } from '../types/network.declarations'; import type { ILogger } from '../types/logger.declarations'; import type { IPlayerSource } from '../types/source.declarations'; - +import { IEventEmitter } from '../types/event-emitter.declarations'; +import { PrivateEventTypeToEventMap } from '../types/mappers/event-type-to-event-map.declarations'; +import { PlayerEventType } from '../consts/events'; /** * Eme Manager should be shipped as a separate bundle and included in the player as opt-in feature */ @@ -28,15 +34,20 @@ export class EmeManager implements IEmeManager { protected readonly networkManager_: INetworkManager; protected readonly logger_: ILogger; + protected readonly privateEventEmitter_: IEventEmitter; protected activeVideoElement_: HTMLVideoElement | null = null; protected activeSource_: IPlayerSource | null = null; protected initDataType_: string | null = null; protected initData_: ArrayBuffer | null = null; + protected activeMediaKeys_: MediaKeys | null = null; + protected activeKeySystem_: string | null = null; + protected activeKeySystemConfig_: IKeySystemConfiguration | null = null; public constructor(dependencies: IEmeManagerDependencies) { this.networkManager_ = dependencies.networkManager; this.logger_ = dependencies.logger; + this.privateEventEmitter_ = dependencies.privateEventEmitter; } public setSource(source: IPlayerSource): void { @@ -44,8 +55,42 @@ export class EmeManager implements IEmeManager { } public setInitData(type: string, data: ArrayBuffer): void { + // As of now, this is only being called on encrypted event + // We will probably want to call this on parse events as well + // can receive from both pssh or encrypted event + // Check if init data is already set!! + + if (!this.activeSource_) { + // return error that source is not set + return; + } + + this.getKeySystemAccess_().then((keySystemAccess) => { + this.selectKeySystem_(keySystemAccess).then((keySystem) => { + if (!keySystem || !this.activeKeySystemConfig_) { + // error, there is no selected key system + return; + } + + // Key system was successfully added to the video element + + // Create a session and init a request + this.createKeySession_(this.activeKeySystemConfig_).then(() => { + // + }).catch(() => { + // error creating key session + }); + }).catch(() => { + // error while selecting the key system + }); + }).catch(() => { + // error while getting the key system access + }); + + // TODO: setCertificate logic + if (this.initDataType_ !== null && this.initData_ !== null) { if (this.initDataType_ === type && EmeManager.areInitDataEqual_(this.initData_, data)) { // received duplicate @@ -80,6 +125,8 @@ export class EmeManager implements IEmeManager { } this.activeVideoElement_ = videoElement; + + this.initEmeManager_(); } public detach(): void { @@ -89,5 +136,150 @@ export class EmeManager implements IEmeManager { public dispose(): void { this.stop(); this.detach(); + this.privateEventEmitter_.removeAllEventListeners(); + } + + private initEmeManager_(): void { + this.privateEventEmitter_.addEventListener( + PlayerEventType.HlsPlaylistParsed, + this.handleParsedManifestEvent_ + ); + this.privateEventEmitter_.addEventListener( + PlayerEventType.DashManifestParsed, + this.handleParsedManifestEvent_ + ) + } + + private getKeySystemConfig_(): Record { + // TODO: Write logic to get this info from manifests and segment data + // We will probably need to pass in a list of key systems + + return { + 'com.widevine.alpha': { + videoCapabilities: [{ + contentType: 'video/webm; codecs="vp9"', + robustness: 'SW_SECURE_CRYPTO' + }], + audioCapabilities: [{ + contentType: 'audio/webm; codecs="vorbis"', + robustness: 'SW_SECURE_CRYPTO' + }] + } + }; + + } + + private handleParsedManifestEvent_(): void { + let mediaKeySystemAccess = {} as MediaKeySystemAccess; + + // TODO: update this function to take in parsed data and turn it into keySystemConfig values + // We may need diffrent functions for DASH and HLS + // We may want to call `setInitData` in here + } + + /** + * First, this function creates keySystemConfigurations for each key system + * the source allows. Once those are created, we request a MediaKeySystemAccess + * using the aforementioned config. This method returns a promise containing + * a MediaKeySystemAccess instance. + * + * @returns A promise containing the MediaKeySystemAccess + */ + private async getKeySystemAccess_(): Promise { + let mediaKeySystemAccess = {} as MediaKeySystemAccess; + + const keySystems = this.activeSource_?.keySystems; + + if (!keySystems) { + // TODO: thow error and ignore EME + return mediaKeySystemAccess; + } + + // If `requestMediaKeySystemAccess` report an error + if (navigator.requestMediaKeySystemAccess === undefined || + typeof navigator.requestMediaKeySystemAccess !== 'function') { + // trigger error + return mediaKeySystemAccess; + } + + // TODO: Sort by priority before this + + for (const keySystem in keySystems) { + const keySystemConfig = this.getKeySystemConfig_(); + // const keySystemConfig = this.getKeySystemConfig_(keySystem); + + try { + // TODO: Create an event for request media key system + this.logger_.debug(); + + mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(keySystem, [keySystemConfig]); + return mediaKeySystemAccess; + } catch (error) { + // Error: could not get system acces for keySystem with keySystemConfig + } + } + + return mediaKeySystemAccess; + } + + /** + * Creates media keys and adds them to the video element. + * + * @param keySystemAccess + * @returns A promise containing the Key System name or null if no key system was valid. + */ + private async selectKeySystem_(keySystemAccess: MediaKeySystemAccess): Promise { + // Return early if the mediaKeys are already set. + if (this.activeVideoElement_?.mediaKeys) { + this.activeMediaKeys_ = this.activeVideoElement_.mediaKeys; + this.activeKeySystem_ = keySystemAccess.keySystem; + this.activeKeySystemConfig_ = this.activeSource_?.keySystems[this.activeKeySystem_] || null; + return this.activeKeySystem_; + } + + return new Promise((resolve, reject) => { + keySystemAccess.createMediaKeys().then((mediaKeys) => { + this.activeKeySystem_ = keySystemAccess.keySystem; + this.activeMediaKeys_ = mediaKeys; + this.activeKeySystemConfig_ = this.activeSource_?.keySystems[this.activeKeySystem_] || null; + + if (this.activeVideoElement_) { + return this.activeVideoElement_.setMediaKeys(this.activeMediaKeys_); + } else { + this.logger_.warn(`WARNING: Attempting to set media keys on an invalid media element.`) + Promise.resolve(); + } + }).then(() => { + this.logger_.debug(`Successfully set media keys in the video element for ${this.activeKeySystem_}.`) + resolve(this.activeKeySystem_) + }).catch(function () { + reject(); + // error could not create media keys + }); + }) + } + + /** + * Creates a key session. + * + * @param keySystemConfig + * @returns an empty promise + */ + private async createKeySession_(keySystemConfig: IKeySystemConfiguration): Promise { + if (!this.activeKeySystem_ || !this.activeMediaKeys_){ + // error + return; + } + + if (!this.activeMediaKeys_?.createSession) { + // issue with createSession, error + return; + } + + // TODO: We may want to pass in sessionType from the list of sessionTypes from the config. + const mediaKeySession = this.activeMediaKeys_.createSession(); + + // TODO: create a session token to handle key status changes and other stuff + // TODO: generateRequest with keySystemConfig to initialize a request. } } diff --git a/packages/playback/src/lib/events/parse-events.ts b/packages/playback/src/lib/events/parse-events.ts new file mode 100644 index 0000000..d0a0a76 --- /dev/null +++ b/packages/playback/src/lib/events/parse-events.ts @@ -0,0 +1,25 @@ +import { PlayerEventType } from '../consts/events'; +import { PlayerEvent } from './base-player-event'; +// We may want to move the following somehwere else. +// We will also need a parsed DASH manifest type. +// import { ParsedPlaylist } from '../../../../../node_modules/@videojs/hls-parser/dist/types/index'; + +export class HlsPlaylistParsedEvent extends PlayerEvent { + public readonly type = PlayerEventType.HlsPlaylistParsed; + // public readonly playlist: ParsedPlaylist; + + // TODO: pass and set playlist here + public constructor() { + super(); + } +} + +export class DashManifestParsedEvent extends PlayerEvent { + public readonly type = PlayerEventType.DashManifestParsed; + // public readonly manifest: ParsedManifest; + + // TODO: pass and set manifest here + public constructor() { + super(); + } +} diff --git a/packages/playback/src/lib/player/base/base-player.ts b/packages/playback/src/lib/player/base/base-player.ts index 4b617f3..4362d5b 100644 --- a/packages/playback/src/lib/player/base/base-player.ts +++ b/packages/playback/src/lib/player/base/base-player.ts @@ -9,7 +9,7 @@ import type { PlayerConfiguration } from '../../types/configuration.declarations import type { DeepPartial } from '../../types/utility.declarations'; import type { IStore } from '../../types/store.declarations'; import type { EventListener, IEventEmitter } from '../../types/event-emitter.declarations'; -import type { EventTypeToEventMap } from '../../types/mappers/event-type-to-event-map.declarations'; +import type { EventTypeToEventMap, PrivateEventTypeToEventMap } from '../../types/mappers/event-type-to-event-map.declarations'; // events import { ConfigurationChangedEvent, @@ -59,6 +59,7 @@ export interface PlayerDependencies { readonly interceptorsStorage: IInterceptorsStorage; readonly configurationManager: IStore; readonly eventEmitter: IEventEmitter; + readonly privateEventEmitter: IEventEmitter; readonly pipelineLoaderFactoryStorage: PipelineLoaderFactoryStorage; // we have to duplicate network manager in both main and worker threads since we may have eme controller on main thread, which requires network manager readonly networkManager: INetworkManager; @@ -133,10 +134,15 @@ export abstract class BasePlayer { protected readonly logger_: ILogger; /** - * internal event emitter service + * internal event emitter service for public events */ protected readonly eventEmitter_: IEventEmitter; + /** + * internal event emitter service for private events used throughout the application. + */ + protected readonly privateEventEmitter_: IEventEmitter; + /** * internal interceptor's storage service */ @@ -168,6 +174,7 @@ export abstract class BasePlayer { protected constructor(dependencies: PlayerDependencies) { this.logger_ = dependencies.logger; this.eventEmitter_ = dependencies.eventEmitter; + this.privateEventEmitter_ = dependencies.privateEventEmitter; this.interceptorsStorage_ = dependencies.interceptorsStorage; this.configurationManager_ = dependencies.configurationManager; this.networkManager_ = dependencies.networkManager; @@ -188,6 +195,7 @@ export abstract class BasePlayer { this.emeManager_ = factory({ logger: this.logger_.createSubLogger('EmeManager'), networkManager: this.networkManager_, + privateEventEmitter: this.privateEventEmitter_, }); if (this.activeVideoElement_) { diff --git a/packages/playback/src/lib/types/eme-manager.declarations.ts b/packages/playback/src/lib/types/eme-manager.declarations.ts index d46d9eb..0283e00 100644 --- a/packages/playback/src/lib/types/eme-manager.declarations.ts +++ b/packages/playback/src/lib/types/eme-manager.declarations.ts @@ -1,10 +1,13 @@ import type { INetworkManager } from './network.declarations'; import type { ILogger } from './logger.declarations'; import type { IPlayerSource } from './source.declarations'; +import { IEventEmitter } from './event-emitter.declarations'; +import { PrivateEventTypeToEventMap } from './mappers/event-type-to-event-map.declarations'; export interface IEmeManagerDependencies { networkManager: INetworkManager; logger: ILogger; + privateEventEmitter: IEventEmitter; } export interface IEmeManager { @@ -21,3 +24,13 @@ export interface IEmeApiAdapter { // TODO: implement this adapter type id: string; } + +export interface IKeySystemConfiguration { + label?: string; + initDataTypes?: Array; + audioCapabilities?: Array; + videoCapabilities?: Array; + distinctiveIdentifier?: MediaKeysRequirement; + persistentState?: MediaKeysRequirement; + sessionTypes?: Array; +} diff --git a/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts b/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts index 1999c56..d0938ca 100644 --- a/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts +++ b/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts @@ -40,4 +40,11 @@ export interface PlayerEventMap { [PlayerEventType.Error]: ErrorEvent; } +export interface ParseEventMap { + [PlayerEventType.HlsPlaylistParsed]: LoggerLevelChangedEvent; + [PlayerEventType.DashManifestParsed]: VolumeChangedEvent; +} + export type EventTypeToEventMap = NetworkEventMap & PlayerEventMap; + +export type PrivateEventTypeToEventMap = ParseEventMap; diff --git a/packages/playback/src/lib/types/source.declarations.ts b/packages/playback/src/lib/types/source.declarations.ts index e6e546f..86ef3d6 100644 --- a/packages/playback/src/lib/types/source.declarations.ts +++ b/packages/playback/src/lib/types/source.declarations.ts @@ -40,7 +40,7 @@ export interface ILoadSource { export interface ILoadRemoteSource extends ILoadSource { /** * Popular use-cases: (http:|https:|data:|blob:) all should work fine with fetch - * Potentially, could be any other protocols, so custom network manager should be provider by the client + * Potentially, could be any other protocols, so custom network manager should be provide by the client */ readonly url: URL; } From b61236f4c944be50de298d5d15aabc663781e72a Mon Sep 17 00:00:00 2001 From: wseymour Date: Fri, 17 Jan 2025 15:32:13 -0600 Subject: [PATCH 05/11] fix: small nit fix and warning about getting key system accessw --- packages/playback/src/lib/eme/eme-manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index a655e4e..829873b 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -212,10 +212,10 @@ export class EmeManager implements IEmeManager { // TODO: Create an event for request media key system this.logger_.debug(); - mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(keySystem, [keySystemConfig]); + const mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(keySystem, [keySystemConfig]); return mediaKeySystemAccess; } catch (error) { - // Error: could not get system acces for keySystem with keySystemConfig + // Warn about a failed request, but loop should continue. } } From c1771f54f7ea9a8f6cdfd151c3fe47e856312ae2 Mon Sep 17 00:00:00 2001 From: wseymour Date: Tue, 11 Feb 2025 16:45:49 -0600 Subject: [PATCH 06/11] feat: initial eme controller impl --- packages/playback/src/lib/eme/eme-manager.ts | 456 ++++++++++++++++-- .../src/lib/types/eme-manager.declarations.ts | 18 +- 2 files changed, 432 insertions(+), 42 deletions(-) diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index 829873b..0549f8e 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -1,7 +1,7 @@ import type { IEmeManager, IEmeManagerDependencies, - IKeySystemConfiguration + IKeySessionMetadata } from '../types/eme-manager.declarations'; import type { INetworkManager } from '../types/network.declarations'; import type { ILogger } from '../types/logger.declarations'; @@ -9,6 +9,10 @@ import type { IPlayerSource } from '../types/source.declarations'; import { IEventEmitter } from '../types/event-emitter.declarations'; import { PrivateEventTypeToEventMap } from '../types/mappers/event-type-to-event-map.declarations'; import { PlayerEventType } from '../consts/events'; +import { RequestType } from '../consts/request-type'; + +const IS_EDGE = navigator.userAgent.indexOf("Edg") > -1 + /** * Eme Manager should be shipped as a separate bundle and included in the player as opt-in feature */ @@ -38,11 +42,12 @@ export class EmeManager implements IEmeManager { protected activeVideoElement_: HTMLVideoElement | null = null; protected activeSource_: IPlayerSource | null = null; - protected initDataType_: string | null = null; - protected initData_: ArrayBuffer | null = null; protected activeMediaKeys_: MediaKeys | null = null; protected activeKeySystem_: string | null = null; - protected activeKeySystemConfig_: IKeySystemConfiguration | null = null; + protected activeKeySystemConfig_: MediaKeySystemConfiguration | null = null; + protected activeSessions_: Map = new Map(); + protected storedSessions_: Map = new Map(); + protected currentKeyStatuses_: Map = new Map(); public constructor(dependencies: IEmeManagerDependencies) { this.networkManager_ = dependencies.networkManager; @@ -76,8 +81,20 @@ export class EmeManager implements IEmeManager { // Key system was successfully added to the video element + // Set server certificate if we have it from the source config + const activeKeySystemConfig = this.activeSource_?.keySystems[this.activeKeySystem_ as string]; + const activeKeySystemCertificate = activeKeySystemConfig?.serverCertificate; + + if (activeKeySystemCertificate) { + // TODO: use then()?? + // TODO: handle making a request for the server certificate. + this.setServerCertificate_(activeKeySystemCertificate); + } + // Create a session and init a request - this.createKeySession_(this.activeKeySystemConfig_).then(() => { + // This kicks off the whole process of setting event listeners, which + // is where the bulk of the logic occurs. + this.createKeySession_(new Uint8Array(data), type, this.activeKeySystemConfig_).then(() => { // }).catch(() => { // error creating key session @@ -89,25 +106,6 @@ export class EmeManager implements IEmeManager { // error while getting the key system access }); - // TODO: setCertificate logic - - if (this.initDataType_ !== null && this.initData_ !== null) { - if (this.initDataType_ === type && EmeManager.areInitDataEqual_(this.initData_, data)) { - // received duplicate - return; - } - - // received new init data/type - this.logger_.debug( - `updating init data: previous(${this.initDataType_}, length: ${this.initData_.byteLength}) --> new(${type}, length: ${data.byteLength})` - ); - - // TODO: implement update - } - - this.initDataType_ = type; - this.initData_ = data; - // TODO: implement handling of initData } @@ -116,6 +114,16 @@ export class EmeManager implements IEmeManager { } public stop(): void { + for (const [id, session] of this.activeSessions_) { + this.closeKeySession_(id); + } + + this.activeMediaKeys_= null; + this.activeKeySystem_ = null; + this.activeKeySystemConfig_ = null; + this.activeSessions_.clear(); + this.storedSessions_.clear(); + this.currentKeyStatuses_.clear(); this.activeSource_ = null; } @@ -150,7 +158,7 @@ export class EmeManager implements IEmeManager { ) } - private getKeySystemConfig_(): Record { + private getKeySystemConfig_(): Record { // TODO: Write logic to get this info from manifests and segment data // We will probably need to pass in a list of key systems @@ -206,7 +214,6 @@ export class EmeManager implements IEmeManager { for (const keySystem in keySystems) { const keySystemConfig = this.getKeySystemConfig_(); - // const keySystemConfig = this.getKeySystemConfig_(keySystem); try { // TODO: Create an event for request media key system @@ -215,7 +222,7 @@ export class EmeManager implements IEmeManager { const mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(keySystem, [keySystemConfig]); return mediaKeySystemAccess; } catch (error) { - // Warn about a failed request, but loop should continue. + // TODO: Warn about a failed request, but loop should continue. } } @@ -260,12 +267,12 @@ export class EmeManager implements IEmeManager { } /** - * Creates a key session. + * Creates a key session on the active media keys. * * @param keySystemConfig * @returns an empty promise */ - private async createKeySession_(keySystemConfig: IKeySystemConfiguration): Promise { + private async createKeySession_(initData: Uint8Array, initDataType: string, keySystemConfig: MediaKeySystemConfiguration): Promise { if (!this.activeKeySystem_ || !this.activeMediaKeys_){ // error return; @@ -276,10 +283,391 @@ export class EmeManager implements IEmeManager { return; } - // TODO: We may want to pass in sessionType from the list of sessionTypes from the config. - const mediaKeySession = this.activeMediaKeys_.createSession(); - - // TODO: create a session token to handle key status changes and other stuff - // TODO: generateRequest with keySystemConfig to initialize a request. + const sessionType = this.activeSource_?.keySystems[this.activeKeySystem_]?.sessionType; + + let mediaKeySession: MediaKeySession | undefined; + + try { + // Pass in the session type from the source if it exists. + mediaKeySession = this.activeMediaKeys_.createSession(sessionType || undefined); + } catch { + // TODO: ERROR: Failed to create session + return; + } + + if (!mediaKeySession) { + return; + } + + if (initData !== null && initDataType !== null) { + const allInitData = this.getAllInitData_(); + + allInitData.forEach((data) => { + if (EmeManager.areInitDataEqual_(initData, data)) { + this.logger_.debug('Received duplicate initData. The key session will not be created.'); + return; + } + }); + + // Received new init data/type + + // Do we need this?? Can we just store the initData in the array of activeSessions? + // this.logger_.debug( + // `Updating init data: previous(${type}, length: ${data.byteLength}) --> new(${type}, length: ${data.byteLength})` + // ); + } + + const sessionId = mediaKeySession.sessionId; + + mediaKeySession.addEventListener('keystatuseschange', (event) => this.onKeyStatusesChange_(event as ExtendableEvent)); + mediaKeySession.addEventListener('message', (event) => this.onSessionMessage_(event)); + + // Register callback for session closed Promise + mediaKeySession.closed.then(() => { + this.removeSession_(sessionId); + this.logger_.debug('EME Key Session closed. sessionId: ' + sessionId); + // TODO: KEY_SESSION_CLOSED event + // eventBus.trigger(events.KEY_SESSION_CLOSED, { data: token.getSessionId() }); + }); + + const metadata = { + initData, + initDataType, + loaded: false, + type: sessionType, + session: mediaKeySession + }; + + this.activeSessions_.set(mediaKeySession.sessionId, metadata); + + mediaKeySession.generateRequest(initDataType, initData) + .then(() => { + this.logger_.debug('DRM: Session created. SessionID = ' + sessionId); + // TODO: KEY SESSION created event + // eventBus.trigger(events.KEY_SESSION_CREATED, { data: sessionToken }); + }) + .catch((error) => { + this.removeSession_(sessionId); + // TODO: ERROR: KEY_SESSION_CREATED_FAILED + // eventBus.trigger(events.KEY_SESSION_CREATED, { + // data: null, + // error: new DashJSError(ProtectionErrors.KEY_SESSION_CREATED_ERROR_CODE, ProtectionErrors.KEY_SESSION_CREATED_ERROR_MESSAGE + 'Error generating key request -- ' + error.name) + // }); + }); + } + + /** + * Sets the server certificate on the active media keys. + * + * @param certificate + * @returns an empty promise. + */ + private async setServerCertificate_(certificate: Uint8Array): Promise { + if (!certificate) { + this.logger_.warn('EME: There was attempt to set an invalid server certificate on the active media keys.'); + return; + } + + if (!this.activeMediaKeys_) { + this.logger_.warn('EME: There was attempt to set a server certificate when the media keys do not exist.'); + return; + } + + try { + const isSupported = await this.activeMediaKeys_?.setServerCertificate(certificate); + + if (!isSupported) { + this.logger_.warn('EME: The key system does not support server certificates. Ignoring this certificate.'); + } + + return; + } catch(exception) { + // TODO: throw error for invalid server certificate + } + } + + /** + * Event to handle key status changes event on session. + * + * @param event + * @returns + */ + private onKeyStatusesChange_(event: ExtendableEvent): void { + const session = event.target as MediaKeySession; + const activeSession = this.activeSessions_.get(session.sessionId); + const keyStatusMap = session.keyStatuses; + + let hasExpiredKeys = false; + + keyStatusMap.forEach((status, keyId) => { + // Edge has the order of these values swaped from the spec. + // We need to account for this + if (typeof keyId === 'string') { + const tmp = keyId; + keyId = status as unknown as BufferSource; + status = tmp as unknown as MediaKeyStatus; + } + + // Microsoft's implementation in Edge seems to present key IDs as + // little-endian UUIDs. + // https://bit.ly/2thuzXu + + // NOTE: Skip if byteLength != 16. + // Edge uses single-byte dummy key IDs. Tizen doesn't have this problem. + // TODO: Do we want to check if it is PS4? + if (this.activeKeySystem_ && this.isPlayReadyKeySystem_(this.activeKeySystem_) && + keyId.byteLength === 16 && IS_EDGE) { + // Get little-endian values: + const dataView = this.toDataView_(keyId); + const le0 = dataView.getUint32(0, true); + const le1 = dataView.getUint16(4, true); + const le2 = dataView.getUint16(6, true); + // Write it back in big-endian + dataView.setUint32(0, le0, false); + dataView.setUint16(4, le1, false); + dataView.setUint16(6, le2, false); + } + + if (!activeSession) { + if (status === 'usable') { + this.logger_.warn(`A usable key was found on a closed session. Session ID: ${session.sessionId} Key ID: ${keyId}`); + } + return; + } + + if (status !== 'status-pending') { + activeSession.loaded = true; + } + + if (status === 'expired') { + hasExpiredKeys = true; + } + + const keyIdHexString = this.toHex(keyId); + + this.currentKeyStatuses_.set(keyIdHexString, status); + + // TODO: How do we want ot use these stored keys? We probably need to handle the ones with status-pending + }); + + // Close session when it has expired keys. + const timeUntilExpiration = session.expiration - Date.now(); + if (timeUntilExpiration < 0 || (hasExpiredKeys && timeUntilExpiration < 1000)) { + // TODO: Do we need to handle a promise on the session like Shaka? + // if (activeSession && !isSessionActive.updatePromise) { + this.logger_.debug(`Session has expired. Session ID: ${session.sessionId}`); + this.activeSessions_.delete(session.sessionId); + + this.closeKeySession_(session.sessionId); + } + + if (!this.areAllSessionsLoaded_()) { + return; + } + + // TODO: Resolve all unloaded sessions. + } + + /** + * @param {!MediaKeyMessageEvent} event + * @private + */ + private async onSessionMessage_(event: MediaKeyMessageEvent): Promise { + const session = event.target as MediaKeySession; + + if(!session) { + this.logger_.warn('EME: A message event was received but it did not contain the session.'); + return; + } + + // All other types will be handled by keystatuseschange + if (!['license-request', 'license-renewal', 'individualization-request'].includes(event.messageType)) { + return; + } + + this.logger_.debug(`Sending license request for session ${session.sessionId} of type ${event.messageType}`); + + let licenseServerUri = this.activeSource_?.keySystems[this.activeKeySystem_ as string].licenseServerUri; + let individualizationSever = this.activeSource_?.keySystems[this.activeKeySystem_ as string].individualizationServerUri; + + if (event.messageType === 'individualization-request' && individualizationSever) { + this.logger_.debug(`Using individualization server for license request: ${individualizationSever}`); + licenseServerUri = individualizationSever; + } + + // TODO: We may want to add things to the request (sessionId, drmInfo, messageType, etc.) + let message = ArrayBuffer.isView(event.message) ? event.message.buffer : event.message; + + const payload = { + url: licenseServerUri as unknown as URL, + mapper: (body: Uint8Array) => message, + requestType: RequestType.License + } + + const licenseRequest = this.networkManager_.post(payload); + + licenseRequest.done.then((response) => { + try { + // TODO: Handle this for different DRM scenarios + // TODO: Do we want to log this response in debug mode? + session.update(response); + } catch { + // TODO: Error that the license response was rejected. + } + }).catch(() => { + // TODO: Error that license request failed + }); + + // TODO: INTERNAL_KEY_MESSAGE Internal event that says we updated the session with the new license? + // { data: new KeyMessage(this, message, undefined, event.messageType) }); + } + + /** + * Removes the selected session from the list of active sessions. + * + * @param sessionId + */ + private removeSession_(sessionId: string): void { + this.activeSessions_.delete(sessionId); + } + + /** + * Closes the chosen media key session and removes all listeners. + * + * @param sessionId + */ + private async closeKeySession_(sessionId: string): Promise { + if (!sessionId) { + return Promise.resolve(); + } + + // Send our request to the key session + const activeSession = this.activeSessions_.get(sessionId)?.session; + + if(!activeSession) { + return Promise.resolve(); + } + + // Remove event listeners + activeSession.removeEventListener('keystatuseschange', (event) => this.onKeyStatusesChange_(event as ExtendableEvent)); + activeSession.removeEventListener('message', (event) => this.onSessionMessage_(event)); + + // Send our request to the key session + return activeSession.close().then(() => { + this.logger_.debug(`Key session sucessfully closed. Session ID: ${sessionId}`) + }).catch(() => { + this.removeSession_(sessionId); + // TODO: KEYSESSIONCLOSED error + // error: 'Error closing session (' + sessionToken.getSessionId() + ') ' + error.name + }); + } + + /** + * A helper function that returns a list of all init data currently active. + * + * @returns A list of init data for all active sessions. + */ + private getAllInitData_(): Array { + const initDataArray: Array = []; + for (const [id, session] of this.activeSessions_) { + if (session.initData) { + initDataArray.push(session.initData); + } + } + return initDataArray; + } + + /** + * A helper method to determine if the keySystem is PlayReady + * + * @param {string} keySystem + * @return {boolean} + */ + private isPlayReadyKeySystem_(keySystem: string) { + if (keySystem) { + return !!keySystem.match(/^com\.(microsoft|chromecast)\.playready/); + } + + return false; + } + + /** + * A helper method to determine if the keySystem is ClearKey + * + * @param {string} keySystem + * @return {boolean} + */ + private isClearKeySystem_(keySystem: string): boolean { + return keySystem === 'org.w3.clearkey'; + } + + /** + * Convert a buffer to a DataView type for additional utilities to deal with + * different different array types. + * + * @param bufferSource + * @returns + */ + private toDataView_(bufferSource: BufferSource): DataView { + const buffer = this.getArrayBuffer_(bufferSource); + let bytesPerElement = 1; + + // TODO: Can this case ever happen?? + // if ('BYTES_PER_ELEMENT' in DataView) { + // bytesPerElement = DataView.BYTES_PER_ELEMENT; + // } + + // Note: It can be implied that the byteOffset for an arrayBuffer is 0. + const dataEnd = bufferSource.byteLength / bytesPerElement; + const sourceStart = 0; + const start = Math.floor(Math.max(0, Math.min(sourceStart, dataEnd))); + const end = Math.floor(Math.min(start + Math.max(Infinity, 0), dataEnd)); + return new DataView(buffer, start, end - start); + } + + /** + * @param data + * @returns A hex string key ID + */ + private toHex(data: BufferSource): string { + const arrayBuffer = this.getArrayBuffer_(data); + const arr = new Uint8Array(arrayBuffer); + let hex = ''; + let stringValue; + for (let value of arr) { + stringValue = value.toString(16); + if (stringValue.length === 1) { + stringValue = '0' + stringValue; + } + hex += stringValue; + } + return hex; + } + + /** + * Get the array buffer even if it is inside the BufferSource. + * + * @param source + * @returns The array buffer + */ + private getArrayBuffer_(source: BufferSource): ArrayBuffer { + if (source instanceof ArrayBuffer) { + return source; + } else { + return source.buffer; + } + } + + /** + * @returns Whether or not all key sessions are loaded. + */ + private areAllSessionsLoaded_(): boolean { + this.activeSessions_.forEach((sessionMetadata) => { + if (!sessionMetadata.loaded) { + return false; + } + }) + + return true; } } diff --git a/packages/playback/src/lib/types/eme-manager.declarations.ts b/packages/playback/src/lib/types/eme-manager.declarations.ts index 0283e00..495b7c6 100644 --- a/packages/playback/src/lib/types/eme-manager.declarations.ts +++ b/packages/playback/src/lib/types/eme-manager.declarations.ts @@ -25,12 +25,14 @@ export interface IEmeApiAdapter { id: string; } -export interface IKeySystemConfiguration { - label?: string; - initDataTypes?: Array; - audioCapabilities?: Array; - videoCapabilities?: Array; - distinctiveIdentifier?: MediaKeysRequirement; - persistentState?: MediaKeysRequirement; - sessionTypes?: Array; +export interface IKeySessionMetadata { + loaded: boolean; + initData: Uint8Array; + initDataType?: string; + session: MediaKeySession; + type?: string; + // oldExpiration? - The expiration of the session on the last check. This is used to fire an event when it changes. + // updatePromise? - An optional Promise that will be resolved/rejected on the next update() + // call. This is used to track the 'license-release' message when calling + // remove(). } From dba8d737547f80e7b92a2f05575f99bb5f2877c8 Mon Sep 17 00:00:00 2001 From: wseymour Date: Thu, 13 Feb 2025 12:27:04 -0600 Subject: [PATCH 07/11] fix: errors, events, and lint fixes --- packages/playback/src/lib/consts/errors.ts | 12 + packages/playback/src/lib/consts/events.ts | 7 + packages/playback/src/lib/eme/eme-manager.ts | 376 ++++++++++-------- .../playback/src/lib/errors/eme-errors.ts | 120 ++++++ .../playback/src/lib/events/eme-events.ts | 42 ++ .../src/lib/player/base/base-player.ts | 11 +- .../src/lib/types/eme-manager.declarations.ts | 5 +- .../event-type-to-event-map.declarations.ts | 23 +- .../src/lib/types/source.declarations.ts | 2 +- 9 files changed, 416 insertions(+), 182 deletions(-) create mode 100644 packages/playback/src/lib/errors/eme-errors.ts diff --git a/packages/playback/src/lib/consts/errors.ts b/packages/playback/src/lib/consts/errors.ts index 4d8f6c0..6bd8b09 100644 --- a/packages/playback/src/lib/consts/errors.ts +++ b/packages/playback/src/lib/consts/errors.ts @@ -3,6 +3,7 @@ export enum ErrorCategory { Pipeline = 1, + Eme = 2, } // enums can be imported as types and as values, @@ -10,4 +11,15 @@ export enum ErrorCategory { export enum ErrorCode { NoSupportedPipelines = 1000, PipelineLoaderFailedToDeterminePipeline, + // EME Errors + EmeManagerMissing = 2000, + SourceNotSet, + SourceMissingKeySystems, + KeySessionClosed, + KeySessionCreateFailed, + InvalidServerCertificate, + LicenseResponseRejected, + LicenseRequestFailed, + MediaKeyCreateFailed, + MissingEmeSupport, } diff --git a/packages/playback/src/lib/consts/events.ts b/packages/playback/src/lib/consts/events.ts index 2c1f1a2..d2b0b2e 100644 --- a/packages/playback/src/lib/consts/events.ts +++ b/packages/playback/src/lib/consts/events.ts @@ -9,13 +9,20 @@ export enum PlayerEventType { CurrentTimeChanged = 'CurrentTimeChanged', MutedStatusChanged = 'MutedStatusChanged', PlaybackStateChanged = 'PlaybackStateChanged', + // EME Events Encrypted = 'Encrypted', WaitingForKey = 'WaitingForKey', + KeySessionCreated = 'KeySessionCreated', + KeySessionUpdated = 'KeySessionUpdated', + KeySessionClosed = 'KeySessionClosed', + KeySystemAccessRequested = 'KeySystemAccessRequested', Error = 'Error', + // Network Events NetworkRequestAttemptStarted = 'NetworkRequestAttemptStarted', NetworkRequestAttemptCompletedSuccessfully = 'NetworkRequestAttemptCompletedSuccessfully', NetworkRequestAttemptCompletedUnsuccessfully = 'NetworkRequestAttemptCompletedUnsuccessfully', NetworkRequestAttemptFailed = 'NetworkRequestAttemptFailed', + // Parse Events HlsPlaylistParsed = 'HlsPlaylistParsed', DashManifestParsed = 'DashManifestParsed', } diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index 0549f8e..965ffd4 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -1,22 +1,38 @@ -import type { - IEmeManager, - IEmeManagerDependencies, - IKeySessionMetadata -} from '../types/eme-manager.declarations'; +import type { IEmeManager, IEmeManagerDependencies, IKeySessionMetadata } from '../types/eme-manager.declarations'; import type { INetworkManager } from '../types/network.declarations'; import type { ILogger } from '../types/logger.declarations'; import type { IPlayerSource } from '../types/source.declarations'; -import { IEventEmitter } from '../types/event-emitter.declarations'; -import { PrivateEventTypeToEventMap } from '../types/mappers/event-type-to-event-map.declarations'; +import type { IEventEmitter } from '../types/event-emitter.declarations'; +import type { + EventTypeToEventMap, + PrivateEventTypeToEventMap, +} from '../types/mappers/event-type-to-event-map.declarations'; import { PlayerEventType } from '../consts/events'; import { RequestType } from '../consts/request-type'; - -const IS_EDGE = navigator.userAgent.indexOf("Edg") > -1 +import { + InvalidServerCertificateError, + KeySessionClosedError, + KeySessionCreateError, + LicenseRequestError, + LicenseResponseRejectedError, + MediaKeyCreateError, + MissingEmeSupportError, + SourceMissingKeySystemsError, + SourceNotSetError, +} from '../errors/eme-errors'; +import { ErrorEvent } from '../events/player-events'; +import { + KeySessionClosedEvent, + KeySessionCreatedEvent, + KeySessionUpdatedEvent, + KeySystemAccessRequestedEvent, +} from '../events/eme-events'; + +const IS_EDGE = navigator.userAgent.indexOf('Edg') > -1; /** * Eme Manager should be shipped as a separate bundle and included in the player as opt-in feature */ - export class EmeManager implements IEmeManager { private static areInitDataEqual_(a: ArrayBuffer, b: ArrayBuffer): boolean { if (a.byteLength !== b.byteLength) { @@ -38,6 +54,7 @@ export class EmeManager implements IEmeManager { protected readonly networkManager_: INetworkManager; protected readonly logger_: ILogger; + protected readonly eventEmitter_: IEventEmitter; protected readonly privateEventEmitter_: IEventEmitter; protected activeVideoElement_: HTMLVideoElement | null = null; @@ -45,13 +62,14 @@ export class EmeManager implements IEmeManager { protected activeMediaKeys_: MediaKeys | null = null; protected activeKeySystem_: string | null = null; protected activeKeySystemConfig_: MediaKeySystemConfiguration | null = null; - protected activeSessions_: Map = new Map(); - protected storedSessions_: Map = new Map(); - protected currentKeyStatuses_: Map = new Map(); + protected activeSessions_ = new Map(); + protected storedSessions_ = new Map(); + protected currentKeyStatuses_ = new Map(); public constructor(dependencies: IEmeManagerDependencies) { this.networkManager_ = dependencies.networkManager; this.logger_ = dependencies.logger; + this.eventEmitter_ = dependencies.eventEmitter; this.privateEventEmitter_ = dependencies.privateEventEmitter; } @@ -68,7 +86,7 @@ export class EmeManager implements IEmeManager { // Check if init data is already set!! if (!this.activeSource_) { - // return error that source is not set + this.eventEmitter_.emitEvent(new ErrorEvent(new SourceNotSetError(false))); return; } @@ -94,31 +112,33 @@ export class EmeManager implements IEmeManager { // Create a session and init a request // This kicks off the whole process of setting event listeners, which // is where the bulk of the logic occurs. - this.createKeySession_(new Uint8Array(data), type, this.activeKeySystemConfig_).then(() => { - // - }).catch(() => { - // error creating key session + this.createKeySession_(new Uint8Array(data), type).then(() => { + // key session was successfully created }); - }).catch(() => { - // error while selecting the key system }); - }).catch(() => { - // error while getting the key system access }); - - // TODO: implement handling of initData } public handleWaitingForKey(): void { + if (!this.activeVideoElement_) { + return; + } + + this.activeSessions_.forEach((sessionMetadata) => { + if (!sessionMetadata.loaded) { + // This means `status-pending` was set on any value in sessionMetadata.session.keyStatuses + } + }); + // TODO: check if we have pending request or init one if we have init data } public stop(): void { - for (const [id, session] of this.activeSessions_) { + for (const [id] of this.activeSessions_) { this.closeKeySession_(id); } - this.activeMediaKeys_= null; + this.activeMediaKeys_ = null; this.activeKeySystem_ = null; this.activeKeySystemConfig_ = null; this.activeSessions_.clear(); @@ -148,14 +168,8 @@ export class EmeManager implements IEmeManager { } private initEmeManager_(): void { - this.privateEventEmitter_.addEventListener( - PlayerEventType.HlsPlaylistParsed, - this.handleParsedManifestEvent_ - ); - this.privateEventEmitter_.addEventListener( - PlayerEventType.DashManifestParsed, - this.handleParsedManifestEvent_ - ) + this.privateEventEmitter_.addEventListener(PlayerEventType.HlsPlaylistParsed, this.handleParsedManifestEvent_); + this.privateEventEmitter_.addEventListener(PlayerEventType.DashManifestParsed, this.handleParsedManifestEvent_); } private getKeySystemConfig_(): Record { @@ -164,22 +178,27 @@ export class EmeManager implements IEmeManager { return { 'com.widevine.alpha': { - videoCapabilities: [{ - contentType: 'video/webm; codecs="vp9"', - robustness: 'SW_SECURE_CRYPTO' - }], - audioCapabilities: [{ - contentType: 'audio/webm; codecs="vorbis"', - robustness: 'SW_SECURE_CRYPTO' - }] - } + videoCapabilities: [ + { + contentType: 'video/webm; codecs="vp9"', + robustness: 'SW_SECURE_CRYPTO', + }, + ], + audioCapabilities: [ + { + contentType: 'audio/webm; codecs="vorbis"', + robustness: 'SW_SECURE_CRYPTO', + }, + ], + }, }; - } + /** + * Converts the parsed manifest data to keySystemConfig values. + */ private handleParsedManifestEvent_(): void { - let mediaKeySystemAccess = {} as MediaKeySystemAccess; - + // let mediaKeySystemAccess = {} as MediaKeySystemAccess; // TODO: update this function to take in parsed data and turn it into keySystemConfig values // We may need diffrent functions for DASH and HLS // We may want to call `setInitData` in here @@ -190,7 +209,6 @@ export class EmeManager implements IEmeManager { * the source allows. Once those are created, we request a MediaKeySystemAccess * using the aforementioned config. This method returns a promise containing * a MediaKeySystemAccess instance. - * * @returns A promise containing the MediaKeySystemAccess */ private async getKeySystemAccess_(): Promise { @@ -199,30 +217,36 @@ export class EmeManager implements IEmeManager { const keySystems = this.activeSource_?.keySystems; if (!keySystems) { - // TODO: thow error and ignore EME + this.eventEmitter_.emitEvent(new ErrorEvent(new SourceMissingKeySystemsError(false))); return mediaKeySystemAccess; } - // If `requestMediaKeySystemAccess` report an error - if (navigator.requestMediaKeySystemAccess === undefined || - typeof navigator.requestMediaKeySystemAccess !== 'function') { - // trigger error + // If `requestMediaKeySystemAccess` is missing report an error + if ( + navigator.requestMediaKeySystemAccess === undefined || + typeof navigator.requestMediaKeySystemAccess !== 'function' + ) { + this.eventEmitter_.emitEvent(new ErrorEvent(new MissingEmeSupportError(false))); return mediaKeySystemAccess; } - // TODO: Sort by priority before this + // TODO: Sort by priority before this for (const keySystem in keySystems) { const keySystemConfig = this.getKeySystemConfig_(); try { - // TODO: Create an event for request media key system - this.logger_.debug(); + this.eventEmitter_.emitEvent(new KeySystemAccessRequestedEvent(keySystem)); + this.logger_.debug('EME: Requesting media key system access.'); + + mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(keySystem, [keySystemConfig]); - const mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(keySystem, [keySystemConfig]); return mediaKeySystemAccess; } catch (error) { - // TODO: Warn about a failed request, but loop should continue. + // Warn about a failed request, but loop should continue. + this.logger_.warn( + `EME: Media key system access request failed. Key System: ${keySystem} Error: ${error as Error}` + ); } } @@ -231,8 +255,7 @@ export class EmeManager implements IEmeManager { /** * Creates media keys and adds them to the video element. - * - * @param keySystemAccess + * @param keySystemAccess The media key system access * @returns A promise containing the Key System name or null if no key system was valid. */ private async selectKeySystem_(keySystemAccess: MediaKeySystemAccess): Promise { @@ -243,37 +266,43 @@ export class EmeManager implements IEmeManager { this.activeKeySystemConfig_ = this.activeSource_?.keySystems[this.activeKeySystem_] || null; return this.activeKeySystem_; } - + return new Promise((resolve, reject) => { - keySystemAccess.createMediaKeys().then((mediaKeys) => { - this.activeKeySystem_ = keySystemAccess.keySystem; - this.activeMediaKeys_ = mediaKeys; - this.activeKeySystemConfig_ = this.activeSource_?.keySystems[this.activeKeySystem_] || null; - - if (this.activeVideoElement_) { - return this.activeVideoElement_.setMediaKeys(this.activeMediaKeys_); - } else { - this.logger_.warn(`WARNING: Attempting to set media keys on an invalid media element.`) - Promise.resolve(); - } - }).then(() => { - this.logger_.debug(`Successfully set media keys in the video element for ${this.activeKeySystem_}.`) - resolve(this.activeKeySystem_) - }).catch(function () { - reject(); - // error could not create media keys - }); - }) + keySystemAccess + .createMediaKeys() + .then((mediaKeys) => { + this.activeKeySystem_ = keySystemAccess.keySystem; + this.activeMediaKeys_ = mediaKeys; + this.activeKeySystemConfig_ = this.activeSource_?.keySystems[this.activeKeySystem_] || null; + + if (this.activeVideoElement_) { + return this.activeVideoElement_.setMediaKeys(this.activeMediaKeys_); + } else { + this.logger_.warn(`EME: Attempting to set media keys on an invalid media element.`); + Promise.resolve(); + } + }) + .then(() => { + this.logger_.debug(`Successfully set media keys in the video element for ${this.activeKeySystem_}.`); + resolve(this.activeKeySystem_); + }) + .catch((error) => { + this.eventEmitter_.emitEvent(new ErrorEvent(new MediaKeyCreateError(false, error))); + reject(); + }); + }); } /** * Creates a key session on the active media keys. - * - * @param keySystemConfig - * @returns an empty promise + * @param initData The init data to generate a license request + * @param initDataType The init data format + * @returns An empty promise */ - private async createKeySession_(initData: Uint8Array, initDataType: string, keySystemConfig: MediaKeySystemConfiguration): Promise { - if (!this.activeKeySystem_ || !this.activeMediaKeys_){ + private async createKeySession_(initData: Uint8Array, initDataType: string): Promise { + // TODO: Do we need keySystemConfig in this function? + + if (!this.activeKeySystem_ || !this.activeMediaKeys_) { // error return; } @@ -290,8 +319,8 @@ export class EmeManager implements IEmeManager { try { // Pass in the session type from the source if it exists. mediaKeySession = this.activeMediaKeys_.createSession(sessionType || undefined); - } catch { - // TODO: ERROR: Failed to create session + } catch (error) { + this.eventEmitter_.emitEvent(new ErrorEvent(new KeySessionCreateError(false, error as Error))); return; } @@ -310,7 +339,7 @@ export class EmeManager implements IEmeManager { }); // Received new init data/type - + // Do we need this?? Can we just store the initData in the array of activeSessions? // this.logger_.debug( // `Updating init data: previous(${type}, length: ${data.byteLength}) --> new(${type}, length: ${data.byteLength})` @@ -319,15 +348,16 @@ export class EmeManager implements IEmeManager { const sessionId = mediaKeySession.sessionId; - mediaKeySession.addEventListener('keystatuseschange', (event) => this.onKeyStatusesChange_(event as ExtendableEvent)); + mediaKeySession.addEventListener('keystatuseschange', (event) => + this.onKeyStatusesChange_(event as ExtendableEvent) + ); mediaKeySession.addEventListener('message', (event) => this.onSessionMessage_(event)); // Register callback for session closed Promise mediaKeySession.closed.then(() => { this.removeSession_(sessionId); this.logger_.debug('EME Key Session closed. sessionId: ' + sessionId); - // TODO: KEY_SESSION_CLOSED event - // eventBus.trigger(events.KEY_SESSION_CLOSED, { data: token.getSessionId() }); + this.eventEmitter_.emitEvent(new KeySessionClosedEvent(sessionId)); }); const metadata = { @@ -335,31 +365,26 @@ export class EmeManager implements IEmeManager { initDataType, loaded: false, type: sessionType, - session: mediaKeySession + session: mediaKeySession, }; this.activeSessions_.set(mediaKeySession.sessionId, metadata); - mediaKeySession.generateRequest(initDataType, initData) + mediaKeySession + .generateRequest(initDataType, initData) .then(() => { - this.logger_.debug('DRM: Session created. SessionID = ' + sessionId); - // TODO: KEY SESSION created event - // eventBus.trigger(events.KEY_SESSION_CREATED, { data: sessionToken }); + this.logger_.debug('EME: Session created. SessionID: ' + sessionId); + this.eventEmitter_.emitEvent(new KeySessionCreatedEvent(sessionId)); }) .catch((error) => { this.removeSession_(sessionId); - // TODO: ERROR: KEY_SESSION_CREATED_FAILED - // eventBus.trigger(events.KEY_SESSION_CREATED, { - // data: null, - // error: new DashJSError(ProtectionErrors.KEY_SESSION_CREATED_ERROR_CODE, ProtectionErrors.KEY_SESSION_CREATED_ERROR_MESSAGE + 'Error generating key request -- ' + error.name) - // }); + this.eventEmitter_.emitEvent(new ErrorEvent(new KeySessionCreateError(false, error as Error))); }); } /** * Sets the server certificate on the active media keys. - * - * @param certificate + * @param certificate The server certificate * @returns an empty promise. */ private async setServerCertificate_(certificate: Uint8Array): Promise { @@ -381,16 +406,14 @@ export class EmeManager implements IEmeManager { } return; - } catch(exception) { - // TODO: throw error for invalid server certificate + } catch (error) { + this.eventEmitter_.emitEvent(new ErrorEvent(new InvalidServerCertificateError(false, error as Error))); } } /** * Event to handle key status changes event on session. - * - * @param event - * @returns + * @param event The event containing the media key session */ private onKeyStatusesChange_(event: ExtendableEvent): void { const session = event.target as MediaKeySession; @@ -414,9 +437,12 @@ export class EmeManager implements IEmeManager { // NOTE: Skip if byteLength != 16. // Edge uses single-byte dummy key IDs. Tizen doesn't have this problem. - // TODO: Do we want to check if it is PS4? - if (this.activeKeySystem_ && this.isPlayReadyKeySystem_(this.activeKeySystem_) && - keyId.byteLength === 16 && IS_EDGE) { + if ( + this.activeKeySystem_ && + this.isPlayReadyKeySystem_(this.activeKeySystem_) && + keyId.byteLength === 16 && + IS_EDGE + ) { // Get little-endian values: const dataView = this.toDataView_(keyId); const le0 = dataView.getUint32(0, true); @@ -428,9 +454,13 @@ export class EmeManager implements IEmeManager { dataView.setUint16(6, le2, false); } + const keyIdHexString = this.toHex_(keyId); + if (!activeSession) { if (status === 'usable') { - this.logger_.warn(`A usable key was found on a closed session. Session ID: ${session.sessionId} Key ID: ${keyId}`); + this.logger_.warn( + `A usable key was found on a closed session. Session ID: ${session.sessionId} Key ID: ${keyIdHexString}` + ); } return; } @@ -443,8 +473,6 @@ export class EmeManager implements IEmeManager { hasExpiredKeys = true; } - const keyIdHexString = this.toHex(keyId); - this.currentKeyStatuses_.set(keyIdHexString, status); // TODO: How do we want ot use these stored keys? We probably need to handle the ones with status-pending @@ -469,13 +497,15 @@ export class EmeManager implements IEmeManager { } /** - * @param {!MediaKeyMessageEvent} event - * @private + * On a message event, we get the key session from the event and update the session + * with the license that was requested, + * @param event The media key message event + * @returns An empty promise */ private async onSessionMessage_(event: MediaKeyMessageEvent): Promise { const session = event.target as MediaKeySession; - if(!session) { + if (!session) { this.logger_.warn('EME: A message event was received but it did not contain the session.'); return; } @@ -488,7 +518,8 @@ export class EmeManager implements IEmeManager { this.logger_.debug(`Sending license request for session ${session.sessionId} of type ${event.messageType}`); let licenseServerUri = this.activeSource_?.keySystems[this.activeKeySystem_ as string].licenseServerUri; - let individualizationSever = this.activeSource_?.keySystems[this.activeKeySystem_ as string].individualizationServerUri; + const individualizationSever = + this.activeSource_?.keySystems[this.activeKeySystem_ as string].individualizationServerUri; if (event.messageType === 'individualization-request' && individualizationSever) { this.logger_.debug(`Using individualization server for license request: ${individualizationSever}`); @@ -496,36 +527,38 @@ export class EmeManager implements IEmeManager { } // TODO: We may want to add things to the request (sessionId, drmInfo, messageType, etc.) - let message = ArrayBuffer.isView(event.message) ? event.message.buffer : event.message; + const message = ArrayBuffer.isView(event.message) ? event.message.buffer : event.message; const payload = { url: licenseServerUri as unknown as URL, - mapper: (body: Uint8Array) => message, - requestType: RequestType.License - } + mapper: (): ArrayBufferLike => message, + requestType: RequestType.License, + }; const licenseRequest = this.networkManager_.post(payload); - licenseRequest.done.then((response) => { - try { - // TODO: Handle this for different DRM scenarios - // TODO: Do we want to log this response in debug mode? - session.update(response); - } catch { - // TODO: Error that the license response was rejected. - } - }).catch(() => { - // TODO: Error that license request failed - }); + licenseRequest.done + .then((response) => { + try { + // TODO: Handle this for different DRM scenarios + session.update(response); + } catch (error) { + this.eventEmitter_.emitEvent(new ErrorEvent(new LicenseResponseRejectedError(false, error as Error))); + } + }) + .catch((error) => { + this.eventEmitter_.emitEvent(new ErrorEvent(new LicenseRequestError(false, error))); + }); - // TODO: INTERNAL_KEY_MESSAGE Internal event that says we updated the session with the new license? - // { data: new KeyMessage(this, message, undefined, event.messageType) }); + this.logger_.debug( + `EME: Key session updated with new license. SessionID: ${session.sessionId} MessageType: ${event.messageType}` + ); + this.privateEventEmitter_.emitEvent(new KeySessionUpdatedEvent(session.sessionId, event.messageType)); } /** * Removes the selected session from the list of active sessions. - * - * @param sessionId + * @param sessionId Key session ID */ private removeSession_(sessionId: string): void { this.activeSessions_.delete(sessionId); @@ -533,8 +566,7 @@ export class EmeManager implements IEmeManager { /** * Closes the chosen media key session and removes all listeners. - * - * @param sessionId + * @param sessionId Key session ID */ private async closeKeySession_(sessionId: string): Promise { if (!sessionId) { @@ -544,46 +576,48 @@ export class EmeManager implements IEmeManager { // Send our request to the key session const activeSession = this.activeSessions_.get(sessionId)?.session; - if(!activeSession) { + if (!activeSession) { return Promise.resolve(); } // Remove event listeners - activeSession.removeEventListener('keystatuseschange', (event) => this.onKeyStatusesChange_(event as ExtendableEvent)); + activeSession.removeEventListener('keystatuseschange', (event) => + this.onKeyStatusesChange_(event as ExtendableEvent) + ); activeSession.removeEventListener('message', (event) => this.onSessionMessage_(event)); // Send our request to the key session - return activeSession.close().then(() => { - this.logger_.debug(`Key session sucessfully closed. Session ID: ${sessionId}`) - }).catch(() => { - this.removeSession_(sessionId); - // TODO: KEYSESSIONCLOSED error - // error: 'Error closing session (' + sessionToken.getSessionId() + ') ' + error.name - }); + return activeSession + .close() + .then(() => { + this.logger_.debug(`Key session sucessfully closed. Session ID: ${sessionId}`); + }) + .catch((error) => { + this.removeSession_(sessionId); + this.eventEmitter_.emitEvent(new ErrorEvent(new KeySessionClosedError(false, sessionId, error))); + }); } /** * A helper function that returns a list of all init data currently active. - * * @returns A list of init data for all active sessions. */ private getAllInitData_(): Array { const initDataArray: Array = []; - for (const [id, session] of this.activeSessions_) { - if (session.initData) { - initDataArray.push(session.initData); - } + for (const session of this.activeSessions_.values()) { + if (session.initData) { + initDataArray.push(session.initData); + } } return initDataArray; } /** - * A helper method to determine if the keySystem is PlayReady - * - * @param {string} keySystem - * @return {boolean} + * A helper method to determine if the keySystem is PlayReady + * @param keySystem The key system string + * @returns Whether the key system is PlayReady */ - private isPlayReadyKeySystem_(keySystem: string) { + private isPlayReadyKeySystem_(keySystem: string): boolean { if (keySystem) { return !!keySystem.match(/^com\.(microsoft|chromecast)\.playready/); } @@ -592,10 +626,8 @@ export class EmeManager implements IEmeManager { } /** - * A helper method to determine if the keySystem is ClearKey - * - * @param {string} keySystem - * @return {boolean} + * @param keySystem The key system type + * @returns Whether the keySystem is ClearKey */ private isClearKeySystem_(keySystem: string): boolean { return keySystem === 'org.w3.clearkey'; @@ -604,13 +636,12 @@ export class EmeManager implements IEmeManager { /** * Convert a buffer to a DataView type for additional utilities to deal with * different different array types. - * - * @param bufferSource - * @returns + * @param bufferSource The buffer containing key data + * @returns The data view of the key data */ private toDataView_(bufferSource: BufferSource): DataView { const buffer = this.getArrayBuffer_(bufferSource); - let bytesPerElement = 1; + const bytesPerElement = 1; // TODO: Can this case ever happen?? // if ('BYTES_PER_ELEMENT' in DataView) { @@ -626,15 +657,15 @@ export class EmeManager implements IEmeManager { } /** - * @param data + * @param data A buffer source * @returns A hex string key ID */ - private toHex(data: BufferSource): string { + private toHex_(data: BufferSource): string { const arrayBuffer = this.getArrayBuffer_(data); const arr = new Uint8Array(arrayBuffer); let hex = ''; let stringValue; - for (let value of arr) { + for (const value of arr) { stringValue = value.toString(16); if (stringValue.length === 1) { stringValue = '0' + stringValue; @@ -646,15 +677,14 @@ export class EmeManager implements IEmeManager { /** * Get the array buffer even if it is inside the BufferSource. - * - * @param source + * @param source The buffer source * @returns The array buffer */ private getArrayBuffer_(source: BufferSource): ArrayBuffer { if (source instanceof ArrayBuffer) { - return source; + return source; } else { - return source.buffer; + return source.buffer; } } @@ -666,7 +696,7 @@ export class EmeManager implements IEmeManager { if (!sessionMetadata.loaded) { return false; } - }) + }); return true; } diff --git a/packages/playback/src/lib/errors/eme-errors.ts b/packages/playback/src/lib/errors/eme-errors.ts new file mode 100644 index 0000000..737f808 --- /dev/null +++ b/packages/playback/src/lib/errors/eme-errors.ts @@ -0,0 +1,120 @@ +import { PlayerError } from './base-player-errors'; +import { ErrorCategory, ErrorCode } from '../consts/errors'; + +abstract class EmeError extends PlayerError { + public readonly category = ErrorCategory.Eme; +} + +export class SourceNotSetError extends EmeError { + public readonly code = ErrorCode.SourceNotSet; + public readonly isFatal: boolean; + + public constructor(isFatal: boolean) { + super(); + this.isFatal = isFatal; + } +} + +export class SourceMissingKeySystemsError extends EmeError { + public readonly code = ErrorCode.SourceMissingKeySystems; + public readonly isFatal: boolean; + + public constructor(isFatal: boolean) { + super(); + this.isFatal = isFatal; + } +} + +export class KeySessionClosedError extends EmeError { + public readonly code = ErrorCode.KeySessionClosed; + public readonly isFatal: boolean; + public readonly sessionId: string; + public readonly reason: Error; + + public constructor(isFatal: boolean, sessionId: string, reason: Error) { + super(); + this.isFatal = isFatal; + this.sessionId = sessionId; + this.reason = reason; + } +} + +export class KeySessionCreateError extends EmeError { + public readonly code = ErrorCode.KeySessionCreateFailed; + public readonly isFatal: boolean; + public readonly reason: Error; + + public constructor(isFatal: boolean, reason: Error) { + super(); + this.isFatal = isFatal; + this.reason = reason; + } +} + +export class InvalidServerCertificateError extends EmeError { + public readonly code = ErrorCode.InvalidServerCertificate; + public readonly isFatal: boolean; + public readonly reason: Error; + + public constructor(isFatal: boolean, reason: Error) { + super(); + this.isFatal = isFatal; + this.reason = reason; + } +} + +export class LicenseResponseRejectedError extends EmeError { + public readonly code = ErrorCode.LicenseResponseRejected; + public readonly isFatal: boolean; + public readonly reason: Error; + + public constructor(isFatal: boolean, reason: Error) { + super(); + this.isFatal = isFatal; + this.reason = reason; + } +} + +export class LicenseRequestError extends EmeError { + public readonly code = ErrorCode.LicenseRequestFailed; + public readonly isFatal: boolean; + public readonly reason: Error; + + public constructor(isFatal: boolean, reason: Error) { + super(); + this.isFatal = isFatal; + this.reason = reason; + } +} + +export class MediaKeyCreateError extends EmeError { + public readonly code = ErrorCode.MediaKeyCreateFailed; + public readonly isFatal: boolean; + public readonly reason: Error; + + public constructor(isFatal: boolean, reason: Error) { + super(); + this.isFatal = isFatal; + this.reason = reason; + } +} + +export class EmeManagerMissingError extends EmeError { + public readonly code = ErrorCode.EmeManagerMissing; + public readonly isFatal: boolean; + + public constructor(isFatal: boolean) { + super(); + this.isFatal = isFatal; + } +} + +export class MissingEmeSupportError extends EmeError { + public readonly code = ErrorCode.MissingEmeSupport; + public readonly isFatal: boolean; + + public constructor(isFatal: boolean) { + super(); + this.isFatal = isFatal; + } +} diff --git a/packages/playback/src/lib/events/eme-events.ts b/packages/playback/src/lib/events/eme-events.ts index a762640..6e312f6 100644 --- a/packages/playback/src/lib/events/eme-events.ts +++ b/packages/playback/src/lib/events/eme-events.ts @@ -16,3 +16,45 @@ export class EncryptedEvent extends PlayerEvent { export class WaitingForKeyEvent extends PlayerEvent { public readonly type = PlayerEventType.WaitingForKey; } + +export class KeySessionCreatedEvent extends PlayerEvent { + public readonly type = PlayerEventType.KeySessionCreated; + public readonly sessionId: string; + + public constructor(sessionId: string) { + super(); + this.sessionId = sessionId; + } +} + +export class KeySessionUpdatedEvent extends PlayerEvent { + public readonly type = PlayerEventType.KeySessionUpdated; + public readonly sessionId: string; + public readonly messageType: string; + + public constructor(sessionId: string, messageType: string) { + super(); + this.sessionId = sessionId; + this.messageType = messageType; + } +} + +export class KeySystemAccessRequestedEvent extends PlayerEvent { + public readonly type = PlayerEventType.KeySystemAccessRequested; + public readonly keySystem: string; + + public constructor(keySystem: string) { + super(); + this.keySystem = keySystem; + } +} + +export class KeySessionClosedEvent extends PlayerEvent { + public readonly type = PlayerEventType.KeySessionClosed; + public readonly sessionId: string; + + public constructor(sessionId: string) { + super(); + this.sessionId = sessionId; + } +} diff --git a/packages/playback/src/lib/player/base/base-player.ts b/packages/playback/src/lib/player/base/base-player.ts index 4362d5b..25c8668 100644 --- a/packages/playback/src/lib/player/base/base-player.ts +++ b/packages/playback/src/lib/player/base/base-player.ts @@ -9,7 +9,10 @@ import type { PlayerConfiguration } from '../../types/configuration.declarations import type { DeepPartial } from '../../types/utility.declarations'; import type { IStore } from '../../types/store.declarations'; import type { EventListener, IEventEmitter } from '../../types/event-emitter.declarations'; -import type { EventTypeToEventMap, PrivateEventTypeToEventMap } from '../../types/mappers/event-type-to-event-map.declarations'; +import type { + EventTypeToEventMap, + PrivateEventTypeToEventMap, +} from '../../types/mappers/event-type-to-event-map.declarations'; // events import { ConfigurationChangedEvent, @@ -49,6 +52,7 @@ import { import type { IEmeManager, IEmeManagerDependencies, IEmeApiAdapter } from '../../types/eme-manager.declarations'; import { EncryptedEvent, WaitingForKeyEvent } from '../../events/eme-events'; import type { PipelineLoaderFactoryStorage } from './pipeline-loader-factory-storage'; +import { EmeManagerMissingError } from 'src/lib/errors/eme-errors'; declare const __COMMIT_HASH: string; declare const __VERSION: string; @@ -195,6 +199,7 @@ export abstract class BasePlayer { this.emeManager_ = factory({ logger: this.logger_.createSubLogger('EmeManager'), networkManager: this.networkManager_, + eventEmitter: this.eventEmitter_, privateEventEmitter: this.privateEventEmitter_, }); @@ -594,7 +599,7 @@ export abstract class BasePlayer { this.logger_.debug('received encrypted event', event); if (!this.emeManager_) { - // TODO: stop and emit error + this.eventEmitter_.emitEvent(new ErrorEvent(new EmeManagerMissingError(true))); return; } @@ -610,7 +615,7 @@ export abstract class BasePlayer { this.logger_.debug('received "waitingforkey" event'); if (!this.emeManager_) { - // TODO: stop and emit error + this.eventEmitter_.emitEvent(new ErrorEvent(new EmeManagerMissingError(true))); return; } diff --git a/packages/playback/src/lib/types/eme-manager.declarations.ts b/packages/playback/src/lib/types/eme-manager.declarations.ts index 495b7c6..3233ed6 100644 --- a/packages/playback/src/lib/types/eme-manager.declarations.ts +++ b/packages/playback/src/lib/types/eme-manager.declarations.ts @@ -1,12 +1,13 @@ import type { INetworkManager } from './network.declarations'; import type { ILogger } from './logger.declarations'; import type { IPlayerSource } from './source.declarations'; -import { IEventEmitter } from './event-emitter.declarations'; -import { PrivateEventTypeToEventMap } from './mappers/event-type-to-event-map.declarations'; +import type { IEventEmitter } from './event-emitter.declarations'; +import type { PrivateEventTypeToEventMap, EventTypeToEventMap } from './mappers/event-type-to-event-map.declarations'; export interface IEmeManagerDependencies { networkManager: INetworkManager; logger: ILogger; + eventEmitter: IEventEmitter; privateEventEmitter: IEventEmitter; } diff --git a/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts b/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts index d0938ca..2b0a41c 100644 --- a/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts +++ b/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts @@ -17,7 +17,14 @@ import type { NetworkRequestAttemptStartedEvent, } from '../../events/network-events'; import type { PlayerEvent } from '../../events/base-player-event'; -import type { EncryptedEvent, WaitingForKeyEvent } from '../../events/eme-events'; +import type { + EncryptedEvent, + KeySessionClosedEvent, + KeySessionCreatedEvent, + KeySessionUpdatedEvent, + KeySystemAccessRequestedEvent, + WaitingForKeyEvent, +} from '../../events/eme-events'; export interface NetworkEventMap { [PlayerEventType.NetworkRequestAttemptStarted]: NetworkRequestAttemptStartedEvent; @@ -45,6 +52,16 @@ export interface ParseEventMap { [PlayerEventType.DashManifestParsed]: VolumeChangedEvent; } -export type EventTypeToEventMap = NetworkEventMap & PlayerEventMap; +export interface EmeEventMap { + [PlayerEventType.KeySessionCreated]: KeySessionCreatedEvent; + [PlayerEventType.KeySystemAccessRequested]: KeySystemAccessRequestedEvent; + [PlayerEventType.KeySessionClosed]: KeySessionClosedEvent; +} + +export interface EmePrivateEventMap { + [PlayerEventType.KeySessionUpdated]: KeySessionUpdatedEvent; +} + +export type EventTypeToEventMap = NetworkEventMap & PlayerEventMap & EmeEventMap; -export type PrivateEventTypeToEventMap = ParseEventMap; +export type PrivateEventTypeToEventMap = ParseEventMap & EmePrivateEventMap; diff --git a/packages/playback/src/lib/types/source.declarations.ts b/packages/playback/src/lib/types/source.declarations.ts index 86ef3d6..715c0ba 100644 --- a/packages/playback/src/lib/types/source.declarations.ts +++ b/packages/playback/src/lib/types/source.declarations.ts @@ -40,7 +40,7 @@ export interface ILoadSource { export interface ILoadRemoteSource extends ILoadSource { /** * Popular use-cases: (http:|https:|data:|blob:) all should work fine with fetch - * Potentially, could be any other protocols, so custom network manager should be provide by the client + * Potentially, could be any other protocols, so custom network manager should be provided by the client */ readonly url: URL; } From 80a3fa012b7de62d2f8b65f7694cc130dadc2a42 Mon Sep 17 00:00:00 2001 From: wseymour Date: Thu, 13 Feb 2025 12:31:04 -0600 Subject: [PATCH 08/11] fix: correct import in player base --- .../playback/src/lib/player/base/base-player.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/playback/src/lib/player/base/base-player.ts b/packages/playback/src/lib/player/base/base-player.ts index 25c8668..7d1be55 100644 --- a/packages/playback/src/lib/player/base/base-player.ts +++ b/packages/playback/src/lib/player/base/base-player.ts @@ -24,10 +24,18 @@ import { VolumeChangedEvent, PlaybackStateChangedEvent, } from '../../events/player-events'; +import { EncryptedEvent, WaitingForKeyEvent } from '../../events/eme-events'; +import { + NetworkRequestAttemptCompletedSuccessfullyEvent, + NetworkRequestAttemptCompletedUnsuccessfullyEvent, + NetworkRequestAttemptFailedEvent, + NetworkRequestAttemptStartedEvent, +} from '../../events/network-events'; // models import { PlayerSource } from '../../models/player-source'; // errors import { NoSupportedPipelineError, PipelineLoaderFailedToDeterminePipelineError } from '../../errors/pipeline-errors'; +import { EmeManagerMissingError } from '../../errors/eme-errors'; // pipelines import { InterceptorType } from '../../consts/interceptor-type'; import type { InterceptorTypeToInterceptorPayloadMap } from '../../types/mappers/interceptor-type-to-interceptor-map.declarations'; @@ -43,16 +51,8 @@ import type { IPlayerTextTrack } from '../../types/text-track.declarations'; import { NativePipeline } from '../../pipelines/native/native-pipeline'; import type { IPipeline, IPipelineLoader, IPipelineLoaderFactory } from '../../types/pipeline.declarations'; import type { INetworkManager, INetworkRequestInfo, INetworkResponseInfo } from '../../types/network.declarations'; -import { - NetworkRequestAttemptCompletedSuccessfullyEvent, - NetworkRequestAttemptCompletedUnsuccessfullyEvent, - NetworkRequestAttemptFailedEvent, - NetworkRequestAttemptStartedEvent, -} from '../../events/network-events'; import type { IEmeManager, IEmeManagerDependencies, IEmeApiAdapter } from '../../types/eme-manager.declarations'; -import { EncryptedEvent, WaitingForKeyEvent } from '../../events/eme-events'; import type { PipelineLoaderFactoryStorage } from './pipeline-loader-factory-storage'; -import { EmeManagerMissingError } from 'src/lib/errors/eme-errors'; declare const __COMMIT_HASH: string; declare const __VERSION: string; From 49d1ffaf1bf01eea330bb73ba5eb920b947aa746 Mon Sep 17 00:00:00 2001 From: wseymour Date: Fri, 21 Feb 2025 12:01:19 -0600 Subject: [PATCH 09/11] feat: utilities and initDataTransform and other minor changes --- packages/playback/src/lib/consts/errors.ts | 1 + packages/playback/src/lib/consts/events.ts | 1 + packages/playback/src/lib/eme/buffer-utils.ts | 139 +++++++++ packages/playback/src/lib/eme/eme-manager.ts | 272 ++++++++++-------- packages/playback/src/lib/eme/eme-utils.ts | 33 +++ packages/playback/src/lib/eme/string-utils.ts | 220 ++++++++++++++ .../playback/src/lib/errors/eme-errors.ts | 10 + .../playback/src/lib/events/eme-events.ts | 10 + .../src/lib/player/base/base-player.ts | 25 +- .../src/lib/types/eme-manager.declarations.ts | 2 + .../event-type-to-event-map.declarations.ts | 2 + .../src/lib/types/source.declarations.ts | 9 +- 12 files changed, 599 insertions(+), 125 deletions(-) create mode 100644 packages/playback/src/lib/eme/buffer-utils.ts create mode 100644 packages/playback/src/lib/eme/eme-utils.ts create mode 100644 packages/playback/src/lib/eme/string-utils.ts diff --git a/packages/playback/src/lib/consts/errors.ts b/packages/playback/src/lib/consts/errors.ts index 6bd8b09..d2cc612 100644 --- a/packages/playback/src/lib/consts/errors.ts +++ b/packages/playback/src/lib/consts/errors.ts @@ -22,4 +22,5 @@ export enum ErrorCode { LicenseRequestFailed, MediaKeyCreateFailed, MissingEmeSupport, + MissingServerCertificate, } diff --git a/packages/playback/src/lib/consts/events.ts b/packages/playback/src/lib/consts/events.ts index d2b0b2e..c7a551c 100644 --- a/packages/playback/src/lib/consts/events.ts +++ b/packages/playback/src/lib/consts/events.ts @@ -16,6 +16,7 @@ export enum PlayerEventType { KeySessionUpdated = 'KeySessionUpdated', KeySessionClosed = 'KeySessionClosed', KeySystemAccessRequested = 'KeySystemAccessRequested', + KeyStatusesUpdated = 'KeyStatusesUpdated', Error = 'Error', // Network Events NetworkRequestAttemptStarted = 'NetworkRequestAttemptStarted', diff --git a/packages/playback/src/lib/eme/buffer-utils.ts b/packages/playback/src/lib/eme/buffer-utils.ts new file mode 100644 index 0000000..df15ea8 --- /dev/null +++ b/packages/playback/src/lib/eme/buffer-utils.ts @@ -0,0 +1,139 @@ +/** + * @param a First buffer for comparison + * @param b Second buffer for comparison + * @returns Whether or not the buffers are equal + */ +export const areBuffersEqual = (a: ArrayBuffer, b: ArrayBuffer): boolean => { + if (a.byteLength !== b.byteLength) { + return false; + } + + const dataA = new Uint8Array(a); + const dataB = new Uint8Array(b); + const l = dataA.length; + + for (let i = 0; i < l; i++) { + if (dataA[i] !== dataB[i]) { + return false; + } + } + + return true; +}; + +/** + * @param data A buffer source + * @returns A hex string key ID + */ +export const toHex = (data: BufferSource): string => { + const arrayBuffer = getArrayBuffer(data); + const arr = new Uint8Array(arrayBuffer); + let hex = ''; + let stringValue; + for (const value of arr) { + stringValue = value.toString(16); + if (stringValue.length === 1) { + stringValue = '0' + stringValue; + } + hex += stringValue; + } + return hex; +}; + +/** + * Get the array buffer even if it is inside the BufferSource. + * @param source The buffer source + * @returns The array buffer + */ +export const getArrayBuffer = (source: BufferSource): ArrayBuffer => { + if (source instanceof ArrayBuffer) { + return source; + } else { + return source.buffer; + } +}; + +/** + * Gets an ArrayBuffer that contains the data from the given TypedArray. Note + * this will allocate a new ArrayBuffer if the object is a partial view of + * the data. + * @param buffer The buffer source + * @returns An array buffer + */ +export const toArrayBuffer = (buffer: BufferSource): ArrayBuffer => { + if (!ArrayBuffer.isView(buffer)) { + return buffer; + } else { + const arrayView = buffer as ArrayBufferView; + if (arrayView.byteOffset == 0 && arrayView.byteLength == arrayView.buffer.byteLength) { + // TypedArray for the buffer. + return arrayView.buffer; + } + // View on the buffer. Create a new buffer that only contains + // the data. Note that since this isn't an ArrayBuffer, the "new" call + // will allocate a new buffer to hold the copy. + return new Uint8Array(arrayView as unknown as ArrayBufferLike).buffer; + } +}; + +/** + * @param data Buffer source data + * @param offset Offest of the data + * @param length Length of the data + * @param type The expected type of typed array + * @returns The typed array based on the data and expected type of array. + */ +export const bufferSourceToTypedArray = ( + data: BufferSource, + offset: number, + length: number, + type: string +): ArrayBuffer | DataView | null => { + const buffer = getArrayBuffer(data); + const bytesPerElement = 1; + // Note: It can be implied that the byteOffset for an arrayBuffer is 0. + const dataEnd = data.byteLength / bytesPerElement; + const rawStart = offset / bytesPerElement; + const start = Math.floor(Math.max(0, Math.min(rawStart, dataEnd))); + const end = Math.floor(Math.min(start + Math.max(length, 0), dataEnd)); + + if (type === 'DataView') { + return new DataView(buffer, start, end - start); + } else if (type === 'Uint16Array') { + return new Uint8Array(buffer, start, end - start); + } else if (type === 'Uint8Array') { + return new Uint16Array(buffer, start, end - start); + } else { + return null; + } +}; + +/** + * @param buffer The data buffer + * @param offset Offset for the buffer + * @param length Length for the buffer + * @returns A Uint8Array from the buffer + */ +export const toUint8 = (buffer: BufferSource, offset = 0, length = Infinity): Uint8Array => { + return bufferSourceToTypedArray(buffer, offset, length, 'Uint8Array') as Uint8Array; +}; + +/** + * @param buffer The data buffer + * @param offset Offset for the buffer + * @param length Length for the buffer + * @returns A Uint16Array from the buffer + */ +export const toUint16 = (buffer: BufferSource, offset = 0, length = Infinity): Uint16Array => { + return bufferSourceToTypedArray(buffer, offset, length, 'Uint16Array') as Uint16Array; +}; + +/** + * @param buffer The data buffer + * @param offset Offset for the buffer + * @param length Length for the buffer + * @returns A DataView for the buffer + */ +export const toDataView = (buffer: BufferSource, offset = 0, length = Infinity): DataView => { + return bufferSourceToTypedArray(buffer, offset, length, 'DataView') as DataView; +}; diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index 965ffd4..4866e58 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -17,6 +17,7 @@ import { LicenseResponseRejectedError, MediaKeyCreateError, MissingEmeSupportError, + MissingServerCertificateError, SourceMissingKeySystemsError, SourceNotSetError, } from '../errors/eme-errors'; @@ -25,37 +26,27 @@ import { KeySessionClosedEvent, KeySessionCreatedEvent, KeySessionUpdatedEvent, + KeyStatusesUpdatedEvent, KeySystemAccessRequestedEvent, } from '../events/eme-events'; +import type { PlayerEmeConfiguration } from '../types/configuration.declarations'; +import { bufferToString, toUTF16 } from './string-utils'; +import { toHex, areBuffersEqual, toUint8, toDataView } from './buffer-utils'; +import { isFairPlayKeySystem, isPlayReadyKeySystem } from './eme-utils'; const IS_EDGE = navigator.userAgent.indexOf('Edg') > -1; +// Minimum HDCP versions will exist in the manifests + /** * Eme Manager should be shipped as a separate bundle and included in the player as opt-in feature */ export class EmeManager implements IEmeManager { - private static areInitDataEqual_(a: ArrayBuffer, b: ArrayBuffer): boolean { - if (a.byteLength !== b.byteLength) { - return false; - } - - const dataA = new Uint8Array(a); - const dataB = new Uint8Array(b); - const l = dataA.length; - - for (let i = 0; i < l; i++) { - if (dataA[i] !== dataB[i]) { - return false; - } - } - - return true; - } - protected readonly networkManager_: INetworkManager; protected readonly logger_: ILogger; protected readonly eventEmitter_: IEventEmitter; protected readonly privateEventEmitter_: IEventEmitter; + protected readonly configuration_: PlayerEmeConfiguration; protected activeVideoElement_: HTMLVideoElement | null = null; protected activeSource_: IPlayerSource | null = null; @@ -71,6 +62,7 @@ export class EmeManager implements IEmeManager { this.logger_ = dependencies.logger; this.eventEmitter_ = dependencies.eventEmitter; this.privateEventEmitter_ = dependencies.privateEventEmitter; + this.configuration_ = dependencies.configuration; } public setSource(source: IPlayerSource): void { @@ -103,6 +95,8 @@ export class EmeManager implements IEmeManager { const activeKeySystemConfig = this.activeSource_?.keySystems[this.activeKeySystem_ as string]; const activeKeySystemCertificate = activeKeySystemConfig?.serverCertificate; + // TODO: handle all configs in activeKeySystemConfig + if (activeKeySystemCertificate) { // TODO: use then()?? // TODO: handle making a request for the server certificate. @@ -190,6 +184,7 @@ export class EmeManager implements IEmeManager { robustness: 'SW_SECURE_CRYPTO', }, ], + sessionTypes: ['persistent-license'], }, }; } @@ -275,12 +270,13 @@ export class EmeManager implements IEmeManager { this.activeMediaKeys_ = mediaKeys; this.activeKeySystemConfig_ = this.activeSource_?.keySystems[this.activeKeySystem_] || null; - if (this.activeVideoElement_) { - return this.activeVideoElement_.setMediaKeys(this.activeMediaKeys_); - } else { + if (!this.activeVideoElement_) { this.logger_.warn(`EME: Attempting to set media keys on an invalid media element.`); Promise.resolve(); + return; } + + return this.activeVideoElement_.setMediaKeys(this.activeMediaKeys_); }) .then(() => { this.logger_.debug(`Successfully set media keys in the video element for ${this.activeKeySystem_}.`); @@ -332,7 +328,7 @@ export class EmeManager implements IEmeManager { const allInitData = this.getAllInitData_(); allInitData.forEach((data) => { - if (EmeManager.areInitDataEqual_(initData, data)) { + if (areBuffersEqual(initData, data)) { this.logger_.debug('Received duplicate initData. The key session will not be created.'); return; } @@ -370,6 +366,18 @@ export class EmeManager implements IEmeManager { this.activeSessions_.set(mediaKeySession.sessionId, metadata); + // Transform the initData if FairPlay is being used + if (isFairPlayKeySystem(this.activeKeySystem_)) { + const activeKeySystemConfig = this.activeSource_?.keySystems[this.activeKeySystem_ as string]; + const activeKeySystemCertificate = activeKeySystemConfig?.serverCertificate; + + initData = this.initDataTransform_( + initData, + initDataType, + activeKeySystemCertificate as BufferSource + ) as Uint8Array; + } + mediaKeySession .generateRequest(initDataType, initData) .then(() => { @@ -431,30 +439,28 @@ export class EmeManager implements IEmeManager { status = tmp as unknown as MediaKeyStatus; } - // Microsoft's implementation in Edge seems to present key IDs as - // little-endian UUIDs. + // Edge uses little endian for Key IDs IDs // https://bit.ly/2thuzXu // NOTE: Skip if byteLength != 16. // Edge uses single-byte dummy key IDs. Tizen doesn't have this problem. - if ( - this.activeKeySystem_ && - this.isPlayReadyKeySystem_(this.activeKeySystem_) && - keyId.byteLength === 16 && - IS_EDGE - ) { + if (this.activeKeySystem_ && isPlayReadyKeySystem(this.activeKeySystem_) && keyId.byteLength === 16 && IS_EDGE) { // Get little-endian values: - const dataView = this.toDataView_(keyId); - const le0 = dataView.getUint32(0, true); - const le1 = dataView.getUint16(4, true); - const le2 = dataView.getUint16(6, true); - // Write it back in big-endian - dataView.setUint32(0, le0, false); - dataView.setUint16(4, le1, false); - dataView.setUint16(6, le2, false); + const dataView = toDataView(keyId) as DataView | null; + + // If KeyID was invalid, ignore the current key and continue + if (dataView) { + const le0 = dataView.getUint32(0, true); + const le1 = dataView.getUint16(4, true); + const le2 = dataView.getUint16(6, true); + // Write it back in big-endian + dataView.setUint32(0, le0, false); + dataView.setUint16(4, le1, false); + dataView.setUint16(6, le2, false); + } } - const keyIdHexString = this.toHex_(keyId); + const keyIdHexString = toHex(keyId); if (!activeSession) { if (status === 'usable') { @@ -476,6 +482,7 @@ export class EmeManager implements IEmeManager { this.currentKeyStatuses_.set(keyIdHexString, status); // TODO: How do we want ot use these stored keys? We probably need to handle the ones with status-pending + // See shaka onKeyStatus on their player (they use a timer) }); // Close session when it has expired keys. @@ -493,6 +500,7 @@ export class EmeManager implements IEmeManager { return; } + this.privateEventEmitter_.emitEvent(new KeyStatusesUpdatedEvent(this.currentKeyStatuses_)); // TODO: Resolve all unloaded sessions. } @@ -503,6 +511,8 @@ export class EmeManager implements IEmeManager { * @returns An empty promise */ private async onSessionMessage_(event: MediaKeyMessageEvent): Promise { + const customLicenseRequest = this.activeSource_?.keySystems[this.activeKeySystem_ as string].getLicense; + const customContentIdTransform = this.activeSource_?.keySystems[this.activeKeySystem_ as string].getContentId; const session = event.target as MediaKeySession; if (!session) { @@ -517,6 +527,36 @@ export class EmeManager implements IEmeManager { this.logger_.debug(`Sending license request for session ${session.sessionId} of type ${event.messageType}`); + if (customLicenseRequest) { + this.logger_.debug( + `A custom license request function was configured for KeySystem: ${this.activeKeySystem_} for Session: ${session.sessionId}` + ); + + const initData = this.activeSessions_.get(session.sessionId)?.initData; + const initDataType = this.activeSessions_.get(session.sessionId)?.initDataType; + + let contentId = ''; + + if (customContentIdTransform) { + contentId = customContentIdTransform(initData as Uint8Array); + } else { + // TODO: Is this the correct content id? + contentId = initDataType || ''; + } + + const licenseResponse = customLicenseRequest(contentId, event); + + try { + // TODO: Handle this for different DRM scenarios + session.update(licenseResponse); + this.privateEventEmitter_.emitEvent(new KeySessionUpdatedEvent(session.sessionId, event.messageType)); + } catch (error) { + this.eventEmitter_.emitEvent(new ErrorEvent(new LicenseResponseRejectedError(false, error as Error))); + } + + return; + } + let licenseServerUri = this.activeSource_?.keySystems[this.activeKeySystem_ as string].licenseServerUri; const individualizationSever = this.activeSource_?.keySystems[this.activeKeySystem_ as string].individualizationServerUri; @@ -542,6 +582,10 @@ export class EmeManager implements IEmeManager { try { // TODO: Handle this for different DRM scenarios session.update(response); + this.logger_.debug( + `EME: Key session updated with new license. SessionID: ${session.sessionId} MessageType: ${event.messageType}` + ); + this.privateEventEmitter_.emitEvent(new KeySessionUpdatedEvent(session.sessionId, event.messageType)); } catch (error) { this.eventEmitter_.emitEvent(new ErrorEvent(new LicenseResponseRejectedError(false, error as Error))); } @@ -549,11 +593,6 @@ export class EmeManager implements IEmeManager { .catch((error) => { this.eventEmitter_.emitEvent(new ErrorEvent(new LicenseRequestError(false, error))); }); - - this.logger_.debug( - `EME: Key session updated with new license. SessionID: ${session.sessionId} MessageType: ${event.messageType}` - ); - this.privateEventEmitter_.emitEvent(new KeySessionUpdatedEvent(session.sessionId, event.messageType)); } /** @@ -613,91 +652,94 @@ export class EmeManager implements IEmeManager { } /** - * A helper method to determine if the keySystem is PlayReady - * @param keySystem The key system string - * @returns Whether the key system is PlayReady + * @returns Whether or not all key sessions are loaded. */ - private isPlayReadyKeySystem_(keySystem: string): boolean { - if (keySystem) { - return !!keySystem.match(/^com\.(microsoft|chromecast)\.playready/); - } + private areAllSessionsLoaded_(): boolean { + this.activeSessions_.forEach((sessionMetadata) => { + if (!sessionMetadata.loaded) { + return false; + } + }); - return false; + return true; } /** - * @param keySystem The key system type - * @returns Whether the keySystem is ClearKey + * Transforms the init data buffer using the given data. The format is: + * [4 bytes] initDataSize + * [initDataSize bytes] initData + * [4 bytes] contentIdSize + * [contentIdSize bytes] contentId + * [4 bytes] certSize + * [certSize bytes] cert + * @param initData The initData to transform + * @param contentId The content ID containing information about the stream + * @param cert The certificate for the license + * @returns The transformed init data */ - private isClearKeySystem_(keySystem: string): boolean { - return keySystem === 'org.w3.clearkey'; - } + private initDataTransform_( + initData: BufferSource, + contentId: BufferSource | string, + cert: BufferSource + ): BufferSource { + if (!cert || !cert.byteLength) { + this.eventEmitter_.emitEvent(new ErrorEvent(new MissingServerCertificateError(false))); + return initData; + } - /** - * Convert a buffer to a DataView type for additional utilities to deal with - * different different array types. - * @param bufferSource The buffer containing key data - * @returns The data view of the key data - */ - private toDataView_(bufferSource: BufferSource): DataView { - const buffer = this.getArrayBuffer_(bufferSource); - const bytesPerElement = 1; - - // TODO: Can this case ever happen?? - // if ('BYTES_PER_ELEMENT' in DataView) { - // bytesPerElement = DataView.BYTES_PER_ELEMENT; - // } - - // Note: It can be implied that the byteOffset for an arrayBuffer is 0. - const dataEnd = bufferSource.byteLength / bytesPerElement; - const sourceStart = 0; - const start = Math.floor(Math.max(0, Math.min(sourceStart, dataEnd))); - const end = Math.floor(Math.min(start + Math.max(Infinity, 0), dataEnd)); - return new DataView(buffer, start, end - start); - } + let contentIdArray; - /** - * @param data A buffer source - * @returns A hex string key ID - */ - private toHex_(data: BufferSource): string { - const arrayBuffer = this.getArrayBuffer_(data); - const arr = new Uint8Array(arrayBuffer); - let hex = ''; - let stringValue; - for (const value of arr) { - stringValue = value.toString(16); - if (stringValue.length === 1) { - stringValue = '0' + stringValue; - } - hex += stringValue; + const customContentIdTransform = this.activeSource_?.keySystems[this.activeKeySystem_ as string].getContentId; + + if (customContentIdTransform) { + contentId = customContentIdTransform(initData as ArrayBuffer); } - return hex; - } - /** - * Get the array buffer even if it is inside the BufferSource. - * @param source The buffer source - * @returns The array buffer - */ - private getArrayBuffer_(source: BufferSource): ArrayBuffer { - if (source instanceof ArrayBuffer) { - return source; + if (typeof contentId == 'string') { + contentIdArray = toUTF16(contentId, true); } else { - return source.buffer; + contentIdArray = contentId; } - } - /** - * @returns Whether or not all key sessions are loaded. - */ - private areAllSessionsLoaded_(): boolean { - this.activeSessions_.forEach((sessionMetadata) => { - if (!sessionMetadata.loaded) { - return false; + // The init data we get is a UTF-8 string; convert that to a UTF-16 string. + const skdUri = bufferToString(initData); + + if (!skdUri) { + // There was a failure getting the SDK URI. Return original init data. + return initData; + } + + const utf16 = toUTF16(skdUri, true); + + const newData = new Uint8Array(12 + utf16.byteLength + contentIdArray.byteLength + cert.byteLength); + + let offset = 0; + + // Add to the new data and update the offset considering byte length. + const addToNewData = (array: BufferSource): void => { + const view = toDataView(newData); + + if (!view) { + // error converting newData to DataView + return; } - }); - return true; + const value = array.byteLength; + (view as DataView).setUint32(offset, value, true); + offset += 4; + + newData.set(toUint8(array), offset); + offset += array.byteLength; + }; + + addToNewData(utf16); + addToNewData(contentIdArray); + addToNewData(cert); + + if (offset !== newData.length) { + this.logger_.warn('EME: Transformed init data length does not match the original.'); + } + + return newData; } } diff --git a/packages/playback/src/lib/eme/eme-utils.ts b/packages/playback/src/lib/eme/eme-utils.ts new file mode 100644 index 0000000..393acf4 --- /dev/null +++ b/packages/playback/src/lib/eme/eme-utils.ts @@ -0,0 +1,33 @@ +/** + * A helper method to determine if the keySystem is FairPlay + * @param keySystem The key system string + * @returns Whether the key system is FairPlay + */ +export const isFairPlayKeySystem = (keySystem: string): boolean => { + if (keySystem) { + return !!keySystem.match(/^com\.apple\.fps/); + } + + return false; +}; + +/** + * A helper method to determine if the keySystem is PlayReady + * @param keySystem The key system string + * @returns Whether the key system is PlayReady + */ +export const isPlayReadyKeySystem = (keySystem: string): boolean => { + if (keySystem) { + return !!keySystem.match(/^com\.(microsoft|chromecast)\.playready/); + } + + return false; +}; + +/** + * @param keySystem The key system type + * @returns Whether the keySystem is ClearKey + */ +export const isClearKeySystem = (keySystem: string): boolean => { + return keySystem === 'org.w3.clearkey'; +}; diff --git a/packages/playback/src/lib/eme/string-utils.ts b/packages/playback/src/lib/eme/string-utils.ts new file mode 100644 index 0000000..8ade1fd --- /dev/null +++ b/packages/playback/src/lib/eme/string-utils.ts @@ -0,0 +1,220 @@ +import { toArrayBuffer, toUint8, toDataView } from './buffer-utils'; + +/** + * Takes a string and converts it to UTF16. + * @param data The string to convert to UTF16 + * @param isLittleEndian Whether endianness is little + * @returns the UTF16 array buffer + */ +export const toUTF16 = (data: string, isLittleEndian: boolean): ArrayBuffer => { + const result = new ArrayBuffer(data.length * 2); + const view = new DataView(result); + for (let i = 0; i < data.length; ++i) { + const value = data.charCodeAt(i); + view.setUint16(i * 2, value, isLittleEndian); + } + return result; +}; + +/** + * @param data A string with buffer data + * @returns An ArrayBuffer coverted to UTF-8 + */ +export const toUTF8 = (data: string): ArrayBuffer => { + if (window.TextEncoder) { + const utf8Encoder = new TextEncoder(); + return toArrayBuffer(utf8Encoder.encode(data)); + } else { + // Converts the given string to a URI encoded string. This + // logic is to account for escaoe sequences. + const encoded = encodeURIComponent(data); + // Convert each escape sequence individually into a character. + const utf8 = decodeURIComponent(encoded); + + const result = new Uint8Array(utf8.length); + for (let i = 0; i < utf8.length; i++) { + const item = utf8[i]; + result[i] = item.charCodeAt(0); + } + + return toArrayBuffer(result); + } +}; + +/** + * Automatically detects which buffer type is being used, and converts it to a string. + * @param data The buffer source + * @returns A string from the buffer + */ +export const bufferToString = (data: BufferSource): string | null => { + const uint8 = toUint8(data) as Uint8Array; + + const isAscii = (num: number): boolean => { + return uint8.byteLength <= num || (uint8[num] >= 0x20 && uint8[num] <= 0x7e); + }; + + if (uint8[0] == 0xef && uint8[1] == 0xbb && uint8[2] == 0xbf) { + return fromUTF8(uint8); + } else if (uint8[0] == 0xfe && uint8[1] == 0xff) { + return fromUTF16(uint8.subarray(2), false); + } else if (uint8[0] == 0xff && uint8[1] == 0xfe) { + return fromUTF16(uint8.subarray(2), true); + } + + // These are the fallback cases when byte order was not found. + if (uint8[0] == 0 && uint8[2] == 0) { + return fromUTF16(data, false); + } else if (uint8[1] == 0 && uint8[3] == 0) { + return fromUTF16(data, true); + } else if (isAscii(0) && isAscii(1) && isAscii(2) && isAscii(3)) { + return fromUTF8(data); + } + + // Something went wrong. + return null; +}; + +export const fromUTF16 = (data: BufferSource, isLittleEndian: boolean): string | null => { + if (data.byteLength % 2 != 0) { + // Data length must be even + return null; + } + + // Use DataView to ensure correct endianness. + const length = Math.floor(data.byteLength / 2); + const uint16 = new Uint16Array(length); + const dataView = toDataView(data) as DataView; + for (let i = 0; i < length; i++) { + uint16[i] = dataView.getUint16(i * 2, isLittleEndian); + } + return fromCharCode(uint16); +}; + +/** + * @param data The buffer source + * @returns A string from the given UTF-8 encoded buffer. Returns null if there was a failure. + */ +export const fromUTF8 = (data: BufferSource): string | null => { + let uint8 = toUint8(data) as Uint8Array; + // Remove the UTF-8 BOM. + if (uint8[0] == 0xef && uint8[1] == 0xbb && uint8[2] == 0xbf) { + uint8 = uint8.subarray(3); + } + + if (window.TextDecoder) { + // Use the TextDecoder when present in browser. + const utf8decoder = new TextDecoder(); + const decoded = utf8decoder.decode(uint8); + if (decoded.includes('\uFFFD')) { + // There is an unknown character, so the encoding was invalid. + return null; + } + return decoded; + } else { + // See https://github.com/shaka-project/shaka-player/blob/356de09850b1f920400d8d0c3a817ee1f713c1cd/lib/util/string_utils.js + // A decoder for when the Text Decoder is not present. + + let decoded = ''; + for (let i = 0; i < uint8.length; ++i) { + // By default, the replacement character codepoint. + let codePoint = 0xfffd; + + // Top bit is 0, 1-byte encoding. + if ((uint8[i] & 0x80) == 0) { + codePoint = uint8[i]; + } else if (uint8.length >= i + 2 && (uint8[i] & 0xe0) == 0xc0 && (uint8[i + 1] & 0xc0) == 0x80) { + // Top 3 bits of byte 0 are 110, top 2 bits of byte 1 are 10, + // 2-byte encoding. + codePoint = ((uint8[i] & 0x1f) << 6) | (uint8[i + 1] & 0x3f); + // Move one byte. + i += 1; + } else if ( + uint8.length >= i + 3 && + (uint8[i] & 0xf0) == 0xe0 && + (uint8[i + 1] & 0xc0) == 0x80 && + (uint8[i + 2] & 0xc0) == 0x80 + ) { + // Top 4 bits of byte 0 are 1110, top 2 bits of byte 1 and 2 are 10, + // 3-byte encoding. + codePoint = ((uint8[i] & 0x0f) << 12) | ((uint8[i + 1] & 0x3f) << 6) | (uint8[i + 2] & 0x3f); + + // Move two bytes + i += 2; + } else if ( + uint8.length >= i + 4 && + (uint8[i] & 0xf1) == 0xf0 && + (uint8[i + 1] & 0xc0) == 0x80 && + (uint8[i + 2] & 0xc0) == 0x80 && + (uint8[i + 3] & 0xc0) == 0x80 + ) { + // Top 5 bits of byte 0 are 11110, top 2 bits of byte 1, 2 and 3 are 10, + // 4-byte encoding. + codePoint = + ((uint8[i] & 0x07) << 18) | + ((uint8[i + 1] & 0x3f) << 12) | + ((uint8[i + 2] & 0x3f) << 6) | + (uint8[i + 3] & 0x3f); + + // Move three bytes. + i += 3; + } + + // JavaScript strings are a series of UTF-16 characters. + if (codePoint <= 0xffff) { + decoded += String.fromCharCode(codePoint); + } else { + // UTF-16 surrogate-pair encoding, based on + // https://en.wikipedia.org/wiki/UTF-16#Description + const baseCodePoint = codePoint - 0x10000; + const highPart = baseCodePoint >> 10; + const lowPart = baseCodePoint & 0x3ff; + decoded += String.fromCharCode(0xd800 + highPart); + decoded += String.fromCharCode(0xdc00 + lowPart); + } + } + + return decoded; + } +}; + +/** + * @param buffer The typed array of data + * @returns The typed array as a string + */ +export const fromCharCode = (buffer: Uint8Array | Uint16Array): string | null => { + // Different browsers support different chunk sizes; find out the largest + // this browser supports so we can use larger chunks on supported browsers + // but still support lower-end devices that require small chunks. + // 64k is supported on all major desktop browsers. + for (let size = 64 * 1024; size > 0; size /= 2) { + if (supportsChunkSize(size)) { + let ret = ''; + for (let i = 0; i < buffer.length; i += size) { + const subArray = buffer.subarray(i, i + size); + ret += String.fromCharCode.apply(null, Array.from(subArray)); + } + + return ret; + } + } + // Chunk size was not supported + return null; +}; + +/** + * @param size The chunk size + * @returns Whether or not this browser supports the chunk size. + */ +const supportsChunkSize = (size: number): boolean => { + try { + const buffer = new Uint8Array(size); + + // This can't use the spread operator, or it blows up on Xbox One. + // So we use apply() instead, which is normally not allowed. + // See issue #2186 for more details. + const supported = String.fromCharCode.apply(null, Array.from(new Uint8Array(buffer))); + return supported.length > 0; + } catch (error) { + return false; + } +}; diff --git a/packages/playback/src/lib/errors/eme-errors.ts b/packages/playback/src/lib/errors/eme-errors.ts index 737f808..70a4371 100644 --- a/packages/playback/src/lib/errors/eme-errors.ts +++ b/packages/playback/src/lib/errors/eme-errors.ts @@ -118,3 +118,13 @@ export class MissingEmeSupportError extends EmeError { this.isFatal = isFatal; } } + +export class MissingServerCertificateError extends EmeError { + public readonly code = ErrorCode.MissingServerCertificate; + public readonly isFatal: boolean; + + public constructor(isFatal: boolean) { + super(); + this.isFatal = isFatal; + } +} diff --git a/packages/playback/src/lib/events/eme-events.ts b/packages/playback/src/lib/events/eme-events.ts index 6e312f6..340fc2b 100644 --- a/packages/playback/src/lib/events/eme-events.ts +++ b/packages/playback/src/lib/events/eme-events.ts @@ -58,3 +58,13 @@ export class KeySessionClosedEvent extends PlayerEvent { this.sessionId = sessionId; } } + +export class KeyStatusesUpdatedEvent extends PlayerEvent { + public readonly type = PlayerEventType.KeyStatusesUpdated; + public readonly keyStatusMap: Map; + + public constructor(keyStatusMap: Map) { + super(); + this.keyStatusMap = keyStatusMap; + } +} diff --git a/packages/playback/src/lib/player/base/base-player.ts b/packages/playback/src/lib/player/base/base-player.ts index 7d1be55..8113c0a 100644 --- a/packages/playback/src/lib/player/base/base-player.ts +++ b/packages/playback/src/lib/player/base/base-player.ts @@ -201,6 +201,7 @@ export abstract class BasePlayer { networkManager: this.networkManager_, eventEmitter: this.eventEmitter_, privateEventEmitter: this.privateEventEmitter_, + configuration: this.configurationManager_.getSnapshot().eme, }); if (this.activeVideoElement_) { @@ -358,9 +359,9 @@ export abstract class BasePlayer { */ public addEventListener( eventType: K, - eventListener: EventListener + eventListener: EventListener ): void { - return this.eventEmitter_.addEventListener(eventType, eventListener); + return this.eventEmitter_.addEventListener(eventType as keyof EventTypeToEventMap, eventListener); } /** @@ -368,8 +369,11 @@ export abstract class BasePlayer { * @param eventType - specific event type * @param eventListener - event listener */ - public once(eventType: K, eventListener: EventListener): void { - return this.eventEmitter_.once(eventType, eventListener); + public once( + eventType: K, + eventListener: EventListener + ): void { + return this.eventEmitter_.once(eventType as keyof EventTypeToEventMap, eventListener); } /** * Remove specific registered event listener for a specific event type @@ -378,9 +382,9 @@ export abstract class BasePlayer { */ public removeEventListener( eventType: K, - eventListener: EventListener + eventListener: EventListener ): void { - return this.eventEmitter_.removeEventListener(eventType, eventListener); + return this.eventEmitter_.removeEventListener(eventType as keyof EventTypeToEventMap, eventListener); } /** @@ -388,7 +392,7 @@ export abstract class BasePlayer { * @param eventType - specific event type */ public removeAllEventListenersForType(eventType: K): void { - return this.eventEmitter_.removeAllEventListenersFor(eventType); + return this.eventEmitter_.removeAllEventListenersFor(eventType as keyof EventTypeToEventMap); } /** @@ -423,6 +427,7 @@ export abstract class BasePlayer { // EME this.activeVideoElement_.addEventListener('encrypted', this.handleEncryptedEvent_); this.activeVideoElement_.addEventListener('waitingforkey', this.handleWaitingForKeyEvent_); + this.privateEventEmitter_.addEventListener(PlayerEventType.KeyStatusesUpdated, this.handleKeyStatusesUpdated_); } /** @@ -451,6 +456,7 @@ export abstract class BasePlayer { // EME this.activeVideoElement_.removeEventListener('encrypted', this.handleEncryptedEvent_); this.activeVideoElement_.removeEventListener('waitingforkey', this.handleWaitingForKeyEvent_); + this.privateEventEmitter_.removeEventListener(PlayerEventType.KeyStatusesUpdated, this.handleKeyStatusesUpdated_); this.emeManager_?.detach(); this.activeVideoElement_ = null; @@ -623,6 +629,11 @@ export abstract class BasePlayer { this.emeManager_.handleWaitingForKey(); }; + protected readonly handleKeyStatusesUpdated_ = (): void => { + // TODO: Handle filtering out invalid key stauses from variants/representations/renditions + // event.keyStatusMap should contain the necessary info from the EME controller + }; + private transitionPlaybackState_(to: PlaybackState): void { if (this.currentPlaybackState_ === to) { return; diff --git a/packages/playback/src/lib/types/eme-manager.declarations.ts b/packages/playback/src/lib/types/eme-manager.declarations.ts index 3233ed6..3a0821b 100644 --- a/packages/playback/src/lib/types/eme-manager.declarations.ts +++ b/packages/playback/src/lib/types/eme-manager.declarations.ts @@ -3,12 +3,14 @@ import type { ILogger } from './logger.declarations'; import type { IPlayerSource } from './source.declarations'; import type { IEventEmitter } from './event-emitter.declarations'; import type { PrivateEventTypeToEventMap, EventTypeToEventMap } from './mappers/event-type-to-event-map.declarations'; +import type { PlayerEmeConfiguration } from './configuration.declarations'; export interface IEmeManagerDependencies { networkManager: INetworkManager; logger: ILogger; eventEmitter: IEventEmitter; privateEventEmitter: IEventEmitter; + configuration: PlayerEmeConfiguration; } export interface IEmeManager { diff --git a/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts b/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts index 2b0a41c..6a1943a 100644 --- a/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts +++ b/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts @@ -22,6 +22,7 @@ import type { KeySessionClosedEvent, KeySessionCreatedEvent, KeySessionUpdatedEvent, + KeyStatusesUpdatedEvent, KeySystemAccessRequestedEvent, WaitingForKeyEvent, } from '../../events/eme-events'; @@ -60,6 +61,7 @@ export interface EmeEventMap { export interface EmePrivateEventMap { [PlayerEventType.KeySessionUpdated]: KeySessionUpdatedEvent; + [PlayerEventType.KeyStatusesUpdated]: KeyStatusesUpdatedEvent; } export type EventTypeToEventMap = NetworkEventMap & PlayerEventMap & EmeEventMap; diff --git a/packages/playback/src/lib/types/source.declarations.ts b/packages/playback/src/lib/types/source.declarations.ts index 715c0ba..91ab158 100644 --- a/packages/playback/src/lib/types/source.declarations.ts +++ b/packages/playback/src/lib/types/source.declarations.ts @@ -19,9 +19,12 @@ export interface IKeySystemConfig { * Defaults to ''. */ individualizationServerUri?: string; - getContentId?: (contentId: string) => string; - // Rare cases when we want to leave it up to the user to get the license - getLicense?: (contentId: string, keyMessage: MediaKeyMessageEvent) => void; + getContentId?: (initData: ArrayBuffer) => string; + /** + * Rare cases when we want to leave it up to the user to get the license + * This function should return the response from the license request + */ + getLicense?: (contentId: string, keyMessage: MediaKeyMessageEvent) => ArrayBufferLike; } export interface ILoadSource { From 0ea63e86ed0c9fe59cde4355c757374ff07cfa63 Mon Sep 17 00:00:00 2001 From: wseymour Date: Mon, 24 Feb 2025 12:07:19 -0600 Subject: [PATCH 10/11] feat: sort key systems by priority --- packages/playback/src/lib/eme/eme-manager.ts | 33 +++++++++++++++---- .../src/lib/types/source.declarations.ts | 3 ++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index 4866e58..cb2a946 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -1,7 +1,7 @@ import type { IEmeManager, IEmeManagerDependencies, IKeySessionMetadata } from '../types/eme-manager.declarations'; import type { INetworkManager } from '../types/network.declarations'; import type { ILogger } from '../types/logger.declarations'; -import type { IPlayerSource } from '../types/source.declarations'; +import type { IKeySystemConfig, IPlayerSource } from '../types/source.declarations'; import type { IEventEmitter } from '../types/event-emitter.declarations'; import type { EventTypeToEventMap, @@ -166,7 +166,7 @@ export class EmeManager implements IEmeManager { this.privateEventEmitter_.addEventListener(PlayerEventType.DashManifestParsed, this.handleParsedManifestEvent_); } - private getKeySystemConfig_(): Record { + private getMediaKeySystemConfig_(): Record { // TODO: Write logic to get this info from manifests and segment data // We will probably need to pass in a list of key systems @@ -225,16 +225,35 @@ export class EmeManager implements IEmeManager { return mediaKeySystemAccess; } - // TODO: Sort by priority before this + // Sort key systems by priority + const keySystemsArray = Object.keys(keySystems).map((key) => [key, keySystems[key]]); - for (const keySystem in keySystems) { - const keySystemConfig = this.getKeySystemConfig_(); + keySystemsArray.sort((a, b) => { + const keySystemPriorityA = (a[1] as IKeySystemConfig).priority; + const keySystemPriorityB = (b[1] as IKeySystemConfig).priority; + + if (!keySystemPriorityA && !keySystemPriorityB) { + return 0; + } else if (!keySystemPriorityA) { + return 1; + } else if (!keySystemPriorityB) { + return -1; + } + + return keySystemPriorityA - keySystemPriorityB; + }); + + keySystemsArray.forEach(async (keySystemArr) => { + const keySystem = keySystemArr[0] as string; + // const keySystemConfig = keySystemArr[1] as IKeySystemConfig; + // TODO: Pass in key system info + const mediaKeySystemConfig = this.getMediaKeySystemConfig_(); try { this.eventEmitter_.emitEvent(new KeySystemAccessRequestedEvent(keySystem)); this.logger_.debug('EME: Requesting media key system access.'); - mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(keySystem, [keySystemConfig]); + mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(keySystem, [mediaKeySystemConfig]); return mediaKeySystemAccess; } catch (error) { @@ -243,7 +262,7 @@ export class EmeManager implements IEmeManager { `EME: Media key system access request failed. Key System: ${keySystem} Error: ${error as Error}` ); } - } + }); return mediaKeySystemAccess; } diff --git a/packages/playback/src/lib/types/source.declarations.ts b/packages/playback/src/lib/types/source.declarations.ts index 91ab158..d9600de 100644 --- a/packages/playback/src/lib/types/source.declarations.ts +++ b/packages/playback/src/lib/types/source.declarations.ts @@ -19,6 +19,9 @@ export interface IKeySystemConfig { * Defaults to ''. */ individualizationServerUri?: string; + /** + * A custom function to find the content ID from the init data. + */ getContentId?: (initData: ArrayBuffer) => string; /** * Rare cases when we want to leave it up to the user to get the license From efcab477c5b539a76bd41dc314ec4b3a2de3b008 Mon Sep 17 00:00:00 2001 From: wseymour Date: Wed, 5 Mar 2025 11:55:29 -0600 Subject: [PATCH 11/11] feat: updates for persistence, parsing, and other misc changes --- packages/playback/src/lib/eme/buffer-utils.ts | 14 +- packages/playback/src/lib/eme/eme-manager.ts | 147 +++++++++++++++--- .../src/lib/player/base/base-player.ts | 22 +-- .../main-thread-with-worker-player.ts | 1 + packages/playback/src/lib/service-locator.ts | 11 +- .../event-type-to-event-map.declarations.ts | 11 +- packages/playback/test/player.test.ts | 6 +- 7 files changed, 166 insertions(+), 46 deletions(-) diff --git a/packages/playback/src/lib/eme/buffer-utils.ts b/packages/playback/src/lib/eme/buffer-utils.ts index df15ea8..f666cf5 100644 --- a/packages/playback/src/lib/eme/buffer-utils.ts +++ b/packages/playback/src/lib/eme/buffer-utils.ts @@ -49,7 +49,7 @@ export const getArrayBuffer = (source: BufferSource): ArrayBuffer => { if (source instanceof ArrayBuffer) { return source; } else { - return source.buffer; + return source.buffer as ArrayBuffer; } }; @@ -67,12 +67,12 @@ export const toArrayBuffer = (buffer: BufferSource): ArrayBuffer => { const arrayView = buffer as ArrayBufferView; if (arrayView.byteOffset == 0 && arrayView.byteLength == arrayView.buffer.byteLength) { // TypedArray for the buffer. - return arrayView.buffer; + return arrayView.buffer as ArrayBuffer; } // View on the buffer. Create a new buffer that only contains // the data. Note that since this isn't an ArrayBuffer, the "new" call // will allocate a new buffer to hold the copy. - return new Uint8Array(arrayView as unknown as ArrayBufferLike).buffer; + return new Uint8Array(arrayView as unknown as ArrayBufferLike).buffer as ArrayBuffer; } }; @@ -100,9 +100,9 @@ export const bufferSourceToTypedArray = ( if (type === 'DataView') { return new DataView(buffer, start, end - start); } else if (type === 'Uint16Array') { - return new Uint8Array(buffer, start, end - start); + return new Uint8Array(buffer, start, end - start) as unknown as ArrayBuffer; } else if (type === 'Uint8Array') { - return new Uint16Array(buffer, start, end - start); + return new Uint16Array(buffer, start, end - start) as unknown as ArrayBuffer; } else { return null; } @@ -115,7 +115,7 @@ export const bufferSourceToTypedArray = ( * @returns A Uint8Array from the buffer */ export const toUint8 = (buffer: BufferSource, offset = 0, length = Infinity): Uint8Array => { - return bufferSourceToTypedArray(buffer, offset, length, 'Uint8Array') as Uint8Array; + return bufferSourceToTypedArray(buffer, offset, length, 'Uint8Array') as unknown as Uint8Array; }; /** @@ -125,7 +125,7 @@ export const toUint8 = (buffer: BufferSource, offset = 0, length = Infinity): Ui * @returns A Uint16Array from the buffer */ export const toUint16 = (buffer: BufferSource, offset = 0, length = Infinity): Uint16Array => { - return bufferSourceToTypedArray(buffer, offset, length, 'Uint16Array') as Uint16Array; + return bufferSourceToTypedArray(buffer, offset, length, 'Uint16Array') as unknown as Uint16Array; }; /** diff --git a/packages/playback/src/lib/eme/eme-manager.ts b/packages/playback/src/lib/eme/eme-manager.ts index cb2a946..21323d6 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -75,13 +75,20 @@ export class EmeManager implements IEmeManager { // can receive from both pssh or encrypted event - // Check if init data is already set!! - if (!this.activeSource_) { this.eventEmitter_.emitEvent(new ErrorEvent(new SourceNotSetError(false))); return; } + const activeSessions = this.activeSessions_.values(); + + for (const session of activeSessions) { + if (areBuffersEqual(session.initData as unknown as ArrayBuffer, data as unknown as ArrayBuffer)) { + this.logger_.debug('EME: Init data already exists on an active session. Ignoring this update.'); + return; + } + } + this.getKeySystemAccess_().then((keySystemAccess) => { this.selectKeySystem_(keySystemAccess).then((keySystem) => { if (!keySystem || !this.activeKeySystemConfig_) { @@ -185,6 +192,7 @@ export class EmeManager implements IEmeManager { }, ], sessionTypes: ['persistent-license'], + persistentState: 'required', }, }; } @@ -197,13 +205,36 @@ export class EmeManager implements IEmeManager { // TODO: update this function to take in parsed data and turn it into keySystemConfig values // We may need diffrent functions for DASH and HLS // We may want to call `setInitData` in here + + // TODO: This keyID, initData, initDataType can come from the parsed manifest data + const keyId = 'test'; + const initData = new ArrayBuffer(8); + const initDataType = 'test'; + + // We already are persisting the current keyId, so we can skip + // and continue using the current one. + if (this.currentKeyStatuses_.has(keyId)) { + // If we implement timers and other things, that can be updated + return; + } + + const currentKeyStatus = this.currentKeyStatuses_.get(keyId) as string; + + // Is there anything else we need to check for validity? + // Like set up timers? + + if (currentKeyStatus !== 'usable') { + // TODO: error or warning to let the user know the key is not usable. + return; + } + + this.setInitData(initDataType, initData); } /** * First, this function creates keySystemConfigurations for each key system * the source allows. Once those are created, we request a MediaKeySystemAccess - * using the aforementioned config. This method returns a promise containing - * a MediaKeySystemAccess instance. + * using the aforementioned config. * @returns A promise containing the MediaKeySystemAccess */ private async getKeySystemAccess_(): Promise { @@ -347,7 +378,7 @@ export class EmeManager implements IEmeManager { const allInitData = this.getAllInitData_(); allInitData.forEach((data) => { - if (areBuffersEqual(initData, data)) { + if (areBuffersEqual(initData as unknown as ArrayBuffer, data as unknown as ArrayBuffer)) { this.logger_.debug('Received duplicate initData. The key session will not be created.'); return; } @@ -370,7 +401,7 @@ export class EmeManager implements IEmeManager { // Register callback for session closed Promise mediaKeySession.closed.then(() => { - this.removeSession_(sessionId); + this.removeActiveSession_(sessionId); this.logger_.debug('EME Key Session closed. sessionId: ' + sessionId); this.eventEmitter_.emitEvent(new KeySessionClosedEvent(sessionId)); }); @@ -384,6 +415,10 @@ export class EmeManager implements IEmeManager { }; this.activeSessions_.set(mediaKeySession.sessionId, metadata); + // Check if this session should be persisted + if (this.activeKeySystemConfig_?.persistentState === 'required' && sessionType === 'persistent-license') { + this.storedSessions_.set(mediaKeySession.sessionId, metadata); + } // Transform the initData if FairPlay is being used if (isFairPlayKeySystem(this.activeKeySystem_)) { @@ -397,16 +432,28 @@ export class EmeManager implements IEmeManager { ) as Uint8Array; } - mediaKeySession - .generateRequest(initDataType, initData) - .then(() => { - this.logger_.debug('EME: Session created. SessionID: ' + sessionId); - this.eventEmitter_.emitEvent(new KeySessionCreatedEvent(sessionId)); - }) - .catch((error) => { - this.removeSession_(sessionId); - this.eventEmitter_.emitEvent(new ErrorEvent(new KeySessionCreateError(false, error as Error))); + // Check to see if the session is already stored + if (this.storedSessions_.has(sessionId)) { + // If it is, simply load the session. + mediaKeySession.load(sessionId).then((loaded) => { + if (!loaded) { + this.removeActiveSession_(sessionId); + this.removeStoredSession_(sessionId); + } }); + } else { + // If it is not persisted already, we need to generate a request + mediaKeySession + .generateRequest(initDataType, initData) + .then(() => { + this.logger_.debug('EME: Session created. SessionID: ' + sessionId); + this.eventEmitter_.emitEvent(new KeySessionCreatedEvent(sessionId)); + }) + .catch((error) => { + this.removeActiveSession_(sessionId); + this.eventEmitter_.emitEvent(new ErrorEvent(new KeySessionCreateError(false, error as Error))); + }); + } } /** @@ -557,7 +604,7 @@ export class EmeManager implements IEmeManager { let contentId = ''; if (customContentIdTransform) { - contentId = customContentIdTransform(initData as Uint8Array); + contentId = customContentIdTransform(initData as unknown as ArrayBuffer); } else { // TODO: Is this the correct content id? contentId = initDataType || ''; @@ -567,7 +614,7 @@ export class EmeManager implements IEmeManager { try { // TODO: Handle this for different DRM scenarios - session.update(licenseResponse); + session.update(licenseResponse as BufferSource); this.privateEventEmitter_.emitEvent(new KeySessionUpdatedEvent(session.sessionId, event.messageType)); } catch (error) { this.eventEmitter_.emitEvent(new ErrorEvent(new LicenseResponseRejectedError(false, error as Error))); @@ -600,7 +647,7 @@ export class EmeManager implements IEmeManager { .then((response) => { try { // TODO: Handle this for different DRM scenarios - session.update(response); + session.update(response as BufferSource); this.logger_.debug( `EME: Key session updated with new license. SessionID: ${session.sessionId} MessageType: ${event.messageType}` ); @@ -618,10 +665,23 @@ export class EmeManager implements IEmeManager { * Removes the selected session from the list of active sessions. * @param sessionId Key session ID */ - private removeSession_(sessionId: string): void { + private removeActiveSession_(sessionId: string): void { this.activeSessions_.delete(sessionId); } + /** + * Removes the selected session from the list of active sessions. + * @param sessionId Key session ID + */ + private removeStoredSession_(sessionId: string): void { + const stored = this.storedSessions_.get(sessionId); + const session = stored?.session; + + session?.remove().then(() => { + this.storedSessions_.delete(sessionId); + }); + } + /** * Closes the chosen media key session and removes all listeners. * @param sessionId Key session ID @@ -644,6 +704,8 @@ export class EmeManager implements IEmeManager { ); activeSession.removeEventListener('message', (event) => this.onSessionMessage_(event)); + this.removeActiveSession_(sessionId); + // Send our request to the key session return activeSession .close() @@ -651,7 +713,6 @@ export class EmeManager implements IEmeManager { this.logger_.debug(`Key session sucessfully closed. Session ID: ${sessionId}`); }) .catch((error) => { - this.removeSession_(sessionId); this.eventEmitter_.emitEvent(new ErrorEvent(new KeySessionClosedError(false, sessionId, error))); }); } @@ -761,4 +822,50 @@ export class EmeManager implements IEmeManager { return newData; } + + /** + * Parse pssh from a media segment and announce new initData + * This is used for offline DRM. + * @param contentType type of media content + * @param mediaSegment Segment data used to find the pssh box + * @returns An empty promise + */ + private parsePssh_(contentType: string, mediaSegment: BufferSource): Promise { + if (!['audio', 'video'].includes(contentType)) { + return Promise.resolve(); + } + + // TODO: We need to write utilities to parse a pssh box + // Will this be done in parsers or will this be in the EME utilities? + // toUint8(mediaSegment) is the pssh box + toUint8(mediaSegment); + + const psshInfo = { + systemIds: ['test'] as Array, + cencKeyids: ['test'] as Array, + data: [] as Array, + }; + + let dataLength = 0; + + for (const data of psshInfo.data) { + dataLength += data.length; + } + + if (dataLength == 0) { + return Promise.resolve(); + } + + const newData = new Uint8Array(dataLength); + + let pos = 0; + for (const data of psshInfo.data) { + newData.set(data, pos); + pos += data.length; + } + + this.setInitData('cenc', newData as unknown as ArrayBuffer); + + return Promise.resolve(); + } } diff --git a/packages/playback/src/lib/player/base/base-player.ts b/packages/playback/src/lib/player/base/base-player.ts index 8113c0a..8bb80fc 100644 --- a/packages/playback/src/lib/player/base/base-player.ts +++ b/packages/playback/src/lib/player/base/base-player.ts @@ -357,11 +357,11 @@ export abstract class BasePlayer { * @param eventType - specific event type * @param eventListener - event listener */ - public addEventListener( + public addEventListener( eventType: K, - eventListener: EventListener + eventListener: EventListener ): void { - return this.eventEmitter_.addEventListener(eventType as keyof EventTypeToEventMap, eventListener); + return this.eventEmitter_.addEventListener(eventType, eventListener); } /** @@ -369,30 +369,30 @@ export abstract class BasePlayer { * @param eventType - specific event type * @param eventListener - event listener */ - public once( + public once( eventType: K, - eventListener: EventListener + eventListener: EventListener ): void { - return this.eventEmitter_.once(eventType as keyof EventTypeToEventMap, eventListener); + return this.eventEmitter_.once(eventType, eventListener); } /** * Remove specific registered event listener for a specific event type * @param eventType - specific event type * @param eventListener - specific event listener */ - public removeEventListener( + public removeEventListener( eventType: K, - eventListener: EventListener + eventListener: EventListener ): void { - return this.eventEmitter_.removeEventListener(eventType as keyof EventTypeToEventMap, eventListener); + return this.eventEmitter_.removeEventListener(eventType, eventListener); } /** * Remove all registered event handlers for specific event type * @param eventType - specific event type */ - public removeAllEventListenersForType(eventType: K): void { - return this.eventEmitter_.removeAllEventListenersFor(eventType as keyof EventTypeToEventMap); + public removeAllEventListenersForType(eventType: K): void { + return this.eventEmitter_.removeAllEventListenersFor(eventType); } /** diff --git a/packages/playback/src/lib/player/main-thread-with-worker/main-thread-with-worker-player.ts b/packages/playback/src/lib/player/main-thread-with-worker/main-thread-with-worker-player.ts index 1e31b1d..00809b3 100644 --- a/packages/playback/src/lib/player/main-thread-with-worker/main-thread-with-worker-player.ts +++ b/packages/playback/src/lib/player/main-thread-with-worker/main-thread-with-worker-player.ts @@ -83,6 +83,7 @@ export class Player extends BasePlayer { }; private handleEmitEventMessage_(message: EmitEventMessage): void { + // @ts-expect-error PlayerEventType will always match key of the EventToTypeMap in this scenario. this.eventEmitter_.emitEvent(message.event); } diff --git a/packages/playback/src/lib/service-locator.ts b/packages/playback/src/lib/service-locator.ts index cbfcaa1..59b7d1f 100644 --- a/packages/playback/src/lib/service-locator.ts +++ b/packages/playback/src/lib/service-locator.ts @@ -5,7 +5,10 @@ import type { IInterceptorsStorage } from './types/interceptors.declarations'; import type { PlayerConfiguration } from './types/configuration.declarations'; import type { IStore } from './types/store.declarations'; import type { IEventEmitter } from './types/event-emitter.declarations'; -import type { EventTypeToEventMap } from './types/mappers/event-type-to-event-map.declarations'; +import type { + EventTypeToEventMap, + PrivateEventTypeToEventMap, +} from './types/mappers/event-type-to-event-map.declarations'; import type { INetworkManager } from './types/network.declarations'; import type { InterceptorTypeToInterceptorPayloadMap } from './types/mappers/interceptor-type-to-interceptor-map.declarations'; import type { NetworkManagerDependencies } from './network/network-manager'; @@ -24,6 +27,7 @@ export class ServiceLocator { public readonly interceptorsStorage: IInterceptorsStorage; public readonly configurationManager: IStore; public readonly eventEmitter: IEventEmitter; + public readonly privateEventEmitter: IEventEmitter; public readonly networkManager: INetworkManager; public readonly pipelineLoaderFactoryStorage: PipelineLoaderFactoryStorage; @@ -40,6 +44,7 @@ export class ServiceLocator { this.interceptorsStorage = this.createInterceptorsStorage_(); this.eventEmitter = this.createEventEmitter_(); + this.privateEventEmitter = this.createPrivateEventEmitter_(); this.networkManager = this.createNetworkManager_({ logger: this.logger.createSubLogger('NetworkManager'), configuration: configuration.network, @@ -63,6 +68,10 @@ export class ServiceLocator { return new EventEmitter(PlayerEventType.All); } + protected createPrivateEventEmitter_(): IEventEmitter { + return new EventEmitter(PlayerEventType.All); + } + protected createNetworkManager_(dependencies: NetworkManagerDependencies): INetworkManager { return new NetworkManager(dependencies); } diff --git a/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts b/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts index 6a1943a..fdbf6d6 100644 --- a/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts +++ b/packages/playback/src/lib/types/mappers/event-type-to-event-map.declarations.ts @@ -26,6 +26,7 @@ import type { KeySystemAccessRequestedEvent, WaitingForKeyEvent, } from '../../events/eme-events'; +import type { HlsPlaylistParsedEvent, DashManifestParsedEvent } from 'src/lib/events/parse-events'; export interface NetworkEventMap { [PlayerEventType.NetworkRequestAttemptStarted]: NetworkRequestAttemptStartedEvent; @@ -49,8 +50,8 @@ export interface PlayerEventMap { } export interface ParseEventMap { - [PlayerEventType.HlsPlaylistParsed]: LoggerLevelChangedEvent; - [PlayerEventType.DashManifestParsed]: VolumeChangedEvent; + [PlayerEventType.HlsPlaylistParsed]: HlsPlaylistParsedEvent; + [PlayerEventType.DashManifestParsed]: DashManifestParsedEvent; } export interface EmeEventMap { @@ -64,6 +65,8 @@ export interface EmePrivateEventMap { [PlayerEventType.KeyStatusesUpdated]: KeyStatusesUpdatedEvent; } -export type EventTypeToEventMap = NetworkEventMap & PlayerEventMap & EmeEventMap; +export interface EventTypeToEventMap extends PlayerEventMap, NetworkEventMap, EmeEventMap {} -export type PrivateEventTypeToEventMap = ParseEventMap & EmePrivateEventMap; +export interface PrivateEventTypeToEventMap extends ParseEventMap, EmePrivateEventMap { + [PlayerEventType.All]: PlayerEvent; +} diff --git a/packages/playback/test/player.test.ts b/packages/playback/test/player.test.ts index 59cc9e1..a841920 100644 --- a/packages/playback/test/player.test.ts +++ b/packages/playback/test/player.test.ts @@ -34,7 +34,7 @@ describe('Player spec', () => { const actualEvents: Array = []; player.addEventListener(Player.EventType.LoggerLevelChanged, (event) => { - actualEvents.push(event); + actualEvents.push(event as LoggerLevelChangedEvent); }); expect(player.getLoggerLevel()).toBe(Player.LoggerLevel.Debug); @@ -62,7 +62,7 @@ describe('Player spec', () => { const actualEvents: Array = []; player.addEventListener(Player.EventType.ConfigurationChanged, (event) => { - actualEvents.push(event); + actualEvents.push(event as ConfigurationChangedEvent); }); const snapshot1 = player.getConfigurationSnapshot(); @@ -90,7 +90,7 @@ describe('Player spec', () => { const actualEvents: Array = []; player.addEventListener(Player.EventType.ConfigurationChanged, (event) => { - actualEvents.push(event); + actualEvents.push(event as ConfigurationChangedEvent); }); const snapshot1 = player.getConfigurationSnapshot();