@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.
- package/CHANGELOG.md +28 -0
- package/README.md +68 -3
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.LICENSE.txt +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.LICENSE.txt +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/umd/newrelic-video-videojs.min.js +1 -1
- package/dist/umd/newrelic-video-videojs.min.js.LICENSE.txt +3 -1
- package/dist/umd/newrelic-video-videojs.min.js.map +1 -1
- package/package.json +9 -2
- package/src/ads/dai.js +6 -3
- package/src/ads/ima.js +3 -2
- package/src/ads/media-tailor.js +1246 -0
- package/src/ads/utils/mt-constants.js +82 -0
- package/src/ads/utils/mt.js +792 -0
- package/src/techs/contrib-hls.js +7 -8
- package/src/techs/hls-js.js +5 -5
- package/src/techs/shaka.js +7 -6
- package/src/tracker.js +64 -12
|
@@ -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
|
+
}
|