mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
feat(cmcd): upgrade to CMCD v2 using @svta/cml-cmcd CmcdReporter (#7725)
* 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 * 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 * 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 * fix: remove @svta/common-media-library * feat: update cmcd-controller to use CML's CmcdReporter * chore: update CML packages * fix: build system cleanup * fix: export clean up * fix: api-extractor rebuild * revert: build config change * fix: address code review comment about muxed buffers * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix: return 204 for event mode responses * 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 <noreply@anthropic.com> * chore: update api file * chore: update CMCD package for revision B changes * 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) <noreply@anthropic.com> * chore: update @svta/cml-cmcd to 2.3.1 * chore: update CML versions * 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. * 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. * 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. * 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) <noreply@anthropic.com> * test(cmcd): add failing tests for getBufferLength buffer-info sources * fix(cmcd): use hls.audioForwardBufferInfo in getBufferLength (resolves #7858 TODO) * refactor(cmcd): drop unused audioBuffer field and BUFFER_CREATED wiring * fix(cmcd): derive object type from variant codecs / elementary streams 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) <noreply@anthropic.com> * fix(cmcd): emit br/tb/bl for single-rendition fragments 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) <noreply@anthropic.com> * fix(cmcd): keep bl fresh on event reports via buffer-info listener 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) <noreply@anthropic.com> * fix(cmcd): gate variant-codec ot inference on audioTracks; prefer parsed streams 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) <noreply@anthropic.com> * fix(cmcd): defer reporter creation to MANIFEST_LOADING, one per session 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) <noreply@anthropic.com> * chore: fix lint issue * chore: fix lint issue * chore: fix lint issue * fix(cmcd): set initial sta on MEDIA_ATTACHING; default to PRELOADING 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * chore: update cml-cmcd to version 2.4.0 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
29c25a8b4d
commit
8814807e8e
@@ -46,3 +46,6 @@ _ReSharper*/
|
||||
|
||||
# VSCode custom workspace settings
|
||||
.vscode/settings.json
|
||||
|
||||
# Claude local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
```ts
|
||||
|
||||
import type { CmcdEventReportConfig } from '@svta/cml-cmcd';
|
||||
import type { CmcdKey } from '@svta/cml-cmcd';
|
||||
import type { CmcdVersion } from '@svta/cml-cmcd';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
// 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)
|
||||
@@ -1012,7 +1015,19 @@ export type CMCDControllerConfig = {
|
||||
sessionId?: string;
|
||||
contentId?: string;
|
||||
useHeaders?: boolean;
|
||||
includeKeys?: string[];
|
||||
includeKeys?: CmcdKey[];
|
||||
version?: CmcdVersion;
|
||||
eventTargets?: (Omit<CmcdEventReportConfig, 'enabledKeys'> & {
|
||||
includeKeys?: CmcdKey[];
|
||||
})[];
|
||||
loader?: (request: {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
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)
|
||||
|
||||
+9
-1
@@ -1895,7 +1895,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`
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Generated
+75
-992
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -88,7 +88,9 @@
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/plugin-terser": "0.4.4",
|
||||
"@rollup/plugin-typescript": "12.3.0",
|
||||
"@svta/common-media-library": "0.17.1",
|
||||
"@svta/cml-cmcd": "2.4.0",
|
||||
"@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",
|
||||
|
||||
+16
-1
@@ -45,6 +45,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,
|
||||
CmcdKey,
|
||||
CmcdVersion,
|
||||
} from '@svta/cml-cmcd';
|
||||
|
||||
export type ABRControllerConfig = {
|
||||
abrEwmaFastLive: number;
|
||||
@@ -85,7 +90,17 @@ export type CMCDControllerConfig = {
|
||||
sessionId?: string;
|
||||
contentId?: string;
|
||||
useHeaders?: boolean;
|
||||
includeKeys?: string[];
|
||||
includeKeys?: CmcdKey[];
|
||||
version?: CmcdVersion;
|
||||
eventTargets?: (Omit<CmcdEventReportConfig, 'enabledKeys'> & {
|
||||
includeKeys?: CmcdKey[];
|
||||
})[];
|
||||
loader?: (request: {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit;
|
||||
}) => Promise<{ status: number }>;
|
||||
};
|
||||
|
||||
export type DRMSystemOptions = {
|
||||
|
||||
+345
-104
@@ -1,19 +1,41 @@
|
||||
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_HEADERS,
|
||||
CMCD_KEYS,
|
||||
CMCD_QUERY,
|
||||
CMCD_V1,
|
||||
CMCD_V1_KEYS,
|
||||
CMCD_V2,
|
||||
CmcdEventType,
|
||||
CmcdObjectType,
|
||||
CmcdPlayerState,
|
||||
CmcdReporter,
|
||||
CmcdStreamingFormat,
|
||||
CmcdStreamType,
|
||||
toCmcdValue,
|
||||
} 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,
|
||||
PlaylistLoaderConstructor,
|
||||
} from '../config';
|
||||
import type Hls from '../hls';
|
||||
import type { Fragment, Part } from '../loader/fragment';
|
||||
import type { ExtendedSourceBuffer } from '../types/buffer';
|
||||
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
|
||||
import type { ComponentAPI } from '../types/component-api';
|
||||
import type { BufferCreatedData, MediaAttachedData } from '../types/events';
|
||||
import type {
|
||||
BufferAppendedData,
|
||||
BufferFlushedData,
|
||||
ErrorData,
|
||||
LevelSwitchingData,
|
||||
ManifestLoadingData,
|
||||
MediaAttachedData,
|
||||
MediaEndedData,
|
||||
} from '../types/events';
|
||||
import type { Level } from '../types/level';
|
||||
import type {
|
||||
FragmentLoaderContext,
|
||||
Loader,
|
||||
@@ -22,8 +44,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 } from '@svta/cml-cmcd';
|
||||
|
||||
/**
|
||||
* Controller to deal with Common Media Client Data (CMCD)
|
||||
@@ -33,15 +54,11 @@ 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 initialized: boolean = false;
|
||||
private starved: boolean = false;
|
||||
private buffering: boolean = true;
|
||||
private audioBuffer?: ExtendedSourceBuffer;
|
||||
private videoBuffer?: ExtendedSourceBuffer;
|
||||
private playerState?: CmcdPlayerState;
|
||||
private reporter?: CmcdReporter;
|
||||
|
||||
constructor(hls: Hls) {
|
||||
this.hls = hls;
|
||||
@@ -52,103 +69,267 @@ 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.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 ?? CMCD_KEYS,
|
||||
}),
|
||||
),
|
||||
},
|
||||
cmcd.loader,
|
||||
);
|
||||
|
||||
this.reporter.update({
|
||||
sf: CmcdStreamingFormat.HLS,
|
||||
sta: this.playerState,
|
||||
});
|
||||
this.reporter.start();
|
||||
}
|
||||
|
||||
private registerListeners() {
|
||||
const hls = this.hls;
|
||||
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
|
||||
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
|
||||
hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
|
||||
hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this);
|
||||
hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this);
|
||||
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() {
|
||||
const hls = this.hls;
|
||||
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
|
||||
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
|
||||
hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
|
||||
hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this);
|
||||
hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this);
|
||||
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() {
|
||||
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;
|
||||
this.hls = this.config = null;
|
||||
// @ts-ignore
|
||||
this.onWaiting = this.onPlaying = this.media = null;
|
||||
this.onWaiting = this.onPlay = this.onPlaying = this.onPause = null;
|
||||
// @ts-ignore
|
||||
this.onSeeking = this.onSeeked = this.onRateChange = this.media = null;
|
||||
}
|
||||
|
||||
private onMediaAttached(
|
||||
event: Events.MEDIA_ATTACHED,
|
||||
private onMediaAttaching(
|
||||
event: Events.MEDIA_ATTACHING,
|
||||
data: MediaAttachedData,
|
||||
) {
|
||||
this.media = data.media;
|
||||
this.media.addEventListener('waiting', this.onWaiting);
|
||||
this.media.addEventListener('playing', this.onPlaying);
|
||||
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);
|
||||
addEventListener(media, 'seeked', this.onSeeked);
|
||||
addEventListener(media, 'ratechange', this.onRateChange);
|
||||
}
|
||||
|
||||
private onMediaDetached() {
|
||||
if (!this.media) {
|
||||
const media = this.media;
|
||||
|
||||
if (!media) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.media.removeEventListener('waiting', this.onWaiting);
|
||||
this.media.removeEventListener('playing', this.onPlaying);
|
||||
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);
|
||||
removeEventListener(media, 'seeked', this.onSeeked);
|
||||
removeEventListener(media, 'ratechange', this.onRateChange);
|
||||
|
||||
// @ts-ignore
|
||||
this.media = null;
|
||||
}
|
||||
|
||||
private onBufferCreated(
|
||||
event: Events.BUFFER_CREATED,
|
||||
data: BufferCreatedData,
|
||||
) {
|
||||
this.audioBuffer = data.tracks.audio?.buffer;
|
||||
this.videoBuffer = data.tracks.video?.buffer;
|
||||
}
|
||||
|
||||
private onWaiting = () => {
|
||||
if (this.initialized) {
|
||||
this.starved = true;
|
||||
this.setPlayerState(CmcdPlayerState.REBUFFERING);
|
||||
}
|
||||
|
||||
this.buffering = true;
|
||||
};
|
||||
|
||||
private onPlay = () => {
|
||||
if (!this.initialized) {
|
||||
this.setPlayerState(CmcdPlayerState.STARTING);
|
||||
}
|
||||
};
|
||||
|
||||
private onPlaying = () => {
|
||||
if (!this.initialized) {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
this.buffering = false;
|
||||
this.setPlayerState(CmcdPlayerState.PLAYING);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create baseline CMCD data
|
||||
*/
|
||||
private createData(): Cmcd {
|
||||
return {
|
||||
v: 1,
|
||||
sf: CmcdStreamingFormat.HLS,
|
||||
sid: this.sid,
|
||||
cid: this.cid,
|
||||
pr: this.media?.playbackRate,
|
||||
mtp: this.hls.bandwidthEstimate / 1000,
|
||||
};
|
||||
private onPause = () => {
|
||||
if (this.media && !this.media.ended) {
|
||||
this.setPlayerState(CmcdPlayerState.PAUSED);
|
||||
}
|
||||
};
|
||||
|
||||
private onSeeking = () => {
|
||||
if (this.initialized) {
|
||||
this.setPlayerState(CmcdPlayerState.SEEKING);
|
||||
}
|
||||
};
|
||||
|
||||
private onSeeked = () => {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (this.media?.paused) {
|
||||
this.setPlayerState(CmcdPlayerState.PAUSED);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private onManifestLoading(
|
||||
event: Events.MANIFEST_LOADING,
|
||||
data: ManifestLoadingData,
|
||||
) {
|
||||
this.initialized = false;
|
||||
this.starved = false;
|
||||
this.buffering = true;
|
||||
|
||||
if (this.reporter) {
|
||||
this.reporter.stop(true);
|
||||
this.reporter = undefined;
|
||||
}
|
||||
|
||||
this.createReporter();
|
||||
|
||||
if (!this.media) {
|
||||
this.setPlayerState(CmcdPlayerState.PRELOADING);
|
||||
}
|
||||
}
|
||||
|
||||
private onError(event: Events.ERROR, data: ErrorData) {
|
||||
if (data.fatal) {
|
||||
this.setPlayerState(CmcdPlayerState.FATAL_ERROR);
|
||||
if (this.reporter) {
|
||||
this.reporter.recordEvent(CmcdEventType.ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onLevelSwitching(
|
||||
event: Events.LEVEL_SWITCHING,
|
||||
data: LevelSwitchingData,
|
||||
) {
|
||||
if (!this.reporter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData: Cmcd = { br: [data.bitrate / 1000] };
|
||||
const frag = data.details?.fragments[0];
|
||||
if (frag) {
|
||||
eventData.ot = this.getObjectType(frag, data);
|
||||
}
|
||||
this.reporter.recordEvent(CmcdEventType.BITRATE_CHANGE, eventData);
|
||||
}
|
||||
|
||||
private setPlayerState(state: CmcdPlayerState) {
|
||||
this.playerState = state;
|
||||
if (this.reporter) {
|
||||
this.reporter.update({ sta: state });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CMCD data to a request.
|
||||
* Get the stream type based on level details.
|
||||
*/
|
||||
private getStreamType(): CmcdStreamType | undefined {
|
||||
const details = this.hls.latestLevelDetails;
|
||||
|
||||
if (!details) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!details.live) {
|
||||
return CmcdStreamType.VOD;
|
||||
}
|
||||
|
||||
// TODO: Replace with an `isLowLatency` check in #7729
|
||||
if (!!details.partList && details.canBlockReload) {
|
||||
return CmcdStreamType.LOW_LATENCY;
|
||||
}
|
||||
|
||||
return CmcdStreamType.LIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CMCD data to a request using the reporter.
|
||||
*/
|
||||
private apply(context: LoaderContext, data: Cmcd = {}) {
|
||||
// apply baseline data
|
||||
Object.assign(data, this.createData());
|
||||
if (!this.reporter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update persistent data
|
||||
this.reporter.update({
|
||||
mtp: [this.hls.bandwidthEstimate / 1000],
|
||||
pr: this.media?.playbackRate,
|
||||
st: this.getStreamType(),
|
||||
});
|
||||
|
||||
const isVideo =
|
||||
data.ot === CmcdObjectType.INIT ||
|
||||
@@ -165,27 +346,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 report = this.reporter.createRequestReport(
|
||||
{ url: context.url, headers: context.headers },
|
||||
data,
|
||||
);
|
||||
|
||||
const options: CmcdEncodeOptions = { baseUrl: context.url };
|
||||
|
||||
if (this.useHeaders) {
|
||||
if (!context.headers) {
|
||||
context.headers = {};
|
||||
}
|
||||
|
||||
appendCmcdHeaders(context.headers, data, options);
|
||||
} else {
|
||||
context.url = appendCmcdQuery(context.url, data, options);
|
||||
}
|
||||
context.url = report.url;
|
||||
context.headers = report.headers;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,23 +378,38 @@ 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 (
|
||||
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;
|
||||
data.tb = this.getTopBandwidth(ot) / 1000;
|
||||
data.bl = this.getBufferLength(ot);
|
||||
data.br = [level.bitrate / 1000];
|
||||
const tb = this.getTopBandwidth(frag) / 1000;
|
||||
if (Number.isFinite(tb)) {
|
||||
data.tb = [tb];
|
||||
}
|
||||
const bl = this.getBufferLength(frag);
|
||||
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);
|
||||
@@ -264,7 +448,10 @@ export default class CMCDController implements ComponentAPI {
|
||||
/**
|
||||
* The CMCD object type.
|
||||
*/
|
||||
private getObjectType(fragment: Fragment): CmcdObjectType | undefined {
|
||||
private getObjectType(
|
||||
fragment: Fragment | MediaFragment,
|
||||
variant?: Level | LevelSwitchingData,
|
||||
): CmcdObjectType | undefined {
|
||||
const { type } = fragment;
|
||||
|
||||
if (type === 'subtitle') {
|
||||
@@ -280,25 +467,52 @@ export default class CMCDController implements ComponentAPI {
|
||||
}
|
||||
|
||||
if (type === 'main') {
|
||||
if (!this.hls.audioTracks.length) {
|
||||
return CmcdObjectType.MUXED;
|
||||
// Parsed elementary streams are ground truth when present.
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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 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;
|
||||
@@ -316,24 +530,51 @@ 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) {
|
||||
const media = this.media;
|
||||
const buffer =
|
||||
type === CmcdObjectType.AUDIO ? this.audioBuffer : this.videoBuffer;
|
||||
|
||||
if (!buffer || !media) {
|
||||
private getBufferLength(fragment: Fragment | MediaFragment) {
|
||||
if (!this.media) {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
const info = BufferHelper.bufferInfo(
|
||||
buffer,
|
||||
media.currentTime,
|
||||
this.config.maxBufferHole,
|
||||
);
|
||||
const info =
|
||||
fragment.type === 'audio'
|
||||
? this.hls.audioForwardBufferInfo
|
||||
: this.hls.mainForwardBufferInfo;
|
||||
return info ? info.len * 1000 : NaN;
|
||||
}
|
||||
|
||||
return info.len * 1000;
|
||||
/**
|
||||
* 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] });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -379,7 +620,7 @@ export default class CMCDController implements ComponentAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a playlist loader
|
||||
* Create a fragment loader
|
||||
*/
|
||||
private createFragmentLoader(): FragmentLoaderConstructor | undefined {
|
||||
const { fLoader } = this.config;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
+10
-6
@@ -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';
|
||||
@@ -230,10 +230,15 @@ export default class Hls implements HlsEventEmitter {
|
||||
? (this.capLevelController = new _CapLevelController(this))
|
||||
: null;
|
||||
const fpsController = _FpsController ? new _FpsController(this) : null;
|
||||
const _CMCDController = config.cmcdController;
|
||||
// Instantiate CMCDController before PlaylistLoader to receive Manifest Loading events first
|
||||
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;
|
||||
@@ -329,10 +334,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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
import {
|
||||
CmcdEventType,
|
||||
CmcdReportRecorder,
|
||||
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 type { CmcdRecordedReport } from '@svta/cml-cmcd';
|
||||
|
||||
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<void> {
|
||||
return new Promise<void>((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 validateRecordedReport(report: CmcdRecordedReport) {
|
||||
const result = validateCmcdRequest(report.request);
|
||||
expect(result.valid).to.equal(
|
||||
true,
|
||||
`CMCD validation failed: ${JSON.stringify(result.issues)}`,
|
||||
);
|
||||
return result.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('CMCD v2 E2E Tests', function () {
|
||||
this.timeout(60000);
|
||||
|
||||
let recorder: CmcdReportRecorder;
|
||||
let video: HTMLVideoElement;
|
||||
let hls: Hls;
|
||||
let origOnerror: OnErrorEventHandler;
|
||||
|
||||
beforeEach(function () {
|
||||
recorder = new CmcdReportRecorder();
|
||||
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);
|
||||
recorder.detach();
|
||||
recorder.clear();
|
||||
self.onerror = origOnerror;
|
||||
});
|
||||
|
||||
describe('Group 1: Query Mode (v2)', function () {
|
||||
beforeEach(function () {
|
||||
recorder.attach({ waitTimeout: REQUEST_TIMEOUT });
|
||||
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 recorder.waitForManifest();
|
||||
const decoded = validateRecordedReport(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);
|
||||
// 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 () {
|
||||
// Wait for segments directly — segments are requested before playback starts
|
||||
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);
|
||||
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 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');
|
||||
});
|
||||
|
||||
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 recorder.waitForManifest();
|
||||
expect(validateRecordedReport(manifests[0])).to.have.property('sta', 's');
|
||||
|
||||
await waitForPlayback(hls, video);
|
||||
await recorder.waitForSegments({ count: 4 });
|
||||
|
||||
const segments = recorder
|
||||
.getReports()
|
||||
.filter((r) => r.type === 'segment');
|
||||
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: ${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(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include measured throughput (mtp) after playback', async function () {
|
||||
await waitForPlayback(hls, video);
|
||||
|
||||
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(
|
||||
true,
|
||||
'No segment had "mtp" (measured throughput)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group 2: Header Mode (v2)', function () {
|
||||
beforeEach(function () {
|
||||
recorder.attach({ waitTimeout: REQUEST_TIMEOUT });
|
||||
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 recorder.waitForManifest();
|
||||
const report = manifests[0];
|
||||
|
||||
expect(report.reportingMode).to.equal('header');
|
||||
expect(report.request.url).to.not.include('CMCD=');
|
||||
|
||||
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);
|
||||
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 recorder.waitForManifest();
|
||||
const report = manifests[0];
|
||||
|
||||
expect(report.reportingMode).to.equal('header');
|
||||
|
||||
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
|
||||
// request is built.
|
||||
expect(decoded).to.have.property('v', 2);
|
||||
expect(decoded).to.have.property('sta', 's');
|
||||
|
||||
// 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 recorder.waitForManifest();
|
||||
const headers = manifests[0].request.headers;
|
||||
|
||||
// CMCD-Session should contain sid, sf
|
||||
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?.['cmcd-object'];
|
||||
if (object) {
|
||||
expect(object).to.include('ot=');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group 3: Event Mode (v2)', function () {
|
||||
beforeEach(function () {
|
||||
recorder.attach({
|
||||
eventTargetUrls: [EVENT_TARGET_URL],
|
||||
waitTimeout: REQUEST_TIMEOUT,
|
||||
});
|
||||
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 recorder.waitForEvents();
|
||||
expect(events.length).to.be.greaterThan(0);
|
||||
|
||||
const report = events[0];
|
||||
expect(report.request.method).to.equal('POST');
|
||||
|
||||
const body = report.request.body as string;
|
||||
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 () {
|
||||
recorder.attach({ waitTimeout: REQUEST_TIMEOUT });
|
||||
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 recorder.waitForManifest();
|
||||
const result = validateCmcdRequest(manifests[0].request);
|
||||
const decoded = result.data as Record<string, unknown>;
|
||||
|
||||
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 () {
|
||||
recorder.attach({ waitTimeout: REQUEST_TIMEOUT });
|
||||
hls = new Hls({
|
||||
loader: FetchLoader,
|
||||
cmcd: {
|
||||
sessionId: SESSION_ID,
|
||||
contentId: CONTENT_ID,
|
||||
},
|
||||
});
|
||||
hls.attachMedia(video);
|
||||
hls.loadSource(TEST_STREAM);
|
||||
|
||||
const manifests = await recorder.waitForManifest();
|
||||
const result = validateCmcdRequest(manifests[0].request);
|
||||
const decoded = result.data as Record<string, unknown>;
|
||||
|
||||
expect(decoded).to.not.have.property('v');
|
||||
expect(decoded).to.not.have.property('sta');
|
||||
});
|
||||
|
||||
it('v2 should include v=2 and sta', async function () {
|
||||
recorder.attach({ waitTimeout: REQUEST_TIMEOUT });
|
||||
hls = new Hls({
|
||||
loader: FetchLoader,
|
||||
cmcd: {
|
||||
version: 2,
|
||||
sessionId: SESSION_ID,
|
||||
contentId: CONTENT_ID,
|
||||
},
|
||||
});
|
||||
hls.attachMedia(video);
|
||||
hls.loadSource(TEST_STREAM);
|
||||
|
||||
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
|
||||
// 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<string, Scenario> = {
|
||||
'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);
|
||||
|
||||
recorder.attach({ waitTimeout: REQUEST_TIMEOUT });
|
||||
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 recorder.waitForManifest();
|
||||
const decoded = validateRecordedReport(manifests[0]);
|
||||
|
||||
expect(decoded).to.have.property('sta', scenario.expectedSta);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 () {
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "commonjs",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
Reference in New Issue
Block a user