From 109a9f383b7d95c70e353a46cf1a8e199c25eb14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Mon, 2 Mar 2026 22:51:58 +0100 Subject: [PATCH] add IGDB metadata enrichment, image proxy, rich game UI server: metadata cache + fetch service, image proxy with disk cache, POST /igdb/metadata + GET /igdb/image/:id/:size routes. client: 002-metadata migration, enrichment wired into sync pipeline, extracted SyncProgress component, square cover icons in list items, genres/rating/summary on discover cards, tap-to-navigate on card stack, rich game detail with screenshots, trailer embed, developer info. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + deploy.sh | 2 +- server/src/features/igdb/image-cache.ts | 43 +++++++++ server/src/features/igdb/metadata-cache.ts | 53 +++++++++++ server/src/features/igdb/router.ts | 44 ++++++++- server/src/features/igdb/service.ts | 84 +++++++++++++++++ server/src/index.ts | 2 + .../discover/components/card-stack.tsx | 22 ++++- .../components/game-discover-card.tsx | 43 ++++++--- src/features/games/components/game-detail.tsx | 74 ++++++++++++++- .../games/components/game-list-item.tsx | 17 +++- .../settings/components/gog-settings.tsx | 5 +- .../settings/components/steam-settings.tsx | 35 +------ src/shared/components/sync-progress.tsx | 53 +++++++++++ src/shared/db/client.ts | 2 + src/shared/db/migrations/002-metadata.sql | 9 ++ src/shared/db/schema.ts | 9 ++ src/shared/hooks/use-swipe-gesture.ts | 7 ++ src/shared/i18n/locales/de.ts | 5 + src/shared/i18n/locales/en.ts | 5 + src/shared/i18n/locales/es.ts | 6 +- src/shared/i18n/locales/fr.ts | 6 +- src/shared/stores/sync-store.ts | 91 ++++++++++++++++++- 23 files changed, 552 insertions(+), 67 deletions(-) create mode 100644 server/src/features/igdb/image-cache.ts create mode 100644 server/src/features/igdb/metadata-cache.ts create mode 100644 src/shared/components/sync-progress.tsx create mode 100644 src/shared/db/migrations/002-metadata.sql diff --git a/.gitignore b/.gitignore index 671fdf7..842f8ad 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ node_modules # IGDB cache (generated at runtime) server/data/igdb-cache.json +server/data/igdb-metadata.json +server/data/igdb-images/ # Build outputs dist diff --git a/deploy.sh b/deploy.sh index b33999b..0245ca2 100755 --- a/deploy.sh +++ b/deploy.sh @@ -17,7 +17,7 @@ ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_HTML_DIR" rsync -avz --delete dist/ "$UBERSPACE_HOST:$REMOTE_HTML_DIR/" echo "==> syncing server to $REMOTE_SERVICE_DIR/" -ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR/src $REMOTE_SERVICE_DIR/data/steam-icons" +ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR/src $REMOTE_SERVICE_DIR/data/steam-icons $REMOTE_SERVICE_DIR/data/igdb-images/thumb $REMOTE_SERVICE_DIR/data/igdb-images/cover_big $REMOTE_SERVICE_DIR/data/igdb-images/screenshot_med" rsync -avz --delete \ server/src/ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/src/" rsync -avz \ diff --git a/server/src/features/igdb/image-cache.ts b/server/src/features/igdb/image-cache.ts new file mode 100644 index 0000000..61c2629 --- /dev/null +++ b/server/src/features/igdb/image-cache.ts @@ -0,0 +1,43 @@ +import { existsSync, 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 IMAGE_DIR = join(__dirname, "../../../data/igdb-images") + +const ALLOWED_SIZES = ["thumb", "cover_big", "screenshot_med"] as const +export type IgdbImageSize = (typeof ALLOWED_SIZES)[number] + +export function isValidSize(size: string): size is IgdbImageSize { + return (ALLOWED_SIZES as readonly string[]).includes(size) +} + +for (const size of ALLOWED_SIZES) { + mkdirSync(join(IMAGE_DIR, size), { recursive: true }) +} + +function imagePath(imageId: string, size: IgdbImageSize): string { + return join(IMAGE_DIR, size, `${imageId}.jpg`) +} + +export function hasImage(imageId: string, size: IgdbImageSize): boolean { + return existsSync(imagePath(imageId, size)) +} + +export function readImage(imageId: string, size: IgdbImageSize): Uint8Array { + return new Uint8Array(readFileSync(imagePath(imageId, size))) +} + +export async function fetchAndCacheImage( + imageId: string, + size: IgdbImageSize, +): Promise { + const url = `https://images.igdb.com/igdb/image/upload/t_${size}/${imageId}.jpg` + const res = await fetch(url) + if (!res.ok) { + throw new Error(`IGDB CDN returned ${res.status} for ${imageId}/${size}`) + } + const bytes = new Uint8Array(await res.arrayBuffer()) + writeFileSync(imagePath(imageId, size), bytes) + return bytes +} diff --git a/server/src/features/igdb/metadata-cache.ts b/server/src/features/igdb/metadata-cache.ts new file mode 100644 index 0000000..cf44108 --- /dev/null +++ b/server/src/features/igdb/metadata-cache.ts @@ -0,0 +1,53 @@ +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-metadata.json") + +export interface IgdbMetadata { + summary: string | null + coverImageId: string | null + screenshots: string[] + videoIds: string[] + genres: string[] + aggregatedRating: number | null + releaseDate: string | null + developers: string[] +} + +const cache = new Map() + +export function loadMetadataCache() { + 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] Metadata cache loaded: ${cache.size} entries`) + } catch { + console.log("[IGDB] No metadata cache file found, starting fresh") + } +} + +function saveMetadataCache() { + 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 metadata cache:", (err as Error).message) + } +} + +export function getMetadata(canonicalId: string): IgdbMetadata | undefined { + return cache.get(canonicalId) +} + +export function setMetadataBatch(entries: Map) { + for (const [key, value] of entries) { + cache.set(key, value) + } + saveMetadataCache() +} diff --git a/server/src/features/igdb/router.ts b/server/src/features/igdb/router.ts index a6c8cd5..9c2c524 100644 --- a/server/src/features/igdb/router.ts +++ b/server/src/features/igdb/router.ts @@ -1,7 +1,8 @@ import { zValidator } from "@hono/zod-validator" import { Hono } from "hono" import { z } from "zod" -import { enrichGamesWithIgdb } from "./service.ts" +import { fetchAndCacheImage, hasImage, isValidSize, readImage } from "./image-cache.ts" +import { enrichGamesWithIgdb, fetchMetadataForGames } from "./service.ts" const enrichInput = z.object({ games: z.array( @@ -14,8 +15,41 @@ const enrichInput = z.object({ ), }) -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 }) +const metadataInput = z.object({ + canonicalIds: z.array(z.string()), }) + +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 }) + }) + .post("/metadata", zValidator("json", metadataInput), async (c) => { + const { canonicalIds } = c.req.valid("json") + const metadataMap = await fetchMetadataForGames(canonicalIds) + return c.json({ metadata: Object.fromEntries(metadataMap) }) + }) + .get("/image/:imageId/:size", async (c) => { + const { imageId, size } = c.req.param() + if (!/^[a-z0-9]+$/.test(imageId)) { + return c.text("Invalid image ID", 400) + } + if (!isValidSize(size)) { + return c.text("Invalid size", 400) + } + try { + const bytes = hasImage(imageId, size) + ? readImage(imageId, size) + : await fetchAndCacheImage(imageId, size) + return new Response(bytes.buffer as ArrayBuffer, { + headers: { + "Content-Type": "image/jpeg", + "Cache-Control": "public, max-age=604800, immutable", + }, + }) + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error" + return c.json({ error: message }, 502) + } + }) diff --git a/server/src/features/igdb/service.ts b/server/src/features/igdb/service.ts index e5a7ecd..730ef4d 100644 --- a/server/src/features/igdb/service.ts +++ b/server/src/features/igdb/service.ts @@ -1,5 +1,6 @@ import { env } from "../../shared/lib/env.ts" import { getCacheEntry, getCacheSize, saveCache, setCacheEntry } from "./cache.ts" +import { type IgdbMetadata, getMetadata, setMetadataBatch } from "./metadata-cache.ts" const CATEGORY_STEAM = 1 const CATEGORY_GOG = 2 @@ -152,3 +153,86 @@ export async function enrichGamesWithIgdb( return game }) } + +interface IgdbGameResponse { + id: number + summary?: string + cover?: { image_id: string } + screenshots?: Array<{ image_id: string }> + videos?: Array<{ video_id: string }> + genres?: Array<{ name: string }> + aggregated_rating?: number + first_release_date?: number + involved_companies?: Array<{ + developer: boolean + company: { name: string } + }> +} + +export async function fetchMetadataForGames( + canonicalIds: string[], +): Promise> { + const results = new Map() + + if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) { + return results + } + + const uncached = canonicalIds.filter((id) => !getMetadata(id)) + if (uncached.length === 0) { + for (const id of canonicalIds) { + const cached = getMetadata(id) + if (cached) results.set(id, cached) + } + return results + } + + console.log(`[IGDB] Fetching metadata for ${uncached.length} games...`) + + const BATCH_SIZE = 10 + const freshEntries = new Map() + + for (let i = 0; i < uncached.length; i += BATCH_SIZE) { + const batch = uncached.slice(i, i + BATCH_SIZE) + const ids = batch.join(",") + const query = `fields summary, cover.image_id, screenshots.image_id, videos.video_id, genres.name, aggregated_rating, first_release_date, involved_companies.company.name, involved_companies.developer; where id = (${ids}); limit ${BATCH_SIZE};` + + const data = (await igdbRequest("/games", query)) as IgdbGameResponse[] + + for (const game of data) { + const developers = (game.involved_companies ?? []) + .filter((ic) => ic.developer) + .map((ic) => ic.company.name) + + const metadata: IgdbMetadata = { + summary: game.summary ?? null, + coverImageId: game.cover?.image_id ?? null, + screenshots: (game.screenshots ?? []).map((s) => s.image_id), + videoIds: (game.videos ?? []).map((v) => v.video_id), + genres: (game.genres ?? []).map((g) => g.name), + aggregatedRating: game.aggregated_rating ?? null, + releaseDate: game.first_release_date + ? new Date(game.first_release_date * 1000).toISOString().split("T")[0] + : null, + developers, + } + freshEntries.set(String(game.id), metadata) + } + + if (i + BATCH_SIZE < uncached.length) { + await sleep(260) + } + } + + if (freshEntries.size > 0) { + setMetadataBatch(freshEntries) + console.log(`[IGDB] Fetched metadata for ${freshEntries.size} games`) + } + + for (const id of canonicalIds) { + const meta = freshEntries.get(id) ?? getMetadata(id) + if (meta) results.set(id, meta) + } + + return results +} diff --git a/server/src/index.ts b/server/src/index.ts index 315662c..3755dd7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,8 +1,10 @@ import app from "./app.ts" import { loadCache } from "./features/igdb/cache.ts" +import { loadMetadataCache } from "./features/igdb/metadata-cache.ts" import { env } from "./shared/lib/env.ts" loadCache() +loadMetadataCache() console.log(`[server] listening on http://localhost:${env.PORT}`) diff --git a/src/features/discover/components/card-stack.tsx b/src/features/discover/components/card-stack.tsx index 058167c..20ae905 100644 --- a/src/features/discover/components/card-stack.tsx +++ b/src/features/discover/components/card-stack.tsx @@ -1,12 +1,21 @@ import type { Game } from "@/shared/db/schema" import { useSwipeGesture } from "@/shared/hooks/use-swipe-gesture" +import { useNavigate } from "@tanstack/react-router" import { useEffect } from "react" import { GameDiscoverCard } from "./game-discover-card" +const apiBase = import.meta.env.BASE_URL.replace(/\/$/, "") + function getSteamHeaderImage(sourceId: string): string { return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg` } +function getPreloadUrl(game: Game): string | null { + if (game.cover_image_id) return `${apiBase}/api/igdb/image/${game.cover_image_id}/cover_big` + if (game.source === "steam") return getSteamHeaderImage(game.source_id) + return null +} + interface CardStackProps { games: Game[] onSwipeLeft: () => void @@ -14,19 +23,26 @@ interface CardStackProps { } export function CardStack({ games, onSwipeLeft, onSwipeRight }: CardStackProps) { + const navigate = useNavigate() + const topGame = games[0] + const { offsetX, isDragging, handlers } = useSwipeGesture({ threshold: 80, onSwipeLeft, onSwipeRight, + onTap: topGame + ? () => navigate({ to: "/games/$gameId", params: { gameId: topGame.id } }) + : undefined, }) // Preload 4th card's image so it's cached before entering the visible stack const preloadGame = games[3] useEffect(() => { - if (preloadGame?.source !== "steam") return + const url = preloadGame ? getPreloadUrl(preloadGame) : null + if (!url) return const img = new Image() - img.src = getSteamHeaderImage(preloadGame.source_id) - }, [preloadGame?.source, preloadGame?.source_id]) + img.src = url + }, [preloadGame]) const visibleCards = games.slice(0, 3) diff --git a/src/features/discover/components/game-discover-card.tsx b/src/features/discover/components/game-discover-card.tsx index efd9e2f..bcb853d 100644 --- a/src/features/discover/components/game-discover-card.tsx +++ b/src/features/discover/components/game-discover-card.tsx @@ -1,8 +1,6 @@ import { formatPlaytime, gameStateColors } from "@/features/games/schema" import { Badge } from "@/shared/components/ui/badge" import type { Game } from "@/shared/db/schema" -import { useNavigate } from "@tanstack/react-router" -import { Info } from "lucide-react" interface GameDiscoverCardProps { game: Game @@ -12,10 +10,20 @@ function getSteamHeaderImage(sourceId: string): string { return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg` } +function parseJsonArray(text: string | null): string[] { + if (!text) return [] + try { + return JSON.parse(text) + } catch { + return [] + } +} + export function GameDiscoverCard({ game }: GameDiscoverCardProps) { - const navigate = useNavigate() const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null const dotColor = game.game_state !== "not_set" ? gameStateColors[game.game_state] : null + const genres = parseJsonArray(game.genres).slice(0, 3) + const rating = game.aggregated_rating != null ? Math.round(game.aggregated_rating) : null return (
@@ -31,18 +39,25 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) { {dotColor && ( )} - + {rating != null && ( + + {rating}% + + )}
- {game.playtime_hours > 0 && ( + {genres.length > 0 && ( +
+ {genres.map((g) => ( + + {g} + + ))} +
+ )} + {game.summary && ( +

{game.summary}

+ )} + {!game.summary && game.playtime_hours > 0 && (

{formatPlaytime(game.playtime_hours)} played

diff --git a/src/features/games/components/game-detail.tsx b/src/features/games/components/game-detail.tsx index 4a3f1d8..ce774c7 100644 --- a/src/features/games/components/game-detail.tsx +++ b/src/features/games/components/game-detail.tsx @@ -8,10 +8,21 @@ import { FavoriteButton } from "./favorite-button" import { GameStateSelect } from "./game-state-select" import { StarRating } from "./star-rating" +const apiBase = import.meta.env.BASE_URL.replace(/\/$/, "") + function getSteamHeaderImage(sourceId: string): string { return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg` } +function parseJsonArray(text: string | null): string[] { + if (!text) return [] + try { + return JSON.parse(text) + } catch { + return [] + } +} + interface GameDetailProps { gameId: string } @@ -30,6 +41,11 @@ export function GameDetail({ gameId }: GameDetailProps) { function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => void }) { const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null + const genres = parseJsonArray(game.genres) + const developers = parseJsonArray(game.developers) + const screenshots = parseJsonArray(game.screenshots) + const videoIds = parseJsonArray(game.video_ids) + const rating = game.aggregated_rating != null ? Math.round(game.aggregated_rating) : null return (
@@ -61,11 +77,32 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
+ {genres.length > 0 && ( +
+ {genres.map((g) => ( + + {g} + + ))} +
+ )} +
- +
+ + {rating != null && {rating}%} +
+ {(developers.length > 0 || game.release_date) && ( +

+ {developers.length > 0 && <>by {developers.join(", ")}} + {developers.length > 0 && game.release_date && " · "} + {game.release_date && <>{game.release_date}} +

+ )} + {game.url && ( voi {t("game.openStore")} )} + + {game.summary && ( +
+

{t("game.summary")}

+

{game.summary}

+
+ )} + + {screenshots.length > 0 && ( +
+

{t("game.screenshots")}

+
+ {screenshots.map((id) => ( + + ))} +
+
+ )} + + {videoIds.length > 0 && ( +
+

{t("game.trailer")}

+