Sasha commited on
Commit
f26efec
·
1 Parent(s): a0432b5

feat: smart gap detection and user roles sync backfill improvements

Browse files
local_worker/chat_mod_worker.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import os
2
  import sys
3
  import time
@@ -32,6 +33,65 @@ mod_action_queue = queue.Queue()
32
  # Flag to signal thread termination
33
  stop_flag = threading.Event()
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def parse_irc_tags(tags_str):
36
  """Parse IRC v3 tags into a dictionary"""
37
  tags = {}
@@ -131,6 +191,7 @@ def twitch_irc_listener():
131
  is_streamer = (user.lower() == TWITCH_CHANNEL)
132
  is_mod = "moderator" in badges or "broadcaster" in badges
133
  is_sub = "subscriber" in badges or "founder" in badges
 
134
 
135
  parsed_msg = {
136
  "id": msg_id,
@@ -140,7 +201,8 @@ def twitch_irc_listener():
140
  "timestamp": new_iso_timestamp(),
141
  "isStreamer": is_streamer,
142
  "isMod": is_mod,
143
- "isSub": is_sub
 
144
  }
145
 
146
  chat_queue.put(parsed_msg)
@@ -336,53 +398,29 @@ def run_backfill_loop():
336
  title = target.get("title", "Unknown Archive")
337
  start_time = target.get("start_time")
338
 
339
- min_msg_time = target.get("min_message_time")
340
- max_msg_time = target.get("max_message_time")
341
-
342
- # Parse ISO strings to epoch timestamps
343
- def parse_time(t_str):
344
- if not t_str:
345
- return None
346
- try:
347
- clean_t = t_str.split(".")[0].replace("Z", "").replace("+00:00", "")
348
- return time.mktime(time.strptime(clean_t, "%Y-%m-%dT%H:%M:%S"))
349
- except Exception as parse_err:
350
- print(f"[Backfill] Error parsing time '{t_str}': {parse_err}")
351
- return None
352
-
353
- start_epoch = parse_time(start_time)
354
-
355
- # -- CHAT BACKFILL TASKS --
356
- chat_tasks = []
357
- if not min_msg_time:
358
- # 0 messages, download everything
359
- chat_tasks.append((0, None))
360
- else:
361
- min_msg_epoch = parse_time(min_msg_time)
362
- max_msg_epoch = parse_time(max_msg_time)
363
-
364
- min_msg_offset = int(min_msg_epoch - start_epoch) if (min_msg_epoch and start_epoch) else 0
365
- max_msg_offset = int(max_msg_epoch - start_epoch) if (max_msg_epoch and start_epoch) else 0
366
-
367
- # If first message is > 60s from start, download the beginning gap
368
- if min_msg_offset > 60:
369
- chat_tasks.append((0, min_msg_offset))
370
- # Download from last message to end
371
- chat_tasks.append((max_msg_offset, None))
372
 
373
  print(f"\n=========================================================")
374
- print(f"[Backfill] Processing Chat: {title}")
375
- print(f"[Backfill] VOD ID: {vod_id}")
376
- print(f"[Backfill] Stream ID: {stream_id}")
377
- print(f"[Backfill] Chat Tasks: {chat_tasks}")
378
  print(f"=========================================================")
379
 
380
- # 1. Download and upload chat segments
381
- for start_off, end_off in chat_tasks:
382
- print(f"[Backfill] Downloading chat from {start_off}s to {'end' if end_off is None else str(end_off) + 's'}...")
383
- chat_comments = download_vod_chat(vod_id, start_offset=start_off, end_offset=end_off)
 
 
 
 
384
  if chat_comments:
385
- print(f"[Backfill] Uploading {len(chat_comments)} chat comments...")
386
  batch_size = 100
387
  headers_post = {"x-api-key": API_KEY, "Content-Type": "application/json"}
388
  for i in range(0, len(chat_comments), batch_size):
@@ -391,6 +429,30 @@ def run_backfill_loop():
391
  requests.post(f"{API_URL}/api/log/messages", json={"messages": batch}, headers=headers_post, timeout=10)
392
  except Exception as e:
