@newrelic/video-videojs 4.1.1 → 4.1.2

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