diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 7a24dd66aaa..f765f517566 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -1840,6 +1840,8 @@ export interface FragDecryptedData { // (undocumented) frag: Fragment; // (undocumented) + part: Part | null; + // (undocumented) payload: ArrayBuffer; // (undocumented) stats: { @@ -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; diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 8fa5e75ebec..15ac0e18109 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -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, diff --git a/src/controller/subtitle-stream-controller.ts b/src/controller/subtitle-stream-controller.ts index 89d0aa9b74e..afc029f2491 100644 --- a/src/controller/subtitle-stream-controller.ts +++ b/src/controller/subtitle-stream-controller.ts @@ -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() { diff --git a/src/types/events.ts b/src/types/events.ts index bf1c341c630..9dc71030ded 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -422,6 +422,7 @@ export interface PartsLoadedData { export interface FragDecryptedData { frag: Fragment; + part: Part | null; payload: ArrayBuffer; stats: { tstart: number; diff --git a/tests/unit/controller/subtitle-stream-controller.ts b/tests/unit/controller/subtitle-stream-controller.ts index cc1876a06ac..c4c26ef46f8 100644 --- a/tests/unit/controller/subtitle-stream-controller.ts +++ b/tests/unit/controller/subtitle-stream-controller.ts @@ -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); + }); + }); });