Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/lemon-hotels-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@theoplayer/nielsen-connector-web": major
---

Split DCR/DTVR implementations

- inconvenient to maintain a single multipurpose codebase
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These entries should not be part of our changelog as it does not change the behavior of the connector.
Instead I'd mention that they are split indeed and the changes to the configuration object as well as removing updateDCRContentMetadata in favour of the general updateMetadata.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it again, maybe it would be better if we could deprecate the updateDCRContentMetadata and enableDCR/enableDTVR instead of removing them already. Then we can give everyone some time to update to the new way. @wjoosen thoughts?

- some configuration/signature breaking changes
25 changes: 18 additions & 7 deletions nielsen/src/integration/NielsenConnector.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously DTVR was the default value but now it changed to DCR. Probably should keep the default to DTVR.

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();
}
Expand Down
324 changes: 20 additions & 304 deletions nielsen/src/integration/NielsenHandler.ts
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is not used

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);
}
}
Loading