diff --git a/bun.lockb b/bun.lockb index 100acace..2e389993 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 141b82b9..be023dd4 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -1,13 +1,10 @@ import { parseEnv } from "shared/env"; export const env = parseEnv((z) => ({ - // process PORT: z.coerce.number().default(52001), - HOST: z.string().default("0.0.0.0"), - - // config.env - REDIS_HOST: z.string(), - REDIS_PORT: z.coerce.number(), + HOST: z.string().optional(), + REDIS_HOST: z.string().default("localhost"), + REDIS_PORT: z.coerce.number().default(6379), S3_ENDPOINT: z.string(), S3_REGION: z.string(), S3_ACCESS_KEY: z.string(), diff --git a/packages/app/src/routes/(dashboard)/_layout/player.tsx b/packages/app/src/routes/(dashboard)/_layout/player.tsx index 28a18616..7a173b25 100644 --- a/packages/app/src/routes/(dashboard)/_layout/player.tsx +++ b/packages/app/src/routes/(dashboard)/_layout/player.tsx @@ -1,4 +1,4 @@ -import { Card } from "@nextui-org/react"; +import { Card, Modal, ModalBody, ModalContent } from "@nextui-org/react"; import { createFileRoute } from "@tanstack/react-router"; import { useRef, useState } from "react"; import { CodeEditor } from "../../../components/CodeEditor"; @@ -14,6 +14,7 @@ export const Route = createFileRoute("/(dashboard)/_layout/player")({ function RouteComponent() { const formRef = useRef(null); const [url, setUrl] = useState(null); + const [error, setError] = useState(null); const schema = useSwaggerSchema( `${window.__ENV__.PUBLIC_STITCHER_ENDPOINT}/swagger/json`, @@ -21,7 +22,7 @@ function RouteComponent() { ); return ( -
+
@@ -46,6 +47,8 @@ function RouteComponent() { schema={schema} localStorageKey="stitcherEditor" onSave={async (body) => { + setError(null); + const response = await fetch( `${window.__ENV__.PUBLIC_STITCHER_ENDPOINT}/session`, { @@ -57,14 +60,30 @@ function RouteComponent() { }, ); + const data = await response.json(); if (response.ok) { - const { url } = await response.json(); - formRef.current?.setValue("url", url); - setUrl(url); + formRef.current?.setValue("url", data.url); + setUrl(data.url); + } else { + setError(data); } }} /> + setError(null)} + size="5xl" + scrollBehavior="inside" + > + + +
+              {JSON.stringify(error, null, 2)}
+            
+
+
+
); } diff --git a/packages/artisan/src/env.ts b/packages/artisan/src/env.ts index da667798..6ada0bbd 100644 --- a/packages/artisan/src/env.ts +++ b/packages/artisan/src/env.ts @@ -1,12 +1,11 @@ import { parseEnv } from "shared/env"; export const env = parseEnv((z) => ({ - // config.env + REDIS_HOST: z.string().default("localhost"), + REDIS_PORT: z.coerce.number().default(6379), S3_ENDPOINT: z.string(), S3_REGION: z.string(), S3_ACCESS_KEY: z.string(), S3_SECRET_KEY: z.string(), S3_BUCKET: z.string(), - REDIS_HOST: z.string(), - REDIS_PORT: z.coerce.number(), })); diff --git a/packages/bolt/src/env.ts b/packages/bolt/src/env.ts index de1a591a..809bfaf1 100644 --- a/packages/bolt/src/env.ts +++ b/packages/bolt/src/env.ts @@ -2,8 +2,8 @@ import { parseEnv } from "shared/env"; const env = parseEnv((z) => ({ // config.env - REDIS_HOST: z.string(), - REDIS_PORT: z.coerce.number(), + REDIS_HOST: z.string().default("localhost"), + REDIS_PORT: z.coerce.number().default(6379), })); export const connection = { diff --git a/packages/stitcher/package.json b/packages/stitcher/package.json index 1218c0ec..57b2d733 100644 --- a/packages/stitcher/package.json +++ b/packages/stitcher/package.json @@ -28,13 +28,13 @@ "@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", "lru-cache": "^11.0.2", "luxon": "^3.5.0", "redis": "^4.7.0", + "secure-encrypt": "^1.0.12", "shared": "workspace:*", "superjson": "^2.2.1", "uuid": "^10.0.0", diff --git a/packages/stitcher/runtime/local.ts b/packages/stitcher/runtime/local.ts index 8b5dc109..27dcffd8 100644 --- a/packages/stitcher/runtime/local.ts +++ b/packages/stitcher/runtime/local.ts @@ -1,10 +1,5 @@ -import { parseEnv } from "shared/env"; import { createApp } from "../src"; - -const env = parseEnv((z) => ({ - PORT: z.coerce.number().default(52002), - HOST: z.string().default("0.0.0.0"), -})); +import { env } from "../src/env"; const app = createApp({ aot: true, diff --git a/packages/stitcher/src/adapters/kv/cloudflare-kv.ts b/packages/stitcher/src/adapters/kv/cloudflare-kv.ts new file mode 100644 index 00000000..d4b22d43 --- /dev/null +++ b/packages/stitcher/src/adapters/kv/cloudflare-kv.ts @@ -0,0 +1,21 @@ +import type { KVNamespace } from "@cloudflare/workers-types"; + +// Make sure wrangler.toml has a binding named "kv". +const kv = process.env["kv"] as unknown as KVNamespace; + +if (!kv) { + throw new ReferenceError( + 'No kv found for Cloudflare, make sure you have "kv"' + + " set as binding in wrangler.toml.", + ); +} + +export async function set(key: string, value: string, ttl: number) { + await kv.put(key, value, { + expirationTtl: ttl, + }); +} + +export async function get(key: string) { + return await kv.get(key); +} diff --git a/packages/stitcher/src/adapters/kv/index.ts b/packages/stitcher/src/adapters/kv/index.ts new file mode 100644 index 00000000..7109b935 --- /dev/null +++ b/packages/stitcher/src/adapters/kv/index.ts @@ -0,0 +1,15 @@ +import { env } from "../../env"; + +interface KeyValue { + set(key: string, value: string, ttl: number): Promise; + get(key: string): Promise; +} + +export let kv: KeyValue; + +// Map each KV adapter here to their corresponding import. +if (env.KV === "cloudflare-kv") { + kv = await import("./cloudflare-kv"); +} else if (env.KV === "redis") { + kv = await import("./redis"); +} diff --git a/packages/stitcher/src/adapters/kv/redis.ts b/packages/stitcher/src/adapters/kv/redis.ts new file mode 100644 index 00000000..0aa4a80f --- /dev/null +++ b/packages/stitcher/src/adapters/kv/redis.ts @@ -0,0 +1,23 @@ +import { createClient } from "redis"; +import { env } from "../../env"; + +const REDIS_PREFIX = "stitcher"; + +const client = createClient({ + socket: { + host: env.REDIS_HOST, + port: env.REDIS_PORT, + }, +}); + +await client.connect(); + +export async function set(key: string, value: string, ttl: number) { + await client.set(`${REDIS_PREFIX}:${key}`, value, { + EX: ttl, + }); +} + +export async function get(key: string) { + return await client.get(`${REDIS_PREFIX}:${key}`); +} diff --git a/packages/stitcher/src/env.ts b/packages/stitcher/src/env.ts index c92213ca..c38a4dc2 100644 --- a/packages/stitcher/src/env.ts +++ b/packages/stitcher/src/env.ts @@ -1,10 +1,13 @@ import { parseEnv } from "shared/env"; export const env = parseEnv((z) => ({ - SERVERLESS: z.coerce.boolean().default(false), + PORT: z.coerce.number().default(52002), + HOST: z.string().optional(), + + KV: z.enum(["redis", "cloudflare-kv"]).default("redis"), + REDIS_HOST: z.string().default("localhost"), + REDIS_PORT: z.coerce.number().default(6379), - REDIS_HOST: z.string(), - REDIS_PORT: z.coerce.number(), PUBLIC_S3_ENDPOINT: z.string(), PUBLIC_STITCHER_ENDPOINT: z.string(), PUBLIC_API_ENDPOINT: z.string(), diff --git a/packages/stitcher/src/filters.ts b/packages/stitcher/src/filters.ts index 02c830bc..0df125cd 100644 --- a/packages/stitcher/src/filters.ts +++ b/packages/stitcher/src/filters.ts @@ -1,3 +1,4 @@ +import { t } from "elysia"; import type { MasterPlaylist } from "./parser"; export interface Filter { @@ -5,6 +6,20 @@ export interface Filter { audioLanguage?: string; } +export function formatFilterToQueryParam(filter?: Filter) { + if (!filter) { + return undefined; + } + return btoa(JSON.stringify(filter)); +} + +export const filterSchema = t.Optional( + t + .Transform(t.String()) + .Decode((value) => JSON.parse(atob(value)) as Filter) + .Encode((filter) => btoa(JSON.stringify(filter))), +); + function parseRange(input: string): [number, number] | null { const match = input.match(/^(\d+)-(\d+)$/); @@ -77,24 +92,3 @@ export function filterMasterPlaylist(master: MasterPlaylist, filter: Filter) { }); } } - -export function getFilterFromQuery(query: Record) { - 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; -} - -export function getQueryParamsFromFilter(filter: Filter) { - const queryParams: Record = {}; - - Object.entries(filter).forEach(([key, value]) => { - queryParams[`filter.${key}`] = value; - }); - - return queryParams; -} diff --git a/packages/stitcher/src/interstitials.ts b/packages/stitcher/src/interstitials.ts index 98cdb3c3..ccf6f375 100644 --- a/packages/stitcher/src/interstitials.ts +++ b/packages/stitcher/src/interstitials.ts @@ -1,17 +1,18 @@ -import { assert } from "shared/assert"; import { Group } from "./lib/group"; import { makeUrl, resolveUri } from "./lib/url"; import { fetchDuration } from "./playlist"; import { getAdMediasFromAdBreak } from "./vast"; -import { parseVmap } from "./vmap"; +import { toAdBreakTimeOffset } from "./vmap"; import type { DateRange } from "./parser"; import type { Session } from "./session"; +import type { AdMedia } from "./vast"; import type { VmapResponse } from "./vmap"; import type { DateTime } from "luxon"; export type InterstitialType = "ad" | "bumper"; export interface Interstitial { + position: number; url: string; duration?: number; type?: InterstitialType; @@ -24,36 +25,44 @@ interface InterstitialAsset { } export function getStaticDateRanges(startTime: DateTime, session: Session) { - const group = new Group(); + const group = new Group(); if (session.vmapResponse) { - const vmap = parseVmap(session.vmapResponse); - for (const adBreak of vmap.adBreaks) { - group.add(adBreak.timeOffset, "ad"); + for (const adBreak of session.vmapResponse.adBreaks) { + const timeOffset = toAdBreakTimeOffset(adBreak); + if (timeOffset !== null) { + group.add(timeOffset, "ad"); + } } } - session.interstitials?.forEach((timeOffset, interstitials) => { - interstitials.forEach((interstitial) => { - group.add(timeOffset, interstitial.type); - }); - }); + if (session.interstitials) { + for (const interstitial of session.interstitials) { + group.add(interstitial.position, interstitial.type); + } + } const dateRanges: DateRange[] = []; group.forEach((timeOffset, types) => { const startDate = startTime.plus({ seconds: timeOffset }); - const assetListUrl = makeUrl(`session/${session.id}/asset-list.json`, { - startDate: startDate.toISO(), + const assetListUrl = makeAssetListUrl({ + timeOffset, + session, }); const clientAttributes: Record = { RESTRICT: "SKIP,JUMP", "RESUME-OFFSET": 0, "ASSET-LIST": assetListUrl, + CUE: "ONCE", }; + if (timeOffset === 0) { + clientAttributes["CUE"] += ",PRE"; + } + if (types.length) { clientAttributes["SPRS-TYPES"] = types.join(","); } @@ -69,51 +78,34 @@ export function getStaticDateRanges(startTime: DateTime, session: Session) { return dateRanges; } -export async function getAssets(session: Session, lookupDate: DateTime) { +export async function getAssets(session: Session, timeOffset?: number) { const assets: InterstitialAsset[] = []; - if (session.startTime) { + if (timeOffset !== undefined) { if (session.vmapResponse) { - const vmap = parseVmap(session.vmapResponse); - const vmapAssets = await getAssetsFromVmap( - vmap, - session.startTime, - lookupDate, - ); - assets.push(...vmapAssets); + const items = await getAssetsFromVmap(session.vmapResponse, timeOffset); + assets.push(...items); } if (session.interstitials) { - const groupAssets = await getAssetsFromGroup( - session.interstitials, - session.startTime, - lookupDate, - ); - assets.push(...groupAssets); + const items = await getAssetsFromGroup(session.interstitials, timeOffset); + assets.push(...items); } } return assets; } -async function getAssetsFromVmap( - vmap: VmapResponse, - baseDate: DateTime, - lookupDate: DateTime, -) { - const timeOffset = getTimeOffset(baseDate, lookupDate); - const adBreak = vmap.adBreaks.find( - (adBreak) => adBreak.timeOffset === timeOffset, +async function getAssetsFromVmap(vmap: VmapResponse, timeOffset: number) { + const adBreaks = vmap.adBreaks.filter( + (adBreak) => toAdBreakTimeOffset(adBreak) === timeOffset, ); - - if (!adBreak) { - // No adbreak found for the time offset. There's nothing left to do. - return []; - } - const assets: InterstitialAsset[] = []; - const adMedias = await getAdMediasFromAdBreak(adBreak); + const adMedias: AdMedia[] = []; + for (const adBreak of adBreaks) { + adMedias.push(...(await getAdMediasFromAdBreak(adBreak))); + } for (const adMedia of adMedias) { assets.push({ @@ -127,17 +119,16 @@ async function getAssetsFromVmap( } async function getAssetsFromGroup( - interstitialsGroup: Group, - baseDate: DateTime, - lookupDate: DateTime, + interstitials: Interstitial[], + timeOffset: number, ) { const assets: InterstitialAsset[] = []; - const timeOffset = getTimeOffset(baseDate, lookupDate); - - const interstitials = interstitialsGroup.get(timeOffset); - for (const interstitial of interstitials) { + if (interstitial.position !== timeOffset) { + continue; + } + let duration = interstitial.duration; if (!duration) { duration = await fetchDuration(interstitial.url); @@ -153,8 +144,9 @@ async function getAssetsFromGroup( return assets; } -function getTimeOffset(baseDate: DateTime, lookupDate: DateTime) { - const { seconds } = lookupDate.diff(baseDate, "seconds").toObject(); - assert(seconds); - return seconds; +function makeAssetListUrl(params: { timeOffset: number; session?: Session }) { + return makeUrl("out/asset-list.json", { + timeOffset: params.timeOffset, + sid: params.session?.id, + }); } diff --git a/packages/stitcher/src/kv/cloudflare.ts b/packages/stitcher/src/kv/cloudflare.ts deleted file mode 100644 index af4af2ef..00000000 --- a/packages/stitcher/src/kv/cloudflare.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { KVNamespace } from "@cloudflare/workers-types"; - -const env = process.env as unknown as { kv: KVNamespace }; - -export default { - async set(key: string, value: string, ttl: number) { - await env.kv.put(key, value, { - expirationTtl: ttl, - }); - }, - async get(key: string) { - return await env.kv.get(key); - }, -}; diff --git a/packages/stitcher/src/kv/index.ts b/packages/stitcher/src/kv/index.ts deleted file mode 100644 index 2e999e94..00000000 --- a/packages/stitcher/src/kv/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { env } from "../env"; - -async function createKv() { - if (env.SERVERLESS) { - return await import("./cloudflare"); - } - return await import("./redis"); -} - -export const kv = (await createKv()).default; diff --git a/packages/stitcher/src/kv/redis.ts b/packages/stitcher/src/kv/redis.ts deleted file mode 100644 index 88256706..00000000 --- a/packages/stitcher/src/kv/redis.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createClient } from "redis"; -import { env } from "../env"; - -const client = createClient({ - socket: { - host: env.REDIS_HOST, - port: env.REDIS_PORT, - }, -}); - -await client.connect(); - -export default { - async set(key: string, value: string, ttl: number) { - await client.set(`stitcher:${key}`, value, { - EX: ttl, - }); - }, - async get(key: string) { - return await client.get(`stitcher:${key}`); - }, -}; diff --git a/packages/stitcher/src/lib/crypto.ts b/packages/stitcher/src/lib/crypto.ts index b238e937..669fb2c8 100644 --- a/packages/stitcher/src/lib/crypto.ts +++ b/packages/stitcher/src/lib/crypto.ts @@ -1,14 +1,12 @@ -import Cryptr from "cryptr"; +import * as crypto from "secure-encrypt"; import { env } from "../env"; -const cryptr = new Cryptr(env.SUPER_SECRET ?? "__UNSECURE__", { - encoding: "base64", -}); +const secret = env.SUPER_SECRET ?? "__UNSECURE__"; export function encrypt(value: string) { - return cryptr.encrypt(value); + return btoa(crypto.encrypt(value, secret)); } export function decrypt(value: string) { - return cryptr.decrypt(value); + return crypto.decrypt(atob(value), secret); } diff --git a/packages/stitcher/src/lib/group.ts b/packages/stitcher/src/lib/group.ts index 18e69a95..b44d5324 100644 --- a/packages/stitcher/src/lib/group.ts +++ b/packages/stitcher/src/lib/group.ts @@ -1,13 +1,15 @@ export class Group { constructor(public map = new Map>()) {} - add(key: K, value: 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); + if (value !== undefined) { + set.add(value); + } } forEach(callback: (value: K, items: V[]) => void) { diff --git a/packages/stitcher/src/lib/json.ts b/packages/stitcher/src/lib/json.ts index dd00413d..cc01eafe 100644 --- a/packages/stitcher/src/lib/json.ts +++ b/packages/stitcher/src/lib/json.ts @@ -1,7 +1,6 @@ import { DateTime } from "luxon"; import { assert } from "shared/assert"; import { parse, registerCustom, stringify } from "superjson"; -import { Group } from "./group"; registerCustom( { @@ -16,15 +15,6 @@ registerCustom( "DateTime", ); -registerCustom( - { - isApplicable: (value) => value instanceof Group, - serialize: (group) => stringify(group.map), - deserialize: (value) => new Group(parse(value)), - }, - "Group", -); - export const JSON = { parse, stringify, diff --git a/packages/stitcher/src/lib/url.ts b/packages/stitcher/src/lib/url.ts index 3ce71b58..9fe7360a 100644 --- a/packages/stitcher/src/lib/url.ts +++ b/packages/stitcher/src/lib/url.ts @@ -61,7 +61,7 @@ export function joinUrl(urlFile: string, filePath: string) { export function makeUrl( path: string, - params: Record = {}, + params: Record = {}, ) { return buildUrl(`${env.PUBLIC_STITCHER_ENDPOINT}/${path}`, params); } diff --git a/packages/stitcher/src/parser/helpers.ts b/packages/stitcher/src/parser/helpers.ts index 4e8b59d1..5e2e03c3 100644 --- a/packages/stitcher/src/parser/helpers.ts +++ b/packages/stitcher/src/parser/helpers.ts @@ -1,6 +1,6 @@ import type { Rendition, Variant } from "./types"; -export function groupRenditions(variants: Variant[]) { +export function getRenditions(variants: Variant[]) { const group = new Set(); variants.forEach((variant) => { variant.audio.forEach((rendition) => { diff --git a/packages/stitcher/src/parser/index.ts b/packages/stitcher/src/parser/index.ts index 44ce678b..c0559a96 100644 --- a/packages/stitcher/src/parser/index.ts +++ b/packages/stitcher/src/parser/index.ts @@ -1,5 +1,5 @@ export { parseMasterPlaylist, parseMediaPlaylist } from "./parse"; export { stringifyMasterPlaylist, stringifyMediaPlaylist } from "./stringify"; -export { groupRenditions } from "./helpers"; +export { getRenditions } from "./helpers"; export * from "./types"; diff --git a/packages/stitcher/src/parser/parse.ts b/packages/stitcher/src/parser/parse.ts index fa9791c3..544c3fbe 100644 --- a/packages/stitcher/src/parser/parse.ts +++ b/packages/stitcher/src/parser/parse.ts @@ -50,29 +50,27 @@ function formatMediaPlaylist(tags: Tag[]): MediaPlaylist { } }); - const segments = tags.reduce((acc, [name], index) => { - if (name !== "EXTINF") { - return acc; - } + const segments: Segment[] = []; + let segmentStart = -1; - let segmentStart = index; - const segmentEnd = index + 1; - for (let i = index; i > 0; i--) { - if (tags[i]?.[0] === "LITERAL") { - segmentStart = i + 1; - break; - } + tags.forEach(([name], index) => { + if (isSegmentTag(name)) { + segmentStart = index - 1; } - const segmentTags = tags.slice(segmentStart, segmentEnd); - const uri = nextLiteral(tags, index); - - const segment = parseSegment(segmentTags, uri, map); + if (name === "LITERAL") { + if (segmentStart < 0) { + throw new Error("LITERAL: no segment start"); + } + const segmentTags = tags.slice(segmentStart, index + 1); + const uri = nextLiteral(segmentTags, segmentTags.length - 2); - acc.push(segment); + const segment = parseSegment(segmentTags, uri, map); + segments.push(segment); - return acc; - }, []); + segmentStart = -1; + } + }); assert(targetDuration); @@ -223,6 +221,17 @@ function nextLiteral(tags: Tag[], index: number) { return value; } +function isSegmentTag(name: Tag[0]) { + switch (name) { + case "EXTINF": + case "EXT-X-DISCONTINUITY": + case "EXT-X-MAP": + case "EXT-X-PROGRAM-DATE-TIME": + return true; + } + return false; +} + export function parseMasterPlaylist(text: string) { const tags = lexicalParse(text); return formatMasterPlaylist(tags); diff --git a/packages/stitcher/src/playlist.ts b/packages/stitcher/src/playlist.ts index a7ed4d04..c8e3cd44 100644 --- a/packages/stitcher/src/playlist.ts +++ b/packages/stitcher/src/playlist.ts @@ -1,45 +1,44 @@ -import { DateTime } from "luxon"; import { assert } from "shared/assert"; -import { filterMasterPlaylist, getQueryParamsFromFilter } from "./filters"; +import { filterMasterPlaylist, formatFilterToQueryParam } from "./filters"; import { getAssets, getStaticDateRanges } from "./interstitials"; import { encrypt } from "./lib/crypto"; import { joinUrl, makeUrl, resolveUri } from "./lib/url"; import { - groupRenditions, parseMasterPlaylist, parseMediaPlaylist, stringifyMasterPlaylist, stringifyMediaPlaylist, } from "./parser"; +import { getRenditions } from "./parser/helpers"; import type { Filter } from "./filters"; import type { Session } from "./session"; -export async function formatMasterPlaylist( - masterUrl: string, - options: { - filter: Filter; - session?: Session; - }, -) { - const master = await fetchMasterPlaylist(masterUrl); +export async function formatMasterPlaylist(params: { + origUrl: string; + sessionId?: string; + filter?: Filter; +}) { + const master = await fetchMasterPlaylist(params.origUrl); - filterMasterPlaylist(master, options.filter); + if (params.filter) { + filterMasterPlaylist(master, params.filter); + } for (const variant of master.variants) { - const url = joinUrl(masterUrl, variant.uri); + const url = joinUrl(params.origUrl, variant.uri); variant.uri = makeMediaUrl({ url, - session: options.session, - type: "VIDEO", + sessionId: params.sessionId, }); } - const renditions = groupRenditions(master.variants); + const renditions = getRenditions(master.variants); + renditions.forEach((rendition) => { - const url = joinUrl(masterUrl, rendition.uri); + const url = joinUrl(params.origUrl, rendition.uri); rendition.uri = makeMediaUrl({ url, - session: options.session, + sessionId: params.sessionId, type: rendition.type, }); }); @@ -48,23 +47,33 @@ export async function formatMasterPlaylist( } export async function formatMediaPlaylist( - session: Session, - mediaType: string, mediaUrl: string, + session?: Session, + renditionType?: string, ) { - const { startTime } = session; - assert(startTime, "No startTime in session"); - const media = await fetchMediaPlaylist(mediaUrl); - // Type is the actual value of EXT-X-MEDIA, thus it's in capital. Let's lowercase it first. - const type = mediaType.toLowerCase(); + // We're in a video playlist when we have no renditionType passed along, + // this means it does not belong to EXT-X-MEDIA, or when we explicitly VIDEO. + const videoPlaylist = !renditionType || renditionType === "VIDEO"; + const firstSegment = media.segments[0]; + + if (session) { + // If we have a session, we must have a startTime thus meaning we started. + assert(session.startTime); + + if (media.endlist) { + assert(firstSegment); + firstSegment.programDateTime = session.startTime; + } - if (type === "video" && media.endlist && media.segments[0]) { - // When we have an endlist, the playlist is static. We can check whether we need - // to add dateRanges. - media.segments[0].programDateTime = startTime; - media.dateRanges = getStaticDateRanges(startTime, session); + if (videoPlaylist && firstSegment?.programDateTime) { + // If we have an endlist and a PDT, we can add static date ranges based on this. + media.dateRanges = getStaticDateRanges( + firstSegment.programDateTime, + session, + ); + } } media.segments.forEach((segment) => { @@ -77,9 +86,8 @@ export async function formatMediaPlaylist( return stringifyMediaPlaylist(media); } -export async function formatAssetList(session: Session, startDate: string) { - const lookupDate = DateTime.fromISO(startDate); - const assets = await getAssets(session, lookupDate); +export async function formatAssetList(session: Session, timeOffset?: number) { + const assets = await getAssets(session, timeOffset); return { ASSETS: assets, }; @@ -114,24 +122,34 @@ export async function fetchDuration(uri: string) { export function makeMasterUrl(params: { url: string; - filter: Filter; + filter?: Filter; session?: Session; }) { - return makeUrl("out/master.m3u8", { + const fil = formatFilterToQueryParam(params.filter); + + const outUrl = makeUrl("out/master.m3u8", { eurl: encrypt(params.url), sid: params.session?.id, - ...getQueryParamsFromFilter(params.filter), + fil, }); + + const url = params.session + ? makeUrl(`session/${params.session.id}/master.m3u8`, { + fil, + }) + : undefined; + + return { url, outUrl }; } function makeMediaUrl(params: { - type: string; url: string; - session?: Session; + sessionId?: string; + type?: string; }) { return makeUrl("out/playlist.m3u8", { - type: params.type, eurl: encrypt(params.url), - sid: params.session?.id, + sid: params.sessionId, + type: params.type, }); } diff --git a/packages/stitcher/src/routes/session.ts b/packages/stitcher/src/routes/session.ts index ad97ed0d..cbab36fb 100644 --- a/packages/stitcher/src/routes/session.ts +++ b/packages/stitcher/src/routes/session.ts @@ -1,7 +1,6 @@ import { Elysia, t } from "elysia"; -import { getFilterFromQuery, getQueryParamsFromFilter } from "../filters"; +import { filterSchema } from "../filters"; import { decrypt } from "../lib/crypto"; -import { makeUrl } from "../lib/url"; import { formatAssetList, formatMasterPlaylist, @@ -13,6 +12,27 @@ import { getSession, processSessionOnMasterReq, } from "../session"; +import type { Filter } from "../filters"; +import type { Session } from "../session"; + +async function handleMasterPlaylist( + origUrl: string, + session?: Session, + filter?: Filter, +) { + if (session) { + await processSessionOnMasterReq(session); + } + + const sessionId = session?.id; + const playlist = await formatMasterPlaylist({ + origUrl, + sessionId, + filter, + }); + + return playlist; +} export const sessionRoutes = new Elysia() .post( @@ -20,10 +40,12 @@ export const sessionRoutes = new Elysia() async ({ body }) => { const session = await createSession(body); - const filter = body.filter ?? {}; + const filter = body.filter; - const url = makeUrl(`session/${session.id}/master.m3u8`, { - ...getQueryParamsFromFilter(filter), + const { url } = makeMasterUrl({ + url: session.url, + filter, + session, }); return { url }; @@ -40,7 +62,7 @@ export const sessionRoutes = new Elysia() interstitials: t.Optional( t.Array( t.Object({ - timeOffset: t.Number(), + position: t.Number(), uri: t.String(), duration: t.Optional(t.Number()), type: t.Optional(t.Union([t.Literal("ad"), t.Literal("bumper")])), @@ -89,40 +111,37 @@ export const sessionRoutes = new Elysia() ) .get( "/session/:sessionId/master.m3u8", - async ({ params, query, redirect }) => { + async ({ set, params, query }) => { const session = await getSession(params.sessionId); - const url = makeMasterUrl({ - url: session.url, - filter: getFilterFromQuery(query), + + const playlist = await handleMasterPlaylist( + session.url, session, - }); - return redirect(url, 302); + query.fil, + ); + + set.headers["content-type"] = "application/vnd.apple.mpegurl"; + + return playlist; }, { params: t.Object({ sessionId: t.String(), }), query: t.Object({ - "filter.resolution": t.Optional(t.String()), - "filter.audioLanguage": t.Optional(t.String()), + fil: filterSchema, }), }, ) .get( "/out/master.m3u8", async ({ set, query }) => { - const session = await getSession(query.sid); - - await processSessionOnMasterReq(session); - - const filter = getFilterFromQuery(query); const url = decrypt(query.eurl); - const playlist = await formatMasterPlaylist(url, { - session, - filter, - }); - set.headers["content-type"] = "application/x-mpegURL"; + const session = query.sid ? await getSession(query.sid) : undefined; + const playlist = await handleMasterPlaylist(url, session, query.fil); + + set.headers["content-type"] = "application/vnd.apple.mpegurl"; return playlist; }, @@ -132,21 +151,22 @@ export const sessionRoutes = new Elysia() }, query: t.Object({ eurl: t.String(), - sid: t.String(), - "filter.resolution": t.Optional(t.String()), - "filter.audioLanguage": t.Optional(t.String()), + sid: t.Optional(t.String()), + fil: filterSchema, }), }, ) .get( "/out/playlist.m3u8", async ({ set, query }) => { - const session = await getSession(query.sid); + const session = query.sid ? await getSession(query.sid) : undefined; const url = decrypt(query.eurl); - const playlist = await formatMediaPlaylist(session, query.type, url); + const type = query.type; + + const playlist = await formatMediaPlaylist(url, session, type); - set.headers["content-type"] = "application/x-mpegURL"; + set.headers["content-type"] = "application/vnd.apple.mpegurl"; return playlist; }, @@ -155,28 +175,29 @@ export const sessionRoutes = new Elysia() hide: true, }, query: t.Object({ - type: t.String(), eurl: t.String(), - sid: t.String(), + sid: t.Optional(t.String()), + type: t.Optional(t.String()), }), }, ) .get( - "/session/:sessionId/asset-list.json", - async ({ params, query }) => { - const session = await getSession(params.sessionId); + "/out/asset-list.json", + async ({ query }) => { + const sessionId = query.sid; + const timeOffset = query.timeOffset; + + const session = await getSession(sessionId); - return await formatAssetList(session, query.startDate); + return await formatAssetList(session, timeOffset); }, { detail: { hide: true, }, - params: t.Object({ - sessionId: t.String(), - }), query: t.Object({ - startDate: t.String(), + timeOffset: t.Optional(t.Number()), + sid: t.String(), _HLS_primary_id: t.Optional(t.String()), }), }, diff --git a/packages/stitcher/src/session.ts b/packages/stitcher/src/session.ts index eafaab74..48c7731d 100644 --- a/packages/stitcher/src/session.ts +++ b/packages/stitcher/src/session.ts @@ -1,10 +1,11 @@ import { randomUUID } from "crypto"; import { DateTime } from "luxon"; -import { kv } from "./kv"; -import { Group } from "./lib/group"; +import { kv } from "./adapters/kv"; import { JSON } from "./lib/json"; import { resolveUri } from "./lib/url"; +import { fetchVmap } from "./vmap"; import type { Interstitial, InterstitialType } from "./interstitials"; +import type { VmapParams, VmapResponse } from "./vmap"; export interface Session { id: string; @@ -14,11 +15,9 @@ export interface Session { startTime?: DateTime; // User defined options - vmap?: { - url: string; - }; - vmapResponse?: string; - interstitials?: Group; + vmap?: VmapParams; + vmapResponse?: VmapResponse; + interstitials?: Interstitial[]; } export async function createSession(params: { @@ -27,7 +26,7 @@ export async function createSession(params: { url: string; }; interstitials?: { - timeOffset: number; + position: number; uri: string; duration?: number; type?: InterstitialType; @@ -45,15 +44,14 @@ export async function createSession(params: { }; if (params.interstitials) { - const group = new Group(); - params.interstitials.forEach((interstitial) => { - group.add(interstitial.timeOffset, { + session.interstitials = params.interstitials.map((interstitial) => { + return { + position: interstitial.position, url: resolveUri(interstitial.uri), duration: interstitial.duration, type: interstitial.type, - }); + }; }); - session.interstitials = group; } // We'll initially store the session for 10 minutes, if it's not been consumed @@ -82,14 +80,7 @@ export async function processSessionOnMasterReq(session: Session) { session.startTime = DateTime.now(); if (session.vmap) { - const USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"; - const response = await fetch(session.vmap.url, { - headers: { - "User-Agent": USER_AGENT, - }, - }); - session.vmapResponse = await response.text(); + session.vmapResponse = await fetchVmap(session.vmap); delete session.vmap; } diff --git a/packages/stitcher/src/vast.ts b/packages/stitcher/src/vast.ts index ad6c7458..8e7fc79b 100644 --- a/packages/stitcher/src/vast.ts +++ b/packages/stitcher/src/vast.ts @@ -36,27 +36,20 @@ async function getAdMedias(adBreak: VmapAdBreak): Promise { const vastClient = new VASTClient(); const parser = new DOMParser(); - const result: AdMedia[] = []; - - for (const slot of adBreak.slots) { - let vastResponse: VastResponse | undefined; - - if (slot.vastUrl) { - vastResponse = await vastClient.get(slot.vastUrl); - } else if (slot.vastData) { - const xml = parser.parseFromString(slot.vastData, "text/xml"); - vastResponse = await vastClient.parseVAST(xml); - } + let vastResponse: VastResponse | undefined; - if (!vastResponse) { - continue; - } + if (adBreak.vastUrl) { + vastResponse = await vastClient.get(adBreak.vastUrl); + } else if (adBreak.vastData) { + const xml = parser.parseFromString(adBreak.vastData, "text/xml"); + vastResponse = await vastClient.parseVAST(xml); + } - const adMedias = await formatVastResponse(vastResponse); - result.push(...adMedias); + if (!vastResponse) { + return []; } - return result; + return await formatVastResponse(vastResponse); } async function scheduleForPackage(adMedia: AdMedia) { diff --git a/packages/stitcher/src/vmap.ts b/packages/stitcher/src/vmap.ts index 843b5dc2..ebfbe701 100644 --- a/packages/stitcher/src/vmap.ts +++ b/packages/stitcher/src/vmap.ts @@ -1,21 +1,33 @@ import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; -import * as timeFormat from "hh-mm-ss"; +import { toS } from "hh-mm-ss"; -export interface VmapSlot { +export interface VmapAdBreak { + timeOffset: string; vastUrl?: string; vastData?: string; } -export interface VmapAdBreak { - timeOffset: number; - slots: VmapSlot[]; -} - export interface VmapResponse { adBreaks: VmapAdBreak[]; } -export function parseVmap(text: string): VmapResponse { +export interface VmapParams { + url: string; +} + +export async function fetchVmap(params: VmapParams): Promise { + const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"; + const response = await fetch(params.url, { + headers: { + "User-Agent": USER_AGENT, + }, + }); + const text = await response.text(); + return parseVmap(text); +} + +function parseVmap(text: string): VmapResponse { const parser = new DOMParser(); const doc = parser.parseFromString(text, "text/xml"); const rootElement = doc.documentElement; @@ -24,51 +36,50 @@ export function parseVmap(text: string): VmapResponse { throw new Error("Url did not resolve in a vmap"); } - const adBreaks: VmapAdBreak[] = []; - - childList(rootElement).forEach((element) => { - if (element.localName === "AdBreak") { - const timeOffset = toTimeOffset(element.getAttribute("timeOffset")); - if (timeOffset === null) { - return; - } - - const slot = getSlot(element); - if (!slot) { - return; - } - - let adBreak = adBreaks.find( - (adBreak) => adBreak.timeOffset === timeOffset, - ); - if (!adBreak) { - adBreak = { - timeOffset, - slots: [], - }; - adBreaks.push(adBreak); + const adBreaks = childList(rootElement).reduce( + (acc, element) => { + const adBreak = formatAdBreak(element); + if (adBreak) { + acc.push(adBreak); } - adBreak.slots.push(slot); - } - }); + return acc; + }, + [], + ); - return { adBreaks }; + return { + adBreaks, + }; } -function getAdSource(element: Element) { - return childList(element).find((child) => child.localName === "AdSource"); -} +function formatAdBreak(element: Element): VmapAdBreak | null { + if (element.localName !== "AdBreak") { + return null; + } + + const timeOffset = element.getAttribute("timeOffset"); + if (timeOffset === null) { + return null; + } -function getSlot(element: Element): VmapSlot { const vastUrl = getVastUrl(element); const vastData = getVastData(element); + if (!vastUrl && !vastData) { + return null; + } + return { + timeOffset, vastUrl, vastData, }; } +function getAdSource(element: Element) { + return childList(element).find((child) => child.localName === "AdSource"); +} + function getVastUrl(element: Element) { const adSource = getAdSource(element); if (!adSource) { @@ -105,15 +116,12 @@ function childList(node: Element) { return Array.from(node.childNodes) as Element[]; } -function toTimeOffset(value: string | null) { - if (value === null) { - return null; - } - if (value === "start") { +export function toAdBreakTimeOffset(adBreak: VmapAdBreak) { + if (adBreak.timeOffset === "start") { return 0; } - if (value === "end") { + if (adBreak.timeOffset === "end") { return null; } - return timeFormat.toS(value); + return toS(adBreak.timeOffset); } diff --git a/packages/stitcher/test/setup.ts b/packages/stitcher/test/setup.ts index f77a7560..b47f730b 100644 --- a/packages/stitcher/test/setup.ts +++ b/packages/stitcher/test/setup.ts @@ -5,6 +5,4 @@ process.env = { S3_ACCESS_KEY: "s3-access-key", S3_SECRET_KEY: "s3-secret-key", S3_BUCKET: "s3-bucket", - REDIS_HOST: "redis-host", - REDIS_PORT: "6379", }; diff --git a/packages/stitcher/wrangler.toml b/packages/stitcher/wrangler.toml index f0281c67..4792ff2e 100644 --- a/packages/stitcher/wrangler.toml +++ b/packages/stitcher/wrangler.toml @@ -1,14 +1,10 @@ -name = "superstreamer-stitcher" +name = "sprs-stitcher" compatibility_flags = [ "nodejs_compat" ] compatibility_date = "2024-09-23" send_metrics = false [vars] -SERVERLESS = "true" -PORT = "0" -HOST = "0.0.0.0" -REDIS_HOST = "redis" -REDIS_PORT = "0000" +KV = "cloudflare-kv" PUBLIC_S3_ENDPOINT = "https://cdn.superstreamer.xyz" PUBLIC_STITCHER_ENDPOINT = "https://stitcher.superstreamer.xyz" PUBLIC_API_ENDPOINT = "https://api.tunnel.superstreamer.xyz"