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 Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import EventEmitter from 'eventemitter3'; 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 { bootstrapInnertube, type IngestionContext } from './ingestion/youtubei';
import crypto from 'crypto'; import crypto from 'crypto';
@@ -30,7 +30,9 @@ export async function startBackend() {
const store: ChatMessage[] = []; const store: ChatMessage[] = [];
let currentSelection: ChatMessage | null = null; let currentSelection: ChatMessage | null = null;
let currentPoll: Poll | null = null;
const overlayEmitter = new EventEmitter<{ update: (message: ChatMessage | null) => void }>(); 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 rawLiveId = process.env.YOUTUBE_LIVE_ID ?? '';
const parsedLiveId = extractLiveId(rawLiveId); const parsedLiveId = extractLiveId(rawLiveId);
@@ -50,6 +52,10 @@ export async function startBackend() {
// This ensures we don't wait until hitting MAX_MESSAGES // This ensures we don't wait until hitting MAX_MESSAGES
trimMessages(store); trimMessages(store);
}); });
ingestion.emitter.on('poll', (poll) => {
currentPoll = poll;
pollEmitter.emit('update', poll);
});
ingestion.emitter.on('error', (error) => { ingestion.emitter.on('error', (error) => {
console.error('[Backend] Innertube ingestion 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}`); console.log(`[Backend] Connecting to YouTube Live ID: ${parsedLiveId}`);
ingestion = await bootstrapInnertube(parsedLiveId); ingestion = await bootstrapInnertube(parsedLiveId);
console.log(`[Backend] ✓ YouTube chat connected successfully`); console.log(`[Backend] ✓ YouTube chat connected successfully`);
ingestion.emitter.on('message', (message) => { ingestion.emitter.on('message', (message) => {
store.push(message); store.push(message);
// Trim regularly to keep regular messages under control // Trim regularly to keep regular messages under control
// This ensures we don't wait until hitting MAX_MESSAGES // This ensures we don't wait until hitting MAX_MESSAGES
trimMessages(store); trimMessages(store);
}); });
ingestion.emitter.on('poll', (poll) => {
currentPoll = poll;
pollEmitter.emit('update', poll);
});
ingestion.emitter.on('error', (error) => { ingestion.emitter.on('error', (error) => {
console.error('[Backend] Innertube ingestion error:', error); console.error('[Backend] Innertube ingestion error:', error);
}); });
@@ -157,6 +168,10 @@ export async function startBackend() {
messages: store messages: store
})); }));
fastify.get('/poll/current', async () => ({
poll: currentPoll
}));
fastify.post<{ Body: { id?: string } }>('/overlay/selection', async (request, reply) => { fastify.post<{ Body: { id?: string } }>('/overlay/selection', async (request, reply) => {
const { id } = request.body ?? {}; const { id } = request.body ?? {};
if (!id) { if (!id) {
@@ -182,6 +197,39 @@ export async function startBackend() {
return { ok: true }; 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) => { fastify.get('/overlay/stream', async (request, reply) => {
reply.hijack(); 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 EventEmitter from 'eventemitter3';
import Innertube, { UniversalCache } from 'youtubei.js'; import Innertube, { UniversalCache } from 'youtubei.js';
@@ -17,6 +17,7 @@ export type ContinuationState = {
export type ChatEventEmitter = EventEmitter<{ export type ChatEventEmitter = EventEmitter<{
message: (message: ChatMessage) => void; message: (message: ChatMessage) => void;
poll: (poll: Poll) => void;
error: (error: unknown) => void; error: (error: unknown) => void;
}>; }>;
@@ -64,11 +65,21 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
const emitter: ChatEventEmitter = new EventEmitter(); const emitter: ChatEventEmitter = new EventEmitter();
liveChat.on('chat-update', (action: any) => { liveChat.on('chat-update', (action: any) => {
// Log the complete raw action data from YouTube (commented out for production) // Handle poll updates (just detect active/closed state)
//console.log('=== YOUTUBE RAW MESSAGE DATA ==='); if (action?.type === 'UpdateLiveChatPollAction') {
//console.log('Action type:', action?.type); const pollId = action?.poll_to_update?.live_chat_poll_id;
//console.log('Complete action object:', JSON.stringify(action, null, 2)); 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); const normalized = normalizeAction(action);
if (normalized) { if (normalized) {
emitter.emit('message', normalized); emitter.emit('message', normalized);
@@ -77,8 +88,11 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
liveChat.on('error', (err: unknown) => { liveChat.on('error', (err: unknown) => {
const msg = (err as any)?.message || String(err); const msg = (err as any)?.message || String(err);
if (msg && msg.includes('LiveChatReportModerationStateCommand not found')) { // Ignore known non-fatal parser drift issues that YouTube.js auto-generates
console.warn('[Ingestion] Non-fatal parser drift (ignored):', msg); if (msg && (
msg.includes('LiveChatReportModerationStateCommand not found') ||
msg.includes('CloseLiveChatActionPanelAction not found')
)) {
return; // ignore noisy parser drift that YouTube.js JITs around return; // ignore noisy parser drift that YouTube.js JITs around
} }
console.error('[Ingestion] Live chat error:', err); console.error('[Ingestion] Live chat error:', err);
+41 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import type { ChatMessage } from '@shared/chat'; import type { ChatMessage, Poll } from '@shared/chat';
import { useTimezone } from '../../lib/TimezoneContext'; import { useTimezone } from '../../lib/TimezoneContext';
import { formatTimestamp } from '../../lib/timezone'; import { formatTimestamp } from '../../lib/timezone';
import { proxyImageUrl } from '../../lib/imageProxy'; import { proxyImageUrl } from '../../lib/imageProxy';
@@ -46,6 +46,7 @@ let globalConnectionListeners: Set<(payload: any) => void> = new Set();
export default function DashboardPage() { export default function DashboardPage() {
const { messages, refresh, error: pollError } = useChatMessages(); const { messages, refresh, error: pollError } = useChatMessages();
const { selection, status: overlayStatus } = useOverlaySelection(); const { selection, status: overlayStatus } = useOverlaySelection();
const { poll: currentPoll } = usePoll();
const { connected, liveId, connect, disconnect, connecting } = useConnection(); const { connected, liveId, connect, disconnect, connecting } = useConnection();
const [confirmUrl, setConfirmUrl] = useState<string | null>(null); const [confirmUrl, setConfirmUrl] = useState<string | null>(null);
const [superChatPulse, setSuperChatPulse] = useState(false); const [superChatPulse, setSuperChatPulse] = useState(false);
@@ -198,6 +199,12 @@ export default function DashboardPage() {
{connected && ( {connected && (
<> <>
<header className="dashboard__tabs"> <header className="dashboard__tabs">
{currentPoll && (
<div className="poll-indicator" title="Active poll on YouTube">
<span className="poll-indicator__icon">📊</span>
<span className="poll-indicator__text">Active Poll</span>
</div>
)}
<div className="tab"> <div className="tab">
Messages <span className="tab__count">{regularMessages.length}</span> Messages <span className="tab__count">{regularMessages.length}</span>
</div> </div>
@@ -313,6 +320,7 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
)} )}
</main> </main>
); );
} }
@@ -455,6 +463,38 @@ function useConnection() {
return { connected, liveId, connect, disconnect, connecting }; return { connected, liveId, connect, disconnect, connecting };
} }
function usePoll() {
const [poll, setPoll] = useState<Poll | null>(null);
useEffect(() => {
// Create SSE connection for polls
const eventSource = new EventSource(`${BACKEND_URL}/poll/stream`);
eventSource.addEventListener('poll', ((event: MessageEvent) => {
try {
const payload = JSON.parse(event.data);
setPoll(payload.poll);
} catch (error) {
console.error('Failed to parse poll payload', error);
}
}) as EventListener);
eventSource.addEventListener('heartbeat', () => {
// Heartbeat - connection is alive
});
eventSource.onerror = (error) => {
console.error('[Dashboard] Poll SSE connection error', error);
};
return () => {
eventSource.close();
};
}, []);
return { poll };
}
type ConnectionControlProps = { type ConnectionControlProps = {
connected: boolean; connected: boolean;
liveId: string | null; liveId: string | null;
+33
View File
@@ -1357,3 +1357,36 @@ main {
background: rgba(248, 113, 113, 0.2); background: rgba(248, 113, 113, 0.2);
color: #f87171; color: #f87171;
} }
/* Poll indicator */
.poll-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.875rem;
background: rgba(139, 92, 246, 0.15);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
color: #c4b5fd;
animation: pollPulse 2s ease-in-out infinite;
margin-right: 0.5rem;
}
.poll-indicator__icon {
font-size: 1rem;
}
.poll-indicator__text {
white-space: nowrap;
}
@keyframes pollPulse {
0%, 100% {
box-shadow: 0 0 0 rgba(139, 92, 246, 0);
}
50% {
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
}
+6 -6
View File
@@ -31,14 +31,14 @@
- **Smart Message Preservation**: Implemented intelligent message trimming - regular chat messages limited to 200, but superchats and memberships preserved for entire session. Trimming happens on every message to prevent loss of special messages. - **Smart Message Preservation**: Implemented intelligent message trimming - regular chat messages limited to 200, but superchats and memberships preserved for entire session. Trimming happens on every message to prevent loss of special messages.
- **Super Sticker Support**: Added full support for super sticker image display. Backend parser extracts sticker URL and accessibility label from `item.sticker` array in YouTube data. Added `stickerUrl` and `stickerAlt` fields to `SuperChatInfo` type. Image proxy whitelist updated to include `lh3.googleusercontent.com` domain. Dashboard and overlay both render super stickers with 144x144px max dimensions, centered layout, and graceful error handling that hides broken images. Protocol-relative URLs (`//domain.com`) are automatically converted to HTTPS. - **Super Sticker Support**: Added full support for super sticker image display. Backend parser extracts sticker URL and accessibility label from `item.sticker` array in YouTube data. Added `stickerUrl` and `stickerAlt` fields to `SuperChatInfo` type. Image proxy whitelist updated to include `lh3.googleusercontent.com` domain. Dashboard and overlay both render super stickers with 144x144px max dimensions, centered layout, and graceful error handling that hides broken images. Protocol-relative URLs (`//domain.com`) are automatically converted to HTTPS.
- **Leaderboard Badge Support**: Implemented YouTube leaderboard rank display (Top Chatter feature). Backend parser extracts rank from `before_content_buttons` array with CROWN icon, parsing rank number from title field (e.g., "#3"). Added `leaderboardRank` field to `ChatMessage` type. Dashboard and overlay render leaderboard badge with crown emoji (👑) and rank number, styled with golden background (rgba(251, 191, 36, 0.2)) and yellow text (#fcd34d) for prominence. - **Leaderboard Badge Support**: Implemented YouTube leaderboard rank display (Top Chatter feature). Backend parser extracts rank from `before_content_buttons` array with CROWN icon, parsing rank number from title field (e.g., "#3"). Added `leaderboardRank` field to `ChatMessage` type. Dashboard and overlay render leaderboard badge with crown emoji (👑) and rank number, styled with golden background (rgba(251, 191, 36, 0.2)) and yellow text (#fcd34d) for prominence.
- **Task Master AI Integration**: Initialized Task Master AI with OpenRouter's x-ai/grok-code-fast-1 (main), google/gemini-2.5-pro (research), and google/gemini-2.5-flash (fallback) models. Successfully completed Task 2 (Super Sticker Display) and Task 3 (Leaderboard Badge) with all subtasks. - **Task Master AI Integration**: Initialized Task Master AI with OpenRouter's x-ai/grok-code-fast-1 (main), google/gemini-2.5-pro (research), and google/gemini-2.5-flash (fallback) models. Successfully completed Task 2 (Super Sticker Display), Task 3 (Leaderboard Badge), and Task 4 (Live Poll Indicator).
- **Live Poll Indicator**: Added simple poll detection that shows a pulsing "📊 Active Poll" indicator in the dashboard header when a YouTube poll is active. Backend listens for `UpdateLiveChatPollAction` and `CloseLiveChatActionPanelAction`/`RemoveBannerForLiveChatCommand` events. Indicator automatically appears/disappears based on poll state. Note: YouTube's API does not provide live vote percentages, so the feature only indicates poll presence without showing results.
## Immediate Next Steps ## Immediate Next Steps
1. Add live poll display functionality (Task 4) 1. Implement user authentication via YouTube OAuth 2.0 (Task 5)
2. Implement user authentication via YouTube OAuth 2.0 (Task 5) 2. Add persistent image cache with SQLite (Task 6)
3. Add persistent image cache with SQLite (Task 6) 3. Develop overlay theme controls (Task 7)
4. Consider persistent cache for images (SQLite or file-based) for better reliability 4. Add error recovery and reconnection logic for stream interruptions
5. Add error recovery and reconnection logic for stream interruptions
## Open Questions ## Open Questions
- Whether to add message search/filtering UI controls - Whether to add message search/filtering UI controls
+6 -4
View File
@@ -23,8 +23,7 @@
- [x] Visual selection state management (active, normal, previously-selected). - [x] Visual selection state management (active, normal, previously-selected).
- [x] Fix super sticker image not showing. - [x] Fix super sticker image not showing.
- [x] Add chat leaderboard badge. - [x] Add chat leaderboard badge.
- [ ] Show open polls. - [x] Show active poll indicator.
- [ ] Handle error states (rate limits, disconnects) gracefully in UI.
## Phase 3 OBS Overlay Experience ## Phase 3 OBS Overlay Experience
- [x] Create overlay page that consumes SSE stream at `/overlay`. - [x] Create overlay page that consumes SSE stream at `/overlay`.
@@ -34,7 +33,6 @@
- [x] Optimize overlay spacing for efficient use (30-40% more compact). - [x] Optimize overlay spacing for efficient use (30-40% more compact).
- [x] Remove timestamps from overlay for cleaner display. - [x] Remove timestamps from overlay for cleaner display.
- [x] Add smooth fade transitions when switching between messages. - [x] Add smooth fade transitions when switching between messages.
- [ ] Add theme controls and customization options.
## Phase 4 Reliability & Polish ## Phase 4 Reliability & Polish
- [x] Implement image proxy with caching to prevent YouTube CDN 429 errors. - [x] Implement image proxy with caching to prevent YouTube CDN 429 errors.
@@ -45,6 +43,9 @@
- [ ] Ensure the deployment. - [ ] Ensure the deployment.
- [ ] Write tests (unit/integration) and contributor documentation. - [ ] Write tests (unit/integration) and contributor documentation.
## Phase 5 - Customization
- [ ] Add customization options
## Current Status ## Current Status
- **✅ YouTube Integration Live**: Backend connects to real YouTube Live chat and parses all message types - **✅ YouTube Integration Live**: Backend connects to real YouTube Live chat and parses all message types
- **✅ Modern UI Complete**: Dashboard features centered layout, gradient backgrounds, badges, and superchat displays - **✅ Modern UI Complete**: Dashboard features centered layout, gradient backgrounds, badges, and superchat displays
@@ -60,4 +61,5 @@
- **✅ Smart Storage**: Regular messages limited to 200, superchats and memberships preserved for entire session - **✅ Smart Storage**: Regular messages limited to 200, superchats and memberships preserved for entire session
- **✅ Super Sticker Support**: Super sticker images now display correctly in both dashboard and overlay with proper error handling and accessibility labels - **✅ Super Sticker Support**: Super sticker images now display correctly in both dashboard and overlay with proper error handling and accessibility labels
- **✅ Leaderboard Badge Support**: YouTube leaderboard ranks (Top Chatter) now display with crown emoji and rank number next to usernames in both dashboard and overlay with golden styling - **✅ Leaderboard Badge Support**: YouTube leaderboard ranks (Top Chatter) now display with crown emoji and rank number next to usernames in both dashboard and overlay with golden styling
- **Next**: Show open polls, implement error recovery, add user authentication - **✅ Live Poll Indicator**: Pulsing poll indicator shows in dashboard header when a YouTube poll is active, automatically hides when closed
- **Next**: Implement user authentication, add persistent image cache, develop overlay theme controls
+1
View File
@@ -23,3 +23,4 @@
- **Overlay Animation System**: Two-state approach with `message` (backend data) and `displayMessage` (UI state) enables smooth fade transitions when switching messages. 300ms fade-out followed by content swap and automatic fade-in. - **Overlay Animation System**: Two-state approach with `message` (backend data) and `displayMessage` (UI state) enables smooth fade transitions when switching messages. 300ms fade-out followed by content swap and automatic fade-in.
- **Super Sticker Parsing**: Backend `extractSuperChatInfo()` function detects super stickers via `item.sticker` array. Extracts largest image (144x144px) from sticker thumbnails array, handles protocol-relative URLs by converting to HTTPS, and captures accessibility labels for screen readers. Frontend conditionally renders stickers with error handling that hides broken images via `onError` handler. - **Super Sticker Parsing**: Backend `extractSuperChatInfo()` function detects super stickers via `item.sticker` array. Extracts largest image (144x144px) from sticker thumbnails array, handles protocol-relative URLs by converting to HTTPS, and captures accessibility labels for screen readers. Frontend conditionally renders stickers with error handling that hides broken images via `onError` handler.
- **Leaderboard Badge Parsing**: Backend `extractLeaderboardRank()` function scans `before_content_buttons` array for CROWN icon entries. Extracts rank number from title field using regex pattern `/#(\d+)/`. Returns numeric rank or undefined if not present. Frontend displays badge with crown emoji and rank using golden color scheme for visual prominence. - **Leaderboard Badge Parsing**: Backend `extractLeaderboardRank()` function scans `before_content_buttons` array for CROWN icon entries. Extracts rank number from title field using regex pattern `/#(\d+)/`. Returns numeric rank or undefined if not present. Frontend displays badge with crown emoji and rank using golden color scheme for visual prominence.
- **Poll Detection System**: Backend listens for `UpdateLiveChatPollAction` events to detect active polls and emits simple poll state (id + active flag) via SSE. Frontend displays pulsing purple poll indicator in dashboard header when poll is active. Poll closes automatically when backend receives `CloseLiveChatActionPanelAction` or `RemoveBannerForLiveChatCommand` events. Note: YouTube's live chat API does not provide vote percentages even with authentication, so only poll presence is indicated.
+5
View File
@@ -19,6 +19,11 @@ export type MessageRun = {
emojiAlt?: string; emojiAlt?: string;
}; };
export type Poll = {
id: string; // live_chat_poll_id from YouTube
active: boolean;
};
export type ChatMessage = { export type ChatMessage = {
id: string; id: string;
author: string; author: string;