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