Sasha commited on
Commit
e98099c
·
1 Parent(s): fe2d35c

feat: add backfill resume support and session/retry fixes for VODs

Browse files
local_worker/role_backfiller.py CHANGED
@@ -102,107 +102,116 @@ def download_vod_roles(vod_id):
102
  total_messages = 0
103
  MAX_RETRIES = 5
104
 
105
- while True:
106
- payload = {
107
- "operationName": "VideoCommentsByOffsetOrCursor",
108
- "variables": {
109
- "videoID": str(vod_id),
110
- "contentOffsetSeconds": current_offset
111
- },
112
- "extensions": {
113
- "persistedQuery": {
114
- "version": 1,
115
- "sha256Hash": "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
 
 
116
  }
117
  }
118
- }
119
 
120
- success = False
121
- for attempt in range(MAX_RETRIES):
122
- try:
123
- res = requests.post(url, json=payload, headers=gql_headers, timeout=15)
124
- if res.status_code != 200:
125
- break
126
-
127
- data = res.json()
128
- if isinstance(data, list):
129
- data = data[0]
130
-
131
- video = data.get("data", {}).get("video", {})
132
- if not video:
133
- return list(roles_dict.values()) # VOD ended or not found
134
-
135
- edges = (video.get("comments") or {}).get("edges") or []
136
- if not edges:
137
- return list(roles_dict.values()) # Reached end
138
-
139
- for edge in edges:
140
- if not edge:
141
- continue
142
- node = edge.get("node")
143
- if not node:
144
  continue
145
- commenter = node.get("commenter")
146
- if not commenter:
147
- continue
148
- user = commenter.get("login")
149
- if not user:
150
- continue
151
-
152
- display_name = commenter.get("displayName", user)
153
- message = node.get("message")
154
- if not message:
155
- continue
156
-
157
- timestamp = node.get("createdAt")
158
- user_badges = message.get("userBadges") or []
159
- badges = [b.get("setID") for b in user_badges if b]
160
-
161
- # Debug: print first badge encounter to verify GQL structure
162
- if total_messages < 3 and user_badges:
163
- print(f"\n [DEBUG] userBadges raw: {user_badges}")
164
- print(f" [DEBUG] parsed badges: {badges}")
165
-
166
- is_mod = "moderator" in badges or "broadcaster" in badges
167
- is_sub = "subscriber" in badges or "founder" in badges
168
- is_vip = "vip" in badges
169
-
170
- # Only store users who have at least one badge
171
- if is_mod or is_sub or is_vip:
172
- roles_dict[user.lower()] = {
173
- "username": user,
174
- "displayName": display_name,
175
- "isMod": is_mod,
176
- "isSub": is_sub,
177
- "isVip": is_vip,
178
- "timestamp": timestamp
179
- }
180
- total_messages += 1
181
-
182
- print(f" -> Обработано {total_messages} сообщений, найдено {len(roles_dict)} уникальных пользователей...", end="\r")
183
-
184
- last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
185
- if last_offset is not None:
186
- next_offset = last_offset + 1
187
- if next_offset <= current_offset:
188
- next_offset = current_offset + 30
189
- current_offset = next_offset
190
- else:
191
- return list(roles_dict.values())
192
-
193
- success = True
194
- break # Success — go to next page
195
-
196
- except Exception as e:
197
- wait = 2 ** attempt # 1, 2, 4, 8, 16 seconds
198
- print(f"\n [!] Ошибка (попытка {attempt+1}/{MAX_RETRIES}): {e}")
199
- if attempt < MAX_RETRIES - 1:
200
- print(f" [!] Повтор через {wait}с...")
201
- time.sleep(wait)
202
-
203
- if not success:
204
- print(f"\n [!] Не удалось получить данные после {MAX_RETRIES} попыток. Останавливаемся на offset={current_offset}.")
205
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
  return list(roles_dict.values())
208
 
 
102
  total_messages = 0
103
  MAX_RETRIES = 5
104
 
105
+ with requests.Session() as session:
106
+ while True:
107
+ payload = {
108
+ "operationName": "VideoCommentsByOffsetOrCursor",
109
+ "variables": {
110
+ "videoID": str(vod_id),
111
+ "contentOffsetSeconds": current_offset
112
+ },
113
+ "extensions": {
114
+ "persistedQuery": {
115
+ "version": 1,
116
+ "sha256Hash": "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
117
+ }
118
  }
119
  }
 
