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/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index dbcfa740111..7a24dd66aaa 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -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 & { + 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) diff --git a/docs/API.md b/docs/API.md index fb7d403bfd4..520b97fc36c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` diff --git a/karma.conf.js b/karma.conf.js index 7fa57d38a82..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({ diff --git a/package-lock.json b/package-lock.json index 4cf281320c0..d9c3377b564 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,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", @@ -4043,11 +4045,53 @@ "dev": true, "license": "CC0-1.0" }, - "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==", + "node_modules/@svta/cml-cmcd": { + "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": { + "node": ">=20" + }, + "peerDependencies": { + "@svta/cml-structured-field-values": "1.1.3", + "@svta/cml-utils": "1.5.0" + } + }, + "node_modules/@svta/cml-id3": { + "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.5.0" + } + }, + "node_modules/@svta/cml-structured-field-values": { + "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, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@svta/cml-utils": "1.5.0" + } + }, + "node_modules/@svta/cml-utils": { + "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": { "node": ">=20" } @@ -4160,44 +4204,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", @@ -4630,198 +4642,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", @@ -4847,17 +4667,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", @@ -4946,17 +4755,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.4", "resolved": "https://registry.npmjs.org/anchor-markdown-header/-/anchor-markdown-header-0.8.4.tgz", @@ -5713,17 +5511,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", @@ -6636,14 +6423,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", @@ -7095,21 +6874,6 @@ "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.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", @@ -7337,17 +7101,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", @@ -7369,17 +7122,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", @@ -7992,14 +7734,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", @@ -9232,50 +8966,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", @@ -9319,14 +9009,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", @@ -9694,17 +9376,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", @@ -10149,14 +9820,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", @@ -13139,62 +12802,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", @@ -13859,117 +13466,22 @@ "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" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "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": { @@ -17050,10 +16562,32 @@ "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", "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==", + "@svta/cml-cmcd": { + "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": {} + }, + "@svta/cml-id3": { + "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.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.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": { @@ -17155,44 +16689,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", @@ -17493,198 +16995,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", @@ -17701,15 +17011,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", @@ -17773,15 +17074,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.4", "resolved": "https://registry.npmjs.org/anchor-markdown-header/-/anchor-markdown-header-0.8.4.tgz", @@ -18327,14 +17619,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", @@ -19027,14 +18311,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", @@ -19447,18 +18723,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", @@ -19533,14 +18797,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", @@ -19559,14 +18815,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", @@ -20006,14 +19254,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", @@ -20851,40 +20091,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", @@ -20920,14 +20126,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", @@ -21211,14 +20409,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", @@ -21538,14 +20728,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", @@ -23740,36 +22922,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", @@ -24224,81 +23376,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 6b90b3f9e59..8e695a07ef0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config.ts b/src/config.ts index eb49e01ace6..505fe5aab35 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 & { + 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 f0d48b26f79..5256f4c2bac 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -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); }; + 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 }); + } + } + /** - * Create baseline CMCD data + * Get the stream type based on level details. */ - 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 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. + * 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 options: CmcdEncodeOptions = { baseUrl: context.url }; - - 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; } /** @@ -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; diff --git a/src/controller/id3-track-controller.ts b/src/controller/id3-track-controller.ts index e48597fcd9d..f9c6e16cf3f 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, 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 5d7fa2dc9a9..cf108959aed 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'; diff --git a/src/demux/audio/base-audio-demuxer.ts b/src/demux/audio/base-audio-demuxer.ts index 4a980e11023..fee943e3a01 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, diff --git a/src/demux/audio/mp3demuxer.ts b/src/demux/audio/mp3demuxer.ts index 8cd5005872d..a64bf48adc2 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'; diff --git a/src/hls.ts b/src/hls.ts index 4d1ba28e765..303a794b123 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'; @@ -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, diff --git a/src/utils/imsc1-ttml-parser.ts b/src/utils/imsc1-ttml-parser.ts index 2a6dc5b6d9c..c4a16c7c71f 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'; diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 5b964795f90..b871c47b20b 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 54648e3884f..f0534e0b07e 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/e2e/cmcd.ts b/tests/e2e/cmcd.ts new file mode 100644 index 00000000000..6809afd73a3 --- /dev/null +++ b/tests/e2e/cmcd.ts @@ -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 { + 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 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; +} + +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; + + 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; + + 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 = { + '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); + }); + }); + }); +}); diff --git a/tests/index.js b/tests/index.js index 7a80f240239..a2e9e64e85d 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/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index 21a5b43c3e2..114a7cbf5e3 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -1,6 +1,7 @@ -import { CmcdHeaderField } from '@svta/common-media-library/cmcd/CmcdHeaderField'; +import { CmcdEventType, CmcdHeaderField, CmcdObjectType } from '@svta/cml-cmcd'; import { expect } from 'chai'; 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'; @@ -42,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, @@ -55,6 +69,8 @@ const setupEach = (cmcd?: CMCDControllerConfig) => { const level = { bitrate: 1000, details, + audioCodec: 'mp4a.40.2', + videoCodec: 'avc1.640028', }; const hls = new Hls({ cmcd }) as any; @@ -66,13 +82,36 @@ const setupEach = (cmcd?: CMCDControllerConfig) => { levels: [level], level: 0, }; + 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, @@ -174,6 +213,950 @@ 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 () { + it('defaults to version 1', function () { + setupEach({}); + + const { url } = applyPlaylistData(); + // v=1 is the default and is omitted per CMCD spec + expect(url).to.not.include('v%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'); + }); + + it('uses version 2 when configured', function () { + setupEach({ version: 2 }); + + const { url } = applyPlaylistData(); + expectField(url, `v%3D2`); + }); + + // 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`); + }); + + 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 () { + 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)`); + }); + + 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%3Dd`); + // Standard fragment fields still present (inner list format in v2) + expectField(url, `br%3D%281%29`); + expectField(url, `ot%3Dav`); + }); + + it('includes v2 fields in headers mode', function () { + const details = setupEach({ version: 2, useHeaders: true }); + attachMedia(false); + 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=d'); + }); + }); + + describe('v2 event reporting', function () { + it('creates reporter without eventTargets (no event reporting)', function () { + setupEach({ version: 2 }); + expect((cmcdController as any).reporter).to.not.equal(undefined); + }); + + it('creates reporter for v1 (without event targets)', function () { + setupEach({ + version: 1, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + expect((cmcdController as any).reporter).to.not.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('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, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + const reporter = (cmcdController as any).reporter; + const stopCalls: any[][] = []; + const origStop = reporter.stop.bind(reporter); + reporter.stop = (...args: any[]) => { + stopCalls.push(args); + return origStop(...args); + }; + + cmcdController.destroy(); + + expect(stopCalls).to.have.lengthOf(1); + expect(stopCalls[0][0]).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' }], + }); + + // Simulate playing event via the arrow function + (cmcdController as any).onPlaying(); + + // 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(); + }); + + it('records error events on fatal errors', function () { + setupEach({ + version: 2, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + + // Trigger fatal error via hls event + (cmcdController as any).hls.trigger(Events.ERROR, { + type: 'networkError', + details: 'fragLoadError', + fatal: true, + error: new Error('test'), + }); + + // Player state should transition to FATAL_ERROR + expect((cmcdController as any).playerState).to.equal('f'); + + 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).initialized = true; + (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).initialized = true; + (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('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, + eventTargets: [{ url: 'https://analytics.example.com/cmcd' }], + }); + const reporter = (cmcdController as any).reporter; + 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(); + + // Only one recordEvent call for the state change (deduplicated) + expect(recordCalls).to.have.lengthOf(1); + + cmcdController.destroy(); + }); + }); + + 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('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). + 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, + prop: 'mainForwardBufferInfo' | 'audioForwardBufferInfo', + info: { len: number } | null, + ) => { + Object.defineProperty(hls, prop, { + configurable: true, + get: () => info, + }); + }; + + 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 = { + 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 }); + + const result = (cmcdController as any).getBufferLength( + fragWithType(PlaylistLevelType.AUDIO), + ); + expect(result).to.equal(12500); + }); + + it('returns NaN for type=audio when audioForwardBufferInfo is null', function () { + setupEach({}); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 }); + + const result = (cmcdController as any).getBufferLength( + fragWithType(PlaylistLevelType.AUDIO), + ); + expect(Number.isNaN(result)).to.equal(true); + }); + + it('uses hls.mainForwardBufferInfo for fragments with type=main', function () { + setupEach({}); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 }); + + expect( + (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 = { + removeEventListener: () => {}, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 }); + + expect( + (cmcdController as any).getBufferLength( + fragWithType(PlaylistLevelType.MAIN), + ), + ).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( + fragWithType(PlaylistLevelType.AUDIO), + ), + ), + ).to.equal(true); + expect( + Number.isNaN( + (cmcdController as any).getBufferLength( + fragWithType(PlaylistLevelType.MAIN), + ), + ), + ).to.equal(true); + }); + }); + + 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[]; + // 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(playingEvents.length).to.be.greaterThan(0); + expect(playingEvents[0].bl).to.deep.equal([10000]); + + cmcdController.destroy(); + }); + }); + + 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); + }); }); }); }); 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 () { 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,