Skip to content

Commit

Permalink
feat: Simplify Stitcher playlist generation (#114)
Browse files Browse the repository at this point in the history
* fix: Store vmap text instead of full response object

* Simpler playlist generation

* Added superjson serialize

* Added group logic
  • Loading branch information
matvp91 authored Nov 16, 2024
1 parent 34246e1 commit 68c5368
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 186 deletions.
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions packages/stitcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"luxon": "^3.5.0",
"redis": "^4.7.0",
"shared": "workspace:*",
"superjson": "^2.2.1",
"uuid": "^10.0.0",
"vast-client": "workspace:*"
}
Expand Down
152 changes: 76 additions & 76 deletions packages/stitcher/src/interstitials.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { DateTime } from "luxon";
import { assert } from "shared/assert";
import { env } from "./env";
import { resolveUri, toAssetProtocol } from "./lib/url";
import { fetchMasterPlaylistDuration } from "./playlist";
import { Group } from "./lib/group";
import { buildProxyUrl, resolveUri } from "./lib/url";
import { fetchDuration } from "./playlist";
import { getAdMediasFromAdBreak } from "./vast";
import { parseVmap } from "./vmap";
import type { DateRange } from "./parser";
import type { Session } from "./session";
import type { VmapResponse } from "./vmap";
import type { DateTime } from "luxon";

export type InterstitialType = "ad" | "bumper";

export interface Interstitial {
timeOffset: number;
url: string;
duration?: number;
type?: InterstitialType;
}

Expand All @@ -23,30 +23,33 @@ interface InterstitialAsset {
"SPRS-TYPE"?: InterstitialType;
}

