blog-backup/src/lib/podcast.ts
OpenClaw Bot 9720390e1a Add podcast feature with TTS, RSS feed, and web player
- Multi-provider TTS service (OpenAI, Piper, macOS say)
- Supabase Storage integration for audio files
- RSS 2.0 feed with iTunes extensions for podcast distribution
- Web audio player at /podcast page
- Integration with daily digest workflow
- Manual TTS generation script
- Complete documentation in PODCAST_SETUP.md
2026-02-23 20:15:27 -06:00

204 lines
6.4 KiB
TypeScript

/**
* Podcast RSS Feed Generation Utilities
* Generates RSS 2.0 with iTunes extensions for podcast distribution
*/
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
export interface PodcastEpisode {
id: string;
title: string;
description: string;
content: string;
date: string;
timestamp: number;
audioUrl?: string;
audioDuration?: number;
tags?: string[];
}
export interface PodcastConfig {
title: string;
description: string;
author: string;
email: string;
category: string;
language: string;
websiteUrl: string;
imageUrl: string;
explicit: boolean;
}
// Default podcast configuration
export const DEFAULT_CONFIG: PodcastConfig = {
title: "OpenClaw Daily Digest",
description: "Daily curated tech news covering AI coding assistants, iOS development, and digital entrepreneurship. AI-powered summaries delivered as audio.",
author: "OpenClaw",
email: "podcast@openclaw.ai",
category: "Technology",
language: "en-US",
websiteUrl: "https://blog-backup-two.vercel.app",
imageUrl: "https://blog-backup-two.vercel.app/podcast-cover.png",
explicit: false,
};
/**
* Parse title from markdown content
*/
export function extractTitle(content: string): string {
const lines = content.split("\n");
const titleLine = lines.find((l) => l.startsWith("# ") || l.startsWith("## "));
return titleLine?.replace(/#{1,2}\s/, "").trim() || "Daily Digest";
}
/**
* Extract plain text excerpt from markdown
*/
export function extractExcerpt(content: string, maxLength: number = 300): string {
const plainText = content
.replace(/#{1,6}\s/g, "")
.replace(/(\*\*|__|\*|_)/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/\n+/g, " ")
.trim();
if (plainText.length <= maxLength) return plainText;
return plainText.substring(0, maxLength).trim() + "...";
}
/**
* Format duration in seconds to HH:MM:SS or MM:SS
*/
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
/**
* Format date to RFC 2822 format for RSS
*/
export function formatRFC2822(date: Date | string | number): string {
const d = new Date(date);
return d.toUTCString();
}
/**
* Escape XML special characters
*/
export function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Generate unique GUID for episode
*/
export function generateGuid(episodeId: string): string {
return `openclaw-digest-${episodeId}`;
}
/**
* Fetch episodes from database
*/
export async function fetchEpisodes(limit: number = 50): Promise<PodcastEpisode[]> {
const { data, error } = await supabase
.from("blog_messages")
.select("id, date, content, timestamp, audio_url, audio_duration, tags")
.not("audio_url", "is", null) // Only episodes with audio
.order("timestamp", { ascending: false })
.limit(limit);
if (error) {
console.error("Error fetching episodes:", error);
throw error;
}
return (data || []).map(item => ({
id: item.id,
title: extractTitle(item.content),
description: extractExcerpt(item.content),
content: item.content,
date: item.date,
timestamp: item.timestamp,
audioUrl: item.audio_url,
audioDuration: item.audio_duration || 300, // Default 5 min if not set
tags: item.tags || [],
}));
}
/**
* Generate RSS feed XML
*/
export function generateRSS(episodes: PodcastEpisode[], config: PodcastConfig = DEFAULT_CONFIG): string {
const now = new Date();
const lastBuildDate = episodes.length > 0
? new Date(episodes[0].timestamp)
: now;
const itemsXml = episodes.map(episode => {
const guid = generateGuid(episode.id);
const pubDate = formatRFC2822(episode.timestamp);
const duration = formatDuration(episode.audioDuration || 300);
const enclosureUrl = escapeXml(episode.audioUrl || "");
const title = escapeXml(episode.title);
const description = escapeXml(episode.description);
const keywords = episode.tags?.join(", ") || "technology, ai, programming";
return `
<item>
<title>${title}</title>
<description>${description}</description>
<pubDate>${pubDate}</pubDate>
<guid isPermaLink="false">${guid}</guid>
<link>${escapeXml(`${config.websiteUrl}/?post=${episode.id}`)}</link>
<enclosure url="${enclosureUrl}" length="${episode.audioDuration || 300}" type="audio/mpeg"/>
<itunes:title>${title}</itunes:title>
<itunes:author>${escapeXml(config.author)}</itunes:author>
<itunes:summary>${description}</itunes:summary>
<itunes:duration>${duration}</itunes:duration>
<itunes:explicit>${config.explicit ? "yes" : "no"}</itunes:explicit>
<itunes:keywords>${escapeXml(keywords)}</itunes:keywords>
</item>`;
}).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>${escapeXml(config.title)}</title>
<link>${escapeXml(config.websiteUrl)}</link>
<description>${escapeXml(config.description)}</description>
<language>${config.language}</language>
<lastBuildDate>${formatRFC2822(lastBuildDate)}</lastBuildDate>
<atom:link href="${escapeXml(`${config.websiteUrl}/api/podcast/rss`)}" rel="self" type="application/rss+xml"/>
<itunes:author>${escapeXml(config.author)}</itunes:author>
<itunes:summary>${escapeXml(config.description)}</itunes:summary>
<itunes:category text="${escapeXml(config.category)}"/>
<itunes:explicit>${config.explicit ? "yes" : "no"}</itunes:explicit>
<itunes:image href="${escapeXml(config.imageUrl)}"/>
<itunes:owner>
<itunes:name>${escapeXml(config.author)}</itunes:name>
<itunes:email>${escapeXml(config.email)}</itunes:email>
</itunes:owner>
${itemsXml}
</channel>
</rss>`;
}