120
 
121
+ success = False
122
+ for attempt in range(MAX_RETRIES):
123
+ try:
124
+ res = session.post(url, json=payload, headers=gql_headers, timeout=15)
125
+ if res.status_code == 429:
126
+ wait = 2 ** attempt
127
+ print(f"\n [!] Rate limited (429). Повтор через {wait}с...")
128
+ time.sleep(wait)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  continue
130
+ if res.status_code != 200:
131
+ print(f"\n [!] Ошибка GQL статус {res.status_code}")
132
+ break
133
+
134
+ data = res.json()
135
+ if isinstance(data, list):
136
+ data = data[0]
137
+
138
+ video = data.get("data", {}).get("video", {})
139
+ if not video:
140
+ return list(roles_dict.values()) # VOD ended or not found
141
+
142
+ edges = (video.get("comments") or {}).get("edges") or []
143
+ if not edges:
144
+ return list(roles_dict.values()) # Reached end
145
+
146
+ for edge in edges:
147
+ if not edge:
148
+ continue
149
+ node = edge.get("node")
150
+ if not node:
151
+ continue
152
+ commenter = node.get("commenter")
153
+ if not commenter:
154
+ continue
155
+ user = commenter.get("login")
156
+ if not user:
157
+ continue
158
+
159
+ display_name = commenter.get("displayName", user)
160
+ message = node.get("message")
161
+ if not message:
162
+ continue
163
+
164
+ timestamp = node.get("createdAt")
165
+ user_badges = message.get("userBadges") or []
166
+ badges = [b.get("setID") for b in user_badges if b]
167
+
168
+ # Debug: print first badge encounter to verify GQL structure
169
+ if total_messages < 3 and user_badges:
170
+ print(f"\n [DEBUG] userBadges raw: {user_badges}")
171
+ print(f" [DEBUG] parsed badges: {badges}")
172
+
173
+ is_mod = "moderator" in badges or "broadcaster" in badges
174
+ is_sub = "subscriber" in badges or "founder" in badges
175
+ is_vip = "vip" in badges
176
+
177
+ # Only store users who have at least one badge
178
+ if is_mod or is_sub or is_vip:
179
+ roles_dict[user.lower()] = {
180
+ "username": user,
181
+ "displayName": display_name,
182
+ "isMod": is_mod,
183
+ "isSub": is_sub,
184
+ "isVip": is_vip,
185
+ "timestamp": timestamp
186
+ }
187
+ total_messages += 1
188
+
189
+ print(f" -> Обработано {total_messages} сообщений, найдено {len(roles_dict)} уникальных пользователей...", end="\r")
190
+
191
+ last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
192
+ if last_offset is not None:
193
+ next_offset = last_offset + 1
194
+ if next_offset <= current_offset:
195
+ next_offset = current_offset + 30
196
+ current_offset = next_offset
197
+ else:
198
+ return list(roles_dict.values())
199
+
200
+ # Tiny sleep to avoid aggressive spamming
201
+ time.sleep(0.1)
202
+ success = True
203
+ break # Success — go to next page
204
+
205
+ except Exception as e:
206
+ wait = 2 ** attempt # 1, 2, 4, 8, 16 seconds
207
+ print(f"\n [!] Ошибка (попытка {attempt+1}/{MAX_RETRIES}): {e}")
208
+ if attempt < MAX_RETRIES - 1:
209
+ print(f" [!] Повтор через {wait}с...")
210
+ time.sleep(wait)
211
+
212
+ if not success:
213
+ print(f"\n [!] Не удалось получить данные после {MAX_RETRIES} попыток. Останавливаемся на offset={current_offset}.")
214
+ break
215
 
216
  return list(roles_dict.values())
217
 
local_worker/vod_backfiller.py CHANGED
@@ -95,108 +95,130 @@ def download_vod_chat(vod_id, start_offset=0, end_offset=None):
95
  current_offset = start_offset
