mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
fix(subtitle): decrypt LL-HLS VTT AES parts per-part (#7881)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user