diff --git a/src/main.ts b/src/main.ts index e77864e..5bfc087 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2319,6 +2319,44 @@ const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512; const streamerProfileCache = new Map>(); const inFlightProfileRequests = new Map>(); +// Avatar bytes get embedded as data URLs in the profile so the renderer +// doesn't have to do its own HTTPS fetch (Electron's renderer img loader +// has a habit of failing silently against the Twitch CDN — undocumented, +// but reproducibly: the same URL works in DevTools but not in the live +// page). Cached by source URL so a single avatar change across multiple +// streamer entries only downloads once. +const avatarDataUrlCache = new Map(); +const MAX_AVATAR_DATA_URL_CACHE = 256; + +async function fetchAvatarAsDataUrl(url: string): Promise { + if (!url) return ''; + const cached = avatarDataUrlCache.get(url); + if (cached !== undefined) return cached; + try { + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 8000, + headers: { 'User-Agent': 'TwitchVODManager/1.0' } + }); + const buf = Buffer.from(response.data); + // Twitch CDN almost always serves PNG or JPEG. Detect from the + // response content-type when available, fall back to PNG which is + // the default for profile_image_url. + const contentType = (response.headers['content-type'] as string | undefined)?.split(';')[0]?.trim() || 'image/png'; + const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`; + avatarDataUrlCache.set(url, dataUrl); + if (avatarDataUrlCache.size > MAX_AVATAR_DATA_URL_CACHE) { + // FIFO eviction — Map preserves insertion order. + const firstKey = avatarDataUrlCache.keys().next().value as string | undefined; + if (firstKey) avatarDataUrlCache.delete(firstKey); + } + return dataUrl; + } catch (e) { + appendDebugLog('avatar-fetch-failed', { url, error: String(e) }); + return ''; + } +} + interface HelixUser { id: string; login: string; @@ -2477,10 +2515,15 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< } } catch (_) { /* best-effort */ } + // Embed the avatar bytes as a data URL so the renderer doesn't + // have to make its own HTTPS request. Bypasses any CSP, CORS, + // referrer-policy, or Electron renderer image-loading quirks. + const avatarDataUrl = avatarUrl ? await fetchAvatarAsDataUrl(avatarUrl) : ''; + const profile: StreamerProfile = { login: normalized, displayName, - avatarUrl, + avatarUrl: avatarDataUrl || avatarUrl, description, broadcasterType, followerCount,