Skip to content

Commit 68c5368

Browse files
authored
feat: Simplify Stitcher playlist generation (#114)
* fix: Store vmap text instead of full response object * Simpler playlist generation * Added superjson serialize * Added group logic
1 parent 34246e1 commit 68c5368

File tree

10 files changed

+225
-186
lines changed

10 files changed

+225
-186
lines changed

bun.lockb

1.04 KB
Binary file not shown.

packages/stitcher/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"luxon": "^3.5.0",
3737
"redis": "^4.7.0",
3838
"shared": "workspace:*",
39+
"superjson": "^2.2.1",
3940
"uuid": "^10.0.0",
4041
"vast-client": "workspace:*"
4142
}
Lines changed: 76 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
import { DateTime } from "luxon";
21
import { assert } from "shared/assert";
3-
import { env } from "./env";
4-
import { resolveUri, toAssetProtocol } from "./lib/url";
5-
import { fetchMasterPlaylistDuration } from "./playlist";
2+
import { Group } from "./lib/group";
3+
import { buildProxyUrl, resolveUri } from "./lib/url";
4+
import { fetchDuration } from "./playlist";
65
import { getAdMediasFromAdBreak } from "./vast";
76
import { parseVmap } from "./vmap";
87
import type { DateRange } from "./parser";
98
import type { Session } from "./session";
109
import type { VmapResponse } from "./vmap";
10+
import type { DateTime } from "luxon";
1111

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

1414
export interface Interstitial {
15-
timeOffset: number;
1615
url: string;
16+
duration?: number;
1717
type?: InterstitialType;
1818
}
1919

@@ -23,30 +23,33 @@ interface InterstitialAsset {
2323
"SPRS-TYPE"?: InterstitialType;
2424
}
2525

