Skip to content
Draft
37 changes: 36 additions & 1 deletion demo/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,44 @@ if (demoConfig) {

const hlsjsDefaults = {
debug: true,
enableWorker: true,
enableWorker: false,
lowLatencyMode: true,
backBufferLength: 60 * 1.5,
emeEnabled: true,
drmSystems: {
'com.microsoft.playready': {
licenseUrl: 'https://shield-drm.imggaming.com/api/v2/license',
},
},
drmSystemOptions: {
videoRobustness: '3000',
audioRobustness: '3000',
},
requiresEncryptionInfoInAllInitSegments: false,
licenseXhrSetup: async function (xhr) {
const res = await fetch(
'https://shield-api.imggaming.com/admin/v1/ovp/dice/client/dce.sandbox/action/sign_test_content_token',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ttl_seconds: 300,
claims: {
eid: '786df1e4-9a75-4052-8625-204ba23b2bae',
aid: '00000000-0000-0000-0000-000000000000',
did: '00000000-0000-0000-0000-000000000000',
def: 'uhd2',
},
}),
}
);
const { token } = await res.json();
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.setRequestHeader(
'X-DRM-INFO',
btoa(JSON.stringify({ system: 'com.microsoft.playready' }))
);
},
};

let enableStreaming = getDemoConfigPropOrDefault('enableStreaming', true);
Expand Down
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,8 @@
"typescript": "5.8.3",
"url-toolkit": "2.2.5",
"wrangler": "4.26.1"
},
"dependencies": {
"mp4box": "^2.3.0"
}
}
48 changes: 46 additions & 2 deletions src/controller/base-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
getPartWith,
updateFragPTSDTS,
} from '../utils/level-helper';
import { appendUint8Array } from '../utils/mp4-tools';
import { appendUint8Array, dumpSegment } from '../utils/mp4-tools';

Check warning on line 42 in src/controller/base-stream-controller.ts

View workflow job for this annotation

GitHub Actions / build

'dumpSegment' is defined but never used
import { patchClearInitSegment } from '../utils/playready-workaround';
import TimeRanges from '../utils/time-ranges';
import type { FragmentTracker } from './fragment-tracker';
import type { HlsConfig } from '../config';
Expand Down Expand Up @@ -122,8 +123,13 @@
protected initPTS: TimestampOffset[] = [];
protected buffering: boolean = true;
protected loadingParts: boolean = false;
protected firstEncryptedInitSegmentData: Uint8Array | null = null;
protected firstEncryptedMediaSegmentData: Uint8Array | null = null;
protected waitingForInitSegmentAppend: boolean = false;
private loopSn?: string | number;

private encryptedInitSegmentPatched: boolean = false;

constructor(
hls: Hls,
fragmentTracker: FragmentTracker,
Expand Down Expand Up @@ -329,6 +335,7 @@
return;
}
this.loadingParts = false;
this.encryptedInitSegmentPatched = false;
this.fragmentTracker.removeAllFragments();
this.stopLoad();
}
Expand Down Expand Up @@ -534,9 +541,17 @@
}

if ('payload' in data) {
// dumpSegment(
// 'media segment',
// new Uint8Array(data.payload),
// 'media segment',
// );
this.log(
`Loaded ${frag.type} sn: ${frag.sn} of ${this.playlistLabel()} ${frag.level}`,
);
if (hls.config.requiresEncryptionInfoInAllInitSegments) {
data.payload = this.patchMediaSegment(data.payload);
}
this.hls.trigger(Events.FRAG_LOADED, data);
}

Expand All @@ -552,6 +567,10 @@
});
}

protected patchMediaSegment(payload: ArrayBuffer): ArrayBuffer {
return payload;
}

