@newrelic/video-videojs 4.1.2 → 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.
@@ -36,7 +36,8 @@ export const DASH_SCTE35_EVENT_STREAM_SELECTOR =
36
36
  export const HLS_MIME_TYPE = 'application/vnd.apple.mpegurl'; // MIME type used to detect Safari/native HLS playback support
37
37
 
38
38
  // MediaTailor URL Patterns
39
- export const MT_SEGMENT_PATTERN = 'segments.mediatailor'; // Identifies MediaTailor ad segments
39
+ export const MT_SEGMENT_PATTERN = 'segments.mediatailor'; // Identifies MediaTailor ad segments (AWS default hostname)
40
+ export const MT_DEFAULT_AD_SEGMENT_PATH = '/tm/'; // Default ad-segment path per AWS CDN integration guide (custom-CDN setups)
40
41
 
41
42
  export const TRACKING_API_TIMEOUT_MS = 5000; // Keep tracking metadata requests within the guide's 5s end-to-end budget
42
43
  export const DEFAULT_LIVE_POLL_INTERVAL_MS = 5000; // Temporary fallback until manifest metadata provides cadence
@@ -3,6 +3,11 @@
3
3
  * Helper functions for AWS MediaTailor ad tracking
4
4
  */
5
5
 
6
+ import nrvideo from '@newrelic/video-core';
7
+
8
+ const nrvideoCore = nrvideo.default || nrvideo;
9
+ const Log = nrvideoCore.Log;
10
+
6
11
  import {
7
12
  DASH_MANIFEST_EXTENSION,
8
13
  DASH_SCTE35_EVENT_STREAM_SELECTOR,
@@ -21,6 +26,7 @@ import {
21
26
  MT_HLS_CUE_IN_TAG,
22
27
  MT_HLS_CUE_OUT_TAG,
23
28
  MT_SEGMENT_PATTERN,
29
+ MT_DEFAULT_AD_SEGMENT_PATH,
24
30
  MIN_AD_DURATION,
25
31
  AD_TIMING_TOLERANCE,
26
32
  SCTE35_SCHEME_MARKER,
@@ -84,16 +90,30 @@ export function buildTrackingEndpointUrl(manifestUrl) {
84
90
  /**
85
91
  * Checks if segment is a MediaTailor ad segment
86
92
  */
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;
93
+ export function isMediaTailorSegment(segment, { adSegmentPrefix } = {}) {
94
+ const candidates = [MT_SEGMENT_PATTERN, MT_DEFAULT_AD_SEGMENT_PATH];
95
+ if (adSegmentPrefix) candidates.push(adSegmentPrefix);
96
+
97
+ const mapUri = (segment.map && segment.map.uri) || '';
98
+ const segUri = segment.uri || '';
99
+
100
+ return candidates.some(
101
+ (marker) => mapUri.includes(marker) || segUri.includes(marker),
102
+ );
103
+ }
104
+
105
+ export function whichAdSegmentMarker(segment, { adSegmentPrefix } = {}) {
106
+ const labeled = [
107
+ { marker: MT_SEGMENT_PATTERN, label: 'aws-hostname (segments.mediatailor)' },
108
+ { marker: MT_DEFAULT_AD_SEGMENT_PATH, label: 'default-cdn-path (/tm/)' },
109
+ ];
110
+ if (adSegmentPrefix) labeled.push({ marker: adSegmentPrefix, label: `custom-prefix (${adSegmentPrefix})` });
111
+
112
+ const mapUri = (segment.map && segment.map.uri) || '';
113
+ const segUri = segment.uri || '';
114
+
115
+ const hit = labeled.find(({ marker }) => mapUri.includes(marker) || segUri.includes(marker));
116
+ return hit ? hit.label : null;
97
117
  }
98
118
 
99
119
  /**
@@ -350,7 +370,7 @@ export function parseHlsManifestForAdBreaks(manifestText) {
350
370
  /**
351
371
  * Detects ads from VHS playlist using discontinuityStarts and MediaTailor segments
352
372
  */
353
- export function detectAdBreaksFromVhsPlaylist(playlist) {
373
+ export function detectAdBreaksFromVhsPlaylist(playlist, { adSegmentPrefix } = {}) {
354
374
  const segments = playlist.segments;
355
375
  const discontinuityStarts = playlist.discontinuityStarts || [];
356
376
  const adBreaks = [];
@@ -360,7 +380,7 @@ export function detectAdBreaksFromVhsPlaylist(playlist) {
360
380
  let currentTime = 0;
361
381
 
362
382
  segments.forEach((segment, index) => {
363
- const isMTSegment = isMediaTailorSegment(segment);
383
+ const isMTSegment = isMediaTailorSegment(segment, { adSegmentPrefix });
364
384
  const hasDiscontinuity = discontinuityStarts.includes(index);
365
385
 
366
386
  if (isMTSegment) {
@@ -627,19 +647,19 @@ export function parseIsoDuration(durationStr) {
627
647
  * SINGLE_PERIOD: the entire stream is one period; ads are signalled via
628
648
  * SCTE-35 <EventStream> elements inside that period.
629
649
  */
630
- export function parseDashManifestForAdBreaks(xmlText) {
650
+ export function parseDashManifestForAdBreaks(xmlText, { adSegmentPrefix } = {}) {
631
651
  const parser = new DOMParser();
632
652
  const xml = parser.parseFromString(xmlText, 'text/xml');
633
653
  const ads = [];
634
654
 
635
655
  const parserError = xml.querySelector('parsererror');
636
656
  if (parserError) {
637
- console.error('[MT] DASH XML parse error:', parserError.textContent);
657
+ Log.error('[MT] DASH XML parse error:', parserError.textContent);
638
658
  return ads;
639
659
  }
640
660
 
641
661
  const periods = xml.querySelectorAll('Period');
642
- console.log(`[MT] Found ${periods.length} Period(s) in DASH manifest`);
662
+ Log.debug(`[MT] Found ${periods.length} Period(s) in DASH manifest`);
643
663
 
644
664
  if (periods.length > 1) {
645
665
  // ── MULTI_PERIOD ──────────────────────────────────────────────────────────
@@ -648,7 +668,9 @@ export function parseDashManifestForAdBreaks(xmlText) {
648
668
  const baseUrlEl = period.querySelector('BaseURL');
649
669
  const baseUrl = baseUrlEl ? baseUrlEl.textContent.trim() : '';
650
670
 
651
- if (!baseUrl.includes(MT_SEGMENT_PATTERN)) {
671
+ const adCandidates = [MT_SEGMENT_PATTERN, MT_DEFAULT_AD_SEGMENT_PATH];
672
+ if (adSegmentPrefix) adCandidates.push(adSegmentPrefix);
673
+ if (!adCandidates.some((m) => baseUrl.includes(m))) {
652
674
  return; // content period
653
675
  }
654
676
 
@@ -657,11 +679,11 @@ export function parseDashManifestForAdBreaks(xmlText) {
657
679
  const duration = parseIsoDuration(period.getAttribute('duration') || '');
658
680
 
659
681
  if (duration < MIN_AD_DURATION) {
660
- console.log(`[MT] Skipping period ${periodId} - duration too short (${duration}s)`);
682
+ Log.debug(`[MT] Skipping period ${periodId} - duration too short (${duration}s)`);
661
683
  return;
662
684
  }
663
685
 
664
- console.log(`[MT] Ad period detected: ${periodId}`, { startTime, duration });
686
+ Log.debug(`[MT] Ad period detected: ${periodId}`, { startTime, duration });
665
687
 
666
688
  ads.push({
667
689
  id: periodId,
@@ -686,7 +708,7 @@ export function parseDashManifestForAdBreaks(xmlText) {
686
708
  DASH_SCTE35_EVENT_STREAM_SELECTOR,
687
709
  );
688
710
 
689
- console.log(`[MT] Found ${eventStreams.length} SCTE-35 EventStream(s) in single-period manifest`);
711
+ Log.debug(`[MT] Found ${eventStreams.length} SCTE-35 EventStream(s) in single-period manifest`);
690
712
 
691
713
  eventStreams.forEach((stream) => {
692
714
  const timescale = parseFloat(stream.getAttribute('timescale') || '1');
@@ -702,11 +724,11 @@ export function parseDashManifestForAdBreaks(xmlText) {
702
724
  const durationSeconds = timescale !== 1 ? duration / timescale : duration;
703
725
 
704
726
  if (durationSeconds < MIN_AD_DURATION) {
705
- console.log(`[MT] Skipping event ${eventId} - duration too short (${durationSeconds}s)`);
727
+ Log.debug(`[MT] Skipping event ${eventId} - duration too short (${durationSeconds}s)`);
706
728
  return;
707
729
  }
708
730
 
709
- console.log(`[MT] SCTE-35 event detected: ${eventId}`, { startTime, durationSeconds });
731
+ Log.debug(`[MT] SCTE-35 event detected: ${eventId}`, { startTime, durationSeconds });
710
732
 
711
733
  ads.push({
712
734
  id: eventId,
@@ -727,7 +749,7 @@ export function parseDashManifestForAdBreaks(xmlText) {
727
749
  });
728
750
  }
729
751
 
730
- console.log(`[MT] Parsed ${ads.length} valid ad break(s) from DASH manifest`);
752
+ Log.debug(`[MT] Parsed ${ads.length} valid ad break(s) from DASH manifest`);
731
753
  return ads;
732
754
  }
733
755
 
@@ -12,8 +12,8 @@ if (typeof videojs !== 'undefined') {
12
12
  */
13
13
  registerPlugin('newrelic', function (options) {
14
14
  if (!this.newrelictracker) {
15
- this.newrelictracker = new nrvideo.VideojsTracker(this);
16
- nrvideo.Core.addTracker(this.newrelictracker);
15
+ this.newrelictracker = new nrvideo.VideojsTracker(this, options);
16
+ nrvideo.Core.addTracker(this.newrelictracker, options);
17
17
  }
18
18
  return this.newrelictracker;
19
19
  });
package/src/tracker.js CHANGED
@@ -10,6 +10,30 @@ import FreewheelAdsTracker from './ads/freewheel';
10
10
  import DaiAdsTracker from './ads/dai';
11
11
  import MediaTailorAdsTracker from './ads/media-tailor';
12
12
 
13
+ /**
14
+ * Explicit ad tracking modes. Pass via config.ad.type.
15
+ *
16
+ * CSAI (Client-Side Ad Insertion)
17
+ * All CSAI frameworks share the adsready event path. The tracker auto-detects
18
+ * the right framework (Brightcove IMA → IMA → Freewheel → generic) — no sub-type needed.
19
+ *
20
+ * SSAI (Server-Side Ad Insertion) — sub-type is always required.
21
+ * Each SSAI platform has its own SDK and a different activation path.
22
+ * Cannot be auto-detected — declare which one you are using.
23
+ * SSAI.DAI — Google DAI (google.ima.dai.api)
24
+ * SSAI.MT — AWS MediaTailor
25
+ *
26
+ * If config.ad is not set, no ad tracking runs (safe default).
27
+ * To add a new SSAI platform: add a key to SSAI — validation derives from this object.
28
+ */
29
+ export const AD_TRACKING = {
30
+ CSAI: 'csai',
31
+ SSAI: Object.freeze({
32
+ DAI: 'ssai:dai',
33
+ MT: 'ssai:mt',
34
+ }),
35
+ };
36
+
13
37
  export default class VideojsTracker extends nrvideo.VideoTracker {
14
38
  constructor(player, options) {
15
39
  super(player, options);
@@ -17,9 +41,53 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
17
41
  this.isContentEnd = false;
18
42
  this.imaAdCuePoints = '';
19
43
  this.daiInitialized = false;
44
+ this.adTracking = options?.config?.ad?.type || null;
45
+
46
+ // When options are provided but config.ad is not declared, no ad tracking
47
+ // will run. Warn so the caller knows to set it explicitly.
48
+ if (options && !this.adTracking) {
49
+ nrvideo.Log.warn(
50
+ 'VideojsTracker: config.ad not set — no ad tracking will run. ' +
51
+ 'Set config.ad.type to enable it (e.g. AD_TRACKING.CSAI, AD_TRACKING.SSAI.MT).'
52
+ );
53
+ }
54
+
55
+ // Merge config.ad options (segmentPrefix, trackingUrl) into the internal
56
+ // mediatailor object that MediaTailorAdsTracker reads.
57
+ if (this.adTracking === AD_TRACKING.SSAI.MT) {
58
+ const adConfig = options?.config?.ad || {};
59
+ this.options = Object.assign({}, this.options, {
60
+ mediatailor: {
61
+ segmentPrefix: adConfig.segmentPrefix,
62
+ trackingUrl: adConfig.trackingUrl,
63
+ ...this.options?.mediatailor,
64
+ },
65
+ });
66
+ }
67
+
20
68
  nrvideo.Core.addTracker(this, options);
21
69
  }
22
70
 
71
+ /**
72
+ * Sets the ad tracking mode at runtime.
73
+ * Must be called before the first loadstart / adsready event to take effect.
74
+ * @param {'csai'|'ssai:dai'|'ssai:mt'} type
75
+ */
76
+ setAdTracking(type) {
77
+ const valid = [
78
+ AD_TRACKING.CSAI,
79
+ ...Object.values(AD_TRACKING.SSAI),
80
+ ];
81
+ if (!valid.includes(type)) {
82
+ nrvideo.Log.warn(
83
+ `VideojsTracker.setAdTracking: unknown value "${type}". Valid values: ${valid.join(', ')}`
84
+ );
85
+ return;
86
+ }
87
+ this.adTracking = type;
88
+ nrvideo.Log.debug(`VideojsTracker: adTracking set to "${type}"`);
89
+ }
90
+
23
91
  getTech() {
24
92
  let tech = this.player.tech({ IWillNotUseThisInPlugins: true });
25
93
 
@@ -126,7 +194,8 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
126
194
  if (tech?.vhs?.playlists?.media()) {
127
195
  const activePlaylist = tech.vhs.playlists.media();
128
196
  // Use AVERAGE-BANDWIDTH if available, fallback to BANDWIDTH
129
- const bitrate = activePlaylist.attributes['AVERAGE-BANDWIDTH'] ||
197
+ const bitrate =
198
+ activePlaylist.attributes['AVERAGE-BANDWIDTH'] ||
130
199
  activePlaylist.attributes.BANDWIDTH ||
131
200
  null;
132
201
  return bitrate !== null ? Math.round(bitrate) : null;
@@ -276,6 +345,7 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
276
345
  this.onTimeupdate = this.onTimeupdate.bind(this);
277
346
  this.OnAdsAllpodsCompleted = this.OnAdsAllpodsCompleted.bind(this);
278
347
  this.onStreamManager = this.onStreamManager.bind(this);
348
+ this.onCanPlayThrough = this.onCanPlayThrough.bind(this);
279
349
 
280
350
  this.player.on('loadstart', this.onDownload);
281
351
  this.player.on('loadeddata', this.onDownload);
@@ -296,6 +366,7 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
296
366
  this.player.on('timeupdate', this.onTimeupdate);
297
367
  this.player.on('ads-allpods-completed', this.OnAdsAllpodsCompleted);
298
368
  this.player.on('stream-manager', this.onStreamManager);
369
+ this.player.on('canplaythrough', this.onCanPlayThrough);
299
370
  }
300
371
 
301
372
  unregisterListeners() {
@@ -318,30 +389,31 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
318
389
  this.player.off('timeupdate', this.onTimeupdate);
319
390
  this.player.off('ads-allpods-completed', this.OnAdsAllpodsCompleted);
320
391
  this.player.off('stream-manager', this.onStreamManager);
392
+ this.player.off('canplaythrough', this.onCanPlayThrough);
321
393
  }
322
394
 
323
395
  onDownload(e) {
324
396
  this.sendDownload({ state: e.type });
325
397
 
326
- // Check if MediaTailor should be used after the source is loaded
327
- // Only check on 'loadstart' to avoid multiple checks
328
398
  if (
399
+ this.adTracking === AD_TRACKING.SSAI.MT &&
329
400
  !this.adsTracker &&
330
- e.type === 'loadstart' &&
331
- MediaTailorAdsTracker.isUsing(this.player)
401
+ e.type === 'loadstart'
332
402
  ) {
333
- console.log(
334
- 'VideojsTracker: Creating MediaTailorAdsTracker after source load'
335
- );
403
+ nrvideo.Log.debug('VideojsTracker: Creating MediaTailorAdsTracker');
336
404
  this.setAdsTracker(new MediaTailorAdsTracker(this.player, this.options));
337
- // MediaTailor SSAI starts with content, not ads (unlike client-side ad frameworks)
405
+ // MediaTailor SSAI starts with content, not ads (unlike client-side frameworks)
338
406
  this.adsTracker.setIsAd(false);
339
407
  }
340
408
  }
341
409
 
342
410
  // DAI methods
343
411
  onStreamManager(event) {
344
- if (!this.adsTracker && event.StreamManager) {
412
+ if (
413
+ this.adTracking === AD_TRACKING.SSAI.DAI &&
414
+ !this.adsTracker &&
415
+ event.StreamManager
416
+ ) {
345
417
  const daiTracker = new DaiAdsTracker(this.player);
346
418
  daiTracker.setStreamManager(event.StreamManager);
347
419
  this.setAdsTracker(daiTracker);
@@ -350,20 +422,29 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
350
422
  // DAI methods end
351
423
 
352
424
  onAdsready() {
425
+ // SSAI platforms never fire adsready — skip when an SSAI type is explicitly set.
426
+ if (Object.values(AD_TRACKING.SSAI).includes(this.adTracking)) return;
427
+
428
+ // config.ad.type is not set — warn and fall back to auto-detection.
429
+ if (!this.adTracking) {
430
+ nrvideo.Log.warn(
431
+ 'VideojsTracker: adsready fired but config.ad.type is not set — ' +
432
+ 'attempting CSAI auto-detection for backward compatibility.'
433
+ );
434
+ }
435
+
353
436
  if (!this.adsTracker) {
354
437
  if (BrightcoveImaAdsTracker.isUsing(this.player)) {
355
- // BC IMA
438
+ nrvideo.Log.debug('VideojsTracker: auto-detected BrightcoveImaAdsTracker');
356
439
  this.setAdsTracker(new BrightcoveImaAdsTracker(this.player));
357
440
  } else if (ImaAdsTracker.isUsing(this.player)) {
358
- // IMA
441
+ nrvideo.Log.debug('VideojsTracker: auto-detected ImaAdsTracker');
359
442
  this.setAdsTracker(new ImaAdsTracker(this.player));
360
443
  } else if (FreewheelAdsTracker.isUsing(this.player)) {
361
- // FW
362
-
444
+ nrvideo.Log.debug('VideojsTracker: auto-detected FreewheelAdsTracker');
363
445
  this.setAdsTracker(new FreewheelAdsTracker(this.player));
364
- // } else if (OnceAdsTracker.isUsing(this)) { // Once
365
446
  } else {
366
- // Generic
447
+ nrvideo.Log.debug('VideojsTracker: no specific CSAI framework detected, using generic VideojsAdsTracker');
367
448
  this.setAdsTracker(new VideojsAdsTracker(this.player));
368
449
  }
369
450
  }
@@ -393,6 +474,11 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
393
474
  * @returns {boolean} True if ads are playing
394
475
  */
395
476
  isAdsTrackerActive() {
477
+ // For CSAI: videojs-contrib-ads manages ad state exclusively
478
+ if (this.player.ads) {
479
+ return this.player.ads.state === 'ads-playback';
480
+ }
481
+ // For SSAI (no player.ads): check tracker's isAd flag (manually toggled)
396
482
  return this.adsTracker && this.adsTracker.isAd && this.adsTracker.isAd();
397
483
  }
398
484
 
@@ -412,6 +498,16 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
412
498
  this.sendPause();
413
499
  }
414
500
 
501
+ onCanPlayThrough() {
502
+ // Don't send CONTENT_BUFFER_END if ads are playing (ads tracker handles it)
503
+ if (this.isAdsTrackerActive()) {
504
+ return;
505
+ }
506
+ // Send BUFFER_END when buffer fills, even if paused
507
+ // This ensures accurate buffer duration metrics when user pauses during buffering
508
+ this.sendBufferEnd();
509
+ }
510
+
415
511
  onPlaying() {
416
512
  // Don't send CONTENT_RESUME if ads are playing (ads tracker handles it)
417
513
  if (this.isAdsTrackerActive()) {
@@ -481,6 +577,11 @@ export default class VideojsTracker extends nrvideo.VideoTracker {
481
577
  }
482
578
  }
483
579
 
580
+ // Attach AD_TRACKING and Log as static properties so UMD callers can use
581
+ // VideojsTracker.AD_TRACKING and VideojsTracker.Log without importing named exports.
582
+ VideojsTracker.AD_TRACKING = AD_TRACKING;
583
+ VideojsTracker.Log = nrvideo.Log;
584
+
484
585
  // Static members
485
586
  export {
486
587
  HlsJsTech,
@@ -490,5 +591,6 @@ export {
490
591
  ImaAdsTracker,
491
592
  BrightcoveImaAdsTracker,
492
593
  FreewheelAdsTracker,
594
+ DaiAdsTracker,
493
595
  MediaTailorAdsTracker,
494
596
  };