96
  print(f"\n[Chat] Запуск скачивания чата для VOD {vod_id} с секунды {start_offset} до {'конца' if end_offset is None else str(end_offset) + 's'}...")
97
 
98
- while True:
99
- if end_offset is not None and current_offset >= end_offset:
100
- print(f"\n[Chat] Достигнут конечный офсет {end_offset}s. Завершаем скачивание чата.")
101
- break
 
 
 
102
 
103
- payload = {
104
- "operationName": "VideoCommentsByOffsetOrCursor",
105
- "variables": {
106
- "videoID": str(vod_id),
107
- "contentOffsetSeconds": current_offset
108
- },
109
- "extensions": {
110
- "persistedQuery": {
111
- "version": 1,
112
- "sha256Hash": "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
 
113
  }
114
  }
115
- }
116
-
117
- try:
118
- res = requests.post(url, json=payload, headers=gql_headers, timeout=10)
119
- if res.status_code != 200:
120
- print(f"[Chat] Ошибка GQL: {res.status_code}")
121
- break
122
-
123
- data = res.json()
124
- if isinstance(data, list):
125
- data = data[0]
126
-
127
- video = data.get("data", {}).get("video", {})
128
- if not video:
129
- break
130
 
131
- comments_edge = video.get("comments") or {}
132
- edges = comments_edge.get("edges") or []
133
- if not edges:
134
- break
135
-
136
- for edge in edges:
137
- if not edge:
138
- continue
139
- node = edge.get("node")
140
- if not node:
141
- continue
142
- msg_id = node.get("id")
143
- if not msg_id or msg_id in seen_ids:
144
- continue
 
 
 
 
 
 
 
 
 
 
 
145
 
