From 7ff557e3a9978642442e77c379a5d3f2e3bb022f Mon Sep 17 00:00:00 2001 From: Matthias Van Parijs Date: Sat, 31 Aug 2024 14:42:14 +0200 Subject: [PATCH] feat: Better error handling when stitcher cannot resolve the HLS playlist --- packages/stitcher/package.json | 1 + packages/stitcher/src/helpers.ts | 11 +++ packages/stitcher/src/playlist.ts | 118 ++++++++++++++++++++---------- packages/stitcher/src/session.ts | 9 ++- packages/stitcher/src/vast.ts | 14 +--- pnpm-lock.yaml | 7 ++ 6 files changed, 109 insertions(+), 51 deletions(-) create mode 100644 packages/stitcher/src/helpers.ts diff --git a/packages/stitcher/package.json b/packages/stitcher/package.json index 2acfbd23..ce518099 100644 --- a/packages/stitcher/package.json +++ b/packages/stitcher/package.json @@ -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", diff --git a/packages/stitcher/src/helpers.ts b/packages/stitcher/src/helpers.ts new file mode 100644 index 00000000..e2d6307b --- /dev/null +++ b/packages/stitcher/src/helpers.ts @@ -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; +} diff --git a/packages/stitcher/src/playlist.ts b/packages/stitcher/src/playlist.ts index f0597b87..cf8f7611 100644 --- a/packages/stitcher/src/playlist.ts +++ b/packages/stitcher/src/playlist.ts @@ -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(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) { @@ -23,7 +40,11 @@ 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) { @@ -31,38 +52,11 @@ export async function formatMediaPlaylist(session: Session, path: string) { const media = await fetchPlaylist(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((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) { @@ -84,13 +78,61 @@ export async function formatAssetList(session: Session, timeOffset: number) { } async function getDuration(url: string) { - const master = await fetchPlaylist(url); const filePath = parseFilepath(url); + + const master = await fetchPlaylist(url); + const media = await fetchPlaylist( `${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((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}`; + } +} diff --git a/packages/stitcher/src/session.ts b/packages/stitcher/src/session.ts index 03140a2f..907eaf6f 100644 --- a/packages/stitcher/src/session.ts +++ b/packages/stitcher/src/session.ts @@ -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) { @@ -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; diff --git a/packages/stitcher/src/vast.ts b/packages/stitcher/src/vast.ts index 88fcca68..deeca326 100644 --- a/packages/stitcher/src/vast.ts +++ b/packages/stitcher/src/vast.ts @@ -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"; @@ -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, @@ -51,16 +51,6 @@ async function getAdMedias(adBreak: VmapAdBreak): Promise { 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cfc0730..f57a8cdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: '@fastify/cors': specifier: ^9.0.1 version: 9.0.1 + '@fastify/error': + specifier: ^4.0.0 + version: 4.0.0 '@mixwave/artisan': specifier: workspace:* version: link:../artisan @@ -2904,6 +2907,10 @@ packages: resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} dev: false + /@fastify/error@4.0.0: + resolution: {integrity: sha512-OO/SA8As24JtT1usTUTKgGH7uLvhfwZPwlptRi2Dp5P4KKmJI3gvsZ8MIHnNwDs4sLf/aai5LzTyl66xr7qMxA==} + dev: false + /@fastify/fast-json-stringify-compiler@4.3.0: resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} dependencies: