Sasha commited on
Commit
513eb9c
·
1 Parent(s): 91f86ea

feat: global roles migration, VIP badges, role backfiller script

Browse files
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.is_sub ? <span className="badge badge-sub">Саб</span> : null}
 
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 = node.get("userBadges") or []
166
- badges = [b.get("id") 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_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, is_mod, is_sub)
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: msg.username.toLowerCase(),
654
  display_name: msg.displayName || msg.username,
655
  message: msg.message,
656
- timestamp: msg.timestamp || new Date().toISOString(),
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'}-${msg.username.toLowerCase()}`;
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: msg.timestamp || new Date().toISOString()
 
 
 
 
 
 
 
 
 
 
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
- max(v.display_name) as display_name,
941
- max(v.is_mod) as is_mod,
942
- max(v.is_sub) as is_sub,
943
- max(v.has_chatted) as has_chatted,
 
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
- v.username,
956
- max(v.display_name) as display_name,
957
- max(v.is_mod) as is_mod,
958
- max(v.is_sub) as is_sub,
959
- max(v.has_chatted) as has_chatted,
960
- (SELECT COUNT(*) FROM messages m WHERE m.username = v.username) as message_count
961
- FROM stream_viewers v
962
- GROUP BY v.username
 
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
- CREATE OR REPLACE VIEW stream_chatter_stats AS
 
102
  SELECT
103
- stream_id,
104
- username,
105
- max(display_name) as display_name,
106
- bool_or(is_mod) as is_mod,
107
- bool_or(is_sub) as is_sub,
108
- count(*) as message_count
109
- FROM messages
110
- GROUP BY stream_id, username;
 
 
 
111
 
112
  -- View for chatter statistics overall (all-time)
113
- CREATE OR REPLACE VIEW global_chatter_stats AS
 
114
  SELECT
115
- username,
116
- max(display_name) as display_name,
117
- bool_or(is_mod) as is_mod,
118
- bool_or(is_sub) as is_sub,
 
119
  count(*) as message_count
120
- FROM messages
121
- GROUP BY username;
 
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;