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/errors.ts b/packages/playback/src/lib/consts/errors.ts index 4d8f6c0..d2cc612 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,16 @@ export enum ErrorCategory { export enum ErrorCode { NoSupportedPipelines = 1000, PipelineLoaderFailedToDeterminePipeline, + // EME Errors + EmeManagerMissing = 2000, + SourceNotSet, + SourceMissingKeySystems, + KeySessionClosed, + KeySessionCreateFailed, + InvalidServerCertificate, + LicenseResponseRejected, + LicenseRequestFailed, + MediaKeyCreateFailed, + MissingEmeSupport, + MissingServerCertificate, } diff --git a/packages/playback/src/lib/consts/events.ts b/packages/playback/src/lib/consts/events.ts index 0e6ca91..c7a551c 100644 --- a/packages/playback/src/lib/consts/events.ts +++ b/packages/playback/src/lib/consts/events.ts @@ -9,11 +9,21 @@ export enum PlayerEventType { CurrentTimeChanged = 'CurrentTimeChanged', MutedStatusChanged = 'MutedStatusChanged', PlaybackStateChanged = 'PlaybackStateChanged', + // EME Events Encrypted = 'Encrypted', WaitingForKey = 'WaitingForKey', + KeySessionCreated = 'KeySessionCreated', + KeySessionUpdated = 'KeySessionUpdated', + KeySessionClosed = 'KeySessionClosed', + KeySystemAccessRequested = 'KeySystemAccessRequested', + KeyStatusesUpdated = 'KeyStatusesUpdated', 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/buffer-utils.ts b/packages/playback/src/lib/eme/buffer-utils.ts new file mode 100644 index 0000000..f666cf5 --- /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 as ArrayBuffer; + } +}; + +/** + * 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 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 as ArrayBuffer; + } +}; + +/** + * @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) as unknown as ArrayBuffer; + } else if (type === 'Uint8Array') { + return new Uint16Array(buffer, start, end - start) as unknown as ArrayBuffer; + } 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 unknown 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 unknown 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 088d5b5..21323d6 100644 --- a/packages/playback/src/lib/eme/eme-manager.ts +++ b/packages/playback/src/lib/eme/eme-manager.ts @@ -1,42 +1,68 @@ -import type { IEmeManager, IEmeManagerDependencies } 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 type { IKeySystemConfig, IPlayerSource } from '../types/source.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'; +import { + InvalidServerCertificateError, + KeySessionClosedError, + KeySessionCreateError, + LicenseRequestError, + LicenseResponseRejectedError, + MediaKeyCreateError, + MissingEmeSupportError, + MissingServerCertificateError, + SourceMissingKeySystemsError, + SourceNotSetError, +} from '../errors/eme-errors'; +import { ErrorEvent } from '../events/player-events'; +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; - protected initDataType_: string | null = null; - protected initData_: ArrayBuffer | null = null; + protected activeMediaKeys_: MediaKeys | null = null; + protected activeKeySystem_: string | null = null; + protected activeKeySystemConfig_: MediaKeySystemConfiguration | null = null; + 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; + this.configuration_ = dependencies.configuration; } public setSource(source: IPlayerSource): void { @@ -44,33 +70,81 @@ 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 - if (this.initDataType_ !== null && this.initData_ !== null) { - if (this.initDataType_ === type && EmeManager.areInitDataEqual_(this.initData_, data)) { - // received duplicate + 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; } + } - // received new init data/type - this.logger_.debug( - `updating init data: previous(${this.initDataType_}, length: ${this.initData_.byteLength}) --> new(${type}, length: ${data.byteLength})` - ); + this.getKeySystemAccess_().then((keySystemAccess) => { + this.selectKeySystem_(keySystemAccess).then((keySystem) => { + if (!keySystem || !this.activeKeySystemConfig_) { + // error, there is no selected key system + return; + } - // TODO: implement update - } + // Key system was successfully added to the video element - this.initDataType_ = type; - this.initData_ = data; + // Set server certificate if we have it from the source config + const activeKeySystemConfig = this.activeSource_?.keySystems[this.activeKeySystem_ as string]; + const activeKeySystemCertificate = activeKeySystemConfig?.serverCertificate; - // TODO: implement handling of initData + // TODO: handle all configs in activeKeySystemConfig + + 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 kicks off the whole process of setting event listeners, which + // is where the bulk of the logic occurs. + this.createKeySession_(new Uint8Array(data), type).then(() => { + // key session was successfully created + }); + }); + }); } 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] 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; } @@ -80,6 +154,8 @@ export class EmeManager implements IEmeManager { } this.activeVideoElement_ = videoElement; + + this.initEmeManager_(); } public detach(): void { @@ -89,5 +165,707 @@ 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 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 + + return { + 'com.widevine.alpha': { + videoCapabilities: [ + { + contentType: 'video/webm; codecs="vp9"', + robustness: 'SW_SECURE_CRYPTO', + }, + ], + audioCapabilities: [ + { + contentType: 'audio/webm; codecs="vorbis"', + robustness: 'SW_SECURE_CRYPTO', + }, + ], + sessionTypes: ['persistent-license'], + persistentState: 'required', + }, + }; + } + + /** + * Converts the parsed manifest data to keySystemConfig values. + */ + 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 + + // 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. + * @returns A promise containing the MediaKeySystemAccess + */ + private async getKeySystemAccess_(): Promise { + let mediaKeySystemAccess = {} as MediaKeySystemAccess; + + const keySystems = this.activeSource_?.keySystems; + + if (!keySystems) { + this.eventEmitter_.emitEvent(new ErrorEvent(new SourceMissingKeySystemsError(false))); + return mediaKeySystemAccess; + } + + // 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; + } + + // Sort key systems by priority + const keySystemsArray = Object.keys(keySystems).map((key) => [key, keySystems[key]]); + + 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, [mediaKeySystemConfig]); + + return mediaKeySystemAccess; + } catch (error) { + // 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}` + ); + } + }); + + return mediaKeySystemAccess; + } + + /** + * Creates media keys and adds them to the video element. + * @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 { + // 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_) { + 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_}.`); + 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 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): Promise { + // TODO: Do we need keySystemConfig in this function? + + if (!this.activeKeySystem_ || !this.activeMediaKeys_) { + // error + return; + } + + if (!this.activeMediaKeys_?.createSession) { + // issue with createSession, error + return; + } + + 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 (error) { + this.eventEmitter_.emitEvent(new ErrorEvent(new KeySessionCreateError(false, error as Error))); + return; + } + + if (!mediaKeySession) { + return; + } + + if (initData !== null && initDataType !== null) { + const allInitData = this.getAllInitData_(); + + allInitData.forEach((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; + } + }); + + // 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.removeActiveSession_(sessionId); + this.logger_.debug('EME Key Session closed. sessionId: ' + sessionId); + this.eventEmitter_.emitEvent(new KeySessionClosedEvent(sessionId)); + }); + + const metadata = { + initData, + initDataType, + loaded: false, + type: sessionType, + session: mediaKeySession, + }; + + 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_)) { + const activeKeySystemConfig = this.activeSource_?.keySystems[this.activeKeySystem_ as string]; + const activeKeySystemCertificate = activeKeySystemConfig?.serverCertificate; + + initData = this.initDataTransform_( + initData, + initDataType, + activeKeySystemCertificate as BufferSource + ) as Uint8Array; + } + + // 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))); + }); + } + } + + /** + * Sets the server certificate on the active media keys. + * @param certificate The server 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 (error) { + this.eventEmitter_.emitEvent(new ErrorEvent(new InvalidServerCertificateError(false, error as Error))); + } + } + + /** + * Event to handle key status changes event on session. + * @param event The event containing the media key session + */ + 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; + } + + // 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_ && isPlayReadyKeySystem(this.activeKeySystem_) && keyId.byteLength === 16 && IS_EDGE) { + // Get little-endian values: + 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 = 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: ${keyIdHexString}` + ); + } + return; + } + + if (status !== 'status-pending') { + activeSession.loaded = true; + } + + if (status === 'expired') { + hasExpiredKeys = true; + } + + 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. + 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; + } + + this.privateEventEmitter_.emitEvent(new KeyStatusesUpdatedEvent(this.currentKeyStatuses_)); + // TODO: Resolve all unloaded sessions. + } + + /** + * 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 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) { + 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}`); + + 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 unknown as ArrayBuffer); + } 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 as BufferSource); + 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; + + 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.) + const message = ArrayBuffer.isView(event.message) ? event.message.buffer : event.message; + + const payload = { + url: licenseServerUri as unknown as URL, + mapper: (): ArrayBufferLike => message, + requestType: RequestType.License, + }; + + const licenseRequest = this.networkManager_.post(payload); + + licenseRequest.done + .then((response) => { + try { + // TODO: Handle this for different DRM scenarios + session.update(response as BufferSource); + 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))); + } + }) + .catch((error) => { + this.eventEmitter_.emitEvent(new ErrorEvent(new LicenseRequestError(false, error))); + }); + } + + /** + * Removes the selected session from the list of active sessions. + * @param sessionId Key session ID + */ + 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 + */ + 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)); + + this.removeActiveSession_(sessionId); + + // Send our request to the key session + return activeSession + .close() + .then(() => { + this.logger_.debug(`Key session sucessfully closed. Session ID: ${sessionId}`); + }) + .catch((error) => { + 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 session of this.activeSessions_.values()) { + if (session.initData) { + initDataArray.push(session.initData); + } + } + return initDataArray; + } + + /** + * @returns Whether or not all key sessions are loaded. + */ + private areAllSessionsLoaded_(): boolean { + this.activeSessions_.forEach((sessionMetadata) => { + if (!sessionMetadata.loaded) { + return false; + } + }); + + return true; + } + + /** + * 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 initDataTransform_( + initData: BufferSource, + contentId: BufferSource | string, + cert: BufferSource + ): BufferSource { + if (!cert || !cert.byteLength) { + this.eventEmitter_.emitEvent(new ErrorEvent(new MissingServerCertificateError(false))); + return initData; + } + + let contentIdArray; + + const customContentIdTransform = this.activeSource_?.keySystems[this.activeKeySystem_ as string].getContentId; + + if (customContentIdTransform) { + contentId = customContentIdTransform(initData as ArrayBuffer); + } + + if (typeof contentId == 'string') { + contentIdArray = toUTF16(contentId, true); + } else { + contentIdArray = contentId; + } + + // 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; + } + + 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; + } + + /** + * 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/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 new file mode 100644 index 0000000..70a4371 --- /dev/null +++ b/packages/playback/src/lib/errors/eme-errors.ts @@ -0,0 +1,130 @@ +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; + } +} + +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 a762640..340fc2b 100644 --- a/packages/playback/src/lib/events/eme-events.ts +++ b/packages/playback/src/lib/events/eme-events.ts @@ -16,3 +16,55 @@ 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; + } +} + +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/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 812a3b8..8bb80fc 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 } 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, @@ -21,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'; @@ -40,14 +51,7 @@ 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 } from '../../types/eme-manager.declarations'; -import { EncryptedEvent, WaitingForKeyEvent } from '../../events/eme-events'; +import type { IEmeManager, IEmeManagerDependencies, IEmeApiAdapter } from '../../types/eme-manager.declarations'; import type { PipelineLoaderFactoryStorage } from './pipeline-loader-factory-storage'; declare const __COMMIT_HASH: string; @@ -59,6 +63,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 +138,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 +178,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 +199,9 @@ export abstract class BasePlayer { this.emeManager_ = factory({ logger: this.logger_.createSubLogger('EmeManager'), networkManager: this.networkManager_, + eventEmitter: this.eventEmitter_, + privateEventEmitter: this.privateEventEmitter_, + configuration: this.configurationManager_.getSnapshot().eme, }); if (this.activeVideoElement_) { @@ -204,6 +218,13 @@ export abstract class BasePlayer { this.emeManager_ = null; } + // TODO: create adapter type + // eslint-disable-next-line + public registerEmeApiAdapter(adapter: IEmeApiAdapter): 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); }; @@ -336,7 +357,7 @@ export abstract class BasePlayer { * @param eventType - specific event type * @param eventListener - event listener */ - public addEventListener( + public addEventListener( eventType: K, eventListener: EventListener ): void { @@ -348,7 +369,10 @@ export abstract class BasePlayer { * @param eventType - specific event type * @param eventListener - event listener */ - public once(eventType: K, eventListener: EventListener): void { + public once( + eventType: K, + eventListener: EventListener + ): void { return this.eventEmitter_.once(eventType, eventListener); } /** @@ -356,7 +380,7 @@ export abstract class BasePlayer { * @param eventType - specific event type * @param eventListener - specific event listener */ - public removeEventListener( + public removeEventListener( eventType: K, eventListener: EventListener ): void { @@ -367,7 +391,7 @@ export abstract class BasePlayer { * Remove all registered event handlers for specific event type * @param eventType - specific event type */ - public removeAllEventListenersForType(eventType: K): void { + public removeAllEventListenersForType(eventType: K): void { return this.eventEmitter_.removeAllEventListenersFor(eventType); } @@ -403,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_); } /** @@ -431,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; @@ -579,7 +605,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; } @@ -595,7 +621,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; } @@ -603,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/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/configuration.declarations.ts b/packages/playback/src/lib/types/configuration.declarations.ts index 2859f00..90fbc9d 100644 --- a/packages/playback/src/lib/types/configuration.declarations.ts +++ b/packages/playback/src/lib/types/configuration.declarations.ts @@ -63,8 +63,31 @@ export interface PlayerHlsConfiguration { transformTagAttributes: TransformTagAttributes; } +export interface PlayerEmeConfiguration { + /** + * 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. + */ + reusePersistentKeySessions: boolean; + + /** + * The video robustness level associated with the content type. + * Defults to an empty string. + */ + videoRobustness: string; + + /** + * The audio robustness level associated with the content type. + * Defaults to an emptry string. + */ + audioRobustness: string; +} + 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..3a0821b 100644 --- a/packages/playback/src/lib/types/eme-manager.declarations.ts +++ b/packages/playback/src/lib/types/eme-manager.declarations.ts @@ -1,10 +1,16 @@ import type { INetworkManager } from './network.declarations'; 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 { @@ -16,3 +22,20 @@ export interface IEmeManager { handleWaitingForKey(): void; setInitData(type: string, data: ArrayBuffer): void; } + +export interface IEmeApiAdapter { + // TODO: implement this adapter type + id: string; +} + +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(). +} 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..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 @@ -17,7 +17,16 @@ 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, + KeyStatusesUpdatedEvent, + KeySystemAccessRequestedEvent, + WaitingForKeyEvent, +} from '../../events/eme-events'; +import type { HlsPlaylistParsedEvent, DashManifestParsedEvent } from 'src/lib/events/parse-events'; export interface NetworkEventMap { [PlayerEventType.NetworkRequestAttemptStarted]: NetworkRequestAttemptStartedEvent; @@ -40,4 +49,24 @@ export interface PlayerEventMap { [PlayerEventType.Error]: ErrorEvent; } -export type EventTypeToEventMap = NetworkEventMap & PlayerEventMap; +export interface ParseEventMap { + [PlayerEventType.HlsPlaylistParsed]: HlsPlaylistParsedEvent; + [PlayerEventType.DashManifestParsed]: DashManifestParsedEvent; +} + +export interface EmeEventMap { + [PlayerEventType.KeySessionCreated]: KeySessionCreatedEvent; + [PlayerEventType.KeySystemAccessRequested]: KeySystemAccessRequestedEvent; + [PlayerEventType.KeySessionClosed]: KeySessionClosedEvent; +} + +export interface EmePrivateEventMap { + [PlayerEventType.KeySessionUpdated]: KeySessionUpdatedEvent; + [PlayerEventType.KeyStatusesUpdated]: KeyStatusesUpdatedEvent; +} + +export interface EventTypeToEventMap extends PlayerEventMap, NetworkEventMap, EmeEventMap {} + +export interface PrivateEventTypeToEventMap extends ParseEventMap, EmePrivateEventMap { + [PlayerEventType.All]: PlayerEvent; +} diff --git a/packages/playback/src/lib/types/source.declarations.ts b/packages/playback/src/lib/types/source.declarations.ts index 4552d84..d9600de 100644 --- a/packages/playback/src/lib/types/source.declarations.ts +++ b/packages/playback/src/lib/types/source.declarations.ts @@ -4,11 +4,30 @@ export interface IKeySystemConfig { serverCertificate?: Uint8Array; persistentState?: MediaKeysRequirement; distinctiveIdentifier?: MediaKeysRequirement; - videoRobustness?: string; - audioRobustness?: string; sessionType?: MediaKeySessionType; sessionId?: string; - getContentId?: (contentId: string) => 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 + * 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 + * This function should return the response from the license request + */ + getLicense?: (contentId: string, keyMessage: MediaKeyMessageEvent) => ArrayBufferLike; } export interface ILoadSource { @@ -27,7 +46,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 provided by the client */ readonly url: URL; } 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();