Spaces:
Sleeping
Sleeping
Sasha commited on
Commit ·
a721446
1
Parent(s): 977232b
feat: automatic VOD ID resolution for stream sessions and historical pending streams
Browse files- local_worker/worker.py +145 -6
- server/db.js +42 -0
- 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
|
| 323 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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;
|