diff --git a/.gitignore b/.gitignore index 8d5f239..671fdf7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ node_modules .DS_Store .claude +# Override global gitignore exclusions +!/server/ +!/src/ +!**/lib/ + # Secrets .env .env.* diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..77b322b --- /dev/null +++ b/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "whattoplay-server", + "private": true, + "version": "2026.03.01", + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts" + }, + "dependencies": { + "@hono/zod-validator": "^0.5.0", + "hono": "^4.7.0", + "zod": "^3.24.0" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..04ba6c9 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,26 @@ +import { Hono } from "hono" +import { cors } from "hono/cors" +import { gogRouter } from "./features/gog/router.ts" +import { igdbRouter } from "./features/igdb/router.ts" +import { steamRouter } from "./features/steam/router.ts" +import { env } from "./shared/lib/env.ts" + +const app = new Hono() + +app.use( + "/api/*", + cors({ + origin: env.ALLOWED_ORIGIN, + allowMethods: ["GET", "POST"], + allowHeaders: ["Content-Type"], + }), +) + +const apiRoutes = app + .get("/api/health", (c) => c.json({ status: "ok" })) + .route("/api/steam", steamRouter) + .route("/api/gog", gogRouter) + .route("/api/igdb", igdbRouter) + +export type AppType = typeof apiRoutes +export default app diff --git a/server/src/features/gog/router.ts b/server/src/features/gog/router.ts new file mode 100644 index 0000000..126c6b2 --- /dev/null +++ b/server/src/features/gog/router.ts @@ -0,0 +1,16 @@ +import { zValidator } from "@hono/zod-validator" +import { Hono } from "hono" +import { gogAuthInput, gogGamesInput } from "./schema.ts" +import { exchangeGogCode, fetchGogGames } from "./service.ts" + +export const gogRouter = new Hono() + .post("/auth", zValidator("json", gogAuthInput), async (c) => { + const { code } = c.req.valid("json") + const result = await exchangeGogCode(code) + return c.json(result) + }) + .post("/games", zValidator("json", gogGamesInput), async (c) => { + const { accessToken, refreshToken } = c.req.valid("json") + const result = await fetchGogGames(accessToken, refreshToken) + return c.json(result) + }) diff --git a/server/src/features/gog/schema.ts b/server/src/features/gog/schema.ts new file mode 100644 index 0000000..c3d58c3 --- /dev/null +++ b/server/src/features/gog/schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +export const gogAuthInput = z.object({ + code: z.string().min(1), +}) + +export const gogGamesInput = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), +}) + +export const gogGameSchema = z.object({ + id: z.string(), + title: z.string(), + source: z.literal("gog"), + sourceId: z.string(), + platform: z.literal("PC"), + url: z.string().optional(), +}) diff --git a/server/src/features/gog/service.ts b/server/src/features/gog/service.ts new file mode 100644 index 0000000..6ea4a29 --- /dev/null +++ b/server/src/features/gog/service.ts @@ -0,0 +1,116 @@ +const CLIENT_ID = "46899977096215655" +const CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" +const REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client" + +export async function exchangeGogCode(code: string) { + const url = new URL("https://auth.gog.com/token") + url.searchParams.set("client_id", CLIENT_ID) + url.searchParams.set("client_secret", CLIENT_SECRET) + url.searchParams.set("grant_type", "authorization_code") + url.searchParams.set("code", code) + url.searchParams.set("redirect_uri", REDIRECT_URI) + + const response = await fetch(url) + if (!response.ok) { + const text = await response.text() + throw new Error(`GOG Token Exchange Error: ${response.status} ${text}`) + } + + const data = await response.json() + return { + access_token: data.access_token as string, + refresh_token: data.refresh_token as string, + user_id: data.user_id as string, + expires_in: data.expires_in as number, + } +} + +async function refreshAccessToken(refreshToken: string) { + const url = new URL("https://auth.gog.com/token") + url.searchParams.set("client_id", CLIENT_ID) + url.searchParams.set("client_secret", CLIENT_SECRET) + url.searchParams.set("grant_type", "refresh_token") + url.searchParams.set("refresh_token", refreshToken) + + const response = await fetch(url) + if (!response.ok) { + const text = await response.text() + throw new Error(`GOG Token Refresh Error: ${response.status} ${text}`) + } + + const data = await response.json() + return { + access_token: data.access_token as string, + refresh_token: data.refresh_token as string, + expires_in: data.expires_in as number, + } +} + +interface GogProduct { + id: number + title?: string + url?: string +} + +export async function fetchGogGames(accessToken: string, refreshToken: string) { + let token = accessToken + let newTokens: Awaited> | null = null + + let page = 1 + let totalPages = 1 + const allProducts: GogProduct[] = [] + + while (page <= totalPages) { + const url = `https://embed.gog.com/account/getFilteredProducts?mediaType=1&page=${page}` + + let response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }) + + if (response.status === 401 && !newTokens) { + newTokens = await refreshAccessToken(refreshToken) + token = newTokens.access_token + response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }) + } + + if (!response.ok) { + throw new Error(`GOG API Error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + totalPages = data.totalPages || 1 + allProducts.push(...(data.products || [])) + page++ + } + + const games = allProducts + .filter((product): product is GogProduct & { title: string } => Boolean(product.title)) + .map((product) => ({ + id: `gog-${product.id}`, + title: product.title, + source: "gog" as const, + sourceId: String(product.id), + platform: "PC" as const, + url: product.url ? `https://www.gog.com${product.url}` : undefined, + })) + + return { + games, + count: games.length, + ...(newTokens && { + newAccessToken: newTokens.access_token, + newRefreshToken: newTokens.refresh_token, + }), + } +} + +export function getGogAuthUrl() { + const url = new URL("https://auth.gog.com/auth") + url.searchParams.set("client_id", CLIENT_ID) + url.searchParams.set("redirect_uri", REDIRECT_URI) + url.searchParams.set("response_type", "code") + url.searchParams.set("layout", "client2") + return url.toString() +} diff --git a/server/src/features/igdb/cache.ts b/server/src/features/igdb/cache.ts new file mode 100644 index 0000000..df1be65 --- /dev/null +++ b/server/src/features/igdb/cache.ts @@ -0,0 +1,47 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const CACHE_FILE = join(__dirname, "../../data/igdb-cache.json") + +interface CacheEntry { + igdbId: number | null +} + +const cache = new Map() + +export function loadCache() { + try { + const data = readFileSync(CACHE_FILE, "utf-8") + const entries: Record = JSON.parse(data) + for (const [key, value] of Object.entries(entries)) { + cache.set(key, value) + } + console.log(`[IGDB] Cache loaded: ${cache.size} entries`) + } catch { + console.log("[IGDB] No cache file found, starting fresh") + } +} + +export function saveCache() { + try { + mkdirSync(join(__dirname, "../../data"), { recursive: true }) + const obj = Object.fromEntries(cache) + writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2)) + } catch (err) { + console.error("[IGDB] Failed to save cache:", (err as Error).message) + } +} + +export function getCacheEntry(key: string): CacheEntry | undefined { + return cache.get(key) +} + +export function setCacheEntry(key: string, value: CacheEntry) { + cache.set(key, value) +} + +export function getCacheSize() { + return cache.size +} diff --git a/server/src/features/igdb/router.ts b/server/src/features/igdb/router.ts new file mode 100644 index 0000000..a6c8cd5 --- /dev/null +++ b/server/src/features/igdb/router.ts @@ -0,0 +1,21 @@ +import { zValidator } from "@hono/zod-validator" +import { Hono } from "hono" +import { z } from "zod" +import { enrichGamesWithIgdb } from "./service.ts" + +const enrichInput = z.object({ + games: z.array( + z + .object({ + source: z.string(), + sourceId: z.string(), + }) + .passthrough(), + ), +}) + +export const igdbRouter = new Hono().post("/enrich", zValidator("json", enrichInput), async (c) => { + const { games } = c.req.valid("json") + const enriched = await enrichGamesWithIgdb(games) + return c.json({ games: enriched }) +}) diff --git a/server/src/features/igdb/service.ts b/server/src/features/igdb/service.ts new file mode 100644 index 0000000..e5a7ecd --- /dev/null +++ b/server/src/features/igdb/service.ts @@ -0,0 +1,154 @@ +import { env } from "../../shared/lib/env.ts" +import { getCacheEntry, getCacheSize, saveCache, setCacheEntry } from "./cache.ts" + +const CATEGORY_STEAM = 1 +const CATEGORY_GOG = 2 + +const SOURCE_TO_CATEGORY: Record = { + steam: CATEGORY_STEAM, + gog: CATEGORY_GOG, +} + +let twitchToken: string | null = null +let tokenExpiry = 0 + +async function getIgdbToken(): Promise { + if (twitchToken && Date.now() < tokenExpiry) { + return twitchToken + } + + if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) { + return null + } + + const url = new URL("https://id.twitch.tv/oauth2/token") + url.searchParams.set("client_id", env.TWITCH_CLIENT_ID) + url.searchParams.set("client_secret", env.TWITCH_CLIENT_SECRET) + url.searchParams.set("grant_type", "client_credentials") + + const response = await fetch(url, { method: "POST" }) + if (!response.ok) { + console.error(`[IGDB] Twitch auth failed: ${response.status} ${response.statusText}`) + return null + } + + const data = await response.json() + twitchToken = data.access_token + tokenExpiry = Date.now() + (data.expires_in - 300) * 1000 + console.log("[IGDB] Twitch token acquired") + return twitchToken +} + +async function igdbRequest(endpoint: string, query: string): Promise { + const token = await getIgdbToken() + if (!token) return [] + + const response = await fetch(`https://api.igdb.com/v4${endpoint}`, { + method: "POST", + headers: { + "Client-ID": env.TWITCH_CLIENT_ID, + Authorization: `Bearer ${token}`, + "Content-Type": "text/plain", + }, + body: query, + }) + + if (!response.ok) { + const text = await response.text() + console.error(`[IGDB] API error: ${response.status} ${text}`) + return [] + } + + return response.json() +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function batchResolve(category: number, sourceIds: string[]): Promise> { + const results = new Map() + const BATCH_SIZE = 500 + + for (let i = 0; i < sourceIds.length; i += BATCH_SIZE) { + const batch = sourceIds.slice(i, i + BATCH_SIZE) + const uids = batch.map((id) => `"${id}"`).join(",") + const query = `fields game,uid; where category = ${category} & uid = (${uids}); limit ${BATCH_SIZE};` + + const data = (await igdbRequest("/external_games", query)) as Array<{ + game?: number + uid?: string + }> + + for (const entry of data) { + if (entry.game && entry.uid) { + results.set(entry.uid, entry.game) + } + } + + if (i + BATCH_SIZE < sourceIds.length) { + await sleep(260) + } + } + + return results +} + +interface GameForEnrichment { + source: string + sourceId: string + [key: string]: unknown +} + +export async function enrichGamesWithIgdb( + games: T[], +): Promise<(T & { canonicalId?: string })[]> { + if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) { + return games + } + + const uncachedBySource: Record = {} + for (const game of games) { + const cacheKey = `${game.source}:${game.sourceId}` + if (!getCacheEntry(cacheKey) && SOURCE_TO_CATEGORY[game.source]) { + if (!uncachedBySource[game.source]) { + uncachedBySource[game.source] = [] + } + uncachedBySource[game.source].push(game.sourceId) + } + } + + let newEntries = 0 + try { + for (const [source, sourceIds] of Object.entries(uncachedBySource)) { + const category = SOURCE_TO_CATEGORY[source] + console.log(`[IGDB] Resolving ${sourceIds.length} ${source} games...`) + + const resolved = await batchResolve(category, sourceIds) + + for (const [uid, igdbId] of resolved) { + setCacheEntry(`${source}:${uid}`, { igdbId }) + newEntries++ + } + + for (const uid of sourceIds) { + if (!resolved.has(uid)) { + setCacheEntry(`${source}:${uid}`, { igdbId: null }) + } + } + } + + if (newEntries > 0) { + console.log(`[IGDB] Resolved ${newEntries} new games, cache: ${getCacheSize()} entries`) + saveCache() + } + } catch (err) { + console.error("[IGDB] Enrichment failed (non-fatal):", (err as Error).message) + } + + return games.map((game) => { + const cached = getCacheEntry(`${game.source}:${game.sourceId}`) + if (cached?.igdbId) { + return { ...game, canonicalId: String(cached.igdbId) } + } + return game + }) +} diff --git a/server/src/features/steam/router.ts b/server/src/features/steam/router.ts new file mode 100644 index 0000000..e27760c --- /dev/null +++ b/server/src/features/steam/router.ts @@ -0,0 +1,14 @@ +import { zValidator } from "@hono/zod-validator" +import { Hono } from "hono" +import { steamRefreshInput } from "./schema.ts" +import { fetchSteamGames } from "./service.ts" + +export const steamRouter = new Hono().post( + "/games", + zValidator("json", steamRefreshInput), + async (c) => { + const { apiKey, steamId } = c.req.valid("json") + const result = await fetchSteamGames(apiKey, steamId) + return c.json(result) + }, +) diff --git a/server/src/features/steam/schema.ts b/server/src/features/steam/schema.ts new file mode 100644 index 0000000..552bcb5 --- /dev/null +++ b/server/src/features/steam/schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod" + +export const steamRefreshInput = z.object({ + apiKey: z.string().min(1), + steamId: z.string().min(1), +}) + +export const steamGameSchema = z.object({ + id: z.string(), + title: z.string(), + source: z.literal("steam"), + sourceId: z.string(), + platform: z.literal("PC"), + lastPlayed: z.string().nullable(), + playtimeHours: z.number(), + url: z.string(), +}) + +export const steamGamesOutput = z.object({ + games: z.array(steamGameSchema), + count: z.number(), +}) diff --git a/server/src/features/steam/service.ts b/server/src/features/steam/service.ts new file mode 100644 index 0000000..2acae1f --- /dev/null +++ b/server/src/features/steam/service.ts @@ -0,0 +1,37 @@ +interface SteamGame { + appid: number + name: string + playtime_forever: number + rtime_last_played?: number +} + +export async function fetchSteamGames(apiKey: string, steamId: string) { + const url = new URL("https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/") + url.searchParams.set("key", apiKey) + url.searchParams.set("steamid", steamId) + url.searchParams.set("include_appinfo", "true") + url.searchParams.set("include_played_free_games", "true") + + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Steam API Error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + const rawGames: SteamGame[] = data.response?.games ?? [] + + const games = rawGames.map((game) => ({ + id: `steam-${game.appid}`, + title: game.name, + source: "steam" as const, + sourceId: String(game.appid), + platform: "PC" as const, + lastPlayed: game.rtime_last_played + ? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10) + : null, + playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10, + url: `https://store.steampowered.com/app/${game.appid}`, + })) + + return { games, count: games.length } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..5be5ab0 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,12 @@ +import app from "./app.ts" +import { loadCache } from "./features/igdb/cache.ts" +import { env } from "./shared/lib/env.ts" + +loadCache() + +console.log(`[server] listening on http://localhost:${env.PORT}`) + +export default { + port: env.PORT, + fetch: app.fetch, +} diff --git a/server/src/shared/lib/env.ts b/server/src/shared/lib/env.ts new file mode 100644 index 0000000..59d0112 --- /dev/null +++ b/server/src/shared/lib/env.ts @@ -0,0 +1,10 @@ +import { z } from "zod" + +const envSchema = z.object({ + PORT: z.coerce.number().default(3001), + ALLOWED_ORIGIN: z.string().default("http://localhost:5173"), + TWITCH_CLIENT_ID: z.string().default(""), + TWITCH_CLIENT_SECRET: z.string().default(""), +}) + +export const env = envSchema.parse(process.env) diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..7db3bef --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts new file mode 100644 index 0000000..5e1abdf --- /dev/null +++ b/src/shared/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +}