From b669c2d0b7ec82fde582df35db636f776c72cb9c Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 8 Jul 2026 18:04:02 +0000 Subject: [PATCH] Add streamer dashboard stats; fix TTS overlap race properly Dashboard now shows unique visitors (via sender link), total messages, and unique senders per streamer, tracked via new viewer_visits/messages tables. The previous overlay TTS-overlap fix had a race: audio not having arrived yet was treated the same as "no audio at all", letting the queue advance before slow-to-synthesize audio (e.g. a long question) ever started playing. Now "not arrived yet" is explicitly distinct from "finished playing", with a grace-period fallback for genuine TTS failures. Also drop the hardcoded AI name from ai-prompt.txt since it's now injected dynamically from the streamer's configured ai_name. --- ai-prompt.txt | 2 +- db.js | 42 ++++++++++++++++++++++++++++++++++++++++++ public/dashboard.html | 36 ++++++++++++++++++++++++++++++++++++ public/overlay.js | 29 +++++++++++++++++++++-------- server.js | 14 ++++++++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/ai-prompt.txt b/ai-prompt.txt index c232bf4..410db4a 100644 --- a/ai-prompt.txt +++ b/ai-prompt.txt @@ -1,4 +1,4 @@ -Ты дружелюбный ИИ-ассистент в чате стрима по имени Альтушка Ирина. Тебе присылают сообщение зрители, +Ты дружелюбный ИИ-ассистент в чате стрима. Тебе присылают сообщение зрители, а ты отвечаешь на них. Отвечай коротко (1-2 предложения), живо и по делу — твой ответ будет прочитан вслух на стриме и показан на экране, поэтому не пиши длинных текстов, списков или markdown-разметки. diff --git a/db.js b/db.js index 8cf088b..a2b1336 100644 --- a/db.js +++ b/db.js @@ -30,6 +30,21 @@ db.exec(` updated_at INTEGER NOT NULL, PRIMARY KEY (streamer_id, google_id) ); + + CREATE TABLE IF NOT EXISTS viewer_visits ( + streamer_id INTEGER NOT NULL, + google_id TEXT NOT NULL, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + PRIMARY KEY (streamer_id, google_id) + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + streamer_id INTEGER NOT NULL, + google_id TEXT NOT NULL, + created_at INTEGER NOT NULL + ); `); // Idempotent migration for databases created before display_duration_seconds existed. @@ -94,6 +109,30 @@ function updateAiName(streamerId, aiName) { db.prepare('UPDATE streamers SET ai_name = ? WHERE id = ?').run(aiName, streamerId); } +function recordVisit(streamerId, googleId) { + const now = Date.now(); + db.prepare( + `INSERT INTO viewer_visits (streamer_id, google_id, first_seen_at, last_seen_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(streamer_id, google_id) DO UPDATE SET last_seen_at = excluded.last_seen_at` + ).run(streamerId, googleId, now, now); +} + +function recordMessage(streamerId, googleId) { + db.prepare( + 'INSERT INTO messages (streamer_id, google_id, created_at) VALUES (?, ?, ?)' + ).run(streamerId, googleId, Date.now()); +} + +function getStreamerStats(streamerId) { + const visitors = db.prepare('SELECT COUNT(*) AS c FROM viewer_visits WHERE streamer_id = ?').get(streamerId).c; + const messages = db.prepare('SELECT COUNT(*) AS c FROM messages WHERE streamer_id = ?').get(streamerId).c; + const uniqueSenders = db + .prepare('SELECT COUNT(DISTINCT google_id) AS c FROM messages WHERE streamer_id = ?') + .get(streamerId).c; + return { visitors, messages, uniqueSenders }; +} + module.exports = { findStreamerByOverlayToken, findStreamerBySenderToken, @@ -102,4 +141,7 @@ module.exports = { setViewerName, updateDisplayDuration, updateAiName, + recordVisit, + recordMessage, + getStreamerStats, }; diff --git a/public/dashboard.html b/public/dashboard.html index 795a6c9..74c7eac 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -74,12 +74,45 @@ margin-top: 8px; min-height: 16px; } + .stats-row { + display: flex; + gap: 24px; + } + .stat { + flex: 1; + } + .stat .value { + font-size: 24px; + font-weight: 600; + } + .stat .label { + font-size: 13px; + color: #666; + }

