From 4a4b2407b6343ea00a92ebecd5ba0bb0b478eb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sun, 12 Oct 2025 23:04:57 +0300 Subject: [PATCH] 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 --- backend/src/index.ts | 54 +++++++++++++++++++++++++++++-- backend/src/ingestion/youtubei.ts | 30 ++++++++++++----- client/app/dashboard/page.tsx | 42 +++++++++++++++++++++++- client/app/globals.css | 33 +++++++++++++++++++ memory-bank/activeContext.md | 12 +++---- memory-bank/progress.md | 10 +++--- memory-bank/systemPatterns.md | 1 + shared/chat.ts | 5 +++ 8 files changed, 165 insertions(+), 22 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index cc35336..3dbcd1e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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(); diff --git a/backend/src/ingestion/youtubei.ts b/backend/src/ingestion/youtubei.ts index 7c965c3..8d94930 100644 --- a/backend/src/ingestion/youtubei.ts +++ b/backend/src/ingestion/youtubei.ts @@ -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 { - // 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 { 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); diff --git a/client/app/dashboard/page.tsx b/client/app/dashboard/page.tsx index 6c07efc..385f33a 100644 --- a/client/app/dashboard/page.tsx +++ b/client/app/dashboard/page.tsx @@ -1,7 +1,7 @@ 'use client'; 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 { formatTimestamp } from '../../lib/timezone'; import { proxyImageUrl } from '../../lib/imageProxy'; @@ -46,6 +46,7 @@ let globalConnectionListeners: Set<(payload: any) => void> = new Set(); export default function DashboardPage() { const { messages, refresh, error: pollError } = useChatMessages(); const { selection, status: overlayStatus } = useOverlaySelection(); + const { poll: currentPoll } = usePoll(); const { connected, liveId, connect, disconnect, connecting } = useConnection(); const [confirmUrl, setConfirmUrl] = useState(null); const [superChatPulse, setSuperChatPulse] = useState(false); @@ -198,6 +199,12 @@ export default function DashboardPage() { {connected && ( <>
+ {currentPoll && ( +
+ 📊 + Active Poll +
+ )}
Messages {regularMessages.length}
@@ -313,6 +320,7 @@ export default function DashboardPage() { )} + ); } @@ -455,6 +463,38 @@ function useConnection() { return { connected, liveId, connect, disconnect, connecting }; } +function usePoll() { + const [poll, setPoll] = useState(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 = { connected: boolean; liveId: string | null; diff --git a/client/app/globals.css b/client/app/globals.css index cd7c61b..1415054 100644 --- a/client/app/globals.css +++ b/client/app/globals.css @@ -1357,3 +1357,36 @@ main { background: rgba(248, 113, 113, 0.2); 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); + } +} diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index ff798d5..dea518c 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -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. - **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. -- **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 -1. Add live poll display functionality (Task 4) -2. Implement user authentication via YouTube OAuth 2.0 (Task 5) -3. Add persistent image cache with SQLite (Task 6) -4. Consider persistent cache for images (SQLite or file-based) for better reliability -5. Add error recovery and reconnection logic for stream interruptions +1. Implement user authentication via YouTube OAuth 2.0 (Task 5) +2. Add persistent image cache with SQLite (Task 6) +3. Develop overlay theme controls (Task 7) +4. Add error recovery and reconnection logic for stream interruptions ## Open Questions - Whether to add message search/filtering UI controls diff --git a/memory-bank/progress.md b/memory-bank/progress.md index b476be6..e0dd94b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -23,8 +23,7 @@ - [x] Visual selection state management (active, normal, previously-selected). - [x] Fix super sticker image not showing. - [x] Add chat leaderboard badge. -- [ ] Show open polls. -- [ ] Handle error states (rate limits, disconnects) gracefully in UI. +- [x] Show active poll indicator. ## Phase 3 – OBS Overlay Experience - [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] Remove timestamps from overlay for cleaner display. - [x] Add smooth fade transitions when switching between messages. -- [ ] Add theme controls and customization options. ## Phase 4 – Reliability & Polish - [x] Implement image proxy with caching to prevent YouTube CDN 429 errors. @@ -45,6 +43,9 @@ - [ ] Ensure the deployment. - [ ] Write tests (unit/integration) and contributor documentation. +## Phase 5 - Customization +- [ ] Add customization options + ## Current Status - **✅ 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 @@ -60,4 +61,5 @@ - **✅ 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 - **✅ 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 diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md index 52844af..e426587 100644 --- a/memory-bank/systemPatterns.md +++ b/memory-bank/systemPatterns.md @@ -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. - **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. +- **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. diff --git a/shared/chat.ts b/shared/chat.ts index 3c8d374..c0db088 100644 --- a/shared/chat.ts +++ b/shared/chat.ts @@ -19,6 +19,11 @@ export type MessageRun = { emojiAlt?: string; }; +export type Poll = { + id: string; // live_chat_poll_id from YouTube + active: boolean; +}; + export type ChatMessage = { id: string; author: string;