feat: enhance sync with error tracking, image validation, and SteamGridDB fallback
This commit is contained in:
+125
-9
@@ -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<void> {
|
||||
async function validateImageUrl(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSteamGridDBCover(gameTitle: string): Promise<string | null> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user