Spaces:
Sleeping
Sleeping
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 +123 -43
- local_worker/role_backfiller.py +143 -76
- local_worker/worker.py +89 -25
- server/db.js +9 -9
- server/server.js +4 -4
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 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 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:
|
| 376 |
-
print(f"[Backfill] Stream ID:
|
| 377 |
-
print(f"[Backfill]
|
| 378 |
print(f"=========================================================")
|
| 379 |
|
| 380 |
-
# 1. Download and upload chat segments
|
| 381 |
-
for
|
| 382 |
-
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
if chat_comments:
|
| 385 |
-
print(f"[Backfill] Uploading {len(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
|
| 38 |
-
twitch_id = stream.get("twitch_stream_id", "")
|
| 39 |
if twitch_id.startswith("vod-"):
|
| 40 |
return twitch_id[4:]
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
data =
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
"
|
| 117 |
-
"
|
| 118 |
-
"
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 176 |
-
|
| 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(
|
| 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=
|
| 342 |
headers=headers, timeout=5)
|
| 343 |
if res.status_code == 200:
|
| 344 |
-
|
| 345 |
-
print(f"[Coverage] Started window #{
|
|
|
|
|
|
|
|
|
|
| 346 |
except Exception as e:
|
| 347 |
print(f"[Coverage] Error starting coverage: {e}")
|
|
|
|
| 348 |
|
| 349 |
-
def stop_coverage():
|
| 350 |
-
"""Finalize
|
| 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=
|
| 358 |
headers=headers, timeout=5)
|
| 359 |
-
print(f"[Coverage] Ended window #{
|
| 360 |
except Exception as e:
|
| 361 |
print(f"[Coverage] Error ending coverage: {e}")
|
| 362 |
-
|
|
|
|
| 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 |
-
#
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 480 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 1585 |
if (dbMode === 'sqlite') {
|
| 1586 |
const result = sqliteDb.prepare(
|
| 1587 |
-
'INSERT INTO stream_coverage (stream_id, covered_from, source) VALUES (?, ?, ?)'
|
| 1588 |
-
).run(streamId,
|
| 1589 |
return result.lastInsertRowid;
|
| 1590 |
} else {
|
| 1591 |
const { data, error } = await supabase
|
| 1592 |
.from('stream_coverage')
|
| 1593 |
-
.insert({ stream_id: streamId, covered_from:
|
| 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
|
| 1618 |
if (dbMode === 'sqlite') {
|
| 1619 |
-
sqliteDb.prepare('UPDATE stream_coverage SET covered_to = ? WHERE id = ?').run(
|
| 1620 |
} else {
|
| 1621 |
-
await supabase.from('stream_coverage').update({ covered_to:
|
| 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);
|