npaw-plugin-adapters
Version:
NPAW's Plugin Adapters
1,255 lines (1,098 loc) • 43.8 kB
JavaScript
/**
* NPAW Ads Adapter for Nowtilus SSAI (Server-Side Ad Insertion)
*
* This adapter integrates with MediaMelon's mmNowtilusSSAIPlugin to track
* server-side ad insertion events and report them to NPAW analytics.
*
* Usage:
* const nowtilusAdPlugin = new mmNowtilusSSAIPlugin(videoElement);
* const adsAdapter = npawPlugin.registerAdsAdapter(nowtilusAdPlugin, NowtilusAdsAdapter);
*
* The adapter will automatically listen to Nowtilus SSAI events and fire
* corresponding NPAW analytics events.
*/
export default class NowtilusAdsAdapter {
/* ──────────────────────────────────────────────────────────────── */
/* Public adapter methods */
/* ──────────────────────────────────────────────────────────────── */
isUsed() {
// Check if the Nowtilus SSAI plugin is available and has the required interface
// Note: this.player is the video element, this.nowtilusPlugin is the Nowtilus instance
return this.nowtilusPlugin && typeof this.nowtilusPlugin.addListener === 'function';
}
setNowtilusPlugin(nowtilusPlugin) {
if (
this._registeredNowtilusPlugin &&
this._registeredNowtilusPlugin !== nowtilusPlugin &&
this._areNowtilusListenersRegistered &&
typeof this._registeredNowtilusPlugin.removeListener === 'function'
) {
for (const eventName in this._nowtilusListeners) {
if (Object.prototype.hasOwnProperty.call(this._nowtilusListeners, eventName)) {
this._registeredNowtilusPlugin.removeListener(eventName, this._nowtilusListeners[eventName]);
}
}
this._areNowtilusListenersRegistered = false;
this._registeredNowtilusPlugin = null;
}
if (nowtilusPlugin && typeof nowtilusPlugin.addListener === 'function') {
this.nowtilusPlugin = nowtilusPlugin;
}
this.registerListeners();
}
getVersion() {
return '7.0.1-nowtilus-ssai-jsclass';
}
getPlayerName() {
return 'Nowtilus SSAI';
}
getPlayerVersion() {
// Try to get version from Nowtilus plugin if available
if (this.player && this.player.version) {
return this.player.version;
}
return undefined;
}
getAdInsertionType() {
return this.getNpawReference().Constants.AdInsertionType.ServerSide;
}
getExpectedAds() {
if (this._adTimeline) {
return this._adTimeline.length;
}
return undefined;
}
getProvider() {
// Extract ad provider/server from adInfo
if (this._currentAd && this._currentAd.adServer) {
return this._currentAd.adServer;
}
return undefined;
}
getCreativeId() {
// Extract creative ID from adInfo structure
if (this._currentAd && this._currentAd.creativeId) {
return this._currentAd.creativeId;
}
return undefined;
}
getCampaign() {
// Extract campaign/insertion ID from adInfo
if (this._currentAd && this._currentAd.campaignId) {
return this._currentAd.campaignId;
}
return undefined;
}
getGivenBreaks() {
if (!this._hasConsistentManifestMetrics()) {
return undefined;
}
// Return number of breaks given
if (this._manifestBreaks) {
return this._manifestBreaks.length;
}
if (this._breakCount !== undefined) {
return this._breakCount;
}
return undefined;
}
getBreaksTime() {
if (!this._hasConsistentManifestMetrics()) {
return undefined;
}
// Return array of break start times
if (this._manifestBreaks) {
const manifestTimes = this._manifestBreaks.map((b) => b.offset).filter((offset) => Number.isFinite(offset));
if (manifestTimes.length > 0) {
return manifestTimes;
}
}
if (this._breakTimes && this._breakTimes.length > 0) {
return this._breakTimes.filter((offset) => Number.isFinite(offset));
}
return undefined;
}
getExpectedBreaks() {
if (!this._hasConsistentManifestMetrics()) {
return undefined;
}
if (this._manifestBreaks) {
return this._manifestBreaks.length;
}
return undefined;
}
getExpectedPattern() {
if (!this._hasConsistentManifestMetrics()) {
return undefined;
}
if (this._manifestPattern) {
return JSON.stringify(this._manifestPattern);
}
return undefined;
}
/* ──────────────────────────────────────────────────────────────── */
/* Ad Metrics Getters */
/* ──────────────────────────────────────────────────────────────── */
getPlayhead() {
// For SSAI, calculate ad playhead relative to the video time when ad started
if (this._adStartTime !== undefined) {
let currentTime = 0;
if (this.player && this.player.currentTime) {
currentTime = this.player.currentTime;
} else if (this.getVideo() && typeof this.getVideo().getPlayhead === 'function') {
currentTime = this.getVideo().getPlayhead();
}
const adPlayhead = currentTime - this._adStartTime;
return adPlayhead > 0 ? adPlayhead : 0;
}
return 0;
}
getDuration() {
if (this._currentAd && this._currentAd.duration !== undefined) {
// Nowtilus provides duration in milliseconds, convert to seconds
return this._currentAd.duration / 1000;
}
return undefined;
}
getTitle() {
if (!this._currentAd) {
return undefined;
}
const rawTitle = this._currentAd.adTitle && String(this._currentAd.adTitle).trim();
const rawDesc = this._currentAd.adDescription && String(this._currentAd.adDescription).trim();
const idStr = this._currentAd.adId !== undefined ? String(this._currentAd.adId) : '';
const campStr =
this._currentAd.campaignId !== undefined && this._currentAd.campaignId !== null
? String(this._currentAd.campaignId)
: '';
const titleIsOnlyDigits = rawTitle ? /^[0-9]+$/.test(rawTitle) : false;
const titleLooksLikeId = titleIsOnlyDigits && (rawTitle === idStr || rawTitle === campStr);
if (rawDesc && titleLooksLikeId) {
return rawDesc;
}
if (rawTitle && !titleLooksLikeId) {
return rawTitle;
}
if (rawDesc) {
return rawDesc;
}
if (rawTitle) {
return rawTitle;
}
if (this._currentAd.adId !== undefined) {
return String(this._currentAd.adId);
}
return undefined;
}
getPosition() {
if (this._currentBreak && this._currentBreak.position) {
return this._currentBreak.position;
}
if (this._currentAd && this._currentAd.position) {
return this._currentAd.position;
}
if (this.player) {
const videoAdapter = this.getVideo?.()?.getAdapter?.();
if (videoAdapter && !videoAdapter.flags.isJoined) {
return this.getNpawReference().Constants.AdPosition.Preroll;
}
if (this.getIsLive()) {
return this.getNpawReference().Constants.AdPosition.Midroll;
}
return this.adPosition || this.getNpawReference().Constants.AdPosition.Midroll;
}
return null;
}
getIsLive() {
const videoAdapter = this.getVideo?.()?.getAdapter?.();
if (videoAdapter && typeof videoAdapter.getIsLive === 'function') {
return videoAdapter.getIsLive();
}
return false;
}
getResource() {
// Prefer linear creative file URL (VAST mediaFiles); then SSAI manifest; then ids
if (this._currentAd && this._currentAd.mediaUrl) {
return this._currentAd.mediaUrl;
}
if (this._manifestUrl) {
return this._manifestUrl;
}
if (this._currentAd) {
return this._currentAd.creativeId || this._currentAd.adId;
}
return undefined;
}
getGivenAds() {
// Return the number of ads in the current break
if (this._currentBreak) {
if (this._currentBreak.observedAds > 0) {
return this._currentBreak.observedAds;
}
if (this._currentBreak.totalAds !== undefined) {
return this._currentBreak.totalAds;
}
}
return undefined;
}
/* ──────────────────────────────────────────────────────────────── */
/* Public configuration methods */
/* ──────────────────────────────────────────────────────────────── */
/**
* Set the manifest URL for resource tracking
* @param {string} url - The manifest URL
*/
setManifestUrl(url) {
if (url && typeof url === 'string') {
this._manifestUrl = url;
}
}
/* ──────────────────────────────────────────────────────────────── */
/* Listener registration / cleanup */
/* ──────────────────────────────────────────────────────────────── */
registerListeners() {
// Only initialize state on first call
if (!this._nowtilusListeners) {
this._currentAd = null;
this._currentBreak = null;
this._adTimeline = [];
this._manifestUrl = null;
this._nowtilusListeners = {}; // Use different property name to avoid NPAW framework interference
this._breakCount = 0;
this._breakTimes = [];
this._manifestBreaks = [];
this._manifestPattern = null;
this._areNowtilusListenersRegistered = false;
this._registeredNowtilusPlugin = null;
this._areVideoListenersRegistered = false;
this._lastAdIndexInBreak = null;
this._manifestSent = false;
this._activeAdSessionKey = null;
this._adPauseActive = false;
this._lastAdPauseAt = 0;
}
// If nowtilusPlugin is not set yet, bail out early
// (This happens when NPAW framework calls registerListeners before setNowtilusPlugin)
if (!this.nowtilusPlugin) {
return;
}
// Bind all event listeners (only if not already bound)
if (Object.keys(this._nowtilusListeners).length === 0) {
this._nowtilusListeners.onCueTimelineAdded = this._onCueTimelineAdded.bind(this);
this._nowtilusListeners.onCueTimelineEnter = this._onCueTimelineEnter.bind(this);
this._nowtilusListeners.impression = this._onImpression.bind(this);
this._nowtilusListeners.start = this._onAdStart.bind(this);
this._nowtilusListeners.firstQuartile = this._onFirstQuartile.bind(this);
this._nowtilusListeners.midpoint = this._onMidpoint.bind(this);
this._nowtilusListeners.thirdQuartile = this._onThirdQuartile.bind(this);
this._nowtilusListeners.complete = this._onAdComplete.bind(this);
this._nowtilusListeners.onCueTimelineExit = this._onCueTimelineExit.bind(this);
}
// Register listeners with the Nowtilus SSAI plugin
const alreadyRegistered =
this._areNowtilusListenersRegistered && this._registeredNowtilusPlugin === this.nowtilusPlugin;
if (this.nowtilusPlugin && typeof this.nowtilusPlugin.addListener === 'function' && !alreadyRegistered) {
// If listeners were previously registered in another plugin instance, remove them first.
if (
this._registeredNowtilusPlugin &&
this._registeredNowtilusPlugin !== this.nowtilusPlugin &&
typeof this._registeredNowtilusPlugin.removeListener === 'function'
) {
for (const eventName in this._nowtilusListeners) {
if (Object.prototype.hasOwnProperty.call(this._nowtilusListeners, eventName)) {
this._registeredNowtilusPlugin.removeListener(eventName, this._nowtilusListeners[eventName]);
}
}
}
for (const eventName in this._nowtilusListeners) {
if (Object.prototype.hasOwnProperty.call(this._nowtilusListeners, eventName)) {
const listener = this._nowtilusListeners[eventName];
if (typeof listener === 'function') {
this.nowtilusPlugin.addListener(eventName, listener);
}
}
}
this._areNowtilusListenersRegistered = true;
this._registeredNowtilusPlugin = this.nowtilusPlugin;
}
// Register video listeners for Buffer/Pause tracking
if (this.player && typeof this.player.addEventListener === 'function') {
if (!this._areVideoListenersRegistered) {
this._videoListeners = {
pause: this._onVideoPause.bind(this),
play: this._onVideoPlay.bind(this),
waiting: this._onVideoWaiting.bind(this),
stalled: this._onVideoStalled.bind(this),
playing: this._onVideoPlaying.bind(this),
error: this._onVideoError.bind(this)
};
// Use capture: true to intercept events before the main video adapter sees them
this.player.addEventListener('pause', this._videoListeners.pause, true);
this.player.addEventListener('play', this._videoListeners.play, true);
this.player.addEventListener('waiting', this._videoListeners.waiting, true);
this.player.addEventListener('stalled', this._videoListeners.stalled, true);
this.player.addEventListener('playing', this._videoListeners.playing, true);
this.player.addEventListener('error', this._videoListeners.error, true);
this._areVideoListenersRegistered = true;
}
}
}
unregisterListeners() {
const shouldPreserveRuntimeState = !!this._currentAd || !!this._currentBreak;
// Remove all registered listeners
if (this.nowtilusPlugin && typeof this.nowtilusPlugin.removeListener === 'function') {
for (const eventName in this._nowtilusListeners) {
if (Object.prototype.hasOwnProperty.call(this._nowtilusListeners, eventName)) {
this.nowtilusPlugin.removeListener(eventName, this._nowtilusListeners[eventName]);
}
}
}
this._areNowtilusListenersRegistered = false;
this._registeredNowtilusPlugin = null;
// Remove video listeners
if (this.player && typeof this.player.removeEventListener === 'function' && this._videoListeners) {
this.player.removeEventListener('pause', this._videoListeners.pause, true);
this.player.removeEventListener('play', this._videoListeners.play, true);
this.player.removeEventListener('waiting', this._videoListeners.waiting, true);
this.player.removeEventListener('stalled', this._videoListeners.stalled, true);
this.player.removeEventListener('playing', this._videoListeners.playing, true);
this.player.removeEventListener('error', this._videoListeners.error, true);
this._areVideoListenersRegistered = false;
}
// Clean up state
this._areNowtilusListenersRegistered = false;
this._registeredNowtilusPlugin = null;
// Dash streams may transiently unregister/re-register while playback continues.
// Preserve runtime ad state in that case to avoid false ad/break closures.
if (!shouldPreserveRuntimeState) {
this._currentAd = null;
this._currentBreak = null;
this._adTimeline = [];
this._nowtilusListeners = {};
this._breakCount = 0;
this._breakTimes = [];
this._manifestBreaks = [];
this._manifestPattern = null;
this._manifestUrl = null;
this._adStartTime = undefined;
this._lastAdIndexInBreak = null;
this._manifestSent = false;
this._activeAdSessionKey = null;
this._adPauseActive = false;
this._lastAdPauseAt = 0;
}
}
/* ──────────────────────────────────────────────────────────────── */
/* Nowtilus Event Handlers */
/* ──────────────────────────────────────────────────────────────── */
/**
* Called when a new ad timeline is added (upcoming ad break detected)
* @param {Object} adinfo - Information about the current ad
* @param {Array} adTimeline - Array of ad info objects for the break
*/
_onCueTimelineAdded(adinfo, adTimeline) {
if (adTimeline && Array.isArray(adTimeline)) {
this._adTimeline = adTimeline;
this._parseManifest(adTimeline);
}
}
/**
* Called when entering an ad break
* @param {Object} adinfo - Information about the ad
*/
_onCueTimelineEnter(adinfo) {
// Ensure video analytics is initialized before starting ad break
if (this._npawVideo && !this._npawVideo.isInitiated) {
this._npawVideo.fireInit();
}
// Determine ad position (pre-roll, mid-roll, post-roll)
const position = this._determineAdPosition(adinfo);
// Dynamic Manifest Update: Check if this break is new
const offset = this._getAdOffset(adinfo) !== null ? this._getAdOffset(adinfo) : 0;
let breakExists = false;
if (this._manifestBreaks) {
breakExists = this._manifestBreaks.some((b) => Math.abs(b.offset - offset) < 1);
}
if (!breakExists) {
const newBreak = {
offset: offset,
ads: [], // ads array unknown yet
totalAds: adinfo.totalAds || 1,
position: position
};
if (!this._manifestBreaks) this._manifestBreaks = [];
this._manifestBreaks.push(newBreak);
this._manifestBreaks.sort((a, b) => a.offset - b.offset);
this._updateManifestPattern();
}
// Track break count and timing
this._breakCount++;
if (adinfo.offset !== null && adinfo.offset !== undefined) {
this._breakTimes.push(adinfo.offset);
}
// Initialize break tracking
this._currentBreak = {
position: position,
totalAds: adinfo.totalAds || this._adTimeline.length || 1,
adIndex: 0,
observedAds: 0,
offset: offset,
isActive: true
};
this.adPosition = position;
this._lastAdIndexInBreak = null;
// Fire break start event
this.fireBreakStart();
}
/**
* Called when an ad impression is recorded
* @param {Object} adinfo - Information about the ad
*/
_onImpression(adinfo) {
const impressionKey = this._buildAdSessionKey(adinfo);
if (
this.flags &&
this.flags.isStarted &&
this._activeAdSessionKey &&
impressionKey &&
impressionKey !== this._activeAdSessionKey
) {
// Ignore out-of-order impression updates from a different ad while current ad is active.
return;
}
// Ad impression is typically fired before start.
this._updateCurrentAdInfo(adinfo);
}
/**
* Called when an ad starts playing
* @param {Object} adinfo - Information about the ad
*/
_onAdStart(adinfo) {
const startKey = this._buildAdSessionKey(adinfo);
// Ignore duplicate start callback for the same active ad.
if (
this._currentAd &&
this.flags &&
this.flags.isStarted &&
this._activeAdSessionKey &&
startKey &&
this._activeAdSessionKey === startKey
) {
return;
}
// Fallback for inconsistent SDK timelines:
// close dangling ad state before opening a new ad.
if (this._currentAd) {
this._finalizeCurrentAd('fallback-next-ad-start');
}
// Some streams skip cue-enter for dynamic ad pods.
if (!this._currentBreak || !this._currentBreak.isActive) {
const position = this._determineAdPosition(adinfo);
const offset = this._getAdOffset(adinfo) !== null ? this._getAdOffset(adinfo) : 0;
this._currentBreak = {
position: position,
totalAds: adinfo?.totalAds || this._adTimeline.length || 1,
adIndex: 0,
observedAds: 0,
offset: offset,
isActive: true
};
this.adPosition = position;
this._breakCount++;
if (Number.isFinite(offset)) {
this._breakTimes.push(offset);
}
this.fireBreakStart();
}
// Update current ad information
this._updateCurrentAdInfo(adinfo);
this._activeAdSessionKey = this._buildAdSessionKey(adinfo);
if (this._currentBreak) {
const adIndex = adinfo && adinfo.adIndex !== undefined ? Number(adinfo.adIndex) : undefined;
if (adIndex !== undefined && adIndex !== this._lastAdIndexInBreak) {
this._currentBreak.observedAds = (this._currentBreak.observedAds || 0) + 1;
this._lastAdIndexInBreak = adIndex;
} else if (adIndex === undefined && !this._currentBreak.observedAds) {
this._currentBreak.observedAds = 1;
}
}
// Capture start time for playhead calculation (fallback if offset is missing)
if (this.player && this.player.currentTime !== undefined) {
this._adStartTime = this.player.currentTime;
} else if (this.getVideo() && typeof this.getVideo().getPlayhead === 'function') {
this._adStartTime = this.getVideo().getPlayhead();
} else {
this._adStartTime = 0;
}
// Manually start join chrono for ads (fireStart doesn't do it for ads)
if (this.chronos && this.chronos.join) {
this.chronos.join.start();
}
// Fire NPAW ad start event
this.fireStart();
// Fire NPAW ad join event (reports time-to-first-frame)
// This will stop the join chrono and calculate the duration
this.fireJoin();
}
/**
* Called when ad reaches first quartile (25%)
* @param {Object} adinfo - Information about the ad
*/
_onFirstQuartile(adinfo) {
this._updateCurrentAdInfo(adinfo);
this.fireQuartile(1);
}
/**
* Called when ad reaches midpoint (50%)
* @param {Object} adinfo - Information about the ad
*/
_onMidpoint(adinfo) {
this._updateCurrentAdInfo(adinfo);
this.fireQuartile(2);
}
/**
* Called when ad reaches third quartile (75%)
* @param {Object} adinfo - Information about the ad
*/
_onThirdQuartile(adinfo) {
this._updateCurrentAdInfo(adinfo);
this.fireQuartile(3);
}
/**
* Called when an ad completes playback
* @param {Object} adinfo - Information about the ad
*/
_onAdComplete(adinfo) {
this._updateCurrentAdInfo(adinfo);
this._finalizeCurrentAd('complete');
}
/**
* Called when exiting an ad break
*/
_onCueTimelineExit() {
const nowTs = Date.now();
const isPaused = !!(this.player && this.player.paused);
const recentlyPaused = this._lastAdPauseAt && nowTs - this._lastAdPauseAt < 2500;
// Some streams emit cue-exit when pausing ads; that must not close the ad break.
if (this._currentAd && (isPaused || this._adPauseActive || recentlyPaused)) {
return;
}
// Guard against SDK cue-exit noise during pause/resume in the same ad.
if (this._currentAd) {
const currentPlayhead = this.getPlayhead();
const currentDuration = this.getDuration();
const hasCompletionEvidence =
Number.isFinite(currentDuration) &&
Number.isFinite(currentPlayhead) &&
currentPlayhead + 0.25 >= currentDuration;
const pluginSaysAdStopped =
this.nowtilusPlugin &&
typeof this.nowtilusPlugin.isAdPlaying === 'function' &&
this.nowtilusPlugin.isAdPlaying() === false;
if (!hasCompletionEvidence && !pluginSaysAdStopped) {
return;
}
this._finalizeCurrentAd('cueTimelineExit');
}
this._syncCurrentBreakIntoManifest();
this._updateManifestPattern();
this._tryFireManifest();
// Fire break stop event
if (this._currentBreak && this._currentBreak.isActive) {
this.fireBreakStop();
this._currentBreak.isActive = false;
}
// Clean up break tracking
this._currentBreak = null;
this._adTimeline = [];
this._lastAdIndexInBreak = null;
// Resume main content playback if video adapter exists
const videoAdapter = this.getVideo()?.getAdapter();
if (videoAdapter) {
videoAdapter.fireResume();
}
}
/* ──────────────────────────────────────────────────────────────── */
/* Video Event Handlers (for Buffer/Pause tracking) */
/* ──────────────────────────────────────────────────────────────── */
_onVideoPause(e) {
if (this._isAdPlaying()) {
if (e && e.stopImmediatePropagation) e.stopImmediatePropagation();
this._adPauseActive = true;
this._lastAdPauseAt = Date.now();
this.firePause();
}
}
_onVideoPlay(e) {
if (this._isAdPlaying()) {
if (e && e.stopImmediatePropagation) e.stopImmediatePropagation();
this._adPauseActive = false;
this.fireResume();
}
}
_onVideoWaiting(e) {
if (this._isAdPlaying()) {
if (e && e.stopImmediatePropagation) e.stopImmediatePropagation();
this.fireBufferBegin();
}
}
_onVideoStalled(e) {
if (this._isAdPlaying()) {
if (e && e.stopImmediatePropagation) e.stopImmediatePropagation();
this.fireBufferBegin();
}
}
_onVideoPlaying(e) {
if (this._isAdPlaying()) {
if (e && e.stopImmediatePropagation) e.stopImmediatePropagation();
this.fireBufferEnd();
}
}
_onVideoError(e) {
if (this._isAdPlaying()) {
if (e && e.stopImmediatePropagation) e.stopImmediatePropagation();
let errorCode, errorMessage;
if (this.player && this.player.error) {
errorCode = this.player.error.code;
errorMessage = this.player.error.message;
}
this.fireError(errorCode, errorMessage);
}
}
_isAdPlaying() {
if (this.nowtilusPlugin && typeof this.nowtilusPlugin.isAdPlaying === 'function') {
return this.nowtilusPlugin.isAdPlaying();
}
return !!this._currentAd || !!(this._currentBreak && this._currentBreak.isActive);
}
/* ──────────────────────────────────────────────────────────────── */
/* Internal Helper Methods */
/* ──────────────────────────────────────────────────────────────── */
/**
* Pick best linear media URL from a VAST-like `mediaFiles` array (prefer video/mp4).
* @param {Array} mediaFiles - from `adInfo.creatives[].mediaFiles` when present
* @returns {string|undefined}
*/
_extractLinearMediaUrlFromMediaFiles(mediaFiles) {
if (!mediaFiles || !Array.isArray(mediaFiles)) {
return undefined;
}
const mp4 = mediaFiles.find((m) => m && m.fileURL && m.mimeType === 'video/mp4');
if (mp4 && mp4.fileURL) {
return mp4.fileURL;
}
const first = mediaFiles.find((m) => m && m.fileURL);
return first ? first.fileURL : undefined;
}
/**
* Pick best linear creative media URL (prefer video/mp4 progressive file).
* @param {Object} creative - VAST / Equativ-style creative from Nowtilus adInfo.creatives[]
* @returns {string|undefined}
*/
_extractLinearMediaUrlFromCreative(creative) {
if (!creative) {
return undefined;
}
return this._extractLinearMediaUrlFromMediaFiles(creative.mediaFiles);
}
/**
* Normalize nested `adInfo` (object or JSON string).
* @param {Object} adinfo - Raw Nowtilus callback payload
* @returns {Object|undefined}
*/
_normalizeNestedAdInfo(adinfo) {
if (!adinfo) {
return undefined;
}
let nested = adinfo.adInfo;
if (nested === undefined || nested === null) {
return undefined;
}
if (typeof nested === 'string') {
try {
nested = JSON.parse(nested);
} catch (e) {
return undefined;
}
}
if (typeof nested !== 'object' || !nested) {
return undefined;
}
return nested;
}
/**
* Resolve creative by 1-based `adIndex` when multiple creatives exist.
* @param {Array} creatives - adInfo.creatives
* @param {*} adIndex - 1-based index from Nowtilus
* @returns {Object|null}
*/
_pickCreativeFromCreatives(creatives, adIndex) {
if (!creatives || !creatives.length) {
return null;
}
const raw = adIndex !== undefined && adIndex !== null ? Number(adIndex) : NaN;
const idx = Number.isFinite(raw) ? Math.min(Math.max(Math.floor(raw) - 1, 0), creatives.length - 1) : 0;
return creatives[idx];
}
/**
* Update current ad information from adinfo object
* @param {Object} adinfo - Ad information from Nowtilus
*/
_updateCurrentAdInfo(adinfo) {
if (!adinfo) return;
if (!this._currentAd) {
this._currentAd = {};
}
// Extract relevant ad properties from adinfo
// Note: Nowtilus uses 1-based adIndex
if (adinfo.adIndex !== undefined) {
this._currentAd.index = adinfo.adIndex;
}
if (adinfo.offset !== undefined && adinfo.offset !== null) {
this._currentAd.offset = adinfo.offset;
this._currentAd.playhead = 0; // Reset playhead at ad start
} else if (adinfo.adStartPosition !== undefined && adinfo.adStartPosition !== null) {
// Use adStartPosition (ms) as offset (s)
this._currentAd.offset = Number(adinfo.adStartPosition) / 1000;
this._currentAd.playhead = 0;
}
// Nowtilus provides duration in milliseconds (adDuration field)
if (adinfo.adDuration !== undefined) {
this._currentAd.duration = adinfo.adDuration; // Store in ms, convert in getDuration()
}
// Nowtilus uses 'adId' not 'id'
if (adinfo.adId !== undefined) {
this._currentAd.adId = adinfo.adId;
} else if (adinfo.adIndex !== undefined) {
// Fallback: use ad index as ID if no explicit ID
this._currentAd.adId = `Ad-${adinfo.adIndex}`;
}
// Store numeric position if provided
if (adinfo.position !== undefined) {
this._currentAd.numericPosition = adinfo.position;
}
// Extract ad server/provider
if (adinfo.adServer) {
this._currentAd.adServer = adinfo.adServer;
}
// Nested VAST-shaped object from Nowtilus (`adinfo.adInfo`): title, creatives[], id, advertiser.
// Only map fields we read from callbacks in this adapter — do not invent top-level `adinfo` aliases.
const nested = this._normalizeNestedAdInfo(adinfo);
if (nested) {
if (nested.title !== undefined && nested.title !== null && String(nested.title).trim()) {
this._currentAd.adTitle = String(nested.title).trim();
}
if (nested.description !== undefined && nested.description !== null && String(nested.description).trim()) {
this._currentAd.adDescription = String(nested.description).trim();
}
}
if (nested && nested.creatives && nested.creatives.length > 0) {
const creative = this._pickCreativeFromCreatives(nested.creatives, adinfo.adIndex);
if (creative) {
if (creative.id !== undefined && creative.id !== null) {
this._currentAd.creativeId = creative.id;
}
if (creative.adId !== undefined && creative.adId !== null) {
this._currentAd.creativeAdId = creative.adId;
}
const mediaUrl = this._extractLinearMediaUrlFromCreative(creative);
if (mediaUrl && String(mediaUrl).trim()) {
this._currentAd.mediaUrl = String(mediaUrl).trim();
}
}
}
// Campaign-level identifier.
// Observed in Equativ payloads: `nested.id` often equals the concatenated `adId`
// (e.g. adId="1253734041726242" == nested.id), which is useless as a campaign.
// `nested.advertiser` (e.g. "518842") is the actual advertiser/campaign id.
if (nested && nested.advertiser !== undefined && nested.advertiser !== null && String(nested.advertiser).trim()) {
this._currentAd.campaignId = nested.advertiser;
} else if (nested && nested.id !== undefined && nested.id !== null && String(nested.id).trim()) {
const nestedIdStr = String(nested.id);
const adIdStr = this._currentAd.adId !== undefined ? String(this._currentAd.adId) : '';
if (nestedIdStr !== adIdStr) {
this._currentAd.campaignId = nested.id;
}
}
// Store tracking URLs if needed for custom tracking
if (adinfo.clickTrackingURLs) {
this._currentAd.clickTrackingURLs = adinfo.clickTrackingURLs;
}
if (adinfo.clickThroughURLs) {
this._currentAd.clickThroughURLs = adinfo.clickThroughURLs;
}
if (!this._currentAd.position && this._currentBreak && this._currentBreak.position) {
this._currentAd.position = this._currentBreak.position;
}
}
/**
* Determine ad position (pre-roll, mid-roll, post-roll)
* @param {Object} adinfo - Ad information from Nowtilus
* @returns {string} Ad position constant
*/
_determineAdPosition(adinfo) {
const AdsPos = this.getNpawReference().Constants.AdPosition;
// Resolve offset: prefer 'offset' (seconds), fallback to 'adStartPosition' (ms) converted to seconds
let offset = null;
if (adinfo && adinfo.offset !== undefined && adinfo.offset !== null) {
offset = Number(adinfo.offset);
} else if (adinfo && adinfo.adStartPosition !== undefined && adinfo.adStartPosition !== null) {
offset = Number(adinfo.adStartPosition) / 1000;
}
// Check offset first as it is more reliable for determining time-based position
if (offset !== null) {
// Pre-roll: ads at or near the beginning (within first second)
if (offset <= 1) {
return AdsPos.Preroll;
}
// Check for Post-roll
// We need content duration to determine post-roll
const videoAdapter = this.getVideo?.()?.getAdapter?.();
if (videoAdapter && typeof videoAdapter.getDuration === 'function') {
const contentDuration = videoAdapter.getDuration();
// If offset is close to content duration (within 5 seconds), consider it post-roll
if (contentDuration > 0 && offset >= contentDuration - 5) {
return AdsPos.Postroll;
}
}
// Default to midroll for ads with offset > 1
return AdsPos.Midroll;
}
// Fallback: Check content playhead to distinguish Pre-roll from Mid-roll
// Since offset is null, we rely on the video player's current time.
const videoAdapter = this.getVideo?.()?.getAdapter?.();
let playhead = 0;
if (videoAdapter && typeof videoAdapter.getPlayhead === 'function') {
playhead = videoAdapter.getPlayhead();
}
// Nowtilus provides numeric position: 1 = pre-roll, 2 = mid-roll, 3+ = other mid-rolls
if (adinfo && adinfo.position !== undefined && adinfo.position !== null) {
const position = Number(adinfo.position);
if (position === 1) {
// If position is 1, it could be Pre-roll OR the first ad of a Mid-roll.
// If playhead is advanced (> 1s), assume it's a Mid-roll.
if (playhead > 1) {
return AdsPos.Midroll;
}
return AdsPos.Preroll;
} else if (position > 1) {
// Position > 1 indicates mid-roll (or subsequent ad in a break)
return AdsPos.Midroll;
}
}
// If no position/offset info, guess based on playhead
if (playhead > 1) {
return AdsPos.Midroll;
}
// Default to midroll if no position/offset information
return AdsPos.Midroll;
}
/**
* Parse the ad timeline to determine breaks structure
* @param {Array} adTimeline - Array of ad objects
*/
_parseManifest(adTimeline) {
if (!adTimeline || !Array.isArray(adTimeline)) return;
const breaks = [];
let currentBreak = null;
let lastPosition = -1;
// Helper to get offset from ad
const getOffset = (ad) => {
if (ad.offset !== undefined && ad.offset !== null) return Number(ad.offset);
if (ad.adStartPosition !== undefined && ad.adStartPosition !== null) return Number(ad.adStartPosition) / 1000;
return null;
};
// Check if we have valid offsets to group by time
const hasOffsets = adTimeline.some((ad) => getOffset(ad) !== null);
// Sort by offset if available to ensure correct grouping
const sortedAds = hasOffsets
? [...adTimeline].sort((a, b) => (getOffset(a) || 0) - (getOffset(b) || 0))
: adTimeline;
sortedAds.forEach((ad) => {
const offset = getOffset(ad) !== null ? getOffset(ad) : 0;
const position = ad.position !== undefined ? Number(ad.position) : 0;
// Determine if this ad starts a new break
let isNewBreak = false;
if (hasOffsets) {
// Group by time: if offset difference is significant (> 1s), it's a new break
if (!currentBreak || Math.abs(currentBreak.offset - offset) >= 1) {
isNewBreak = true;
}
} else {
// Fallback: heuristic based on 'position' (index in pod)
// If position resets to 1 or drops (e.g., 3 -> 1), it's a new break
if (!currentBreak || position === 1 || position <= lastPosition) {
isNewBreak = true;
}
}
if (isNewBreak) {
// Start a new break
// For manifest parsing, we can't use playhead.
// We assume first break is Preroll if offset is small or unknown.
// Subsequent breaks are Midroll.
let adPos = this.getNpawReference().Constants.AdPosition.Midroll;
if (hasOffsets) {
if (offset <= 1) adPos = this.getNpawReference().Constants.AdPosition.Preroll;
} else {
// If no offsets, assume first break found is Preroll
if (breaks.length === 0) adPos = this.getNpawReference().Constants.AdPosition.Preroll;
}
currentBreak = {
offset: offset,
ads: [ad],
position: adPos
};
breaks.push(currentBreak);
} else {
// Add to existing break
currentBreak.ads.push(ad);
}
lastPosition = position;
});
this._manifestBreaks = breaks;
// Calculate expected pattern
const pattern = {
pre: [],
mid: [],
post: []
};
const AdsPos = this.getNpawReference().Constants.AdPosition;
breaks.forEach((b) => {
if (b.position === AdsPos.Preroll) {
pattern.pre.push(b.ads.length);
} else if (b.position === AdsPos.Postroll) {
pattern.post.push(b.ads.length);
} else {
pattern.mid.push(b.ads.length);
}
});
this._manifestPattern = pattern;
}
/**
* Helper to extract offset from ad info
*/
_getAdOffset(adinfo) {
if (adinfo.offset !== undefined && adinfo.offset !== null) return Number(adinfo.offset);
if (adinfo.adStartPosition !== undefined && adinfo.adStartPosition !== null)
return Number(adinfo.adStartPosition) / 1000;
return null;
}
_buildAdSessionKey(adinfo) {
if (!adinfo) return null;
const id = adinfo.adId !== undefined ? adinfo.adId : '';
const idx = adinfo.adIndex !== undefined ? adinfo.adIndex : '';
const st = adinfo.startTime !== undefined ? adinfo.startTime : '';
const offset = this._getAdOffset(adinfo);
return `${id}|${idx}|${st}|${offset !== null ? offset : ''}`;
}
/**
* Update manifest pattern from breaks
*/
_updateManifestPattern() {
const pattern = {
pre: [],
mid: [],
post: []
};
const AdsPos = this.getNpawReference().Constants.AdPosition;
if (this._manifestBreaks) {
this._manifestBreaks.forEach((b) => {
const count = b.totalAds || (b.ads ? b.ads.length : 1);
if (b.position === AdsPos.Preroll) {
pattern.pre.push(count);
} else if (b.position === AdsPos.Postroll) {
pattern.post.push(count);
} else {
pattern.mid.push(count);
}
});
}
this._manifestPattern = pattern;
}
_syncCurrentBreakIntoManifest() {
if (!this._currentBreak) return;
if (!this._manifestBreaks) {
this._manifestBreaks = [];
}
const breakOffset = Number.isFinite(this._currentBreak.offset) ? this._currentBreak.offset : 0;
const observedAds = this._currentBreak.observedAds || 0;
const breakTotalAds = this._currentBreak.totalAds || observedAds || this._currentBreak.adIndex || 1;
const effectiveAds = Math.max(observedAds, this._currentBreak.adIndex || 0, breakTotalAds, 1);
const existingBreak = this._manifestBreaks.find((b) => {
if (!Number.isFinite(b.offset)) return false;
return Math.abs(b.offset - breakOffset) < 1;
});
if (existingBreak) {
existingBreak.totalAds = Math.max(existingBreak.totalAds || 0, effectiveAds);
if (this._currentBreak.position) {
existingBreak.position = this._currentBreak.position;
}
if (!Number.isFinite(existingBreak.offset)) {
existingBreak.offset = breakOffset;
}
return;
}
this._manifestBreaks.push({
offset: breakOffset,
ads: [],
totalAds: effectiveAds,
position: this._currentBreak.position || this.getNpawReference().Constants.AdPosition.Midroll
});
}
_hasConsistentManifestMetrics() {
if (!this._manifestBreaks || this._manifestBreaks.length === 0 || !this._manifestPattern) {
return false;
}
const AdsPos = this.getNpawReference().Constants.AdPosition;
const builtPattern = { pre: [], mid: [], post: [] };
for (let i = 0; i < this._manifestBreaks.length; i++) {
const adBreak = this._manifestBreaks[i];
if (!adBreak || !Number.isFinite(adBreak.offset)) {
return false;
}
const count = adBreak.totalAds || (adBreak.ads ? adBreak.ads.length : 0);
if (!Number.isFinite(count) || count <= 0) {
return false;
}
if (adBreak.position === AdsPos.Preroll) {
builtPattern.pre.push(count);
} else if (adBreak.position === AdsPos.Postroll) {
builtPattern.post.push(count);
} else {
builtPattern.mid.push(count);
}
}
return JSON.stringify(builtPattern) === JSON.stringify(this._manifestPattern);
}
_tryFireManifest() {
if (this._manifestSent) return;
if (!this._hasConsistentManifestMetrics()) {
return;
}
this.fireManifest();
this._manifestSent = true;
}
_finalizeCurrentAd(triggeredBy) {
if (!this._currentAd) return;
const duration = this._currentAd.duration ? this._currentAd.duration / 1000 : undefined;
const currentPlayhead = this.getPlayhead();
const stopPlayhead = Number.isFinite(currentPlayhead) && currentPlayhead > 0 ? currentPlayhead : duration;
this.fireStop({ adPlayhead: stopPlayhead }, triggeredBy);
if (this._currentBreak) {
this._currentBreak.adIndex = (this._currentBreak.adIndex || 0) + 1;
}
this._currentAd = null;
this._adStartTime = undefined;
this._activeAdSessionKey = null;
}
/**
* Get reference to NPAW video analytics instance
* @returns {Object|null} Video analytics instance
*/
get _npawVideo() {
if (this.getVideo) {
return this.getVideo();
}
return null;
}
}