diff --git a/lib/steam/sync.ts b/lib/steam/sync.ts index f971ed2..1eb8d59 100644 --- a/lib/steam/sync.ts +++ b/lib/steam/sync.ts @@ -1,6 +1,6 @@ import { db } from "@/lib/db/index" import { games } from "@/lib/db/schema" -import { eq } from "drizzle-orm" +import { eq, sql } from "drizzle-orm" interface SteamAppDetails { steam_appid: number @@ -28,8 +28,73 @@ export function isSyncStale(lastSync: Date | null): boolean { return Date.now() - new Date(lastSync).getTime() > SEVEN_DAYS_MS } -export async function syncSteamGame(steamAppId: number): Promise { +async function validateImageUrl(url: string): Promise { try { + const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000) }); + return res.ok; + } catch { + return false; + } +} + +async function fetchSteamGridDBCover(gameTitle: string): Promise { + try { + const apiKey = process.env.STEAMGRIDDB_API_KEY; + if (!apiKey) return null; + + // Search for game + const searchRes = await fetch( + `https://www.steamgriddb.com/api/v2/search/autocomplete/${encodeURIComponent(gameTitle)}`, + { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(5000) } + ); + if (!searchRes.ok) return null; + + const searchData = await searchRes.json(); + if (!searchData.data || searchData.data.length === 0) return null; + + const gameId = searchData.data[0].id; + + // Get grids + const gridsRes = await fetch( + `https://www.steamgriddb.com/api/v2/grids/game/${gameId}?dimensions=600x900,342x482`, + { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(5000) } + ); + if (!gridsRes.ok) return null; + + const gridsData = await gridsRes.json(); + if (!gridsData.data || gridsData.data.length === 0) return null; + + return gridsData.data[0].url; + } catch { + return null; + } +} + +export async function syncSteamGame( + steamAppId: number, + options?: { forceRetry?: boolean } +): Promise<{ success: boolean; error?: string }> { + try { + // Check if recently synced (unless forceRetry) + if (!options?.forceRetry) { + const existing = await db + .select({ lastSync: games.lastSync, syncStatus: games.syncStatus, syncNextRetry: games.syncNextRetry }) + .from(games) + .where(eq(games.steamAppId, steamAppId)) + .limit(1); + + if (existing.length > 0 && existing[0].lastSync) { + const nextRetry = existing[0].syncNextRetry ? new Date(existing[0].syncNextRetry) : null; + if (nextRetry && nextRetry.getTime() > Date.now()) { + return { success: false, error: "Sync skipped: next retry not yet reached" }; + } + if (!isSyncStale(existing[0].lastSync)) { + return { success: false, error: "Sync skipped: recently synced" }; + } + } + } + + // Fetch from Steam API with timeout const url = new URL("https://store.steampowered.com/api/appdetails/") url.searchParams.set("appids", String(steamAppId)) url.searchParams.set("cc", "US") @@ -37,11 +102,21 @@ export async function syncSteamGame(steamAppId: number): Promise { const res = await fetch(url.toString(), { headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10000), }) if (!res.ok) { - console.error(`Steam sync failed for ${steamAppId}: ${res.status}`) - return + const errorMsg = `Steam API returned ${res.status}` + await db + .update(games) + .set({ + syncError: errorMsg, + syncRetryCount: sql`COALESCE(sync_retry_count, 0) + 1`, + syncNextRetry: new Date(Date.now() + SEVEN_DAYS_MS), + updatedAt: new Date(), + }) + .where(eq(games.steamAppId, steamAppId)) + return { success: false, error: errorMsg } } const data = (await res.json()) as Record< @@ -51,12 +126,33 @@ export async function syncSteamGame(steamAppId: number): Promise { const entry = data[String(steamAppId)] if (!entry?.success || !entry.data) { - console.error(`Steam sync: no data for ${steamAppId}`) - return + const errorMsg = `No data returned from Steam for app ${steamAppId}` + await db + .update(games) + .set({ + syncError: errorMsg, + syncRetryCount: sql`COALESCE(sync_retry_count, 0) + 1`, + syncNextRetry: new Date(Date.now() + SEVEN_DAYS_MS), + updatedAt: new Date(), + }) + .where(eq(games.steamAppId, steamAppId)) + return { success: false, error: errorMsg } } const d = entry.data + // Build capsule image URL and validate it + const capsuleUrl = `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg` + let finalCapsuleUrl: string | null = capsuleUrl + + const imageValid = await validateImageUrl(capsuleUrl) + if (!imageValid) { + // Fall back to SteamGridDB + const fallbackUrl = await fetchSteamGridDBCover(d.name) + finalCapsuleUrl = fallbackUrl || null + } + + // Update database with all fields including error tracking await db .update(games) .set({ @@ -66,7 +162,7 @@ export async function syncSteamGame(steamAppId: number): Promise { description: d.short_description || null, genres: d.genres?.map((g) => g.description) || [], headerImage: d.header_image || null, - capsuleImage: `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg`, + capsuleImage: finalCapsuleUrl, storeUrl: `https://store.steampowered.com/app/${steamAppId}`, systemRequirements: d.pc_requirements ? { minimum: d.pc_requirements.minimum || null, recommended: d.pc_requirements.recommended || null } @@ -83,10 +179,30 @@ export async function syncSteamGame(steamAppId: number): Promise { platforms: d.platforms ?? null, lastSync: new Date(), syncStatus: "synced", + syncError: null, + syncRetryCount: 0, + syncNextRetry: null, updatedAt: new Date(), }) .where(eq(games.steamAppId, steamAppId)) + + return { success: true } } catch (err) { - console.error(`Steam sync error for ${steamAppId}:`, err) + const errorMsg = err instanceof Error ? err.message : String(err) + try { + await db + .update(games) + .set({ + syncError: errorMsg, + syncRetryCount: sql`COALESCE(sync_retry_count, 0) + 1`, + syncNextRetry: new Date(Date.now() + SEVEN_DAYS_MS), + updatedAt: new Date(), + }) + .where(eq(games.steamAppId, steamAppId)) + } catch { + // If DB update fails too, just log it + console.error(`Failed to update error tracking for ${steamAppId}`) + } + return { success: false, error: errorMsg } } -} +} \ No newline at end of file