Spaces:
Sleeping
Sleeping
Sasha commited on
Commit ·
a0432b5
1
Parent(s): 03bc181
feat: smart gap detection with coverage windows - track exact time ranges captured per stream
Browse files- local_worker/worker.py +109 -59
- server/db.js +156 -45
- server/server.js +42 -1
- server/supabase_migration_coverage.sql +14 -0
local_worker/worker.py
CHANGED
|
@@ -328,6 +328,51 @@ def notify_stream_end():
|
|
| 328 |
except Exception as e:
|
| 329 |
print(f"[Sync] Failed to send stream-end: {e}")
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
# =========================================================================
|
| 332 |
# CAPTURE METHODS
|
| 333 |
# =========================================================================
|
|
@@ -389,76 +434,63 @@ def run_stream_capture(model):
|
|
| 389 |
|
| 390 |
start_epoch = parse_time(start_time)
|
| 391 |
|
| 392 |
-
#
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
min_voice_offset = int(min_voice_epoch - start_epoch) if (min_voice_epoch and start_epoch) else 0
|
| 420 |
-
max_voice_offset = int(max_voice_epoch - start_epoch) if (max_voice_epoch and start_epoch) else 0
|
| 421 |
-
|
| 422 |
-
# If first voice word is > 60s from start, transcribe the beginning gap
|
| 423 |
-
if min_voice_offset > 60:
|
| 424 |
-
voice_tasks.append((0, min_voice_offset))
|
| 425 |
-
# Transcribe from last word (with 10s safety overlap) to end
|
| 426 |
-
voice_tasks.append((max(0, max_voice_offset - 10), None))
|
| 427 |
-
|
| 428 |
-
print(f"\n=========================================================")
|
| 429 |
-
print(f"[Backfill] Processing: {title}")
|
| 430 |
-
print(f"[Backfill] VOD ID: {vod_id}")
|
| 431 |
-
print(f"[Backfill] Stream ID: {stream_id}")
|
| 432 |
-
print(f"[Backfill] Chat Tasks: {chat_tasks}")
|
| 433 |
-
print(f"[Backfill] Voice Tasks: {voice_tasks}")
|
| 434 |
-
print(f"=========================================================")
|
| 435 |
-
|
| 436 |
-
# 1. Download and upload chat segments
|
| 437 |
-
for start_off, end_off in chat_tasks:
|
| 438 |
-
print(f"[Backfill] Downloading chat from {start_off}s to {'end' if end_off is None else str(end_off) + 's'}...")
|
| 439 |
-
chat_comments = download_vod_chat(vod_id, start_offset=start_off, end_offset=end_off)
|
| 440 |
if chat_comments:
|
| 441 |
-
print(f"[Backfill] Uploading {len(chat_comments)}
|
| 442 |
batch_size = 100
|
| 443 |
headers_post = {"x-api-key": API_KEY, "Content-Type": "application/json"}
|
| 444 |
for i in range(0, len(chat_comments), batch_size):
|
| 445 |
batch = chat_comments[i:i+batch_size]
|
| 446 |
try:
|
| 447 |
-
requests.post(f"{API_URL}/api/log/messages",
|
|
|
|
|
|
|
| 448 |
except Exception as e:
|
| 449 |
print(f"[Backfill] Chat batch upload error: {e}")
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
try:
|
| 456 |
start_time_iso = start_time.replace("+00:00", "").replace("Z", "") + "Z"
|
| 457 |
-
transcribe_vod_audio(vod_id, start_time_iso, model,
|
|
|
|
| 458 |
except Exception as e:
|
| 459 |
print(f"[Backfill] Transcription error: {e}")
|
| 460 |
|
| 461 |
-
# 3. Mark completed
|
| 462 |
print(f"[Backfill] Marking stream {stream_id} as backfilled...")
|
| 463 |
try:
|
| 464 |
res_mark = requests.post(f"{API_URL}/api/streams/mark-backfilled", json={"streamId": stream_id}, headers=headers_get, timeout=10)
|
|
@@ -468,6 +500,7 @@ def run_stream_capture(model):
|
|
| 468 |
print(f"[Backfill] Failed to mark stream: {res_mark.status_code} - {res_mark.text}")
|
| 469 |
except Exception as e:
|
| 470 |
print(f"[Backfill] Network error marking stream: {e}")
|
|
|
|
| 471 |
|
| 472 |
# Loop again immediately to process next VOD or check if live
|
| 473 |
continue
|
|
@@ -600,13 +633,28 @@ if __name__ == "__main__":
|
|
| 600 |
# 2. Start Twitch Chat background threads
|
| 601 |
t_chat = threading.Thread(target=twitch_chat_listener, daemon=True)
|
| 602 |
t_sender = threading.Thread(target=chat_sender, daemon=True)
|
|
|
|
| 603 |
|
| 604 |
t_chat.start()
|
| 605 |
t_sender.start()
|
|
|
|
| 606 |
|
| 607 |
-
# Notify backend that stream has started
|
| 608 |
notify_stream_start()
|
| 609 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
# 3. Start Audio Capture (Blocks main thread)
|
| 611 |
try:
|
| 612 |
if CAPTURE_METHOD == "stream":
|
|
@@ -618,7 +666,9 @@ if __name__ == "__main__":
|
|
| 618 |
except KeyboardInterrupt:
|
| 619 |
print("\n[Shutting Down] Gracefully stopping threads...")
|
| 620 |
finally:
|
| 621 |
-
#
|
|
|
|
|
|
|
| 622 |
notify_stream_end()
|
| 623 |
stop_flag.set()
|
| 624 |
time.sleep(1)
|
|
|
|
| 328 |
except Exception as e:
|
| 329 |
print(f"[Sync] Failed to send stream-end: {e}")
|
| 330 |
|
| 331 |
+
# Coverage window tracking
|
| 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"""
|
| 366 |
+
headers = {"x-api-key": API_KEY}
|
| 367 |
+
while not coverage_stop_flag.is_set():
|
| 368 |
+
time.sleep(120)
|
| 369 |
+
if coverage_id:
|
| 370 |
+
try:
|
| 371 |
+
requests.patch(f"{API_URL}/api/log/coverage/{coverage_id}/heartbeat",
|
| 372 |
+
headers=headers, timeout=5)
|
| 373 |
+
except Exception:
|
| 374 |
+
pass
|
| 375 |
+
|
| 376 |
# =========================================================================
|
| 377 |
# CAPTURE METHODS
|
| 378 |
# =========================================================================
|
|
|
|
| 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:
|
| 442 |
+
print(f"[Backfill] No gaps found for stream {stream_id}, marking complete.")
|
| 443 |
+
try:
|
| 444 |
+
requests.post(f"{API_URL}/api/streams/mark-backfilled",
|
| 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:")
|
| 452 |
+
for g in gaps:
|
| 453 |
+
to_str = str(g['to_offset']) + 's' if g['to_offset'] is not None else 'end'
|
| 454 |
+
print(f" Gap: {g['from_offset']}s → {to_str}")
|
| 455 |
+
|
| 456 |
+
# Download and upload each gap in order
|
| 457 |
+
for gap_idx, gap in enumerate(gaps, 1):
|
| 458 |
+
from_off = gap.get('from_offset', 0)
|
| 459 |
+
to_off = gap.get('to_offset', None)
|
| 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
|
| 467 |
headers_post = {"x-api-key": API_KEY, "Content-Type": "application/json"}
|
| 468 |
for i in range(0, len(chat_comments), batch_size):
|
| 469 |
batch = chat_comments[i:i+batch_size]
|
| 470 |
try:
|
| 471 |
+
requests.post(f"{API_URL}/api/log/messages",
|
| 472 |
+
json={"messages": batch},
|
| 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 |
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
|
|
|
|
| 633 |
# 2. Start Twitch Chat background threads
|
| 634 |
t_chat = threading.Thread(target=twitch_chat_listener, daemon=True)
|
| 635 |
t_sender = threading.Thread(target=chat_sender, daemon=True)
|
| 636 |
+
t_heartbeat = threading.Thread(target=coverage_heartbeat_loop, daemon=True)
|
| 637 |
|
| 638 |
t_chat.start()
|
| 639 |
t_sender.start()
|
| 640 |
+
t_heartbeat.start()
|
| 641 |
|
| 642 |
+
# Notify backend that stream has started and get stream id for coverage
|
| 643 |
notify_stream_start()
|
| 644 |
|
| 645 |
+
# Start coverage window for the active stream
|
| 646 |
+
try:
|
| 647 |
+
import requests as _req
|
| 648 |
+
_headers = {"x-api-key": API_KEY}
|
| 649 |
+
_stream_res = _req.get(f"{API_URL}/api/streams/active", headers=_headers, timeout=5)
|
| 650 |
+
if _stream_res.status_code == 200:
|
| 651 |
+
_active = _stream_res.json()
|
| 652 |
+
_sid = _active.get("stream", {}).get("id") or _active.get("id")
|
| 653 |
+
if _sid:
|
| 654 |
+
start_coverage(_sid, source='live')
|
| 655 |
+
except Exception as _e:
|
| 656 |
+
print(f"[Coverage] Could not get active stream for coverage: {_e}")
|
| 657 |
+
|
| 658 |
# 3. Start Audio Capture (Blocks main thread)
|
| 659 |
try:
|
| 660 |
if CAPTURE_METHOD == "stream":
|
|
|
|
| 666 |
except KeyboardInterrupt:
|
| 667 |
print("\n[Shutting Down] Gracefully stopping threads...")
|
| 668 |
finally:
|
| 669 |
+
# End coverage window and notify stream ended
|
| 670 |
+
stop_coverage()
|
| 671 |
+
coverage_stop_flag.set()
|
| 672 |
notify_stream_end()
|
| 673 |
stop_flag.set()
|
| 674 |
time.sleep(1)
|
server/db.js
CHANGED
|
@@ -1573,6 +1573,160 @@ export async function getModeratorProfilesData(streamId = null) {
|
|
| 1573 |
return profiles.sort((a, b) => b.total_actions - a.total_actions);
|
| 1574 |
}
|
| 1575 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1576 |
/**
|
| 1577 |
* Get list of VOD streams waiting for backfill
|
| 1578 |
*/
|
|
@@ -1580,15 +1734,7 @@ export async function getPendingBackfillStreams() {
|
|
| 1580 |
if (dbMode === 'sqlite') {
|
| 1581 |
const streams = sqliteDb.prepare("SELECT * FROM streams WHERE backfill_status = 'pending' AND twitch_vod_id IS NOT NULL ORDER BY start_time ASC").all();
|
| 1582 |
for (const stream of streams) {
|
| 1583 |
-
|
| 1584 |
-
const msgMinRow = sqliteDb.prepare("SELECT min(timestamp) as min_time FROM messages WHERE stream_id = ?").get(stream.id);
|
| 1585 |
-
const voiceMaxRow = sqliteDb.prepare("SELECT max(timestamp) as max_time FROM voice_words WHERE stream_id = ?").get(stream.id);
|
| 1586 |
-
const voiceMinRow = sqliteDb.prepare("SELECT min(timestamp) as min_time FROM voice_words WHERE stream_id = ?").get(stream.id);
|
| 1587 |
-
|
| 1588 |
-
stream.max_message_time = msgMaxRow ? msgMaxRow.max_time : null;
|
| 1589 |
-
stream.min_message_time = msgMinRow ? msgMinRow.min_time : null;
|
| 1590 |
-
stream.max_voice_time = voiceMaxRow ? voiceMaxRow.max_time : null;
|
| 1591 |
-
stream.min_voice_time = voiceMinRow ? voiceMinRow.min_time : null;
|
| 1592 |
}
|
| 1593 |
return streams;
|
| 1594 |
} else {
|
|
@@ -1606,42 +1752,7 @@ export async function getPendingBackfillStreams() {
|
|
| 1606 |
|
| 1607 |
if (streams) {
|
| 1608 |
for (const stream of streams) {
|
| 1609 |
-
|
| 1610 |
-
const { data: msgMaxData } = await supabase
|
| 1611 |
-
.from('messages')
|
| 1612 |
-
.select('timestamp')
|
| 1613 |
-
.eq('stream_id', stream.id)
|
| 1614 |
-
.order('timestamp', { ascending: false })
|
| 1615 |
-
.limit(1);
|
| 1616 |
-
|
| 1617 |
-
// Query min message timestamp in Supabase
|
| 1618 |
-
const { data: msgMinData } = await supabase
|
| 1619 |
-
.from('messages')
|
| 1620 |
-
.select('timestamp')
|
| 1621 |
-
.eq('stream_id', stream.id)
|
| 1622 |
-
.order('timestamp', { ascending: true })
|
| 1623 |
-
.limit(1);
|
| 1624 |
-
|
| 1625 |
-
// Query max voice timestamp in Supabase
|
| 1626 |
-
const { data: voiceMaxData } = await supabase
|
| 1627 |
-
.from('voice_words')
|
| 1628 |
-
.select('timestamp')
|
| 1629 |
-
.eq('stream_id', stream.id)
|
| 1630 |
-
.order('timestamp', { ascending: false })
|
| 1631 |
-
.limit(1);
|
| 1632 |
-
|
| 1633 |
-
// Query min voice timestamp in Supabase
|
| 1634 |
-
const { data: voiceMinData } = await supabase
|
| 1635 |
-
.from('voice_words')
|
| 1636 |
-
.select('timestamp')
|
| 1637 |
-
.eq('stream_id', stream.id)
|
| 1638 |
-
.order('timestamp', { ascending: true })
|
| 1639 |
-
.limit(1);
|
| 1640 |
-
|
| 1641 |
-
stream.max_message_time = msgMaxData && msgMaxData.length > 0 ? msgMaxData[0].timestamp : null;
|
| 1642 |
-
stream.min_message_time = msgMinData && msgMinData.length > 0 ? msgMinData[0].timestamp : null;
|
| 1643 |
-
stream.max_voice_time = voiceMaxData && voiceMaxData.length > 0 ? voiceMaxData[0].timestamp : null;
|
| 1644 |
-
stream.min_voice_time = voiceMinData && voiceMinData.length > 0 ? voiceMinData[0].timestamp : null;
|
| 1645 |
}
|
| 1646 |
}
|
| 1647 |
return streams || [];
|
|
|
|
| 1573 |
return profiles.sort((a, b) => b.total_actions - a.total_actions);
|
| 1574 |
}
|
| 1575 |
|
| 1576 |
+
// ============================================================
|
| 1577 |
+
// COVERAGE WINDOWS — track which time ranges were captured
|
| 1578 |
+
// ============================================================
|
| 1579 |
+
|
| 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);
|
| 1597 |
+
return data?.id || null;
|
| 1598 |
+
}
|
| 1599 |
+
}
|
| 1600 |
+
|
| 1601 |
+
/**
|
| 1602 |
+
* Update coverage heartbeat (worker still running)
|
| 1603 |
+
*/
|
| 1604 |
+
export async function updateCoverageHeartbeat(coverageId) {
|
| 1605 |
+
const now = new Date().toISOString();
|
| 1606 |
+
if (dbMode === 'sqlite') {
|
| 1607 |
+
sqliteDb.prepare('UPDATE stream_coverage SET covered_to = ? WHERE id = ?').run(now, coverageId);
|
| 1608 |
+
} else {
|
| 1609 |
+
await supabase.from('stream_coverage').update({ covered_to: now }).eq('id', coverageId);
|
| 1610 |
+
}
|
| 1611 |
+
}
|
| 1612 |
+
|
| 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 |
+
|
| 1625 |
+
/**
|
| 1626 |
+
* Fetch VOD duration in seconds via Twitch GQL
|
| 1627 |
+
*/
|
| 1628 |
+
async function getVodDurationSeconds(vodId) {
|
| 1629 |
+
try {
|
| 1630 |
+
const res = await fetch('https://gql.twitch.tv/gql', {
|
| 1631 |
+
method: 'POST',
|
| 1632 |
+
headers: {
|
| 1633 |
+
'Client-Id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
| 1634 |
+
'Content-Type': 'application/json'
|
| 1635 |
+
},
|
| 1636 |
+
body: JSON.stringify({
|
| 1637 |
+
query: `query { video(id: "${vodId}") { lengthSeconds } }`
|
| 1638 |
+
})
|
| 1639 |
+
});
|
| 1640 |
+
const data = await res.json();
|
| 1641 |
+
return data?.data?.video?.lengthSeconds || null;
|
| 1642 |
+
} catch (e) {
|
| 1643 |
+
console.error('[GQL] getVodDurationSeconds error:', e.message);
|
| 1644 |
+
return null;
|
| 1645 |
+
}
|
| 1646 |
+
}
|
| 1647 |
+
|
| 1648 |
+
/**
|
| 1649 |
+
* Compute uncovered gaps for a stream given its coverage windows.
|
| 1650 |
+
* Returns array of { from_offset, to_offset } in seconds from stream start.
|
| 1651 |
+
*/
|
| 1652 |
+
export async function getCoverageGaps(streamId, vodId, streamStartTime) {
|
| 1653 |
+
const startEpoch = new Date(streamStartTime).getTime() / 1000;
|
| 1654 |
+
|
| 1655 |
+
// 1. Get VOD duration
|
| 1656 |
+
const durationSeconds = vodId ? await getVodDurationSeconds(vodId) : null;
|
| 1657 |
+
const vodEnd = durationSeconds != null ? startEpoch + durationSeconds : null;
|
| 1658 |
+
|
| 1659 |
+
// 2. Get all coverage windows for this stream
|
| 1660 |
+
let windows = [];
|
| 1661 |
+
if (dbMode === 'sqlite') {
|
| 1662 |
+
windows = sqliteDb.prepare(
|
| 1663 |
+
'SELECT covered_from, covered_to FROM stream_coverage WHERE stream_id = ? AND covered_to IS NOT NULL ORDER BY covered_from ASC'
|
| 1664 |
+
).all(streamId);
|
| 1665 |
+
} else {
|
| 1666 |
+
const { data } = await supabase
|
| 1667 |
+
.from('stream_coverage')
|
| 1668 |
+
.select('covered_from, covered_to')
|
| 1669 |
+
.eq('stream_id', streamId)
|
| 1670 |
+
.not('covered_to', 'is', null)
|
| 1671 |
+
.order('covered_from', { ascending: true });
|
| 1672 |
+
windows = data || [];
|
| 1673 |
+
}
|
| 1674 |
+
|
| 1675 |
+
if (windows.length === 0) {
|
| 1676 |
+
// No coverage at all — backfill entire VOD
|
| 1677 |
+
if (vodEnd) return [{ from_offset: 0, to_offset: durationSeconds }];
|
| 1678 |
+
return [{ from_offset: 0, to_offset: null }];
|
| 1679 |
+
}
|
| 1680 |
+
|
| 1681 |
+
// 3. Convert to [start_offset, end_offset] pairs (seconds from stream start)
|
| 1682 |
+
const intervals = windows.map(w => ({
|
| 1683 |
+
s: Math.max(0, Math.round(new Date(w.covered_from).getTime() / 1000 - startEpoch)),
|
| 1684 |
+
e: Math.round(new Date(w.covered_to).getTime() / 1000 - startEpoch)
|
| 1685 |
+
})).filter(w => w.e > w.s);
|
| 1686 |
+
|
| 1687 |
+
// 4. Sort and merge overlapping intervals
|
| 1688 |
+
intervals.sort((a, b) => a.s - b.s);
|
| 1689 |
+
const merged = [];
|
| 1690 |
+
for (const iv of intervals) {
|
| 1691 |
+
if (merged.length === 0 || iv.s > merged[merged.length - 1].e + 30) {
|
| 1692 |
+
merged.push({ ...iv });
|
| 1693 |
+
} else {
|
| 1694 |
+
merged[merged.length - 1].e = Math.max(merged[merged.length - 1].e, iv.e);
|
| 1695 |
+
}
|
| 1696 |
+
}
|
| 1697 |
+
|
| 1698 |
+
// 5. Find gaps
|
| 1699 |
+
const gaps = [];
|
| 1700 |
+
const THRESHOLD = 60; // ignore gaps < 60 seconds
|
| 1701 |
+
|
| 1702 |
+
// Gap before first coverage window
|
| 1703 |
+
if (merged[0].s > THRESHOLD) {
|
| 1704 |
+
gaps.push({ from_offset: 0, to_offset: merged[0].s });
|
| 1705 |
+
}
|
| 1706 |
+
|
| 1707 |
+
// Gaps between windows
|
| 1708 |
+
for (let i = 0; i < merged.length - 1; i++) {
|
| 1709 |
+
const gapSize = merged[i + 1].s - merged[i].e;
|
| 1710 |
+
if (gapSize > THRESHOLD) {
|
| 1711 |
+
gaps.push({ from_offset: merged[i].e, to_offset: merged[i + 1].s });
|
| 1712 |
+
}
|
| 1713 |
+
}
|
| 1714 |
+
|
| 1715 |
+
// Gap after last coverage window (to end of VOD)
|
| 1716 |
+
const lastEnd = merged[merged.length - 1].e;
|
| 1717 |
+
if (vodEnd) {
|
| 1718 |
+
const remainingSeconds = durationSeconds - lastEnd;
|
| 1719 |
+
if (remainingSeconds > THRESHOLD) {
|
| 1720 |
+
gaps.push({ from_offset: lastEnd, to_offset: durationSeconds });
|
| 1721 |
+
}
|
| 1722 |
+
} else {
|
| 1723 |
+
// Unknown VOD duration — backfill from last known point to end
|
| 1724 |
+
gaps.push({ from_offset: lastEnd, to_offset: null });
|
| 1725 |
+
}
|
| 1726 |
+
|
| 1727 |
+
return gaps;
|
| 1728 |
+
}
|
| 1729 |
+
|
| 1730 |
/**
|
| 1731 |
* Get list of VOD streams waiting for backfill
|
| 1732 |
*/
|
|
|
|
| 1734 |
if (dbMode === 'sqlite') {
|
| 1735 |
const streams = sqliteDb.prepare("SELECT * FROM streams WHERE backfill_status = 'pending' AND twitch_vod_id IS NOT NULL ORDER BY start_time ASC").all();
|
| 1736 |
for (const stream of streams) {
|
| 1737 |
+
stream.gaps = await getCoverageGaps(stream.id, stream.twitch_vod_id, stream.start_time);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1738 |
}
|
| 1739 |
return streams;
|
| 1740 |
} else {
|
|
|
|
| 1752 |
|
| 1753 |
if (streams) {
|
| 1754 |
for (const stream of streams) {
|
| 1755 |
+
stream.gaps = await getCoverageGaps(stream.id, stream.twitch_vod_id, stream.start_time);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1756 |
}
|
| 1757 |
}
|
| 1758 |
return streams || [];
|
server/server.js
CHANGED
|
@@ -34,7 +34,10 @@ import {
|
|
| 34 |
deleteStream,
|
| 35 |
updateStreamMetadata,
|
| 36 |
getSystemStats,
|
| 37 |
-
cleanupGhostStreams
|
|
|
|
|
|
|
|
|
|
| 38 |
} from './db.js';
|
| 39 |
// import { initializeEventSub } from './eventsub.js';
|
| 40 |
import { cache, cacheMiddleware } from './cache.js';
|
|
@@ -277,6 +280,44 @@ app.get('/api/streams/pending-backfill', authenticateWorker, async (req, res) =>
|
|
| 277 |
}
|
| 278 |
});
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
// POST mark stream as backfilled
|
| 281 |
app.post('/api/streams/mark-backfilled', authenticateWorker, async (req, res) => {
|
| 282 |
const { streamId, twitchStreamId } = req.body;
|
|
|
|
| 34 |
deleteStream,
|
| 35 |
updateStreamMetadata,
|
| 36 |
getSystemStats,
|
| 37 |
+
cleanupGhostStreams,
|
| 38 |
+
startCoverageWindow,
|
| 39 |
+
updateCoverageHeartbeat,
|
| 40 |
+
endCoverageWindow
|
| 41 |
} from './db.js';
|
| 42 |
// import { initializeEventSub } from './eventsub.js';
|
| 43 |
import { cache, cacheMiddleware } from './cache.js';
|
|
|
|
| 280 |
}
|
| 281 |
});
|
| 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);
|
| 292 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 293 |
+
}
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
// PATCH heartbeat coverage window
|
| 297 |
+
app.patch('/api/log/coverage/:id/heartbeat', authenticateWorker, async (req, res) => {
|
| 298 |
+
const coverageId = parseInt(req.params.id);
|
| 299 |
+
try {
|
| 300 |
+
await updateCoverageHeartbeat(coverageId);
|
| 301 |
+
res.json({ success: true });
|
| 302 |
+
} catch (err) {
|
| 303 |
+
console.error('[API Error] /api/log/coverage/heartbeat:', err);
|
| 304 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 305 |
+
}
|
| 306 |
+
});
|
| 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);
|
| 317 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 318 |
+
}
|
| 319 |
+
});
|
| 320 |
+
|
| 321 |
// POST mark stream as backfilled
|
| 322 |
app.post('/api/streams/mark-backfilled', authenticateWorker, async (req, res) => {
|
| 323 |
const { streamId, twitchStreamId } = req.body;
|
server/supabase_migration_coverage.sql
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Migration: Add stream_coverage table for smart gap detection
|
| 2 |
+
-- Run this in Supabase SQL editor
|
| 3 |
+
|
| 4 |
+
CREATE TABLE IF NOT EXISTS stream_coverage (
|
| 5 |
+
id BIGSERIAL PRIMARY KEY,
|
| 6 |
+
stream_id INTEGER REFERENCES streams(id) ON DELETE CASCADE,
|
| 7 |
+
covered_from TIMESTAMPTZ NOT NULL,
|
| 8 |
+
covered_to TIMESTAMPTZ,
|
| 9 |
+
source TEXT NOT NULL DEFAULT 'live', -- 'live' | 'backfill' | 'vod_backfiller'
|
| 10 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 11 |
+
);
|
| 12 |
+
|
| 13 |
+
CREATE INDEX IF NOT EXISTS idx_stream_coverage_stream_id ON stream_coverage(stream_id);
|
| 14 |
+
CREATE INDEX IF NOT EXISTS idx_stream_coverage_covered_from ON stream_coverage(covered_from);
|