diff --git a/.changeset/lemon-hotels-shop.md b/.changeset/lemon-hotels-shop.md new file mode 100644 index 00000000..e3438a64 --- /dev/null +++ b/.changeset/lemon-hotels-shop.md @@ -0,0 +1,8 @@ +--- +"@theoplayer/nielsen-connector-web": major +--- + +Split DCR/DTVR implementations + +- inconvenient to maintain a single multipurpose codebase +- some configuration/signature breaking changes diff --git a/nielsen/src/integration/NielsenConnector.ts b/nielsen/src/integration/NielsenConnector.ts index 23c4177b..b4928746 100644 --- a/nielsen/src/integration/NielsenConnector.ts +++ b/nielsen/src/integration/NielsenConnector.ts @@ -1,6 +1,16 @@ import type { ChromelessPlayer } from 'theoplayer'; -import { NielsenConfiguration, NielsenDCRContentMetadata, NielsenOptions } from '../nielsen/Types'; -import { NielsenHandler } from './NielsenHandler'; +import { + AdLoadType, + HasAds, + NielsenConfiguration, + NielsenCountry, + NielsenDCRContentMetadata, + NielsenDCRContentMetadataCZ, + NielsenHandler, + NielsenOptions +} from '../nielsen/Types'; +import { NielsenHandlerDCR } from './NielsenHandlerDCR'; +import { NielsenHandlerDTVR } from './NielsenHandlerDTVR'; export class NielsenConnector { private nielsenHandler: NielsenHandler; @@ -12,6 +22,7 @@ export class NielsenConnector { * @param appId UniqueID assigned to player/site. * @param instanceName User-defined string value for describing the player/site. * @param options Additional options. + * @param configuration Specifies nielsen configuration, e.g. handler type. */ constructor( player: ChromelessPlayer, @@ -20,17 +31,17 @@ export class NielsenConnector { options?: NielsenOptions, configuration?: NielsenConfiguration ) { - this.nielsenHandler = new NielsenHandler(player, appId, instanceName, options, configuration); + if (configuration?.country === NielsenCountry.US) { + this.nielsenHandler = new NielsenHandlerDTVR(player, appId, instanceName, options, configuration); + } else { + this.nielsenHandler = new NielsenHandlerDCR(player, appId, instanceName, options, configuration); + } } updateMetadata(metadata: { [key: string]: string }): void { this.nielsenHandler.updateMetadata(metadata); } - updateDCRContentMetadata(metadata: NielsenDCRContentMetadata): void { - this.nielsenHandler.updateDCRContentMetadata(metadata); - } - destroy() { this.nielsenHandler.destroy(); } diff --git a/nielsen/src/integration/NielsenHandler.ts b/nielsen/src/integration/NielsenHandler.ts index 9d14a7c8..3ae3cb1f 100644 --- a/nielsen/src/integration/NielsenHandler.ts +++ b/nielsen/src/integration/NielsenHandler.ts @@ -1,306 +1,22 @@ -import type { - Ad, - AdBreakEvent, - AdEvent, - AddTrackEvent, - ChromelessPlayer, - DurationChangeEvent, - TextTrack, - TextTrackEnterCueEvent, - TimeUpdateEvent, - VolumeChangeEvent -} from 'theoplayer'; -import { loadNielsenLibrary } from '../nielsen/NOLBUNDLE'; -import { - AdMetadata, - DCRContentMetadata, - DTVRContentMetadata, - NielsenConfiguration, - NielsenCountry, - NielsenDCRContentMetadata, - NielsenOptions -} from '../nielsen/Types'; -import { buildDCRAdMetadata, buildDCRContentMetadata, getAdType } from '../utils/Util'; - -const EMSG_PRIV_SUFFIX = 'PRIV{'; -const EMSG_PAYLOAD_SUFFIX = 'payload='; - -export class NielsenHandler { - private player: ChromelessPlayer; - - private debug: boolean; - - private dcrEnabled: boolean; - private dtvrEnabled: boolean; - private country: NielsenCountry = NielsenCountry.US; - - private metadata: DCRContentMetadata | undefined; - private lastReportedPlayheadPosition: number | undefined; - - private nSdkInstance: any; - - private sessionInProgress: boolean = false; - - private duration: number = NaN; - - private decoder = new TextDecoder('utf-8'); - - constructor( - player: ChromelessPlayer, - appId: string, - instanceName: string, - options?: NielsenOptions, - configuration?: NielsenConfiguration - ) { - this.player = player; - this.debug = options?.nol_sdkDebug === 'debug' ? true : false; - this.dcrEnabled = configuration?.enableDCR ?? false; - this.dtvrEnabled = configuration?.enableDTVR ?? true; - this.country = configuration?.country ?? NielsenCountry.US; - this.nSdkInstance = loadNielsenLibrary(appId, instanceName, options, this.country); - - this.addEventListeners(); - } - - updateMetadata(metadata: { [key: string]: string }): void { - switch (this.country) { - case NielsenCountry.US: { - const { type, vidtype, assetid, ...updateableParameters } = metadata; - if (this.debug) - console.log(`[NIELSEN] updateMetadata: ${{ type, vidtype, assetid }} will not be updated`); - this.nSdkInstance.ggPM('updateMetadata', updateableParameters); - break; - } - case NielsenCountry.CZ: - default: - } - } - - updateDCRContentMetadata(metadata: NielsenDCRContentMetadata): void { - if (!this.dcrEnabled) return; - this.metadata = buildDCRContentMetadata(metadata, this.country); - } - - private addEventListeners(): void { - this.player.addEventListener('play', this.onPlay); - this.player.addEventListener('pause', this.onInterrupt); - this.player.addEventListener('waiting', this.onInterrupt); - this.player.addEventListener('ended', this.onEnd); - this.player.addEventListener('sourcechange', this.onSourceChange); - this.player.addEventListener('volumechange', this.onVolumeChange); - this.player.addEventListener('loadedmetadata', this.onLoadMetadata); - this.player.addEventListener('durationchange', this.onDurationChange); - this.player.addEventListener('timeupdate', this.onTimeUpdate); - - this.player.textTracks.addEventListener('addtrack', this.onAddTrack); - - if (this.player.ads) { - this.player.ads.addEventListener('adbegin', this.onAdBegin); - this.player.ads.addEventListener('adend', this.onAdEnd); - this.player.ads.addEventListener('adbreakbegin', this.onAdBreakBegin); - } - - window.addEventListener('beforeunload', this.onEnd); - } - - private removeEventListeners(): void { - this.player.removeEventListener('play', this.onPlay); - this.player.removeEventListener('pause', this.onInterrupt); - this.player.removeEventListener('waiting', this.onInterrupt); - this.player.removeEventListener('ended', this.onEnd); - this.player.removeEventListener('sourcechange', this.onSourceChange); - this.player.removeEventListener('volumechange', this.onVolumeChange); - this.player.removeEventListener('loadedmetadata', this.onLoadMetadata); - this.player.removeEventListener('durationchange', this.onDurationChange); - this.player.removeEventListener('timeupdate', this.onTimeUpdate); - - this.player.textTracks.removeEventListener('addtrack', this.onAddTrack); - - if (this.player.ads) { - this.player.ads.removeEventListener('adbegin', this.onAdBegin); - this.player.ads.removeEventListener('adend', this.onAdEnd); - this.player.ads.removeEventListener('adbreakbegin', this.onAdBreakBegin); - } - - window.removeEventListener('beforeunload', this.onEnd); - } - - private onPlay = () => { - this.maybeSendPlayEvent(); - }; - - private onInterrupt = () => { - if (!this.dcrEnabled) return; - this.nSdkInstance.ggPM('stop', this.getPlayHeadPosition()); - }; - - private onEnd = () => { - if (this.dcrEnabled && this.player.ads?.playing) this.nSdkInstance.ggPM('stop', this.getPlayHeadPosition()); - this.endSession(); - }; - - private onSourceChange = () => { - this.duration = NaN; - this.endSession(); - }; - - private onVolumeChange = (event: VolumeChangeEvent) => { - if (!this.dtvrEnabled) return; - const volumeLevel = this.player.muted ? 0 : event.volume * 100; - this.nSdkInstance.ggPM('setVolume', volumeLevel); - }; - - private onDurationChange = ({ duration }: DurationChangeEvent) => { - if (isNaN(duration)) return; - this.duration = this.player.duration; - this.maybeSendPlayEvent(); - }; - - private onTimeUpdate = ({ currentTime }: TimeUpdateEvent) => { - if (!this.dcrEnabled) return; - const currentTimeFloor = Math.floor(currentTime); - if (currentTimeFloor === this.lastReportedPlayheadPosition) return; - this.lastReportedPlayheadPosition = currentTimeFloor; - this.nSdkInstance.ggPM('setPlayheadPosition', currentTimeFloor); - }; - - private onLoadMetadata = () => { - if (!this.dtvrEnabled) return; - const data: DTVRContentMetadata = { - type: 'content', - adModel: '1' // Always '1' for DTVR - }; - this.nSdkInstance.ggPM('loadMetadata', data); - }; - - private onAddTrack = (event: AddTrackEvent) => { - if (!this.dtvrEnabled) return; - if (event.track.kind === 'metadata') { - const track = event.track as TextTrack; - if (track.type === 'id3' || track.type === 'emsg') { - // Make sure we get cues. - if (track.mode === 'disabled') { - track.mode = 'hidden'; - } - track.addEventListener('entercue', this.onEnterCue); - } - } - }; - - private onEnterCue = (event: TextTrackEnterCueEvent) => { - const { cue } = event; - if (cue.content) { - if (cue.track.type === 'id3') { - this.handleNielsenId3Payload(cue.content); - } else if (cue.track.type === 'emsg') { - this.handleNielsenEmsgPayload(cue.content); - } - } - }; - - private handleNielsenId3Payload = (content: any) => { - if (content.id === 'PRIV' && content.ownerIdentifier.indexOf('www.nielsen.com') !== -1) { - this.nSdkInstance.ggPM('sendID3', content.ownerIdentifier); - } - }; - - private handleNielsenEmsgPayload = (content: any) => { - const cueContentText = this.decoder.decode(content); - if (cueContentText.startsWith('type=nielsen_tag')) { - // extract payload - const base64Index = cueContentText.indexOf(EMSG_PAYLOAD_SUFFIX); - try { - if (base64Index !== -1) { - const base64Payload = cueContentText.substring(base64Index + EMSG_PAYLOAD_SUFFIX.length); - - // sanitise base64payload before decoding, remove null and %-encoded chars. - // eslint-disable-next-line no-control-regex - const sanitizedBase64Payload = base64Payload.replace(/\x00|%[0-9A-Fa-f]{2}/g, ''); - const payload = atob(sanitizedBase64Payload); - - // sanitise payload before submitting: - // - only allow printable characters within ASCII 32 to 126 range. - // - no character beyond the last digit. - // - drop everything before ID3 PRIV{ - let sanitizedPayload = payload.replace(/[^ -~]|\D+$/g, ''); - const privIndex = sanitizedPayload.indexOf(EMSG_PRIV_SUFFIX); - sanitizedPayload = - privIndex !== -1 - ? sanitizedPayload.substring(privIndex + EMSG_PRIV_SUFFIX.length) - : sanitizedPayload; - - // send payload. Note that there is no separate method for sending emsg content. - this.nSdkInstance.ggPM('sendID3', sanitizedPayload); - } - } catch (error) { - console.error('NielsenConnector', 'Failed to parse Nielsen payload', error); - } - } - }; - - private onAdBegin = ({ ad }: AdEvent<'adbegin'>) => { - if (ad.type !== 'linear') return; - const { adBreak } = ad; - const { timeOffset } = adBreak; - const offset = this.player.ads?.dai?.contentTimeForStreamTime(timeOffset) ?? timeOffset; - const duration = this.player.ads?.dai?.contentTimeForStreamTime(this.duration) ?? this.duration; - const type = getAdType(offset, duration); - if (this.dtvrEnabled) { - const dtvrAdMetadata: AdMetadata = { - type, - assetid: ad.id! - }; - this.nSdkInstance.ggPM('loadMetadata', dtvrAdMetadata); - } - if (this.dcrEnabled) { - const dcrAdMetadata = buildDCRAdMetadata(ad, this.country, this.duration); - this.nSdkInstance.ggPM('loadMetadata', dcrAdMetadata); - } - }; - - private onAdEnd = () => { - if (!this.dcrEnabled) return; - this.nSdkInstance.ggPM('stop', this.getPlayHeadPosition()); - }; - - private onAdBreakBegin = ({ adBreak }: AdBreakEvent<'adbreakbegin'>) => { - if (!this.dcrEnabled) return; - const { timeOffset } = adBreak; - const offset = this.player.ads?.dai?.contentTimeForStreamTime(timeOffset) ?? timeOffset; - const duration = this.player.ads?.dai?.contentTimeForStreamTime(this.duration) ?? this.duration; - const isPostroll = getAdType(offset, duration) === 'postroll'; - if (!isPostroll) return; - this.endSession(); - }; - - private maybeSendPlayEvent(): void { - if (this.sessionInProgress || Number.isNaN(this.duration)) return; - this.sessionInProgress = true; - if (this.dtvrEnabled) { - const metadataObject = { - channelName: this.player.src, - length: this.duration - }; - this.nSdkInstance.ggPM('play', metadataObject); - } - if (this.dcrEnabled) { - this.nSdkInstance.ggPM('loadMetadata', this.metadata); - } - } - - private endSession(): void { - if (this.sessionInProgress) { - this.sessionInProgress = false; - this.nSdkInstance.ggPM('end', this.getPlayHeadPosition()); - } - } - - private getPlayHeadPosition(): string { - return Math.floor(this.player.currentTime).toString(); - } - - destroy() { - this.removeEventListeners(); - this.endSession(); +import { NielsenConfiguration, NielsenHandler, NielsenOptions } from '../nielsen/Types'; +import { ChromelessPlayer } from 'theoplayer'; +import { NielsenHandlerDTVR } from './NielsenHandlerDTVR'; +import { NielsenHandlerDCR } from './NielsenHandlerDCR'; + +/** + * Returns an appropriate handler specified in the configuration. + * + */ +export function getNielsenHandler( + player: ChromelessPlayer, + appId: string, + instanceName: string, + options?: NielsenOptions, + configuration?: NielsenConfiguration +): NielsenHandler { + if (configuration?.handlerType === 'DTVR') { + return new NielsenHandlerDTVR(player, appId, instanceName, options, configuration); + } else { + return new NielsenHandlerDCR(player, appId, instanceName, options, configuration); } } diff --git a/nielsen/src/integration/NielsenHandlerDCR.ts b/nielsen/src/integration/NielsenHandlerDCR.ts new file mode 100644 index 00000000..3383d71b --- /dev/null +++ b/nielsen/src/integration/NielsenHandlerDCR.ts @@ -0,0 +1,191 @@ +import type { AdBreakEvent, AdEvent, ChromelessPlayer, DurationChangeEvent, TimeUpdateEvent } from 'theoplayer'; +import { loadNielsenLibrary } from '../nielsen/NOLBUNDLE'; +import { + DCRContentMetadata, + NielsenConfiguration, + NielsenCountry, + NielsenDCRContentMetadata, + NielsenHandler, + NielsenOptions +} from '../nielsen/Types'; +import { buildDCRAdMetadata, buildDCRContentMetadata, getAdType } from '../utils/Util'; + +export class NielsenHandlerDCR implements NielsenHandler { + private player: ChromelessPlayer; + private readonly country: NielsenCountry = NielsenCountry.CZ; + private metadata: DCRContentMetadata | undefined; + + private lastReportedPlayheadPosition: number | undefined; + private contentDuration: number | undefined; + + private nSdkInstance: any; + + private contentEnded: boolean; + private isLiveStream: boolean; + private didFirstPlay: boolean; + + constructor( + player: ChromelessPlayer, + appId: string, + instanceName: string, + options?: NielsenOptions, + configuration?: NielsenConfiguration + ) { + this.player = player; + this.country = configuration?.country ?? NielsenCountry.CZ; + this.nSdkInstance = loadNielsenLibrary(appId, instanceName, options, configuration?.country); + + this.contentEnded = false; + this.didFirstPlay = false; + this.isLiveStream = false; + this.lastReportedPlayheadPosition = -1; + + this.addEventListeners(); + } + + updateMetadata(metadata: { [key: string]: string }): void { + if (isNielsenDCRContentMetadata(metadata)) { + this.metadata = buildDCRContentMetadata(metadata, this.country); + } + } + + private addEventListeners(): void { + this.player.addEventListener('play', this.onFirstPlay); + this.player.addEventListener('pause', this.onInterrupt); + this.player.addEventListener('waiting', this.onInterrupt); + this.player.addEventListener('durationchange', this.onDurationChange); + this.player.addEventListener('sourcechange', this.onSourceChange); + + if (this.player.ads) { + this.player.ads.addEventListener('adbegin', this.onAdBegin); + this.player.ads.addEventListener('adend', this.onAdEnded); + this.player.ads.addEventListener('adbreakbegin', this.onAdBreakBegin); + this.player.ads.addEventListener('adbreakend', this.onAdBreakEnd); + } + + window.addEventListener('beforeunload', this.onBeforeUnload); + } + + private removeEventListeners(): void { + this.player.removeEventListener('play', this.onFirstPlay); + this.player.removeEventListener('pause', this.onInterrupt); + this.player.removeEventListener('waiting', this.onInterrupt); + this.player.removeEventListener('durationchange', this.onDurationChange); + this.player.removeEventListener('sourcechange', this.onSourceChange); + + if (this.player.ads) { + this.player.ads.removeEventListener('adbegin', this.onAdBegin); + this.player.ads.removeEventListener('adend', this.onAdEnded); + this.player.ads.removeEventListener('adbreakbegin', this.onAdBreakBegin); + this.player.ads.removeEventListener('adbreakend', this.onAdBreakEnd); + } + + window.removeEventListener('beforeunload', this.onBeforeUnload); + } + + private onFirstPlay = () => { + if (!this.didFirstPlay) { + this.nSdkInstance.ggPM('loadMetadata', this.metadata); + this.player.addEventListener('timeupdate', this.onTimeUpdate); + this.didFirstPlay = true; + } + }; + + private onInterrupt = () => { + if (this.didFirstPlay) { + this.nSdkInstance.ggPM('stop', this.getPlayHeadPosition()); + } + }; + + private onDurationChange = (event: DurationChangeEvent) => { + if (!this.player.ads?.playing) { + this.contentDuration = event.duration; + } + }; + + private onTimeUpdate = (event: TimeUpdateEvent) => { + if (this.contentEnded) { + return; + } + + const { currentTime } = event; + if (currentTime < 0) { + return; + } + const currentTimeFloor = Math.floor(currentTime); + if (currentTimeFloor === this.lastReportedPlayheadPosition) { + return; + } + this.lastReportedPlayheadPosition = currentTimeFloor; + this.nSdkInstance.ggPM( + 'setPlayheadPosition', + !this.isLiveStream ? currentTimeFloor.toString() : Math.floor(Date.now() / 1000) + ); + + // end reached + if (this.contentDuration && Math.floor(this.contentDuration) === currentTimeFloor) { + this.contentEnded = true; + this.nSdkInstance.ggPM('end', currentTimeFloor.toString()); + } + }; + + private onSourceChange = () => { + // content switch without proper end + if (this.didFirstPlay && !this.contentEnded) { + this.nSdkInstance.ggPM('end', this.lastReportedPlayheadPosition); + } + // load metadata, reset flags + this.isLiveStream = this.player.duration === Infinity; + this.contentDuration = NaN; + this.contentEnded = false; + this.didFirstPlay = false; + this.lastReportedPlayheadPosition = -1; + this.player.removeEventListener('timeupdate', this.onTimeUpdate); + }; + + private onAdBegin = ({ ad }: AdEvent<'adbegin'>) => { + if (ad.type !== 'linear') { + return; + } + + this.nSdkInstance.ggPM('loadMetadata', buildDCRAdMetadata(ad, this.country, this.contentDuration ?? Infinity)); + }; + + private onAdEnded = () => { + this.nSdkInstance.ggPM('stop', this.lastReportedPlayheadPosition); + }; + + private onAdBreakBegin = ({ adBreak }: AdBreakEvent<'adbreakbegin'>) => { + if (getAdType(adBreak.timeOffset, this.contentDuration ?? Infinity) === 'midroll') { + this.nSdkInstance.ggPM('stop', this.lastReportedPlayheadPosition); + } + }; + + private onAdBreakEnd = () => { + if (!this.contentEnded) { + this.nSdkInstance.ggPM('loadMetadata', this.metadata); + } + }; + + private getPlayHeadPosition(): string { + return Math.floor(this.player.currentTime).toString(); + } + + private onBeforeUnload = () => { + if (this.player.ads?.playing) { + this.nSdkInstance.ggPM('stop', this.getPlayHeadPosition()); + } + this.nSdkInstance.ggPM('end', this.getPlayHeadPosition()); + }; + + destroy() { + this.removeEventListeners(); + } +} + +function isNielsenDCRContentMetadata(obj: unknown): obj is NielsenDCRContentMetadata { + if (typeof obj !== 'object' || obj === null) return false; + + const entries = Object.entries(obj); + return entries.every(([, value]) => typeof value === 'string'); +} diff --git a/nielsen/src/integration/NielsenHandlerDTVR.ts b/nielsen/src/integration/NielsenHandlerDTVR.ts new file mode 100644 index 00000000..dab6c711 --- /dev/null +++ b/nielsen/src/integration/NielsenHandlerDTVR.ts @@ -0,0 +1,229 @@ +import type { + AddTrackEvent, + AdEvent, + ChromelessPlayer, + DurationChangeEvent, + TextTrack, + TextTrackEnterCueEvent, + VolumeChangeEvent +} from 'theoplayer'; +import { loadNielsenLibrary } from '../nielsen/NOLBUNDLE'; +import { + AdMetadata, + DTVRContentMetadata, + NielsenConfiguration, + NielsenCountry, + NielsenHandler, + NielsenOptions +} from '../nielsen/Types'; +import { getAdType } from '../utils/Util'; + +const EMSG_PRIV_SUFFIX = 'PRIV{'; +const EMSG_PAYLOAD_SUFFIX = 'payload='; + +export class NielsenHandlerDTVR implements NielsenHandler { + private player: ChromelessPlayer; + + private debug: boolean; + + private country: NielsenCountry = NielsenCountry.US; + + private nSdkInstance: any; + + private sessionInProgress: boolean = false; + + private duration: number = NaN; + + private decoder = new TextDecoder('utf-8'); + + constructor( + player: ChromelessPlayer, + appId: string, + instanceName: string, + options?: NielsenOptions, + configuration?: NielsenConfiguration + ) { + this.player = player; + this.debug = options?.nol_sdkDebug === 'debug'; + this.country = configuration?.country ?? NielsenCountry.US; + this.nSdkInstance = loadNielsenLibrary(appId, instanceName, options, configuration?.country); + + this.addEventListeners(); + } + + updateMetadata(metadata: { [key: string]: string }): void { + const { type, vidtype, assetid, ...updateableParameters } = metadata; + if (this.debug) console.log(`[NIELSEN] updateMetadata: ${{ type, vidtype, assetid }} will not be updated`); + this.nSdkInstance.ggPM('updateMetadata', updateableParameters); + } + + private addEventListeners(): void { + this.player.addEventListener('play', this.onPlay); + this.player.addEventListener('ended', this.onEnd); + this.player.addEventListener('sourcechange', this.onSourceChange); + this.player.addEventListener('volumechange', this.onVolumeChange); + this.player.addEventListener('loadedmetadata', this.onLoadMetadata); + this.player.addEventListener('durationchange', this.onDurationChange); + + this.player.textTracks.addEventListener('addtrack', this.onAddTrack); + + if (this.player.ads) { + this.player.ads.addEventListener('adbegin', this.onAdBegin); + } + + window.addEventListener('beforeunload', this.onEnd); + } + + private removeEventListeners(): void { + this.player.removeEventListener('play', this.onPlay); + this.player.removeEventListener('ended', this.onEnd); + this.player.removeEventListener('sourcechange', this.onSourceChange); + this.player.removeEventListener('volumechange', this.onVolumeChange); + this.player.removeEventListener('loadedmetadata', this.onLoadMetadata); + this.player.removeEventListener('durationchange', this.onDurationChange); + + this.player.textTracks.removeEventListener('addtrack', this.onAddTrack); + + if (this.player.ads) { + this.player.ads.removeEventListener('adbegin', this.onAdBegin); + } + + window.removeEventListener('beforeunload', this.onEnd); + } + + private onPlay = () => { + this.maybeSendPlayEvent(); + }; + + private onEnd = () => { + this.endSession(); + }; + + private onSourceChange = () => { + this.duration = NaN; + this.endSession(); + }; + + private onVolumeChange = (event: VolumeChangeEvent) => { + const volumeLevel = this.player.muted ? 0 : event.volume * 100; + this.nSdkInstance.ggPM('setVolume', volumeLevel); + }; + + private onDurationChange = ({ duration }: DurationChangeEvent) => { + if (isNaN(duration)) return; + this.duration = this.player.duration; + this.maybeSendPlayEvent(); + }; + + private onLoadMetadata = () => { + const data: DTVRContentMetadata = { + type: 'content', + adModel: '1' // Always '1' for DTVR + }; + this.nSdkInstance.ggPM('loadMetadata', data); + }; + + private onAddTrack = (event: AddTrackEvent) => { + if (event.track.kind === 'metadata') { + const track = event.track as TextTrack; + if (track.type === 'id3' || track.type === 'emsg') { + // Make sure we get cues. + if (track.mode === 'disabled') { + track.mode = 'hidden'; + } + track.addEventListener('entercue', this.onEnterCue); + } + } + }; + + private onEnterCue = (event: TextTrackEnterCueEvent) => { + const { cue } = event; + if (cue.content) { + if (cue.track.type === 'id3') { + this.handleNielsenId3Payload(cue.content); + } else if (cue.track.type === 'emsg') { + this.handleNielsenEmsgPayload(cue.content); + } + } + }; + + private handleNielsenId3Payload = (content: any) => { + if (content.id === 'PRIV' && content.ownerIdentifier.indexOf('www.nielsen.com') !== -1) { + this.nSdkInstance.ggPM('sendID3', content.ownerIdentifier); + } + }; + + private handleNielsenEmsgPayload = (content: any) => { + const cueContentText = this.decoder.decode(content); + if (cueContentText.startsWith('type=nielsen_tag')) { + // extract payload + const base64Index = cueContentText.indexOf(EMSG_PAYLOAD_SUFFIX); + try { + if (base64Index !== -1) { + const base64Payload = cueContentText.substring(base64Index + EMSG_PAYLOAD_SUFFIX.length); + + // sanitise base64payload before decoding, remove null and %-encoded chars. + // eslint-disable-next-line no-control-regex + const sanitizedBase64Payload = base64Payload.replace(/\x00|%[0-9A-Fa-f]{2}/g, ''); + const payload = atob(sanitizedBase64Payload); + + // sanitise payload before submitting: + // - only allow printable characters within ASCII 32 to 126 range. + // - no character beyond the last digit. + // - drop everything before ID3 PRIV{ + let sanitizedPayload = payload.replace(/[^ -~]|\D+$/g, ''); + const privIndex = sanitizedPayload.indexOf(EMSG_PRIV_SUFFIX); + sanitizedPayload = + privIndex !== -1 + ? sanitizedPayload.substring(privIndex + EMSG_PRIV_SUFFIX.length) + : sanitizedPayload; + + // send payload. Note that there is no separate method for sending emsg content. + this.nSdkInstance.ggPM('sendID3', sanitizedPayload); + } + } catch (error) { + console.error('NielsenConnector', 'Failed to parse Nielsen payload', error); + } + } + }; + + private onAdBegin = ({ ad }: AdEvent<'adbegin'>) => { + if (ad.type !== 'linear') return; + const { adBreak } = ad; + const { timeOffset } = adBreak; + const offset = this.player.ads?.dai?.contentTimeForStreamTime(timeOffset) ?? timeOffset; + const duration = this.player.ads?.dai?.contentTimeForStreamTime(this.duration) ?? this.duration; + const type = getAdType(offset, duration); + const dtvrAdMetadata: AdMetadata = { + type, + assetid: ad.id! + }; + this.nSdkInstance.ggPM('loadMetadata', dtvrAdMetadata); + }; + + private maybeSendPlayEvent(): void { + if (this.sessionInProgress || Number.isNaN(this.duration)) return; + this.sessionInProgress = true; + const metadataObject = { + channelName: this.player.src, + length: this.duration + }; + this.nSdkInstance.ggPM('play', metadataObject); + } + + private endSession(): void { + if (this.sessionInProgress) { + this.sessionInProgress = false; + this.nSdkInstance.ggPM('end', this.getPlayHeadPosition()); + } + } + + private getPlayHeadPosition(): string { + return Math.floor(this.player.currentTime).toString(); + } + + destroy() { + this.removeEventListeners(); + this.endSession(); + } +} diff --git a/nielsen/src/nielsen/Types.ts b/nielsen/src/nielsen/Types.ts index d480836b..ede36bdd 100644 --- a/nielsen/src/nielsen/Types.ts +++ b/nielsen/src/nielsen/Types.ts @@ -2,10 +2,11 @@ export type AdType = 'preroll' | 'midroll' | 'postroll' | 'ad'; export type NielsenConfiguration = { country: NielsenCountry; - enableDTVR: boolean; - enableDCR: boolean; + handlerType: NielsenHandlerType; }; +export type NielsenHandlerType = 'DCR' | 'DTVR'; + export type NielsenOptions = { // HTML DOM element id of the player container containerId?: string; @@ -17,6 +18,14 @@ export type NielsenOptions = { optout?: boolean; }; +/** + * Nielsen handler type definition. + */ +export interface NielsenHandler { + updateMetadata(metadata: { [key: string]: string }): void; + destroy(): void; +} + export type DCRContentMetadata = { /* * A fixed dial specifying the type of measured content @@ -89,7 +98,7 @@ export type NielsenDCRContentMetadata = { /* * Indication of whether the video content being played is the entire episode or only part of it. Always reported as true for live. */ - isfullepisode: boolean; + isfullepisode: 'y' | 'n'; /* * CMS tag helper item. The method of recording ads insertion: 1. Linear – corresponds to TV insertion of ads 2. Dynamic – Dynamic Ad Insertion (DAI) */ diff --git a/nielsen/src/utils/Util.ts b/nielsen/src/utils/Util.ts index f2c3b3a4..9cda2e6e 100644 --- a/nielsen/src/utils/Util.ts +++ b/nielsen/src/utils/Util.ts @@ -37,7 +37,7 @@ export function buildDCRContentMetadata( title: metadata.title, length: metadata.length, airdate: metadata?.airdate ?? '19700101 00:00:01', - isfullepisode: metadata.isfullepisode ? 'y' : 'n', + isfullepisode: metadata.isfullepisode, adloadtype: metadata.adloadtype }; if (country === NielsenCountry.CZ) { @@ -45,10 +45,10 @@ export function buildDCRContentMetadata( const dcrContentMetadataCZ: DCRContentMetadataCZ = { ...dcrContentMetadata, ['crossId1']: crossId1, - ['nol_c1']: `p1,${c1 ?? ''}`, - ['nol_c2']: `p2,${c2 ?? ''}`, - ['nol_c4']: `p4,${c4 ?? ''}`, - segB: segB, + ['nol_c1']: c1 ? `p1, ${c1}` : 'p1,', + ['nol_c2']: c2 ? `p2, ${c2}` : 'p2,', + ['nol_c4']: c4 ? `p4, ${c4}` : 'p4,', + segB: segB ?? '', segC: segC ?? '', hasAds: hasAds }; @@ -69,23 +69,34 @@ export function buildDCRContentMetadata( } export function buildDCRAdMetadata(ad: Ad, country: NielsenCountry, duration: number): AdMetadata { + const adBreakType = getAdType(ad.adBreak.timeOffset, duration); const adMetadata = { assetid: ad.id ?? '', - type: getAdType(ad.adBreak.timeOffset, duration) + type: adBreakType }; if (country == NielsenCountry.US) { return adMetadata; } if (country == NielsenCountry.CZ) { - const dcrAdMetadataCZ: DCRAdMetadataCZ = { + let asmeaCode = ''; + try { + const traffickingParametersString = (ad as GoogleImaAd)?.traffickingParametersString; + if (traffickingParametersString) { + const adParams: any = JSON.parse(traffickingParametersString); + asmeaCode = adParams ? adParams.akaCode : ''; + } + } catch (error) { + // skip error + } + + return { ...adMetadata, - ['nol_c4']: 'PLACEHOLDER', - ['nol_c5']: '2', // 1. regular show, 2. advertising, 3. trailer, 4. divide, 5. komerce (teleshopping,sponsor) TODO: provide API to control this - ['nol_c6']: `p6,${adMetadata.type}`, + nol_c4: asmeaCode ? `p4,${asmeaCode}` : 'p4,', + nol_c5: 'p5,2', + nol_c6: `p6,${adBreakType}`, title: (ad as GoogleImaAd).title ?? '', length: ad.duration?.toString() ?? '0' }; - return dcrAdMetadataCZ; } console.error('[NIELSEN - Error] No NielsenCountry was provided - sending only assetid and type'); return adMetadata;