26-
export function getStaticDateRanges(session: Session) {
27-
assert(session.startTime, "No startTime in session");
28-
29-
const group: Record<string, InterstitialType[]> = {};
26+
export function getStaticDateRanges(startTime: DateTime, session: Session) {
27+
const group = new Group<number, InterstitialType | undefined>();
3028

3129
if (session.vmapResponse) {
3230
const vmap = parseVmap(session.vmapResponse);
3331
for (const adBreak of vmap.adBreaks) {
34-
const dateTime = session.startTime.plus({ seconds: adBreak.timeOffset });
35-
groupTimeOffset(group, dateTime, "ad");
32+
group.add(adBreak.timeOffset, "ad");
3633
}
3734
}
3835

39-
if (session.interstitials) {
40-
for (const interstitial of session.interstitials) {
41-
const dateTime = session.startTime.plus({
42-
seconds: interstitial.timeOffset,
43-
});
44-
groupTimeOffset(group, dateTime, interstitial.type);
45-
}
46-
}
36+
session.interstitials?.forEach((timeOffset, interstitials) => {
37+
interstitials.forEach((interstitial) => {
38+
group.add(timeOffset, interstitial.type);
39+
});
40+
});
4741

48-
return Object.entries(group).map<DateRange>(([startDate, types], index) => {
49-
const assetListUrl = `${env.PUBLIC_STITCHER_ENDPOINT}/session/${session.id}/asset-list.json?startDate=${encodeURIComponent(startDate)}`;
42+
const dateRanges: DateRange[] = [];
43+
44+
group.forEach((timeOffset, types) => {
45+
const startDate = startTime.plus({ seconds: timeOffset });
46+
47+
const assetListUrl = buildProxyUrl(
48+
`session/${session.id}/asset-list.json`,
49+
{
50+
startDate: startDate.toISO(),
51+
},
52+
);
5053

5154
const clientAttributes: Record<string, number | string> = {
5255
RESTRICT: "SKIP,JUMP",
@@ -58,106 +61,103 @@ export function getStaticDateRanges(session: Session) {
5861
clientAttributes["SPRS-TYPES"] = types.join(",");
5962
}
6063

61-
return {
64+
dateRanges.push({
6265
classId: "com.apple.hls.interstitial",
63-
id: `i${index}`,
64-
startDate: DateTime.fromISO(startDate),
66+
id: `sdr${timeOffset}`,
67+
startDate,
6568
clientAttributes,
66-
};
69+
});
6770
});
68-
}
6971

70-
function groupTimeOffset(
71-
group: Record<string, InterstitialType[]>,
72-
dateTime: DateTime,
73-
type?: InterstitialType,
74-
) {
75-
const key = dateTime.toISO();
76-
if (!key) {
77-
return;
78-
}
79-
if (!group[key]) {
80-
group[key] = [];
81-
}
82-
if (type) {
83-
group[key].push(type);
84-
}
72+
return dateRanges;
8573
}
8674

8775
export async function getAssets(session: Session, lookupDate: DateTime) {
88-
assert(session.startTime, "No startTime in session");
89-
9076
const assets: InterstitialAsset[] = [];
9177

92-
if (session.vmapResponse) {
93-
const vmap = parseVmap(session.vmapResponse);
94-
await formatStaticAdBreaks(assets, vmap, session.startTime, lookupDate);
95-
}
78+
if (session.startTime) {
79+
if (session.vmapResponse) {
80+
const vmap = parseVmap(session.vmapResponse);
81+
const vmapAssets = await getAssetsFromVmap(
82+
vmap,
83+
session.startTime,
84+
lookupDate,
85+
);
86+
assets.push(...vmapAssets);
87+
}
9688

97-
if (session.interstitials) {
98-
await formatStaticInterstitials(
99-
assets,
100-
session.interstitials,
101-
session.startTime,
102-
lookupDate,
103-
);
89+
if (session.interstitials) {
90+
const groupAssets = await getAssetsFromGroup(
91+
session.interstitials,
92+
session.startTime,
93+
lookupDate,
94+
);
95+
assets.push(...groupAssets);
96+
}
10497
}
10598

10699
return assets;
107100
}
108101

109-
async function formatStaticAdBreaks(
110-
assets: InterstitialAsset[],
102+
async function getAssetsFromVmap(
111103
vmap: VmapResponse,
112104
baseDate: DateTime,
113105
lookupDate: DateTime,
114106
) {
115-
const adBreak = vmap.adBreaks.find((adBreak) =>
116-
isEqualTimeOffset(baseDate, adBreak.timeOffset, lookupDate),
107+
const timeOffset = getTimeOffset(baseDate, lookupDate);
108+
const adBreak = vmap.adBreaks.find(
109+
(adBreak) => adBreak.timeOffset === timeOffset,
117110
);
118111

119112
if (!adBreak) {
120113
// No adbreak found for the time offset. There's nothing left to do.
121-
return;
114+
return [];
122115
}
123116

117+
const assets: InterstitialAsset[] = [];
118+
124119
const adMedias = await getAdMediasFromAdBreak(adBreak);
125120

126121
for (const adMedia of adMedias) {
127-
const uri = toAssetProtocol(adMedia.assetId);
128122
assets.push({
129-
URI: resolveUri(uri),
123+
URI: resolveUri(`asset://${adMedia.assetId}`),
130124
DURATION: adMedia.duration,
131125
"SPRS-TYPE": "ad",
132126
});
133127
}
128+
129+
return assets;
134130
}
135131

136-
async function formatStaticInterstitials(
137-
assets: InterstitialAsset[],
138-
interstitials: Interstitial[],
132+
async function getAssetsFromGroup(
133+
interstitialsGroup: Group<number, Interstitial>,
139134
baseDate: DateTime,
140135
lookupDate: DateTime,
141136
) {
142-
// Filter each interstitial and match it with the given lookup time.
143-
const list = interstitials.filter((interstitial) =>
144-
isEqualTimeOffset(baseDate, interstitial.timeOffset, lookupDate),
145-
);
137+
const assets: InterstitialAsset[] = [];
138+
139+
const timeOffset = getTimeOffset(baseDate, lookupDate);
140+
141+
const interstitials = interstitialsGroup.get(timeOffset);
142+
143+
for (const interstitial of interstitials) {
144+
let duration = interstitial.duration;
145+
if (!duration) {
146+
duration = await fetchDuration(interstitial.url);
147+
}
146148

147-
for (const interstitial of list) {
148-
const duration = await fetchMasterPlaylistDuration(interstitial.url);
149149
assets.push({
150150
URI: interstitial.url,
151151
DURATION: duration,
152152
"SPRS-TYPE": interstitial.type,
153153
});
154154
}
155+
156+
return assets;
155157
}
156158

157-
function isEqualTimeOffset(
158-
baseDate: DateTime,
159-
timeOffset: number,
160-
lookupDate: DateTime,
161-
) {
162-
return baseDate.plus({ seconds: timeOffset }).toISO() === lookupDate.toISO();
159+
function getTimeOffset(baseDate: DateTime, lookupDate: DateTime) {
160+
const { seconds } = lookupDate.diff(baseDate, "seconds").toObject();
161+
assert(seconds);
162+
return seconds;
163163
}

packages/stitcher/src/lib/group.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export class Group<K = unknown, V = unknown> {
2+
constructor(public map = new Map<K, Set<V>>()) {}
3+
4+
add(key: K, value: V) {
5+
let set = this.map.get(key);
6+
if (!set) {
7+
set = new Set();
8+
this.map.set(key, set);
9+
}
10+
set.add(value);
11+
}
12+
13+
forEach(callback: (value: K, items: V[]) => void) {
14+
Array.from(this.map.entries()).forEach(([key, set]) => {
15+
const items = Array.from(set.values());
16+
callback(key, items);
17+
});
18+
}
19+
20+
get(key: K) {
21+
const set = this.map.get(key);
22+
return set ? Array.from(set.values()) : [];
23+
}
24+
}

packages/stitcher/src/lib/json.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { DateTime } from "luxon";
2+
import { assert } from "shared/assert";
3+
import { parse, registerCustom, stringify } from "superjson";
4+
import { Group } from "./group";
5+
6+
registerCustom<DateTime, string>(
7+
{
8+
isApplicable: (value) => DateTime.isDateTime(value),
9+
serialize: (dateTime) => {
10+
const value = dateTime.toISO();
11+
assert(value, "No convert to ISO");
12+
return value;
13+
},
14+
deserialize: (value) => DateTime.fromISO(value),
15+
},
16+
"DateTime",
17+
);
18+
19+
registerCustom<Group, string>(
20+
{
21+
isApplicable: (value) => value instanceof Group,
22+
serialize: (group) => stringify(group.map),
23+
deserialize: (value) => new Group(parse(value)),
24+
},
25+
"Group",
26+
);
27+
28+
export const JSON = {
29+
parse,
30+
stringify,
31+
};

packages/stitcher/src/lib/url.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as path from "path";
22
import { encrypt } from "./crypto";
33
import { env } from "../env";
4+
import { filterQuery } from "../filters";
5+
import type { Filter } from "../filters";
46
import type { Session } from "../session";
57

68
const uuidRegex = /^[a-z,0-9,-]{36,36}$/;
@@ -61,22 +63,33 @@ export function joinUrl(urlFile: string, filePath: string) {
6163
return `${url.protocol}//${url.host}${path.join(url.pathname, filePath)}`;
6264
}
6365

64-
export function toAssetProtocol(uuid: string) {
65-
return `${ASSET_PROTOCOL}:${uuid}`;
66-
}
67-
6866
export function buildProxyUrl(
69-
file: string,
70-
options: {
71-
url?: string;
72-
session?: Session;
73-
params?: Record<string, string | undefined>;
74-
} = {},
67+
path: string,
68+
params: Record<string, string | undefined | null> = {},
7569
) {
76-
const { url, session, params } = options;
77-
return buildUrl(`${env.PUBLIC_STITCHER_ENDPOINT}/out/${file}`, {
78-
eurl: url ? encrypt(url) : undefined,
79-
sid: session?.id,
80-
...params,
70+
return buildUrl(`${env.PUBLIC_STITCHER_ENDPOINT}/${path}`, params);
71+
}
72+
73+
export function buildProxyMasterUrl(params: {
74+
url: string;
75+
session?: Session;
76+
filter?: Filter;
77+
}) {
78+
return buildProxyUrl("out/master.m3u8", {
79+
eurl: encrypt(params.url),
80+
sid: params.session?.id,
81+
...filterQuery(params.filter),
82+
});
83+
}
84+
85+
export function buildProxyMediaUrl(params: {
86+
type: string;
87+
url: string;
88+
session?: Session;
89+
}) {
90+
return buildProxyUrl("out/playlist.m3u8", {
91+
type: params.type,
92+
eurl: encrypt(params.url),
93+
sid: params.session?.id,
8194
});
8295
}

0 commit comments

Comments
 (0)