@theoplayer/react-native-analytics-adobe
Version:
Adobe analytics connector for @theoplayer/react-native
477 lines (413 loc) • 16.4 kB
text/typescript
import type { Ad, AdBreak, AdEvent, ErrorEvent, MediaTrackEvent, TextTrackCue, TextTrackEvent, THEOplayer } from 'react-native-theoplayer';
import { AdEventType, MediaTrackEventType, PlayerEventType, TextTrackEventType } from 'react-native-theoplayer';
import type { AdobeEventRequestBody, AdobeMetaData, ContentType } from './Types';
import { AdobeEventTypes } from './Types';
import { calculateAdBeginMetadata, calculateAdBreakBeginMetadata, calculateChapterStartMetadata } from '../utils/Utils';
import { Platform } from 'react-native';
import { buildUserAgent } from '../utils/UserAgent';
import { AdobeConnectorAdapter } from './AdobeConnectorAdapter';
const TAG = 'AdobeConnector';
const CONTENT_PING_INTERVAL = 10000;
const AD_PING_INTERVAL = 1000;
/**
* An all-TypeScript implementation of the AdobeConnector.
*/
export class DefaultAdobeConnectorAdapter implements AdobeConnectorAdapter {
private player: THEOplayer;
/** Media Collection APIs end point */
private readonly uri: string;
/** Visitor Experience Cloud Org ID */
private readonly ecid: string;
/** Analytics Report Suite ID */
private readonly sid: string;
/** Analytics Tracking Server URL */
private readonly trackingUrl: string;
/** The id of the current session */
private sessionId = '';
/** Queue for events that happened before sessionid has been obtained */
private eventQueue: AdobeEventRequestBody[] = [];
/** Timer handling the ping event request */
private pingInterval: ReturnType<typeof setInterval> | undefined;
/** Whether we are in a current session or not */
private sessionInProgress = false;
private adBreakPodIndex = 0;
private adPodPosition = 1;
private isPlayingAd = false;
private customMetadata: AdobeMetaData = {};
private currentChapter: TextTrackCue | undefined;
private readonly customUserAgent: string | undefined;
private debug = false;
constructor(
player: THEOplayer,
uri: string,
ecid: string,
sid: string,
trackingUrl: string,
metadata?: AdobeMetaData,
userAgent?: string,
debug = false,
) {
this.player = player;
this.uri = `https://${uri}/api/v1/sessions`;
this.ecid = ecid;
this.sid = sid;
this.debug = debug;
this.trackingUrl = trackingUrl;
this.customMetadata = { ...this.customMetadata, ...metadata };
this.customUserAgent = userAgent || buildUserAgent();
this.addEventListeners();
this.logDebug('Initialized connector');
}
setDebug(debug: boolean) {
this.debug = debug;
}
updateMetadata(metadata: AdobeMetaData): void {
this.customMetadata = { ...this.customMetadata, ...metadata };
}
setError(metadata: AdobeMetaData): void {
void this.sendEventRequest(AdobeEventTypes.ERROR, this.sessionId, metadata);
}
async stopAndStartNewSession(metadata?: AdobeMetaData): Promise<void> {
await this.maybeEndSession();
if (metadata !== undefined) {
this.updateMetadata(metadata);
}
await this.maybeStartSession();
if (this.player.paused) {
this.onPause();
} else {
this.onPlaying();
}
}
private addEventListeners(): void {
this.player.addEventListener(PlayerEventType.PLAYING, this.onPlaying);
this.player.addEventListener(PlayerEventType.PAUSE, this.onPause);
this.player.addEventListener(PlayerEventType.ENDED, this.onEnded);
this.player.addEventListener(PlayerEventType.WAITING, this.onWaiting);
this.player.addEventListener(PlayerEventType.SOURCE_CHANGE, this.onSourceChange);
this.player.addEventListener(PlayerEventType.TEXT_TRACK, this.onTextTrackEvent);
this.player.addEventListener(PlayerEventType.MEDIA_TRACK, this.onMediaTrackEvent);
this.player.addEventListener(PlayerEventType.ERROR, this.onError);
this.player.addEventListener(PlayerEventType.AD_EVENT, this.onAdEvent);
if (Platform.OS === 'web') {
window.addEventListener('beforeunload', this.onBeforeUnload);
}
}
private removeEventListeners(): void {
this.player.removeEventListener(PlayerEventType.PLAYING, this.onPlaying);
this.player.removeEventListener(PlayerEventType.PAUSE, this.onPause);
this.player.removeEventListener(PlayerEventType.ENDED, this.onEnded);
this.player.removeEventListener(PlayerEventType.WAITING, this.onWaiting);
this.player.removeEventListener(PlayerEventType.SOURCE_CHANGE, this.onSourceChange);
this.player.removeEventListener(PlayerEventType.TEXT_TRACK, this.onTextTrackEvent);
this.player.removeEventListener(PlayerEventType.MEDIA_TRACK, this.onMediaTrackEvent);
this.player.removeEventListener(PlayerEventType.ERROR, this.onError);
this.player.removeEventListener(PlayerEventType.AD_EVENT, this.onAdEvent);
if (Platform.OS === 'web') {
window.removeEventListener('beforeunload', this.onBeforeUnload);
}
}
private onPlaying = async () => {
this.logDebug('onPlaying');
// NOTE: In case of a pre-roll ad, the `playing` event will be sent twice: once starting the re-roll, and once
// starting content. During the pre-roll, all events will be queued. The session will be started after the pre-roll,
// making sure we can start the session with the correct content duration (not the ad duration).
await this.maybeStartSession();
void this.sendEventRequest(AdobeEventTypes.PLAY, this.sessionId);
};
private onPause = () => {
this.logDebug('onPause');
void this.sendEventRequest(AdobeEventTypes.PAUSE_START, this.sessionId);
};
private onWaiting = () => {
this.logDebug('onWaiting');
void this.sendEventRequest(AdobeEventTypes.BUFFER_START, this.sessionId);
};
private onEnded = async () => {
this.logDebug('onEnded');
const sessionId = this.sessionId;
this.reset();
await this.sendEventRequest(AdobeEventTypes.SESSION_COMPLETE, sessionId);
};
private onSourceChange = () => {
this.logDebug('onSourceChange');
void this.maybeEndSession();
};
private onMediaTrackEvent = (event: MediaTrackEvent) => {
if (event.subType === MediaTrackEventType.ACTIVE_QUALITY_CHANGED) {
void this.sendEventRequest(AdobeEventTypes.BITRATE_CHANGE, this.sessionId);
}
};
private onTextTrackEvent = (event: TextTrackEvent) => {
const track = this.player.textTracks.find((track) => track.uid === event.trackUid);
// @ts-ignore
if (track !== undefined && track.kind === 'chapters') {
switch (event.subType) {
case TextTrackEventType.ENTER_CUE: {
const chapterCue = event.cue;
if (this.currentChapter && this.currentChapter.endTime !== chapterCue.startTime) {
void this.sendEventRequest(AdobeEventTypes.CHAPTER_SKIP, this.sessionId);
}
const metadata = calculateChapterStartMetadata(chapterCue);
void this.sendEventRequest(AdobeEventTypes.CHAPTER_START, this.sessionId, metadata);
this.currentChapter = chapterCue;
break;
}
case TextTrackEventType.EXIT_CUE: {
void this.sendEventRequest(AdobeEventTypes.CHAPTER_COMPLETE, this.sessionId);
break;
}
}
}
};
private onError = (error: ErrorEvent) => {
const metadata: AdobeMetaData = {
qoeData: {
'media.qoe.errorID': error.error.errorCode,
'media.qoe.errorSource': 'player',
},
};
void this.sendEventRequest(AdobeEventTypes.ERROR, this.sessionId, metadata);
};
private onAdEvent = (event: AdEvent) => {
switch (event.subType) {
case AdEventType.AD_BREAK_BEGIN: {
this.isPlayingAd = true;
this.startPinger(AD_PING_INTERVAL);
const adBreak = event.ad as AdBreak;
const metadata = calculateAdBreakBeginMetadata(adBreak, this.adBreakPodIndex);
void this.sendEventRequest(AdobeEventTypes.AD_BREAK_START, this.sessionId, metadata);
if ((metadata.params as any)['media.ad.podIndex'] > this.adBreakPodIndex) {
// TODO fix!
this.adBreakPodIndex++;
}
break;
}
case AdEventType.AD_BREAK_END: {
this.isPlayingAd = false;
this.adPodPosition = 1;
this.startPinger(CONTENT_PING_INTERVAL);
void this.sendEventRequest(AdobeEventTypes.AD_BREAK_COMPLETE, this.sessionId);
break;
}
case AdEventType.AD_BEGIN: {
const ad = event.ad as Ad;
const metadata = calculateAdBeginMetadata(ad, this.adPodPosition);
void this.sendEventRequest(AdobeEventTypes.AD_START, this.sessionId, metadata);
this.adPodPosition++;
break;
}
case AdEventType.AD_END: {
void this.sendEventRequest(AdobeEventTypes.AD_COMPLETE, this.sessionId);
break;
}
case AdEventType.AD_SKIP: {
void this.sendEventRequest(AdobeEventTypes.AD_SKIP, this.sessionId);
break;
}
}
};
private onBeforeUnload = () => {
void this.maybeEndSession();
};
private async maybeEndSession(): Promise<void> {
this.logDebug(`maybeEndSession - sessionId: '${this.sessionId}'`);
if (this.sessionId !== '') {
const sessionId = this.sessionId;
this.reset();
await this.sendEventRequest(AdobeEventTypes.SESSION_END, sessionId);
}
return Promise.resolve();
}
private createBaseRequest(eventType: string): AdobeEventRequestBody {
return {
playerTime: {
playhead: this.getCurrentTime(),
ts: Date.now(),
},
eventType: eventType,
qoeData: {},
};
}
private getCurrentTime(): number {
if (this.player.duration === Infinity) {
// If content is live, the playhead must be the current second of the day.
const date = new Date();
return date.getSeconds() + 60 * (date.getMinutes() + 60 * date.getHours());
}
return this.player.currentTime / 1000;
}
/**
* Start a new session, but only if:
* - no existing session has is in progress;
* - the player has a valid source;
* - no ad is playing, otherwise the ad's media duration will be picked up;
* - the player's content media duration is known.
*
* @param mediaLengthMsec
* @private
*/
private async maybeStartSession(mediaLengthMsec?: number): Promise<void> {
const mediaLength = this.getContentLength(mediaLengthMsec);
const hasValidSource = this.player.source !== undefined;
const hasValidDuration = isValidDuration(mediaLength);
const isPlayingAd = await this.player.ads.playing();
this.logDebug(
`maybeStartSession -`,
`mediaLength: ${mediaLength},`,
`hasValidSource: ${hasValidSource},`,
`hasValidDuration: ${hasValidDuration},`,
`isPlayingAd: ${isPlayingAd}`,
);
if (this.sessionInProgress || !hasValidSource || !hasValidDuration || isPlayingAd) {
this.logDebug('maybeStartSession - NOT started');
return;
}
const initialBody = this.createBaseRequest(AdobeEventTypes.SESSION_START);
let friendlyName = {};
if (this.player?.source?.metadata?.title) {
friendlyName = {
'media.name': this.player.source.metadata.title,
};
}
initialBody.params = {
'analytics.reportSuite': this.sid,
'analytics.trackingServer': this.trackingUrl,
'media.channel': 'N/A',
'media.contentType': this.getContentType(),
'media.id': 'N/A',
'media.length': mediaLength,
'media.playerName': 'THEOplayer', // TODO make distinctions between platforms?
'visitor.marketingCloudOrgId': this.ecid,
...friendlyName,
...this.customMetadata.params,
};
const body = this.addCustomMetadata(AdobeEventTypes.SESSION_START, initialBody);
const response = await this.sendRequest(this.uri, body);
if (response?.status !== 201) {
console.error(TAG, 'Error during session creation', response);
return;
}
this.sessionInProgress = true;
this.logDebug('maybeStartSession - sessionInProgress');
const splitResponseUrl = response.headers.get('location')?.split('/sessions/');
if (splitResponseUrl === undefined) {
console.error(TAG, 'No location header present');
return;
}
this.sessionId = splitResponseUrl[splitResponseUrl.length - 1];
this.logDebug('maybeStartSession - STARTED', `sessionId: ${this.sessionId}`);
if (this.eventQueue.length !== 0) {
const url = `${this.uri}/${this.sessionId}/events`;
for (const body of this.eventQueue) {
await this.sendRequest(url, body); // TODO another fallback necessary on top?
}
this.eventQueue = [];
}
if (!this.isPlayingAd) {
this.startPinger(CONTENT_PING_INTERVAL);
} else {
this.startPinger(AD_PING_INTERVAL);
}
}
private addCustomMetadata(eventType: AdobeEventTypes, body: AdobeEventRequestBody): AdobeEventRequestBody {
if (eventType !== AdobeEventTypes.PING) {
if (
eventType === AdobeEventTypes.AD_BREAK_START ||
eventType === AdobeEventTypes.CHAPTER_START ||
eventType === AdobeEventTypes.AD_START ||
eventType === AdobeEventTypes.SESSION_START
) {
body.customMetadata = { ...this.customMetadata.customMetadata };
}
// TODO check params which are fine and which need more limitations?
}
body.qoeData = { ...body.qoeData, ...this.customMetadata.qoeData };
return body;
}
private async sendEventRequest(eventType: AdobeEventTypes, sessionId: string, metadata?: AdobeEventRequestBody): Promise<void> {
const initialBody: AdobeEventRequestBody = { ...this.createBaseRequest(eventType), ...metadata };
const body = this.addCustomMetadata(eventType, initialBody);
if (sessionId === '') {
// Session hasn't started yet but no session id --> add to queue
this.eventQueue.push(body);
return;
}
const url = `${this.uri}/${sessionId}/events`;
const response = await this.sendRequest(url, body);
if (response?.status === 404 || response?.status === 410) {
// Faulty session id, store in queue and remake session
this.eventQueue.push(body);
if (this.sessionId !== '' && this.sessionInProgress) {
// avoid calling multiple startSessions close together
this.sessionId = '';
this.sessionInProgress = false;
await this.maybeStartSession();
}
}
}
private startPinger(interval: number): void {
if (this.pingInterval !== undefined) {
clearInterval(this.pingInterval);
}
this.pingInterval = setInterval(() => {
void this.sendEventRequest(AdobeEventTypes.PING, this.sessionId);
}, interval);
}
private async sendRequest(url: string, body: AdobeEventRequestBody): Promise<Response | undefined> {
try {
return await fetch(url, {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
// Override User-Agent with provided value.
...(this.customUserAgent && { 'User-Agent': this.customUserAgent }),
},
});
} catch (e) {
console.error(TAG, 'Failed to send request');
return undefined;
}
}
/**
* Get the current media length in seconds.
*
* - In case of a live stream, set it to 24h.
*
* @param mediaLengthMsec optional mediaLengthMsec provided by a player event.
* @private
*/
private getContentLength(mediaLengthMsec?: number): number {
if (mediaLengthMsec !== undefined) {
return mediaLengthMsec === Infinity ? 86400 : 1e-3 * mediaLengthMsec;
}
return this.player.duration === Infinity ? 86400 : 1e-3 * this.player.duration;
}
private getContentType(): ContentType {
return this.player.duration === Infinity ? 'Live' : 'VOD';
}
reset(): void {
this.logDebug('reset');
this.adBreakPodIndex = 0;
this.adPodPosition = 1;
this.isPlayingAd = false;
this.sessionId = '';
this.sessionInProgress = false;
clearInterval(this.pingInterval);
this.pingInterval = undefined;
this.currentChapter = undefined;
}
async destroy(): Promise<void> {
await this.maybeEndSession();
this.removeEventListeners();
}
private logDebug(message?: any, ...optionalParams: any[]) {
if (this.debug) {
console.debug(TAG, message, ...optionalParams);
}
}
}
function isValidDuration(v: number | undefined): boolean {
return v !== undefined && !Number.isNaN(v);
}