@svta/cml-cmcd 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SfItem, SfToken, decodeSfDict, encodeSfDict, symbolToStr } from "@svta/cml-structured-field-values";
2
- import { urlToRelativePath, uuid } from "@svta/cml-utils";
2
+ import { getBaseUrl, urlToRelativePath, uuid } from "@svta/cml-utils";
3
3
 
4
4
  //#region src/CMCD_FORMATTER_MAP.ts
5
5
  const roundValue = (value) => {
@@ -14,7 +14,7 @@ const toUrlSafe = (value, options) => {
14
14
  if (Array.isArray(value)) return value.map((item) => toUrlSafe(item, options));
15
15
  if (value instanceof SfItem && typeof value.value === "string") return new SfItem(toUrlSafe(value.value, options), value.params);
16
16
  else {
17
- if (options.baseUrl) value = urlToRelativePath(value, options.baseUrl);
17
+ if (options.baseUrl) value = urlToRelativePath(value, getBaseUrl(options.baseUrl));
18
18
  return options.version === 1 ? encodeURIComponent(value) : value;
19
19
  }
20
20
  };
@@ -74,6 +74,12 @@ const CMCD_EVENT_BITRATE_CHANGE = "bc";
74
74
  */
75
75
  const CMCD_EVENT_PLAY_STATE = "ps";
76
76
  /**
77
+ * CMCD event type for the 'pr' key (playback rate change).
78
+ *
79
+ * @public
80
+ */
81
+ const CMCD_EVENT_PLAYBACK_RATE = "pr";
82
+ /**
77
83
  * CMCD event type for the 'e' key (error).
78
84
  *
79
85
  * @public
@@ -175,6 +181,7 @@ const CMCD_EVENT_CUSTOM_EVENT = "ce";
175
181
  const CmcdEventType = {
176
182
  BITRATE_CHANGE: CMCD_EVENT_BITRATE_CHANGE,
177
183
  PLAY_STATE: CMCD_EVENT_PLAY_STATE,
184
+ PLAYBACK_RATE: CMCD_EVENT_PLAYBACK_RATE,
178
185
  ERROR: CMCD_EVENT_ERROR,
179
186
  TIME_INTERVAL: CMCD_EVENT_TIME_INTERVAL,
180
187
  CONTENT_ID: CMCD_EVENT_CONTENT_ID,
@@ -192,6 +199,32 @@ const CmcdEventType = {
192
199
  CUSTOM_EVENT: CMCD_EVENT_CUSTOM_EVENT
193
200
  };
194
201
 
202
+ //#endregion
203
+ //#region src/CMCD_STATE_EVENT_FIELDS.ts
204
+ /**
205
+ * Maps each state-change event type to the persistent field whose value
206
+ * the event signals.
207
+ *
208
+ * Per CTA-5004-B, the state-change events `ps`, `pr`, `c`, `b`, `bc` are
209
+ * state-transition markers and must carry the field whose value they signal.
210
+ * Consumers force-include the field post-filter (`prepareCmcdData`), dedup
211
+ * against its value (`CmcdReporter`), and check its presence in payloads
212
+ * (`validateCmcdStructure`).
213
+ *
214
+ * Iteration order is load-bearing: `CmcdReporter.update()` fires state-change
215
+ * events in map order when multiple tracked fields change in a single call.
216
+ * Do not reorder entries without auditing reporter behavior.
217
+ *
218
+ * @internal
219
+ */
220
+ const CMCD_STATE_EVENT_FIELDS = new Map([
221
+ [CMCD_EVENT_PLAY_STATE, "sta"],
222
+ [CMCD_EVENT_PLAYBACK_RATE, "pr"],
223
+ [CMCD_EVENT_CONTENT_ID, "cid"],
224
+ [CMCD_EVENT_BACKGROUNDED_MODE, "bg"],
225
+ [CMCD_EVENT_BITRATE_CHANGE, "br"]
226
+ ]);
227
+
195
228
  //#endregion
196
229
  //#region src/CmcdReportingMode.ts
197
230
  /**
@@ -550,6 +583,8 @@ function prepareCmcdData(obj, options = {}) {
550
583
  if (!keys.includes("e") && eventType != null) keys.push("e");
551
584
  if (!keys.includes("ts")) keys.push("ts");
552
585
  if (!keys.includes("cen") && data["cen"] != null && eventType === CMCD_EVENT_CUSTOM_EVENT) keys.push("cen");
586
+ const requiredField = eventType ? CMCD_STATE_EVENT_FIELDS.get(eventType) : void 0;
587
+ if (requiredField && data[requiredField] != null && !keys.includes(requiredField)) keys.push(requiredField);
553
588
  }
554
589
  if (keys.length === 0) return results;
555
590
  if (version > 1 && !keys.includes("v")) keys.push("v");
@@ -565,9 +600,10 @@ function prepareCmcdData(obj, options = {}) {
565
600
  if (typeof formatter === "function") value = formatter(value, formatterOptions);
566
601
  if (key === "v") if (version === 1) continue;
567
602
  else value = version;
568
- if (key === "pr" && value === 1) continue;
603
+ if (key === "pr" && value === 1 && !(isEventMode && data["e"] === CMCD_EVENT_PLAYBACK_RATE)) continue;
569
604
  if (isEventMode && key === "ts" && !Number.isFinite(value)) value = Date.now();
570
- if (!isValid(value)) continue;
605
+ const isBgFalseTransition = isEventMode && value === false && key === "bg" && data["e"] === CMCD_EVENT_BACKGROUNDED_MODE;
606
+ if (!isValid(value) && !isBgFalseTransition) continue;
571
607
  if (isTokenField(key) && typeof value === "string") value = new SfToken(value);
572
608
  results[key] = value;
573
609
  }
@@ -979,6 +1015,417 @@ const CmcdPlayerState = {
979
1015
  PRELOADING: "d"
980
1016
  };
981
1017
 
1018
+ //#endregion
1019
+ //#region src/CmcdRecordedReportMode.ts
1020
+ /**
1021
+ * CMCD query reporting mode — CMCD data carried as a `CMCD=` URL
1022
+ * query parameter.
1023
+ *
1024
+ * @public
1025
+ */
1026
+ const CMCD_RECORDED_REPORT_MODE_QUERY = "query";
1027
+ /**
1028
+ * CMCD header reporting mode — CMCD data carried as `Cmcd-*` HTTP
1029
+ * headers.
1030
+ *
1031
+ * @public
1032
+ */
1033
+ const CMCD_RECORDED_REPORT_MODE_HEADER = "header";
1034
+ /**
1035
+ * CMCD event reporting mode — CMCD data carried as a POST body to an
1036
+ * event-target URL.
1037
+ *
1038
+ * @public
1039
+ */
1040
+ const CMCD_RECORDED_REPORT_MODE_EVENT = "event";
1041
+ /**
1042
+ * Reporting mode under which a captured report was observed by
1043
+ * `CmcdReportRecorder`.
1044
+ *
1045
+ * @enum
1046
+ *
1047
+ * @public
1048
+ */
1049
+ const CmcdRecordedReportMode = {
1050
+ QUERY: CMCD_RECORDED_REPORT_MODE_QUERY,
1051
+ HEADER: CMCD_RECORDED_REPORT_MODE_HEADER,
1052
+ EVENT: CMCD_RECORDED_REPORT_MODE_EVENT
1053
+ };
1054
+
1055
+ //#endregion
1056
+ //#region src/CmcdRecordedRequestType.ts
1057
+ /**
1058
+ * Manifest or playlist request classification.
1059
+ *
1060
+ * @public
1061
+ */
1062
+ const CMCD_RECORDED_REQUEST_TYPE_MANIFEST = "manifest";
1063
+ /**
1064
+ * Media segment request classification.
1065
+ *
1066
+ * @public
1067
+ */
1068
+ const CMCD_RECORDED_REQUEST_TYPE_SEGMENT = "segment";
1069
+ /**
1070
+ * CMCD event-mode report request classification (POST to a URL in
1071
+ * `eventTargetUrls`).
1072
+ *
1073
+ * @public
1074
+ */
1075
+ const CMCD_RECORDED_REQUEST_TYPE_EVENT = "event";
1076
+ /**
1077
+ * Fallback classification for requests the recorder did not recognize
1078
+ * as manifest, segment, or event.
1079
+ *
1080
+ * @public
1081
+ */
1082
+ const CMCD_RECORDED_REQUEST_TYPE_UNKNOWN = "unknown";
1083
+ /**
1084
+ * Classification of a captured request, used by `CmcdReportRecorder`
1085
+ * for filtering.
1086
+ *
1087
+ * @enum
1088
+ *
1089
+ * @public
1090
+ */
1091
+ const CmcdRecordedRequestType = {
1092
+ MANIFEST: CMCD_RECORDED_REQUEST_TYPE_MANIFEST,
1093
+ SEGMENT: CMCD_RECORDED_REQUEST_TYPE_SEGMENT,
1094
+ EVENT: CMCD_RECORDED_REQUEST_TYPE_EVENT,
1095
+ UNKNOWN: CMCD_RECORDED_REQUEST_TYPE_UNKNOWN
1096
+ };
1097
+
1098
+ //#endregion
1099
+ //#region src/createFetchTransport.ts
1100
+ async function toHttpRequest(request) {
1101
+ const headers = {};
1102
+ request.headers.forEach((value, name) => {
1103
+ headers[name.toLowerCase()] = value;
1104
+ });
1105
+ let body;
1106
+ if (request.body) try {
1107
+ body = await request.text();
1108
+ } catch {
1109
+ body = void 0;
1110
+ }
1111
+ return {
1112
+ url: request.url,
1113
+ method: request.method,
1114
+ headers,
1115
+ body
1116
+ };
1117
+ }
1118
+ /**
1119
+ * Create a transport adapter that patches `globalThis.fetch` to capture
1120
+ * CMCD-bearing requests, normalizing each to `HttpRequest` (lowercase
1121
+ * headers, body read as UTF-8 string). Returns the adapter object
1122
+ * expected by `CmcdReportRecorder`.
1123
+ *
1124
+ * Bodies are read once via `Request.text()` for inspection. This is
1125
+ * safe for the body types CMCD reports use in practice (`string`,
1126
+ * `Blob`, `ArrayBuffer`, `FormData`, `URLSearchParams`), which can be
1127
+ * read by the wrapper and re-read by the underlying `fetch`. Passing
1128
+ * a `ReadableStream` as `init.body` is not supported — the stream is
1129
+ * consumed by the wrapper and the underlying `fetch` will receive an
1130
+ * already-disturbed stream.
1131
+ *
1132
+ * @public
1133
+ */
1134
+ function createFetchTransport() {
1135
+ return { attach(deliver) {
1136
+ const origFetch = globalThis.fetch;
1137
+ globalThis.fetch = async (input, init) => {
1138
+ const synthetic = deliver(await toHttpRequest(input instanceof Request ? input.clone() : new Request(input, init)));
1139
+ if (synthetic) return synthetic;
1140
+ return origFetch.call(globalThis, input, init);
1141
+ };
1142
+ return () => {
1143
+ globalThis.fetch = origFetch;
1144
+ };
1145
+ } };
1146
+ }
1147
+
1148
+ //#endregion
1149
+ //#region src/createXhrTransport.ts
1150
+ async function completeXhrWith(xhr, response) {
1151
+ const text = await response.clone().text().catch(() => "");
1152
+ const ProgressEventCtor = typeof ProgressEvent !== "undefined" ? ProgressEvent : Event;
1153
+ queueMicrotask(() => {
1154
+ try {
1155
+ Object.defineProperty(xhr, "status", {
1156
+ value: response.status,
1157
+ configurable: true
1158
+ });
1159
+ Object.defineProperty(xhr, "statusText", {
1160
+ value: response.statusText,
1161
+ configurable: true
1162
+ });
1163
+ Object.defineProperty(xhr, "readyState", {
1164
+ value: 4,
1165
+ configurable: true
1166
+ });
1167
+ Object.defineProperty(xhr, "responseURL", {
1168
+ value: xhr._cmcdUrl ?? "",
1169
+ configurable: true
1170
+ });
1171
+ Object.defineProperty(xhr, "response", {
1172
+ value: text,
1173
+ configurable: true
1174
+ });
1175
+ Object.defineProperty(xhr, "responseText", {
1176
+ value: text,
1177
+ configurable: true
1178
+ });
1179
+ if (typeof xhr.onload === "function") xhr.onload.call(xhr, new ProgressEventCtor("load"));
1180
+ if (typeof xhr.onloadend === "function") xhr.onloadend.call(xhr, new ProgressEventCtor("loadend"));
1181
+ } catch {
1182
+ const target = xhr;
1183
+ target["status"] = response.status;
1184
+ target["statusText"] = response.statusText;
1185
+ target["readyState"] = 4;
1186
+ target["responseURL"] = xhr._cmcdUrl ?? "";
1187
+ target["response"] = text;
1188
+ target["responseText"] = text;
1189
+ if (typeof xhr.onload === "function") xhr.onload.call(xhr, new ProgressEventCtor("load"));
1190
+ if (typeof xhr.onloadend === "function") xhr.onloadend.call(xhr, new ProgressEventCtor("loadend"));
1191
+ }
1192
+ });
1193
+ }
1194
+ /**
1195
+ * Create a transport adapter that patches `XMLHttpRequest.prototype` to
1196
+ * capture CMCD-bearing requests. Returns the adapter object expected by
1197
+ * `CmcdReportRecorder`.
1198
+ *
1199
+ * @public
1200
+ */
1201
+ function createXhrTransport() {
1202
+ return { attach(deliver) {
1203
+ const Xhr = globalThis.XMLHttpRequest;
1204
+ if (!Xhr) return () => {};
1205
+ const origOpen = Xhr.prototype.open;
1206
+ const origSetRequestHeader = Xhr.prototype.setRequestHeader;
1207
+ const origSend = Xhr.prototype.send;
1208
+ Xhr.prototype.open = function(method, url) {
1209
+ this._cmcdMethod = method;
1210
+ this._cmcdUrl = typeof url === "string" ? url : url.toString();
1211
+ this._cmcdHeaders = {};
1212
+ return origOpen.apply(this, arguments);
1213
+ };
1214
+ Xhr.prototype.setRequestHeader = function(name, value) {
1215
+ if (this._cmcdHeaders) this._cmcdHeaders[name.toLowerCase()] = value;
1216
+ return origSetRequestHeader.apply(this, [name, value]);
1217
+ };
1218
+ Xhr.prototype.send = function(body) {
1219
+ const synthetic = deliver({
1220
+ url: this._cmcdUrl ?? "",
1221
+ method: (this._cmcdMethod ?? "GET").toUpperCase(),
1222
+ headers: this._cmcdHeaders ?? {},
1223
+ body: (typeof Document !== "undefined" && body instanceof Document ? void 0 : body) ?? void 0
1224
+ });
1225
+ if (synthetic) {
1226
+ completeXhrWith(this, synthetic);
1227
+ return;
1228
+ }
1229
+ return origSend.apply(this, [body]);
1230
+ };
1231
+ return () => {
1232
+ Xhr.prototype.open = origOpen;
1233
+ Xhr.prototype.setRequestHeader = origSetRequestHeader;
1234
+ Xhr.prototype.send = origSend;
1235
+ };
1236
+ } };
1237
+ }
1238
+
1239
+ //#endregion
1240
+ //#region src/CmcdReportRecorder.ts
1241
+ const MANIFEST_EXTENSIONS = /\.(m3u8|mpd)(\?|$|\/)/i;
1242
+ const SEGMENT_EXTENSIONS = /\.(m4s|m4v|m4a|mp4|ts|aac)(\?|$|\/)/i;
1243
+ function classifyUrl(url, isEventTarget) {
1244
+ if (isEventTarget) return CmcdRecordedRequestType.EVENT;
1245
+ if (MANIFEST_EXTENSIONS.test(url)) return CmcdRecordedRequestType.MANIFEST;
1246
+ if (SEGMENT_EXTENSIONS.test(url)) return CmcdRecordedRequestType.SEGMENT;
1247
+ return CmcdRecordedRequestType.UNKNOWN;
1248
+ }
1249
+ function hasCmcdHeader(headers) {
1250
+ if (!headers) return false;
1251
+ for (const name in headers) if (name.toLowerCase().startsWith("cmcd-")) return true;
1252
+ return false;
1253
+ }
1254
+ function detectReportingMode(url, headers, isEventTarget) {
1255
+ if (isEventTarget) return "event";
1256
+ if (hasCmcdHeader(headers)) return "header";
1257
+ return url.includes(`${CMCD_PARAM}=`) ? "query" : null;
1258
+ }
1259
+ /**
1260
+ * Test helper that records CMCD-bearing reports across XHR and fetch
1261
+ * transports for assertion in e2e tests. Each captured request is
1262
+ * normalized to {@link @svta/cml-utils!HttpRequest | HttpRequest} so
1263
+ * tests are identical regardless of which transport the player uses.
1264
+ *
1265
+ * @example
1266
+ * {@includeCode ../test/CmcdReportRecorder.test.ts#example}
1267
+ *
1268
+ * @public
1269
+ */
1270
+ var CmcdReportRecorder = class {
1271
+ #reports = [];
1272
+ #detachers = [];
1273
+ #attached = false;
1274
+ #eventTargetUrls = [];
1275
+ #waiters = /* @__PURE__ */ new Map();
1276
+ #onReport;
1277
+ #waitTimeout = 15e3;
1278
+ #deliver = (request) => {
1279
+ const url = request.url;
1280
+ const isEventTarget = (request.method ?? "GET").toUpperCase() === "POST" && this.#eventTargetUrls.some((t) => url.startsWith(t));
1281
+ const reportingMode = detectReportingMode(url, request.headers, isEventTarget);
1282
+ if (reportingMode === null) return;
1283
+ const captured = {
1284
+ request,
1285
+ type: classifyUrl(url, isEventTarget),
1286
+ reportingMode,
1287
+ timestamp: Date.now()
1288
+ };
1289
+ this.#reports.push(captured);
1290
+ if (this.#onReport) try {
1291
+ this.#onReport(captured);
1292
+ } catch (err) {
1293
+ console.error("CmcdReportRecorder onReport listener threw:", err);
1294
+ }
1295
+ this.#notifyWaiters();
1296
+ return isEventTarget ? new Response(null, { status: 204 }) : void 0;
1297
+ };
1298
+ #getMatching(type) {
1299
+ return type === void 0 ? [...this.#reports] : this.#reports.filter((r) => r.type === type);
1300
+ }
1301
+ #notifyWaiters() {
1302
+ for (const [timer, waiter] of this.#waiters) {
1303
+ const matching = this.#getMatching(waiter.type);
1304
+ if (matching.length >= waiter.count) {
1305
+ clearTimeout(timer);
1306
+ this.#waiters.delete(timer);
1307
+ waiter.resolve(matching);
1308
+ }
1309
+ }
1310
+ }
1311
+ #waitFor(type, options) {
1312
+ const count = options.count ?? 1;
1313
+ const timeout = options.timeout ?? this.#waitTimeout;
1314
+ const matching = this.#getMatching(type);
1315
+ if (matching.length >= count) return Promise.resolve(matching);
1316
+ return new Promise((resolve, reject) => {
1317
+ const timer = setTimeout(() => {
1318
+ this.#waiters.delete(timer);
1319
+ const current = this.#getMatching(type);
1320
+ reject(/* @__PURE__ */ new Error(`Timeout waiting for ${count} ${type ?? "any"} CMCD report(s). Got ${current.length}. Total recorded: ${this.#reports.length}.`));
1321
+ }, timeout);
1322
+ this.#waiters.set(timer, {
1323
+ type,
1324
+ count,
1325
+ resolve,
1326
+ reject
1327
+ });
1328
+ });
1329
+ }
1330
+ /**
1331
+ * Install transport patches and begin recording CMCD reports.
1332
+ * No-op if already attached — the previously supplied options are
1333
+ * retained and the new `options` argument is silently ignored.
1334
+ * Call `detach()` first if you need to re-attach with different
1335
+ * options.
1336
+ *
1337
+ * @public
1338
+ */
1339
+ attach(options = {}) {
1340
+ if (this.#attached) return;
1341
+ this.#attached = true;
1342
+ this.#eventTargetUrls = options.eventTargetUrls ?? [];
1343
+ this.#onReport = options.onReport;
1344
+ this.#waitTimeout = options.waitTimeout ?? 15e3;
1345
+ const transports = options.transports ?? [createXhrTransport(), createFetchTransport()];
1346
+ for (const transport of transports) this.#detachers.push(transport.attach(this.#deliver));
1347
+ }
1348
+ /**
1349
+ * Remove transport patches and stop recording. Rejects any pending
1350
+ * wait promises with `Error('Recorder detached while waiting')`.
1351
+ *
1352
+ * @public
1353
+ */
1354
+ detach() {
1355
+ if (!this.#attached) return;
1356
+ for (const detacher of this.#detachers) detacher();
1357
+ this.#detachers = [];
1358
+ this.#attached = false;
1359
+ this.#eventTargetUrls = [];
1360
+ this.#onReport = void 0;
1361
+ for (const [timer, waiter] of this.#waiters) {
1362
+ clearTimeout(timer);
1363
+ waiter.reject(/* @__PURE__ */ new Error("Recorder detached while waiting"));
1364
+ }
1365
+ this.#waiters.clear();
1366
+ }
1367
+ /**
1368
+ * Discard all recorded reports. Does not affect the attached state.
1369
+ *
1370
+ * @public
1371
+ */
1372
+ clear() {
1373
+ this.#reports = [];
1374
+ }
1375
+ /**
1376
+ * Return a defensive copy of all recorded reports.
1377
+ *
1378
+ * @public
1379
+ */
1380
+ getReports() {
1381
+ return [...this.#reports];
1382
+ }
1383
+ /**
1384
+ * Wait until at least `count` reports of any type are recorded.
1385
+ * Resolves with all matching reports; rejects with a diagnostic error
1386
+ * on timeout. `count` defaults to 1; `timeout` falls back to the
1387
+ * recorder's `waitTimeout` option (default 15000 ms).
1388
+ *
1389
+ * @public
1390
+ */
1391
+ waitForReports(options = {}) {
1392
+ return this.#waitFor(void 0, options);
1393
+ }
1394
+ /**
1395
+ * Wait until at least `count` manifest reports are recorded.
1396
+ * Resolves with all matching reports; rejects with a diagnostic error
1397
+ * on timeout. `count` defaults to 1; `timeout` falls back to the
1398
+ * recorder's `waitTimeout` option (default 15000 ms).
1399
+ *
1400
+ * @public
1401
+ */
1402
+ waitForManifest(options = {}) {
1403
+ return this.#waitFor(CmcdRecordedRequestType.MANIFEST, options);
1404
+ }
1405
+ /**
1406
+ * Wait until at least `count` segment reports are recorded.
1407
+ * Resolves with all matching reports; rejects with a diagnostic error
1408
+ * on timeout. `count` defaults to 1; `timeout` falls back to the
1409
+ * recorder's `waitTimeout` option (default 15000 ms).
1410
+ *
1411
+ * @public
1412
+ */
1413
+ waitForSegments(options = {}) {
1414
+ return this.#waitFor(CmcdRecordedRequestType.SEGMENT, options);
1415
+ }
1416
+ /**
1417
+ * Wait until at least `count` event-mode reports are recorded.
1418
+ * Resolves with all matching reports; rejects with a diagnostic error
1419
+ * on timeout. `count` defaults to 1; `timeout` falls back to the
1420
+ * recorder's `waitTimeout` option (default 15000 ms).
1421
+ *
1422
+ * @public
1423
+ */
1424
+ waitForEvents(options = {}) {
1425
+ return this.#waitFor(CmcdRecordedRequestType.EVENT, options);
1426
+ }
1427
+ };
1428
+
982
1429
  //#endregion
983
1430
  //#region src/CmcdTransmissionMode.ts
984
1431
  /**
@@ -1025,6 +1472,53 @@ function createEncodingOptions(reportingMode, config, baseUrl) {
1025
1472
  baseUrl
1026
1473
  };
1027
1474
  }
1475
+ /**
1476
+ * Deep equality for CmcdObjectTypeList (used for `br` dedup).
1477
+ *
1478
+ * Order-sensitive: arrays with the same elements in different positions
1479
+ * are treated as different. Players that construct `br` consistently
1480
+ * get correct dedup; shuffling produces spurious emits, which is the
1481
+ * safer failure mode.
1482
+ */
1483
+ function cmcdObjectTypeListEqual(a, b) {
1484
+ if (a === b) return true;
1485
+ if (a.length !== b.length) return false;
1486
+ for (let i = 0; i < a.length; i++) {
1487
+ const ai = a[i];
1488
+ const bi = b[i];
1489
+ if (ai === bi) continue;
1490
+ if (typeof ai === "number" || typeof bi === "number") return false;
1491
+ if (ai.value !== bi.value) return false;
1492
+ const ap = ai.params;
1493
+ const bp = bi.params;
1494
+ const ak = ap && Object.keys(ap)[0];
1495
+ const bk = bp && Object.keys(bp)[0];
1496
+ if (ak !== bk) return false;
1497
+ if (ak !== void 0 && bk !== void 0 && ap && bp && ap[ak] !== bp[bk]) return false;
1498
+ }
1499
+ return true;
1500
+ }
1501
+ const equal = Object.is;
1502
+ const identity = (v) => v;
1503
+ /**
1504
+ * Maps each tracked state field to its event type and equality function.
1505
+ * Order matters: `update()` fires events in this order for multi-field updates.
1506
+ */
1507
+ const STATE_FIELDS = Array.from(CMCD_STATE_EVENT_FIELDS, ([event, field]) => {
1508
+ if (field === "br") return {
1509
+ event,
1510
+ field,
1511
+ equal: (a, b) => a === void 0 || b === void 0 ? a === b : cmcdObjectTypeListEqual(a, b),
1512
+ snapshot: (v) => v.slice()
1513
+ };
1514
+ return {
1515
+ event,
1516
+ field,
1517
+ equal,
1518
+ snapshot: identity
1519
+ };
1520
+ });
1521
+ const STATE_FIELDS_BY_EVENT = new Map(STATE_FIELDS.map((e) => [e.event, e]));
1028
1522
  function defaultRequester(request) {
1029
1523
  const { url,...init } = request;
1030
1524
  return fetch(url, init);
@@ -1070,6 +1564,7 @@ var CmcdReporter = class {
1070
1564
  this.data = {};
1071
1565
  this.msd = NaN;
1072
1566
  this.eventTargets = /* @__PURE__ */ new Map();
1567
+ this.lastEmitted = {};
1073
1568
  this.requestTarget = {
1074
1569
  sn: 0,
1075
1570
  msdSent: false
@@ -1098,7 +1593,7 @@ var CmcdReporter = class {
1098
1593
  start() {
1099
1594
  this.eventTargets.forEach((target, config) => {
1100
1595
  this.disarmInterval(target);
1101
- if (config.interval === 0 || !config.events.includes(CmcdEventType.TIME_INTERVAL)) return;
1596
+ if (config.interval === 0 || !config.events.includes(CMCD_EVENT_TIME_INTERVAL)) return;
1102
1597
  const timeIntervalEvent = () => {
1103
1598
  this.recordTargetEvent(target, config, CMCD_EVENT_TIME_INTERVAL);
1104
1599
  this.processEventTargets();
@@ -1127,7 +1622,22 @@ var CmcdReporter = class {
1127
1622
  this.processEventTargets(true);
1128
1623
  }
1129
1624
  /**
1130
- * Updates the CMCD data. Called by the player when the data changes.
1625
+ * Updates the CMCD data.
1626
+ *
1627
+ * Called by the player when data changes. For tracked state fields
1628
+ * (`sta`, `pr`, `cid`, `bg`, `br`), if the new value differs from the
1629
+ * last-reported value (the value most recently emitted on the wire for
1630
+ * that field), the corresponding state-change event is automatically
1631
+ * fired. Comparing against the last-reported value (rather than the
1632
+ * previous persisted value) ensures the first state-change event in a
1633
+ * new session always emits, even when the persisted value didn't change
1634
+ * across the `sid` boundary.
1635
+ *
1636
+ * Multi-field updates fire multiple events in the order: `sta` → `pr` →
1637
+ * `cid` → `bg` → `br`. The order of keys in the input object does not
1638
+ * affect the firing order.
1639
+ *
1640
+ * A `sid` change resets the dedup baseline.
1131
1641
  *
1132
1642
  * @param data - The data to update.
1133
1643
  */
@@ -1139,16 +1649,44 @@ var CmcdReporter = class {
1139
1649
  ...data,
1140
1650
  msd: void 0
1141
1651
  };
1652
+ for (const entry of STATE_FIELDS) if (entry.field in data && !entry.equal(this.data[entry.field], this.lastEmitted[entry.field])) this.recordEvent(entry.event);
1142
1653
  }
1143
1654
  /**
1144
1655
  * Records an event. Called by the player when an event occurs.
1145
1656
  *
1657
+ * For state-change events (`PLAY_STATE`, `PLAYBACK_RATE`, `CONTENT_ID`,
1658
+ * `BACKGROUNDED_MODE`, `BITRATE_CHANGE`), this method:
1659
+ * 1. Persists the dedup field from `data` (if present) into the reporter's
1660
+ * persistent data store — equivalent to a write-through `update()`.
1661
+ * 2. Drops the event entirely if the dedup field has no value after the
1662
+ * write-through (never set, or cleared via `update({ field: undefined })`).
1663
+ * State-change events without their required field would violate CTA-5004-B.
1664
+ * 3. Suppresses the event if the field's current value matches the
1665
+ * last-emitted value (no state transition).
1666
+ *
1667
+ * For all other event types, the event is always emitted.
1668
+ *
1669
+ * Most callers can rely on {@link CmcdReporter.update} to auto-fire state-change events.
1670
+ * Use `recordEvent` directly when you need to attach extra context to the
1671
+ * event report (e.g., `bl`, `pt` at the moment of a play-state transition).
1672
+ *
1146
1673
  * @param type - The type of event to record.
1147
1674
  * @param data - Additional data to record with the event. This data
1148
- * only applies to this event report. Persistent data should
1149
- * be updated using `update()`.
1675
+ * only applies to this event report, except for the dedup
1676
+ * field of a state-change event, which is also persisted
1677
+ * into the reporter's data store.
1150
1678
  */
1151
1679
  recordEvent(type, data = {}) {
1680
+ const entry = STATE_FIELDS_BY_EVENT.get(type);
1681
+ if (entry) {
1682
+ const field = entry.field;
1683
+ const incoming = data[field];
1684
+ if (incoming !== void 0) Object.assign(this.data, { [field]: incoming });
1685
+ const current = this.data[field];
1686
+ if (current === void 0) return;
1687
+ if (entry.equal(current, this.lastEmitted[field])) return;
1688
+ Object.assign(this.lastEmitted, { [field]: entry.snapshot(current) });
1689
+ }
1152
1690
  this.eventTargets.forEach((target, config) => {
1153
1691
  this.recordTargetEvent(target, config, type, data);
1154
1692
  });
@@ -1270,7 +1808,7 @@ var CmcdReporter = class {
1270
1808
  ...data,
1271
1809
  sn: this.requestTarget.sn++
1272
1810
  };
1273
- const options = createEncodingOptions(CMCD_REQUEST_MODE, this.config, url.origin);
1811
+ const options = createEncodingOptions(CMCD_REQUEST_MODE, this.config, report.url);
1274
1812
  if (!isNaN(this.msd) && !this.requestTarget.msdSent) {
1275
1813
  cmcdData.msd = this.msd;
1276
1814
  this.requestTarget.msdSent = true;
@@ -1351,6 +1889,7 @@ var CmcdReporter = class {
1351
1889
  resetSession() {
1352
1890
  this.eventTargets.forEach((target) => target.sn = 0);
1353
1891
  this.requestTarget.sn = 0;
1892
+ this.lastEmitted = {};
1354
1893
  }
1355
1894
  };
1356
1895
 
@@ -1680,9 +2219,9 @@ function validateCmcdStructure(data, options) {
1680
2219
  message: `Response key "${key}" must only be present when e="rr".`,
1681
2220
  severity: CMCD_VALIDATION_SEVERITY_ERROR
1682
2221
  });
1683
- if (data["e"] === CMCD_EVENT_PLAY_STATE && !("sta" in data)) issues.push({
1684
- key: "sta",
1685
- message: "Play state event (e=\"ps\") requires the \"sta\" key to be present.",
2222
+ for (const [eventType, requiredField] of CMCD_STATE_EVENT_FIELDS) if (data["e"] === eventType && !(requiredField in data)) issues.push({
2223
+ key: requiredField,
2224
+ message: `State-change event (e="${eventType}") requires the "${requiredField}" key to be present.`,
1686
2225
  severity: CMCD_VALIDATION_SEVERITY_ERROR
1687
2226
  });
1688
2227
  if (data["e"] === CMCD_EVENT_ERROR && !("ec" in data)) issues.push({
@@ -1860,6 +2399,7 @@ const CMCD_TOKEN_VALUES = {
1860
2399
  e: [
1861
2400
  "bc",
1862
2401
  "ps",
2402
+ "pr",
1863
2403
  "e",
1864
2404
  "t",
1865
2405
  "c",
@@ -2377,5 +2917,5 @@ function extractHeaderRecord(headers) {
2377
2917
  }
2378
2918
 
2379
2919
  //#endregion
2380
- export { CMCD_DEFAULT_TIME_INTERVAL, CMCD_EVENT_AD_BREAK_END, CMCD_EVENT_AD_BREAK_START, CMCD_EVENT_AD_END, CMCD_EVENT_AD_START, CMCD_EVENT_BACKGROUNDED_MODE, CMCD_EVENT_BITRATE_CHANGE, CMCD_EVENT_CONTENT_ID, CMCD_EVENT_CUSTOM_EVENT, CMCD_EVENT_ERROR, CMCD_EVENT_KEYS, CMCD_EVENT_MODE, CMCD_EVENT_MUTE, CMCD_EVENT_PLAYER_COLLAPSE, CMCD_EVENT_PLAYER_EXPAND, CMCD_EVENT_PLAY_STATE, CMCD_EVENT_RESPONSE_RECEIVED, CMCD_EVENT_SKIP, CMCD_EVENT_TIME_INTERVAL, CMCD_EVENT_UNMUTE, CMCD_FORMATTER_MAP, CMCD_HEADERS, CMCD_HEADER_FIELDS, CMCD_HEADER_MAP, CMCD_JSON, CMCD_KEYS, CMCD_MIME_TYPE, CMCD_OBJECT, CMCD_PARAM, CMCD_QUERY, CMCD_REQUEST, CMCD_REQUEST_KEYS, CMCD_REQUEST_MODE, CMCD_RESPONSE_KEYS, CMCD_SESSION, CMCD_STATUS, CMCD_V1, CMCD_V1_KEYS, CMCD_V2, CMCD_VALIDATION_SEVERITY_ERROR, CMCD_VALIDATION_SEVERITY_WARNING, CmcdEventType, CmcdHeaderField, CmcdObjectType, CmcdPlayerState, CmcdReporter, CmcdReportingMode, CmcdStreamType, CmcdStreamingFormat, CmcdTransmissionMode, CmcdValidationSeverity, appendCmcdHeaders, appendCmcdQuery, decodeCmcd, encodeCmcd, fromCmcdHeaders, fromCmcdQuery, fromCmcdUrl, groupCmcdHeaders, isCmcdCustomKey, isCmcdEventKey, isCmcdRequestKey, isCmcdResponseReceivedKey, isCmcdV1Data, isCmcdV1Key, isCmcdV2Data, prepareCmcdData, toCmcdHeaders, toCmcdQuery, toCmcdUrl, toCmcdValue, validateCmcd, validateCmcdEventReport, validateCmcdEvents, validateCmcdHeaders, validateCmcdKeys, validateCmcdRequest, validateCmcdStructure, validateCmcdValues };
2920
+ export { CMCD_DEFAULT_TIME_INTERVAL, CMCD_EVENT_AD_BREAK_END, CMCD_EVENT_AD_BREAK_START, CMCD_EVENT_AD_END, CMCD_EVENT_AD_START, CMCD_EVENT_BACKGROUNDED_MODE, CMCD_EVENT_BITRATE_CHANGE, CMCD_EVENT_CONTENT_ID, CMCD_EVENT_CUSTOM_EVENT, CMCD_EVENT_ERROR, CMCD_EVENT_KEYS, CMCD_EVENT_MODE, CMCD_EVENT_MUTE, CMCD_EVENT_PLAYBACK_RATE, CMCD_EVENT_PLAYER_COLLAPSE, CMCD_EVENT_PLAYER_EXPAND, CMCD_EVENT_PLAY_STATE, CMCD_EVENT_RESPONSE_RECEIVED, CMCD_EVENT_SKIP, CMCD_EVENT_TIME_INTERVAL, CMCD_EVENT_UNMUTE, CMCD_FORMATTER_MAP, CMCD_HEADERS, CMCD_HEADER_FIELDS, CMCD_HEADER_MAP, CMCD_JSON, CMCD_KEYS, CMCD_MIME_TYPE, CMCD_OBJECT, CMCD_PARAM, CMCD_QUERY, CMCD_RECORDED_REPORT_MODE_EVENT, CMCD_RECORDED_REPORT_MODE_HEADER, CMCD_RECORDED_REPORT_MODE_QUERY, CMCD_RECORDED_REQUEST_TYPE_EVENT, CMCD_RECORDED_REQUEST_TYPE_MANIFEST, CMCD_RECORDED_REQUEST_TYPE_SEGMENT, CMCD_RECORDED_REQUEST_TYPE_UNKNOWN, CMCD_REQUEST, CMCD_REQUEST_KEYS, CMCD_REQUEST_MODE, CMCD_RESPONSE_KEYS, CMCD_SESSION, CMCD_STATUS, CMCD_V1, CMCD_V1_KEYS, CMCD_V2, CMCD_VALIDATION_SEVERITY_ERROR, CMCD_VALIDATION_SEVERITY_WARNING, CmcdEventType, CmcdHeaderField, CmcdObjectType, CmcdPlayerState, CmcdRecordedReportMode, CmcdRecordedRequestType, CmcdReportRecorder, CmcdReporter, CmcdReportingMode, CmcdStreamType, CmcdStreamingFormat, CmcdTransmissionMode, CmcdValidationSeverity, appendCmcdHeaders, appendCmcdQuery, createFetchTransport, createXhrTransport, decodeCmcd, encodeCmcd, fromCmcdHeaders, fromCmcdQuery, fromCmcdUrl, groupCmcdHeaders, isCmcdCustomKey, isCmcdEventKey, isCmcdRequestKey, isCmcdResponseReceivedKey, isCmcdV1Data, isCmcdV1Key, isCmcdV2Data, prepareCmcdData, toCmcdHeaders, toCmcdQuery, toCmcdUrl, toCmcdValue, validateCmcd, validateCmcdEventReport, validateCmcdEvents, validateCmcdHeaders, validateCmcdKeys, validateCmcdRequest, validateCmcdStructure, validateCmcdValues };
2381
2921
  //# sourceMappingURL=index.js.map