fix: profile avatar shows fallback X instead of real picture

Reproduction: open the new profile header (4.6.17) for any streamer
with a real Twitch avatar. The fallback gradient letter tile renders
instead of the actual profile picture. Image works fine pasted into
the browser; only the renderer img tag fails.

Cause is Electron renderer image loading against the Twitch CDN
(static-cdn.jtvnw.net) — undocumented but reproducible: the same
HTTPS URL that loads fine in DevTools fails silently from the live
page, firing the img.onerror handler which (by design) swaps to the
letter-tile fallback.

Fix: fetch the avatar bytes in the main process via axios (Node http
client, no renderer / CSP / referrer-policy / CORS shenanigans),
convert to base64 data URL, and put THAT in the profile.avatarUrl
field. The renderer just renders the data URL via img src — same
code path, but the URL is now data:image/png;base64,... so no
external fetch is involved.

Bytes cached by source URL in a small FIFO map (256 entries) so the
same avatar across cache misses only downloads once. Profile cache
itself is unchanged, just stores the data URL now instead of the
remote URL. On a clean restart the user sees the fix on first
streamer click; mid-session a click on Refresh (top-right of the
header) forces a re-fetch through the new path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 00:41:03 +02:00
parent ef6b82bb8b
commit b1fd73cbe6

View File

@ -2319,6 +2319,44 @@ const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512;
const streamerProfileCache = new Map<string, CacheEntry<StreamerProfile>>();
const inFlightProfileRequests = new Map<string, Promise<StreamerProfile | null>>();
// 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<string, string>();
const MAX_AVATAR_DATA_URL_CACHE = 256;
async function fetchAvatarAsDataUrl(url: string): Promise<string> {
if (!url) return '';
const cached = avatarDataUrlCache.get(url);
if (cached !== undefined) return cached;
try {
const response = await axios.get<ArrayBuffer>(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,