Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/api/webhooks/sanity-revalidate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export async function POST(request: NextRequest) {

// Revalidate all sanity-tagged caches (the "heavy hammer" approach)
// This is a backup for when no visitors are active to trigger SanityLive revalidation
revalidateTag("sanity");
// Next.js 16 requires a second argument — { expire: 0 } for immediate invalidation
revalidateTag("sanity", { expire: 0 });

return NextResponse.json({
revalidated: true,
Expand Down
4 changes: 3 additions & 1 deletion lib/gemini.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GoogleGenerativeAI } from "@google/generative-ai";
import { getConfigValue } from "@/lib/config";

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || "");

Expand All @@ -12,8 +13,9 @@ export async function generateWithGemini(
prompt: string,
systemInstruction?: string,
): Promise<string> {
const geminiModel = await getConfigValue("pipeline_config", "geminiModel", "gemini-2.0-flash");
const model = genAI.getGenerativeModel({
model: process.env.GEMINI_MODEL || "gemini-2.5-flash",
model: geminiModel,
...(systemInstruction && { systemInstruction }),
});
const result = await model.generateContent(prompt);
Expand Down
2 changes: 1 addition & 1 deletion lib/services/elevenlabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ async function getElevenLabsConfig(): Promise<ElevenLabsConfig> {
const apiKey = process.env.ELEVENLABS_API_KEY;
const voiceId = await getConfigValue(
"pipeline_config", "elevenLabsVoiceId",
process.env.ELEVENLABS_VOICE_ID || "pNInz6obpgDQGcFmaJgB"
"pNInz6obpgDQGcFmaJgB"
);

if (!apiKey) {
Expand Down
21 changes: 12 additions & 9 deletions lib/services/gcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
* Authenticates via service account JWT → OAuth2 access token exchange.
*
* Requires env vars:
* - GCS_BUCKET
* - GCS_PROJECT_ID
* - GCS_CLIENT_EMAIL
* - GCS_PRIVATE_KEY
*
* GCS bucket and project config is read from Sanity singletons via getConfigValue().
*
* @module lib/services/gcs
*/

Expand Down Expand Up @@ -58,27 +58,30 @@ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes
// ---------------------------------------------------------------------------

/**
* Get GCS configuration from environment variables.
* Throws if any required env var is missing.
* Get GCS configuration from Sanity config + environment variables.
* Bucket and project ID come from Sanity; credentials from env vars.
* Throws if any required value is missing.
*
* The private key may contain literal `\\n` sequences from the env var;
* these are converted to real newline characters.
*/
export async function getGCSConfig(): Promise<GCSConfig> {
const bucket = await getConfigValue("gcs_config", "bucketName", process.env.GCS_BUCKET);
const projectId = await getConfigValue("gcs_config", "projectId", process.env.GCS_PROJECT_ID);
const bucket = await getConfigValue("gcs_config", "bucketName", "codingcatdev-content-engine");
const projectId = await getConfigValue("gcs_config", "projectId", "codingcatdev");
const clientEmail = process.env.GCS_CLIENT_EMAIL;
let privateKey = process.env.GCS_PRIVATE_KEY;

if (!bucket || !projectId || !clientEmail || !privateKey) {
const missing = [
!bucket && "GCS_BUCKET",
!projectId && "GCS_PROJECT_ID",
!bucket && "gcs_config.bucketName",
!projectId && "gcs_config.projectId",
!clientEmail && "GCS_CLIENT_EMAIL",
!privateKey && "GCS_PRIVATE_KEY",
].filter(Boolean);
throw new Error(
`[GCS] Missing required environment variables: ${missing.join(", ")}`
`[GCS] Missing required configuration: ${missing.join(", ")}. ` +
`Bucket and project ID are managed in the Sanity GCS Config singleton. ` +
`Credentials (GCS_CLIENT_EMAIL, GCS_PRIVATE_KEY) come from env vars.`
);
}

Expand Down
34 changes: 18 additions & 16 deletions lib/services/remotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
* Requires env vars:
* - AWS_ACCESS_KEY_ID
* - AWS_SECRET_ACCESS_KEY
* - REMOTION_AWS_REGION
* - REMOTION_SERVE_URL (generated during Lambda deployment)
* - REMOTION_FUNCTION_NAME (optional, defaults to DEFAULT_FUNCTION_NAME)
*
* Remotion-specific config (region, serveUrl, functionName) is read from
* Sanity singletons via getConfigValue().
*
* @module lib/services/remotion
*/
Expand Down Expand Up @@ -76,7 +76,7 @@ export interface RenderResult {
// Constants
// ---------------------------------------------------------------------------

/** Default Lambda function name if REMOTION_FUNCTION_NAME is not set */
/** Default Lambda function name if not configured in Sanity */
const DEFAULT_FUNCTION_NAME = "remotion-render-4-0-431";

/** Polling interval in ms when waiting for render completion */
Expand Down Expand Up @@ -128,26 +128,28 @@ function mapInputProps(input: RenderInput): Record<string, unknown> {
// ---------------------------------------------------------------------------

/**
* Get Remotion Lambda configuration from environment variables.
* Throws if any required env var is missing.
* Get Remotion Lambda configuration from Sanity config + environment variables.
* AWS credentials come from env vars; Remotion-specific config from Sanity.
* Throws if any required value is missing.
*/
export async function getRemotionConfig(): Promise<RemotionLambdaConfig> {
const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
const region = await getConfigValue("remotion_config", "awsRegion", process.env.REMOTION_AWS_REGION);
const serveUrl = await getConfigValue("remotion_config", "serveUrl", process.env.REMOTION_SERVE_URL);
const region = await getConfigValue("remotion_config", "awsRegion", "us-east-1");
const serveUrl = await getConfigValue("remotion_config", "serveUrl", undefined);

const missing: string[] = [];
if (!awsAccessKeyId) missing.push("AWS_ACCESS_KEY_ID");
if (!awsSecretAccessKey) missing.push("AWS_SECRET_ACCESS_KEY");
if (!region) missing.push("REMOTION_AWS_REGION");
if (!serveUrl) missing.push("REMOTION_SERVE_URL");
if (!region) missing.push("remotion_config.awsRegion");
if (!serveUrl) missing.push("remotion_config.serveUrl");

if (missing.length > 0) {
throw new Error(
`[REMOTION] Missing required environment variables: ${missing.join(", ")}. ` +
`Set these before calling any render function. ` +
`REMOTION_SERVE_URL is generated by running deployRemotionLambda().`
`[REMOTION] Missing required configuration: ${missing.join(", ")}. ` +
`AWS credentials come from env vars. Remotion config (region, serveUrl, functionName) ` +
`is managed in the Sanity Remotion Config singleton. ` +
`serveUrl is generated by running deployRemotionLambda().`
);
}

Expand All @@ -160,10 +162,10 @@ export async function getRemotionConfig(): Promise<RemotionLambdaConfig> {
}

/**
* Get the Lambda function name from env or use the default.
* Get the Lambda function name from Sanity config or use the default.
*/
async function getFunctionName(): Promise<string> {
return getConfigValue("remotion_config", "functionName", process.env.REMOTION_FUNCTION_NAME || DEFAULT_FUNCTION_NAME);
return getConfigValue("remotion_config", "functionName", "remotion-render-4-0-431-mem2048mb-disk2048mb-240sec");
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -239,7 +241,7 @@ async function startRender(
throw new Error(
`[REMOTION] Failed to trigger Lambda render for "${composition}": ${message}. ` +
`Ensure the Lambda function "${functionName}" is deployed in ${region} ` +
`and REMOTION_SERVE_URL points to a valid Remotion bundle.`
`and the Remotion Config serveUrl points to a valid Remotion bundle.`
);
}
}
Expand Down
4 changes: 3 additions & 1 deletion lib/sponsor/gemini-intent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GoogleGenerativeAI } from '@google/generative-ai'
import { getConfigValue } from '@/lib/config'

const SPONSORSHIP_TIERS = [
'dedicated-video',
Expand Down Expand Up @@ -33,8 +34,9 @@ export async function extractSponsorIntent(message: string): Promise<SponsorInte
}
}

const geminiModel = await getConfigValue('pipeline_config', 'geminiModel', 'gemini-2.0-flash')
const genAI = new GoogleGenerativeAI(apiKey)
const model = genAI.getGenerativeModel({ model: process.env.GEMINI_MODEL || 'gemini-2.5-flash' })
const model = genAI.getGenerativeModel({ model: geminiModel })

const prompt = `You are analyzing an inbound sponsorship inquiry for CodingCat.dev, a developer education platform with YouTube videos, podcasts, blog posts, and newsletters.

Expand Down
13 changes: 7 additions & 6 deletions lib/youtube-upload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { Readable } from "node:stream";
import { getConfigValue } from "@/lib/config";

const oauth2Client = new google.auth.OAuth2(
process.env.YOUTUBE_CLIENT_ID,
Expand All @@ -11,12 +12,12 @@ oauth2Client.setCredentials({

const youtube = google.youtube({ version: "v3", auth: oauth2Client });

function getDefaultPrivacyStatus(): "public" | "private" | "unlisted" {
const envValue = process.env.YOUTUBE_UPLOAD_VISIBILITY?.toLowerCase();
if (envValue === "public" || envValue === "private" || envValue === "unlisted") {
return envValue;
async function getDefaultPrivacyStatus(): Promise<"public" | "private" | "unlisted"> {
const value = await getConfigValue("pipeline_config", "youtubeUploadVisibility", "private");
if (value === "public" || value === "private" || value === "unlisted") {
return value;
}
return "private"; // Default to private when not set
return "private"; // Default to private when not set or invalid
}

interface UploadOptions {
Expand All @@ -36,7 +37,7 @@ export async function uploadVideo(opts: UploadOptions): Promise<{ videoId: strin
const response = await fetch(opts.videoUrl);
if (!response.ok) throw new Error(`Failed to fetch video: ${response.statusText}`);

const resolvedPrivacyStatus = opts.privacyStatus || getDefaultPrivacyStatus();
const resolvedPrivacyStatus = opts.privacyStatus || await getDefaultPrivacyStatus();
console.log(`[youtube-upload] Uploading "${opts.title.slice(0, 60)}" with privacy: ${resolvedPrivacyStatus}`);
// Convert Web ReadableStream to Node.js Readable stream
// googleapis expects a Node.js stream with .pipe(), not a Web ReadableStream
Expand Down
6 changes: 6 additions & 0 deletions sanity/schemas/singletons/contentConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineType({
name: "rssFeeds",
title: "RSS Feeds",
type: "array",
description: "RSS/Atom feeds to monitor for trending topics. The ingest cron checks these daily for new content ideas",
of: [
{
type: "object",
Expand Down Expand Up @@ -42,6 +43,7 @@ export default defineType({
name: "trendSourcesEnabled",
title: "Trend Sources Enabled",
type: "object",
description: "Toggle individual trend discovery sources on/off. Disabling a source skips it during the daily ingest scan",
fields: [
defineField({
name: "hn",
Expand Down Expand Up @@ -79,24 +81,28 @@ export default defineType({
name: "systemInstruction",
title: "System Instruction",
type: "text",
description: "The AI system prompt used for script generation. Defines the writing style, tone, and format for all generated video scripts",
initialValue: "You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.\n\nYour style is inspired by Cleo Abram's \"Huge If True\" — you make complex technical topics feel exciting, accessible, and important. Key principles:\n- Start with a BOLD claim or surprising fact that makes people stop scrolling\n- Use analogies and real-world comparisons to explain technical concepts\n- Build tension: \"Here's the problem... here's why it matters... here's the breakthrough\"\n- Keep energy HIGH — short sentences, active voice, conversational tone\n- End with a clear takeaway that makes the viewer feel smarter\n- Target audience: developers who want to stay current but don't have time to read everything\n\nScript format: 60-90 second explainer videos. Think TikTok/YouTube Shorts energy with real educational depth.\n\nCodingCat.dev covers: React, Next.js, TypeScript, Svelte, web APIs, CSS, Node.js, cloud services, AI/ML for developers, and web platform updates.",
}),
defineField({
name: "targetVideoDurationSec",
title: "Target Video Duration (sec)",
type: "number",
description: "Target duration for generated videos in seconds. Scripts are calibrated to this length",
initialValue: 90,
}),
defineField({
name: "sceneCountMin",
title: "Scene Count Min",
type: "number",
description: "Minimum number of scenes per video. The AI generates at least this many visual segments",
initialValue: 3,
}),
defineField({
name: "sceneCountMax",
title: "Scene Count Max",
type: "number",
description: "Maximum number of scenes per video. Keeps videos focused and within duration targets",
initialValue: 5,
}),
],
Expand Down
4 changes: 4 additions & 0 deletions sanity/schemas/singletons/distributionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,30 @@ export default defineType({
name: "notificationEmails",
title: "Notification Emails",
type: "array",
description: "Email addresses that receive notifications when a new video is published",
of: [{ type: "string" }],
initialValue: ["alex@codingcat.dev"],
}),
defineField({
name: "youtubeDescriptionTemplate",
title: "YouTube Description Template",
type: "text",
description: "Template for YouTube video descriptions. Use {{title}} and {{summary}} placeholders",
initialValue: "{{title}}\n\n{{summary}}\n\n🔗 Learn more at https://codingcat.dev\n\n#webdev #coding #programming",
}),
defineField({
name: "youtubeDefaultTags",
title: "YouTube Default Tags",
type: "array",
description: "Default tags applied to all uploaded YouTube videos. Topic-specific tags are added by AI",
of: [{ type: "string" }],
initialValue: ["web development", "coding", "programming", "tutorial", "codingcat"],
}),
defineField({
name: "resendFromEmail",
title: "Resend From Email",
type: "string",
description: "Sender email address for notification emails via Resend",
initialValue: "content@codingcat.dev",
}),
],
Expand Down
2 changes: 2 additions & 0 deletions sanity/schemas/singletons/gcsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ export default defineType({
name: "bucketName",
title: "Bucket Name",
type: "string",
description: "Google Cloud Storage bucket name for storing video assets, audio files, and renders",
initialValue: "codingcatdev-content-engine",
}),
defineField({
name: "projectId",
title: "Project ID",
type: "string",
description: "Google Cloud project ID that owns the GCS bucket",
initialValue: "codingcatdev",
}),
],
Expand Down
8 changes: 8 additions & 0 deletions sanity/schemas/singletons/pipelineConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@ export default defineType({
name: "geminiModel",
title: "Gemini Model",
type: "string",
description: "The Google Gemini model used for script generation and content analysis (e.g., gemini-2.0-flash, gemini-2.5-pro)",
initialValue: "gemini-2.0-flash",
}),
defineField({
name: "elevenLabsVoiceId",
title: "ElevenLabs Voice ID",
type: "string",
description: "ElevenLabs voice ID for text-to-speech narration. Find voice IDs at elevenlabs.io/voice-library",
initialValue: "pNInz6obpgDQGcFmaJgB",
}),
defineField({
name: "youtubeUploadVisibility",
title: "YouTube Upload Visibility",
type: "string",
description: "Default visibility for uploaded YouTube videos. Use 'private' for testing, 'unlisted' for review, 'public' for production",
initialValue: "private",
options: {
list: ["private", "unlisted", "public"],
Expand All @@ -31,32 +34,37 @@ export default defineType({
name: "youtubeChannelId",
title: "YouTube Channel ID",
type: "string",
description: "Your YouTube channel ID \u2014 used for analytics and upload targeting",
initialValue: "",
}),
defineField({
name: "enableNotebookLmResearch",
title: "Enable NotebookLM Research",
type: "boolean",
description: "When enabled, the ingest cron creates a NotebookLM notebook for deep research before script generation. Requires NOTEBOOKLM_AUTH_JSON env var",
initialValue: false,
}),
defineField({
name: "qualityThreshold",
title: "Quality Threshold",
type: "number",
description: "Minimum quality score (0-100) from the AI critic. Videos scoring below this are flagged for manual review instead of auto-publishing",
initialValue: 50,
validation: (rule) => rule.min(0).max(100),
}),
defineField({
name: "stuckTimeoutMinutes",
title: "Stuck Timeout Minutes",
type: "number",
description: "Minutes before a pipeline document is considered stuck and auto-flagged. Sub-statuses use proportional timeouts (infographics: 50%, enriching: 33%)",
initialValue: 30,
validation: (rule) => rule.min(5).max(120),
}),
defineField({
name: "maxIdeasPerRun",
title: "Max Ideas Per Run",
type: "number",
description: "Maximum number of content ideas to process per ingest cron run. Keep low (1-3) to stay within serverless time limits",
initialValue: 1,
validation: (rule) => rule.min(1).max(10),
}),
Expand Down
Loading
Loading