1import * as ivs from "@distilled.cloud/aws/ivs";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
13import { toWireSeconds } from "../../Util/Duration.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 retryWhileConflict,
17 retryWhileThrottled,
18 syncIvsTags,
19 toTagRecord,
20} from "./internal.ts";
21
22export interface RecordingDestinationConfiguration {
23 /**
24 * S3 destination for recordings.
25 */
26 s3?: {
27 /**
28 * Name of the S3 bucket recordings are written to. Must be in the
29 * same region and account as the recording configuration.
30 */
31 bucketName: string;
32 };
33}
34
35export interface RecordingThumbnailConfiguration {
36 /**
37 * Thumbnail recording mode (`DISABLED` or `INTERVAL`).
38 * @default "INTERVAL"
39 */
40 recordingMode?: "DISABLED" | "INTERVAL";
41 /**
42 * The targeted interval between thumbnails when `recordingMode` is
43 * `INTERVAL`. Accepts any `Duration.Input` (e.g. `"60 seconds"`,
44 * `Duration.minutes(2)`); converted to whole seconds on the wire.
45 * Minimum 1 second, maximum 60 seconds when `storage` includes
46 * `LATEST`.
47 * @default 60 seconds
48 */
49 targetInterval?: Duration.Input;
50 /**
51 * Desired resolution of recorded thumbnails (`SD`, `HD`, `FULL_HD`, or
52 * `LOWEST_RESOLUTION`).
53 * @default the source resolution
54 */
55 resolution?: "SD" | "HD" | "FULL_HD" | "LOWEST_RESOLUTION";
56 /**
57 * Where thumbnails are stored: `SEQUENTIAL` archives all thumbnails,
58 * `LATEST` keeps only the most recent (overwritten in place).
59 * @default ["SEQUENTIAL"]
60 */
61 storage?: ("SEQUENTIAL" | "LATEST")[];
62}
63
64export interface RecordingRenditionConfiguration {
65 /**
66 * Which renditions are recorded (`ALL`, `NONE`, or `CUSTOM`).
67 * @default "ALL"
68 */
69 renditionSelection?: "ALL" | "NONE" | "CUSTOM";
70 /**
71 * The renditions recorded when `renditionSelection` is `CUSTOM` (`SD`,
72 * `HD`, `FULL_HD`, `LOWEST_RESOLUTION`).
73 */
74 renditions?: ("SD" | "HD" | "FULL_HD" | "LOWEST_RESOLUTION")[];
75}
76
77export interface RecordingConfigurationProps {
78 /**
79 * Where recordings are written (an S3 bucket in the same region and
80 * account). Changing the destination replaces the recording
81 * configuration.
82 */
83 destinationConfiguration: RecordingDestinationConfiguration;
84 /**
85 * Name of the recording configuration. If omitted, a deterministic
86 * physical name is generated. There is no update operation, so
87 * changing the name replaces the recording configuration.
88 */
89 recordingConfigurationName?: string;
90 /**
91 * If a broadcast disconnects and reconnects within this window, the
92 * multiple streams are considered a single broadcast and merged into a
93 * single recording. Accepts any `Duration.Input` (e.g. `"2 minutes"`,
94 * `Duration.seconds(30)`); converted to whole seconds on the wire.
95 * Maximum 5 minutes. Changing the window replaces the recording
96 * configuration.
97 * @default 0 seconds (no merge)
98 */
99 recordingReconnectWindow?: Duration.Input;
100 /**
101 * Thumbnail generation settings for the recording. Changing them
102 * replaces the recording configuration.
103 */
104 thumbnailConfiguration?: RecordingThumbnailConfiguration;
105 /**
106 * Which renditions of the live stream are recorded. Changing them
107 * replaces the recording configuration.
108 */
109 renditionConfiguration?: RecordingRenditionConfiguration;
110 /**
111 * Tags to apply to the recording configuration. Merged with internal
112 * Alchemy tags.
113 */
114 tags?: Record<string, string>;
115}
116
117export interface RecordingConfiguration extends Resource<
118 "AWS.IVS.RecordingConfiguration",
119 RecordingConfigurationProps,
120 {
121 /**
122 * ARN of the recording configuration.
123 */
124 recordingConfigurationArn: string;
125 /**
126 * The recording configuration's physical name.
127 */
128 recordingConfigurationName: string | undefined;
129 /**
130 * Name of the S3 bucket recordings are written to.
131 */
132 bucketName: string | undefined;
133 /**
134 * State of the recording configuration (`CREATING`, `ACTIVE`, or
135 * `CREATE_FAILED`).
136 */
137 state: string;
138 },
139 never,
140 Providers
141> {}
142
143/**
144 * An Amazon IVS recording configuration, enabling automatic recording of
145 * live broadcasts to Amazon S3.
146 *
147 * Attach the configuration to a channel via the channel's
148 * `recordingConfigurationArn` prop; every broadcast on that channel is
149 * then archived to the configured bucket. Recording configurations are
150 * immutable — any settings change replaces the resource.
151 * ### Recording Broadcasts
152 * **Example:** Record a Channel to S3
153 * ```typescript
154 * import * as AWS from "alchemy/AWS";
155 * import * as IVS from "alchemy/AWS/IVS";
156 *
157 * const archive = yield* AWS.Bucket("StreamArchive");
158 * const recording = yield* IVS.RecordingConfiguration("Recording", {
159 * destinationConfiguration: { s3: { bucketName: archive.bucketName } },
160 * });
161 * const channel = yield* IVS.Channel("LiveChannel", {
162 * recordingConfigurationArn: recording.recordingConfigurationArn,
163 * });
164 * ```
165 *
166 * **Example:** Merge Reconnects and Record Thumbnails
167 * ```typescript
168 * const recording = yield* IVS.RecordingConfiguration("Recording", {
169 * destinationConfiguration: { s3: { bucketName: archive.bucketName } },
170 * recordingReconnectWindow: "2 minutes",
171 * thumbnailConfiguration: {
172 * recordingMode: "INTERVAL",
173 * targetInterval: "30 seconds",
174 * },
175 * });
176 * ```
177 *
178 * @resource
179 */
180export const RecordingConfiguration = Resource<RecordingConfiguration>(
181 "AWS.IVS.RecordingConfiguration",
182);
183
184/**
185 * Raised when the IVS API returns a recording configuration in an
186 * unusable shape or state (missing from a create response, or stuck in
187 * `CREATE_FAILED`).
188 */
189export class IvsRecordingConfigurationFailed extends Data.TaggedError(
190 "IvsRecordingConfigurationFailed",
191)<{ message: string }> {}
192
193export const RecordingConfigurationProvider = () =>
194 Provider.effect(
195 RecordingConfiguration,
196 Effect.gen(function* () {
197 const toName = (
198 id: string,
199 props: { recordingConfigurationName?: string | undefined },
200 ) =>
201 props.recordingConfigurationName
202 ? Effect.succeed(props.recordingConfigurationName)
203 : createPhysicalName({ id, maxLength: 128 });
204
205 const toAttrs = (config: {
206 arn: string;
207 name?: string | undefined;
208 destinationConfiguration: ivs.DestinationConfiguration;
209 state: string;
210 }) => ({
211 recordingConfigurationArn: config.arn,
212 recordingConfigurationName: config.name,
213 bucketName: config.destinationConfiguration.s3?.bucketName,
214 state: config.state,
215 });
216
217 const getByArn = Effect.fn(function* (arn: string) {
218 const response = yield* ivs.getRecordingConfiguration({ arn }).pipe(
219 retryWhileThrottled,
220 Effect.catchTag("ResourceNotFoundException", () =>
221 Effect.succeed(undefined),
222 ),
223 );
224 return response?.recordingConfiguration;
225 });
226
227 /**
228 * ListRecordingConfigurations has no name filter — enumerate and
229 * match exactly (names are not unique; first hit wins). Used only
230 * when the output ARN cache is unavailable.
231 */
232 const findByName = Effect.fn(function* (name: string) {
233 const summaries = yield* ivs.listRecordingConfigurations.pages({}).pipe(
234 Stream.runCollect,
235 Effect.map((chunk) =>
236 Array.from(chunk).flatMap((page) => page.recordingConfigurations),
237 ),
238 retryWhileThrottled,
239 );
240 const match = summaries.find((s) => s.name === name);
241 return match === undefined ? undefined : yield* getByArn(match.arn);
242 });
243
244 /**
245 * Wait (bounded) for a freshly created configuration to leave
246 * `CREATING`. `CREATE_FAILED` (e.g. the bucket is in another
247 * region) is surfaced as a typed failure.
248 */
249 const awaitActive = Effect.fn(function* (arn: string) {
250 const config = yield* getByArn(arn).pipe(
251 Effect.repeat({
252 schedule: Schedule.fixed("3 seconds"),
253 until: (c): boolean => c === undefined || c.state !== "CREATING",
254 times: 20,
255 }),
256 );
257 if (config === undefined) {
258 return yield* Effect.fail(
259 new IvsRecordingConfigurationFailed({
260 message: `IVS recording configuration '${arn}' vanished while awaiting ACTIVE`,
261 }),
262 );
263 }
264 if (config.state === "CREATE_FAILED") {
265 return yield* Effect.fail(
266 new IvsRecordingConfigurationFailed({
267 message: `IVS recording configuration '${arn}' entered CREATE_FAILED — is the S3 bucket in the same region and account?`,
268 }),
269 );
270 }
271 return config;
272 });
273
274 return {
275 stables: ["recordingConfigurationArn"],
276
277 read: Effect.fn(function* ({ id, olds, output }) {
278 const config = output?.recordingConfigurationArn
279 ? yield* getByArn(output.recordingConfigurationArn)
280 : yield* findByName(yield* toName(id, olds ?? {}));
281 if (config === undefined) return undefined;
282 const attrs = toAttrs(config);
283 return (yield* hasAlchemyTags(id, toTagRecord(config.tags)))
284 ? attrs
285 : Unowned(attrs);
286 }),
287
288 diff: Effect.fn(function* ({ id, news, olds }) {
289 if (!isResolved(news)) return undefined;
290 // There is no UpdateRecordingConfiguration — every setting
291 // except tags is immutable and requires a replacement.
292 const changed =
293 (yield* toName(id, olds)) !== (yield* toName(id, news)) ||
294 olds.destinationConfiguration.s3?.bucketName !==
295 news.destinationConfiguration.s3?.bucketName ||
296 toWireSeconds(olds.recordingReconnectWindow) !==
297 toWireSeconds(news.recordingReconnectWindow) ||
298 JSON.stringify({
299 ...olds.thumbnailConfiguration,
300 targetInterval: toWireSeconds(
301 olds.thumbnailConfiguration?.targetInterval,
302 ),
303 }) !==
304 JSON.stringify({
305 ...news.thumbnailConfiguration,
306 targetInterval: toWireSeconds(
307 news.thumbnailConfiguration?.targetInterval,
308 ),
309 }) ||
310 JSON.stringify(olds.renditionConfiguration) !==
311 JSON.stringify(news.renditionConfiguration);
312 if (changed) return { action: "replace" } as const;
313 }),
314
315 reconcile: Effect.fn(function* ({ id, news, output, session }) {
316 const name = yield* toName(id, news);
317 const internalTags = yield* createInternalTags(id);
318 const desiredTags = { ...internalTags, ...news.tags };
319
320 // 1. Observe — the live configuration is authoritative; the
321 // output ARN is only an identifier cache.
322 let observed = output?.recordingConfigurationArn
323 ? yield* getByArn(output.recordingConfigurationArn)
324 : yield* findByName(name);
325
326 // 2. Ensure — create if missing, then wait for ACTIVE.
327 if (observed === undefined) {
328 const thumbnail = news.thumbnailConfiguration;
329 const created = yield* ivs
330 .createRecordingConfiguration({
331 name,
332 destinationConfiguration: news.destinationConfiguration,
333 recordingReconnectWindowSeconds: toWireSeconds(
334 news.recordingReconnectWindow,
335 ),
336 thumbnailConfiguration:
337 thumbnail === undefined
338 ? undefined
339 : {
340 recordingMode: thumbnail.recordingMode,
341 targetIntervalSeconds: toWireSeconds(
342 thumbnail.targetInterval,
343 ),
344 resolution: thumbnail.resolution,
345 storage: thumbnail.storage,
346 },
347 renditionConfiguration: news.renditionConfiguration,
348 tags: desiredTags,
349 })
350 .pipe(retryWhileThrottled);
351 observed = created.recordingConfiguration;
352 }
353 if (observed === undefined) {
354 return yield* Effect.fail(
355 new IvsRecordingConfigurationFailed({
356 message:
357 "IVS CreateRecordingConfiguration returned no configuration",
358 }),
359 );
360 }
361 const arn = observed.arn;
362 yield* session.note(arn);
363 const active = yield* awaitActive(arn);
364
365 // 3. Sync tags — the only mutable aspect. Diff against OBSERVED
366 // cloud tags so adoption converges.
367 yield* syncIvsTags(arn, desiredTags);
368
369 // 4. Return fresh attributes.
370 return toAttrs(active);
371 }),
372
373 delete: Effect.fn(function* ({ output }) {
374 // Deleting a configuration still attached to a channel raises
375 // ConflictException — retry through a bounded window, then
376 // tolerate already-gone.
377 yield* ivs
378 .deleteRecordingConfiguration({
379 arn: output.recordingConfigurationArn,
380 })
381 .pipe(
382 retryWhileThrottled,
383 retryWhileConflict,
384 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
385 );
386 }),
387
388 list: () =>
389 ivs.listRecordingConfigurations.pages({}).pipe(
390 Stream.runCollect,
391 Effect.map((chunk) =>
392 Array.from(chunk).flatMap((page) => page.recordingConfigurations),
393 ),
394 Effect.map((summaries) =>
395 summaries.map((summary) => toAttrs(summary)),
396 ),
397 ),
398 };
399 }),
400 );