Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1840,6 +1840,8 @@ export interface FragDecryptedData {
// (undocumented)
frag: Fragment;
// (undocumented)
part: Part | null;
// (undocumented)
payload: ArrayBuffer;
// (undocumented)
stats: {
Expand Down Expand Up @@ -5015,6 +5017,8 @@ export class SubtitleStreamController extends BaseStreamController implements Ne
// (undocumented)
_handleFragmentLoadComplete(fragLoadedData: FragLoadedData): void;
// (undocumented)
protected _handleFragmentLoadProgress(data: FragLoadedData): void;
// (undocumented)
protected loadFragment(frag: MediaFragment, level: Level, targetBufferTime: number): void;
// (undocumented)
get mediaBufferTimeRanges(): Bufferable;
Expand Down
1 change: 1 addition & 0 deletions src/controller/base-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,7 @@ export default class BaseStreamController
const endTime = self.performance.now();
hls.trigger(Events.FRAG_DECRYPTED, {
frag,
part: null,
payload: decryptedData,
stats: {
tstart: startTime,
Expand Down
117 changes: 70 additions & 47 deletions src/controller/subtitle-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import BaseStreamController, { State } from './base-stream-controller';
import { FragmentState } from './fragment-tracker';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { isMediaFragment, type MediaFragment } from '../loader/fragment';
import {
type Fragment,
isMediaFragment,
type MediaFragment,
type Part,
} from '../loader/fragment';
import { Level } from '../types/level';
import { PlaylistLevelType } from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
Expand Down Expand Up @@ -333,58 +338,76 @@ export class SubtitleStreamController
this.tickImmediate();
}

_handleFragmentLoadComplete(fragLoadedData: FragLoadedData) {
const { frag, payload } = fragLoadedData;
const decryptData = frag.decryptdata;
const hls = this.hls;

if (this.fragContextChanged(frag)) {
// Decrypt encrypted VTT parts as they arrive. The base class leaves progress a
// no-op for subtitles, so without this override encrypted parts would only decrypt
// at full-segment boundaries, losing the low-latency path.
protected _handleFragmentLoadProgress(data: FragLoadedData) {
const { frag, part, payload } = data;
if (!part || !payload?.byteLength || !this.shouldDecrypt(frag)) {
return;
}
// check to see if the payload needs to be decrypted
this.decryptPayload(payload, frag, part);
}

_handleFragmentLoadComplete(fragLoadedData: FragLoadedData) {
const { frag, payload } = fragLoadedData;
if (
payload &&
payload.byteLength > 0 &&
decryptData?.key &&
decryptData.iv &&
isFullSegmentEncryption(decryptData.method)
this.fragContextChanged(frag) ||
!payload?.byteLength ||
!this.shouldDecrypt(frag)
) {
const startTime = performance.now();
// decrypt the subtitles
this.decrypter
.decrypt(
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer,
getAesModeFromFullSegmentMethod(decryptData.method),
)
.catch((err) => {
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_DECRYPT_ERROR,
fatal: false,
error: err,
reason: err.message,
frag,
});
throw err;
})
.then((decryptedData) => {
const endTime = performance.now();
hls.trigger(Events.FRAG_DECRYPTED, {
frag,
payload: decryptedData,
stats: {
tstart: startTime,
tdecrypt: endTime,
},
});
})
.catch((err) => {
return;
}
this.decryptPayload(payload, frag, null);
}

private shouldDecrypt(frag: Fragment): boolean {
const d = frag.decryptdata;
return !!(d?.key && d.iv && isFullSegmentEncryption(d.method));
}

// Decrypt a full segment (part === null) or a single LL-HLS part and emit
// FRAG_DECRYPTED. On full-segment failure, reset to IDLE so the next tick retries.
private decryptPayload(
payload: ArrayBuffer,
frag: Fragment,
part: Part | null,
): void {
const decryptData = frag.decryptdata!;
const tstart = performance.now();
this.decrypter
.decrypt(
new Uint8Array(payload),
decryptData.key!.buffer,
decryptData.iv!.buffer,
getAesModeFromFullSegmentMethod(decryptData.method),
)
.then((plaintext) => {
if (this.fragContextChanged(frag)) {
return;
}
this.hls.trigger(Events.FRAG_DECRYPTED, {
frag,
part,
payload: plaintext,
stats: { tstart, tdecrypt: performance.now() },
});
})
.catch((err) => {
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_DECRYPT_ERROR,
fatal: false,
error: err,
reason: err.message,
frag,
part,
});
if (!part) {
this.warn(`${err.name}: ${err.message}`);
this.state = State.IDLE;
});
}
}
});
}

doTick() {
Expand Down
1 change: 1 addition & 0 deletions src/types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export interface PartsLoadedData {

export interface FragDecryptedData {
frag: Fragment;
part: Part | null;
payload: ArrayBuffer;
stats: {
tstart: number;
Expand Down
145 changes: 145 additions & 0 deletions tests/unit/controller/subtitle-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import sinonChai from 'sinon-chai';
import { State } from '../../../src/controller/base-stream-controller';
import { FragmentTracker } from '../../../src/controller/fragment-tracker';
import { SubtitleStreamController } from '../../../src/controller/subtitle-stream-controller';
import Decrypter from '../../../src/crypt/decrypter';
import { ErrorDetails } from '../../../src/errors';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { Fragment } from '../../../src/loader/fragment';
Expand Down Expand Up @@ -164,4 +166,147 @@ describe('SubtitleStreamController', function () {
expect(subtitleStreamController.fragPrevious).to.not.exist;
});
});

describe('_handleFragmentLoadProgress', function () {
let sandbox;

beforeEach(function () {
sandbox = sinon.createSandbox();
});

afterEach(function () {
sandbox.restore();
});

function buildEncryptedSubtitleFrag() {
const frag = new Fragment(PlaylistLevelType.SUBTITLE, '');
const decryptdata = {
encrypted: true,
method: 'AES-128',
key: new Uint8Array(16),
iv: new Uint8Array(16),
};
// Override Fragment's `decryptdata` and `encrypted` getters with a
// minimal LevelKey-like stub so we don't need to wire up `levelkeys`.
Object.defineProperty(frag, 'decryptdata', {
value: decryptdata,
configurable: true,
});
Object.defineProperty(frag, 'encrypted', {
value: true,
configurable: true,
});
return frag;
}

function buildPart(frag: Fragment) {
return {
index: 0,
fragment: frag,
start: 0,
duration: 1,
} as any;
}

function flushPromises() {
// Drain the .then -> .catch -> .finally microtask chain before asserting.
let p = Promise.resolve();
for (let i = 0; i < 5; i++) {
p = p.then(() => undefined);
}
return p;
}

function loadProgress(frag, part, payload) {
// fragContextChanged compares frag against fragCurrent; align them so the
// controller does not bail out of the post-decrypt continuation.
subtitleStreamController.fragCurrent = frag;
subtitleStreamController._handleFragmentLoadProgress({
frag,
part,
payload,
networkDetails: null,
});
}

it('does nothing when called without a part (full-segment progress)', function () {
const triggerSpy = sandbox.spy(hls, 'trigger');
const frag = buildEncryptedSubtitleFrag();
loadProgress(frag, null, new ArrayBuffer(16));
expect(triggerSpy).to.not.have.been.calledWith(Events.FRAG_DECRYPTED);
});

it('does nothing when the payload is empty', function () {
const triggerSpy = sandbox.spy(hls, 'trigger');
const frag = buildEncryptedSubtitleFrag();
loadProgress(frag, buildPart(frag), new ArrayBuffer(0));
expect(triggerSpy).to.not.have.been.calledWith(Events.FRAG_DECRYPTED);
});

it('does nothing when the fragment is not encrypted', function () {
const triggerSpy = sandbox.spy(hls, 'trigger');
const frag = new Fragment(PlaylistLevelType.SUBTITLE, '');
loadProgress(frag, buildPart(frag), new ArrayBuffer(16));
expect(triggerSpy).to.not.have.been.calledWith(Events.FRAG_DECRYPTED);
});

it('decrypts an encrypted VTT part and triggers FRAG_DECRYPTED with the part', async function () {
const plaintext = new ArrayBuffer(16);
const decryptStub = sandbox
.stub(Decrypter.prototype, 'decrypt')
.resolves(plaintext);
const triggerSpy = sandbox.spy(hls, 'trigger');

const frag = buildEncryptedSubtitleFrag();
const part = buildPart(frag);
loadProgress(frag, part, new ArrayBuffer(16));

await flushPromises();

expect(decryptStub).to.have.been.calledOnce;
const calls = triggerSpy.getCalls();
let decryptedCall;
for (let i = 0; i < calls.length; i++) {
if (calls[i].args[0] === Events.FRAG_DECRYPTED) {
decryptedCall = calls[i];
break;
}
}
expect(decryptedCall, 'FRAG_DECRYPTED should have been triggered').to
.exist;
expect(decryptedCall.args[1].frag).to.equal(frag);
expect(decryptedCall.args[1].part).to.equal(part);
expect(decryptedCall.args[1].payload).to.equal(plaintext);
});

it('emits ERROR when part decryption fails', async function () {
sandbox.stub(Decrypter.prototype, 'decrypt').rejects(new Error('boom'));
const triggerSpy = sandbox.spy(hls, 'trigger');

const frag = buildEncryptedSubtitleFrag();
const part = buildPart(frag);
loadProgress(frag, part, new ArrayBuffer(16));

await flushPromises();

const calls = triggerSpy.getCalls();
let errorCall;
for (let i = 0; i < calls.length; i++) {
if (
calls[i].args[0] === Events.ERROR &&
calls[i].args[1]?.details === ErrorDetails.FRAG_DECRYPT_ERROR
) {
errorCall = calls[i];
break;
}
}
expect(errorCall, 'a FRAG_DECRYPT_ERROR should have been emitted').to
.exist;
expect(errorCall.args[1].frag).to.equal(frag);
expect(errorCall.args[1].part).to.equal(part);
// Note: `fatal` is intentionally not asserted — the error-controller may
// mutate the event payload to escalate to fatal after our trigger.
expect(triggerSpy).to.not.have.been.calledWith(Events.FRAG_DECRYPTED);
});
});
});
Loading