UNPKG

npaw-plugin-adapters

Version:
1,255 lines (1,098 loc) 43.8 kB
/** * 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; } }