393
  print(f"[Backfill] Chat batch upload error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
  # 2. Mark locally as processed (does NOT mark completed on server, so worker.py can do voice)
396
  save_processed_stream(stream_id)
@@ -424,16 +486,34 @@ if __name__ == "__main__":
424
  t_irc = threading.Thread(target=twitch_irc_listener, daemon=True)
425
  t_chat_send = threading.Thread(target=chat_sender, daemon=True)
426
  t_mod_send = threading.Thread(target=mod_action_sender, daemon=True)
 
427
 
428
  t_irc.start()
429
  t_chat_send.start()
430
  t_mod_send.start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
  # Start VOD backfilling (GQL chat ONLY) in the main thread
433
  try:
434
  run_backfill_loop()
435
  except KeyboardInterrupt:
436
  print("\n[Shutting Down] Gracefully stopping threads...")
 
 
 
437
  stop_flag.set()
438
  time.sleep(1)
439
  print("[Shutting Down] Done.")
 
1
+ from datetime import datetime, timedelta
2
  import os
3
  import sys
4
  import time
 
33
  # Flag to signal thread termination
34
  stop_flag = threading.Event()
35
 
36
+ # Coverage window tracking
37
+ coverage_id = None
38
+ coverage_stop_flag = threading.Event()
39
+
40
+ def start_coverage(stream_id, source='live', covered_from=None, covered_to=None):
41
+ """Register a new coverage window with the backend"""
42
+ global coverage_id
43
+ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
44
+ payload = {"streamId": stream_id, "source": source}
45
+ if covered_from:
46
+ payload["coveredFrom"] = covered_from
47
+ if covered_to:
48
+ payload["coveredTo"] = covered_to
49
+ try:
50
+ res = requests.post(f"{API_URL}/api/log/coverage/start",
51
+ json=payload,
52
+ headers=headers, timeout=5)
53
+ if res.status_code == 200:
54
+ cid = res.json().get("coverageId")
55
+ print(f"[Coverage] Started window #{cid} (source={source}) for stream {stream_id}")
56
+ if source == 'live':
57
+ coverage_id = cid
58
+ return cid
59
+ except Exception as e:
60
+ print(f"[Coverage] Error starting coverage: {e}")
61
+ return None
62
+
63
+ def stop_coverage(cid=None, covered_to=None):
64
+ """Finalize a coverage window"""
65
+ global coverage_id
66
+ target_id = cid if cid is not None else coverage_id
67
+ if not target_id:
68
+ return
69
+ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
70
+ payload = {"coverageId": target_id}
71
+ if covered_to:
72
+ payload["coveredTo"] = covered_to
73
+ try:
74
+ requests.post(f"{API_URL}/api/log/coverage/end",
75
+ json=payload,
76
+ headers=headers, timeout=5)
77
+ print(f"[Coverage] Ended window #{target_id}")
78
+ except Exception as e:
79
+ print(f"[Coverage] Error ending coverage: {e}")
80
+ if cid is None or cid == coverage_id:
81
+ coverage_id = None
82
+
83
+ def coverage_heartbeat_loop():
84
+ """Background thread: ping coverage heartbeat every 2 minutes"""
85
+ headers = {"x-api-key": API_KEY}
86
+ while not coverage_stop_flag.is_set():
87
+ time.sleep(120)
88
+ if coverage_id:
89
+ try:
90
+ requests.patch(f"{API_URL}/api/log/coverage/{coverage_id}/heartbeat",
91
+ headers=headers, timeout=5)
92
+ except Exception:
93
+ pass
94
+
95
  def parse_irc_tags(tags_str):
96
  """Parse IRC v3 tags into a dictionary"""
97
  tags = {}
 
191
  is_streamer = (user.lower() == TWITCH_CHANNEL)
192
  is_mod = "moderator" in badges or "broadcaster" in badges
193
  is_sub = "subscriber" in badges or "founder" in badges
194
+ is_vip = "vip" in badges
195
 
196
  parsed_msg = {
197
  "id": msg_id,
 
201
  "timestamp": new_iso_timestamp(),
202
  "isStreamer": is_streamer,
203
  "isMod": is_mod,
204
+ "isSub": is_sub,
205
+ "isVip": is_vip
206
  }
207
 
208
  chat_queue.put(parsed_msg)
 
398
  title = target.get("title", "Unknown Archive")
399
  start_time = target.get("start_time")
400
 
401
+ gaps = target.get("gaps", [])
402
+ if not gaps:
403
+ print(f"[Backfill] No gaps found for stream {stream_id}, skipping.")
404
+ save_processed_stream(stream_id)
405
+ continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
  print(f"\n=========================================================")
408
+ print(f"[Backfill] Processing Chat Gaps: {title}")
409
+ print(f"[Backfill] VOD ID: {vod_id}")
410
+ print(f"[Backfill] Stream ID: {stream_id}")
411
+ print(f"[Backfill] Gaps count: {len(gaps)}")
412
  print(f"=========================================================")
413
 
414
+ # 1. Download and upload chat segments for each gap
415
+ for gap_idx, gap in enumerate(gaps, 1):
416
+ from_off = gap.get('from_offset', 0)
417
+ to_off = gap.get('to_offset', None)
418
+ to_str = str(to_off) + 's' if to_off is not None else 'end'
419
+ print(f"\n[Backfill] Gap {gap_idx}/{len(gaps)}: chat {from_off}s → {to_str}")
420
+
421
+ chat_comments = download_vod_chat(vod_id, start_offset=from_off, end_offset=to_off)
422
  if chat_comments:
423
+ print(f"[Backfill] Uploading {len(chat_comments)} messages...")
424
  batch_size = 100
425
  headers_post = {"x-api-key": API_KEY, "Content-Type": "application/json"}
426
  for i in range(0, len(chat_comments), batch_size):
 
429
  requests.post(f"{API_URL}/api/log/messages", json={"messages": batch}, headers=headers_post, timeout=10)
430
  except Exception as e:
431
  print(f"[Backfill] Chat batch upload error: {e}")
432
+
433
+ # Extract and upload roles
434
+ roles = [
435
+ {
436
+ "username": m["username"],
437
+ "displayName": m.get("displayName", m["username"]),
438
+ "isMod": m.get("isMod", False),
439
+ "isSub": m.get("isSub", False),
440
+ "isVip": m.get("isVip", False),
441
+ "timestamp": m.get("timestamp")
442
+ }
443
+ for m in chat_comments
444
+ if m.get("isMod") or m.get("isSub") or m.get("isVip")
445
+ ]
446
+ if roles:
447
+ print(f"[Backfill] Uploading {len(roles)} roles...")
448
+ for i in range(0, len(roles), 500):
449
+ batch = roles[i:i+500]
450
+ try:
451
+ requests.post(f"{API_URL}/api/log/roles", json={"roles": batch}, headers=headers_post, timeout=10)
452
+ except Exception as e:
453
+ print(f"[Backfill] Role batch upload error: {e}")
454
+ else:
455
+ print(f"[Backfill] No chat in this gap.")
456
 
457
  # 2. Mark locally as processed (does NOT mark completed on server, so worker.py can do voice)
458
  save_processed_stream(stream_id)
 
486
  t_irc = threading.Thread(target=twitch_irc_listener, daemon=True)
487
  t_chat_send = threading.Thread(target=chat_sender, daemon=True)
488
  t_mod_send = threading.Thread(target=mod_action_sender, daemon=True)
489
+ t_heartbeat = threading.Thread(target=coverage_heartbeat_loop, daemon=True)
490
 
491
  t_irc.start()
492
  t_chat_send.start()
493
  t_mod_send.start()
494
+ t_heartbeat.start()
495
+
496
+ # Start coverage window for the active stream
497
+ try:
498
+ import requests as _req
499
+ _headers = {"x-api-key": API_KEY}
500
+ _stream_res = _req.get(f"{API_URL}/api/streams/active", headers=_headers, timeout=5)
501
+ if _stream_res.status_code == 200:
502
+ _active = _stream_res.json()
503
+ _sid = _active.get("stream", {}).get("id") or _active.get("id")
504
+ if _sid:
505
+ start_coverage(_sid, source='live')
506
+ except Exception as _e:
507
+ print(f"[Coverage] Could not get active stream for coverage: {_e}")
508
 
509
  # Start VOD backfilling (GQL chat ONLY) in the main thread
510
  try:
511
  run_backfill_loop()
512
  except KeyboardInterrupt:
513
  print("\n[Shutting Down] Gracefully stopping threads...")
514
+ finally:
515
+ stop_coverage()
516
+ coverage_stop_flag.set()
517
  stop_flag.set()
518
  time.sleep(1)
519
  print("[Shutting Down] Done.")
local_worker/role_backfiller.py CHANGED
@@ -33,15 +33,64 @@ def get_all_streams():
33
  print(f"[Error] Не удалось получить список стримов: {e}")
34
  return []
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def extract_vod_id(stream):
37
- """Extract VOD ID from stream's twitch_stream_id field (e.g. 'vod-2154382910' -> '2154382910')"""
38
- twitch_id = stream.get("twitch_stream_id", "")
39
  if twitch_id.startswith("vod-"):
40
  return twitch_id[4:]
41
- return None
 
 
 
 
 
 
 
42
 
43
  def download_vod_roles(vod_id):
44
- """Download chat log of a past VOD and extract only roles"""
45
  roles_dict = {}
46
  url = "https://gql.twitch.tv/gql"
47
  gql_headers = {
@@ -51,6 +100,7 @@ def download_vod_roles(vod_id):
51
 
52
  current_offset = 0
53
  total_messages = 0
 
54
 
55
  while True:
56
  payload = {
@@ -67,74 +117,96 @@ def download_vod_roles(vod_id):
67
  }
68
  }
69
 
70
- try:
71
- res = requests.post(url, json=payload, headers=gql_headers, timeout=10)
72
- if res.status_code != 200:
73
- break
74
-
75
- data = res.json()
76
- if isinstance(data, list):
77
- data = data[0]
78
-
79
- video = data.get("data", {}).get("video", {})
80
- if not video:
81
- break
82
-
83
- edges = (video.get("comments") or {}).get("edges") or []
84
- if not edges:
85
- break
86
-
87
- for edge in edges:
88
- if not edge:
89
- continue
90
- node = edge.get("node")
91
- if not node:
92
- continue
93
- commenter = node.get("commenter")
94
- if not commenter:
95
- continue
96
- user = commenter.get("login")
97
- if not user:
98
- continue
99
-
100
- display_name = commenter.get("displayName", user)
101
- message = node.get("message")
102
- if not message:
103
- continue
104
-
105
- timestamp = node.get("createdAt")
106
- user_badges = message.get("userBadges") or []
107
- badges = [b.get("setID") for b in user_badges if b]
108
- is_mod = "moderator" in badges or "broadcaster" in badges
109
- is_sub = "subscriber" in badges or "founder" in badges
110
- is_vip = "vip" in badges
111
-
112
- roles_dict[user.lower()] = {
113
- "username": user,
114
- "displayName": display_name,
115
- "isMod": is_mod,
116
- "isSub": is_sub,
117
- "isVip": is_vip,
118
- "timestamp": timestamp
119
- }
120
- total_messages += 1
121
-
122
- print(f" -> Обработано {total_messages} сообщений, найдено {len(roles_dict)} уникальных пользователей...", end="\r")
123
-
124
- last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
125
- if last_offset is not None:
126
- next_offset = last_offset + 1
127
- if next_offset <= current_offset:
128
- next_offset = current_offset + 30
129
- current_offset = next_offset
130
- else:
131
- break
132
- except Exception as e:
133
- print(f"\n [!] Ошибка при загрузке: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  break
135
 
136
  return list(roles_dict.values())
137
 
 
138
  def upload_roles(roles, vod_id):
139
  """Send roles to backend in batches"""
140
  batch_size = 500
@@ -169,18 +241,13 @@ if __name__ == "__main__":
169
  vod_streams = [s for s in streams if str(s.get("twitch_stream_id", "")).startswith("vod-")]
170
 
171
  print(f"[+] Найдено стримов всего: {len(streams)}")
172
- print(f"[+] Из них архивных VOD: {len(vod_streams)}")
173
  print()
174
 
175
- if not vod_streams:
176
- print("[!] Нет VOD-стримов для обработки. Выход.")
177
- sys.exit(0)
178
-
179
- # Process each VOD
180
- for idx, stream in enumerate(vod_streams, 1):
181
  vod_id = extract_vod_id(stream)
182
  title = stream.get("title", "Без названия")
183
- print(f"[{idx}/{len(vod_streams)}] VOD {vod_id} — «{title}»")
184
 
185
  if not vod_id:
186
  print(" -> Пропуск: нет VOD ID")
 
33
  print(f"[Error] Не удалось получить список стримов: {e}")
34
  return []
35
 
36
+ def find_vod_id_by_stream_id(stream_id):
37
+ """Try to find a VOD ID for a given Twitch stream ID using GQL"""
38
+ url = "https://gql.twitch.tv/gql"
39
+ gql_headers = {
40
+ "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
41
+ "User-Agent": "Mozilla/5.0"
42
+ }
43
+ payload = {
44
+ "query": f"""
45
+ query {{
46
+ channel(name: "{os.getenv('TWITCH_CHANNEL', 'winx_prinx')}") {{
47
+ videos(first: 30, type: ARCHIVE) {{
48
+ edges {{
49
+ node {{
50
+ id
51
+ broadcastType
52
+ lengthSeconds
53
+ publishedAt
54
+ stream {{
55
+ id
56
+ }}
57
+ }}
58
+ }}
59
+ }}
60
+ }}
61
+ }}
62
+ """
63
+ }
64
+ try:
65
+ res = requests.post(url, json=payload, headers=gql_headers, timeout=10)
66
+ if res.status_code == 200:
67
+ data = res.json()
68
+ edges = data.get("data", {}).get("channel", {}).get("videos", {}).get("edges", [])
69
+ for edge in edges:
70
+ node = edge.get("node", {})
71
+ stream_info = node.get("stream") or {}
72
+ if stream_info.get("id") == str(stream_id):
73
+ return node.get("id")
74
+ except Exception as e:
75
+ pass
76
+ return None
77
+
78
  def extract_vod_id(stream):
79
+ """Extract VOD ID from stream record"""
80
+ twitch_id = str(stream.get("twitch_stream_id", ""))
81
  if twitch_id.startswith("vod-"):
82
  return twitch_id[4:]
83
+ # Live stream ID — try to find corresponding VOD
84
+ print(f" -> Поиск VOD для live stream ID {twitch_id}...")
85
+ vod_id = find_vod_id_by_stream_id(twitch_id)
86
+ if vod_id:
87
+ print(f" -> Найден VOD ID: {vod_id}")
88
+ else:
89
+ print(f" -> VOD не найден (стрим мог быть удалён или VOD недоступен)")
90
+ return vod_id
91
 
92
  def download_vod_roles(vod_id):
93
+ """Download chat log of a past VOD and extract only roles, with retry on failure"""
94
  roles_dict = {}
95
  url = "https://gql.twitch.tv/gql"
96
  gql_headers = {
 
100
 
101
  current_offset = 0
102
  total_messages = 0
103
+ MAX_RETRIES = 5
104
 
105
  while True:
106
  payload = {
 
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
 
209
+
210
  def upload_roles(roles, vod_id):
211
  """Send roles to backend in batches"""
212
  batch_size = 500
 
241
  vod_streams = [s for s in streams if str(s.get("twitch_stream_id", "")).startswith("vod-")]
242
 
243
  print(f"[+] Найдено стримов всего: {len(streams)}")
 
244
  print()
245
 
246
+ # Process each stream
247
+ for idx, stream in enumerate(streams, 1):
 
 
 
 
248
  vod_id = extract_vod_id(stream)
249
  title = stream.get("title", "Без названия")
250
+ print(f"[{idx}/{len(streams)}] Stream — «{title[:50]}»")
251
 
252
  if not vod_id:
253
  print(" -> Пропуск: нет VOD ID")
local_worker/worker.py CHANGED
@@ -21,6 +21,7 @@ if added_paths:
21
  else:
22
  os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths)
23
 
 
24
  import time
25
  import socket
26
  import select
@@ -332,34 +333,48 @@ def notify_stream_end():
332
  coverage_id = None
333
  coverage_stop_flag = threading.Event()
334
 
335
- def start_coverage(stream_id, source='live'):
336
  """Register a new coverage window with the backend"""
337
  global coverage_id
338
  headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
 
 
 
 
 
339
  try:
340
  res = requests.post(f"{API_URL}/api/log/coverage/start",
341
- json={"streamId": stream_id, "source": source},
342
  headers=headers, timeout=5)
343
  if res.status_code == 200:
344
- coverage_id = res.json().get("coverageId")
345
- print(f"[Coverage] Started window #{coverage_id} for stream {stream_id}")
 
 
 
346
  except Exception as e:
347
  print(f"[Coverage] Error starting coverage: {e}")
 
348
 
349
- def stop_coverage():
350
- """Finalize the current coverage window"""
351
  global coverage_id
352
- if not coverage_id:
 
353
  return
354
  headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
 
 
 
355
  try:
356
  requests.post(f"{API_URL}/api/log/coverage/end",
357
- json={"coverageId": coverage_id},
358
  headers=headers, timeout=5)
359
- print(f"[Coverage] Ended window #{coverage_id}")
360
  except Exception as e:
361
  print(f"[Coverage] Error ending coverage: {e}")
362
- coverage_id = None
 
363
 
364
  def coverage_heartbeat_loop():
365
  """Background thread: ping coverage heartbeat every 2 minutes"""
@@ -433,9 +448,19 @@ def run_stream_capture(model):
433
  return None
434
 
435
  start_epoch = parse_time(start_time)
436
-
437
- # Notify start of coverage for this backfill session
438
- start_coverage(stream_id, source='backfill')
 
 
 
 
 
 
 
 
 
 
439
 
440
  gaps = target.get("gaps", [])
441
  if not gaps:
@@ -445,7 +470,6 @@ def run_stream_capture(model):
445
  json={"streamId": stream_id}, headers=headers_get, timeout=10)
446
  except Exception as e:
447
  print(f"[Backfill] Error marking complete: {e}")
448
- stop_coverage()
449
  continue
450
 
451
  print(f"[Backfill] Found {len(gaps)} gap(s) to fill:")
@@ -460,7 +484,13 @@ def run_stream_capture(model):
460
  to_str = str(to_off) + 's' if to_off is not None else 'end'
461
  print(f"\n[Backfill] Gap {gap_idx}/{len(gaps)}: chat {from_off}s → {to_str}")
462
 
 
 
 
 
 
463
  chat_comments = download_vod_chat(vod_id, start_offset=from_off, end_offset=to_off)
 
464
  if chat_comments:
465
  print(f"[Backfill] Uploading {len(chat_comments)} messages...")
466
  batch_size = 100
@@ -473,24 +503,59 @@ def run_stream_capture(model):
473
  headers=headers_post, timeout=10)
474
  except Exception as e:
475
  print(f"[Backfill] Chat batch upload error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  else:
477
  print(f"[Backfill] No chat in this gap.")
478
 
479
- # Transcribe audio gaps
480
- print(f"[Backfill] Starting Whisper audio transcription for gaps...")
481
- for gap_idx, gap in enumerate(gaps, 1):
482
- from_off = gap.get('from_offset', 0)
483
- to_off = gap.get('to_offset', None)
484
- to_str = str(to_off) + 's' if to_off is not None else 'end'
485
- print(f"[Backfill] Audio gap {gap_idx}/{len(gaps)}: {from_off}s → {to_str}")
486
  try:
487
  start_time_iso = start_time.replace("+00:00", "").replace("Z", "") + "Z"
488
  transcribe_vod_audio(vod_id, start_time_iso, model,
489
  start_offset=from_off, end_offset=to_off)
490
  except Exception as e:
491
  print(f"[Backfill] Transcription error: {e}")
492
-
493
- # 3. Mark completed and end coverage
 
 
 
 
 
 
494
  print(f"[Backfill] Marking stream {stream_id} as backfilled...")
495
  try:
496
  res_mark = requests.post(f"{API_URL}/api/streams/mark-backfilled", json={"streamId": stream_id}, headers=headers_get, timeout=10)
@@ -500,8 +565,7 @@ def run_stream_capture(model):
500
  print(f"[Backfill] Failed to mark stream: {res_mark.status_code} - {res_mark.text}")
501
  except Exception as e:
502
  print(f"[Backfill] Network error marking stream: {e}")
503
- stop_coverage()
504
-
505
  # Loop again immediately to process next VOD or check if live
506
  continue
507
  else:
 
21
  else:
22
  os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths)
23
 
24
+ from datetime import datetime, timedelta
25
  import time
26
  import socket
27
  import select
 
333
  coverage_id = None
334
  coverage_stop_flag = threading.Event()
335
 
336
+ def start_coverage(stream_id, source='live', covered_from=None, covered_to=None):
337
  """Register a new coverage window with the backend"""
338
  global coverage_id
339
  headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
340
+ payload = {"streamId": stream_id, "source": source}
341
+ if covered_from:
342
+ payload["coveredFrom"] = covered_from
343
+ if covered_to:
344
+ payload["coveredTo"] = covered_to
345
  try:
346
  res = requests.post(f"{API_URL}/api/log/coverage/start",
347
+ json=payload,
348
  headers=headers, timeout=5)
349
  if res.status_code == 200:
350
+ cid = res.json().get("coverageId")
351
+ print(f"[Coverage] Started window #{cid} (source={source}) for stream {stream_id}")
352
+ if source == 'live':
353
+ coverage_id = cid
354
+ return cid
355
  except Exception as e:
356
  print(f"[Coverage] Error starting coverage: {e}")
357
+ return None
358
 
359
+ def stop_coverage(cid=None, covered_to=None):
360
+ """Finalize a coverage window"""
361
  global coverage_id
362
+ target_id = cid if cid is not None else coverage_id
363
+ if not target_id:
364
  return
365
  headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
366
+ payload = {"coverageId": target_id}
367
+ if covered_to:
368
+ payload["coveredTo"] = covered_to
369
  try:
370
  requests.post(f"{API_URL}/api/log/coverage/end",
371
+ json=payload,
372
  headers=headers, timeout=5)
373
+ print(f"[Coverage] Ended window #{target_id}")
374
  except Exception as e:
375
  print(f"[Coverage] Error ending coverage: {e}")
376
+ if cid is None or cid == coverage_id:
377
+ coverage_id = None
378
 
379
  def coverage_heartbeat_loop():
380
  """Background thread: ping coverage heartbeat every 2 minutes"""
 
448
  return None
449
 
450
  start_epoch = parse_time(start_time)
451
+
452
+ # Helper to compute ISO timestamp for given offset from start_time
453
+ def get_offset_iso_time(start_time_str, offset_seconds):
454
+ if not start_time_str or offset_seconds is None:
455
+ return None
456
+ try:
457
+ clean_str = start_time_str.replace('Z', '+00:00')
458
+ dt = datetime.fromisoformat(clean_str)
459
+ dt_offset = dt + timedelta(seconds=int(offset_seconds))
460
+ return dt_offset.strftime("%Y-%m-%dT%H:%M:%SZ")
461
+ except Exception as e:
462
+ print(f"[Coverage] Error calculating offset time: {e}")
463
+ return None
464
 
465
  gaps = target.get("gaps", [])
466
  if not gaps:
 
470
  json={"streamId": stream_id}, headers=headers_get, timeout=10)
