-
-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathroute.ts
More file actions
402 lines (355 loc) · 14.1 KB
/
route.ts
File metadata and controls
402 lines (355 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
import { NextResponse, after } from "next/server";
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
import { writeClient } from "@/lib/sanity-write-client";
import { generateWithGemini } from "@/lib/gemini";
import { uploadVideo, uploadShort, generateShortsMetadata } from "@/lib/youtube-upload";
import { notifySubscribers } from "@/lib/resend-notify";
import { postVideoAnnouncement } from "@/lib/x-social";
import { getConfig } from "@/lib/config";
const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface WebhookPayload {
_id: string;
_type: string;
status?: string;
}
interface AutomatedVideoDoc {
_id: string;
_type: "automatedVideo";
title: string;
script: {
hook: string;
scenes: Array<{
sceneNumber: number;
narration: string;
visualDescription: string;
bRollKeywords: string[];
durationEstimate: number;
}>;
cta: string;
};
status: "draft" | "script_ready" | "audio_gen" | "video_gen" | "flagged" | "uploading" | "published";
videoUrl?: string;
shortUrl?: string;
audioUrl?: string;
youtubeId?: string;
youtubeShortId?: string;
flaggedReason?: string;
scheduledPublishAt?: string;
distributionLog?: DistributionLogEntry[];
}
interface DistributionLogEntry {
_key: string;
step: string;
status: "success" | "failed" | "skipped";
error?: string;
timestamp: string;
result?: string;
}
interface YouTubeMetadata { title: string; description: string; tags: string[]; }
// ---------------------------------------------------------------------------
// Distribution log helpers
// ---------------------------------------------------------------------------
function logEntry(step: string, status: "success" | "failed" | "skipped", opts?: { error?: string; result?: string }): DistributionLogEntry {
return {
_key: `${step}-${Date.now()}`,
step,
status,
error: opts?.error,
timestamp: new Date().toISOString(),
result: opts?.result,
};
}
// ---------------------------------------------------------------------------
// Gemini metadata generation for long-form videos
// ---------------------------------------------------------------------------
async function generateYouTubeMetadata(doc: AutomatedVideoDoc): Promise<YouTubeMetadata> {
const scriptText = doc.script
? [doc.script.hook, ...(doc.script.scenes?.map((s) => s.narration) ?? []), doc.script.cta].filter(Boolean).join("\n\n")
: "";
const prompt = `You are a YouTube SEO expert for CodingCat.dev, a developer education channel.
Video Title: ${doc.title}
Script: ${scriptText}
Generate optimized YouTube metadata for a LONG-FORM video (not Shorts).
Return JSON:
{
"title": "SEO-optimized title, max 100 chars, engaging but not clickbait",
"description": "500-1000 chars with key points, timestamps placeholder, channel links, and hashtags",
"tags": ["10-15 relevant tags for discoverability"]
}
Include in the description:
- Brief summary of what viewers will learn
- Key topics covered
- Links section placeholder
- Relevant hashtags at the end`;
const raw = await generateWithGemini(prompt);
try {
const parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, "").trim()) as YouTubeMetadata;
return {
title: parsed.title?.slice(0, 100) || doc.title,
description: parsed.description || doc.title,
tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 15) : [],
};
} catch {
return { title: doc.title, description: doc.title, tags: [] };
}
}
// ---------------------------------------------------------------------------
// Sanity helpers
// ---------------------------------------------------------------------------
async function updateStatus(docId: string, status: string, extra: Record<string, unknown> = {}): Promise<void> {
await writeClient.patch(docId).set({ status, ...extra }).commit();
console.log(`[sanity-distribute] ${docId} -> ${status}`);
}
async function appendDistributionLog(docId: string, entries: DistributionLogEntry[]): Promise<void> {
const ops = entries.map((entry) => ({
insert: { after: "distributionLog[-1]", items: [entry] },
}));
// Use setIfMissing to create the array if it doesn't exist, then append
let patch = writeClient.patch(docId).setIfMissing({ distributionLog: [] });
for (const entry of entries) {
patch = patch.append("distributionLog", [entry]);
}
await patch.commit();
}
// ---------------------------------------------------------------------------
// Core distribution pipeline (runs inside after())
// ---------------------------------------------------------------------------
async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<void> {
const log: DistributionLogEntry[] = [];
// Fetch distribution config from Sanity singleton
const distConfig = await getConfig("distribution_config");
try {
await updateStatus(docId, "uploading");
// Step 1: Generate long-form YouTube metadata via Gemini
console.log("[sanity-distribute] Step 1/6 - Generating long-form metadata");
let metadata: YouTubeMetadata;
try {
metadata = await generateYouTubeMetadata(doc);
log.push(logEntry("gemini-metadata", "success"));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[sanity-distribute] Gemini metadata failed:", msg);
log.push(logEntry("gemini-metadata", "failed", { error: msg }));
// Fallback metadata so we can still upload
metadata = { title: doc.title, description: doc.title, tags: [] };
}
// Step 2: Upload main video to YouTube
let youtubeVideoId = "";
if (doc.videoUrl) {
console.log("[sanity-distribute] Step 2/6 - Uploading main video");
try {
const r = await uploadVideo({ videoUrl: doc.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags });
youtubeVideoId = r.videoId;
log.push(logEntry("youtube-upload", "success", { result: youtubeVideoId }));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[sanity-distribute] YouTube upload failed:", msg);
log.push(logEntry("youtube-upload", "failed", { error: msg }));
}
} else {
log.push(logEntry("youtube-upload", "skipped", { error: "No videoUrl" }));
}
// Step 3: Generate Shorts-optimized metadata + upload Short
let youtubeShortId = "";
if (doc.shortUrl) {
console.log("[sanity-distribute] Step 3/6 - Generating Shorts metadata + uploading");
try {
const shortsMetadata = await generateShortsMetadata(generateWithGemini, doc);
const r = await uploadShort({
videoUrl: doc.shortUrl,
title: shortsMetadata.title,
description: shortsMetadata.description,
tags: shortsMetadata.tags,
});
youtubeShortId = r.videoId;
log.push(logEntry("youtube-short", "success", { result: youtubeShortId }));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[sanity-distribute] Short upload failed:", msg);
log.push(logEntry("youtube-short", "failed", { error: msg }));
}
} else {
log.push(logEntry("youtube-short", "skipped", { error: "No shortUrl" }));
}
// Step 4: Email notification (non-fatal) — uses distribution config
console.log("[sanity-distribute] Step 4/6 - Sending email");
const ytUrl = youtubeVideoId ? `https://www.youtube.com/watch?v=${youtubeVideoId}` : doc.videoUrl || "";
try {
await notifySubscribers({
subject: `New Video: ${metadata.title}`,
videoTitle: metadata.title,
videoUrl: ytUrl,
description: metadata.description.slice(0, 280),
fromEmail: distConfig.resendFromEmail,
notificationEmails: distConfig.notificationEmails,
});
log.push(logEntry("email", "success"));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn("[sanity-distribute] Email error:", msg);
log.push(logEntry("email", "failed", { error: msg }));
}
// Step 5: Post to X/Twitter (non-fatal)
console.log("[sanity-distribute] Step 5/6 - Posting to X/Twitter");
try {
const tweetResult = await postVideoAnnouncement({
videoTitle: metadata.title,
youtubeUrl: ytUrl,
tags: metadata.tags,
});
if (tweetResult.success) {
log.push(logEntry("x-twitter", "success", { result: tweetResult.tweetId }));
} else {
log.push(logEntry("x-twitter", "failed", { error: tweetResult.error }));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn("[sanity-distribute] X/Twitter error:", msg);
log.push(logEntry("x-twitter", "failed", { error: msg }));
}
// Step 6: Mark published in Sanity + save distribution log
console.log("[sanity-distribute] Step 6/6 - Marking published");
await writeClient
.patch(docId)
.set({
status: "published",
youtubeId: youtubeVideoId || undefined,
youtubeShortId: youtubeShortId || undefined,
})
.setIfMissing({ distributionLog: [] })
.append("distributionLog", log)
.commit();
console.log(`[sanity-distribute] ✅ Distribution complete for ${docId}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[sanity-distribute] ❌ Failed ${docId}: ${msg}`);
log.push(logEntry("pipeline", "failed", { error: msg }));
try {
await writeClient
.patch(docId)
.set({
status: "flagged",
flaggedReason: `Distribution error: ${msg}`,
})
.setIfMissing({ distributionLog: [] })
.append("distributionLog", log)
.commit();
} catch {
// Last resort — at least try to save the log
console.error("[sanity-distribute] Failed to save error state");
}
}
}
// ---------------------------------------------------------------------------
// POST handler
// ---------------------------------------------------------------------------
/**
* Sanity webhook handler for the distribution pipeline.
*
* Listens for automatedVideo documents transitioning to "video_gen" status
* and triggers YouTube upload, email notification, and social posting.
*
* Uses after() to return 200 immediately and run the heavy pipeline work
* in the background — prevents Vercel from killing the function mid-upload.
*
* Configure in Sanity: Webhook → POST → filter: `_type == "automatedVideo"`
* with projection: `{ _id, _type, status }`
*/
export async function POST(request: Request) {
try {
if (!WEBHOOK_SECRET) {
console.log("[sanity-distribute] Missing SANITY_WEBHOOK_SECRET environment variable");
return NextResponse.json(
{ error: "Server misconfigured: missing webhook secret" },
{ status: 500 }
);
}
// Read the raw body as text for signature verification
const rawBody = await request.text();
const signature = request.headers.get(SIGNATURE_HEADER_NAME);
if (!signature) {
console.log("[sanity-distribute] Missing signature header");
return NextResponse.json(
{ error: "Missing signature" },
{ status: 401 }
);
}
// Verify the webhook signature (same as sanity-content route)
const isValid = await isValidSignature(rawBody, signature, WEBHOOK_SECRET);
if (!isValid) {
console.log("[sanity-distribute] Invalid signature received");
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 401 }
);
}
// Parse the verified body
let webhookPayload: WebhookPayload;
try {
webhookPayload = JSON.parse(rawBody);
} catch {
console.log("[sanity-distribute] Failed to parse webhook body");
return NextResponse.json(
{ skipped: true, reason: "Invalid JSON body" },
{ status: 200 }
);
}
console.log(`[sanity-distribute] Received: type=${webhookPayload._type}, id=${webhookPayload._id}, status=${webhookPayload.status}`);
if (webhookPayload._type !== "automatedVideo") {
return NextResponse.json(
{ skipped: true, reason: `Not automatedVideo` },
{ status: 200 }
);
}
if (webhookPayload.status !== "video_gen") {
return NextResponse.json(
{ skipped: true, reason: `Status "${webhookPayload.status}" is not "video_gen"` },
{ status: 200 }
);
}
const docId = webhookPayload._id;
// Fetch the full document from Sanity (webhook only sends minimal projection)
const doc = await writeClient.fetch<AutomatedVideoDoc | null>(
`*[_id == $id][0]`,
{ id: docId }
);
if (!doc) {
console.error(`[sanity-distribute] Document ${docId} not found`);
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
// Re-check status from the actual document (race condition guard)
if (doc.status !== "video_gen") {
return NextResponse.json(
{ skipped: true, reason: `Document status is "${doc.status}", not "video_gen"` },
{ status: 200 }
);
}
if (doc.flaggedReason) {
return NextResponse.json(
{ skipped: true, reason: "Flagged" },
{ status: 200 }
);
}
// Use after() to run the distribution pipeline after the response is sent.
// On Vercel, serverless functions terminate after the response — fire-and-forget
// (promise.catch() without await) silently dies. after() keeps the function alive.
console.log(`[sanity-distribute] Triggering distribution for: ${docId}`);
after(async () => {
try {
await runDistribution(docId, doc);
} catch (error) {
console.error(`[sanity-distribute] Background processing error for ${docId}:`, error);
}
});
return NextResponse.json({ triggered: true, docId }, { status: 200 });
} catch (error) {
console.log("[sanity-distribute] Unexpected error processing webhook:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}