import { steam2ToSteam64 } from './steam'; export interface SteamSummary { avatarUrl: string; personaName: string | null; } // Steam's generic "no avatar set" question-mark image — used whenever a // player has no avatar, or the lookup fails for any reason. export const DEFAULT_AVATAR_URL = 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg'; /** * Fetches avatar + persona name from Steam's GetPlayerSummaries endpoint. * Always resolves to a usable avatarUrl (falls back to the default * question-mark image on any failure — including a missing STEAM_API_KEY) * rather than null, so callers don't need their own fallback logic. * * Cached for an hour via Next's extended fetch() — avatars rarely change, * and this keeps calls well within Steam's rate limits without needing a * separate DB cache table. */ export async function getSteamSummary(steamId64: string): Promise { const apiKey = process.env.STEAM_API_KEY; if (!apiKey) { return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null }; } try { const res = await fetch( `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${steamId64}`, { next: { revalidate: 3600 } }, ); if (!res.ok) { return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null }; } const data = await res.json(); const player = data?.response?.players?.[0]; return { avatarUrl: player?.avatarfull || DEFAULT_AVATAR_URL, personaName: player?.personaname ?? null, }; } catch { return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null }; } } /** * Same as getSteamSummary, but for many players in one go — Steam's * GetPlayerSummaries accepts up to 100 comma-separated steamids per call, * so fetching a list of players (e.g. "who was online at this point in * time") is one HTTP call per 100 players rather than one per player. * Returns a map keyed by SteamID64; missing entries mean "use the default * avatar" at the call site. */ export async function getSteamSummariesBatch( steamId64List: string[], ): Promise> { const result = new Map(); const apiKey = process.env.STEAM_API_KEY; if (!apiKey || steamId64List.length === 0) { return result; } const chunks: string[][] = []; for (let i = 0; i < steamId64List.length; i += 100) { chunks.push(steamId64List.slice(i, i + 100)); } await Promise.all( chunks.map(async (chunk) => { try { const res = await fetch( `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${chunk.join(',')}`, { next: { revalidate: 3600 } }, ); if (!res.ok) return; const data = await res.json(); const players = data?.response?.players ?? []; for (const player of players) { result.set(player.steamid, { avatarUrl: player.avatarfull || DEFAULT_AVATAR_URL, personaName: player.personaname ?? null, }); } } catch { // this chunk's players just fall back to the default avatar downstream } }), ); return result; } /** * Attaches avatarUrl to any list of objects that have a `steamid` (Steam2 * format) field — the shared enrichment step used everywhere a list of * players gets rendered, so every list in the app shows avatars * consistently without duplicating the batch-fetch/fallback logic per call * site. */ export async function attachAvatars( players: T[], ): Promise<(T & { avatarUrl: string })[]> { const steam64ByPlayer = new Map(); for (const p of players) { const id64 = steam2ToSteam64(p.steamid); if (id64) steam64ByPlayer.set(p.steamid, id64); } const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]); return players.map((p) => { const id64 = steam64ByPlayer.get(p.steamid); const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL; return { ...p, avatarUrl }; }); }