471
  except Exception as e:
472
  print(f"[Backfill] Error marking complete: {e}")
 
473
  continue
474
 
475
  print(f"[Backfill] Found {len(gaps)} gap(s) to fill:")
 
484
  to_str = str(to_off) + 's' if to_off is not None else 'end'
485
  print(f"\n[Backfill] Gap {gap_idx}/{len(gaps)}: chat {from_off}s → {to_str}")
486
 
487
+ # 1. Register a coverage window for this gap (start)
488
+ covered_from_iso = get_offset_iso_time(start_time, from_off)
489
+ gap_coverage_id = start_coverage(stream_id, source='backfill', covered_from=covered_from_iso)
490
+
491
+ # 2. Download and upload chat comments
492
  chat_comments = download_vod_chat(vod_id, start_offset=from_off, end_offset=to_off)
493
+ actual_to_offset = to_off
494
  if chat_comments:
495
  print(f"[Backfill] Uploading {len(chat_comments)} messages...")
496
  batch_size = 100
 
503
  headers=headers_post, timeout=10)
504
  except Exception as e:
505
  print(f"[Backfill] Chat batch upload error: {e}")
506
+
507
+ # 2.5 Extract and upload roles
508
+ roles = [
509
+ {
510
+ "username": m["username"],
511
+ "displayName": m.get("displayName", m["username"]),
512
+ "isMod": m.get("isMod", False),
513
+ "isSub": m.get("isSub", False),
514
+ "isVip": m.get("isVip", False),
515
+ "timestamp": m.get("timestamp")
516
+ }
517
+ for m in chat_comments
518
+ if m.get("isMod") or m.get("isSub") or m.get("isVip")
519
+ ]
520
+ if roles:
521
+ print(f"[Backfill] Uploading {len(roles)} roles...")
522
+ for i in range(0, len(roles), 500):
523
+ batch = roles[i:i+500]
524
+ try:
525
+ requests.post(f"{API_URL}/api/log/roles",
526
+ json={"roles": batch},
527
+ headers=headers_post, timeout=10)
528
+ except Exception as e:
529
+ print(f"[Backfill] Role batch upload error: {e}")
530
+
531
+ # If to_off was None, we can use the offset of the last downloaded message
532
+ if to_off is None:
533
+ try:
534
+ last_msg_time_str = chat_comments[-1]["timestamp"]
535
+ last_msg_epoch = parse_time(last_msg_time_str)
536
+ if last_msg_epoch and start_epoch:
537
+ actual_to_offset = int(last_msg_epoch - start_epoch)
538
+ except Exception as e:
539
+ print(f"[Backfill] Error estimating end offset: {e}")
540
  else:
