Skip to content

Commit

Permalink
feat: Better error handling when stitcher cannot resolve the HLS play…
Browse files Browse the repository at this point in the history
…list
  • Loading branch information
matvp91 committed Aug 31, 2024
1 parent b55664d commit 7ff557e
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 51 deletions.
1 change: 1 addition & 0 deletions packages/stitcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/error": "^4.0.0",
"@mixwave/artisan": "workspace:*",
"@ts-rest/core": "^3.49.3",
"@ts-rest/fastify": "^3.49.3",
Expand Down
11 changes: 11 additions & 0 deletions packages/stitcher/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { env } from "./env.js";

export async function isAssetAvailable(assetId: string) {
const response = await fetch(
`${env.S3_PUBLIC_URL}/package/${assetId}/hls/master.m3u8`,
{
method: "HEAD",
},
);
return response.ok;
}
118 changes: 80 additions & 38 deletions packages/stitcher/src/playlist.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import { parse, stringify } from "../extern/hls-parser/index.js";
import * as hlsParser from "../extern/hls-parser/index.js";
import parseFilepath from "parse-filepath";
import { Interstitial } from "../extern/hls-parser/types.js";
import { env } from "./env.js";
import { MasterPlaylist, MediaPlaylist } from "../extern/hls-parser/types.js";
import type { Session } from "./types.js";
import createError from "@fastify/error";
import type { Session, Interstitial } from "./types.js";

const PlaylistUnavailableError = createError<[string]>(
"PLAYLIST_UNAVAILABLE",
"%s is unavailable.",
404,
);

const NoVariantsError = createError(
"NO_VARIANTS",
"The playlist does not contain variants, " +
"this is possibly caused by variant filtering.",
400,
);

async function fetchPlaylist<T>(url: string) {
const response = await fetch(url);
const text = await response.text();
return parse(text) as T;
try {
const response = await fetch(url);
const text = await response.text();
return hlsParser.parse(text) as T;
} catch (error) {
throw new PlaylistUnavailableError(url);
}
}

export async function formatMasterPlaylist(session: Session) {
Expand All @@ -23,46 +40,23 @@ export async function formatMasterPlaylist(session: Session) {
return variant.resolution.height <= session.maxResolution;
});

return stringify(master);
if (!master.variants.length) {
throw new NoVariantsError();
}

return hlsParser.stringify(master);
}

export async function formatMediaPlaylist(session: Session, path: string) {
const url = `${env.S3_PUBLIC_URL}/package/${session.assetId}/hls/${path}/playlist.m3u8`;

const media = await fetchPlaylist<MediaPlaylist>(url);

const filePath = parseFilepath(url);
rewriteSegmentUrls(media, url);

for (const segment of media.segments) {
if (segment.map?.uri === "init.mp4") {
segment.map.uri = `${filePath.dir}/init.mp4`;
}
addInterstitials(media, session.interstitials, session.id);

segment.uri = `${filePath.dir}/${segment.uri}`;
}

const now = Date.now();

media.segments[0].programDateTime = new Date(now);

session.interstitials
.reduce<number[]>((acc, interstitial) => {
if (!acc.includes(interstitial.timeOffset)) {
acc.push(interstitial.timeOffset);
}
return acc;
}, [])
.forEach((timeOffset) => {
media.interstitials.push(
new Interstitial({
id: `${timeOffset}`,
startDate: new Date(now + timeOffset * 1000),
list: `/session/${session.id}/asset-list.json?timeOffset=${timeOffset}`,
}),
);
});

return stringify(media);
return hlsParser.stringify(media);
}

export async function formatAssetList(session: Session, timeOffset: number) {
Expand All @@ -84,13 +78,61 @@ export async function formatAssetList(session: Session, timeOffset: number) {
}

async function getDuration(url: string) {
const master = await fetchPlaylist<MasterPlaylist>(url);
const filePath = parseFilepath(url);

const master = await fetchPlaylist<MasterPlaylist>(url);

const media = await fetchPlaylist<MediaPlaylist>(
`${filePath.dir}/${master.variants[0].uri}`,
);

return media.segments.reduce((acc, segment) => {
acc += segment.duration;
return acc;
}, 0);
}

function addInterstitials(
media: MediaPlaylist,
interstitials: Interstitial[],
sessionId: string,
) {
if (!interstitials.length) {
// If we have no interstitials, there is nothing to insert and
// we can bail out early.
return;
}

const now = Date.now();

media.segments[0].programDateTime = new Date(now);

interstitials
.reduce<number[]>((acc, interstitial) => {
if (!acc.includes(interstitial.timeOffset)) {
acc.push(interstitial.timeOffset);
}
return acc;
}, [])
.forEach((timeOffset) => {
media.interstitials.push(
new hlsParser.types.Interstitial({
id: `${timeOffset}`,
startDate: new Date(now + timeOffset * 1000),
list: `/session/${sessionId}/asset-list.json?timeOffset=${timeOffset}`,
}),
);
});
}

function rewriteSegmentUrls(media: MediaPlaylist, url: string) {
const filePath = parseFilepath(url);

for (const segment of media.segments) {
if (segment.map?.uri === "init.mp4") {
segment.map.uri = `${filePath.dir}/init.mp4`;
}

segment.uri = `${filePath.dir}/${segment.uri}`;
}
}
9 changes: 8 additions & 1 deletion packages/stitcher/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ import { client } from "./redis.js";
import { randomUUID } from "crypto";
import { extractInterstitialFromVmapAdbreak } from "./vast.js";
import { getVmap } from "./vmap.js";
import createError from "@fastify/error";
import type { Session, Interstitial } from "./types.js";

const NoSessionError = createError<[string]>(
"NO_SESSION",
"A session with id %s is not found.",
404,
);

const REDIS_PREFIX = `stitcher:session`;

function getRedisKey(sessionId: string) {
Expand Down Expand Up @@ -69,7 +76,7 @@ export async function getSession(sessionId: string) {

const data = await client.json.get(redisKey);
if (!data) {
throw new Error("No session found for id");
throw new NoSessionError(sessionId);
}

return data as Session;
Expand Down
14 changes: 2 additions & 12 deletions packages/stitcher/src/vast.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { env } from "./env.js";
import { isAssetAvailable } from "./helpers.js";
import { addTranscodeJob } from "@mixwave/artisan/producer";
import { VASTClient } from "../extern/vast-client/index.js";
import { DOMParser } from "@xmldom/xmldom";
Expand All @@ -18,7 +18,7 @@ export async function extractInterstitialFromVmapAdbreak(adBreak: VmapAdBreak) {
const adMedias = await getAdMedias(adBreak);

for (const adMedia of adMedias) {
if (await isPackaged(adMedia.assetId)) {
if (await isAssetAvailable(adMedia.assetId)) {
interstitials.push({
timeOffset: adBreak.timeOffset,
assetId: adMedia.assetId,
Expand Down Expand Up @@ -51,16 +51,6 @@ async function getAdMedias(adBreak: VmapAdBreak): Promise<AdMedia[]> {
return [];
}

async function isPackaged(assetId: string) {
const response = await fetch(
`${env.S3_PUBLIC_URL}/package/${assetId}/hls/master.m3u8`,
{
method: "HEAD",
},
);
return response.ok;
}

function scheduleForPackage(adMedia: AdMedia) {
addTranscodeJob({
tag: "ad",
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 7ff557e

Please sign in to comment.