146
- commenter = node.get("commenter")
147
- if not commenter:
148
- continue
149
-
150
- user = commenter.get("login")
151
- if not user:
152
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- display_name = commenter.get("displayName", user)
155
-
156
- message = node.get("message")
157
- if not message:
158
- continue
159
- fragments = message.get("fragments") or []
160
- message_text = "".join([f.get("text", "") for f in fragments if f])
161
-
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)
173
- comments.append({
174
- "id": msg_id,
175
- "username": user,
176
- "displayName": display_name,
177
- "message": message_text,
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")
186
 
187
- # Progress offset
188
- last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
189
- if last_offset is not None:
190
- next_offset = last_offset + 1
191
- if next_offset <= current_offset:
192
- next_offset = current_offset + 30
193
- current_offset = next_offset
194
- else:
195
  break
196
- except Exception as e:
197
- print(f"\n[Chat] Ошибка при загрузке: {e}")
198
- break
199
-
200
  print(f"\n[Chat] Загрузка завершена. Всего сообщений: {len(comments)}")
201
  return comments
202
 
@@ -376,8 +398,51 @@ if __name__ == "__main__":
376
  print(f"[Error] Ошибка сети при обращении к бэкенду: {e}")
377
  sys.exit(1)
378
 
379
- # 3. Download and Upload Chat comments
380
- chat_comments = download_vod_chat(vod_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  if chat_comments:
382
  print("\n[Sync] Отправка чата на сервер (пакетами по 100 сообщений)...")
383
  batch_size = 100
@@ -394,7 +459,7 @@ if __name__ == "__main__":
394
  print(f"\n-> Ошибка сети при отправке пакета: {e}")
395
  print("\n-> Синхронизация чата успешно завершена!")
396
 
397
- # 4. Transcribe Audio
398
  transcribe_confirm = input("\nХотите запустить скачивание и транскрибацию аудиодорожки (Whisper)? (y/n): ").strip().lower()
399
  if transcribe_confirm == 'y':
400
  # Init Whisper
@@ -406,7 +471,7 @@ if __name__ == "__main__":
406
  compute_type=WHISPER_COMPUTE_TYPE,
407
  cpu_threads=WHISPER_CPU_THREADS
408
  )
409
- transcribe_vod_audio(vod_id, created_at, model)
410
  except Exception as e:
411
  print(f"[Whisper Error] Не удалось инициализировать Whisper: {e}")
412
 
 
95
  current_offset = start_offset
96
  print(f"\n[Chat] Запуск скачивания чата для VOD {vod_id} с секунды {start_offset} до {'конца' if end_offset is None else str(end_offset) + 's'}...")
97
 
98
+ MAX_RETRIES = 5
99
+
100
+ with requests.Session() as session:
101
+ while True:
102
+ if end_offset is not None and current_offset >= end_offset:
103
+ print(f"\n[Chat] Достигнут конечный офсет {end_offset}s. Завершаем скачивание чата.")
104
+ break
105
 
106
+ payload = {
107
+ "operationName": "VideoCommentsByOffsetOrCursor",
108
+ "variables": {
109
+ "videoID": str(vod_id),
110
+ "contentOffsetSeconds": current_offset
111
+ },
112
+ "extensions": {
113
+ "persistedQuery": {
114
+ "version": 1,
115
+ "sha256Hash": "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
116
+ }
117
  }
118
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
+ success = False
121
+ for attempt in range(MAX_RETRIES):
122
+ try:
123
+ res = session.post(url, json=payload, headers=gql_headers, timeout=15)
124
+ if res.status_code == 429:
125
+ wait = 2 ** attempt
126
+ print(f"\n[Chat] Rate limited (429). Повтор через {wait}с...")
127
+ time.sleep(wait)
128
+ continue
129
+ if res.status_code != 200:
130
+ print(f"\n[Chat] Ошибка GQL статус {res.status_code}")
131
+ break
132
+
133
+ data = res.json()
134
+ if isinstance(data, list):
135
+ data = data[0]
136
+
137
+ video = data.get("data", {}).get("video", {})
138
+ if not video:
139
+ break
140
+
141
+ comments_edge = video.get("comments") or {}
142
+ edges = comments_edge.get("edges") or []
143
+ if not edges:
144
+ break
145
 
146
+ for edge in edges:
147
+ if not edge:
148
+ continue
149
+ node = edge.get("node")
150
+ if not node:
151
+ continue
152
+ msg_id = node.get("id")
153
+ if not msg_id or msg_id in seen_ids:
154
+ continue
155
+
156
+ commenter = node.get("commenter")
157
+ if not commenter:
158
+ continue
159
+
160
+ user = commenter.get("login")
161
+ if not user:
162
+ continue
163
+
164
+ display_name = commenter.get("displayName", user)
165
+
166
+ message = node.get("message")
167
+ if not message:
168
+ continue
169
+ fragments = message.get("fragments") or []
170
+ message_text = "".join([f.get("text", "") for f in fragments if f])
171
+
172
+ timestamp = node.get("createdAt")
173
+
174
+ # Badges parse
175
+ user_badges = message.get("userBadges") or []
176
+ badges = [b.get("setID") for b in user_badges if b]
177
+ is_mod = "moderator" in badges or "broadcaster" in badges
178
+ is_sub = "subscriber" in badges or "founder" in badges
179
+ is_vip = "vip" in badges
180
+ is_streamer = (user.lower() == TWITCH_CHANNEL)
181
+
182
+ seen_ids.add(msg_id)
183
+ comments.append({
184
+ "id": msg_id,
185
+ "username": user,
186
+ "displayName": display_name,
187
+ "message": message_text,
188
+ "timestamp": timestamp,
189
+ "isStreamer": is_streamer,
190
+ "isMod": is_mod,
191
+ "isSub": is_sub,
192
+ "isVip": is_vip
193
+ })
194
+
195
+ print(f"-> Загружено {len(comments)} комментариев...", end="\r")
196
 
197
+ # Progress offset
198
+ last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
199
+ if last_offset is not None:
200
+ next_offset = last_offset + 1
201
+ if next_offset <= current_offset:
202
+ next_offset = current_offset + 30
203
+ current_offset = next_offset
204
+ else:
205
+ break
206
+
207
+ # Tiny sleep to avoid aggressive spamming
208
+ time.sleep(0.1)
209
+ success = True
210
+ break
211
+ except Exception as e:
212
+ wait = 2 ** attempt
213
+ print(f"\n[Chat] Ошибка при загрузке (попытка {attempt+1}/{MAX_RETRIES}): {e}")
214
+ if attempt < MAX_RETRIES - 1:
215
+ print(f"[Chat] Повтор через {wait}с...")
216
+ time.sleep(wait)
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
+ if not success:
219
+ print(f"\n[Chat] Не удалось продолжить скачивание из-за ошибок на offset {current_offset}.")
 
 
 
 
 
 
220
  break
221
+
 
 
 
222
  print(f"\n[Chat] Загрузка завершена. Всего сообщений: {len(comments)}")
223
  return comments
224
 
 
398
  print(f"[Error] Ошибка сети при обращении к бэкенду: {e}")
399
  sys.exit(1)
400
 
401
+ # 3. Check for existing backfill status (resume/append support)
402
+ chat_start_offset = 0
403
+ voice_start_offset = 0
404
+ try:
405
+ status_url = f"{API_URL}/api/streams/backfill-status?twitchStreamId=vod-{vod_id}"
406
+ res = requests.get(status_url, headers=headers, timeout=5)
407
+ if res.status_code == 200:
408
+ status_data = res.json()
409
+
410
+ # Chat check
411
+ msg_count = status_data.get("messageCount", 0)
412
+ latest_msg_ts = status_data.get("latestMessageTimestamp")
413
+ if msg_count > 0 and latest_msg_ts:
414
+ from datetime import datetime
415
+ clean_ts = latest_msg_ts.replace('Z', '+00:00')
416
+ latest_epoch = datetime.fromisoformat(clean_ts).timestamp()
417
+
418
+ clean_created = created_at.replace('Z', '+00:00')
419
+ created_epoch = datetime.fromisoformat(clean_created).timestamp()
420
+
421
+ diff = int(latest_epoch - created_epoch)
422
+ if diff > 0:
423
+ chat_start_offset = diff
424
+ print(f"[Sync] Найден существующий чат в базе ({msg_count} сообщений). Возобновляем загрузку с секунды {chat_start_offset}...")
425
+
426
+ # Voice check
427
+ voice_count = status_data.get("voiceCount", 0)
428
+ latest_voice_ts = status_data.get("latestVoiceTimestamp")
429
+ if voice_count > 0 and latest_voice_ts:
430
+ from datetime import datetime
431
+ clean_ts = latest_voice_ts.replace('Z', '+00:00')
432
+ latest_epoch = datetime.fromisoformat(clean_ts).timestamp()
433
+
434
+ clean_created = created_at.replace('Z', '+00:00')
435
+ created_epoch = datetime.fromisoformat(clean_created).timestamp()
436
+
437
+ diff = int(latest_epoch - created_epoch)
438
+ if diff > 0:
439
+ voice_start_offset = (diff // 30) * 30
440
+ print(f"[Sync] Найден распознанный голос в базе ({voice_count} слов). Возобновляем транскрибацию с секунды {voice_start_offset}...")
441
+ except Exception as e:
442
+ print(f"[Sync Warning] Не удалось получить статус дозаписи: {e}")
443
+
444
+ # 4. Download and Upload Chat comments
445
+ chat_comments = download_vod_chat(vod_id, start_offset=chat_start_offset)
446
  if chat_comments:
447
  print("\n[Sync] Отправка чата на сервер (пакетами по 100 сообщений)...")
448
  batch_size = 100
 
459
  print(f"\n-> Ошибка сети при отправке пакета: {e}")
460
  print("\n-> Синхронизация чата успешно завершена!")
461
 
462
+ # 5. Transcribe Audio
463
  transcribe_confirm = input("\nХотите запустить скачивание и транскрибацию аудиодорожки (Whisper)? (y/n): ").strip().lower()
464
  if transcribe_confirm == 'y':
465
  # Init Whisper
 
471
  compute_type=WHISPER_COMPUTE_TYPE,
472
  cpu_threads=WHISPER_CPU_THREADS
473
  )
474
+ transcribe_vod_audio(vod_id, created_at, model, start_offset=voice_start_offset)
475
  except Exception as e:
476
  print(f"[Whisper Error] Не удалось инициализировать Whisper: {e}")
477
 
local_worker/worker.py CHANGED
@@ -722,6 +722,22 @@ def run_stream_capture(model):
722
 
723
  print(f"[Capture] Stream is LIVE! Starting audio pipeline...")
724
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  # Start streamlink piping audio to ffmpeg, converting it to raw 16kHz mono 16-bit PCM
726
  streamlink_cmd = ["streamlink", f"twitch.tv/{TWITCH_CHANNEL}", "audio,worst", "-O"]
727
  ffmpeg_cmd = ["ffmpeg", "-i", "pipe:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"]
@@ -853,22 +869,6 @@ if __name__ == "__main__":
853
  t_sender.start()
854
  t_heartbeat.start()
855
 
856
- # Notify backend that stream has started and get stream id for coverage
857
- notify_stream_start()
858
-
859
- # Start coverage window for the active stream
860
- try:
861
- import requests as _req
862
- _headers = {"x-api-key": API_KEY}
863
- _stream_res = _req.get(f"{API_URL}/api/streams/active", headers=_headers, timeout=5)
864
- if _stream_res.status_code == 200:
865
- _active = _stream_res.json()
866
- _sid = _active.get("stream", {}).get("id") or _active.get("id")
867
- if _sid:
868
- start_coverage(_sid, source='live')
869
- except Exception as _e:
870
- print(f"[Coverage] Could not get active stream for coverage: {_e}")
871
-
872
  # 3. Start Audio Capture (Blocks main thread)
873
  try:
874
  if CAPTURE_METHOD == "stream":
 
722
 
723
  print(f"[Capture] Stream is LIVE! Starting audio pipeline...")
724
 
725
+ # Notify backend that stream has started and get stream id for coverage
726
+ notify_stream_start()
727
+
728
+ # Start coverage window for the active stream
729
+ try:
730
+ import requests as _req
731
+ _headers = {"x-api-key": API_KEY}
732
+ _stream_res = _req.get(f"{API_URL}/api/streams/active", headers=_headers, timeout=5)
733
+ if _stream_res.status_code == 200:
734
+ _active = _stream_res.json()
735
+ _sid = _active.get("stream", {}).get("id") or _active.get("id")
736
+ if _sid:
737
+ start_coverage(_sid, source='live')
738
+ except Exception as _e:
739
+ print(f"[Coverage] Could not get active stream for coverage: {_e}")
740
+
741
  # Start streamlink piping audio to ffmpeg, converting it to raw 16kHz mono 16-bit PCM
742
  streamlink_cmd = ["streamlink", f"twitch.tv/{TWITCH_CHANNEL}", "audio,worst", "-O"]
743
  ffmpeg_cmd = ["ffmpeg", "-i", "pipe:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"]
 
869
  t_sender.start()
870
  t_heartbeat.start()
871
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
872
  # 3. Start Audio Capture (Blocks main thread)
873
  try:
874
  if CAPTURE_METHOD == "stream":
server/db.js CHANGED
@@ -2022,3 +2022,85 @@ export async function logViewerJoin(username) {
2022
  }
2023
  }
2024
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2022
  }
2023
  }