541
  print(f"[Backfill] No chat in this gap.")
542
 
543
+ # 3. Transcribe audio for this gap
544
+ print(f"[Backfill] Starting Whisper audio transcription for this gap...")
 
 
 
 
 
545
  try:
546
  start_time_iso = start_time.replace("+00:00", "").replace("Z", "") + "Z"
547
  transcribe_vod_audio(vod_id, start_time_iso, model,
548
  start_offset=from_off, end_offset=to_off)
549
  except Exception as e:
550
  print(f"[Backfill] Transcription error: {e}")
551
+
552
+ # 4. Register a coverage window for this gap (end)
553
+ covered_to_iso = get_offset_iso_time(start_time, actual_to_offset)
554
+ if not covered_to_iso:
555
+ covered_to_iso = get_offset_iso_time(start_time, from_off + 60)
556
+ stop_coverage(gap_coverage_id, covered_to=covered_to_iso)
557
+
558
+ # 3. Mark completed
559
  print(f"[Backfill] Marking stream {stream_id} as backfilled...")
560
  try:
561
  res_mark = requests.post(f"{API_URL}/api/streams/mark-backfilled", json={"streamId": stream_id}, headers=headers_get, timeout=10)
 
565
  print(f"[Backfill] Failed to mark stream: {res_mark.status_code} - {res_mark.text}")
