feat: add YouTube live poll support

Add backend support for YouTube live polls including:
- Poll event handling and state tracking
- New `/poll/current` endpoint for current poll data
- Server-sent events stream at `/poll/stream` for real-time updates
- Poll emitter for broadcasting poll changes to clients
This commit is contained in:
Yusuf İpek
2025-10-12 23:04:57 +03:00
parent 81d4df5ff7
commit 4a4b2407b6
8 changed files with 165 additions and 22 deletions
+51 -3
View File
@@ -1,7 +1,7 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import EventEmitter from 'eventemitter3';
import type { ChatMessage } from '@shared/chat';
import type { ChatMessage, Poll } from '@shared/chat';
import { bootstrapInnertube, type IngestionContext } from './ingestion/youtubei';
import crypto from 'crypto';
@@ -30,7 +30,9 @@ export async function startBackend() {
const store: ChatMessage[] = [];
let currentSelection: ChatMessage | null = null;
let currentPoll: Poll | null = null;
const overlayEmitter = new EventEmitter<{ update: (message: ChatMessage | null) => void }>();
const pollEmitter = new EventEmitter<{ update: (poll: Poll | null) => void }>();
const rawLiveId = process.env.YOUTUBE_LIVE_ID ?? '';
const parsedLiveId = extractLiveId(rawLiveId);
@@ -50,6 +52,10 @@ export async function startBackend() {
// This ensures we don't wait until hitting MAX_MESSAGES
trimMessages(store);
});
ingestion.emitter.on('poll', (poll) => {
currentPoll = poll;
pollEmitter.emit('update', poll);
});
ingestion.emitter.on('error', (error) => {
console.error('[Backend] Innertube ingestion error:', error);
});
@@ -110,14 +116,19 @@ export async function startBackend() {
console.log(`[Backend] Connecting to YouTube Live ID: ${parsedLiveId}`);
ingestion = await bootstrapInnertube(parsedLiveId);
console.log(`[Backend] ✓ YouTube chat connected successfully`);
ingestion.emitter.on('message', (message) => {
store.push(message);
// Trim regularly to keep regular messages under control
// This ensures we don't wait until hitting MAX_MESSAGES
trimMessages(store);
});
ingestion.emitter.on('poll', (poll) => {
currentPoll = poll;
pollEmitter.emit('update', poll);
});
ingestion.emitter.on('error', (error) => {
console.error('[Backend] Innertube ingestion error:', error);
});
@@ -157,6 +168,10 @@ export async function startBackend() {
messages: store
}));
fastify.get('/poll/current', async () => ({
poll: currentPoll
}));
fastify.post<{ Body: { id?: string } }>('/overlay/selection', async (request, reply) => {
const { id } = request.body ?? {};
if (!id) {
@@ -182,6 +197,39 @@ export async function startBackend() {
return { ok: true };
});
fastify.get('/poll/stream', async (request, reply) => {
reply.hijack();
const res = reply.raw;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.writeHead(200);
res.write(': connected\n\n');
const send = (poll: Poll | null) => {
res.write(`event: poll\ndata: ${JSON.stringify({ poll })}\n\n`);
};
const heartbeat = setInterval(() => {
res.write('event: heartbeat\ndata: {}\n\n');
}, 15000);
pollEmitter.on('update', send);
if (currentPoll) {
send(currentPoll);
}
request.raw.on('close', () => {
clearInterval(heartbeat);
pollEmitter.off('update', send);
});
});
fastify.get('/overlay/stream', async (request, reply) => {
reply.hijack();
+22 -8
View File
@@ -1,4 +1,4 @@
import type { ChatMessage, Badge, SuperChatInfo } from '@shared/chat';
import type { ChatMessage, Badge, SuperChatInfo, Poll } from '@shared/chat';
import EventEmitter from 'eventemitter3';
import Innertube, { UniversalCache } from 'youtubei.js';
@@ -17,6 +17,7 @@ export type ContinuationState = {
export type ChatEventEmitter = EventEmitter<{
message: (message: ChatMessage) => void;
poll: (poll: Poll) => void;
error: (error: unknown) => void;
}>;
@@ -64,11 +65,21 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
const emitter: ChatEventEmitter = new EventEmitter();
liveChat.on('chat-update', (action: any) => {
// Log the complete raw action data from YouTube (commented out for production)
//console.log('=== YOUTUBE RAW MESSAGE DATA ===');
//console.log('Action type:', action?.type);
//console.log('Complete action object:', JSON.stringify(action, null, 2));
// Handle poll updates (just detect active/closed state)
if (action?.type === 'UpdateLiveChatPollAction') {
const pollId = action?.poll_to_update?.live_chat_poll_id;
if (pollId) {
emitter.emit('poll', { id: String(pollId), active: true });
}
return;
}
// Handle poll closing
if (action?.type === 'CloseLiveChatActionPanelAction' || action?.type === 'RemoveBannerForLiveChatCommand') {
emitter.emit('poll', null);
return;
}
const normalized = normalizeAction(action);
if (normalized) {
emitter.emit('message', normalized);
@@ -77,8 +88,11 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
liveChat.on('error', (err: unknown) => {
const msg = (err as any)?.message || String(err);
if (msg && msg.includes('LiveChatReportModerationStateCommand not found')) {
console.warn('[Ingestion] Non-fatal parser drift (ignored):', msg);
// Ignore known non-fatal parser drift issues that YouTube.js auto-generates
if (msg && (
msg.includes('LiveChatReportModerationStateCommand not found') ||
msg.includes('CloseLiveChatActionPanelAction not found')
)) {
return; // ignore noisy parser drift that YouTube.js JITs around
}
console.error('[Ingestion] Live chat error:', err);