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 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
- # -- CHAT BACKFILL TASKS --
393
- chat_tasks = []
394
- if not min_msg_time:
395
- # 0 messages, download everything
396
- chat_tasks.append((0, None))
397
- else:
398
- min_msg_epoch = parse_time(min_msg_time)
399
- max_msg_epoch = parse_time(max_msg_time)
400
-
401
- min_msg_offset = int(min_msg_epoch - start_epoch) if (min_msg_epoch and start_epoch) else 0
402
- max_msg_offset = int(max_msg_epoch - start_epoch) if (max_msg_epoch and start_epoch) else 0
403
-
404
- # If first message is > 60s from start, download the beginning gap
405
- if min_msg_offset > 60:
406
- chat_tasks.append((0, min_msg_offset))
407
- # Download from last message to end
408
- chat_tasks.append((max_msg_offset, None))
409
-
410
- # -- VOICE BACKFILL TASKS --
411
- voice_tasks = []
412
- if not min_voice_time:
413
- # 0 words, transcribe everything
414
- voice_tasks.append((0, None))
415
- else:
416
- min_voice_epoch = parse_time(min_voice_time)
417
- max_voice_epoch = parse_time(max_voice_time)
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)} 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", json={"messages": batch}, headers=headers_post, timeout=10)
 
 
448
  except Exception as e:
449
  print(f"[Backfill] Chat batch upload error: {e}")
450
-
451
- # 2. Download and transcribe audio segments
452
- print(f"[Backfill] Starting Whisper audio transcription...")
453
- for start_off, end_off in voice_tasks:
454
- print(f"[Backfill] Transcribing audio from {start_off}s to {'end' if end_off is None else str(end_off) + 's'}...")
 
 
 
 
 
455
  try:
456
  start_time_iso = start_time.replace("+00:00", "").replace("Z", "") + "Z"
457
- transcribe_vod_audio(vod_id, start_time_iso, model, start_offset=start_off, end_offset=end_off)
 
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
- # Notify backend that stream has ended
 
 
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
- const msgMaxRow = sqliteDb.prepare("SELECT max(timestamp) as max_time FROM messages WHERE stream_id = ?").get(stream.id);
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
- // Query max message timestamp in Supabase
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);