1import * as transcoder from "@distilled.cloud/gcp/transcoder_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 configKey,
12 desiredLabelsOf,
13 getJobTemplate,
14 JobTemplateNotResolved,
15 JobTemplateStillExists,
16 labelsKey,
17 listOwnedJobTemplates,
18 locationParent,
19 normalizeLocation,
20 ownedByAlchemy,
21 parseName,
22 resourceName,
23 toJobTemplateId,
24 userLabels,
25 waitUntilGone,
26} from "./internal.ts";
27
28export type JobConfig = transcoder.JobConfig;
29export type ElementaryStream = transcoder.ElementaryStream;
30export type MuxStream = transcoder.MuxStream;
31export type AudioStream = transcoder.AudioStream;
32export type VideoStream = transcoder.VideoStream;
33
34export type JobTemplateProps = {
35 /**
36 * Job template id (the `{job_template}` segment of
37 * `projects/{project}/locations/{location}/jobTemplates/{job_template}`).
38 * If omitted, a unique name is generated from the stack, stage, and
39 * logical id. 4-63 characters matching `[a-zA-Z][a-zA-Z0-9_-]*`.
40 * Immutable — changing it replaces the template.
41 */
42 jobTemplateId?: string;
43 /**
44 * Transcoder location (`us-central1`, `us-east1`, …). Immutable —
45 * changing it replaces the template. `US-CENTRAL1` is accepted and
46 * normalized to `us-central1`.
47 * @default the stack's GCP region (`GCP.Region`, else the profile
48 * region, else `us-central1`)
49 */
50 location?: string;
51 /**
52 * Transcoding configuration (elementary streams, mux streams, inputs,
53 * outputs, manifests, …). The Transcoder API has no update method, so
54 * changing `config` replaces the template.
55 */
56 config: JobConfig;
57 /**
58 * User labels. Alchemy ownership labels are merged in automatically.
59 * The API has no update method, so changing labels replaces the
60 * template.
61 */
62 labels?: Record<string, string>;
63};
64
65export type JobTemplate = Resource<
66 "GCP.Transcoder.JobTemplate",
67 JobTemplateProps,
68 {
69 /** Full resource name `projects/{project}/locations/{location}/jobTemplates/{job_template}`. */
70 name: string;
71 /** Job template id (last path segment). */
72 jobTemplateId: string;
73 /** Location id (`us-central1`, …). */
74 location: string;
75 /** Project id. */
76 project: string;
77 /** Parent `projects/{project}/locations/{location}`. */
78 parent: string;
79 /** Transcoding configuration stored on the template. */
80 config: JobConfig | undefined;
81 /** User labels (Alchemy ownership labels stripped). */
82 labels: Record<string, string>;
83 },
84 never,
85 Providers
86>;
87
88/**
89 * A Transcoder API job template. Templates capture a reusable
90 * `JobConfig` so jobs can be created with `templateId` instead of
91 * repeating elementary/mux stream settings.
92 *
93 * The API exposes create, get, list, and delete only — there is no
94 * patch. Changing `jobTemplateId`, `location`, `config`, or `labels`
95 * replaces the template. Ownership is stamped with Alchemy labels so
96 * `list` / nuke can find it.
97 *
98 * ### Creating a Job Template
99 * **Example:** Generated id
100 * ```typescript
101 * const template = yield* GCP.Transcoder.JobTemplate("WebHd", {
102 * config: {
103 * elementaryStreams: [
104 * {
105 * key: "video-stream0",
106 * videoStream: {
107 * h264: {
108 * heightPixels: 360,
109 * widthPixels: 640,
110 * bitrateBps: 550000,
111 * frameRate: 30,
112 * },
113 * },
114 * },
115 * {
116 * key: "audio-stream0",
117 * audioStream: { codec: "aac", bitrateBps: 64000 },
118 * },
119 * ],
120 * muxStreams: [
121 * {
122 * key: "sd",
123 * container: "mp4",
124 * elementaryStreams: ["video-stream0", "audio-stream0"],
125 * },
126 * ],
127 * },
128 * });
129 * ```
130 *
131 * **Example:** Explicit id and labels
132 * ```typescript
133 * const template = yield* GCP.Transcoder.JobTemplate("WebHd", {
134 * jobTemplateId: "web-sd",
135 * location: "us-central1",
136 * labels: { env: "prod" },
137 * config: {
138 * elementaryStreams: [
139 * {
140 * key: "video-stream0",
141 * videoStream: {
142 * h264: { bitrateBps: 550000, frameRate: 30 },
143 * },
144 * },
145 * ],
146 * muxStreams: [
147 * {
148 * key: "sd",
149 * container: "mp4",
150 * elementaryStreams: ["video-stream0"],
151 * },
152 * ],
153 * },
154 * });
155 * ```
156 *
157 * ### Creating a Job from a Template
158 * **Example:** Start a transcode
159 * ```typescript
160 * const createJob = yield* GCP.Transcoder.CreateJob(template);
161 * const job = yield* createJob({
162 * body: {
163 * inputUri: "gs://bucket/inputs/file.mp4",
164 * outputUri: "gs://bucket/outputs/",
165 * },
166 * });
167 * ```
168 *
169 * @resource
170 * @category Transcoder
171 */
172export const JobTemplate = Resource<JobTemplate>("GCP.Transcoder.JobTemplate");
173
174export { JobTemplateNotResolved, JobTemplateStillExists };
175
176const toAttrs = (
177 template: transcoder.JobTemplate,
178 project: string,
179 region: string,
180) => {
181 const name = template.name ?? "";
182 const parsed = parseName(name, region);
183 return {
184 name,
185 jobTemplateId: parsed.jobTemplateId,
186 location: parsed.location,
187 project: parsed.project || project,
188 parent: parsed.parent || locationParent(project, parsed.location),
189 config: template.config,
190 labels: userLabels(template.labels),
191 };
192};
193
194export const JobTemplateProvider = () =>
195 Provider.succeed(JobTemplate, {
196 stables: ["name", "jobTemplateId", "location", "project", "parent"],
197
198 diff: Effect.fn(function* ({ news, olds, output }) {
199 if (!isResolved(news)) return undefined;
200 const env = yield* GcpEnvironment.current;
201 const previousId = olds?.jobTemplateId ?? output?.jobTemplateId;
202 const nextId = news.jobTemplateId ?? previousId;
203 const idChanged =
204 previousId !== undefined &&
205 nextId !== undefined &&
206 previousId !== nextId;
207 const previousLocation = normalizeLocation(
208 olds?.location ?? output?.location,
209 env.region,
210 );
211 const nextLocation = normalizeLocation(
212 news.location ?? olds?.location ?? output?.location,
213 env.region,
214 );
215 const locationChanged = previousLocation !== nextLocation;
216 const configChanged =
217 olds !== undefined && configKey(news.config) !== configKey(olds.config);
218 const labelsChanged =
219 olds !== undefined && labelsKey(news.labels) !== labelsKey(olds.labels);
220 if (!idChanged && !locationChanged && !configChanged && !labelsChanged) {
221 return undefined;
222 }
223 return {
224 action: "replace" as const,
225 deleteFirst: !idChanged && !locationChanged,
226 };
227 }),
228
229 read: Effect.fn(function* ({ id, olds, output }) {
230 const env = yield* GcpEnvironment.current;
231 const jobTemplateId = yield* toJobTemplateId(
232 id,
233 olds?.jobTemplateId,
234 output?.jobTemplateId,
235 );
236 const location = normalizeLocation(
237 olds?.location ?? output?.location,
238 env.region,
239 );
240 const name =
241 output?.name ?? resourceName(env.project, location, jobTemplateId);
242 const existing = yield* getJobTemplate(name);
243 if (existing === undefined) return undefined;
244 const attrs = toAttrs(existing, env.project, env.region);
245 return (yield* ownedByAlchemy(id, existing.labels))
246 ? attrs
247 : Unowned(attrs);
248 }),
249
250 list: () =>
251 Effect.gen(function* () {
252 const env = yield* GcpEnvironment.current;
253 const rows = yield* listOwnedJobTemplates(env.project, env.region);
254 return rows.map((row) => toAttrs(row, env.project, env.region));
255 }),
256
257 reconcile: Effect.fn(function* ({ id, news, output }) {
258 const env = yield* GcpEnvironment.current;
259 const jobTemplateId = yield* toJobTemplateId(
260 id,
261 news.jobTemplateId,
262 output?.jobTemplateId,
263 );
264 const location = normalizeLocation(
265 news.location ?? output?.location,
266 env.region,
267 );
268 const parent = locationParent(env.project, location);
269 const name = resourceName(env.project, location, jobTemplateId);
270 const desiredLabels = yield* desiredLabelsOf(id, news.labels);
271
272 let current = yield* getJobTemplate(name);
273
274 if (current === undefined) {
275 const created = yield* transcoder
276 .createProjectsLocationsJobTemplates({
277 parent,
278 jobTemplateId,
279 body: {
280 name,
281 config: news.config,
282 labels: desiredLabels,
283 },
284 })
285 .pipe(Effect.catchTag("Conflict", () => getJobTemplate(name)));
286 current = created ?? undefined;
287 }
288
289 if (current === undefined) {
290 return yield* new JobTemplateNotResolved({
291 name: output?.name ?? name,
292 });
293 }
294
295 return toAttrs(current, env.project, env.region);
296 }),
297
298 delete: Effect.fn(function* ({ output }) {
299 yield* transcoder
300 .deleteProjectsLocationsJobTemplates({
301 name: output.name,
302 allowMissing: true,
303 })
304 .pipe(
305 Effect.catchTag("NotFound", () => Effect.void),
306 Effect.retry({
307 while: (error) => error._tag === "Conflict",
308 times: 8,
309 schedule: Schedule.spaced("1 second"),
310 }),
311 );
312 yield* waitUntilGone(output.name);
313 }),
314 });