@newrelic/video-videojs 4.1.1 → 4.2.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.
@@ -0,0 +1,814 @@
1
+ /**
2
+ * MediaTailor Utility Functions
3
+ * Helper functions for AWS MediaTailor ad tracking
4
+ */
5
+
6
+ import nrvideo from '@newrelic/video-core';
7
+
8
+ const nrvideoCore = nrvideo.default || nrvideo;
9
+ const Log = nrvideoCore.Log;
10
+
11
+ import {
12
+ DASH_MANIFEST_EXTENSION,
13
+ DASH_SCTE35_EVENT_STREAM_SELECTOR,
14
+ HLS_MANIFEST_EXTENSION,
15
+ HLS_SEGMENT_DURATION_TAG,
16
+ HLS_TAG_PREFIX,
17
+ REGEX_CUE_OUT,
18
+ REGEX_DASH_MINIMUM_UPDATE_PERIOD,
19
+ REGEX_DISCONTINUITY,
20
+ REGEX_HLS_TARGET_DURATION,
21
+ REGEX_ISO_8601_DURATION,
22
+ REGEX_MAP,
23
+ REGEX_MANIFEST_FILE_SUFFIX,
24
+ REGEX_SESSION_ID,
25
+ REGEX_TRACKING_PATH_SEGMENT,
26
+ MT_HLS_CUE_IN_TAG,
27
+ MT_HLS_CUE_OUT_TAG,
28
+ MT_SEGMENT_PATTERN,
29
+ MT_DEFAULT_AD_SEGMENT_PATH,
30
+ MIN_AD_DURATION,
31
+ AD_TIMING_TOLERANCE,
32
+ SCTE35_SCHEME_MARKER,
33
+ STREAM_TYPE,
34
+ MANIFEST_TYPE,
35
+ AD_POSITION,
36
+ AD_SOURCE,
37
+ QUARTILES,
38
+ } from './mt-constants.js';
39
+
40
+ /**
41
+ * Generates timestamp string for logging (HH:MM:SS.mmm format)
42
+ */
43
+ export function getTimestamp() {
44
+ const now = new Date();
45
+ return now.toISOString().substring(11, 23);
46
+ }
47
+
48
+ /**
49
+ * Detects manifest format from URL (.m3u8 = HLS, .mpd = DASH)
50
+ */
51
+ export function detectManifestFormatFromUrl(url) {
52
+ if (url.includes(HLS_MANIFEST_EXTENSION)) {
53
+ return MANIFEST_TYPE.HLS;
54
+ } else if (url.includes(DASH_MANIFEST_EXTENSION)) {
55
+ return MANIFEST_TYPE.DASH;
56
+ }
57
+ return MANIFEST_TYPE.HLS; // Default fallback
58
+ }
59
+
60
+ /**
61
+ * Detects stream type from player duration (Infinity = Live, else = VOD)
62
+ */
63
+ export function detectPlaybackStreamType(duration) {
64
+ return duration === Infinity ? STREAM_TYPE.LIVE : STREAM_TYPE.VOD;
65
+ }
66
+
67
+ /**
68
+ * Builds a tracking endpoint URL from a sessionized manifest URL
69
+ * Format: /v1/tracking/{customerId}/{configName}/{sessionId}
70
+ */
71
+ export function buildTrackingEndpointUrl(manifestUrl) {
72
+ const match = manifestUrl.match(REGEX_SESSION_ID);
73
+
74
+ if (!match) {
75
+ return null;
76
+ }
77
+
78
+ const sessionId = match[1];
79
+
80
+ // Convert manifest URL to tracking URL:
81
+ // /v1/master/{id}/{name}/master.m3u8?aws.sessionId=xxx → /v1/tracking/{id}/{name}/{sessionId}
82
+ // /v1/dash/{id}/{name}/index.mpd?aws.sessionId=xxx → /v1/tracking/{id}/{name}/{sessionId}
83
+ const trackingEndpointUrl = manifestUrl
84
+ .replace(REGEX_TRACKING_PATH_SEGMENT, '/v1/tracking/')
85
+ .replace(REGEX_MANIFEST_FILE_SUFFIX, `/${sessionId}`);
86
+
87
+ return trackingEndpointUrl;
88
+ }
89
+
90
+ /**
91
+ * Checks if segment is a MediaTailor ad segment
92
+ */
93
+ export function isMediaTailorSegment(segment, { adSegmentPrefix } = {}) {
94
+ const candidates = [MT_SEGMENT_PATTERN, MT_DEFAULT_AD_SEGMENT_PATH];
95
+ if (adSegmentPrefix) candidates.push(adSegmentPrefix);
96
+
97
+ const mapUri = (segment.map && segment.map.uri) || '';
98
+ const segUri = segment.uri || '';
99
+
100
+ return candidates.some(
101
+ (marker) => mapUri.includes(marker) || segUri.includes(marker),
102
+ );
103
+ }
104
+
105
+ export function whichAdSegmentMarker(segment, { adSegmentPrefix } = {}) {
106
+ const labeled = [
107
+ { marker: MT_SEGMENT_PATTERN, label: 'aws-hostname (segments.mediatailor)' },
108
+ { marker: MT_DEFAULT_AD_SEGMENT_PATH, label: 'default-cdn-path (/tm/)' },
109
+ ];
110
+ if (adSegmentPrefix) labeled.push({ marker: adSegmentPrefix, label: `custom-prefix (${adSegmentPrefix})` });
111
+
112
+ const mapUri = (segment.map && segment.map.uri) || '';
113
+ const segUri = segment.uri || '';
114
+
115
+ const hit = labeled.find(({ marker }) => mapUri.includes(marker) || segUri.includes(marker));
116
+ return hit ? hit.label : null;
117
+ }
118
+
119
+ /**
120
+ * Determines ad position based on schedule index (VOD only, Live returns null)
121
+ */
122
+ export function determineAdPosition(adBreakIndex, totalAdBreaks, streamType) {
123
+ if (streamType === STREAM_TYPE.LIVE) {
124
+ return null; // Live streams have no position concept
125
+ }
126
+
127
+ // VOD: Determine position by schedule index
128
+ if (adBreakIndex === 0) {
129
+ return AD_POSITION.PRE_ROLL; // First ad
130
+ } else if (adBreakIndex === totalAdBreaks - 1) {
131
+ return AD_POSITION.POST_ROLL; // Last ad
132
+ } else {
133
+ return AD_POSITION.MID_ROLL; // Middle ad
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Finds ad break index in schedule by start time
139
+ */
140
+ export function findAdBreakIndex(adSchedule, startTime) {
141
+ return adSchedule.findIndex(
142
+ (ad) => Math.abs(ad.startTime - startTime) < AD_TIMING_TOLERANCE
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Calculates quartile thresholds for an ad duration
148
+ */
149
+ export function calculateQuartiles(duration) {
150
+ return {
151
+ q1: duration * QUARTILES.Q1,
152
+ q2: duration * QUARTILES.Q2,
153
+ q3: duration * QUARTILES.Q3,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Checks which quartile events should fire based on progress
159
+ */
160
+ export function getQuartilesToFire(progress, duration, firedQuartiles) {
161
+ const quartiles = calculateQuartiles(duration);
162
+ const toFire = [];
163
+
164
+ if (progress >= quartiles.q1 && !firedQuartiles.q1) {
165
+ toFire.push({ quartile: 1, key: 'q1' });
166
+ }
167
+ if (progress >= quartiles.q2 && !firedQuartiles.q2) {
168
+ toFire.push({ quartile: 2, key: 'q2' });
169
+ }
170
+ if (progress >= quartiles.q3 && !firedQuartiles.q3) {
171
+ toFire.push({ quartile: 3, key: 'q3' });
172
+ }
173
+
174
+ return toFire;
175
+ }
176
+
177
+ /**
178
+ * Finds active ad break at current playhead time
179
+ */
180
+ export function findActiveAdBreak(adSchedule, currentTime) {
181
+ return adSchedule.find(
182
+ (ad) => currentTime >= ad.startTime && currentTime < ad.endTime
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Finds active pod within ad break at current playhead time
188
+ */
189
+ export function findActivePod(adBreak, currentTime) {
190
+ if (!adBreak || !adBreak.pods || adBreak.pods.length === 0) {
191
+ return null;
192
+ }
193
+
194
+ return adBreak.pods.find(
195
+ (pod) => currentTime >= pod.startTime && currentTime < pod.endTime
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Validates if ad break duration meets minimum threshold
201
+ */
202
+ export function isValidAdBreak(adBreak) {
203
+ return adBreak.duration > MIN_AD_DURATION;
204
+ }
205
+
206
+ /**
207
+ * Merges new ads into existing schedule (deduplicates by start time)
208
+ */
209
+ export function mergeAdSchedules(existingSchedule, newAds) {
210
+ const scheduleMap = new Map();
211
+
212
+ // Add existing ads to map (keyed by rounded start time)
213
+ existingSchedule.forEach((ad) => {
214
+ const key = Math.round(ad.startTime);
215
+ scheduleMap.set(key, ad);
216
+ });
217
+
218
+ // Merge new ads
219
+ const merged = [];
220
+ newAds.forEach((newAd) => {
221
+ const key = Math.round(newAd.startTime);
222
+ const existingAd = scheduleMap.get(key);
223
+
224
+ if (!existingAd) {
225
+ merged.push(newAd);
226
+ scheduleMap.set(key, newAd);
227
+ } else if (!existingAd.confirmedByTracking && newAd.confirmedByTracking) {
228
+ Object.assign(existingAd, newAd);
229
+ }
230
+ });
231
+
232
+ // Combine and sort by start time
233
+ const finalSchedule = [...existingSchedule, ...merged];
234
+ finalSchedule.sort((a, b) => a.startTime - b.startTime);
235
+
236
+ return finalSchedule;
237
+ }
238
+
239
+ /**
240
+ * Parses HLS manifest text for CUE-OUT/CUE-IN markers
241
+ */
242
+ export function parseHlsManifestForAdBreaks(manifestText) {
243
+ const lines = manifestText.split('\n');
244
+ const adBreaks = [];
245
+
246
+ let currentTime = 0;
247
+ let currentAdBreak = null;
248
+ let currentPodStartTime = null;
249
+ let lastMapUrl = null;
250
+ let adPods = [];
251
+ let isInAdBreak = false;
252
+
253
+ for (const line of lines) {
254
+ // Detect DISCONTINUITY marker
255
+ if (REGEX_DISCONTINUITY.test(line)) {
256
+ if (isInAdBreak && lastMapUrl) {
257
+ const mapMatch = line.match(REGEX_MAP);
258
+ const mapUrl = mapMatch ? mapMatch[1] : null;
259
+
260
+ if (mapUrl && mapUrl !== lastMapUrl) {
261
+ // New MAP = new pod boundary
262
+ if (currentPodStartTime !== null) {
263
+ const podDuration = currentTime - currentPodStartTime;
264
+ adPods.push({
265
+ startTime: currentPodStartTime,
266
+ duration: podDuration,
267
+ endTime: currentTime,
268
+ mapUrl: lastMapUrl,
269
+ });
270
+ }
271
+ currentPodStartTime = currentTime;
272
+ lastMapUrl = mapUrl;
273
+ }
274
+ }
275
+ }
276
+
277
+ // Detect MAP URL changes
278
+ const mapMatch = line.match(REGEX_MAP);
279
+ if (mapMatch && isInAdBreak) {
280
+ const mapUrl = mapMatch[1];
281
+ if (mapUrl && mapUrl !== lastMapUrl) {
282
+ // New MAP = new pod
283
+ if (currentPodStartTime !== null) {
284
+ const podDuration = currentTime - currentPodStartTime;
285
+ adPods.push({
286
+ startTime: currentPodStartTime,
287
+ duration: podDuration,
288
+ endTime: currentTime,
289
+ mapUrl: lastMapUrl,
290
+ });
291
+ }
292
+ currentPodStartTime = currentTime;
293
+ lastMapUrl = mapUrl;
294
+ }
295
+ }
296
+
297
+ // Detect CUE-OUT (ad break start)
298
+ if (line.startsWith(MT_HLS_CUE_OUT_TAG)) {
299
+ const durationMatch = line.match(REGEX_CUE_OUT);
300
+ const duration = durationMatch ? parseFloat(durationMatch[1]) : null;
301
+
302
+ isInAdBreak = true;
303
+ adPods = [];
304
+ currentAdBreak = {
305
+ id: `avail-${currentTime}`,
306
+ startTime: currentTime,
307
+ duration: duration,
308
+ endTime: null,
309
+ pods: [],
310
+ hasFiredStart: false,
311
+ hasFiredEnd: false,
312
+ hasFiredAdStart: false,
313
+ confirmedByTracking: false,
314
+ };
315
+ }
316
+
317
+ // Detect CUE-IN (ad break end)
318
+ if (line.startsWith(MT_HLS_CUE_IN_TAG)) {
319
+ if (currentAdBreak) {
320
+ // Close final pod
321
+ if (currentPodStartTime !== null) {
322
+ const podDuration = currentTime - currentPodStartTime;
323
+ adPods.push({
324
+ startTime: currentPodStartTime,
325
+ duration: podDuration,
326
+ endTime: currentTime,
327
+ mapUrl: lastMapUrl,
328
+ });
329
+ }
330
+
331
+ // Calculate actual duration
332
+ const actualDuration = currentTime - currentAdBreak.startTime;
333
+
334
+ // Filter zero-duration false positives
335
+ if (actualDuration >= MIN_AD_DURATION) {
336
+ currentAdBreak.duration = actualDuration;
337
+ currentAdBreak.endTime = currentTime;
338
+ currentAdBreak.pods = adPods;
339
+ adBreaks.push(currentAdBreak);
340
+ }
341
+
342
+ // Reset state
343
+ currentAdBreak = null;
344
+ isInAdBreak = false;
345
+ currentPodStartTime = null;
346
+ lastMapUrl = null;
347
+ adPods = [];
348
+ }
349
+ }
350
+
351
+ // Track time via EXTINF
352
+ if (line.startsWith(HLS_SEGMENT_DURATION_TAG)) {
353
+ const duration = parseFloat(line.split(':')[1]);
354
+ if (!isNaN(duration)) {
355
+ currentTime += duration;
356
+ }
357
+ }
358
+ }
359
+
360
+ // Handle unclosed ad break
361
+ if (currentAdBreak && currentAdBreak.duration) {
362
+ currentAdBreak.endTime = currentAdBreak.startTime + currentAdBreak.duration;
363
+ currentAdBreak.pods = adPods;
364
+ adBreaks.push(currentAdBreak);
365
+ }
366
+
367
+ return adBreaks;
368
+ }
369
+
370
+ /**
371
+ * Detects ads from VHS playlist using discontinuityStarts and MediaTailor segments
372
+ */
373
+ export function detectAdBreaksFromVhsPlaylist(playlist, { adSegmentPrefix } = {}) {
374
+ const segments = playlist.segments;
375
+ const discontinuityStarts = playlist.discontinuityStarts || [];
376
+ const adBreaks = [];
377
+
378
+ let currentAdBreak = null;
379
+ let currentPod = null;
380
+ let currentTime = 0;
381
+
382
+ segments.forEach((segment, index) => {
383
+ const isMTSegment = isMediaTailorSegment(segment, { adSegmentPrefix });
384
+ const hasDiscontinuity = discontinuityStarts.includes(index);
385
+
386
+ if (isMTSegment) {
387
+ // Discontinuity marks new pod boundary
388
+ if (hasDiscontinuity && currentPod) {
389
+ currentPod.endTime = currentTime;
390
+ if (currentPod.duration > MIN_AD_DURATION) {
391
+ currentAdBreak.pods.push(currentPod);
392
+ }
393
+ currentPod = null;
394
+ }
395
+
396
+ // Start new ad break if not in one
397
+ if (!currentAdBreak) {
398
+ currentAdBreak = {
399
+ id: `avail-${currentTime}`,
400
+ startTime: currentTime,
401
+ duration: 0,
402
+ endTime: null,
403
+ source: 'vhs-discontinuity',
404
+ confirmedByTracking: false,
405
+ hasFiredStart: false,
406
+ hasFiredEnd: false,
407
+ hasFiredAdStart: false,
408
+ hasFiredQ1: false,
409
+ hasFiredQ2: false,
410
+ hasFiredQ3: false,
411
+ pods: [],
412
+ };
413
+ }
414
+
415
+ // Start new pod if not in one
416
+ if (!currentPod) {
417
+ currentPod = {
418
+ startTime: currentTime,
419
+ duration: 0,
420
+ endTime: null,
421
+ hasFiredStart: false,
422
+ hasFiredQ1: false,
423
+ hasFiredQ2: false,
424
+ hasFiredQ3: false,
425
+ };
426
+ }
427
+
428
+ // Accumulate durations
429
+ const segmentDuration = segment.duration || 0;
430
+ currentAdBreak.duration += segmentDuration;
431
+ currentPod.duration += segmentDuration;
432
+ } else {
433
+ // Not MediaTailor segment - end ad break
434
+ if (currentAdBreak) {
435
+ // Close current pod
436
+ if (currentPod) {
437
+ currentPod.endTime = currentTime;
438
+ if (currentPod.duration > MIN_AD_DURATION) {
439
+ currentAdBreak.pods.push(currentPod);
440
+ }
441
+ currentPod = null;
442
+ }
443
+
444
+ // Close ad break
445
+ currentAdBreak.endTime = currentTime;
446
+ if (currentAdBreak.duration > MIN_AD_DURATION) {
447
+ adBreaks.push(currentAdBreak);
448
+ }
449
+ currentAdBreak = null;
450
+ }
451
+ }
452
+
453
+ currentTime += segment.duration || 0;
454
+ });
455
+
456
+ // Handle unclosed pod/ad break at end
457
+ if (currentAdBreak) {
458
+ if (currentPod) {
459
+ currentPod.endTime = currentTime;
460
+ if (currentPod.duration > MIN_AD_DURATION) {
461
+ currentAdBreak.pods.push(currentPod);
462
+ }
463
+ }
464
+ currentAdBreak.endTime = currentTime;
465
+ if (currentAdBreak.duration > MIN_AD_DURATION) {
466
+ adBreaks.push(currentAdBreak);
467
+ }
468
+ }
469
+
470
+ return adBreaks;
471
+ }
472
+
473
+ /**
474
+ * Enriches ad schedule with tracking API metadata
475
+ */
476
+ export function enrichAdScheduleWithTrackingMetadata(adSchedule, trackingAvails) {
477
+ const scheduleMap = new Map();
478
+
479
+ // Build map of existing ads
480
+ adSchedule.forEach((ad) => {
481
+ const key = Math.round(ad.startTime);
482
+ scheduleMap.set(key, ad);
483
+ });
484
+
485
+ // Process each avail from tracking API
486
+ const newAds = [];
487
+ trackingAvails.forEach((avail) => {
488
+ const firstAd = avail.ads && avail.ads.length > 0 ? avail.ads[0] : null;
489
+ if (!firstAd) return;
490
+
491
+ const key = Math.round(firstAd.startTimeInSeconds);
492
+ const existingAd = scheduleMap.get(key);
493
+
494
+ if (existingAd) {
495
+ // Enrich existing ad with tracking metadata
496
+ existingAd.id = avail.availId;
497
+ existingAd.creativeId = firstAd.adId;
498
+ existingAd.title = firstAd.adTitle;
499
+ existingAd.confirmedByTracking = true;
500
+
501
+ // Enrich pods with tracking ad metadata
502
+ if (avail.ads && avail.ads.length > 0 && existingAd.pods) {
503
+ avail.ads.forEach((trackingAd, adIndex) => {
504
+ if (existingAd.pods[adIndex]) {
505
+ // Update existing pod
506
+ existingAd.pods[adIndex].title = trackingAd.adTitle;
507
+ existingAd.pods[adIndex].creativeId = trackingAd.adId;
508
+ existingAd.pods[adIndex].trackingStartTime = trackingAd.startTimeInSeconds;
509
+ existingAd.pods[adIndex].trackingDuration = trackingAd.durationInSeconds;
510
+ } else {
511
+ // Add new pod from tracking
512
+ existingAd.pods.push({
513
+ startTime: trackingAd.startTimeInSeconds,
514
+ duration: trackingAd.durationInSeconds,
515
+ endTime: trackingAd.startTimeInSeconds + trackingAd.durationInSeconds,
516
+ title: trackingAd.adTitle,
517
+ creativeId: trackingAd.adId,
518
+ hasFiredStart: false,
519
+ hasFiredQ1: false,
520
+ hasFiredQ2: false,
521
+ hasFiredQ3: false,
522
+ });
523
+ }
524
+ });
525
+ }
526
+ } else {
527
+ // Add new ad from tracking
528
+ newAds.push({
529
+ id: avail.availId,
530
+ startTime: firstAd.startTimeInSeconds,
531
+ duration: avail.durationInSeconds,
532
+ endTime: firstAd.startTimeInSeconds + avail.durationInSeconds,
533
+ title: firstAd.adTitle,
534
+ creativeId: firstAd.adId,
535
+ source: 'tracking-api',
536
+ confirmedByTracking: true,
537
+ hasFiredStart: false,
538
+ hasFiredEnd: false,
539
+ hasFiredAdStart: false,
540
+ hasFiredQ1: false,
541
+ hasFiredQ2: false,
542
+ hasFiredQ3: false,
543
+ pods: avail.ads.map((ad) => ({
544
+ startTime: ad.startTimeInSeconds,
545
+ duration: ad.durationInSeconds,
546
+ endTime: ad.startTimeInSeconds + ad.durationInSeconds,
547
+ title: ad.adTitle,
548
+ creativeId: ad.adId,
549
+ hasFiredStart: false,
550
+ hasFiredQ1: false,
551
+ hasFiredQ2: false,
552
+ hasFiredQ3: false,
553
+ })),
554
+ });
555
+ }
556
+ });
557
+
558
+ return newAds;
559
+ }
560
+
561
+ /**
562
+ * Extracts the HLS live target duration from manifest text.
563
+ * In HLS, EXT-X-TARGETDURATION is the closest manifest-level hint for refresh cadence.
564
+ */
565
+ export function extractHlsTargetDurationSeconds(manifestText) {
566
+ const match = manifestText.match(REGEX_HLS_TARGET_DURATION);
567
+ return match ? parseInt(match[1], 10) : null;
568
+ }
569
+
570
+ /**
571
+ * Extracts the DASH live minimumUpdatePeriod from MPD manifest text.
572
+ * In DASH, minimumUpdatePeriod is the MPD's refresh hint for clients polling a live manifest.
573
+ */
574
+ export function extractDashMinimumUpdatePeriodSeconds(manifestText) {
575
+ const match = manifestText.match(REGEX_DASH_MINIMUM_UPDATE_PERIOD);
576
+ return match ? parseIsoDuration(match[1]) : null;
577
+ }
578
+
579
+ async function fetchTextOrThrow(url) {
580
+ const response = await fetch(url, { credentials: 'include' });
581
+
582
+ if (response.ok === false) {
583
+ throw new Error(
584
+ `Manifest request failed: ${response.status || 'unknown'} ${
585
+ response.statusText || 'Request failed'
586
+ }`
587
+ );
588
+ }
589
+
590
+ return await response.text();
591
+ }
592
+
593
+ /**
594
+ * Fetches HLS master manifest and returns master text + first media playlist URL
595
+ */
596
+ export async function fetchHlsMasterManifest(manifestUrl) {
597
+ const masterText = await fetchTextOrThrow(manifestUrl);
598
+
599
+ // Find first media playlist URL
600
+ const lines = masterText.split('\n');
601
+ let mediaPlaylistUrl = null;
602
+
603
+ for (const line of lines) {
604
+ if (!line.startsWith(HLS_TAG_PREFIX) && line.includes(HLS_MANIFEST_EXTENSION)) {
605
+ mediaPlaylistUrl = new URL(line.trim(), manifestUrl).href;
606
+ break;
607
+ }
608
+ }
609
+
610
+ return { masterText, mediaPlaylistUrl };
611
+ }
612
+
613
+ /**
614
+ * Fetches HLS media playlist and returns text
615
+ */
616
+ export async function fetchHlsMediaPlaylist(playlistUrl) {
617
+ return await fetchTextOrThrow(playlistUrl);
618
+ }
619
+
620
+ /**
621
+ * Fetches DASH MPD manifest and returns XML text
622
+ */
623
+ export async function fetchDashManifest(manifestUrl) {
624
+ return await fetchTextOrThrow(manifestUrl);
625
+ }
626
+
627
+ /**
628
+ * Parses ISO 8601 duration string to seconds
629
+ * e.g. "PT1M14S" → 74, "PT10S" → 10, "PT12M54S" → 774
630
+ */
631
+ export function parseIsoDuration(durationStr) {
632
+ if (!durationStr) return 0;
633
+ const match = durationStr.match(REGEX_ISO_8601_DURATION);
634
+ if (!match) return 0;
635
+ const hours = parseFloat(match[1] || 0);
636
+ const minutes = parseFloat(match[2] || 0);
637
+ const seconds = parseFloat(match[3] || 0);
638
+ return hours * 3600 + minutes * 60 + seconds;
639
+ }
640
+
641
+ /**
642
+ * Parses DASH MPD for ad breaks — supports both MULTI_PERIOD and SINGLE_PERIOD formats.
643
+ *
644
+ * MULTI_PERIOD (MediaTailor default): ad breaks are separate <Period> elements
645
+ * whose <BaseURL> points to segments.mediatailor.amazonaws.com.
646
+ *
647
+ * SINGLE_PERIOD: the entire stream is one period; ads are signalled via
648
+ * SCTE-35 <EventStream> elements inside that period.
649
+ */
650
+ export function parseDashManifestForAdBreaks(xmlText, { adSegmentPrefix } = {}) {
651
+ const parser = new DOMParser();
652
+ const xml = parser.parseFromString(xmlText, 'text/xml');
653
+ const ads = [];
654
+
655
+ const parserError = xml.querySelector('parsererror');
656
+ if (parserError) {
657
+ Log.error('[MT] DASH XML parse error:', parserError.textContent);
658
+ return ads;
659
+ }
660
+
661
+ const periods = xml.querySelectorAll('Period');
662
+ Log.debug(`[MT] Found ${periods.length} Period(s) in DASH manifest`);
663
+
664
+ if (periods.length > 1) {
665
+ // ── MULTI_PERIOD ──────────────────────────────────────────────────────────
666
+ // Ad periods are identified by a BaseURL pointing to the MediaTailor CDN.
667
+ periods.forEach((period) => {
668
+ const baseUrlEl = period.querySelector('BaseURL');
669
+ const baseUrl = baseUrlEl ? baseUrlEl.textContent.trim() : '';
670
+
671
+ const adCandidates = [MT_SEGMENT_PATTERN, MT_DEFAULT_AD_SEGMENT_PATH];
672
+ if (adSegmentPrefix) adCandidates.push(adSegmentPrefix);
673
+ if (!adCandidates.some((m) => baseUrl.includes(m))) {
674
+ return; // content period
675
+ }
676
+
677
+ const periodId = period.getAttribute('id') || '';
678
+ const startTime = parseIsoDuration(period.getAttribute('start') || 'PT0S');
679
+ const duration = parseIsoDuration(period.getAttribute('duration') || '');
680
+
681
+ if (duration < MIN_AD_DURATION) {
682
+ Log.debug(`[MT] Skipping period ${periodId} - duration too short (${duration}s)`);
683
+ return;
684
+ }
685
+
686
+ Log.debug(`[MT] Ad period detected: ${periodId}`, { startTime, duration });
687
+
688
+ ads.push({
689
+ id: periodId,
690
+ startTime,
691
+ duration,
692
+ endTime: startTime + duration,
693
+ source: AD_SOURCE.MANIFEST_CUE,
694
+ confirmedByTracking: false,
695
+ hasFiredStart: false,
696
+ hasFiredEnd: false,
697
+ hasFiredAdStart: false,
698
+ hasFiredQ1: false,
699
+ hasFiredQ2: false,
700
+ hasFiredQ3: false,
701
+ pods: [],
702
+ });
703
+ });
704
+ } else {
705
+ // ── SINGLE_PERIOD ─────────────────────────────────────────────────────────
706
+ // Ads are signalled via SCTE-35 EventStream elements within the single period.
707
+ const eventStreams = xml.querySelectorAll(
708
+ DASH_SCTE35_EVENT_STREAM_SELECTOR,
709
+ );
710
+
711
+ Log.debug(`[MT] Found ${eventStreams.length} SCTE-35 EventStream(s) in single-period manifest`);
712
+
713
+ eventStreams.forEach((stream) => {
714
+ const timescale = parseFloat(stream.getAttribute('timescale') || '1');
715
+
716
+ stream.querySelectorAll('Event').forEach((event) => {
717
+ const presentationTime = parseFloat(event.getAttribute('presentationTime') || 0);
718
+ const duration = parseFloat(event.getAttribute('duration') || 0);
719
+ const eventId =
720
+ event.getAttribute('id') ||
721
+ `${SCTE35_SCHEME_MARKER}-${presentationTime}`;
722
+
723
+ const startTime = timescale !== 1 ? presentationTime / timescale : presentationTime;
724
+ const durationSeconds = timescale !== 1 ? duration / timescale : duration;
725
+
726
+ if (durationSeconds < MIN_AD_DURATION) {
727
+ Log.debug(`[MT] Skipping event ${eventId} - duration too short (${durationSeconds}s)`);
728
+ return;
729
+ }
730
+
731
+ Log.debug(`[MT] SCTE-35 event detected: ${eventId}`, { startTime, durationSeconds });
732
+
733
+ ads.push({
734
+ id: eventId,
735
+ startTime,
736
+ duration: durationSeconds,
737
+ endTime: startTime + durationSeconds,
738
+ source: AD_SOURCE.MANIFEST_CUE,
739
+ confirmedByTracking: false,
740
+ hasFiredStart: false,
741
+ hasFiredEnd: false,
742
+ hasFiredAdStart: false,
743
+ hasFiredQ1: false,
744
+ hasFiredQ2: false,
745
+ hasFiredQ3: false,
746
+ pods: [],
747
+ });
748
+ });
749
+ });
750
+ }
751
+
752
+ Log.debug(`[MT] Parsed ${ads.length} valid ad break(s) from DASH manifest`);
753
+ return ads;
754
+ }
755
+
756
+ /**
757
+ * Fetches tracking metadata from AWS MediaTailor Tracking API
758
+ * @param {string} trackingEndpointUrl - The tracking API URL
759
+ * @param {number} timeout - Timeout in milliseconds
760
+ * @param {AbortSignal} externalSignal - Optional external abort signal for cancellation
761
+ */
762
+ export async function getTrackingMetadata(trackingEndpointUrl, timeout = 8000, externalSignal = null) {
763
+ // Create AbortController for timeout support
764
+ const controller = new AbortController();
765
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
766
+
767
+ // If external signal provided, listen for its abort event
768
+ const abortHandler = () => controller.abort();
769
+ if (externalSignal) {
770
+ externalSignal.addEventListener('abort', abortHandler);
771
+ }
772
+
773
+ try {
774
+ const response = await fetch(`${trackingEndpointUrl}?t=${Date.now()}`, {
775
+ signal: controller.signal,
776
+ credentials: 'include',
777
+ });
778
+
779
+ clearTimeout(timeoutId);
780
+ if (externalSignal) {
781
+ externalSignal.removeEventListener('abort', abortHandler);
782
+ }
783
+
784
+ if (!response.ok) {
785
+ throw new Error(`Tracking API error: ${response.status}`);
786
+ }
787
+
788
+ return await response.json();
789
+ } catch (error) {
790
+ clearTimeout(timeoutId);
791
+ if (externalSignal) {
792
+ externalSignal.removeEventListener('abort', abortHandler);
793
+ }
794
+ throw error;
795
+ }
796
+ }
797
+
798
+ /**
799
+ * Extracts first media playlist URL from HLS master manifest text
800
+ */
801
+ export function extractMediaPlaylistUrl(masterText, baseUrl) {
802
+ const lines = masterText.split('\n');
803
+
804
+ for (const line of lines) {
805
+ if (
806
+ !line.startsWith(HLS_TAG_PREFIX) &&
807
+ line.includes(HLS_MANIFEST_EXTENSION)
808
+ ) {
809
+ return new URL(line.trim(), baseUrl).href;
810
+ }
811
+ }
812
+
813
+ return null;
814
+ }