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 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 22:51:58 +01:00
parent 0f8c9f331f
commit 109a9f383b
23 changed files with 552 additions and 67 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -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 \

View File

@@ -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<Uint8Array> {
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
}

View File

@@ -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<string, IgdbMetadata>()
export function loadMetadataCache() {
try {
const data = readFileSync(CACHE_FILE, "utf-8")
const entries: Record<string, IgdbMetadata> = 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<string, IgdbMetadata>) {
for (const [key, value] of entries) {
cache.set(key, value)
}
saveMetadataCache()
}

View File

@@ -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)
}
})

View File

@@ -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<T extends GameForEnrichment>(
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<Map<string, IgdbMetadata>> {
const results = new Map<string, IgdbMetadata>()
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<string, IgdbMetadata>()
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
}

View File

@@ -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}`)

View File

@@ -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)

View File

@@ -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 (
<div className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-lg">
@@ -31,18 +39,25 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
{dotColor && (
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
)}
<button
type="button"
className="ml-auto shrink-0 text-muted-foreground"
onClick={(e) => {
e.stopPropagation()
navigate({ to: "/games/$gameId", params: { gameId: game.id } })
}}
>
<Info className="h-5 w-5" />
</button>
{rating != null && (
<Badge variant="outline" className="ml-auto shrink-0">
{rating}%
</Badge>
)}
</div>
{game.playtime_hours > 0 && (
{genres.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{genres.map((g) => (
<Badge key={g} variant="secondary" className="text-[10px]">
{g}
</Badge>
))}
</div>
)}
{game.summary && (
<p className="mt-1.5 line-clamp-2 text-xs text-muted-foreground">{game.summary}</p>
)}
{!game.summary && game.playtime_hours > 0 && (
<p className="mt-1 text-sm text-muted-foreground">
{formatPlaytime(game.playtime_hours)} played
</p>

View File

@@ -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 (
<div className="space-y-4">
@@ -61,11 +77,32 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
<FavoriteButton gameId={game.id} isFavorite={game.is_favorite} onChange={onUpdate} />
</div>
{genres.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{genres.map((g) => (
<Badge key={g} variant="secondary">
{g}
</Badge>
))}
</div>
)}
<div className="flex items-center justify-between">
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
<div className="flex items-center gap-3">
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
{rating != null && <Badge variant="outline">{rating}%</Badge>}
</div>
<GameStateSelect gameId={game.id} state={game.game_state} onChange={onUpdate} />
</div>
{(developers.length > 0 || game.release_date) && (
<p className="text-sm text-muted-foreground">
{developers.length > 0 && <>by {developers.join(", ")}</>}
{developers.length > 0 && game.release_date && " · "}
{game.release_date && <>{game.release_date}</>}
</p>
)}
{game.url && (
<a
href={game.url}
@@ -77,6 +114,41 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
{t("game.openStore")}
</a>
)}
{game.summary && (
<div>
<h3 className="mb-1 text-sm font-semibold">{t("game.summary")}</h3>
<p className="text-sm text-muted-foreground">{game.summary}</p>
</div>
)}
{screenshots.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold">{t("game.screenshots")}</h3>
<div className="flex snap-x gap-2 overflow-x-auto pb-2">
{screenshots.map((id) => (
<img
key={id}
src={`${apiBase}/api/igdb/image/${id}/screenshot_med`}
alt=""
className="h-40 shrink-0 snap-start rounded-lg object-cover"
/>
))}
</div>
</div>
)}
{videoIds.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold">{t("game.trailer")}</h3>
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoIds[0]}`}
className="aspect-video w-full rounded-lg"
allowFullScreen
title={t("game.trailer")}
/>
</div>
)}
</div>
)
}

View File

