@newrelic/video-videojs 4.1.0 → 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,1246 @@
1
+ /* eslint-disable no-undef */
2
+ import nrvideo from '@newrelic/video-core';
3
+ import VideojsAdsTracker from './videojs-ads';
4
+ import {
5
+ DEFAULT_LIVE_POLL_INTERVAL_MS,
6
+ HLS_MIME_TYPE,
7
+ MEDIATAILOR_HOST_MARKER,
8
+ SCTE35_SCHEME_MARKER,
9
+ TRACKING_API_TIMEOUT_MS,
10
+ STREAM_TYPE,
11
+ MANIFEST_TYPE,
12
+ } from './utils/mt-constants.js';
13
+ import {
14
+ getTimestamp,
15
+ detectManifestFormatFromUrl,
16
+ detectPlaybackStreamType,
17
+ buildTrackingEndpointUrl,
18
+ determineAdPosition,
19
+ getQuartilesToFire,
20
+ findActiveAdBreak,
21
+ findActivePod,
22
+ mergeAdSchedules,
23
+ parseHlsManifestForAdBreaks,
24
+ parseDashManifestForAdBreaks,
25
+ detectAdBreaksFromVhsPlaylist,
26
+ enrichAdScheduleWithTrackingMetadata,
27
+ extractHlsTargetDurationSeconds,
28
+ extractDashMinimumUpdatePeriodSeconds,
29
+ fetchHlsMasterManifest,
30
+ fetchHlsMediaPlaylist,
31
+ fetchDashManifest,
32
+ getTrackingMetadata,
33
+ } from './utils/mt.js';
34
+
35
+ // Handle both direct and default export from video-core
36
+ const nrvideoCore = nrvideo.default || nrvideo;
37
+ const Log = nrvideoCore.Log;
38
+
39
+ /**
40
+ * AWS MediaTailor Ad Tracker
41
+ * Tracks ads from AWS MediaTailor SSAI streams (HLS/DASH)
42
+ *
43
+ * Features:
44
+ * - Client-side ad detection from manifest markers (CUE-OUT/CUE-IN)
45
+ * - Pod-level tracking (multiple ads within one break)
46
+ * - VOD and Live stream support
47
+ * - Zero race conditions via VHS player hooks
48
+ * - Tracking API metadata enrichment
49
+ */
50
+ export default class MediaTailorAdsTracker extends VideojsAdsTracker {
51
+ /**
52
+ * Checks if tracker should be used for this player source
53
+ */
54
+ static isUsing(player) {
55
+ return (
56
+ player &&
57
+ typeof player.currentSrc === 'function' &&
58
+ player.currentSrc().includes(MEDIATAILOR_HOST_MARKER)
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Returns tracker name for New Relic instrumentation
64
+ */
65
+ getTrackerName() {
66
+ return 'aws-media-tailor';
67
+ }
68
+
69
+ /**
70
+ * Returns player version (MediaTailor doesn't have version)
71
+ */
72
+ getPlayerVersion() {
73
+ return 'MediaTailor';
74
+ }
75
+
76
+ /**
77
+ * Override to return the correct ad position from our ad schedule
78
+ * This overrides video-core's default logic which only checks if content started
79
+ */
80
+ getAdPosition() {
81
+ if (this.currentAdBreak && this.currentAdBreak.adPosition) {
82
+ return this.currentAdBreak.adPosition;
83
+ }
84
+ return super.getAdPosition();
85
+ }
86
+
87
+ constructor(player, options = {}) {
88
+ super(player);
89
+
90
+ // Initialize state
91
+ this.streamType = null; // 'vod' or 'live'
92
+ this.manifestFormat = null; // 'hls' or 'dash'
93
+ this.playbackManifestUrl = player.currentSrc();
94
+
95
+ // Ad tracking state
96
+ this.adSchedule = [];
97
+ this.currentAdBreak = null;
98
+ this.currentAdPod = null;
99
+ this.hasEndedContent = false;
100
+
101
+ // Disposal and abort state
102
+ this.isDisposed = false;
103
+ this.trackingAbortController = null;
104
+ this.manifestAbortController = null;
105
+ this.isFetchingTracking = false;
106
+ this.isFetchingManifest = false;
107
+
108
+ // Tracking API state
109
+ this.trackingEndpointUrl = null;
110
+ this.hasAttemptedTrackingFetch = false;
111
+ this.trackingFetchRetries = 0;
112
+ this.maxTrackingRetries = 1;
113
+
114
+ // Live polling timers
115
+ this.manifestPollTimer = null;
116
+ this.trackingPollTimer = null;
117
+ this.liveRefreshIntervalSeconds = null;
118
+
119
+ // Manifest parsing cache
120
+ this.mediaPlaylistUrl = null;
121
+ this.lastMediaPlaylistText = null;
122
+
123
+ console.log(`[MT - ${getTimestamp()}] MediaTailorAdsTracker initialized`, {
124
+ endpoint: this.playbackManifestUrl,
125
+ trackingAPITimeout: TRACKING_API_TIMEOUT_MS,
126
+ });
127
+
128
+ this.manifestFormat = detectManifestFormatFromUrl(this.playbackManifestUrl);
129
+ console.log(
130
+ `[MT - ${getTimestamp()}] Manifest type: ${this.manifestFormat.toUpperCase()}`,
131
+ );
132
+
133
+ this.player.one('loadedmetadata', () => {
134
+ this.streamType = detectPlaybackStreamType(this.player.duration());
135
+ console.log(
136
+ `[MT - ${getTimestamp()}] Stream type: ${this.streamType.toUpperCase()}`,
137
+ );
138
+ this.initializeTracking();
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Initializes tracking based on detected stream type
144
+ */
145
+ initializeTracking() {
146
+ console.log(
147
+ `[MT - ${getTimestamp()}] Initializing ${this.manifestFormat.toUpperCase()} ${this.streamType.toUpperCase()} tracking`,
148
+ );
149
+
150
+ // Extract tracking URL from sessionized URL
151
+ this.trackingEndpointUrl = buildTrackingEndpointUrl(this.playbackManifestUrl);
152
+ if (this.trackingEndpointUrl) {
153
+ console.log(
154
+ `[MT - ${getTimestamp()}] Tracking URL extracted:`,
155
+ this.trackingEndpointUrl,
156
+ );
157
+ } else {
158
+ console.warn(
159
+ `[MT - ${getTimestamp()}] No sessionId found - user must initialize session externally`,
160
+ );
161
+ }
162
+
163
+ // Set up format-specific tracking
164
+ if (this.streamType === STREAM_TYPE.VOD) {
165
+ this.setupVODTracking();
166
+ } else {
167
+ this.setupLiveTracking();
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Register listeners (overrides parent class method)
173
+ */
174
+ registerListeners() {
175
+ super.registerListeners();
176
+
177
+ // Bind before registering — super(player) calls registerListeners() before
178
+ // the constructor body runs, so bindings must happen here, not in the constructor.
179
+ this.onPause = this.onPause.bind(this);
180
+ this.onPlaying = this.onPlaying.bind(this);
181
+ this.onSeeking = this.onSeeking.bind(this);
182
+ this.onSeeked = this.onSeeked.bind(this);
183
+ this.onWaiting = this.onWaiting.bind(this);
184
+ this.onEnded = this.onEnded.bind(this);
185
+ this.onTimeUpdate = this.onTimeUpdate.bind(this);
186
+
187
+ this.player.on('pause', this.onPause);
188
+ this.player.on('playing', this.onPlaying);
189
+ this.player.on('seeking', this.onSeeking);
190
+ this.player.on('seeked', this.onSeeked);
191
+ this.player.on('waiting', this.onWaiting);
192
+ this.player.on('ended', this.onEnded);
193
+ this.player.on('timeupdate', this.onTimeUpdate);
194
+ console.log(`[MT - ${getTimestamp()}] Event listeners registered`);
195
+ }
196
+
197
+ /**
198
+ * Unregister listeners (overrides parent class method)
199
+ */
200
+ unregisterListeners() {
201
+ super.unregisterListeners();
202
+ this.player.off('pause', this.onPause);
203
+ this.player.off('playing', this.onPlaying);
204
+ this.player.off('seeking', this.onSeeking);
205
+ this.player.off('seeked', this.onSeeked);
206
+ this.player.off('waiting', this.onWaiting);
207
+ this.player.off('ended', this.onEnded);
208
+ this.player.off('timeupdate', this.onTimeUpdate);
209
+ this.stopLivePolling();
210
+ }
211
+
212
+ /**
213
+ * Stops live polling timers
214
+ */
215
+ stopLivePolling() {
216
+ if (this.manifestPollTimer) {
217
+ clearTimeout(this.manifestPollTimer);
218
+ this.manifestPollTimer = null;
219
+ }
220
+
221
+ if (this.trackingPollTimer) {
222
+ clearInterval(this.trackingPollTimer);
223
+ this.trackingPollTimer = null;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Sets up VOD tracking (single parse, no polling)
229
+ */
230
+ setupVODTracking() {
231
+ console.log(`[MT - ${getTimestamp()}] VOD mode: Single manifest parse`);
232
+ this.hookPlayerManifest();
233
+ }
234
+
235
+ /**
236
+ * Sets up Live tracking (continuous polling)
237
+ */
238
+ setupLiveTracking() {
239
+ console.log(`[MT - ${getTimestamp()}] Live mode: Continuous polling`);
240
+ this.hookPlayerManifest();
241
+
242
+ const pollingInterval = this.getLiveRefreshIntervalMs();
243
+
244
+ // Start polling timers
245
+ this.manifestPollTimer = setInterval(() => {
246
+ this.pollManifestForNewAds();
247
+ }, pollingInterval);
248
+
249
+ this.trackingPollTimer = setInterval(() => {
250
+ this.getAndProcessTrackingMetadata();
251
+ }, pollingInterval);
252
+
253
+ console.log(`[MT - ${getTimestamp()}] Live polling started`, {
254
+ pollingInterval,
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Returns the live polling interval in milliseconds
260
+ */
261
+ getLiveRefreshIntervalMs() {
262
+ return this.liveRefreshIntervalSeconds
263
+ ? this.liveRefreshIntervalSeconds * 1000
264
+ : DEFAULT_LIVE_POLL_INTERVAL_MS;
265
+ }
266
+
267
+ /**
268
+ * Updates live polling intervals after manifest metadata is detected
269
+ */
270
+ restartLivePollingTimers() {
271
+ if (!this.liveRefreshIntervalSeconds) return;
272
+
273
+ const newInterval = this.getLiveRefreshIntervalMs();
274
+ console.log(
275
+ `[MT - ${getTimestamp()}] Updating live polling interval: ${this.liveRefreshIntervalSeconds}s`,
276
+ );
277
+
278
+ // Restart timers with new interval
279
+ if (this.manifestPollTimer) clearInterval(this.manifestPollTimer);
280
+ if (this.trackingPollTimer) clearInterval(this.trackingPollTimer);
281
+
282
+ this.manifestPollTimer = setInterval(() => {
283
+ this.pollManifestForNewAds();
284
+ }, newInterval);
285
+
286
+ this.trackingPollTimer = setInterval(() => {
287
+ this.getAndProcessTrackingMetadata();
288
+ }, newInterval);
289
+ }
290
+
291
+ /**
292
+ * Hooks into player's manifest loading (zero race condition)
293
+ * Supports: VHS, Native HLS, contrib-hls, Shaka, dash.js
294
+ */
295
+ hookPlayerManifest() {
296
+ const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
297
+ if (!tech) {
298
+ console.log(`[MT - ${getTimestamp()}] No tech - using fallback fetch`);
299
+ this.getManifestDirectly();
300
+ return;
301
+ }
302
+
303
+ // Try hooks in order of preference
304
+ if (this.manifestFormat === MANIFEST_TYPE.HLS) {
305
+ if (
306
+ this.hookHLSViaVHS(tech) ||
307
+ this.hookHLSViaNative(tech) ||
308
+ this.hookHLSViaContribHls(tech)
309
+ ) {
310
+ return; // Successfully hooked
311
+ }
312
+ } else if (this.manifestFormat === MANIFEST_TYPE.DASH) {
313
+ if (this.hookDASHViaShaka(tech) || this.hookDASHViaDashJs(tech)) {
314
+ return; // Successfully hooked
315
+ }
316
+ }
317
+
318
+ // Fallback: Direct manifest fetch
319
+ console.log(
320
+ `[MT - ${getTimestamp()}] Using fallback: direct manifest fetch`,
321
+ );
322
+ this.getManifestDirectly();
323
+ }
324
+
325
+ /**
326
+ * Hook: VHS (videojs-http-streaming) - Video.js 7.0+
327
+ */
328
+ hookHLSViaVHS(tech) {
329
+ if (!tech.vhs || !tech.vhs.playlists) return false;
330
+
331
+ console.log(`[MT - ${getTimestamp()}] Hooked: VHS`);
332
+
333
+ // Parse already-loaded playlist
334
+ const currentPlaylist = tech.vhs.playlists.media();
335
+ if (
336
+ currentPlaylist &&
337
+ currentPlaylist.segments &&
338
+ currentPlaylist.segments.length > 0
339
+ ) {
340
+ console.log(`[MT - ${getTimestamp()}] Parsing existing playlist`);
341
+ this.parseVhsPlaylistForAdBreaks(currentPlaylist);
342
+ }
343
+
344
+ // Hook future playlist loads
345
+ tech.vhs.on('loadedplaylist', () => {
346
+ const playlist = tech.vhs.playlists.media();
347
+ if (playlist) {
348
+ this.parseVhsPlaylistForAdBreaks(playlist);
349
+ }
350
+ });
351
+
352
+ return true;
353
+ }
354
+
355
+ /**
356
+ * Hook: Native HLS (Safari)
357
+ */
358
+ hookHLSViaNative(tech) {
359
+ // Safari uses native HLS - can't hook directly
360
+ if (
361
+ tech.el_ &&
362
+ tech.el_.canPlayType &&
363
+ tech.el_.canPlayType(HLS_MIME_TYPE)
364
+ ) {
365
+ console.log(
366
+ `[MT - ${getTimestamp()}] Native HLS detected - using fallback`,
367
+ );
368
+ this.getManifestDirectly();
369
+ return true;
370
+ }
371
+ return false;
372
+ }
373
+
374
+ /**
375
+ * Hook: videojs-contrib-hls (legacy Video.js 5.x/6.x)
376
+ */
377
+ hookHLSViaContribHls(tech) {
378
+ if (!tech.hls || !tech.hls.playlists) return false;
379
+
380
+ console.log(`[MT - ${getTimestamp()}] Hooked: contrib-hls (legacy)`);
381
+
382
+ // Parse already-loaded playlist
383
+ const currentPlaylist = tech.hls.playlists.media();
384
+ if (
385
+ currentPlaylist &&
386
+ currentPlaylist.segments &&
387
+ currentPlaylist.segments.length > 0
388
+ ) {
389
+ this.parseVhsPlaylistForAdBreaks(currentPlaylist);
390
+ }
391
+
392
+ // Hook future playlist loads
393
+ tech.hls.on('loadedplaylist', () => {
394
+ const playlist = tech.hls.playlists.media();
395
+ if (playlist) {
396
+ this.parseVhsPlaylistForAdBreaks(playlist);
397
+ }
398
+ });
399
+
400
+ return true;
401
+ }
402
+
403
+ /**
404
+ * Hook: Shaka Player (DASH)
405
+ */
406
+ hookDASHViaShaka(tech) {
407
+ if (!tech.shakaPlayer) return false;
408
+
409
+ console.log(`[MT - ${getTimestamp()}] Hooked: Shaka Player`);
410
+ tech.shakaPlayer.addEventListener('emsg', (event) => {
411
+ this.handleDASHEmsgEvent(event);
412
+ });
413
+
414
+ return true;
415
+ }
416
+
417
+ /**
418
+ * Hook: dash.js (DASH)
419
+ */
420
+ hookDASHViaDashJs(tech) {
421
+ if (!tech.dash || !tech.dash.on) return false;
422
+
423
+ console.log(`[MT - ${getTimestamp()}] Hooked: dash.js`);
424
+ tech.dash.on('EVENT_MODE_ON_RECEIVE', (event) => {
425
+ this.handleDASHEventStream(event);
426
+ });
427
+
428
+ return true;
429
+ }
430
+
431
+ /**
432
+ * Fetches manifest directly (fallback when hooks unavailable)
433
+ */
434
+ async getManifestDirectly() {
435
+ console.log(
436
+ `[MT - ${getTimestamp()}] Fallback: fetching manifest directly`,
437
+ );
438
+
439
+ try {
440
+ const manifestUrl = this.playbackManifestUrl;
441
+
442
+ if (this.manifestFormat === MANIFEST_TYPE.HLS) {
443
+ await this.fetchAndParseHlsManifest(manifestUrl);
444
+ } else if (this.manifestFormat === MANIFEST_TYPE.DASH) {
445
+ await this.fetchAndParseDashManifest(manifestUrl);
446
+ }
447
+ } catch (error) {
448
+ console.log(`[MT - ${getTimestamp()}] Fallback fetch error:`, error);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Fetches and parses HLS master + media manifest
454
+ */
455
+ async fetchAndParseHlsManifest(manifestUrl) {
456
+ try {
457
+ console.log(`[MT - ${getTimestamp()}] Fetching HLS master manifest`);
458
+
459
+ // Fetch master manifest
460
+ const { mediaPlaylistUrl } = await fetchHlsMasterManifest(manifestUrl);
461
+
462
+ if (!mediaPlaylistUrl) {
463
+ console.log(`[MT - ${getTimestamp()}] No media playlist found`);
464
+ return;
465
+ }
466
+
467
+ console.log(`[MT - ${getTimestamp()}] Fetching media playlist`);
468
+
469
+ // Fetch media playlist
470
+ const mediaText = await fetchHlsMediaPlaylist(mediaPlaylistUrl);
471
+
472
+ const hlsTargetDurationSeconds = extractHlsTargetDurationSeconds(mediaText);
473
+ this.updateLiveRefreshIntervalFromManifest(
474
+ hlsTargetDurationSeconds,
475
+ 'hls target duration',
476
+ );
477
+
478
+ // Parse for ads
479
+ const ads = parseHlsManifestForAdBreaks(mediaText);
480
+ if (ads.length > 0) {
481
+ console.log(
482
+ `[MT - ${getTimestamp()}] Detected ${ads.length} ad break(s)`,
483
+ );
484
+ this.mergeNewAds(ads);
485
+ }
486
+ } catch (error) {
487
+ console.log(`[MT - ${getTimestamp()}] HLS fetch error:`, error);
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Fetches and parses DASH MPD manifest
493
+ */
494
+ async fetchAndParseDashManifest(manifestUrl) {
495
+ try {
496
+ console.log(`[MT - ${getTimestamp()}] Fetching DASH manifest`);
497
+
498
+ // Fetch DASH manifest
499
+ const xmlText = await fetchDashManifest(manifestUrl);
500
+
501
+ const dashMinimumUpdatePeriodSeconds =
502
+ extractDashMinimumUpdatePeriodSeconds(xmlText);
503
+ this.updateLiveRefreshIntervalFromManifest(
504
+ dashMinimumUpdatePeriodSeconds,
505
+ 'dash minimumUpdatePeriod',
506
+ );
507
+
508
+ // Parse for ads
509
+ const ads = parseDashManifestForAdBreaks(xmlText);
510
+
511
+ console.log(
512
+ `[MT - ${getTimestamp()}] DASH: ${ads.length} ad break(s) found`,
513
+ );
514
+
515
+ if (ads.length > 0) {
516
+ this.mergeNewAds(ads); // also triggers tracking fetch
517
+ } else if (
518
+ this.streamType === STREAM_TYPE.VOD &&
519
+ this.trackingEndpointUrl &&
520
+ !this.hasAttemptedTrackingFetch
521
+ ) {
522
+ // No SCTE-35 markers in manifest - fall back to tracking API directly
523
+ console.log(
524
+ `[MT - ${getTimestamp()}] DASH: no manifest cues, fetching tracking API`,
525
+ );
526
+ this.hasAttemptedTrackingFetch = true;
527
+ this.getAndProcessTrackingMetadata();
528
+ }
529
+ } catch (error) {
530
+ console.log(`[MT - ${getTimestamp()}] DASH fetch error:`, error);
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Handles DASH emsg events from Shaka Player
536
+ */
537
+ handleDASHEmsgEvent(event) {
538
+ console.log(`[MT - ${getTimestamp()}] DASH emsg event:`, event);
539
+
540
+ try {
541
+ // Shaka Player emits emsg events with event.detail containing the emsg box data
542
+ const emsgData = event.detail;
543
+
544
+ if (!emsgData) {
545
+ console.log(`[MT - ${getTimestamp()}] No emsg data in event`);
546
+ return;
547
+ }
548
+
549
+ // Check if this is a SCTE-35 event
550
+ // schemeIdUri for SCTE-35: urn:scte:scte35:2013:bin or urn:scte:scte35:2014:xml
551
+ const schemeIdUri = emsgData.schemeIdUri || '';
552
+
553
+ if (!schemeIdUri.includes(SCTE35_SCHEME_MARKER)) {
554
+ console.log(
555
+ `[MT - ${getTimestamp()}] Non-SCTE-35 emsg, skipping:`,
556
+ schemeIdUri,
557
+ );
558
+ return;
559
+ }
560
+
561
+ console.log(`[MT - ${getTimestamp()}] SCTE-35 emsg detected:`, {
562
+ schemeIdUri: emsgData.schemeIdUri,
563
+ value: emsgData.value,
564
+ timescale: emsgData.timescale,
565
+ presentationTime: emsgData.presentationTime,
566
+ presentationTimeDelta: emsgData.presentationTimeDelta,
567
+ eventDuration: emsgData.eventDuration,
568
+ });
569
+
570
+ // Calculate start time in seconds
571
+ const timescale = emsgData.timescale || 1;
572
+ const presentationTime = emsgData.presentationTime || 0;
573
+ const eventDuration = emsgData.eventDuration || 0;
574
+
575
+ const startTime = presentationTime / timescale;
576
+ const duration = eventDuration / timescale;
577
+
578
+ // Parse SCTE-35 message data
579
+ const messageData = emsgData.messageData;
580
+
581
+ if (messageData && duration > 0) {
582
+ const adBreak = {
583
+ id: `dash-emsg-${startTime}`,
584
+ startTime: startTime,
585
+ duration: duration,
586
+ endTime: startTime + duration,
587
+ source: 'dash-emsg',
588
+ confirmedByTracking: false,
589
+ hasFiredStart: false,
590
+ hasFiredEnd: false,
591
+ hasFiredAdStart: false,
592
+ pods: [],
593
+ };
594
+
595
+ console.log(
596
+ `[MT - ${getTimestamp()}] Adding ad break from DASH emsg:`,
597
+ adBreak,
598
+ );
599
+ this.mergeNewAds([adBreak]);
600
+ }
601
+ } catch (error) {
602
+ console.log(
603
+ `[MT - ${getTimestamp()}] Error parsing DASH emsg event:`,
604
+ error,
605
+ );
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Handles DASH event stream from dash.js
611
+ */
612
+ handleDASHEventStream(event) {
613
+ console.log(`[MT - ${getTimestamp()}] DASH event stream:`, event);
614
+
615
+ try {
616
+ // dash.js emits events with different structure
617
+ const eventData = event.event || event;
618
+
619
+ if (!eventData) {
620
+ console.log(`[MT - ${getTimestamp()}] No event data`);
621
+ return;
622
+ }
623
+
624
+ // Check if this is a SCTE-35 event
625
+ const schemeIdUri = eventData.schemeIdUri || '';
626
+
627
+ if (!schemeIdUri.includes(SCTE35_SCHEME_MARKER)) {
628
+ console.log(
629
+ `[MT - ${getTimestamp()}] Non-SCTE-35 event, skipping:`,
630
+ schemeIdUri,
631
+ );
632
+ return;
633
+ }
634
+
635
+ console.log(`[MT - ${getTimestamp()}] SCTE-35 event stream detected:`, {
636
+ id: eventData.id,
637
+ schemeIdUri: eventData.schemeIdUri,
638
+ presentationTime: eventData.presentationTime,
639
+ duration: eventData.duration,
640
+ messageData: eventData.messageData,
641
+ });
642
+
643
+ // Calculate timing
644
+ const startTime = parseFloat(eventData.presentationTime || 0);
645
+ const duration = parseFloat(eventData.duration || 0);
646
+
647
+ if (duration > 0) {
648
+ const adBreak = {
649
+ id: eventData.id || `dash-event-${startTime}`,
650
+ startTime: startTime,
651
+ duration: duration,
652
+ endTime: startTime + duration,
653
+ source: 'dash-event-stream',
654
+ confirmedByTracking: false,
655
+ hasFiredStart: false,
656
+ hasFiredEnd: false,
657
+ hasFiredAdStart: false,
658
+ pods: [],
659
+ };
660
+
661
+ console.log(
662
+ `[MT - ${getTimestamp()}] Adding ad break from DASH event stream:`,
663
+ adBreak,
664
+ );
665
+ this.mergeNewAds([adBreak]);
666
+ }
667
+ } catch (error) {
668
+ console.log(
669
+ `[MT - ${getTimestamp()}] Error parsing DASH event stream:`,
670
+ error,
671
+ );
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Parses VHS playlist object for ads
677
+ */
678
+ parseVhsPlaylistForAdBreaks(playlist) {
679
+ console.log(
680
+ `[MT - ${getTimestamp()}] Parsing VHS playlist (${
681
+ playlist.segments?.length || 0
682
+ } segments)`,
683
+ );
684
+
685
+ if (!playlist.segments || playlist.segments.length === 0) {
686
+ console.log(`[MT - ${getTimestamp()}] No segments in playlist`);
687
+ return;
688
+ }
689
+
690
+ this.updateLiveRefreshIntervalFromManifest(
691
+ playlist.targetDuration,
692
+ 'vhs target duration',
693
+ );
694
+
695
+ // VHS strips CUE tags - detect via discontinuityStarts + MediaTailor segments
696
+ const ads = detectAdBreaksFromVhsPlaylist(playlist);
697
+
698
+ if (ads.length > 0) {
699
+ console.log(
700
+ `[MT - ${getTimestamp()}] VHS detected ${ads.length} ad break(s), ${ads.reduce(
701
+ (sum, ab) => sum + ab.pods.length,
702
+ 0,
703
+ )} pod(s)`,
704
+ );
705
+ this.mergeNewAds(ads);
706
+ } else {
707
+ console.log(`[MT - ${getTimestamp()}] No ads detected in VHS playlist`);
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Polls manifest for new ads (Live streams only)
713
+ */
714
+ async pollManifestForNewAds() {
715
+ if (this.isDisposed) return;
716
+ if (this.streamType !== STREAM_TYPE.LIVE) return;
717
+
718
+ if (this.isFetchingManifest) {
719
+ console.log(
720
+ `[MT - ${getTimestamp()}] Manifest fetch already in progress, skipping`,
721
+ );
722
+ return;
723
+ }
724
+
725
+ this.isFetchingManifest = true;
726
+
727
+ try {
728
+ const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
729
+ if (tech && tech.vhs) {
730
+ const playlist = tech.vhs.playlists.media();
731
+ if (playlist) {
732
+ this.parseVhsPlaylistForAdBreaks(playlist);
733
+ }
734
+ } else if (this.manifestFormat === MANIFEST_TYPE.DASH) {
735
+ await this.fetchAndParseDashManifest(this.playbackManifestUrl);
736
+ }
737
+ } catch (error) {
738
+ console.log(`[MT - ${getTimestamp()}] Manifest poll error:`, error);
739
+ } finally {
740
+ this.isFetchingManifest = false;
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Merges new ads into schedule (deduplicates)
746
+ */
747
+ mergeNewAds(newAds) {
748
+ this.adSchedule = mergeAdSchedules(this.adSchedule, newAds);
749
+
750
+ console.log(
751
+ `[MT - ${getTimestamp()}] Ad schedule: ${this.adSchedule.length} ad break(s)`,
752
+ );
753
+
754
+ // VOD: Fetch tracking metadata after first manifest parse (AWS best practice)
755
+ if (
756
+ this.streamType === STREAM_TYPE.VOD &&
757
+ this.trackingEndpointUrl &&
758
+ !this.hasAttemptedTrackingFetch
759
+ ) {
760
+ this.hasAttemptedTrackingFetch = true;
761
+ console.log(
762
+ `[MT - ${getTimestamp()}] Fetching tracking metadata (first manifest parse)`,
763
+ );
764
+ this.getAndProcessTrackingMetadata();
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Updates live polling cadence from manifest-derived metadata
770
+ */
771
+ updateLiveRefreshIntervalFromManifest(intervalSeconds, source) {
772
+ if (
773
+ this.streamType !== STREAM_TYPE.LIVE ||
774
+ !intervalSeconds ||
775
+ intervalSeconds <= 0
776
+ ) {
777
+ return;
778
+ }
779
+
780
+ if (this.liveRefreshIntervalSeconds === intervalSeconds) {
781
+ return;
782
+ }
783
+
784
+ this.liveRefreshIntervalSeconds = intervalSeconds;
785
+ console.log(
786
+ `[MT - ${getTimestamp()}] Derived live polling interval from ${source}: ${intervalSeconds}s`,
787
+ );
788
+
789
+ if (this.manifestPollTimer || this.trackingPollTimer) {
790
+ this.restartLivePollingTimers();
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Fetches and processes tracking metadata from AWS MediaTailor Tracking API
796
+ */
797
+ async getAndProcessTrackingMetadata() {
798
+ if (this.isDisposed || !this.trackingEndpointUrl) return;
799
+
800
+ if (this.isFetchingTracking) {
801
+ console.log(
802
+ `[MT - ${getTimestamp()}] Tracking fetch already in progress, skipping`,
803
+ );
804
+ return;
805
+ }
806
+
807
+ this.isFetchingTracking = true;
808
+
809
+ try {
810
+ console.log(`[MT - ${getTimestamp()}] Fetching tracking metadata`);
811
+
812
+ this.trackingAbortController = new AbortController();
813
+
814
+ const data = await getTrackingMetadata(
815
+ this.trackingEndpointUrl,
816
+ TRACKING_API_TIMEOUT_MS,
817
+ this.trackingAbortController.signal,
818
+ );
819
+
820
+ if (this.isDisposed) {
821
+ console.log(
822
+ `[MT - ${getTimestamp()}] Disposed during tracking fetch, ignoring result`,
823
+ );
824
+ return;
825
+ }
826
+
827
+ if (data.avails && data.avails.length > 0) {
828
+ console.log(
829
+ `[MT - ${getTimestamp()}] Enriching with ${data.avails.length} avail(s)`,
830
+ );
831
+ this.enrichWithTrackingMetadata(data.avails);
832
+ this.trackingFetchRetries = 0;
833
+ } else {
834
+ console.log(`[MT - ${getTimestamp()}] Tracking API returned 0 avails`);
835
+ }
836
+ } catch (error) {
837
+ if (error.name === 'AbortError' || this.isDisposed) {
838
+ console.log(`[MT - ${getTimestamp()}] Tracking fetch aborted`);
839
+ return;
840
+ }
841
+
842
+ console.log(
843
+ `[MT - ${getTimestamp()}] Tracking API error: ${error.message}`,
844
+ error,
845
+ );
846
+
847
+ if (this.trackingFetchRetries < this.maxTrackingRetries) {
848
+ this.trackingFetchRetries++;
849
+ console.log(
850
+ `[MT - ${getTimestamp()}] Retrying tracking fetch (${this.trackingFetchRetries}/${this.maxTrackingRetries})`,
851
+ );
852
+ this.isFetchingTracking = false;
853
+ await this.getAndProcessTrackingMetadata();
854
+ return;
855
+ }
856
+
857
+ console.log(
858
+ `[MT - ${getTimestamp()}] Max retries reached, continuing with manifest data only`,
859
+ );
860
+ } finally {
861
+ this.isFetchingTracking = false;
862
+ this.trackingAbortController = null;
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Enriches ad schedule with tracking API metadata
868
+ */
869
+ enrichWithTrackingMetadata(avails) {
870
+ const newAds = enrichAdScheduleWithTrackingMetadata(this.adSchedule, avails);
871
+
872
+ // Add any new ads from tracking
873
+ if (newAds.length > 0) {
874
+ this.adSchedule.push(...newAds);
875
+ this.adSchedule.sort((a, b) => a.startTime - b.startTime);
876
+ }
877
+
878
+ console.log(
879
+ `[MT - ${getTimestamp()}] Enrichment complete: ${
880
+ this.adSchedule.length
881
+ } ad break(s)`,
882
+ );
883
+
884
+ // Log enriched schedule with full details
885
+ console.log(
886
+ `[MT - ${getTimestamp()}] Enriched schedule:`,
887
+ this.adSchedule.map((ab) => ({
888
+ id: ab.id,
889
+ startTime: ab.startTime,
890
+ endTime: ab.endTime,
891
+ duration: ab.duration,
892
+ title: ab.title,
893
+ podCount: ab.pods.length,
894
+ pods: ab.pods.map((p) => ({
895
+ title: p.title,
896
+ startTime: p.startTime,
897
+ endTime: p.endTime,
898
+ duration: p.duration,
899
+ })),
900
+ })),
901
+ );
902
+
903
+ // Log current player time for debugging
904
+ console.log(
905
+ `[MT - ${getTimestamp()}] Current player time: ${this.player.currentTime()}s`,
906
+ );
907
+ }
908
+
909
+ /**
910
+ * Tracks quartile events for active pod/ad
911
+ */
912
+ trackQuartiles(adObject, progress) {
913
+ if (!adObject.duration || adObject.duration <= 0) return;
914
+
915
+ const quartilesToFire = getQuartilesToFire(progress, adObject.duration, {
916
+ q1: adObject.hasFiredQ1,
917
+ q2: adObject.hasFiredQ2,
918
+ q3: adObject.hasFiredQ3,
919
+ });
920
+
921
+ quartilesToFire.forEach(({ quartile, key }) => {
922
+ console.log(`[MT - ${getTimestamp()}] → AD_QUARTILE ${quartile * 25}%`);
923
+ this.sendAdQuartile({ quartile });
924
+ adObject[`hasFired${key.toUpperCase()}`] = true;
925
+ });
926
+ }
927
+
928
+ /**
929
+ * Called on timeupdate - main event tracking logic
930
+ */
931
+ onTimeUpdate() {
932
+ const currentTime = this.player.currentTime();
933
+ const activeAdBreak = findActiveAdBreak(this.adSchedule, currentTime);
934
+
935
+ // Debug logging (only log when schedule exists and every 5 seconds)
936
+ if (
937
+ this.adSchedule.length > 0 &&
938
+ Math.floor(currentTime) % 5 === 0 &&
939
+ Math.floor(currentTime * 10) % 10 === 0
940
+ ) {
941
+ console.log(
942
+ `[MT - ${getTimestamp()}] TimeUpdate: ${currentTime.toFixed(2)}s, Active break: ${
943
+ activeAdBreak ? activeAdBreak.id : 'none'
944
+ }, Schedule count: ${this.adSchedule.length}`,
945
+ );
946
+ }
947
+
948
+ if (activeAdBreak) {
949
+ // === INSIDE AD BREAK ===
950
+
951
+ // Fire AD_BREAK_START once
952
+ if (!activeAdBreak.hasFiredStart) {
953
+ this.currentAdBreak = activeAdBreak;
954
+ this.setIsAd(true); // Switch to ad mode
955
+ console.log(
956
+ `[MT - ${getTimestamp()}] setIsAd(true) - Entering ad break`,
957
+ );
958
+
959
+ // Calculate ad position by finding index based on startTime
960
+ const adBreakIndex = this.adSchedule.findIndex(
961
+ (ad) => Math.abs(ad.startTime - activeAdBreak.startTime) < 0.5,
962
+ );
963
+ const adPosition = determineAdPosition(
964
+ adBreakIndex,
965
+ this.adSchedule.length,
966
+ this.streamType,
967
+ );
968
+
969
+ // Store position on the ad break for reuse
970
+ activeAdBreak.adPosition = adPosition;
971
+
972
+ console.log(`[MT - ${getTimestamp()}] → AD_BREAK_START`, {
973
+ startTime: activeAdBreak.startTime,
974
+ duration: activeAdBreak.duration,
975
+ podCount: activeAdBreak.pods?.length || 0,
976
+ position: adPosition,
977
+ breakIndex: adBreakIndex,
978
+ totalBreaks: this.adSchedule.length,
979
+ });
980
+ this.sendAdBreakStart();
981
+ activeAdBreak.hasFiredStart = true;
982
+ }
983
+
984
+ // Check for pod-level tracking
985
+ if (activeAdBreak.pods && activeAdBreak.pods.length > 0) {
986
+ const activePod = findActivePod(activeAdBreak, currentTime);
987
+
988
+ if (activePod) {
989
+ // Entering new pod
990
+ if (!this.currentAdPod || this.currentAdPod !== activePod) {
991
+ // End previous pod
992
+ if (this.currentAdPod) {
993
+ console.log(`[MT - ${getTimestamp()}] → AD_END (pod transition)`);
994
+ this.sendEnd();
995
+ }
996
+
997
+ // Start new pod
998
+ this.currentAdPod = activePod;
999
+
1000
+ console.log(`[MT - ${getTimestamp()}] → AD_START (new pod)`, {
1001
+ startTime: activePod.startTime,
1002
+ duration: activePod.duration,
1003
+ position: activeAdBreak.adPosition,
1004
+ });
1005
+
1006
+ // NOTE: If the tracking API was slow to respond, the no-pods path
1007
+ // (below) will have already called sendStart() on this break.
1008
+ // Calling sendStart() again here is intentional — video-core's state
1009
+ // machine suppresses duplicate AD_START transitions (AD_START →
1010
+ // AD_START is a no-op), so nothing double-fires in New Relic. Once
1011
+ // pods are populated by the tracking API, this path takes over and
1012
+ // provides pod-level metadata (title, duration, creativeId) for all
1013
+ // subsequent events in the break.
1014
+ this.sendRequest({
1015
+ adPartner: 'aws-mediatailor',
1016
+ adPosition: activeAdBreak.adPosition,
1017
+ });
1018
+
1019
+ this.sendStart({
1020
+ adPartner: 'aws-mediatailor',
1021
+ adPosition: activeAdBreak.adPosition,
1022
+ });
1023
+ activePod.hasFiredStart = true;
1024
+ }
1025
+
1026
+ // Track quartiles for pod
1027
+ const podProgress = currentTime - activePod.startTime;
1028
+ this.trackQuartiles(activePod, podProgress);
1029
+ }
1030
+ } else {
1031
+ // No pods - treat entire break as single ad
1032
+ if (!activeAdBreak.hasFiredAdStart) {
1033
+ console.log(`[MT - ${getTimestamp()}] → AD_START (no pods)`, {
1034
+ startTime: activeAdBreak.startTime,
1035
+ duration: activeAdBreak.duration,
1036
+ position: activeAdBreak.adPosition,
1037
+ });
1038
+
1039
+ // Send AD_REQUEST before AD_START (required sequence)
1040
+ this.sendRequest({
1041
+ adPartner: 'aws-mediatailor',
1042
+ adPosition: activeAdBreak.adPosition,
1043
+ });
1044
+
1045
+ // Send AD_START
1046
+ this.sendStart({
1047
+ adPartner: 'aws-mediatailor',
1048
+ adPosition: activeAdBreak.adPosition,
1049
+ });
1050
+ activeAdBreak.hasFiredAdStart = true;
1051
+ }
1052
+
1053
+ // Track quartiles for entire break
1054
+ const adProgress = currentTime - activeAdBreak.startTime;
1055
+ this.trackQuartiles(activeAdBreak, adProgress);
1056
+ }
1057
+ } else if (this.currentAdBreak) {
1058
+ // === EXITING AD BREAK ===
1059
+
1060
+ // End last pod
1061
+ if (this.currentAdPod) {
1062
+ console.log(`[MT - ${getTimestamp()}] → AD_END (final pod)`);
1063
+ this.sendEnd();
1064
+ this.currentAdPod = null;
1065
+ }
1066
+
1067
+ // End ad break
1068
+ if (!this.currentAdBreak.hasFiredEnd) {
1069
+ console.log(`[MT - ${getTimestamp()}] → AD_BREAK_END`);
1070
+ this.sendAdBreakEnd();
1071
+ this.currentAdBreak.hasFiredEnd = true;
1072
+ }
1073
+
1074
+ this.currentAdBreak = null;
1075
+ this.setIsAd(false); // Switch back to content mode
1076
+ console.log(`[MT - ${getTimestamp()}] setIsAd(false) - Exiting ad break`);
1077
+
1078
+ // Check if video has ended after exiting last ad break
1079
+ if (this.player.ended() && !this.hasEndedContent) {
1080
+ console.log(
1081
+ `[MT - ${getTimestamp()}] Video ended after last ad → CONTENT_END`,
1082
+ );
1083
+ this.sendContentEnd();
1084
+ this.hasEndedContent = true;
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ /**
1090
+ * Sends CONTENT_END event via parent content tracker
1091
+ */
1092
+ sendContentEnd() {
1093
+ if (this.parentTracker) {
1094
+ this.parentTracker.sendEnd();
1095
+ } else {
1096
+ super.sendEnd();
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * Generic handler for ad events - only fires if currently in an ad break
1102
+ * @param {string} eventName - The event name for logging (e.g., 'AD_PAUSE')
1103
+ * @param {Function} sendMethod - The method to call (e.g., this.sendPause)
1104
+ */
1105
+ handleAdEvent(eventName, sendMethod) {
1106
+ if (this.isAd()) {
1107
+ console.log(`[MT - ${getTimestamp()}] → ${eventName}`);
1108
+ sendMethod.call(this);
1109
+ }
1110
+ }
1111
+
1112
+ /**
1113
+ * Handle pause events - sends AD_PAUSE only when ads are playing
1114
+ */
1115
+ onPause() {
1116
+ this.handleAdEvent('AD_PAUSE', this.sendPause);
1117
+ }
1118
+
1119
+ /**
1120
+ * Handle playing events - sends AD_RESUME only when ads are playing
1121
+ */
1122
+ onPlaying() {
1123
+ if (this.isAd()) {
1124
+ console.log(`[MT - ${getTimestamp()}] → AD_RESUME`);
1125
+ this.sendResume();
1126
+ this.sendBufferEnd(); // Playing event also ends any buffering
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * Handle seeking events - sends AD_SEEK_START only when ads are playing
1132
+ */
1133
+ onSeeking() {
1134
+ this.handleAdEvent('AD_SEEK_START', this.sendSeekStart);
1135
+ }
1136
+
1137
+ /**
1138
+ * Handle seeked events - sends AD_SEEK_END only when ads are playing
1139
+ */
1140
+ onSeeked() {
1141
+ this.handleAdEvent('AD_SEEK_END', this.sendSeekEnd);
1142
+ }
1143
+
1144
+ /**
1145
+ * Handle waiting (buffering) events - sends AD_BUFFER_START only when ads are playing
1146
+ */
1147
+ onWaiting() {
1148
+ this.handleAdEvent('AD_BUFFER_START', this.sendBufferStart);
1149
+ }
1150
+
1151
+ /**
1152
+ * Override: Fire CONTENT_END when video ends
1153
+ */
1154
+ onEnded() {
1155
+ if (!this.hasEndedContent) {
1156
+ console.log(`[MT - ${getTimestamp()}] Video ended → CONTENT_END`);
1157
+ this.sendContentEnd();
1158
+ this.hasEndedContent = true;
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * Returns ad title for New Relic
1164
+ */
1165
+ getTitle() {
1166
+ if (this.currentAdPod) {
1167
+ return this.currentAdPod.title || this.currentAdBreak?.id || null;
1168
+ }
1169
+ return this.currentAdBreak?.title || this.currentAdBreak?.id || null;
1170
+ }
1171
+
1172
+ /**
1173
+ * Returns ad ID for New Relic (adId attribute)
1174
+ */
1175
+ getVideoId() {
1176
+ if (this.currentAdPod) {
1177
+ return (
1178
+ this.currentAdPod.creativeId ||
1179
+ this.currentAdPod.title ||
1180
+ this.currentAdBreak?.id ||
1181
+ null
1182
+ );
1183
+ }
1184
+ return this.currentAdBreak?.creativeId || this.currentAdBreak?.id || null;
1185
+ }
1186
+
1187
+ /**
1188
+ * Returns ad source URL for New Relic (adSrc attribute)
1189
+ */
1190
+ getSrc() {
1191
+ // MediaTailor doesn't provide individual creative URLs
1192
+ return this.trackingEndpointUrl || this.playbackManifestUrl || null;
1193
+ }
1194
+
1195
+ /**
1196
+ * Returns ad duration in milliseconds for New Relic
1197
+ */
1198
+ getDuration() {
1199
+ if (this.currentAdPod) {
1200
+ return this.currentAdPod.duration * 1000;
1201
+ }
1202
+ return this.currentAdBreak ? this.currentAdBreak.duration * 1000 : null;
1203
+ }
1204
+
1205
+ /**
1206
+ * Stops polling timers
1207
+ */
1208
+ stopPolling() {
1209
+ if (this.manifestPollTimer) {
1210
+ clearInterval(this.manifestPollTimer);
1211
+ this.manifestPollTimer = null;
1212
+ }
1213
+
1214
+ if (this.trackingPollTimer) {
1215
+ clearInterval(this.trackingPollTimer);
1216
+ this.trackingPollTimer = null;
1217
+ }
1218
+
1219
+ console.log(`[MT - ${getTimestamp()}] Polling stopped`);
1220
+ }
1221
+
1222
+ /**
1223
+ * Cleanup when tracker is destroyed
1224
+ */
1225
+ dispose() {
1226
+ console.log(`[MT - ${getTimestamp()}] Disposing MediaTailorAdsTracker`);
1227
+
1228
+ this.isDisposed = true;
1229
+
1230
+ if (this.trackingAbortController) {
1231
+ console.log(`[MT - ${getTimestamp()}] Aborting in-flight tracking fetch`);
1232
+ this.trackingAbortController.abort();
1233
+ this.trackingAbortController = null;
1234
+ }
1235
+
1236
+ if (this.manifestAbortController) {
1237
+ console.log(`[MT - ${getTimestamp()}] Aborting in-flight manifest fetch`);
1238
+ this.manifestAbortController.abort();
1239
+ this.manifestAbortController = null;
1240
+ }
1241
+
1242
+ this.stopLivePolling();
1243
+ this.unregisterListeners();
1244
+ super.dispose && super.dispose();
1245
+ }
1246
+ }