566
  except Exception as e:
567
  print(f"[Backfill] Network error marking stream: {e}")
568
+
 
569
  # Loop again immediately to process next VOD or check if live
570
  continue
571
  else:
server/db.js CHANGED
@@ -1580,17 +1580,17 @@ export async function getModeratorProfilesData(streamId = null) {
1580
  /**
1581
  * Start a new coverage window (worker session started)
1582
  */
1583
- export async function startCoverageWindow(streamId, source = 'live') {
1584
- const now = new Date().toISOString();
1585
  if (dbMode === 'sqlite') {
1586
  const result = sqliteDb.prepare(
1587
- 'INSERT INTO stream_coverage (stream_id, covered_from, source) VALUES (?, ?, ?)'
1588
- ).run(streamId, now, source);
1589
  return result.lastInsertRowid;
1590
  } else {
1591
  const { data, error } = await supabase
1592
  .from('stream_coverage')
1593
- .insert({ stream_id: streamId, covered_from: now, source })
1594
  .select('id')
1595
  .single();
1596
  if (error) console.error('[Supabase] startCoverageWindow error:', error.message);
@@ -1613,12 +1613,12 @@ export async function updateCoverageHeartbeat(coverageId) {
1613
  /**
1614
  * End a coverage window (worker session stopped)
1615
  */
1616
- export async function endCoverageWindow(coverageId) {
1617
- const now = new Date().toISOString();
1618
  if (dbMode === 'sqlite') {
1619
- sqliteDb.prepare('UPDATE stream_coverage SET covered_to = ? WHERE id = ?').run(now, coverageId);
1620
  } else {
1621
- await supabase.from('stream_coverage').update({ covered_to: now }).eq('id', coverageId);
1622
  }
1623
  }
1624
 
 
1580
  /**
1581
  * Start a new coverage window (worker session started)
1582
  */
1583
+ export async function startCoverageWindow(streamId, source = 'live', coveredFrom = null, coveredTo = null) {
1584
+ const fromTime = coveredFrom || new Date().toISOString();
1585
  if (dbMode === 'sqlite') {
1586
  const result = sqliteDb.prepare(
1587
+ 'INSERT INTO stream_coverage (stream_id, covered_from, covered_to, source) VALUES (?, ?, ?, ?)'
1588
+ ).run(streamId, fromTime, coveredTo, source);
1589
  return result.lastInsertRowid;
1590
  } else {
1591
  const { data, error } = await supabase
1592
  .from('stream_coverage')
1593
+ .insert({ stream_id: streamId, covered_from: fromTime, covered_to: coveredTo, source })
1594
  .select('id')
1595
  .single();
1596
  if (error) console.error('[Supabase] startCoverageWindow error:', error.message);
 
1613
  /**
1614
  * End a coverage window (worker session stopped)
1615
  */
1616
+ export async function endCoverageWindow(coverageId, coveredTo = null) {
1617
+ const toTime = coveredTo || new Date().toISOString();
1618
  if (dbMode === 'sqlite') {
1619
+ sqliteDb.prepare('UPDATE stream_coverage SET covered_to = ? WHERE id = ?').run(toTime, coverageId);
1620
  } else {
1621
+ await supabase.from('stream_coverage').update({ covered_to: toTime }).eq('id', coverageId);
1622
  }
1623
  }
1624
 
server/server.js CHANGED
@@ -282,10 +282,10 @@ app.get('/api/streams/pending-backfill', authenticateWorker, async (req, res) =>
282
 
283
  // POST start coverage window
284
  app.post('/api/log/coverage/start', authenticateWorker, async (req, res) => {
285
- const { streamId, source } = req.body;
286
  if (!streamId) return res.status(400).json({ error: 'Missing streamId' });
287
  try {
288
- const coverageId = await startCoverageWindow(streamId, source || 'live');
289
  res.json({ success: true, coverageId });
290
  } catch (err) {
291
  console.error('[API Error] /api/log/coverage/start:', err);
@@ -307,10 +307,10 @@ app.patch('/api/log/coverage/:id/heartbeat', authenticateWorker, async (req, res
307
 
308
  // POST end coverage window
309
  app.post('/api/log/coverage/end', authenticateWorker, async (req, res) => {
310
- const { coverageId } = req.body;
311
  if (!coverageId) return res.status(400).json({ error: 'Missing coverageId' });
312
  try {
313
- await endCoverageWindow(coverageId);
314
  res.json({ success: true });
315
  } catch (err) {
316
  console.error('[API Error] /api/log/coverage/end:', err);
 
282
 
283
  // POST start coverage window
284
  app.post('/api/log/coverage/start', authenticateWorker, async (req, res) => {
285
+ const { streamId, source, coveredFrom, coveredTo } = req.body;
286
  if (!streamId) return res.status(400).json({ error: 'Missing streamId' });
287
  try {
288
+ const coverageId = await startCoverageWindow(streamId, source || 'live', coveredFrom, coveredTo);
289
  res.json({ success: true, coverageId });
290
  } catch (err) {
291
  console.error('[API Error] /api/log/coverage/start:', err);
 
307
 
308
  // POST end coverage window
309
  app.post('/api/log/coverage/end', authenticateWorker, async (req, res) => {
310
+ const { coverageId, coveredTo } = req.body;
311
  if (!coverageId) return res.status(400).json({ error: 'Missing coverageId' });
312
  try {
313
+ await endCoverageWindow(coverageId, coveredTo);
314
  res.json({ success: true });
315
  } catch (err) {
316
  console.error('[API Error] /api/log/coverage/end:', err);