Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
e3b47b69ea
commit
0a34e3de47
24
schema.sql
Normal file
24
schema.sql
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
-- Blog Backup Database Schema
|
||||||
|
-- Run this SQL in Supabase Dashboard SQL Editor
|
||||||
|
|
||||||
|
-- Add audio URL column for storing podcast audio file URLs
|
||||||
|
ALTER TABLE blog_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS audio_url TEXT;
|
||||||
|
|
||||||
|
-- Add audio duration column (in seconds)
|
||||||
|
ALTER TABLE blog_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS audio_duration INTEGER;
|
||||||
|
|
||||||
|
-- Create index for faster queries on posts with audio
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blog_messages_audio
|
||||||
|
ON blog_messages(audio_url)
|
||||||
|
WHERE audio_url IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN blog_messages.audio_url IS 'Public URL to the audio file in Supabase Storage';
|
||||||
|
COMMENT ON COLUMN blog_messages.audio_duration IS 'Audio duration in seconds (estimated from file size)';
|
||||||
|
|
||||||
|
-- Verify columns were added
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'blog_messages'
|
||||||
|
ORDER BY ordinal_position;
|
||||||
@ -37,18 +37,19 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = Date.now().toString();
|
const id = Date.now().toString();
|
||||||
const newMessage = {
|
const newMessage: Record<string, any> = {
|
||||||
id,
|
id,
|
||||||
date,
|
date,
|
||||||
content,
|
content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
tags: tags || ["daily-digest"],
|
tags: tags || ["daily-digest"]
|
||||||
audio_url: null as string | null,
|
|
||||||
audio_duration: null as number | null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only add audio fields if they exist in the table
|
||||||
|
// This handles schema differences between environments
|
||||||
|
|
||||||
// Save message first (without audio)
|
// Save message first (without audio)
|
||||||
const { error } = await supabase
|
const { error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_messages")
|
||||||
.insert(newMessage);
|
.insert(newMessage);
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,11 @@ const supabase = createClient(
|
|||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const serviceSupabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
);
|
||||||
|
|
||||||
function getBearerToken(request: Request): string | null {
|
function getBearerToken(request: Request): string | null {
|
||||||
const authorization = request.headers.get("authorization");
|
const authorization = request.headers.get("authorization");
|
||||||
if (!authorization?.startsWith("Bearer ")) {
|
if (!authorization?.startsWith("Bearer ")) {
|
||||||
@ -35,7 +40,7 @@ const CRON_API_KEY = process.env.CRON_API_KEY;
|
|||||||
|
|
||||||
// GET is public (read-only)
|
// GET is public (read-only)
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { data: messages, error } = await supabase
|
const { data: messages, error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_messages")
|
||||||
.select("id, date, content, timestamp, tags")
|
.select("id, date, content, timestamp, tags")
|
||||||
.order("timestamp", { ascending: false });
|
.order("timestamp", { ascending: false });
|
||||||
@ -74,7 +79,7 @@ export async function POST(request: Request) {
|
|||||||
tags: tags || [],
|
tags: tags || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_messages")
|
||||||
.insert(newMessage);
|
.insert(newMessage);
|
||||||
|
|
||||||
@ -104,7 +109,7 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: "ID required" }, { status: 400 });
|
return NextResponse.json({ error: "ID required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_messages")
|
||||||
.delete()
|
.delete()
|
||||||
.eq("id", id);
|
.eq("id", id);
|
||||||
|
|||||||
118
src/app/api/tts/route.ts
Normal file
118
src/app/api/tts/route.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Text-to-Speech API Endpoint
|
||||||
|
* Generates TTS audio for digest content and returns the audio file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { generateSpeech, TTSOptions } from "@/lib/tts";
|
||||||
|
import { uploadAudio } from "@/lib/storage";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
const serviceSupabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tts
|
||||||
|
* Generate TTS audio for provided text content
|
||||||
|
*
|
||||||
|
* Body: { text: string, postId?: string, options?: TTSOptions }
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const apiKey = request.headers.get("x-api-key");
|
||||||
|
const CRON_API_KEY = process.env.CRON_API_KEY;
|
||||||
|
|
||||||
|
if (!CRON_API_KEY || apiKey !== CRON_API_KEY) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { text, postId, options } = await request.json();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return NextResponse.json({ error: "Text content is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate TTS audio
|
||||||
|
const { audioBuffer, duration, format } = await generateSpeech(text, {
|
||||||
|
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
|
||||||
|
voice: process.env.TTS_VOICE || "alloy",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine file extension based on format
|
||||||
|
const ext = format === "audio/wav" ? "wav" :
|
||||||
|
format === "audio/aiff" ? "aiff" : "mp3";
|
||||||
|
|
||||||
|
// Create filename
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const filename = postId
|
||||||
|
? `tts-${postId}-${timestamp}.${ext}`
|
||||||
|
: `tts-${timestamp}.${ext}`;
|
||||||
|
|
||||||
|
// Upload to Supabase Storage
|
||||||
|
const { url, path, size } = await uploadAudio(audioBuffer, filename, format);
|
||||||
|
|
||||||
|
// If postId provided, update the blog post with audio URL
|
||||||
|
if (postId) {
|
||||||
|
const { error: updateError } = await serviceSupabase
|
||||||
|
.from("blog_messages")
|
||||||
|
.update({
|
||||||
|
audio_url: url,
|
||||||
|
audio_duration: duration,
|
||||||
|
})
|
||||||
|
.eq("id", postId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error("[TTS] Error updating post with audio:", updateError);
|
||||||
|
// Don't fail the request, just log the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url,
|
||||||
|
path,
|
||||||
|
duration,
|
||||||
|
size,
|
||||||
|
format,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[TTS] Generation error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to generate TTS audio" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tts?postId={id}
|
||||||
|
* Get TTS audio info for a specific blog post
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const postId = searchParams.get("postId");
|
||||||
|
|
||||||
|
if (!postId) {
|
||||||
|
return NextResponse.json({ error: "Post ID required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await serviceSupabase
|
||||||
|
.from("blog_messages")
|
||||||
|
.select("id, audio_url, audio_duration")
|
||||||
|
.eq("id", postId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching TTS info:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch TTS info" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
hasAudio: !!data?.audio_url,
|
||||||
|
audioUrl: data?.audio_url,
|
||||||
|
duration: data?.audio_duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -14,7 +14,6 @@ export function AudioPlayer({ url, duration, className = "" }: AudioPlayerProps)
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [audioDuration, setAudioDuration] = useState(duration || 0);
|
const [audioDuration, setAudioDuration] = useState(duration || 0);
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@ -23,7 +22,6 @@ export function AudioPlayer({ url, duration, className = "" }: AudioPlayerProps)
|
|||||||
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||||||
const handleLoadedMetadata = () => {
|
const handleLoadedMetadata = () => {
|
||||||
setAudioDuration(audio.duration);
|
setAudioDuration(audio.duration);
|
||||||
setIsLoaded(true);
|
|
||||||
};
|
};
|
||||||
const handleEnded = () => setIsPlaying(false);
|
const handleEnded = () => setIsPlaying(false);
|
||||||
const handlePlay = () => setIsPlaying(true);
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
|||||||
12
vercel.json
Normal file
12
vercel.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"crons": [
|
||||||
|
{
|
||||||
|
"path": "/api/ingest",
|
||||||
|
"schedule": "0 6 * * *"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/generate-digest",
|
||||||
|
"schedule": "0 6 * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user