Skip to content

Commit

Permalink
feat: Stitcher support abs url (#112)
Browse files Browse the repository at this point in the history
* Added support for absolute URLS

* initTime remove

* Encrypt url

* Added audioLanguage filter
  • Loading branch information
matvp91 authored Nov 14, 2024
1 parent e406a4a commit 8db7e1c
Show file tree
Hide file tree
Showing 32 changed files with 461 additions and 2,848 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 @@ -28,6 +28,7 @@
"@matvp91/elysia-swagger": "^2.0.0",
"@superstreamer/api": "workspace:*",
"@xmldom/xmldom": "^0.8.10",
"cryptr": "^6.3.0",
"dom-parser": "^1.1.5",
"elysia": "^1.1.24",
"hh-mm-ss": "^1.2.0",
Expand Down
107 changes: 74 additions & 33 deletions packages/stitcher/src/filters.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,92 @@
import type { MasterPlaylist, Variant } from "./parser";
import type { SessionFilter } from "./session";
import type { MasterPlaylist } from "./parser";

const FILTER_VARIANTS_OPERATOR = {
"<": (a: number, b: number) => a < b,
"<=": (a: number, b: number) => a <= b,
">": (a: number, b: number) => a > b,
">=": (a: number, b: number) => a >= b,
} as const;
export interface Filter {
resolution?: string;
audioLanguage?: string;
}

function parseRange(input: string): [number, number] | null {
const match = input.match(/^(\d+)-(\d+)$/);

function getResolutionFilter(
resolution: string,
): [number, (a: number, b: number) => boolean] {
const [operator, value] = resolution.split(" ");
if (!value) {
throw new Error(`Failed to parse operator / value pair "${value}"`);
if (match?.[1] && match[2]) {
const min = parseInt(match[1]);
const max = parseInt(match[2]);
return [min, max];
}

const height = parseInt(value, 10);
return null;
}

function parseOperatorToRange(input: string): [number, number] | null {
const match = input.match(/(<=?|>=?)\s*(\d+)/);
if (match?.[2] === undefined) {
return null;
}

const fn =
FILTER_VARIANTS_OPERATOR[operator as keyof typeof FILTER_VARIANTS_OPERATOR];
const operator = match[1];
const number = parseInt(match[2]);

if (Number.isNaN(height) || !fn) {
throw new Error(`Resolution filter with value "${resolution}" is invalid.`);
if (operator === "<=") {
return [0, number];
} else if (operator === "<") {
return [0, number - 1];
} else if (operator === ">=") {
return [number, Infinity];
} else if (operator === ">") {
return [number + 1, Infinity];
}

return [height, fn];
return null;
}

function filterVariantsByResolution(variants: Variant[], resolution: string) {
const [height, fn] = getResolutionFilter(resolution);
return variants.filter(
(item) => item.resolution && fn(item.resolution.height, height),
);
function parseFilterToRange(input: string): [number, number] {
let range = parseRange(input);
if (range) {
return range;
}

range = parseOperatorToRange(input);
if (range) {
return range;
}

throw new Error(`Failed to parse to range "${input}"`);
}

function parseFilterToList(input: string) {
return input.split(",").map((value) => value.trim());
}

export function filterMaster(master: MasterPlaylist, filter: SessionFilter) {
if (filter.resolution) {
master.variants = filterVariantsByResolution(
master.variants,
filter.resolution,
export function filterMasterPlaylist(master: MasterPlaylist, filter: Filter) {
if (filter.resolution !== undefined) {
const [min, max] = parseFilterToRange(filter.resolution);
master.variants = master.variants.filter(
(variant) =>
// If we have no height, we'll make it pass.
!variant.resolution?.height ||
// If the variant height is within our range.
(variant.resolution.height >= min && variant.resolution.height <= max),
);
}
if (filter.audioLanguage !== undefined) {
const list = parseFilterToList(filter.audioLanguage);
master.variants.filter((variant) => {
variant.audio = variant.audio.filter(
(audio) => !audio.language || list.includes(audio.language),
);
});
}
}

export function validateFilter(filter: SessionFilter) {
if (filter.resolution) {
getResolutionFilter(filter.resolution);
export function extractFilterFromQuery(query: Record<string, string>) {
const filter: Filter = {};

if ("filter.resolution" in query) {
filter.resolution = query["filter.resolution"];
}
if ("filter.audioLanguage" in query) {
filter.audioLanguage = query["filter.audioLanguage"];
}

return filter;
}
92 changes: 51 additions & 41 deletions packages/stitcher/src/interstitials.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,45 @@
import { DateTime } from "luxon";
import { assert } from "shared/assert";
import { env } from "./env";
import { Presentation } from "./presentation";
import { resolveUri, toAssetProtocol } from "./lib/url";
import { fetchMasterPlaylistDuration } from "./playlist";
import { getAdMediasFromAdBreak } from "./vast";
import type { DateRange } from "./parser";
import type {
Session,
SessionInterstitial,
SessionInterstitialType,
} from "./session";
import type { Session } from "./session";
import type { VmapResponse } from "./vmap";

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

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

interface InterstitialAsset {
URI: string;
DURATION: number;
"SPRS-TYPE": Required<SessionInterstitial["type"]>;
}

export function getStaticPDT(session: Session) {
return session.initialTime;
"SPRS-TYPE"?: InterstitialType;
}

export function getStaticDateRanges(session: Session) {
const group: Record<string, SessionInterstitialType[]> = {};
assert(session.startTime, "No startTime in session");

const group: Record<string, InterstitialType[]> = {};

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

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

Expand All @@ -63,12 +66,11 @@ export function getStaticDateRanges(session: Session) {
}

function groupTimeOffset(
group: Record<string, SessionInterstitialType[]>,
startDate: DateTime,
timeOffset: number,
type?: SessionInterstitialType,
group: Record<string, InterstitialType[]>,
dateTime: DateTime,
type?: InterstitialType,
) {
const key = startDate.plus({ seconds: timeOffset }).toISO();
const key = dateTime.toISO();
if (!key) {
return;
}
Expand All @@ -81,25 +83,32 @@ function groupTimeOffset(
}

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

const assets: InterstitialAsset[] = [];

if (session.vmap) {
await formatAdBreaks(assets, session.vmap, session.initialTime, lookupDate);
if (session.vmapResponse) {
await formatStaticAdBreaks(
assets,
session.vmapResponse,
session.startTime,
lookupDate,
);
}

if (session.interstitials) {
await formatInterstitials(
await formatStaticInterstitials(
assets,
session.interstitials,
session.initialTime,
session.startTime,
lookupDate,
);
}

return assets;
}

async function formatAdBreaks(
async function formatStaticAdBreaks(
assets: InterstitialAsset[],
vmapResponse: VmapResponse,
baseDate: DateTime,
Expand All @@ -117,30 +126,31 @@ async function formatAdBreaks(
const adMedias = await getAdMediasFromAdBreak(adBreak);

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

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

for (const interstitial of filteredInterstitials) {
const presentation = new Presentation(interstitial.uri);
for (const interstitial of list) {
const duration = await fetchMasterPlaylistDuration(interstitial.url);
assets.push({
URI: presentation.url,
DURATION: await presentation.getDuration(),
URI: interstitial.url,
DURATION: duration,
"SPRS-TYPE": interstitial.type,
});
}
Expand Down
14 changes: 14 additions & 0 deletions packages/stitcher/src/lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Cryptr from "cryptr";
import { env } from "../env";

const cryptr = new Cryptr(env.SUPER_SECRET ?? "__UNSECURE__", {
encoding: "base64",
});

export function encrypt(value: string) {
return cryptr.encrypt(value);
}

export function decrypt(value: string) {
return cryptr.decrypt(value);
}
60 changes: 54 additions & 6 deletions packages/stitcher/src/lib/url.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import * as path from "path";
import { encrypt } from "./crypto";
import { env } from "../env";
import type { Filter } from "../filters";
import type { Session } from "../session";

const uuidRegex = /^[a-z,0-9,-]{36,36}$/;

const ASSET_PROTOCOL = "asset:";

export function getMasterUrl(uri: string) {
export function resolveUri(uri: string) {
if (uri.startsWith("http://") || uri.startsWith("https://")) {
return uri;
}
Expand All @@ -31,11 +34,56 @@ export function getMasterUrl(uri: string) {
throw new Error(`Invalid uri: "${uri}"`);
}

export function joinPath(base: string, ...paths: string[]) {
const url = new URL(base);
return `${url.protocol}//${url.host}${path.join(url.pathname, ...paths)}`;
function buildUrl(
url: string,
query?: Record<string, string | number | undefined | null>,
) {
const queryString = Object.entries(query ?? {})
.map(([key, value]) => {
if (value === undefined || value === null) {
return null;
}
return `${key}=${encodeURIComponent(value)}`;
})
.filter((chunk) => chunk !== null)
.join("&");

return `${url}${queryString ? `?${queryString}` : ""}`;
}

export function joinUrl(urlFile: string, filePath: string) {
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
return filePath;
}
const urlBase = urlFile.substring(0, urlFile.lastIndexOf("/"));

const url = new URL(urlBase);

return `${url.protocol}//${url.host}${path.join(url.pathname, filePath)}`;
}

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

export function getDir(url: string) {
return url.substring(0, url.lastIndexOf("/"));
export function buildProxyUrl(
file: string,
url: string,
params?: {
filter?: Filter;
session?: Session;
params?: Record<string, string>;
},
) {
return buildUrl(`${env.PUBLIC_STITCHER_ENDPOINT}/out/${file}`, {
eurl: encrypt(url),
sid: params?.session?.id,

// Filter query params.
"filter.resolution": params?.filter?.resolution,
"filter.audioLanguage": params?.filter?.audioLanguage,

// Rest params
...params?.params,
});
}
Loading

0 comments on commit 8db7e1c

Please sign in to comment.