From 7553b32e4dff50b1d371a81fa096ae9b66ffcd1f Mon Sep 17 00:00:00 2001 From: Matthias Van Parijs Date: Mon, 9 Dec 2024 11:25:24 +0100 Subject: [PATCH] Added assetList type --- packages/stitcher/src/interstitials.ts | 123 ++++++++++++++---------- packages/stitcher/src/playlist.ts | 18 +++- packages/stitcher/src/routes/session.ts | 3 + packages/stitcher/src/session.ts | 88 ++++++++++------- packages/stitcher/src/types.ts | 34 ++++--- packages/stitcher/src/vast.ts | 46 ++++----- 6 files changed, 178 insertions(+), 134 deletions(-) diff --git a/packages/stitcher/src/interstitials.ts b/packages/stitcher/src/interstitials.ts index b7a0cb9..e711b8c 100644 --- a/packages/stitcher/src/interstitials.ts +++ b/packages/stitcher/src/interstitials.ts @@ -1,32 +1,20 @@ +import { DateTime } from "luxon"; import { createUrl } from "./lib/url"; -import { fetchDuration } from "./playlist"; -import { getAdMediasFromVast } from "./vast"; +import { getAssetsFromVast } from "./vast"; +import type { DateRange } from "./parser"; import type { Session } from "./session"; -import type { DateTime } from "luxon"; +import type { Interstitial, InterstitialAsset } from "./types"; export function getStaticDateRanges(session: Session, isLive: boolean) { - const group: { - dateTime: DateTime; - }[] = []; - - for (const interstitial of session.interstitials) { - let item = group.find((item) => - item.dateTime.equals(interstitial.dateTime), - ); - - if (!item) { - item = { - dateTime: interstitial.dateTime, - }; - group.push(item); - } - } + const group = getGroupedInterstitials(session.interstitials); - return group.map((item) => { - const assetListUrl = createAssetListUrl({ - dateTime: item.dateTime, - session, - }); + const dateRanges: DateRange[] = []; + + for (const [ts, interstitials] of group.entries()) { + const startDate = DateTime.fromMillis(ts); + + const assetListUrl = getAssetListUrl(startDate, interstitials, session); + const kinds = getInterstitialsKinds(interstitials); const clientAttributes: Record = { RESTRICT: "SKIP,JUMP", @@ -38,55 +26,88 @@ export function getStaticDateRanges(session: Session, isLive: boolean) { clientAttributes["RESUME-OFFSET"] = 0; } - const isPreroll = item.dateTime.equals(session.startTime); - if (isPreroll) { + const atStart = startDate.equals(session.startTime); + if (atStart) { clientAttributes["CUE"] += ",PRE"; } - return { + if (kinds.length) { + clientAttributes["SPRS-INCLUDES-KIND"] = kinds.join(","); + } + + dateRanges.push({ classId: "com.apple.hls.interstitial", - id: `${item.dateTime.toUnixInteger()}`, - startDate: item.dateTime, + id: `${ts}`, + startDate, clientAttributes, - }; - }); + }); + } + + return dateRanges; } export async function getAssets(session: Session, dateTime: DateTime) { - const assets: { - URI: string; - DURATION: number; - }[] = []; + const assets: InterstitialAsset[] = []; const interstitials = session.interstitials.filter((interstitial) => interstitial.dateTime.equals(dateTime), ); for (const interstitial of interstitials) { - if (interstitial.type === "vast") { - const adMedias = await getAdMediasFromVast(interstitial); - for (const adMedia of adMedias) { - assets.push({ - URI: adMedia.masterUrl, - DURATION: adMedia.duration, - }); - } + if (interstitial.vast) { + const nextAssets = await getAssetsFromVast(interstitial.vast); + assets.push(...nextAssets); } - if (interstitial.type === "asset") { - assets.push({ - URI: interstitial.url, - DURATION: await fetchDuration(interstitial.url), - }); + if (interstitial.asset) { + assets.push(interstitial.asset); } } return assets; } -function createAssetListUrl(params: { dateTime: DateTime; session?: Session }) { +function getGroupedInterstitials(interstitials: Interstitial[]) { + const result = new Map(); + + for (const interstitial of interstitials) { + const ts = interstitial.dateTime.toMillis(); + let items = result.get(ts); + if (!items) { + items = []; + result.set(ts, items); + } + items.push(interstitial); + } + + return result; +} + +function getAssetListUrl( + dateTime: DateTime, + interstitials: Interstitial[], + session?: Session, +) { + if (interstitials.length === 1 && interstitials[0]?.assetList) { + return interstitials[0].assetList.url; + } + return createUrl("out/asset-list.json", { - dt: params.dateTime.toISO(), - sid: params.session?.id, + dt: dateTime.toISO(), + sid: session?.id, }); } + +function getInterstitialsKinds(interstitials: Interstitial[]) { + return interstitials + .map((interstitial) => { + if (interstitial.asset?.kind) { + return interstitial.asset.kind; + } + if (interstitial.vast) { + return "ad"; + } + return null; + }) + .filter((kind) => kind !== null); +} diff --git a/packages/stitcher/src/playlist.ts b/packages/stitcher/src/playlist.ts index c0148af..bfbcd37 100644 --- a/packages/stitcher/src/playlist.ts +++ b/packages/stitcher/src/playlist.ts @@ -71,8 +71,17 @@ export async function formatMediaPlaylist( export async function formatAssetList(session: Session, dateTime: DateTime) { const assets = await getAssets(session, dateTime); + + const assetsPromises = assets.map(async (asset) => { + return { + URI: asset.url, + DURATION: asset.duration ?? (await fetchDuration(asset.url)), + "SPRS-KIND": asset.kind, + }; + }); + return { - ASSETS: assets, + ASSETS: await Promise.all(assetsPromises), }; } @@ -207,9 +216,10 @@ export function mapAdBreaksToSessionInterstitials( session.interstitials.push({ dateTime, - type: "vast", - url: adBreak.vastUrl, - data: adBreak.vastData, + vast: { + url: adBreak.vastUrl, + data: adBreak.vastData, + }, }); } } diff --git a/packages/stitcher/src/routes/session.ts b/packages/stitcher/src/routes/session.ts index 7de5fbb..f085893 100644 --- a/packages/stitcher/src/routes/session.ts +++ b/packages/stitcher/src/routes/session.ts @@ -45,6 +45,9 @@ export const sessionRoutes = new Elysia() t.Object({ type: t.Literal("asset"), uri: t.String(), + kind: t.Optional( + t.Union([t.Literal("ad"), t.Literal("bumper")]), + ), }), t.Object({ type: t.Literal("vast"), diff --git a/packages/stitcher/src/session.ts b/packages/stitcher/src/session.ts index 6f18fa0..bf6c686 100644 --- a/packages/stitcher/src/session.ts +++ b/packages/stitcher/src/session.ts @@ -17,25 +17,22 @@ export interface Session { interstitials: Interstitial[]; } +type SessionInterstitial = { + time: number | string; +} & ( + | { + type: "asset"; + uri: string; + kind?: "ad" | "bumper"; + } + | { type: "vast"; url: string } + | { type: "assetList"; url: string } +); + export async function createSession(params: { uri: string; vmap?: VmapParams; - interstitials?: ({ - time: number | string; - } & ( - | { - type: "asset"; - uri: string; - } - | { - type: "vast"; - url: string; - } - | { - type: "assetList"; - url: string; - } - ))[]; + interstitials?: SessionInterstitial[]; expiry?: number; }) { const id = randomUUID(); @@ -52,27 +49,10 @@ export async function createSession(params: { }; if (params.interstitials) { - session.interstitials = params.interstitials.map((item) => { - const { time, ...rest } = item; - const dateTime = - typeof time === "string" - ? DateTime.fromISO(time) - : startTime.plus({ seconds: time }); - - // TODO: Below is heavily untyped. Find an explicit way to map input to an |Interstitial|. - let params; - if (rest.type === "asset") { - const { uri, ...assetRest } = rest; - params = { url: resolveUri(uri), ...assetRest }; - } else { - params = rest; - } - - return { - dateTime, - ...params, - }; - }); + session.interstitials = mapSessionInterstitials( + startTime, + params.interstitials, + ); } // We'll initially store the session for 10 minutes, if it's not been consumed @@ -95,3 +75,37 @@ export async function updateSession(session: Session) { const value = JSON.stringify(session); await kv.set(`session:${session.id}`, value, session.expiry); } + +function mapSessionInterstitials( + startTime: DateTime, + interstitials: SessionInterstitial[], +): Interstitial[] { + return interstitials.reduce((acc, item) => { + const dateTime = + typeof item.time === "string" + ? DateTime.fromISO(item.time) + : startTime.plus({ seconds: item.time }); + + if (item.type === "asset") { + acc.push({ + dateTime, + asset: { + url: resolveUri(item.uri), + kind: item.kind, + }, + }); + } else if (item.type === "vast") { + acc.push({ + dateTime, + vast: { url: item.url }, + }); + } else if (item.type === "assetList") { + acc.push({ + dateTime, + assetList: { url: item.url }, + }); + } + + return acc; + }, []); +} diff --git a/packages/stitcher/src/types.ts b/packages/stitcher/src/types.ts index bb3a90d..9aabab0 100644 --- a/packages/stitcher/src/types.ts +++ b/packages/stitcher/src/types.ts @@ -1,19 +1,21 @@ import type { DateTime } from "luxon"; -export type Interstitial = { +export interface InterstitialVast { + url?: string; + data?: string; +} + +export interface InterstitialAsset { + url: string; + duration?: number; + kind?: "ad" | "bumper"; +} + +export interface Interstitial { dateTime: DateTime; -} & ( - | { - type: "asset"; - url: string; - } - | { - type: "vast"; - url?: string; - data?: string; - } - | { - type: "assetList"; - url: string; - } -); + asset?: InterstitialAsset; + vast?: InterstitialVast; + assetList?: { + url: string; + }; +} diff --git a/packages/stitcher/src/vast.ts b/packages/stitcher/src/vast.ts index 87a474a..32ef57e 100644 --- a/packages/stitcher/src/vast.ts +++ b/packages/stitcher/src/vast.ts @@ -3,29 +3,22 @@ import * as uuid from "uuid"; import { VASTClient } from "vast-client"; import { api } from "./lib/api-client"; import { resolveUri } from "./lib/url"; +import type { InterstitialAsset, InterstitialVast } from "./types"; import type { VastAd, VastCreativeLinear, VastResponse } from "vast-client"; const NAMESPACE_UUID_AD = "5b212a7e-d6a2-43bf-bd30-13b1ca1f9b13"; -export interface AdMedia { - masterUrl: string; - duration: number; -} - -export async function getAdMediasFromVast(params: { - url?: string; - data?: string; -}) { +export async function getAssetsFromVast(vast: InterstitialVast) { const vastClient = new VASTClient(); let vastResponse: VastResponse | undefined; - if (params.url) { - vastResponse = await vastClient.get(params.url); + if (vast.url) { + vastResponse = await vastClient.get(vast.url); } - if (params.data) { + if (vast.data) { const parser = new DOMParser(); - const xml = parser.parseFromString(params.data, "text/xml"); + const xml = parser.parseFromString(vast.data, "text/xml"); vastResponse = await vastClient.parseVAST(xml); } @@ -33,7 +26,7 @@ export async function getAdMediasFromVast(params: { return []; } - return await getAdMediasFromVastResponse(vastResponse); + return await mapVastResponseToAssets(vastResponse); } async function scheduleForPackage(assetId: string, url: string) { @@ -91,7 +84,7 @@ async function fetchAsset(id: string) { throw new Error(`Failed to fetch asset, got status ${status}`); } -async function mapAdMedia(ad: VastAd): Promise { +async function mapAdToAsset(ad: VastAd): Promise { const creative = getCreative(ad); if (!creative) { return null; @@ -99,13 +92,13 @@ async function mapAdMedia(ad: VastAd): Promise { const id = getAdId(creative); - let masterUrl = getCreativeStreamingUrl(creative); + let url = getCreativeStreamingUrl(creative); - if (!masterUrl) { + if (!url) { const asset = await fetchAsset(id); if (asset) { - masterUrl = resolveUri(`asset://${id}`); + url = resolveUri(`asset://${id}`); } else { const fileUrl = getCreativeStaticUrl(creative); if (fileUrl) { @@ -114,28 +107,29 @@ async function mapAdMedia(ad: VastAd): Promise { } } - if (!masterUrl) { + if (!url) { return null; } return { - masterUrl, + url: url, duration: creative.duration, + kind: "ad", }; } -async function getAdMediasFromVastResponse(response: VastResponse) { - const adMedias: AdMedia[] = []; +async function mapVastResponseToAssets(response: VastResponse) { + const assets: InterstitialAsset[] = []; for (const ad of response.ads) { - const adMedia = await mapAdMedia(ad); - if (!adMedia) { + const asset = await mapAdToAsset(ad); + if (!asset) { continue; } - adMedias.push(adMedia); + assets.push(asset); } - return adMedias; + return assets; } function getCreativeStaticUrl(creative: VastCreativeLinear) {