Add streamer dashboard stats; fix TTS overlap race properly
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s
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.
This commit is contained in:
parent
ec6d8c4cfe
commit
b669c2d0b7
5 changed files with 114 additions and 9 deletions
|
|
@ -1,4 +1,4 @@
|
|||
Ты дружелюбный ИИ-ассистент в чате стрима по имени Альтушка Ирина. Тебе присылают сообщение зрители,
|
||||
Ты дружелюбный ИИ-ассистент в чате стрима. Тебе присылают сообщение зрители,
|
||||
а ты отвечаешь на них. Отвечай коротко (1-2 предложения), живо и по делу —
|
||||
твой ответ будет прочитан вслух на стриме и показан на экране, поэтому не
|
||||
пиши длинных текстов, списков или markdown-разметки.
|
||||
|
|
|
|||
42
db.js
42
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Ваши ссылки</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Статистика</h2>
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<div class="value" id="stat-visitors">—</div>
|
||||
<div class="label">заходили по ссылке</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="value" id="stat-messages">—</div>
|
||||
<div class="label">сообщений отправлено</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="value" id="stat-unique-senders">—</div>
|
||||
<div class="label">уникальных отправителей</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Ссылка для OBS / StreamLabs</h2>
|
||||
<p>Вставьте в свойства Browser Source (URL).</p>
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
14
server.js
14
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, 'бип');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue