send2streamer/db.js
vrubelroman b669c2d0b7
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s
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.
2026-07-08 18:04:02 +00:00

147 lines
5 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { DatabaseSync } = require('node:sqlite');
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'app.db');
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS streamers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
google_id TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
name TEXT NOT NULL,
picture TEXT,
overlay_token TEXT UNIQUE NOT NULL,
sender_token TEXT UNIQUE NOT NULL,
tts_voice TEXT NOT NULL DEFAULT 'ru-RU-DmitryNeural',
display_duration_seconds INTEGER NOT NULL DEFAULT 10,
ai_name TEXT NOT NULL DEFAULT 'Альтушка Ирина',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS viewer_names (
streamer_id INTEGER NOT NULL,
google_id TEXT NOT NULL,
display_name TEXT NOT NULL,
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.
const existingColumns = db.prepare('PRAGMA table_info(streamers)').all().map((c) => c.name);
if (!existingColumns.includes('display_duration_seconds')) {
db.exec('ALTER TABLE streamers ADD COLUMN display_duration_seconds INTEGER NOT NULL DEFAULT 10');
}
if (!existingColumns.includes('ai_name')) {
db.exec("ALTER TABLE streamers ADD COLUMN ai_name TEXT NOT NULL DEFAULT 'Альтушка Ирина'");
}
function generateToken() {
return crypto.randomBytes(16).toString('hex');
}
function findStreamerByGoogleId(googleId) {
return db.prepare('SELECT * FROM streamers WHERE google_id = ?').get(googleId);
}
function findStreamerByOverlayToken(token) {
return db.prepare('SELECT * FROM streamers WHERE overlay_token = ?').get(token);
}
function findStreamerBySenderToken(token) {
return db.prepare('SELECT * FROM streamers WHERE sender_token = ?').get(token);
}
function createStreamer({ googleId, email, name, picture }) {
const overlayToken = generateToken();
const senderToken = generateToken();
db.prepare(
`INSERT INTO streamers (google_id, email, name, picture, overlay_token, sender_token, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(googleId, email, name, picture || null, overlayToken, senderToken, Date.now());
return findStreamerByGoogleId(googleId);
}
function getOrCreateStreamerByGoogle({ googleId, email, name, picture }) {
return findStreamerByGoogleId(googleId) || createStreamer({ googleId, email, name, picture });
}
function getViewerName(streamerId, googleId) {
const row = db
.prepare('SELECT display_name FROM viewer_names WHERE streamer_id = ? AND google_id = ?')
.get(streamerId, googleId);
return row ? row.display_name : null;
}
function setViewerName(streamerId, googleId, displayName) {
db.prepare(
`INSERT INTO viewer_names (streamer_id, google_id, display_name, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(streamer_id, google_id) DO UPDATE SET display_name = excluded.display_name, updated_at = excluded.updated_at`
).run(streamerId, googleId, displayName, Date.now());
}
function updateDisplayDuration(streamerId, seconds) {
db.prepare('UPDATE streamers SET display_duration_seconds = ? WHERE id = ?').run(seconds, streamerId);
}
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,
getOrCreateStreamerByGoogle,
getViewerName,
setViewerName,
updateDisplayDuration,
updateAiName,
recordVisit,
recordMessage,
getStreamerStats,
};