From 6d0377bed77d134073ac87fcbeb0a87d7d74f8b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 17:45:17 +0000 Subject: [PATCH 01/38] Migrate CMCD from @svta/common-media-library to @svta/cml-cmcd and add v2 version support Replace the monolithic @svta/common-media-library package with the scoped @svta/cml-cmcd@2.1.0 and @svta/cml-utils@1.3.0 packages for CMCD functionality. The old package is retained for non-CMCD imports (ID3, UTF8). Add a `version` option to CMCDControllerConfig (defaults to 1 for backwards compatibility) that controls CMCD encoding version. When set to 2, the controller uses CMCD v2 Structured Field Value encoding via the new library. Key changes: - Update all CMCD imports to use @svta/cml-cmcd single entry point - Update uuid import to use @svta/cml-utils - Update tsconfig moduleResolution to "bundler" for exports field support - Adapt CMCD data fields (br, bl, mtp, tb, nor) to v2 array types - Pass version through to CmcdEncodeOptions for version-aware encoding https://claude.ai/code/session_01FmnN6xNSm9Qo17tp52ag3U --- package-lock.json | 61 ++++++++++++++++++++++++ package.json | 2 + src/config.ts | 5 ++ src/controller/cmcd-controller.ts | 34 ++++++++----- src/hls.ts | 2 +- tests/unit/controller/cmcd-controller.ts | 2 +- tsconfig-lib.json | 3 +- 7 files changed, 93 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index debe99a358c..a6e045ed43c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,8 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", + "@svta/cml-cmcd": "2.1.0", + "@svta/cml-utils": "1.3.0", "@svta/common-media-library": "0.17.1", "@types/chai": "4.3.20", "@types/chart.js": "2.9.41", @@ -4022,6 +4024,44 @@ "integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==", "dev": true }, + "node_modules/@svta/cml-cmcd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.0.tgz", + "integrity": "sha512-BX7E6AjzqJG6NLl0zGQOEWsgsodimU8wIQPtVivZoJYRDJ2HF6CdmkVNoTaQGeiu355TnGllpZf6e+VfzUxnKg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@svta/cml-structured-field-values": "1.1.1", + "@svta/cml-utils": "1.3.0" + } + }, + "node_modules/@svta/cml-structured-field-values": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.1.tgz", + "integrity": "sha512-04zNbiY2HrOMAQ2F0iZ4yDbKaAg3UybtTARNv930bhIY00vmmd8Elt0jHB+Q3y/hC3I35QU8SGrORKT5LE2sOQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@svta/cml-utils": "1.3.0" + } + }, + "node_modules/@svta/cml-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.3.0.tgz", + "integrity": "sha512-TaVyR899SZpjvw0RgA+/wRnij6pf4r0l+W1qHie9H/cPGNJBsQG1sKLx8inmKHwvZzcPt2SP/fv9MwMyqAbC+Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, "node_modules/@svta/common-media-library": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.17.1.tgz", @@ -17387,6 +17427,27 @@ "integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==", "dev": true }, + "@svta/cml-cmcd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.0.tgz", + "integrity": "sha512-BX7E6AjzqJG6NLl0zGQOEWsgsodimU8wIQPtVivZoJYRDJ2HF6CdmkVNoTaQGeiu355TnGllpZf6e+VfzUxnKg==", + "dev": true, + "requires": {} + }, + "@svta/cml-structured-field-values": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.1.tgz", + "integrity": "sha512-04zNbiY2HrOMAQ2F0iZ4yDbKaAg3UybtTARNv930bhIY00vmmd8Elt0jHB+Q3y/hC3I35QU8SGrORKT5LE2sOQ==", + "dev": true, + "peer": true, + "requires": {} + }, + "@svta/cml-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.3.0.tgz", + "integrity": "sha512-TaVyR899SZpjvw0RgA+/wRnij6pf4r0l+W1qHie9H/cPGNJBsQG1sKLx8inmKHwvZzcPt2SP/fv9MwMyqAbC+Q==", + "dev": true + }, "@svta/common-media-library": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.17.1.tgz", diff --git a/package.json b/package.json index b9e8894b919..7639620b48a 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", + "@svta/cml-cmcd": "2.1.0", + "@svta/cml-utils": "1.3.0", "@svta/common-media-library": "0.17.1", "@types/chai": "4.3.20", "@types/chart.js": "2.9.41", diff --git a/src/config.ts b/src/config.ts index 1e100d3fc86..24c6bb17665 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,6 +75,11 @@ export type CMCDControllerConfig = { contentId?: string; useHeaders?: boolean; includeKeys?: string[]; + /** + * CMCD version to use for encoding. Defaults to 1 for backwards compatibility. + * Set to 2 to enable CMCD v2 Structured Field Value encoding. + */ + version?: 1 | 2; }; export type DRMSystemOptions = { diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index f0d48b26f79..70710f0b0bc 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -1,7 +1,11 @@ -import { CmcdObjectType } from '@svta/common-media-library/cmcd/CmcdObjectType'; -import { CmcdStreamingFormat } from '@svta/common-media-library/cmcd/CmcdStreamingFormat'; -import { appendCmcdHeaders } from '@svta/common-media-library/cmcd/appendCmcdHeaders'; -import { appendCmcdQuery } from '@svta/common-media-library/cmcd/appendCmcdQuery'; +import { + CMCD_V1, + CmcdObjectType, + CmcdStreamingFormat, + appendCmcdHeaders, + appendCmcdQuery, +} from '@svta/cml-cmcd'; +import type { CmcdVersion } from '@svta/cml-cmcd'; import { Events } from '../events'; import { BufferHelper } from '../utils/buffer-helper'; import type { @@ -22,8 +26,7 @@ import type { LoaderContext, PlaylistLoaderContext, } from '../types/loader'; -import type { Cmcd } from '@svta/common-media-library/cmcd/Cmcd'; -import type { CmcdEncodeOptions } from '@svta/common-media-library/cmcd/CmcdEncodeOptions'; +import type { Cmcd, CmcdEncodeOptions } from '@svta/cml-cmcd'; /** * Controller to deal with Common Media Client Data (CMCD) @@ -37,6 +40,7 @@ export default class CMCDController implements ComponentAPI { private cid?: string; private useHeaders: boolean = false; private includeKeys?: string[]; + private version: CmcdVersion = CMCD_V1; private initialized: boolean = false; private starved: boolean = false; private buffering: boolean = true; @@ -56,6 +60,7 @@ export default class CMCDController implements ComponentAPI { this.cid = cmcd.contentId; this.useHeaders = cmcd.useHeaders === true; this.includeKeys = cmcd.includeKeys; + this.version = cmcd.version || CMCD_V1; this.registerListeners(); } } @@ -134,12 +139,12 @@ export default class CMCDController implements ComponentAPI { */ private createData(): Cmcd { return { - v: 1, + v: this.version, sf: CmcdStreamingFormat.HLS, sid: this.sid, cid: this.cid, pr: this.media?.playbackRate, - mtp: this.hls.bandwidthEstimate / 1000, + mtp: [this.hls.bandwidthEstimate / 1000], }; } @@ -175,7 +180,10 @@ export default class CMCDController implements ComponentAPI { }, {}); } - const options: CmcdEncodeOptions = { baseUrl: context.url }; + const options: CmcdEncodeOptions = { + baseUrl: context.url, + version: this.version, + }; if (this.useHeaders) { if (!context.headers) { @@ -217,15 +225,15 @@ export default class CMCDController implements ComponentAPI { ot === CmcdObjectType.AUDIO || ot == CmcdObjectType.MUXED ) { - data.br = level.bitrate / 1000; - data.tb = this.getTopBandwidth(ot) / 1000; - data.bl = this.getBufferLength(ot); + data.br = [level.bitrate / 1000]; + data.tb = [this.getTopBandwidth(ot) / 1000]; + data.bl = [this.getBufferLength(ot)]; } const next = part ? this.getNextPart(part) : this.getNextFrag(frag); if (next?.url && next.url !== frag.url) { - data.nor = next.url; + data.nor = [next.url]; } this.apply(context, data); diff --git a/src/hls.ts b/src/hls.ts index 581086b0c05..c120302c39f 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -1,4 +1,4 @@ -import { uuid } from '@svta/common-media-library/utils/uuid'; +import { uuid } from '@svta/cml-utils'; import { EventEmitter } from 'eventemitter3'; import { buildAbsoluteURL } from 'url-toolkit'; import { enableStreamingMode, hlsDefaultConfig, mergeConfig } from './config'; diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 6e805a62947..643823bdc87 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -1,4 +1,4 @@ -import { CmcdHeaderField } from '@svta/common-media-library/cmcd/CmcdHeaderField'; +import { CmcdHeaderField } from '@svta/cml-cmcd'; import chai from 'chai'; import CMCDController from '../../../src/controller/cmcd-controller'; import Hls from '../../../src/hls'; diff --git a/tsconfig-lib.json b/tsconfig-lib.json index 528f21cd216..e66b3e82948 100644 --- a/tsconfig-lib.json +++ b/tsconfig-lib.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "esnext", - "module": "commonjs", + "module": "esnext", + "moduleResolution": "bundler", "declaration": true, "declarationMap": true, "sourceMap": true, From 17be620252c57f735ec5d0cfca7032cbfcaaa3b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 18:06:07 +0000 Subject: [PATCH 02/38] Add CMCD v2 data fields and CmcdReporter event-mode reporting Phase 3: Add new CMCD v2 data fields to the controller: - Stream type (st): Detect VOD/LIVE/LOW_LATENCY from level details based on live flag, canBlockReload, and canSkipUntil properties - Player state (sta): Track player state transitions via media element events (waiting, playing, pause, seeking, ended) and hls.js ERROR events for fatal errors. Maps to CmcdPlayerState enum values. - Both fields are only included when version >= 2 Phase 4: Integrate CmcdReporter for event-mode reporting: - Add eventTargets config option (CmcdEventReportConfig[]) for v2 event reporting endpoints - Instantiate CmcdReporter when version >= 2 and eventTargets are configured, with session/content ID and transmission mode - Record PLAY_STATE events on player state transitions - Record ERROR events on fatal hls.js errors - Record BITRATE_CHANGE events on level switches - Stop and flush reporter on controller destroy Add unit tests for v2 version encoding, stream type detection (VOD, LIVE, LOW_LATENCY), and player state inclusion. https://claude.ai/code/session_01FmnN6xNSm9Qo17tp52ag3U --- src/config.ts | 7 ++ src/controller/cmcd-controller.ts | 131 ++++++++++++++++++++++- tests/unit/controller/cmcd-controller.ts | 68 ++++++++++++ 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index 24c6bb17665..2eec7e256e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,6 +35,7 @@ import type { import type { CuesInterface } from './utils/cues'; import type { ILogger } from './utils/logger'; import type { KeySystems, MediaKeyFunc } from './utils/mediakeys-helper'; +import type { CmcdEventReportConfig } from '@svta/cml-cmcd'; export type ABRControllerConfig = { abrEwmaFastLive: number; @@ -80,6 +81,12 @@ export type CMCDControllerConfig = { * Set to 2 to enable CMCD v2 Structured Field Value encoding. */ version?: 1 | 2; + /** + * CMCD v2 event reporting targets. Each entry configures an endpoint + * to receive CMCD event reports (play state changes, errors, etc.). + * Requires version: 2. + */ + eventTargets?: CmcdEventReportConfig[]; }; export type DRMSystemOptions = { diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 70710f0b0bc..d3f44b9986f 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -1,6 +1,13 @@ import { CMCD_V1, + CMCD_V2, + CMCD_HEADERS, + CMCD_QUERY, + CmcdEventType, CmcdObjectType, + CmcdPlayerState, + CmcdReporter, + CmcdStreamType, CmcdStreamingFormat, appendCmcdHeaders, appendCmcdQuery, @@ -17,7 +24,12 @@ import type Hls from '../hls'; import type { Fragment, Part } from '../loader/fragment'; import type { ExtendedSourceBuffer } from '../types/buffer'; import type { ComponentAPI } from '../types/component-api'; -import type { BufferCreatedData, MediaAttachedData } from '../types/events'; +import type { + BufferCreatedData, + ErrorData, + LevelSwitchingData, + MediaAttachedData, +} from '../types/events'; import type { FragmentLoaderContext, Loader, @@ -26,7 +38,7 @@ import type { LoaderContext, PlaylistLoaderContext, } from '../types/loader'; -import type { Cmcd, CmcdEncodeOptions } from '@svta/cml-cmcd'; +import type { Cmcd, CmcdEncodeOptions, CmcdKey } from '@svta/cml-cmcd'; /** * Controller to deal with Common Media Client Data (CMCD) @@ -44,8 +56,10 @@ export default class CMCDController implements ComponentAPI { private initialized: boolean = false; private starved: boolean = false; private buffering: boolean = true; + private playerState: CmcdPlayerState = CmcdPlayerState.STARTING; private audioBuffer?: ExtendedSourceBuffer; private videoBuffer?: ExtendedSourceBuffer; + private reporter?: CmcdReporter; constructor(hls: Hls) { this.hls = hls; @@ -62,6 +76,18 @@ export default class CMCDController implements ComponentAPI { this.includeKeys = cmcd.includeKeys; this.version = cmcd.version || CMCD_V1; this.registerListeners(); + + if (this.version >= CMCD_V2 && cmcd.eventTargets?.length) { + this.reporter = new CmcdReporter({ + sid: this.sid, + cid: this.cid, + version: CMCD_V2, + transmissionMode: this.useHeaders ? CMCD_HEADERS : CMCD_QUERY, + enabledKeys: this.includeKeys as CmcdKey[] | undefined, + eventTargets: cmcd.eventTargets, + }); + this.reporter.start(); + } } } @@ -70,6 +96,10 @@ export default class CMCDController implements ComponentAPI { hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this); hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this); + if (this.version >= CMCD_V2) { + hls.on(Events.ERROR, this.onError, this); + hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); + } } private unregisterListeners() { @@ -77,16 +107,27 @@ export default class CMCDController implements ComponentAPI { hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this); hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this); + if (this.version >= CMCD_V2) { + hls.off(Events.ERROR, this.onError, this); + hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); + } } destroy() { this.unregisterListeners(); this.onMediaDetached(); + if (this.reporter) { + this.reporter.stop(true); + this.reporter = undefined; + } + // @ts-ignore this.hls = this.config = this.audioBuffer = this.videoBuffer = null; // @ts-ignore - this.onWaiting = this.onPlaying = this.media = null; + this.onWaiting = this.onPlaying = this.onPause = null; + // @ts-ignore + this.onSeeking = this.onEnded = this.media = null; } private onMediaAttached( @@ -96,6 +137,11 @@ export default class CMCDController implements ComponentAPI { this.media = data.media; this.media.addEventListener('waiting', this.onWaiting); this.media.addEventListener('playing', this.onPlaying); + if (this.version >= CMCD_V2) { + this.media.addEventListener('pause', this.onPause); + this.media.addEventListener('seeking', this.onSeeking); + this.media.addEventListener('ended', this.onEnded); + } } private onMediaDetached() { @@ -105,6 +151,11 @@ export default class CMCDController implements ComponentAPI { this.media.removeEventListener('waiting', this.onWaiting); this.media.removeEventListener('playing', this.onPlaying); + if (this.version >= CMCD_V2) { + this.media.removeEventListener('pause', this.onPause); + this.media.removeEventListener('seeking', this.onSeeking); + this.media.removeEventListener('ended', this.onEnded); + } // @ts-ignore this.media = null; @@ -121,6 +172,7 @@ export default class CMCDController implements ComponentAPI { private onWaiting = () => { if (this.initialized) { this.starved = true; + this.setPlayerState(CmcdPlayerState.REBUFFERING); } this.buffering = true; @@ -132,13 +184,55 @@ export default class CMCDController implements ComponentAPI { } this.buffering = false; + this.setPlayerState(CmcdPlayerState.PLAYING); }; + private onPause = () => { + if (this.media && !this.media.ended) { + this.setPlayerState(CmcdPlayerState.PAUSED); + } + }; + + private onSeeking = () => { + this.setPlayerState(CmcdPlayerState.SEEKING); + }; + + private onEnded = () => { + this.setPlayerState(CmcdPlayerState.ENDED); + }; + + private onError(event: Events.ERROR, data: ErrorData) { + if (data.fatal) { + this.setPlayerState(CmcdPlayerState.FATAL_ERROR); + this.reporter?.recordEvent(CmcdEventType.ERROR); + } + } + + private onLevelSwitching( + event: Events.LEVEL_SWITCHING, + data: LevelSwitchingData, + ) { + this.reporter?.update({ br: [data.bitrate / 1000] }); + this.reporter?.recordEvent(CmcdEventType.BITRATE_CHANGE); + } + + private setPlayerState(state: CmcdPlayerState) { + if (this.playerState === state) { + return; + } + + this.playerState = state; + if (this.reporter) { + this.reporter.update({ sta: state }); + this.reporter.recordEvent(CmcdEventType.PLAY_STATE); + } + } + /** * Create baseline CMCD data */ private createData(): Cmcd { - return { + const data: Cmcd = { v: this.version, sf: CmcdStreamingFormat.HLS, sid: this.sid, @@ -146,6 +240,35 @@ export default class CMCDController implements ComponentAPI { pr: this.media?.playbackRate, mtp: [this.hls.bandwidthEstimate / 1000], }; + + if (this.version >= CMCD_V2) { + const st = this.getStreamType(); + if (st) { + data.st = st; + } + data.sta = this.playerState; + } + + return data; + } + + /** + * Get the stream type based on level details. + */ + private getStreamType(): CmcdStreamType | undefined { + const loadLevel = this.hls.loadLevel; + const details = + loadLevel >= 0 ? this.hls.levels[loadLevel]?.details : undefined; + if (!details) { + return undefined; + } + if (!details.live) { + return CmcdStreamType.VOD; + } + if (details.canBlockReload || details.canSkipUntil) { + return CmcdStreamType.LOW_LATENCY; + } + return CmcdStreamType.LIVE; } /** diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 643823bdc87..77a6edc0f26 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -177,5 +177,73 @@ describe('CMCDController', function () { expectField(url, `ot%3Dav`); }); }); + + describe('v2 configuration', function () { + it('defaults to version 1', function () { + setupEach({}); + + const { url } = applyPlaylistData(); + expectField(url, `v%3D1`); + // v1 should NOT include st or sta + expect(url).to.not.include('st%3D'); + expect(url).to.not.include('sta%3D'); + }); + + it('uses version 2 when configured', function () { + setupEach({ version: 2 }); + + const { url } = applyPlaylistData(); + expectField(url, `v%3D2`); + }); + + it('includes player state (sta) for v2', function () { + setupEach({ version: 2 }); + + const { url } = applyPlaylistData(); + // Initial state is STARTING ("s") + expectField(url, `sta%3Ds`); + }); + + it('includes stream type (st) for v2 when level details are available', function () { + const details = setupEach({ version: 2 }); + // The test playlist has EXT-X-SERVER-CONTROL which makes it live with LL features + // but details.live defaults to true from parsing + + const { url } = applyPlaylistData(); + // Should include st field (live since playlist has no ENDLIST) + expectField(url, `st%3D`); + }); + + it('detects VOD stream type', function () { + const details = setupEach({ version: 2 }); + // Mark level details as VOD + details.live = false; + + const { url } = applyPlaylistData(); + // VOD = "v" + expectField(url, `st%3Dv`); + }); + + it('detects low-latency stream type', function () { + const details = setupEach({ version: 2 }); + details.live = true; + details.canBlockReload = true; + + const { url } = applyPlaylistData(); + // LOW_LATENCY = "ll" + expectField(url, `st%3Dll`); + }); + + it('detects live stream type', function () { + const details = setupEach({ version: 2 }); + details.live = true; + details.canBlockReload = false; + details.canSkipUntil = 0; + + const { url } = applyPlaylistData(); + // LIVE = "l" (negative lookahead to avoid matching "ll") + expectField(url, `st%3Dl(?!l)`); + }); + }); }); }); From 72b69d0962168dcb7f72a0bd3d96e5cfcdbd4732 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 18:25:47 +0000 Subject: [PATCH 03/38] Export CMCD v2 types and add comprehensive test coverage Phase 6: Re-export CMCD types and constants from exports-named.ts for ESM consumers: CmcdObjectType, CmcdStreamType, CmcdStreamingFormat, CmcdPlayerState, CmcdEventType, CmcdHeaderField, CMCD_V1, CMCD_V2, and type exports for Cmcd, CmcdEncodeOptions, CmcdEventReportConfig, CmcdVersion. Add @svta/cml-cmcd, @svta/cml-utils, and @svta/cml-structured-field-values to api-extractor bundledPackages so external types are inlined in the rolled-up dist/hls.d.ts. Phase 7: Add tests for: - v2 fragment data includes version, stream type, and player state - v2 headers mode includes v2 fields in CMCD headers - Reporter is not created without eventTargets or for v1 - Reporter is created with v2 + eventTargets - Reporter.stop(true) is called on destroy - Play state events are recorded on state transitions - Error events are recorded on fatal hls.js errors - Duplicate player state events are deduplicated Phase 8: Verified TypeScript type-check, all Rollup build configs (full, fullEsm, light), and api-extractor declaration bundling. https://claude.ai/code/session_01FmnN6xNSm9Qo17tp52ag3U --- api-extractor.json | 6 +- api-extractor/report/hls.js.api.md | 6 ++ src/exports-named.ts | 16 +++ tests/unit/controller/cmcd-controller.ts | 132 +++++++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/api-extractor.json b/api-extractor.json index fcadb0645dd..e00d4fa2084 100644 --- a/api-extractor.json +++ b/api-extractor.json @@ -1,7 +1,11 @@ { "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", "mainEntryPointFilePath": "/lib/hls.d.ts", - "bundledPackages": [], + "bundledPackages": [ + "@svta/cml-cmcd", + "@svta/cml-utils", + "@svta/cml-structured-field-values" + ], "compiler": { "tsconfigFilePath": "/tsconfig-lib.json" }, diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 9900b1c5c59..af0fb7afbbf 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -946,6 +946,8 @@ export type CMCDControllerConfig = { contentId?: string; useHeaders?: boolean; includeKeys?: string[]; + version?: 1 | 2; + eventTargets?: CmcdEventReportConfig[]; }; // Warning: (ae-missing-release-tag) "CodecsParsed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -5091,6 +5093,10 @@ export class XhrLoader implements Loader { stats: LoaderStats; } +// Warnings were encountered during analysis: +// +// src/config.ts:89:3 - (ae-forgotten-export) The symbol "CmcdEventReportConfig" needs to be exported by the entry point hls.d.ts + // (No @packageDocumentation comment for this package) ``` diff --git a/src/exports-named.ts b/src/exports-named.ts index 73132a3de63..5896a7a7003 100644 --- a/src/exports-named.ts +++ b/src/exports-named.ts @@ -67,3 +67,19 @@ export { AttrList } from './utils/attr-list'; export { fetchSupported } from './utils/fetch-loader'; export { isSupported, isMSESupported } from './is-supported'; export { getMediaSource } from './utils/mediasource-helper'; +export { + CmcdObjectType, + CmcdStreamType, + CmcdStreamingFormat, + CmcdPlayerState, + CmcdEventType, + CmcdHeaderField, + CMCD_V1, + CMCD_V2, +} from '@svta/cml-cmcd'; +export type { + Cmcd, + CmcdEncodeOptions, + CmcdEventReportConfig, + CmcdVersion, +} from '@svta/cml-cmcd'; diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 77a6edc0f26..1a19bee5bd0 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -1,7 +1,9 @@ import { CmcdHeaderField } from '@svta/cml-cmcd'; import chai from 'chai'; +import sinon from 'sinon'; import CMCDController from '../../../src/controller/cmcd-controller'; import Hls from '../../../src/hls'; +import { Events } from '../../../src/events'; import M3U8Parser from '../../../src/loader/m3u8-parser'; import { PlaylistLevelType } from '../../../src/types/loader'; import type { CMCDControllerConfig } from '../../../src/config'; @@ -244,6 +246,136 @@ describe('CMCDController', function () { // LIVE = "l" (negative lookahead to avoid matching "ll") expectField(url, `st%3Dl(?!l)`); }); + + it('includes v2 fields in fragment data', function () { + const details = setupEach({ version: 2 }); + details.live = false; + + const { url } = applyFragmentData(details.fragments[0]); + // Should contain v2 version, stream type, and player state + expectField(url, `v%3D2`); + expectField(url, `st%3Dv`); + expectField(url, `sta%3Ds`); + // Standard fragment fields still present + expectField(url, `br%3D1`); + expectField(url, `ot%3Dav`); + }); + + it('includes v2 fields in headers mode', function () { + const details = setupEach({ version: 2, useHeaders: true }); + details.live = false; + + const { url, headers = {} } = applyPlaylistData(); + expect(url).to.equal(base.url); + // v2 fields should appear in appropriate CMCD headers + const allHeaders = Object.values(headers).join(','); + expect(allHeaders).to.include('v=2'); + expect(allHeaders).to.include('st=v'); + expect(allHeaders).to.include('sta=s'); + }); + }); + + describe('v2 event reporting', function () { + afterEach(function () { + sinon.restore(); + }); + + it('does not create reporter without eventTargets', function () { + setupEach({ version: 2 }); + expect((cmcdController as any).reporter).to.equal(undefined); + }); + + it('does not create reporter for v1', function () { + setupEach({ + version: 1, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + } as CMCDControllerConfig); + expect((cmcdController as any).reporter).to.equal(undefined); + }); + + it('creates reporter with v2 and eventTargets', function () { + setupEach({ + version: 2, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + expect((cmcdController as any).reporter).to.not.equal(undefined); + }); + + it('stops reporter on destroy', function () { + setupEach({ + version: 2, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + const reporter = (cmcdController as any).reporter; + const stopSpy = sinon.spy(reporter, 'stop'); + + cmcdController.destroy(); + + expect(stopSpy.calledOnce).to.equal(true); + expect(stopSpy.calledWith(true)).to.equal(true); + expect((cmcdController as any).reporter).to.equal(undefined); + }); + + it('records play state events on state transitions', function () { + setupEach({ + version: 2, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + const reporter = (cmcdController as any).reporter; + const updateSpy = sinon.spy(reporter, 'update'); + const recordSpy = sinon.spy(reporter, 'recordEvent'); + + // Simulate playing event via the arrow function + (cmcdController as any).onPlaying(); + + expect(updateSpy.calledOnce).to.equal(true); + expect(updateSpy.firstCall.args[0]).to.deep.include({ sta: 'p' }); + expect(recordSpy.calledOnce).to.equal(true); + expect(recordSpy.firstCall.args[0]).to.equal('ps'); + + cmcdController.destroy(); + }); + + it('records error events on fatal errors', function () { + setupEach({ + version: 2, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + const reporter = (cmcdController as any).reporter; + const recordSpy = sinon.spy(reporter, 'recordEvent'); + + // Trigger fatal error via hls event + (cmcdController as any).hls.trigger(Events.ERROR, { + type: 'networkError', + details: 'fragLoadError', + fatal: true, + error: new Error('test'), + }); + + // Should record both PLAY_STATE (FATAL_ERROR) and ERROR events + expect(recordSpy.calledTwice).to.equal(true); + expect(recordSpy.firstCall.args[0]).to.equal('ps'); + expect(recordSpy.secondCall.args[0]).to.equal('e'); + + cmcdController.destroy(); + }); + + it('does not record duplicate play state events', function () { + setupEach({ + version: 2, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + const reporter = (cmcdController as any).reporter; + const recordSpy = sinon.spy(reporter, 'recordEvent'); + + // Call onPlaying twice — second call should be deduplicated + (cmcdController as any).onPlaying(); + (cmcdController as any).onPlaying(); + + expect(recordSpy.calledOnce).to.equal(true); + + cmcdController.destroy(); + }); }); }); }); From db011bc0bc6107f6884c4a93fd1d173fea71f8fa Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:20:18 -0800 Subject: [PATCH 04/38] fix: remove @svta/common-media-library --- package-lock.json | 51 ++++++++++++++------------ package.json | 4 +- src/config.ts | 20 +++++++++- src/controller/id3-track-controller.ts | 5 +-- src/demux/audio/aacdemuxer.ts | 2 +- src/demux/audio/ac3-demuxer.ts | 5 +-- src/demux/audio/base-audio-demuxer.ts | 8 ++-- src/demux/audio/mp3demuxer.ts | 5 +-- src/utils/imsc1-ttml-parser.ts | 6 ++- src/utils/mp4-tools.ts | 2 +- src/utils/webvtt-parser.ts | 2 +- tests/unit/utils/utf8.ts | 2 +- 12 files changed, 66 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6e045ed43c..0c65e75bdad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,9 +25,9 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.1.0", + "@svta/cml-cmcd": "2.1.1", + "@svta/cml-id3": "1.0.4", "@svta/cml-utils": "1.3.0", - "@svta/common-media-library": "0.17.1", "@types/chai": "4.3.20", "@types/chart.js": "2.9.41", "@types/mocha": "10.0.10", @@ -4025,9 +4025,9 @@ "dev": true }, "node_modules/@svta/cml-cmcd": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.0.tgz", - "integrity": "sha512-BX7E6AjzqJG6NLl0zGQOEWsgsodimU8wIQPtVivZoJYRDJ2HF6CdmkVNoTaQGeiu355TnGllpZf6e+VfzUxnKg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.1.tgz", + "integrity": "sha512-cEkULGNbZ2QhOGBby9KwqkjdznJRhPXDEUZM8pmwEg33lKGM0bCCK8kBTpVjs5+jP+6M5g4ua5nrgYDOFYCR3w==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4038,6 +4038,19 @@ "@svta/cml-utils": "1.3.0" } }, + "node_modules/@svta/cml-id3": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.4.tgz", + "integrity": "sha512-v2IU+91SDrDXga6yZITqyBHa2Y1RVGT1ts6BHg3/YOHOCBAXy4o5gq9SQSRlHFvw6jzhJ3JdSpjSaXs3PPBwDw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@svta/cml-utils": "1.3.0" + } + }, "node_modules/@svta/cml-structured-field-values": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.1.tgz", @@ -4062,15 +4075,6 @@ "node": ">=20" } }, - "node_modules/@svta/common-media-library": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.17.1.tgz", - "integrity": "sha512-UcmqRe1cJ/OloNEeXqKJjbw6MAKPaGZUk4yLsy1QOwtzUmo7hDVvZeIoqXlmoXZNQRQ/P1MsNuboKirsTw9e7w==", - "dev": true, - "engines": { - "node": ">=20" - } - }, "node_modules/@testim/chrome-version": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.4.tgz", @@ -17428,9 +17432,16 @@ "dev": true }, "@svta/cml-cmcd": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.0.tgz", - "integrity": "sha512-BX7E6AjzqJG6NLl0zGQOEWsgsodimU8wIQPtVivZoJYRDJ2HF6CdmkVNoTaQGeiu355TnGllpZf6e+VfzUxnKg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.1.tgz", + "integrity": "sha512-cEkULGNbZ2QhOGBby9KwqkjdznJRhPXDEUZM8pmwEg33lKGM0bCCK8kBTpVjs5+jP+6M5g4ua5nrgYDOFYCR3w==", + "dev": true, + "requires": {} + }, + "@svta/cml-id3": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.4.tgz", + "integrity": "sha512-v2IU+91SDrDXga6yZITqyBHa2Y1RVGT1ts6BHg3/YOHOCBAXy4o5gq9SQSRlHFvw6jzhJ3JdSpjSaXs3PPBwDw==", "dev": true, "requires": {} }, @@ -17448,12 +17459,6 @@ "integrity": "sha512-TaVyR899SZpjvw0RgA+/wRnij6pf4r0l+W1qHie9H/cPGNJBsQG1sKLx8inmKHwvZzcPt2SP/fv9MwMyqAbC+Q==", "dev": true }, - "@svta/common-media-library": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.17.1.tgz", - "integrity": "sha512-UcmqRe1cJ/OloNEeXqKJjbw6MAKPaGZUk4yLsy1QOwtzUmo7hDVvZeIoqXlmoXZNQRQ/P1MsNuboKirsTw9e7w==", - "dev": true - }, "@testim/chrome-version": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.4.tgz", diff --git a/package.json b/package.json index 7639620b48a..c33842a14e6 100644 --- a/package.json +++ b/package.json @@ -83,9 +83,9 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.1.0", + "@svta/cml-cmcd": "2.1.1", + "@svta/cml-id3": "1.0.4", "@svta/cml-utils": "1.3.0", - "@svta/common-media-library": "0.17.1", "@types/chai": "4.3.20", "@types/chart.js": "2.9.41", "@types/mocha": "10.0.10", diff --git a/src/config.ts b/src/config.ts index 2eec7e256e9..71d2044b94a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,7 +35,11 @@ import type { import type { CuesInterface } from './utils/cues'; import type { ILogger } from './utils/logger'; import type { KeySystems, MediaKeyFunc } from './utils/mediakeys-helper'; -import type { CmcdEventReportConfig } from '@svta/cml-cmcd'; +import type { + CmcdEventReportConfig, + CmcdKey, + CmcdVersion, +} from '@svta/cml-cmcd'; export type ABRControllerConfig = { abrEwmaFastLive: number; @@ -75,12 +79,24 @@ export type CMCDControllerConfig = { sessionId?: string; contentId?: string; useHeaders?: boolean; + + /** + * @deprecated use enabledKeys + */ includeKeys?: string[]; + + /** + * An optional array of CMCD keys. When present, only these CMCD fields will + * be included with each each request. + */ + enabledKeys?: CmcdKey[]; + /** * CMCD version to use for encoding. Defaults to 1 for backwards compatibility. * Set to 2 to enable CMCD v2 Structured Field Value encoding. */ - version?: 1 | 2; + version?: CmcdVersion; + /** * CMCD v2 event reporting targets. Each entry configures an endpoint * to receive CMCD event reports (play state changes, errors, etc.). diff --git a/src/controller/id3-track-controller.ts b/src/controller/id3-track-controller.ts index 9bd02ab6f37..55339bcf8f3 100644 --- a/src/controller/id3-track-controller.ts +++ b/src/controller/id3-track-controller.ts @@ -1,5 +1,4 @@ -import { getId3Frames } from '@svta/common-media-library/id3/getId3Frames'; -import { isId3TimestampFrame } from '@svta/common-media-library/id3/isId3TimestampFrame'; +import { getId3Frames, isId3TimestampFrame } from '@svta/cml-id3'; import { Events } from '../events'; import { isDateRangeCueAttribute, @@ -209,7 +208,7 @@ class ID3TrackController implements ComponentAPI { continue; } - const frames = getId3Frames(samples[i].data); + const frames = getId3Frames(samples[i].data as Uint8Array); const startTime = samples[i].pts; let endTime: number = startTime + samples[i].duration; diff --git a/src/demux/audio/aacdemuxer.ts b/src/demux/audio/aacdemuxer.ts index f398ccc547a..8b031037c9f 100644 --- a/src/demux/audio/aacdemuxer.ts +++ b/src/demux/audio/aacdemuxer.ts @@ -1,7 +1,7 @@ /** * AAC demuxer */ -import { getId3Data } from '@svta/common-media-library/id3/getId3Data'; +import { getId3Data } from '@svta/cml-id3'; import * as ADTS from './adts'; import BaseAudioDemuxer from './base-audio-demuxer'; import * as MpegAudio from './mpegaudio'; diff --git a/src/demux/audio/ac3-demuxer.ts b/src/demux/audio/ac3-demuxer.ts index 8f130a7a1e5..978d0328ac7 100644 --- a/src/demux/audio/ac3-demuxer.ts +++ b/src/demux/audio/ac3-demuxer.ts @@ -1,5 +1,4 @@ -import { getId3Data } from '@svta/common-media-library/id3/getId3Data'; -import { getId3Timestamp } from '@svta/common-media-library/id3/getId3Timestamp'; +import { getId3Data, getId3Timestamp } from '@svta/cml-id3'; import BaseAudioDemuxer from './base-audio-demuxer'; import { getAudioBSID } from './dolby'; import type { HlsEventEmitter } from '../../events'; @@ -72,7 +71,7 @@ export class AC3Demuxer extends BaseAudioDemuxer { if ( data[offset] === 0x0b && data[offset + 1] === 0x77 && - getId3Timestamp(id3Data) !== undefined && + getId3Timestamp(id3Data as Uint8Array) !== undefined && // check the bsid to confirm ac-3 getAudioBSID(data, offset) < 16 ) { diff --git a/src/demux/audio/base-audio-demuxer.ts b/src/demux/audio/base-audio-demuxer.ts index 4a980e11023..3ac40fd21ef 100644 --- a/src/demux/audio/base-audio-demuxer.ts +++ b/src/demux/audio/base-audio-demuxer.ts @@ -1,6 +1,4 @@ -import { canParseId3 } from '@svta/common-media-library/id3/canParseId3'; -import { getId3Data } from '@svta/common-media-library/id3/getId3Data'; -import { getId3Timestamp } from '@svta/common-media-library/id3/getId3Timestamp'; +import { canParseId3, getId3Data, getId3Timestamp } from '@svta/cml-id3'; import { type AudioFrame, type DemuxedAudioTrack, @@ -78,7 +76,9 @@ class BaseAudioDemuxer implements Demuxer { let lastDataIndex; const track = this._audioTrack as DemuxedAudioTrack; const id3Track = this._id3Track as DemuxedMetadataTrack; - const timestamp = id3Data ? getId3Timestamp(id3Data) : undefined; + const timestamp = id3Data + ? getId3Timestamp(id3Data as Uint8Array) + : undefined; const length = data.length; if ( diff --git a/src/demux/audio/mp3demuxer.ts b/src/demux/audio/mp3demuxer.ts index 8cd5005872d..5f01112cc69 100644 --- a/src/demux/audio/mp3demuxer.ts +++ b/src/demux/audio/mp3demuxer.ts @@ -1,8 +1,7 @@ /** * MP3 demuxer */ -import { getId3Data } from '@svta/common-media-library/id3/getId3Data'; -import { getId3Timestamp } from '@svta/common-media-library/id3/getId3Timestamp'; +import { getId3Data, getId3Timestamp } from '@svta/cml-id3'; import BaseAudioDemuxer from './base-audio-demuxer'; import { getAudioBSID } from './dolby'; import * as MpegAudio from './mpegaudio'; @@ -48,7 +47,7 @@ class MP3Demuxer extends BaseAudioDemuxer { id3Data && data[offset] === 0x0b && data[offset + 1] === 0x77 && - getId3Timestamp(id3Data) !== undefined && + getId3Timestamp(id3Data as Uint8Array) !== undefined && // check the bsid to confirm ac-3 or ec-3 (not mp3) getAudioBSID(data, offset) <= 16 ) { diff --git a/src/utils/imsc1-ttml-parser.ts b/src/utils/imsc1-ttml-parser.ts index 2a6dc5b6d9c..9efe2f214a5 100644 --- a/src/utils/imsc1-ttml-parser.ts +++ b/src/utils/imsc1-ttml-parser.ts @@ -1,4 +1,4 @@ -import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr'; +import { utf8ArrayToStr } from '@svta/cml-id3'; import { findBox } from './mp4-tools'; import { toTimescaleFromScale } from './timescale-conversion'; import VTTCue from './vttcue'; @@ -34,7 +34,9 @@ export function parseIMSC1( return; } - const ttmlList = results.map((mdat) => utf8ArrayToStr(mdat)); + const ttmlList = results.map((mdat) => + utf8ArrayToStr(mdat as Uint8Array), + ); const syncTime = toTimescaleFromScale(initPTS.baseTime, 1, initPTS.timescale); diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 4d6d836db2f..6825aee281a 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1,4 +1,4 @@ -import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr'; +import { utf8ArrayToStr } from '@svta/cml-id3'; import { arrayToHex } from './hex'; import { ElementaryStreamTypes } from '../loader/fragment'; import { logger } from '../utils/logger'; diff --git a/src/utils/webvtt-parser.ts b/src/utils/webvtt-parser.ts index 00eb8ae8c20..bbe367f93ae 100644 --- a/src/utils/webvtt-parser.ts +++ b/src/utils/webvtt-parser.ts @@ -1,4 +1,4 @@ -import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr'; +import { utf8ArrayToStr } from '@svta/cml-id3'; import { hash } from './hash'; import { toMpegTsClockFromTimescale } from './timescale-conversion'; import { VTTParser } from './vttparser'; diff --git a/tests/unit/utils/utf8.ts b/tests/unit/utils/utf8.ts index a56eedafb39..aa32014d004 100644 --- a/tests/unit/utils/utf8.ts +++ b/tests/unit/utils/utf8.ts @@ -1,4 +1,4 @@ -import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr'; +import { utf8ArrayToStr } from '@svta/cml-id3'; import { expect } from 'chai'; describe('UTF8 tests', function () { From 5f8351116935ef89947c36f5c1fe54cd3377b962 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:12:17 -0800 Subject: [PATCH 05/38] feat: update cmcd-controller to use CML's CmcdReporter --- src/config.ts | 29 +--- src/controller/cmcd-controller.ts | 194 ++++++++++------------- tests/unit/controller/cmcd-controller.ts | 21 +-- 3 files changed, 102 insertions(+), 142 deletions(-) diff --git a/src/config.ts b/src/config.ts index 71d2044b94a..a5dc9056b47 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,34 +75,17 @@ export type CapLevelControllerConfig = { capLevelToPlayerSize: boolean; }; +export type CmcdEventTarget = Omit & { + includeKeys?: CmcdKey[]; +}; + export type CMCDControllerConfig = { sessionId?: string; contentId?: string; useHeaders?: boolean; - - /** - * @deprecated use enabledKeys - */ - includeKeys?: string[]; - - /** - * An optional array of CMCD keys. When present, only these CMCD fields will - * be included with each each request. - */ - enabledKeys?: CmcdKey[]; - - /** - * CMCD version to use for encoding. Defaults to 1 for backwards compatibility. - * Set to 2 to enable CMCD v2 Structured Field Value encoding. - */ + includeKeys?: CmcdKey[]; version?: CmcdVersion; - - /** - * CMCD v2 event reporting targets. Each entry configures an endpoint - * to receive CMCD event reports (play state changes, errors, etc.). - * Requires version: 2. - */ - eventTargets?: CmcdEventReportConfig[]; + eventTargets?: CmcdEventTarget[]; }; export type DRMSystemOptions = { diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index d3f44b9986f..61022754cc0 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -1,18 +1,18 @@ import { - CMCD_V1, - CMCD_V2, CMCD_HEADERS, + CMCD_KEYS, CMCD_QUERY, + CMCD_V1, + CMCD_V1_KEYS, + CMCD_V2, CmcdEventType, CmcdObjectType, CmcdPlayerState, CmcdReporter, - CmcdStreamType, CmcdStreamingFormat, - appendCmcdHeaders, - appendCmcdQuery, + CmcdStreamType, + toCmcdValue, } from '@svta/cml-cmcd'; -import type { CmcdVersion } from '@svta/cml-cmcd'; import { Events } from '../events'; import { BufferHelper } from '../utils/buffer-helper'; import type { @@ -21,7 +21,7 @@ import type { PlaylistLoaderConstructor, } from '../config'; import type Hls from '../hls'; -import type { Fragment, Part } from '../loader/fragment'; +import type { Fragment, MediaFragment, Part } from '../loader/fragment'; import type { ExtendedSourceBuffer } from '../types/buffer'; import type { ComponentAPI } from '../types/component-api'; import type { @@ -38,7 +38,7 @@ import type { LoaderContext, PlaylistLoaderContext, } from '../types/loader'; -import type { Cmcd, CmcdEncodeOptions, CmcdKey } from '@svta/cml-cmcd'; +import type { Cmcd } from '@svta/cml-cmcd'; /** * Controller to deal with Common Media Client Data (CMCD) @@ -48,11 +48,6 @@ export default class CMCDController implements ComponentAPI { private hls: Hls; private config: HlsConfig; private media?: HTMLMediaElement; - private sid?: string; - private cid?: string; - private useHeaders: boolean = false; - private includeKeys?: string[]; - private version: CmcdVersion = CMCD_V1; private initialized: boolean = false; private starved: boolean = false; private buffering: boolean = true; @@ -70,24 +65,22 @@ export default class CMCDController implements ComponentAPI { config.pLoader = this.createPlaylistLoader(); config.fLoader = this.createFragmentLoader(); - this.sid = cmcd.sessionId || hls.sessionId; - this.cid = cmcd.contentId; - this.useHeaders = cmcd.useHeaders === true; - this.includeKeys = cmcd.includeKeys; - this.version = cmcd.version || CMCD_V1; - this.registerListeners(); + const version = cmcd.version || CMCD_V1; + + this.reporter = new CmcdReporter({ + sid: cmcd.sessionId || hls.sessionId, + cid: cmcd.contentId, + version, + transmissionMode: cmcd.useHeaders === true ? CMCD_HEADERS : CMCD_QUERY, + enabledKeys: cmcd.includeKeys ?? [ + ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), + ], + eventTargets: cmcd.eventTargets ?? [], + }); - if (this.version >= CMCD_V2 && cmcd.eventTargets?.length) { - this.reporter = new CmcdReporter({ - sid: this.sid, - cid: this.cid, - version: CMCD_V2, - transmissionMode: this.useHeaders ? CMCD_HEADERS : CMCD_QUERY, - enabledKeys: this.includeKeys as CmcdKey[] | undefined, - eventTargets: cmcd.eventTargets, - }); - this.reporter.start(); - } + this.reporter.update({ sf: CmcdStreamingFormat.HLS }); + this.reporter.start(); + this.registerListeners(); } } @@ -96,10 +89,8 @@ export default class CMCDController implements ComponentAPI { hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this); hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this); - if (this.version >= CMCD_V2) { - hls.on(Events.ERROR, this.onError, this); - hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); - } + hls.on(Events.ERROR, this.onError, this); + hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); } private unregisterListeners() { @@ -107,10 +98,8 @@ export default class CMCDController implements ComponentAPI { hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this); hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this); - if (this.version >= CMCD_V2) { - hls.off(Events.ERROR, this.onError, this); - hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); - } + hls.off(Events.ERROR, this.onError, this); + hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); } destroy() { @@ -137,11 +126,9 @@ export default class CMCDController implements ComponentAPI { this.media = data.media; this.media.addEventListener('waiting', this.onWaiting); this.media.addEventListener('playing', this.onPlaying); - if (this.version >= CMCD_V2) { - this.media.addEventListener('pause', this.onPause); - this.media.addEventListener('seeking', this.onSeeking); - this.media.addEventListener('ended', this.onEnded); - } + this.media.addEventListener('pause', this.onPause); + this.media.addEventListener('seeking', this.onSeeking); + this.media.addEventListener('ended', this.onEnded); } private onMediaDetached() { @@ -151,11 +138,9 @@ export default class CMCDController implements ComponentAPI { this.media.removeEventListener('waiting', this.onWaiting); this.media.removeEventListener('playing', this.onPlaying); - if (this.version >= CMCD_V2) { - this.media.removeEventListener('pause', this.onPause); - this.media.removeEventListener('seeking', this.onSeeking); - this.media.removeEventListener('ended', this.onEnded); - } + this.media.removeEventListener('pause', this.onPause); + this.media.removeEventListener('seeking', this.onSeeking); + this.media.removeEventListener('ended', this.onEnded); // @ts-ignore this.media = null; @@ -204,7 +189,7 @@ export default class CMCDController implements ComponentAPI { private onError(event: Events.ERROR, data: ErrorData) { if (data.fatal) { this.setPlayerState(CmcdPlayerState.FATAL_ERROR); - this.reporter?.recordEvent(CmcdEventType.ERROR); + this.reporter!.recordEvent(CmcdEventType.ERROR); } } @@ -212,8 +197,14 @@ export default class CMCDController implements ComponentAPI { event: Events.LEVEL_SWITCHING, data: LevelSwitchingData, ) { - this.reporter?.update({ br: [data.bitrate / 1000] }); - this.reporter?.recordEvent(CmcdEventType.BITRATE_CHANGE); + this.reporter!.update({ br: [data.bitrate / 1000] }); + + const eventData: Cmcd = {}; + const frag = data.details?.fragments[0]; + if (frag) { + eventData.ot = this.getObjectType(frag); + } + this.reporter!.recordEvent(CmcdEventType.BITRATE_CHANGE, eventData); } private setPlayerState(state: CmcdPlayerState) { @@ -222,34 +213,8 @@ export default class CMCDController implements ComponentAPI { } this.playerState = state; - if (this.reporter) { - this.reporter.update({ sta: state }); - this.reporter.recordEvent(CmcdEventType.PLAY_STATE); - } - } - - /** - * Create baseline CMCD data - */ - private createData(): Cmcd { - const data: Cmcd = { - v: this.version, - sf: CmcdStreamingFormat.HLS, - sid: this.sid, - cid: this.cid, - pr: this.media?.playbackRate, - mtp: [this.hls.bandwidthEstimate / 1000], - }; - - if (this.version >= CMCD_V2) { - const st = this.getStreamType(); - if (st) { - data.st = st; - } - data.sta = this.playerState; - } - - return data; + this.reporter!.update({ sta: state }); + this.reporter!.recordEvent(CmcdEventType.PLAY_STATE); } /** @@ -259,24 +224,33 @@ export default class CMCDController implements ComponentAPI { const loadLevel = this.hls.loadLevel; const details = loadLevel >= 0 ? this.hls.levels[loadLevel]?.details : undefined; + if (!details) { return undefined; } + if (!details.live) { return CmcdStreamType.VOD; } + + // TODO: Is this the best way to determine the low-latency stream type? if (details.canBlockReload || details.canSkipUntil) { return CmcdStreamType.LOW_LATENCY; } + return CmcdStreamType.LIVE; } /** - * Apply CMCD data to a request. + * Apply CMCD data to a request using the reporter. */ private apply(context: LoaderContext, data: Cmcd = {}) { - // apply baseline data - Object.assign(data, this.createData()); + // Update persistent data + this.reporter!.update({ + mtp: [this.hls.bandwidthEstimate / 1000], + pr: this.media?.playbackRate, + st: this.getStreamType(), + }); const isVideo = data.ot === CmcdObjectType.INIT || @@ -293,30 +267,15 @@ export default class CMCDController implements ComponentAPI { data.su = this.buffering; } - // TODO: Implement rtp, nrr, dl + // TODO: Implement rtp, dl - const { includeKeys } = this; - if (includeKeys) { - data = Object.keys(data).reduce((acc, key) => { - includeKeys.includes(key) && (acc[key] = data[key]); - return acc; - }, {}); - } - - const options: CmcdEncodeOptions = { - baseUrl: context.url, - version: this.version, - }; - - if (this.useHeaders) { - if (!context.headers) { - context.headers = {}; - } + const report = this.reporter!.createRequestReport( + { url: context.url, headers: context.headers }, + data, + ); - appendCmcdHeaders(context.headers, data, options); - } else { - context.url = appendCmcdQuery(context.url, data, options); - } + context.url = report.url; + context.headers = report.headers; } /** @@ -349,14 +308,28 @@ export default class CMCDController implements ComponentAPI { ot == CmcdObjectType.MUXED ) { data.br = [level.bitrate / 1000]; - data.tb = [this.getTopBandwidth(ot) / 1000]; - data.bl = [this.getBufferLength(ot)]; + const tb = this.getTopBandwidth(ot) / 1000; + if (Number.isFinite(tb)) { + data.tb = [tb]; + } + const bl = this.getBufferLength(ot); + if (Number.isFinite(bl)) { + data.bl = [bl]; + } } const next = part ? this.getNextPart(part) : this.getNextFrag(frag); if (next?.url && next.url !== frag.url) { - data.nor = [next.url]; + if (next.byteRange.length > 0) { + data.nor = [ + toCmcdValue(next.url, { + r: `${next.byteRange[0]}-${next.byteRange[1]}`, + }), + ]; + } else { + data.nor = [next.url]; + } } this.apply(context, data); @@ -395,7 +368,9 @@ export default class CMCDController implements ComponentAPI { /** * The CMCD object type. */ - private getObjectType(fragment: Fragment): CmcdObjectType | undefined { + private getObjectType( + fragment: Fragment | MediaFragment, + ): CmcdObjectType | undefined { const { type } = fragment; if (type === 'subtitle') { @@ -458,6 +433,7 @@ export default class CMCDController implements ComponentAPI { return NaN; } + // TODO: Implement parameterized buffer length array const info = BufferHelper.bufferInfo( buffer, media.currentTime, @@ -510,7 +486,7 @@ export default class CMCDController implements ComponentAPI { } /** - * Create a playlist loader + * Create a fragment loader */ private createFragmentLoader(): FragmentLoaderConstructor | undefined { const { fLoader } = this.config; diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 1a19bee5bd0..00fbdcbeb96 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -2,8 +2,8 @@ import { CmcdHeaderField } from '@svta/cml-cmcd'; import chai from 'chai'; import sinon from 'sinon'; import CMCDController from '../../../src/controller/cmcd-controller'; -import Hls from '../../../src/hls'; import { Events } from '../../../src/events'; +import Hls from '../../../src/hls'; import M3U8Parser from '../../../src/loader/m3u8-parser'; import { PlaylistLevelType } from '../../../src/types/loader'; import type { CMCDControllerConfig } from '../../../src/config'; @@ -185,7 +185,8 @@ describe('CMCDController', function () { setupEach({}); const { url } = applyPlaylistData(); - expectField(url, `v%3D1`); + // v=1 is the default and is omitted per CMCD spec + expect(url).to.not.include('v%3D'); // v1 should NOT include st or sta expect(url).to.not.include('st%3D'); expect(url).to.not.include('sta%3D'); @@ -207,7 +208,7 @@ describe('CMCDController', function () { }); it('includes stream type (st) for v2 when level details are available', function () { - const details = setupEach({ version: 2 }); + setupEach({ version: 2 }); // The test playlist has EXT-X-SERVER-CONTROL which makes it live with LL features // but details.live defaults to true from parsing @@ -256,8 +257,8 @@ describe('CMCDController', function () { expectField(url, `v%3D2`); expectField(url, `st%3Dv`); expectField(url, `sta%3Ds`); - // Standard fragment fields still present - expectField(url, `br%3D1`); + // Standard fragment fields still present (inner list format in v2) + expectField(url, `br%3D%281%29`); expectField(url, `ot%3Dav`); }); @@ -280,17 +281,17 @@ describe('CMCDController', function () { sinon.restore(); }); - it('does not create reporter without eventTargets', function () { + it('creates reporter without eventTargets (no event reporting)', function () { setupEach({ version: 2 }); - expect((cmcdController as any).reporter).to.equal(undefined); + expect((cmcdController as any).reporter).to.not.equal(undefined); }); - it('does not create reporter for v1', function () { + it('creates reporter for v1 (without event targets)', function () { setupEach({ version: 1, eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], - } as CMCDControllerConfig); - expect((cmcdController as any).reporter).to.equal(undefined); + }); + expect((cmcdController as any).reporter).to.not.equal(undefined); }); it('creates reporter with v2 and eventTargets', function () { From ba31f04f49ff2ec08971049dcfcf8f9f086e502e Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:18:36 -0800 Subject: [PATCH 06/38] chore: update CML packages --- api-extractor/report/hls.js.api.md | 10 ++-- package-lock.json | 64 ++++++++++++------------ package.json | 6 +-- src/controller/cmcd-controller.ts | 5 +- src/controller/id3-track-controller.ts | 2 +- src/demux/audio/ac3-demuxer.ts | 2 +- src/demux/audio/base-audio-demuxer.ts | 4 +- src/demux/audio/mp3demuxer.ts | 2 +- src/utils/imsc1-ttml-parser.ts | 4 +- tests/unit/controller/cmcd-controller.ts | 51 ++++++++++--------- 10 files changed, 77 insertions(+), 73 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index af0fb7afbbf..b46debefa4e 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -945,9 +945,9 @@ export type CMCDControllerConfig = { sessionId?: string; contentId?: string; useHeaders?: boolean; - includeKeys?: string[]; - version?: 1 | 2; - eventTargets?: CmcdEventReportConfig[]; + includeKeys?: CmcdKey[]; + version?: CmcdVersion; + eventTargets?: CmcdEventTarget[]; }; // Warning: (ae-missing-release-tag) "CodecsParsed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -5095,7 +5095,9 @@ export class XhrLoader implements Loader { // Warnings were encountered during analysis: // -// src/config.ts:89:3 - (ae-forgotten-export) The symbol "CmcdEventReportConfig" needs to be exported by the entry point hls.d.ts +// src/config.ts:86:3 - (ae-forgotten-export) The symbol "CmcdKey" needs to be exported by the entry point hls.d.ts +// src/config.ts:87:3 - (ae-forgotten-export) The symbol "CmcdVersion" needs to be exported by the entry point hls.d.ts +// src/config.ts:88:3 - (ae-forgotten-export) The symbol "CmcdEventTarget" needs to be exported by the entry point hls.d.ts // (No @packageDocumentation comment for this package) diff --git a/package-lock.json b/package-lock.json index 94b54853180..9d0c9053379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,10 +25,10 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.1.1", - "@svta/cml-id3": "1.0.4", - "@svta/cml-utils": "1.3.0", - "@types/chai": "5.2.3" + "@svta/cml-cmcd": "2.1.2", + "@svta/cml-id3": "1.0.5", + "@svta/cml-utils": "1.4.0", + "@types/chai": "5.2.3", "@types/chart.js": "2.9.41", "@types/mocha": "10.0.10", "@types/sinon-chai": "4.0.0", @@ -4227,36 +4227,36 @@ "license": "CC0-1.0" }, "node_modules/@svta/cml-cmcd": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.1.tgz", - "integrity": "sha512-cEkULGNbZ2QhOGBby9KwqkjdznJRhPXDEUZM8pmwEg33lKGM0bCCK8kBTpVjs5+jP+6M5g4ua5nrgYDOFYCR3w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.2.tgz", + "integrity": "sha512-HE5ePM088b++7dehiLNxPUwhjJGUqbsEkoQMd+E+QBOt20qLyKeCtCfHz2DQTTCYss8fVZScDPXbzCM6bUI5tw==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-structured-field-values": "1.1.1", - "@svta/cml-utils": "1.3.0" + "@svta/cml-structured-field-values": "1.1.2", + "@svta/cml-utils": "1.4.0" } }, "node_modules/@svta/cml-id3": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.4.tgz", - "integrity": "sha512-v2IU+91SDrDXga6yZITqyBHa2Y1RVGT1ts6BHg3/YOHOCBAXy4o5gq9SQSRlHFvw6jzhJ3JdSpjSaXs3PPBwDw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.5.tgz", + "integrity": "sha512-RCqoHnEv9TPFHtou3M3oa39g8d99Cim0SVFwwpf3oG3fNIlTDltb7O1986zBpp7SteWZWZa77H6y+dsLCJokvg==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.3.0" + "@svta/cml-utils": "1.4.0" } }, "node_modules/@svta/cml-structured-field-values": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.1.tgz", - "integrity": "sha512-04zNbiY2HrOMAQ2F0iZ4yDbKaAg3UybtTARNv930bhIY00vmmd8Elt0jHB+Q3y/hC3I35QU8SGrORKT5LE2sOQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.2.tgz", + "integrity": "sha512-9Se5b/T7BqgHECVacf/sNZ5wFIOm6d6vXLpfcIfHRGplOaOxDzcGgP+OezQ2CxBcYJKZMkw6pT+QMYDyGKOb1g==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -4264,13 +4264,13 @@ "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.3.0" + "@svta/cml-utils": "1.4.0" } }, "node_modules/@svta/cml-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.3.0.tgz", - "integrity": "sha512-TaVyR899SZpjvw0RgA+/wRnij6pf4r0l+W1qHie9H/cPGNJBsQG1sKLx8inmKHwvZzcPt2SP/fv9MwMyqAbC+Q==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.4.0.tgz", + "integrity": "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -17353,31 +17353,31 @@ "dev": true }, "@svta/cml-cmcd": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.1.tgz", - "integrity": "sha512-cEkULGNbZ2QhOGBby9KwqkjdznJRhPXDEUZM8pmwEg33lKGM0bCCK8kBTpVjs5+jP+6M5g4ua5nrgYDOFYCR3w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.2.tgz", + "integrity": "sha512-HE5ePM088b++7dehiLNxPUwhjJGUqbsEkoQMd+E+QBOt20qLyKeCtCfHz2DQTTCYss8fVZScDPXbzCM6bUI5tw==", "dev": true, "requires": {} }, "@svta/cml-id3": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.4.tgz", - "integrity": "sha512-v2IU+91SDrDXga6yZITqyBHa2Y1RVGT1ts6BHg3/YOHOCBAXy4o5gq9SQSRlHFvw6jzhJ3JdSpjSaXs3PPBwDw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.5.tgz", + "integrity": "sha512-RCqoHnEv9TPFHtou3M3oa39g8d99Cim0SVFwwpf3oG3fNIlTDltb7O1986zBpp7SteWZWZa77H6y+dsLCJokvg==", "dev": true, "requires": {} }, "@svta/cml-structured-field-values": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.1.tgz", - "integrity": "sha512-04zNbiY2HrOMAQ2F0iZ4yDbKaAg3UybtTARNv930bhIY00vmmd8Elt0jHB+Q3y/hC3I35QU8SGrORKT5LE2sOQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.2.tgz", + "integrity": "sha512-9Se5b/T7BqgHECVacf/sNZ5wFIOm6d6vXLpfcIfHRGplOaOxDzcGgP+OezQ2CxBcYJKZMkw6pT+QMYDyGKOb1g==", "dev": true, "peer": true, "requires": {} }, "@svta/cml-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.3.0.tgz", - "integrity": "sha512-TaVyR899SZpjvw0RgA+/wRnij6pf4r0l+W1qHie9H/cPGNJBsQG1sKLx8inmKHwvZzcPt2SP/fv9MwMyqAbC+Q==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.4.0.tgz", + "integrity": "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA==", "dev": true }, "@testim/chrome-version": { diff --git a/package.json b/package.json index 91f2224be33..fe3258c75b1 100644 --- a/package.json +++ b/package.json @@ -83,9 +83,9 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.1.1", - "@svta/cml-id3": "1.0.4", - "@svta/cml-utils": "1.3.0", + "@svta/cml-cmcd": "2.1.2", + "@svta/cml-id3": "1.0.5", + "@svta/cml-utils": "1.4.0", "@types/chai": "5.2.3", "@types/chart.js": "2.9.41", "@types/mocha": "10.0.10", diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 61022754cc0..3f68b31aa7e 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -78,7 +78,10 @@ export default class CMCDController implements ComponentAPI { eventTargets: cmcd.eventTargets ?? [], }); - this.reporter.update({ sf: CmcdStreamingFormat.HLS }); + this.reporter.update({ + sf: CmcdStreamingFormat.HLS, + sta: this.playerState, + }); this.reporter.start(); this.registerListeners(); } diff --git a/src/controller/id3-track-controller.ts b/src/controller/id3-track-controller.ts index 55339bcf8f3..d3b68bc0a50 100644 --- a/src/controller/id3-track-controller.ts +++ b/src/controller/id3-track-controller.ts @@ -208,7 +208,7 @@ class ID3TrackController implements ComponentAPI { continue; } - const frames = getId3Frames(samples[i].data as Uint8Array); + const frames = getId3Frames(samples[i].data); const startTime = samples[i].pts; let endTime: number = startTime + samples[i].duration; diff --git a/src/demux/audio/ac3-demuxer.ts b/src/demux/audio/ac3-demuxer.ts index 978d0328ac7..139687cc7ae 100644 --- a/src/demux/audio/ac3-demuxer.ts +++ b/src/demux/audio/ac3-demuxer.ts @@ -71,7 +71,7 @@ export class AC3Demuxer extends BaseAudioDemuxer { if ( data[offset] === 0x0b && data[offset + 1] === 0x77 && - getId3Timestamp(id3Data as Uint8Array) !== undefined && + getId3Timestamp(id3Data) !== undefined && // check the bsid to confirm ac-3 getAudioBSID(data, offset) < 16 ) { diff --git a/src/demux/audio/base-audio-demuxer.ts b/src/demux/audio/base-audio-demuxer.ts index 3ac40fd21ef..fee943e3a01 100644 --- a/src/demux/audio/base-audio-demuxer.ts +++ b/src/demux/audio/base-audio-demuxer.ts @@ -76,9 +76,7 @@ class BaseAudioDemuxer implements Demuxer { let lastDataIndex; const track = this._audioTrack as DemuxedAudioTrack; const id3Track = this._id3Track as DemuxedMetadataTrack; - const timestamp = id3Data - ? getId3Timestamp(id3Data as Uint8Array) - : undefined; + const timestamp = id3Data ? getId3Timestamp(id3Data) : undefined; const length = data.length; if ( diff --git a/src/demux/audio/mp3demuxer.ts b/src/demux/audio/mp3demuxer.ts index 5f01112cc69..a64bf48adc2 100644 --- a/src/demux/audio/mp3demuxer.ts +++ b/src/demux/audio/mp3demuxer.ts @@ -47,7 +47,7 @@ class MP3Demuxer extends BaseAudioDemuxer { id3Data && data[offset] === 0x0b && data[offset + 1] === 0x77 && - getId3Timestamp(id3Data as Uint8Array) !== undefined && + getId3Timestamp(id3Data) !== undefined && // check the bsid to confirm ac-3 or ec-3 (not mp3) getAudioBSID(data, offset) <= 16 ) { diff --git a/src/utils/imsc1-ttml-parser.ts b/src/utils/imsc1-ttml-parser.ts index 9efe2f214a5..c4a16c7c71f 100644 --- a/src/utils/imsc1-ttml-parser.ts +++ b/src/utils/imsc1-ttml-parser.ts @@ -34,9 +34,7 @@ export function parseIMSC1( return; } - const ttmlList = results.map((mdat) => - utf8ArrayToStr(mdat as Uint8Array), - ); + const ttmlList = results.map((mdat) => utf8ArrayToStr(mdat)); const syncTime = toTimescaleFromScale(initPTS.baseTime, 1, initPTS.timescale); diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 10406d02f34..846d5bf54c9 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -184,8 +184,9 @@ describe('CMCDController', function () { const { url } = applyPlaylistData(); // v=1 is the default and is omitted per CMCD spec expect(url).to.not.include('v%3D'); - // v1 should NOT include st or sta - expect(url).to.not.include('st%3D'); + // st is a v1 key and should be included when available + expectField(url, `st%3D`); + // sta is NOT a v1 key and should not be included expect(url).to.not.include('sta%3D'); }); @@ -274,10 +275,6 @@ describe('CMCDController', function () { }); describe('v2 event reporting', function () { - afterEach(function () { - sinon.restore(); - }); - it('creates reporter without eventTargets (no event reporting)', function () { setupEach({ version: 2 }); expect((cmcdController as any).reporter).to.not.equal(undefined); @@ -305,12 +302,17 @@ describe('CMCDController', function () { eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], }); const reporter = (cmcdController as any).reporter; - const stopSpy = sinon.spy(reporter, 'stop'); + const stopCalls: any[][] = []; + const origStop = reporter.stop.bind(reporter); + reporter.stop = (...args: any[]) => { + stopCalls.push(args); + return origStop(...args); + }; cmcdController.destroy(); - expect(stopSpy.calledOnce).to.equal(true); - expect(stopSpy.calledWith(true)).to.equal(true); + expect(stopCalls).to.have.lengthOf(1); + expect(stopCalls[0][0]).to.equal(true); expect((cmcdController as any).reporter).to.equal(undefined); }); @@ -319,17 +321,16 @@ describe('CMCDController', function () { version: 2, eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], }); - const reporter = (cmcdController as any).reporter; - const updateSpy = sinon.spy(reporter, 'update'); - const recordSpy = sinon.spy(reporter, 'recordEvent'); // Simulate playing event via the arrow function (cmcdController as any).onPlaying(); - expect(updateSpy.calledOnce).to.equal(true); - expect(updateSpy.firstCall.args[0]).to.deep.include({ sta: 'p' }); - expect(recordSpy.calledOnce).to.equal(true); - expect(recordSpy.firstCall.args[0]).to.equal('ps'); + // Player state should transition to PLAYING + expect((cmcdController as any).playerState).to.equal('p'); + + // Verify sta=p appears in subsequent CMCD data + const { url } = applyPlaylistData(); + expectField(url, `sta%3Dp`); cmcdController.destroy(); }); @@ -339,8 +340,6 @@ describe('CMCDController', function () { version: 2, eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], }); - const reporter = (cmcdController as any).reporter; - const recordSpy = sinon.spy(reporter, 'recordEvent'); // Trigger fatal error via hls event (cmcdController as any).hls.trigger(Events.ERROR, { @@ -350,10 +349,8 @@ describe('CMCDController', function () { error: new Error('test'), }); - // Should record both PLAY_STATE (FATAL_ERROR) and ERROR events - expect(recordSpy.calledTwice).to.equal(true); - expect(recordSpy.firstCall.args[0]).to.equal('ps'); - expect(recordSpy.secondCall.args[0]).to.equal('e'); + // Player state should transition to FATAL_ERROR + expect((cmcdController as any).playerState).to.equal('f'); cmcdController.destroy(); }); @@ -364,13 +361,19 @@ describe('CMCDController', function () { eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], }); const reporter = (cmcdController as any).reporter; - const recordSpy = sinon.spy(reporter, 'recordEvent'); + const recordCalls: any[][] = []; + const origRecord = reporter.recordEvent.bind(reporter); + reporter.recordEvent = (...args: any[]) => { + recordCalls.push(args); + return origRecord(...args); + }; // Call onPlaying twice — second call should be deduplicated (cmcdController as any).onPlaying(); (cmcdController as any).onPlaying(); - expect(recordSpy.calledOnce).to.equal(true); + // Only one recordEvent call for the state change (deduplicated) + expect(recordCalls).to.have.lengthOf(1); cmcdController.destroy(); }); From b65b1e0444e718bbd830c9ca4e9f05af92b5ebe7 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:14:53 -0800 Subject: [PATCH 07/38] fix: build system cleanup --- api-extractor.json | 6 +- api-extractor/report/hls.js.api.md | 129 ++++++++++++++++++++++++++++- build-config.js | 1 - src/exports-external.ts | 50 +++++++++++ src/exports-named.ts | 16 ---- 5 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 src/exports-external.ts diff --git a/api-extractor.json b/api-extractor.json index e00d4fa2084..fcadb0645dd 100644 --- a/api-extractor.json +++ b/api-extractor.json @@ -1,11 +1,7 @@ { "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", "mainEntryPointFilePath": "/lib/hls.d.ts", - "bundledPackages": [ - "@svta/cml-cmcd", - "@svta/cml-utils", - "@svta/cml-structured-field-values" - ], + "bundledPackages": [], "compiler": { "tsconfigFilePath": "/tsconfig-lib.json" }, diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index b46debefa4e..1528861d5a0 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -4,6 +4,49 @@ ```ts +import { Cmcd } from '@svta/cml-cmcd'; +import { CMCD_DEFAULT_TIME_INTERVAL } from '@svta/cml-cmcd'; +import { CMCD_EVENT_AD_BREAK_END } from '@svta/cml-cmcd'; +import { CMCD_EVENT_AD_BREAK_START } from '@svta/cml-cmcd'; +import { CMCD_EVENT_AD_END } from '@svta/cml-cmcd'; +import { CMCD_EVENT_AD_START } from '@svta/cml-cmcd'; +import { CMCD_EVENT_BACKGROUNDED_MODE } from '@svta/cml-cmcd'; +import { CMCD_EVENT_BITRATE_CHANGE } from '@svta/cml-cmcd'; +import { CMCD_EVENT_CONTENT_ID } from '@svta/cml-cmcd'; +import { CMCD_EVENT_CUSTOM_EVENT } from '@svta/cml-cmcd'; +import { CMCD_EVENT_ERROR } from '@svta/cml-cmcd'; +import { CMCD_EVENT_MUTE } from '@svta/cml-cmcd'; +import { CMCD_EVENT_PLAY_STATE } from '@svta/cml-cmcd'; +import { CMCD_EVENT_PLAYER_COLLAPSE } from '@svta/cml-cmcd'; +import { CMCD_EVENT_PLAYER_EXPAND } from '@svta/cml-cmcd'; +import { CMCD_EVENT_RESPONSE_RECEIVED } from '@svta/cml-cmcd'; +import { CMCD_EVENT_SKIP } from '@svta/cml-cmcd'; +import { CMCD_EVENT_TIME_INTERVAL } from '@svta/cml-cmcd'; +import { CMCD_EVENT_UNMUTE } from '@svta/cml-cmcd'; +import { CMCD_V1 } from '@svta/cml-cmcd'; +import { CMCD_V2 } from '@svta/cml-cmcd'; +import { CmcdCustomKey } from '@svta/cml-cmcd'; +import { CmcdCustomValue } from '@svta/cml-cmcd'; +import { CmcdEvent } from '@svta/cml-cmcd'; +import { CmcdEventReportConfig } from '@svta/cml-cmcd'; +import { CmcdEventType } from '@svta/cml-cmcd'; +import { CmcdKey } from '@svta/cml-cmcd'; +import { CmcdObjectType } from '@svta/cml-cmcd'; +import { CmcdObjectTypeList } from '@svta/cml-cmcd'; +import { CmcdPlayerState } from '@svta/cml-cmcd'; +import { CmcdReportConfig } from '@svta/cml-cmcd'; +import { CmcdRequest } from '@svta/cml-cmcd'; +import { CmcdResponse } from '@svta/cml-cmcd'; +import { CmcdStreamingFormat } from '@svta/cml-cmcd'; +import { CmcdStreamType } from '@svta/cml-cmcd'; +import { CmcdV1 } from '@svta/cml-cmcd'; +import { CmcdVersion } from '@svta/cml-cmcd'; +import { ExclusiveRecord } from '@svta/cml-utils'; +import { SfBareItem } from '@svta/cml-structured-field-values'; +import { SfItem } from '@svta/cml-structured-field-values'; +import { SfToken } from '@svta/cml-structured-field-values'; +import { ValueOf } from '@svta/cml-utils'; + // Warning: (ae-missing-release-tag) "AbrComponentAPI" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -929,6 +972,48 @@ export class ChunkMetadata { readonly transmuxing: HlsChunkPerformanceTiming; } +export { Cmcd } + +export { CMCD_DEFAULT_TIME_INTERVAL } + +export { CMCD_EVENT_AD_BREAK_END } + +export { CMCD_EVENT_AD_BREAK_START } + +export { CMCD_EVENT_AD_END } + +export { CMCD_EVENT_AD_START } + +export { CMCD_EVENT_BACKGROUNDED_MODE } + +export { CMCD_EVENT_BITRATE_CHANGE } + +export { CMCD_EVENT_CONTENT_ID } + +export { CMCD_EVENT_CUSTOM_EVENT } + +export { CMCD_EVENT_ERROR } + +export { CMCD_EVENT_MUTE } + +export { CMCD_EVENT_PLAY_STATE } + +export { CMCD_EVENT_PLAYER_COLLAPSE } + +export { CMCD_EVENT_PLAYER_EXPAND } + +export { CMCD_EVENT_RESPONSE_RECEIVED } + +export { CMCD_EVENT_SKIP } + +export { CMCD_EVENT_TIME_INTERVAL } + +export { CMCD_EVENT_UNMUTE } + +export { CMCD_V1 } + +export { CMCD_V2 } + // Warning: (ae-missing-release-tag) "CMCDController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -950,6 +1035,38 @@ export type CMCDControllerConfig = { eventTargets?: CmcdEventTarget[]; }; +export { CmcdCustomKey } + +export { CmcdCustomValue } + +export { CmcdEvent } + +export { CmcdEventReportConfig } + +export { CmcdEventType } + +export { CmcdKey } + +export { CmcdObjectType } + +export { CmcdObjectTypeList } + +export { CmcdPlayerState } + +export { CmcdReportConfig } + +export { CmcdRequest } + +export { CmcdResponse } + +export { CmcdStreamingFormat } + +export { CmcdStreamType } + +export { CmcdV1 } + +export { CmcdVersion } + // Warning: (ae-missing-release-tag) "CodecsParsed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1637,6 +1754,8 @@ export class EwmaBandWidthEstimator { update(slow: number, fast: number): void; } +export { ExclusiveRecord } + // Warning: (ae-missing-release-tag) "ExtendedSourceBuffer" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -4510,6 +4629,12 @@ export type SelectionPreferences = { subtitlePreference?: SubtitleSelectionOption; }; +export { SfBareItem } + +export { SfItem } + +export { SfToken } + // Warning: (ae-missing-release-tag) "SnapOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -5034,6 +5159,8 @@ export interface UserdataSample { uuid?: string; } +export { ValueOf } + // Warning: (ae-missing-release-tag) "VariableMap" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -5095,8 +5222,6 @@ export class XhrLoader implements Loader { // Warnings were encountered during analysis: // -// src/config.ts:86:3 - (ae-forgotten-export) The symbol "CmcdKey" needs to be exported by the entry point hls.d.ts -// src/config.ts:87:3 - (ae-forgotten-export) The symbol "CmcdVersion" needs to be exported by the entry point hls.d.ts // src/config.ts:88:3 - (ae-forgotten-export) The symbol "CmcdEventTarget" needs to be exported by the entry point hls.d.ts // (No @packageDocumentation comment for this package) diff --git a/build-config.js b/build-config.js index 755e6baea58..c497e7349fd 100644 --- a/build-config.js +++ b/build-config.js @@ -113,7 +113,6 @@ const babelTsWithPresetEnvTargets = ({ targets, stripConsole }) => babel({ extensions, babelHelpers: 'bundled', - exclude: /node_modules\/(?!(@svta)\/).*/, assumptions: { noDocumentAll: true, noClassCalls: true, diff --git a/src/exports-external.ts b/src/exports-external.ts new file mode 100644 index 00000000000..e6da907284f --- /dev/null +++ b/src/exports-external.ts @@ -0,0 +1,50 @@ +// CMCD types and constants re-exported from the bundled @svta/cml-* packages. +// These are transitively referenced by CMCDControllerConfig and CmcdEventTarget +// and must be visible from the api-extractor entry point. +export type { + Cmcd, + CMCD_DEFAULT_TIME_INTERVAL, + CMCD_EVENT_AD_BREAK_END, + CMCD_EVENT_AD_BREAK_START, + CMCD_EVENT_AD_END, + CMCD_EVENT_AD_START, + CMCD_EVENT_BACKGROUNDED_MODE, + CMCD_EVENT_BITRATE_CHANGE, + CMCD_EVENT_CONTENT_ID, + CMCD_EVENT_CUSTOM_EVENT, + CMCD_EVENT_ERROR, + CMCD_EVENT_MUTE, + CMCD_EVENT_PLAY_STATE, + CMCD_EVENT_PLAYER_COLLAPSE, + CMCD_EVENT_PLAYER_EXPAND, + CMCD_EVENT_RESPONSE_RECEIVED, + CMCD_EVENT_SKIP, + CMCD_EVENT_TIME_INTERVAL, + CMCD_EVENT_UNMUTE, + CMCD_V1, + CMCD_V2, + CmcdCustomKey, + CmcdCustomValue, + CmcdEvent, + CmcdEventReportConfig, + CmcdEventType, + CmcdKey, + CmcdObjectType, + CmcdObjectTypeList, + CmcdPlayerState, + CmcdReportConfig, + CmcdRequest, + CmcdResponse, + CmcdStreamingFormat, + CmcdStreamType, + CmcdV1, + CmcdVersion, +} from '@svta/cml-cmcd'; + +export type { + SfBareItem, + SfItem, + SfToken, +} from '@svta/cml-structured-field-values'; + +export type { ExclusiveRecord, ValueOf } from '@svta/cml-utils'; diff --git a/src/exports-named.ts b/src/exports-named.ts index 5896a7a7003..73132a3de63 100644 --- a/src/exports-named.ts +++ b/src/exports-named.ts @@ -67,19 +67,3 @@ export { AttrList } from './utils/attr-list'; export { fetchSupported } from './utils/fetch-loader'; export { isSupported, isMSESupported } from './is-supported'; export { getMediaSource } from './utils/mediasource-helper'; -export { - CmcdObjectType, - CmcdStreamType, - CmcdStreamingFormat, - CmcdPlayerState, - CmcdEventType, - CmcdHeaderField, - CMCD_V1, - CMCD_V2, -} from '@svta/cml-cmcd'; -export type { - Cmcd, - CmcdEncodeOptions, - CmcdEventReportConfig, - CmcdVersion, -} from '@svta/cml-cmcd'; From 8c5f8cacec1cf29ff657650aa86f8132b85ce6e7 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:21:39 -0800 Subject: [PATCH 08/38] fix: export clean up --- src/config.ts | 8 +++---- src/exports-external.ts | 50 ----------------------------------------- 2 files changed, 3 insertions(+), 55 deletions(-) delete mode 100644 src/exports-external.ts diff --git a/src/config.ts b/src/config.ts index a5dc9056b47..7b623d81831 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,17 +75,15 @@ export type CapLevelControllerConfig = { capLevelToPlayerSize: boolean; }; -export type CmcdEventTarget = Omit & { - includeKeys?: CmcdKey[]; -}; - export type CMCDControllerConfig = { sessionId?: string; contentId?: string; useHeaders?: boolean; includeKeys?: CmcdKey[]; version?: CmcdVersion; - eventTargets?: CmcdEventTarget[]; + eventTargets?: (Omit & { + includeKeys?: CmcdKey[]; + })[]; }; export type DRMSystemOptions = { diff --git a/src/exports-external.ts b/src/exports-external.ts deleted file mode 100644 index e6da907284f..00000000000 --- a/src/exports-external.ts +++ /dev/null @@ -1,50 +0,0 @@ -// CMCD types and constants re-exported from the bundled @svta/cml-* packages. -// These are transitively referenced by CMCDControllerConfig and CmcdEventTarget -// and must be visible from the api-extractor entry point. -export type { - Cmcd, - CMCD_DEFAULT_TIME_INTERVAL, - CMCD_EVENT_AD_BREAK_END, - CMCD_EVENT_AD_BREAK_START, - CMCD_EVENT_AD_END, - CMCD_EVENT_AD_START, - CMCD_EVENT_BACKGROUNDED_MODE, - CMCD_EVENT_BITRATE_CHANGE, - CMCD_EVENT_CONTENT_ID, - CMCD_EVENT_CUSTOM_EVENT, - CMCD_EVENT_ERROR, - CMCD_EVENT_MUTE, - CMCD_EVENT_PLAY_STATE, - CMCD_EVENT_PLAYER_COLLAPSE, - CMCD_EVENT_PLAYER_EXPAND, - CMCD_EVENT_RESPONSE_RECEIVED, - CMCD_EVENT_SKIP, - CMCD_EVENT_TIME_INTERVAL, - CMCD_EVENT_UNMUTE, - CMCD_V1, - CMCD_V2, - CmcdCustomKey, - CmcdCustomValue, - CmcdEvent, - CmcdEventReportConfig, - CmcdEventType, - CmcdKey, - CmcdObjectType, - CmcdObjectTypeList, - CmcdPlayerState, - CmcdReportConfig, - CmcdRequest, - CmcdResponse, - CmcdStreamingFormat, - CmcdStreamType, - CmcdV1, - CmcdVersion, -} from '@svta/cml-cmcd'; - -export type { - SfBareItem, - SfItem, - SfToken, -} from '@svta/cml-structured-field-values'; - -export type { ExclusiveRecord, ValueOf } from '@svta/cml-utils'; From cf201b6f56ba4983e0702b8be0d7996dac47a62a Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:23:34 -0800 Subject: [PATCH 09/38] fix: api-extractor rebuild --- api-extractor/report/hls.js.api.md | 137 ++--------------------------- 1 file changed, 6 insertions(+), 131 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 1528861d5a0..7556fa3de4f 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -4,48 +4,9 @@ ```ts -import { Cmcd } from '@svta/cml-cmcd'; -import { CMCD_DEFAULT_TIME_INTERVAL } from '@svta/cml-cmcd'; -import { CMCD_EVENT_AD_BREAK_END } from '@svta/cml-cmcd'; -import { CMCD_EVENT_AD_BREAK_START } from '@svta/cml-cmcd'; -import { CMCD_EVENT_AD_END } from '@svta/cml-cmcd'; -import { CMCD_EVENT_AD_START } from '@svta/cml-cmcd'; -import { CMCD_EVENT_BACKGROUNDED_MODE } from '@svta/cml-cmcd'; -import { CMCD_EVENT_BITRATE_CHANGE } from '@svta/cml-cmcd'; -import { CMCD_EVENT_CONTENT_ID } from '@svta/cml-cmcd'; -import { CMCD_EVENT_CUSTOM_EVENT } from '@svta/cml-cmcd'; -import { CMCD_EVENT_ERROR } from '@svta/cml-cmcd'; -import { CMCD_EVENT_MUTE } from '@svta/cml-cmcd'; -import { CMCD_EVENT_PLAY_STATE } from '@svta/cml-cmcd'; -import { CMCD_EVENT_PLAYER_COLLAPSE } from '@svta/cml-cmcd'; -import { CMCD_EVENT_PLAYER_EXPAND } from '@svta/cml-cmcd'; -import { CMCD_EVENT_RESPONSE_RECEIVED } from '@svta/cml-cmcd'; -import { CMCD_EVENT_SKIP } from '@svta/cml-cmcd'; -import { CMCD_EVENT_TIME_INTERVAL } from '@svta/cml-cmcd'; -import { CMCD_EVENT_UNMUTE } from '@svta/cml-cmcd'; -import { CMCD_V1 } from '@svta/cml-cmcd'; -import { CMCD_V2 } from '@svta/cml-cmcd'; -import { CmcdCustomKey } from '@svta/cml-cmcd'; -import { CmcdCustomValue } from '@svta/cml-cmcd'; -import { CmcdEvent } from '@svta/cml-cmcd'; -import { CmcdEventReportConfig } from '@svta/cml-cmcd'; -import { CmcdEventType } from '@svta/cml-cmcd'; -import { CmcdKey } from '@svta/cml-cmcd'; -import { CmcdObjectType } from '@svta/cml-cmcd'; -import { CmcdObjectTypeList } from '@svta/cml-cmcd'; -import { CmcdPlayerState } from '@svta/cml-cmcd'; -import { CmcdReportConfig } from '@svta/cml-cmcd'; -import { CmcdRequest } from '@svta/cml-cmcd'; -import { CmcdResponse } from '@svta/cml-cmcd'; -import { CmcdStreamingFormat } from '@svta/cml-cmcd'; -import { CmcdStreamType } from '@svta/cml-cmcd'; -import { CmcdV1 } from '@svta/cml-cmcd'; -import { CmcdVersion } from '@svta/cml-cmcd'; -import { ExclusiveRecord } from '@svta/cml-utils'; -import { SfBareItem } from '@svta/cml-structured-field-values'; -import { SfItem } from '@svta/cml-structured-field-values'; -import { SfToken } from '@svta/cml-structured-field-values'; -import { ValueOf } from '@svta/cml-utils'; +import type { CmcdEventReportConfig } from '@svta/cml-cmcd'; +import type { CmcdKey } from '@svta/cml-cmcd'; +import type { CmcdVersion } from '@svta/cml-cmcd'; // Warning: (ae-missing-release-tag) "AbrComponentAPI" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -972,48 +933,6 @@ export class ChunkMetadata { readonly transmuxing: HlsChunkPerformanceTiming; } -export { Cmcd } - -export { CMCD_DEFAULT_TIME_INTERVAL } - -export { CMCD_EVENT_AD_BREAK_END } - -export { CMCD_EVENT_AD_BREAK_START } - -export { CMCD_EVENT_AD_END } - -export { CMCD_EVENT_AD_START } - -export { CMCD_EVENT_BACKGROUNDED_MODE } - -export { CMCD_EVENT_BITRATE_CHANGE } - -export { CMCD_EVENT_CONTENT_ID } - -export { CMCD_EVENT_CUSTOM_EVENT } - -export { CMCD_EVENT_ERROR } - -export { CMCD_EVENT_MUTE } - -export { CMCD_EVENT_PLAY_STATE } - -export { CMCD_EVENT_PLAYER_COLLAPSE } - -export { CMCD_EVENT_PLAYER_EXPAND } - -export { CMCD_EVENT_RESPONSE_RECEIVED } - -export { CMCD_EVENT_SKIP } - -export { CMCD_EVENT_TIME_INTERVAL } - -export { CMCD_EVENT_UNMUTE } - -export { CMCD_V1 } - -export { CMCD_V2 } - // Warning: (ae-missing-release-tag) "CMCDController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -1032,41 +951,11 @@ export type CMCDControllerConfig = { useHeaders?: boolean; includeKeys?: CmcdKey[]; version?: CmcdVersion; - eventTargets?: CmcdEventTarget[]; + eventTargets?: (Omit & { + includeKeys?: CmcdKey[]; + })[]; }; -export { CmcdCustomKey } - -export { CmcdCustomValue } - -export { CmcdEvent } - -export { CmcdEventReportConfig } - -export { CmcdEventType } - -export { CmcdKey } - -export { CmcdObjectType } - -export { CmcdObjectTypeList } - -export { CmcdPlayerState } - -export { CmcdReportConfig } - -export { CmcdRequest } - -export { CmcdResponse } - -export { CmcdStreamingFormat } - -export { CmcdStreamType } - -export { CmcdV1 } - -export { CmcdVersion } - // Warning: (ae-missing-release-tag) "CodecsParsed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1754,8 +1643,6 @@ export class EwmaBandWidthEstimator { update(slow: number, fast: number): void; } -export { ExclusiveRecord } - // Warning: (ae-missing-release-tag) "ExtendedSourceBuffer" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -4629,12 +4516,6 @@ export type SelectionPreferences = { subtitlePreference?: SubtitleSelectionOption; }; -export { SfBareItem } - -export { SfItem } - -export { SfToken } - // Warning: (ae-missing-release-tag) "SnapOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -5159,8 +5040,6 @@ export interface UserdataSample { uuid?: string; } -export { ValueOf } - // Warning: (ae-missing-release-tag) "VariableMap" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -5220,10 +5099,6 @@ export class XhrLoader implements Loader { stats: LoaderStats; } -// Warnings were encountered during analysis: -// -// src/config.ts:88:3 - (ae-forgotten-export) The symbol "CmcdEventTarget" needs to be exported by the entry point hls.d.ts - // (No @packageDocumentation comment for this package) ``` From 214bcbbf678d45fcf8b674ad5e5ef2ab1906bd98 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:46:01 -0800 Subject: [PATCH 10/38] revert: build config change --- build-config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/build-config.js b/build-config.js index c497e7349fd..755e6baea58 100644 --- a/build-config.js +++ b/build-config.js @@ -113,6 +113,7 @@ const babelTsWithPresetEnvTargets = ({ targets, stripConsole }) => babel({ extensions, babelHelpers: 'bundled', + exclude: /node_modules\/(?!(@svta)\/).*/, assumptions: { noDocumentAll: true, noClassCalls: true, From 62203b6e9baaf1360b920ab2d2fc712ddaae64bf Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:22:13 -0800 Subject: [PATCH 11/38] fix: address code review comment about muxed buffers --- src/controller/cmcd-controller.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 3f68b31aa7e..afdc949c273 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -54,6 +54,7 @@ export default class CMCDController implements ComponentAPI { private playerState: CmcdPlayerState = CmcdPlayerState.STARTING; private audioBuffer?: ExtendedSourceBuffer; private videoBuffer?: ExtendedSourceBuffer; + private audiovideoBuffer?: ExtendedSourceBuffer; private reporter?: CmcdReporter; constructor(hls: Hls) { @@ -155,6 +156,7 @@ export default class CMCDController implements ComponentAPI { ) { this.audioBuffer = data.tracks.audio?.buffer; this.videoBuffer = data.tracks.video?.buffer; + this.audiovideoBuffer = data.tracks.audiovideo?.buffer; } private onWaiting = () => { @@ -430,7 +432,11 @@ export default class CMCDController implements ComponentAPI { private getBufferLength(type: CmcdObjectType) { const media = this.media; const buffer = - type === CmcdObjectType.AUDIO ? this.audioBuffer : this.videoBuffer; + type === CmcdObjectType.AUDIO + ? this.audioBuffer + : type === CmcdObjectType.VIDEO + ? this.videoBuffer + : this.audiovideoBuffer; if (!buffer || !media) { return NaN; From 66c92dd269937e2e41a825787a8ad4e09d506930 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:08:12 -0800 Subject: [PATCH 12/38] feat(cmcd): route event report requests through xhrSetup/fetchSetup hooks CmcdReporter sends CMCD v2 event reports via a requester function that defaults to bare fetch(), bypassing customer auth headers and credentials configured via xhrSetup/fetchSetup. Add a createCmcdRequester adapter that routes through the same setup hooks applied to media/playlist requests. Co-Authored-By: Claude Opus 4.6 --- src/controller/cmcd-controller.ts | 95 ++++++++++++++-- tests/unit/controller/cmcd-controller.ts | 139 ++++++++++++++++++++++- 2 files changed, 222 insertions(+), 12 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index afdc949c273..f81eadd0fab 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -15,6 +15,7 @@ import { } from '@svta/cml-cmcd'; import { Events } from '../events'; import { BufferHelper } from '../utils/buffer-helper'; +import FetchLoader from '../utils/fetch-loader'; import type { FragmentLoaderConstructor, HlsConfig, @@ -40,6 +41,76 @@ import type { } from '../types/loader'; import type { Cmcd } from '@svta/cml-cmcd'; +export function createCmcdRequester( + config: HlsConfig, +): (request: { + url: string; + method?: string; + headers?: Record; + body?: BodyInit; +}) => Promise<{ status: number }> { + if (config.loader === FetchLoader) { + return (request) => { + const initParams: RequestInit = { + method: request.method || 'POST', + headers: request.headers, + body: request.body, + mode: 'cors', + credentials: 'same-origin', + }; + const fetchSetup = config.fetchSetup; + const context: LoaderContext = { + url: request.url, + responseType: 'text', + }; + const requestOrPromise = fetchSetup + ? fetchSetup(context, initParams) + : new Request(request.url, initParams); + return Promise.resolve(requestOrPromise) + .then((req) => self.fetch(req)) + .then((response) => ({ status: response.status })); + }; + } + + return (request) => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const xhrSetup = config.xhrSetup; + const url = request.url; + + const openSendAndResolve = () => { + if (!xhr.readyState) { + xhr.open(request.method || 'POST', url, true); + } + if (request.headers) { + for (const name in request.headers) { + xhr.setRequestHeader(name, request.headers[name]); + } + } + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + resolve({ status: xhr.status }); + } + }; + xhr.send(request.body as XMLHttpRequestBodyInit); + }; + + if (xhrSetup) { + Promise.resolve() + .then(() => xhrSetup(xhr, url)) + .catch(() => { + xhr.open(request.method || 'POST', url, true); + return xhrSetup(xhr, url); + }) + .then(() => openSendAndResolve()) + .catch((error) => reject(error)); + } else { + openSendAndResolve(); + } + }); + }; +} + /** * Controller to deal with Common Media Client Data (CMCD) * @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf @@ -68,16 +139,20 @@ export default class CMCDController implements ComponentAPI { const version = cmcd.version || CMCD_V1; - this.reporter = new CmcdReporter({ - sid: cmcd.sessionId || hls.sessionId, - cid: cmcd.contentId, - version, - transmissionMode: cmcd.useHeaders === true ? CMCD_HEADERS : CMCD_QUERY, - enabledKeys: cmcd.includeKeys ?? [ - ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), - ], - eventTargets: cmcd.eventTargets ?? [], - }); + this.reporter = new CmcdReporter( + { + sid: cmcd.sessionId || hls.sessionId, + cid: cmcd.contentId, + version, + transmissionMode: + cmcd.useHeaders === true ? CMCD_HEADERS : CMCD_QUERY, + enabledKeys: cmcd.includeKeys ?? [ + ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), + ], + eventTargets: cmcd.eventTargets ?? [], + }, + createCmcdRequester(config), + ); this.reporter.update({ sf: CmcdStreamingFormat.HLS, diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 846d5bf54c9..9bd5bba097c 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -1,11 +1,15 @@ import { CmcdHeaderField } from '@svta/cml-cmcd'; import { expect } from 'chai'; -import CMCDController from '../../../src/controller/cmcd-controller'; +import CMCDController, { + createCmcdRequester, +} from '../../../src/controller/cmcd-controller'; import { Events } from '../../../src/events'; import Hls from '../../../src/hls'; import M3U8Parser from '../../../src/loader/m3u8-parser'; import { PlaylistLevelType } from '../../../src/types/loader'; -import type { CMCDControllerConfig } from '../../../src/config'; +import FetchLoader from '../../../src/utils/fetch-loader'; +import XhrLoader from '../../../src/utils/xhr-loader'; +import type { CMCDControllerConfig, HlsConfig } from '../../../src/config'; import type { Fragment, Part } from '../../../src/loader/fragment'; let cmcdController; @@ -378,5 +382,136 @@ describe('CMCDController', function () { cmcdController.destroy(); }); }); + + describe('createCmcdRequester', function () { + it('calls xhrSetup when using XhrLoader', function (done) { + const xhrSetupCalls: any[][] = []; + const config = { + loader: XhrLoader, + xhrSetup: (xhr: XMLHttpRequest, url: string) => { + xhrSetupCalls.push([xhr, url]); + xhr.open('POST', url, true); + }, + } as unknown as HlsConfig; + + const requester = createCmcdRequester(config); + + // Use a data URI to avoid actual network requests + requester({ + url: 'data:text/plain,test', + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'test-body', + }).then( + () => { + expect(xhrSetupCalls).to.have.lengthOf(1); + expect(xhrSetupCalls[0][1]).to.equal('data:text/plain,test'); + expect(xhrSetupCalls[0][0]).to.be.an.instanceOf(XMLHttpRequest); + done(); + }, + (err) => done(err), + ); + }); + + it('calls fetchSetup when using FetchLoader', function (done) { + const fetchSetupCalls: any[][] = []; + const config = { + loader: FetchLoader, + fetchSetup: (context: any, initParams: any) => { + fetchSetupCalls.push([context, initParams]); + return new Request(context.url, initParams); + }, + } as unknown as HlsConfig; + + const requester = createCmcdRequester(config); + + // Stub fetch to avoid actual network request + const origFetch = self.fetch; + self.fetch = (() => + Promise.resolve( + new Response('', { status: 200 }), + )) as typeof self.fetch; + + requester({ + url: 'https://analytics.example.com/cmcd', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }).then( + (result) => { + self.fetch = origFetch; + expect(fetchSetupCalls).to.have.lengthOf(1); + expect(fetchSetupCalls[0][0].url).to.equal( + 'https://analytics.example.com/cmcd', + ); + expect(fetchSetupCalls[0][1].method).to.equal('POST'); + expect(result.status).to.equal(200); + done(); + }, + (err) => { + self.fetch = origFetch; + done(err); + }, + ); + }); + + it('uses plain fetch when no fetchSetup is configured with FetchLoader', function (done) { + const config = { + loader: FetchLoader, + fetchSetup: undefined, + } as unknown as HlsConfig; + + const requester = createCmcdRequester(config); + + const origFetch = self.fetch; + const fetchCalls: Request[] = []; + self.fetch = ((req: Request) => { + fetchCalls.push(req); + return Promise.resolve(new Response(null, { status: 204 })); + }) as typeof self.fetch; + + requester({ + url: 'https://analytics.example.com/cmcd', + method: 'POST', + body: '{}', + }).then( + (result) => { + self.fetch = origFetch; + expect(fetchCalls).to.have.lengthOf(1); + expect(fetchCalls[0].url).to.equal( + 'https://analytics.example.com/cmcd', + ); + expect(fetchCalls[0].method).to.equal('POST'); + expect(result.status).to.equal(204); + done(); + }, + (err) => { + self.fetch = origFetch; + done(err); + }, + ); + }); + + it('uses plain XHR when no xhrSetup is configured with XhrLoader', function (done) { + const config = { + loader: XhrLoader, + xhrSetup: undefined, + } as unknown as HlsConfig; + + const requester = createCmcdRequester(config); + + requester({ + url: 'data:text/plain,test', + method: 'POST', + body: 'test-body', + }).then( + (result) => { + expect(result).to.have.property('status'); + done(); + }, + (err) => done(err), + ); + }); + }); }); }); From aa08b0e51c37c0547f87240633d43abdaf90b855 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:54:32 -0800 Subject: [PATCH 13/38] feat(cmcd): add CMCD v2 e2e tests and upgrade @svta/cml-cmcd to 2.2.0 Add end-to-end tests for CMCD v2 covering query mode, header mode, event mode, key filtering, and version comparison (v1 vs v2). Fix eventTargets enabledKeys mapping bug in CMCDController where includeKeys was not being mapped to the library's enabledKeys parameter, causing event reports to have no enabled keys. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + karma.conf.js | 9 + package-lock.json | 14 +- package.json | 2 +- src/controller/cmcd-controller.ts | 9 +- tests/e2e/cmcd.ts | 469 ++++++++++++++++++++++++++ tests/index.js | 1 + tests/mocks/cmcd-request-collector.ts | 204 +++++++++++ 8 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/cmcd.ts create mode 100644 tests/mocks/cmcd-request-collector.ts diff --git a/.gitignore b/.gitignore index 8993e0dee37..ca1d665963e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ _ReSharper*/ # VSCode custom workspace settings .vscode/settings.json + +# Claude local settings +.claude/settings.local.json diff --git a/karma.conf.js b/karma.conf.js index 498d2b4035e..1b1bc461389 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -79,5 +79,14 @@ module.exports = function (config) { // Concurrency level // how many browser should be started simultaneous concurrency: 1, + + // Timeout for e2e tests that perform real network I/O + browserNoActivityTimeout: 120000, + + client: { + mocha: { + timeout: 60000, + }, + }, }); }; diff --git a/package-lock.json b/package-lock.json index 66f22380d3e..60314a4d778 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.1.2", + "@svta/cml-cmcd": "2.2.0", "@svta/cml-id3": "1.0.5", "@svta/cml-utils": "1.4.0", "@types/chai": "5.2.3", @@ -4227,9 +4227,9 @@ "license": "CC0-1.0" }, "node_modules/@svta/cml-cmcd": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.2.tgz", - "integrity": "sha512-HE5ePM088b++7dehiLNxPUwhjJGUqbsEkoQMd+E+QBOt20qLyKeCtCfHz2DQTTCYss8fVZScDPXbzCM6bUI5tw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.2.0.tgz", + "integrity": "sha512-sJSlQPbciKJ8duqUIFOo29C0Pn31Q+IRYNfhdBB/9jCZ453KLpk2eblCfWInVn2F3hnZyjTMSsy44RzwFUwo7g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -17353,9 +17353,9 @@ "dev": true }, "@svta/cml-cmcd": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.2.tgz", - "integrity": "sha512-HE5ePM088b++7dehiLNxPUwhjJGUqbsEkoQMd+E+QBOt20qLyKeCtCfHz2DQTTCYss8fVZScDPXbzCM6bUI5tw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.2.0.tgz", + "integrity": "sha512-sJSlQPbciKJ8duqUIFOo29C0Pn31Q+IRYNfhdBB/9jCZ453KLpk2eblCfWInVn2F3hnZyjTMSsy44RzwFUwo7g==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index fd2ba0e3bce..42a99c51492 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.1.2", + "@svta/cml-cmcd": "2.2.0", "@svta/cml-id3": "1.0.5", "@svta/cml-utils": "1.4.0", "@types/chai": "5.2.3", diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index f81eadd0fab..e8ce3341af2 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -149,7 +149,14 @@ export default class CMCDController implements ComponentAPI { enabledKeys: cmcd.includeKeys ?? [ ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), ], - eventTargets: cmcd.eventTargets ?? [], + eventTargets: (cmcd.eventTargets ?? []).map( + ({ includeKeys, ...rest }) => ({ + ...rest, + enabledKeys: includeKeys ?? [ + ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), + ], + }), + ), }, createCmcdRequester(config), ); diff --git a/tests/e2e/cmcd.ts b/tests/e2e/cmcd.ts new file mode 100644 index 00000000000..8540cc4ea76 --- /dev/null +++ b/tests/e2e/cmcd.ts @@ -0,0 +1,469 @@ +import { + CmcdEventType, + validateCmcdEvents, + validateCmcdRequest, +} from '@svta/cml-cmcd'; +import { assert, expect } from 'chai'; +import { Events } from '../../src/events'; +import Hls from '../../src/hls'; +import FetchLoader from '../../src/utils/fetch-loader'; +import { CmcdRequestCollector } from '../mocks/cmcd-request-collector'; +import type { CollectedRequest } from '../mocks/cmcd-request-collector'; + +const TEST_STREAM = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; +const SESSION_ID = 'e2e-test-session'; +const CONTENT_ID = 'e2e-test-content'; +const EVENT_TARGET_URL = 'https://httpbin.org/post'; +const REQUEST_TIMEOUT = 30000; + +function createVideoElement(): HTMLVideoElement { + const video = document.createElement('video'); + video.muted = true; + video.playsInline = true; + video.autoplay = true; + document.body.appendChild(video); + return video; +} + +function destroyVideoElement(video: HTMLVideoElement): void { + video.pause(); + video.removeAttribute('src'); + video.load(); + if (video.parentNode) { + video.parentNode.removeChild(video); + } +} + +function waitForPlayback( + hls: Hls, + video: HTMLVideoElement, + timeout: number = REQUEST_TIMEOUT, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + settled = true; + self.clearTimeout(timer); + self.clearInterval(interval); + hls.off(Events.FRAG_CHANGED, onFragChanged); + }; + + const timer = self.setTimeout(() => { + if (!settled) { + cleanup(); + reject( + new Error( + `Timeout waiting for playback (currentTime=${video.currentTime})`, + ), + ); + } + }, timeout); + + const onFragChanged = () => { + if (!settled && video.currentTime > 0) { + cleanup(); + resolve(); + } + }; + + hls.on(Events.FRAG_CHANGED, onFragChanged); + + // Also check periodically in case FRAG_CHANGED already fired + const interval = self.setInterval(() => { + if (!settled && video.currentTime > 0) { + cleanup(); + resolve(); + } + }, 200); + }); +} + +function validateCollectedRequest(collected: CollectedRequest) { + const result = validateCmcdRequest(collected.request); + expect(result.valid).to.equal( + true, + `CMCD validation failed: ${JSON.stringify(result.issues)}`, + ); + return result.data as Record; +} + +describe('CMCD v2 E2E Tests', function () { + this.timeout(60000); + + let collector: CmcdRequestCollector; + let video: HTMLVideoElement; + let hls: Hls; + let origOnerror: OnErrorEventHandler; + + beforeEach(function () { + collector = new CmcdRequestCollector(); + video = createVideoElement(); + + // Suppress uncaught errors from the transmuxer worker blob. + // The worker blob runs the full IIFE bundle which references mocha's + // `describe` at the top level, causing "describe is not defined" errors + // in the worker scope. These are harmless and pre-existing. + origOnerror = self.onerror; + self.onerror = function (message, source, ...rest) { + if ( + typeof source === 'string' && + source.startsWith('blob:') && + typeof message === 'string' && + message.includes('describe is not defined') + ) { + return true; // suppress + } + if (origOnerror) { + return (origOnerror as any).call(this, message, source, ...rest); + } + return false; + }; + }); + + afterEach(function () { + if (hls) { + hls.destroy(); + } + destroyVideoElement(video); + collector.detach(); + collector.clear(); + self.onerror = origOnerror; + }); + + describe('Group 1: Query Mode (v2)', function () { + beforeEach(function () { + collector.attach(); + hls = new Hls({ + loader: FetchLoader, + cmcd: { + version: 2, + sessionId: SESSION_ID, + contentId: CONTENT_ID, + }, + }); + hls.attachMedia(video); + hls.loadSource(TEST_STREAM); + }); + + it('should send valid CMCD v2 on manifest requests', async function () { + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const decoded = validateCollectedRequest(manifests[0]); + + expect(decoded).to.have.property('ot', 'm'); + expect(decoded).to.have.property('sf', 'h'); + expect(decoded).to.have.property('sid', SESSION_ID); + expect(decoded).to.have.property('cid', CONTENT_ID); + expect(decoded).to.have.property('v', 2); + expect(decoded).to.have.property('sta'); + }); + + it('should send valid CMCD v2 on segment requests', async function () { + // Wait for segments directly — segments are requested before playback starts + const segments = await collector.waitForRequests( + 'segment', + 2, + REQUEST_TIMEOUT, + ); + const decoded = validateCollectedRequest(segments[0]); + + expect(decoded).to.have.property('ot'); + expect(['av', 'v', 'a', 'i']).to.include(decoded.ot as string); + expect(decoded).to.have.property('d'); + expect(decoded.d as number).to.be.greaterThan(0); + expect(decoded).to.have.property('br'); + expect(decoded).to.have.property('st', 'v'); + }); + + it('should include next object request (nor) on at least one segment', async function () { + await waitForPlayback(hls, video); + + const segments = await collector.waitForRequests( + 'segment', + 2, + REQUEST_TIMEOUT, + ); + const hasNor = segments.some((req) => { + const result = validateCmcdRequest(req.request); + return result.data.nor != null; + }); + expect(hasNor).to.equal(true, 'No segment had a "nor" field'); + }); + + it('should reflect player state transitions (sta)', async function () { + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const firstDecoded = validateCollectedRequest(manifests[0]); + expect(firstDecoded).to.have.property('sta', 's'); + + // Wait for playback to start, then wait for additional segments + // which should carry sta=p (playing state) + await waitForPlayback(hls, video); + + // After playback starts, wait for more segments to be requested + // with the updated player state + await collector.waitForRequests('segment', 4, REQUEST_TIMEOUT); + + const segments = collector.getRequests('segment'); + const hasPlaying = segments.some((req) => { + const result = validateCmcdRequest(req.request); + return result.data.sta === 'p'; + }); + expect(hasPlaying).to.equal( + true, + `No segment had sta=p (playing state). States found: ${segments + .map((r) => validateCmcdRequest(r.request).data.sta) + .join(', ')}`, + ); + }); + + it('should include measured throughput (mtp) after playback', async function () { + await waitForPlayback(hls, video); + + const segments = await collector.waitForRequests( + 'segment', + 2, + REQUEST_TIMEOUT, + ); + const hasMtp = segments.some((req) => { + const result = validateCmcdRequest(req.request); + return result.data.mtp != null; + }); + expect(hasMtp).to.equal( + true, + 'No segment had "mtp" (measured throughput)', + ); + }); + }); + + describe('Group 2: Header Mode (v2)', function () { + beforeEach(function () { + collector.attach(); + hls = new Hls({ + loader: FetchLoader, + cmcd: { + version: 2, + useHeaders: true, + sessionId: SESSION_ID, + contentId: CONTENT_ID, + }, + }); + hls.attachMedia(video); + hls.loadSource(TEST_STREAM); + }); + + it('should send CMCD headers (not query) on manifest requests', async function () { + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const req = manifests[0]; + + expect(req.reportingMode).to.equal('header'); + expect(req.request.url).to.not.include('CMCD='); + + const decoded = validateCollectedRequest(req); + expect(decoded).to.have.property('ot', 'm'); + expect(decoded).to.have.property('sf', 'h'); + expect(decoded).to.have.property('sid', SESSION_ID); + expect(decoded).to.have.property('v', 2); + }); + + it('should validate CMCD headers contain correct v2 fields', async function () { + // Note: CMCD header mode adds custom CMCD-* headers which trigger CORS + // preflight on cross-origin requests. The test stream server may not + // support these headers in CORS. We validate the headers on the initial + // manifest request which is always captured by the interceptor. + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const req = manifests[0]; + + expect(req.reportingMode).to.equal('header'); + + const decoded = validateCollectedRequest(req); + + // v2-specific fields + expect(decoded).to.have.property('v', 2); + expect(decoded).to.have.property('sta'); + + // Standard fields + expect(decoded).to.have.property('ot', 'm'); + expect(decoded).to.have.property('sf', 'h'); + expect(decoded).to.have.property('sid', SESSION_ID); + expect(decoded).to.have.property('cid', CONTENT_ID); + }); + + it('should place CMCD fields in correct header shards', async function () { + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const { headers } = manifests[0].request; + + // CMCD-Session should contain sid, sf + const session = headers.get('CMCD-Session'); + if (session) { + expect(session).to.include('sid='); + expect(session).to.include('sf='); + } + + // CMCD-Object should contain ot + const object = headers.get('CMCD-Object'); + if (object) { + expect(object).to.include('ot='); + } + }); + }); + + describe('Group 3: Event Mode (v2)', function () { + beforeEach(function () { + collector.attach({ eventTargetUrls: [EVENT_TARGET_URL] }); + hls = new Hls({ + loader: FetchLoader, + cmcd: { + version: 2, + sessionId: SESSION_ID, + contentId: CONTENT_ID, + eventTargets: [ + { + url: EVENT_TARGET_URL, + events: [CmcdEventType.PLAY_STATE], + }, + ], + }, + }); + hls.attachMedia(video); + hls.loadSource(TEST_STREAM); + }); + + it('should send play state events via POST', async function () { + await waitForPlayback(hls, video); + + const events = await collector.waitForRequests( + 'event', + 1, + REQUEST_TIMEOUT, + ); + expect(events.length).to.be.greaterThan(0); + + const req = events[0]; + expect(req.request.method).to.equal('POST'); + + const body = await req.request.text(); + expect(body.length).to.be.greaterThan(0); + + const result = validateCmcdEvents(body); + expect(result.valid).to.equal( + true, + `CMCD event validation failed: ${JSON.stringify(result.issues)}`, + ); + expect(result.data.length).to.be.greaterThan(0); + + result.data.forEach((decoded) => { + assert(decoded.v === 2); + expect(decoded.e).to.equal('ps'); + expect(decoded).to.have.property('ts'); + }); + }); + }); + + describe('Group 4: Key Filtering', function () { + const INCLUDED_KEYS = ['sid', 'cid', 'ot', 'v', 'sf']; + + beforeEach(function () { + collector.attach(); + hls = new Hls({ + loader: FetchLoader, + cmcd: { + version: 2, + sessionId: SESSION_ID, + contentId: CONTENT_ID, + includeKeys: INCLUDED_KEYS as any, + }, + }); + hls.attachMedia(video); + hls.loadSource(TEST_STREAM); + }); + + it('should only include specified keys', async function () { + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const result = validateCmcdRequest(manifests[0].request); + const decoded = result.data as Record; + + INCLUDED_KEYS.forEach((key) => { + expect(decoded).to.have.property(key); + }); + + // These keys should be excluded + expect(decoded).to.not.have.property('su'); + expect(decoded).to.not.have.property('sta'); + expect(decoded).to.not.have.property('mtp'); + }); + }); + + describe('Group 5: Version Comparison', function () { + it('v1 should omit v and sta fields', async function () { + collector.attach(); + hls = new Hls({ + loader: FetchLoader, + cmcd: { + sessionId: SESSION_ID, + contentId: CONTENT_ID, + }, + }); + hls.attachMedia(video); + hls.loadSource(TEST_STREAM); + + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const result = validateCmcdRequest(manifests[0].request); + const decoded = result.data as Record; + + expect(decoded).to.not.have.property('v'); + expect(decoded).to.not.have.property('sta'); + }); + + it('v2 should include v=2 and sta', async function () { + collector.attach(); + hls = new Hls({ + loader: FetchLoader, + cmcd: { + version: 2, + sessionId: SESSION_ID, + contentId: CONTENT_ID, + }, + }); + hls.attachMedia(video); + hls.loadSource(TEST_STREAM); + + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const decoded = validateCollectedRequest(manifests[0]); + + expect(decoded).to.have.property('v', 2); + expect(decoded).to.have.property('sta', 's'); + }); + }); +}); diff --git a/tests/index.js b/tests/index.js index e2080cb5983..013e07231d5 100644 --- a/tests/index.js +++ b/tests/index.js @@ -1,6 +1,7 @@ import 'promise-polyfill/src/polyfill'; import './unit/hls'; import './unit/events'; +import './e2e/cmcd'; import './unit/controller/abr-controller'; import './unit/controller/audio-stream-controller'; import './unit/controller/audio-track-controller'; diff --git a/tests/mocks/cmcd-request-collector.ts b/tests/mocks/cmcd-request-collector.ts new file mode 100644 index 00000000000..b50678aa642 --- /dev/null +++ b/tests/mocks/cmcd-request-collector.ts @@ -0,0 +1,204 @@ +/** + * CmcdRequestCollector — intercepts fetch requests to capture CMCD data. + * + * Monkey-patches self.fetch so that outgoing requests carrying CMCD query + * params or headers are recorded for later assertion in e2e tests. + * + * For event target URLs, intercepts POST requests and returns a synthetic + * 200 response to prevent actual network calls to external endpoints. + * + * Requires hls.js to be configured with `loader: FetchLoader` so that all + * requests flow through fetch. + */ + +export type CmcdRequestType = 'manifest' | 'segment' | 'event' | 'unknown'; + +export type CmcdRequestMode = 'query' | 'header' | 'event'; + +export interface CollectedRequest { + request: Request; + type: CmcdRequestType; + reportingMode: CmcdRequestMode; + timestamp: number; +} + +export interface CollectorOptions { + eventTargetUrls?: string[]; +} + +interface Waiter { + type: CmcdRequestType | undefined; + count: number; + resolve: (requests: CollectedRequest[]) => void; + reject: (reason: Error) => void; + timer: number; +} + +const MANIFEST_EXTENSIONS = /\.(m3u8|mpd)/i; +const SEGMENT_EXTENSIONS = /\.(m4s|ts|mp4|m4a|m4v|aac)(\?|$)/i; + +function classifyUrl(url: string, method: string): CmcdRequestType { + if (method === 'POST') { + return 'event'; + } + if (MANIFEST_EXTENSIONS.test(url)) { + return 'manifest'; + } + if (SEGMENT_EXTENSIONS.test(url)) { + return 'segment'; + } + return 'unknown'; +} + +function hasCmcdHeaders(request: Request): boolean { + let found = false; + request.headers.forEach((_value, name) => { + if (name.toLowerCase().startsWith('cmcd-')) { + found = true; + } + }); + return found; +} + +export class CmcdRequestCollector { + private requests: CollectedRequest[] = []; + private waiters: Waiter[] = []; + private attached = false; + private eventTargetUrls: string[] = []; + private origFetch: typeof self.fetch | null = null; + + attach(options: CollectorOptions = {}): void { + if (this.attached) { + return; + } + this.attached = true; + this.eventTargetUrls = options.eventTargetUrls || []; + + const origFetch = (this.origFetch = self.fetch); + + (self as any).fetch = ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const request = + input instanceof Request ? input : new Request(String(input), init); + + const type = classifyUrl(request.url, request.method); + + const entry: CollectedRequest = { + request: request.clone(), + type, + reportingMode: + type === 'event' + ? 'event' + : hasCmcdHeaders(request) + ? 'header' + : 'query', + timestamp: Date.now(), + }; + + this.addRequest(entry); + + // Intercept event target POSTs — return synthetic 200 response + if (this.isEventTargetRequest(request)) { + return Promise.resolve(new Response('', { status: 200 })); + } + + return origFetch.call(self, input, init); + }; + } + + detach(): void { + if (!this.attached) { + return; + } + + if (this.origFetch) { + self.fetch = this.origFetch; + } + + this.origFetch = null; + this.attached = false; + + // Clear pending waiters + this.waiters.forEach((waiter) => { + self.clearTimeout(waiter.timer); + waiter.reject(new Error('Collector detached while waiting')); + }); + this.waiters = []; + } + + getRequests(type?: CmcdRequestType): CollectedRequest[] { + if (type) { + return this.requests.filter((r) => r.type === type); + } + return [...this.requests]; + } + + clear(): void { + this.requests = []; + } + + waitForRequests( + type: CmcdRequestType | undefined, + count: number, + timeout: number = 30000, + ): Promise { + const matching = this.getRequests(type); + + if (matching.length >= count) { + return Promise.resolve(matching); + } + + return new Promise((resolve, reject) => { + const timer = self.setTimeout(() => { + this.removeWaiter(waiter); + const current = this.getRequests(type); + reject( + new Error( + `Timeout waiting for ${count} ${type || 'any'} CMCD request(s). ` + + `Got ${current.length}. Total collected: ${this.requests.length}.`, + ), + ); + }, timeout); + + const waiter: Waiter = { type, count, resolve, reject, timer }; + this.waiters.push(waiter); + }); + } + + private addRequest(entry: CollectedRequest): void { + this.requests.push(entry); + this.checkWaiters(); + } + + private isEventTargetRequest(request: Request): boolean { + const { method, url } = request; + return ( + method === 'POST' && + this.eventTargetUrls.some((target) => url.startsWith(target)) + ); + } + + private removeWaiter(waiter: Waiter): void { + const idx = this.waiters.indexOf(waiter); + if (idx >= 0) { + this.waiters.splice(idx, 1); + } + } + + private checkWaiters(): void { + const resolved: Waiter[] = []; + + this.waiters.forEach((waiter) => { + const matching = this.getRequests(waiter.type); + if (matching.length >= waiter.count) { + self.clearTimeout(waiter.timer); + waiter.resolve(matching); + resolved.push(waiter); + } + }); + + resolved.forEach((w) => this.removeWaiter(w)); + } +} From f2e021e1f76c26f2c386a6f3523c73d04a34ca3c Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:09:20 -0800 Subject: [PATCH 14/38] fix: return 204 for event mode responses --- tests/mocks/cmcd-request-collector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mocks/cmcd-request-collector.ts b/tests/mocks/cmcd-request-collector.ts index b50678aa642..ee52aa0e912 100644 --- a/tests/mocks/cmcd-request-collector.ts +++ b/tests/mocks/cmcd-request-collector.ts @@ -101,7 +101,7 @@ export class CmcdRequestCollector { // Intercept event target POSTs — return synthetic 200 response if (this.isEventTargetRequest(request)) { - return Promise.resolve(new Response('', { status: 200 })); + return Promise.resolve(new Response('', { status: 204 })); } return origFetch.call(self, input, init); From 2cc2d75f05b9035fc7622310f00fa19ae8315274 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:41:25 -0800 Subject: [PATCH 15/38] refactor(cmcd): use CmcdReporter default requester, add loader config option Remove createCmcdRequester function and allow users to pass a custom loader via cmcd.loader config. When not provided, CmcdReporter uses its default fetch-based requester. Co-Authored-By: Claude Opus 4.5 --- src/config.ts | 6 + src/controller/cmcd-controller.ts | 73 +----------- tests/unit/controller/cmcd-controller.ts | 139 +---------------------- 3 files changed, 9 insertions(+), 209 deletions(-) diff --git a/src/config.ts b/src/config.ts index 7b623d81831..7ebfa2c7d5d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -84,6 +84,12 @@ export type CMCDControllerConfig = { eventTargets?: (Omit & { includeKeys?: CmcdKey[]; })[]; + loader?: (request: { + url: string; + method?: string; + headers?: Record; + body?: BodyInit; + }) => Promise<{ status: number }>; }; export type DRMSystemOptions = { diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index e8ce3341af2..39aecf0a827 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -15,7 +15,6 @@ import { } from '@svta/cml-cmcd'; import { Events } from '../events'; import { BufferHelper } from '../utils/buffer-helper'; -import FetchLoader from '../utils/fetch-loader'; import type { FragmentLoaderConstructor, HlsConfig, @@ -41,76 +40,6 @@ import type { } from '../types/loader'; import type { Cmcd } from '@svta/cml-cmcd'; -export function createCmcdRequester( - config: HlsConfig, -): (request: { - url: string; - method?: string; - headers?: Record; - body?: BodyInit; -}) => Promise<{ status: number }> { - if (config.loader === FetchLoader) { - return (request) => { - const initParams: RequestInit = { - method: request.method || 'POST', - headers: request.headers, - body: request.body, - mode: 'cors', - credentials: 'same-origin', - }; - const fetchSetup = config.fetchSetup; - const context: LoaderContext = { - url: request.url, - responseType: 'text', - }; - const requestOrPromise = fetchSetup - ? fetchSetup(context, initParams) - : new Request(request.url, initParams); - return Promise.resolve(requestOrPromise) - .then((req) => self.fetch(req)) - .then((response) => ({ status: response.status })); - }; - } - - return (request) => { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - const xhrSetup = config.xhrSetup; - const url = request.url; - - const openSendAndResolve = () => { - if (!xhr.readyState) { - xhr.open(request.method || 'POST', url, true); - } - if (request.headers) { - for (const name in request.headers) { - xhr.setRequestHeader(name, request.headers[name]); - } - } - xhr.onreadystatechange = () => { - if (xhr.readyState === 4) { - resolve({ status: xhr.status }); - } - }; - xhr.send(request.body as XMLHttpRequestBodyInit); - }; - - if (xhrSetup) { - Promise.resolve() - .then(() => xhrSetup(xhr, url)) - .catch(() => { - xhr.open(request.method || 'POST', url, true); - return xhrSetup(xhr, url); - }) - .then(() => openSendAndResolve()) - .catch((error) => reject(error)); - } else { - openSendAndResolve(); - } - }); - }; -} - /** * Controller to deal with Common Media Client Data (CMCD) * @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf @@ -158,7 +87,7 @@ export default class CMCDController implements ComponentAPI { }), ), }, - createCmcdRequester(config), + cmcd.loader, ); this.reporter.update({ diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 9bd5bba097c..846d5bf54c9 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -1,15 +1,11 @@ import { CmcdHeaderField } from '@svta/cml-cmcd'; import { expect } from 'chai'; -import CMCDController, { - createCmcdRequester, -} from '../../../src/controller/cmcd-controller'; +import CMCDController from '../../../src/controller/cmcd-controller'; import { Events } from '../../../src/events'; import Hls from '../../../src/hls'; import M3U8Parser from '../../../src/loader/m3u8-parser'; import { PlaylistLevelType } from '../../../src/types/loader'; -import FetchLoader from '../../../src/utils/fetch-loader'; -import XhrLoader from '../../../src/utils/xhr-loader'; -import type { CMCDControllerConfig, HlsConfig } from '../../../src/config'; +import type { CMCDControllerConfig } from '../../../src/config'; import type { Fragment, Part } from '../../../src/loader/fragment'; let cmcdController; @@ -382,136 +378,5 @@ describe('CMCDController', function () { cmcdController.destroy(); }); }); - - describe('createCmcdRequester', function () { - it('calls xhrSetup when using XhrLoader', function (done) { - const xhrSetupCalls: any[][] = []; - const config = { - loader: XhrLoader, - xhrSetup: (xhr: XMLHttpRequest, url: string) => { - xhrSetupCalls.push([xhr, url]); - xhr.open('POST', url, true); - }, - } as unknown as HlsConfig; - - const requester = createCmcdRequester(config); - - // Use a data URI to avoid actual network requests - requester({ - url: 'data:text/plain,test', - method: 'POST', - headers: { 'Content-Type': 'text/plain' }, - body: 'test-body', - }).then( - () => { - expect(xhrSetupCalls).to.have.lengthOf(1); - expect(xhrSetupCalls[0][1]).to.equal('data:text/plain,test'); - expect(xhrSetupCalls[0][0]).to.be.an.instanceOf(XMLHttpRequest); - done(); - }, - (err) => done(err), - ); - }); - - it('calls fetchSetup when using FetchLoader', function (done) { - const fetchSetupCalls: any[][] = []; - const config = { - loader: FetchLoader, - fetchSetup: (context: any, initParams: any) => { - fetchSetupCalls.push([context, initParams]); - return new Request(context.url, initParams); - }, - } as unknown as HlsConfig; - - const requester = createCmcdRequester(config); - - // Stub fetch to avoid actual network request - const origFetch = self.fetch; - self.fetch = (() => - Promise.resolve( - new Response('', { status: 200 }), - )) as typeof self.fetch; - - requester({ - url: 'https://analytics.example.com/cmcd', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{}', - }).then( - (result) => { - self.fetch = origFetch; - expect(fetchSetupCalls).to.have.lengthOf(1); - expect(fetchSetupCalls[0][0].url).to.equal( - 'https://analytics.example.com/cmcd', - ); - expect(fetchSetupCalls[0][1].method).to.equal('POST'); - expect(result.status).to.equal(200); - done(); - }, - (err) => { - self.fetch = origFetch; - done(err); - }, - ); - }); - - it('uses plain fetch when no fetchSetup is configured with FetchLoader', function (done) { - const config = { - loader: FetchLoader, - fetchSetup: undefined, - } as unknown as HlsConfig; - - const requester = createCmcdRequester(config); - - const origFetch = self.fetch; - const fetchCalls: Request[] = []; - self.fetch = ((req: Request) => { - fetchCalls.push(req); - return Promise.resolve(new Response(null, { status: 204 })); - }) as typeof self.fetch; - - requester({ - url: 'https://analytics.example.com/cmcd', - method: 'POST', - body: '{}', - }).then( - (result) => { - self.fetch = origFetch; - expect(fetchCalls).to.have.lengthOf(1); - expect(fetchCalls[0].url).to.equal( - 'https://analytics.example.com/cmcd', - ); - expect(fetchCalls[0].method).to.equal('POST'); - expect(result.status).to.equal(204); - done(); - }, - (err) => { - self.fetch = origFetch; - done(err); - }, - ); - }); - - it('uses plain XHR when no xhrSetup is configured with XhrLoader', function (done) { - const config = { - loader: XhrLoader, - xhrSetup: undefined, - } as unknown as HlsConfig; - - const requester = createCmcdRequester(config); - - requester({ - url: 'data:text/plain,test', - method: 'POST', - body: 'test-body', - }).then( - (result) => { - expect(result).to.have.property('status'); - done(); - }, - (err) => done(err), - ); - }); - }); }); }); From 1ef698a7cedb85569ebd215974e191631b429418 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:47:11 -0800 Subject: [PATCH 16/38] chore: update api file --- api-extractor/report/hls.js.api.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index d67dbafc66a..7b94eb76d33 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -954,6 +954,14 @@ export type CMCDControllerConfig = { eventTargets?: (Omit & { includeKeys?: CmcdKey[]; })[]; + loader?: (request: { + url: string; + method?: string; + headers?: Record; + body?: BodyInit; + }) => Promise<{ + status: number; + }>; }; // Warning: (ae-missing-release-tag) "CodecsParsed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) From c148687c6d44f0dc6fd2bf22b40627c1bcd5651b Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:16:02 -0700 Subject: [PATCH 17/38] chore: update CMCD package for revision B changes --- package-lock.json | 1023 +-------------------------------------------- package.json | 2 +- 2 files changed, 21 insertions(+), 1004 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61c3f46e711..d4faa89d165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.2.0", + "@svta/cml-cmcd": "2.3.0", "@svta/cml-id3": "1.0.5", "@svta/cml-utils": "1.4.0", "@types/chai": "5.2.3", @@ -4257,9 +4257,9 @@ "license": "CC0-1.0" }, "node_modules/@svta/cml-cmcd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.2.0.tgz", - "integrity": "sha512-sJSlQPbciKJ8duqUIFOo29C0Pn31Q+IRYNfhdBB/9jCZ453KLpk2eblCfWInVn2F3hnZyjTMSsy44RzwFUwo7g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.0.tgz", + "integrity": "sha512-8bqSb9KP/anWQCrLyXqh/bvo3qmJtSrJhccp0OttKXqnethZByQrhGaNKpXU0tur2jdXTJLJKeUUb1p80H4PNQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4391,44 +4391,12 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "8.4.9", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.9.tgz", - "integrity": "sha512-jFCSo4wJzlHQLCpceUhUnXdrPuCNOjGFMQ8Eg6JXxlz3QaCKOb7eGi2cephQdM4XTYsNej69P9JDJ1zqNIbncQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", "dev": true }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -4861,198 +4829,6 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, - "node_modules/@webassemblyjs/ast": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", - "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", - "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", - "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", - "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", - "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", - "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", - "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", - "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", - "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", - "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", - "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/helper-wasm-section": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-opt": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "@webassemblyjs/wast-printer": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", - "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", - "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", - "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", - "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5078,17 +4854,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", - "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", - "dev": true, - "optional": true, - "peer": true, - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -5177,17 +4942,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "optional": true, - "peer": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/anchor-markdown-header": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/anchor-markdown-header/-/anchor-markdown-header-0.8.2.tgz", @@ -5925,17 +5679,6 @@ "node": ">= 6" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/chromedriver": { "version": "146.0.6", "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-146.0.6.tgz", @@ -6849,14 +6592,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7308,21 +7043,6 @@ "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/eslint-utils": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", @@ -7550,17 +7270,6 @@ "node": ">=4.0" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -7582,17 +7291,6 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8205,14 +7903,6 @@ "integrity": "sha512-Um/XtCfPYhDdrlbsNAiakk6AxCpJSLwvF+a95Cmq0Nn/xF/AihHlsBo0EfoUqnKUqyrgoXzX+q5QsSKzH+/gvw==", "dev": true }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", @@ -9438,50 +9128,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -9525,14 +9171,6 @@ "node": ">=6" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9900,17 +9538,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6.11.5" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10354,14 +9981,6 @@ "node": ">= 0.10.0" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -13348,62 +12967,6 @@ "node": ">=10" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz", - "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.14", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.0", - "terser": "^5.14.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -14072,125 +13635,30 @@ "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", "dev": true, "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/webpack": { - "version": "5.76.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz", - "integrity": "sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^0.0.51", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.10.0", - "es-module-lexer": "^0.9.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } + "url": "https://opencollective.com/unified" } }, - "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "node_modules/void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", "dev": true, - "optional": true, - "peer": true, "engines": { - "node": ">=10.13.0" + "node": ">=0.10.0" } }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">= 8" } }, "node_modules/whatwg-encoding": { @@ -17422,9 +16890,9 @@ "dev": true }, "@svta/cml-cmcd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.2.0.tgz", - "integrity": "sha512-sJSlQPbciKJ8duqUIFOo29C0Pn31Q+IRYNfhdBB/9jCZ453KLpk2eblCfWInVn2F3hnZyjTMSsy44RzwFUwo7g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.0.tgz", + "integrity": "sha512-8bqSb9KP/anWQCrLyXqh/bvo3qmJtSrJhccp0OttKXqnethZByQrhGaNKpXU0tur2jdXTJLJKeUUb1p80H4PNQ==", "dev": true, "requires": {} }, @@ -17530,44 +16998,12 @@ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true }, - "@types/eslint": { - "version": "8.4.9", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.9.tgz", - "integrity": "sha512-jFCSo4wJzlHQLCpceUhUnXdrPuCNOjGFMQ8Eg6JXxlz3QaCKOb7eGi2cephQdM4XTYsNej69P9JDJ1zqNIbncQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "@types/estree": { "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", "dev": true }, - "@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "optional": true, - "peer": true - }, "@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -17868,198 +17304,6 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, - "@webassemblyjs/ast": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", - "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@webassemblyjs/helper-numbers": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1" - } - }, - "@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", - "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@webassemblyjs/helper-api-error": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", - "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", - "dev": true, - "optional": true, - "peer": true - }, - "@webassemblyjs/helper-buffer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", - "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", - "dev": true, - "optional": true, - "peer": true - }, - "@webassemblyjs/helper-numbers": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", - "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@webassemblyjs/floating-point-hex-parser": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", - "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", - "dev": true, - "optional": true, - "peer": true - }, - "@webassemblyjs/helper-wasm-section": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", - "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1" - } - }, - "@webassemblyjs/ieee754": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", - "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "@webassemblyjs/leb128": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", - "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/utf8": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", - "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@webassemblyjs/wasm-edit": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", - "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/helper-wasm-section": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-opt": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "@webassemblyjs/wast-printer": "1.11.1" - } - }, - "@webassemblyjs/wasm-gen": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", - "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "@webassemblyjs/wasm-opt": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", - "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1" - } - }, - "@webassemblyjs/wasm-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", - "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "@webassemblyjs/wast-printer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", - "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "optional": true, - "peer": true - }, - "@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "optional": true, - "peer": true - }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -18076,15 +17320,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true }, - "acorn-import-assertions": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", - "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", - "dev": true, - "optional": true, - "peer": true, - "requires": {} - }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -18148,15 +17383,6 @@ } } }, - "ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": {} - }, "anchor-markdown-header": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/anchor-markdown-header/-/anchor-markdown-header-0.8.2.tgz", @@ -18684,14 +17910,6 @@ } } }, - "chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true, - "optional": true, - "peer": true - }, "chromedriver": { "version": "146.0.6", "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-146.0.6.tgz", @@ -19385,14 +18603,6 @@ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true }, - "es-module-lexer": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", - "dev": true, - "optional": true, - "peer": true - }, "es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -19805,18 +19015,6 @@ "@eslint-community/eslint-utils": "^4.4.0" } }, - "eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, "eslint-utils": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", @@ -19891,14 +19089,6 @@ } } }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "optional": true, - "peer": true - }, "estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -19917,14 +19107,6 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "optional": true, - "peer": true - }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -20364,14 +19546,6 @@ "integrity": "sha512-Um/XtCfPYhDdrlbsNAiakk6AxCpJSLwvF+a95Cmq0Nn/xF/AihHlsBo0EfoUqnKUqyrgoXzX+q5QsSKzH+/gvw==", "dev": true }, - "glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "optional": true, - "peer": true - }, "globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", @@ -21203,40 +20377,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "optional": true, - "peer": true - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, "jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -21272,14 +20412,6 @@ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "optional": true, - "peer": true - }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -21563,14 +20695,6 @@ } } }, - "loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "optional": true, - "peer": true - }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -21893,14 +21017,6 @@ "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", "dev": true }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "optional": true, - "peer": true - }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -24096,36 +23212,6 @@ } } }, - "terser-webpack-plugin": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz", - "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.14", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.0", - "terser": "^5.14.1" - }, - "dependencies": { - "schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - } - } - } - }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -24592,81 +23678,12 @@ "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", "dev": true }, - "watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - } - }, "web-streams-polyfill": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", "dev": true }, - "webpack": { - "version": "5.76.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz", - "integrity": "sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^0.0.51", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.10.0", - "es-module-lexer": "^0.9.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" - }, - "dependencies": { - "schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - } - } - } - }, - "webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, - "optional": true, - "peer": true - }, "whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", diff --git a/package.json b/package.json index ed3d5a62e38..4fb3d5caa8b 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.2.0", + "@svta/cml-cmcd": "2.3.0", "@svta/cml-id3": "1.0.5", "@svta/cml-utils": "1.4.0", "@types/chai": "5.2.3", From c10b1f529bd5789fa5766fbf76a2af5f9784fc3b Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:59:23 -0700 Subject: [PATCH 18/38] fix(cmcd): address review feedback on controller, tests, and docs - Swap native 'ended' listener for hls Events.MEDIA_ENDED - Reset + recreate reporter on MANIFEST_LOADING so loadSource() on an existing Hls instance behaves like a fresh CMCD session - Replace this.reporter! assertions with if (this.reporter) blocks - Use hls.latestLevelDetails in getStreamType - Tighten low-latency detection to require details.partList (TODO #7729) - Document version, eventTargets (with child properties), and loader config options in docs/API.md - Extend setupEach test harness with a streamController.getLevelDetails mock to support latestLevelDetails lookup Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/API.md | 10 +- src/controller/cmcd-controller.ts | 131 +++++++++++++++-------- tests/unit/controller/cmcd-controller.ts | 3 + 3 files changed, 97 insertions(+), 47 deletions(-) diff --git a/docs/API.md b/docs/API.md index 2452dd4b8e4..74fd4e1c436 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1884,7 +1884,15 @@ data will be passed on all media requests (manifests, playlists, a/v segments, t - `sessionId`: The CMCD session id. One will be automatically generated if none is provided. - `contentId`: The CMCD content id. - `useHeaders`: Send CMCD data in request headers instead of as query args. Defaults to `false`. -- `includeKeys`: An optional array of CMCD keys. When present, only these CMCD fields will be included with each each request. +- `includeKeys`: An optional array of CMCD keys. When present, only these CMCD fields will be included with each request. Defaults to the full set of keys for the configured `version`. +- `version`: CMCD version to report. Accepts `1` (CMCD v1) or `2` (CMCD v2). Defaults to `1`. When set to `2`, additional v2 fields (player state `sta`, buffer starvation duration `bs`, etc.) and event-mode reporting become available. +- `eventTargets`: An optional array of CMCD v2 event report targets. Each target configures an endpoint that receives batched event reports (player state changes, bitrate changes, errors, etc.). Requires `version: 2`. Each target has the following properties: + - `url`: The endpoint URL that receives CMCD event reports. Required. + - `events`: An optional array of [CMCD event types](https://github.com/streaming-video-technology-alliance/common-media-library/tree/main/libs/cmcd) to report to this target. If omitted, no events are sent. Values are the short codes such as `"ps"` (play state), `"bc"` (bitrate change), `"e"` (error), `"t"` (time interval). + - `interval`: For the time-interval event, the reporting cadence in seconds. Defaults to `30`. + - `batchSize`: The number of events to batch before sending a report. Defaults to `1` (send each event immediately). + - `includeKeys`: An optional array of CMCD keys that overrides the top-level `includeKeys` for this target. +- `loader`: An optional async function `(request) => Promise<{ status }>` used to deliver CMCD v2 event reports. When omitted, event reports are delivered via `fetch` (honoring the Hls `xhrSetup`/`fetchSetup` hooks). Only used when `eventTargets` is configured. ### `enableInterstitialPlayback` diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 39aecf0a827..2ba083132c6 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -28,7 +28,9 @@ import type { BufferCreatedData, ErrorData, LevelSwitchingData, + ManifestLoadingData, MediaAttachedData, + MediaEndedData, } from '../types/events'; import type { FragmentLoaderContext, @@ -66,43 +68,53 @@ export default class CMCDController implements ComponentAPI { config.pLoader = this.createPlaylistLoader(); config.fLoader = this.createFragmentLoader(); - const version = cmcd.version || CMCD_V1; - - this.reporter = new CmcdReporter( - { - sid: cmcd.sessionId || hls.sessionId, - cid: cmcd.contentId, - version, - transmissionMode: - cmcd.useHeaders === true ? CMCD_HEADERS : CMCD_QUERY, - enabledKeys: cmcd.includeKeys ?? [ - ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), - ], - eventTargets: (cmcd.eventTargets ?? []).map( - ({ includeKeys, ...rest }) => ({ - ...rest, - enabledKeys: includeKeys ?? [ - ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), - ], - }), - ), - }, - cmcd.loader, - ); - - this.reporter.update({ - sf: CmcdStreamingFormat.HLS, - sta: this.playerState, - }); - this.reporter.start(); + this.createReporter(); this.registerListeners(); } } + private createReporter() { + const { cmcd } = this.config; + if (cmcd == null) { + return; + } + + const version = cmcd.version || CMCD_V1; + + this.reporter = new CmcdReporter( + { + sid: cmcd.sessionId || this.hls.sessionId, + cid: cmcd.contentId, + version, + transmissionMode: cmcd.useHeaders === true ? CMCD_HEADERS : CMCD_QUERY, + enabledKeys: cmcd.includeKeys ?? [ + ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), + ], + eventTargets: (cmcd.eventTargets ?? []).map( + ({ includeKeys, ...rest }) => ({ + ...rest, + enabledKeys: includeKeys ?? [ + ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), + ], + }), + ), + }, + cmcd.loader, + ); + + this.reporter.update({ + sf: CmcdStreamingFormat.HLS, + sta: this.playerState, + }); + this.reporter.start(); + } + private registerListeners() { const hls = this.hls; + hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this); + hls.on(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this); hls.on(Events.ERROR, this.onError, this); hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); @@ -110,8 +122,10 @@ export default class CMCDController implements ComponentAPI { private unregisterListeners() { const hls = this.hls; + hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this); + hls.off(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this); hls.off(Events.ERROR, this.onError, this); hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); @@ -131,7 +145,7 @@ export default class CMCDController implements ComponentAPI { // @ts-ignore this.onWaiting = this.onPlaying = this.onPause = null; // @ts-ignore - this.onSeeking = this.onEnded = this.media = null; + this.onSeeking = this.media = null; } private onMediaAttached( @@ -143,7 +157,6 @@ export default class CMCDController implements ComponentAPI { this.media.addEventListener('playing', this.onPlaying); this.media.addEventListener('pause', this.onPause); this.media.addEventListener('seeking', this.onSeeking); - this.media.addEventListener('ended', this.onEnded); } private onMediaDetached() { @@ -155,7 +168,6 @@ export default class CMCDController implements ComponentAPI { this.media.removeEventListener('playing', this.onPlaying); this.media.removeEventListener('pause', this.onPause); this.media.removeEventListener('seeking', this.onSeeking); - this.media.removeEventListener('ended', this.onEnded); // @ts-ignore this.media = null; @@ -198,14 +210,33 @@ export default class CMCDController implements ComponentAPI { this.setPlayerState(CmcdPlayerState.SEEKING); }; - private onEnded = () => { + private onMediaEnded(event: Events.MEDIA_ENDED, data: MediaEndedData) { this.setPlayerState(CmcdPlayerState.ENDED); - }; + } + + private onManifestLoading( + event: Events.MANIFEST_LOADING, + data: ManifestLoadingData, + ) { + this.playerState = CmcdPlayerState.STARTING; + this.initialized = false; + this.starved = false; + this.buffering = true; + + if (this.reporter) { + this.reporter.stop(true); + this.reporter = undefined; + } + + this.createReporter(); + } private onError(event: Events.ERROR, data: ErrorData) { if (data.fatal) { this.setPlayerState(CmcdPlayerState.FATAL_ERROR); - this.reporter!.recordEvent(CmcdEventType.ERROR); + if (this.reporter) { + this.reporter.recordEvent(CmcdEventType.ERROR); + } } } @@ -213,14 +244,18 @@ export default class CMCDController implements ComponentAPI { event: Events.LEVEL_SWITCHING, data: LevelSwitchingData, ) { - this.reporter!.update({ br: [data.bitrate / 1000] }); + if (!this.reporter) { + return; + } + + this.reporter.update({ br: [data.bitrate / 1000] }); const eventData: Cmcd = {}; const frag = data.details?.fragments[0]; if (frag) { eventData.ot = this.getObjectType(frag); } - this.reporter!.recordEvent(CmcdEventType.BITRATE_CHANGE, eventData); + this.reporter.recordEvent(CmcdEventType.BITRATE_CHANGE, eventData); } private setPlayerState(state: CmcdPlayerState) { @@ -229,17 +264,17 @@ export default class CMCDController implements ComponentAPI { } this.playerState = state; - this.reporter!.update({ sta: state }); - this.reporter!.recordEvent(CmcdEventType.PLAY_STATE); + if (this.reporter) { + this.reporter.update({ sta: state }); + this.reporter.recordEvent(CmcdEventType.PLAY_STATE); + } } /** * Get the stream type based on level details. */ private getStreamType(): CmcdStreamType | undefined { - const loadLevel = this.hls.loadLevel; - const details = - loadLevel >= 0 ? this.hls.levels[loadLevel]?.details : undefined; + const details = this.hls.latestLevelDetails; if (!details) { return undefined; @@ -249,8 +284,8 @@ export default class CMCDController implements ComponentAPI { return CmcdStreamType.VOD; } - // TODO: Is this the best way to determine the low-latency stream type? - if (details.canBlockReload || details.canSkipUntil) { + // TODO: Replace with an `isLowLatency` check in #7729 + if (!!details.partList && details.canBlockReload) { return CmcdStreamType.LOW_LATENCY; } @@ -261,8 +296,12 @@ export default class CMCDController implements ComponentAPI { * Apply CMCD data to a request using the reporter. */ private apply(context: LoaderContext, data: Cmcd = {}) { + if (!this.reporter) { + return; + } + // Update persistent data - this.reporter!.update({ + this.reporter.update({ mtp: [this.hls.bandwidthEstimate / 1000], pr: this.media?.playbackRate, st: this.getStreamType(), @@ -285,7 +324,7 @@ export default class CMCDController implements ComponentAPI { // TODO: Implement rtp, dl - const report = this.reporter!.createRequestReport( + const report = this.reporter.createRequestReport( { url: context.url, headers: context.headers }, data, ); diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 846d5bf54c9..8964002d040 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -67,6 +67,9 @@ const setupEach = (cmcd?: CMCDControllerConfig) => { levels: [level], level: 0, }; + hls.streamController = { + getLevelDetails: () => details, + }; // hls.audioTracks = []; cmcdController = new CMCDController(hls); From 26f493a5f97f60d777ecff9f3a078750203e0e45 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:50:10 -0700 Subject: [PATCH 19/38] chore: update @svta/cml-cmcd to 2.3.1 --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d8ad624efe..2549281bd80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.3.0", + "@svta/cml-cmcd": "2.3.1", "@svta/cml-id3": "1.0.5", "@svta/cml-utils": "1.4.0", "@types/chai": "5.2.3", @@ -4257,9 +4257,9 @@ "license": "CC0-1.0" }, "node_modules/@svta/cml-cmcd": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.0.tgz", - "integrity": "sha512-8bqSb9KP/anWQCrLyXqh/bvo3qmJtSrJhccp0OttKXqnethZByQrhGaNKpXU0tur2jdXTJLJKeUUb1p80H4PNQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.1.tgz", + "integrity": "sha512-tG/dyFoMhjT6W8rcUD5VXPAthHxvYIQwWunysRzmtPe6qSdWp+MlibT9+Dv3wEmrWrTzo4LbjvihucyMtP5JCA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -16890,9 +16890,9 @@ "dev": true }, "@svta/cml-cmcd": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.0.tgz", - "integrity": "sha512-8bqSb9KP/anWQCrLyXqh/bvo3qmJtSrJhccp0OttKXqnethZByQrhGaNKpXU0tur2jdXTJLJKeUUb1p80H4PNQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.1.tgz", + "integrity": "sha512-tG/dyFoMhjT6W8rcUD5VXPAthHxvYIQwWunysRzmtPe6qSdWp+MlibT9+Dv3wEmrWrTzo4LbjvihucyMtP5JCA==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index 61e09bbff37..f7d289cf296 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.3.0", + "@svta/cml-cmcd": "2.3.1", "@svta/cml-id3": "1.0.5", "@svta/cml-utils": "1.4.0", "@types/chai": "5.2.3", From 34fe1b1af53bb02714130f59f48ffecd4247af52 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 13 May 2026 14:50:58 -0700 Subject: [PATCH 20/38] chore: update CML versions --- package-lock.json | 62 +++++++++++++++++++++++------------------------ package.json | 6 ++--- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index f7b85d71536..44664b46201 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,9 +25,9 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.3.1", - "@svta/cml-id3": "1.0.5", - "@svta/cml-utils": "1.4.0", + "@svta/cml-cmcd": "2.3.2", + "@svta/cml-id3": "1.0.6", + "@svta/cml-utils": "1.5.0", "@types/chai": "5.2.3", "@types/chart.js": "2.9.41", "@types/mocha": "10.0.10", @@ -4041,36 +4041,36 @@ "license": "CC0-1.0" }, "node_modules/@svta/cml-cmcd": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.1.tgz", - "integrity": "sha512-tG/dyFoMhjT6W8rcUD5VXPAthHxvYIQwWunysRzmtPe6qSdWp+MlibT9+Dv3wEmrWrTzo4LbjvihucyMtP5JCA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.2.tgz", + "integrity": "sha512-SKBBjLmci0WK8HMjuv+36tVIMktonoOoxsXblOFZmB+ePPV2zjRMTD+2ZmE/1VEPJkKHENyhSjSHgJyeOlvZ1A==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-structured-field-values": "1.1.2", - "@svta/cml-utils": "1.4.0" + "@svta/cml-structured-field-values": "1.1.3", + "@svta/cml-utils": "1.5.0" } }, "node_modules/@svta/cml-id3": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.5.tgz", - "integrity": "sha512-RCqoHnEv9TPFHtou3M3oa39g8d99Cim0SVFwwpf3oG3fNIlTDltb7O1986zBpp7SteWZWZa77H6y+dsLCJokvg==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.6.tgz", + "integrity": "sha512-63j8gkAnPOmOBWlp0hIZPvsIioZttdbg6/TgwITqMYbSLYVJ+6QGa/UtIP0I84NsfstANw6QdCn7i8SS08kn1A==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.4.0" + "@svta/cml-utils": "1.5.0" } }, "node_modules/@svta/cml-structured-field-values": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.2.tgz", - "integrity": "sha512-9Se5b/T7BqgHECVacf/sNZ5wFIOm6d6vXLpfcIfHRGplOaOxDzcGgP+OezQ2CxBcYJKZMkw6pT+QMYDyGKOb1g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.3.tgz", + "integrity": "sha512-XqLzQOTznz6kh/nsSh8dy1kV7GQjL4csG+2U5EvLGhAqNuNUczbVg5EAsDobKDVg8xhdthdXx2UuwM+ZHQCxSg==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -4078,13 +4078,13 @@ "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.4.0" + "@svta/cml-utils": "1.5.0" } }, "node_modules/@svta/cml-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.4.0.tgz", - "integrity": "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.5.0.tgz", + "integrity": "sha512-JMqclD7Akd+GSJiuaYNUHOP2wNtf/nauKeszlYeivSHfi0Lp3pmSW5PXDvJ2dO4aPmmSOQbF0ztTsW9Vcs2Whw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -16555,31 +16555,31 @@ "dev": true }, "@svta/cml-cmcd": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.1.tgz", - "integrity": "sha512-tG/dyFoMhjT6W8rcUD5VXPAthHxvYIQwWunysRzmtPe6qSdWp+MlibT9+Dv3wEmrWrTzo4LbjvihucyMtP5JCA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.2.tgz", + "integrity": "sha512-SKBBjLmci0WK8HMjuv+36tVIMktonoOoxsXblOFZmB+ePPV2zjRMTD+2ZmE/1VEPJkKHENyhSjSHgJyeOlvZ1A==", "dev": true, "requires": {} }, "@svta/cml-id3": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.5.tgz", - "integrity": "sha512-RCqoHnEv9TPFHtou3M3oa39g8d99Cim0SVFwwpf3oG3fNIlTDltb7O1986zBpp7SteWZWZa77H6y+dsLCJokvg==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.6.tgz", + "integrity": "sha512-63j8gkAnPOmOBWlp0hIZPvsIioZttdbg6/TgwITqMYbSLYVJ+6QGa/UtIP0I84NsfstANw6QdCn7i8SS08kn1A==", "dev": true, "requires": {} }, "@svta/cml-structured-field-values": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.2.tgz", - "integrity": "sha512-9Se5b/T7BqgHECVacf/sNZ5wFIOm6d6vXLpfcIfHRGplOaOxDzcGgP+OezQ2CxBcYJKZMkw6pT+QMYDyGKOb1g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.3.tgz", + "integrity": "sha512-XqLzQOTznz6kh/nsSh8dy1kV7GQjL4csG+2U5EvLGhAqNuNUczbVg5EAsDobKDVg8xhdthdXx2UuwM+ZHQCxSg==", "dev": true, "peer": true, "requires": {} }, "@svta/cml-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.4.0.tgz", - "integrity": "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.5.0.tgz", + "integrity": "sha512-JMqclD7Akd+GSJiuaYNUHOP2wNtf/nauKeszlYeivSHfi0Lp3pmSW5PXDvJ2dO4aPmmSOQbF0ztTsW9Vcs2Whw==", "dev": true }, "@testim/chrome-version": { diff --git a/package.json b/package.json index 07a58ff47af..f1f0491379f 100644 --- a/package.json +++ b/package.json @@ -88,9 +88,9 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.3.1", - "@svta/cml-id3": "1.0.5", - "@svta/cml-utils": "1.4.0", + "@svta/cml-cmcd": "2.3.2", + "@svta/cml-id3": "1.0.6", + "@svta/cml-utils": "1.5.0", "@types/chai": "5.2.3", "@types/chart.js": "2.9.41", "@types/mocha": "10.0.10", From c3deb030704d1ee1628895e7a35d18514566d51c Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Mon, 18 May 2026 14:04:48 -0700 Subject: [PATCH 21/38] refactor(cmcd): use addEventListener/removeEventListener helpers Replace direct media.addEventListener/removeEventListener calls with the shared helpers from utils/event-listener-helper. The helpers minify better and guard against duplicate listener registration, matching the pattern used by base-stream-controller, buffer-controller, and gap-controller. --- src/controller/cmcd-controller.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 2ba083132c6..bb7837120b6 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -15,6 +15,10 @@ import { } from '@svta/cml-cmcd'; import { Events } from '../events'; import { BufferHelper } from '../utils/buffer-helper'; +import { + addEventListener, + removeEventListener, +} from '../utils/event-listener-helper'; import type { FragmentLoaderConstructor, HlsConfig, @@ -152,22 +156,24 @@ export default class CMCDController implements ComponentAPI { event: Events.MEDIA_ATTACHED, data: MediaAttachedData, ) { - this.media = data.media; - this.media.addEventListener('waiting', this.onWaiting); - this.media.addEventListener('playing', this.onPlaying); - this.media.addEventListener('pause', this.onPause); - this.media.addEventListener('seeking', this.onSeeking); + const media = (this.media = data.media); + addEventListener(media, 'waiting', this.onWaiting); + addEventListener(media, 'playing', this.onPlaying); + addEventListener(media, 'pause', this.onPause); + addEventListener(media, 'seeking', this.onSeeking); } private onMediaDetached() { - if (!this.media) { + const media = this.media; + + if (!media) { return; } - this.media.removeEventListener('waiting', this.onWaiting); - this.media.removeEventListener('playing', this.onPlaying); - this.media.removeEventListener('pause', this.onPause); - this.media.removeEventListener('seeking', this.onSeeking); + removeEventListener(media, 'waiting', this.onWaiting); + removeEventListener(media, 'playing', this.onPlaying); + removeEventListener(media, 'pause', this.onPause); + removeEventListener(media, 'seeking', this.onSeeking); // @ts-ignore this.media = null; From ed1009d47e9d1d8834c8935ff53a88365c53204d Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Mon, 18 May 2026 14:14:40 -0700 Subject: [PATCH 22/38] fix(cmcd): resolve SEEKING state when seek lands while paused After a seek, the native 'seeked' event fires but no 'playing' or 'pause' event follows when the media was already paused. Add an 'onSeeked' handler that transitions SEEKING -> PAUSED in that case. PLAYING and REBUFFERING transitions are still handled by the existing 'playing' and 'waiting' listeners. --- src/controller/cmcd-controller.ts | 10 +++++++- tests/unit/controller/cmcd-controller.ts | 32 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index bb7837120b6..666e9c66fbe 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -149,7 +149,7 @@ export default class CMCDController implements ComponentAPI { // @ts-ignore this.onWaiting = this.onPlaying = this.onPause = null; // @ts-ignore - this.onSeeking = this.media = null; + this.onSeeking = this.onSeeked = this.media = null; } private onMediaAttached( @@ -161,6 +161,7 @@ export default class CMCDController implements ComponentAPI { addEventListener(media, 'playing', this.onPlaying); addEventListener(media, 'pause', this.onPause); addEventListener(media, 'seeking', this.onSeeking); + addEventListener(media, 'seeked', this.onSeeked); } private onMediaDetached() { @@ -174,6 +175,7 @@ export default class CMCDController implements ComponentAPI { removeEventListener(media, 'playing', this.onPlaying); removeEventListener(media, 'pause', this.onPause); removeEventListener(media, 'seeking', this.onSeeking); + removeEventListener(media, 'seeked', this.onSeeked); // @ts-ignore this.media = null; @@ -216,6 +218,12 @@ export default class CMCDController implements ComponentAPI { this.setPlayerState(CmcdPlayerState.SEEKING); }; + private onSeeked = () => { + if (this.media?.paused) { + this.setPlayerState(CmcdPlayerState.PAUSED); + } + }; + private onMediaEnded(event: Events.MEDIA_ENDED, data: MediaEndedData) { this.setPlayerState(CmcdPlayerState.ENDED); } diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 8964002d040..d1afadb4383 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -358,6 +358,38 @@ describe('CMCDController', function () { cmcdController.destroy(); }); + it('resolves SEEKING to PAUSED via onSeeked when paused', function () { + setupEach({ version: 2 }); + + (cmcdController as any).media = { + paused: true, + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + (cmcdController as any).onSeeking(); + expect((cmcdController as any).playerState).to.equal('k'); + + (cmcdController as any).onSeeked(); + expect((cmcdController as any).playerState).to.equal('a'); + + cmcdController.destroy(); + }); + + it('keeps SEEKING via onSeeked when not paused', function () { + setupEach({ version: 2 }); + + (cmcdController as any).media = { + paused: false, + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + (cmcdController as any).onSeeking(); + expect((cmcdController as any).playerState).to.equal('k'); + + (cmcdController as any).onSeeked(); + expect((cmcdController as any).playerState).to.equal('k'); + + cmcdController.destroy(); + }); + it('does not record duplicate play state events', function () { setupEach({ version: 2, From 095fb8e7278d250f3e9b999ec72f254a796ecc48 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Mon, 18 May 2026 14:52:02 -0700 Subject: [PATCH 23/38] chore(cmcd): drop unused karma timeout config The browserNoActivityTimeout and client.mocha.timeout overrides were added during an earlier iteration of the CMCD e2e tests. The e2e suite now sets its own describe-level timeout, so the karma-level overrides aren't needed. --- karma.conf.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/karma.conf.js b/karma.conf.js index 4c730afe9f4..242d73a2176 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -3,6 +3,7 @@ const { buildRollupConfig, BUILD_TYPE, FORMAT } = require('./build-config'); // Do not add coverage for JavaScript debugging when running `test:unit:debug` // eslint-disable-next-line no-undef const includeCoverage = !process.env.DEBUG_UNIT_TESTS && !process.env.CI; +// eslint-disable-next-line no-undef const isDevContainer = process.env.IN_DEV_CONTAINER === 'true'; const rollupPreprocessor = buildRollupConfig({ @@ -87,14 +88,5 @@ module.exports = function (config) { // Concurrency level // how many browser should be started simultaneous concurrency: 1, - - // Timeout for e2e tests that perform real network I/O - browserNoActivityTimeout: 120000, - - client: { - mocha: { - timeout: 60000, - }, - }, }); }; From 44ee326e2ec4de87b55c796046890f2508e6f41c Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Tue, 19 May 2026 21:19:15 -0700 Subject: [PATCH 24/38] fix(cmcd): use mainForwardBufferInfo for buffer length Defers to hls.mainForwardBufferInfo for VIDEO/MUXED so reported bl matches the player's authoritative forward-buffer view (handles alt-audio vs muxed SourceBuffer selection, backward-seek/paused maxBufferHole=0, gap skipping, and live/interstitial buffer-before-seek). Audio path keeps the direct SourceBuffer query pending #7858 but mirrors the paused -> hole=0 rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/controller/cmcd-controller.ts | 37 ++++++++++++++----------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 666e9c66fbe..76c7d82bbe3 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -59,8 +59,6 @@ export default class CMCDController implements ComponentAPI { private buffering: boolean = true; private playerState: CmcdPlayerState = CmcdPlayerState.STARTING; private audioBuffer?: ExtendedSourceBuffer; - private videoBuffer?: ExtendedSourceBuffer; - private audiovideoBuffer?: ExtendedSourceBuffer; private reporter?: CmcdReporter; constructor(hls: Hls) { @@ -145,7 +143,7 @@ export default class CMCDController implements ComponentAPI { } // @ts-ignore - this.hls = this.config = this.audioBuffer = this.videoBuffer = null; + this.hls = this.config = this.audioBuffer = null; // @ts-ignore this.onWaiting = this.onPlaying = this.onPause = null; // @ts-ignore @@ -186,8 +184,6 @@ export default class CMCDController implements ComponentAPI { data: BufferCreatedData, ) { this.audioBuffer = data.tracks.audio?.buffer; - this.videoBuffer = data.tracks.video?.buffer; - this.audiovideoBuffer = data.tracks.audiovideo?.buffer; } private onWaiting = () => { @@ -495,25 +491,26 @@ export default class CMCDController implements ComponentAPI { */ private getBufferLength(type: CmcdObjectType) { const media = this.media; - const buffer = - type === CmcdObjectType.AUDIO - ? this.audioBuffer - : type === CmcdObjectType.VIDEO - ? this.videoBuffer - : this.audiovideoBuffer; - - if (!buffer || !media) { + if (!media) { return NaN; } - // TODO: Implement parameterized buffer length array - const info = BufferHelper.bufferInfo( - buffer, - media.currentTime, - this.config.maxBufferHole, - ); + // TODO: Replace with hls.audioForwardBufferInfo once exposed. https://github.com/video-dev/hls.js/issues/7858 + if (type === CmcdObjectType.AUDIO) { + const buffer = this.audioBuffer; + if (!buffer) { + return NaN; + } + const info = BufferHelper.bufferInfo( + buffer, + media.currentTime, + media.paused ? 0 : this.config.maxBufferHole, + ); + return info.len * 1000; + } - return info.len * 1000; + const info = this.hls.mainForwardBufferInfo; + return info ? info.len * 1000 : NaN; } /** From 98c63566275d113545fe5c75d1e25fb7bf3f32ef Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 20 May 2026 21:43:42 -0700 Subject: [PATCH 25/38] test(cmcd): add failing tests for getBufferLength buffer-info sources --- tests/unit/controller/cmcd-controller.ts | 76 +++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index d1afadb4383..a5f1ae26a38 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -1,4 +1,4 @@ -import { CmcdHeaderField } from '@svta/cml-cmcd'; +import { CmcdHeaderField, CmcdObjectType } from '@svta/cml-cmcd'; import { expect } from 'chai'; import CMCDController from '../../../src/controller/cmcd-controller'; import { Events } from '../../../src/events'; @@ -413,5 +413,79 @@ describe('CMCDController', function () { cmcdController.destroy(); }); }); + + describe('getBufferLength', function () { + const stubBufferInfo = ( + hls: any, + prop: 'mainForwardBufferInfo' | 'audioForwardBufferInfo', + info: { len: number } | null, + ) => { + Object.defineProperty(hls, prop, { + configurable: true, + get: () => info, + }); + }; + + it('uses hls.audioForwardBufferInfo for CmcdObjectType.AUDIO', function () { + setupEach({}); + const hls = cmcdController.hls; + (cmcdController as any).media = {} as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 12.5 }); + // Set main to a sentinel that would fail the assertion if used by mistake. + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 }); + + const result = (cmcdController as any).getBufferLength( + CmcdObjectType.AUDIO, + ); + expect(result).to.equal(12500); + }); + + it('returns NaN for CmcdObjectType.AUDIO when audioForwardBufferInfo is null', function () { + setupEach({}); + const hls = cmcdController.hls; + (cmcdController as any).media = {} as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 }); + + const result = (cmcdController as any).getBufferLength( + CmcdObjectType.AUDIO, + ); + expect(Number.isNaN(result)).to.equal(true); + }); + + it('uses hls.mainForwardBufferInfo for CmcdObjectType.VIDEO and MUXED', function () { + setupEach({}); + const hls = cmcdController.hls; + (cmcdController as any).media = {} as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 }); + + expect( + (cmcdController as any).getBufferLength(CmcdObjectType.VIDEO), + ).to.equal(8000); + expect( + (cmcdController as any).getBufferLength(CmcdObjectType.MUXED), + ).to.equal(8000); + }); + + it('returns NaN when no media is attached', function () { + setupEach({}); + const hls = cmcdController.hls; + (cmcdController as any).media = undefined; + stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 12.5 }); + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 }); + + expect( + Number.isNaN( + (cmcdController as any).getBufferLength(CmcdObjectType.AUDIO), + ), + ).to.equal(true); + expect( + Number.isNaN( + (cmcdController as any).getBufferLength(CmcdObjectType.VIDEO), + ), + ).to.equal(true); + }); + }); }); }); From de7bb76afcf8499cd2f7f8aea4dd43c526a07cea Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 20 May 2026 21:51:17 -0700 Subject: [PATCH 26/38] fix(cmcd): use hls.audioForwardBufferInfo in getBufferLength (resolves #7858 TODO) --- src/controller/cmcd-controller.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 76c7d82bbe3..a9b02c9886d 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -490,26 +490,14 @@ export default class CMCDController implements ComponentAPI { * Get the buffer length for a media type in milliseconds */ private getBufferLength(type: CmcdObjectType) { - const media = this.media; - if (!media) { + if (!this.media) { return NaN; } - // TODO: Replace with hls.audioForwardBufferInfo once exposed. https://github.com/video-dev/hls.js/issues/7858 - if (type === CmcdObjectType.AUDIO) { - const buffer = this.audioBuffer; - if (!buffer) { - return NaN; - } - const info = BufferHelper.bufferInfo( - buffer, - media.currentTime, - media.paused ? 0 : this.config.maxBufferHole, - ); - return info.len * 1000; - } - - const info = this.hls.mainForwardBufferInfo; + const info = + type === CmcdObjectType.AUDIO + ? this.hls.audioForwardBufferInfo + : this.hls.mainForwardBufferInfo; return info ? info.len * 1000 : NaN; } From 5bf565c0da2b65232a0b94e57ac14e7172e1f95e Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Wed, 20 May 2026 21:57:27 -0700 Subject: [PATCH 27/38] refactor(cmcd): drop unused audioBuffer field and BUFFER_CREATED wiring --- src/controller/cmcd-controller.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index a9b02c9886d..9efbd7bb800 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -14,7 +14,6 @@ import { toCmcdValue, } from '@svta/cml-cmcd'; import { Events } from '../events'; -import { BufferHelper } from '../utils/buffer-helper'; import { addEventListener, removeEventListener, @@ -26,10 +25,8 @@ import type { } from '../config'; import type Hls from '../hls'; import type { Fragment, MediaFragment, Part } from '../loader/fragment'; -import type { ExtendedSourceBuffer } from '../types/buffer'; import type { ComponentAPI } from '../types/component-api'; import type { - BufferCreatedData, ErrorData, LevelSwitchingData, ManifestLoadingData, @@ -58,7 +55,6 @@ export default class CMCDController implements ComponentAPI { private starved: boolean = false; private buffering: boolean = true; private playerState: CmcdPlayerState = CmcdPlayerState.STARTING; - private audioBuffer?: ExtendedSourceBuffer; private reporter?: CmcdReporter; constructor(hls: Hls) { @@ -117,7 +113,6 @@ export default class CMCDController implements ComponentAPI { hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this); hls.on(Events.MEDIA_ENDED, this.onMediaEnded, this); - hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this); hls.on(Events.ERROR, this.onError, this); hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); } @@ -128,7 +123,6 @@ export default class CMCDController implements ComponentAPI { hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this); hls.off(Events.MEDIA_ENDED, this.onMediaEnded, this); - hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this); hls.off(Events.ERROR, this.onError, this); hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); } @@ -143,7 +137,7 @@ export default class CMCDController implements ComponentAPI { } // @ts-ignore - this.hls = this.config = this.audioBuffer = null; + this.hls = this.config = null; // @ts-ignore this.onWaiting = this.onPlaying = this.onPause = null; // @ts-ignore @@ -179,13 +173,6 @@ export default class CMCDController implements ComponentAPI { this.media = null; } - private onBufferCreated( - event: Events.BUFFER_CREATED, - data: BufferCreatedData, - ) { - this.audioBuffer = data.tracks.audio?.buffer; - } - private onWaiting = () => { if (this.initialized) { this.starved = true; From 1c2c3ae4e2af04234e4a012ebabae83c6b9924eb Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Thu, 21 May 2026 11:33:27 -0700 Subject: [PATCH 28/38] fix(cmcd): derive object type from variant codecs / elementary streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves Rob's review feedback on #7725. getObjectType now accepts an optional Level | LevelSwitchingData and resolves main-fragment ot by (1) variant audioCodec/videoCodec, then (2) Fragment.elementaryStreams when parsed, otherwise undefined. The hls.audioTracks.length heuristic is removed — it misclassified muxed fMP4 with alt audio renditions, audio-only main playlists, and video-only variants. getBufferLength and getTopBandwidth now branch on Fragment.type instead of CmcdObjectType, so audio-only main playlists correctly draw from hls.levels / mainForwardBufferInfo. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/controller/cmcd-controller.ts | 53 ++++-- tests/unit/controller/cmcd-controller.ts | 202 ++++++++++++++++++++++- 2 files changed, 232 insertions(+), 23 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 9efbd7bb800..ebafdc4f67e 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -33,6 +33,7 @@ import type { MediaAttachedData, MediaEndedData, } from '../types/events'; +import type { Level } from '../types/level'; import type { FragmentLoaderContext, Loader, @@ -250,7 +251,7 @@ export default class CMCDController implements ComponentAPI { const eventData: Cmcd = {}; const frag = data.details?.fragments[0]; if (frag) { - eventData.ot = this.getObjectType(frag); + eventData.ot = this.getObjectType(frag, data); } this.reporter.recordEvent(CmcdEventType.BITRATE_CHANGE, eventData); } @@ -351,7 +352,7 @@ export default class CMCDController implements ComponentAPI { try { const { frag, part } = context; const level = this.hls.levels[frag.level]; - const ot = this.getObjectType(frag); + const ot = this.getObjectType(frag, level); const data: Cmcd = { d: (part || frag).duration * 1000, ot }; if ( @@ -360,11 +361,11 @@ export default class CMCDController implements ComponentAPI { ot == CmcdObjectType.MUXED ) { data.br = [level.bitrate / 1000]; - const tb = this.getTopBandwidth(ot) / 1000; + const tb = this.getTopBandwidth(frag) / 1000; if (Number.isFinite(tb)) { data.tb = [tb]; } - const bl = this.getBufferLength(ot); + const bl = this.getBufferLength(frag); if (Number.isFinite(bl)) { data.bl = [bl]; } @@ -422,6 +423,7 @@ export default class CMCDController implements ComponentAPI { */ private getObjectType( fragment: Fragment | MediaFragment, + variant?: Level | LevelSwitchingData, ): CmcdObjectType | undefined { const { type } = fragment; @@ -438,25 +440,48 @@ export default class CMCDController implements ComponentAPI { } if (type === 'main') { - if (!this.hls.audioTracks.length) { - return CmcdObjectType.MUXED; + // Prefer variant codec info (set from STREAM-INF CODECS or parser). + if (variant) { + const { audioCodec, videoCodec } = variant; + if (audioCodec && videoCodec) { + return CmcdObjectType.MUXED; + } + if (videoCodec) { + return CmcdObjectType.VIDEO; + } + if (audioCodec) { + return CmcdObjectType.AUDIO; + } + } + // Fall back to parsed fragment's elementary streams. + if (fragment.hasStreams) { + const es = fragment.elementaryStreams; + if (es.audiovideo) { + return CmcdObjectType.MUXED; + } + if (es.video) { + return CmcdObjectType.VIDEO; + } + if (es.audio) { + return CmcdObjectType.AUDIO; + } } - - return CmcdObjectType.VIDEO; } return undefined; } /** - * Get the highest bitrate. + * Get the highest bitrate available for the source backing this fragment. + * Audio renditions live in hls.audioTracks; everything else (including + * audio-only main playlists) draws from hls.levels. */ - private getTopBandwidth(type: CmcdObjectType) { + private getTopBandwidth(fragment: Fragment | MediaFragment) { let bitrate: number = 0; let levels; const hls = this.hls; - if (type === CmcdObjectType.AUDIO) { + if (fragment.type === 'audio') { levels = hls.audioTracks; } else { const max = hls.maxAutoLevel; @@ -474,15 +499,15 @@ export default class CMCDController implements ComponentAPI { } /** - * Get the buffer length for a media type in milliseconds + * Get the buffer length in milliseconds for the source backing this fragment. */ - private getBufferLength(type: CmcdObjectType) { + private getBufferLength(fragment: Fragment | MediaFragment) { if (!this.media) { return NaN; } const info = - type === CmcdObjectType.AUDIO + fragment.type === 'audio' ? this.hls.audioForwardBufferInfo : this.hls.mainForwardBufferInfo; return info ? info.len * 1000 : NaN; diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index a5f1ae26a38..2644a2b0705 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -56,6 +56,8 @@ const setupEach = (cmcd?: CMCDControllerConfig) => { const level = { bitrate: 1000, details, + audioCodec: 'mp4a.40.2', + videoCodec: 'avc1.640028', }; const hls = new Hls({ cmcd }) as any; @@ -414,6 +416,128 @@ describe('CMCDController', function () { }); }); + describe('getObjectType', function () { + it('returns MUXED for main fragments when variant has both audio and video codecs', function () { + const details = setupEach({}); + const frag = details.fragments[0]; + const variant = { audioCodec: 'mp4a.40.2', videoCodec: 'avc1.640028' }; + const result = (cmcdController as any).getObjectType(frag, variant); + expect(result).to.equal(CmcdObjectType.MUXED); + }); + + it('returns VIDEO for main fragments when variant has only a video codec', function () { + const details = setupEach({}); + const frag = details.fragments[0]; + const variant = { videoCodec: 'avc1.640028' }; + const result = (cmcdController as any).getObjectType(frag, variant); + expect(result).to.equal(CmcdObjectType.VIDEO); + }); + + it('returns AUDIO for main fragments when variant has only an audio codec (audio-only main playlist)', function () { + const details = setupEach({}); + const frag = details.fragments[0]; + const variant = { audioCodec: 'mp4a.40.2' }; + const result = (cmcdController as any).getObjectType(frag, variant); + expect(result).to.equal(CmcdObjectType.AUDIO); + }); + + it('falls back to fragment.elementaryStreams.audiovideo when variant codecs are absent', function () { + const details = setupEach({}); + const frag = details.fragments[0]; + frag.elementaryStreams.audiovideo = { + startPTS: 0, + endPTS: 2, + startDTS: 0, + endDTS: 2, + }; + const result = (cmcdController as any).getObjectType(frag); + expect(result).to.equal(CmcdObjectType.MUXED); + }); + + it('falls back to fragment.elementaryStreams.video when only video stream present', function () { + const details = setupEach({}); + const frag = details.fragments[0]; + frag.elementaryStreams.video = { + startPTS: 0, + endPTS: 2, + startDTS: 0, + endDTS: 2, + }; + const result = (cmcdController as any).getObjectType(frag); + expect(result).to.equal(CmcdObjectType.VIDEO); + }); + + it('falls back to fragment.elementaryStreams.audio when only audio stream present', function () { + const details = setupEach({}); + const frag = details.fragments[0]; + frag.elementaryStreams.audio = { + startPTS: 0, + endPTS: 2, + startDTS: 0, + endDTS: 2, + }; + const result = (cmcdController as any).getObjectType(frag); + expect(result).to.equal(CmcdObjectType.AUDIO); + }); + + it('returns undefined for main fragments when neither variant codecs nor elementary streams are known', function () { + const details = setupEach({}); + const frag = details.fragments[0]; + // No variant, no elementary streams populated + const result = (cmcdController as any).getObjectType(frag); + expect(result).to.equal(undefined); + }); + + it('does not infer MUXED from hls.audioTracks.length === 0 alone', function () { + // Regression: previous logic returned MUXED whenever there were no alt audio renditions, + // misclassifying video-only variants. + const details = setupEach({}); + const frag = details.fragments[0]; + // hls.audioTracks is empty by default, mimicking a video-only main variant. + const variant = { videoCodec: 'avc1.640028' }; + const result = (cmcdController as any).getObjectType(frag, variant); + expect(result).to.equal(CmcdObjectType.VIDEO); + }); + + it('uses the fragment loader call site to derive ot from the active level', function () { + // Audio-only main playlist: level carries only an audioCodec. + // applyFragmentData should pass the level into getObjectType and produce ot=a (not ot=av). + const details = setupEach({}); + const hls = cmcdController.hls; + hls.levelController.levels[0].audioCodec = 'mp4a.40.2'; + hls.levelController.levels[0].videoCodec = undefined; + + const { url } = applyFragmentData(details.fragments[0]); + // ot=a but not ot=av (negative lookahead, since 'a' is a prefix of 'av'). + expect(url).to.match(/ot%3Da(?!v)/); + expect(url).not.to.include('ot%3Dav'); + }); + + it('derives ot from LevelSwitchingData in the BITRATE_CHANGE event payload', function () { + // Video-only variant: only videoCodec set. Must resolve to VIDEO, not MUXED. + const details = setupEach({ + version: 2, + eventTargets: [{ url: 'https://x' }], + }); + const reporter = (cmcdController as any).reporter; + const recordCalls: any[][] = []; + reporter.recordEvent = (...args: any[]) => { + recordCalls.push(args); + }; + + (cmcdController as any).hls.trigger(Events.LEVEL_SWITCHING, { + level: 0, + bitrate: 2000000, + details, + videoCodec: 'avc1.640028', + }); + + const bitrateCalls = recordCalls.filter((c) => c[0] === 'bc'); + expect(bitrateCalls).to.have.lengthOf(1); + expect(bitrateCalls[0][1]?.ot).to.equal(CmcdObjectType.VIDEO); + }); + }); + describe('getBufferLength', function () { const stubBufferInfo = ( hls: any, @@ -426,7 +550,9 @@ describe('CMCDController', function () { }); }; - it('uses hls.audioForwardBufferInfo for CmcdObjectType.AUDIO', function () { + const fragWithType = (type: PlaylistLevelType): any => ({ type }); + + it('uses hls.audioForwardBufferInfo for fragments with type=audio', function () { setupEach({}); const hls = cmcdController.hls; (cmcdController as any).media = {} as unknown as HTMLMediaElement; @@ -435,12 +561,12 @@ describe('CMCDController', function () { stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 }); const result = (cmcdController as any).getBufferLength( - CmcdObjectType.AUDIO, + fragWithType(PlaylistLevelType.AUDIO), ); expect(result).to.equal(12500); }); - it('returns NaN for CmcdObjectType.AUDIO when audioForwardBufferInfo is null', function () { + it('returns NaN for type=audio when audioForwardBufferInfo is null', function () { setupEach({}); const hls = cmcdController.hls; (cmcdController as any).media = {} as unknown as HTMLMediaElement; @@ -448,12 +574,12 @@ describe('CMCDController', function () { stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 }); const result = (cmcdController as any).getBufferLength( - CmcdObjectType.AUDIO, + fragWithType(PlaylistLevelType.AUDIO), ); expect(Number.isNaN(result)).to.equal(true); }); - it('uses hls.mainForwardBufferInfo for CmcdObjectType.VIDEO and MUXED', function () { + it('uses hls.mainForwardBufferInfo for fragments with type=main', function () { setupEach({}); const hls = cmcdController.hls; (cmcdController as any).media = {} as unknown as HTMLMediaElement; @@ -461,10 +587,26 @@ describe('CMCDController', function () { stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 }); expect( - (cmcdController as any).getBufferLength(CmcdObjectType.VIDEO), + (cmcdController as any).getBufferLength( + fragWithType(PlaylistLevelType.MAIN), + ), ).to.equal(8000); + }); + + it('uses hls.mainForwardBufferInfo for audio-only main playlists (type=main, ot=audio)', function () { + // Regression: previous logic switched on CmcdObjectType, which would have read + // audioForwardBufferInfo for an audio-only main playlist even though the buffer + // lives on the main source buffer. + setupEach({}); + const hls = cmcdController.hls; + (cmcdController as any).media = {} as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 }); + expect( - (cmcdController as any).getBufferLength(CmcdObjectType.MUXED), + (cmcdController as any).getBufferLength( + fragWithType(PlaylistLevelType.MAIN), + ), ).to.equal(8000); }); @@ -477,15 +619,57 @@ describe('CMCDController', function () { expect( Number.isNaN( - (cmcdController as any).getBufferLength(CmcdObjectType.AUDIO), + (cmcdController as any).getBufferLength( + fragWithType(PlaylistLevelType.AUDIO), + ), ), ).to.equal(true); expect( Number.isNaN( - (cmcdController as any).getBufferLength(CmcdObjectType.VIDEO), + (cmcdController as any).getBufferLength( + fragWithType(PlaylistLevelType.MAIN), + ), ), ).to.equal(true); }); }); + + describe('getTopBandwidth', function () { + const fragWithType = (type: PlaylistLevelType): any => ({ type }); + + const stubAudioTracks = (hls: any, tracks: any[]) => { + Object.defineProperty(hls, 'audioTracks', { + configurable: true, + get: () => tracks, + }); + }; + + it('uses hls.audioTracks for fragments with type=audio', function () { + setupEach({}); + const hls = cmcdController.hls; + stubAudioTracks(hls, [{ bitrate: 96000 }, { bitrate: 128000 }]); + // hls.levels has the test playlist's level at bitrate 1000; should NOT be used here. + const result = (cmcdController as any).getTopBandwidth( + fragWithType(PlaylistLevelType.AUDIO), + ); + expect(result).to.equal(128000); + }); + + it('uses hls.levels for fragments with type=main (including audio-only main playlists)', function () { + // Regression: previous logic switched on CmcdObjectType, which would have read + // audioTracks for an audio-only main playlist even though the renditions live in hls.levels. + setupEach({}); + const hls = cmcdController.hls; + hls.levelController.levels = [ + { bitrate: 500000 }, + { bitrate: 2000000 }, + ]; + stubAudioTracks(hls, [{ bitrate: 999999 }]); + const result = (cmcdController as any).getTopBandwidth( + fragWithType(PlaylistLevelType.MAIN), + ); + expect(result).to.equal(2000000); + }); + }); }); }); From 84d2e089c5dc67c0c1f1d3ef2c57540cdceeae23 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Thu, 21 May 2026 14:03:10 -0700 Subject: [PATCH 29/38] fix(cmcd): emit br/tb/bl for single-rendition fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The br/tb/bl block in applyFragmentData was gated on ot ∈ {VIDEO,AUDIO,MUXED}. For single-rendition streams with no master playlist (muxed-fmp4, mp3 audio- only), the variant carries no codec metadata, and the fragment's elementaryStreams aren't populated until after the request fires — so getObjectType returns undefined and the gate skipped these fields entirely. Widen the gate to also run when ot is null and frag.type is 'main' or 'audio'. getBufferLength and getTopBandwidth already branch on frag.type, not ot, so they return correct values regardless of ot resolution. Also tighten the loose-equality MUXED check to strict equality. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/controller/cmcd-controller.ts | 3 +- tests/unit/controller/cmcd-controller.ts | 35 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index ebafdc4f67e..ecd898e4a3c 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -358,7 +358,8 @@ export default class CMCDController implements ComponentAPI { if ( ot === CmcdObjectType.VIDEO || ot === CmcdObjectType.AUDIO || - ot == CmcdObjectType.MUXED + ot === CmcdObjectType.MUXED || + (ot == null && (frag.type === 'main' || frag.type === 'audio')) ) { data.br = [level.bitrate / 1000]; const tb = this.getTopBandwidth(frag) / 1000; diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 2644a2b0705..d16f9fc7801 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -180,6 +180,41 @@ describe('CMCDController', function () { expectField(url, `d%3D1000`); expectField(url, `ot%3Dav`); }); + + it('emits br/tb/bl for single-rendition main fragments where ot is undefined', function () { + // Regression: single-rendition streams (e.g. muxed-fmp4 or mp3 audio-only with + // no master playlist) carry no codec metadata on the variant, and the fragment + // hasn't been parsed by the time the request fires — so getObjectType returns + // undefined. The br/tb/bl block must still emit, since the buffer for the + // segment exists regardless of whether ot resolved. + const details = setupEach({}); + const hls = cmcdController.hls; + hls.levelController.levels[0].audioCodec = undefined; + hls.levelController.levels[0].videoCodec = undefined; + (cmcdController as any).media = {} as unknown as HTMLMediaElement; + Object.defineProperty(hls, 'mainForwardBufferInfo', { + configurable: true, + get: () => ({ len: 8.0 }), + }); + + const frag = details.fragments[0]; + // Sanity: ot really is undefined for this fragment. + expect( + (cmcdController as any).getObjectType( + frag, + hls.levelController.levels[0], + ), + ).to.equal(undefined); + + const { url } = applyFragmentData(frag); + + // br: level.bitrate / 1000 = 1 + expectField(url, `br%3D1`); + // tb: top bandwidth from hls.levels (sole level at 1000) = 1 + expectField(url, `tb%3D1`); + // bl: 8.0s * 1000 = 8000ms + expectField(url, `bl%3D8000`); + }); }); describe('v2 configuration', function () { From 00c24d55671c8471ad0a4a42396d3421ab8aa2c8 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Thu, 21 May 2026 14:11:52 -0700 Subject: [PATCH 30/38] fix(cmcd): keep bl fresh on event reports via buffer-info listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bl was only written to per-request CMCD payloads in applyFragmentData, never to the reporter's persistent data (this.data) or recordEvent payloads. Since events are built as { ...this.data, ...perEventData, e, ts, sn }, bl never appeared on TIME_INTERVAL, PLAY_STATE, BITRATE_CHANGE, or other event reports. Register listeners on BUFFER_APPENDED and BUFFER_FLUSHED that call reporter.update({ bl: [...] }) using a no-fragment buffer-length helper — min(main, audio) when both buffer sources exist, otherwise whichever is available. This makes bl available on all subsequent event reports while keeping per-request bl unchanged (fragment- specific value still flows through applyFragmentData). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/controller/cmcd-controller.ts | 39 +++++ tests/unit/controller/cmcd-controller.ts | 191 ++++++++++++++++++++++- 2 files changed, 225 insertions(+), 5 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index ecd898e4a3c..963b24660bf 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -27,6 +27,8 @@ import type Hls from '../hls'; import type { Fragment, MediaFragment, Part } from '../loader/fragment'; import type { ComponentAPI } from '../types/component-api'; import type { + BufferAppendedData, + BufferFlushedData, ErrorData, LevelSwitchingData, ManifestLoadingData, @@ -116,6 +118,8 @@ export default class CMCDController implements ComponentAPI { hls.on(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.on(Events.ERROR, this.onError, this); hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); + hls.on(Events.BUFFER_APPENDED, this.onBufferInfoChange, this); + hls.on(Events.BUFFER_FLUSHED, this.onBufferInfoChange, this); } private unregisterListeners() { @@ -126,6 +130,8 @@ export default class CMCDController implements ComponentAPI { hls.off(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.off(Events.ERROR, this.onError, this); hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); + hls.off(Events.BUFFER_APPENDED, this.onBufferInfoChange, this); + hls.off(Events.BUFFER_FLUSHED, this.onBufferInfoChange, this); } destroy() { @@ -514,6 +520,39 @@ export default class CMCDController implements ComponentAPI { return info ? info.len * 1000 : NaN; } + /** + * Get the buffer length in milliseconds without a specific fragment context. + * Used to keep `bl` fresh on event reports independent of segment requests. + * Returns the playback bottleneck: min of main and audio forward buffer + * lengths when both exist; otherwise whichever is available. + */ + private getEventBufferLength(): number { + if (!this.media) { + return NaN; + } + const main = this.hls.mainForwardBufferInfo; + const audio = this.hls.audioForwardBufferInfo; + if (main && audio) { + return Math.min(main.len, audio.len) * 1000; + } + const info = main || audio; + return info ? info.len * 1000 : NaN; + } + + private onBufferInfoChange( + event: Events.BUFFER_APPENDED | Events.BUFFER_FLUSHED, + data: BufferAppendedData | BufferFlushedData, + ) { + if (!this.reporter) { + return; + } + const bl = this.getEventBufferLength(); + if (!Number.isFinite(bl)) { + return; + } + this.reporter.update({ bl: [bl] }); + } + /** * Create a playlist loader */ diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index d16f9fc7801..fe1b24fe6c0 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -1,4 +1,4 @@ -import { CmcdHeaderField, CmcdObjectType } from '@svta/cml-cmcd'; +import { CmcdEventType, CmcdHeaderField, CmcdObjectType } from '@svta/cml-cmcd'; import { expect } from 'chai'; import CMCDController from '../../../src/controller/cmcd-controller'; import { Events } from '../../../src/events'; @@ -590,7 +590,9 @@ describe('CMCDController', function () { it('uses hls.audioForwardBufferInfo for fragments with type=audio', function () { setupEach({}); const hls = cmcdController.hls; - (cmcdController as any).media = {} as unknown as HTMLMediaElement; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 12.5 }); // Set main to a sentinel that would fail the assertion if used by mistake. stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 }); @@ -604,7 +606,9 @@ describe('CMCDController', function () { it('returns NaN for type=audio when audioForwardBufferInfo is null', function () { setupEach({}); const hls = cmcdController.hls; - (cmcdController as any).media = {} as unknown as HTMLMediaElement; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; stubBufferInfo(hls, 'audioForwardBufferInfo', null); stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 }); @@ -617,7 +621,9 @@ describe('CMCDController', function () { it('uses hls.mainForwardBufferInfo for fragments with type=main', function () { setupEach({}); const hls = cmcdController.hls; - (cmcdController as any).media = {} as unknown as HTMLMediaElement; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 }); stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 }); @@ -634,7 +640,9 @@ describe('CMCDController', function () { // lives on the main source buffer. setupEach({}); const hls = cmcdController.hls; - (cmcdController as any).media = {} as unknown as HTMLMediaElement; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 }); stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 }); @@ -669,6 +677,179 @@ describe('CMCDController', function () { }); }); + describe('event bl propagation', function () { + const stubBufferInfo = ( + hls: any, + prop: 'mainForwardBufferInfo' | 'audioForwardBufferInfo', + info: { len: number } | null, + ) => { + Object.defineProperty(hls, prop, { + configurable: true, + get: () => info, + }); + }; + + it('updates persistent bl on BUFFER_APPENDED', function () { + setupEach({ version: 2 }); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + + const reporter = (cmcdController as any).reporter; + expect(reporter.data.bl).to.equal(undefined); + + hls.trigger(Events.BUFFER_APPENDED, { + type: 'video', + frag: null, + part: null, + chunkMeta: {}, + parent: 'main', + timeRanges: {}, + }); + + expect(reporter.data.bl).to.deep.equal([10000]); + + cmcdController.destroy(); + }); + + it('updates persistent bl on BUFFER_FLUSHED (covers seek-to-empty)', function () { + setupEach({ version: 2 }); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 0 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + + const reporter = (cmcdController as any).reporter; + hls.trigger(Events.BUFFER_FLUSHED, { + type: 'video', + start: 0, + end: 0, + }); + + expect(reporter.data.bl).to.deep.equal([0]); + + cmcdController.destroy(); + }); + + it('uses min(main, audio) when both buffer sources are available', function () { + setupEach({ version: 2 }); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 7 }); + + const reporter = (cmcdController as any).reporter; + hls.trigger(Events.BUFFER_APPENDED, { + type: 'audio', + frag: null, + part: null, + chunkMeta: {}, + parent: 'main', + timeRanges: {}, + }); + + expect(reporter.data.bl).to.deep.equal([7000]); + + cmcdController.destroy(); + }); + + it('does not update bl when no media is attached', function () { + setupEach({ version: 2 }); + const hls = cmcdController.hls; + (cmcdController as any).media = undefined; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + + const reporter = (cmcdController as any).reporter; + hls.trigger(Events.BUFFER_APPENDED, { + type: 'video', + frag: null, + part: null, + chunkMeta: {}, + parent: 'main', + timeRanges: {}, + }); + + expect(reporter.data.bl).to.equal(undefined); + + cmcdController.destroy(); + }); + + it('does not update bl when both buffer info sources are null', function () { + setupEach({ version: 2 }); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', null); + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + + const reporter = (cmcdController as any).reporter; + hls.trigger(Events.BUFFER_APPENDED, { + type: 'video', + frag: null, + part: null, + chunkMeta: {}, + parent: 'main', + timeRanges: {}, + }); + + expect(reporter.data.bl).to.equal(undefined); + + cmcdController.destroy(); + }); + + it('emits bl in queued event payloads after buffer info is known', function () { + setupEach({ + version: 2, + eventTargets: [ + { + url: 'https://analytics.example.com/cmcd', + events: [CmcdEventType.PLAY_STATE], + batchSize: 100, + }, + ], + }); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + + hls.trigger(Events.BUFFER_APPENDED, { + type: 'video', + frag: null, + part: null, + chunkMeta: {}, + parent: 'main', + timeRanges: {}, + }); + + (cmcdController as any).onPlaying(); + + const reporter = (cmcdController as any).reporter; + const targetStates = Array.from( + reporter.eventTargets.values(), + ) as any[]; + const queue = targetStates[0].queue as any[]; + const psEvents = queue.filter( + (e: any) => e.e === CmcdEventType.PLAY_STATE, + ); + expect(psEvents.length).to.be.greaterThan(0); + expect(psEvents[0].bl).to.deep.equal([10000]); + + cmcdController.destroy(); + }); + }); + describe('getTopBandwidth', function () { const fragWithType = (type: PlaylistLevelType): any => ({ type }); From 4fb0f363b4193c93a2cde5a93b0c77ac08fc48bc Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Thu, 21 May 2026 16:46:45 -0700 Subject: [PATCH 31/38] fix(cmcd): gate variant-codec ot inference on audioTracks; prefer parsed streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CODECS in a STREAM-INF describes the variant plus any alternate renditions it references, so an audioCodec value can belong to an alt audio track rather than the main variant. Only infer MUXED/AUDIO from the variant's audio+video codecs when no audio media options exist; videoCodec always implies VIDEO. Also reorder getObjectType so parsed fragment.elementaryStreams take precedence over variant codec hints — the parsed segment is ground truth when available. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/controller/cmcd-controller.ts | 32 ++++++++++-------- tests/unit/controller/cmcd-controller.ts | 43 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 963b24660bf..60cc1ae5caf 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -447,20 +447,7 @@ export default class CMCDController implements ComponentAPI { } if (type === 'main') { - // Prefer variant codec info (set from STREAM-INF CODECS or parser). - if (variant) { - const { audioCodec, videoCodec } = variant; - if (audioCodec && videoCodec) { - return CmcdObjectType.MUXED; - } - if (videoCodec) { - return CmcdObjectType.VIDEO; - } - if (audioCodec) { - return CmcdObjectType.AUDIO; - } - } - // Fall back to parsed fragment's elementary streams. + // Parsed elementary streams are ground truth when present. if (fragment.hasStreams) { const es = fragment.elementaryStreams; if (es.audiovideo) { @@ -473,6 +460,23 @@ export default class CMCDController implements ComponentAPI { return CmcdObjectType.AUDIO; } } + // Fall back to variant codec info. STREAM-INF CODECS describes the variant + // including any alternate renditions it pulls from, so audioCodec only + // implies the main variant carries audio when no audio media options exist. + if (variant) { + const { audioCodec, videoCodec } = variant; + if (!this.hls.audioTracks.length) { + if (audioCodec && videoCodec) { + return CmcdObjectType.MUXED; + } + if (audioCodec) { + return CmcdObjectType.AUDIO; + } + } + if (videoCodec) { + return CmcdObjectType.VIDEO; + } + } } return undefined; diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index fe1b24fe6c0..3ef24a219a1 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -534,6 +534,49 @@ describe('CMCDController', function () { expect(result).to.equal(CmcdObjectType.VIDEO); }); + it('returns VIDEO (not MUXED) when alt audio renditions exist and variant has both codecs', function () { + // STREAM-INF CODECS describes the variant plus its alternate renditions, so audioCodec + // here belongs to the alt audio track, not the main variant. + const details = setupEach({}); + const frag = details.fragments[0]; + Object.defineProperty(cmcdController.hls, 'audioTracks', { + configurable: true, + get: () => [{ bitrate: 128000 }], + }); + const variant = { audioCodec: 'mp4a.40.2', videoCodec: 'avc1.640028' }; + const result = (cmcdController as any).getObjectType(frag, variant); + expect(result).to.equal(CmcdObjectType.VIDEO); + }); + + it('returns undefined when alt audio renditions exist and variant has only an audio codec', function () { + // Same reasoning: audioCodec alone is not enough to call the main variant audio-only + // when alternate audio renditions are present. + const details = setupEach({}); + const frag = details.fragments[0]; + Object.defineProperty(cmcdController.hls, 'audioTracks', { + configurable: true, + get: () => [{ bitrate: 128000 }], + }); + const variant = { audioCodec: 'mp4a.40.2' }; + const result = (cmcdController as any).getObjectType(frag, variant); + expect(result).to.equal(undefined); + }); + + it('prefers parsed elementary streams over variant codecs', function () { + // Elementary streams reflect what was actually parsed; trust them over the variant hint. + const details = setupEach({}); + const frag = details.fragments[0]; + frag.elementaryStreams.video = { + startPTS: 0, + endPTS: 2, + startDTS: 0, + endDTS: 2, + }; + const variant = { audioCodec: 'mp4a.40.2', videoCodec: 'avc1.640028' }; + const result = (cmcdController as any).getObjectType(frag, variant); + expect(result).to.equal(CmcdObjectType.VIDEO); + }); + it('uses the fragment loader call site to derive ot from the active level', function () { // Audio-only main playlist: level carries only an audioCodec. // applyFragmentData should pass the level into getObjectType and produce ot=a (not ot=av). From a85bd36d6731047e99c80d38fdc5f0384e193c0d Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Thu, 21 May 2026 20:24:19 -0700 Subject: [PATCH 32/38] fix(cmcd): defer reporter creation to MANIFEST_LOADING, one per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two TIME_INTERVAL events with sn=0 fired back-to-back at the start of playback. The CMCDController created the reporter in its constructor (firing the initial t event via start()) and then immediately tore it down and recreated it on MANIFEST_LOADING (firing another t event with sn=0 from the new reporter's fresh counter). Move reporter creation out of the constructor — it now lives only in onManifestLoading, giving one reporter per master manifest. Construct CMCDController before PlaylistLoader so its MANIFEST_LOADING listener registers first and the reporter exists when the manifest request applies CMCD data. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/controller/cmcd-controller.ts | 1 - src/hls.ts | 13 ++-- tests/unit/controller/cmcd-controller.ts | 79 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 60cc1ae5caf..0c0839b3a15 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -69,7 +69,6 @@ export default class CMCDController implements ComponentAPI { config.pLoader = this.createPlaylistLoader(); config.fLoader = this.createFragmentLoader(); - this.createReporter(); this.registerListeners(); } } diff --git a/src/hls.ts b/src/hls.ts index f4dabd57566..f9e2430a5b3 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -230,6 +230,12 @@ export default class Hls implements HlsEventEmitter { ? (this.capLevelController = new _CapLevelController(this)) : null; const fpsController = _FpsController ? new _FpsController(this) : null; + // CMCDController must be constructed before PlaylistLoader so its + // MANIFEST_LOADING listener fires first — the reporter is created there + // and must exist before the manifest request applies CMCD data. + const cmcdController = config.cmcdController + ? (this.cmcdController = new config.cmcdController(this)) + : null; const playListLoader = new PlaylistLoader(this); const _ContentSteeringController = config.contentSteeringController; @@ -329,10 +335,9 @@ export default class Hls implements HlsEventEmitter { config.emeController, coreComponents, ); - this.cmcdController = this.createController( - config.cmcdController, - coreComponents, - ); + if (cmcdController) { + coreComponents.push(cmcdController); + } this.latencyController = this.createController( config.latencyController, coreComponents, diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 3ef24a219a1..0696acf987f 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -75,6 +75,7 @@ const setupEach = (cmcd?: CMCDControllerConfig) => { // hls.audioTracks = []; cmcdController = new CMCDController(hls); + hls.trigger(Events.MANIFEST_LOADING, { url }); return details; }; @@ -336,6 +337,84 @@ describe('CMCDController', function () { expect((cmcdController as any).reporter).to.not.equal(undefined); }); + it('emits a single TIME_INTERVAL event at session start (no duplicate sn=0)', function () { + const requests: any[] = []; + const captureLoader = (req: any) => { + requests.push(req); + return Promise.resolve({ status: 204 }); + }; + setupEach({ + version: 2, + loader: captureLoader as any, + eventTargets: [ + { + url: 'https://analytics.example.com/cmcd', + events: [CmcdEventType.TIME_INTERVAL], + interval: 30, + }, + ], + }); + + const tEvents = requests + .flatMap((r) => + String(r.body || '') + .trim() + .split('\n'), + ) + .filter((line) => /(^|,)e=t(,|$)/.test(line)); + expect(tEvents).to.have.lengthOf(1); + expect(tEvents[0]).to.match(/(^|,)sn=0(,|$)/); + }); + + it('does not create the reporter in the constructor (deferred to MANIFEST_LOADING)', function () { + const hls = new Hls({ cmcd: { version: 2 } }) as any; + hls.networkControllers.forEach((c) => c.destroy()); + hls.networkControllers.length = 0; + hls.coreComponents.forEach((c) => c.destroy()); + hls.coreComponents.length = 0; + + const controller = new CMCDController(hls); + expect((controller as any).reporter).to.equal(undefined); + }); + + it('creates a fresh reporter on each MANIFEST_LOADING (one per session)', function () { + const requests: any[] = []; + const captureLoader = (req: any) => { + requests.push(req); + return Promise.resolve({ status: 204 }); + }; + setupEach({ + version: 2, + loader: captureLoader as any, + eventTargets: [ + { + url: 'https://analytics.example.com/cmcd', + events: [CmcdEventType.TIME_INTERVAL], + interval: 30, + }, + ], + }); + + const reporter1 = (cmcdController as any).reporter; + expect(reporter1).to.not.equal(undefined); + + // Simulate a second loadSource on the same Hls instance. + cmcdController.hls.trigger(Events.MANIFEST_LOADING, { url }); + + const reporter2 = (cmcdController as any).reporter; + expect(reporter2).to.not.equal(undefined); + expect(reporter2).to.not.equal(reporter1); + + const tEvents = requests + .flatMap((r) => + String(r.body || '') + .trim() + .split('\n'), + ) + .filter((line) => /(^|,)e=t(,|$)/.test(line)); + expect(tEvents).to.have.lengthOf(2); + }); + it('stops reporter on destroy', function () { setupEach({ version: 2, From 6e89055cdac6e6854c9d47db5d0ff9497c7d7e5f Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Fri, 22 May 2026 08:39:30 -0700 Subject: [PATCH 33/38] chore: fix lint issue --- src/hls.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hls.ts b/src/hls.ts index f9e2430a5b3..0d1e3e5a967 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -233,13 +233,14 @@ export default class Hls implements HlsEventEmitter { // CMCDController must be constructed before PlaylistLoader so its // MANIFEST_LOADING listener fires first — the reporter is created there // and must exist before the manifest request applies CMCD data. - const cmcdController = config.cmcdController - ? (this.cmcdController = new config.cmcdController(this)) + const _CmcdController = config.cmcdController; + const cmcdController = _CmcdController + ? (this.cmcdController = new _CmcdController(this)) : null; const playListLoader = new PlaylistLoader(this); const _ContentSteeringController = config.contentSteeringController; - // Instantiate ConentSteeringController before LevelController to receive Multivariant Playlist events first + // Instantiate ContentSteeringController before LevelController to receive Multivariant Playlist events first const contentSteering = _ContentSteeringController ? new _ContentSteeringController(this) : null; From 57b8b27c921221e0128935b0f20ed7b5d320f959 Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Fri, 22 May 2026 08:40:36 -0700 Subject: [PATCH 34/38] chore: fix lint issue --- src/hls.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hls.ts b/src/hls.ts index 0d1e3e5a967..b86b687e3b6 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -233,9 +233,9 @@ export default class Hls implements HlsEventEmitter { // CMCDController must be constructed before PlaylistLoader so its // MANIFEST_LOADING listener fires first — the reporter is created there // and must exist before the manifest request applies CMCD data. - const _CmcdController = config.cmcdController; - const cmcdController = _CmcdController - ? (this.cmcdController = new _CmcdController(this)) + const _CMCDController = config.cmcdController; + const cmcdController = _CMCDController + ? (this.cmcdController = new _CMCDController(this)) : null; const playListLoader = new PlaylistLoader(this); From 1785e7ff16b9c635589a27f8de27c8fa3a7e8f4f Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Fri, 22 May 2026 08:45:25 -0700 Subject: [PATCH 35/38] chore: fix lint issue --- src/hls.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/hls.ts b/src/hls.ts index b86b687e3b6..303a794b123 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -230,10 +230,8 @@ export default class Hls implements HlsEventEmitter { ? (this.capLevelController = new _CapLevelController(this)) : null; const fpsController = _FpsController ? new _FpsController(this) : null; - // CMCDController must be constructed before PlaylistLoader so its - // MANIFEST_LOADING listener fires first — the reporter is created there - // and must exist before the manifest request applies CMCD data. const _CMCDController = config.cmcdController; + // Instantiate CMCDController before PlaylistLoader to receive Manifest Loading events first const cmcdController = _CMCDController ? (this.cmcdController = new _CMCDController(this)) : null; From bc361e1f8b00469f7fabbc443177fb92c6c0c3bd Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Fri, 22 May 2026 15:25:11 -0700 Subject: [PATCH 36/38] fix(cmcd): set initial sta on MEDIA_ATTACHING; default to PRELOADING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CTA-5004-B, sta should be present from the first CMCD request and should reflect the actual playback context. Previously the controller defaulted to STARTING ("s") at construction, which inflated the msd metric (measured from STARTING → PLAYING) by counting time before play was instructed. Changes: - Listen to MEDIA_ATTACHING (sync from hls.attachMedia) instead of MEDIA_ATTACHED (async from buffer-controller). This lets us read media.autoplay before the first manifest URL is built when attachMedia is called before loadSource. - On MEDIA_ATTACHING, set state to STARTING when media.autoplay is true, otherwise PRELOADING ("d"). - In onManifestLoading, when no media is attached yet (load-before-attach order), set state to PRELOADING so the first manifest still carries sta. - Add a 'play' media-event listener that transitions PRELOADING → STARTING on the first play() call. Subsequent play events (post-pause) are no-ops since this.initialized is set by onPlaying. - Guard onSeeking/onSeeked on this.initialized: per spec, SEEKING is "after starting", and STARTING/PRELOADING should persist through pre-playback seeks. Mirrors the existing onWaiting guard for REBUFFERING. Tests: - Unit: new "initial sta matrix (attach order × autoplay)" describe covering the 4 attach-order/autoplay combinations. - E2E: new "Group 6: initial sta matrix" covering all 6 combinations of autoplay × autoStartLoad × attach order against a real stream. - Existing e2e manifest-mode tests restored to assert sta=s (autoplay=T, attach→load matches the default test setup). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/controller/cmcd-controller.ts | 42 ++++-- tests/e2e/cmcd.ts | 150 +++++++++++++++++--- tests/unit/controller/cmcd-controller.ts | 173 +++++++++++++++++++++-- 3 files changed, 325 insertions(+), 40 deletions(-) diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 0c0839b3a15..2d65046e60d 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -57,7 +57,7 @@ export default class CMCDController implements ComponentAPI { private initialized: boolean = false; private starved: boolean = false; private buffering: boolean = true; - private playerState: CmcdPlayerState = CmcdPlayerState.STARTING; + private playerState?: CmcdPlayerState; private reporter?: CmcdReporter; constructor(hls: Hls) { @@ -93,9 +93,7 @@ export default class CMCDController implements ComponentAPI { eventTargets: (cmcd.eventTargets ?? []).map( ({ includeKeys, ...rest }) => ({ ...rest, - enabledKeys: includeKeys ?? [ - ...(version >= CMCD_V2 ? CMCD_KEYS : CMCD_V1_KEYS), - ], + enabledKeys: includeKeys ?? CMCD_KEYS, }), ), }, @@ -112,7 +110,7 @@ export default class CMCDController implements ComponentAPI { private registerListeners() { const hls = this.hls; hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); - hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); + hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this); hls.on(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.on(Events.ERROR, this.onError, this); @@ -124,7 +122,7 @@ export default class CMCDController implements ComponentAPI { private unregisterListeners() { const hls = this.hls; hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); - hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); + hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this); hls.off(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.off(Events.ERROR, this.onError, this); @@ -145,17 +143,24 @@ export default class CMCDController implements ComponentAPI { // @ts-ignore this.hls = this.config = null; // @ts-ignore - this.onWaiting = this.onPlaying = this.onPause = null; + this.onWaiting = this.onPlay = this.onPlaying = this.onPause = null; // @ts-ignore this.onSeeking = this.onSeeked = this.media = null; } - private onMediaAttached( - event: Events.MEDIA_ATTACHED, + private onMediaAttaching( + event: Events.MEDIA_ATTACHING, data: MediaAttachedData, ) { const media = (this.media = data.media); + this.setPlayerState( + this.media.autoplay + ? CmcdPlayerState.STARTING + : CmcdPlayerState.PRELOADING, + ); + addEventListener(media, 'waiting', this.onWaiting); + addEventListener(media, 'play', this.onPlay); addEventListener(media, 'playing', this.onPlaying); addEventListener(media, 'pause', this.onPause); addEventListener(media, 'seeking', this.onSeeking); @@ -170,6 +175,7 @@ export default class CMCDController implements ComponentAPI { } removeEventListener(media, 'waiting', this.onWaiting); + removeEventListener(media, 'play', this.onPlay); removeEventListener(media, 'playing', this.onPlaying); removeEventListener(media, 'pause', this.onPause); removeEventListener(media, 'seeking', this.onSeeking); @@ -188,6 +194,12 @@ export default class CMCDController implements ComponentAPI { this.buffering = true; }; + private onPlay = () => { + if (!this.initialized) { + this.setPlayerState(CmcdPlayerState.STARTING); + } + }; + private onPlaying = () => { if (!this.initialized) { this.initialized = true; @@ -204,10 +216,15 @@ export default class CMCDController implements ComponentAPI { }; private onSeeking = () => { - this.setPlayerState(CmcdPlayerState.SEEKING); + if (this.initialized) { + this.setPlayerState(CmcdPlayerState.SEEKING); + } }; private onSeeked = () => { + if (!this.initialized) { + return; + } if (this.media?.paused) { this.setPlayerState(CmcdPlayerState.PAUSED); } @@ -221,7 +238,6 @@ export default class CMCDController implements ComponentAPI { event: Events.MANIFEST_LOADING, data: ManifestLoadingData, ) { - this.playerState = CmcdPlayerState.STARTING; this.initialized = false; this.starved = false; this.buffering = true; @@ -232,6 +248,10 @@ export default class CMCDController implements ComponentAPI { } this.createReporter(); + + if (!this.media) { + this.setPlayerState(CmcdPlayerState.PRELOADING); + } } private onError(event: Events.ERROR, data: ErrorData) { diff --git a/tests/e2e/cmcd.ts b/tests/e2e/cmcd.ts index 8540cc4ea76..d7b76dc9373 100644 --- a/tests/e2e/cmcd.ts +++ b/tests/e2e/cmcd.ts @@ -159,7 +159,9 @@ describe('CMCD v2 E2E Tests', function () { expect(decoded).to.have.property('sid', SESSION_ID); expect(decoded).to.have.property('cid', CONTENT_ID); expect(decoded).to.have.property('v', 2); - expect(decoded).to.have.property('sta'); + // Video has autoplay=true and attachMedia runs before loadSource, so + // MEDIA_ATTACHING sets STARTING (s) before the manifest URL is built. + expect(decoded).to.have.property('sta', 's'); }); it('should send valid CMCD v2 on segment requests', async function () { @@ -195,32 +197,41 @@ describe('CMCD v2 E2E Tests', function () { }); it('should reflect player state transitions (sta)', async function () { + // Video has autoplay=true and attachMedia is called before loadSource, + // so the first manifest carries sta=s (STARTING). Once 'playing' fires + // the state transitions to PLAYING (p) for subsequent requests. const manifests = await collector.waitForRequests( 'manifest', 1, REQUEST_TIMEOUT, ); - const firstDecoded = validateCollectedRequest(manifests[0]); - expect(firstDecoded).to.have.property('sta', 's'); + expect(validateCollectedRequest(manifests[0])).to.have.property( + 'sta', + 's', + ); - // Wait for playback to start, then wait for additional segments - // which should carry sta=p (playing state) await waitForPlayback(hls, video); - - // After playback starts, wait for more segments to be requested - // with the updated player state await collector.waitForRequests('segment', 4, REQUEST_TIMEOUT); const segments = collector.getRequests('segment'); - const hasPlaying = segments.some((req) => { - const result = validateCmcdRequest(req.request); - return result.data.sta === 'p'; - }); + const states = segments.map( + (r) => validateCmcdRequest(r.request).data.sta, + ); + + const hasPlaying = states.includes('p'); expect(hasPlaying).to.equal( true, - `No segment had sta=p (playing state). States found: ${segments - .map((r) => validateCmcdRequest(r.request).data.sta) - .join(', ')}`, + `No segment had sta=p (playing state). States found: ${states.join(', ')}`, + ); + + // Per spec/design: with autoplay=true, the only non-PLAYING value + // segments should ever report is STARTING — never PRELOADING. + const unexpected = states.filter( + (s) => s !== undefined && s !== 's' && s !== 'p', + ); + expect(unexpected).to.deep.equal( + [], + `Unexpected sta tokens on segments: ${unexpected.join(', ')}`, ); }); @@ -293,9 +304,11 @@ describe('CMCD v2 E2E Tests', function () { const decoded = validateCollectedRequest(req); - // v2-specific fields + // v2-specific fields. Video has autoplay=true and attachMedia runs + // before loadSource, so sta=s (STARTING) is set before the manifest + // request is built. expect(decoded).to.have.property('v', 2); - expect(decoded).to.have.property('sta'); + expect(decoded).to.have.property('sta', 's'); // Standard fields expect(decoded).to.have.property('ot', 'm'); @@ -463,7 +476,110 @@ describe('CMCD v2 E2E Tests', function () { const decoded = validateCollectedRequest(manifests[0]); expect(decoded).to.have.property('v', 2); + // Video has autoplay=true and attachMedia runs before loadSource, so + // sta=s (STARTING) is set before the manifest URL is built. expect(decoded).to.have.property('sta', 's'); }); }); + + // Verifies the master-manifest sta value across all combinations of + // video.autoplay, hls.config.autoStartLoad, and the + // attachMedia/loadSource call order. + // + // Expected outcome derives entirely from the autoplay+order pair: + // attach→load + autoplay=T → STARTING (s) — MEDIA_ATTACHING sets it + // before the manifest URL is built. + // any other combination → PRELOADING (d) — either set by + // onMediaAttaching (autoplay=false) or by the no-media fallback in + // onManifestLoading (load-before-attach). + // autoStartLoad does not affect the master manifest's sta; it only + // determines whether subsequent segments load automatically. + describe('Group 6: initial sta matrix', function () { + type Scenario = { + autoplay: boolean; + autoStartLoad: boolean; + order: 'attach-load' | 'load-attach'; + expectedSta: 's' | 'd'; + }; + + const scenarios: Record = { + '1A: autoplay=T, autoStartLoad=T, attach→load': { + autoplay: true, + autoStartLoad: true, + order: 'attach-load', + expectedSta: 's', + }, + '1B: autoplay=T, autoStartLoad=T, load→attach': { + autoplay: true, + autoStartLoad: true, + order: 'load-attach', + expectedSta: 'd', + }, + '2A: autoplay=F, autoStartLoad=T, attach→load': { + autoplay: false, + autoStartLoad: true, + order: 'attach-load', + expectedSta: 'd', + }, + '2B: autoplay=F, autoStartLoad=T, load→attach': { + autoplay: false, + autoStartLoad: true, + order: 'load-attach', + expectedSta: 'd', + }, + '3A: autoplay=F, autoStartLoad=F, attach→load': { + autoplay: false, + autoStartLoad: false, + order: 'attach-load', + expectedSta: 'd', + }, + '3B: autoplay=F, autoStartLoad=F, load→attach': { + autoplay: false, + autoStartLoad: false, + order: 'load-attach', + expectedSta: 'd', + }, + }; + + Object.entries(scenarios).forEach(([label, scenario]) => { + it(`${label} → sta=${scenario.expectedSta} on first manifest`, async function () { + // Override the default-created video element with one that matches + // this scenario's autoplay setting. + destroyVideoElement(video); + video = document.createElement('video'); + video.muted = true; + video.playsInline = true; + video.autoplay = scenario.autoplay; + document.body.appendChild(video); + + collector.attach(); + hls = new Hls({ + loader: FetchLoader, + autoStartLoad: scenario.autoStartLoad, + cmcd: { + version: 2, + sessionId: SESSION_ID, + contentId: CONTENT_ID, + }, + }); + + if (scenario.order === 'attach-load') { + hls.attachMedia(video); + hls.loadSource(TEST_STREAM); + } else { + hls.loadSource(TEST_STREAM); + hls.attachMedia(video); + } + + const manifests = await collector.waitForRequests( + 'manifest', + 1, + REQUEST_TIMEOUT, + ); + const decoded = validateCollectedRequest(manifests[0]); + + expect(decoded).to.have.property('sta', scenario.expectedSta); + }); + }); + }); }); diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 0696acf987f..114a7cbf5e3 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -43,7 +43,20 @@ https://dummy.url.com/10906.m4s`; const uuidRegex = /[a-f\d]{8}-[a-f\d]{4}-4[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}/; -const setupEach = (cmcd?: CMCDControllerConfig) => { +const makeMockMedia = (autoplay: boolean): HTMLMediaElement => + ({ + autoplay, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as HTMLMediaElement; + +const setupEach = ( + cmcd?: CMCDControllerConfig, + // Pass `attachBefore` to simulate `hls.attachMedia()` running *before* + // `hls.loadSource()` — i.e., MEDIA_ATTACHING fires before MANIFEST_LOADING. + // Omit it for the load-before-attach order (the default in tests). + attachBefore?: { autoplay: boolean }, +) => { const details = M3U8Parser.parseLevelPlaylist( playlist, url, @@ -71,15 +84,34 @@ const setupEach = (cmcd?: CMCDControllerConfig) => { }; hls.streamController = { getLevelDetails: () => details, + // Tests that fire MEDIA_ATTACHING will exercise getBufferLength, which + // calls hls.mainForwardBufferInfo → streamController.getMainFwdBufferInfo. + // Default to null (no buffer info known); individual tests override. + getMainFwdBufferInfo: () => null, }; // hls.audioTracks = []; cmcdController = new CMCDController(hls); + + if (attachBefore) { + hls.trigger(Events.MEDIA_ATTACHING, { + media: makeMockMedia(attachBefore.autoplay), + }); + } + hls.trigger(Events.MANIFEST_LOADING, { url }); return details; }; +// Trigger MEDIA_ATTACHING after setupEach to simulate `hls.attachMedia()` +// being called *after* `hls.loadSource()`. +const attachMedia = (autoplay = false) => { + cmcdController.hls.trigger(Events.MEDIA_ATTACHING, { + media: makeMockMedia(autoplay), + }); +}; + const base = { url, headers: undefined, @@ -238,12 +270,65 @@ describe('CMCDController', function () { expectField(url, `v%3D2`); }); - it('includes player state (sta) for v2', function () { - setupEach({ version: 2 }); + // The first manifest request's `sta` depends on which of attachMedia + // and loadSource was called first. The 4 combinations below cover the + // matrix: + // attach→load + autoplay=T → STARTING ("s") + // attach→load + autoplay=F → PRELOADING ("d") + // load→attach (or no attach) → PRELOADING ("d") via the no-media + // branch in onManifestLoading; autoplay only affects subsequent + // requests after attach completes. + describe('initial sta matrix (attach order × autoplay)', function () { + it('attach→load, autoplay=true → STARTING on first manifest', function () { + setupEach({ version: 2 }, { autoplay: true }); + + expect((cmcdController as any).playerState).to.equal('s'); + const { url } = applyPlaylistData(); + expectField(url, `sta%3Ds`); + }); - const { url } = applyPlaylistData(); - // Initial state is STARTING ("s") - expectField(url, `sta%3Ds`); + it('attach→load, autoplay=false → PRELOADING on first manifest', function () { + setupEach({ version: 2 }, { autoplay: false }); + + expect((cmcdController as any).playerState).to.equal('d'); + const { url } = applyPlaylistData(); + expectField(url, `sta%3Dd`); + }); + + it('load→attach (no media yet at load) → PRELOADING on first manifest', function () { + setupEach({ version: 2 }); + + // onManifestLoading sets PRELOADING because this.media is undefined + expect((cmcdController as any).playerState).to.equal('d'); + const { url } = applyPlaylistData(); + expectField(url, `sta%3Dd`); + }); + + it('load→attach, autoplay=true → first manifest is PRELOADING, then transitions to STARTING', function () { + setupEach({ version: 2 }); + + const { url: first } = applyPlaylistData(); + expectField(first, `sta%3Dd`); + + attachMedia(true); + expect((cmcdController as any).playerState).to.equal('s'); + + const { url: second } = applyPlaylistData(); + expectField(second, `sta%3Ds`); + }); + + it('load→attach, autoplay=false → stays PRELOADING after attach', function () { + setupEach({ version: 2 }); + + const { url: first } = applyPlaylistData(); + expectField(first, `sta%3Dd`); + + attachMedia(false); + expect((cmcdController as any).playerState).to.equal('d'); + + const { url: second } = applyPlaylistData(); + expectField(second, `sta%3Dd`); + }); }); it('includes stream type (st) for v2 when level details are available', function () { @@ -289,13 +374,14 @@ describe('CMCDController', function () { it('includes v2 fields in fragment data', function () { const details = setupEach({ version: 2 }); + attachMedia(false); details.live = false; const { url } = applyFragmentData(details.fragments[0]); // Should contain v2 version, stream type, and player state expectField(url, `v%3D2`); expectField(url, `st%3Dv`); - expectField(url, `sta%3Ds`); + expectField(url, `sta%3Dd`); // Standard fragment fields still present (inner list format in v2) expectField(url, `br%3D%281%29`); expectField(url, `ot%3Dav`); @@ -303,6 +389,7 @@ describe('CMCDController', function () { it('includes v2 fields in headers mode', function () { const details = setupEach({ version: 2, useHeaders: true }); + attachMedia(false); details.live = false; const { url, headers = {} } = applyPlaylistData(); @@ -311,7 +398,7 @@ describe('CMCDController', function () { const allHeaders = Object.values(headers).join(','); expect(allHeaders).to.include('v=2'); expect(allHeaders).to.include('st=v'); - expect(allHeaders).to.include('sta=s'); + expect(allHeaders).to.include('sta=d'); }); }); @@ -481,6 +568,7 @@ describe('CMCDController', function () { paused: true, removeEventListener: () => {}, } as unknown as HTMLMediaElement; + (cmcdController as any).initialized = true; (cmcdController as any).onSeeking(); expect((cmcdController as any).playerState).to.equal('k'); @@ -497,6 +585,7 @@ describe('CMCDController', function () { paused: false, removeEventListener: () => {}, } as unknown as HTMLMediaElement; + (cmcdController as any).initialized = true; (cmcdController as any).onSeeking(); expect((cmcdController as any).playerState).to.equal('k'); @@ -506,6 +595,63 @@ describe('CMCDController', function () { cmcdController.destroy(); }); + it('stays in PRELOADING when onSeeking fires before play()', function () { + // Per CTA-5004-B: SEEKING is for playhead moves "after starting". + // Before play() the player is PRELOADING; seeks during preload + // should not transition out of it. + setupEach({ version: 2 }); + attachMedia(false); + + (cmcdController as any).media = { + paused: true, + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + expect((cmcdController as any).playerState).to.equal('d'); + + (cmcdController as any).onSeeking(); + expect((cmcdController as any).playerState).to.equal('d'); + + (cmcdController as any).onSeeked(); + expect((cmcdController as any).playerState).to.equal('d'); + + cmcdController.destroy(); + }); + + it('transitions PRELOADING to STARTING on first play()', function () { + setupEach({ version: 2 }); + attachMedia(false); + expect((cmcdController as any).playerState).to.equal('d'); + + (cmcdController as any).onPlay(); + expect((cmcdController as any).playerState).to.equal('s'); + + cmcdController.destroy(); + }); + + it('does not re-enter STARTING on subsequent play() after PLAYING', function () { + setupEach({ version: 2 }); + + (cmcdController as any).onPlay(); + (cmcdController as any).onPlaying(); + expect((cmcdController as any).playerState).to.equal('p'); + + // After pause/play cycle, 'play' fires again but we stay on + // the prior state until 'playing' resolves to PLAYING. + (cmcdController as any).media = { + paused: true, + ended: false, + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + (cmcdController as any).onPause(); + expect((cmcdController as any).playerState).to.equal('a'); + + (cmcdController as any).onPlay(); + // initialized is now true; onPlay must not transition back to STARTING. + expect((cmcdController as any).playerState).to.equal('a'); + + cmcdController.destroy(); + }); + it('does not record duplicate play state events', function () { setupEach({ version: 2, @@ -962,11 +1108,14 @@ describe('CMCDController', function () { reporter.eventTargets.values(), ) as any[]; const queue = targetStates[0].queue as any[]; - const psEvents = queue.filter( - (e: any) => e.e === CmcdEventType.PLAY_STATE, + // The PLAY_STATE event for the PLAYING transition (sta=p) should + // carry the bl we populated above. Earlier PRELOADING transitions + // queued by onManifestLoading do not have bl. + const playingEvents = queue.filter( + (e: any) => e.e === CmcdEventType.PLAY_STATE && e.sta === 'p', ); - expect(psEvents.length).to.be.greaterThan(0); - expect(psEvents[0].bl).to.deep.equal([10000]); + expect(playingEvents.length).to.be.greaterThan(0); + expect(playingEvents[0].bl).to.deep.equal([10000]); cmcdController.destroy(); }); From 3df0419f624e1b597f556895ac23350e8085588f Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Sat, 23 May 2026 21:17:06 -0700 Subject: [PATCH 37/38] feat(cmcd): adopt cml-cmcd 2.4.0 auto-fire and recorder helper - Add ratechange listener; reporter.update({ pr }) auto-fires PLAYBACK_RATE - Drop local sta dedup guard and explicit recordEvent calls in setPlayerState and onLevelSwitching; rely on library auto-fire and br write-through - Replace CmcdRequestCollector mock with CmcdReportRecorder in e2e tests Co-Authored-By: Claude Opus 4.7 (1M context) --- src/controller/cmcd-controller.ts | 19 +-- tests/e2e/cmcd.ts | 164 +++++++-------------- tests/mocks/cmcd-request-collector.ts | 204 -------------------------- 3 files changed, 67 insertions(+), 320 deletions(-) delete mode 100644 tests/mocks/cmcd-request-collector.ts diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index 2d65046e60d..5256f4c2bac 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -145,7 +145,7 @@ export default class CMCDController implements ComponentAPI { // @ts-ignore this.onWaiting = this.onPlay = this.onPlaying = this.onPause = null; // @ts-ignore - this.onSeeking = this.onSeeked = this.media = null; + this.onSeeking = this.onSeeked = this.onRateChange = this.media = null; } private onMediaAttaching( @@ -165,6 +165,7 @@ export default class CMCDController implements ComponentAPI { addEventListener(media, 'pause', this.onPause); addEventListener(media, 'seeking', this.onSeeking); addEventListener(media, 'seeked', this.onSeeked); + addEventListener(media, 'ratechange', this.onRateChange); } private onMediaDetached() { @@ -180,6 +181,7 @@ export default class CMCDController implements ComponentAPI { removeEventListener(media, 'pause', this.onPause); removeEventListener(media, 'seeking', this.onSeeking); removeEventListener(media, 'seeked', this.onSeeked); + removeEventListener(media, 'ratechange', this.onRateChange); // @ts-ignore this.media = null; @@ -230,6 +232,12 @@ export default class CMCDController implements ComponentAPI { } }; + private onRateChange = () => { + if (this.reporter && this.media) { + this.reporter.update({ pr: this.media.playbackRate }); + } + }; + private onMediaEnded(event: Events.MEDIA_ENDED, data: MediaEndedData) { this.setPlayerState(CmcdPlayerState.ENDED); } @@ -271,9 +279,7 @@ export default class CMCDController implements ComponentAPI { return; } - this.reporter.update({ br: [data.bitrate / 1000] }); - - const eventData: Cmcd = {}; + const eventData: Cmcd = { br: [data.bitrate / 1000] }; const frag = data.details?.fragments[0]; if (frag) { eventData.ot = this.getObjectType(frag, data); @@ -282,14 +288,9 @@ export default class CMCDController implements ComponentAPI { } private setPlayerState(state: CmcdPlayerState) { - if (this.playerState === state) { - return; - } - this.playerState = state; if (this.reporter) { this.reporter.update({ sta: state }); - this.reporter.recordEvent(CmcdEventType.PLAY_STATE); } } diff --git a/tests/e2e/cmcd.ts b/tests/e2e/cmcd.ts index d7b76dc9373..6809afd73a3 100644 --- a/tests/e2e/cmcd.ts +++ b/tests/e2e/cmcd.ts @@ -1,5 +1,6 @@ import { CmcdEventType, + CmcdReportRecorder, validateCmcdEvents, validateCmcdRequest, } from '@svta/cml-cmcd'; @@ -7,8 +8,7 @@ import { assert, expect } from 'chai'; import { Events } from '../../src/events'; import Hls from '../../src/hls'; import FetchLoader from '../../src/utils/fetch-loader'; -import { CmcdRequestCollector } from '../mocks/cmcd-request-collector'; -import type { CollectedRequest } from '../mocks/cmcd-request-collector'; +import type { CmcdRecordedReport } from '@svta/cml-cmcd'; const TEST_STREAM = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; const SESSION_ID = 'e2e-test-session'; @@ -79,8 +79,8 @@ function waitForPlayback( }); } -function validateCollectedRequest(collected: CollectedRequest) { - const result = validateCmcdRequest(collected.request); +function validateRecordedReport(report: CmcdRecordedReport) { + const result = validateCmcdRequest(report.request); expect(result.valid).to.equal( true, `CMCD validation failed: ${JSON.stringify(result.issues)}`, @@ -91,13 +91,13 @@ function validateCollectedRequest(collected: CollectedRequest) { describe('CMCD v2 E2E Tests', function () { this.timeout(60000); - let collector: CmcdRequestCollector; + let recorder: CmcdReportRecorder; let video: HTMLVideoElement; let hls: Hls; let origOnerror: OnErrorEventHandler; beforeEach(function () { - collector = new CmcdRequestCollector(); + recorder = new CmcdReportRecorder(); video = createVideoElement(); // Suppress uncaught errors from the transmuxer worker blob. @@ -126,14 +126,14 @@ describe('CMCD v2 E2E Tests', function () { hls.destroy(); } destroyVideoElement(video); - collector.detach(); - collector.clear(); + recorder.detach(); + recorder.clear(); self.onerror = origOnerror; }); describe('Group 1: Query Mode (v2)', function () { beforeEach(function () { - collector.attach(); + recorder.attach({ waitTimeout: REQUEST_TIMEOUT }); hls = new Hls({ loader: FetchLoader, cmcd: { @@ -147,12 +147,8 @@ describe('CMCD v2 E2E Tests', function () { }); it('should send valid CMCD v2 on manifest requests', async function () { - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); - const decoded = validateCollectedRequest(manifests[0]); + const manifests = await recorder.waitForManifest(); + const decoded = validateRecordedReport(manifests[0]); expect(decoded).to.have.property('ot', 'm'); expect(decoded).to.have.property('sf', 'h'); @@ -166,12 +162,8 @@ describe('CMCD v2 E2E Tests', function () { it('should send valid CMCD v2 on segment requests', async function () { // Wait for segments directly — segments are requested before playback starts - const segments = await collector.waitForRequests( - 'segment', - 2, - REQUEST_TIMEOUT, - ); - const decoded = validateCollectedRequest(segments[0]); + const segments = await recorder.waitForSegments({ count: 2 }); + const decoded = validateRecordedReport(segments[0]); expect(decoded).to.have.property('ot'); expect(['av', 'v', 'a', 'i']).to.include(decoded.ot as string); @@ -184,13 +176,9 @@ describe('CMCD v2 E2E Tests', function () { it('should include next object request (nor) on at least one segment', async function () { await waitForPlayback(hls, video); - const segments = await collector.waitForRequests( - 'segment', - 2, - REQUEST_TIMEOUT, - ); - const hasNor = segments.some((req) => { - const result = validateCmcdRequest(req.request); + const segments = await recorder.waitForSegments({ count: 2 }); + const hasNor = segments.some((report) => { + const result = validateCmcdRequest(report.request); return result.data.nor != null; }); expect(hasNor).to.equal(true, 'No segment had a "nor" field'); @@ -200,20 +188,15 @@ describe('CMCD v2 E2E Tests', function () { // Video has autoplay=true and attachMedia is called before loadSource, // so the first manifest carries sta=s (STARTING). Once 'playing' fires // the state transitions to PLAYING (p) for subsequent requests. - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); - expect(validateCollectedRequest(manifests[0])).to.have.property( - 'sta', - 's', - ); + const manifests = await recorder.waitForManifest(); + expect(validateRecordedReport(manifests[0])).to.have.property('sta', 's'); await waitForPlayback(hls, video); - await collector.waitForRequests('segment', 4, REQUEST_TIMEOUT); + await recorder.waitForSegments({ count: 4 }); - const segments = collector.getRequests('segment'); + const segments = recorder + .getReports() + .filter((r) => r.type === 'segment'); const states = segments.map( (r) => validateCmcdRequest(r.request).data.sta, ); @@ -238,13 +221,9 @@ describe('CMCD v2 E2E Tests', function () { it('should include measured throughput (mtp) after playback', async function () { await waitForPlayback(hls, video); - const segments = await collector.waitForRequests( - 'segment', - 2, - REQUEST_TIMEOUT, - ); - const hasMtp = segments.some((req) => { - const result = validateCmcdRequest(req.request); + const segments = await recorder.waitForSegments({ count: 2 }); + const hasMtp = segments.some((report) => { + const result = validateCmcdRequest(report.request); return result.data.mtp != null; }); expect(hasMtp).to.equal( @@ -256,7 +235,7 @@ describe('CMCD v2 E2E Tests', function () { describe('Group 2: Header Mode (v2)', function () { beforeEach(function () { - collector.attach(); + recorder.attach({ waitTimeout: REQUEST_TIMEOUT }); hls = new Hls({ loader: FetchLoader, cmcd: { @@ -271,17 +250,13 @@ describe('CMCD v2 E2E Tests', function () { }); it('should send CMCD headers (not query) on manifest requests', async function () { - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); - const req = manifests[0]; + const manifests = await recorder.waitForManifest(); + const report = manifests[0]; - expect(req.reportingMode).to.equal('header'); - expect(req.request.url).to.not.include('CMCD='); + expect(report.reportingMode).to.equal('header'); + expect(report.request.url).to.not.include('CMCD='); - const decoded = validateCollectedRequest(req); + const decoded = validateRecordedReport(report); expect(decoded).to.have.property('ot', 'm'); expect(decoded).to.have.property('sf', 'h'); expect(decoded).to.have.property('sid', SESSION_ID); @@ -293,16 +268,12 @@ describe('CMCD v2 E2E Tests', function () { // preflight on cross-origin requests. The test stream server may not // support these headers in CORS. We validate the headers on the initial // manifest request which is always captured by the interceptor. - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); - const req = manifests[0]; + const manifests = await recorder.waitForManifest(); + const report = manifests[0]; - expect(req.reportingMode).to.equal('header'); + expect(report.reportingMode).to.equal('header'); - const decoded = validateCollectedRequest(req); + const decoded = validateRecordedReport(report); // v2-specific fields. Video has autoplay=true and attachMedia runs // before loadSource, so sta=s (STARTING) is set before the manifest @@ -318,22 +289,18 @@ describe('CMCD v2 E2E Tests', function () { }); it('should place CMCD fields in correct header shards', async function () { - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); - const { headers } = manifests[0].request; + const manifests = await recorder.waitForManifest(); + const headers = manifests[0].request.headers; // CMCD-Session should contain sid, sf - const session = headers.get('CMCD-Session'); + const session = headers?.['cmcd-session']; if (session) { expect(session).to.include('sid='); expect(session).to.include('sf='); } // CMCD-Object should contain ot - const object = headers.get('CMCD-Object'); + const object = headers?.['cmcd-object']; if (object) { expect(object).to.include('ot='); } @@ -342,7 +309,10 @@ describe('CMCD v2 E2E Tests', function () { describe('Group 3: Event Mode (v2)', function () { beforeEach(function () { - collector.attach({ eventTargetUrls: [EVENT_TARGET_URL] }); + recorder.attach({ + eventTargetUrls: [EVENT_TARGET_URL], + waitTimeout: REQUEST_TIMEOUT, + }); hls = new Hls({ loader: FetchLoader, cmcd: { @@ -364,17 +334,13 @@ describe('CMCD v2 E2E Tests', function () { it('should send play state events via POST', async function () { await waitForPlayback(hls, video); - const events = await collector.waitForRequests( - 'event', - 1, - REQUEST_TIMEOUT, - ); + const events = await recorder.waitForEvents(); expect(events.length).to.be.greaterThan(0); - const req = events[0]; - expect(req.request.method).to.equal('POST'); + const report = events[0]; + expect(report.request.method).to.equal('POST'); - const body = await req.request.text(); + const body = report.request.body as string; expect(body.length).to.be.greaterThan(0); const result = validateCmcdEvents(body); @@ -396,7 +362,7 @@ describe('CMCD v2 E2E Tests', function () { const INCLUDED_KEYS = ['sid', 'cid', 'ot', 'v', 'sf']; beforeEach(function () { - collector.attach(); + recorder.attach({ waitTimeout: REQUEST_TIMEOUT }); hls = new Hls({ loader: FetchLoader, cmcd: { @@ -411,11 +377,7 @@ describe('CMCD v2 E2E Tests', function () { }); it('should only include specified keys', async function () { - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); + const manifests = await recorder.waitForManifest(); const result = validateCmcdRequest(manifests[0].request); const decoded = result.data as Record; @@ -432,7 +394,7 @@ describe('CMCD v2 E2E Tests', function () { describe('Group 5: Version Comparison', function () { it('v1 should omit v and sta fields', async function () { - collector.attach(); + recorder.attach({ waitTimeout: REQUEST_TIMEOUT }); hls = new Hls({ loader: FetchLoader, cmcd: { @@ -443,11 +405,7 @@ describe('CMCD v2 E2E Tests', function () { hls.attachMedia(video); hls.loadSource(TEST_STREAM); - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); + const manifests = await recorder.waitForManifest(); const result = validateCmcdRequest(manifests[0].request); const decoded = result.data as Record; @@ -456,7 +414,7 @@ describe('CMCD v2 E2E Tests', function () { }); it('v2 should include v=2 and sta', async function () { - collector.attach(); + recorder.attach({ waitTimeout: REQUEST_TIMEOUT }); hls = new Hls({ loader: FetchLoader, cmcd: { @@ -468,12 +426,8 @@ describe('CMCD v2 E2E Tests', function () { hls.attachMedia(video); hls.loadSource(TEST_STREAM); - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); - const decoded = validateCollectedRequest(manifests[0]); + const manifests = await recorder.waitForManifest(); + const decoded = validateRecordedReport(manifests[0]); expect(decoded).to.have.property('v', 2); // Video has autoplay=true and attachMedia runs before loadSource, so @@ -552,7 +506,7 @@ describe('CMCD v2 E2E Tests', function () { video.autoplay = scenario.autoplay; document.body.appendChild(video); - collector.attach(); + recorder.attach({ waitTimeout: REQUEST_TIMEOUT }); hls = new Hls({ loader: FetchLoader, autoStartLoad: scenario.autoStartLoad, @@ -571,12 +525,8 @@ describe('CMCD v2 E2E Tests', function () { hls.attachMedia(video); } - const manifests = await collector.waitForRequests( - 'manifest', - 1, - REQUEST_TIMEOUT, - ); - const decoded = validateCollectedRequest(manifests[0]); + const manifests = await recorder.waitForManifest(); + const decoded = validateRecordedReport(manifests[0]); expect(decoded).to.have.property('sta', scenario.expectedSta); }); diff --git a/tests/mocks/cmcd-request-collector.ts b/tests/mocks/cmcd-request-collector.ts deleted file mode 100644 index ee52aa0e912..00000000000 --- a/tests/mocks/cmcd-request-collector.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * CmcdRequestCollector — intercepts fetch requests to capture CMCD data. - * - * Monkey-patches self.fetch so that outgoing requests carrying CMCD query - * params or headers are recorded for later assertion in e2e tests. - * - * For event target URLs, intercepts POST requests and returns a synthetic - * 200 response to prevent actual network calls to external endpoints. - * - * Requires hls.js to be configured with `loader: FetchLoader` so that all - * requests flow through fetch. - */ - -export type CmcdRequestType = 'manifest' | 'segment' | 'event' | 'unknown'; - -export type CmcdRequestMode = 'query' | 'header' | 'event'; - -export interface CollectedRequest { - request: Request; - type: CmcdRequestType; - reportingMode: CmcdRequestMode; - timestamp: number; -} - -export interface CollectorOptions { - eventTargetUrls?: string[]; -} - -interface Waiter { - type: CmcdRequestType | undefined; - count: number; - resolve: (requests: CollectedRequest[]) => void; - reject: (reason: Error) => void; - timer: number; -} - -const MANIFEST_EXTENSIONS = /\.(m3u8|mpd)/i; -const SEGMENT_EXTENSIONS = /\.(m4s|ts|mp4|m4a|m4v|aac)(\?|$)/i; - -function classifyUrl(url: string, method: string): CmcdRequestType { - if (method === 'POST') { - return 'event'; - } - if (MANIFEST_EXTENSIONS.test(url)) { - return 'manifest'; - } - if (SEGMENT_EXTENSIONS.test(url)) { - return 'segment'; - } - return 'unknown'; -} - -function hasCmcdHeaders(request: Request): boolean { - let found = false; - request.headers.forEach((_value, name) => { - if (name.toLowerCase().startsWith('cmcd-')) { - found = true; - } - }); - return found; -} - -export class CmcdRequestCollector { - private requests: CollectedRequest[] = []; - private waiters: Waiter[] = []; - private attached = false; - private eventTargetUrls: string[] = []; - private origFetch: typeof self.fetch | null = null; - - attach(options: CollectorOptions = {}): void { - if (this.attached) { - return; - } - this.attached = true; - this.eventTargetUrls = options.eventTargetUrls || []; - - const origFetch = (this.origFetch = self.fetch); - - (self as any).fetch = ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - const request = - input instanceof Request ? input : new Request(String(input), init); - - const type = classifyUrl(request.url, request.method); - - const entry: CollectedRequest = { - request: request.clone(), - type, - reportingMode: - type === 'event' - ? 'event' - : hasCmcdHeaders(request) - ? 'header' - : 'query', - timestamp: Date.now(), - }; - - this.addRequest(entry); - - // Intercept event target POSTs — return synthetic 200 response - if (this.isEventTargetRequest(request)) { - return Promise.resolve(new Response('', { status: 204 })); - } - - return origFetch.call(self, input, init); - }; - } - - detach(): void { - if (!this.attached) { - return; - } - - if (this.origFetch) { - self.fetch = this.origFetch; - } - - this.origFetch = null; - this.attached = false; - - // Clear pending waiters - this.waiters.forEach((waiter) => { - self.clearTimeout(waiter.timer); - waiter.reject(new Error('Collector detached while waiting')); - }); - this.waiters = []; - } - - getRequests(type?: CmcdRequestType): CollectedRequest[] { - if (type) { - return this.requests.filter((r) => r.type === type); - } - return [...this.requests]; - } - - clear(): void { - this.requests = []; - } - - waitForRequests( - type: CmcdRequestType | undefined, - count: number, - timeout: number = 30000, - ): Promise { - const matching = this.getRequests(type); - - if (matching.length >= count) { - return Promise.resolve(matching); - } - - return new Promise((resolve, reject) => { - const timer = self.setTimeout(() => { - this.removeWaiter(waiter); - const current = this.getRequests(type); - reject( - new Error( - `Timeout waiting for ${count} ${type || 'any'} CMCD request(s). ` + - `Got ${current.length}. Total collected: ${this.requests.length}.`, - ), - ); - }, timeout); - - const waiter: Waiter = { type, count, resolve, reject, timer }; - this.waiters.push(waiter); - }); - } - - private addRequest(entry: CollectedRequest): void { - this.requests.push(entry); - this.checkWaiters(); - } - - private isEventTargetRequest(request: Request): boolean { - const { method, url } = request; - return ( - method === 'POST' && - this.eventTargetUrls.some((target) => url.startsWith(target)) - ); - } - - private removeWaiter(waiter: Waiter): void { - const idx = this.waiters.indexOf(waiter); - if (idx >= 0) { - this.waiters.splice(idx, 1); - } - } - - private checkWaiters(): void { - const resolved: Waiter[] = []; - - this.waiters.forEach((waiter) => { - const matching = this.getRequests(waiter.type); - if (matching.length >= waiter.count) { - self.clearTimeout(waiter.timer); - waiter.resolve(matching); - resolved.push(waiter); - } - }); - - resolved.forEach((w) => this.removeWaiter(w)); - } -} From dfb7087f641db8e43c6eff96b7667bb99613696d Mon Sep 17 00:00:00 2001 From: Casey Occhialini <1508707+littlespex@users.noreply.github.com> Date: Sat, 23 May 2026 22:21:17 -0700 Subject: [PATCH 38/38] chore: update cml-cmcd to version 2.4.0 --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index a2450ed0233..259989ca4a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.3.2", + "@svta/cml-cmcd": "2.4.0", "@svta/cml-id3": "1.0.6", "@svta/cml-utils": "1.5.0", "@types/chai": "5.2.3", @@ -4046,9 +4046,9 @@ "license": "CC0-1.0" }, "node_modules/@svta/cml-cmcd": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.2.tgz", - "integrity": "sha512-SKBBjLmci0WK8HMjuv+36tVIMktonoOoxsXblOFZmB+ePPV2zjRMTD+2ZmE/1VEPJkKHENyhSjSHgJyeOlvZ1A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.4.0.tgz", + "integrity": "sha512-LEVH5t5igv1fN18huFK+9S+qQOej2jFU16Z6E1phlaL615uP/+G3JiVZrw5n/GPAME4XjWmcgzbd7rP8dNwzvQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -16563,9 +16563,9 @@ "dev": true }, "@svta/cml-cmcd": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.2.tgz", - "integrity": "sha512-SKBBjLmci0WK8HMjuv+36tVIMktonoOoxsXblOFZmB+ePPV2zjRMTD+2ZmE/1VEPJkKHENyhSjSHgJyeOlvZ1A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.4.0.tgz", + "integrity": "sha512-LEVH5t5igv1fN18huFK+9S+qQOej2jFU16Z6E1phlaL615uP/+G3JiVZrw5n/GPAME4XjWmcgzbd7rP8dNwzvQ==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index c63889f90b4..83010422650 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.3.0", - "@svta/cml-cmcd": "2.3.2", + "@svta/cml-cmcd": "2.4.0", "@svta/cml-id3": "1.0.6", "@svta/cml-utils": "1.5.0", "@types/chai": "5.2.3",