import type { IgdbGame } from "@/lib/game/game-profile-types"; import { normalizeGameTitle } from "@/lib/game/game-format"; import { warnDev } from "@/lib/log"; const IGDB_GAME_FIELDS = "name,slug,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," + "websites.url,websites.category,involved_companies.developer," + "involved_companies.publisher,involved_companies.company.name," + "platforms.abbreviation,platforms.name,first_release_date;"; const TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token"; const TOKEN_FALLBACK_EXPIRY_S = 3600; const MS_PER_SECOND = 1000; const TOKEN_EXPIRY_SKEW_MS = 60_000; let igdbTokenCache: { token: string; expires: number } | null = null; let igdbKeyWarned = false; export function _resetIgdbState(): void { igdbTokenCache = null; igdbKeyWarned = false; } async function getIgdbToken(): Promise { const clientId = process.env.TWITCH_CLIENT_ID; const clientSecret = process.env.TWITCH_CLIENT_SECRET; if (!clientId || !clientSecret) { if (!igdbKeyWarned) { igdbKeyWarned = true; warnDev("igdb not configured"); } return null; } if (igdbTokenCache && Date.now() < igdbTokenCache.expires) { return igdbTokenCache.token; } try { const response = await fetch(TWITCH_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "client_credentials", }), }); if (!response.ok) { warnDev(`igdb token failed: ${response.status}`); return null; } const json = (await response.json()) as { access_token?: string; expires_in?: number }; if (!json.access_token) { return null; } igdbTokenCache = { token: json.access_token, expires: Date.now() + (json.expires_in ?? TOKEN_FALLBACK_EXPIRY_S) * MS_PER_SECOND - TOKEN_EXPIRY_SKEW_MS, }; return json.access_token; } catch (error) { warnDev(`igdb token request failed: ${error instanceof Error ? error.message : error}`); return null; } } async function igdbQuery(token: string, endpoint: string, body: string): Promise { try { const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, { method: "POST", headers: { "Client-ID": process.env.TWITCH_CLIENT_ID ?? "", Authorization: `Bearer ${token}`, "Content-Type": "text/plain", }, body, next: { revalidate: 86_400 }, }); if (!response.ok) { warnDev(`igdb query failed: ${response.status}`); return null; } return (await response.json()) as T; } catch (error) { warnDev(`igdb query request failed: ${error instanceof Error ? error.message : error}`); return null; } } export function igdbImage(url: string | undefined, size: string): string | null { if (!url) { return null; } const full = url.startsWith("//") ? `https:${url}` : url; return full.replace(/t_thumb|t_cover_small|t_logo_med/, size); } export async function getIgdbGame(name: string, steamId: string | null): Promise { const token = await getIgdbToken(); if (!token) { return null; } if (steamId) { const external = await igdbQuery>( token, "external_games", `fields game; where uid = "${steamId}"; limit 1;`, ); const gameId = external?.[0]?.game; if (gameId) { const games = await igdbQuery( token, "games", `fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`, ); if (games?.[0]) { return games[0]; } } } const cleaned = name .replace(/[®™©]/g, "") .replace(/\s{2,}/g, " ") .trim(); const escaped = cleaned.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const games = await igdbQuery( token, "games", `fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`, ); if (!games || games.length === 0) { return null; } const wanted = normalizeGameTitle(name); return games.find((g) => g.name && normalizeGameTitle(g.name) === wanted) ?? games[0] ?? null; }