Sasha commited on
Commit
a721446
·
1 Parent(s): 977232b

feat: automatic VOD ID resolution for stream sessions and historical pending streams

Browse files
Files changed (3) hide show
  1. local_worker/worker.py +145 -6
  2. server/db.js +42 -0
  3. server/server.js +28 -1
local_worker/worker.py CHANGED
@@ -319,13 +319,130 @@ def notify_stream_start():
319
  except Exception as e:
320
  print(f"[Sync] Error sending stream-start: {e}")
321
 
322
- def notify_stream_end():
323
- """Notify backend that the stream has ended"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  headers = {"x-api-key": API_KEY}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  url = f"{API_URL}/api/log/stream-end"
 
 
 
326
  try:
327
- requests.post(url, headers=headers, timeout=5)
328
- print("[Sync] Sent stream-end signal.")
329
  except Exception as e:
330
  print(f"[Sync] Failed to send stream-end: {e}")
331
 
@@ -415,6 +532,8 @@ def run_stream_capture(model):
415
 
416
  while not stop_flag.is_set():
417
  if not check_stream_live():
 
 
418
  print(f"[Capture] Channel '{TWITCH_CHANNEL}' is offline. Checking for pending VOD backfill tasks...")
419
  try:
420
  headers_get = {"x-api-key": API_KEY}
@@ -620,7 +739,16 @@ def run_stream_capture(model):
620
  try: p.kill()
621
  except: pass
622
 
623
- notify_stream_end()
 
 
 
 
 
 
 
 
 
624
  print("[Capture] Pipeline stopped. Waiting 30 seconds before checking stream status...")
625
  time.sleep(30)
626
 
@@ -733,7 +861,18 @@ if __name__ == "__main__":
733
  # End coverage window and notify stream ended
734
  stop_coverage()
735
  coverage_stop_flag.set()
736
- notify_stream_end()
 
 
 
 
 
 
 
 
 
 
 
737
  stop_flag.set()
738
  time.sleep(1)
739
  print("[Shutting Down] Done.")
 
319
  except Exception as e:
320
  print(f"[Sync] Error sending stream-start: {e}")
321
 
322
+ def get_recent_twitch_vods(channel_name):
323
+ """Fetch recent Twitch VODs of a user using public Twitch GQL API"""
324
+ url = "https://gql.twitch.tv/gql"
325
+ headers = {
326
+ "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
327
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
328
+ }
329
+ payload = {
330
+ "query": """
331
+ query($login: String!) {
332
+ user(login: $login) {
333
+ videos(first: 10, type: ARCHIVE) {
334
+ edges {
335
+ node {
336
+ id
337
+ title
338
+ createdAt
339
+ lengthSeconds
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }
345
+ """,
346
+ "variables": {
347
+ "login": channel_name
348
+ }
349
+ }
350
+ try:
351
+ res = requests.post(url, json=payload, headers=headers, timeout=10)
352
+ if res.status_code == 200:
353
+ data = res.json()
354
+ edges = data.get("data", {}).get("user", {}).get("videos", {}).get("edges", [])
355
+ return [edge.get("node") for edge in edges if edge and edge.get("node")]
356
+ except Exception as e:
357
+ print(f"[VOD Resolve] Error fetching Twitch VODs: {e}")
358
+ return []
359
+
360
+ def find_matching_vod(stream_start_time_iso, recent_vods):
361
+ try:
362
+ from datetime import datetime
363
+ clean_stream = stream_start_time_iso.replace('Z', '+00:00')
364
+ stream_dt = datetime.fromisoformat(clean_stream)
365
+ except Exception as e:
366
+ print(f"[Match] Error parsing stream start time: {e}")
367
+ return None
368
+
369
+ best_match = None
370
+ min_diff = float('inf')
371
+
372
+ for node in recent_vods:
373
+ vod_id = node.get("id")
374
+ created_at_raw = node.get("createdAt")
375
+ if not vod_id or not created_at_raw:
376
+ continue
377
+ try:
378
+ clean_vod = created_at_raw.replace('Z', '+00:00')
379
+ vod_dt = datetime.fromisoformat(clean_vod)
380
+ except Exception:
381
+ continue
382
+
383
+ diff = abs((stream_dt - vod_dt).total_seconds())
384
+ # If the start times are within 2.5 hours (9000 seconds)
385
+ if diff < 9000 and diff < min_diff:
386
+ min_diff = diff
387
+ best_match = vod_id
388
+
389
+ return best_match
390
+
391
+ def resolve_missing_vods():
392
+ """Find and resolve twitch_vod_id for pending streams that are missing it"""
393
  headers = {"x-api-key": API_KEY}
394
+ try:
395
+ # 1. Fetch missing VOD streams from backend
396
+ res = requests.get(f"{API_URL}/api/streams/missing-vod", headers=headers, timeout=10)
397
+ if res.status_code != 200:
398
+ return
399
+
400
+ streams = res.json().get("streams", [])
401
+ if not streams:
402
+ return
403
+
404
+ print(f"[VOD Resolve] Found {len(streams)} pending stream(s) lacking VOD ID.")
405
+
406
+ # 2. Get recent VODs from Twitch
407
+ recent_vods = get_recent_twitch_vods(TWITCH_CHANNEL)
408
+ if not recent_vods:
409
+ print("[VOD Resolve] Could not retrieve recent Twitch VODs. Skipping resolve.")
410
+ return
411
+
412
+ # 3. Match each stream to a Twitch VOD
413
+ for stream in streams:
414
+ stream_id = stream.get("id")
415
+ start_time_str = stream.get("start_time")
416
+ if not stream_id or not start_time_str:
417
+ continue
418
+
419
+ matched_vod_id = find_matching_vod(start_time_str, recent_vods)
420
+ if matched_vod_id:
421
+ print(f"[VOD Resolve] Stream ID {stream_id} ({start_time_str}) matched with Twitch VOD {matched_vod_id}.")
422
+ # Send update to server
423
+ up_res = requests.post(f"{API_URL}/api/streams/{stream_id}/resolve-vod",
424
+ json={"twitchVodId": matched_vod_id},
425
+ headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
426
+ timeout=10)
427
+ if up_res.status_code == 200:
428
+ print(f"[VOD Resolve] Successfully updated VOD ID for stream {stream_id}!")
429
+ else:
430
+ print(f"[VOD Resolve] Failed to update VOD ID: {up_res.status_code}")
431
+ else:
432
+ print(f"[VOD Resolve] No matching VOD found for stream ID {stream_id} ({start_time_str}) within timeframe.")
433
+ except Exception as e:
434
+ print(f"[VOD Resolve] Exception during resolve loop: {e}")
435
+
436
+ def notify_stream_end(vod_id=None):
437
+ """Notify backend that the stream has ended"""
438
+ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
439
  url = f"{API_URL}/api/log/stream-end"
440
+ payload = {}
441
+ if vod_id:
442
+ payload["twitchVodId"] = vod_id
443
  try:
444
+ requests.post(url, json=payload, headers=headers, timeout=5)
445
+ print(f"[Sync] Sent stream-end signal. VOD ID resolved: {vod_id}")
446
  except Exception as e:
447
  print(f"[Sync] Failed to send stream-end: {e}")
448
 
 
532
 
533
  while not stop_flag.is_set():
534
  if not check_stream_live():
535
+ # Try to resolve any missing VOD IDs before checking pending backfills
536
+ resolve_missing_vods()
537
  print(f"[Capture] Channel '{TWITCH_CHANNEL}' is offline. Checking for pending VOD backfill tasks...")
538
  try:
539
  headers_get = {"x-api-key": API_KEY}
 
739
  try: p.kill()
740
  except: pass
741
 
742
+ # Try to get the latest VOD ID from Twitch GQL to auto-resolve it
743
+ latest_vod_id = None
744
+ try:
745
+ vods = get_recent_twitch_vods(TWITCH_CHANNEL)
746
+ if vods:
747
+ latest_vod_id = vods[0].get("id")
748
+ except Exception as e:
749
+ print(f"[Capture] Error pre-fetching latest VOD ID: {e}")
750
+
751
+ notify_stream_end(latest_vod_id)
752
  print("[Capture] Pipeline stopped. Waiting 30 seconds before checking stream status...")
753
  time.sleep(30)
754
 
 
861
  # End coverage window and notify stream ended
862
  stop_coverage()
863
  coverage_stop_flag.set()
864
+
865
+ # Try to get the latest VOD ID from Twitch GQL to auto-resolve it
866
+ latest_vod_id = None
867
+ if CAPTURE_METHOD == "stream":
868
+ try:
869
+ vods = get_recent_twitch_vods(TWITCH_CHANNEL)
870
+ if vods:
871
+ latest_vod_id = vods[0].get("id")
872
+ except Exception as e:
873
+ print(f"[Capture] Error pre-fetching latest VOD ID: {e}")
874
+
875
+ notify_stream_end(latest_vod_id)
876
  stop_flag.set()
877
  time.sleep(1)
878
  print("[Shutting Down] Done.")
server/db.js CHANGED
@@ -1759,6 +1759,48 @@ export async function getPendingBackfillStreams() {
1759
  }
1760
  }
1761
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1762
  /**
1763
  * Mark a stream as backfilled and completed
1764
  */
 
1759
  }
1760
  }
1761
 
1762
+ /**
1763
+ * Get list of pending streams that are missing their twitch_vod_id
1764
+ */
1765
+ export async function getStreamsMissingVod() {
1766
+ if (dbMode === 'sqlite') {
1767
+ return sqliteDb.prepare("SELECT * FROM streams WHERE backfill_status = 'pending' AND twitch_vod_id IS NULL ORDER BY start_time ASC").all();
1768
+ } else {
1769
+ const { data: streams, error } = await supabase
1770
+ .from('streams')
1771
+ .select('*')
1772
+ .eq('backfill_status', 'pending')
1773
+ .is('twitch_vod_id', null)
1774
+ .order('start_time', { ascending: true });
1775
+
1776
+ if (error) {
1777
+ console.error('[Supabase] Error getting streams missing VOD:', error);
1778
+ return [];
1779
+ }
1780
+ return streams || [];
1781
+ }
1782
+ }
1783
+
1784
+ /**
1785
+ * Update the twitch_vod_id of a stream
1786
+ */
1787
+ export async function updateStreamVodId(streamId, twitchVodId) {
1788
+ if (dbMode === 'sqlite') {
1789
+ sqliteDb.prepare("UPDATE streams SET twitch_vod_id = ? WHERE id = ?").run(twitchVodId, streamId);
1790
+ console.log(`[Database] SQLite Stream ID ${streamId} updated twitch_vod_id to ${twitchVodId}`);
1791
+ } else {
1792
+ const { error } = await supabase
1793
+ .from('streams')
1794
+ .update({ twitch_vod_id: twitchVodId })
1795
+ .eq('id', streamId);
1796
+ if (error) {
1797
+ console.error('[Supabase] Error updating twitch_vod_id:', error);
1798
+ } else {
1799
+ console.log(`[Database] Supabase Stream ID ${streamId} updated twitch_vod_id to ${twitchVodId}`);
1800
+ }
1801
+ }
1802
+ }
1803
+
1804
  /**
1805
  * Mark a stream as backfilled and completed
1806
  */
server/server.js CHANGED
@@ -28,6 +28,8 @@ import {
28
  getModeratorProfilesData,
29
  getActiveStream,
30
  getPendingBackfillStreams,
 
 
31
  markStreamBackfilled,
32
  checkIfStreamExists,
33
  resetStreamBackfill,
@@ -261,7 +263,8 @@ app.post('/api/log/stream-start', authenticateWorker, async (req, res) => {
261
  // Explicit end stream command
262
  app.post('/api/log/stream-end', authenticateWorker, async (req, res) => {
263
  try {
264
- await endActiveStream();
 
265
  res.json({ success: true });
266
  } catch (err) {
267
  console.error('[API Error] /api/log/stream-end:', err);
@@ -280,6 +283,30 @@ app.get('/api/streams/pending-backfill', authenticateWorker, async (req, res) =>
280
  }
281
  });
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;
 
28
  getModeratorProfilesData,
29
  getActiveStream,
30
  getPendingBackfillStreams,
31
+ getStreamsMissingVod,
32
+ updateStreamVodId,
33
  markStreamBackfilled,
34
  checkIfStreamExists,
35
  resetStreamBackfill,
 
263
  // Explicit end stream command
264
  app.post('/api/log/stream-end', authenticateWorker, async (req, res) => {
265
  try {
266
+ const { twitchVodId } = req.body || {};
267
+ await endActiveStream(null, null, twitchVodId);
268
  res.json({ success: true });
269
  } catch (err) {
270
  console.error('[API Error] /api/log/stream-end:', err);
 
283
  }
284
  });
285
 
286
+ // GET list of streams missing twitch_vod_id
287
+ app.get('/api/streams/missing-vod', authenticateWorker, async (req, res) => {
288
+ try {
289
+ const streams = await getStreamsMissingVod();
290
+ res.json({ success: true, streams });
291
+ } catch (err) {
292
+ console.error('[API Error] /api/streams/missing-vod:', err);
293
+ res.status(500).json({ error: 'Internal Server Error' });
294
+ }
295
+ });
296
+
297
+ // POST to update twitch_vod_id for a stream
298
+ app.post('/api/streams/:id/resolve-vod', authenticateWorker, async (req, res) => {
299
+ const { id } = req.params;
300
+ const { twitchVodId } = req.body;
301
+ try {
302
+ await updateStreamVodId(id, twitchVodId);
303
+ res.json({ success: true });
304
+ } catch (err) {
305
+ console.error('[API Error] /api/streams/resolve-vod:', err);
306
+ res.status(500).json({ error: 'Internal Server Error' });
307
+ }
308
+ });
309
+
310
  // POST start coverage window
311
  app.post('/api/log/coverage/start', authenticateWorker, async (req, res) => {
312
  const { streamId, source, coveredFrom, coveredTo } = req.body;