fix(subtitle): decrypt LL-HLS VTT AES parts per-part (#7881)

This commit is contained in:
brodi
2026-06-01 18:12:17 -07:00
committed by GitHub
parent 97f8d8d3c9
commit 18307e5fe2
5 changed files with 221 additions and 47 deletions
+4
View File
@@ -1840,6 +1840,8 @@ export interface FragDecryptedData {
// (undocumented)
frag: Fragment;
// (undocumented)
part: Part | null;
// (undocumented)
payload: ArrayBuffer;
// (undocumented)
stats: {
@@ -5017,6 +5019,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;
+1
View File
@@ -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,
+70 -47
View File
@@ -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';
@@ -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() {
+1
View File
@@ -422,6 +422,7 @@ export interface PartsLoadedData {
export interface FragDecryptedData {
frag: Fragment;
part: Part | null;
payload: ArrayBuffer;
stats: {
tstart: number;
@@ -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';
@@ -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);
});
});
});