Skip to content

Commit 8dff08f

Browse files
author
Miriad
committed
feat: YouTube Shorts optimization + X/Twitter social posting
- Shorts-optimized metadata: punchy titles, minimal descriptions, trending tags - generateShortsMetadata() uses Gemini with Shorts-specific SEO prompt - uploadShort() now formats title/description/tags differently from long-form - New lib/x-social.ts: X/Twitter v2 API with OAuth 1.0a signature generation - postVideoAnnouncement() formats tweets with title, URL, and hashtags - Webhook route now 6 steps: metadata → YouTube → Shorts → email → tweet → publish - X/Twitter posting is non-fatal (gracefully skips if API keys not set) - Long-form metadata prompt improved with timestamps/links placeholders
1 parent 2597e92 commit 8dff08f

File tree

3 files changed

+339
-37
lines changed

3 files changed

+339
-37
lines changed

app/api/webhooks/sanity-distribute/route.ts

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { type NextRequest, NextResponse } from "next/server";
22
import * as crypto from "node:crypto";
33
import { writeClient } from "@/lib/sanity-write-client";
44
import { generateWithGemini } from "@/lib/gemini";
5-
import { uploadVideo, uploadShort } from "@/lib/youtube-upload";
5+
import { uploadVideo, uploadShort, generateShortsMetadata } from "@/lib/youtube-upload";
66
import { notifySubscribers } from "@/lib/resend-notify";
7+
import { postVideoAnnouncement } from "@/lib/x-social";
8+
9+
// ---------------------------------------------------------------------------
10+
// Webhook signature validation
11+
// ---------------------------------------------------------------------------
712

813
function isValidSignature(body: string, signature: string | null): boolean {
914
const secret = process.env.SANITY_WEBHOOK_SECRET;
@@ -22,7 +27,10 @@ function isValidSignature(body: string, signature: string | null): boolean {
2227
}
2328
}
2429

25-
// Minimal webhook payload — we fetch the full doc from Sanity
30+
// ---------------------------------------------------------------------------
31+
// Types
32+
// ---------------------------------------------------------------------------
33+
2634
interface WebhookPayload {
2735
_id: string;
2836
_type: string;
@@ -56,33 +64,69 @@ interface AutomatedVideoDoc {
5664

5765
interface YouTubeMetadata { title: string; description: string; tags: string[]; }
5866

67+
// ---------------------------------------------------------------------------
68+
// Gemini metadata generation for long-form videos
69+
// ---------------------------------------------------------------------------
70+
5971
async function generateYouTubeMetadata(doc: AutomatedVideoDoc): Promise<YouTubeMetadata> {
6072
const scriptText = doc.script
6173
? [doc.script.hook, ...(doc.script.scenes?.map((s) => s.narration) ?? []), doc.script.cta].filter(Boolean).join("\n\n")
6274
: "";
63-
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"]}`;
75+
76+
const prompt = `You are a YouTube SEO expert for CodingCat.dev, a developer education channel.
77+
78+
Video Title: ${doc.title}
79+
Script: ${scriptText}
80+
81+
Generate optimized YouTube metadata for a LONG-FORM video (not Shorts).
82+
83+
Return JSON:
84+
{
85+
"title": "SEO-optimized title, max 100 chars, engaging but not clickbait",
86+
"description": "500-1000 chars with key points, timestamps placeholder, channel links, and hashtags",
87+
"tags": ["10-15 relevant tags for discoverability"]
88+
}
89+
90+
Include in the description:
91+
- Brief summary of what viewers will learn
92+
- Key topics covered
93+
- Links section placeholder (🔗 Links mentioned in this video:)
94+
- Social links placeholder
95+
- Relevant hashtags at the end`;
96+
6497
const raw = await generateWithGemini(prompt);
6598
try {
6699
const parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, "").trim()) as YouTubeMetadata;
67-
return { title: parsed.title?.slice(0, 100) || doc.title, description: parsed.description || doc.title, tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 15) : [] };
100+
return {
101+
title: parsed.title?.slice(0, 100) || doc.title,
102+
description: parsed.description || doc.title,
103+
tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 15) : [],
104+
};
68105
} catch {
69106
return { title: doc.title, description: doc.title, tags: [] };
70107
}
71108
}
72109

