Spaces:
Sleeping
Sleeping
Sasha commited on
Commit ·
513eb9c
1
Parent(s): 91f86ea
feat: global roles migration, VIP badges, role backfiller script
Browse files- client/src/App.jsx +2 -1
- local_worker/role_backfiller.py +157 -0
- local_worker/vod_backfiller.py +5 -3
- local_worker/worker.py +3 -1
- server/db.js +185 -24
- server/init_db.sql +33 -16
- server/server.js +18 -0
- server/supabase_migration_roles.sql +57 -0
client/src/App.jsx
CHANGED
|
@@ -1074,7 +1074,8 @@ export default function App() {
|
|
| 1074 |
<span className="badge badge-streamer">Стример</span>
|
| 1075 |
)}
|
| 1076 |
{c.is_mod ? <span className="badge badge-mod">Мод</span> : null}
|
| 1077 |
-
{c.
|
|
|
|
| 1078 |
</div>
|
| 1079 |
</td>
|
| 1080 |
<td style={{ fontWeight: 700, color: 'var(--color-text-accent)' }}>
|
|
|
|
| 1074 |
<span className="badge badge-streamer">Стример</span>
|
| 1075 |
)}
|
| 1076 |
{c.is_mod ? <span className="badge badge-mod">Мод</span> : null}
|
| 1077 |
+
{c.is_vip && !c.is_mod ? <span className="badge badge-vip">VIP</span> : null}
|
| 1078 |
+
{c.is_sub && !c.is_mod && !c.is_vip ? <span className="badge badge-sub">Саб</span> : null}
|
| 1079 |
</div>
|
| 1080 |
</td>
|
| 1081 |
<td style={{ fontWeight: 700, color: 'var(--color-text-accent)' }}>
|
local_worker/role_backfiller.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import time
|
| 4 |
+
import requests
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
# Load local environment settings
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
API_URL = os.getenv("API_URL", "http://localhost:3000")
|
| 11 |
+
API_KEY = os.getenv("API_KEY", "")
|
| 12 |
+
|
| 13 |
+
if not API_KEY:
|
| 14 |
+
print("[Error] API_KEY is missing in .env! Cannot push data to backend.")
|
| 15 |
+
sys.exit(1)
|
| 16 |
+
|
| 17 |
+
headers = {
|
| 18 |
+
"x-api-key": API_KEY,
|
| 19 |
+
"Content-Type": "application/json"
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
def download_vod_roles(vod_id):
|
| 23 |
+
"""Download chat log of a past VOD and extract only roles"""
|
| 24 |
+
roles_dict = {} # Store latest role per user
|
| 25 |
+
url = "https://gql.twitch.tv/gql"
|
| 26 |
+
gql_headers = {
|
| 27 |
+
"Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
|
| 28 |
+
"User-Agent": "Mozilla/5.0"
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
current_offset = 0
|
| 32 |
+
print(f"\n[Roles] Запуск сверхбыстрого парсера ролей для VOD {vod_id}...")
|
| 33 |
+
|
| 34 |
+
total_messages = 0
|
| 35 |
+
|
| 36 |
+
while True:
|
| 37 |
+
payload = {
|
| 38 |
+
"operationName": "VideoCommentsByOffsetOrCursor",
|
| 39 |
+
"variables": {
|
| 40 |
+
"videoID": str(vod_id),
|
| 41 |
+
"contentOffsetSeconds": current_offset
|
| 42 |
+
},
|
| 43 |
+
"extensions": {
|
| 44 |
+
"persistedQuery": {
|
| 45 |
+
"version": 1,
|
| 46 |
+
"sha256Hash": "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
res = requests.post(url, json=payload, headers=gql_headers, timeout=10)
|
| 53 |
+
if res.status_code != 200:
|
| 54 |
+
print(f"[Roles] Ошибка GQL: {res.status_code}")
|
| 55 |
+
break
|
| 56 |
+
|
| 57 |
+
data = res.json()
|
| 58 |
+
if isinstance(data, list):
|
| 59 |
+
data = data[0]
|
| 60 |
+
|
| 61 |
+
video = data.get("data", {}).get("video", {})
|
| 62 |
+
if not video:
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
comments_edge = video.get("comments") or {}
|
| 66 |
+
edges = comments_edge.get("edges") or []
|
| 67 |
+
if not edges:
|
| 68 |
+
break
|
| 69 |
+
|
| 70 |
+
for edge in edges:
|
| 71 |
+
if not edge:
|
| 72 |
+
continue
|
| 73 |
+
node = edge.get("node")
|
| 74 |
+
if not node:
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
commenter = node.get("commenter")
|
| 78 |
+
if not commenter:
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
user = commenter.get("login")
|
| 82 |
+
if not user:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
display_name = commenter.get("displayName", user)
|
| 86 |
+
|
| 87 |
+
message = node.get("message")
|
| 88 |
+
if not message:
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
timestamp = node.get("createdAt")
|
| 92 |
+
|
| 93 |
+
# Badges parse
|
| 94 |
+
user_badges = message.get("userBadges") or []
|
| 95 |
+
badges = [b.get("setID") for b in user_badges if b]
|
| 96 |
+
is_mod = "moderator" in badges or "broadcaster" in badges
|
| 97 |
+
is_sub = "subscriber" in badges or "founder" in badges
|
| 98 |
+
is_vip = "vip" in badges
|
| 99 |
+
|
| 100 |
+
# We always overwrite with the latest role from the VOD
|
| 101 |
+
# because the chat goes forward in time
|
| 102 |
+
roles_dict[user.lower()] = {
|
| 103 |
+
"username": user,
|
| 104 |
+
"displayName": display_name,
|
| 105 |
+
"isMod": is_mod,
|
| 106 |
+
"isSub": is_sub,
|
| 107 |
+
"isVip": is_vip,
|
| 108 |
+
"timestamp": timestamp
|
| 109 |
+
}
|
| 110 |
+
total_messages += 1
|
| 111 |
+
|
| 112 |
+
print(f"-> Обработано {total_messages} сообщений. Найдено уникальных пользователей: {len(roles_dict)}...", end="\r")
|
| 113 |
+
|
| 114 |
+
# Progress offset
|
| 115 |
+
last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
|
| 116 |
+
if last_offset is not None:
|
| 117 |
+
next_offset = last_offset + 1
|
| 118 |
+
if next_offset <= current_offset:
|
| 119 |
+
next_offset = current_offset + 30
|
| 120 |
+
current_offset = next_offset
|
| 121 |
+
else:
|
| 122 |
+
break
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f"\n[Roles] Ошибка при загрузке: {e}")
|
| 125 |
+
break
|
| 126 |
+
|
| 127 |
+
print(f"\n[Roles] Парсинг завершен. Всего уникальных пользователей: {len(roles_dict)}")
|
| 128 |
+
return list(roles_dict.values())
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
print("=========================================================")
|
| 132 |
+
print(" Ultra-Fast Role Backfiller ")
|
| 133 |
+
print("=========================================================")
|
| 134 |
+
|
| 135 |
+
vod_id = input("Введите ID Twitch VOD (например, 2154382910): ").strip()
|
| 136 |
+
if not vod_id:
|
| 137 |
+
print("ID VOD не введен. Выход.")
|
| 138 |
+
sys.exit(0)
|
| 139 |
+
|
| 140 |
+
roles = download_vod_roles(vod_id)
|
| 141 |
+
if roles:
|
| 142 |
+
print("\n[Sync] Отправка ролей на сервер (пакетами по 500 пользователей)...")
|
| 143 |
+
batch_size = 500
|
| 144 |
+
for i in range(0, len(roles), batch_size):
|
| 145 |
+
batch = roles[i:i+batch_size]
|
| 146 |
+
try:
|
| 147 |
+
url = f"{API_URL}/api/log/roles"
|
| 148 |
+
res = requests.post(url, json={"roles": batch}, headers=headers, timeout=10)
|
| 149 |
+
if res.status_code == 200:
|
| 150 |
+
print(f"-> Обновлено пользователей: {i + len(batch)} / {len(roles)}", end="\r")
|
| 151 |
+
else:
|
| 152 |
+
print(f"\n-> Ошибка отправки пакета: {res.status_code}")
|
| 153 |
+
except Exception as e:
|
| 154 |
+
print(f"\n-> Ошибка сети при отправке пакета: {e}")
|
| 155 |
+
print("\n-> Восстановление ролей успешно завершено!")
|
| 156 |
+
else:
|
| 157 |
+
print("\n-> Нет ролей для обновления.")
|
local_worker/vod_backfiller.py
CHANGED
|
@@ -162,10 +162,11 @@ def download_vod_chat(vod_id, start_offset=0, end_offset=None):
|
|
| 162 |
timestamp = node.get("createdAt")
|
| 163 |
|
| 164 |
# Badges parse
|
| 165 |
-
user_badges =
|
| 166 |
-
badges = [b.get("
|
| 167 |
is_mod = "moderator" in badges or "broadcaster" in badges
|
| 168 |
is_sub = "subscriber" in badges or "founder" in badges
|
|
|
|
| 169 |
is_streamer = (user.lower() == TWITCH_CHANNEL)
|
| 170 |
|
| 171 |
seen_ids.add(msg_id)
|
|
@@ -177,7 +178,8 @@ def download_vod_chat(vod_id, start_offset=0, end_offset=None):
|
|
| 177 |
"timestamp": timestamp,
|
| 178 |
"isStreamer": is_streamer,
|
| 179 |
"isMod": is_mod,
|
| 180 |
-
"isSub": is_sub
|
|
|
|
| 181 |
})
|
| 182 |
|
| 183 |
print(f"-> Загружено {len(comments)} комментариев...", end="\r")
|
|
|
|
| 162 |
timestamp = node.get("createdAt")
|
| 163 |
|
| 164 |
# Badges parse
|
| 165 |
+
user_badges = message.get("userBadges") or []
|
| 166 |
+
badges = [b.get("setID") for b in user_badges if b]
|
| 167 |
is_mod = "moderator" in badges or "broadcaster" in badges
|
| 168 |
is_sub = "subscriber" in badges or "founder" in badges
|
| 169 |
+
is_vip = "vip" in badges
|
| 170 |
is_streamer = (user.lower() == TWITCH_CHANNEL)
|
| 171 |
|
| 172 |
seen_ids.add(msg_id)
|
|
|
|
| 178 |
"timestamp": timestamp,
|
| 179 |
"isStreamer": is_streamer,
|
| 180 |
"isMod": is_mod,
|
| 181 |
+
"isSub": is_sub,
|
| 182 |
+
"isVip": is_vip
|
| 183 |
})
|
| 184 |
|
| 185 |
print(f"-> Загружено {len(comments)} комментариев...", end="\r")
|
local_worker/worker.py
CHANGED
|
@@ -163,6 +163,7 @@ def twitch_chat_listener():
|
|
| 163 |
is_streamer = (user.lower() == TWITCH_CHANNEL)
|
| 164 |
is_mod = "moderator" in badges or "broadcaster" in badges
|
| 165 |
is_sub = "subscriber" in badges or "founder" in badges
|
|
|
|
| 166 |
|
| 167 |
parsed_msg = {
|
| 168 |
"id": msg_id,
|
|
@@ -172,7 +173,8 @@ def twitch_chat_listener():
|
|
| 172 |
"timestamp": new_iso_timestamp(),
|
| 173 |
"isStreamer": is_streamer,
|
| 174 |
"isMod": is_mod,
|
| 175 |
-
"isSub": is_sub
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
# Add to queue
|
|
|
|
| 163 |
is_streamer = (user.lower() == TWITCH_CHANNEL)
|
| 164 |
is_mod = "moderator" in badges or "broadcaster" in badges
|
| 165 |
is_sub = "subscriber" in badges or "founder" in badges
|
| 166 |
+
is_vip = "vip" in badges
|
| 167 |
|
| 168 |
parsed_msg = {
|
| 169 |
"id": msg_id,
|
|
|
|
| 173 |
"timestamp": new_iso_timestamp(),
|
| 174 |
"isStreamer": is_streamer,
|
| 175 |
"isMod": is_mod,
|
| 176 |
+
"isSub": is_sub,
|
| 177 |
+
"isVip": is_vip
|
| 178 |
}
|
| 179 |
|
| 180 |
# Add to queue
|
server/db.js
CHANGED
|
@@ -92,6 +92,26 @@ if (dbMode === 'sqlite') {
|
|
| 92 |
key TEXT PRIMARY KEY,
|
| 93 |
value TEXT NOT NULL
|
| 94 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
`);
|
| 96 |
console.log('[Database] SQLite Tables & Indexes initialized.');
|
| 97 |
|
|
@@ -589,8 +609,8 @@ export async function logChatMessagesBatch(messages) {
|
|
| 589 |
|
| 590 |
if (dbMode === 'sqlite') {
|
| 591 |
const insertMsg = sqliteDb.prepare(`
|
| 592 |
-
INSERT INTO messages (id, stream_id, username, display_name, message, timestamp, is_streamer
|
| 593 |
-
VALUES (?, ?, ?, ?, ?, ?, ?
|
| 594 |
`);
|
| 595 |
|
| 596 |
const insertViewer = sqliteDb.prepare(`
|
|
@@ -602,6 +622,18 @@ export async function logChatMessagesBatch(messages) {
|
|
| 602 |
is_sub = excluded.is_sub
|
| 603 |
`);
|
| 604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
const runTransaction = sqliteDb.transaction((msgs) => {
|
| 606 |
for (const msg of msgs) {
|
| 607 |
const streamId = getStreamIdForTime(msg.timestamp);
|
|
@@ -613,9 +645,7 @@ export async function logChatMessagesBatch(messages) {
|
|
| 613 |
msg.displayName || msg.username,
|
| 614 |
msg.message,
|
| 615 |
msg.timestamp || new Date().toISOString(),
|
| 616 |
-
msg.isStreamer ? 1 : 0
|
| 617 |
-
msg.isMod ? 1 : 0,
|
| 618 |
-
msg.isSub ? 1 : 0
|
| 619 |
);
|
| 620 |
} catch (err) {
|
| 621 |
if (!err.message.includes('UNIQUE constraint failed')) {
|
|
@@ -635,6 +665,19 @@ export async function logChatMessagesBatch(messages) {
|
|
| 635 |
} catch (err) {
|
| 636 |
console.error('[Database] SQLite Batch Error updating viewer:', err.message);
|
| 637 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
}
|
| 639 |
});
|
| 640 |
|
|
@@ -643,23 +686,24 @@ export async function logChatMessagesBatch(messages) {
|
|
| 643 |
// Supabase Mode: Build batch lists
|
| 644 |
const messagesToInsert = [];
|
| 645 |
const viewersToUpsert = new Map(); // Use Map to unique-fy viewers by stream_id + username
|
|
|
|
| 646 |
|
| 647 |
for (const msg of messages) {
|
| 648 |
const streamId = getStreamIdForTime(msg.timestamp);
|
|
|
|
|
|
|
| 649 |
|
| 650 |
messagesToInsert.push({
|
| 651 |
id: msg.id,
|
| 652 |
stream_id: streamId,
|
| 653 |
-
username:
|
| 654 |
display_name: msg.displayName || msg.username,
|
| 655 |
message: msg.message,
|
| 656 |
-
timestamp:
|
| 657 |
-
is_streamer: msg.isStreamer || false
|
| 658 |
-
is_mod: msg.isMod || false,
|
| 659 |
-
is_sub: msg.isSub || false
|
| 660 |
});
|
| 661 |
|
| 662 |
-
const viewerKey = `${streamId || 'null'}-${
|
| 663 |
viewersToUpsert.set(viewerKey, {
|
| 664 |
stream_id: streamId,
|
| 665 |
username: msg.username.toLowerCase(),
|
|
@@ -667,7 +711,17 @@ export async function logChatMessagesBatch(messages) {
|
|
| 667 |
has_chatted: true,
|
| 668 |
is_mod: msg.isMod || false,
|
| 669 |
is_sub: msg.isSub || false,
|
| 670 |
-
first_seen:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
});
|
| 672 |
}
|
| 673 |
|
|
@@ -689,6 +743,110 @@ export async function logChatMessagesBatch(messages) {
|
|
| 689 |
if (viewerError) {
|
| 690 |
console.error('[Supabase] Bulk viewers upsert error:', viewerError.message);
|
| 691 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
}
|
| 693 |
}
|
| 694 |
|
|
@@ -937,12 +1095,14 @@ export async function getTopChatters(streamId = null, limit = 50) {
|
|
| 937 |
stmt = sqliteDb.prepare(`
|
| 938 |
SELECT
|
| 939 |
v.username,
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
|
|
|
| 944 |
(SELECT COUNT(*) FROM messages m WHERE m.stream_id = v.stream_id AND m.username = v.username) as message_count
|
| 945 |
FROM stream_viewers v
|
|
|
|
| 946 |
WHERE v.stream_id = ?
|
| 947 |
GROUP BY v.username
|
| 948 |
ORDER BY message_count DESC
|
|
@@ -952,14 +1112,15 @@ export async function getTopChatters(streamId = null, limit = 50) {
|
|
| 952 |
} else {
|
| 953 |
stmt = sqliteDb.prepare(`
|
| 954 |
SELECT
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
FROM
|
| 962 |
-
|
|
|
|
| 963 |
ORDER BY message_count DESC
|
| 964 |
LIMIT ?
|
| 965 |
`);
|
|
|
|
| 92 |
key TEXT PRIMARY KEY,
|
| 93 |
value TEXT NOT NULL
|
| 94 |
);
|
| 95 |
+
|
| 96 |
+
CREATE TABLE IF NOT EXISTS stream_viewers (
|
| 97 |
+
stream_id INTEGER,
|
| 98 |
+
username TEXT NOT NULL,
|
| 99 |
+
display_name TEXT,
|
| 100 |
+
has_chatted INTEGER DEFAULT 0,
|
| 101 |
+
is_mod INTEGER DEFAULT 0,
|
| 102 |
+
is_sub INTEGER DEFAULT 0,
|
| 103 |
+
first_seen TEXT,
|
| 104 |
+
PRIMARY KEY (stream_id, username)
|
| 105 |
+
);
|
| 106 |
+
|
| 107 |
+
CREATE TABLE IF NOT EXISTS chat_users (
|
| 108 |
+
username TEXT PRIMARY KEY,
|
| 109 |
+
display_name TEXT,
|
| 110 |
+
is_mod INTEGER DEFAULT 0,
|
| 111 |
+
is_sub INTEGER DEFAULT 0,
|
| 112 |
+
is_vip INTEGER DEFAULT 0,
|
| 113 |
+
last_seen TEXT NOT NULL
|
| 114 |
+
);
|
| 115 |
`);
|
| 116 |
console.log('[Database] SQLite Tables & Indexes initialized.');
|
| 117 |
|
|
|
|
| 609 |
|
| 610 |
if (dbMode === 'sqlite') {
|
| 611 |
const insertMsg = sqliteDb.prepare(`
|
| 612 |
+
INSERT INTO messages (id, stream_id, username, display_name, message, timestamp, is_streamer)
|
| 613 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 614 |
`);
|
| 615 |
|
| 616 |
const insertViewer = sqliteDb.prepare(`
|
|
|
|
| 622 |
is_sub = excluded.is_sub
|
| 623 |
`);
|
| 624 |
|
| 625 |
+
const insertChatUser = sqliteDb.prepare(`
|
| 626 |
+
INSERT INTO chat_users (username, display_name, is_mod, is_sub, is_vip, last_seen)
|
| 627 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 628 |
+
ON CONFLICT(username) DO UPDATE SET
|
| 629 |
+
display_name = excluded.display_name,
|
| 630 |
+
is_mod = excluded.is_mod,
|
| 631 |
+
is_sub = excluded.is_sub,
|
| 632 |
+
is_vip = excluded.is_vip,
|
| 633 |
+
last_seen = excluded.last_seen
|
| 634 |
+
WHERE excluded.last_seen > chat_users.last_seen
|
| 635 |
+
`);
|
| 636 |
+
|
| 637 |
const runTransaction = sqliteDb.transaction((msgs) => {
|
| 638 |
for (const msg of msgs) {
|
| 639 |
const streamId = getStreamIdForTime(msg.timestamp);
|
|
|
|
| 645 |
msg.displayName || msg.username,
|
| 646 |
msg.message,
|
| 647 |
msg.timestamp || new Date().toISOString(),
|
| 648 |
+
msg.isStreamer ? 1 : 0
|
|
|
|
|
|
|
| 649 |
);
|
| 650 |
} catch (err) {
|
| 651 |
if (!err.message.includes('UNIQUE constraint failed')) {
|
|
|
|
| 665 |
} catch (err) {
|
| 666 |
console.error('[Database] SQLite Batch Error updating viewer:', err.message);
|
| 667 |
}
|
| 668 |
+
|
| 669 |
+
try {
|
| 670 |
+
insertChatUser.run(
|
| 671 |
+
msg.username.toLowerCase(),
|
| 672 |
+
msg.displayName || msg.username,
|
| 673 |
+
msg.isMod ? 1 : 0,
|
| 674 |
+
msg.isSub ? 1 : 0,
|
| 675 |
+
msg.isVip ? 1 : 0,
|
| 676 |
+
msg.timestamp || new Date().toISOString()
|
| 677 |
+
);
|
| 678 |
+
} catch (err) {
|
| 679 |
+
console.error('[Database] SQLite Batch Error updating chat_user:', err.message);
|
| 680 |
+
}
|
| 681 |
}
|
| 682 |
});
|
| 683 |
|
|
|
|
| 686 |
// Supabase Mode: Build batch lists
|
| 687 |
const messagesToInsert = [];
|
| 688 |
const viewersToUpsert = new Map(); // Use Map to unique-fy viewers by stream_id + username
|
| 689 |
+
const chatUsersToUpsert = new Map();
|
| 690 |
|
| 691 |
for (const msg of messages) {
|
| 692 |
const streamId = getStreamIdForTime(msg.timestamp);
|
| 693 |
+
const ts = msg.timestamp || new Date().toISOString();
|
| 694 |
+
const username = msg.username.toLowerCase();
|
| 695 |
|
| 696 |
messagesToInsert.push({
|
| 697 |
id: msg.id,
|
| 698 |
stream_id: streamId,
|
| 699 |
+
username: username,
|
| 700 |
display_name: msg.displayName || msg.username,
|
| 701 |
message: msg.message,
|
| 702 |
+
timestamp: ts,
|
| 703 |
+
is_streamer: msg.isStreamer || false
|
|
|
|
|
|
|
| 704 |
});
|
| 705 |
|
| 706 |
+
const viewerKey = `${streamId || 'null'}-${username}`;
|
| 707 |
viewersToUpsert.set(viewerKey, {
|
| 708 |
stream_id: streamId,
|
| 709 |
username: msg.username.toLowerCase(),
|
|
|
|
| 711 |
has_chatted: true,
|
| 712 |
is_mod: msg.isMod || false,
|
| 713 |
is_sub: msg.isSub || false,
|
| 714 |
+
first_seen: ts
|
| 715 |
+
});
|
| 716 |
+
|
| 717 |
+
const userKey = username;
|
| 718 |
+
chatUsersToUpsert.set(userKey, {
|
| 719 |
+
username: username,
|
| 720 |
+
display_name: msg.displayName || msg.username,
|
| 721 |
+
is_mod: msg.isMod || false,
|
| 722 |
+
is_sub: msg.isSub || false,
|
| 723 |
+
is_vip: msg.isVip || false,
|
| 724 |
+
last_seen: ts
|
| 725 |
});
|
| 726 |
}
|
| 727 |
|
|
|
|
| 743 |
if (viewerError) {
|
| 744 |
console.error('[Supabase] Bulk viewers upsert error:', viewerError.message);
|
| 745 |
}
|
| 746 |
+
|
| 747 |
+
// 3. Bulk upsert global chat_users safely based on last_seen
|
| 748 |
+
const uniqueChatUsers = Array.from(chatUsersToUpsert.values());
|
| 749 |
+
if (uniqueChatUsers.length > 0) {
|
| 750 |
+
const { data: existingUsers } = await supabase
|
| 751 |
+
.from('chat_users')
|
| 752 |
+
.select('username, last_seen')
|
| 753 |
+
.in('username', uniqueChatUsers.map(u => u.username));
|
| 754 |
+
|
| 755 |
+
const existingMap = new Map((existingUsers || []).map(u => [u.username, new Date(u.last_seen).getTime()]));
|
| 756 |
+
const toUpsert = uniqueChatUsers.filter(u => {
|
| 757 |
+
const oldTime = existingMap.get(u.username) || 0;
|
| 758 |
+
const newTime = new Date(u.last_seen).getTime();
|
| 759 |
+
return newTime > oldTime;
|
| 760 |
+
});
|
| 761 |
+
|
| 762 |
+
if (toUpsert.length > 0) {
|
| 763 |
+
const { error: chatUserError } = await supabase
|
| 764 |
+
.from('chat_users')
|
| 765 |
+
.upsert(toUpsert, { onConflict: 'username' });
|
| 766 |
+
if (chatUserError) {
|
| 767 |
+
console.error('[Supabase] Bulk chat_users upsert error:', chatUserError.message);
|
| 768 |
+
}
|
| 769 |
+
}
|
| 770 |
+
}
|
| 771 |
+
}
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
/**
|
| 775 |
+
* Log only roles (used by role backfiller)
|
| 776 |
+
*/
|
| 777 |
+
export async function logRolesBatch(users) {
|
| 778 |
+
if (!users || users.length === 0) return;
|
| 779 |
+
|
| 780 |
+
const chatUsersToUpsert = new Map();
|
| 781 |
+
for (const user of users) {
|
| 782 |
+
const ts = user.timestamp || new Date().toISOString();
|
| 783 |
+
const username = user.username.toLowerCase();
|
| 784 |
+
|
| 785 |
+
chatUsersToUpsert.set(username, {
|
| 786 |
+
username: username,
|
| 787 |
+
display_name: user.displayName || user.username,
|
| 788 |
+
is_mod: user.isMod || false,
|
| 789 |
+
is_sub: user.isSub || false,
|
| 790 |
+
is_vip: user.isVip || false,
|
| 791 |
+
last_seen: ts
|
| 792 |
+
});
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
const uniqueChatUsers = Array.from(chatUsersToUpsert.values());
|
| 796 |
+
|
| 797 |
+
if (dbMode === 'sqlite') {
|
| 798 |
+
const insertChatUser = sqliteDb.prepare(`
|
| 799 |
+
INSERT INTO chat_users (username, display_name, is_mod, is_sub, is_vip, last_seen)
|
| 800 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 801 |
+
ON CONFLICT(username) DO UPDATE SET
|
| 802 |
+
display_name = excluded.display_name,
|
| 803 |
+
is_mod = excluded.is_mod,
|
| 804 |
+
is_sub = excluded.is_sub,
|
| 805 |
+
is_vip = excluded.is_vip,
|
| 806 |
+
last_seen = excluded.last_seen
|
| 807 |
+
WHERE excluded.last_seen > chat_users.last_seen
|
| 808 |
+
`);
|
| 809 |
+
|
| 810 |
+
const runTransaction = sqliteDb.transaction((usersToInsert) => {
|
| 811 |
+
for (const u of usersToInsert) {
|
| 812 |
+
try {
|
| 813 |
+
insertChatUser.run(
|
| 814 |
+
u.username,
|
| 815 |
+
u.display_name,
|
| 816 |
+
u.is_mod ? 1 : 0,
|
| 817 |
+
u.is_sub ? 1 : 0,
|
| 818 |
+
u.is_vip ? 1 : 0,
|
| 819 |
+
u.last_seen
|
| 820 |
+
);
|
| 821 |
+
} catch (err) {
|
| 822 |
+
console.error('[Database] SQLite Error updating role for user:', u.username, err.message);
|
| 823 |
+
}
|
| 824 |
+
}
|
| 825 |
+
});
|
| 826 |
+
|
| 827 |
+
runTransaction(uniqueChatUsers);
|
| 828 |
+
} else {
|
| 829 |
+
// Supabase Mode
|
| 830 |
+
const { data: existingUsers } = await supabase
|
| 831 |
+
.from('chat_users')
|
| 832 |
+
.select('username, last_seen')
|
| 833 |
+
.in('username', uniqueChatUsers.map(u => u.username));
|
| 834 |
+
|
| 835 |
+
const existingMap = new Map((existingUsers || []).map(u => [u.username, new Date(u.last_seen).getTime()]));
|
| 836 |
+
const toUpsert = uniqueChatUsers.filter(u => {
|
| 837 |
+
const oldTime = existingMap.get(u.username) || 0;
|
| 838 |
+
const newTime = new Date(u.last_seen).getTime();
|
| 839 |
+
return newTime > oldTime;
|
| 840 |
+
});
|
| 841 |
+
|
| 842 |
+
if (toUpsert.length > 0) {
|
| 843 |
+
const { error: chatUserError } = await supabase
|
| 844 |
+
.from('chat_users')
|
| 845 |
+
.upsert(toUpsert, { onConflict: 'username' });
|
| 846 |
+
if (chatUserError) {
|
| 847 |
+
console.error('[Supabase] Bulk roles upsert error:', chatUserError.message);
|
| 848 |
+
}
|
| 849 |
+
}
|
| 850 |
}
|
| 851 |
}
|
| 852 |
|
|
|
|
| 1095 |
stmt = sqliteDb.prepare(`
|
| 1096 |
SELECT
|
| 1097 |
v.username,
|
| 1098 |
+
u.display_name,
|
| 1099 |
+
u.is_mod,
|
| 1100 |
+
u.is_sub,
|
| 1101 |
+
u.is_vip,
|
| 1102 |
+
v.has_chatted,
|
| 1103 |
(SELECT COUNT(*) FROM messages m WHERE m.stream_id = v.stream_id AND m.username = v.username) as message_count
|
| 1104 |
FROM stream_viewers v
|
| 1105 |
+
LEFT JOIN chat_users u ON v.username = u.username
|
| 1106 |
WHERE v.stream_id = ?
|
| 1107 |
GROUP BY v.username
|
| 1108 |
ORDER BY message_count DESC
|
|
|
|
| 1112 |
} else {
|
| 1113 |
stmt = sqliteDb.prepare(`
|
| 1114 |
SELECT
|
| 1115 |
+
m.username,
|
| 1116 |
+
u.display_name,
|
| 1117 |
+
u.is_mod,
|
| 1118 |
+
u.is_sub,
|
| 1119 |
+
u.is_vip,
|
| 1120 |
+
COUNT(*) as message_count
|
| 1121 |
+
FROM messages m
|
| 1122 |
+
LEFT JOIN chat_users u ON m.username = u.username
|
| 1123 |
+
GROUP BY m.username
|
| 1124 |
ORDER BY message_count DESC
|
| 1125 |
LIMIT ?
|
| 1126 |
`);
|
server/init_db.sql
CHANGED
|
@@ -44,6 +44,16 @@ CREATE TABLE IF NOT EXISTS stream_viewers (
|
|
| 44 |
);
|
| 45 |
CREATE INDEX IF NOT EXISTS idx_stream_viewers_stream_id ON stream_viewers(stream_id);
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
-- 3. VOICE WORDS TABLE (transcribed spoken words from streamer)
|
| 48 |
CREATE TABLE IF NOT EXISTS voice_words (
|
| 49 |
id SERIAL PRIMARY KEY,
|
|
@@ -98,27 +108,34 @@ CREATE OR REPLACE TRIGGER update_settings_modtime
|
|
| 98 |
-- =========================================================================
|
| 99 |
|
| 100 |
-- View for chatter statistics per stream
|
| 101 |
-
|
|
|
|
| 102 |
SELECT
|
| 103 |
-
stream_id,
|
| 104 |
-
username,
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-- View for chatter statistics overall (all-time)
|
| 113 |
-
|
|
|
|
| 114 |
SELECT
|
| 115 |
-
username,
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
| 119 |
count(*) as message_count
|
| 120 |
-
FROM messages
|
| 121 |
-
|
|
|
|
| 122 |
|
| 123 |
-- View for spoken word statistics per stream
|
| 124 |
CREATE OR REPLACE VIEW stream_voice_word_stats AS
|
|
|
|
| 44 |
);
|
| 45 |
CREATE INDEX IF NOT EXISTS idx_stream_viewers_stream_id ON stream_viewers(stream_id);
|
| 46 |
|
| 47 |
+
-- 2.6 CHAT USERS TABLE
|
| 48 |
+
CREATE TABLE IF NOT EXISTS chat_users (
|
| 49 |
+
username TEXT PRIMARY KEY,
|
| 50 |
+
display_name TEXT,
|
| 51 |
+
is_mod BOOLEAN DEFAULT FALSE,
|
| 52 |
+
is_sub BOOLEAN DEFAULT FALSE,
|
| 53 |
+
is_vip BOOLEAN DEFAULT FALSE,
|
| 54 |
+
last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
-- 3. VOICE WORDS TABLE (transcribed spoken words from streamer)
|
| 58 |
CREATE TABLE IF NOT EXISTS voice_words (
|
| 59 |
id SERIAL PRIMARY KEY,
|
|
|
|
| 108 |
-- =========================================================================
|
| 109 |
|
| 110 |
-- View for chatter statistics per stream
|
| 111 |
+
DROP VIEW IF EXISTS stream_chatter_stats;
|
| 112 |
+
CREATE VIEW stream_chatter_stats AS
|
| 113 |
SELECT
|
| 114 |
+
v.stream_id,
|
| 115 |
+
v.username,
|
| 116 |
+
u.display_name,
|
| 117 |
+
u.is_mod,
|
| 118 |
+
u.is_sub,
|
| 119 |
+
u.is_vip,
|
| 120 |
+
v.has_chatted,
|
| 121 |
+
v.first_seen,
|
| 122 |
+
(SELECT COUNT(*) FROM messages m WHERE m.stream_id = v.stream_id AND m.username = v.username) as message_count
|
| 123 |
+
FROM stream_viewers v
|
| 124 |
+
LEFT JOIN chat_users u ON v.username = u.username;
|
| 125 |
|
| 126 |
-- View for chatter statistics overall (all-time)
|
| 127 |
+
DROP VIEW IF EXISTS global_chatter_stats;
|
| 128 |
+
CREATE VIEW global_chatter_stats AS
|
| 129 |
SELECT
|
| 130 |
+
m.username,
|
| 131 |
+
u.display_name,
|
| 132 |
+
u.is_mod,
|
| 133 |
+
u.is_sub,
|
| 134 |
+
u.is_vip,
|
| 135 |
count(*) as message_count
|
| 136 |
+
FROM messages m
|
| 137 |
+
LEFT JOIN chat_users u ON m.username = u.username
|
| 138 |
+
GROUP BY m.username, u.display_name, u.is_mod, u.is_sub, u.is_vip;
|
| 139 |
|
| 140 |
-- View for spoken word statistics per stream
|
| 141 |
CREATE OR REPLACE VIEW stream_voice_word_stats AS
|
server/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from 'fs';
|
|
| 8 |
import {
|
| 9 |
logChatMessage,
|
| 10 |
logChatMessagesBatch,
|
|
|
|
| 11 |
logVoiceWords,
|
| 12 |
logModAction,
|
| 13 |
logViewerJoin,
|
|
@@ -187,6 +188,23 @@ app.post('/api/log/messages', authenticateWorker, async (req, res) => {
|
|
| 187 |
res.json({ success: true, count: messages.length });
|
| 188 |
});
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
// Endpoint for the Python worker to log viewer joins
|
| 191 |
app.post('/api/log/viewers/join', authenticateWorker, async (req, res) => {
|
| 192 |
const username = req.body.username;
|
|
|
|
| 8 |
import {
|
| 9 |
logChatMessage,
|
| 10 |
logChatMessagesBatch,
|
| 11 |
+
logRolesBatch,
|
| 12 |
logVoiceWords,
|
| 13 |
logModAction,
|
| 14 |
logViewerJoin,
|
|
|
|
| 188 |
res.json({ success: true, count: messages.length });
|
| 189 |
});
|
| 190 |
|
| 191 |
+
// Ingest chat roles ONLY (used by role_backfiller)
|
| 192 |
+
app.post('/api/log/roles', authenticateWorker, async (req, res) => {
|
| 193 |
+
const { roles } = req.body;
|
| 194 |
+
if (!roles || !Array.isArray(roles)) {
|
| 195 |
+
return res.status(400).json({ error: 'Invalid input. Expected array of roles.' });
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
try {
|
| 199 |
+
await logRolesBatch(roles);
|
| 200 |
+
} catch (err) {
|
| 201 |
+
console.error('Error logging batch roles:', err);
|
| 202 |
+
return res.status(500).json({ error: 'Failed to log roles' });
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
res.json({ success: true, count: roles.length });
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
// Endpoint for the Python worker to log viewer joins
|
| 209 |
app.post('/api/log/viewers/join', authenticateWorker, async (req, res) => {
|
| 210 |
const username = req.body.username;
|
server/supabase_migration_roles.sql
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 1. Create the new global users table
|
| 2 |
+
CREATE TABLE IF NOT EXISTS chat_users (
|
| 3 |
+
username TEXT PRIMARY KEY,
|
| 4 |
+
display_name TEXT,
|
| 5 |
+
is_mod BOOLEAN DEFAULT FALSE,
|
| 6 |
+
is_sub BOOLEAN DEFAULT FALSE,
|
| 7 |
+
is_vip BOOLEAN DEFAULT FALSE,
|
| 8 |
+
last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
-- 2. Backfill existing user roles into chat_users
|
| 12 |
+
-- We find the most recent message for each user and take their roles.
|
| 13 |
+
-- Since the old DB only had is_mod and is_sub, we use those.
|
| 14 |
+
INSERT INTO chat_users (username, display_name, is_mod, is_sub, is_vip, last_seen)
|
| 15 |
+
SELECT
|
| 16 |
+
username,
|
| 17 |
+
max(display_name),
|
| 18 |
+
bool_or(is_mod),
|
| 19 |
+
bool_or(is_sub),
|
| 20 |
+
FALSE,
|
| 21 |
+
max(first_seen)
|
| 22 |
+
FROM stream_viewers
|
| 23 |
+
GROUP BY username
|
| 24 |
+
ON CONFLICT (username) DO NOTHING;
|
| 25 |
+
|
| 26 |
+
-- 3. We can optionally drop is_mod and is_sub from messages and stream_viewers,
|
| 27 |
+
-- but it's safer to just leave them (or drop them later). For now, we leave them to not break things.
|
| 28 |
+
|
| 29 |
+
-- 4. Recreate the global_chatter_stats view to use the new global roles
|
| 30 |
+
DROP VIEW IF EXISTS global_chatter_stats;
|
| 31 |
+
CREATE VIEW global_chatter_stats AS
|
| 32 |
+
SELECT
|
| 33 |
+
m.username,
|
| 34 |
+
u.display_name,
|
| 35 |
+
u.is_mod,
|
| 36 |
+
u.is_sub,
|
| 37 |
+
u.is_vip,
|
| 38 |
+
count(*) as message_count
|
| 39 |
+
FROM messages m
|
| 40 |
+
LEFT JOIN chat_users u ON m.username = u.username
|
| 41 |
+
GROUP BY m.username, u.display_name, u.is_mod, u.is_sub, u.is_vip;
|
| 42 |
+
|
| 43 |
+
-- 5. Recreate the stream_chatter_stats view to use the new global roles
|
| 44 |
+
DROP VIEW IF EXISTS stream_chatter_stats;
|
| 45 |
+
CREATE VIEW stream_chatter_stats AS
|
| 46 |
+
SELECT
|
| 47 |
+
v.stream_id,
|
| 48 |
+
v.username,
|
| 49 |
+
u.display_name,
|
| 50 |
+
u.is_mod,
|
| 51 |
+
u.is_sub,
|
| 52 |
+
u.is_vip,
|
| 53 |
+
v.has_chatted,
|
| 54 |
+
v.first_seen,
|
| 55 |
+
(SELECT COUNT(*) FROM messages m WHERE m.stream_id = v.stream_id AND m.username = v.username) as message_count
|
| 56 |
+
FROM stream_viewers v
|
| 57 |
+
LEFT JOIN chat_users u ON v.username = u.username;
|