2024
  }
2025
+
2026
+ /**
2027
+ * Get backfill status for a specific stream by its twitch_stream_id
2028
+ */
2029
+ export async function getStreamBackfillStatus(twitchStreamId) {
2030
+ let stream = null;
2031
+ if (dbMode === 'sqlite') {
2032
+ stream = sqliteDb.prepare('SELECT * FROM streams WHERE twitch_stream_id = ?').get(twitchStreamId);
2033
+ } else {
2034
+ const { data, error } = await supabase
2035
+ .from('streams')
2036
+ .select('*')
2037
+ .eq('twitch_stream_id', twitchStreamId)
2038
+ .limit(1);
2039
+ if (data && data.length > 0) {
2040
+ stream = data[0];
2041
+ }
2042
+ }
2043
+
2044
+ if (!stream) return null;
2045
+
2046
+ let latestMessageTimestamp = null;
2047
+ let latestVoiceTimestamp = null;
2048
+ let messageCount = 0;
2049
+ let voiceCount = 0;
2050
+
2051
+ if (dbMode === 'sqlite') {
2052
+ const msgStats = sqliteDb.prepare('SELECT COUNT(*) as count, MAX(timestamp) as latest FROM messages WHERE stream_id = ?').get(stream.id);
2053
+ messageCount = msgStats.count;
2054
+ latestMessageTimestamp = msgStats.latest;
2055
+
2056
+ const voiceStats = sqliteDb.prepare('SELECT COUNT(*) as count, MAX(timestamp) as latest FROM voice_words WHERE stream_id = ?').get(stream.id);
2057
+ voiceCount = voiceStats.count;
2058
+ latestVoiceTimestamp = voiceStats.latest;
2059
+ } else {
2060
+ // Supabase
2061
+ const { count: msgCount } = await supabase
2062
+ .from('messages')
2063
+ .select('*', { count: 'exact', head: true })
2064
+ .eq('stream_id', stream.id);
2065
+ messageCount = msgCount || 0;
2066
+
2067
+ if (messageCount > 0) {
2068
+ const { data: latestMsg } = await supabase
2069
+ .from('messages')
2070
+ .select('timestamp')
2071
+ .eq('stream_id', stream.id)
2072
+ .order('timestamp', { ascending: false })
2073
+ .limit(1);
2074
+ if (latestMsg && latestMsg.length > 0) {
2075
+ latestMessageTimestamp = latestMsg[0].timestamp;
2076
+ }
2077
+ }
2078
+
2079
+ const { count: vCount } = await supabase
2080
+ .from('voice_words')
2081
+ .select('*', { count: 'exact', head: true })
2082
+ .eq('stream_id', stream.id);
2083
+ voiceCount = vCount || 0;
2084
+
2085
+ if (voiceCount > 0) {
2086
+ const { data: latestVoice } = await supabase
2087
+ .from('voice_words')
2088
+ .select('timestamp')
2089
+ .eq('stream_id', stream.id)
2090
+ .order('timestamp', { ascending: false })
2091
+ .limit(1);
2092
+ if (latestVoice && latestVoice.length > 0) {
2093
+ latestVoiceTimestamp = latestVoice[0].timestamp;
2094
+ }
2095
+ }
2096
+ }
2097
+
2098
+ return {
2099
+ streamId: stream.id,
2100
+ twitchStreamId: stream.twitch_stream_id,
2101
+ messageCount,
2102
+ latestMessageTimestamp,
2103
+ voiceCount,
2104
+ latestVoiceTimestamp
2105
+ };
2106
+ }
server/server.js CHANGED
@@ -39,7 +39,8 @@ import {
39
  cleanupGhostStreams,
40
  startCoverageWindow,
41
  updateCoverageHeartbeat,
42
- endCoverageWindow
 
43
  } from './db.js';