export function getStaticDateRanges(session: Session) {
assert(session.startTime, "No startTime in session");

const group: Record<string, InterstitialType[]> = {};
export function getStaticDateRanges(startTime: DateTime, session: Session) {
const group = new Group<number, InterstitialType | undefined>();

if (session.vmapResponse) {
const vmap = parseVmap(session.vmapResponse);
for (const adBreak of vmap.adBreaks) {
const dateTime = session.startTime.plus({ seconds: adBreak.timeOffset });
groupTimeOffset(group, dateTime, "ad");
group.add(adBreak.timeOffset, "ad");
}
}

if (session.interstitials) {
for (const interstitial of session.interstitials) {
const dateTime = session.startTime.plus({
seconds: interstitial.timeOffset,
});
groupTimeOffset(group, dateTime, interstitial.type);
}
}
session.interstitials?.forEach((timeOffset, interstitials) => {
interstitials.forEach((interstitial) => {
group.add(timeOffset, interstitial.type);
});
});

return Object.entries(group).map<DateRange>(([startDate, types], index) => {
const assetListUrl = `${env.PUBLIC_STITCHER_ENDPOINT}/session/${session.id}/asset-list.json?startDate=${encodeURIComponent(startDate)}`;
const dateRanges: DateRange[] = [];

group.forEach((timeOffset, types) => {
const startDate = startTime.plus({ seconds: timeOffset });

const assetListUrl = buildProxyUrl(
`session/${session.id}/asset-list.json`,
{
startDate: startDate.toISO(),
},
);

const clientAttributes: Record<string, number | string> = {
RESTRICT: "SKIP,JUMP",
Expand All @@ -58,106 +61,103 @@ export function getStaticDateRanges(session: Session) {
clientAttributes["SPRS-TYPES"] = types.join(",");
}

return {
dateRanges.push({
classId: "com.apple.hls.interstitial",
id: `i${index}`,
startDate: DateTime.fromISO(startDate),
id: `sdr${timeOffset}`,
startDate,
clientAttributes,
};
});
});
}

function groupTimeOffset(
group: Record<string, InterstitialType[]>,
dateTime: DateTime,
type?: InterstitialType,
) {
const key = dateTime.toISO();
if (!key) {
return;
}
if (!group[key]) {
group[key] = [];
}
if (type) {
group[key].push(type);
}
return dateRanges;
}

export async function getAssets(session: Session, lookupDate: DateTime) {
assert(session.startTime, "No startTime in session");

const assets: InterstitialAsset[] = [];

if (session.vmapResponse) {
const vmap = parseVmap(session.vmapResponse);
await formatStaticAdBreaks(assets, vmap, session.startTime, lookupDate);
}
if (session.startTime) {
if (session.vmapResponse) {
const vmap = parseVmap(session.vmapResponse);
const vmapAssets = await getAssetsFromVmap(
vmap,
session.startTime,
lookupDate,
);
assets.push(...vmapAssets);
}

if (session.interstitials) {
await formatStaticInterstitials(
assets,
session.interstitials,
session.startTime,
lookupDate,
);
if (session.interstitials) {
const groupAssets = await getAssetsFromGroup(
session.interstitials,
session.startTime,
lookupDate,
);
assets.push(...groupAssets);
}
}

return assets;
}

async function formatStaticAdBreaks(
assets: InterstitialAsset[],
async function getAssetsFromVmap(
vmap: VmapResponse,
baseDate: DateTime,
lookupDate: DateTime,
) {
const adBreak = vmap.adBreaks.find((adBreak) =>
isEqualTimeOffset(baseDate, adBreak.timeOffset, lookupDate),
const timeOffset = getTimeOffset(baseDate, lookupDate);
const adBreak = vmap.adBreaks.find(
(adBreak) => adBreak.timeOffset === timeOffset,
);

if (!adBreak) {
// No adbreak found for the time offset. There's nothing left to do.
return;
return [];
}

const assets: InterstitialAsset[] = [];

const adMedias = await getAdMediasFromAdBreak(adBreak);

for (const adMedia of adMedias) {
const uri = toAssetProtocol(adMedia.assetId);
assets.push({
URI: resolveUri(uri),
URI: resolveUri(`asset://${adMedia.assetId}`),
DURATION: adMedia.duration,
"SPRS-TYPE": "ad",
});
}

return assets;
}

async function formatStaticInterstitials(
assets: InterstitialAsset[],
interstitials: Interstitial[],
async function getAssetsFromGroup(
interstitialsGroup: Group<number, Interstitial>,
baseDate: DateTime,
lookupDate: DateTime,
) {
// Filter each interstitial and match it with the given lookup time.
const list = interstitials.filter((interstitial) =>
isEqualTimeOffset(baseDate, interstitial.timeOffset, lookupDate),
);
const assets: InterstitialAsset[] = [];

const timeOffset = getTimeOffset(baseDate, lookupDate);

const interstitials = interstitialsGroup.get(timeOffset);

for (const interstitial of interstitials) {
let duration = interstitial.duration;
if (!duration) {
duration = await fetchDuration(interstitial.url);
}

for (const interstitial of list) {
const duration = await fetchMasterPlaylistDuration(interstitial.url);
assets.push({
URI: interstitial.url,
DURATION: duration,
"SPRS-TYPE": interstitial.type,
});
}

return assets;
}

function isEqualTimeOffset(
baseDate: DateTime,
timeOffset: number,
lookupDate: DateTime,
) {
return baseDate.plus({ seconds: timeOffset }).toISO() === lookupDate.toISO();
function getTimeOffset(baseDate: DateTime, lookupDate: DateTime) {
const { seconds } = lookupDate.diff(baseDate, "seconds").toObject();
assert(seconds);
return seconds;
}
24 changes: 24 additions & 0 deletions packages/stitcher/src/lib/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export class Group<K = unknown, V = unknown> {
constructor(public map = new Map<K, Set<V>>()) {}

add(key: K, value: V) {
let set = this.map.get(key);
if (!set) {
set = new Set();
this.map.set(key, set);
}
set.add(value);
}

forEach(callback: (value: K, items: V[]) => void) {
Array.from(this.map.entries()).forEach(([key, set]) => {
const items = Array.from(set.values());
callback(key, items);
});
}

get(key: K) {
const set = this.map.get(key);
return set ? Array.from(set.values()) : [];
}
}
31 changes: 31 additions & 0 deletions packages/stitcher/src/lib/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { DateTime } from "luxon";
import { assert } from "shared/assert";
import { parse, registerCustom, stringify } from "superjson";
import { Group } from "./group";

registerCustom<DateTime, string>(
{
isApplicable: (value) => DateTime.isDateTime(value),
serialize: (dateTime) => {
const value = dateTime.toISO();
assert(value, "No convert to ISO");
return value;
},
deserialize: (value) => DateTime.fromISO(value),
},
"DateTime",
);

registerCustom<Group, string>(
{
isApplicable: (value) => value instanceof Group,
serialize: (group) => stringify(group.map),
deserialize: (value) => new Group(parse(value)),
},
"Group",
);

export const JSON = {
parse,
stringify,
};
43 changes: 28 additions & 15 deletions packages/stitcher/src/lib/url.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as path from "path";
import { encrypt } from "./crypto";
import { env } from "../env";
import { filterQuery } from "../filters";
import type { Filter } from "../filters";
import type { Session } from "../session";

const uuidRegex = /^[a-z,0-9,-]{36,36}$/;
Expand Down Expand Up @@ -61,22 +63,33 @@ export function joinUrl(urlFile: string, filePath: string) {
return `${url.protocol}//${url.host}${path.join(url.pathname, filePath)}`;
}

export function toAssetProtocol(uuid: string) {
return `${ASSET_PROTOCOL}:${uuid}`;
}

export function buildProxyUrl(
file: string,
options: {
url?: string;
session?: Session;
params?: Record<string, string | undefined>;
} = {},
path: string,
params: Record<string, string | undefined | null> = {},
) {
const { url, session, params } = options;
return buildUrl(`${env.PUBLIC_STITCHER_ENDPOINT}/out/${file}`, {
eurl: url ? encrypt(url) : undefined,
sid: session?.id,
...params,
return buildUrl(`${env.PUBLIC_STITCHER_ENDPOINT}/${path}`, params);
}

export function buildProxyMasterUrl(params: {
url: string;
session?: Session;
filter?: Filter;
}) {
return buildProxyUrl("out/master.m3u8", {
eurl: encrypt(params.url),
sid: params.session?.id,
...filterQuery(params.filter),
});
}

export function buildProxyMediaUrl(params: {
type: string;
url: string;
session?: Session;
}) {
return buildProxyUrl("out/playlist.m3u8", {
type: params.type,
eurl: encrypt(params.url),
sid: params.session?.id,
});
}
Loading

0 comments on commit 68c5368

Please sign in to comment.