diff --git a/packages/stitcher/src/interstitials.ts b/packages/stitcher/src/interstitials.ts index 68855be..96ddd25 100644 --- a/packages/stitcher/src/interstitials.ts +++ b/packages/stitcher/src/interstitials.ts @@ -1,114 +1,118 @@ 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 { Interstitial, InterstitialAssetType } from "./types"; +import type { Interstitial, InterstitialAsset } from "./types"; import type { DateTime } from "luxon"; export function getStaticDateRanges(session: Session, isLive: boolean) { - const group: { - dateTime: DateTime; - types: InterstitialAssetType[]; - }[] = []; - - for (const interstitial of session.interstitials) { - let item = group.find((item) => - item.dateTime.equals(interstitial.dateTime), - ); - - if (!item) { - item = { - dateTime: interstitial.dateTime, - types: [], - }; - group.push(item); - } - - const type = getInterstitialType(interstitial); - if (type && !item.types.includes(type)) { - item.types.push(type); - } - } - - return group.map((item) => { - const assetListUrl = createAssetListUrl({ - dateTime: item.dateTime, - session, - }); + return session.interstitials.map((interstitial) => { + const startDate = interstitial.dateTime; + const assetListUrl = getAssetListUrl(interstitial, session); const clientAttributes: Record = { RESTRICT: "SKIP,JUMP", "ASSET-LIST": assetListUrl, - CUE: "ONCE", + "CONTENT-MAY-VARY": "YES", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": getTimelineStyle(interstitial), }; if (!isLive) { clientAttributes["RESUME-OFFSET"] = 0; } - const isPreroll = item.dateTime.equals(session.startTime); - if (isPreroll) { - clientAttributes["CUE"] += ",PRE"; + const cue: string[] = ["ONCE"]; + if (startDate.equals(session.startTime)) { + cue.push("PRE"); } - if (item.types.length) { - clientAttributes["SPRS-TYPES"] = item.types.join(","); + if (cue.length) { + clientAttributes["CUE"] = cue.join(","); } return { classId: "com.apple.hls.interstitial", - id: `${item.dateTime.toUnixInteger()}`, - startDate: item.dateTime, + id: `${startDate.toMillis()}`, + startDate, clientAttributes, }; }); } export async function getAssets(session: Session, dateTime: DateTime) { - const assets: { - URI: string; - DURATION: number; - "SPRS-TYPE"?: InterstitialAssetType; - }[] = []; + const assets: InterstitialAsset[] = []; - const interstitials = session.interstitials.filter((interstitial) => + const interstitial = session.interstitials.find((interstitial) => interstitial.dateTime.equals(dateTime), ); + if (interstitial?.vast) { + const nextAssets = await getAssetsFromVast(interstitial.vast); + assets.push(...nextAssets); + } + + if (interstitial?.assets) { + assets.push(...interstitial.assets); + } + + return assets; +} + +export function appendInterstitials( + source: Interstitial[], + interstitials: Interstitial[], +) { for (const interstitial of interstitials) { - const adMedias = await getAdMediasFromVast(interstitial); - for (const adMedia of adMedias) { - assets.push({ - URI: adMedia.masterUrl, - DURATION: adMedia.duration, - "SPRS-TYPE": "ad", - }); + const target = source.find((item) => + item.dateTime.equals(interstitial.dateTime), + ); + + if (!target) { + source.push(interstitial); + continue; } - if (interstitial.asset) { - assets.push({ - URI: interstitial.asset.url, - DURATION: await fetchDuration(interstitial.asset.url), - "SPRS-TYPE": interstitial.asset.type, - }); + if (interstitial.assets) { + if (!target.assets) { + target.assets = interstitial.assets; + } else { + target.assets.push(...interstitial.assets); + } } - } - return assets; + if (interstitial.vast) { + target.vast = interstitial.vast; + } + + if (interstitial.assetList) { + target.assetList = interstitial.assetList; + } + } } -function createAssetListUrl(params: { dateTime: DateTime; session?: Session }) { +function getAssetListUrl(interstitial: Interstitial, session?: Session) { + if (interstitial.assetList) { + return interstitial.assetList.url; + } return createUrl("out/asset-list.json", { - dt: params.dateTime.toISO(), - sid: params.session?.id, + dt: interstitial.dateTime.toISO(), + sid: session?.id, }); } -function getInterstitialType( - interstitial: Interstitial, -): InterstitialAssetType | undefined { - if (interstitial.vastData || interstitial.vastUrl) { - return "ad"; +function getTimelineStyle(interstitial: Interstitial) { + if (interstitial.assets) { + for (const asset of interstitial.assets) { + if (asset.kind === "ad") { + return "HIGHLIGHT"; + } + } } - return interstitial.asset?.type; + + if (interstitial.vast) { + return "HIGHLIGHT"; + } + + return "PRIMARY"; } diff --git a/packages/stitcher/src/playlist.ts b/packages/stitcher/src/playlist.ts index 0f015f8..d335256 100644 --- a/packages/stitcher/src/playlist.ts +++ b/packages/stitcher/src/playlist.ts @@ -1,6 +1,10 @@ import { assert } from "shared/assert"; import { filterMasterPlaylist, formatFilterToQueryParam } from "./filters"; -import { getAssets, getStaticDateRanges } from "./interstitials"; +import { + appendInterstitials, + getAssets, + getStaticDateRanges, +} from "./interstitials"; import { encrypt } from "./lib/crypto"; import { createUrl, joinUrl, resolveUri } from "./lib/url"; import { @@ -15,6 +19,7 @@ import { fetchVmap, toAdBreakTimeOffset } from "./vmap"; import type { Filter } from "./filters"; import type { MasterPlaylist, MediaPlaylist, RenditionType } from "./parser"; import type { Session } from "./session"; +import type { Interstitial } from "./types"; import type { VmapAdBreak } from "./vmap"; import type { DateTime } from "luxon"; @@ -71,8 +76,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), }; } @@ -181,8 +195,14 @@ async function initSessionOnMasterReq(session: Session) { if (session.vmap) { const vmap = await fetchVmap(session.vmap); + delete session.vmap; - mapAdBreaksToSessionInterstitials(session, vmap.adBreaks); + + const interstitials = mapAdBreaksToSessionInterstitials( + session, + vmap.adBreaks, + ); + appendInterstitials(session.interstitials, interstitials); storeSession = true; } @@ -196,6 +216,8 @@ export function mapAdBreaksToSessionInterstitials( session: Session, adBreaks: VmapAdBreak[], ) { + const interstitials: Interstitial[] = []; + for (const adBreak of adBreaks) { const timeOffset = toAdBreakTimeOffset(adBreak); @@ -205,18 +227,14 @@ export function mapAdBreaksToSessionInterstitials( const dateTime = session.startTime.plus({ seconds: timeOffset }); - if (adBreak.vastUrl) { - session.interstitials.push({ - dateTime, - vastUrl: adBreak.vastUrl, - }); - } - - if (adBreak.vastData) { - session.interstitials.push({ - dateTime, - vastData: adBreak.vastData, - }); - } + interstitials.push({ + dateTime, + vast: { + url: adBreak.vastUrl, + data: adBreak.vastData, + }, + }); } + + return interstitials; } diff --git a/packages/stitcher/src/routes/session.ts b/packages/stitcher/src/routes/session.ts index 33512e3..f1934e5 100644 --- a/packages/stitcher/src/routes/session.ts +++ b/packages/stitcher/src/routes/session.ts @@ -39,13 +39,27 @@ export const sessionRoutes = new Elysia() t.Array( t.Object({ time: t.Union([t.Number(), t.String()]), - vastUrl: t.Optional(t.String()), - uri: t.Optional(t.String()), - type: t.Optional(t.Union([t.Literal("ad"), t.Literal("bumper")])), + assets: t.Optional( + t.Array( + t.Object({ + uri: t.String(), + kind: t.Optional( + t.Union([t.Literal("ad"), t.Literal("bumper")]), + ), + }), + ), + ), + vast: t.Optional( + t.Object({ + url: t.String(), + }), + ), + assetList: t.Optional( + t.Object({ + url: t.String(), + }), + ), }), - { - description: "Manual HLS interstitial insertion.", - }, ), ), filter: t.Optional( diff --git a/packages/stitcher/src/session.ts b/packages/stitcher/src/session.ts index cb5cbf8..f236fca 100644 --- a/packages/stitcher/src/session.ts +++ b/packages/stitcher/src/session.ts @@ -1,9 +1,10 @@ import { randomUUID } from "crypto"; import { DateTime } from "luxon"; import { kv } from "./adapters/kv"; +import { appendInterstitials } from "./interstitials"; import { JSON } from "./lib/json"; import { resolveUri } from "./lib/url"; -import type { Interstitial, InterstitialAssetType } from "./types"; +import type { Interstitial } from "./types"; import type { VmapParams } from "./vmap"; export interface Session { @@ -17,17 +18,24 @@ export interface Session { interstitials: Interstitial[]; } -export async function createSession(params: { - uri: string; - vmap?: { +interface SessionInterstitial { + time: number | string; + assets?: { + uri: string; + kind?: "ad" | "bumper"; + }[]; + vast?: { url: string; }; - interstitials?: { - time: string | number; - vastUrl?: string; - uri?: string; - type?: InterstitialAssetType; - }[]; + assetList?: { + url: string; + }; +} + +export async function createSession(params: { + uri: string; + vmap?: VmapParams; + interstitials?: SessionInterstitial[]; expiry?: number; }) { const id = randomUUID(); @@ -44,29 +52,11 @@ export async function createSession(params: { }; if (params.interstitials) { - params.interstitials.forEach((interstitial) => { - const dateTime = - typeof interstitial.time === "string" - ? DateTime.fromISO(interstitial.time) - : startTime.plus({ seconds: interstitial.time }); - - if (interstitial.uri) { - session.interstitials.push({ - dateTime, - asset: { - url: resolveUri(interstitial.uri), - type: interstitial.type, - }, - }); - } - - if (interstitial.vastUrl) { - session.interstitials.push({ - dateTime, - vastUrl: interstitial.vastUrl, - }); - } - }); + const interstitials = mapSessionInterstitials( + startTime, + params.interstitials, + ); + appendInterstitials(session.interstitials, interstitials); } // We'll initially store the session for 10 minutes, if it's not been consumed @@ -89,3 +79,25 @@ 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[], +) { + return interstitials.map((item) => { + const { time, assets, ...rest } = item; + const dateTime = + typeof time === "string" + ? DateTime.fromISO(time) + : startTime.plus({ seconds: time }); + + return { + dateTime, + assets: assets?.map((asset) => { + const { uri, ...rest } = asset; + return { url: resolveUri(uri), ...rest }; + }), + ...rest, + }; + }); +} diff --git a/packages/stitcher/src/types.ts b/packages/stitcher/src/types.ts index a65bc24..5326d96 100644 --- a/packages/stitcher/src/types.ts +++ b/packages/stitcher/src/types.ts @@ -1,15 +1,23 @@ import type { DateTime } from "luxon"; -export type InterstitialAssetType = "ad" | "bumper"; +export interface InterstitialVast { + url?: string; + data?: string; +} export interface InterstitialAsset { url: string; - type?: InterstitialAssetType; + duration?: number; + kind?: "ad" | "bumper"; +} + +export interface InterstitialAssetList { + url: string; } export interface Interstitial { dateTime: DateTime; - vastUrl?: string; - vastData?: string; - asset?: InterstitialAsset; + assets?: InterstitialAsset[]; + vast?: InterstitialVast; + assetList?: InterstitialAssetList; } diff --git a/packages/stitcher/src/vast.ts b/packages/stitcher/src/vast.ts index 245a761..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: { - vastUrl?: string; - vastData?: string; -}) { +export async function getAssetsFromVast(vast: InterstitialVast) { const vastClient = new VASTClient(); let vastResponse: VastResponse | undefined; - if (params.vastUrl) { - vastResponse = await vastClient.get(params.vastUrl); + if (vast.url) { + vastResponse = await vastClient.get(vast.url); } - if (params.vastData) { + if (vast.data) { const parser = new DOMParser(); - const xml = parser.parseFromString(params.vastData, "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) { diff --git a/packages/stitcher/test/__snapshots__/interstitials.test.ts.snap b/packages/stitcher/test/__snapshots__/interstitials.test.ts.snap index 1ca6cca..15824c8 100644 --- a/packages/stitcher/test/__snapshots__/interstitials.test.ts.snap +++ b/packages/stitcher/test/__snapshots__/interstitials.test.ts.snap @@ -1,17 +1,32 @@ // Bun Snapshot v1, https://goo.gl/fbAQLP +exports[`getAssets should get assets by interstitials 1`] = ` +[ + { + "duration": 25, + "url": "https://mock.com/ad_1/master.m3u8", + }, + { + "kind": "ad", + "url": "https://mock.com/interstitial1/master.m3u8", + }, +] +`; + exports[`getStaticDateRanges should create dateRanges for vod 1`] = ` [ { "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A12%3A05.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE,PRE", "RESTRICT": "SKIP,JUMP", "RESUME-OFFSET": 0, - "SPRS-TYPES": "ad", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "HIGHLIGHT", }, - "id": "1619950325", + "id": "1619950325250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -54,12 +69,14 @@ exports[`getStaticDateRanges should create dateRanges for vod 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A12%3A15.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE", "RESTRICT": "SKIP,JUMP", "RESUME-OFFSET": 0, - "SPRS-TYPES": "ad", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "HIGHLIGHT", }, - "id": "1619950335", + "id": "1619950335250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -102,12 +119,14 @@ exports[`getStaticDateRanges should create dateRanges for vod 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A12%3A35.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE", "RESTRICT": "SKIP,JUMP", "RESUME-OFFSET": 0, - "SPRS-TYPES": "bumper", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "PRIMARY", }, - "id": "1619950355", + "id": "1619950355250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -150,12 +169,14 @@ exports[`getStaticDateRanges should create dateRanges for vod 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A12%3A45.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE", "RESTRICT": "SKIP,JUMP", "RESUME-OFFSET": 0, - "SPRS-TYPES": "ad", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "HIGHLIGHT", }, - "id": "1619950365", + "id": "1619950365250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -198,11 +219,64 @@ exports[`getStaticDateRanges should create dateRanges for vod 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A13%3A45.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE", "RESTRICT": "SKIP,JUMP", "RESUME-OFFSET": 0, + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "PRIMARY", }, - "id": "1619950425", + "id": "1619950425250", + "startDate": DateTime { + "_zone": SystemZone {}, + "c": { + "day": 2, + "hour": 10, + "millisecond": 250, + "minute": 13, + "month": 5, + "second": 45, + "year": 2021, + }, + "invalid": null, + "isLuxonDateTime": true, + "loc": Locale { + "eraCache": {}, + "fastNumbersCached": null, + "intl": "en-US", + "locale": "en-US", + "meridiemCache": null, + "monthsCache": { + "format": {}, + "standalone": {}, + }, + "numberingSystem": null, + "outputCalendar": null, + "specifiedLocale": null, + "weekSettings": null, + "weekdaysCache": { + "format": {}, + "standalone": {}, + }, + }, + "localWeekData": null, + "o": -0, + "ts": 1619950425250, + "weekData": null, + }, + }, + { + "classId": "com.apple.hls.interstitial", + "clientAttributes": { + "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A13%3A45.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", + "CUE": "ONCE", + "RESTRICT": "SKIP,JUMP", + "RESUME-OFFSET": 0, + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "PRIMARY", + }, + "id": "1619950425250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -250,11 +324,13 @@ exports[`getStaticDateRanges should create dateRanges for live 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A12%3A05.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE,PRE", "RESTRICT": "SKIP,JUMP", - "SPRS-TYPES": "ad", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "HIGHLIGHT", }, - "id": "1619950325", + "id": "1619950325250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -297,11 +373,13 @@ exports[`getStaticDateRanges should create dateRanges for live 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A12%3A15.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE", "RESTRICT": "SKIP,JUMP", - "SPRS-TYPES": "ad", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "HIGHLIGHT", }, - "id": "1619950335", + "id": "1619950335250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -344,11 +422,13 @@ exports[`getStaticDateRanges should create dateRanges for live 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A12%3A35.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE", "RESTRICT": "SKIP,JUMP", - "SPRS-TYPES": "bumper", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "PRIMARY", }, - "id": "1619950355", + "id": "1619950355250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -391,11 +471,13 @@ exports[`getStaticDateRanges should create dateRanges for live 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A12%3A45.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE", "RESTRICT": "SKIP,JUMP", - "SPRS-TYPES": "ad", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "HIGHLIGHT", }, - "id": "1619950365", + "id": "1619950365250", "startDate": DateTime { "_zone": SystemZone {}, "c": { @@ -438,10 +520,62 @@ exports[`getStaticDateRanges should create dateRanges for live 1`] = ` "classId": "com.apple.hls.interstitial", "clientAttributes": { "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A13%3A45.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", + "CUE": "ONCE", + "RESTRICT": "SKIP,JUMP", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "PRIMARY", + }, + "id": "1619950425250", + "startDate": DateTime { + "_zone": SystemZone {}, + "c": { + "day": 2, + "hour": 10, + "millisecond": 250, + "minute": 13, + "month": 5, + "second": 45, + "year": 2021, + }, + "invalid": null, + "isLuxonDateTime": true, + "loc": Locale { + "eraCache": {}, + "fastNumbersCached": null, + "intl": "en-US", + "locale": "en-US", + "meridiemCache": null, + "monthsCache": { + "format": {}, + "standalone": {}, + }, + "numberingSystem": null, + "outputCalendar": null, + "specifiedLocale": null, + "weekSettings": null, + "weekdaysCache": { + "format": {}, + "standalone": {}, + }, + }, + "localWeekData": null, + "o": -0, + "ts": 1619950425250, + "weekData": null, + }, + }, + { + "classId": "com.apple.hls.interstitial", + "clientAttributes": { + "ASSET-LIST": "stitcher-endpoint/out/asset-list.json?dt=2021-05-02T10%3A13%3A45.250%2B00%3A00&sid=36bab417-0952-4c23-bdf0-9a424e4651ad", + "CONTENT-MAY-VARY": "YES", "CUE": "ONCE", "RESTRICT": "SKIP,JUMP", + "TIMELINE-OCCUPIES": "POINT", + "TIMELINE-STYLE": "PRIMARY", }, - "id": "1619950425", + "id": "1619950425250", "startDate": DateTime { "_zone": SystemZone {}, "c": { diff --git a/packages/stitcher/test/__snapshots__/playlist.test.ts.snap b/packages/stitcher/test/__snapshots__/playlist.test.ts.snap index abe06f1..8345dd4 100644 --- a/packages/stitcher/test/__snapshots__/playlist.test.ts.snap +++ b/packages/stitcher/test/__snapshots__/playlist.test.ts.snap @@ -132,7 +132,10 @@ exports[`mapAdBreaksToSessionInterstitials should handle time based with vastUrl "ts": 1619950325250, "weekData": null, }, - "vastUrl": "http://mock.com/vast_1.xml", + "vast": { + "data": undefined, + "url": "http://mock.com/vast_1.xml", + }, }, { "dateTime": DateTime { @@ -172,7 +175,10 @@ exports[`mapAdBreaksToSessionInterstitials should handle time based with vastUrl "ts": 1619950340250, "weekData": null, }, - "vastUrl": "http://mock.com/vast_2.xml", + "vast": { + "data": undefined, + "url": "http://mock.com/vast_2.xml", + }, }, { "dateTime": DateTime { @@ -212,7 +218,10 @@ exports[`mapAdBreaksToSessionInterstitials should handle time based with vastUrl "ts": 1619950350250, "weekData": null, }, - "vastUrl": "http://mock.com/vast_3.xml", + "vast": { + "data": undefined, + "url": "http://mock.com/vast_3.xml", + }, }, ] `; @@ -257,7 +266,10 @@ exports[`mapAdBreaksToSessionInterstitials should handle vastData 1`] = ` "ts": 1619950335250, "weekData": null, }, - "vastData": "mocked VAST data", + "vast": { + "data": "mocked VAST data", + "url": undefined, + }, }, ] `; diff --git a/packages/stitcher/test/interstitials.test.ts b/packages/stitcher/test/interstitials.test.ts index b502f95..3fbd8b7 100644 --- a/packages/stitcher/test/interstitials.test.ts +++ b/packages/stitcher/test/interstitials.test.ts @@ -1,10 +1,12 @@ -import { describe, expect, test } from "bun:test"; -import { mockSessionWithInterstitials } from "./mock"; -import { getStaticDateRanges } from "../src/interstitials"; +import { describe, expect, spyOn, test } from "bun:test"; +import { DateTime } from "luxon"; +import { addFakeInterstitials, fakeSession } from "./test-data"; +import { getAssets, getStaticDateRanges } from "../src/interstitials"; describe("getStaticDateRanges", () => { test("should create dateRanges for vod", () => { - const session = mockSessionWithInterstitials(); + const session = fakeSession(); + addFakeInterstitials(session); const isLive = false; const dateRanges = getStaticDateRanges(session, isLive); @@ -13,7 +15,8 @@ describe("getStaticDateRanges", () => { }); test("should create dateRanges for live", () => { - const session = mockSessionWithInterstitials(); + const session = fakeSession(); + addFakeInterstitials(session); const isLive = true; const dateRanges = getStaticDateRanges(session, isLive); @@ -21,3 +24,42 @@ describe("getStaticDateRanges", () => { expect(dateRanges).toMatchSnapshot(); }); }); + +describe("getAssets", () => { + test("should get assets by interstitials", async () => { + const session = fakeSession(); + const dateTime = DateTime.now(); + + const spy = spyOn(await import("../src/vast"), "getAssetsFromVast"); + spy.mockReturnValueOnce( + Promise.resolve([ + { + url: "https://mock.com/ad_1/master.m3u8", + duration: 25, + }, + ]), + ); + + session.interstitials = [ + { + dateTime, + assets: [ + { + url: "https://mock.com/interstitial1/master.m3u8", + kind: "ad", + }, + ], + vast: { + url: "https://mock.com/vast.xml", + }, + }, + ]; + + const assets = await getAssets(session, dateTime); + + expect(spy).toHaveBeenCalledTimes(1); + expect(assets).toMatchSnapshot(); + + spy.mockRestore(); + }); +}); diff --git a/packages/stitcher/test/playlist.test.ts b/packages/stitcher/test/playlist.test.ts index f1ad569..0b4445a 100644 --- a/packages/stitcher/test/playlist.test.ts +++ b/packages/stitcher/test/playlist.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; import { - mockMaster, - mockMediaWithAbsSeg, - mockMediaWithRelSeg, - mockSession, -} from "./mock"; + fakeMasterPlaylist, + fakeMediaPlaylistWithAbsSeg, + fakeMediaPlaylistWithRelSeg, + fakeSession, +} from "./test-data"; import { createMasterUrl, mapAdBreaksToSessionInterstitials, @@ -14,7 +14,7 @@ import { describe("rewriteMasterPlaylistUrls", () => { test("should rewrite", () => { - const master = mockMaster(); + const master = fakeMasterPlaylist(); rewriteMasterPlaylistUrls(master, { origUrl: "http://mock.com/master.m3u8", @@ -24,8 +24,8 @@ describe("rewriteMasterPlaylistUrls", () => { }); test("should include session id", () => { - const master = mockMaster(); - const session = mockSession(); + const master = fakeMasterPlaylist(); + const session = fakeSession(); rewriteMasterPlaylistUrls(master, { origUrl: "http://mock.com/master.m3u8", @@ -38,7 +38,7 @@ describe("rewriteMasterPlaylistUrls", () => { describe("rewriteMediaPlaylistUrls", () => { test("should rewrite relative segments", () => { - const media = mockMediaWithRelSeg(); + const media = fakeMediaPlaylistWithRelSeg(); rewriteMediaPlaylistUrls(media, "https://mock.com/video_1.m3u8"); @@ -46,7 +46,7 @@ describe("rewriteMediaPlaylistUrls", () => { }); test("should rewrite absolute segments", () => { - const media = mockMediaWithAbsSeg(); + const media = fakeMediaPlaylistWithAbsSeg(); rewriteMediaPlaylistUrls(media, "https://mock.com/video_2.m3u8"); @@ -56,9 +56,9 @@ describe("rewriteMediaPlaylistUrls", () => { describe("mapAdBreaksToSessionInterstitials", () => { test("should handle time based with vastUrl", () => { - const session = mockSession(); + const session = fakeSession(); - mapAdBreaksToSessionInterstitials(session, [ + const interstitials = mapAdBreaksToSessionInterstitials(session, [ { timeOffset: "start", vastUrl: "http://mock.com/vast_1.xml", @@ -73,20 +73,20 @@ describe("mapAdBreaksToSessionInterstitials", () => { }, ]); - expect(session.interstitials).toMatchSnapshot(); + expect(interstitials).toMatchSnapshot(); }); test("should handle vastData", () => { - const session = mockSession(); + const session = fakeSession(); - mapAdBreaksToSessionInterstitials(session, [ + const interstitials = mapAdBreaksToSessionInterstitials(session, [ { timeOffset: "00:00:10.000", vastData: "mocked VAST data", }, ]); - expect(session.interstitials).toMatchSnapshot(); + expect(interstitials).toMatchSnapshot(); }); }); @@ -105,7 +105,7 @@ describe("createMasterUrl", () => { }); test("should create with session", () => { - const session = mockSession(); + const session = fakeSession(); const result = createMasterUrl({ url: "https://mock.com/master.m3u8", diff --git a/packages/stitcher/test/setup.ts b/packages/stitcher/test/setup.ts index 7d8da19..779b326 100644 --- a/packages/stitcher/test/setup.ts +++ b/packages/stitcher/test/setup.ts @@ -15,6 +15,12 @@ mock.module("redis", () => ({ }, })); +mock.module("vast-client", () => ({ + VASTClient: class { + get = mock(); + }, +})); + // The day my son was born! setSystemTime(new Date(2021, 4, 2, 10, 12, 5, 250)); diff --git a/packages/stitcher/test/mock.ts b/packages/stitcher/test/test-data.ts similarity index 57% rename from packages/stitcher/test/mock.ts rename to packages/stitcher/test/test-data.ts index fc8c0de..d3bca6a 100644 --- a/packages/stitcher/test/mock.ts +++ b/packages/stitcher/test/test-data.ts @@ -2,7 +2,7 @@ import { DateTime } from "luxon"; import type { MasterPlaylist, MediaPlaylist } from "../src/parser"; import type { Session } from "../src/session"; -export function mockMaster(): MasterPlaylist { +export function fakeMasterPlaylist(): MasterPlaylist { return { variants: [ { @@ -29,7 +29,7 @@ export function mockMaster(): MasterPlaylist { }; } -export function mockMediaWithRelSeg(): MediaPlaylist { +export function fakeMediaPlaylistWithRelSeg(): MediaPlaylist { return { targetDuration: 2, endlist: true, @@ -50,7 +50,7 @@ export function mockMediaWithRelSeg(): MediaPlaylist { }; } -export function mockMediaWithAbsSeg(): MediaPlaylist { +export function fakeMediaPlaylistWithAbsSeg(): MediaPlaylist { return { targetDuration: 2, endlist: true, @@ -67,7 +67,7 @@ export function mockMediaWithAbsSeg(): MediaPlaylist { }; } -export function mockSession(): Session { +export function fakeSession(): Session { return { id: "36bab417-0952-4c23-bdf0-9a424e4651ad", url: "http://mock.com/master.m3u8", @@ -77,50 +77,56 @@ export function mockSession(): Session { }; } -export function mockSessionWithInterstitials(): Session { - const session = mockSession(); - - const startDate = DateTime.now(); - +export function addFakeInterstitials(session: Session) { session.interstitials = [ { - dateTime: startDate, - vastUrl: "https://mock.com/vast.xml", + dateTime: session.startTime, + vast: { + url: "https://mock.com/vast.xml", + }, }, { - dateTime: startDate.plus({ seconds: 10 }), - vastUrl: "mocked VAST data", + dateTime: session.startTime.plus({ seconds: 10 }), + vast: { + data: "mocked VAST data", + }, }, // Manual bumper interstitial { - dateTime: startDate.plus({ seconds: 30 }), - asset: { - url: "https://mock.com/interstitial/bumper.m3u8", - type: "bumper", - }, + dateTime: session.startTime.plus({ seconds: 30 }), + assets: [ + { + url: "https://mock.com/interstitial/bumper.m3u8", + kind: "bumper", + }, + ], }, // Manual ad interstitial { - dateTime: startDate.plus({ seconds: 40 }), - asset: { - url: "https://mock.com/interstitial/ad.m3u8", - type: "ad", - }, + dateTime: session.startTime.plus({ seconds: 40 }), + assets: [ + { + url: "https://mock.com/interstitial/ad.m3u8", + kind: "ad", + }, + ], }, // Multiple manual interstitials { - dateTime: startDate.plus({ seconds: 100 }), - asset: { - url: "https://mock.com/interstitial/master1.m3u8", - }, + dateTime: session.startTime.plus({ seconds: 100 }), + assets: [ + { + url: "https://mock.com/interstitial/master1.m3u8", + }, + ], }, { - dateTime: startDate.plus({ seconds: 100 }), - asset: { - url: "https://mock.com/interstitial/master2.m3u8", - }, + dateTime: session.startTime.plus({ seconds: 100 }), + assets: [ + { + url: "https://mock.com/interstitial/master2.m3u8", + }, + ], }, ]; - - return session; }