feat: implement image proxy for YouTube CDN images with caching to prevent rate limits

This commit is contained in:
Yusuf İpek
2025-10-11 20:34:25 +03:00
parent edd6c4416f
commit 7b43932fa6
7 changed files with 149 additions and 13 deletions
+101
View File
@@ -3,9 +3,15 @@ import cors from '@fastify/cors';
import EventEmitter from 'eventemitter3'; import EventEmitter from 'eventemitter3';
import type { ChatMessage } from '@shared/chat'; import type { ChatMessage } from '@shared/chat';
import { bootstrapInnertube, type IngestionContext } from './ingestion/youtubei'; import { bootstrapInnertube, type IngestionContext } from './ingestion/youtubei';
import crypto from 'crypto';
const MAX_MESSAGES = 500; const MAX_MESSAGES = 500;
// Simple in-memory cache for images
const imageCache = new Map<string, { buffer: Buffer; contentType: string; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
const MAX_CACHE_SIZE = 1000; // Maximum number of cached images
export async function startBackend() { export async function startBackend() {
const fastify = Fastify({ const fastify = Fastify({
logger: { logger: {
@@ -208,6 +214,101 @@ export async function startBackend() {
}); });
}); });
// Image proxy endpoint to avoid YouTube CDN rate limits
fastify.get<{ Querystring: { url: string } }>('/proxy/image', async (request, reply) => {
const { url } = request.query;
if (!url || typeof url !== 'string') {
reply.status(400);
return { error: 'url parameter is required' };
}
// Only allow YouTube CDN domains
const allowedDomains = ['yt3.ggpht.com', 'yt4.ggpht.com', 'i.ytimg.com'];
try {
const urlObj = new URL(url);
if (!allowedDomains.includes(urlObj.hostname)) {
reply.status(403);
return { error: 'Only YouTube CDN URLs are allowed' };
}
} catch (error) {
reply.status(400);
return { error: 'Invalid URL' };
}
// Create cache key from URL
const cacheKey = crypto.createHash('md5').update(url).digest('hex');
// Check cache
const cached = imageCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
reply.header('Content-Type', cached.contentType);
reply.header('Cache-Control', 'public, max-age=86400'); // 24 hours
reply.header('Access-Control-Allow-Origin', '*');
return reply.send(cached.buffer);
}
// Fetch from YouTube
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Referer': 'https://www.youtube.com/',
}
});
if (!response.ok) {
if (response.status === 429) {
console.warn('[Backend] Rate limited by YouTube CDN for:', url);
// Return from cache even if expired, or return error
if (cached) {
reply.header('Content-Type', cached.contentType);
reply.header('Cache-Control', 'public, max-age=86400');
reply.header('Access-Control-Allow-Origin', '*');
return reply.send(cached.buffer);
}
}
throw new Error(`Failed to fetch image: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || 'image/jpeg';
// Cache the image
imageCache.set(cacheKey, {
buffer,
contentType,
timestamp: Date.now()
});
// Cleanup old cache entries if we exceed max size
if (imageCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(imageCache.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
const toDelete = entries.slice(0, Math.floor(MAX_CACHE_SIZE * 0.2)); // Remove oldest 20%
toDelete.forEach(([key]) => imageCache.delete(key));
}
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=86400');
reply.header('Access-Control-Allow-Origin', '*');
return reply.send(buffer);
} catch (error) {
console.error('[Backend] Failed to proxy image:', error);
// Try to return stale cache if available
if (cached) {
reply.header('Content-Type', cached.contentType);
reply.header('Cache-Control', 'public, max-age=86400');
reply.header('Access-Control-Allow-Origin', '*');
return reply.send(cached.buffer);
}
reply.status(500);
return { error: 'Failed to fetch image' };
}
});
const port = Number(process.env.PORT ?? 4100); const port = Number(process.env.PORT ?? 4100);
await fastify.listen({ port, host: '0.0.0.0' }); await fastify.listen({ port, host: '0.0.0.0' });
+6 -5
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import type { ChatMessage } from '@shared/chat'; import type { ChatMessage } 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';
// URL regex for detecting links (http/https) // URL regex for detecting links (http/https)
const URL_REGEX = /(https?:\/\/[^\s]+)/gi; const URL_REGEX = /(https?:\/\/[^\s]+)/gi;
@@ -607,7 +608,7 @@ function ChatItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySele
> >
<div className="chatItem__header"> <div className="chatItem__header">
{message.authorPhoto && ( {message.authorPhoto && (
<img src={message.authorPhoto} alt={message.author} className="chatItem__avatar" /> <img src={proxyImageUrl(message.authorPhoto)} alt={message.author} className="chatItem__avatar" />
)} )}
<div className="chatItem__meta"> <div className="chatItem__meta">
<div className="chatItem__authorLine"> <div className="chatItem__authorLine">
@@ -617,7 +618,7 @@ function ChatItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySele
<span className="chatItem__author">{message.author}</span> <span className="chatItem__author">{message.author}</span>
{message.badges && message.badges.map((badge, i) => ( {message.badges && message.badges.map((badge, i) => (
badge.imageUrl ? ( badge.imageUrl ? (
<img key={i} src={badge.imageUrl} alt={badge.label} className="badge badge--image" title={badge.label} /> <img key={i} src={proxyImageUrl(badge.imageUrl)} alt={badge.label} className="badge badge--image" title={badge.label} />
) : ( ) : (
<span key={i} className={`badge badge--${badge.type}`} title={badge.label}> <span key={i} className={`badge badge--${badge.type}`} title={badge.label}>
{badge.type === 'moderator' && '🛡️'} {badge.type === 'moderator' && '🛡️'}
@@ -639,7 +640,7 @@ function ChatItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySele
<p className="chatItem__text"> <p className="chatItem__text">
{message.runs.map((r, i) => {message.runs.map((r, i) =>
r.emojiUrl ? ( r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="chatItem__emoji" /> <img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="chatItem__emoji" />
) : ( ) : (
<span key={i}><MessageText text={r.text || ''} onLinkClick={onLinkClick} /></span> <span key={i}><MessageText text={r.text || ''} onLinkClick={onLinkClick} /></span>
) )
@@ -670,7 +671,7 @@ function MemberItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySe
> >
<div className="memberItem__header"> <div className="memberItem__header">
{message.authorPhoto && ( {message.authorPhoto && (
<img src={message.authorPhoto} alt={message.author} className="memberItem__avatar" /> <img src={proxyImageUrl(message.authorPhoto)} alt={message.author} className="memberItem__avatar" />
)} )}
<div className="memberItem__info"> <div className="memberItem__info">
<span className="memberItem__author">{message.author}</span> <span className="memberItem__author">{message.author}</span>
@@ -686,7 +687,7 @@ function MemberItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySe
<p className="memberItem__text"> <p className="memberItem__text">
{message.runs.map((r, i) => {message.runs.map((r, i) =>
r.emojiUrl ? ( r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="memberItem__emoji" /> <img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="memberItem__emoji" />
) : ( ) : (
<span key={i}><MessageText text={r.text || ''} onLinkClick={onLinkClick} /></span> <span key={i}><MessageText text={r.text || ''} onLinkClick={onLinkClick} /></span>
) )
+8 -7
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
import type { ChatMessage } from '@shared/chat'; import type { ChatMessage } 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';
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100'; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
@@ -74,7 +75,7 @@ export default function OverlayPage() {
<> <>
<div className="overlay__superchat-header" style={{ backgroundColor: message.superChat.color }}> <div className="overlay__superchat-header" style={{ backgroundColor: message.superChat.color }}>
{message.authorPhoto && ( {message.authorPhoto && (
<img src={message.authorPhoto} alt={message.author} className="overlay__superchat-avatar" /> <img src={proxyImageUrl(message.authorPhoto)} alt={message.author} className="overlay__superchat-avatar" />
)} )}
<span className="overlay__superchat-name">{message.author}</span> <span className="overlay__superchat-name">{message.author}</span>
<span className="overlay__superchat-separator"> - </span> <span className="overlay__superchat-separator"> - </span>
@@ -86,7 +87,7 @@ export default function OverlayPage() {
<p className="overlay__superchat-text"> <p className="overlay__superchat-text">
{message.runs.map((r, i) => {message.runs.map((r, i) =>
r.emojiUrl ? ( r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" /> <img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
) : ( ) : (
<span key={i}>{r.text}</span> <span key={i}>{r.text}</span>
) )
@@ -100,7 +101,7 @@ export default function OverlayPage() {
<> <>
<div className="overlay__membership-header"> <div className="overlay__membership-header">
{message.authorPhoto && ( {message.authorPhoto && (
<img src={message.authorPhoto} alt={message.author} className="overlay__membership-avatar" /> <img src={proxyImageUrl(message.authorPhoto)} alt={message.author} className="overlay__membership-avatar" />
)} )}
<div className="overlay__membership-info"> <div className="overlay__membership-info">
<span className="overlay__membership-name">{message.author}</span> <span className="overlay__membership-name">{message.author}</span>
@@ -116,7 +117,7 @@ export default function OverlayPage() {
<p className="overlay__membership-text"> <p className="overlay__membership-text">
{message.runs.map((r, i) => {message.runs.map((r, i) =>
r.emojiUrl ? ( r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" /> <img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
) : ( ) : (
<span key={i}>{r.text}</span> <span key={i}>{r.text}</span>
) )
@@ -130,14 +131,14 @@ export default function OverlayPage() {
<> <>
<div className="overlay__header"> <div className="overlay__header">
{message.authorPhoto && ( {message.authorPhoto && (
<img src={message.authorPhoto} alt={message.author} className="overlay__avatar" /> <img src={proxyImageUrl(message.authorPhoto)} alt={message.author} className="overlay__avatar" />
)} )}
<div> <div>
<div className="overlay__authorLine"> <div className="overlay__authorLine">
<span className="overlay__author">{message.author}</span> <span className="overlay__author">{message.author}</span>
{message.badges && message.badges.map((badge, i) => ( {message.badges && message.badges.map((badge, i) => (
badge.imageUrl ? ( badge.imageUrl ? (
<img key={i} src={badge.imageUrl} alt={badge.label} className="overlay__badge overlay__badge--image" title={badge.label} /> <img key={i} src={proxyImageUrl(badge.imageUrl)} alt={badge.label} className="overlay__badge overlay__badge--image" title={badge.label} />
) : ( ) : (
<span key={i} className={`overlay__badge overlay__badge--${badge.type}`} title={badge.label}> <span key={i} className={`overlay__badge overlay__badge--${badge.type}`} title={badge.label}>
{badge.type === 'moderator' && '🛡️'} {badge.type === 'moderator' && '🛡️'}
@@ -154,7 +155,7 @@ export default function OverlayPage() {
<p className="overlay__text"> <p className="overlay__text">
{message.runs.map((r, i) => {message.runs.map((r, i) =>
r.emojiUrl ? ( r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" /> <img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
) : ( ) : (
<span key={i}>{r.text}</span> <span key={i}>{r.text}</span>
) )
+27
View File
@@ -0,0 +1,27 @@
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
/**
* Converts a YouTube CDN image URL to use our backend proxy
* This prevents 429 rate limit errors from YouTube's CDN
*/
export function proxyImageUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
// Check if it's a YouTube CDN URL
const youtubeCdnDomains = ['yt3.ggpht.com', 'yt4.ggpht.com', 'i.ytimg.com'];
try {
const urlObj = new URL(url);
if (youtubeCdnDomains.includes(urlObj.hostname)) {
// Proxy through our backend
return `${BACKEND_URL}/proxy/image?url=${encodeURIComponent(url)}`;
}
} catch {
// If URL parsing fails, return as-is
return url;
}
// Return non-YouTube URLs as-is
return url;
}
+4 -1
View File
@@ -7,6 +7,7 @@
- **Rich message parsing**: Full support for superchats, memberships, badges (moderator, member, verified) - **Rich message parsing**: Full support for superchats, memberships, badges (moderator, member, verified)
- **Timezone-aware timestamps**: All message timestamps display in user's local timezone with proper browser detection - **Timezone-aware timestamps**: All message timestamps display in user's local timezone with proper browser detection
- **Visual selection feedback**: Previously selected messages show dimmed state for better user experience - **Visual selection feedback**: Previously selected messages show dimmed state for better user experience
- **Image proxy implemented**: Backend proxies all YouTube CDN images with caching to prevent 429 rate limit errors
## Recent Decisions ## Recent Decisions
- Fixed CORS issues by setting headers on raw response object after `reply.hijack()` for SSE endpoint - Fixed CORS issues by setting headers on raw response object after `reply.hijack()` for SSE endpoint
@@ -24,14 +25,16 @@
- **Timezone Support**: Implemented browser timezone detection and proper timestamp formatting across dashboard and overlay using `Intl.DateTimeFormat` with GMT+3 fallback. - **Timezone Support**: Implemented browser timezone detection and proper timestamp formatting across dashboard and overlay using `Intl.DateTimeFormat` with GMT+3 fallback.
- **Visual Selection States**: Added three-tier visual feedback system - active (selected), normal, and previously-selected (dimmed) states for better user experience. - **Visual Selection States**: Added three-tier visual feedback system - active (selected), normal, and previously-selected (dimmed) states for better user experience.
- **UI Polish**: Fixed pulse animations on initial load, hidden N/A messages, and improved overlay timestamp display. - **UI Polish**: Fixed pulse animations on initial load, hidden N/A messages, and improved overlay timestamp display.
- **Image Proxy**: Added `/proxy/image` endpoint in backend with in-memory caching (24hr TTL, max 1000 images) to prevent YouTube CDN 429 rate limit errors. All avatars, badges, and emojis now route through proxy with stale-on-error fallback.
## Immediate Next Steps ## Immediate Next Steps
1. Test with live YouTube stream to verify badge parsing and superchat detection 1. Test with live YouTube stream to verify badge parsing and superchat detection
2. Add search/filter functionality for chat messages 2. Add search/filter functionality for chat messages
3. Implement error recovery and reconnection logic for stream interruptions 3. Implement error recovery and reconnection logic for stream interruptions
4. Add keyboard shortcuts for quick message selection 4. Add keyboard shortcuts for quick message selection
5. Consider persistent cache for images (SQLite or file-based) for better reliability
## Open Questions ## Open Questions
- Whether to add message search/filtering UI controls - Whether to add message search/filtering UI controls
- How to handle rate limiting and backoff strategies for long streams
- Whether to persist Innertube visitor data between runs - Whether to persist Innertube visitor data between runs
- Should image cache be persistent across restarts?
+2
View File
@@ -34,6 +34,7 @@
- [ ] Add theme controls and customization options. - [ ] 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.
- [ ] Expand logging/metrics for long-stream observability. - [ ] Expand logging/metrics for long-stream observability.
- [ ] Write tests (unit/integration) and contributor documentation. - [ ] Write tests (unit/integration) and contributor documentation.
@@ -47,4 +48,5 @@
- **✅ Timestamp Resolution Fixed**: Critical bug resolved - timestamps now display correct current time instead of 1970 epoch - **✅ Timestamp Resolution Fixed**: Critical bug resolved - timestamps now display correct current time instead of 1970 epoch
- **✅ Timezone Support**: All timestamps display in user's local timezone with browser detection and GMT+3 fallback - **✅ Timezone Support**: All timestamps display in user's local timezone with browser detection and GMT+3 fallback
- **✅ Visual Selection States**: Three-tier feedback system (active/normal/previously-selected) for better UX - **✅ Visual Selection States**: Three-tier feedback system (active/normal/previously-selected) for better UX
- **✅ Image Proxy Implemented**: Backend now proxies all YouTube CDN images (avatars, badges, emojis) with 24hr in-memory cache to prevent 429 rate limit errors
- **Next**: Add search/filter, error recovery, and polish UX details - **Next**: Add search/filter, error recovery, and polish UX details
+1
View File
@@ -18,3 +18,4 @@
- **Timezone Context Pattern**: React context provider (`TimezoneContext`) manages browser timezone detection and shares across components for consistent timestamp formatting. - **Timezone Context Pattern**: React context provider (`TimezoneContext`) manages browser timezone detection and shares across components for consistent timestamp formatting.
- **Selection State Management**: Three-tier visual state system (active/selected, normal, previously-selected) with CSS class composition for clear user feedback. - **Selection State Management**: Three-tier visual state system (active/selected, normal, previously-selected) with CSS class composition for clear user feedback.
- **Timestamp Resolution**: Backend uses `timestamp_usec` (microseconds) from YouTube data, converts to milliseconds, and frontend formats in user's local timezone. - **Timestamp Resolution**: Backend uses `timestamp_usec` (microseconds) from YouTube data, converts to milliseconds, and frontend formats in user's local timezone.
- **Image Proxy Pattern**: Backend `/proxy/image` endpoint caches YouTube CDN images (avatars, badges, emojis) with MD5-hashed keys, 24hr TTL, and 1000-image LRU eviction. Returns stale cache on 429 errors or network failures. Frontend `proxyImageUrl()` helper transparently rewrites YouTube CDN URLs to use proxy.