Skip to content

Commit

Permalink
feat: Stitcher interstitials support for live streams (#128)
Browse files Browse the repository at this point in the history
* Stitcher changes

* Support for streaming HLS

* Wait for session to update

* Different interstitial structure

* Simplified code
  • Loading branch information
matvp91 authored Dec 4, 2024
1 parent 866dc84 commit c67b2af
Show file tree
Hide file tree
Showing 20 changed files with 1,408 additions and 300 deletions.
4 changes: 3 additions & 1 deletion packages/app/src/components/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ interface PlayerProps {

export function Player({ url, lang, metadata }: PlayerProps) {
const [hls] = useState(() => new Hls());
const controller = useController(hls);
const controller = useController(hls, {
multipleVideoElements: false,
});

useEffect(() => {
if (url) {
Expand Down
8 changes: 6 additions & 2 deletions packages/player/src/react/hooks/useController.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { HlsFacade } from "..";
import type { HlsFacadeOptions } from "..";
import type Hls from "hls.js";

type MediaRefCallback = (media: HTMLMediaElement | null) => void;

export type Controller = ReturnType<typeof createController>;

export function useController(hls: Hls) {
const [facade] = useState<HlsFacade>(() => new HlsFacade(hls));
export function useController(
hls: Hls,
userOptions?: Partial<HlsFacadeOptions>,
) {
const [facade] = useState<HlsFacade>(() => new HlsFacade(hls, userOptions));
const lastMediaRef = useRef<HTMLMediaElement | null>(null);

useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions packages/stitcher/src/adapters/kv/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ if (env.KV === "cloudflare-kv") {
kv = await import("./cloudflare-kv");
} else if (env.KV === "redis") {
kv = await import("./redis");
} else if (env.KV === "memory") {
kv = await import("./memory");
}
10 changes: 10 additions & 0 deletions packages/stitcher/src/adapters/kv/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const kv = new Map<string, string>();

export async function set(key: string, value: string, ttl: number) {
kv.set(key, value);
await Promise.resolve(ttl);
}

export async function get(key: string) {
return Promise.resolve(kv.get(key) ?? null);
}
4 changes: 2 additions & 2 deletions packages/stitcher/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ export const env = parseEnv((z) => ({
PORT: z.coerce.number().default(52002),
HOST: z.string().optional(),

KV: z.enum(["redis", "cloudflare-kv"]).default("redis"),
KV: z.enum(["memory", "redis", "cloudflare-kv"]).default("redis"),
REDIS_HOST: z.string().default("localhost"),
REDIS_PORT: z.coerce.number().default(6379),

PUBLIC_S3_ENDPOINT: z.string(),
PUBLIC_STITCHER_ENDPOINT: z.string(),
PUBLIC_API_ENDPOINT: z.string(),
PUBLIC_API_ENDPOINT: z.string().optional(),

// Secret is optional, if we don't provide it, we won't be able to
// call API requests but one might not need to do that.
Expand Down
181 changes: 72 additions & 109 deletions packages/stitcher/src/interstitials.ts
Original file line number Diff line number Diff line change
@@ -1,152 +1,115 @@
import { Group } from "./lib/group";
import { makeUrl, resolveUri } from "./lib/url";
import { createUrl } from "./lib/url";
import { fetchDuration } from "./playlist";
import { getAdMediasFromAdBreak } from "./vast";
import { toAdBreakTimeOffset } from "./vmap";
import type { DateRange } from "./parser";
import { getAdMediasFromVast } from "./vast";
import type { Session } from "./session";
import type { AdMedia } from "./vast";
import type { VmapResponse } from "./vmap";
import type { Interstitial, InterstitialAssetType } from "./types";
import type { DateTime } from "luxon";

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

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

interface InterstitialAsset {
URI: string;
DURATION: number;
"SPRS-TYPE"?: InterstitialType;
}

export function getStaticDateRanges(startTime: DateTime, session: Session) {
const group = new Group<number, InterstitialType>();

if (session.vmapResponse) {
for (const adBreak of session.vmapResponse.adBreaks) {
const timeOffset = toAdBreakTimeOffset(adBreak);
if (timeOffset !== null) {
group.add(timeOffset, "ad");
}
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);
}
}

if (session.interstitials) {
for (const interstitial of session.interstitials) {
group.add(interstitial.timeOffset, interstitial.type);
const type = getInterstitialType(interstitial);
if (type && !item.types.includes(type)) {
item.types.push(type);
}
}

const dateRanges: DateRange[] = [];

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

const assetListUrl = makeAssetListUrl({
timeOffset,
return group.map((item) => {
const assetListUrl = createAssetListUrl({
dateTime: item.dateTime,
session,
});

const clientAttributes: Record<string, number | string> = {
RESTRICT: "SKIP,JUMP",
"RESUME-OFFSET": 0,
"ASSET-LIST": assetListUrl,
CUE: "ONCE",
};

if (timeOffset === 0) {
const isPreroll = item.dateTime.equals(session.startTime);

if (!isLive || isPreroll) {
clientAttributes["RESUME-OFFSET"] = 0;
}

if (isPreroll) {
clientAttributes["CUE"] += ",PRE";
}

if (types.length) {
clientAttributes["SPRS-TYPES"] = types.join(",");
if (item.types.length) {
clientAttributes["SPRS-TYPES"] = item.types.join(",");
}

dateRanges.push({
return {
classId: "com.apple.hls.interstitial",
id: `sdr${timeOffset}`,
startDate,
id: `${item.dateTime.toUnixInteger()}`,
startDate: item.dateTime,
clientAttributes,
});
};
});

return dateRanges;
}

export async function getAssets(session: Session, timeOffset?: number) {
const assets: InterstitialAsset[] = [];
export async function getAssets(session: Session, dateTime: DateTime) {
const assets: {
URI: string;
DURATION: number;
"SPRS-TYPE"?: InterstitialAssetType;
}[] = [];

if (timeOffset !== undefined) {
if (session.vmapResponse) {
const items = await getAssetsFromVmap(session.vmapResponse, timeOffset);
assets.push(...items);
}

if (session.interstitials) {
const items = await getAssetsFromGroup(session.interstitials, timeOffset);
assets.push(...items);
}
}

return assets;
}

async function getAssetsFromVmap(vmap: VmapResponse, timeOffset: number) {
const adBreaks = vmap.adBreaks.filter(
(adBreak) => toAdBreakTimeOffset(adBreak) === timeOffset,
const interstitials = session.interstitials.filter((interstitial) =>
interstitial.dateTime.equals(dateTime),
);
const assets: InterstitialAsset[] = [];

const adMedias: AdMedia[] = [];
for (const adBreak of adBreaks) {
adMedias.push(...(await getAdMediasFromAdBreak(adBreak)));
}

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

return assets;
}

async function getAssetsFromGroup(
interstitials: Interstitial[],
timeOffset: number,
) {
const assets: InterstitialAsset[] = [];

for (const interstitial of interstitials) {
if (interstitial.timeOffset !== timeOffset) {
continue;
const adMedias = await getAdMediasFromVast(interstitial);
for (const adMedia of adMedias) {
assets.push({
URI: adMedia.masterUrl,
DURATION: adMedia.duration,
"SPRS-TYPE": "ad",
});
}

let duration = interstitial.duration;
if (!duration) {
duration = await fetchDuration(interstitial.url);
if (interstitial.asset) {
assets.push({
URI: interstitial.asset.url,
DURATION: await fetchDuration(interstitial.asset.url),
"SPRS-TYPE": interstitial.asset.type,
});
}

assets.push({
URI: interstitial.url,
DURATION: duration,
"SPRS-TYPE": interstitial.type,
});
}

return assets;
}

function makeAssetListUrl(params: { timeOffset: number; session?: Session }) {
return makeUrl("out/asset-list.json", {
timeOffset: params.timeOffset,
function createAssetListUrl(params: { dateTime: DateTime; session?: Session }) {
return createUrl("out/asset-list.json", {
dt: params.dateTime.toISO(),
sid: params.session?.id,
});
}

function getInterstitialType(
interstitial: Interstitial,
): InterstitialAssetType | undefined {
if (interstitial.vastData || interstitial.vastUrl) {
return "ad";
}
return interstitial.asset?.type;
}
8 changes: 5 additions & 3 deletions packages/stitcher/src/lib/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createApiClient } from "@superstreamer/api/client";
import { env } from "../env";

export const api = createApiClient(env.PUBLIC_API_ENDPOINT, {
apiKey: env.SUPER_SECRET,
});
export const api = env.PUBLIC_API_ENDPOINT
? createApiClient(env.PUBLIC_API_ENDPOINT, {
apiKey: env.SUPER_SECRET,
})
: null;
17 changes: 10 additions & 7 deletions packages/stitcher/src/lib/group.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
export class Group<K = unknown, V = unknown> {
constructor(public map = new Map<K, Set<V>>()) {}
private map_ = new Map<K, Set<V>>();

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

forEach(callback: (value: K, items: V[]) => void) {
Array.from(this.map.entries()).forEach(([key, set]) => {
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()) : [];
map<R>(callback: (value: K, items: V[]) => R) {
const result: R[] = [];
this.forEach((value, items) => {
result.push(callback(value, items));
});
return result;
}
}
2 changes: 1 addition & 1 deletion packages/stitcher/src/lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function joinUrl(urlFile: string, filePath: string) {
return `${url.protocol}//${url.host}${path.join(url.pathname, filePath)}`;
}

export function makeUrl(
export function createUrl(
path: string,
params: Record<string, string | number | undefined | null> = {},
) {
Expand Down
Loading

0 comments on commit c67b2af

Please sign in to comment.