1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as MediaLive from "@/AWS/MediaLive";
4import type * as medialive from "@distilled.cloud/aws/medialive";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "channel-handler.ts");
12
13// A minimal single-pipeline UDP encoder config — the cheapest valid
14// encoder settings. The channel is provisioned IDLE and never started.
15const ENCODER_SETTINGS: medialive.EncoderSettings = {
16 TimecodeConfig: { Source: "EMBEDDED" },
17 AudioDescriptions: [
18 {
19 Name: "audio_1",
20 AudioSelectorName: "default",
21 CodecSettings: {
22 AacSettings: { Bitrate: 96000, SampleRate: 48000 },
23 },
24 },
25 ],
26 VideoDescriptions: [
27 {
28 Name: "video_1",
29 Width: 640,
30 Height: 360,
31 CodecSettings: {
32 H264Settings: {
33 Bitrate: 1000000,
34 FramerateControl: "SPECIFIED",
35 FramerateNumerator: 30,
36 FramerateDenominator: 1,
37 GopSize: 60,
38 },
39 },
40 },
41 ],
42 OutputGroups: [
43 {
44 Name: "udp",
45 OutputGroupSettings: { UdpGroupSettings: {} },
46 Outputs: [
47 {
48 OutputName: "output_1",
49 VideoDescriptionName: "video_1",
50 AudioDescriptionNames: ["audio_1"],
51 OutputSettings: {
52 UdpOutputSettings: {
53 Destination: { DestinationRefId: "dest1" },
54 ContainerSettings: { M2tsSettings: {} },
55 },
56 },
57 },
58 ],
59 },
60 ],
61};
62
63export class MediaLiveChannelTestFunction extends Lambda.Function<Lambda.Function>()(
64 "MediaLiveChannelTestFunction",
65) {}
66
67export default MediaLiveChannelTestFunction.make(
68 {
69 main,
70 functionUrl: true,
71 },
72 Effect.gen(function* () {
73 const role = yield* IAM.Role("ChannelBindingRole", {
74 assumeRolePolicyDocument: {
75 Version: "2012-10-17",
76 Statement: [
77 {
78 Effect: "Allow",
79 Principal: { Service: "medialive.amazonaws.com" },
80 Action: ["sts:AssumeRole"],
81 },
82 ],
83 },
84 });
85 const input = yield* MediaLive.Input("ChannelBindingInput", {
86 type: "URL_PULL",
87 sources: [{ Url: "https://example.com/stream/index.m3u8" }],
88 });
89 // The channel the channel-scoped bindings are bound to. It is created
90 // IDLE (which does not bill — only RUNNING channels do) and nothing
91 // ever starts it, so the observability ops see the typed not-running
92 // behavior.
93 const channel = yield* MediaLive.Channel("BindingChannel", {
94 channelClass: "SINGLE_PIPELINE",
95 roleArn: role.roleArn,
96 inputAttachments: [
97 {
98 InputId: input.inputId,
99 InputAttachmentName: "primary",
100 InputSettings: { AudioSelectors: [{ Name: "default" }] },
101 },
102 ],
103 inputSpecification: {
104 Codec: "AVC",
105 Resolution: "SD",
106 MaximumBitrate: "MAX_10_MBPS",
107 },
108 destinations: [
109 { Id: "dest1", Settings: [{ Url: "udp://10.220.171.28:5000" }] },
110 ],
111 encoderSettings: ENCODER_SETTINGS,
112 tags: { fixture: "medialive-channel-bindings" },
113 });
114
115 const describeChannel = yield* MediaLive.DescribeChannel(channel);
116 const describeSchedule = yield* MediaLive.DescribeSchedule(channel);
117 const updateSchedule = yield* MediaLive.BatchUpdateSchedule(channel);
118 const deleteSchedule = yield* MediaLive.DeleteSchedule(channel);
119 const listAlerts = yield* MediaLive.ListAlerts(channel);
120 const describeThumbnails = yield* MediaLive.DescribeThumbnails(channel);
121 const startChannel = yield* MediaLive.StartChannel(channel);
122 const stopChannel = yield* MediaLive.StopChannel(channel);
123 const restartPipelines = yield* MediaLive.RestartChannelPipelines(channel);
124
125 // startChannel / stopChannel / restartPipelines are bound (their IAM
126 // grants are attached and their init proved) but never driven — a
127 // RUNNING channel bills by the hour.
128 const bound = {
129 describeChannel,
130 describeSchedule,
131 updateSchedule,
132 deleteSchedule,
133 listAlerts,
134 describeThumbnails,
135 startChannel,
136 stopChannel,
137 restartPipelines,
138 };
139
140 return {
141 fetch: Effect.gen(function* () {
142 const request = yield* HttpServerRequest;
143 const url = new URL(request.originalUrl);
144 const pathname = url.pathname;
145
146 if (request.method === "GET" && pathname === "/bindings") {
147 return yield* HttpServerResponse.json({
148 bound: Object.keys(bound),
149 });
150 }
151
152 // Read the channel's live state — the fixture channel idles IDLE.
153 if (request.method === "GET" && pathname === "/channel") {
154 const current = yield* describeChannel();
155 return yield* HttpServerResponse.json({
156 state: current.State,
157 channelClass: current.ChannelClass,
158 pipelinesRunning: current.PipelinesRunningCount,
159 });
160 }
161
162 // The schedule of a freshly-created channel is empty.
163 if (request.method === "GET" && pathname === "/schedule") {
164 const { ScheduleActions } = yield* describeSchedule();
165 return yield* HttpServerResponse.json({
166 actions: (ScheduleActions ?? []).length,
167 });
168 }
169
170 // Program a fixed-time input switch far in the future, observe it
171 // in the schedule, then clear the schedule — a full
172 // BatchUpdateSchedule/DescribeSchedule/DeleteSchedule roundtrip on
173 // an IDLE channel.
174 if (request.method === "POST" && pathname === "/schedule-cycle") {
175 const startTime = yield* Effect.sync(() =>
176 new Date(Date.now() + 3_600_000).toISOString(),
177 );
178 const result = yield* updateSchedule({
179 Creates: {
180 ScheduleActions: [
181 {
182 ActionName: "alchemy-binding-test",
183 ScheduleActionStartSettings: {
184 FixedModeScheduleActionStartSettings: { Time: startTime },
185 },
186 ScheduleActionSettings: {
187 InputSwitchSettings: {
188 InputAttachmentNameReference: "primary",
189 },
190 },
191 },
192 ],
193 },
194 }).pipe(
195 Effect.flatMap(() => describeSchedule()),
196 Effect.flatMap(({ ScheduleActions }) =>
197 deleteSchedule().pipe(
198 Effect.map(() => ({
199 created: (ScheduleActions ?? []).length,
200 cleared: true,
201 tag: undefined as string | undefined,
202 })),
203 ),
204 ),
205 Effect.catchTag(
206 ["BadRequestException", "UnprocessableEntityException"],
207 (e) =>
208 Effect.succeed({
209 created: 0,
210 cleared: false,
211 tag: e._tag as string | undefined,
212 }),
213 ),
214 );
215 return yield* HttpServerResponse.json(result);
216 }
217
218 // A channel that has never run has no (or only informational)
219 // alerts; the call itself must succeed.
220 if (request.method === "GET" && pathname === "/alerts") {
221 const { Alerts } = yield* listAlerts();
222 return yield* HttpServerResponse.json({
223 alerts: (Alerts ?? []).length,
224 });
225 }
226
227 // Thumbnails on an IDLE channel: either empty details or the typed
228 // BadRequestException (thumbnails require a running channel).
229 if (request.method === "GET" && pathname === "/thumbnails") {
230 const result = yield* describeThumbnails({ PipelineId: "0" }).pipe(
231 Effect.map(({ ThumbnailDetails }) => ({
232 details: (ThumbnailDetails ?? []).length,
233 tag: undefined as string | undefined,
234 })),
235 Effect.catchTag("BadRequestException", (e) =>
236 Effect.succeed({ details: 0, tag: e._tag as string | undefined }),
237 ),
238 );
239 return yield* HttpServerResponse.json(result);
240 }
241
242 return yield* HttpServerResponse.json(
243 { error: "Not found", method: request.method, pathname },
244 { status: 404 },
245 );
246 }).pipe(Effect.orDie),
247 };
248 }).pipe(
249 Effect.provide(
250 Layer.mergeAll(
251 MediaLive.DescribeChannelHttp,
252 MediaLive.DescribeScheduleHttp,
253 MediaLive.BatchUpdateScheduleHttp,
254 MediaLive.DeleteScheduleHttp,
255 MediaLive.ListAlertsHttp,
256 MediaLive.DescribeThumbnailsHttp,
257 MediaLive.StartChannelHttp,
258 MediaLive.StopChannelHttp,
259 MediaLive.RestartChannelPipelinesHttp,
260 ),
261 ),
262 ),
263);