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