- Log Steam, ITAD and detectables upstream failures with status codes for diagnosis - Show retry affordance when all profile sources come back empty or the load fails
626 lines
19 KiB
TypeScript
626 lines
19 KiB
TypeScript
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<DetectableEntry[]> {
|
|
if (detectablesCache && Date.now() < detectablesCache.expires) {
|
|
return detectablesCache.data;
|
|
}
|
|
try {
|
|
const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json", {
|
|
next: { revalidate: 86_400 },
|
|
});
|
|
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<string, string> = {
|
|
steam: "PC",
|
|
battlenet: "PC",
|
|
gog: "PC",
|
|
epic: "PC",
|
|
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<string, string> = {
|
|
tr: "turkish",
|
|
en: "english",
|
|
de: "german",
|
|
};
|
|
|
|
async function getSteamDetails(
|
|
steamId: string,
|
|
lang = "en",
|
|
): Promise<SteamAppDetails["data"] | null> {
|
|
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<string, SteamAppDetails>;
|
|
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<GameReviewSummary | null> {
|
|
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;
|
|
|
|
async function getIgdbToken(): Promise<string | null> {
|
|
const clientId = process.env.TWITCH_CLIENT_ID;
|
|
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
|
|
if (!clientId || !clientSecret) {
|
|
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<T>(token: string, endpoint: string, body: string): Promise<T | null> {
|
|
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<IgdbGame | null> {
|
|
const token = await getIgdbToken();
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
if (steamId) {
|
|
const external = await igdbQuery<Array<{ game?: number }>>(
|
|
token,
|
|
"external_games",
|
|
`fields game; where uid = "${steamId}"; limit 1;`,
|
|
);
|
|
const gameId = external?.[0]?.game;
|
|
if (gameId) {
|
|
const games = await igdbQuery<IgdbGame[]>(
|
|
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<IgdbGame[]>(
|
|
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<number, string> = {
|
|
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;
|
|
}
|
|
|
|
async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
|
|
const key = process.env.ITAD_API_KEY;
|
|
if (!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<string | null> {
|
|
const results = await itadFetch<Array<{ id?: string; title?: string }>>(
|
|
`/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<GameProfile | null> {
|
|
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<ItadGameInfo>(`/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<string>();
|
|
for (const p of igdb?.platforms ?? []) {
|
|
const label = `${p.abbreviation ?? ""} ${p.name ?? ""}`.toUpperCase();
|
|
if (/PS|PLAYSTATION/.test(label)) {
|
|
igdbPlatforms.add("PlayStation");
|
|
} else if (/XBOX|XONE|XSX/.test(label)) {
|
|
igdbPlatforms.add("Xbox");
|
|
} else if (/PC|WIN|MAC|LIN/.test(label)) {
|
|
igdbPlatforms.add("PC");
|
|
} else if (/SWITCH/.test(label)) {
|
|
igdbPlatforms.add("Switch");
|
|
}
|
|
}
|
|
const platforms = new Set<string>(igdbPlatforms);
|
|
if (platforms.size === 0) {
|
|
if (
|
|
steam?.platforms &&
|
|
(steam.platforms.windows || steam.platforms.mac || steam.platforms.linux)
|
|
) {
|
|
platforms.add("PC");
|
|
}
|
|
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,
|
|
},
|
|
};
|
|
}
|