desloppify: code health overhaul plus abuse hardening (140 green, strict 87.2)
Build & Push / Lint & Build (pull_request) Failing after 52s
Build & Push / Build & Push Image (pull_request) Skipped
Build & Push / Trigger Deploy (pull_request) Skipped

Honest baseline after removing 45 gaming suppressions. Structural: lib/game plus root hooks extraction, CORS origin echo, DEV_ORIGINS layering. Fixes: error-cause binding, discord 5xx split, null-cache guard, co-located tests, rejecting-fetch coverage, naming and typing cleanup, socket controller, cache bound, cascade helpers, latch resets. Hardening: per-IP rate limit on API routes, deterministic-miss UX, retry cooldown plus cap. Suite 140 green, build plus lint clean.
This commit is contained in:
fristy committed 2026-09-11 23:54:59 +03:00
1 parent 7d6d31265b
commit 864b9226fc
78 files changed
+4908 -1430

No files matched your search

-19
View File
@@ -1,19 +0,0 @@
import { NextResponse } from "next/server";
const ALLOWED_ORIGIN = "https://fristy.net";
export function isAllowedOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
return origin === null || origin === ALLOWED_ORIGIN;
}
export function corsHeaders(): HeadersInit {
return {
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
Vary: "Origin",
};
}
export function forbidden(): NextResponse {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { invalidIdResponse, jsonErrorWithCors, jsonWithCors, parseAppId } from "@/lib/api-route";
function contextWith(id: string) {
return { params: Promise.resolve({ id }) };
}
describe("parseAppId", () => {
it("accepts numeric ids", async () => {
await expect(parseAppId(contextWith("12345"))).resolves.toBe("12345");
});
it("rejects non-numeric ids", async () => {
await expect(parseAppId(contextWith("abc"))).resolves.toBeNull();
await expect(parseAppId(contextWith(""))).resolves.toBeNull();
});
});
describe("invalidIdResponse", () => {
it("returns a 400 JSON response", async () => {
const response = invalidIdResponse();
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Invalid application id" });
});
});
describe("jsonErrorWithCors", () => {
it("sets status, cache, and CORS headers", async () => {
const response = jsonErrorWithCors({ error: "Nope" }, 404);
expect(response.status).toBe(404);
expect(response.headers.get("Cache-Control")).toContain("s-maxage=60");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
await expect(response.json()).resolves.toEqual({ error: "Nope" });
});
});
describe("jsonWithCors", () => {
it("sets cache and CORS headers", async () => {
const response = jsonWithCors({ ok: true }, 3600);
expect(response.headers.get("Cache-Control")).toContain("s-maxage=3600");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
await expect(response.json()).resolves.toEqual({ ok: true });
});
it("echoes a validated dev origin instead of the production URL", async () => {
const response = jsonWithCors({ ok: true }, 3600, "http://localhost:3000");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:3000");
});
it("falls back to production for untrusted origins", async () => {
const response = jsonWithCors({ ok: true }, 3600, "https://evil.example");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
});
});
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { DEV_ORIGINS, SITE_URL } from "@/lib/site";
const ALLOWED_ORIGIN = SITE_URL;
function isDev(): boolean {
return process.env.NODE_ENV !== "production";
}
export function isAllowedOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
if (origin === null) {
return true;
}
if (origin === ALLOWED_ORIGIN) {
return true;
}
if (isDev() && DEV_ORIGINS.has(origin)) {
return true;
}
return false;
}
function resolveAllowedOrigin(origin: string | null): string {
if (
origin !== null &&
(origin === ALLOWED_ORIGIN || (isDev() && DEV_ORIGINS.has(origin)))
) {
return origin;
}
return ALLOWED_ORIGIN;
}
export function corsHeaders(origin?: string | null): Record<string, string> {
return {
"Access-Control-Allow-Origin": resolveAllowedOrigin(origin ?? null),
Vary: "Origin",
};
}
export function forbidden(origin?: string | null): NextResponse {
return NextResponse.json(
{ error: "Forbidden" },
{
status: 403,
headers: {
"Cache-Control": "public, s-maxage=0, stale-while-revalidate=86400",
...corsHeaders(origin),
},
},
);
}
export async function parseAppId(context: {
params: Promise<{ id: string }>;
}): Promise<string | null> {
const { id } = await context.params;
if (!/^\d{1,20}$/.test(id)) {
return null;
}
return id;
}
export function invalidIdResponse(origin?: string | null): NextResponse {
return jsonErrorWithCors({ error: "Invalid application id" }, 400, 0, origin);
}
export function jsonErrorWithCors(
data: unknown,
status: number,
sMaxage = 60,
origin?: string | null,
): NextResponse {
return NextResponse.json(data, {
status,
headers: {
"Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`,
...corsHeaders(origin),
},
});
}
export function jsonWithCors(
data: unknown,
sMaxage = 3600,
origin?: string | null,
): NextResponse {
return NextResponse.json(data, {
headers: {
"Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`,
...corsHeaders(origin),
},
});
}
export function requestOrigin(request: Request): string | null {
return request.headers.get("origin");
}
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchJson } from "@/lib/client-fetch";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("fetchJson", () => {
it("returns parsed JSON on success", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ a: 1 }) }) as Response),
);
await expect(fetchJson<{ a: number }>("https://example.com")).resolves.toEqual({ a: 1 });
});
it("returns null on HTTP error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
await expect(fetchJson("https://example.com")).resolves.toBeNull();
});
it("returns null on network failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(fetchJson("https://example.com")).resolves.toBeNull();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { warnDev } from "@/lib/log";
export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null> {
try {
const response = await fetch(url, init);
if (!response.ok) {
warnDev(`client fetch failed: ${url.split("?")[0]} -> ${response.status}`);
return null;
}
return (await response.json()) as T;
} catch {
warnDev(`client fetch failed: ${url.split("?")[0]}`);
return null;
}
}
-635
View File
@@ -1,635 +0,0 @@
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");
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> = {
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;
let igdbKeyWarned = false;
async function getIgdbToken(): Promise<string | null> {
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<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;
}
let itadKeyWarned = false;
async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
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<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 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<string>(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,
},
};
}
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { _resetDetectablesCache, getDetectables, steamIdFromEntry } from "@/lib/game/detectables";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
_resetDetectablesCache();
});
describe("steamIdFromEntry", () => {
it("extracts numeric Steam SKUs", () => {
expect(
steamIdFromEntry({
id: "game",
name: "Game",
third_party_skus: [{ distributor: "steam", id: "12345" }],
}),
).toBe("12345");
});
it("rejects non-numeric or non-Steam SKUs", () => {
expect(
steamIdFromEntry({
id: "game",
name: "Game",
third_party_skus: [{ distributor: "steam", id: "abc" }],
}),
).toBeNull();
expect(
steamIdFromEntry({
id: "game",
name: "Game",
third_party_skus: [{ distributor: "gog", id: "12345" }],
}),
).toBeNull();
});
it("handles missing entries", () => {
expect(steamIdFromEntry(undefined)).toBeNull();
});
});
describe("getDetectables", () => {
it("caches the snapshot within the TTL", async () => {
const fetchMock = vi.fn(
async () =>
({
ok: true,
json: async () => [{ id: "1", name: "Game" }],
}) as Response,
);
vi.stubGlobal("fetch", fetchMock);
const first = await getDetectables();
const second = await getDetectables();
expect(first).toHaveLength(1);
expect(second).toBe(first);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("falls back to empty when fetch rejects", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getDetectables()).resolves.toEqual([]);
});
it("falls back to empty on HTTP error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
await expect(getDetectables()).resolves.toEqual([]);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { warnDev } from "@/lib/log";
import type { DetectableEntry } from "@/lib/game/game-profile-types";
const DETECTABLES_URL = "https://cdn.discordapp.com/detectables/games-v1.json";
const DETECTABLES_TTL_MS = 86_400_000;
let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null;
export function _resetDetectablesCache(): void {
detectablesCache = null;
}
export async function getDetectables(): Promise<DetectableEntry[]> {
if (detectablesCache && Date.now() < detectablesCache.expires) {
return detectablesCache.data;
}
try {
const response = await fetch(DETECTABLES_URL);
if (!response.ok) {
warnDev(`detectables failed: ${response.status}`);
return detectablesCache?.data ?? [];
}
const data = (await response.json()) as DetectableEntry[];
detectablesCache = { data, expires: Date.now() + DETECTABLES_TTL_MS };
return data;
} catch (error) {
warnDev(`detectables request failed: ${error instanceof Error ? error.message : error}`);
return detectablesCache?.data ?? [];
}
}
export 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;
}
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getDiscordAppInfo } from "@/lib/game/discord-app";
afterEach(() => {
vi.unstubAllGlobals();
});
function okRpc(overrides = {}) {
return {
ok: true,
json: async () => ({ id: "123", name: "Game", ...overrides }),
} as Response;
}
describe("getDiscordAppInfo", () => {
it("returns null for non-numeric ids without fetching", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(getDiscordAppInfo("abc")).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("maps a successful RPC response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => okRpc()),
);
const info = await getDiscordAppInfo("123");
expect(info?.gameUrl).toBe("https://discord.com/games/123");
});
it("returns null on HTTP error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 404 }) as Response),
);
await expect(getDiscordAppInfo("123")).resolves.toBeNull();
});
it("throws on network failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getDiscordAppInfo("123")).rejects.toThrow("discord rpc unreachable");
});
});
+95
View File
@@ -0,0 +1,95 @@
import { warnDev } from "@/lib/log";
export function discordAppIconUrl(
applicationId: string,
iconHash: string | null,
size = 128,
): string | null {
if (!iconHash) {
return null;
}
const extension = iconHash.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`;
}
export function discordGameUrl(gameId: string): string {
return `https://discord.com/games/${gameId}`;
}
export function discordAppCoverUrl(
applicationId: string,
coverHash: string | null,
size = 512,
): string | null {
if (!coverHash) {
return null;
}
return `https://cdn.discordapp.com/app-icons/${applicationId}/${coverHash}.png?size=${size}`;
}
export function steamdbAppUrl(steamId: string): string {
return `https://steamdb.info/app/${steamId}/`;
}
export interface DiscordAppInfo {
id: string;
name: string;
icon: string | null;
iconUrl: string | null;
coverImage: string | null;
coverUrl: string | null;
gameId: string;
gameUrl: string;
}
interface DiscordRpcResponse {
id?: string;
name?: string;
icon?: string | null;
cover_image?: string | null;
linked_games?: Array<{ id: string }> | null;
}
export async function getDiscordAppInfo(applicationId: string): Promise<DiscordAppInfo | null> {
if (!/^\d+$/.test(applicationId)) {
return null;
}
let response: Response;
try {
response = await fetch(`https://discord.com/api/v10/applications/${applicationId}/rpc`, {
headers: { "User-Agent": "fristy.net (personal site)" },
next: { revalidate: 86_400 },
});
} catch (error) {
throw new Error(`discord rpc unreachable: ${applicationId}`, { cause: error });
}
if (!response.ok) {
if (response.status === 404) {
return null;
}
if (response.status >= 500 || response.status === 429) {
throw new Error(`discord rpc upstream failed: ${applicationId} -> ${response.status}`, {
cause: new Error(`HTTP ${response.status}`),
});
}
warnDev(`discord app failed: ${response.status}`);
return null;
}
let json: DiscordRpcResponse;
try {
json = (await response.json()) as DiscordRpcResponse;
} catch (error) {
throw new Error(`discord rpc malformed response: ${applicationId}`, { cause: error });
}
const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId;
return {
id: json.id ?? applicationId,
name: json.name ?? "",
icon: json.icon ?? null,
iconUrl: discordAppIconUrl(applicationId, json.icon ?? null),
coverImage: json.cover_image ?? null,
coverUrl: discordAppCoverUrl(applicationId, json.cover_image ?? null),
gameId,
gameUrl: discordGameUrl(gameId),
};
}
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import type { GamePricePoint } from "@/lib/game/game-profile-types";
import {
describeProfile,
discountPercent,
formatCount,
formatElapsed,
formatPrice,
formatReleaseDate,
localizeScore,
normalizeGameTitle,
resolveGameLocale,
selectGameView,
type ModalDict,
} from "@/lib/game/game-format";
const dict = {
reviewScores: {
overwhelminglyPositive: "Son Derece Olumlu",
positive: "Olumlu",
mixed: "Karışık",
},
} as unknown as ModalDict;
const point: GamePricePoint = {
price: 100,
regular: 200,
currency: "USD",
shop: "Steam",
url: "https://example.com",
};
describe("localizeScore", () => {
it("maps known Steam labels", () => {
expect(localizeScore("Overwhelmingly Positive", dict)).toBe("Son Derece Olumlu");
});
it("falls back to the raw label", () => {
expect(localizeScore("Unknown Label", dict)).toBe("Unknown Label");
});
});
describe("discountPercent", () => {
it("computes the discount", () => {
expect(discountPercent(point)).toBe(50);
});
it("returns null without a real discount", () => {
expect(discountPercent({ ...point, regular: 100 })).toBeNull();
expect(discountPercent({ ...point, regular: 0 })).toBeNull();
});
});
describe("formatCount", () => {
it("formats with unit", () => {
expect(formatCount(3, "en-US", "reviews")).toBe("3 reviews");
});
it("renders a dash for nullish values", () => {
expect(formatCount(null, "en-US", "reviews")).toBe("—");
expect(formatCount(undefined, "en-US", "reviews")).toBe("—");
});
});
describe("formatPrice", () => {
it("formats currency", () => {
expect(formatPrice(point, "en-US")).toContain("100");
});
});
describe("formatReleaseDate", () => {
it("localizes ISO dates", () => {
expect(formatReleaseDate("2024-01-15", "en-US")).toContain("2024");
});
it("passes through non-ISO values", () => {
expect(formatReleaseDate("soon", "en-US")).toBe("soon");
});
});
describe("normalizeGameTitle", () => {
it("lowercases and strips marks", () => {
expect(normalizeGameTitle("Elden Ring™")).toBe("elden ring");
});
it("collapses separators", () => {
expect(normalizeGameTitle("Half-Life 2: Episode One")).toBe("half life 2 episode one");
});
});
describe("resolveGameLocale", () => {
it("resolves supported locales", () => {
expect(resolveGameLocale("tr")).toEqual({
lang: "tr",
country: "TR",
localeTag: "tr-TR",
steamLang: "turkish",
});
expect(resolveGameLocale("de").country).toBe("DE");
});
it("falls back to English", () => {
expect(resolveGameLocale("fr").lang).toBe("en");
expect(resolveGameLocale("").localeTag).toBe("en-US");
});
});
describe("selectGameView", () => {
it("falls back without a profile", () => {
expect(selectGameView(null, "Name", "https://fallback")).toEqual({
gameUrl: "https://fallback",
name: "Name",
bestPrice: null,
historyLow: null,
isFree: false,
});
});
});
describe("formatElapsed", () => {
it("formats per-locale units from one table", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
try {
const start = new Date("2024-01-15T10:30:00Z").getTime();
expect(formatElapsed(start, "tr")).toBe("1s 30dk");
expect(formatElapsed(start, "de")).toBe("1 Std. 30 Min.");
expect(formatElapsed(start, "en")).toBe("1h 30m");
expect(formatElapsed(start, "fr")).toBe("1h 30m");
expect(formatElapsed(Date.now(), "tr")).toBe("<1dk");
} finally {
vi.useRealTimers();
}
});
});
describe("describeProfile", () => {
it("handles a null profile", () => {
const summary = describeProfile(null);
expect(summary).toEqual({
dbRating: null,
itadReviews: [],
hasDetails: false,
hasReviews: false,
metascore: null,
opencritic: null,
});
});
});
+211
View File
@@ -0,0 +1,211 @@
import type { GamePricePoint, GameProfile, ItadReview } from "@/lib/game/game-profile-types";
import type { Dictionary } from "@/lib/i18n/get-dictionary";
import type { Locale } from "@/lib/i18n/locales";
export type ModalDict = Dictionary["discord"]["gameModal"];
export const STEAM_SCORE_KEYS: Partial<Record<string, keyof ModalDict["reviewScores"]>> = {
"Overwhelmingly Positive": "overwhelminglyPositive",
"Very Positive": "veryPositive",
Positive: "positive",
"Mostly Positive": "mostlyPositive",
Mixed: "mixed",
"Mostly Negative": "mostlyNegative",
Negative: "negative",
"Very Negative": "veryNegative",
"Overwhelmingly Negative": "overwhelminglyNegative",
};
export const LOCALE_TAGS: Record<Locale, string> = {
tr: "tr-TR",
en: "en-US",
de: "de-DE",
};
export const COUNTRIES: Record<Locale, string> = {
tr: "TR",
en: "US",
de: "DE",
};
export function localizeScore(label: string, dict: ModalDict): string {
const key = STEAM_SCORE_KEYS[label];
return key ? dict.reviewScores[key] : label;
}
export function formatRatingPercent(value: number, localeTag: string): string {
try {
return new Intl.NumberFormat(localeTag, {
style: "percent",
maximumFractionDigits: 2,
}).format(value);
} catch {
return `%${(value * 100).toFixed(2)}`;
}
}
export function formatPrice(point: GamePricePoint, localeTag: string): string {
try {
return new Intl.NumberFormat(localeTag, {
style: "currency",
currency: point.currency,
}).format(point.price);
} catch {
return `${point.price} ${point.currency}`;
}
}
export function formatReleaseDate(value: string, localeTag: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const parsed = new Date(`${value}T00:00:00`);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toLocaleDateString(localeTag, {
day: "numeric",
month: "long",
year: "numeric",
});
}
}
return value;
}
export function formatCount(value: number | null | undefined, localeTag: string, unit: string): string {
if (value == null) {
return "—";
}
return `${value.toLocaleString(localeTag)} ${unit}`;
}
export function discountPercent(point: GamePricePoint): number | null {
if (point.regular > point.price && point.regular > 0) {
return Math.round((1 - point.price / point.regular) * 100);
}
return null;
}
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 function toIsoDate(unixSeconds: number | undefined): string | null {
if (!unixSeconds) {
return null;
}
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
export interface GameView {
gameUrl: string;
name: string;
bestPrice: GamePricePoint | null;
historyLow: GamePricePoint | null;
isFree: boolean;
}
export function selectGameView(
profile: GameProfile | null,
fallbackName: string,
fallbackGameUrl: string,
): GameView {
const bestPrice = profile?.itad?.prices ?? null;
const historyLow = profile?.itad?.historyLow ?? null;
return {
gameUrl: profile?.gameUrl ?? fallbackGameUrl,
name: profile?.name || fallbackName,
bestPrice,
historyLow,
isFree: !!bestPrice && bestPrice.price === 0,
};
}
export interface ProfileSummary {
dbRating: number | null;
itadReviews: ItadReview[];
hasDetails: boolean;
hasReviews: boolean;
metascore: ItadReview | null;
opencritic: ItadReview | null;
}
export function describeProfile(profile: GameProfile | null): ProfileSummary {
const dbRating =
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
: null;
const itadReviews = profile?.itad?.reviews ?? [];
const hasDetails = !!(
profile &&
(profile.developers.length > 0 ||
profile.publishers.length > 0 ||
profile.releaseDate ||
profile.platforms.length > 0 ||
profile.links.length > 0)
);
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
const hasReviews = !!(profile?.steamReviews || metascore || opencritic);
return { dbRating, itadReviews, hasDetails, hasReviews, metascore, opencritic };
}
const DEFAULT_ELAPSED_UNITS = { hour: "h", minute: "m", lessThanMinute: "<1m" };
const ELAPSED_UNITS: Partial<
Record<string, { hour: string; minute: string; lessThanMinute: string }>
> = {
tr: { hour: "s", minute: "dk", lessThanMinute: "<1dk" },
de: { hour: " Std.", minute: " Min.", lessThanMinute: "<1 Min." },
en: DEFAULT_ELAPSED_UNITS,
};
export function formatElapsed(start: number, lang: string): string {
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const units = ELAPSED_UNITS[lang] ?? ELAPSED_UNITS.en ?? DEFAULT_ELAPSED_UNITS;
if (hours > 0) {
return `${hours}${units.hour} ${minutes}${units.minute}`;
}
if (minutes > 0) {
return `${minutes}${units.minute}`;
}
return units.lessThanMinute;
}
export function normalizeGameTitle(value: string): string {
return value
.toLowerCase()
.replace(/[®™©]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
export interface GameLocale {
lang: Locale;
country: string;
localeTag: string;
steamLang: string;
}
const STEAM_LANG: Record<Locale, string> = {
tr: "turkish",
en: "english",
de: "german",
};
export function resolveGameLocale(lang: string): GameLocale {
const resolved = lang === "tr" || lang === "en" || lang === "de" ? lang : "en";
return {
lang: resolved,
country: COUNTRIES[resolved],
localeTag: LOCALE_TAGS[resolved],
steamLang: STEAM_LANG[resolved],
};
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getGameProfile } from "@/lib/game/game-profile";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mockFetch(handler: (url: string, init?: RequestInit) => unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => handler(url, init)),
);
}
function ok(data: unknown) {
return { ok: true, json: async () => data };
}
function baseEnv() {
vi.stubGlobal("process", { ...process, env: {} });
}
describe("getGameProfile", () => {
it("rejects non-numeric ids without fetching", async () => {
baseEnv();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(getGameProfile("abc")).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("assembles a profile from Steam when providers are absent", async () => {
baseEnv();
mockFetch((url) => {
if (url.includes("discord.com")) {
return ok({ id: "1", name: "Game" });
}
if (url.includes("detectables")) {
return ok([
{
id: "1",
name: "Game",
third_party_skus: [{ distributor: "steam", id: "100" }],
},
]);
}
if (url.includes("appdetails")) {
return ok({
"100": {
success: true,
data: {
name: "Game",
developers: ["Dev"],
publishers: ["Pub"],
platforms: { windows: true, mac: false, linux: false },
},
},
});
}
if (url.includes("appreviews")) {
return ok({
query_summary: {
review_score_desc: "Positive",
total_positive: 8,
total_negative: 2,
total_reviews: 10,
},
});
}
throw new Error(`unexpected fetch: ${url}`);
});
const profile = await getGameProfile("1", "en", "US");
expect(profile?.name).toBe("Game");
expect(profile?.developers).toEqual(["Dev"]);
expect(profile?.platforms).toContain("Windows");
expect(profile?.sources).toEqual({ igdb: false, steam: true, itad: false });
expect(profile?.itad).toBeNull();
});
it("returns null when Discord RPC is missing", async () => {
baseEnv();
mockFetch((url) => {
if (url.includes("discord.com")) {
return { ok: false, status: 404 };
}
throw new Error(`unexpected fetch: ${url}`);
});
await expect(getGameProfile("1")).resolves.toBeNull();
});
});
+141
View File
@@ -0,0 +1,141 @@
export interface GameExternalLink {
label: string;
url: string;
}
export interface GameReviewSummary {
label: string;
total: number | null;
positive: number | null;
negative: number | null;
url: string;
}
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;
};
}
export interface DetectableEntry {
id: string;
name: string;
third_party_skus?: Array<{ distributor: string; id: string }>;
}
export 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 }>;
};
}
export interface SteamReviewQuery {
review_score_desc?: string;
total_positive?: number;
total_negative?: number;
total_reviews?: number;
}
export 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;
}
export 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 };
}
export 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;
url?: string;
} | null;
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { buildLinks, mergePeople, mergePlatforms, selectScreenshots } from "@/lib/game/game-profile";
import type { IgdbGame } from "@/lib/game/game-profile-types";
describe("mergePeople", () => {
it("prefers IGDB companies when present", () => {
const igdb = {
involved_companies: [
{ developer: true, company: { name: "Dev Studio" } },
{ publisher: true, company: { name: "Pub Inc" } },
],
} as IgdbGame;
const { developers, publishers } = mergePeople(igdb, null, null);
expect(developers).toEqual(["Dev Studio"]);
expect(publishers).toEqual(["Pub Inc"]);
});
it("falls back to Steam and ITAD lists", () => {
const steam = { developers: ["Valve"], publishers: ["Valve"] };
const { developers, publishers } = mergePeople(null, steam, null);
expect(developers).toEqual(["Valve"]);
expect(publishers).toEqual(["Valve"]);
});
it("returns empty lists without sources", () => {
expect(mergePeople(null, null, null)).toEqual({ developers: [], publishers: [] });
});
});
describe("mergePlatforms", () => {
it("normalizes IGDB platform names", () => {
const igdb = {
platforms: [{ name: "PC (Microsoft Windows)" }, { name: "Mac" }, { name: "Switch" }],
} as IgdbGame;
expect(mergePlatforms(igdb, null, undefined)).toEqual(["Windows", "MacOS", "Switch"]);
});
it("merges IGDB, Steam, and SKU platforms without early exit", () => {
const igdb = { platforms: [{ name: "Switch" }] } as IgdbGame;
const steam = { platforms: { windows: true, mac: false, linux: false } };
const entry = { id: "g", name: "G", third_party_skus: [{ distributor: "xbox", id: "1" }] };
expect(mergePlatforms(igdb, steam, entry)).toEqual(["Switch", "Windows", "Xbox"]);
});
it("still resolves SKUs when Steam data is missing", () => {
const entry = {
id: "g",
name: "G",
third_party_skus: [{ distributor: "playstation", id: "1" }],
};
expect(mergePlatforms(null, null, entry)).toEqual(["PlayStation"]);
});
});
describe("buildLinks", () => {
it("labels known categories and dedupes", () => {
const igdb = {
websites: [
{ url: "https://store.steampowered.com/app/1", category: 13 },
{ url: "https://store.steampowered.com/app/1", category: 13 },
{ url: "example.com", category: 99 },
],
} as IgdbGame;
const links = buildLinks(igdb);
expect(links).toHaveLength(2);
expect(links[0]).toEqual({
label: "Steam",
url: "https://store.steampowered.com/app/1",
});
expect(links[1].label).toBe("example.com");
});
it("returns no links without websites", () => {
expect(buildLinks(null)).toEqual([]);
});
});
describe("selectScreenshots", () => {
it("prefers IGDB shots and caps at 8", () => {
const igdb = {
screenshots: Array.from({ length: 10 }, (_, i) => ({
url: `//images.igdb.com/t_thumb/${i}.jpg`,
})),
} as IgdbGame;
const shots = selectScreenshots(igdb, null);
expect(shots).toHaveLength(8);
expect(shots[0]).toContain("t_screenshot_big");
});
it("falls back to Steam shots", () => {
const steam = { screenshots: [{ path_full: "https://cdn/shot.jpg" }] };
expect(selectScreenshots(null, steam)).toEqual(["https://cdn/shot.jpg"]);
});
});
+287
View File
@@ -0,0 +1,287 @@
import { getDiscordAppInfo } from "@/lib/game/discord-app";
import { getDetectables, steamIdFromEntry } from "@/lib/game/detectables";
import { getSteamDetails, getSteamReviewSummary } from "@/lib/game/steam";
import { getIgdbGame, igdbImage } from "@/lib/game/igdb";
import { getItadId, itadFetch } from "@/lib/game/itad";
import { toIsoDate } from "@/lib/game/game-format";
import type {
GameExternalLink,
GamePricePoint,
GameProfile,
IgdbGame,
ItadGameInfo,
ItadOverviewPrice,
ItadReview,
SteamAppDetails,
} from "@/lib/game/game-profile-types";
import type { DetectableEntry } from "@/lib/game/game-profile-types";
const DISTRIBUTOR_PLATFORM: Partial<Record<string, string>> = {
xbox: "Xbox",
xbox_title: "Xbox",
xbox_game_pass: "Xbox",
playstation: "PlayStation",
nintendo: "Switch",
};
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",
};
type SteamData = SteamAppDetails["data"];
export function mergePeople(
igdb: IgdbGame | null,
steam: SteamData | null,
itadInfo: ItadGameInfo | null,
): { developers: string[]; publishers: string[] } {
const companies = igdb?.involved_companies ?? [];
if (companies.length === 0) {
const steamDevs = steam?.developers?.length
? steam.developers
: itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || [];
const steamPubs = steam?.publishers?.length
? steam.publishers
: itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || [];
return { developers: steamDevs, publishers: steamPubs };
}
const names = (developer: boolean): string[] => [
...new Set(
companies
.filter((c) => (developer ? c.developer : c.publisher))
.map((c) => c.company?.name)
.filter((n): n is string => !!n),
),
];
return { developers: names(true), publishers: names(false) };
}
export function mergePlatforms(
igdb: IgdbGame | null,
steam: SteamData | null,
entry: DetectableEntry | undefined,
): string[] {
const platforms = new Set<string>();
for (const p of igdb?.platforms ?? []) {
const raw = (p.name ?? "").trim();
if (!raw) {
continue;
}
if (raw === "PC (Microsoft Windows)") {
platforms.add("Windows");
} else if (raw === "Mac") {
platforms.add("MacOS");
} else {
platforms.add(raw);
}
}
if (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);
}
}
return [...platforms];
}
export function buildLinks(igdb: IgdbGame | null): GameExternalLink[] {
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 parsed: URL | null = null;
try {
parsed = new URL(url);
} catch {
continue;
}
if (parsed.protocol !== "https:") {
continue;
}
let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null;
if (!label) {
label = parsed.hostname.replace(/^www\./, "");
}
if (!links.some((l) => l.url === url)) {
links.push({ label, url });
}
}
return links;
}
export function selectScreenshots(igdb: IgdbGame | null, steam: SteamData | null): string[] {
return [
...(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);
}
export function buildGenres(igdb: IgdbGame | null, itadInfo: ItadGameInfo | null): string[] {
return [
...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n),
...(igdb?.genres?.some((g) => !!g.name) ? [] : (itadInfo?.tags ?? []).slice(0, 5)),
].slice(0, 6);
}
export function selectDescription(
lang: string,
igdb: IgdbGame | null,
steam: SteamData | null,
): string | null {
return lang === "en"
? (igdb?.summary ?? steam?.short_description ?? null)
: (steam?.short_description ?? igdb?.summary ?? null);
}
export function selectArtwork(
igdb: IgdbGame | null,
steam: SteamData | null,
itadInfo: ItadGameInfo | null,
): string | null {
return (
igdbImage(igdb?.cover?.url, "t_cover_big") ??
steam?.header_image ??
itadInfo?.assets?.banner600 ??
null
);
}
export function toPricePoint(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?.startsWith("https://") ? p.url : "",
};
}
export function mapItadBlock(
itadInfo: ItadGameInfo | null,
priceEntry: { current?: ItadOverviewPrice["current"]; lowest?: ItadOverviewPrice["current"] } | undefined,
): NonNullable<GameProfile["itad"]> | null {
if (!process.env.ITAD_API_KEY || (!itadInfo && !priceEntry)) {
return null;
}
return {
prices: toPricePoint(priceEntry?.current),
historyLow: toPricePoint(priceEntry?.lowest),
pageUrl: itadInfo?.urls?.game ?? null,
playerCount: itadInfo?.players?.recent ?? null,
reviews: (itadInfo?.reviews ?? [])
.filter(
(r): r is ItadReview & { source: string; url: string } =>
!!r.source && !!r.url,
)
.map((r) => ({
score: r.score ?? null,
source: r.source,
count: r.count ?? null,
url: r.url,
})),
};
}
export async function getGameProfile(
applicationId: string,
lang = "en",
country = "TR",
): 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, country) : 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 { developers, publishers } = mergePeople(igdb, steam, itadInfo);
const platforms = mergePlatforms(igdb, steam, entry);
const links = buildLinks(igdb);
const screenshots = selectScreenshots(igdb, steam);
const priceEntry = itadOverview?.prices?.[0];
return {
applicationId,
gameId: appInfo.gameId,
gameUrl: appInfo.gameUrl,
name: igdb?.name ?? steam?.name ?? appInfo.name,
genres: buildGenres(igdb, itadInfo),
description: selectDescription(lang, igdb, steam),
developers,
publishers,
releaseDate:
toIsoDate(igdb?.first_release_date) ??
itadInfo?.releaseDate ??
steam?.release_date?.date ??
null,
platforms,
links,
coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"),
artworkUrl: selectArtwork(igdb, steam, itadInfo),
screenshots,
steamId,
steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null,
igdbUrl: igdb?.slug ? `https://www.igdb.com/games/${igdb.slug}` : null,
steamReviews,
itad: mapItadBlock(itadInfo, priceEntry),
sources: {
igdb: !!igdb,
steam: !!steam,
itad: !!(itadInfo || priceEntry),
},
};
}
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { _resetIgdbState, getIgdbGame, igdbImage } from "@/lib/game/igdb";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
_resetIgdbState();
});
function tokenResponse() {
return {
ok: true,
json: async () => ({ access_token: "tok", expires_in: 3600 }),
} as Response;
}
describe("igdbImage", () => {
it("upgrades thumbnail sizes", () => {
expect(igdbImage("//images.igdb.com/t_thumb/1.jpg", "t_cover_big")).toBe(
"https://images.igdb.com/t_cover_big/1.jpg",
);
});
it("returns null without a url", () => {
expect(igdbImage(undefined, "t_cover_big")).toBeNull();
});
});
describe("getIgdbGame", () => {
it("returns null without credentials and warns once", async () => {
vi.stubGlobal("process", { ...process, env: {} });
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns null when the token fetch rejects", async () => {
vi.stubGlobal("process", {
...process,
env: { TWITCH_CLIENT_ID: "id", TWITCH_CLIENT_SECRET: "secret" },
});
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
});
it("escapes backslashes and quotes in search queries", async () => {
vi.stubGlobal("process", {
...process,
env: { TWITCH_CLIENT_ID: "id", TWITCH_CLIENT_SECRET: "secret" },
});
const bodies: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (typeof init?.body === "string" && init.body.includes("external_games")) {
return { ok: true, json: async () => [] } as Response;
}
if (typeof init?.body === "string") {
bodies.push(init.body);
return { ok: true, json: async () => [] } as Response;
}
return tokenResponse();
}),
);
await expect(getIgdbGame('a\\b"c', null)).resolves.toBeNull();
expect(bodies.some((b) => b.includes('a\\\\b\\"c'))).toBe(true);
});
});
+139
View File
@@ -0,0 +1,139 @@
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<string | null> {
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<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) {
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<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 escaped = cleaned.replace(/\\/g, "\\\\").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 = normalizeGameTitle(name);
return games.find((g) => g.name && normalizeGameTitle(g.name) === wanted) ?? games[0] ?? null;
}
+69
View File
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { _resetItadWarning, getItadId, itadFetch } from "@/lib/game/itad";
import { toIsoDate } from "@/lib/game/game-format";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
_resetItadWarning();
});
describe("toIsoDate", () => {
it("converts unix seconds to ISO dates", () => {
expect(toIsoDate(1705276800)).toBe("2024-01-15");
});
it("returns null for missing values", () => {
expect(toIsoDate(undefined)).toBeNull();
expect(toIsoDate(0)).toBeNull();
});
});
describe("itadFetch", () => {
it("returns null without an API key and without fetching", async () => {
vi.stubGlobal("process", { ...process, env: {} });
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns data on success and null on HTTP error", async () => {
vi.stubGlobal("process", { ...process, env: { ITAD_API_KEY: "k" } });
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ id: "1" }) }) as Response),
);
await expect(itadFetch("/games/info/v2")).resolves.toEqual({ id: "1" });
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 429 }) as Response),
);
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
});
});
describe("getItadId", () => {
it("matches normalized titles", async () => {
vi.stubGlobal("process", { ...process, env: { ITAD_API_KEY: "k" } });
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({
ok: true,
json: async () => [{ id: "7", title: "Elden Ring" }],
}) as Response,
),
);
await expect(getItadId("ELDEN RING")).resolves.toBe("7");
await expect(getItadId("Unknown")).resolves.toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { normalizeGameTitle } from "@/lib/game/game-format";
import { warnDev } from "@/lib/log";
let itadKeyWarned = false;
export function _resetItadWarning(): void {
itadKeyWarned = false;
}
export async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
const key = process.env.ITAD_API_KEY;
if (!key) {
if (!itadKeyWarned) {
itadKeyWarned = true;
warnDev("itad not configured");
}
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) {
warnDev(`itad failed: ${response.status}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
warnDev(`itad request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
export 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 wanted = normalizeGameTitle(title);
return results.find((r) => r.title && normalizeGameTitle(r.title) === wanted)?.id ?? null;
}
+109
View File
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
activityImageUrl,
discordAvatarUrl,
formatSpotifyList,
getLanyard,
spotifyTrackUrl,
type LanyardActivity,
} from "@/lib/game/lanyard";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function activity(overrides: Partial<LanyardActivity> = {}): LanyardActivity {
return { id: "1", name: "Game", type: 0, ...overrides };
}
describe("activityImageUrl", () => {
it("resolves external media keys", () => {
expect(activityImageUrl(activity({ assets: { large_image: "mp:abc" } }), "large")).toBe(
"https://media.discordapp.net/external/abc",
);
});
it("resolves Spotify keys", () => {
expect(activityImageUrl(activity({ assets: { large_image: "spotify:img" } }), "large")).toBe(
"https://i.scdn.co/image/img",
);
});
it("resolves app assets with application id", () => {
expect(
activityImageUrl(
activity({ application_id: "9", assets: { large_image: "art" } }),
"large",
),
).toBe("https://cdn.discordapp.com/app-assets/9/art.png");
});
it("returns null without a key", () => {
expect(activityImageUrl(activity(), "large")).toBeNull();
});
it("picks small vs large art", () => {
const withBoth = activity({
assets: { large_image: "mp:big", small_image: "mp:small" },
});
expect(activityImageUrl(withBoth, "small")).toContain("small");
expect(activityImageUrl(withBoth, "large")).toContain("big");
});
});
describe("formatSpotifyList", () => {
it("normalizes separators", () => {
expect(formatSpotifyList("a;b, ,c")).toBe("a,b,c");
expect(formatSpotifyList("a b")).toBe("a b");
});
it("returns empty for nullish input", () => {
expect(formatSpotifyList(null)).toBe("");
expect(formatSpotifyList(undefined)).toBe("");
});
});
describe("spotifyTrackUrl", () => {
it("builds the track URL", () => {
expect(spotifyTrackUrl("xyz")).toBe("https://open.spotify.com/track/xyz");
});
});
describe("discordAvatarUrl", () => {
it("falls back to the default avatar", () => {
expect(discordAvatarUrl("1", null)).toContain("embed/avatars/0.png");
});
it("picks gif for animated hashes", () => {
expect(discordAvatarUrl("1", "a_hash")).toContain(".gif");
expect(discordAvatarUrl("1", "hash")).toContain(".png");
});
});
describe("getLanyard", () => {
it("returns data on success and null otherwise", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ success: true, data: {} }) })),
);
await expect(getLanyard()).resolves.toEqual({});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ success: false }) })),
);
await expect(getLanyard()).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
await expect(getLanyard()).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getLanyard()).resolves.toBeNull();
});
});
+13 -77
View File
@@ -1,6 +1,8 @@
import { warnDev } from "@/lib/log";
export const DISCORD_ID = "1014549805608992901";
const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`;
export const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`;
export const LANYARD_WS = "wss://api.lanyard.rest/socket";
@@ -31,8 +33,13 @@ export interface LanyardActivity {
assets?: LanyardActivityAssets | null;
}
export function activityImageUrl(activity: LanyardActivity, small: boolean): string | null {
const key = small ? activity.assets?.small_image : activity.assets?.large_image;
export type ActivityImageSize = "small" | "large";
export function activityImageUrl(
activity: LanyardActivity,
size: ActivityImageSize = "large",
): string | null {
const key = size === "small" ? activity.assets?.small_image : activity.assets?.large_image;
if (!key) {
return null;
}
@@ -75,75 +82,6 @@ export function spotifyTrackUrl(trackId: string): string {
return `https://open.spotify.com/track/${trackId}`;
}
export function discordAppIconUrl(
applicationId: string,
iconHash: string | null,
size = 128,
): string | null {
if (!iconHash) {
return null;
}
const extension = iconHash.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`;
}
export function discordGameUrl(gameId: string): string {
return `https://discord.com/games/${gameId}`;
}
export interface DiscordAppInfo {
id: string;
name: string;
icon: string | null;
iconUrl: string | null;
coverImage: string | null;
coverUrl: string | null;
gameId: string;
gameUrl: string;
}
interface DiscordRpcResponse {
id: string;
name: string;
icon?: string | null;
cover_image?: string | null;
linked_games?: Array<{ id: string }> | null;
}
export async function getDiscordAppInfo(applicationId: string): Promise<DiscordAppInfo | null> {
if (!/^\d+$/.test(applicationId)) {
return null;
}
try {
const response = await fetch(
`https://discord.com/api/v10/applications/${applicationId}/rpc`,
{
headers: { "User-Agent": "fristy.net (personal site)" },
next: { revalidate: 86_400 },
},
);
if (!response.ok) {
return null;
}
const json = (await response.json()) as DiscordRpcResponse;
const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId;
return {
id: json.id ?? applicationId,
name: json.name ?? "",
icon: json.icon ?? null,
iconUrl: discordAppIconUrl(applicationId, json.icon ?? null),
coverImage: json.cover_image ?? null,
coverUrl: json.cover_image
? `https://cdn.discordapp.com/app-icons/${applicationId}/${json.cover_image}.png?size=512`
: null,
gameId,
gameUrl: discordGameUrl(gameId),
};
} catch {
return null;
}
}
export interface LanyardData {
discord_user: {
id: string;
@@ -172,19 +110,17 @@ export async function getLanyard(): Promise<LanyardData | null> {
try {
const response = await fetch(LANYARD_API, { next: { revalidate: 10 } });
if (!response.ok) {
warnDev(`lanyard failed: ${response.status}`);
return null;
}
const json = (await response.json()) as LanyardResponse;
return json.success ? json.data : null;
} catch {
} catch (error) {
warnDev(`lanyard request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
export function lanyardUrl(): string {
return LANYARD_API;
}
export function discordAvatarUrl(userId: string, avatarHash: string | null, size = 128): string {
if (!avatarHash) {
return `https://cdn.discordapp.com/embed/avatars/0.png?size=${size}`;
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getSteamDetails, getSteamReviewSummary } from "@/lib/game/steam";
import { steamdbRating } from "@/lib/game/game-format";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("steamdbRating", () => {
it("returns null for invalid input", () => {
expect(steamdbRating(5, 0)).toBeNull();
expect(steamdbRating(-1, 10)).toBeNull();
expect(steamdbRating(11, 10)).toBeNull();
expect(steamdbRating(Number.NaN, 10)).toBeNull();
});
it("pulls low-count ratios toward 50%", () => {
const rating = steamdbRating(9, 10);
expect(rating).not.toBeNull();
expect(rating as number).toBeLessThan(0.9);
expect(rating as number).toBeGreaterThan(0.5);
});
it("converges to the raw ratio with many reviews", () => {
const rating = steamdbRating(900000, 1000000);
expect(rating).not.toBeNull();
expect(Math.abs((rating as number) - 0.9)).toBeLessThan(0.01);
});
it("rates unanimous positives below 100%", () => {
const rating = steamdbRating(10, 10);
expect(rating).not.toBeNull();
expect(rating as number).toBeLessThan(1);
});
});
describe("getSteamDetails", () => {
it("returns entry data on success", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({
ok: true,
json: async () => ({ "100": { success: true, data: { name: "Game" } } }),
}) as Response,
),
);
await expect(getSteamDetails("100")).resolves.toEqual({ name: "Game" });
});
it("returns null when fetch rejects", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getSteamDetails("100")).resolves.toBeNull();
});
it("returns null on failed entries and HTTP errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ "100": { success: false } }) })),
);
await expect(getSteamDetails("100")).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
await expect(getSteamDetails("100")).resolves.toBeNull();
});
});
describe("getSteamReviewSummary", () => {
it("maps the query summary", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({
ok: true,
json: async () => ({
query_summary: {
review_score_desc: "Positive",
total_positive: 9,
total_negative: 1,
total_reviews: 10,
},
}),
}) as Response,
),
);
const summary = await getSteamReviewSummary("100");
expect(summary?.total).toBe(10);
expect(summary?.url).toContain("100");
});
it("returns null without a numeric total", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ query_summary: {} }) })),
);
await expect(getSteamReviewSummary("100")).resolves.toBeNull();
});
});
+58
View File
@@ -0,0 +1,58 @@
import type {
GameReviewSummary,
SteamAppDetails,
SteamReviewQuery,
} from "@/lib/game/game-profile-types";
import { resolveGameLocale } from "@/lib/game/game-format";
import { warnDev } from "@/lib/log";
export async function getSteamDetails(
steamId: string,
lang = "en",
country = "US",
): Promise<SteamAppDetails["data"] | null> {
try {
const { steamLang } = resolveGameLocale(lang);
const response = await fetch(
`https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=${country}`,
{ next: { revalidate: 86_400 } },
);
if (!response.ok) {
warnDev(`steam details failed: ${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 (error) {
warnDev(`steam details request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
export 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) {
warnDev(`steam reviews failed: ${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 (error) {
warnDev(`steam reviews request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getRepos } from "@/lib/gitea";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function apiRepo(overrides: Record<string, unknown>) {
return {
id: 0,
name: "repo",
full_name: "fristy/repo",
description: null,
language: null,
html_url: "https://git.fristy.net/fristy/repo",
website: "",
stars_count: 0,
forks_count: 0,
updated_at: "2024-01-01T00:00:00Z",
...overrides,
};
}
describe("getRepos", () => {
it("maps, filters, and sorts repos", async () => {
const payload = [
apiRepo({ id: 2, name: "old", full_name: "fristy/old", language: "TypeScript" }),
apiRepo({ id: 1, name: ".profile", updated_at: "2025-01-01T00:00:00Z" }),
apiRepo({
id: 3,
name: "new",
full_name: "fristy/new",
description: "fresh",
language: "Go",
website: "https://example.com",
stars_count: 5,
forks_count: 2,
updated_at: "2026-01-01T00:00:00Z",
}),
];
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => payload })),
);
const repos = await getRepos();
expect(repos.map((r) => r.name)).toEqual(["new", "old"]);
expect(repos[0]?.website).toBe("https://example.com");
expect(repos[1]?.website).toBeNull();
});
it("returns empty on HTTP error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 })),
);
await expect(getRepos()).resolves.toEqual([]);
});
});
+5 -3
View File
@@ -1,3 +1,5 @@
import { warnDev } from "@/lib/log";
const GITEA_API = "https://git.fristy.net/api/v1";
const GITEA_USER = "fristy";
@@ -36,7 +38,7 @@ export async function getRepos(): Promise<GiteaRepo[]> {
{ next: { revalidate: 3600 } },
);
if (!response.ok) {
console.error(`cannot fetch gitea repos: ${response.status}`);
warnDev(`cannot fetch gitea repos: ${response.status}`);
return [];
}
const data = (await response.json()) as GiteaApiRepo[];
@@ -55,8 +57,8 @@ export async function getRepos(): Promise<GiteaRepo[]> {
updatedAt: repo.updated_at,
}))
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
} catch (error) {
console.error("cannot fetch gitea repos:", error);
} catch {
warnDev("cannot fetch gitea repos");
return [];
}
}
+2 -1
View File
@@ -49,11 +49,12 @@
"loadError": "Spieldaten konnten nicht geladen werden.",
"retry": "Erneut versuchen",
"partialFail": "Einige Daten konnten nicht geladen werden.",
"noGameData": "Für diese App wurden keine Spieldaten gefunden.",
"screenshots": "Screenshots",
"reviewsUnit": "Bewertungen",
"scrollBack": "Zurück",
"scrollForward": "Weiter",
"totalReviews": "Bewertungen insgesamt",
"totalReviews": "Steam",
"steamdbRating": "SteamDB-Wertung",
"reviewScores": {
"overwhelminglyPositive": "Äußerst positiv",
+2 -1
View File
@@ -49,11 +49,12 @@
"loadError": "Couldn't load game info.",
"retry": "Retry",
"partialFail": "Some data failed to load.",
"noGameData": "No game data found for this app.",
"screenshots": "Screenshots",
"reviewsUnit": "reviews",
"scrollBack": "Back",
"scrollForward": "Forward",
"totalReviews": "Total Reviews",
"totalReviews": "Steam",
"steamdbRating": "SteamDB Rating",
"reviewScores": {
"overwhelminglyPositive": "Overwhelmingly Positive",
+2 -1
View File
@@ -49,11 +49,12 @@
"loadError": "Oyun bilgileri yüklenemedi.",
"retry": "Tekrar Dene",
"partialFail": "Bazı veriler yüklenemedi.",
"noGameData": "Bu uygulama için oyun verisi bulunamadı.",
"screenshots": "Görseller",
"reviewsUnit": "değerlendirme",
"scrollBack": "Geri",
"scrollForward": "İleri",
"totalReviews": "Toplam Değerlendirme",
"totalReviews": "Steam",
"steamdbRating": "SteamDB Puanı",
"reviewScores": {
"overwhelminglyPositive": "Son Derece Olumlu",
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { DEFAULT_LOCALE, matchLocale, type Locale } from "@/lib/i18n/locales";
describe("getDictionary", () => {
it("returns the requested locale", () => {
expect(getDictionary("de").discord.gameProfile).toBe("Spielprofil");
});
it("falls back to Turkish for unknown locales", () => {
expect(getDictionary(matchLocale("fr"))).toEqual(getDictionary("tr"));
});
it("keeps all locales structurally in sync", () => {
const shape = (locale: Locale): string => JSON.stringify(getDictionary(locale));
expect(Object.keys(JSON.parse(shape("en"))).sort()).toEqual(
Object.keys(JSON.parse(shape("tr"))).sort(),
);
expect(Object.keys(JSON.parse(shape("de"))).sort()).toEqual(
Object.keys(JSON.parse(shape("tr"))).sort(),
);
});
it("defaults to Turkish", () => {
expect(DEFAULT_LOCALE).toBe("tr");
});
});
+1 -1
View File
@@ -8,5 +8,5 @@ const dictionaries = { tr, en, de } as const;
export type Dictionary = typeof tr;
export function getDictionary(locale: Locale): Dictionary {
return dictionaries[locale] ?? dictionaries.tr;
return dictionaries[locale];
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
import { cookies } from "next/headers";
import { getLocale } from "@/lib/i18n/get-locale";
describe("getLocale", () => {
it("returns the cookie locale when valid", async () => {
vi.mocked(cookies).mockResolvedValue({ get: () => ({ value: "de" }) } as never);
await expect(getLocale()).resolves.toBe("de");
});
it("falls back to default without a cookie", async () => {
vi.mocked(cookies).mockResolvedValue({ get: () => undefined } as never);
await expect(getLocale()).resolves.toBe("tr");
});
it("falls back on unknown values", async () => {
vi.mocked(cookies).mockResolvedValue({ get: () => ({ value: "fr" }) } as never);
await expect(getLocale()).resolves.toBe("tr");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { isLocale, matchLocale } from "@/lib/i18n/locales";
describe("isLocale", () => {
it("accepts supported locales", () => {
expect(isLocale("tr")).toBe(true);
expect(isLocale("en")).toBe(true);
expect(isLocale("de")).toBe(true);
});
it("rejects anything else", () => {
expect(isLocale("fr")).toBe(false);
expect(isLocale("")).toBe(false);
});
});
describe("matchLocale", () => {
it("defaults without a header", () => {
expect(matchLocale(null)).toBe("tr");
expect(matchLocale("")).toBe("tr");
});
it("matches primary ranges", () => {
expect(matchLocale("en-US,en;q=0.9")).toBe("en");
expect(matchLocale("de-DE,de;q=0.9,en;q=0.8")).toBe("de");
});
it("respects q-values", () => {
expect(matchLocale("en;q=0.5,tr;q=0.9")).toBe("tr");
});
it("falls back on unknown or wildcard ranges", () => {
expect(matchLocale("fr-FR,fr;q=0.9")).toBe("tr");
expect(matchLocale("*")).toBe("tr");
});
});
-16
View File
@@ -1,16 +0,0 @@
"use server";
import { cookies } from "next/headers";
import { LOCALE_COOKIE, isLocale, type Locale } from "./locales";
export async function setLocaleCookie(locale: Locale): Promise<void> {
if (!isLocale(locale)) {
return;
}
const store = await cookies();
store.set(LOCALE_COOKIE, locale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
}
+23
View File
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { warnDev } from "@/lib/log";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("warnDev", () => {
it("warns outside production", () => {
vi.stubGlobal("process", { ...process, env: { ...process.env, NODE_ENV: "test" } });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
warnDev("hello");
expect(warn).toHaveBeenCalledWith("hello");
});
it("stays silent in production", () => {
vi.stubGlobal("process", { ...process, env: { ...process.env, NODE_ENV: "production" } });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
warnDev("hello");
expect(warn).not.toHaveBeenCalled();
});
});
+6
View File
@@ -0,0 +1,6 @@
export function warnDev(message: string): void {
if (process.env.NODE_ENV === "production") {
return;
}
console.warn(message);
}
+4
View File
@@ -7,3 +7,7 @@ export const DISCORD_SERVER_URL = "https://discord.gg/fristy";
export const SPOTIFY_URL = "https://open.spotify.com/user/gayiw5j9i0rtvyra6gfzhpfwf";
export const STEAM_URL = "https://steamcommunity.com/id/fristylikeaillusion";
export const APACHE_LICENSE_URL = "https://www.apache.org/licenses/LICENSE-2.0";
export const DEV_ORIGINS: ReadonlySet<string> = new Set([
"http://localhost:3000",
"http://127.0.0.1:3000",
]);