Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { TokenUsage } from "./types";
|
||||
|
||||
// Rough 2026 unit prices (USD). Tune in one place; admin cost dashboards read AiCostLog.
|
||||
const PRICE = {
|
||||
gptInputPer1k: 0.0025,
|
||||
gptOutputPer1k: 0.01,
|
||||
elevenPer1kChars: 0.3,
|
||||
dallePerImage: 0.04,
|
||||
};
|
||||
|
||||
export function scriptCostUsd(usage: TokenUsage): number {
|
||||
return round4(
|
||||
(usage.inputTokens / 1000) * PRICE.gptInputPer1k +
|
||||
(usage.outputTokens / 1000) * PRICE.gptOutputPer1k
|
||||
);
|
||||
}
|
||||
|
||||
export function audioCostUsd(characters: number): number {
|
||||
return round4((characters / 1000) * PRICE.elevenPer1kChars);
|
||||
}
|
||||
|
||||
export function artCostUsd(images: number): number {
|
||||
return round4(images * PRICE.dallePerImage);
|
||||
}
|
||||
|
||||
export interface CostEntry {
|
||||
provider: "openai" | "elevenlabs";
|
||||
operation: "script" | "audio" | "art" | "repurpose";
|
||||
units: number;
|
||||
costUsd: number;
|
||||
episodeId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/** Record an AI usage/cost line for the admin monitoring dashboard. */
|
||||
export async function recordCost(entry: CostEntry): Promise<void> {
|
||||
await prisma.aiCostLog.create({
|
||||
data: {
|
||||
provider: entry.provider,
|
||||
operation: entry.operation,
|
||||
units: entry.units,
|
||||
costUsd: entry.costUsd.toFixed(4),
|
||||
episodeId: entry.episodeId,
|
||||
userId: entry.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function round4(n: number): number {
|
||||
return Math.round(n * 10000) / 10000;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const FFMPEG = process.env.FFMPEG_PATH ?? "ffmpeg";
|
||||
const FFPROBE = process.env.FFPROBE_PATH ?? "ffprobe";
|
||||
|
||||
/** Run ffmpeg with the given args; rejects with the tail of stderr on non-zero exit. */
|
||||
export function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(FFMPEG, args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let stderr = "";
|
||||
proc.stderr.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
proc.on("error", (err) =>
|
||||
reject(new Error(`Failed to spawn ffmpeg (${FFMPEG}): ${err.message}`))
|
||||
);
|
||||
proc.on("close", (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-600)}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Probe an audio file's duration in whole seconds, or null if ffprobe is unavailable. */
|
||||
export function ffprobeDuration(file: string): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(FFPROBE, [
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
file,
|
||||
]);
|
||||
let out = "";
|
||||
proc.stdout.on("data", (d) => {
|
||||
out += d.toString();
|
||||
});
|
||||
proc.on("error", () => resolve(null));
|
||||
proc.on("close", () => {
|
||||
const n = parseFloat(out.trim());
|
||||
resolve(Number.isFinite(n) ? Math.round(n) : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import OpenAI from "openai";
|
||||
|
||||
let client: OpenAI | null = null;
|
||||
|
||||
/** Lazily-constructed OpenAI client (used for GPT-4 scripts and DALL·E art). */
|
||||
export function openai(): OpenAI {
|
||||
if (!client) {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) throw new Error("OPENAI_API_KEY is not set");
|
||||
client = new OpenAI({ apiKey });
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export const SCRIPT_MODEL = process.env.OPENAI_SCRIPT_MODEL ?? "gpt-4o";
|
||||
export const ART_MODEL = process.env.OPENAI_ART_MODEL ?? "dall-e-3";
|
||||
@@ -0,0 +1,228 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { setEpisodeStatus } from "@/lib/episodes/status";
|
||||
import { scriptProvider, audioProvider, artProvider } from "@/lib/ai/providers";
|
||||
import { buildCoverPrompt } from "@/lib/ai/providers/openai-art";
|
||||
import { segmentScript } from "./segment";
|
||||
import { stitchMp3 } from "./stitch";
|
||||
import { storage, assetKey } from "@/lib/storage";
|
||||
import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/cost";
|
||||
import { incrementUsage } from "@/lib/usage/meter";
|
||||
import { sendEmail, emailLayout } from "@/lib/email";
|
||||
import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
|
||||
import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
|
||||
import type { GenerationType } from "@/lib/queue/jobs";
|
||||
|
||||
type EpisodeWithRelations = Prisma.EpisodeGetPayload<{
|
||||
include: { speakers: true; user: true };
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The episode generation pipeline, run by the worker.
|
||||
* Stages: script → segment → synthesize → stitch → art → save → meter.
|
||||
* `type` selects which stages run (full, or a single re-generation).
|
||||
*/
|
||||
export async function runEpisodeGeneration(
|
||||
episodeId: string,
|
||||
type: GenerationType = "full"
|
||||
): Promise<void> {
|
||||
const episode = await loadEpisode(episodeId);
|
||||
const config = toConfig(episode);
|
||||
|
||||
const did = { script: false, audio: false, art: false };
|
||||
|
||||
if (type === "full" || type === "script") {
|
||||
await generateScript(episode, config);
|
||||
did.script = true;
|
||||
}
|
||||
if (type === "full" || type === "script" || type === "audio") {
|
||||
await generateAudio(episode);
|
||||
did.audio = true;
|
||||
}
|
||||
if (type === "full" || type === "art") {
|
||||
await generateArt(episode);
|
||||
did.art = true;
|
||||
}
|
||||
|
||||
await setEpisodeStatus(episodeId, "SAVING", { stage: "Finalizing your episode" });
|
||||
await meter(episode, did);
|
||||
await setEpisodeStatus(episodeId, "READY", { stage: "Done" });
|
||||
await notifyReady(episode);
|
||||
}
|
||||
|
||||
async function loadEpisode(episodeId: string): Promise<EpisodeWithRelations> {
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id: episodeId },
|
||||
include: { speakers: true, user: true },
|
||||
});
|
||||
if (!episode) throw new Error(`Episode ${episodeId} not found`);
|
||||
return episode;
|
||||
}
|
||||
|
||||
function toConfig(episode: EpisodeWithRelations): EpisodeConfig {
|
||||
const speakers =
|
||||
episode.speakers.length > 0
|
||||
? episode.speakers.map((s) => ({ speakerKey: s.speakerKey, displayName: s.displayName }))
|
||||
: [{ speakerKey: "host", displayName: "Host" }];
|
||||
return {
|
||||
title: episode.title,
|
||||
topic: episode.topic,
|
||||
tone: episode.tone,
|
||||
format: episode.format,
|
||||
language: episode.language,
|
||||
targetLengthMin: episode.targetLengthMin,
|
||||
audience: episode.audience ?? undefined,
|
||||
speakers,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────── Stage 1: script ───────────────
|
||||
async function generateScript(episode: EpisodeWithRelations, config: EpisodeConfig) {
|
||||
await setEpisodeStatus(episode.id, "SCRIPTING", { stage: "Writing the script" });
|
||||
const { script, usage } = await scriptProvider().generate(config);
|
||||
|
||||
await prisma.script.upsert({
|
||||
where: { episodeId: episode.id },
|
||||
create: {
|
||||
episodeId: episode.id,
|
||||
content: script as unknown as Prisma.InputJsonValue,
|
||||
model: scriptProvider().model,
|
||||
},
|
||||
update: { content: script as unknown as Prisma.InputJsonValue, version: { increment: 1 } },
|
||||
});
|
||||
|
||||
// Adopt the generated title when the user didn't set one.
|
||||
if (!episode.title?.trim() && script.title) {
|
||||
await prisma.episode.update({ where: { id: episode.id }, data: { title: script.title } });
|
||||
episode.title = script.title;
|
||||
}
|
||||
|
||||
await recordCost({
|
||||
provider: "openai",
|
||||
operation: "script",
|
||||
units: usage.inputTokens + usage.outputTokens,
|
||||
costUsd: scriptCostUsd(usage),
|
||||
episodeId: episode.id,
|
||||
userId: episode.userId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────── Stages 2–4: segment → synthesize → stitch ───────────────
|
||||
async function generateAudio(episode: EpisodeWithRelations) {
|
||||
await setEpisodeStatus(episode.id, "SYNTHESIZING", { stage: "Recording the audio" });
|
||||
|
||||
const scriptRow = await prisma.script.findUnique({ where: { episodeId: episode.id } });
|
||||
if (!scriptRow) throw new Error("Cannot synthesize audio before a script exists");
|
||||
const script = scriptRow.content as unknown as StructuredScript;
|
||||
|
||||
const voiceMap: Record<string, string> = {};
|
||||
for (const s of episode.speakers) voiceMap[s.speakerKey] = s.elevenVoiceId;
|
||||
const fallback =
|
||||
episode.speakers[0]?.elevenVoiceId ?? DEFAULT_VOICE_IDS.host;
|
||||
|
||||
const provider = audioProvider();
|
||||
const segments = segmentScript(script, voiceMap, fallback, provider.maxCharsPerRequest);
|
||||
if (segments.length === 0) throw new Error("Script produced no spoken lines");
|
||||
|
||||
const buffers: Buffer[] = [];
|
||||
let totalChars = 0;
|
||||
for (const seg of segments) {
|
||||
const res =
|
||||
seg.uniqueVoices <= 1
|
||||
? await provider.synthesizeSpeech(
|
||||
seg.turns.map((t) => t.text).join(" "),
|
||||
seg.turns[0].voiceId,
|
||||
{ language: episode.language }
|
||||
)
|
||||
: await provider.synthesizeDialogue(seg.turns, { language: episode.language });
|
||||
buffers.push(res.audio);
|
||||
totalChars += res.characters;
|
||||
}
|
||||
|
||||
await setEpisodeStatus(episode.id, "STITCHING", { stage: "Mixing the audio" });
|
||||
const { data, durationSec } = await stitchMp3(buffers);
|
||||
|
||||
const key = assetKey("mp3", `${episode.id}.mp3`);
|
||||
await storage().put(key, data, "audio/mpeg");
|
||||
|
||||
await prisma.audioAsset.upsert({
|
||||
where: { episodeId: episode.id },
|
||||
create: {
|
||||
episodeId: episode.id,
|
||||
storageKey: key,
|
||||
durationSec,
|
||||
sizeBytes: data.length,
|
||||
segments: { count: segments.length } as Prisma.InputJsonValue,
|
||||
},
|
||||
update: {
|
||||
storageKey: key,
|
||||
durationSec,
|
||||
sizeBytes: data.length,
|
||||
segments: { count: segments.length } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await recordCost({
|
||||
provider: "elevenlabs",
|
||||
operation: "audio",
|
||||
units: totalChars,
|
||||
costUsd: audioCostUsd(totalChars),
|
||||
episodeId: episode.id,
|
||||
userId: episode.userId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────── Stage 5: cover art ───────────────
|
||||
async function generateArt(episode: EpisodeWithRelations) {
|
||||
await setEpisodeStatus(episode.id, "ART", { stage: "Designing the cover art" });
|
||||
const prompt = buildCoverPrompt(episode.topic, episode.tone, episode.title);
|
||||
const { data, revisedPrompt } = await artProvider().generateCover(prompt);
|
||||
|
||||
const key = assetKey("art", `${episode.id}.png`);
|
||||
await storage().put(key, data, "image/png");
|
||||
|
||||
await prisma.coverArt.upsert({
|
||||
where: { episodeId: episode.id },
|
||||
create: { episodeId: episode.id, storageKey: key, prompt: revisedPrompt ?? prompt, model: artProvider().model },
|
||||
update: { storageKey: key, prompt: revisedPrompt ?? prompt },
|
||||
});
|
||||
|
||||
await recordCost({
|
||||
provider: "openai",
|
||||
operation: "art",
|
||||
units: 1,
|
||||
costUsd: artCostUsd(1),
|
||||
episodeId: episode.id,
|
||||
userId: episode.userId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────── Stage 7: meter ───────────────
|
||||
async function meter(
|
||||
episode: EpisodeWithRelations,
|
||||
did: { script: boolean; audio: boolean; art: boolean }
|
||||
) {
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
if (did.script) await incrementUsage(ownerId, ownerType, "script");
|
||||
if (did.audio) await incrementUsage(ownerId, ownerType, "audio");
|
||||
if (did.art) await incrementUsage(ownerId, ownerType, "art");
|
||||
}
|
||||
|
||||
async function notifyReady(episode: EpisodeWithRelations) {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
try {
|
||||
await sendEmail({
|
||||
to: episode.user.email,
|
||||
subject: `🎙️ "${episode.title}" is ready`,
|
||||
html: emailLayout(
|
||||
"Your episode is ready",
|
||||
`“${episode.title}” has finished generating — script, audio, and cover art are all set.`,
|
||||
{ label: "Open episode", url: `${appUrl}/episodes/${episode.id}` }
|
||||
),
|
||||
text: `Your episode "${episode.title}" is ready: ${appUrl}/episodes/${episode.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notifyReady] email failed (non-fatal)", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
import { openai, SCRIPT_MODEL } from "@/lib/ai/openai";
|
||||
import type { StructuredScript, TokenUsage } from "@/lib/ai/types";
|
||||
|
||||
export type RepurposeFormat = "blog" | "social_thread" | "newsletter";
|
||||
|
||||
const FORMAT_PROMPTS: Record<RepurposeFormat, string> = {
|
||||
blog: "Write an engaging, SEO-friendly blog post based on this episode. Include a compelling title and well-structured markdown body with headings and a short conclusion.",
|
||||
social_thread:
|
||||
"Write a punchy social thread (6–10 posts, numbered) summarizing the episode's best insights. Start with a strong hook. Put the whole thread in the markdown body.",
|
||||
newsletter:
|
||||
"Write a friendly email newsletter edition about this episode: a subject line as the title, a short intro, 3–4 key takeaways as bullets, and a call-to-action to listen. Markdown body.",
|
||||
};
|
||||
|
||||
const outputSchema = z.object({ title: z.string().min(1), body: z.string().min(1) });
|
||||
export type RepurposedOutput = z.infer<typeof outputSchema>;
|
||||
|
||||
function scriptToText(script: StructuredScript): string {
|
||||
return script.sections
|
||||
.map((s) => `## ${s.title}\n` + s.turns.map((t) => t.text).join("\n"))
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export async function repurposeScript(
|
||||
script: StructuredScript,
|
||||
format: RepurposeFormat
|
||||
): Promise<{ content: RepurposedOutput; usage: TokenUsage }> {
|
||||
const transcript = scriptToText(script).slice(0, 9000);
|
||||
const res = await openai().chat.completions.create({
|
||||
model: SCRIPT_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a content marketer who repurposes podcast episodes into other formats. Return STRICT JSON: { \"title\": string, \"body\": string } where body is markdown.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `${FORMAT_PROMPTS[format]}\n\nEpisode title: ${script.title}\n\nTranscript:\n${transcript}`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.7,
|
||||
});
|
||||
const content = outputSchema.parse(JSON.parse(res.choices[0]?.message?.content ?? "{}"));
|
||||
return {
|
||||
content,
|
||||
usage: {
|
||||
inputTokens: res.usage?.prompt_tokens ?? 0,
|
||||
outputTokens: res.usage?.completion_tokens ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DialogueTurn, ScriptSection, StructuredScript } from "../types";
|
||||
|
||||
export interface AudioSegment {
|
||||
turns: DialogueTurn[];
|
||||
characters: number;
|
||||
/** Distinct voices used in this segment (drives speech vs dialogue choice). */
|
||||
uniqueVoices: number;
|
||||
}
|
||||
|
||||
/** Map each script turn to a voice, dropping turns with empty text. */
|
||||
export function flattenTurns(
|
||||
script: StructuredScript,
|
||||
voiceMap: Record<string, string>,
|
||||
fallbackVoiceId: string
|
||||
): DialogueTurn[] {
|
||||
const turns: DialogueTurn[] = [];
|
||||
for (const section of script.sections) {
|
||||
for (const turn of section.turns) {
|
||||
const text = turn.text.trim();
|
||||
if (!text) continue;
|
||||
turns.push({ text, voiceId: voiceMap[turn.speakerKey] ?? fallbackVoiceId });
|
||||
}
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
/** Split text longer than maxChars at sentence boundaries (then hard-wrap if needed). */
|
||||
export function splitLongText(text: string, maxChars: number): string[] {
|
||||
if (text.length <= maxChars) return [text];
|
||||
const sentences = text.match(/[^.!?]+[.!?]*\s*/g) ?? [text];
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
for (const sentence of sentences) {
|
||||
if (sentence.length > maxChars) {
|
||||
// A single very long sentence — hard-wrap on whitespace.
|
||||
if (current) {
|
||||
parts.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
for (let i = 0; i < sentence.length; i += maxChars) {
|
||||
parts.push(sentence.slice(i, i + maxChars).trim());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((current + sentence).length > maxChars) {
|
||||
parts.push(current.trim());
|
||||
current = sentence;
|
||||
} else {
|
||||
current += sentence;
|
||||
}
|
||||
}
|
||||
if (current.trim()) parts.push(current.trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group dialogue turns into segments each within `maxChars`. Turns longer than
|
||||
* the limit are split (preserving their voice). Each segment is later sent to
|
||||
* ElevenLabs as one request, then all segment MP3s are stitched together.
|
||||
*/
|
||||
export function segmentTurns(turns: DialogueTurn[], maxChars: number): AudioSegment[] {
|
||||
// First expand any oversized turns into multiple sub-turns.
|
||||
const expanded: DialogueTurn[] = [];
|
||||
for (const turn of turns) {
|
||||
for (const piece of splitLongText(turn.text, maxChars)) {
|
||||
expanded.push({ text: piece, voiceId: turn.voiceId });
|
||||
}
|
||||
}
|
||||
|
||||
const segments: AudioSegment[] = [];
|
||||
let bucket: DialogueTurn[] = [];
|
||||
let chars = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (bucket.length === 0) return;
|
||||
segments.push({
|
||||
turns: bucket,
|
||||
characters: chars,
|
||||
uniqueVoices: new Set(bucket.map((t) => t.voiceId)).size,
|
||||
});
|
||||
bucket = [];
|
||||
chars = 0;
|
||||
};
|
||||
|
||||
for (const turn of expanded) {
|
||||
if (chars + turn.text.length > maxChars && bucket.length > 0) flush();
|
||||
bucket.push(turn);
|
||||
chars += turn.text.length;
|
||||
}
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Convenience: full script → audio segments. */
|
||||
export function segmentScript(
|
||||
script: StructuredScript,
|
||||
voiceMap: Record<string, string>,
|
||||
fallbackVoiceId: string,
|
||||
maxChars: number
|
||||
): AudioSegment[] {
|
||||
return segmentTurns(flattenTurns(script, voiceMap, fallbackVoiceId), maxChars);
|
||||
}
|
||||
|
||||
/** Total characters across a script (for cost/limit estimation). */
|
||||
export function totalCharacters(sections: ScriptSection[]): number {
|
||||
return sections.reduce(
|
||||
(sum, s) => sum + s.turns.reduce((n, t) => n + t.text.length, 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { runFfmpeg, ffprobeDuration } from "../ffmpeg";
|
||||
|
||||
/**
|
||||
* Concatenate per-segment MP3 buffers into one normalized episode MP3.
|
||||
*
|
||||
* Segments are re-encoded (not stream-copied) through a single libmp3lame pass
|
||||
* with loudness normalization, which guarantees a uniform codec/bitrate and
|
||||
* avoids the header/timestamp glitches that `-c copy` concat can produce.
|
||||
*/
|
||||
export async function stitchMp3(
|
||||
segments: Buffer[]
|
||||
): Promise<{ data: Buffer; durationSec: number | null }> {
|
||||
if (segments.length === 0) throw new Error("No audio segments to stitch");
|
||||
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "podcastyes-"));
|
||||
try {
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const file = path.join(dir, `seg_${String(i).padStart(4, "0")}.mp3`);
|
||||
await fs.writeFile(file, segments[i]);
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
// concat demuxer list — forward slashes so it parses on Windows and Linux.
|
||||
const listPath = path.join(dir, "list.txt");
|
||||
const listBody = files
|
||||
.map((f) => `file '${f.split(path.sep).join("/").replace(/'/g, "'\\''")}'`)
|
||||
.join("\n");
|
||||
await fs.writeFile(listPath, listBody);
|
||||
|
||||
const outPath = path.join(dir, "episode.mp3");
|
||||
await runFfmpeg([
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
listPath,
|
||||
"-af",
|
||||
"loudnorm=I=-16:TP=-1.5:LRA=11",
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-ar",
|
||||
"44100",
|
||||
outPath,
|
||||
]);
|
||||
|
||||
const data = await fs.readFile(outPath);
|
||||
const durationSec = await ffprobeDuration(outPath);
|
||||
return { data, durationSec };
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { EpisodeConfig, StructuredScript } from "../types";
|
||||
|
||||
const FORMAT_GUIDANCE: Record<EpisodeConfig["format"], string> = {
|
||||
SOLO: "A single host speaking directly to the listener. Use only the host speaker.",
|
||||
INTERVIEW:
|
||||
"A host interviewing a guest. Alternate naturally between host questions and guest answers.",
|
||||
MULTI_HOST:
|
||||
"A panel of co-hosts in lively conversation. Distribute lines across all speakers and let them react to each other.",
|
||||
};
|
||||
|
||||
/** Roughly 150 spoken words per minute → target word budget for the whole episode. */
|
||||
function wordBudget(minutes: number): number {
|
||||
return Math.round(minutes * 150);
|
||||
}
|
||||
|
||||
export function buildScriptMessages(config: EpisodeConfig) {
|
||||
const speakerList = config.speakers
|
||||
.map((s) => `- key "${s.speakerKey}" = ${s.displayName}`)
|
||||
.join("\n");
|
||||
|
||||
const system = [
|
||||
"You are an expert podcast scriptwriter and showrunner.",
|
||||
"You write natural, engaging, spoken-word scripts that sound great when read aloud by AI voices.",
|
||||
"Avoid stage directions, sound-effect notes, and parentheticals — output only spoken dialogue.",
|
||||
"Return STRICT JSON only, matching the requested schema. Do not include markdown fences.",
|
||||
].join(" ");
|
||||
|
||||
const user = [
|
||||
`Write a complete podcast episode script in ${config.language}.`,
|
||||
"",
|
||||
`Topic: ${config.topic}`,
|
||||
`Tone: ${config.tone}`,
|
||||
`Format: ${config.format} — ${FORMAT_GUIDANCE[config.format]}`,
|
||||
config.audience ? `Target audience: ${config.audience}` : "",
|
||||
`Approximate length: ${config.targetLengthMin} minutes (~${wordBudget(
|
||||
config.targetLengthMin
|
||||
)} words total).`,
|
||||
"",
|
||||
"Speakers (use ONLY these keys in `speakerKey`):",
|
||||
speakerList,
|
||||
"",
|
||||
"Structure the episode into 3–6 sections (e.g. intro, main segments, outro).",
|
||||
"Each section has a short title and a list of turns. Each turn is one speaker's spoken line.",
|
||||
"",
|
||||
"Return JSON with this exact shape:",
|
||||
`{
|
||||
"title": "string — a catchy episode title",
|
||||
"sections": [
|
||||
{
|
||||
"id": "kebab-case-id",
|
||||
"title": "string",
|
||||
"turns": [
|
||||
{ "speakerKey": "host", "text": "spoken line..." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
{ role: "system" as const, content: system },
|
||||
{ role: "user" as const, content: user },
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSectionMessages(
|
||||
config: EpisodeConfig,
|
||||
script: StructuredScript,
|
||||
sectionId: string
|
||||
) {
|
||||
const section = script.sections.find((s) => s.id === sectionId);
|
||||
const speakerList = config.speakers.map((s) => `"${s.speakerKey}"=${s.displayName}`).join(", ");
|
||||
|
||||
const system =
|
||||
"You are an expert podcast scriptwriter. Rewrite a single section of an existing episode, keeping the same speakers, tone, and language. Return STRICT JSON for just that one section.";
|
||||
|
||||
const user = [
|
||||
`Episode title: ${script.title}`,
|
||||
`Tone: ${config.tone}. Language: ${config.language}. Speakers: ${speakerList}.`,
|
||||
"",
|
||||
`Rewrite the section titled "${section?.title ?? sectionId}" (id "${sectionId}") to be fresh and engaging while serving the same purpose in the episode.`,
|
||||
"",
|
||||
"Return JSON with this exact shape:",
|
||||
`{ "id": "${sectionId}", "title": "string", "turns": [ { "speakerKey": "host", "text": "..." } ] }`,
|
||||
].join("\n");
|
||||
|
||||
return [
|
||||
{ role: "system" as const, content: system },
|
||||
{ role: "user" as const, content: user },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { AudioProvider, DialogueTurn, Voice } from "../types";
|
||||
|
||||
const API = "https://api.elevenlabs.io/v1";
|
||||
const TTS_MODEL = process.env.ELEVENLABS_TTS_MODEL ?? "eleven_multilingual_v2";
|
||||
const DIALOGUE_MODEL = process.env.ELEVENLABS_DIALOGUE_MODEL ?? "eleven_v3";
|
||||
const OUTPUT_FORMAT = "mp3_44100_128";
|
||||
|
||||
function apiKey(): string {
|
||||
const k = process.env.ELEVENLABS_API_KEY;
|
||||
if (!k) throw new Error("ELEVENLABS_API_KEY is not set");
|
||||
return k;
|
||||
}
|
||||
|
||||
interface ElevenVoice {
|
||||
voice_id: string;
|
||||
name: string;
|
||||
preview_url?: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class ElevenLabsAudioProvider implements AudioProvider {
|
||||
// Kept safely under the ~2,000-char dialogue limit to leave headroom.
|
||||
readonly maxCharsPerRequest = 1800;
|
||||
|
||||
async synthesizeSpeech(
|
||||
text: string,
|
||||
voiceId: string,
|
||||
_opts?: { language?: string }
|
||||
): Promise<{ audio: Buffer; characters: number }> {
|
||||
const res = await fetch(
|
||||
`${API}/text-to-speech/${voiceId}?output_format=${OUTPUT_FORMAT}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": apiKey(),
|
||||
"Content-Type": "application/json",
|
||||
accept: "audio/mpeg",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: TTS_MODEL,
|
||||
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`ElevenLabs TTS ${res.status}: ${await safeText(res)}`);
|
||||
return { audio: Buffer.from(await res.arrayBuffer()), characters: text.length };
|
||||
}
|
||||
|
||||
async synthesizeDialogue(
|
||||
turns: DialogueTurn[],
|
||||
_opts?: { language?: string }
|
||||
): Promise<{ audio: Buffer; characters: number }> {
|
||||
const res = await fetch(`${API}/text-to-dialogue?output_format=${OUTPUT_FORMAT}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": apiKey(),
|
||||
"Content-Type": "application/json",
|
||||
accept: "audio/mpeg",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputs: turns.map((t) => ({ text: t.text, voice_id: t.voiceId })),
|
||||
model_id: DIALOGUE_MODEL,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ElevenLabs dialogue ${res.status}: ${await safeText(res)}`);
|
||||
const characters = turns.reduce((n, t) => n + t.text.length, 0);
|
||||
return { audio: Buffer.from(await res.arrayBuffer()), characters };
|
||||
}
|
||||
|
||||
async listVoices(): Promise<Voice[]> {
|
||||
const res = await fetch(`${API}/voices`, { headers: { "xi-api-key": apiKey() } });
|
||||
if (!res.ok) throw new Error(`ElevenLabs voices ${res.status}`);
|
||||
const data = (await res.json()) as { voices?: ElevenVoice[] };
|
||||
return (data.voices ?? []).map((v) => ({
|
||||
id: v.voice_id,
|
||||
name: v.name,
|
||||
gender: normalizeGender(v.labels?.gender),
|
||||
accent: v.labels?.accent,
|
||||
description: v.labels?.description,
|
||||
previewUrl: v.preview_url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGender(g?: string): Voice["gender"] {
|
||||
if (g === "male" || g === "female") return g;
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
async function safeText(res: Response): Promise<string> {
|
||||
try {
|
||||
return await res.text();
|
||||
} catch {
|
||||
return res.statusText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { OpenAIScriptProvider } from "./openai-script";
|
||||
import { ElevenLabsAudioProvider } from "./elevenlabs-audio";
|
||||
import { OpenAIArtProvider } from "./openai-art";
|
||||
import type { ArtProvider, AudioProvider, ScriptProvider } from "../types";
|
||||
|
||||
// Registry of active providers. Swapping a model later = change one line here.
|
||||
let script: ScriptProvider | null = null;
|
||||
let audio: AudioProvider | null = null;
|
||||
let art: ArtProvider | null = null;
|
||||
|
||||
export function scriptProvider(): ScriptProvider {
|
||||
return (script ??= new OpenAIScriptProvider());
|
||||
}
|
||||
|
||||
export function audioProvider(): AudioProvider {
|
||||
return (audio ??= new ElevenLabsAudioProvider());
|
||||
}
|
||||
|
||||
export function artProvider(): ArtProvider {
|
||||
return (art ??= new OpenAIArtProvider());
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { openai, ART_MODEL } from "../openai";
|
||||
import type { ArtProvider } from "../types";
|
||||
|
||||
export class OpenAIArtProvider implements ArtProvider {
|
||||
readonly model = ART_MODEL;
|
||||
|
||||
async generateCover(
|
||||
prompt: string,
|
||||
opts?: { size?: "1024x1024" }
|
||||
): Promise<{ data: Buffer; revisedPrompt?: string }> {
|
||||
const res = await openai().images.generate({
|
||||
model: this.model,
|
||||
prompt,
|
||||
n: 1,
|
||||
size: opts?.size ?? "1024x1024",
|
||||
response_format: "b64_json",
|
||||
});
|
||||
const item = res.data?.[0];
|
||||
if (!item?.b64_json) throw new Error("DALL·E returned no image data");
|
||||
return {
|
||||
data: Buffer.from(item.b64_json, "base64"),
|
||||
revisedPrompt: item.revised_prompt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a cover-art prompt for an episode topic. */
|
||||
export function buildCoverPrompt(topic: string, tone: string, title?: string): string {
|
||||
return [
|
||||
`Podcast cover art for an episode titled "${title ?? topic}".`,
|
||||
`Topic: ${topic}. Mood/tone: ${tone}.`,
|
||||
"Modern, bold, eye-catching square album-cover style.",
|
||||
"Strong focal subject, clean composition, vibrant but tasteful colors.",
|
||||
"No text, no words, no letters, no logos.",
|
||||
].join(" ");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
import { openai, SCRIPT_MODEL } from "../openai";
|
||||
import { buildScriptMessages, buildSectionMessages } from "../prompts/script";
|
||||
import type {
|
||||
EpisodeConfig,
|
||||
ScriptProvider,
|
||||
ScriptSection,
|
||||
StructuredScript,
|
||||
TokenUsage,
|
||||
} from "../types";
|
||||
|
||||
const turnSchema = z.object({
|
||||
speakerKey: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
});
|
||||
|
||||
const sectionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
turns: z.array(turnSchema).min(1),
|
||||
});
|
||||
|
||||
const scriptSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
sections: z.array(sectionSchema).min(1),
|
||||
});
|
||||
|
||||
/** Coerce/repair speakerKeys the model may have invented to the configured set. */
|
||||
function normalizeSpeakers(script: StructuredScript, config: EpisodeConfig): StructuredScript {
|
||||
const valid = new Set(config.speakers.map((s) => s.speakerKey));
|
||||
const fallback = config.speakers[0]?.speakerKey ?? "host";
|
||||
return {
|
||||
...script,
|
||||
sections: script.sections.map((sec) => ({
|
||||
...sec,
|
||||
turns: sec.turns.map((t) => ({
|
||||
...t,
|
||||
speakerKey: valid.has(t.speakerKey) ? t.speakerKey : fallback,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function usageFrom(u: { prompt_tokens?: number; completion_tokens?: number } | undefined): TokenUsage {
|
||||
return { inputTokens: u?.prompt_tokens ?? 0, outputTokens: u?.completion_tokens ?? 0 };
|
||||
}
|
||||
|
||||
export class OpenAIScriptProvider implements ScriptProvider {
|
||||
readonly model = SCRIPT_MODEL;
|
||||
|
||||
async generate(config: EpisodeConfig): Promise<{ script: StructuredScript; usage: TokenUsage }> {
|
||||
const res = await openai().chat.completions.create({
|
||||
model: this.model,
|
||||
messages: buildScriptMessages(config),
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.8,
|
||||
});
|
||||
const content = res.choices[0]?.message?.content ?? "{}";
|
||||
const parsed = scriptSchema.parse(JSON.parse(content));
|
||||
return { script: normalizeSpeakers(parsed, config), usage: usageFrom(res.usage) };
|
||||
}
|
||||
|
||||
async regenerateSection(
|
||||
config: EpisodeConfig,
|
||||
script: StructuredScript,
|
||||
sectionId: string
|
||||
): Promise<{ section: ScriptSection; usage: TokenUsage }> {
|
||||
const res = await openai().chat.completions.create({
|
||||
model: this.model,
|
||||
messages: buildSectionMessages(config, script, sectionId),
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.9,
|
||||
});
|
||||
const content = res.choices[0]?.message?.content ?? "{}";
|
||||
const section = sectionSchema.parse(JSON.parse(content));
|
||||
const valid = new Set(config.speakers.map((s) => s.speakerKey));
|
||||
const fallback = config.speakers[0]?.speakerKey ?? "host";
|
||||
return {
|
||||
section: {
|
||||
...section,
|
||||
id: sectionId,
|
||||
turns: section.turns.map((t) => ({
|
||||
...t,
|
||||
speakerKey: valid.has(t.speakerKey) ? t.speakerKey : fallback,
|
||||
})),
|
||||
},
|
||||
usage: usageFrom(res.usage),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
import { openai, SCRIPT_MODEL } from "./openai";
|
||||
import type { TokenUsage } from "./types";
|
||||
|
||||
const seasonSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
episodes: z
|
||||
.array(z.object({ title: z.string().min(1), topic: z.string().min(1), summary: z.string().min(1) }))
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export type SeasonPlan = z.infer<typeof seasonSchema>;
|
||||
|
||||
export async function planSeason(input: {
|
||||
theme: string;
|
||||
count: number;
|
||||
tone: string;
|
||||
audience?: string;
|
||||
language: string;
|
||||
}): Promise<{ plan: SeasonPlan; usage: TokenUsage }> {
|
||||
const res = await openai().chat.completions.create({
|
||||
model: SCRIPT_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a podcast showrunner planning a cohesive season. Return STRICT JSON: { \"title\": string, \"description\": string, \"episodes\": [{ \"title\": string, \"topic\": string, \"summary\": string }] }.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Plan a ${input.count}-episode podcast season about: ${input.theme}.
|
||||
Tone: ${input.tone}. ${input.audience ? `Audience: ${input.audience}.` : ""} Language: ${input.language}.
|
||||
Give the season a title and short description, then ${input.count} episodes, each with a catchy title, a specific topic to cover, and a one-sentence summary.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.85,
|
||||
});
|
||||
const plan = seasonSchema.parse(JSON.parse(res.choices[0]?.message?.content ?? "{}"));
|
||||
return {
|
||||
plan,
|
||||
usage: {
|
||||
inputTokens: res.usage?.prompt_tokens ?? 0,
|
||||
outputTokens: res.usage?.completion_tokens ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Provider abstraction for the three AI capabilities. Each capability has a thin
|
||||
* interface so the underlying model (GPT-4, ElevenLabs, DALL·E) can be swapped
|
||||
* via the registry in providers/index.ts without touching call sites.
|
||||
*/
|
||||
|
||||
export type EpisodeFormat = "SOLO" | "INTERVIEW" | "MULTI_HOST";
|
||||
|
||||
export interface SpeakerRole {
|
||||
/** Stable key referenced by script turns, e.g. "host", "guest", "cohost". */
|
||||
speakerKey: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface EpisodeConfig {
|
||||
title?: string;
|
||||
topic: string;
|
||||
tone: string;
|
||||
format: EpisodeFormat;
|
||||
/** ISO language code, e.g. "en", "es". */
|
||||
language: string;
|
||||
targetLengthMin: number;
|
||||
audience?: string;
|
||||
speakers: SpeakerRole[];
|
||||
}
|
||||
|
||||
// ─────────────── Script ───────────────
|
||||
|
||||
export interface ScriptTurn {
|
||||
speakerKey: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ScriptSection {
|
||||
id: string;
|
||||
title: string;
|
||||
turns: ScriptTurn[];
|
||||
}
|
||||
|
||||
export interface StructuredScript {
|
||||
title: string;
|
||||
sections: ScriptSection[];
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface ScriptProvider {
|
||||
readonly model: string;
|
||||
generate(config: EpisodeConfig): Promise<{ script: StructuredScript; usage: TokenUsage }>;
|
||||
regenerateSection(
|
||||
config: EpisodeConfig,
|
||||
script: StructuredScript,
|
||||
sectionId: string
|
||||
): Promise<{ section: ScriptSection; usage: TokenUsage }>;
|
||||
}
|
||||
|
||||
// ─────────────── Audio ───────────────
|
||||
|
||||
export interface Voice {
|
||||
id: string;
|
||||
name: string;
|
||||
gender?: "male" | "female" | "neutral";
|
||||
accent?: string;
|
||||
description?: string;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
/** One line of multi-voice dialogue. */
|
||||
export interface DialogueTurn {
|
||||
text: string;
|
||||
voiceId: string;
|
||||
}
|
||||
|
||||
export interface AudioProvider {
|
||||
/** Synthesize a single voice reading (used for SOLO and as a fallback). */
|
||||
synthesizeSpeech(
|
||||
text: string,
|
||||
voiceId: string,
|
||||
opts?: { language?: string }
|
||||
): Promise<{ audio: Buffer; characters: number }>;
|
||||
/** Synthesize a multi-voice dialogue chunk (≤ provider char limit, ≤10 voices). */
|
||||
synthesizeDialogue(
|
||||
turns: DialogueTurn[],
|
||||
opts?: { language?: string }
|
||||
): Promise<{ audio: Buffer; characters: number }>;
|
||||
/** Live voice catalog for the account. */
|
||||
listVoices(): Promise<Voice[]>;
|
||||
/** Hard cap on characters per synthesis request (drives segmentation). */
|
||||
readonly maxCharsPerRequest: number;
|
||||
}
|
||||
|
||||
// ─────────────── Art ───────────────
|
||||
|
||||
export interface ArtProvider {
|
||||
readonly model: string;
|
||||
generateCover(
|
||||
prompt: string,
|
||||
opts?: { size?: "1024x1024" }
|
||||
): Promise<{ data: Buffer; revisedPrompt?: string }>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Voice } from "./types";
|
||||
|
||||
/**
|
||||
* Curated catalog of ElevenLabs premade voices (stable public voice IDs available
|
||||
* to all accounts). Used by the create-episode wizard so it can render the voice
|
||||
* picker without a live API call. The provider's listVoices() returns the live
|
||||
* account catalog when needed.
|
||||
*/
|
||||
export const VOICE_CATALOG: Voice[] = [
|
||||
{ id: "21m00Tcm4TlvDq8ikWAM", name: "Rachel", gender: "female", accent: "American", description: "Calm, narrational" },
|
||||
{ id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", gender: "female", accent: "American", description: "Soft, news" },
|
||||
{ id: "FGY2WhTYpPnrIDTdsKH5", name: "Laura", gender: "female", accent: "American", description: "Upbeat, social" },
|
||||
{ id: "XB0fDUnXU5powFXDhCwa", name: "Charlotte", gender: "female", accent: "British", description: "Warm, seductive" },
|
||||
{ id: "XrExE9yKIg1WjnnlVkGX", name: "Matilda", gender: "female", accent: "American", description: "Friendly, warm" },
|
||||
{ id: "pFZP5JQG7iQjIQuC4Bku", name: "Lily", gender: "female", accent: "British", description: "Confident narration" },
|
||||
{ id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", gender: "female", accent: "American", description: "Expressive, young" },
|
||||
{ id: "9BWtsMINqrJLrRacOk9x", name: "Aria", gender: "female", accent: "American", description: "Husky, expressive" },
|
||||
{ id: "pNInz6obpgDQGcFmaJgB", name: "Adam", gender: "male", accent: "American", description: "Deep, narration" },
|
||||
{ id: "JBFqnCBsd6RMkjVDRZzb", name: "George", gender: "male", accent: "British", description: "Warm, mature" },
|
||||
{ id: "TX3LPaxmHKxFdv7VOQHJ", name: "Liam", gender: "male", accent: "American", description: "Articulate, young" },
|
||||
{ id: "onwK4e9ZLuTAKqWW03F9", name: "Daniel", gender: "male", accent: "British", description: "Authoritative, news" },
|
||||
{ id: "nPczCjzI2devNBz1zQrb", name: "Brian", gender: "male", accent: "American", description: "Deep, mature" },
|
||||
{ id: "iP95p4xoKVk53GoZ742B", name: "Chris", gender: "male", accent: "American", description: "Casual, conversational" },
|
||||
{ id: "bIHbv24MWmeRgasZH58o", name: "Will", gender: "male", accent: "American", description: "Friendly, chill" },
|
||||
{ id: "cjVigY5qzO86Huf0OWal", name: "Eric", gender: "male", accent: "American", description: "Smooth, classy" },
|
||||
];
|
||||
|
||||
export const DEFAULT_VOICE_IDS: Record<string, string> = {
|
||||
host: "21m00Tcm4TlvDq8ikWAM", // Rachel
|
||||
guest: "pNInz6obpgDQGcFmaJgB", // Adam
|
||||
cohost: "JBFqnCBsd6RMkjVDRZzb", // George
|
||||
};
|
||||
|
||||
export function voiceById(id: string): Voice | undefined {
|
||||
return VOICE_CATALOG.find((v) => v.id === id);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import crypto from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const PREFIX = "pky_live_";
|
||||
|
||||
export function generateRawKey(): string {
|
||||
return PREFIX + crypto.randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
export function hashKey(raw: string): string {
|
||||
return crypto.createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
/** Display-only prefix, e.g. "pky_live_abc123…". */
|
||||
export function keyPreview(raw: string): string {
|
||||
return raw.slice(0, PREFIX.length + 6) + "…";
|
||||
}
|
||||
|
||||
/** Verify a raw API key and return its owner, updating lastUsedAt. */
|
||||
export async function verifyApiKey(raw: string | null): Promise<{ userId: string } | null> {
|
||||
if (!raw || !raw.startsWith(PREFIX)) return null;
|
||||
const key = await prisma.apiKey.findUnique({
|
||||
where: { hashedKey: hashKey(raw) },
|
||||
select: { id: true, userId: true, revokedAt: true },
|
||||
});
|
||||
if (!key || key.revokedAt) return null;
|
||||
void prisma.apiKey.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
|
||||
return { userId: key.userId };
|
||||
}
|
||||
|
||||
/** Extract a bearer key from an Authorization header. */
|
||||
export function bearerKey(authorization: string | null): string | null {
|
||||
if (!authorization) return null;
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { adminClient, organizationClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? undefined,
|
||||
plugins: [adminClient(), organizationClient()],
|
||||
});
|
||||
|
||||
export const { signIn, signUp, signOut, useSession, useActiveOrganization, organization } =
|
||||
authClient;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { admin, organization } from "better-auth/plugins";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sendEmail, emailLayout } from "@/lib/email";
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
|
||||
const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "PodcastYes",
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
baseURL: process.env.BETTER_AUTH_URL ?? appUrl,
|
||||
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
// Flip on once email delivery is verified in prod.
|
||||
requireEmailVerification: false,
|
||||
minPasswordLength: 8,
|
||||
async sendResetPassword({ user, url }) {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your PodcastYes password",
|
||||
html: emailLayout(
|
||||
"Reset your password",
|
||||
"Click the button below to choose a new password.",
|
||||
{ label: "Reset password", url }
|
||||
),
|
||||
text: `Reset your password: ${url}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
emailVerification: {
|
||||
sendOnSignUp: false,
|
||||
async sendVerificationEmail({ user, url }) {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your email for PodcastYes",
|
||||
html: emailLayout(
|
||||
"Confirm your email",
|
||||
"Confirm your email address to secure your account.",
|
||||
{ label: "Verify email", url }
|
||||
),
|
||||
text: `Verify your email: ${url}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
socialProviders: googleConfigured
|
||||
? {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||
updateAge: 60 * 60 * 24, // refresh daily
|
||||
cookieCache: { enabled: true, maxAge: 5 * 60 },
|
||||
},
|
||||
|
||||
account: {
|
||||
accountLinking: { enabled: true, trustedProviders: ["google"] },
|
||||
},
|
||||
|
||||
plugins: [
|
||||
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
||||
organization({
|
||||
teams: { enabled: true, maximumTeams: 1 },
|
||||
// Agency seat cap is enforced in app logic against the subscription's seat count.
|
||||
membershipLimit: 5,
|
||||
}),
|
||||
// Must remain last: lets Server Actions / route handlers set auth cookies.
|
||||
nextCookies(),
|
||||
],
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
@@ -0,0 +1,35 @@
|
||||
import "server-only";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/** Returns the current session (or null) using request headers. */
|
||||
export async function getServerSession() {
|
||||
return auth.api.getSession({ headers: await headers() });
|
||||
}
|
||||
|
||||
/** Require a logged-in user; redirect to sign-in otherwise. */
|
||||
export async function requireAuth(redirectTo?: string) {
|
||||
const session = await getServerSession();
|
||||
if (!session) {
|
||||
const target = redirectTo ? `?redirect=${encodeURIComponent(redirectTo)}` : "";
|
||||
redirect(`/sign-in${target}`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a platform admin. Returns 404 (not 403) for non-admins so the admin
|
||||
* surface isn't disclosed to ordinary users.
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const session = await getServerSession();
|
||||
if (!session || session.user.role !== "admin") notFound();
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Convenience: the active organization id from the session (if any). */
|
||||
export async function getActiveOrgId() {
|
||||
const session = await getServerSession();
|
||||
return session?.session.activeOrganizationId ?? null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PLAN_ORDER, type PlanKey } from "./plans";
|
||||
|
||||
export type BillingInterval = "month" | "year";
|
||||
|
||||
/**
|
||||
* Maps plan keys ↔ provider price/plan IDs, read from env. This is the bridge
|
||||
* between our provider-agnostic plan catalog and Stripe/PayPal. Set the matching
|
||||
* env vars (STRIPE_PRICE_*, PAYPAL_PLAN_*) for the paid tiers.
|
||||
*/
|
||||
const STRIPE_PRICE_ENV: Record<Exclude<PlanKey, "free">, Record<BillingInterval, string | undefined>> = {
|
||||
creator: { month: process.env.STRIPE_PRICE_CREATOR_MONTHLY, year: process.env.STRIPE_PRICE_CREATOR_YEARLY },
|
||||
pro: { month: process.env.STRIPE_PRICE_PRO_MONTHLY, year: process.env.STRIPE_PRICE_PRO_YEARLY },
|
||||
agency: { month: process.env.STRIPE_PRICE_AGENCY_MONTHLY, year: process.env.STRIPE_PRICE_AGENCY_YEARLY },
|
||||
};
|
||||
|
||||
// PayPal plans are created per tier (one billing cycle each); we key by tier and
|
||||
// optionally distinguish interval if you create yearly PayPal plans too.
|
||||
const PAYPAL_PLAN_ENV: Record<Exclude<PlanKey, "free">, string | undefined> = {
|
||||
creator: process.env.PAYPAL_PLAN_CREATOR,
|
||||
pro: process.env.PAYPAL_PLAN_PRO,
|
||||
agency: process.env.PAYPAL_PLAN_AGENCY,
|
||||
};
|
||||
|
||||
export function stripePriceId(plan: PlanKey, interval: BillingInterval): string | undefined {
|
||||
if (plan === "free") return undefined;
|
||||
return STRIPE_PRICE_ENV[plan]?.[interval];
|
||||
}
|
||||
|
||||
export function planFromStripePrice(priceId: string): { plan: PlanKey; interval: BillingInterval } | null {
|
||||
for (const plan of PLAN_ORDER) {
|
||||
if (plan === "free") continue;
|
||||
const intervals = STRIPE_PRICE_ENV[plan];
|
||||
if (intervals.month === priceId) return { plan, interval: "month" };
|
||||
if (intervals.year === priceId) return { plan, interval: "year" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function paypalPlanId(plan: PlanKey): string | undefined {
|
||||
if (plan === "free") return undefined;
|
||||
return PAYPAL_PLAN_ENV[plan];
|
||||
}
|
||||
|
||||
export function planFromPaypalPlan(paypalPlanId: string): PlanKey | null {
|
||||
for (const plan of PLAN_ORDER) {
|
||||
if (plan === "free") continue;
|
||||
if (PAYPAL_PLAN_ENV[plan] === paypalPlanId) return plan;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PlanKey } from "./plans";
|
||||
|
||||
const base = () => process.env.PAYPAL_API_BASE ?? "https://api-m.sandbox.paypal.com";
|
||||
const appUrl = () => process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
|
||||
function creds(): { id: string; secret: string } {
|
||||
const id = process.env.PAYPAL_CLIENT_ID;
|
||||
const secret = process.env.PAYPAL_CLIENT_SECRET;
|
||||
if (!id || !secret) throw new Error("PayPal credentials are not set");
|
||||
return { id, secret };
|
||||
}
|
||||
|
||||
export function isPaypalConfigured(): boolean {
|
||||
return !!(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
async function accessToken(): Promise<string> {
|
||||
const { id, secret } = creds();
|
||||
const res = await fetch(`${base()}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
if (!res.ok) throw new Error(`PayPal token error ${res.status}`);
|
||||
const data = (await res.json()) as { access_token: string };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
export interface PaypalCustom {
|
||||
subjectId: string;
|
||||
subjectType: "user" | "organization";
|
||||
plan: PlanKey;
|
||||
}
|
||||
|
||||
/** Create a PayPal subscription; returns the subscription id + approval URL. */
|
||||
export async function createPaypalSubscription(args: {
|
||||
planId: string;
|
||||
custom: PaypalCustom;
|
||||
}): Promise<{ id: string; approveUrl: string }> {
|
||||
const token = await accessToken();
|
||||
const res = await fetch(`${base()}/v1/billing/subscriptions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan_id: args.planId,
|
||||
custom_id: JSON.stringify(args.custom),
|
||||
application_context: {
|
||||
brand_name: "PodcastYes",
|
||||
user_action: "SUBSCRIBE_NOW",
|
||||
shipping_preference: "NO_SHIPPING",
|
||||
return_url: `${appUrl()}/billing?status=success&provider=paypal`,
|
||||
cancel_url: `${appUrl()}/billing?status=cancel`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`PayPal create subscription ${res.status}: ${await res.text()}`);
|
||||
const data = (await res.json()) as { id: string; links: { rel: string; href: string }[] };
|
||||
const approveUrl = data.links.find((l) => l.rel === "approve")?.href;
|
||||
if (!approveUrl) throw new Error("PayPal did not return an approval URL");
|
||||
return { id: data.id, approveUrl };
|
||||
}
|
||||
|
||||
export async function getPaypalSubscription(id: string): Promise<Record<string, unknown>> {
|
||||
const token = await accessToken();
|
||||
const res = await fetch(`${base()}/v1/billing/subscriptions/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`PayPal get subscription ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function cancelPaypalSubscription(id: string, reason = "Customer requested"): Promise<void> {
|
||||
const token = await accessToken();
|
||||
const res = await fetch(`${base()}/v1/billing/subscriptions/${id}/cancel`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
throw new Error(`PayPal cancel subscription ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a PayPal webhook signature against PAYPAL_WEBHOOK_ID. */
|
||||
export async function verifyPaypalWebhook(
|
||||
headers: Record<string, string | undefined>,
|
||||
rawBody: string
|
||||
): Promise<boolean> {
|
||||
const webhookId = process.env.PAYPAL_WEBHOOK_ID;
|
||||
if (!webhookId) return false;
|
||||
const token = await accessToken();
|
||||
const res = await fetch(`${base()}/v1/notifications/verify-webhook-signature`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
auth_algo: headers["paypal-auth-algo"],
|
||||
cert_url: headers["paypal-cert-url"],
|
||||
transmission_id: headers["paypal-transmission-id"],
|
||||
transmission_sig: headers["paypal-transmission-sig"],
|
||||
transmission_time: headers["paypal-transmission-time"],
|
||||
webhook_id: webhookId,
|
||||
webhook_event: JSON.parse(rawBody),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = (await res.json()) as { verification_status: string };
|
||||
return data.verification_status === "SUCCESS";
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Plan catalog — the single source of truth for tiers, limits, and features.
|
||||
* Provider-agnostic: Stripe and PayPal both map onto these plan keys, and
|
||||
* `enforceLimit` / feature-gating read from here (never from a payment provider).
|
||||
*
|
||||
* Prices are in cents. A limit of `UNLIMITED` (-1) means no cap.
|
||||
*/
|
||||
|
||||
export const UNLIMITED = -1;
|
||||
|
||||
export type PlanKey = "free" | "creator" | "pro" | "agency";
|
||||
|
||||
export type FeatureKey =
|
||||
| "multi_voice"
|
||||
| "cover_art"
|
||||
| "content_repurposing"
|
||||
| "all_languages"
|
||||
| "ai_cohost"
|
||||
| "series_generator"
|
||||
| "api_access"
|
||||
| "team_workspace"
|
||||
| "white_label"
|
||||
| "custom_branding"
|
||||
| "priority_generation";
|
||||
|
||||
export type UsageMetric = "script" | "audio" | "art" | "repurpose";
|
||||
|
||||
export interface PlanLimits {
|
||||
/** Monthly caps per metric; UNLIMITED (-1) = no cap. */
|
||||
script: number;
|
||||
audio: number;
|
||||
art: number;
|
||||
repurpose: number;
|
||||
/** Seats in the team workspace (Agency). 1 for individual plans. */
|
||||
seats: number;
|
||||
/** Max length of a generated episode, in minutes. */
|
||||
maxEpisodeMinutes: number;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
key: PlanKey;
|
||||
name: string;
|
||||
/** Short marketing tagline. */
|
||||
tagline: string;
|
||||
priceMonthly: number; // cents
|
||||
priceYearly: number; // cents (≈ 2 months free)
|
||||
highlight?: boolean; // "most popular"
|
||||
limits: PlanLimits;
|
||||
features: FeatureKey[];
|
||||
/** Human-readable bullet points for the pricing page. */
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export const PLANS: Record<PlanKey, Plan> = {
|
||||
free: {
|
||||
key: "free",
|
||||
name: "Free",
|
||||
tagline: "Try it out, no card required.",
|
||||
priceMonthly: 0,
|
||||
priceYearly: 0,
|
||||
limits: { script: 3, audio: 1, art: 3, repurpose: 1, seats: 1, maxEpisodeMinutes: 5 },
|
||||
features: ["multi_voice", "cover_art"],
|
||||
bullets: [
|
||||
"3 scripts / month",
|
||||
"1 audio generation / month",
|
||||
"Up to 5-minute episodes",
|
||||
"2 narrator voices",
|
||||
"Cover art generation",
|
||||
],
|
||||
},
|
||||
creator: {
|
||||
key: "creator",
|
||||
name: "Creator",
|
||||
tagline: "For solo creators shipping regularly.",
|
||||
priceMonthly: 900,
|
||||
priceYearly: 9000,
|
||||
limits: { script: 50, audio: 20, art: 50, repurpose: 50, seats: 1, maxEpisodeMinutes: 20 },
|
||||
features: ["multi_voice", "cover_art", "content_repurposing", "all_languages"],
|
||||
bullets: [
|
||||
"50 scripts / month",
|
||||
"20 audio generations / month",
|
||||
"Up to 20-minute episodes",
|
||||
"All 14+ voices",
|
||||
"13+ languages",
|
||||
"Content repurposing (blog, social, newsletter)",
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
key: "pro",
|
||||
name: "Pro",
|
||||
tagline: "For serious podcasters and small studios.",
|
||||
priceMonthly: 2900,
|
||||
priceYearly: 29000,
|
||||
highlight: true,
|
||||
limits: { script: UNLIMITED, audio: 100, art: UNLIMITED, repurpose: UNLIMITED, seats: 1, maxEpisodeMinutes: 45 },
|
||||
features: [
|
||||
"multi_voice",
|
||||
"cover_art",
|
||||
"content_repurposing",
|
||||
"all_languages",
|
||||
"ai_cohost",
|
||||
"series_generator",
|
||||
"api_access",
|
||||
"priority_generation",
|
||||
],
|
||||
bullets: [
|
||||
"Unlimited scripts",
|
||||
"100 audio generations / month",
|
||||
"Up to 45-minute episodes",
|
||||
"AI co-host mode",
|
||||
"Series & season generator",
|
||||
"API access",
|
||||
"Priority generation queue",
|
||||
],
|
||||
},
|
||||
agency: {
|
||||
key: "agency",
|
||||
name: "Agency",
|
||||
tagline: "For teams and white-label studios.",
|
||||
priceMonthly: 7900,
|
||||
priceYearly: 79000,
|
||||
limits: { script: UNLIMITED, audio: UNLIMITED, art: UNLIMITED, repurpose: UNLIMITED, seats: 5, maxEpisodeMinutes: 90 },
|
||||
features: [
|
||||
"multi_voice",
|
||||
"cover_art",
|
||||
"content_repurposing",
|
||||
"all_languages",
|
||||
"ai_cohost",
|
||||
"series_generator",
|
||||
"api_access",
|
||||
"priority_generation",
|
||||
"team_workspace",
|
||||
"white_label",
|
||||
"custom_branding",
|
||||
],
|
||||
bullets: [
|
||||
"Everything in Pro, unlimited",
|
||||
"5-seat team workspace",
|
||||
"White-label mode",
|
||||
"Custom branding",
|
||||
"Up to 90-minute episodes",
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const PLAN_ORDER: PlanKey[] = ["free", "creator", "pro", "agency"];
|
||||
|
||||
export function getPlan(key: string | null | undefined): Plan {
|
||||
if (key && key in PLANS) return PLANS[key as PlanKey];
|
||||
return PLANS.free;
|
||||
}
|
||||
|
||||
export function planHasFeature(key: PlanKey, feature: FeatureKey): boolean {
|
||||
return PLANS[key].features.includes(feature);
|
||||
}
|
||||
|
||||
/** True when `count` is within the plan's cap for `metric` (UNLIMITED always passes). */
|
||||
export function withinLimit(key: PlanKey, metric: UsageMetric, count: number): boolean {
|
||||
const limit = PLANS[key].limits[metric];
|
||||
return limit === UNLIMITED || count < limit;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Stripe from "stripe";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { stripePriceId, type BillingInterval } from "./catalog";
|
||||
import type { PlanKey } from "./plans";
|
||||
|
||||
let client: Stripe | null = null;
|
||||
|
||||
export function stripe(): Stripe {
|
||||
if (!client) {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
|
||||
client = new Stripe(key);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export function isStripeConfigured(): boolean {
|
||||
return !!process.env.STRIPE_SECRET_KEY;
|
||||
}
|
||||
|
||||
const appUrl = () => process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
|
||||
/** Find-or-create the Stripe customer for a user and persist the id. */
|
||||
export async function ensureStripeCustomer(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
stripeCustomerId: string | null;
|
||||
}): Promise<string> {
|
||||
if (user.stripeCustomerId) return user.stripeCustomerId;
|
||||
const customer = await stripe().customers.create({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
metadata: { userId: user.id },
|
||||
});
|
||||
await prisma.user.update({ where: { id: user.id }, data: { stripeCustomerId: customer.id } });
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
/** Create a Stripe Checkout Session for a subscription; returns the redirect URL. */
|
||||
export async function createStripeCheckout(args: {
|
||||
user: { id: string; email: string; name: string; stripeCustomerId: string | null };
|
||||
plan: PlanKey;
|
||||
interval: BillingInterval;
|
||||
subjectId: string;
|
||||
subjectType: "user" | "organization";
|
||||
}): Promise<string> {
|
||||
const price = stripePriceId(args.plan, args.interval);
|
||||
if (!price) throw new Error(`No Stripe price configured for ${args.plan}/${args.interval}`);
|
||||
const customer = await ensureStripeCustomer(args.user);
|
||||
|
||||
const session = await stripe().checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
customer,
|
||||
line_items: [{ price, quantity: 1 }],
|
||||
client_reference_id: args.subjectId,
|
||||
metadata: { plan: args.plan, subjectId: args.subjectId, subjectType: args.subjectType },
|
||||
subscription_data: {
|
||||
metadata: { plan: args.plan, subjectId: args.subjectId, subjectType: args.subjectType },
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${appUrl()}/billing?status=success`,
|
||||
cancel_url: `${appUrl()}/billing?status=cancel`,
|
||||
});
|
||||
if (!session.url) throw new Error("Stripe did not return a checkout URL");
|
||||
return session.url;
|
||||
}
|
||||
|
||||
/** Create a Stripe Billing Portal session for managing/cancelling a subscription. */
|
||||
export async function createStripePortal(customerId: string): Promise<string> {
|
||||
const session = await stripe().billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: `${appUrl()}/billing`,
|
||||
});
|
||||
return session.url;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getPlan, type Plan, type PlanKey, type FeatureKey } from "./plans";
|
||||
|
||||
export interface UpsertSubscriptionInput {
|
||||
provider: "stripe" | "paypal";
|
||||
referenceId: string;
|
||||
plan: PlanKey;
|
||||
status: string;
|
||||
billingInterval?: "month" | "year" | null;
|
||||
seats?: number | null;
|
||||
stripeCustomerId?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
paypalSubscriptionId?: string | null;
|
||||
paypalPlanId?: string | null;
|
||||
periodStart?: Date | null;
|
||||
periodEnd?: Date | null;
|
||||
cancelAtPeriodEnd?: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single writer both billing providers funnel into. Idempotent on the
|
||||
* provider subscription id, so duplicate/replayed webhooks converge on one row.
|
||||
*/
|
||||
export async function upsertSubscription(input: UpsertSubscriptionInput) {
|
||||
const existing = input.stripeSubscriptionId
|
||||
? await prisma.subscription.findFirst({
|
||||
where: { stripeSubscriptionId: input.stripeSubscriptionId },
|
||||
})
|
||||
: input.paypalSubscriptionId
|
||||
? await prisma.subscription.findFirst({
|
||||
where: { paypalSubscriptionId: input.paypalSubscriptionId },
|
||||
})
|
||||
: null;
|
||||
|
||||
const data = {
|
||||
provider: input.provider,
|
||||
plan: input.plan,
|
||||
status: input.status,
|
||||
billingInterval: input.billingInterval ?? undefined,
|
||||
seats: input.seats ?? undefined,
|
||||
stripeCustomerId: input.stripeCustomerId ?? undefined,
|
||||
stripeSubscriptionId: input.stripeSubscriptionId ?? undefined,
|
||||
paypalSubscriptionId: input.paypalSubscriptionId ?? undefined,
|
||||
paypalPlanId: input.paypalPlanId ?? undefined,
|
||||
periodStart: input.periodStart ?? undefined,
|
||||
periodEnd: input.periodEnd ?? undefined,
|
||||
cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
return prisma.subscription.update({ where: { id: existing.id }, data });
|
||||
}
|
||||
return prisma.subscription.create({ data: { referenceId: input.referenceId, ...data } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active plan for a billing subject (a user id, or an organization id
|
||||
* for Agency). Returns "free" when there is no active subscription.
|
||||
*
|
||||
* Both Stripe and PayPal write into the same `subscription` table keyed by
|
||||
* `referenceId`, so this is provider-agnostic by construction.
|
||||
*/
|
||||
export async function getSubjectPlanKey(referenceId: string): Promise<PlanKey> {
|
||||
const sub = await prisma.subscription.findFirst({
|
||||
where: { referenceId, status: { in: ["active", "trialing"] } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return (sub?.plan as PlanKey) ?? "free";
|
||||
}
|
||||
|
||||
/** The active subscription row for a subject, if any. */
|
||||
export function getActiveSubscription(referenceId: string) {
|
||||
return prisma.subscription.findFirst({
|
||||
where: { referenceId, status: { in: ["active", "trialing", "past_due"] } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective plan for the current request. When an organization is
|
||||
* active (Agency workspace), the org's plan governs; otherwise the user's own.
|
||||
*/
|
||||
export async function getEffectivePlan(
|
||||
userId: string,
|
||||
activeOrgId?: string | null
|
||||
): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> {
|
||||
if (activeOrgId) {
|
||||
const key = await getSubjectPlanKey(activeOrgId);
|
||||
if (key !== "free") {
|
||||
return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" };
|
||||
}
|
||||
}
|
||||
const key = await getSubjectPlanKey(userId);
|
||||
return { plan: getPlan(key), key, subjectId: userId, subjectType: "user" };
|
||||
}
|
||||
|
||||
export async function subjectHasFeature(
|
||||
userId: string,
|
||||
feature: FeatureKey,
|
||||
activeOrgId?: string | null
|
||||
): Promise<boolean> {
|
||||
const { plan } = await getEffectivePlan(userId, activeOrgId);
|
||||
return plan.features.includes(feature);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { upsertSubscription } from "../subscription";
|
||||
import { planFromPaypalPlan } from "../catalog";
|
||||
import type { PlanKey } from "../plans";
|
||||
|
||||
interface PaypalResource {
|
||||
id?: string;
|
||||
plan_id?: string;
|
||||
custom_id?: string;
|
||||
billing_info?: { next_billing_time?: string };
|
||||
}
|
||||
|
||||
interface PaypalEvent {
|
||||
event_type: string;
|
||||
resource: PaypalResource;
|
||||
}
|
||||
|
||||
function parseCustom(
|
||||
custom?: string
|
||||
): { subjectId: string; subjectType: "user" | "organization"; plan: PlanKey } | null {
|
||||
if (!custom) return null;
|
||||
try {
|
||||
return JSON.parse(custom);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sync(resource: PaypalResource, status: string) {
|
||||
const subId = resource.id;
|
||||
if (!subId) return;
|
||||
const custom = parseCustom(resource.custom_id);
|
||||
const planFromId = resource.plan_id ? planFromPaypalPlan(resource.plan_id) : null;
|
||||
const plan = (custom?.plan || planFromId || "free") as PlanKey;
|
||||
const referenceId = custom?.subjectId;
|
||||
if (!referenceId) {
|
||||
console.warn("[paypal] subscription without custom subjectId, skipping", subId);
|
||||
return;
|
||||
}
|
||||
await upsertSubscription({
|
||||
provider: "paypal",
|
||||
referenceId,
|
||||
plan,
|
||||
status,
|
||||
paypalSubscriptionId: subId,
|
||||
paypalPlanId: resource.plan_id ?? null,
|
||||
periodEnd: resource.billing_info?.next_billing_time
|
||||
? new Date(resource.billing_info.next_billing_time)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaypalEvent(event: PaypalEvent): Promise<void> {
|
||||
switch (event.event_type) {
|
||||
case "BILLING.SUBSCRIPTION.ACTIVATED":
|
||||
case "BILLING.SUBSCRIPTION.UPDATED":
|
||||
case "BILLING.SUBSCRIPTION.RE-ACTIVATED":
|
||||
await sync(event.resource, "active");
|
||||
break;
|
||||
case "BILLING.SUBSCRIPTION.SUSPENDED":
|
||||
await sync(event.resource, "paused");
|
||||
break;
|
||||
case "BILLING.SUBSCRIPTION.CANCELLED":
|
||||
case "BILLING.SUBSCRIPTION.EXPIRED":
|
||||
await sync(event.resource, "canceled");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type Stripe from "stripe";
|
||||
import { stripe } from "../stripe";
|
||||
import { upsertSubscription } from "../subscription";
|
||||
import { planFromStripePrice } from "../catalog";
|
||||
import type { PlanKey } from "../plans";
|
||||
|
||||
function normalizeStatus(status: Stripe.Subscription.Status): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
case "trialing":
|
||||
case "past_due":
|
||||
case "paused":
|
||||
return status;
|
||||
default:
|
||||
return "canceled"; // canceled | incomplete | incomplete_expired | unpaid
|
||||
}
|
||||
}
|
||||
|
||||
async function syncStripeSubscription(
|
||||
sub: Stripe.Subscription,
|
||||
metadata?: Stripe.Metadata | null
|
||||
) {
|
||||
const item = sub.items.data[0];
|
||||
const priceId = item?.price?.id;
|
||||
const mapped = priceId ? planFromStripePrice(priceId) : null;
|
||||
const plan = ((metadata?.plan as PlanKey) || mapped?.plan || "free") as PlanKey;
|
||||
const referenceId = metadata?.subjectId || sub.metadata?.subjectId;
|
||||
if (!referenceId) {
|
||||
console.warn("[stripe] subscription without subjectId metadata, skipping", sub.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval =
|
||||
mapped?.interval ?? (item?.price?.recurring?.interval === "year" ? "year" : "month");
|
||||
|
||||
await upsertSubscription({
|
||||
provider: "stripe",
|
||||
referenceId,
|
||||
plan,
|
||||
status: normalizeStatus(sub.status),
|
||||
billingInterval: interval,
|
||||
stripeCustomerId: typeof sub.customer === "string" ? sub.customer : sub.customer.id,
|
||||
stripeSubscriptionId: sub.id,
|
||||
periodStart: sub.current_period_start ? new Date(sub.current_period_start * 1000) : null,
|
||||
periodEnd: sub.current_period_end ? new Date(sub.current_period_end * 1000) : null,
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
if (session.mode !== "subscription" || !session.subscription) break;
|
||||
const subId =
|
||||
typeof session.subscription === "string" ? session.subscription : session.subscription.id;
|
||||
const sub = await stripe().subscriptions.retrieve(subId);
|
||||
await syncStripeSubscription(sub, session.metadata);
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.created":
|
||||
case "customer.subscription.updated":
|
||||
case "customer.subscription.deleted": {
|
||||
const sub = event.data.object as Stripe.Subscription;
|
||||
await syncStripeSubscription(sub, sub.metadata);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
// Reuse the Prisma client across hot-reloads / serverless invocations.
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,39 @@
|
||||
interface SendEmailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const FROM = process.env.EMAIL_FROM ?? "PodcastYes <noreply@podcastyes.app>";
|
||||
|
||||
/**
|
||||
* Send a transactional email via Resend when configured; otherwise log to the
|
||||
* console (useful in local dev before RESEND_API_KEY is set).
|
||||
*/
|
||||
export async function sendEmail({ to, subject, html, text }: SendEmailInput): Promise<void> {
|
||||
const apiKey = process.env.RESEND_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.info(`[email:dev] To: ${to} | Subject: ${subject}\n${text ?? html}`);
|
||||
return;
|
||||
}
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(apiKey);
|
||||
const { error } = await resend.emails.send({ from: FROM, to, subject, html, text });
|
||||
if (error) throw new Error(`Resend error: ${error.message}`);
|
||||
}
|
||||
|
||||
/** Minimal branded wrapper so transactional emails share a consistent look. */
|
||||
export function emailLayout(title: string, body: string, cta?: { label: string; url: string }) {
|
||||
const button = cta
|
||||
? `<a href="${cta.url}" style="display:inline-block;background:#7c3aed;color:#fff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600;margin-top:16px">${cta.label}</a>`
|
||||
: "";
|
||||
return `
|
||||
<div style="font-family:Inter,Arial,sans-serif;max-width:480px;margin:0 auto;padding:24px;color:#0a0a0a">
|
||||
<h1 style="font-size:20px;margin:0 0 12px">🎙️ PodcastYes</h1>
|
||||
<h2 style="font-size:18px;margin:0 0 12px">${title}</h2>
|
||||
<div style="font-size:14px;line-height:1.6;color:#404040">${body}</div>
|
||||
${button}
|
||||
<p style="font-size:12px;color:#a3a3a3;margin-top:32px">If you didn't request this, you can ignore this email.</p>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export const TONES = [
|
||||
"Conversational",
|
||||
"Professional",
|
||||
"Humorous",
|
||||
"Educational",
|
||||
"Inspirational",
|
||||
"Storytelling",
|
||||
"Investigative",
|
||||
"Casual",
|
||||
] as const;
|
||||
|
||||
export const FORMATS = [
|
||||
{ value: "SOLO", label: "Solo", description: "One host narrating the episode." },
|
||||
{ value: "INTERVIEW", label: "Interview", description: "A host interviewing a guest." },
|
||||
{ value: "MULTI_HOST", label: "Multi-host", description: "A panel of co-hosts in conversation." },
|
||||
] as const;
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "es", label: "Spanish" },
|
||||
{ code: "fr", label: "French" },
|
||||
{ code: "de", label: "German" },
|
||||
{ code: "it", label: "Italian" },
|
||||
{ code: "pt", label: "Portuguese" },
|
||||
{ code: "nl", label: "Dutch" },
|
||||
{ code: "pl", label: "Polish" },
|
||||
{ code: "hi", label: "Hindi" },
|
||||
{ code: "ja", label: "Japanese" },
|
||||
{ code: "ko", label: "Korean" },
|
||||
{ code: "zh", label: "Chinese (Mandarin)" },
|
||||
{ code: "ar", label: "Arabic" },
|
||||
{ code: "tr", label: "Turkish" },
|
||||
{ code: "ru", label: "Russian" },
|
||||
] as const;
|
||||
|
||||
export const LENGTH_OPTIONS = [5, 10, 15, 20, 30, 45, 60, 90] as const;
|
||||
|
||||
/** The speaker roles required for each format. */
|
||||
export const FORMAT_SPEAKERS: Record<string, { speakerKey: string; defaultName: string }[]> = {
|
||||
SOLO: [{ speakerKey: "host", defaultName: "Host" }],
|
||||
INTERVIEW: [
|
||||
{ speakerKey: "host", defaultName: "Host" },
|
||||
{ speakerKey: "guest", defaultName: "Guest" },
|
||||
],
|
||||
MULTI_HOST: [
|
||||
{ speakerKey: "host", defaultName: "Host" },
|
||||
{ speakerKey: "cohost", defaultName: "Co-host" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { EpisodeStatus } from "@prisma/client";
|
||||
|
||||
/** Update an episode's pipeline status (and optional human-readable stage / error). */
|
||||
export async function setEpisodeStatus(
|
||||
episodeId: string,
|
||||
status: EpisodeStatus,
|
||||
opts: { stage?: string; errorMessage?: string | null } = {}
|
||||
): Promise<void> {
|
||||
await prisma.episode.update({
|
||||
where: { id: episodeId },
|
||||
data: {
|
||||
status,
|
||||
stage: opts.stage ?? null,
|
||||
...(opts.errorMessage !== undefined ? { errorMessage: opts.errorMessage } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a GenerationJob as running. */
|
||||
export async function startJob(jobId: string, pgBossJobId?: string): Promise<void> {
|
||||
await prisma.generationJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "running", startedAt: new Date(), pgBossJobId: pgBossJobId ?? undefined },
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a GenerationJob as completed or failed. */
|
||||
export async function finishJob(
|
||||
jobId: string,
|
||||
result: { status: "completed" | "failed"; error?: string; stage?: string }
|
||||
): Promise<void> {
|
||||
await prisma.generationJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: result.status,
|
||||
error: result.error ?? null,
|
||||
stage: result.stage,
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Terminal episode states — used by the UI/SSE to stop polling. */
|
||||
export function isTerminal(status: EpisodeStatus): boolean {
|
||||
return status === "READY" || status === "FAILED";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Queue/job names and their typed payloads. Shared by the web (producer) and worker (consumer). */
|
||||
|
||||
export const QUEUES = {
|
||||
generateEpisode: "episode.generate",
|
||||
generateSeries: "series.generate",
|
||||
repurpose: "episode.repurpose",
|
||||
reconcileBilling: "billing.reconcile",
|
||||
echo: "system.echo",
|
||||
} as const;
|
||||
|
||||
export type QueueName = (typeof QUEUES)[keyof typeof QUEUES];
|
||||
|
||||
export type GenerationType = "full" | "script" | "audio" | "art" | "section" | "repurpose";
|
||||
|
||||
export interface GenerateEpisodePayload {
|
||||
episodeId: string;
|
||||
/** "full" runs the whole pipeline; the others re-run a single stage. */
|
||||
type?: GenerationType;
|
||||
/** For type="section", the script section to regenerate. */
|
||||
sectionId?: string;
|
||||
}
|
||||
|
||||
export interface RepurposePayload {
|
||||
episodeId: string;
|
||||
format: "blog" | "social_thread" | "newsletter";
|
||||
}
|
||||
|
||||
export interface GenerateSeriesPayload {
|
||||
seriesId: string;
|
||||
}
|
||||
|
||||
export interface EchoPayload {
|
||||
message: string;
|
||||
episodeId?: string;
|
||||
}
|
||||
|
||||
/** All queues that must exist before send/work. */
|
||||
export const ALL_QUEUES: QueueName[] = Object.values(QUEUES);
|
||||
@@ -0,0 +1,60 @@
|
||||
import PgBoss from "pg-boss";
|
||||
import { ALL_QUEUES, QUEUES, type GenerateEpisodePayload } from "./jobs";
|
||||
|
||||
// One pg-boss instance per process, lazily started. The worker process supervises
|
||||
// (maintenance/scheduling); the web process only sends, so it skips supervision.
|
||||
let instance: PgBoss | null = null;
|
||||
let startup: Promise<PgBoss> | null = null;
|
||||
let queuesReady = false;
|
||||
|
||||
interface BossOptions {
|
||||
/** True only in the worker process. */
|
||||
supervise?: boolean;
|
||||
}
|
||||
|
||||
export async function getBoss(opts: BossOptions = {}): Promise<PgBoss> {
|
||||
if (instance) return instance;
|
||||
if (!startup) {
|
||||
const boss = new PgBoss({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
supervise: opts.supervise ?? false,
|
||||
schedule: opts.supervise ?? false,
|
||||
// Keep the pool small — the web process shares the DB with Prisma.
|
||||
max: opts.supervise ? 10 : 3,
|
||||
});
|
||||
boss.on("error", (err) => console.error("[pg-boss] error", err));
|
||||
startup = boss.start().then(async () => {
|
||||
instance = boss;
|
||||
await ensureQueues(boss);
|
||||
return boss;
|
||||
});
|
||||
}
|
||||
return startup;
|
||||
}
|
||||
|
||||
/** Create every known queue (idempotent) so send()/work() never race on existence. */
|
||||
async function ensureQueues(boss: PgBoss): Promise<void> {
|
||||
if (queuesReady) return;
|
||||
for (const name of ALL_QUEUES) {
|
||||
await boss.createQueue(name);
|
||||
}
|
||||
queuesReady = true;
|
||||
}
|
||||
|
||||
/** Enqueue a full or partial episode generation. */
|
||||
export async function enqueueEpisodeGeneration(
|
||||
payload: GenerateEpisodePayload,
|
||||
options?: { priority?: number; singletonKey?: string }
|
||||
): Promise<string | null> {
|
||||
const boss = await getBoss();
|
||||
return boss.send(QUEUES.generateEpisode, payload, {
|
||||
retryLimit: 2,
|
||||
retryDelay: 30,
|
||||
retryBackoff: true,
|
||||
expireInMinutes: 30,
|
||||
...(options?.priority ? { priority: options.priority } : {}),
|
||||
...(options?.singletonKey ? { singletonKey: options.singletonKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export { QUEUES };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { RateLimiterMemory } from "rate-limiter-flexible";
|
||||
|
||||
// In-memory limiters (no Redis). Fine for a single-instance Plesk deployment;
|
||||
// swap for RateLimiterPostgres if the app is ever scaled to multiple nodes.
|
||||
const limiters = new Map<string, RateLimiterMemory>();
|
||||
|
||||
function getLimiter(name: string, points: number, durationSec: number): RateLimiterMemory {
|
||||
let limiter = limiters.get(name);
|
||||
if (!limiter) {
|
||||
limiter = new RateLimiterMemory({ points, duration: durationSec });
|
||||
limiters.set(name, limiter);
|
||||
}
|
||||
return limiter;
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
retryAfterSec?: number;
|
||||
}
|
||||
|
||||
/** Consume one unit for `key` under a named bucket; returns ok=false when throttled. */
|
||||
export async function rateLimit(
|
||||
bucket: string,
|
||||
key: string,
|
||||
opts: { points: number; durationSec: number }
|
||||
): Promise<RateLimitResult> {
|
||||
const limiter = getLimiter(bucket, opts.points, opts.durationSec);
|
||||
try {
|
||||
await limiter.consume(key);
|
||||
return { ok: true };
|
||||
} catch (res) {
|
||||
const retryAfterSec = Math.ceil((res as { msBeforeNext?: number }).msBeforeNext ?? 1000) / 1000;
|
||||
return { ok: false, retryAfterSec: Math.ceil(retryAfterSec) };
|
||||
}
|
||||
}
|
||||
|
||||
// Common presets.
|
||||
export const LIMITS = {
|
||||
generation: { points: 10, durationSec: 60 }, // 10 generations / min / user
|
||||
repurpose: { points: 15, durationSec: 60 },
|
||||
api: { points: 60, durationSec: 60 }, // 60 API calls / min / key
|
||||
} as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { LocalStorageProvider } from "./local";
|
||||
import type { StorageProvider } from "./types";
|
||||
|
||||
export * from "./types";
|
||||
|
||||
// Registry: today only local disk. To add S3/R2 later, implement StorageProvider
|
||||
// in lib/storage/s3.ts and switch on an env flag here — no call-site changes.
|
||||
let provider: StorageProvider | null = null;
|
||||
|
||||
export function storage(): StorageProvider {
|
||||
if (!provider) provider = new LocalStorageProvider();
|
||||
return provider;
|
||||
}
|
||||
|
||||
/** Convenience for the worker / asset route which need the on-disk path. */
|
||||
export function localStorage(): LocalStorageProvider {
|
||||
const s = storage();
|
||||
if (s instanceof LocalStorageProvider) return s;
|
||||
throw new Error("Local filesystem path requested but active storage is not local");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { StorageProvider } from "./types";
|
||||
|
||||
const STORAGE_DIR = path.resolve(process.env.STORAGE_DIR ?? "./storage");
|
||||
const MEDIA_BASE = process.env.MEDIA_PUBLIC_BASE_URL ?? "/media";
|
||||
|
||||
// Cover art is the only asset class served publicly by nginx from /media.
|
||||
const PUBLIC_PREFIXES = ["art/"];
|
||||
|
||||
function resolveSafe(key: string): string {
|
||||
// Prevent path traversal: the resolved path must stay inside STORAGE_DIR.
|
||||
const full = path.resolve(STORAGE_DIR, key);
|
||||
if (full !== STORAGE_DIR && !full.startsWith(STORAGE_DIR + path.sep)) {
|
||||
throw new Error(`Invalid storage key: ${key}`);
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
/** Local-disk storage for the single-VPS Plesk deployment. */
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
async put(key: string, data: Buffer | Uint8Array): Promise<void> {
|
||||
const full = resolveSafe(key);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, data);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<Buffer> {
|
||||
return fs.readFile(resolveSafe(key));
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(resolveSafe(key));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(resolveSafe(key));
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async size(key: string): Promise<number | null> {
|
||||
try {
|
||||
const stat = await fs.stat(resolveSafe(key));
|
||||
return stat.size;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
publicUrl(key: string): string | null {
|
||||
if (PUBLIC_PREFIXES.some((p) => key.startsWith(p))) {
|
||||
return `${MEDIA_BASE}/${key}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Absolute filesystem path for a key — used by the worker (ffmpeg) and the asset route. */
|
||||
absolutePath(key: string): string {
|
||||
return resolveSafe(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Storage abstraction. The app only ever references a relative `key`
|
||||
* (e.g. "mp3/<episodeId>.mp3" or "art/<episodeId>.png"); the provider resolves
|
||||
* where it actually lives. Swapping local disk for S3/R2 later means writing a
|
||||
* new provider, with no changes at the call sites or in the database.
|
||||
*/
|
||||
export interface StorageProvider {
|
||||
/** Write bytes at `key`, creating parent "directories" as needed. */
|
||||
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
||||
/** Read the full object as a Buffer. */
|
||||
get(key: string): Promise<Buffer>;
|
||||
/** Whether an object exists at `key`. */
|
||||
exists(key: string): Promise<boolean>;
|
||||
/** Remove the object (no-op if missing). */
|
||||
delete(key: string): Promise<void>;
|
||||
/** Size in bytes, or null if missing. */
|
||||
size(key: string): Promise<number | null>;
|
||||
/**
|
||||
* A directly-fetchable URL for *public* assets (e.g. cover art served by nginx
|
||||
* from /media). Returns null for providers/keys that must go through the
|
||||
* authenticated asset route (e.g. private MP3s).
|
||||
*/
|
||||
publicUrl(key: string): string | null;
|
||||
}
|
||||
|
||||
export type AssetKind = "mp3" | "art" | "exports" | "tmp";
|
||||
|
||||
/** Build a conventional storage key for an asset. */
|
||||
export function assetKey(kind: AssetKind, name: string): string {
|
||||
return `${kind}/${name}`;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { getUsage } from "./meter";
|
||||
import { PLANS, UNLIMITED, withinLimit, type PlanKey, type UsageMetric } from "@/lib/billing/plans";
|
||||
|
||||
export interface LimitCheck {
|
||||
allowed: boolean;
|
||||
used: number;
|
||||
limit: number; // UNLIMITED (-1) when uncapped
|
||||
plan: PlanKey;
|
||||
metric: UsageMetric;
|
||||
}
|
||||
|
||||
/** Thrown by enforceLimit when a metric's monthly cap is reached. */
|
||||
export class LimitExceededError extends Error {
|
||||
constructor(public check: LimitCheck) {
|
||||
super(`Monthly ${check.metric} limit reached on the ${check.plan} plan`);
|
||||
this.name = "LimitExceededError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only check of a metric against the subject's plan + current usage. */
|
||||
export async function checkLimit(
|
||||
userId: string,
|
||||
metric: UsageMetric,
|
||||
activeOrgId?: string | null
|
||||
): Promise<LimitCheck> {
|
||||
const { key, subjectId } = await getEffectivePlan(userId, activeOrgId);
|
||||
const used = await getUsage(subjectId, metric);
|
||||
return {
|
||||
allowed: withinLimit(key, metric, used),
|
||||
used,
|
||||
limit: PLANS[key].limits[metric],
|
||||
plan: key,
|
||||
metric,
|
||||
};
|
||||
}
|
||||
|
||||
/** Throw LimitExceededError if the metric is over its cap. */
|
||||
export async function enforceLimit(
|
||||
userId: string,
|
||||
metric: UsageMetric,
|
||||
activeOrgId?: string | null
|
||||
): Promise<LimitCheck> {
|
||||
const check = await checkLimit(userId, metric, activeOrgId);
|
||||
if (!check.allowed) throw new LimitExceededError(check);
|
||||
return check;
|
||||
}
|
||||
|
||||
export function isUnlimited(limit: number): boolean {
|
||||
return limit === UNLIMITED;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { periodKey } from "@/lib/utils";
|
||||
import type { UsageMetric } from "@/lib/billing/plans";
|
||||
|
||||
export type OwnerType = "user" | "organization";
|
||||
|
||||
/** Increment a monthly usage counter for a billing subject. */
|
||||
export async function incrementUsage(
|
||||
ownerId: string,
|
||||
ownerType: OwnerType,
|
||||
metric: UsageMetric,
|
||||
by = 1
|
||||
): Promise<void> {
|
||||
const key = periodKey(new Date());
|
||||
await prisma.usageRecord.upsert({
|
||||
where: { ownerId_periodKey_metric: { ownerId, periodKey: key, metric } },
|
||||
create: { ownerId, ownerType, periodKey: key, metric, count: by },
|
||||
update: { count: { increment: by } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Current-period count for a single metric. */
|
||||
export async function getUsage(
|
||||
ownerId: string,
|
||||
metric: UsageMetric,
|
||||
date = new Date()
|
||||
): Promise<number> {
|
||||
const rec = await prisma.usageRecord.findUnique({
|
||||
where: { ownerId_periodKey_metric: { ownerId, periodKey: periodKey(date), metric } },
|
||||
});
|
||||
return rec?.count ?? 0;
|
||||
}
|
||||
|
||||
/** Current-period counts for all metrics. */
|
||||
export async function getUsageSummary(
|
||||
ownerId: string,
|
||||
date = new Date()
|
||||
): Promise<Record<UsageMetric, number>> {
|
||||
const rows = await prisma.usageRecord.findMany({
|
||||
where: { ownerId, periodKey: periodKey(date) },
|
||||
});
|
||||
const summary: Record<UsageMetric, number> = { script: 0, audio: 0, art: 0, repurpose: 0 };
|
||||
for (const row of rows) {
|
||||
if (row.metric in summary) summary[row.metric as UsageMetric] = row.count;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** Merge conditional class names and de-duplicate Tailwind utilities. */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Format a number of cents (e.g. 900) as a currency string ("$9"). */
|
||||
export function formatPrice(cents: number, currency = "USD") {
|
||||
const dollars = cents / 100;
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: dollars % 1 === 0 ? 0 : 2,
|
||||
}).format(dollars);
|
||||
}
|
||||
|
||||
/** The monthly usage bucket key, e.g. "2026-06". Pass a date for testability. */
|
||||
export function periodKey(date: Date): string {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
Reference in New Issue
Block a user