diff --git a/doc/api/Decryption_Options.md b/doc/api/Decryption_Options.md index 0b382ca7e5..41a644ed47 100644 --- a/doc/api/Decryption_Options.md +++ b/doc/api/Decryption_Options.md @@ -470,8 +470,60 @@ session state). This is very rarely needed. +### onKeyExpiration + +_type_: `string | undefined` + +`true` by default. + +Behavior the RxPlayer should have when one of the key is known to be expired. + +`onKeyExpiration` can be set to a string, each describing a different behavior, +the default one if not is defined being `"error"`: + + - `"error"`: The RxPlayer will stop on an error when any key is expired. + This is the default behavior. + + The error emiited in that case should be an + [EncryptedMediaError](./Player_Errors.md#encryptedmediaerror) with a + `KEY_STATUS_CHANGE_ERROR` `code` property with a set `keyStatuses` + property containing at least one string set to `"expired"`. + + - `"continue"`: The RxPlayer will not do anything when a key expires. + This may lead in many cases to infinite rebuffering. + + - `"fallback"`: The Representation(s) linked to the expired key(s) will + be fallbacked from, meaning the RxPlayer will switch to other + representation without expired keys. + + If no Representation remain, a NO_PLAYABLE_REPRESENTATION error will + be thrown. + + Note that when the "fallbacking" action is taken, the RxPlayer might + temporarily switch to the `"RELOADING"` state - which should thus be + properly handled. + + - `"close-session"`: The RxPlayer will close and re-create a DRM session + (and thus re-download the corresponding license) if any of the key + associated to this session expired. + + It will try to do so in an efficient manner, only reloading the license + when the corresponding content plays. + + The RxPlayer might go through the `"RELOADING"` state after an expired + key and/or light decoding glitches can arise, depending on the + platform, for some seconds, under that mode. + + + ### throwOnLicenseExpiration +
+This option is deprecated, it will disappear in the next major release +`v4.0.0` (see Deprecated +APIs). +
+ _type_: `Boolean | undefined` `true` by default. diff --git a/doc/api/Miscellaneous/Deprecated_APIs.md b/doc/api/Miscellaneous/Deprecated_APIs.md index 69e624941b..a95d4d15bb 100644 --- a/doc/api/Miscellaneous/Deprecated_APIs.md +++ b/doc/api/Miscellaneous/Deprecated_APIs.md @@ -423,6 +423,47 @@ the true "native" subtitles to display them themselves in a better way. However, this API seems to not be used anymore. Please open an issue if you need it. +### keySystems[].throwOnLicenseExpiration + +The `throwOnLicenseExpiration` property of the `keySystems` option has been +replaced by the more powerful `onKeyExpiration` property. + +#### How to replace that option + +If you set `throwOnLicenseExpiration` to `false` before, you can simply set +`onKeyExpiration` to `"continue"` instead, which reproduce the exact same +behavior: +```ts +// old way +rxPlayer.loadVideo({ + // ... + keySystems: [ + { + throwOnLicenseExpiration: false, + // ... + } + ], +}); + +// new way +rxPlayer.loadVideo({ + // ... + keySystems: [ + { + onKeyExpiration: "continue", + // ... + } + ], +}); +``` + +You can have more information on the `onKeyExpiration` option [in the +correspnding API documentation](./Decryption_Options.md#onkeyexpiration). + +If you previously set `throwOnLicenseExpiration` to `true` or `undefined`, you +can just remove this property as this still the default behavior. + + ## RxPlayer constructor options The following RxPlayer constructor options are deprecated. diff --git a/doc/api/Player_Errors.md b/doc/api/Player_Errors.md index db67d1e770..602169a6c4 100644 --- a/doc/api/Player_Errors.md +++ b/doc/api/Player_Errors.md @@ -243,9 +243,21 @@ An EncryptedMediaError can have the following codes (`code` property): of more than 10 seconds. - `"KEY_STATUS_CHANGE_ERROR"`: An error was detected when the - `MediaKeySession` emitted a keyStatuseschange event (e.g. the key + `MediaKeySession` emitted a keyStatuseschange event (e.g. a key became `"expired"`). + `EncryptedMediaError` having the `KEY_STATUS_CHANGE_ERROR` code will also have + a `keyStatuses` property, which is an array of objects - each describing a + problematic key status with the following properties: + - `keyId` (`BufferSource`): The key id concerned by the status change + indicated by `keyStatus` + - `keyStatus` ([`MediaKeyStatus`](https://www.w3.org/TR/encrypted-media/#dom-mediakeystatus)): + The problematic key status encountered linked to the `keyId` of the same + object. + + If multiple objects are found in the `keyStatuses` property, it means that + multiple keys changed to a problematic status roughly around the same time. + - `"KEY_UPDATE_ERROR"`: An error was detected after a message (like a license was given to the CDM). diff --git a/doc/api/Typescript_Types.md b/doc/api/Typescript_Types.md index 7c0e251098..3c4f88541d 100644 --- a/doc/api/Typescript_Types.md +++ b/doc/api/Typescript_Types.md @@ -449,3 +449,28 @@ rxPlayer.addEventListener("warning", (err : Error | IPlayerError) => { }); ``` + +### EncryptedMediaError's `keyStatuses` property + +Some `EncryptedMediaError` error thrown by the RxPlayer, may have a +`keyStatuses` property set. +In that case, the type is described by the +`IEncryptedMediaErrorKeyStatusObject` type: +```ts +// the type wanted +import { IEncryptedMediaErrorKeyStatusObject } from "rx-player/types"; + +// hypothetical file exporting an RxPlayer instance +import rxPlayer from "./player"; + +rxPlayer.addEventListener("error", (err : Error | IPlayerError) => { + if (err.type === "ENCRYPTED_MEDIA_ERROR" && err.keyStatuses !== undefined) { + logKeyStatuses(err.keyStatuses); + } +}); + +function logKeyStatuses(keyStatuses: IEncryptedMediaErrorKeyStatusObject): void { +console.log(keyStatuses); +} + +``` diff --git a/src/core/decrypt/content_decryptor.ts b/src/core/decrypt/content_decryptor.ts index c0fefb4e05..3879fe0657 100644 --- a/src/core/decrypt/content_decryptor.ts +++ b/src/core/decrypt/content_decryptor.ts @@ -54,6 +54,7 @@ import { MediaKeySessionLoadingType, IProcessedProtectionData, } from "./types"; +import { DecommissionedSessionError } from "./utils/check_key_statuses"; import cleanOldStoredPersistentInfo from "./utils/clean_old_stored_persistent_info"; import getDrmSystemId from "./utils/get_drm_system_id"; import InitDataValuesContainer from "./utils/init_data_values_container"; @@ -393,7 +394,7 @@ export default class ContentDecryptor extends EventEmitter `${acc}, ${bytesToHex(kid)}`, ""); log.debug("DRM: Blacklisting new key ids", hexKids); } - updateDecipherability(initializationData.content.manifest, [], keyIds); + updateDecipherability(initializationData.content.manifest, [], keyIds, []); } return ; } @@ -423,7 +424,8 @@ export default class ContentDecryptor extends EventEmitter { + if (err instanceof DecommissionedSessionError) { + log.warn("DRM: A session's closing condition has been triggered"); + this._lockInitDataQueue(); + const indexOf = this._currentSessions.indexOf(sessionInfo); + if (indexOf >= 0) { + this._currentSessions.splice(indexOf); + } + if (initializationData.content !== undefined) { + updateDecipherability(initializationData.content.manifest, + [], + [], + sessionInfo.record.getAssociatedKeyIds()); + } + stores.persistentSessionsStore?.delete(mediaKeySession.sessionId); + stores.loadedSessionsStore.closeSession(mediaKeySession) + .catch(e => { + const closeError = e instanceof Error ? e : + "unknown error"; + log.warn("DRM: failed to close expired session", closeError); + }) + .then(() => this._unlockInitDataQueue()) + .catch((retryError) => this._onFatalError(retryError)); + + if (!this._isStopped()) { + this.trigger("warning", err.reason); + } + return; + } if (!(err instanceof BlacklistedSessionError)) { this._onFatalError(err); return ; @@ -664,7 +695,8 @@ export default class ContentDecryptor extends EventEmitter} whitelistedKeyIds * @param {Array.} blacklistedKeyIds + * @param {Array.} delistedKeyIds */ function updateDecipherability( manifest : Manifest, whitelistedKeyIds : Uint8Array[], - blacklistedKeyIds : Uint8Array[] + blacklistedKeyIds : Uint8Array[], + delistedKeyIds : Uint8Array[] ) : void { manifest.updateRepresentationsDeciperability((representation) => { if (representation.contentProtections === undefined) { @@ -828,6 +865,11 @@ function updateDecipherability( return true; } } + for (let j = 0; j < delistedKeyIds.length; j++) { + if (areKeyIdsEqual(delistedKeyIds[j], elt.keyId)) { + return undefined; + } + } } } return representation.decipherable; diff --git a/src/core/decrypt/session_events_listener.ts b/src/core/decrypt/session_events_listener.ts index 3f45aaac83..9a8226a135 100644 --- a/src/core/decrypt/session_events_listener.ts +++ b/src/core/decrypt/session_events_listener.ts @@ -207,15 +207,17 @@ function getKeyStatusesEvents( if (session.keyStatuses.size === 0) { return EMPTY; } - const { warnings, blacklistedKeyIds, whitelistedKeyIds } = - checkKeyStatuses(session, options, keySystem); - - const warnings$ = warnings.length > 0 ? observableOf(...warnings) : - EMPTY; + const { warning, + blacklistedKeyIds, + whitelistedKeyIds } = checkKeyStatuses(session, options, keySystem); const keysUpdate$ = observableOf({ type : "keys-update" as const, value : { whitelistedKeyIds, blacklistedKeyIds } }); - return observableConcat(warnings$, keysUpdate$); + if (warning !== undefined) { + return observableConcat(observableOf({ type: "warning" as const, value: warning }), + keysUpdate$); + } + return keysUpdate$; }); } @@ -270,8 +272,9 @@ function updateSessionWithMessage( } /** - * @param {MediaKeySession} - * @param {Object} keySystem + * @param {MediaKeySession} session + * @param {Object} keySystemOptions + * @param {string} keySystem * @param {Event} keyStatusesEvent * @returns {Observable} */ @@ -334,17 +337,8 @@ function getLicenseBackoffOptions( } /** - * Some key ids have updated their status. - * - * We put them in two different list: - * - * - `blacklistedKeyIds`: Those key ids won't be used for decryption and the - * corresponding media it decrypts should not be pushed to the buffer - * Note that a blacklisted key id can become whitelisted in the future. - * - * - `whitelistedKeyIds`: Those key ids were found and their corresponding - * keys are now being considered for decryption. - * Note that a whitelisted key id can become blacklisted in the future. + * Some key ids related to the current MediaKeySession have updated their + * statuses. * * Note that each `IKeysUpdateEvent` is independent of any other. * @@ -352,7 +346,7 @@ function getLicenseBackoffOptions( * one, as it can for example be linked to a whole other decryption session. * * However, if a key id is encountered in both an older and a newer - * `IKeysUpdateEvent`, only the older status should be considered. + * `IKeysUpdateEvent`, only the newer, updated, status should be considered. */ export interface IKeysUpdateEvent { type: "keys-update"; @@ -362,18 +356,28 @@ export interface IKeysUpdateEvent { /** Information on key ids linked to a MediaKeySession. */ export interface IKeyUpdateValue { /** - * The list of key ids that are blacklisted. - * As such, their corresponding keys won't be used by that session, despite - * the fact that they were part of the pushed license. + * The list of key ids linked to the corresponding MediaKeySession that are + * now "blacklisted", i.e. the decryption keys they are linked to are blocked + * from ever being used anymore. + * + * Blacklisted key ids correspond to keys linked to a MediaKeySession that + * cannot and should not be used, due to various reasons, which mainly involve + * unmet output restrictions and CDM internal errors linked to that key. + * + * Content linked to key ids in `blacklistedKeyIds` should be refrained from + * being used. * - * Reasons for blacklisting a keys depend on options, but mainly involve unmet - * output restrictions and CDM internal errors linked to that key id. + * Note that a key id may only be blacklisted temporarily. */ blacklistedKeyIds : Uint8Array[]; /* - * The list of key id linked to that session which are not blacklisted. - * Together with `blacklistedKeyIds` it regroups all key ids linked to the - * session. + * The list of key ids linked to the corresponding MediaKeySession that are + * now "whitelisted", i.e. the decryption keys they are linked to can be used + * to decrypt content. + * + * Content linked to key ids in `whitelistedKeyIds` should be safe to play. + * + * Note that a key id may only be whitelisted temporarily. */ whitelistedKeyIds : Uint8Array[]; } diff --git a/src/core/decrypt/utils/check_key_statuses.ts b/src/core/decrypt/utils/check_key_statuses.ts index 571886705f..0b4580d012 100644 --- a/src/core/decrypt/utils/check_key_statuses.ts +++ b/src/core/decrypt/utils/check_key_statuses.ts @@ -18,36 +18,45 @@ import { ICustomMediaKeySession } from "../../../compat"; /* eslint-disable-next-line max-len */ import getUUIDKidFromKeyStatusKID from "../../../compat/eme/get_uuid_kid_from_keystatus_kid"; import { EncryptedMediaError } from "../../../errors"; +import { + IEncryptedMediaErrorKeyStatusObject, + IKeySystemOption, + IPlayerError, +} from "../../../public_types"; +import assertUnreachable from "../../../utils/assert_unreachable"; import { bytesToHex } from "../../../utils/string_parsing"; -import { IEMEWarningEvent } from "../types"; -const KEY_STATUSES = { EXPIRED: "expired", - INTERNAL_ERROR: "internal-error", - OUTPUT_RESTRICTED: "output-restricted" }; +/** + * Error thrown when the MediaKeySession has to be closed due to a trigger + * specified by user configuration. + * Such MediaKeySession should be closed immediately and may be re-created if + * needed again. + * @class DecommissionedSessionError + * @extends Error + */ +export class DecommissionedSessionError extends Error { + public reason : IPlayerError; -export interface IKeyStatusesCheckingOptions { /** - * If explicitely set to `false`, we won't throw on error when a used license - * is expired. + * Creates a new `DecommissionedSessionError`. + * @param {Error} reason - Error that led to the decision to close the + * current MediaKeySession. Should be used for reporting purposes. */ - throwOnLicenseExpiration? : boolean; - /** Avoid throwing when invalid key statuses are encountered. */ - fallbackOn? : { - /** - * If set to `true`, we won't throw when an "internal-error" key status is - * encountered but just add a warning and the corresponding key id to the list - * of blacklisted key ids. - */ - keyInternalError? : boolean; - /** - * If set to `true`, we won't throw when an "output-restricted" key status is - * encountered but just add a warning and the corresponding key id to the list - * of blacklisted key ids. - */ - keyOutputRestricted? : boolean; - }; + constructor(reason : IPlayerError) { + super(); + // @see https://stackoverflow.com/questions/41102060/typescript-extending-error-class + Object.setPrototypeOf(this, DecommissionedSessionError.prototype); + this.reason = reason; + } } +const KEY_STATUSES = { EXPIRED: "expired", + INTERNAL_ERROR: "internal-error", + OUTPUT_RESTRICTED: "output-restricted" }; + +export type IKeyStatusesCheckingOptions = + Pick; + /** * MediaKeyStatusMap's iterator seems to be quite peculiar and wrongly defined * by TypeScript. @@ -74,14 +83,16 @@ export default function checkKeyStatuses( session : MediaKeySession | ICustomMediaKeySession, options: IKeyStatusesCheckingOptions, keySystem: string -) : { warnings : IEMEWarningEvent[]; +) : { warning : EncryptedMediaError | undefined; blacklistedKeyIds : Uint8Array[]; whitelistedKeyIds : Uint8Array[]; } { - const warnings : IEMEWarningEvent[] = []; + const { fallbackOn = {}, + throwOnLicenseExpiration, + onKeyExpiration } = options; const blacklistedKeyIds : Uint8Array[] = []; const whitelistedKeyIds : Uint8Array[] = []; - const { fallbackOn = {}, throwOnLicenseExpiration } = options; + const badKeyStatuses: IEncryptedMediaErrorKeyStatusObject[] = []; (session.keyStatuses.forEach as IKeyStatusesForEach)(( _arg1 : unknown, @@ -96,40 +107,63 @@ export default function checkKeyStatuses( const keyId = getUUIDKidFromKeyStatusKID(keySystem, new Uint8Array(keyStatusKeyId)); + + const keyStatusObj = { keyId: keyId.buffer, keyStatus }; switch (keyStatus) { case KEY_STATUSES.EXPIRED: { const error = new EncryptedMediaError( "KEY_STATUS_CHANGE_ERROR", - `A decryption key expired (${bytesToHex(keyId)})`); + `A decryption key expired (${bytesToHex(keyId)})`, + { keyStatuses: [keyStatusObj, ...badKeyStatuses] }); - if (throwOnLicenseExpiration !== false) { + if (onKeyExpiration === "error" || + (onKeyExpiration === undefined && throwOnLicenseExpiration === false)) + { throw error; } - warnings.push({ type: "warning", value: error }); - whitelistedKeyIds.push(keyId); + + switch (onKeyExpiration) { + case "close-session": + throw new DecommissionedSessionError(error); + case "fallback": + blacklistedKeyIds.push(keyId); + break; + default: + // I weirdly stopped relying on switch-cases here due to some TypeScript + // issue, not checking properly `case undefined` (bug?) + if (onKeyExpiration === "continue" || onKeyExpiration === undefined) { + whitelistedKeyIds.push(keyId); + } else { + // Compile-time check throwing when not all possible cases are handled + assertUnreachable(onKeyExpiration); + } + break; + } + + badKeyStatuses.push(keyStatusObj); break; } case KEY_STATUSES.INTERNAL_ERROR: { - const error = new EncryptedMediaError( - "KEY_STATUS_CHANGE_ERROR", - `A "${keyStatus}" status has been encountered (${bytesToHex(keyId)})`); if (fallbackOn.keyInternalError !== true) { - throw error; + throw new EncryptedMediaError( + "KEY_STATUS_CHANGE_ERROR", + `A "${keyStatus}" status has been encountered (${bytesToHex(keyId)})`, + { keyStatuses: [keyStatusObj, ...badKeyStatuses] }); } - warnings.push({ type: "warning", value: error }); + badKeyStatuses.push(keyStatusObj); blacklistedKeyIds.push(keyId); break; } case KEY_STATUSES.OUTPUT_RESTRICTED: { - const error = new EncryptedMediaError( - "KEY_STATUS_CHANGE_ERROR", - `A "${keyStatus}" status has been encountered (${bytesToHex(keyId)})`); if (fallbackOn.keyOutputRestricted !== true) { - throw error; + throw new EncryptedMediaError( + "KEY_STATUS_CHANGE_ERROR", + `A "${keyStatus}" status has been encountered (${bytesToHex(keyId)})`, + { keyStatuses: [keyStatusObj, ...badKeyStatuses] }); } - warnings.push({ type: "warning", value: error }); + badKeyStatuses.push(keyStatusObj); blacklistedKeyIds.push(keyId); break; } @@ -139,5 +173,15 @@ export default function checkKeyStatuses( break; } }); - return { warnings, blacklistedKeyIds, whitelistedKeyIds }; + + let warning; + if (badKeyStatuses.length > 0) { + warning = new EncryptedMediaError( + "KEY_STATUS_CHANGE_ERROR", + "One or several problematic key statuses have been encountered", + { keyStatuses: badKeyStatuses }); + } + return { warning, + blacklistedKeyIds, + whitelistedKeyIds }; } diff --git a/src/core/decrypt/utils/persistent_sessions_store.ts b/src/core/decrypt/utils/persistent_sessions_store.ts index 97a5d8b0db..53ec5f994e 100644 --- a/src/core/decrypt/utils/persistent_sessions_store.ts +++ b/src/core/decrypt/utils/persistent_sessions_store.ts @@ -177,8 +177,7 @@ export default class PersistentSessionsStore { /** * Delete stored MediaKeySession information based on its session id. - * @param {Uint8Array} initData - * @param {string|undefined} initDataType + * @param {string} sessionId */ public delete(sessionId : string) : void { let index = -1; diff --git a/src/core/stream/orchestrator/get_blacklisted_ranges.ts b/src/core/stream/orchestrator/get_time_ranges_for_content.ts similarity index 98% rename from src/core/stream/orchestrator/get_blacklisted_ranges.ts rename to src/core/stream/orchestrator/get_time_ranges_for_content.ts index 3b8190bd0a..118d616192 100644 --- a/src/core/stream/orchestrator/get_blacklisted_ranges.ts +++ b/src/core/stream/orchestrator/get_time_ranges_for_content.ts @@ -30,7 +30,7 @@ import { SegmentBuffer } from "../../segment_buffers"; * @param {Array.} contents * @returns {Array.} */ -export default function getBlacklistedRanges( +export default function getTimeRangesForContent( segmentBuffer : SegmentBuffer, contents : Array<{ adaptation : Adaptation; period : Period; diff --git a/src/core/stream/orchestrator/stream_orchestrator.ts b/src/core/stream/orchestrator/stream_orchestrator.ts index b9141e8153..9745e7d7ff 100644 --- a/src/core/stream/orchestrator/stream_orchestrator.ts +++ b/src/core/stream/orchestrator/stream_orchestrator.ts @@ -39,6 +39,7 @@ import config from "../../../config"; import { MediaError } from "../../../errors"; import log from "../../../log"; import Manifest, { + IDecipherabilityUpdateElement, Period, } from "../../../manifest"; import deferSubscriptions from "../../../utils/defer_subscriptions"; @@ -73,7 +74,7 @@ import { } from "../types"; import ActivePeriodEmitter from "./active_period_emitter"; import areStreamsComplete from "./are_streams_complete"; -import getBlacklistedRanges from "./get_blacklisted_ranges"; +import getTimeRangesForContent from "./get_time_ranges_for_content"; // NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default // first type parameter as `any` instead of the perfectly fine `unknown`, @@ -310,71 +311,102 @@ export default function StreamOrchestrator( }) ); - // Free the buffer of undecipherable data const handleDecipherabilityUpdate$ = fromEvent(manifest, "decipherabilityUpdate") - .pipe(mergeMap((updates) => { - const segmentBufferStatus = segmentBuffersStore.getStatus(bufferType); - const ofCurrentType = updates - .filter(update => update.adaptation.type === bufferType); - if (ofCurrentType.length === 0 || segmentBufferStatus.type !== "initialized") { - return EMPTY; // no need to stop the current Streams. - } - const undecipherableUpdates = ofCurrentType.filter(update => - update.representation.decipherable === false); - const segmentBuffer = segmentBufferStatus.value; - const rangesToClean = getBlacklistedRanges(segmentBuffer, undecipherableUpdates); - if (rangesToClean.length === 0) { - // Nothing to clean => no buffer to flush. - return EMPTY; - } + .pipe(mergeMap(onDecipherabilityUpdates)); - // We have to remove the undecipherable media data and then ask the - // current media element to be "flushed" + return observableMerge(restartStreamsWhenOutOfBounds$, + handleDecipherabilityUpdate$, + launchConsecutiveStreamsForPeriod(basePeriod)); - enableOutOfBoundsCheck = false; - destroyStreams$.next(); + function onDecipherabilityUpdates( + updates : IDecipherabilityUpdateElement[] + ) : Observable { + const segmentBufferStatus = segmentBuffersStore.getStatus(bufferType); + const ofCurrentType = updates + .filter(update => update.adaptation.type === bufferType); + if (ofCurrentType.length === 0 || segmentBufferStatus.type !== "initialized") { + return EMPTY; // no need to stop the current Streams. + } - return observableConcat( - ...rangesToClean.map(({ start, end }) => { - if (start >= end) { - return EMPTY; - } - const canceller = new TaskCanceller(); - return fromCancellablePromise(canceller, () => { - return segmentBuffer.removeBuffer(start, end, canceller.signal); - }).pipe(ignoreElements()); - }), - - // Schedule micro task before checking the last playback observation - // to reduce the risk of race conditions where the next observation - // was going to be emitted synchronously. - nextTickObs().pipe(ignoreElements()), - playbackObserver.getReference().asObservable().pipe( - take(1), - mergeMap((observation) => { + const segmentBuffer = segmentBufferStatus.value; + const resettedContent = ofCurrentType.filter(update => + update.representation.decipherable === undefined); + const undecipherableContent = ofCurrentType.filter(update => + update.representation.decipherable === false); + + /** + * Time ranges now containing undecipherable content. + * Those should first be removed and, depending on the platform, may + * need Supplementary actions as playback issues may remain even after + * removal. + */ + const undecipherableRanges = getTimeRangesForContent(segmentBuffer, + undecipherableContent); + + /** + * Time ranges now containing content whose decipherability status came + * back to being unknown. + * To simplify its handling, those are just removed from the buffer. + * Less considerations have to be taken than for the `undecipherableRanges`. + */ + const rangesToRemove = getTimeRangesForContent(segmentBuffer, + resettedContent); + + // First close all Stream currently active so they don't continue to + // load and push segments. + enableOutOfBoundsCheck = false; + destroyStreams$.next(); + + /** Remove from the `SegmentBuffer` all the concerned time ranges. */ + const cleanOperations = [...undecipherableRanges, ...rangesToRemove] + .map(({ start, end }) => { + if (start >= end) { + return EMPTY; + } + const canceller = new TaskCanceller(); + return fromCancellablePromise(canceller, () => { + return segmentBuffer.removeBuffer(start, end, canceller.signal); + }).pipe(ignoreElements()); + }); + + return observableConcat( + ...cleanOperations, + + // Schedule micro task before checking the last playback observation + // to reduce the risk of race conditions where the next observation + // was going to be emitted synchronously. + nextTickObs().pipe(ignoreElements()), + playbackObserver.getReference().asObservable().pipe( + take(1), + mergeMap((observation) => { + const restartStream$ = observableDefer(() => { + const lastPosition = observation.position.pending ?? + observation.position.last; + const newInitialPeriod = manifest.getPeriodForTime(lastPosition); + if (newInitialPeriod == null) { + throw new MediaError( + "MEDIA_TIME_NOT_FOUND", + "The wanted position is not found in the Manifest."); + } + return launchConsecutiveStreamsForPeriod(newInitialPeriod); + }); + + + if (needsFlushingAfterClean(observation, undecipherableRanges)) { const shouldAutoPlay = !(observation.paused.pending ?? playbackObserver.getIsPaused()); return observableConcat( observableOf(EVENTS.needsDecipherabilityFlush(observation.position.last, shouldAutoPlay, observation.duration)), - observableDefer(() => { - const lastPosition = observation.position.pending ?? - observation.position.last; - const newInitialPeriod = manifest.getPeriodForTime(lastPosition); - if (newInitialPeriod == null) { - throw new MediaError( - "MEDIA_TIME_NOT_FOUND", - "The wanted position is not found in the Manifest."); - } - return launchConsecutiveStreamsForPeriod(newInitialPeriod); - })); - }))); - })); - - return observableMerge(restartStreamsWhenOutOfBounds$, - handleDecipherabilityUpdate$, - launchConsecutiveStreamsForPeriod(basePeriod)); + restartStream$); + } else if (needsFlushingAfterClean(observation, rangesToRemove)) { + return observableConcat(observableOf(EVENTS.needsBufferFlush()), + restartStream$); + } + return restartStream$; + }))); + } } /** @@ -496,3 +528,29 @@ export default function StreamOrchestrator( destroyAll$.pipe(ignoreElements())); } } + +/** + * Returns `true` if low-level buffers have to be "flushed" after the given + * `cleanedRanges` time ranges have been removed from an audio or video + * SourceBuffer, to prevent playback issues. + * @param {Object} observation + * @param {Array.} cleanedRanges + * @returns {boolean} + */ +function needsFlushingAfterClean( + observation : IStreamOrchestratorPlaybackObservation, + cleanedRanges : Array<{ start: number; end: number }> +) : boolean { + if (cleanedRanges.length === 0) { + return false; + } + const curPos = observation.position.last; + + // Based on the playback direction, we just check whether we may encounter + // the corresponding ranges, without seeking or re-switching playback + // direction which is expected to lead to a low-level flush anyway. + // There's a 5 seconds security, just to be sure. + return observation.speed >= 0 ? + cleanedRanges[cleanedRanges.length - 1] .end >= curPos - 5 : + cleanedRanges[0].start <= curPos + 5; +} diff --git a/src/core/stream/types.ts b/src/core/stream/types.ts index 197219fcdb..70f7f171e4 100644 --- a/src/core/stream/types.ts +++ b/src/core/stream/types.ts @@ -410,7 +410,9 @@ export interface ILockedStreamEvent { * decipherability status of some `Representation`(s). * * When that event is emitted, the current HTMLMediaElement's buffer might need - * to be "flushed" to continue (e.g. through a little seek operation). + * to be "flushed" to continue (e.g. through a little seek operation) or in + * worst cases completely removed and re-created through the "reload" mechanism, + * depending on the platform. */ export interface INeedsDecipherabilityFlush { type: "needs-decipherability-flush"; diff --git a/src/errors/encrypted_media_error.ts b/src/errors/encrypted_media_error.ts index 3e4d92338a..92bd31c6a8 100644 --- a/src/errors/encrypted_media_error.ts +++ b/src/errors/encrypted_media_error.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { IEncryptedMediaErrorKeyStatusObject } from "../public_types"; import { ErrorTypes, IEncryptedMediaErrorCode, @@ -30,6 +31,7 @@ export default class EncryptedMediaError extends Error { public readonly name : "EncryptedMediaError"; public readonly type : "ENCRYPTED_MEDIA_ERROR"; public readonly code : IEncryptedMediaErrorCode; + public readonly keyStatuses? : IEncryptedMediaErrorKeyStatusObject[]; public message : string; public fatal : boolean; @@ -37,7 +39,20 @@ export default class EncryptedMediaError extends Error { * @param {string} code * @param {string} reason */ - constructor(code : IEncryptedMediaErrorCode, reason : string) { + constructor( + code : "KEY_STATUS_CHANGE_ERROR", + reason : string, + supplementaryInfos : { keyStatuses : IEncryptedMediaErrorKeyStatusObject[] } + ); + constructor( + code : Omit, + reason : string + ); + constructor( + code : IEncryptedMediaErrorCode, + reason : string, + supplementaryInfos? : { keyStatuses? : IEncryptedMediaErrorKeyStatusObject[] } + ) { super(); // @see https://stackoverflow.com/questions/41102060/typescript-extending-error-class Object.setPrototypeOf(this, EncryptedMediaError.prototype); @@ -48,5 +63,9 @@ export default class EncryptedMediaError extends Error { this.code = code; this.message = errorMessage(this.name, this.code, reason); this.fatal = false; + + if (typeof supplementaryInfos?.keyStatuses === "string") { + this.keyStatuses = supplementaryInfos.keyStatuses; + } } } diff --git a/src/manifest/index.ts b/src/manifest/index.ts index cd0c19883c..98675266bf 100644 --- a/src/manifest/index.ts +++ b/src/manifest/index.ts @@ -18,6 +18,7 @@ import Adaptation, { SUPPORTED_ADAPTATIONS_TYPE, } from "./adaptation"; import Manifest, { + IDecipherabilityUpdateElement, IManifestParsingOptions, ISupplementaryImageTrack, ISupplementaryTextTrack, @@ -54,6 +55,7 @@ export { // types IAdaptationType, IBaseContentInfos, + IDecipherabilityUpdateElement, IManifestParsingOptions, IMetaPlaylistPrivateInfos, IRepresentationIndex, diff --git a/src/public_types.ts b/src/public_types.ts index 42b4ba9a91..e64ae9bd7f 100644 --- a/src/public_types.ts +++ b/src/public_types.ts @@ -520,6 +520,7 @@ export interface IKeySystemOption { /** * If explicitely set to `false`, we won't throw on error when a used license * is expired. + * @deprecated */ throwOnLicenseExpiration? : boolean; /** @@ -549,6 +550,46 @@ export interface IKeySystemOption { */ keyOutputRestricted? : boolean; }; + + /** + * Behavior the RxPlayer should have when one of the key is known to be + * expired. + * + * `onKeyExpiration` can be set to a string, each describing a different + * behavior, the default one if not is defined being `"error"`: + * + * - `"error"`: The RxPlayer will stop on an error when any key is expired. + * This is the default behavior. + * + * - `"continue"`: The RxPlayer will not do anything when a key expires. + * This may lead in many cases to infinite rebuffering. + * + * - `"fallback"`: The Representation(s) linked to the expired key(s) will + * be fallbacked from, meaning the RxPlayer will switch to other + * representation without expired keys. + * + * If no Representation remain, a NO_PLAYABLE_REPRESENTATION error will + * be thrown. + * + * Note that when the "fallbacking" action is taken, the RxPlayer might + * temporarily switch to the `"RELOADING"` state - which should thus be + * properly handled. + * + * - `"close-session"`: The RxPlayer will close and re-create a DRM session + * (and thus re-download the corresponding license) if any of the key + * associated to this session expired. + * + * It will try to do so in an efficient manner, only reloading the license + * when the corresponding content plays. + * + * The RxPlayer might go through the `"RELOADING"` state after an expired + * key and/or light decoding glitches can arise, depending on the + * platform, for some seconds, under that mode. + */ + onKeyExpiration? : "error" | + "continue" | + "fallback" | + "close-session"; } /** @@ -756,3 +797,15 @@ export interface IAvailableTextTrack /** Video track from a list of video tracks returned by the RxPlayer. */ export interface IAvailableVideoTrack extends IVideoTrack { active : boolean } + +/** + * Type of a single object from the optional `EncryptedMediaError`'s + * `keyStatuses` property. + */ +export interface IEncryptedMediaErrorKeyStatusObject { + /** Corresponding keyId which encountered the problematic MediaKeyStatus. */ + keyId: ArrayBuffer; + + /** Problematic MediaKeyStatus encountered. */ + keyStatus: MediaKeyStatus; +}