@@ -11,8 +11,15 @@ interface GameListItemProps {
}
export function GameListItem({ game, onClick }: GameListItemProps) {
const iconUrl =
game.source === "steam" ? `${apiBase}/api/steam/icon/${game.source_id}` : undefined
const coverUrl = game.cover_image_id
? `${apiBase}/api/igdb/image/${game.cover_image_id}/thumb`
: game.source === "steam"
? `${apiBase}/api/steam/icon/${game.source_id}`
: undefined
const imgClass = game.cover_image_id
? "h-10 w-10 rounded object-cover"
: "h-10 w-16 rounded object-cover"
return (
<ListItem
@@ -20,10 +27,10 @@ export function GameListItem({ game, onClick }: GameListItemProps) {
title={game.title}
subtitle={game.playtime_hours > 0 ? formatPlaytime(game.playtime_hours) : undefined}
media={
iconUrl ? (
<img src={iconUrl} alt="" className="h-10 w-16 rounded object-cover" />
coverUrl ? (
<img src={coverUrl} alt="" className={imgClass} />
) : (
<span className="flex h-10 w-16 items-center justify-center rounded bg-muted text-[10px] font-medium text-muted-foreground">
<span className="flex h-10 w-10 items-center justify-center rounded bg-muted text-[10px] font-medium text-muted-foreground">
{game.source.toUpperCase()}
</span>
)

View File

@@ -1,3 +1,4 @@
import { SyncProgress } from "@/shared/components/sync-progress"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { ListItem } from "@/shared/components/ui/list-item"
@@ -13,7 +14,7 @@ export function GogSettings() {
const gogConfig = useConfig<{ accessToken: string; refreshToken: string; userId: string }>("gog")
const saveConfig = useSaveConfig()
const lastSync = useConfig<string>("gog_last_sync")
const { syncing, error, lastCount } = useSyncStore((s) => s.gog)
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.gog)
const connectGog = useSyncStore((s) => s.connectGog)
const syncGogGames = useSyncStore((s) => s.syncGogGames)
@@ -99,6 +100,8 @@ export function GogSettings() {
</>
)}
<SyncProgress progress={progress} />
{error && (
<div className="mt-4">
<p className="text-sm text-red-500">{error}</p>

View File

@@ -1,45 +1,12 @@
import { SyncProgress } from "@/shared/components/sync-progress"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { ListItem } from "@/shared/components/ui/list-item"
import { useConfig } from "@/shared/db/hooks"
import { t } from "@/shared/i18n"
import { useSyncStore } from "@/shared/stores/sync-store"
import { Loader2 } from "lucide-react"
import { useState } from "react"
function SyncProgress({ progress }: { progress: string | null }) {
if (!progress) return null
if (progress === "fetching") {
return (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.syncFetching")}
</div>
)
}
if (progress.startsWith("saving:")) {
const [, current, total] = progress.split(":")
return (
<div className="mt-4 space-y-1.5">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.syncSaving", { current, total })}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(Number(current) / Number(total)) * 100}%` }}
/>
</div>
</div>
)
}
return null
}
export function SteamSettings() {
const savedConfig = useConfig<{ apiKey: string; steamId: string }>("steam")
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.steam)

View File

@@ -0,0 +1,53 @@
import { t } from "@/shared/i18n"
import { Loader2 } from "lucide-react"
export function SyncProgress({ progress }: { progress: string | null }) {
if (!progress) return null
if (progress === "fetching") {
return (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.syncFetching")}
</div>
)
}
if (progress.startsWith("saving:")) {
const [, current, total] = progress.split(":")
return (
<div className="mt-4 space-y-1.5">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.syncSaving", { current, total })}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(Number(current) / Number(total)) * 100}%` }}
/>
</div>
</div>
)
}
if (progress === "enriching:ids") {
return (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.syncEnrichingIds")}
</div>
)
}
if (progress === "enriching:metadata") {
return (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.syncEnrichingMetadata")}
</div>
)
}
return null
}

View File

@@ -1,5 +1,6 @@
import { PGlite } from "@electric-sql/pglite"
import migrationSql from "./migrations/001-initial.sql?raw"
import metadataSql from "./migrations/002-metadata.sql?raw"
let db: PGlite | null = null
@@ -8,5 +9,6 @@ export async function getDb(): Promise<PGlite> {
db = new PGlite("idb://whattoplay")
await db.exec(migrationSql)
await db.exec(metadataSql)
return db
}

View File

@@ -0,0 +1,9 @@
ALTER TABLE games ADD COLUMN IF NOT EXISTS summary TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS genres TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS aggregated_rating REAL;
ALTER TABLE games ADD COLUMN IF NOT EXISTS release_date TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS developers TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS cover_image_id TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS screenshots TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS video_ids TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS metadata_fetched_at TIMESTAMPTZ;

View File

@@ -25,6 +25,15 @@ export const gameSchema = z.object({
rating: z.number().min(-1).max(10).default(-1),
game_state: gameStateEnum.default("not_set"),
is_favorite: z.boolean().default(false),
summary: z.string().nullable().default(null),
genres: z.string().nullable().default(null),
aggregated_rating: z.number().nullable().default(null),
release_date: z.string().nullable().default(null),
developers: z.string().nullable().default(null),
cover_image_id: z.string().nullable().default(null),
screenshots: z.string().nullable().default(null),
video_ids: z.string().nullable().default(null),
metadata_fetched_at: z.string().nullable().default(null),
created_at: z.string().optional(),
updated_at: z.string().optional(),
})

View File

@@ -7,6 +7,8 @@ interface SwipeGestureOptions {
onSwipeLeft?: () => void
/** Called when swiped past threshold to the right */
onSwipeRight?: () => void
/** Called when pointer up with minimal movement (tap) */
onTap?: () => void
}
interface SwipeGestureResult {
@@ -23,6 +25,7 @@ export function useSwipeGesture({
threshold = 80,
onSwipeLeft,
onSwipeRight,
onTap,
}: SwipeGestureOptions): SwipeGestureResult {
const [offsetX, setOffsetX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
@@ -30,8 +33,10 @@ export function useSwipeGesture({
const offsetRef = useRef(0)
const onSwipeLeftRef = useRef(onSwipeLeft)
const onSwipeRightRef = useRef(onSwipeRight)
const onTapRef = useRef(onTap)
onSwipeLeftRef.current = onSwipeLeft
onSwipeRightRef.current = onSwipeRight
onTapRef.current = onTap
const handlePointerDown = useCallback((e: React.PointerEvent) => {
startXRef.current = e.clientX
@@ -58,6 +63,8 @@ export function useSwipeGesture({
if (Math.abs(dx) > threshold) {
if (dx > 0) onSwipeRightRef.current?.()
else onSwipeLeftRef.current?.()
} else if (Math.abs(dx) <= 5) {
onTapRef.current?.()
}
setOffsetX(0)
offsetRef.current = 0

View File

@@ -60,10 +60,15 @@ export const de: Record<TranslationKey, string> = {
"settings.syncFetching": "Spiele werden vom Server geladen...",
"settings.syncSaving": "Speichere {current} / {total} Spiele...",
"settings.syncSuccess": "{count} Spiele synchronisiert",
"settings.syncEnrichingIds": "Spiel-IDs werden aufgelöst...",
"settings.syncEnrichingMetadata": "Metadaten werden geladen...",
"game.lastPlayed": "Zuletzt gespielt",
"game.openStore": "Im Store öffnen",
"game.notFound": "Spiel nicht gefunden.",
"game.summary": "Zusammenfassung",
"game.screenshots": "Screenshots",
"game.trailer": "Trailer",
"state.not_set": "Nicht gesetzt",
"state.wishlisted": "Gewünscht",

View File

@@ -63,11 +63,16 @@ export const en = {
"settings.syncFetching": "Fetching games from server...",
"settings.syncSaving": "Saving {current} / {total} games...",
"settings.syncSuccess": "Synced {count} games",
"settings.syncEnrichingIds": "Resolving game IDs...",
"settings.syncEnrichingMetadata": "Fetching metadata...",
// Game detail
"game.lastPlayed": "Last played",
"game.openStore": "Open in Store",
"game.notFound": "Game not found.",
"game.summary": "Summary",
"game.screenshots": "Screenshots",
"game.trailer": "Trailer",
// Game states
"state.not_set": "Not Set",

View File

@@ -1,5 +1,9 @@
import type { TranslationKey } from "./en"
export const es: Partial<Record<TranslationKey, string>> = {
// TODO: complete Spanish translations
"settings.syncEnrichingIds": "Resolviendo IDs de juegos...",
"settings.syncEnrichingMetadata": "Obteniendo metadatos...",
"game.summary": "Resumen",
"game.screenshots": "Capturas de pantalla",
"game.trailer": "Tráiler",
}

View File

@@ -1,5 +1,9 @@
import type { TranslationKey } from "./en"
export const fr: Partial<Record<TranslationKey, string>> = {
// TODO: complete French translations
"settings.syncEnrichingIds": "Résolution des IDs de jeux...",
"settings.syncEnrichingMetadata": "Récupération des métadonnées...",
"game.summary": "Résumé",
"game.screenshots": "Captures d'écran",
"game.trailer": "Bande-annonce",
}

View File

@@ -23,6 +23,80 @@ interface SyncStore {
const initial: SourceState = { syncing: false, error: null, lastCount: null, progress: null }
async function enrichAfterSync(
source: "steam" | "gog",
set: (partial: Partial<Record<"steam" | "gog", SourceState>>) => void,
get: () => SyncStore,
) {
const db = await getDb()
// Step 1: resolve canonical_ids for games that don't have one
try {
const missing = await db.query<{ source: string; source_id: string }>(
"SELECT source, source_id FROM games WHERE canonical_id IS NULL AND source = $1",
[source],
)
if (missing.rows.length > 0) {
set({ [source]: { ...get()[source], progress: "enriching:ids" } })
const gamesToEnrich = missing.rows.map((r) => ({
source: r.source,
sourceId: r.source_id,
}))
const res = await api.igdb.enrich.$post({ json: { games: gamesToEnrich } })
if (res.ok) {
const data = await res.json()
for (const g of data.games) {
if (g.canonicalId) {
await db.query(
"UPDATE games SET canonical_id = $1 WHERE source = $2 AND source_id = $3",
[g.canonicalId, g.source, g.sourceId],
)
}
}
}
}
} catch (err) {
console.error("[sync] canonical_id enrichment failed (non-fatal):", (err as Error).message)
}
// Step 2: fetch metadata for games with canonical_id but no metadata yet
try {
const needsMeta = await db.query<{ canonical_id: string }>(
"SELECT canonical_id FROM games WHERE canonical_id IS NOT NULL AND metadata_fetched_at IS NULL",
)
if (needsMeta.rows.length > 0) {
set({ [source]: { ...get()[source], progress: "enriching:metadata" } })
const canonicalIds = needsMeta.rows.map((r) => r.canonical_id)
const res = await api.igdb.metadata.$post({ json: { canonicalIds } })
if (res.ok) {
const data = await res.json()
for (const [canonicalId, meta] of Object.entries(data.metadata)) {
await db.query(
`UPDATE games SET
summary = $1, cover_image_id = $2, screenshots = $3, video_ids = $4,
genres = $5, aggregated_rating = $6, release_date = $7, developers = $8,
metadata_fetched_at = NOW()
WHERE canonical_id = $9`,
[
meta.summary,
meta.coverImageId,
JSON.stringify(meta.screenshots),
JSON.stringify(meta.videoIds),
JSON.stringify(meta.genres),
meta.aggregatedRating,
meta.releaseDate,
JSON.stringify(meta.developers),
canonicalId,
],
)
}
}
}
} catch (err) {
console.error("[sync] metadata enrichment failed (non-fatal):", (err as Error).message)
}
}
async function saveConfig(key: string, value: unknown) {
const db = await getDb()
await db.query(
@@ -32,9 +106,22 @@ async function saveConfig(key: string, value: unknown) {
)
}
type SyncGame = Pick<
Game,
| "id"
| "title"
| "source"
| "source_id"
| "platform"
| "last_played"
| "playtime_hours"
| "url"
| "canonical_id"
>
async function saveGamesBySource(
_source: string,
games: Omit<Game, "rating" | "game_state" | "is_favorite">[],
games: SyncGame[],
onProgress?: (current: number, total: number) => void,
) {
const db = await getDb()
@@ -106,6 +193,7 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
set({ steam: { ...get().steam, progress: `saving:${current}:${total}` } })
})
await saveConfig("steam_last_sync", new Date().toISOString())
await enrichAfterSync("steam", set, get)
set({ steam: { syncing: false, error: null, lastCount: data.count, progress: null } })
} catch (err) {
set({
@@ -191,6 +279,7 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
set({ gog: { ...get().gog, progress: `saving:${current}:${total}` } })
})
await saveConfig("gog_last_sync", new Date().toISOString())
await enrichAfterSync("gog", set, get)
set({ gog: { syncing: false, error: null, lastCount: data.count, progress: null } })
} catch (err) {
set({