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 newMessage = {
|
||||
const newMessage: Record<string, any> = {
|
||||
id,
|
||||
date,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
tags: tags || ["daily-digest"],
|
||||
audio_url: null as string | null,
|
||||
audio_duration: null as number | null,
|
||||
tags: tags || ["daily-digest"]
|
||||
};
|
||||
|
||||
// Only add audio fields if they exist in the table
|
||||
// This handles schema differences between environments
|
||||
|
||||
// Save message first (without audio)
|
||||
const { error } = await supabase
|
||||
const { error } = await serviceSupabase
|
||||
.from("blog_messages")
|
||||
.insert(newMessage);
|
||||
|
||||
|
||||
@ -6,6 +6,11 @@ const supabase = createClient(
|
||||
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 {
|
||||
const authorization = request.headers.get("authorization");
|
||||
if (!authorization?.startsWith("Bearer ")) {
|
||||
@ -35,7 +40,7 @@ const CRON_API_KEY = process.env.CRON_API_KEY;
|
||||
|
||||
// GET is public (read-only)
|
||||
export async function GET() {
|
||||
const { data: messages, error } = await supabase
|
||||
const { data: messages, error } = await serviceSupabase
|
||||
.from("blog_messages")
|
||||
.select("id, date, content, timestamp, tags")
|
||||
.order("timestamp", { ascending: false });
|
||||
@ -74,7 +79,7 @@ export async function POST(request: Request) {
|
||||
tags: tags || [],
|
||||
};
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await serviceSupabase
|
||||
.from("blog_messages")
|
||||
.insert(newMessage);
|
||||
|
||||
@ -104,7 +109,7 @@ export async function DELETE(request: Request) {
|
||||
return NextResponse.json({ error: "ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await serviceSupabase
|
||||
.from("blog_messages")
|
||||
.delete()
|
||||
.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 [audioDuration, setAudioDuration] = useState(duration || 0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
@ -23,7 +22,6 @@ export function AudioPlayer({ url, duration, className = "" }: AudioPlayerProps)
|
||||
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||||
const handleLoadedMetadata = () => {
|
||||
setAudioDuration(audio.duration);
|
||||
setIsLoaded(true);
|
||||
};
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
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