From 75f05f45f28c5eedae8f59af0120dafed03b063f Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 1 Jun 2026 13:05:34 +0200 Subject: [PATCH] feat: add audios table and ElevenLabs TTS endpoint - New audios table with voice params, S3 link, alignment JSON - POST /api/audios/generate calls ElevenLabs with-timestamps, uploads to S3 - GET/PATCH/DELETE /api/audios endpoints - Requires ELEVENLABS_API_KEY env var Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 3 + src/db-migrate.js | 56 ++++++++++++++++++ src/index.js | 1 + src/routes/audios.js | 137 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 src/routes/audios.js diff --git a/.env.example b/.env.example index 35e67de..77bbcc0 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ PORT=3000 # Anthropic (für Auto-Pairs-Generierung) ANTHROPIC_API_KEY=sk-ant-... + +# ElevenLabs (für TTS-Generierung) +ELEVENLABS_API_KEY=sk_... diff --git a/src/db-migrate.js b/src/db-migrate.js index 37f6ad6..ecab850 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -494,6 +494,62 @@ async function migrate() { AND bbox_x IS NULL `).catch(() => {}); + // user_pair_progress + await query(` + CREATE TABLE IF NOT EXISTS user_pair_progress ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pair_id UUID NOT NULL REFERENCES pairs(id) ON DELETE CASCADE, + seen_count INTEGER NOT NULL DEFAULT 1, + correct_count INTEGER NOT NULL DEFAULT 0, + wrong_count INTEGER NOT NULL DEFAULT 0, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, pair_id) + ) + `); + + await query(` + CREATE OR REPLACE FUNCTION update_last_seen_at() + RETURNS TRIGGER AS $$ + BEGIN NEW.last_seen_at = NOW(); RETURN NEW; END; + $$ LANGUAGE plpgsql + `); + + await query(` + DROP TRIGGER IF EXISTS user_pair_progress_last_seen_at ON user_pair_progress; + CREATE TRIGGER user_pair_progress_last_seen_at + BEFORE UPDATE ON user_pair_progress + FOR EACH ROW EXECUTE FUNCTION update_last_seen_at() + `); + + // audios + await query(` + CREATE TABLE IF NOT EXISTS audios ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status VARCHAR(20) NOT NULL DEFAULT 'generated' + CHECK (status IN ('generated', 'published', 'blocked')), + text TEXT, + audio_link TEXT, + alignment JSONB, + voice_id TEXT, + model_id TEXT, + speed NUMERIC(4,2), + stability NUMERIC(4,2), + similarity_boost NUMERIC(4,2), + style NUMERIC(4,2), + published_at TIMESTAMPTZ, + blocked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + await query(` + DROP TRIGGER IF EXISTS audios_updated_at ON audios; + CREATE TRIGGER audios_updated_at + BEFORE UPDATE ON audios + FOR EACH ROW EXECUTE FUNCTION update_updated_at() + `); + // ── Migrate old {{uuid}} placeholders → new {{label.w:uuid}} / {{label.o:uuid}} ── await migratePlaceholders(); diff --git a/src/index.js b/src/index.js index 3894a57..ca26388 100644 --- a/src/index.js +++ b/src/index.js @@ -40,6 +40,7 @@ app.use('/api/languages', auth, require('./routes/languages')); app.use('/api/user-names', auth, require('./routes/user-names')); app.use('/api/users-public', auth, require('./routes/users-public')); app.use('/api/users', auth, require('./routes/users')); +app.use('/api/audios', auth, require('./routes/audios')); app.use('/api/claude', auth, require('./routes/claude')); // 404 diff --git a/src/routes/audios.js b/src/routes/audios.js new file mode 100644 index 0000000..f61940d --- /dev/null +++ b/src/routes/audios.js @@ -0,0 +1,137 @@ +const router = require('express').Router(); +const { v4: uuidv4 } = require('uuid'); +const { query } = require('../db'); +const { uploadFile, deleteFile, keyFromUrl } = require('../s3'); + +const ELEVENLABS_BASE = 'https://api.elevenlabs.io/v1'; +const ALLOWED_STATUSES = ['generated', 'published', 'blocked']; + +// GET /api/audios +router.get('/', async (req, res, next) => { + try { + const { status, voice_id, limit = 50, offset = 0 } = req.query; + const params = [Math.min(parseInt(limit), 500), parseInt(offset)]; + const conditions = []; + if (status) { conditions.push(`status = $${params.length + 1}`); params.push(status); } + if (voice_id) { conditions.push(`voice_id = $${params.length + 1}`); params.push(voice_id); } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await query( + `SELECT * FROM audios ${where} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, + params + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/audios/:id +router.get('/:id', async (req, res, next) => { + try { + const result = await query('SELECT * FROM audios WHERE id = $1', [req.params.id]); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// POST /api/audios/generate — ElevenLabs TTS → S3 → DB +router.post('/generate', async (req, res, next) => { + try { + const { + text, + voice_id, + model_id = 'eleven_multilingual_v2', + speed = 1.0, + stability = 0.5, + similarity_boost = 0.75, + style = 0.0, + } = req.body; + + if (!text) return res.status(400).json({ error: 'text is required' }); + if (!voice_id) return res.status(400).json({ error: 'voice_id is required' }); + + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) return res.status(500).json({ error: 'ELEVENLABS_API_KEY not configured' }); + + const elevenRes = await fetch( + `${ELEVENLABS_BASE}/text-to-speech/${voice_id}/with-timestamps`, + { + method: 'POST', + headers: { + 'xi-api-key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text, + model_id, + speed, + voice_settings: { stability, similarity_boost, style, use_speaker_boost: true }, + }), + } + ); + + if (!elevenRes.ok) { + const err = await elevenRes.text(); + return res.status(elevenRes.status).json({ error: 'ElevenLabs error', detail: err }); + } + + const { audio_base64, alignment } = await elevenRes.json(); + const buffer = Buffer.from(audio_base64, 'base64'); + + const id = uuidv4(); + const key = `audios/${id}/${uuidv4()}.mp3`; + const audio_link = await uploadFile(key, buffer, 'audio/mpeg'); + + const result = await query( + `INSERT INTO audios + (id, text, audio_link, alignment, voice_id, model_id, speed, stability, similarity_boost, style) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [id, text, audio_link, JSON.stringify(alignment), voice_id, model_id, + speed, stability, similarity_boost, style] + ); + + res.status(201).json(result.rows[0]); + } catch (err) { next(err); } +}); + +// PATCH /api/audios/:id — Status ändern +router.patch('/:id', async (req, res, next) => { + try { + const allowed = ['status', 'text', 'audio_link', 'alignment', 'voice_id', 'model_id', + 'speed', 'stability', 'similarity_boost', 'style', 'published_at', 'blocked_at']; + const fields = Object.keys(req.body).filter(k => allowed.includes(k)); + if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' }); + + if (req.body.status && !ALLOWED_STATUSES.includes(req.body.status)) + return res.status(400).json({ error: `status must be one of: ${ALLOWED_STATUSES.join(', ')}` }); + + if (req.body.status === 'published' && !req.body.published_at) + fields.push('published_at'), req.body.published_at = new Date().toISOString(); + if (req.body.status === 'blocked' && !req.body.blocked_at) + fields.push('blocked_at'), req.body.blocked_at = new Date().toISOString(); + + const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', '); + const values = [...fields.map(f => req.body[f]), req.params.id]; + const result = await query( + `UPDATE audios SET ${setClauses} WHERE id = $${fields.length + 1} RETURNING *`, + values + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// DELETE /api/audios/:id — DB-Row + S3-Datei löschen +router.delete('/:id', async (req, res, next) => { + try { + const existing = await query('SELECT audio_link FROM audios WHERE id = $1', [req.params.id]); + if (!existing.rows.length) return res.status(404).json({ error: 'Not found' }); + + const key = keyFromUrl(existing.rows[0].audio_link); + if (key) await deleteFile(key).catch(() => {}); + + await query('DELETE FROM audios WHERE id = $1', [req.params.id]); + res.status(204).end(); + } catch (err) { next(err); } +}); + +module.exports = router;