1import * as mediapackagev2 from "@distilled.cloud/aws/mediapackagev2";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 deleteChannelWithEndpoints,
13 listAllChannelGroups,
14 listGroupChannels,
15 matchesDesired,
16 policiesEqual,
17 syncMpTags,
18 toMpTagRecord,
19} from "./internal.ts";
20
21export interface ChannelProps {
22 /**
23 * Name of the channel group the channel belongs to. Changing it replaces
24 * the channel.
25 */
26 channelGroupName: string;
27 /**
28 * Name of the channel. Must be unique within the channel group and match
29 * `^[a-zA-Z0-9_-]+$`. If omitted, a unique name is generated. Changing the
30 * name replaces the channel.
31 */
32 channelName?: string;
33 /**
34 * The input type of content the channel receives: `HLS` or `CMAF`.
35 * Immutable — changing it replaces the channel.
36 * @default "HLS"
37 */
38 inputType?: mediapackagev2.InputType;
39 /**
40 * Optional description of the channel (up to 1024 characters).
41 */
42 description?: string;
43 /**
44 * Input-switching behavior between the channel's redundant ingest inputs
45 * (e.g. switch based on the media quality confidence score).
46 */
47 inputSwitchConfiguration?: mediapackagev2.InputSwitchConfiguration;
48 /**
49 * Settings for what common media server data (CMSD) headers AWS Elemental
50 * MediaPackage includes in responses to the CDN.
51 */
52 outputHeaderConfiguration?: mediapackagev2.OutputHeaderConfiguration;
53 /**
54 * IAM resource policy (JSON) attached to the channel, controlling which
55 * principals may push content to it (`mediapackagev2:PutObject`).
56 * Omitting it removes any existing policy.
57 */
58 policy?: string;
59 /**
60 * User-defined tags for the channel. Merged with internal Alchemy tags.
61 */
62 tags?: Record<string, string>;
63}
64
65export interface Channel extends Resource<
66 "AWS.MediaPackageV2.Channel",
67 ChannelProps,
68 {
69 /** Name of the channel group the channel belongs to. */
70 channelGroupName: string;
71 /** Name of the channel. */
72 channelName: string;
73 /** ARN of the channel. */
74 channelArn: string;
75 /** Ingest container type (`HLS` or `CMAF`). */
76 inputType: string | undefined;
77 /** Redundant ingest endpoints the encoder pushes content to. */
78 ingestEndpoints: {
79 /** Ingest endpoint id (e.g. `"1"`, `"2"`). */
80 id: string | undefined;
81 /** Ingest URL the encoder pushes to. */
82 url: string | undefined;
83 }[];
84 },
85 never,
86 Providers
87> {}
88
89/**
90 * An AWS Elemental MediaPackage v2 channel — the entry point for live
91 * content into MediaPackage. An encoder (such as AWS Elemental MediaLive)
92 * pushes an HLS or CMAF stream to the channel's ingest endpoints; origin
93 * endpoints then package and serve that content downstream.
94 *
95 * ### Creating a Channel
96 * **Example:** Basic Channel in a Group
97 * ```typescript
98 * import * as MediaPackageV2 from "alchemy/AWS/MediaPackageV2";
99 *
100 * const group = yield* MediaPackageV2.ChannelGroup("Live");
101 * const channel = yield* MediaPackageV2.Channel("Feed", {
102 * channelGroupName: group.channelGroupName,
103 * });
104 * ```
105 *
106 * **Example:** CMAF Ingest Channel
107 * ```typescript
108 * const channel = yield* MediaPackageV2.Channel("Feed", {
109 * channelGroupName: group.channelGroupName,
110 * inputType: "CMAF",
111 * description: "CMAF contribution feed",
112 * });
113 * ```
114 *
115 * ### Resource Policy
116 * **Example:** Allow a Principal to Push Content
117 * ```typescript
118 * const channel = yield* MediaPackageV2.Channel("Feed", {
119 * channelGroupName: group.channelGroupName,
120 * policy: JSON.stringify({
121 * Version: "2012-10-17",
122 * Statement: [{
123 * Effect: "Allow",
124 * Principal: { AWS: "arn:aws:iam::111122223333:root" },
125 * Action: "mediapackagev2:PutObject",
126 * Resource: "arn:aws:mediapackagev2:us-east-1:111122223333:channelGroup/live/channel/feed",
127 * }],
128 * }),
129 * });
130 * ```
131 *
132 * ### Ingest Endpoints
133 * **Example:** Point the encoder at the ingest URLs
134 * ```typescript
135 * const channel = yield* MediaPackageV2.Channel("Feed", {
136 * channelGroupName: group.channelGroupName,
137 * });
138 * // Two redundant ingest endpoints for the encoder to push to.
139 * const urls = channel.ingestEndpoints;
140 * ```
141 *
142 * @resource
143 */
144export const Channel = Resource<Channel>("AWS.MediaPackageV2.Channel");
145
146export const ChannelProvider = () =>
147 Provider.effect(
148 Channel,
149 Effect.gen(function* () {
150 const createName = Effect.fn(function* (
151 id: string,
152 props: { channelName?: string },
153 ) {
154 return (
155 props.channelName ??
156 (yield* createPhysicalName({ id, maxLength: 256 }))
157 );
158 });
159
160 const toAttrs = (channel: {
161 Arn: string;
162 ChannelName: string;
163 ChannelGroupName: string;
164 IngestEndpoints?: mediapackagev2.IngestEndpoint[];
165 InputType?: mediapackagev2.InputType;
166 }) => ({
167 channelGroupName: channel.ChannelGroupName,
168 channelName: channel.ChannelName,
169 channelArn: channel.Arn,
170 inputType: channel.InputType,
171 ingestEndpoints: (channel.IngestEndpoints ?? []).map((endpoint) => ({
172 id: endpoint.Id,
173 url: endpoint.Url,
174 })),
175 });
176
177 /** Get a channel by group + name; typed not-found → undefined. */
178 const getChannel = Effect.fn(function* (
179 channelGroupName: string,
180 channelName: string,
181 ) {
182 return yield* mediapackagev2
183 .getChannel({
184 ChannelGroupName: channelGroupName,
185 ChannelName: channelName,
186 })
187 .pipe(
188 Effect.catchTag("ResourceNotFoundException", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192 });
193
194 return {
195 stables: ["channelGroupName", "channelName", "channelArn"],
196
197 diff: Effect.fn(function* ({ id, olds, news }) {
198 if (!isResolved(news)) return undefined;
199 // Group, name, and input type are the channel's identity.
200 if (olds.channelGroupName !== news.channelGroupName) {
201 return { action: "replace" } as const;
202 }
203 const oldName = yield* createName(id, olds);
204 const newName = yield* createName(id, news);
205 if (oldName !== newName) return { action: "replace" } as const;
206 if ((olds.inputType ?? "HLS") !== (news.inputType ?? "HLS")) {
207 return { action: "replace" } as const;
208 }
209 }),
210
211 read: Effect.fn(function* ({ id, olds, output }) {
212 const channelGroupName =
213 output?.channelGroupName ?? olds?.channelGroupName;
214 if (channelGroupName === undefined) return undefined;
215 const channelName =
216 output?.channelName ?? (yield* createName(id, olds ?? {}));
217 const channel = yield* getChannel(channelGroupName, channelName);
218 if (channel === undefined) return undefined;
219 const attrs = toAttrs(channel);
220 return (yield* hasAlchemyTags(id, toMpTagRecord(channel.Tags)))
221 ? attrs
222 : Unowned(attrs);
223 }),
224
225 reconcile: Effect.fn(function* ({ id, news, output, session }) {
226 const internalTags = yield* createInternalTags(id);
227 const desiredTags = { ...internalTags, ...news.tags };
228 const channelGroupName = news.channelGroupName;
229 const channelName =
230 output?.channelName ?? (yield* createName(id, news));
231
232 // 1. Observe — cloud state is authoritative; output is an id cache.
233 let channel = yield* getChannel(channelGroupName, channelName);
234
235 // 2. Ensure — create if missing; a Conflict means a peer created it
236 // concurrently, so fall through to observing the winner.
237 if (channel === undefined) {
238 channel = yield* mediapackagev2
239 .createChannel({
240 ChannelGroupName: channelGroupName,
241 ChannelName: channelName,
242 InputType: news.inputType,
243 Description: news.description,
244 InputSwitchConfiguration: news.inputSwitchConfiguration,
245 OutputHeaderConfiguration: news.outputHeaderConfiguration,
246 Tags: desiredTags,
247 })
248 .pipe(
249 Effect.catchTag("ConflictException", () =>
250 mediapackagev2.getChannel({
251 ChannelGroupName: channelGroupName,
252 ChannelName: channelName,
253 }),
254 ),
255 );
256 } else {
257 // 3. Sync — apply the mutable fields only when the observed
258 // state has drifted from the desired state.
259 const desired = {
260 Description: news.description ?? "",
261 InputSwitchConfiguration: news.inputSwitchConfiguration,
262 OutputHeaderConfiguration: news.outputHeaderConfiguration,
263 };
264 const observed = {
265 Description: channel.Description ?? "",
266 InputSwitchConfiguration: channel.InputSwitchConfiguration,
267 OutputHeaderConfiguration: channel.OutputHeaderConfiguration,
268 };
269 if (!matchesDesired(desired, observed)) {
270 channel = yield* mediapackagev2.updateChannel({
271 ChannelGroupName: channelGroupName,
272 ChannelName: channelName,
273 Description: news.description,
274 InputSwitchConfiguration: news.inputSwitchConfiguration,
275 OutputHeaderConfiguration: news.outputHeaderConfiguration,
276 });
277 }
278 }
279
280 // 3b. Sync tags — diff against OBSERVED cloud tags.
281 yield* syncMpTags(
282 channel.Arn,
283 toMpTagRecord(channel.Tags),
284 desiredTags,
285 );
286
287 // 3c. Sync the resource policy — observe the live policy (absent
288 // policy is the typed not-found) and apply only the delta.
289 const observedPolicy = yield* mediapackagev2
290 .getChannelPolicy({
291 ChannelGroupName: channelGroupName,
292 ChannelName: channelName,
293 })
294 .pipe(
295 Effect.map((response) => response.Policy as string | undefined),
296 Effect.catchTag("ResourceNotFoundException", () =>
297 Effect.succeed(undefined),
298 ),
299 );
300 if (news.policy !== undefined) {
301 if (!policiesEqual(observedPolicy, news.policy)) {
302 yield* mediapackagev2.putChannelPolicy({
303 ChannelGroupName: channelGroupName,
304 ChannelName: channelName,
305 Policy: news.policy,
306 });
307 }
308 } else if (observedPolicy !== undefined) {
309 yield* mediapackagev2.deleteChannelPolicy({
310 ChannelGroupName: channelGroupName,
311 ChannelName: channelName,
312 });
313 }
314
315 yield* session.note(channelName);
316 return toAttrs(channel);
317 }),
318
319 delete: Effect.fn(function* ({ output }) {
320 // Reap the channel's origin endpoints first (a normal stack destroy
321 // already deleted them; an orphan sweep may not have), then delete
322 // the channel. Every step is idempotent and the transient Conflict
323 // while just-deleted endpoints are cleaned up is retried.
324 yield* deleteChannelWithEndpoints(
325 output.channelGroupName,
326 output.channelName,
327 );
328 }),
329
330 // Channels are keyed by their parent channel group, so enumerate
331 // the groups first and fan out.
332 list: () =>
333 Effect.gen(function* () {
334 const groups = yield* listAllChannelGroups();
335 const items = yield* Effect.forEach(
336 groups,
337 (group) => listGroupChannels(group.ChannelGroupName),
338 { concurrency: 5 },
339 ).pipe(Effect.map((nested) => nested.flat()));
340 // The list shape omits ingest endpoints, so hydrate each item
341 // via get; a channel can vanish between enumeration and
342 // hydration.
343 const channels = yield* Effect.forEach(
344 items,
345 (item) =>
346 getChannel(item.ChannelGroupName, item.ChannelName).pipe(
347 Effect.map((channel) =>
348 channel === undefined ? undefined : toAttrs(channel),
349 ),
350 ),
351 { concurrency: 5 },
352 );
353 return channels.filter(Predicate.isNotUndefined);
354 }),
355 };
356 }),
357 );