chore: archive inactive projects to apps-archived/

Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 07:03:59 +01:00
parent b97149ac12
commit 61d181fbc2
3148 changed files with 437 additions and 46640 deletions

View file

@ -0,0 +1 @@
v2.31.4

View file

@ -0,0 +1 @@
v2.177.0

View file

@ -0,0 +1 @@
postgresql://postgres.tiecnhktvovcqsrnunko:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres

View file

@ -0,0 +1 @@
17.4.1.054

View file

@ -0,0 +1 @@
tiecnhktvovcqsrnunko

View file

@ -0,0 +1 @@
v12.2.3

View file

@ -0,0 +1 @@
custom-metadata

View file

@ -0,0 +1,299 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'No authorization header' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: { Authorization: authHeader },
},
}
);
const {
data: { user },
error: authError,
} = await supabaseClient.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { url } = await req.json();
if (!url) {
return new Response(JSON.stringify({ error: 'URL is required' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate URL
let validatedUrl;
try {
validatedUrl = new URL(url);
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
throw new Error('Invalid protocol');
}
} catch {
return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Use ScrapingBee API - requires API key in environment
const scrapingBeeApiKey = Deno.env.get('SCRAPINGBEE_API_KEY');
if (!scrapingBeeApiKey) {
console.error('SCRAPINGBEE_API_KEY not configured, falling back to direct fetch');
// Fallback to direct fetch if API key not configured
return fallbackExtraction(validatedUrl, corsHeaders);
}
// ScrapingBee API request
const scrapingBeeUrl = new URL('https://app.scrapingbee.com/api/v1/');
scrapingBeeUrl.searchParams.append('api_key', scrapingBeeApiKey);
scrapingBeeUrl.searchParams.append('url', validatedUrl.toString());
scrapingBeeUrl.searchParams.append('render_js', 'true'); // Render JavaScript
scrapingBeeUrl.searchParams.append('wait', '3000'); // Wait 3s for content to load
scrapingBeeUrl.searchParams.append('block_ads', 'true'); // Block ads
scrapingBeeUrl.searchParams.append('stealth_mode', 'true'); // Bypass anti-bot measures
// Custom JavaScript to remove cookie banners
const jsScript = `
// Remove cookie banners
const selectors = [
'[class*="cookie"]', '[id*="cookie"]',
'[class*="consent"]', '[id*="consent"]',
'[class*="gdpr"]', '[id*="gdpr"]',
'.privacy-banner', '#privacy-banner'
];
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
if (el.textContent.toLowerCase().includes('cookie') ||
el.textContent.toLowerCase().includes('consent')) {
el.remove();
}
});
});
// Click accept buttons if needed
const acceptButtons = document.querySelectorAll('button, a');
acceptButtons.forEach(btn => {
const text = btn.textContent.toLowerCase();
if ((text.includes('accept') || text.includes('akzeptieren')) &&
(text.includes('cookie') || text.includes('all'))) {
btn.click();
}
});
`;
scrapingBeeUrl.searchParams.append('js_scenario', btoa(jsScript));
const response = await fetch(scrapingBeeUrl.toString());
if (!response.ok) {
console.error('ScrapingBee error:', response.status, await response.text());
return fallbackExtraction(validatedUrl, corsHeaders);
}
const html = await response.text();
// Parse and extract content
const doc = new DOMParser().parseFromString(html, 'text/html');
if (!doc) {
return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Use Readability to extract article content
const reader = new Readability(doc);
const article = reader.parse();
if (!article || article.textContent.length < 200) {
// Try manual extraction
return manualExtraction(doc, validatedUrl, corsHeaders);
}
// Extract metadata
const metadata = extractMetadata(doc);
const tags = generateTags(metadata.keywords);
// Clean content
const cleanedContent = article.textContent
.replace(/\s+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
return new Response(
JSON.stringify({
title: article.title || 'Untitled',
content: cleanedContent,
excerpt: article.excerpt || metadata.description || '',
source: validatedUrl.toString(),
domain: validatedUrl.hostname,
author: article.byline || metadata.author || '',
publishDate: metadata.publishDate || '',
wordCount: cleanedContent.split(/\s+/).length,
readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200),
tags,
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Extract URL error:', error);
return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});
// Helper functions
async function fallbackExtraction(url: URL, corsHeaders: any) {
// Original extraction logic as fallback
const response = await fetch(url.toString(), {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
if (!response.ok) {
return new Response(JSON.stringify({ error: `Failed to fetch URL: ${response.status}` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
return manualExtraction(doc!, url, corsHeaders);
}
function manualExtraction(doc: any, url: URL, corsHeaders: any) {
let content = '';
let title = '';
// Find title
const titleElement = doc.querySelector('h1') || doc.querySelector('title');
if (titleElement) {
title = titleElement.textContent?.trim() || '';
}
// Find content
const contentSelectors = [
'main',
'article',
'[role="main"]',
'.content',
'#content',
'.post',
'.entry-content',
'.article-content',
];
for (const selector of contentSelectors) {
const element = doc.querySelector(selector);
if (element && element.textContent) {
content = element.textContent.trim();
break;
}
}
// Get paragraphs
if (!content || content.length < 200) {
const paragraphs = doc.querySelectorAll('p');
const texts: string[] = [];
paragraphs.forEach((p: any) => {
const text = p.textContent?.trim();
if (text && text.length > 50) {
texts.push(text);
}
});
content = texts.join('\n\n');
}
if (!content || content.length < 100) {
return new Response(JSON.stringify({ error: 'Could not extract meaningful content' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response(
JSON.stringify({
title: title || 'Untitled',
content: content,
excerpt: content.substring(0, 200),
source: url.toString(),
domain: url.hostname,
author: '',
publishDate: '',
wordCount: content.split(/\s+/).length,
readingTime: Math.ceil(content.split(/\s+/).length / 200),
tags: [],
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
function extractMetadata(doc: any) {
const metadata: Record<string, string> = {};
const metaTags = doc.querySelectorAll('meta');
metaTags.forEach((meta: any) => {
const name = meta.getAttribute('name') || meta.getAttribute('property');
const content = meta.getAttribute('content');
if (name && content) {
if (name.includes('author')) metadata.author = content;
if (name.includes('description')) metadata.description = content;
if (name.includes('keywords')) metadata.keywords = content;
if (name.includes('publish')) metadata.publishDate = content;
}
});
return metadata;
}
function generateTags(keywords?: string): string[] {
if (!keywords) return [];
return keywords
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0)
.slice(0, 5);
}

View file

@ -0,0 +1,332 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'No authorization header' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: { Authorization: authHeader },
},
}
);
const {
data: { user },
error: authError,
} = await supabaseClient.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { url } = await req.json();
if (!url) {
return new Response(JSON.stringify({ error: 'URL is required' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate URL
let validatedUrl;
try {
validatedUrl = new URL(url);
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
throw new Error('Invalid protocol');
}
} catch {
return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Try Jina.ai Reader API for better extraction
try {
const jinaUrl = `https://r.jina.ai/${validatedUrl.toString()}`;
const jinaResponse = await fetch(jinaUrl, {
headers: {
Accept: 'text/plain',
'X-Return-Format': 'text',
},
signal: AbortSignal.timeout(15000), // 15 second timeout
});
if (jinaResponse.ok) {
const content = await jinaResponse.text();
// Check if we got meaningful content (not just cookie banner)
if (
content &&
content.length > 500 &&
!content.toLowerCase().includes('cookies zustimmen') &&
!content.toLowerCase().includes('cookie banner')
) {
// Extract title from content (usually first line)
const lines = content.split('\n').filter((line) => line.trim());
const title = lines[0] || 'Untitled';
const actualContent = lines.slice(1).join('\n\n');
return new Response(
JSON.stringify({
title: title.substring(0, 200), // Limit title length
content: actualContent || content,
excerpt: actualContent.substring(0, 200),
source: validatedUrl.toString(),
domain: validatedUrl.hostname,
author: '',
publishDate: '',
wordCount: content.split(/\s+/).length,
readingTime: Math.ceil(content.split(/\s+/).length / 200),
tags: [],
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
}
} catch (jinaError) {
console.log('Jina.ai extraction failed:', jinaError);
}
// Fallback to direct webpage fetch
const response = await fetch(validatedUrl.toString(), {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
},
signal: AbortSignal.timeout(15000), // 15 second timeout
});
if (!response.ok) {
return new Response(
JSON.stringify({ error: `Failed to fetch URL: ${response.status} ${response.statusText}` }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
const html = await response.text();
// Parse HTML and extract content
const doc = new DOMParser().parseFromString(html, 'text/html');
if (!doc) {
return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Try to remove common cookie banners and overlays
const elementsToRemove = [
// Cookie banners
'[class*="cookie"]',
'[id*="cookie"]',
'[class*="consent"]',
'[id*="consent"]',
'[class*="gdpr"]',
'[id*="gdpr"]',
'[class*="privacy"]',
'[id*="privacy-banner"]',
// Overlays
'[class*="overlay"]',
'[class*="modal"]',
'[class*="popup"]',
// Specific patterns
'.cookie-banner',
'#cookie-banner',
'.privacy-banner',
'#privacy-banner',
];
elementsToRemove.forEach((selector) => {
try {
const elements = doc.querySelectorAll(selector);
elements.forEach((el: any) => {
// Only remove if it looks like a banner/overlay (not main content)
const text = el.textContent || '';
if (
text.toLowerCase().includes('cookie') ||
text.toLowerCase().includes('datenschutz') ||
text.toLowerCase().includes('privacy') ||
text.toLowerCase().includes('consent')
) {
el.remove();
}
});
} catch (e) {
// Ignore selector errors
}
});
// Use Readability to extract article content
const reader = new Readability(doc);
const article = reader.parse();
if (!article) {
// Fallback: Try to extract content manually
let content = '';
let title = '';
// Try to find title
const titleElement = doc.querySelector('h1') || doc.querySelector('title');
if (titleElement) {
title = titleElement.textContent?.trim() || '';
}
// Try to find main content areas
const contentSelectors = [
'main',
'article',
'[role="main"]',
'.content',
'#content',
'.post',
'.entry-content',
'.article-content',
'.main-content',
];
for (const selector of contentSelectors) {
const element = doc.querySelector(selector);
if (element && element.textContent) {
content = element.textContent.trim();
break;
}
}
// If still no content, get all paragraphs
if (!content) {
const paragraphs = doc.querySelectorAll('p');
const texts: string[] = [];
paragraphs.forEach((p: any) => {
const text = p.textContent?.trim();
if (text && text.length > 50) {
// Filter out short paragraphs
texts.push(text);
}
});
content = texts.join('\n\n');
}
if (!content || content.length < 100) {
return new Response(
JSON.stringify({ error: 'Could not extract meaningful article content' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Create a pseudo-article object
return new Response(
JSON.stringify({
title: title || 'Untitled',
content: content,
excerpt: content.substring(0, 200),
source: validatedUrl.toString(),
domain: validatedUrl.hostname,
author: '',
publishDate: '',
wordCount: content.split(/\s+/).length,
readingTime: Math.ceil(content.split(/\s+/).length / 200),
tags: [],
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Extract additional metadata
const metaTags = doc.querySelectorAll('meta');
const metadata: Record<string, string> = {};
metaTags.forEach((meta: any) => {
const name = meta.getAttribute('name') || meta.getAttribute('property');
const content = meta.getAttribute('content');
if (name && content) {
if (name.includes('author')) metadata.author = content;
if (name.includes('description')) metadata.description = content;
if (name.includes('keywords')) metadata.keywords = content;
if (name.includes('publish')) metadata.publishDate = content;
}
});
// Generate tags from keywords if available
const tags = metadata.keywords
? metadata.keywords
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0)
.slice(0, 5)
: [];
// Clean and format the extracted text
const cleanedContent = article.textContent
.replace(/\s+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
return new Response(
JSON.stringify({
title: article.title || 'Untitled',
content: cleanedContent,
excerpt: article.excerpt || metadata.description || '',
source: validatedUrl.toString(),
domain: validatedUrl.hostname,
author: article.byline || metadata.author || '',
publishDate: metadata.publishDate || '',
wordCount: cleanedContent.split(/\s+/).length,
readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200), // Assuming 200 words per minute
tags,
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Extract URL error:', error);
return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1,512 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
interface AudioRequest {
textId: string;
content: string;
voice: string;
provider: 'google' | 'elevenlabs' | 'openai';
speed: number;
chunkSize?: number;
versionId?: string;
}
interface AudioChunk {
id: string;
start: number;
end: number;
content: string;
}
serve(async (req) => {
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Parse request first to get provider
const requestData: AudioRequest = await req.json();
const { provider = 'google' } = requestData;
// Check required environment variables based on provider
let apiKeyPresent = false;
let missingKeyMessage = '';
switch (provider) {
case 'google':
apiKeyPresent = !!Deno.env.get('GOOGLE_TTS_API_KEY');
missingKeyMessage = 'Missing GOOGLE_TTS_API_KEY environment variable';
break;
case 'elevenlabs':
apiKeyPresent = !!Deno.env.get('ELEVENLABS_API_KEY');
missingKeyMessage = 'Missing ELEVENLABS_API_KEY environment variable';
break;
case 'openai':
apiKeyPresent = !!Deno.env.get('OPENAI_API_KEY');
missingKeyMessage = 'Missing OPENAI_API_KEY environment variable';
break;
}
if (!apiKeyPresent) {
console.error(missingKeyMessage);
return new Response(JSON.stringify({ error: 'TTS service not configured' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Initialize Supabase client
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: { Authorization: req.headers.get('Authorization')! },
},
}
);
// Get user from JWT token
const {
data: { user },
} = await supabaseClient.auth.getUser();
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { textId, content, voice, speed, chunkSize = 1000, versionId } = requestData;
// Validate input
if (!textId || !content) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Split text into chunks
const chunks: AudioChunk[] = [];
for (let i = 0; i < content.length; i += chunkSize) {
chunks.push({
id: `chunk-${chunks.length}`,
start: i,
end: Math.min(i + chunkSize, content.length),
content: content.slice(i, Math.min(i + chunkSize, content.length)),
});
}
// Generate audio based on the provider
let audioResult;
switch (provider) {
case 'elevenlabs':
audioResult = await generateElevenLabsTTS(chunks, voice, speed);
break;
case 'openai':
audioResult = await generateOpenAITTS(chunks, voice, speed);
break;
case 'google':
default:
audioResult = await generateGoogleTTS(chunks, voice, speed);
break;
}
const { audioChunks, totalSize } = audioResult;
// Store audio chunks in Supabase Storage
const storedChunks = [];
for (const chunkData of audioChunks) {
try {
// Use versionId in path if provided, otherwise use default path
const fileName = versionId
? `${user.id}/${textId}/${versionId}/${chunkData.id}.mp3`
: `${user.id}/${textId}/${chunkData.id}.mp3`;
const { error: uploadError } = await supabaseClient.storage
.from('audio')
.upload(fileName, chunkData.audioBuffer, {
contentType: 'audio/mpeg',
upsert: true,
});
if (uploadError) {
console.error('Upload error:', uploadError);
throw uploadError;
}
// Create audio chunk metadata for storage
storedChunks.push({
id: chunkData.id,
start: chunkData.start,
end: chunkData.end,
filename: fileName,
size: chunkData.size,
duration: chunkData.duration,
createdAt: new Date().toISOString(),
});
} catch (error) {
console.error(`Error storing chunk ${chunkData.id}:`, error);
// Continue with other chunks, but log the error
}
}
// Update text record with audio metadata
const { error: updateError } = await supabaseClient
.from('texts')
.update({
data: {
audio: {
hasLocalCache: false, // Will be set to true when downloaded to device
chunks: storedChunks,
totalSize,
lastGenerated: new Date().toISOString(),
settings: { voice, speed, provider },
},
},
})
.eq('id', textId)
.eq('user_id', user.id);
if (updateError) {
throw updateError;
}
return new Response(
JSON.stringify({
success: true,
chunksGenerated: storedChunks.length,
totalSize,
chunks: storedChunks,
provider,
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Error in generate-audio function:', error);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});
function extractLanguageCode(voiceId: string): string {
// Extract language code from voice ID (e.g., "de-DE" from "de-DE-Neural2-G")
const parts = voiceId.split('-');
if (parts.length >= 2) {
return `${parts[0]}-${parts[1]}`;
}
return 'de-DE'; // Default fallback
}
function getVoiceName(voiceId: string): string {
// If it's already a full voice ID (contains more than just language code), return it
if (voiceId.includes('-') && voiceId.split('-').length > 2) {
return voiceId;
}
// Legacy support: map old language codes to default voices
const legacyVoiceMap: Record<string, string> = {
'de-DE': 'de-DE-Neural2-A',
'en-US': 'en-US-Neural2-A',
'en-GB': 'en-GB-Neural2-A',
};
return legacyVoiceMap[voiceId] || 'de-DE-Neural2-A';
}
function estimateAudioDuration(text: string, speed: number): number {
// Rough estimate: 150 words per minute for normal speech
const wordsPerMinute = 150 * speed;
const wordCount = text.split(/\s+/).length;
return Math.ceil((wordCount / wordsPerMinute) * 60);
}
// Google Cloud TTS Implementation
async function generateGoogleTTS(chunks: AudioChunk[], voice: string, speed: number) {
const googleApiKey = Deno.env.get('GOOGLE_TTS_API_KEY');
if (!googleApiKey) {
throw new Error('Google TTS API key not configured');
}
const audioChunks = [];
let totalSize = 0;
for (const chunk of chunks) {
let retries = 0;
const maxRetries = 3;
let delay = 1000; // Start with 1 second delay
while (retries < maxRetries) {
try {
const ttsResponse = await fetch(
`https://texttospeech.googleapis.com/v1/text:synthesize?key=${googleApiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: { text: chunk.content },
voice: {
languageCode: extractLanguageCode(voice),
name: getVoiceName(voice),
},
audioConfig: {
audioEncoding: 'MP3',
speakingRate: speed,
pitch: 0,
volumeGainDb: 0,
},
}),
}
);
if (ttsResponse.status === 429 || ttsResponse.status === 503) {
retries++;
if (retries < maxRetries) {
console.log(
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
continue;
} else {
throw new Error(
`Google TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
);
}
}
if (!ttsResponse.ok) {
const errorBody = await ttsResponse.text();
console.error('Google TTS API Error:', {
status: ttsResponse.status,
body: errorBody,
});
throw new Error(`Google TTS error: ${ttsResponse.status}`);
}
const ttsData = await ttsResponse.json();
const audioContent = ttsData.audioContent;
const audioBuffer = Uint8Array.from(atob(audioContent), (c) => c.charCodeAt(0));
const audioSize = audioBuffer.length;
totalSize += audioSize;
audioChunks.push({
id: chunk.id,
start: chunk.start,
end: chunk.end,
audioBuffer,
size: audioSize,
duration: estimateAudioDuration(chunk.content, speed),
});
break; // Success, exit retry loop
} catch (error) {
retries++;
console.error(
`Error processing Google TTS chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
error
);
if (retries >= maxRetries) {
throw error; // Re-throw after all retries exhausted
}
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff for other errors too
}
}
}
return { audioChunks, totalSize };
}
// ElevenLabs TTS Implementation
async function generateElevenLabsTTS(chunks: AudioChunk[], voice: string, speed: number) {
const elevenLabsApiKey = Deno.env.get('ELEVENLABS_API_KEY');
if (!elevenLabsApiKey) {
throw new Error('ElevenLabs API key not configured');
}
const audioChunks = [];
let totalSize = 0;
// Map voice IDs to ElevenLabs voice IDs
const voiceMapping: Record<string, string> = {
eleven_multilingual_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel
eleven_multilingual_v1: 'pNInz6obpgDQGcFmaJgB', // Adam
eleven_turbo_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel Turbo
eleven_monolingual_v1: '2EiwWnXFnvU5JabPnv8n', // Clyde
};
const elevenLabsVoiceId = voiceMapping[voice] || '21m00Tcm4TlvDq8ikWAM';
for (const chunk of chunks) {
let retries = 0;
const maxRetries = 3;
let delay = 1000; // Start with 1 second delay
while (retries < maxRetries) {
try {
const ttsResponse = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${elevenLabsVoiceId}`,
{
method: 'POST',
headers: {
'xi-api-key': elevenLabsApiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: chunk.content,
model_id: voice.includes('turbo') ? 'eleven_turbo_v2' : 'eleven_multilingual_v2',
voice_settings: {
stability: 0.5,
similarity_boost: 0.5,
style: 0.5,
use_speaker_boost: true,
},
}),
}
);
if (ttsResponse.status === 429 || ttsResponse.status === 503) {
retries++;
if (retries < maxRetries) {
console.log(
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
continue;
} else {
throw new Error(
`ElevenLabs TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
);
}
}
if (!ttsResponse.ok) {
throw new Error(`ElevenLabs TTS error: ${ttsResponse.status}`);
}
const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
const audioSize = audioBuffer.length;
totalSize += audioSize;
audioChunks.push({
id: chunk.id,
start: chunk.start,
end: chunk.end,
audioBuffer,
size: audioSize,
duration: estimateAudioDuration(chunk.content, speed),
});
break; // Success, exit retry loop
} catch (error) {
retries++;
console.error(
`Error processing ElevenLabs chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
error
);
if (retries >= maxRetries) {
throw error; // Re-throw after all retries exhausted
}
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff for other errors too
}
}
}
return { audioChunks, totalSize };
}
// OpenAI TTS Implementation
async function generateOpenAITTS(chunks: AudioChunk[], voice: string, speed: number) {
const openaiApiKey = Deno.env.get('OPENAI_API_KEY');
if (!openaiApiKey) {
throw new Error('OpenAI API key not configured');
}
const audioChunks = [];
let totalSize = 0;
for (const chunk of chunks) {
let retries = 0;
const maxRetries = 3;
let delay = 1000; // Start with 1 second delay
while (retries < maxRetries) {
try {
const ttsResponse = await fetch('https://api.openai.com/v1/audio/speech', {
method: 'POST',
headers: {
Authorization: `Bearer ${openaiApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'tts-1-hd', // Using HD model for better quality
input: chunk.content,
voice: voice,
speed: speed,
}),
});
if (ttsResponse.status === 429) {
retries++;
if (retries < maxRetries) {
console.log(
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
continue;
} else {
throw new Error(
`OpenAI TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
);
}
}
if (!ttsResponse.ok) {
throw new Error(`OpenAI TTS error: ${ttsResponse.status}`);
}
const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
const audioSize = audioBuffer.length;
totalSize += audioSize;
audioChunks.push({
id: chunk.id,
start: chunk.start,
end: chunk.end,
audioBuffer,
size: audioSize,
duration: estimateAudioDuration(chunk.content, speed),
});
break; // Success, exit retry loop
} catch (error) {
retries++;
console.error(
`Error processing OpenAI chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
error
);
if (retries >= maxRetries) {
throw error; // Re-throw after all retries exhausted
}
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff for other errors too
}
}
}
return { audioChunks, totalSize };
}

View file

@ -0,0 +1,110 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
interface AudioUrlRequest {
textId: string;
chunkId: string;
}
serve(async (req) => {
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Initialize Supabase client
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: { Authorization: req.headers.get('Authorization')! },
},
}
);
// Get user from JWT token
const {
data: { user },
} = await supabaseClient.auth.getUser();
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { textId, chunkId }: AudioUrlRequest = await req.json();
// Validate input
if (!textId || !chunkId) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Verify text belongs to user
const { data: text, error: textError } = await supabaseClient
.from('texts')
.select('data')
.eq('id', textId)
.eq('user_id', user.id)
.single();
if (textError || !text) {
return new Response(JSON.stringify({ error: 'Text not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Find the chunk
const chunk = text.data.audio?.chunks?.find((c: any) => c.id === chunkId);
if (!chunk) {
return new Response(JSON.stringify({ error: 'Chunk not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Generate signed URL for the audio file with user-specific path
const filePath = `${user.id}/${textId}/${chunkId}.mp3`;
const { data: urlData, error: urlError } = await supabaseClient.storage
.from('audio')
.createSignedUrl(filePath, 3600); // 1 hour expiration
if (urlError) {
throw urlError;
}
return new Response(
JSON.stringify({
success: true,
url: urlData.signedUrl,
chunk: {
id: chunk.id,
start: chunk.start,
end: chunk.end,
duration: chunk.duration,
},
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Error in get-audio-url function:', error);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1,62 @@
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Die einzige Tabelle die du brauchst
CREATE TABLE texts (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
-- Der eigentliche Content
title TEXT NOT NULL,
content TEXT NOT NULL,
-- ALLES andere in einem JSONB Feld
data JSONB DEFAULT '{}' NOT NULL,
-- Nur die absolut nötigen Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indizes für Performance
CREATE INDEX idx_texts_user ON texts(user_id);
CREATE INDEX idx_texts_data ON texts USING GIN (data);
-- RLS aktivieren
ALTER TABLE texts ENABLE ROW LEVEL SECURITY;
-- Jeder sieht nur seine eigenen Texte
CREATE POLICY "Own texts only" ON texts
FOR ALL USING (auth.uid() = user_id);
-- Update Timestamp Trigger
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_texts_updated_at
BEFORE UPDATE ON texts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- Hilfsfunktion für atomare Play Count Updates
CREATE OR REPLACE FUNCTION increment_play_count(text_id UUID)
RETURNS void AS $$
BEGIN
UPDATE texts
SET data = jsonb_set(
jsonb_set(
data,
'{stats,playCount}',
to_jsonb(COALESCE((data->'stats'->>'playCount')::int, 0) + 1)
),
'{tts,lastPlayed}',
to_jsonb(NOW())
)
WHERE id = text_id AND user_id = auth.uid();
END;
$$ LANGUAGE plpgsql;

View file

@ -0,0 +1,31 @@
-- Create audio storage bucket
INSERT INTO storage.buckets (id, name, public)
VALUES ('audio', 'audio', false);
-- Create policy for authenticated users to upload their own audio files
CREATE POLICY "Users can upload their own audio files" ON storage.objects
FOR INSERT WITH CHECK (
bucket_id = 'audio' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Create policy for authenticated users to view their own audio files
CREATE POLICY "Users can view their own audio files" ON storage.objects
FOR SELECT USING (
bucket_id = 'audio' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Create policy for authenticated users to update their own audio files
CREATE POLICY "Users can update their own audio files" ON storage.objects
FOR UPDATE USING (
bucket_id = 'audio' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Create policy for authenticated users to delete their own audio files
CREATE POLICY "Users can delete their own audio files" ON storage.objects
FOR DELETE USING (
bucket_id = 'audio' AND
auth.uid()::text = (storage.foldername(name))[1]
);