|
| 1 | +import { type NextRequest, NextResponse } from "next/server"; |
| 2 | +import * as crypto from "node:crypto"; |
| 3 | +import { writeClient } from "@/lib/sanity-write-client"; |
| 4 | +import { generateWithGemini } from "@/lib/gemini"; |
| 5 | +import { uploadVideo, uploadShort } from "@/lib/youtube-upload"; |
| 6 | +import { notifySubscribers } from "@/lib/resend-notify"; |
| 7 | + |
| 8 | +function isValidSignature(body: string, signature: string | null): boolean { |
| 9 | + const secret = process.env.SANITY_WEBHOOK_SECRET; |
| 10 | + if (!secret) { |
| 11 | + console.warn("[sanity-distribute] SANITY_WEBHOOK_SECRET not set"); |
| 12 | + return true; |
| 13 | + } |
| 14 | + if (!signature) return false; |
| 15 | + const hmac = crypto.createHmac("sha256", secret); |
| 16 | + hmac.update(body); |
| 17 | + const digest = hmac.digest("base64"); |
| 18 | + try { |
| 19 | + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest)); |
| 20 | + } catch { |
| 21 | + return false; |
| 22 | + } |
| 23 | +} |
| 24 | + |
| 25 | +interface AutomatedVideoDoc { |
| 26 | + _id: string; |
| 27 | + _type: "automatedVideo"; |
| 28 | + title: string; |
| 29 | + script: { |
| 30 | + hook: string; |
| 31 | + scenes: Array<{ |
| 32 | + sceneNumber: number; |
| 33 | + narration: string; |
| 34 | + visualDescription: string; |
| 35 | + bRollKeywords: string[]; |
| 36 | + durationEstimate: number; |
| 37 | + }>; |
| 38 | + cta: string; |
| 39 | + }; |
| 40 | + status: "draft" | "script_ready" | "audio_gen" | "video_gen" | "flagged" | "uploading" | "published"; |
| 41 | + videoUrl?: string; |
| 42 | + shortUrl?: string; |
| 43 | + audioUrl?: string; |
| 44 | + youtubeId?: string; |
| 45 | + youtubeShortId?: string; |
| 46 | + flaggedReason?: string; |
| 47 | + scheduledPublishAt?: string; |
| 48 | +} |
| 49 | + |
| 50 | +interface YouTubeMetadata { title: string; description: string; tags: string[]; } |
| 51 | + |
| 52 | +async function generateYouTubeMetadata(doc: AutomatedVideoDoc): Promise<YouTubeMetadata> { |
| 53 | + const scriptText = doc.script |
| 54 | + ? [doc.script.hook, ...(doc.script.scenes?.map((s) => s.narration) ?? []), doc.script.cta].filter(Boolean).join("\n\n") |
| 55 | + : ""; |
| 56 | + const prompt = `You are a YouTube SEO expert for CodingCat.dev, a developer education channel.\n\nVideo Title: ${doc.title}\nScript: ${scriptText}\n\nReturn JSON: {"title": "SEO title max 100 chars", "description": "500-1000 chars", "tags": ["10-15 tags"]}`; |
| 57 | + const raw = await generateWithGemini(prompt); |
| 58 | + try { |
| 59 | + const parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, "").trim()) as YouTubeMetadata; |
| 60 | + return { title: parsed.title?.slice(0, 100) || doc.title, description: parsed.description || doc.title, tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 15) : [] }; |
| 61 | + } catch { |
| 62 | + return { title: doc.title, description: doc.title, tags: [] }; |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +async function updateStatus(docId: string, status: string, extra: Record<string, unknown> = {}): Promise<void> { |
| 67 | + await writeClient.patch(docId).set({ status, ...extra }).commit(); |
| 68 | + console.log(`[sanity-distribute] ${docId} -> ${status}`); |
| 69 | +} |
| 70 | + |
| 71 | +export async function POST(req: NextRequest): Promise<NextResponse> { |
| 72 | + const rawBody = await req.text(); |
| 73 | + const signature = req.headers.get("sanity-webhook-signature"); |
| 74 | + if (!isValidSignature(rawBody, signature)) { |
| 75 | + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); |
| 76 | + } |
| 77 | + |
| 78 | + let payload: AutomatedVideoDoc; |
| 79 | + try { payload = JSON.parse(rawBody); } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } |
| 80 | + |
| 81 | + if (payload._type !== "automatedVideo") return NextResponse.json({ skipped: true, reason: "Not automatedVideo" }); |
| 82 | + if (payload.status !== "video_gen") return NextResponse.json({ skipped: true, reason: `Status "${payload.status}" != "video_gen"` }); |
| 83 | + if (payload.flaggedReason) return NextResponse.json({ skipped: true, reason: "Flagged" }); |
| 84 | + |
| 85 | + const docId = payload._id; |
| 86 | + console.log(`[sanity-distribute] Processing ${docId}: "${payload.title}"`); |
| 87 | + |
| 88 | + try { |
| 89 | + await updateStatus(docId, "uploading"); |
| 90 | + |
| 91 | + // Step 1: Gemini metadata |
| 92 | + const metadata = await generateYouTubeMetadata(payload); |
| 93 | + |
| 94 | + // Step 2: Upload main video |
| 95 | + let youtubeVideoId = ""; |
| 96 | + if (payload.videoUrl) { |
| 97 | + const r = await uploadVideo({ videoUrl: payload.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags }); |
| 98 | + youtubeVideoId = r.videoId; |
| 99 | + } |
| 100 | + |
| 101 | + // Step 3: Upload short |
| 102 | + let youtubeShortId = ""; |
| 103 | + if (payload.shortUrl) { |
| 104 | + const r = await uploadShort({ videoUrl: payload.shortUrl, title: metadata.title, description: metadata.description, tags: metadata.tags }); |
| 105 | + youtubeShortId = r.videoId; |
| 106 | + } |
| 107 | + |
| 108 | + // Step 4: Email (non-fatal) |
| 109 | + const ytUrl = youtubeVideoId ? `https://www.youtube.com/watch?v=${youtubeVideoId}` : payload.videoUrl || ""; |
| 110 | + try { |
| 111 | + await notifySubscribers({ videoTitle: metadata.title, videoUrl: ytUrl, shortDescription: metadata.description.slice(0, 280), thumbnailUrl: `https://img.youtube.com/vi/${youtubeVideoId}/maxresdefault.jpg` }); |
| 112 | + } catch (e) { console.warn("[sanity-distribute] Email error:", e); } |
| 113 | + |
| 114 | + // Step 5: Mark published |
| 115 | + await updateStatus(docId, "published", { youtubeId: youtubeVideoId || undefined, youtubeShortId: youtubeShortId || undefined }); |
| 116 | + |
| 117 | + return NextResponse.json({ success: true, docId, youtubeId: youtubeVideoId, youtubeShortId }); |
| 118 | + } catch (err) { |
| 119 | + const msg = err instanceof Error ? err.message : String(err); |
| 120 | + console.error(`[sanity-distribute] Failed ${docId}: ${msg}`); |
| 121 | + try { await updateStatus(docId, "flagged", { flaggedReason: `Distribution error: ${msg}` }); } catch {} |
| 122 | + return NextResponse.json({ error: "Distribution failed", details: msg }, { status: 500 }); |
| 123 | + } |
| 124 | +} |
0 commit comments