import { getDiscordAppInfo } from "@/lib/lanyard"; export interface GameExternalLink { label: string; url: string; } export interface GameReviewSummary { label: string; total: number | null; positive: number | null; negative: number | null; url?: string; } export function steamdbRating(positive: number, total: number): number | null { if ( !Number.isFinite(positive) || !Number.isFinite(total) || total <= 0 || positive < 0 || positive > total ) { return null; } const score = positive / total; return score - (score - 0.5) * 2 ** -Math.log10(total + 1); } export interface GamePricePoint { price: number; regular: number; currency: string; shop: string; url: string; } export interface ItadReview { score: number | null; source: string; count: number | null; url: string; } export interface GameProfile { applicationId: string; gameId: string; gameUrl: string; name: string; genres: string[]; description: string | null; developers: string[]; publishers: string[]; releaseDate: string | null; platforms: string[]; links: GameExternalLink[]; coverUrl: string | null; artworkUrl: string | null; screenshots: string[]; steamId: string | null; steamUrl: string | null; igdbUrl: string | null; steamReviews: GameReviewSummary | null; itad: { prices: GamePricePoint | null; historyLow: GamePricePoint | null; pageUrl: string | null; playerCount: number | null; reviews: ItadReview[]; } | null; sources: { igdb: boolean; steam: boolean; itad: boolean; }; } interface DetectableEntry { id: string; name: string; third_party_skus?: Array<{ distributor: string; id: string }>; } let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null; async function getDetectables(): Promise { if (detectablesCache && Date.now() < detectablesCache.expires) { return detectablesCache.data; } try { const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json"); if (!response.ok) { console.error(`detectables failed: ${response.status}`); return detectablesCache?.data ?? []; } const data = (await response.json()) as DetectableEntry[]; detectablesCache = { data, expires: Date.now() + 86_400_000 }; return data; } catch { return detectablesCache?.data ?? []; } } const DISTRIBUTOR_PLATFORM: Record = { xbox: "Xbox", xbox_title: "Xbox", xbox_game_pass: "Xbox", playstation: "PlayStation", nintendo: "Switch", }; function steamIdFromEntry(entry: DetectableEntry | undefined): string | null { const sku = entry?.third_party_skus?.find( (s) => s.distributor === "steam" && /^\d+$/.test(s.id), ); return sku?.id ?? null; } interface SteamAppDetails { success: boolean; data?: { name?: string; short_description?: string; header_image?: string; genres?: Array<{ description?: string }>; developers?: string[]; publishers?: string[]; platforms?: { windows?: boolean; mac?: boolean; linux?: boolean }; release_date?: { date?: string }; screenshots?: Array<{ path_full?: string }>; }; } const STEAM_LANG: Record = { tr: "turkish", en: "english", de: "german", }; async function getSteamDetails( steamId: string, lang = "en", ): Promise { try { const steamLang = STEAM_LANG[lang] ?? "english"; const response = await fetch( `https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=US`, { next: { revalidate: 86_400 } }, ); if (!response.ok) { console.error(`steam details failed: ${steamId} ${response.status}`); return null; } const json = (await response.json()) as Record; const entry = json[steamId]; return entry?.success ? (entry.data ?? null) : null; } catch { return null; } } interface SteamReviewQuery { review_score_desc?: string; total_positive?: number; total_negative?: number; total_reviews?: number; } async function getSteamReviewSummary(steamId: string): Promise { try { const url = `https://store.steampowered.com/appreviews/${steamId}` + "?json=1&language=all&purchase_type=all&num_per_page=0"; const response = await fetch(url, { next: { revalidate: 3600 } }); if (!response.ok) { console.error(`steam reviews failed: ${steamId} ${response.status}`); return null; } const json = (await response.json()) as { query_summary?: SteamReviewQuery }; const summary = json.query_summary; if (!summary || typeof summary.total_reviews !== "number") { return null; } return { label: summary.review_score_desc ?? "", total: summary.total_reviews, positive: summary.total_positive ?? null, negative: summary.total_negative ?? null, url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`, }; } catch { return null; } } interface IgdbGame { name?: string; slug?: string; genres?: Array<{ name?: string }>; summary?: string; storyline?: string; cover?: { url?: string }; artworks?: Array<{ url?: string }>; screenshots?: Array<{ url?: string }>; websites?: Array<{ url?: string; category?: number }>; involved_companies?: Array<{ developer?: boolean; publisher?: boolean; company?: { name?: string }; }>; platforms?: Array<{ abbreviation?: string; name?: string }>; first_release_date?: number; } let igdbTokenCache: { token: string; expires: number } | null = null; let 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; console.error("igdb keys missing: set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET"); } return null; } if (igdbTokenCache && Date.now() < igdbTokenCache.expires) { return igdbTokenCache.token; } try { const response = await fetch("https://id.twitch.tv/oauth2/token", { 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) { console.error(`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 ?? 3600) * 1000 - 60_000, }; return json.access_token; } catch { 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) { console.error(`igdb query failed: ${endpoint} ${response.status}`); return null; } return (await response.json()) as T; } catch { return null; } } 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;"; 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); } 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 norm = (v: string): string => v .toLowerCase() .replace(/[®™©]/g, "") .replace(/[^a-z0-9]+/g, " ") .trim(); const escaped = cleaned.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 = norm(name); return games.find((g) => g.name && norm(g.name) === wanted) ?? games[0] ?? null; } const IGDB_WEBSITE_LABELS: Record = { 1: "Website", 4: "Facebook", 5: "X", 6: "Twitch", 8: "Instagram", 9: "YouTube", 13: "Steam", 14: "Reddit", 16: "Epic", 17: "GOG", 18: "Discord", }; interface ItadGameInfo { title?: string; assets?: { boxart?: string; banner600?: string }; tags?: string[]; releaseDate?: string | null; developers?: Array<{ name?: string }>; publishers?: Array<{ name?: string }>; reviews?: Array<{ score?: number | null; source?: string; count?: number | null; url?: string; }>; players?: { recent?: number } | null; urls?: { game?: string }; } interface ItadOverviewPrice { current?: { shop?: { name?: string }; price?: { amount?: number; currency?: string }; regular?: { amount?: number }; cut?: number; url?: string; } | null; lowest?: { shop?: { name?: string }; price?: { amount?: number; currency?: string }; regular?: { amount?: number }; cut?: number; } | null; } let itadKeyWarned = false; async function itadFetch(path: string, init?: RequestInit): Promise { const key = process.env.ITAD_API_KEY; if (!key) { if (!itadKeyWarned) { itadKeyWarned = true; console.error("itad key missing: set ITAD_API_KEY"); } return null; } try { const separator = path.includes("?") ? "&" : "?"; const response = await fetch( `https://api.isthereanydeal.com${path}${separator}key=${key}`, { ...init, next: { revalidate: 3600 }, }, ); if (!response.ok) { console.error(`itad failed: ${path.split("?")[0]} ${response.status}`); return null; } return (await response.json()) as T; } catch { return null; } } async function getItadId(title: string): Promise { const results = await itadFetch>( `/games/search/v1?title=${encodeURIComponent(title)}&results=5`, ); if (!results || results.length === 0) { return null; } const norm = (value: string): string => value .toLowerCase() .replace(/[®™©]/g, "") .replace(/[^a-z0-9]+/g, " ") .trim(); const wanted = norm(title); return results.find((r) => r.title && norm(r.title) === wanted)?.id ?? null; } function toIsoDate(unixSeconds: number | undefined): string | null { if (!unixSeconds) { return null; } return new Date(unixSeconds * 1000).toISOString().slice(0, 10); } export async function getGameProfile( applicationId: string, country = "TR", lang = "en", ): Promise { if (!/^\d+$/.test(applicationId)) { return null; } const appInfo = await getDiscordAppInfo(applicationId); if (!appInfo) { return null; } const detectables = await getDetectables(); const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId); const steamId = steamIdFromEntry(entry); const [steam, steamReviews, igdb] = await Promise.all([ steamId ? getSteamDetails(steamId, lang) : Promise.resolve(null), steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null), getIgdbGame(appInfo.name || entry?.name || "", steamId), ]); const itadId = process.env.ITAD_API_KEY ? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name) : null; const [itadInfo, itadOverview] = itadId ? await Promise.all([ itadFetch(`/games/info/v2?id=${itadId}`), itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify([itadId]), }), ]) : [null, null]; const companies = igdb?.involved_companies ?? []; const devList: string[] = companies.length > 0 ? [ ...new Set( companies .filter((c) => c.developer) .map((c) => c.company?.name) .filter((n): n is string => !!n), ), ] : (steam?.developers ?? (itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || [])); const pubList: string[] = companies.length > 0 ? [ ...new Set( companies .filter((c) => c.publisher) .map((c) => c.company?.name) .filter((n): n is string => !!n), ), ] : (steam?.publishers ?? (itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || [])); const igdbPlatforms = new Set(); for (const p of igdb?.platforms ?? []) { const raw = (p.name ?? "").trim(); if (!raw) { continue; } if (raw === "PC (Microsoft Windows)") { igdbPlatforms.add("Windows"); } else if (raw === "Mac") { igdbPlatforms.add("MacOS"); } else { igdbPlatforms.add(raw); } } const platforms = new Set(igdbPlatforms); if (platforms.size === 0 && steam?.platforms) { if (steam.platforms.windows) { platforms.add("Windows"); } if (steam.platforms.mac) { platforms.add("MacOS"); } if (steam.platforms.linux) { platforms.add("Linux"); } for (const sku of entry?.third_party_skus ?? []) { const mapped = DISTRIBUTOR_PLATFORM[sku.distributor]; if (mapped) { platforms.add(mapped); } } } const links: GameExternalLink[] = []; for (const site of igdb?.websites ?? []) { if (!site.url) { continue; } const url = site.url.startsWith("http") ? site.url : `https://${site.url}`; let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null; if (!label) { try { label = new URL(url).hostname.replace(/^www\./, ""); } catch { continue; } } if (!links.some((l) => l.url === url)) { links.push({ label, url }); } } const screenshots: string[] = [ ...(igdb?.screenshots ?? []) .map((s) => igdbImage(s.url, "t_screenshot_big")) .filter((u): u is string => !!u), ...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u), ].slice(0, 8); const priceEntry = itadOverview?.prices?.[0]; const toPoint = (p: ItadOverviewPrice["current"]): GamePricePoint | null => { if (!p?.price || typeof p.price.amount !== "number") { return null; } return { price: p.price.amount, regular: p.regular?.amount ?? p.price.amount, currency: p.price.currency ?? "", shop: p.shop?.name ?? "", url: p.url ?? "", }; }; return { applicationId, gameId: appInfo.gameId, gameUrl: appInfo.gameUrl, name: igdb?.name ?? steam?.name ?? appInfo.name, genres: [ ...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n), ...(igdb?.genres?.length ? [] : (itadInfo?.tags ?? []).slice(0, 5)), ].slice(0, 6), description: lang === "en" ? (igdb?.summary ?? steam?.short_description ?? null) : (steam?.short_description ?? igdb?.summary ?? null), developers: devList, publishers: pubList, releaseDate: toIsoDate(igdb?.first_release_date) ?? itadInfo?.releaseDate ?? steam?.release_date?.date ?? null, platforms: [...platforms], links, coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"), artworkUrl: igdbImage(igdb?.artworks?.[0]?.url, "t_1080p") ?? steam?.header_image ?? itadInfo?.assets?.banner600 ?? null, screenshots, steamId, steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null, igdbUrl: igdb?.slug ? `https://www.igdb.com/games/${igdb.slug}` : null, steamReviews, itad: process.env.ITAD_API_KEY && (itadInfo || priceEntry) ? { prices: toPoint(priceEntry?.current), historyLow: toPoint(priceEntry?.lowest), pageUrl: itadInfo?.urls?.game ?? null, playerCount: itadInfo?.players?.recent ?? null, reviews: (itadInfo?.reviews ?? []) .filter((r) => r.source && r.url) .map((r) => ({ score: r.score ?? null, source: r.source as string, count: r.count ?? null, url: r.url as string, })), } : null, sources: { igdb: !!igdb, steam: !!steam, itad: !!itadInfo, }, }; }