import * as medialive from "@distilled.cloud/aws/medialive";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
import { Unowned } from "../../AdoptPolicy.ts";
import { isResolved } from "../../Diff.ts";
import { createPhysicalName } from "../../PhysicalName.ts";
import * as Provider from "../../Provider.ts";
import { Resource } from "../../Resource.ts";
import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
import type { Providers } from "../Providers.ts";
import {
MediaLiveResourcePending,
ensureIdentified,
ensurePresent,
retryWhileConflict,
retryWhilePending,
syncMlTags,
toTagRecord,
} from "./internal.ts";
/**
* The observed shape shared by `DescribeChannelResponse` and the `Channel`
* struct returned by create/update (and `ChannelSummary` from list).
*/
type ObservedChannel = Pick<
medialive.Channel,
| "Arn"
| "Id"
| "Name"
| "State"
| "ChannelClass"
| "EgressEndpoints"
| "LogLevel"
| "RoleArn"
| "Tags"
>;
type IdentifiedChannel = ObservedChannel & { Id: string; Arn: string };
// Explicitly-typed pipeable retry helper (see internal.ts for why inline
// Effect.retry is forbidden in provider ops). A freshly-created IAM role is
// not immediately visible to MediaLive — createChannel rejects it with a
// message-discriminated UnprocessableEntityException naming the trust
// relationship (patched in distilled to the typed `MediaLiveRoleNotYetTrusted`
// tag) until IAM propagation settles.
const retryWhileRolePropagating = (
self: Effect.Effect,
): Effect.Effect =>
Effect.retry(self, {
while: (e) => e._tag === "MediaLiveRoleNotYetTrusted",
schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
});
/**
* Raised when a MediaLive channel lands in `CREATE_FAILED` or `UPDATE_FAILED`
* instead of settling into `IDLE`.
*/
export class MediaLiveChannelFailed extends Data.TaggedError(
"MediaLiveChannelFailed",
)<{ message: string }> {}
export interface ChannelProps {
/**
* Name of the channel. If omitted, a unique name is generated from the
* app, stage, and logical ID. Names are mutable — changing the name
* updates the channel in place.
*/
name?: string;
/**
* The class of the channel. `STANDARD` runs two redundant encoder
* pipelines; `SINGLE_PIPELINE` runs one. Changing the class replaces the
* channel.
* @default "STANDARD"
*/
channelClass?: medialive.ChannelClass;
/**
* ARN of the IAM role MediaLive assumes to read inputs and write outputs
* (trusts `medialive.amazonaws.com`).
*/
roleArn?: string;
/**
* The inputs attached to this channel.
*/
inputAttachments?: medialive.InputAttachment[];
/**
* The encoder settings — audio/video descriptions, output groups, and
* timecode configuration.
*/
encoderSettings?: medialive.EncoderSettings;
/**
* Destinations referenced by the output groups in `encoderSettings`.
*/
destinations?: medialive.OutputDestination[];
/**
* Specification of the input codec/resolution/bitrate tier (drives
* per-hour pricing).
*/
inputSpecification?: medialive.InputSpecification;
/**
* Specification of CDI inputs for this channel.
*/
cdiInputSpecification?: medialive.CdiInputSpecification;
/**
* The CloudWatch log level for the channel.
* @default "DISABLED"
*/
logLevel?: medialive.LogLevel;
/**
* Maintenance window settings (day + start hour).
*/
maintenance?: medialive.MaintenanceCreateSettings;
/**
* VPC output settings. Changing the VPC settings replaces the channel.
*/
vpc?: medialive.VpcOutputSettings;
/**
* User-defined tags for the channel.
*/
tags?: Record;
}
export interface Channel extends Resource<
"AWS.MediaLive.Channel",
ChannelProps,
{
/** Server-assigned unique id of the channel. */
channelId: string;
/** ARN of the channel. */
channelArn: string;
/** Name of the channel. */
channelName: string | undefined;
/** Current lifecycle state (e.g. `IDLE`, `RUNNING`). */
state: medialive.ChannelState | undefined;
/** Pipeline class (`STANDARD` or `SINGLE_PIPELINE`). */
channelClass: medialive.ChannelClass | undefined;
/** Egress endpoints the channel writes output through. */
egressEndpoints: medialive.ChannelEgressEndpoint[];
},
never,
Providers
> {}
/**
* An AWS Elemental MediaLive channel — the live encoder that reads from
* attached inputs, transcodes per its encoder settings, and writes to output
* destinations (HLS, RTMP, MediaPackage, ...).
*
* Channels bill per running hour; Alchemy provisions channels in the `IDLE`
* state and never starts them — start/stop is a runtime operation.
*
* ### Creating a Channel
* **Example:** Single-pipeline HLS channel
* ```typescript
* const channel = yield* MediaLive.Channel("Live", {
* channelClass: "SINGLE_PIPELINE",
* roleArn: role.roleArn,
* inputAttachments: [
* { InputId: input.inputId, InputAttachmentName: "primary" },
* ],
* inputSpecification: {
* Codec: "AVC",
* Resolution: "SD",
* MaximumBitrate: "MAX_10_MBPS",
* },
* destinations,
* encoderSettings,
* });
* ```
*
* **Example:** IAM role for MediaLive
* ```typescript
* const role = yield* IAM.Role("MediaLiveRole", {
* assumeRolePolicyDocument: {
* Version: "2012-10-17",
* Statement: [
* {
* Effect: "Allow",
* Principal: { Service: "medialive.amazonaws.com" },
* Action: ["sts:AssumeRole"],
* },
* ],
* },
* });
* ```
*
* @resource
*/
export const Channel = Resource("AWS.MediaLive.Channel");
export const ChannelProvider = () =>
Provider.effect(
Channel,
Effect.gen(function* () {
const createName = Effect.fn(function* (
id: string,
props: { name?: string | undefined },
) {
return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
});
const toAttrs = (channel: IdentifiedChannel) => ({
channelId: channel.Id,
channelArn: channel.Arn,
channelName: channel.Name,
state: channel.State,
channelClass: channel.ChannelClass,
egressEndpoints: [...(channel.EgressEndpoints ?? [])],
});
const isGone = (state: medialive.ChannelState | undefined) =>
state === "DELETED" || state === "DELETING";
/** Describe by id; typed not-found (or tombstone state) → undefined. */
const getChannel = Effect.fn(function* (channelId: string) {
const channel = yield* medialive
.describeChannel({ ChannelId: channelId })
.pipe(
Effect.catchTag("NotFoundException", () =>
Effect.succeed(undefined),
),
);
if (channel === undefined || isGone(channel.State)) return undefined;
return yield* ensureIdentified(channel, "DescribeChannel Id/Arn");
});
/**
* Channels have server-assigned ids, so a read without cached output
* searches the account list by the deterministic physical name.
*/
const findByName = Effect.fn(function* (name: string) {
const channels = yield* medialive.listChannels.items({}).pipe(
Stream.runCollect,
Effect.map((chunk) => Array.from(chunk)),
);
const match = channels.find(
(channel) => channel.Name === name && !isGone(channel.State),
);
if (match === undefined) return undefined;
return yield* ensureIdentified(match, "ListChannels item Id/Arn");
});
/**
* Wait (bounded, ~90s) for a channel to settle out of the transient
* `CREATING`/`UPDATING` states; fail typed on `*_FAILED`.
*/
const awaitSettled = Effect.fn(function* (channelId: string) {
return yield* retryWhilePending(
Effect.gen(function* () {
const channel = yield* medialive.describeChannel({
ChannelId: channelId,
});
if (
channel.State === "CREATE_FAILED" ||
channel.State === "UPDATE_FAILED"
) {
return yield* Effect.fail(
new MediaLiveChannelFailed({
message: `channel ${channelId} is in state ${channel.State}`,
}),
);
}
if (channel.State === "CREATING" || channel.State === "UPDATING") {
return yield* Effect.fail(
new MediaLiveResourcePending({
message: `channel ${channelId} is still ${channel.State}`,
}),
);
}
return yield* ensureIdentified(channel, "DescribeChannel Id/Arn");
}),
);
});
return Channel.Provider.of({
stables: ["channelId", "channelArn"],
list: () =>
medialive.listChannels.items({}).pipe(
Stream.runCollect,
Effect.map((chunk) =>
Array.from(chunk)
.filter(
(
channel,
): channel is medialive.ChannelSummary & IdentifiedChannel =>
channel.Id !== undefined &&
channel.Arn !== undefined &&
!isGone(channel.State),
)
.map(toAttrs),
),
),
read: Effect.fn(function* ({ id, olds, output }) {
const channel =
output?.channelId !== undefined
? yield* getChannel(output.channelId)
: yield* findByName(yield* createName(id, olds ?? {}));
if (channel === undefined) return undefined;
const attrs = toAttrs(channel);
return (yield* hasAlchemyTags(id, toTagRecord(channel.Tags)))
? attrs
: Unowned(attrs);
}),
diff: Effect.fn(function* ({ news, olds }) {
if (!isResolved(news)) return undefined;
// The channel class and VPC placement are fixed at creation.
if (
(olds.channelClass ?? "STANDARD") !==
(news.channelClass ?? "STANDARD")
) {
return { action: "replace" } as const;
}
if (JSON.stringify(olds.vpc) !== JSON.stringify(news.vpc)) {
return { action: "replace" } as const;
}
}),
reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
const internalTags = yield* createInternalTags(id);
const desiredTags = { ...internalTags, ...news.tags };
const name = yield* createName(id, news);
// 1. Observe — cloud state is authoritative; output is an id cache.
// MediaLive channel names are not unique, so a name match is only
// trusted when its immutable class agrees with the desired state —
// during a replacement the same-named survivor is the doomed old
// instance, not this one.
let channel: IdentifiedChannel | undefined;
if (output?.channelId !== undefined) {
channel = yield* getChannel(output.channelId);
} else {
const found = yield* findByName(name);
channel =
found !== undefined &&
(found.ChannelClass ?? "STANDARD") ===
(news.channelClass ?? "STANDARD")
? found
: undefined;
}
// 2. Ensure — create if missing, then wait for CREATING to settle.
if (channel === undefined) {
const created = yield* medialive
.createChannel({
Name: name,
ChannelClass: news.channelClass,
RoleArn: news.roleArn,
InputAttachments: news.inputAttachments,
EncoderSettings: news.encoderSettings,
Destinations: news.destinations,
InputSpecification: news.inputSpecification,
CdiInputSpecification: news.cdiInputSpecification,
LogLevel: news.logLevel,
Maintenance: news.maintenance,
Vpc: news.vpc,
Tags: desiredTags,
})
.pipe(retryWhileRolePropagating);
const fresh = yield* ensureIdentified(
created.Channel,
"CreateChannel Channel Id/Arn",
);
channel = yield* awaitSettled(fresh.Id);
} else {
// 3. Sync — cheap scalars are diffed against OBSERVED state; the
// deep encoder/attachment documents are diffed against `olds` as
// a no-op hint (Describe echoes them back default-expanded, so an
// observed-vs-desired deep compare would always report drift).
const nameDrift = channel.Name !== name;
const logDrift =
news.logLevel !== undefined && channel.LogLevel !== news.logLevel;
const roleDrift =
news.roleArn !== undefined && channel.RoleArn !== news.roleArn;
const deepDrift =
olds === undefined
? true
: JSON.stringify({
a: olds.inputAttachments,
e: olds.encoderSettings,
d: olds.destinations,
i: olds.inputSpecification,
c: olds.cdiInputSpecification,
}) !==
JSON.stringify({
a: news.inputAttachments,
e: news.encoderSettings,
d: news.destinations,
i: news.inputSpecification,
c: news.cdiInputSpecification,
});
if (nameDrift || logDrift || roleDrift || deepDrift) {
yield* medialive
.updateChannel({
ChannelId: channel.Id,
Name: name,
RoleArn: news.roleArn,
InputAttachments: news.inputAttachments,
EncoderSettings: news.encoderSettings,
Destinations: news.destinations,
InputSpecification: news.inputSpecification,
CdiInputSpecification: news.cdiInputSpecification,
LogLevel: news.logLevel,
Maintenance: news.maintenance
? {
MaintenanceDay: news.maintenance.MaintenanceDay,
MaintenanceStartTime:
news.maintenance.MaintenanceStartTime,
}
: undefined,
})
.pipe(retryWhileConflict);
channel = yield* awaitSettled(channel.Id);
}
}
// Both the ensure and sync branches leave `channel` assigned from a
// typed describe; guard with a typed failure so TS sees it defined.
const settled = yield* ensurePresent(
channel,
"reconciled channel state",
);
// 3b. Sync tags — diff against OBSERVED cloud tags.
yield* syncMlTags(settled.Arn, desiredTags);
yield* session.note(settled.Id);
return toAttrs(settled);
}),
delete: Effect.fn(function* ({ output }) {
yield* medialive.deleteChannel({ ChannelId: output.channelId }).pipe(
retryWhileConflict,
Effect.catchTag("NotFoundException", () => Effect.void),
Effect.asVoid,
);
// Deletion is async (DELETING → DELETED). Wait (bounded) so that
// dependent Inputs/InputSecurityGroups can delete without long
// conflict-retry loops; if the channel is still draining after the
// window, deletion is initiated and irreversible — let downstream
// retries absorb the tail.
yield* medialive
.describeChannel({ ChannelId: output.channelId })
.pipe(
Effect.flatMap((channel) =>
channel.State === "DELETED"
? Effect.void
: Effect.fail(
new MediaLiveResourcePending({
message: `channel ${output.channelId} is still ${channel.State}`,
}),
),
),
retryWhilePending,
Effect.catchTag("NotFoundException", () => Effect.void),
Effect.catchTag("MediaLiveResourcePending", () => Effect.void),
);
}),
});
}),
);