1import * as mediatailor from "@distilled.cloud/aws/mediatailor";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import {
12 createInternalTags,
13 diffTags,
14 hasAlchemyTags,
15 tagRecord,
16} from "../../Tags.ts";
17import { toWireSeconds } from "../../Util/Duration.ts";
18import type { Providers } from "../Providers.ts";
19
20/**
21 * CDN routing configuration for ad and content segment delivery.
22 */
23export interface PlaybackConfigurationCdn {
24 /**
25 * A non-default content delivery network (CDN) prefix that MediaTailor
26 * routes ad segment requests through, e.g. a CloudFront distribution in
27 * front of `ads.mediatailor.<region>.amazonaws.com`.
28 */
29 adSegmentUrlPrefix?: string;
30 /**
31 * A content delivery network (CDN) prefix that content segment URLs in
32 * manifests are rewritten to, so players request content through the CDN.
33 */
34 contentSegmentUrlPrefix?: string;
35}
36
37/**
38 * DASH manifest handling configuration.
39 */
40export interface PlaybackConfigurationDash {
41 /**
42 * Where the `Location` element is placed in the DASH manifest.
43 * @default "EMT_DEFAULT"
44 */
45 mpdLocation?: string;
46 /**
47 * Whether the origin server produces single-period or multi-period DASH
48 * manifests. One of `SINGLE_PERIOD` or `MULTI_PERIOD`.
49 * @default "MULTI_PERIOD"
50 */
51 originManifestType?: "SINGLE_PERIOD" | "MULTI_PERIOD";
52}
53
54/**
55 * Controls how MediaTailor suppresses ad insertion near the live edge.
56 */
57export interface PlaybackConfigurationAvailSuppression {
58 /**
59 * `BEHIND_LIVE_EDGE` suppresses ads behind the configured point in the
60 * stream; `AFTER_LIVE_EDGE` suppresses ads after it.
61 * @default "OFF"
62 */
63 mode?: "OFF" | "BEHIND_LIVE_EDGE" | "AFTER_LIVE_EDGE";
64 /**
65 * A live-edge offset timestamp (HH:MM:SS) that defines the suppression
66 * boundary.
67 */
68 value?: string;
69 /**
70 * `PARTIAL_AVAIL` fills partial ad breaks; `FULL_AVAIL_ONLY` only fills
71 * complete ad breaks.
72 */
73 fillPolicy?: "FULL_AVAIL_ONLY" | "PARTIAL_AVAIL";
74}
75
76/**
77 * Bumpers are short branded videos played before and after ad breaks.
78 */
79export interface PlaybackConfigurationBumper {
80 /** URL of the bumper asset played before each ad break. */
81 startUrl?: string;
82 /** URL of the bumper asset played after each ad break. */
83 endUrl?: string;
84}
85
86/**
87 * Configuration for pre-roll ad insertion on live streams.
88 */
89export interface PlaybackConfigurationLivePreRoll {
90 /** The ad decision server URL used for live pre-roll ads. */
91 adDecisionServerUrl?: string;
92 /**
93 * Maximum allowed duration for the pre-roll ad avail, e.g. `"30 seconds"`.
94 * Sent to the API in whole seconds (`MaxDurationSeconds`).
95 */
96 maxDuration?: Duration.Input;
97}
98
99/**
100 * CloudWatch log configuration for the playback configuration
101 * (`ConfigureLogsForPlaybackConfiguration`).
102 */
103export interface PlaybackConfigurationLogs {
104 /**
105 * The percentage of session logs MediaTailor sends to CloudWatch Logs
106 * (0–100). Session logs land in the `MediaTailor/PlaybackConfiguration`
107 * log group (legacy) or the vended-log delivery you configure.
108 */
109 percentEnabled: number;
110 /**
111 * The method MediaTailor uses to deliver the logs:
112 * `LEGACY_CLOUDWATCH` (direct to CloudWatch Logs) and/or `VENDED_LOGS`
113 * (CloudWatch vended log delivery to CloudWatch/S3/Firehose).
114 */
115 enabledLoggingStrategies?: ("LEGACY_CLOUDWATCH" | "VENDED_LOGS")[];
116}
117
118/**
119 * Rules that customize how MediaTailor processes the origin manifest.
120 */
121export interface PlaybackConfigurationManifestProcessingRules {
122 /**
123 * When enabled, `EXT-X-CUE-IN`/`EXT-X-CUE-OUT` (and other ad markers) from
124 * the origin manifest pass through into the personalized manifest.
125 * @default false
126 */
127 adMarkerPassthroughEnabled?: boolean;
128}
129
130export interface PlaybackConfigurationProps {
131 /**
132 * The identifier for the playback configuration. Maximum 64 characters,
133 * letters, digits, hyphens, and underscores.
134 *
135 * Changing the name replaces the configuration.
136 * @default ${app}-${stage}-${id}
137 */
138 name?: string;
139 /**
140 * The URL for the ad decision server (ADS). This includes the
141 * specification of static parameters and placeholders for dynamic
142 * parameters, e.g. `https://ads.example.com/vast?ip=[client_ip]`.
143 * Maximum 25,000 characters.
144 */
145 adDecisionServerUrl: string;
146 /**
147 * The URL prefix for the source of the content stream (the origin server),
148 * minus the asset ID. Maximum 512 characters.
149 */
150 videoContentSourceUrl: string;
151 /**
152 * The URL for a video asset to transcode and use to fill in time that's
153 * not used by ads. MediaTailor shows the slate when there is no ad to
154 * fill an avail.
155 */
156 slateAdUrl?: string;
157 /**
158 * Defines the maximum duration of underfilled ad time (e.g. `"2 seconds"`)
159 * allowed in an ad break. Underfilled time beyond the threshold is not
160 * filled with slate. Sent to the API in whole seconds
161 * (`PersonalizationThresholdSeconds`).
162 */
163 personalizationThreshold?: Duration.Input;
164 /**
165 * The name that is used to associate this playback configuration with a
166 * custom transcode profile (set up with AWS Support).
167 */
168 transcodeProfileName?: string;
169 /**
170 * The setting that controls whether players can use stitched or guided ad
171 * insertion. One of `STITCHED_ONLY` or `PLAYER_SELECT`.
172 * @default "STITCHED_ONLY"
173 */
174 insertionMode?: "STITCHED_ONLY" | "PLAYER_SELECT";
175 /**
176 * CDN prefixes for routing ad and content segment requests.
177 */
178 cdnConfiguration?: PlaybackConfigurationCdn;
179 /**
180 * DASH manifest configuration.
181 */
182 dashConfiguration?: PlaybackConfigurationDash;
183 /**
184 * Ad suppression behavior near the live edge.
185 */
186 availSuppression?: PlaybackConfigurationAvailSuppression;
187 /**
188 * Bumper videos played before and after ad breaks.
189 */
190 bumper?: PlaybackConfigurationBumper;
191 /**
192 * Pre-roll ad insertion for live streams.
193 */
194 livePreRollConfiguration?: PlaybackConfigurationLivePreRoll;
195 /**
196 * Origin-manifest processing rules (e.g. ad marker passthrough).
197 */
198 manifestProcessingRules?: PlaybackConfigurationManifestProcessingRules;
199 /**
200 * CloudWatch session-log configuration. Omit (or set `percentEnabled: 0`)
201 * to disable session logging.
202 */
203 logConfiguration?: PlaybackConfigurationLogs;
204 /**
205 * Tags to apply to the playback configuration. Merged with internal
206 * Alchemy tags.
207 */
208 tags?: Record<string, string>;
209}
210
211export interface PlaybackConfiguration extends Resource<
212 "AWS.MediaTailor.PlaybackConfiguration",
213 PlaybackConfigurationProps,
214 {
215 /** The identifier for the playback configuration. */
216 name: string;
217 /** The ARN of the playback configuration. */
218 playbackConfigurationArn: string;
219 /** The URL that the player accesses to get a manifest from MediaTailor. */
220 playbackEndpointPrefix: string;
221 /** The URL that the player uses to initialize a session that uses client-side reporting. */
222 sessionInitializationEndpointPrefix: string;
223 /** The HLS manifest endpoint prefix, if HLS is configured. */
224 hlsManifestEndpointPrefix: string | undefined;
225 /** The DASH manifest endpoint prefix, if DASH is configured. */
226 dashManifestEndpointPrefix: string | undefined;
227 },
228 never,
229 Providers
230> {}
231
232/**
233 * An AWS Elemental MediaTailor playback configuration for server-side ad
234 * insertion (SSAI) into HLS and DASH video streams.
235 *
236 * ### Creating Playback Configurations
237 * **Example:** Basic ad-inserted stream
238 * ```typescript
239 * import * as MediaTailor from "alchemy/AWS/MediaTailor";
240 *
241 * const config = yield* MediaTailor.PlaybackConfiguration("Ads", {
242 * adDecisionServerUrl: "https://ads.example.com/vast?ip=[client_ip]",
243 * videoContentSourceUrl: "https://origin.example.com/live",
244 * });
245 * ```
246 *
247 * **Example:** Slate fill and personalization threshold
248 * ```typescript
249 * const config = yield* MediaTailor.PlaybackConfiguration("Ads", {
250 * adDecisionServerUrl: "https://ads.example.com/vast",
251 * videoContentSourceUrl: "https://origin.example.com/vod",
252 * slateAdUrl: "https://origin.example.com/slate.mp4",
253 * personalizationThreshold: "2 seconds",
254 * });
255 * ```
256 *
257 * ### Manifest Behavior
258 * **Example:** Ad marker passthrough and avail suppression
259 * ```typescript
260 * const config = yield* MediaTailor.PlaybackConfiguration("Live", {
261 * adDecisionServerUrl: "https://ads.example.com/vast",
262 * videoContentSourceUrl: "https://origin.example.com/live",
263 * manifestProcessingRules: { adMarkerPassthroughEnabled: true },
264 * availSuppression: { mode: "BEHIND_LIVE_EDGE", value: "00:00:30" },
265 * });
266 * ```
267 *
268 * ### Session Logging
269 * **Example:** Send 10% of session logs to CloudWatch
270 * ```typescript
271 * const config = yield* MediaTailor.PlaybackConfiguration("Logged", {
272 * adDecisionServerUrl: "https://ads.example.com/vast",
273 * videoContentSourceUrl: "https://origin.example.com/live",
274 * logConfiguration: { percentEnabled: 10 },
275 * });
276 * ```
277 *
278 * @resource
279 */
280export const PlaybackConfiguration = Resource<PlaybackConfiguration>(
281 "AWS.MediaTailor.PlaybackConfiguration",
282);
283
284/**
285 * Raised when MediaTailor returns a playback configuration without the
286 * attributes the provider needs (ARN and playback endpoints). This indicates
287 * an unexpected API response rather than a user error.
288 */
289export class MediaTailorIncompletePlaybackConfiguration extends Data.TaggedError(
290 "MediaTailorIncompletePlaybackConfiguration",
291)<{ message: string }> {}
292
293type PlaybackConfigurationAttributes = {
294 name: string;
295 playbackConfigurationArn: string;
296 playbackEndpointPrefix: string;
297 sessionInitializationEndpointPrefix: string;
298 hlsManifestEndpointPrefix: string | undefined;
299 dashManifestEndpointPrefix: string | undefined;
300};
301
302export const PlaybackConfigurationProvider = () =>
303 Provider.effect(
304 PlaybackConfiguration,
305 Effect.gen(function* () {
306 const createName = Effect.fn(function* (
307 id: string,
308 props: Pick<PlaybackConfigurationProps, "name">,
309 ) {
310 // MediaTailor playback configuration names max out at 64 characters.
311 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
312 });
313
314 const toAttributes = Effect.fn(function* (
315 config: mediatailor.GetPlaybackConfigurationResponse,
316 ) {
317 if (
318 config.Name === undefined ||
319 config.PlaybackConfigurationArn === undefined ||
320 config.PlaybackEndpointPrefix === undefined ||
321 config.SessionInitializationEndpointPrefix === undefined
322 ) {
323 return yield* new MediaTailorIncompletePlaybackConfiguration({
324 message: `playback configuration '${config.Name}' is missing its ARN or endpoint prefixes`,
325 });
326 }
327 return {
328 name: config.Name,
329 playbackConfigurationArn: config.PlaybackConfigurationArn,
330 playbackEndpointPrefix: config.PlaybackEndpointPrefix,
331 sessionInitializationEndpointPrefix:
332 config.SessionInitializationEndpointPrefix,
333 hlsManifestEndpointPrefix:
334 config.HlsConfiguration?.ManifestEndpointPrefix,
335 dashManifestEndpointPrefix:
336 config.DashConfiguration?.ManifestEndpointPrefix,
337 } satisfies PlaybackConfigurationAttributes;
338 });
339
340 const observe = (name: string) =>
341 mediatailor.getPlaybackConfiguration({ Name: name }).pipe(
342 Effect.map(
343 (
344 config,
345 ): mediatailor.GetPlaybackConfigurationResponse | undefined =>
346 config,
347 ),
348 // Typed synthetic tag for a missing playback configuration.
349 Effect.catchTag("PlaybackConfigurationNotFound", () =>
350 Effect.succeed(undefined),
351 ),
352 );
353
354 const desiredRequest = (
355 name: string,
356 props: PlaybackConfigurationProps,
357 ): mediatailor.PutPlaybackConfigurationRequest => ({
358 Name: name,
359 AdDecisionServerUrl: props.adDecisionServerUrl,
360 VideoContentSourceUrl: props.videoContentSourceUrl,
361 SlateAdUrl: props.slateAdUrl,
362 PersonalizationThresholdSeconds: toWireSeconds(
363 props.personalizationThreshold,
364 ),
365 TranscodeProfileName: props.transcodeProfileName,
366 InsertionMode: props.insertionMode,
367 CdnConfiguration: props.cdnConfiguration && {
368 AdSegmentUrlPrefix: props.cdnConfiguration.adSegmentUrlPrefix,
369 ContentSegmentUrlPrefix:
370 props.cdnConfiguration.contentSegmentUrlPrefix,
371 },
372 DashConfiguration: props.dashConfiguration && {
373 MpdLocation: props.dashConfiguration.mpdLocation,
374 OriginManifestType: props.dashConfiguration.originManifestType,
375 },
376 AvailSuppression: props.availSuppression && {
377 Mode: props.availSuppression.mode,
378 Value: props.availSuppression.value,
379 FillPolicy: props.availSuppression.fillPolicy,
380 },
381 Bumper: props.bumper && {
382 StartUrl: props.bumper.startUrl,
383 EndUrl: props.bumper.endUrl,
384 },
385 LivePreRollConfiguration: props.livePreRollConfiguration && {
386 AdDecisionServerUrl:
387 props.livePreRollConfiguration.adDecisionServerUrl,
388 MaxDurationSeconds: toWireSeconds(
389 props.livePreRollConfiguration.maxDuration,
390 ),
391 },
392 ManifestProcessingRules: props.manifestProcessingRules && {
393 AdMarkerPassthrough: {
394 Enabled: props.manifestProcessingRules.adMarkerPassthroughEnabled,
395 },
396 },
397 });
398
399 return PlaybackConfiguration.Provider.of({
400 stables: ["name", "playbackConfigurationArn"],
401 list: () =>
402 Effect.gen(function* () {
403 const items = yield* mediatailor.listPlaybackConfigurations
404 .items({})
405 .pipe(Stream.runCollect);
406 return Array.from(items).flatMap((config) =>
407 config.Name !== undefined &&
408 config.PlaybackConfigurationArn !== undefined &&
409 config.PlaybackEndpointPrefix !== undefined &&
410 config.SessionInitializationEndpointPrefix !== undefined
411 ? [
412 {
413 name: config.Name,
414 playbackConfigurationArn: config.PlaybackConfigurationArn,
415 playbackEndpointPrefix: config.PlaybackEndpointPrefix,
416 sessionInitializationEndpointPrefix:
417 config.SessionInitializationEndpointPrefix,
418 hlsManifestEndpointPrefix:
419 config.HlsConfiguration?.ManifestEndpointPrefix,
420 dashManifestEndpointPrefix:
421 config.DashConfiguration?.ManifestEndpointPrefix,
422 },
423 ]
424 : [],
425 );
426 }),
427 read: Effect.fn(function* ({ id, olds, output }) {
428 const name = output?.name ?? (yield* createName(id, olds ?? {}));
429 const found = yield* observe(name);
430 if (found === undefined) return undefined;
431 const attrs = yield* toAttributes(found);
432 return (yield* hasAlchemyTags(id, tagRecord(found.Tags)))
433 ? attrs
434 : Unowned(attrs);
435 }),
436 diff: Effect.fn(function* ({ id, news, olds }) {
437 if (!isResolved(news)) return undefined;
438 const oldName = yield* createName(id, olds ?? {});
439 const newName = yield* createName(id, news ?? {});
440 if (oldName !== newName) {
441 return { action: "replace" } as const;
442 }
443 // fall through: engine default update logic for mutable fields
444 }),
445 reconcile: Effect.fn(function* ({ id, news, output, session }) {
446 const name = output?.name ?? (yield* createName(id, news));
447 const internalTags = yield* createInternalTags(id);
448 const desiredTags = { ...news.tags, ...internalTags };
449
450 // 1. ENSURE + SYNC — PutPlaybackConfiguration is a full-replace
451 // upsert: omitted fields are cleared server-side. Because a
452 // prop the user removed must be un-set on the cloud config,
453 // the put is always applied (a subset comparison against the
454 // observed config cannot see removals, and server-side
455 // defaults make full-equality checks fragile). This one call
456 // converges greenfield, update, and adoption identically.
457 const put = yield* mediatailor.putPlaybackConfiguration({
458 ...desiredRequest(name, news),
459 Tags: desiredTags,
460 });
461 const attrs = yield* toAttributes(put);
462
463 // 2. SYNC LOGS — ConfigureLogsForPlaybackConfiguration is a
464 // separate API; diff the OBSERVED post-put log configuration
465 // against the desired one and only call the API on a delta.
466 // A removed `logConfiguration` prop converges to percent 0
467 // (session logging disabled).
468 const desiredLogs = news.logConfiguration;
469 const observedLogs = put.LogConfiguration;
470 const observedPercent = observedLogs?.PercentEnabled ?? 0;
471 const desiredPercent = desiredLogs?.percentEnabled ?? 0;
472 const observedStrategies = [
473 ...(observedLogs?.EnabledLoggingStrategies ?? []),
474 ].sort();
475 const desiredStrategies =
476 desiredLogs === undefined
477 ? observedStrategies // nothing desired: only percent converges
478 : [...(desiredLogs.enabledLoggingStrategies ?? [])].sort();
479 if (
480 observedPercent !== desiredPercent ||
481 observedStrategies.join(",") !== desiredStrategies.join(",")
482 ) {
483 yield* mediatailor.configureLogsForPlaybackConfiguration({
484 PlaybackConfigurationName: name,
485 PercentEnabled: desiredPercent,
486 ...(desiredLogs?.enabledLoggingStrategies
487 ? {
488 EnabledLoggingStrategies:
489 desiredLogs.enabledLoggingStrategies,
490 }
491 : {}),
492 });
493 }
494
495 // 3. SYNC TAGS — diff against the OBSERVED post-put tags so
496 // adoption (which can bring foreign tags the put may not
497 // remove) converges.
498 const currentTags = tagRecord(put.Tags);
499 const { upsert, removed } = diffTags(currentTags, desiredTags);
500 if (upsert.length > 0) {
501 yield* mediatailor.tagResource({
502 ResourceArn: attrs.playbackConfigurationArn,
503 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
504 });
505 }
506 if (removed.length > 0) {
507 yield* mediatailor.untagResource({
508 ResourceArn: attrs.playbackConfigurationArn,
509 TagKeys: removed,
510 });
511 }
512
513 yield* session.note(name);
514 return attrs;
515 }),
516 delete: Effect.fn(function* ({ output }) {
517 // DeletePlaybackConfiguration is idempotent server-side: deleting
518 // a configuration that does not exist succeeds with an empty body
519 // (verified live), so no not-found handling is required.
520 yield* mediatailor.deletePlaybackConfiguration({
521 Name: output.name,
522 });
523 }),
524 });
525 }),
526 );