protected clearTrackerIfNeeded(frag: Fragment) {
const { fragmentTracker } = this;
const fragState = fragmentTracker.getState(frag);
Expand Down Expand Up @@ -692,6 +711,28 @@
return this.completeInitSegmentLoad(data);
});
}
console.log(

Check warning on line 714 in src/controller/base-stream-controller.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
'$$$ _loadInitSegment - no decryption, payload byteLength:',
payload?.byteLength,
data,
);
if (
this.firstEncryptedInitSegmentData &&
!this.encryptedInitSegmentPatched &&
hls.config.requiresEncryptionInfoInAllInitSegments
) {
this.encryptedInitSegmentPatched = true;
console.log(

Check warning on line 725 in src/controller/base-stream-controller.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
'$$$ _loadInitSegment - TODO patching init segment with encryption data from first encrypted fragment',
this.firstEncryptedInitSegmentData?.byteLength,
);
const patched = patchClearInitSegment(
new Uint8Array(data.payload),
this.firstEncryptedInitSegmentData,
);
data.frag.data = patched;
data.payload = patched.buffer;
}
return this.completeInitSegmentLoad(data);
})
.catch((reason) => {
Expand All @@ -712,7 +753,10 @@
if (this.state !== State.STOPPED) {
this.state = State.IDLE;
}
data.frag.data = new Uint8Array(data.payload);
if (!data.frag.data) {
data.frag.data = new Uint8Array(data.payload);
}
this.waitingForInitSegmentAppend = false;
stats.parsing.start = stats.buffering.start = self.performance.now();
stats.parsing.end = stats.buffering.end = self.performance.now();
this.tick();
Expand Down
2 changes: 2 additions & 0 deletions src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export default class BufferController extends Logger implements ComponentAPI {

constructor(hls: Hls, fragmentTracker: FragmentTracker) {
super('buffer-controller', hls.logger);
this.debug('$$$ BufferController constructor');
this.hls = hls;
this.fragmentTracker = fragmentTracker;
this.appendSource = isManagedMediaSource(
Expand Down Expand Up @@ -254,6 +255,7 @@ export default class BufferController extends Logger implements ComponentAPI {
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
) {
this.log('$$$ BufferController onManifestParsed', data);
// in case of alt audio 2 BUFFER_CODECS events will be triggered, one per stream controller
// sourcebuffers will be created all at once when the expected nb of tracks will be reached
// in case alt audio is not used, only one BUFFER_CODEC event will be fired from main stream controller
Expand Down
97 changes: 91 additions & 6 deletions src/controller/eme-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class EMEController extends Logger implements ComponentAPI {
? [EMEController.CDMCleanupPromise]
: [];
private bannedKeyIds: { [keyId: string]: MediaKeyStatus | undefined } = {};
private pendingCencInitData: ArrayBuffer | null = null;

constructor(hls: Hls) {
super('eme', hls.logger);
Expand Down Expand Up @@ -138,7 +139,12 @@ class EMEController extends Logger implements ComponentAPI {

private getLicenseServerUrl(keySystem: KeySystems): string | undefined {
const { drmSystems, widevineLicenseUrl } = this.config;
const keySystemConfiguration = drmSystems?.[keySystem];
// const keySystemConfiguration = drmSystems?.[keySystem];
const keySystemConfiguration =
drmSystems?.[keySystem] ??
(keySystem === KeySystems.PLAYREADY_RECOMMENDATION
? drmSystems?.[KeySystems.PLAYREADY]
: undefined);

if (keySystemConfiguration) {
return keySystemConfiguration.licenseUrl;
Expand All @@ -162,8 +168,12 @@ class EMEController extends Logger implements ComponentAPI {

private getServerCertificateUrl(keySystem: KeySystems): string | void {
const { drmSystems } = this.config;
const keySystemConfiguration = drmSystems?.[keySystem];

// const keySystemConfiguration = drmSystems?.[keySystem];
const keySystemConfiguration =
drmSystems?.[keySystem] ??
(keySystem === KeySystems.PLAYREADY_RECOMMENDATION
? drmSystems?.[KeySystems.PLAYREADY]
: undefined);
if (keySystemConfiguration) {
return keySystemConfiguration.serverCertificateUrl;
} else {
Expand Down Expand Up @@ -439,7 +449,17 @@ class EMEController extends Logger implements ComponentAPI {
.filter(
(value) => !!value && keySystemsInConfig.indexOf(value) !== -1,
) as any as KeySystems[];

// Chrome on Windows registers PlayReady as the recommendation variant.
// When the standard key system is in the attempt list, add the recommendation
// variant as an immediate fallback so attemptKeySystemAccess tries it next.
if (keySystemsToAttempt.indexOf(KeySystems.PLAYREADY) !== -1) {
const idx = keySystemsToAttempt.indexOf(KeySystems.PLAYREADY);
keySystemsToAttempt.splice(
idx + 1,
0,
KeySystems.PLAYREADY_RECOMMENDATION,
);
}
return this.selectKeySystem(keySystemsToAttempt);
}

Expand Down Expand Up @@ -570,7 +590,7 @@ class EMEController extends Logger implements ComponentAPI {
const keySystemsToAttempt = keySystem
? [keySystem]
: getKeySystemsForConfig(this.config);
return this.attemptKeySystemAccess(keySystemsToAttempt);
return this.getKeySystemSelectionPromise(keySystemsToAttempt);
}
return mediaKeySessionContext;
}
Expand All @@ -593,6 +613,19 @@ class EMEController extends Logger implements ComponentAPI {
})}`,
);
}
// Add recommendation variant as fallback if base PlayReady is in the list
const playreadyIdx = keySystemsToAttempt.indexOf(KeySystems.PLAYREADY);
if (
playreadyIdx !== -1 &&
keySystemsToAttempt.indexOf(KeySystems.PLAYREADY_RECOMMENDATION) === -1
) {
keySystemsToAttempt = keySystemsToAttempt.slice();
keySystemsToAttempt.splice(
playreadyIdx + 1,
0,
KeySystems.PLAYREADY_RECOMMENDATION,
);
}
return this.attemptKeySystemAccess(keySystemsToAttempt);
}

Expand Down Expand Up @@ -622,6 +655,48 @@ class EMEController extends Logger implements ComponentAPI {
this.keyFormatPromise
.then((keySystemFormat) => {
const keySystem = keySystemFormatToKeySystemDomain(keySystemFormat);
if (initDataType === 'cenc') {
// Handle CENC encrypted events for non-FairPlay key systems (e.g., PlayReady).
// This fires when we append a patched init segment containing encv+sinf+PSSH
// for a clear segment that had no PSSH. Find the session that was created from
// playlist key info but had generateRequest() skipped due to missing PSSH, and
// supply the PSSH from this event so the CDM can activate.
const { keyIdToKeySessionPromise, mediaKeySessions } = this;
for (let i = 0; i < mediaKeySessions.length; i++) {
const keyContext = mediaKeySessions[i];
if (
keySystemDomainToKeySystemFormat(keyContext.keySystem) !==
keySystemFormat
) {
continue;
}
const decryptdata = keyContext.decryptdata;
if (decryptdata.pssh) {
continue;
}
const keyId = getKeyIdString(decryptdata);
decryptdata.pssh = new Uint8Array(initData);
const existing =
keyIdToKeySessionPromise[keyId] || Promise.resolve(keyContext);
const cencPromise = existing.then(() =>
this.generateRequestWithPreferredKeySession(
keyContext,
initDataType,
initData,
'encrypted-event-key-match',
),
);
cencPromise.catch((error) => this.handleError(error));
keyIdToKeySessionPromise[keyId] = cencPromise;
return;
}
this.log(
`Storing PSSH from "${event.type}" event for key-system ${keySystem} (no session exists yet)`,
);
this.pendingCencInitData = initData;
return;
}

if (initDataType !== 'sinf' || keySystem !== KeySystems.FAIRPLAY) {
this.log(
`Ignoring "${event.type}" event with init data type: "${initDataType}" for selected key-system ${keySystem}`,
Expand Down Expand Up @@ -776,6 +851,13 @@ class EMEController extends Logger implements ComponentAPI {
}
}

if (initData === null && this.pendingCencInitData) {
this.log('Using stored CENC PSSH for generateRequest');
initData = this.pendingCencInitData;
this.pendingCencInitData = null;
context.decryptdata.pssh = new Uint8Array(initData);
}

if (initData === null) {
this.log(`Skipping key-session request for "${reason}" (no initData)`);
return Promise.resolve(context);
Expand Down Expand Up @@ -1332,7 +1414,10 @@ class EMEController extends Logger implements ComponentAPI {

this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge)
.then(({ xhr, licenseChallenge }) => {
if (keySessionContext.keySystem == KeySystems.PLAYREADY) {
if (
keySessionContext.keySystem == KeySystems.PLAYREADY ||
keySessionContext.keySystem == KeySystems.PLAYREADY_RECOMMENDATION
) {
licenseChallenge = this.unpackPlayReadyKeyMessage(
xhr,
licenseChallenge,
Expand Down
Loading
Loading