Skip to content

Commit

Permalink
[Proposal] DRM: add failOnEncryptedAfterClear keySystems option
Browse files Browse the repository at this point in the history
An application reported to us an issue where they couldn't play a
content of mixed clear and encrypted contents on PlayReady devices.

After a LOT of attempts at work-arounds, some of them described
[here](#1403), we didn't
succeed to actually find a good solution that would both allow smooth
transition between Periods and a mix of encrypted unencrypted content.

So I attempted to play the same content with other players:

  - dash.js didn't had a smooth transition between Periods here, it
    first loaded and played the clear content, then once finished loaded
    and played the encrypted content with a black screen and license
    request in-between.

  - The shaka-player failed to play the content, I tried to debug it but
    I did not succeed to make it play the content.

This is very probably an issue with PlayReady, yet they historically
haven't been quick to fix issues we reported, so we may have to provide
a perhaps-temporary solution here.

That's why I'm introducing the for-now undocumented (and experimental
feature?) `keySystems[].failOnEncryptedAfterClear` `loadVideo` option.

When set to `true`, we'll reload if an encrypted Period is encountered
after a clear Period has been played. The logic for now is not perfect
with very rare risks of false negatives.

NOTE: I also profited from this commit to add the `canFilterProtectionData`
option to the streams. Previously it was implicitly enabled when the
`drmSystemId` was defined.
  • Loading branch information
peaBerberian committed Aug 29, 2024
1 parent e9f9758 commit 5c332da
Show file tree
Hide file tree
Showing 21 changed files with 539 additions and 263 deletions.
26 changes: 25 additions & 1 deletion src/core/main/worker/worker_main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,19 @@ interface IBufferingInitializationInformation {
* optimizations.
*/
drmSystemId: string | undefined;
/**
* If `true`, protection data as found in the content can be manipulated so
* e.g. only the data linked to the given systemId may be communicated.
*
* If `false` the full extent of the protection data, in exactly the way it
* has been found in the content, should be communicated.
*/
canFilterProtectionData: boolean;
/**
* If `true`, the current device is known to not be able to begin playback of
* encrypted content if there's already clear content playing.
*/
failOnEncryptedAfterClear: boolean;
/**
* Enable/Disable fastSwitching: allow to replace lower-quality segments by
* higher-quality ones to have a faster transition.
Expand Down Expand Up @@ -513,7 +526,14 @@ function loadOrReloadPreparedContent(
segmentSinksStore,
segmentFetcherCreator,
} = preparedContent;
const { drmSystemId, enableFastSwitching, initialTime, onCodecSwitch } = val;
const {
canFilterProtectionData,
failOnEncryptedAfterClear,
drmSystemId,
enableFastSwitching,
initialTime,
onCodecSwitch,
} = val;
playbackObservationRef.onUpdate((observation) => {
if (preparedContent.decipherabilityFreezeDetector.needToReload(observation)) {
handleMediaSourceReload({
Expand Down Expand Up @@ -592,6 +612,8 @@ function loadOrReloadPreparedContent(
maxVideoBufferSize,
maxBufferAhead,
maxBufferBehind,
canFilterProtectionData,
failOnEncryptedAfterClear,
drmSystemId,
enableFastSwitching,
onCodecSwitch,
Expand Down Expand Up @@ -874,6 +896,8 @@ function loadOrReloadPreparedContent(
{
initialTime: newInitialTime,
drmSystemId: val.drmSystemId,
canFilterProtectionData: val.canFilterProtectionData,
failOnEncryptedAfterClear: val.failOnEncryptedAfterClear,
enableFastSwitching: val.enableFastSwitching,
onCodecSwitch: val.onCodecSwitch,
},
Expand Down
1 change: 1 addition & 0 deletions src/core/stream/adaptation/adaptation_stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ export default function AdaptationStream(
bufferGoal,
maxBufferSize,
drmSystemId: options.drmSystemId,
canFilterProtectionData: options.canFilterProtectionData,
fastSwitchThreshold,
},
},
Expand Down
8 changes: 8 additions & 0 deletions src/core/stream/adaptation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ export interface IAdaptationStreamOptions {
* those devices.
*/
enableFastSwitching: boolean;
/**
* If `true`, protection data as found in the content can be manipulated so
* e.g. only the data linked to the given systemId may be communicated.
*
* If `false` the full extent of the protection data, in exactly the way it
* has been found in the content, should be communicated.
*/
canFilterProtectionData: boolean;
}

/** Object indicating a choice of Adaptation made by the user. */
Expand Down
59 changes: 59 additions & 0 deletions src/core/stream/orchestrator/stream_orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ export default function StreamOrchestrator(
MAXIMUM_MAX_BUFFER_BEHIND,
} = config.getCurrent();

// Some DRM issues force us to check whether we're initially pushing clear
// segments.
//
// NOTE: Theoretically `initialPeriod` may not be the first Period for
// which we push segments (e.g. we may have a small seek which lead to
// another Period being streamed instead).
// This means that we could have an issue if `initialPeriod` leads to encrypted
// content, but the actually first-pushed segments do not. Here
// `shouldReloadOnEncryptedContent` would be set to `false` despiste the fact
// that it should be set to `true`.
// Yet, checking the first Period for which we pushed segments seems very hard,
// and all that for what is now (2024-07-31) a PlayReady bug, I don't have the
// will.
const shouldReloadOnEncryptedContent =
options.failOnEncryptedAfterClear && !hasEncryptedContentInPeriod(initialPeriod);

// Keep track of a unique BufferGarbageCollector created per
// SegmentSink.
const garbageCollectors = new WeakMapMemory((segmentSink: SegmentSink) => {
Expand Down Expand Up @@ -449,6 +465,26 @@ export default function StreamOrchestrator(
): void {
log.info("Stream: Creating new Stream for", bufferType, basePeriod.start);

if (shouldReloadOnEncryptedContent) {
if (hasEncryptedContentInPeriod(basePeriod)) {
playbackObserver.listen((pos) => {
if (pos.position.getWanted() > basePeriod.start - 0.01) {
callbacks.needsMediaSourceReload({
timeOffset: 0,
minimumPosition: basePeriod.start,
maximumPosition: basePeriod.end,
});
} else {
callbacks.lockedStream({
period: basePeriod,
bufferType,
});
}
});
return;
}
}

/**
* Contains properties linnked to the next chronological `PeriodStream` that
* may be created here.
Expand Down Expand Up @@ -656,6 +692,11 @@ export type IStreamOrchestratorOptions = IPeriodStreamOptions & {
maxVideoBufferSize: IReadOnlySharedReference<number>;
maxBufferAhead: IReadOnlySharedReference<number>;
maxBufferBehind: IReadOnlySharedReference<number>;
/**
* If `true`, the current device is known to not be able to begin playback of
* encrypted content if there's already clear content playing.
*/
failOnEncryptedAfterClear: boolean;
};

/** Callbacks called by the `StreamOrchestrator` on various events. */
Expand Down Expand Up @@ -760,6 +801,24 @@ export interface ILockedStreamPayload {
bufferType: IBufferType;
}

/**
* Return `true` if the given Period has at least one Representation which is
* known to be encrypted.
*
* @param {Object} period
* @returns {boolean}
*/
function hasEncryptedContentInPeriod(period: IPeriod): boolean {
for (const adaptation of period.getAdaptations()) {
for (const representation of adaptation.representations) {
if (representation.contentProtections !== undefined) {
return true;
}
}
}
return false;
}

/**
* Returns `true` if low-level buffers have to be "flushed" after the given
* `cleanedRanges` time ranges have been removed from an audio or video
Expand Down
10 changes: 8 additions & 2 deletions src/core/stream/representation/representation_stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,13 @@ export default function RepresentationStream<TSegmentDataType>(
parentCancelSignal: CancellationSignal,
): void {
const { period, adaptation, representation } = content;
const { bufferGoal, maxBufferSize, drmSystemId, fastSwitchThreshold } = options;
const {
bufferGoal,
maxBufferSize,
drmSystemId,
canFilterProtectionData,
fastSwitchThreshold,
} = options;
const bufferType = adaptation.type;

/** `TaskCanceller` stopping ALL operations performed by the `RepresentationStream` */
Expand Down Expand Up @@ -148,7 +154,7 @@ export default function RepresentationStream<TSegmentDataType>(
// If the DRM system id is already known, and if we already have encryption data
// for it, we may not need to wait until the initialization segment is loaded to
// signal required protection data, thus performing License negotiations sooner
if (drmSystemId !== undefined) {
if (canFilterProtectionData && drmSystemId !== undefined) {
const encryptionData = representation.getEncryptionData(drmSystemId);

// If some key ids are not known yet, it may be safer to wait for this initialization
Expand Down
8 changes: 8 additions & 0 deletions src/core/stream/representation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,14 @@ export interface IRepresentationStreamOptions {
* `0` can be emitted to disable any kind of fast-switching.
*/
fastSwitchThreshold: IReadOnlySharedReference<undefined | number>;
/**
* If `true`, protection data as found in the content can be manipulated so
* e.g. only the data linked to the given systemId may be communicated.
*
* If `false` the full extent of the protection data, in exactly the way it
* has been found in the content, should be communicated.
*/
canFilterProtectionData: boolean;
}

/** Object indicating a choice of allowed Representations made by the user. */
Expand Down
62 changes: 35 additions & 27 deletions src/main_thread/decrypt/__tests__/__global__/get_license.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { describe, afterEach, it, expect, vi } from "vitest";

import type { IPlayerError } from "../../../../public_types";
import { concat } from "../../../../utils/byte_parsing";
import type { IContentDecryptorStateData } from "../../types";
import {
formatFakeChallengeFromInitData,
MediaKeySessionImpl,
Expand Down Expand Up @@ -376,37 +377,44 @@ async function checkGetLicense({
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);

contentDecryptor.addEventListener("stateChange", (newState: number) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
const waitForAttachmentCallback = (newState: IContentDecryptorStateData) => {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState.name}`));
}
contentDecryptor.removeEventListener("stateChange");
contentDecryptor.removeEventListener("stateChange", waitForAttachmentCallback);
contentDecryptor.attach();
});

contentDecryptor.addEventListener("error", (error: unknown) => {
if (shouldFail) {
try {
checkKeyLoadError(error);
expect(mockGetLicense).toHaveBeenCalledTimes(maxRetries + 1);
for (let i = 1; i <= maxRetries + 1; i++) {
// TODO there's seem to be an issue with how vitest check Uint8Array
// equality
expect(mockGetLicense).toHaveBeenNthCalledWith(
i,
challenge,
"license-request",
);
};
contentDecryptor.addEventListener("stateChange", waitForAttachmentCallback);
contentDecryptor.addEventListener(
"stateChange",
(state: IContentDecryptorStateData) => {
if (state.name !== ContentDecryptorState.Error) {
return;
}
const error = state.payload;
if (shouldFail) {
try {
checkKeyLoadError(error);
expect(mockGetLicense).toHaveBeenCalledTimes(maxRetries + 1);
for (let i = 1; i <= maxRetries + 1; i++) {
// TODO there's seem to be an issue with how vitest check Uint8Array
// equality
expect(mockGetLicense).toHaveBeenNthCalledWith(
i,
challenge,
"license-request",
);
}
expect(mockUpdate).toHaveBeenCalledTimes(0);
res();
} catch (e) {
rej(e);
}
expect(mockUpdate).toHaveBeenCalledTimes(0);
res();
} catch (e) {
rej(e);
} else {
rej(new Error(`Unexpected error: ${error}`));
}
} else {
rej(new Error(`Unexpected error: ${error}`));
}
});
},
);

contentDecryptor.addEventListener("warning", (warning: Error) => {
if (warningsLeft-- > 0) {
Expand Down
18 changes: 9 additions & 9 deletions src/main_thread/decrypt/__tests__/__global__/init_data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down Expand Up @@ -104,7 +104,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down Expand Up @@ -176,7 +176,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down Expand Up @@ -276,7 +276,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down Expand Up @@ -347,7 +347,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down Expand Up @@ -403,7 +403,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down Expand Up @@ -481,7 +481,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down Expand Up @@ -577,7 +577,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down Expand Up @@ -661,7 +661,7 @@ describe("decrypt - global tests - init data", () => {
// == test ==
const contentDecryptor = new ContentDecryptor(videoElt, ksConfig);
contentDecryptor.addEventListener("stateChange", (newState: any) => {
if (newState !== ContentDecryptorState.WaitingForAttachment) {
if (newState.name !== ContentDecryptorState.WaitingForAttachment) {
rej(new Error(`Unexpected state: ${newState}`));
}
contentDecryptor.removeEventListener("stateChange");
Expand Down
Loading

0 comments on commit 5c332da

Please sign in to comment.