Initial commit: PodcastYes — AI podcast platform

This commit is contained in:
Leon Serfaty
2026-06-07 03:58:32 -04:00
commit 155507f21a
151 changed files with 19826 additions and 0 deletions
+52
View File
@@ -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;
}
+45
View File
@@ -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);
});
});
}
+16
View File
@@ -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";
+228
View File
@@ -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 24: 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);
}
}
+53
View File
@@ -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 (610 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, 34 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,
},
};
}
+110
View File
@@ -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
);
}
+60
View File
@@ -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(() => {});
}
}
+93
View File
@@ -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 36 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 },
];
}
+97
View File
@@ -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;
}
}
+21
View File
@@ -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());
}
+36
View File
@@ -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(" ");
}
+90
View File
@@ -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),
};
}
}
+48
View File
@@ -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
View File
@@ -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 }>;
}
+36
View File
@@ -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);
}
+36
View File
@@ -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;
}
+12
View File
@@ -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;
+84
View File
@@ -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;
+35
View File
@@ -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;
}
+50
View File
@@ -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;
}
+115
View File
@@ -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";
}
+162
View File
@@ -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;
}
+76
View File
@@ -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;
}
+104
View File
@@ -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);
}
+69
View File
@@ -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;
}
}
+71
View File
@@ -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;
}
}
+12
View File
@@ -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;
+39
View File
@@ -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>`;
}
+49
View File
@@ -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" },
],
};
+47
View File
@@ -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";
}
+38
View File
@@ -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);
+60
View File
@@ -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 };
+42
View File
@@ -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;
+20
View File
@@ -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");
}
+69
View File
@@ -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);
}
}
+31
View File
@@ -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}`;
}
+51
View File
@@ -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;
}
+47
View File
@@ -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;
}
+24
View File
@@ -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}`;
}