mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 10:56:17 +00:00
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:
+51
-3
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user