110+
// ---------------------------------------------------------------------------
111+
// Sanity helpers
112+
// ---------------------------------------------------------------------------
113+
73114
async function updateStatus(docId: string, status: string, extra: Record<string, unknown> = {}): Promise<void> {
74115
await writeClient.patch(docId).set({ status, ...extra }).commit();
75116
console.log(`[sanity-distribute] ${docId} -> ${status}`);
76117
}
77118

119+
// ---------------------------------------------------------------------------
120+
// POST handler
121+
// ---------------------------------------------------------------------------
122+
78123
export async function POST(req: NextRequest): Promise<NextResponse> {
79124
const rawBody = await req.text();
80125
const signature = req.headers.get("sanity-webhook-signature");
81126
if (!isValidSignature(rawBody, signature)) {
82127
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
83128
}
84129

85-
// Parse the minimal webhook payload (just _id, _type, status)
86130
let webhookPayload: WebhookPayload;
87131
try { webhookPayload = JSON.parse(rawBody); } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
88132

@@ -91,18 +135,17 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
91135

92136
const docId = webhookPayload._id;
93137

94-
// Fetch the full document from Sanity (webhook only sends minimal projection)
138+
// Fetch the full document from Sanity
95139
const doc = await writeClient.fetch<AutomatedVideoDoc | null>(
96140
`*[_id == $id][0]`,
97141
{ id: docId }
98142
);
99143

100144
if (!doc) {
101-
console.error(`[sanity-distribute] Document ${docId} not found in Sanity`);
145+
console.error(`[sanity-distribute] Document ${docId} not found`);
102146
return NextResponse.json({ error: "Document not found" }, { status: 404 });
103147
}
104148

105-
// Re-check status from the actual document (in case of race condition)
106149
if (doc.status !== "video_gen") {
107150
return NextResponse.json({ skipped: true, reason: `Document status is "${doc.status}", not "video_gen"` });
108151
}
@@ -115,36 +158,69 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
115158
try {
116159
await updateStatus(docId, "uploading");
117160

118-
// Step 1: Gemini metadata
161+
// Step 1: Generate long-form YouTube metadata via Gemini
162+
console.log("[sanity-distribute] Step 1/6 - Generating long-form metadata");
119163
const metadata = await generateYouTubeMetadata(doc);
120164

121-
// Step 2: Upload main video
165+
// Step 2: Upload main video to YouTube
122166
let youtubeVideoId = "";
123167
if (doc.videoUrl) {
168+
console.log("[sanity-distribute] Step 2/6 - Uploading main video");
124169
const r = await uploadVideo({ videoUrl: doc.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags });
125170
youtubeVideoId = r.videoId;
126171
}
127172

128-
// Step 3: Upload short
173+
// Step 3: Generate Shorts-optimized metadata + upload Short
129174
let youtubeShortId = "";
130175
if (doc.shortUrl) {
131-
const r = await uploadShort({ videoUrl: doc.shortUrl, title: metadata.title, description: metadata.description, tags: metadata.tags });
176+
console.log("[sanity-distribute] Step 3/6 - Generating Shorts metadata + uploading");
177+
const shortsMetadata = await generateShortsMetadata(generateWithGemini, doc);
178+
const r = await uploadShort({
179+
videoUrl: doc.shortUrl,
180+
title: shortsMetadata.title,
181+
description: shortsMetadata.description,
182+
tags: shortsMetadata.tags,
183+
});
132184
youtubeShortId = r.videoId;
133185
}
134186

135-
// Step 4: Email (non-fatal)
187+
// Step 4: Email notification (non-fatal)
188+
console.log("[sanity-distribute] Step 4/6 - Sending email");
136189
const ytUrl = youtubeVideoId ? `https://www.youtube.com/watch?v=${youtubeVideoId}` : doc.videoUrl || "";
137190
try {
138-
await notifySubscribers({ subject: `New Video: ${metadata.title}`, videoTitle: metadata.title, videoUrl: ytUrl, description: metadata.description.slice(0, 280) });
191+
await notifySubscribers({
192+
subject: `New Video: ${metadata.title}`,
193+
videoTitle: metadata.title,
194+
videoUrl: ytUrl,
195+
description: metadata.description.slice(0, 280),
196+
});
139197
} catch (e) { console.warn("[sanity-distribute] Email error:", e); }
140198

141-
// Step 5: Mark published
142-
await updateStatus(docId, "published", { youtubeId: youtubeVideoId || undefined, youtubeShortId: youtubeShortId || undefined });
143-
199+
// Step 5: Post to X/Twitter (non-fatal)
200+
console.log("[sanity-distribute] Step 5/6 - Posting to X/Twitter");
201+
try {
202+
const tweetResult = await postVideoAnnouncement({
203+
videoTitle: metadata.title,
204+
youtubeUrl: ytUrl,
205+
tags: metadata.tags,
206+
});
207+
if (!tweetResult.success) {
208+
console.warn(`[sanity-distribute] Tweet failed: ${tweetResult.error}`);
209+
}
210+
} catch (e) { console.warn("[sanity-distribute] X/Twitter error:", e); }
211+
212+
// Step 6: Mark published in Sanity
213+
console.log("[sanity-distribute] Step 6/6 - Marking published");
214+
await updateStatus(docId, "published", {
215+
youtubeId: youtubeVideoId || undefined,
216+
youtubeShortId: youtubeShortId || undefined,
217+
});
218+
219+
console.log(`[sanity-distribute] ✅ Distribution complete for ${docId}`);
144220
return NextResponse.json({ success: true, docId, youtubeId: youtubeVideoId, youtubeShortId });
145221
} catch (err) {
146222
const msg = err instanceof Error ? err.message : String(err);
147-
console.error(`[sanity-distribute] Failed ${docId}: ${msg}`);
223+
console.error(`[sanity-distribute] Failed ${docId}: ${msg}`);
148224
try { await updateStatus(docId, "flagged", { flaggedReason: `Distribution error: ${msg}` }); } catch {}
149225
return NextResponse.json({ error: "Distribution failed", details: msg }, { status: 500 });
150226
}

lib/x-social.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* X/Twitter social posting service.
3+
* Posts new video announcements to X/Twitter using the v2 API.
4+
*
5+
* Required env vars:
6+
* - X_API_KEY (consumer key)
7+
* - X_API_SECRET (consumer secret)
8+
* - X_ACCESS_TOKEN (user access token)
9+
* - X_ACCESS_SECRET (user access token secret)
10+
*/
11+
12+
import * as crypto from "node:crypto";
13+
14+
// ---------------------------------------------------------------------------
15+
// OAuth 1.0a signature generation for X API v2
16+
// ---------------------------------------------------------------------------
17+
18+
function percentEncode(str: string): string {
19+
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
20+
}
21+
22+
function generateOAuthSignature(
23+
method: string,
24+
url: string,
25+
params: Record<string, string>,
26+
consumerSecret: string,
27+
tokenSecret: string,
28+
): string {
29+
const sortedParams = Object.keys(params)
30+
.sort()
31+
.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`)
32+
.join("&");
33+
34+
const baseString = `${method.toUpperCase()}&${percentEncode(url)}&${percentEncode(sortedParams)}`;
35+
const signingKey = `${percentEncode(consumerSecret)}&${percentEncode(tokenSecret)}`;
36+
37+
return crypto.createHmac("sha1", signingKey).update(baseString).digest("base64");
38+
}
39+
40+
function generateOAuthHeader(method: string, url: string, body?: Record<string, string>): string {
41+
const apiKey = process.env.X_API_KEY;
42+
const apiSecret = process.env.X_API_SECRET;
43+
const accessToken = process.env.X_ACCESS_TOKEN;
44+
const accessSecret = process.env.X_ACCESS_SECRET;
45+
46+
if (!apiKey || !apiSecret || !accessToken || !accessSecret) {
47+
throw new Error("X/Twitter API credentials not configured");
48+
}
49+
50+
const oauthParams: Record<string, string> = {
51+
oauth_consumer_key: apiKey,
52+
oauth_nonce: crypto.randomBytes(16).toString("hex"),
53+
oauth_signature_method: "HMAC-SHA1",
54+
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
55+
oauth_token: accessToken,
56+
oauth_version: "1.0",
57+
};
58+
59+
const allParams = { ...oauthParams, ...(body || {}) };
60+
const signature = generateOAuthSignature(method, url, allParams, apiSecret, accessSecret);
61+
oauthParams.oauth_signature = signature;
62+
63+
const headerParts = Object.keys(oauthParams)
64+
.sort()
65+
.map((k) => `${percentEncode(k)}="${percentEncode(oauthParams[k])}"`)
66+
.join(", ");
67+
68+
return `OAuth ${headerParts}`;
69+
}
70+
71+
// ---------------------------------------------------------------------------
72+
// Public API
73+
// ---------------------------------------------------------------------------
74+
75+
export interface PostTweetOptions {
76+
text: string;
77+
}
78+
79+
export interface TweetResult {
80+
success: boolean;
81+
tweetId?: string;
82+
tweetUrl?: string;
83+
error?: string;
84+
}
85+
86+
/**
87+
* Post a tweet to X/Twitter.
88+
*/
89+
export async function postTweet(opts: PostTweetOptions): Promise<TweetResult> {
90+
const apiKey = process.env.X_API_KEY;
91+
if (!apiKey) {
92+
console.warn("[x-social] X_API_KEY not set — skipping tweet");
93+
return { success: false, error: "X_API_KEY not configured" };
94+
}
95+
96+
const url = "https://api.x.com/2/tweets";
97+
98+
try {
99+
const authHeader = generateOAuthHeader("POST", url);
100+
101+
const response = await fetch(url, {
102+
method: "POST",
103+
headers: {
104+
Authorization: authHeader,
105+
"Content-Type": "application/json",
106+
},
107+
body: JSON.stringify({ text: opts.text }),
108+
});
109+
110+
if (!response.ok) {
111+
const errorBody = await response.text();
112+
console.error(`[x-social] X API error ${response.status}: ${errorBody}`);
113+
return { success: false, error: `X API ${response.status}: ${errorBody}` };
114+
}
115+
116+
const data = await response.json();
117+
const tweetId = data.data?.id;
118+
119+
console.log(`[x-social] Tweet posted: ${tweetId}`);
120+
return {
121+
success: true,
122+
tweetId,
123+
tweetUrl: tweetId ? `https://x.com/CodingCatDev/status/${tweetId}` : undefined,
124+
};
125+
} catch (err) {
126+
const msg = err instanceof Error ? err.message : String(err);
127+
console.error(`[x-social] Failed to post tweet: ${msg}`);
128+
return { success: false, error: msg };
129+
}
130+
}
131+
132+
/**
133+
* Generate and post a video announcement tweet.
134+
* Formats the tweet with title, YouTube URL, and relevant hashtags.
135+
*/
136+
export async function postVideoAnnouncement(opts: {
137+
videoTitle: string;
138+
youtubeUrl: string;
139+
tags?: string[];
140+
}): Promise<TweetResult> {
141+
// Build hashtags from tags (max 3 to keep tweet clean)
142+
const hashtags = (opts.tags || [])
143+
.slice(0, 3)
144+
.map((t) => `#${t.replace(/\s+/g, "").replace(/^#/, "")}`)
145+
.join(" ");
146+
147+
// Format tweet: title + URL + hashtags (max 280 chars)
148+
const baseText = `🎬 New video: ${opts.videoTitle}\n\n${opts.youtubeUrl}`;
149+
const withHashtags = hashtags ? `${baseText}\n\n${hashtags} #WebDev #CodingCatDev` : `${baseText}\n\n#WebDev #CodingCatDev`;
150+
151+
// Trim to 280 chars if needed
152+
const text = withHashtags.length > 280
153+
? `${baseText.slice(0, 275)}...`
154+
: withHashtags;
155+
156+
return postTweet({ text });
157+
}

0 commit comments

Comments
 (0)