diff --git a/src/db-migrate.js b/src/db-migrate.js index 1e31808..c1dfc09 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -572,6 +572,34 @@ async function migrate() { FOR EACH ROW EXECUTE FUNCTION update_updated_at() `); + // tts_settings — Stimme + Parameter pro Sprache (zentral konfigurierbar) + await query(` + CREATE TABLE IF NOT EXISTS tts_settings ( + language VARCHAR(10) PRIMARY KEY, + voice_id TEXT NOT NULL, + model_id TEXT NOT NULL DEFAULT 'eleven_multilingual_v2', + speed NUMERIC(4,2) NOT NULL DEFAULT 1.0, + stability NUMERIC(4,2) NOT NULL DEFAULT 0.5, + similarity_boost NUMERIC(4,2) NOT NULL DEFAULT 0.75, + style NUMERIC(4,2) NOT NULL DEFAULT 0.0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await query(` + DROP TRIGGER IF EXISTS tts_settings_updated_at ON tts_settings; + CREATE TRIGGER tts_settings_updated_at + BEFORE UPDATE ON tts_settings + FOR EACH ROW EXECUTE FUNCTION update_updated_at() + `); + // Seed-Stimmen (nur einfügen wenn fehlt — manuelle Änderungen bleiben erhalten) + await query(` + INSERT INTO tts_settings (language, voice_id) VALUES + ('de', 'rKiu7lQ4c5P3az3745s3'), + ('en', 'cVd39cx0VtXNC13y5Y7z'), + ('sv', 'XXCqsM8I9KhqA7jLGj1U') + ON CONFLICT (language) DO NOTHING + `).catch(() => {}); + // ── Migrate old {{uuid}} placeholders → new {{label.w:uuid}} / {{label.o:uuid}} ── await migratePlaceholders(); diff --git a/src/index.js b/src/index.js index ca26388..0e715cb 100644 --- a/src/index.js +++ b/src/index.js @@ -41,6 +41,7 @@ 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/tts-settings', auth, require('./routes/tts-settings')); app.use('/api/claude', auth, require('./routes/claude')); // 404 diff --git a/src/routes/audios.js b/src/routes/audios.js index 30d4535..5c19040 100644 --- a/src/routes/audios.js +++ b/src/routes/audios.js @@ -8,23 +8,43 @@ const ELEVENLABS_BASE = 'https://api.elevenlabs.io/v1'; const ALLOWED_STATUSES = ['generated', 'published', 'blocked']; const LANGS = ['de', 'en', 'sv']; -// Welche Felder pro Quelle Audio brauchen und ab welchem Status sie "reif" sind. +// Welche Felder pro Quelle Audio brauchen und ab welchem Status sie vertonbar sind. +// Audio darf früh erzeugt werden (sobald Text existiert, nur 'blocked' ausgenommen) — +// das ist entkoppelt vom App-Gate (published). // column = `${field}_${lang}` (z.B. titel_de, sentence_sv, positive_sentence_en) const SOURCE_CONFIG = { - words: { fields: ['titel'], ready: ['generated', 'published'] }, - questions: { fields: ['sentence'], ready: ['reviewed', 'published'] }, - statements: { fields: ['positive_sentence', 'negative_sentence'], ready: ['reviewed', 'published'] }, + words: { fields: ['titel'], ready: ['translated', 'generated', 'published'] }, + questions: { fields: ['sentence'], ready: ['draft', 'reviewed', 'published'] }, + statements: { fields: ['positive_sentence', 'negative_sentence'], ready: ['draft', 'reviewed', 'published'] }, }; +// Stimme + Parameter pro Sprache aus tts_settings (Fallback: env-Voice aus voices.js) +async function getTtsSettings(language) { + const r = await query('SELECT * FROM tts_settings WHERE language = $1', [language]); + if (r.rows.length) { + const s = r.rows[0]; + return { + voice_id: s.voice_id, + model_id: s.model_id || 'eleven_multilingual_v2', + speed: Number(s.speed), stability: Number(s.stability), + similarity_boost: Number(s.similarity_boost), style: Number(s.style), + }; + } + return { voice_id: voiceForLanguage(language), model_id: 'eleven_multilingual_v2', + speed: 1.0, stability: 0.5, similarity_boost: 0.75, style: 0.0 }; +} + // ── ElevenLabs aufrufen + in S3/DB ablegen ────────────────────────────────── async function generateAndStore({ text, voice_id, language, model_id, speed, stability, similarity_boost, style, source_table, source_id, source_field }) { const apiKey = process.env.ELEVENLABS_API_KEY; if (!apiKey) { const e = new Error('ELEVENLABS_API_KEY not configured'); e.status = 500; throw e; } - const voice = voice_id || voiceForLanguage(language); - const m = model_id || 'eleven_multilingual_v2'; - const sp = speed ?? 1.0, st = stability ?? 0.5, sb = similarity_boost ?? 0.75, sy = style ?? 0.0; + const cfg = await getTtsSettings(language); + const voice = voice_id || cfg.voice_id; + const m = model_id || cfg.model_id; + const sp = speed ?? cfg.speed, st = stability ?? cfg.stability, + sb = similarity_boost ?? cfg.similarity_boost, sy = style ?? cfg.style; const elevenRes = await fetch( `${ELEVENLABS_BASE}/text-to-speech/${voice}/with-timestamps`, diff --git a/src/routes/pairs.js b/src/routes/pairs.js index 8b84dfc..6ba1c9b 100644 --- a/src/routes/pairs.js +++ b/src/routes/pairs.js @@ -15,6 +15,116 @@ const STATUS_TIMESTAMP = { blocked: 'blocked_at', }; +const LANGS = ['de', 'en', 'sv']; + +// Sammelt für eine Menge Pairs den Kontext (Fragen, Statements, Bilder, Audios) für eine Sprache. +async function loadPairContext(pairs, lang) { + const questionIds = [...new Set(pairs.map(p => p.question_id).filter(Boolean))]; + const statementIds = [...new Set([ + ...pairs.map(p => p.positive_statement_id), + ...pairs.map(p => p.negative_statement_id), + ].filter(Boolean))]; + const pairIds = pairs.map(p => p.id); + + const questionsMap = {}, statementsMap = {}, pictureMap = {}, audioMap = {}; + + if (questionIds.length) { + const r = await query( + `SELECT id, status, sentence_${lang} AS sentence FROM questions WHERE id = ANY($1)`, [questionIds]); + r.rows.forEach(q => { questionsMap[q.id] = q; }); + } + if (statementIds.length) { + const r = await query( + `SELECT id, status, positive_sentence_${lang} AS positive, negative_sentence_${lang} AS negative + FROM statements WHERE id = ANY($1)`, [statementIds]); + r.rows.forEach(s => { statementsMap[s.id] = s; }); + } + if (pairIds.length) { + const r = await query( + `SELECT op.pair_id, + bool_or(true) AS has_picture, + bool_or(pp.status = 'published') AS has_published_picture + FROM object_pairs op + JOIN object_pictures pic ON pic.object_id = op.object_id + JOIN pictures pp ON pp.id = pic.picture_id + WHERE op.pair_id = ANY($1) + GROUP BY op.pair_id`, [pairIds]); + r.rows.forEach(row => { pictureMap[row.pair_id] = row; }); + + const ids = [...questionIds, ...statementIds]; + if (ids.length) { + const a = await query( + `SELECT source_table, source_id, source_field FROM audios + WHERE source_id = ANY($1) AND language = $2 AND status <> 'blocked'`, [ids, lang]); + a.rows.forEach(x => { audioMap[`${x.source_table}|${x.source_id}|${x.source_field}`] = true; }); + } + } + return { questionsMap, statementsMap, pictureMap, audioMap }; +} + +// Berechnet, was einem Pair zur Veröffentlichung (für eine Sprache) noch fehlt. +function computeReadiness(p, ctx, lang) { + const missing = []; + const q = p.question_id ? ctx.questionsMap[p.question_id] : null; + const ps = p.positive_statement_id ? ctx.statementsMap[p.positive_statement_id] : null; + const ns = p.negative_statement_id ? ctx.statementsMap[p.negative_statement_id] : null; + const pic = ctx.pictureMap[p.id]; + + // Bild + if (!pic || !pic.has_picture) missing.push('Bild fehlt'); + else if (!pic.has_published_picture) missing.push('Bild nicht veröffentlicht'); + + // Frage + if (q) { + if (!(q.sentence || '').trim()) missing.push(`Frage-Text (${lang}) fehlt`); + else { + if (q.status !== 'published') missing.push('Frage nicht freigegeben'); + if (!ctx.audioMap[`questions|${p.question_id}|sentence`]) missing.push('Audio Frage fehlt'); + } + } + // Positiv-Statement + if (ps) { + if (!(ps.positive || '').trim()) missing.push(`Positiv-Satz (${lang}) fehlt`); + else { + if (ps.status !== 'published') missing.push('Positiv-Satz nicht freigegeben'); + if (!ctx.audioMap[`statements|${p.positive_statement_id}|positive_sentence`]) missing.push('Audio Positiv fehlt'); + } + } + // Negativ-Statement (nur wenn Text vorhanden) + if (ns && (ns.negative || '').trim()) { + if (ns.status !== 'published') missing.push('Negativ-Satz nicht freigegeben'); + if (!ctx.audioMap[`statements|${p.negative_statement_id}|negative_sentence`]) missing.push('Audio Negativ fehlt'); + } + + return { missing, missingCount: missing.length, ready: missing.length === 0 }; +} + +// GET /api/pairs/publishability?lang=sv — Pairs mit Readiness, sortierbar nach "am wenigsten fehlt" +router.get('/publishability', async (req, res, next) => { + try { + const lang = LANGS.includes(req.query.lang) ? req.query.lang : 'de'; + const includePublished = req.query.includePublished === 'true'; + const statusFilter = includePublished ? `status <> 'blocked'` : `status IN ('draft','reviewed')`; + const pairsRes = await query( + `SELECT id, answer_type, status, question_id, positive_statement_id, negative_statement_id + FROM pairs WHERE ${statusFilter} ORDER BY created_at DESC LIMIT 300`); + const pairs = pairsRes.rows; + if (!pairs.length) return res.json({ lang, pairs: [] }); + + const ctx = await loadPairContext(pairs, lang); + const result = pairs.map(p => { + const r = computeReadiness(p, ctx, lang); + const q = p.question_id ? ctx.questionsMap[p.question_id] : null; + const ps = p.positive_statement_id ? ctx.statementsMap[p.positive_statement_id] : null; + const preview = (q?.sentence || ps?.positive || '').slice(0, 80); + return { id: p.id, answer_type: p.answer_type, status: p.status, preview, + missing: r.missing, missingCount: r.missingCount, ready: r.ready }; + }).sort((a, b) => a.missingCount - b.missingCount); + + res.json({ lang, pairs: result }); + } catch (err) { next(err); } +}); + // GET /api/pairs router.get('/', async (req, res, next) => { try { @@ -104,6 +214,37 @@ router.patch('/:id', async (req, res, next) => { } catch (err) { next(err); } }); +// POST /api/pairs/:id/publish?lang=sv — validiert Readiness und veröffentlicht kaskadierend +router.post('/:id/publish', async (req, res, next) => { + try { + const lang = LANGS.includes(req.query.lang || req.body.lang) ? (req.query.lang || req.body.lang) : 'de'; + const pr = await query( + `SELECT id, answer_type, status, question_id, positive_statement_id, negative_statement_id + FROM pairs WHERE id = $1`, [req.params.id]); + if (!pr.rows.length) return res.status(404).json({ error: 'Not found' }); + const p = pr.rows[0]; + + const ctx = await loadPairContext([p], lang); + const r = computeReadiness(p, ctx, lang); + if (!r.ready) + return res.status(409).json({ error: 'Noch nicht veröffentlichbar', missing: r.missing, lang }); + + // Kaskadierend veröffentlichen + const now = new Date().toISOString(); + const ids = [p.question_id, p.positive_statement_id, p.negative_statement_id].filter(Boolean); + if (p.question_id) + await query(`UPDATE questions SET status='published', published_at=COALESCE(published_at,$2) WHERE id=$1`, [p.question_id, now]); + const stmtIds = [p.positive_statement_id, p.negative_statement_id].filter(Boolean); + if (stmtIds.length) + await query(`UPDATE statements SET status='published', published_at=COALESCE(published_at,$2) WHERE id = ANY($1)`, [stmtIds, now]); + const upd = await query( + `UPDATE pairs SET status='published', published_at=COALESCE(published_at,$2) WHERE id=$1 RETURNING *`, + [p.id, now]); + + res.json({ ...upd.rows[0], published_languages: [lang] }); + } catch (err) { next(err); } +}); + // DELETE /api/pairs/:id router.delete('/:id', async (req, res, next) => { try { diff --git a/src/routes/tts-settings.js b/src/routes/tts-settings.js new file mode 100644 index 0000000..1a6532f --- /dev/null +++ b/src/routes/tts-settings.js @@ -0,0 +1,58 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +const EDITABLE = ['voice_id', 'model_id', 'speed', 'stability', 'similarity_boost', 'style']; + +// GET /api/tts-settings +router.get('/', async (req, res, next) => { + try { + const r = await query('SELECT * FROM tts_settings ORDER BY language'); + res.json(r.rows); + } catch (err) { next(err); } +}); + +// GET /api/tts-settings/:language +router.get('/:language', async (req, res, next) => { + try { + const r = await query('SELECT * FROM tts_settings WHERE language = $1', [req.params.language]); + if (!r.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(r.rows[0]); + } catch (err) { next(err); } +}); + +// PUT /api/tts-settings/:language — anlegen oder aktualisieren +router.put('/:language', async (req, res, next) => { + try { + const { voice_id } = req.body; + if (!voice_id) return res.status(400).json({ error: 'voice_id is required' }); + const r = await query( + `INSERT INTO tts_settings (language, voice_id, model_id, speed, stability, similarity_boost, style) + VALUES ($1, $2, COALESCE($3,'eleven_multilingual_v2'), COALESCE($4,1.0), COALESCE($5,0.5), COALESCE($6,0.75), COALESCE($7,0.0)) + ON CONFLICT (language) DO UPDATE SET + voice_id = EXCLUDED.voice_id, model_id = EXCLUDED.model_id, speed = EXCLUDED.speed, + stability = EXCLUDED.stability, similarity_boost = EXCLUDED.similarity_boost, style = EXCLUDED.style + RETURNING *`, + [req.params.language, voice_id, req.body.model_id, req.body.speed, + req.body.stability, req.body.similarity_boost, req.body.style] + ); + res.json(r.rows[0]); + } catch (err) { next(err); } +}); + +// PATCH /api/tts-settings/:language +router.patch('/:language', async (req, res, next) => { + try { + const fields = Object.keys(req.body).filter(k => EDITABLE.includes(k)); + if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' }); + const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', '); + const values = [...fields.map(f => req.body[f]), req.params.language]; + const r = await query( + `UPDATE tts_settings SET ${setClauses} WHERE language = $${fields.length + 1} RETURNING *`, + values + ); + if (!r.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(r.rows[0]); + } catch (err) { next(err); } +}); + +module.exports = router;