Files
whattoplay/server/igdb-cache.mjs

226 lines
5.9 KiB
JavaScript

/**
* IGDB Cache - Shared canonical game ID resolution
* Uses Twitch OAuth + IGDB external_games endpoint
* Cache is shared across all users (mappings are universal)
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CACHE_FILE = join(__dirname, "data", "igdb-cache.json");
// IGDB external game categories
const CATEGORY_STEAM = 1;
const CATEGORY_GOG = 2;
const SOURCE_TO_CATEGORY = {
steam: CATEGORY_STEAM,
gog: CATEGORY_GOG,
};
// In-memory cache: "steam:12345" → { igdbId: 67890 }
const cache = new Map();
// Twitch OAuth token state
let twitchToken = null;
let tokenExpiry = 0;
/**
* Load cache from JSON file on disk
*/
export function loadCache() {
try {
const data = readFileSync(CACHE_FILE, "utf-8");
const entries = 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");
}
}
/**
* Save cache to JSON file on disk
*/
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.message);
}
}
/**
* Get a valid Twitch access token (refreshes if expired)
*/
async function getIgdbToken() {
if (twitchToken && Date.now() < tokenExpiry) {
return twitchToken;
}
const clientId = process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return null;
}
const url = new URL("https://id.twitch.tv/oauth2/token");
url.searchParams.set("client_id", clientId);
url.searchParams.set("client_secret", clientSecret);
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;
// Refresh 5 minutes before actual expiry
tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
console.log("[IGDB] Twitch token acquired");
return twitchToken;
}
/**
* Make an IGDB API request with Apicalypse query
*/
async function igdbRequest(endpoint, query) {
const token = await getIgdbToken();
if (!token) return [];
const response = await fetch(`https://api.igdb.com/v4${endpoint}`, {
method: "POST",
headers: {
"Client-ID": process.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();
}
/**
* Sleep helper for rate limiting (4 req/sec max)
*/
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Batch-resolve IGDB IDs for a list of source IDs
* @param {number} category - IGDB category (1=Steam, 2=GOG)
* @param {string[]} sourceIds - List of source-specific IDs
* @returns {Map<string, number>} sourceId → igdbGameId
*/
async function batchResolve(category, sourceIds) {
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);
for (const entry of data) {
if (entry.game && entry.uid) {
results.set(entry.uid, entry.game);
}
}
// Rate limit: wait between batches
if (i + BATCH_SIZE < sourceIds.length) {
await sleep(260);
}
}
return results;
}
/**
* Enrich games with IGDB canonical IDs
* Graceful: if IGDB is unavailable or no credentials, games pass through unchanged
* @param {Array<{source: string, sourceId: string}>} games
* @returns {Promise<Array>} Games with canonicalId added where available
*/
export async function enrichGamesWithIgdb(games) {
// Check if IGDB credentials are configured
if (!process.env.TWITCH_CLIENT_ID || !process.env.TWITCH_CLIENT_SECRET) {
return games;
}
// Find uncached games, grouped by source
const uncachedBySource = {};
for (const game of games) {
const cacheKey = `${game.source}:${game.sourceId}`;
if (!cache.has(cacheKey) && SOURCE_TO_CATEGORY[game.source]) {
if (!uncachedBySource[game.source]) {
uncachedBySource[game.source] = [];
}
uncachedBySource[game.source].push(game.sourceId);
}
}
// Batch-resolve uncached games from IGDB
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 (category ${category})...`,
);
const resolved = await batchResolve(category, sourceIds);
for (const [uid, igdbId] of resolved) {
cache.set(`${source}:${uid}`, { igdbId });
newEntries++;
}
// Mark unresolved games as null so we don't re-query them
for (const uid of sourceIds) {
if (!resolved.has(uid)) {
cache.set(`${source}:${uid}`, { igdbId: null });
}
}
}
if (newEntries > 0) {
console.log(
`[IGDB] Resolved ${newEntries} new games, cache now has ${cache.size} entries`,
);
saveCache();
}
} catch (err) {
console.error("[IGDB] Enrichment failed (non-fatal):", err.message);
}
// Enrich games with canonicalId from cache
return games.map((game) => {
const cached = cache.get(`${game.source}:${game.sourceId}`);
if (cached?.igdbId) {
return { ...game, canonicalId: String(cached.igdbId) };
}
return game;
});
}