226 lines
5.9 KiB
JavaScript
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;
|
|
});
|
|
}
|