44
  // import { initializeEventSub } from './eventsub.js';
45
  import { cache, cacheMiddleware } from './cache.js';
@@ -283,6 +284,25 @@ app.get('/api/streams/pending-backfill', authenticateWorker, async (req, res) =>
283
  }
284
  });
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  // GET list of streams missing twitch_vod_id
287
  app.get('/api/streams/missing-vod', authenticateWorker, async (req, res) => {
288
  try {
 
39
  cleanupGhostStreams,
40
  startCoverageWindow,
41
  updateCoverageHeartbeat,
42
+ endCoverageWindow,
43
+ getStreamBackfillStatus
44
  } from './db.js';
45
  // import { initializeEventSub } from './eventsub.js';
46
  import { cache, cacheMiddleware } from './cache.js';
 
284
  }
285
  });
286
 
287
+ // GET backfill status for a specific stream (chat messages count & latest timestamp, voice words count & latest timestamp)
288
+ app.get('/api/streams/backfill-status', authenticateWorker, async (req, res) => {
289
+ const { twitchStreamId } = req.query;
290
+ if (!twitchStreamId) {
291
+ return res.status(400).json({ error: 'Missing twitchStreamId query parameter' });
292
+ }
293
+
294
+ try {
295
+ const status = await getStreamBackfillStatus(twitchStreamId);
296
+ if (!status) {
297
+ return res.status(404).json({ error: 'Stream not found' });
298
+ }
299
+ res.json(status);
300
+ } catch (err) {
301
+ console.error('[API Error] /api/streams/backfill-status:', err);
302
+ res.status(500).json({ error: 'Internal Server Error' });
303
+ }
304
+ });
305
+
306
  // GET list of streams missing twitch_vod_id
307
  app.get('/api/streams/missing-vod', authenticateWorker, async (req, res) => {
308
  try {