From b4ee1cf3db4fcd02a663b6e23f211aaf75dc0e5e Mon Sep 17 00:00:00 2001 From: Matthias Van Parijs Date: Fri, 3 Jan 2025 18:36:41 +0100 Subject: [PATCH] feat: Added impression and quartile tracking --- packages/stitcher/src/playlist.ts | 78 ++---------------------------- packages/stitcher/src/session.ts | 15 ++++-- packages/stitcher/src/signaling.ts | 69 ++++++++++++++++++++++++++ packages/stitcher/src/types.ts | 8 ++- packages/stitcher/src/vast.ts | 16 +++++- 5 files changed, 104 insertions(+), 82 deletions(-) create mode 100644 packages/stitcher/src/signaling.ts diff --git a/packages/stitcher/src/playlist.ts b/packages/stitcher/src/playlist.ts index fbbab988..2fa4180b 100644 --- a/packages/stitcher/src/playlist.ts +++ b/packages/stitcher/src/playlist.ts @@ -14,11 +14,12 @@ import { stringifyMediaPlaylist, } from "./parser"; import { updateSession } from "./session"; +import { getSignalingForAsset } from "./signaling"; import { fetchVmap, toAdBreakTimeOffset } from "./vmap"; import type { Filter } from "./filters"; import type { MasterPlaylist, MediaPlaylist } from "./parser"; import type { Session } from "./session"; -import type { Interstitial, InterstitialAsset } from "./types"; +import type { Interstitial } from "./types"; import type { VmapAdBreak } from "./vmap"; import type { DateTime } from "luxon"; @@ -76,21 +77,13 @@ export async function formatMediaPlaylist( export async function formatAssetList(session: Session, dateTime: DateTime) { const assets = await getAssets(session, dateTime); - await Promise.all( - assets.map(async (asset) => { - if (asset.duration === undefined) { - asset.duration = await fetchDuration(asset.url); - } - }), - ); - return { ASSETS: assets.map((asset) => { return { URI: asset.url, DURATION: asset.duration, "SPRS-KIND": asset.kind, - "X-AD-CREATIVE-SIGNALING": getAdCreativeSignaling(assets, asset), + "X-AD-CREATIVE-SIGNALING": getSignalingForAsset(assets, asset), }; }), }; @@ -108,8 +101,7 @@ async function fetchMediaPlaylist(url: string) { return parseMediaPlaylist(result); } -export async function fetchDuration(uri: string) { - const url = resolveUri(uri); +export async function fetchDuration(url: string) { const variant = (await fetchMasterPlaylist(url))?.variants[0]; if (!variant) { @@ -249,65 +241,3 @@ export function mapAdBreaksToSessionInterstitials( return interstitials; } - -interface SignalingEvent { - type: "clickthrough" | "quartile"; - start?: number; - urls: string[]; -} - -export function getAdCreativeSignaling( - assets: InterstitialAsset[], - asset: InterstitialAsset, -) { - const { duration, tracking } = asset; - - assert(duration); - - if (!tracking) { - return null; - } - - let startTime = 0; - for (const tempAsset of assets) { - if (tempAsset === asset) { - break; - } - assert(tempAsset.duration); - startTime += tempAsset.duration; - } - - const signalingEvents: SignalingEvent[] = []; - - Object.entries(tracking).forEach(([name, urls]) => { - const offset = QUARTILE_EVENTS[name]; - if (offset !== undefined) { - signalingEvents.push({ - type: "quartile", - start: startTime + duration * offset, - urls, - }); - } - }); - - return { - version: 2, - type: "slot", - payload: [ - { - type: "linear", - start: startTime, - duration: asset.duration, - tracking: signalingEvents, - }, - ], - }; -} - -const QUARTILE_EVENTS: Record = { - start: 0, - firstQuartile: 0.25, - midpoint: 0.5, - thirdQuartile: 0.75, - complete: 1, -}; diff --git a/packages/stitcher/src/session.ts b/packages/stitcher/src/session.ts index 86610146..955df29d 100644 --- a/packages/stitcher/src/session.ts +++ b/packages/stitcher/src/session.ts @@ -4,6 +4,7 @@ import { kv } from "./adapters/kv"; import { mergeInterstitials } from "./interstitials"; import { JSON } from "./lib/json"; import { resolveUri } from "./lib/url"; +import { fetchDuration } from "./playlist"; import type { Interstitial, InterstitialChunk } from "./types"; import type { VmapParams } from "./vmap"; @@ -53,7 +54,7 @@ export async function createSession(params: { }; if (params.interstitials) { - const interstitials = mapSessionInterstitials( + const interstitials = await mapSessionInterstitials( startTime, params.interstitials, ); @@ -79,7 +80,7 @@ export async function updateSession(session: Session) { await kv.set(`session:${session.id}`, value, session.expiry); } -function mapSessionInterstitials( +async function mapSessionInterstitials( startTime: DateTime, interstitials: SessionInterstitial[], ) { @@ -95,12 +96,16 @@ function mapSessionInterstitials( if (interstitial.assets) { for (const asset of interstitial.assets) { - const { uri, ...rest } = asset; + const { uri, kind } = asset; + + const url = resolveUri(uri); + chunks.push({ type: "asset", data: { - url: resolveUri(uri), - ...rest, + url, + duration: await fetchDuration(url), + kind, }, }); } diff --git a/packages/stitcher/src/signaling.ts b/packages/stitcher/src/signaling.ts new file mode 100644 index 00000000..3fc0b05d --- /dev/null +++ b/packages/stitcher/src/signaling.ts @@ -0,0 +1,69 @@ +import type { InterstitialAsset } from "./types"; + +interface SignalingEvent { + type: "impression" | "quartile" | "clickthrough"; + start?: number; + urls: string[]; +} + +const QUARTILE_EVENTS: Record = { + start: 0, + firstQuartile: 0.25, + midpoint: 0.5, + thirdQuartile: 0.75, + complete: 1, +}; + +export function getSignalingForAsset( + assets: InterstitialAsset[], + asset: InterstitialAsset, +) { + const { duration, tracking } = asset; + if (!tracking) { + return null; + } + + const assetIndex = assets.indexOf(asset); + const startTime = assets.splice(0, assetIndex).reduce((acc, asset) => { + acc += asset.duration; + return acc; + }, 0); + + const signalingEvents: SignalingEvent[] = []; + + signalingEvents.push({ + type: "impression", + start: 0, + urls: tracking.impression, + }); + + signalingEvents.push({ + type: "clickthrough", + urls: tracking.clickThrough, + }); + + // Map each tracking URL to their corresponding quartile. + Object.entries(tracking).forEach(([name, urls]) => { + const offset = QUARTILE_EVENTS[name]; + if (offset !== undefined) { + signalingEvents.push({ + type: "quartile", + start: duration * offset, + urls, + }); + } + }); + + return { + version: 2, + type: "slot", + payload: [ + { + type: "linear", + start: startTime, + duration: asset.duration, + tracking: signalingEvents, + }, + ], + }; +} diff --git a/packages/stitcher/src/types.ts b/packages/stitcher/src/types.ts index 55249177..290a1062 100644 --- a/packages/stitcher/src/types.ts +++ b/packages/stitcher/src/types.ts @@ -7,9 +7,13 @@ export interface InterstitialVast { export interface InterstitialAsset { url: string; - duration?: number; + duration: number; kind?: "ad" | "bumper"; - tracking?: Record; + tracking?: { + impression: string[]; + clickThrough: string[]; + [key: string]: string[]; + }; } export interface InterstitialAssetList { diff --git a/packages/stitcher/src/vast.ts b/packages/stitcher/src/vast.ts index 273139ff..d34cc39a 100644 --- a/packages/stitcher/src/vast.ts +++ b/packages/stitcher/src/vast.ts @@ -111,11 +111,25 @@ async function mapAdToAsset(ad: VastAd): Promise { return null; } + const impressionUrls: string[] = []; + for (const urlTemplate of ad.impressionURLTemplates) { + impressionUrls.push(urlTemplate.url); + } + + const clickThroughUrls: string[] = []; + for (const urlTemplate of creative.videoClickTrackingURLTemplates) { + clickThroughUrls.push(urlTemplate.url); + } + return { url: url, duration: creative.duration, kind: "ad", - tracking: creative.trackingEvents, + tracking: { + impression: impressionUrls, + clickThrough: clickThroughUrls, + ...creative.trackingEvents, + }, }; }