Ваши ссылки

+
+

Статистика

+
+
+
+
заходили по ссылке
+
+
+
+
сообщений отправлено
+
+
+
+
уникальных отправителей
+
+
+
+

Ссылка для OBS / StreamLabs

Вставьте в свойства Browser Source (URL).

@@ -141,6 +174,9 @@ document.getElementById('sender-url').value = data.senderUrl; document.getElementById('display-duration').value = data.displayDurationSeconds; document.getElementById('ai-name').value = data.aiName; + document.getElementById('stat-visitors').textContent = data.stats.visitors; + document.getElementById('stat-messages').textContent = data.stats.messages; + document.getElementById('stat-unique-senders').textContent = data.stats.uniqueSenders; } document.getElementById('save-settings').addEventListener('click', async () => { diff --git a/public/overlay.js b/public/overlay.js index 923d61b..621a47c 100644 --- a/public/overlay.js +++ b/public/overlay.js @@ -70,24 +70,28 @@ function advanceQueue() { playNotificationSound(); // The message stays up for at least durationMs (streamer's configured - // display time), but if its TTS audio is still playing when that timer - // fires (e.g. a long question read aloud), we must not start the next - // message's audio on top of it — so we also wait for the audio to - // actually finish before advancing. + // display time), but if its TTS audio is still playing (or hasn't even + // arrived yet — synthesis is async and can take a few seconds, longer + // than a short configured duration) when that timer fires, we must not + // start the next message's audio on top of it. So we only advance once + // BOTH the minimum time has passed AND we know the audio is actually + // done — "not arrived yet" is treated as "still waiting", not "done", + // unlike an earlier version of this code that got that backwards. let minTimeElapsed = false; - let audioFinished = !current.audio; + // 'unknown' (no audio yet, may still arrive), 'playing', or 'done'. + let audioState = current.audio ? 'playing' : 'unknown'; const maybeAdvance = () => { - if (!minTimeElapsed || !audioFinished) return; + if (!minTimeElapsed || audioState !== 'done') return; box.classList.remove('visible'); current = null; setTimeout(advanceQueue, FADE_MS); }; current.startAudio = (audio) => { - audioFinished = false; + audioState = 'playing'; playSpeech(audio.data, audio.mimeType, () => { - audioFinished = true; + audioState = 'done'; maybeAdvance(); }); }; @@ -100,6 +104,15 @@ function advanceQueue() { minTimeElapsed = true; maybeAdvance(); }, current.durationMs || 10000); + + // Safety net: if TTS synthesis fails server-side, no display_audio ever + // arrives for this id — don't wait forever for audio that's never coming. + setTimeout(() => { + if (audioState === 'unknown') { + audioState = 'done'; + maybeAdvance(); + } + }, (current.durationMs || 10000) + 8000); } socket.on('display_message', (payload) => { diff --git a/server.js b/server.js index 531b12a..5d33343 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,9 @@ const { setViewerName, updateDisplayDuration, updateAiName, + recordVisit, + recordMessage, + getStreamerStats, } = require('./db'); const { censorText } = require('./profanity'); @@ -127,6 +130,7 @@ app.get('/api/dashboard', requireAuth, (req, res) => { ttsVoice: streamer.tts_voice, displayDurationSeconds: streamer.display_duration_seconds, aiName: streamer.ai_name, + stats: getStreamerStats(streamer.id), }); }); @@ -265,6 +269,14 @@ io.on('connection', (socket) => { } else { socket.join(`streamer-${streamer.id}`); } + } else if (role === 'sender' && connectedUser) { + // Only reached on the reconnect that happens right after login (see + // socket.disconnect()/connect() in send.js) — the initial anonymous + // connection has no session.user yet, so no visit is recorded for it. + const streamer = findStreamerBySenderToken(token); + if (streamer) { + recordVisit(streamer.id, connectedUser.id); + } } socket.on('disconnect', (reason) => { @@ -294,6 +306,8 @@ io.on('connection', (socket) => { return; } + recordMessage(streamer.id, user.id); + const rawMessage = text.trim().slice(0, 500); const message = censorText(rawMessage, '...'); const speechMessage = censorText(rawMessage, 'бип');