IGDB-Integration für Game Library + Electron Update-Button im Version-Modal

- Neues IGDB-Service-Modul (igdb.ts): Token-Management, Rate-Limiting, Game-Lookup per Steam-AppID/Name, Batch-Enrichment mit In-Memory-Cache
- Server: 2 neue Routes (/igdb/enrich/:steamId, /igdb/game/:appid), Auto-Enrichment bei Steam-Login und Refresh
- Frontend: IGDB-Cover, Genre-Tags, Plattform-Badges, farbcodiertes Rating, IGDB-Anreichern-Button
- Version-Modal: Update-Button für Electron-App (checkForUpdates/installUpdate), Desktop-App-Version anzeigen
- CSS: Styles für IGDB-Elemente und Update-UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-08 01:48:15 +01:00
parent aec1142bff
commit b404c20eca
6 changed files with 785 additions and 25 deletions

View file

@ -0,0 +1,314 @@
// ──────────────────────────────────────────────────────────────────────────────
// IGDB (Internet Game Database) API service
// Uses Twitch OAuth for authentication. All requests go through a simple
// throttle so we never exceed the 4 req/s rate-limit.
// ──────────────────────────────────────────────────────────────────────────────
const TWITCH_CLIENT_ID = 'n6u8unhmwvhzsrvw2d2nb2a3qxapsl';
const TWITCH_CLIENT_SECRET = 'h6f6g2r6yyxkfg2xsob0jt8p994s7v';
const TWITCH_AUTH_URL = 'https://id.twitch.tv/oauth2/token';
const IGDB_API_URL = 'https://api.igdb.com/v4';
// ── Types ────────────────────────────────────────────────────────────────────
export interface IgdbGameInfo {
igdbId: number;
name: string;
coverUrl: string | null;
genres: string[];
platforms: string[];
rating: number | null;
firstReleaseDate: string | null;
summary: string | null;
igdbUrl: string | null;
}
// ── Token cache ──────────────────────────────────────────────────────────────
interface TokenCache {
accessToken: string;
expiresAt: number; // unix-ms
}
let tokenCache: TokenCache | null = null;
/**
* Obtain (and cache) a Twitch OAuth bearer token for the IGDB API.
* Tokens typically last ~60 days. We refresh automatically when the cached
* token is expired (or about to expire within 60 seconds).
*/
export async function getAccessToken(): Promise<string> {
if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
return tokenCache.accessToken;
}
console.log('[IGDB] Fetching new Twitch OAuth token...');
const params = new URLSearchParams({
client_id: TWITCH_CLIENT_ID,
client_secret: TWITCH_CLIENT_SECRET,
grant_type: 'client_credentials',
});
const res = await fetch(TWITCH_AUTH_URL, {
method: 'POST',
body: params,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`[IGDB] Token request failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
access_token: string;
expires_in: number;
token_type: string;
};
tokenCache = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
console.log(
`[IGDB] Token acquired, expires in ${Math.round(data.expires_in / 86400)} days`,
);
return tokenCache.accessToken;
}
// ── Rate-limit throttle (max 4 req/s → min 250 ms between requests) ─────────
const MIN_REQUEST_INTERVAL_MS = 250;
let lastRequestTime = 0;
async function throttle(): Promise<void> {
const now = Date.now();
const elapsed = now - lastRequestTime;
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
await new Promise((resolve) =>
setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed),
);
}
lastRequestTime = Date.now();
}
// ── Generic IGDB query runner ────────────────────────────────────────────────
/**
* Send an IGDB Apicalypse query to the given endpoint.
*
* @param endpoint e.g. "games", "covers"
* @param body IGDB query-language string
* @returns parsed JSON array
*/
export async function igdbQuery(
endpoint: string,
body: string,
): Promise<any[]> {
const token = await getAccessToken();
await throttle();
const url = `${IGDB_API_URL}/${endpoint}`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Client-ID': TWITCH_CLIENT_ID,
Authorization: `Bearer ${token}`,
'Content-Type': 'text/plain',
},
body,
});
if (!res.ok) {
const text = await res.text();
console.error(`[IGDB] Query to ${endpoint} failed (${res.status}): ${text}`);
throw new Error(`[IGDB] Query failed (${res.status}): ${text}`);
}
return (await res.json()) as any[];
}
// ── Cover URL helper ─────────────────────────────────────────────────────────
/**
* Convert an IGDB cover URL (or hash) into a full HTTPS image URL.
*
* IGDB returns cover.url like `//images.igdb.com/igdb/image/upload/t_thumb/co1234.jpg`
* This helper swaps in the requested size template and ensures HTTPS.
*
* @param hash The cover hash or raw URL returned by the API
* @param size Image size template defaults to `t_cover_big` (264x374)
*/
export function igdbCoverUrl(
hash: string,
size: string = 't_cover_big',
): string {
// If the caller passed a full IGDB url, extract the hash portion
// e.g. "//images.igdb.com/igdb/image/upload/t_thumb/co1234.jpg" → "co1234"
let imageHash = hash;
const match = hash.match(/\/t_\w+\/(.+?)(?:\.\w+)?$/);
if (match) {
imageHash = match[1];
}
return `https://images.igdb.com/igdb/image/upload/${size}/${imageHash}.jpg`;
}
// ── Raw response → IgdbGameInfo mapper ───────────────────────────────────────
function mapToGameInfo(raw: any): IgdbGameInfo {
let coverUrl: string | null = null;
if (raw.cover?.url) {
coverUrl = igdbCoverUrl(raw.cover.url);
}
let firstReleaseDate: string | null = null;
if (typeof raw.first_release_date === 'number') {
firstReleaseDate = new Date(raw.first_release_date * 1000)
.toISOString()
.slice(0, 10);
}
return {
igdbId: raw.id,
name: raw.name ?? 'Unknown',
coverUrl,
genres: Array.isArray(raw.genres)
? raw.genres.map((g: any) => g.name).filter(Boolean)
: [],
platforms: Array.isArray(raw.platforms)
? raw.platforms.map((p: any) => p.name).filter(Boolean)
: [],
rating: typeof raw.rating === 'number' ? Math.round(raw.rating) : null,
firstReleaseDate,
summary: raw.summary ?? null,
igdbUrl: raw.url ?? null,
};
}
// ── Game lookup functions ────────────────────────────────────────────────────
/**
* Find a game by its Steam app-id using IGDB external-game references.
* Category 1 = Steam in IGDB's external-game categories.
*/
export async function lookupByAppId(
steamAppId: number,
): Promise<IgdbGameInfo | null> {
const body = [
'fields name,cover.url,genres.name,platforms.name,rating,',
'first_release_date,summary,url,external_games.uid,external_games.category;',
`where external_games.category = 1 & external_games.uid = "${steamAppId}";`,
'limit 1;',
].join(' ');
const results = await igdbQuery('games', body);
if (results.length === 0) return null;
return mapToGameInfo(results[0]);
}
/**
* Fallback: search for a game by name.
*/
export async function lookupByName(
name: string,
): Promise<IgdbGameInfo | null> {
const safeName = name.replace(/"/g, '\\"');
const body = [
`search "${safeName}";`,
'fields name,cover.url,genres.name,platforms.name,rating,',
'first_release_date,summary,url;',
'limit 1;',
].join(' ');
const results = await igdbQuery('games', body);
if (results.length === 0) return null;
return mapToGameInfo(results[0]);
}
/**
* Try to look up a game by Steam app-id first; fall back to name search.
*/
export async function lookupGame(
steamAppId: number,
gameName: string,
): Promise<IgdbGameInfo | null> {
const byId = await lookupByAppId(steamAppId);
if (byId) return byId;
console.log(
`[IGDB] No result for Steam appid ${steamAppId}, falling back to name search: "${gameName}"`,
);
return lookupByName(gameName);
}
// ── Batch enrichment ─────────────────────────────────────────────────────────
/** In-memory cache keyed by Steam appid. Persists for the server lifetime. */
const enrichmentCache = new Map<number, IgdbGameInfo>();
/**
* Enrich a list of Steam games with IGDB metadata.
*
* - Processes in batches of 4 (respecting the 4 req/s rate limit).
* - 300 ms pause between batches.
* - Uses an in-memory cache so repeated calls for the same appid are free.
*
* @returns Map keyed by Steam appid IgdbGameInfo (only matched games)
*/
export async function enrichGames(
games: Array<{ appid: number; name: string }>,
): Promise<Map<number, IgdbGameInfo>> {
const result = new Map<number, IgdbGameInfo>();
const toFetch: Array<{ appid: number; name: string }> = [];
// Check cache first
for (const game of games) {
const cached = enrichmentCache.get(game.appid);
if (cached) {
console.log(`[IGDB] Cache hit for appid ${game.appid} ("${cached.name}")`);
result.set(game.appid, cached);
} else {
toFetch.push(game);
}
}
if (toFetch.length === 0) return result;
const BATCH_SIZE = 4;
for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
if (i > 0) {
// Pause between batches
await new Promise((resolve) => setTimeout(resolve, 300));
}
const batch = toFetch.slice(i, i + BATCH_SIZE);
const promises = batch.map(async (game) => {
try {
const info = await lookupGame(game.appid, game.name);
if (info) {
enrichmentCache.set(game.appid, info);
result.set(game.appid, info);
}
} catch (err) {
console.error(
`[IGDB] Error enriching appid ${game.appid} ("${game.name}"):`,
err,
);
}
});
await Promise.all(promises);
}
console.log(
`[IGDB] Enrichment complete: ${result.size}/${games.length} games matched`,
);
return result;
}

View file

@ -3,6 +3,7 @@ import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import fs from 'node:fs';
import path from 'node:path';
import { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.js';
// ── Types ──
@ -11,6 +12,7 @@ interface SteamGame {
name: string;
playtime_forever: number; // minutes
img_icon_url: string;
igdb?: IgdbGameInfo;
}
interface SteamUser {
@ -185,6 +187,21 @@ const gameLibraryPlugin: Plugin = {
saveData(ctx, data);
broadcastUpdate(data);
// Fire-and-forget IGDB enrichment
enrichGames(games.map(g => ({ appid: g.appid, name: g.name }))).then(igdbMap => {
const data2 = loadData(ctx);
const user2 = data2.users[steamId];
if (user2) {
let count = 0;
for (const game of user2.games) {
const info = igdbMap.get(game.appid);
if (info) { game.igdb = info; count++; }
}
saveData(ctx, data2);
console.log(`[GameLibrary] IGDB auto-enrichment: ${count}/${games.length} games`);
}
}).catch(err => console.error('[GameLibrary] IGDB auto-enrichment error:', err));
const personaName = profile.personaName;
console.log(`[GameLibrary] Steam-Benutzer verknuepft: ${personaName} (${steamId}) - ${games.length} Spiele`);
@ -283,6 +300,7 @@ const gameLibraryPlugin: Plugin = {
appid: refGame.appid,
name: refGame.name,
img_icon_url: refGame.img_icon_url,
...(refGame.igdb ? { igdb: refGame.igdb } : {}),
owners,
};
});
@ -330,6 +348,89 @@ const gameLibraryPlugin: Plugin = {
res.json({ games, count: games.length });
});
// ── GET /api/game-library/igdb/enrich/:steamId ──
app.get('/api/game-library/igdb/enrich/:steamId', async (req, res) => {
try {
const { steamId } = req.params;
const force = req.query.force === 'true';
const data = loadData(ctx);
const user = data.users[steamId];
if (!user) {
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
return;
}
const gamesToEnrich = force
? user.games.map(g => ({ appid: g.appid, name: g.name }))
: user.games.filter(g => !g.igdb).map(g => ({ appid: g.appid, name: g.name }));
const igdbMap = await enrichGames(gamesToEnrich);
// Reload data to avoid stale writes
const freshData = loadData(ctx);
const freshUser = freshData.users[steamId];
if (!freshUser) {
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
return;
}
let enriched = 0;
for (const game of freshUser.games) {
const info = igdbMap.get(game.appid);
if (info) {
game.igdb = info;
enriched++;
}
}
saveData(ctx, freshData);
console.log(`[GameLibrary] IGDB enrichment for ${freshUser.personaName}: ${enriched}/${freshUser.games.length} games`);
res.json({ enriched, total: freshUser.games.length });
} catch (err) {
console.error('[GameLibrary] IGDB enrich error:', err);
res.status(500).json({ error: 'Fehler bei der IGDB-Anreicherung.' });
}
});
// ── GET /api/game-library/igdb/game/:appid ──
app.get('/api/game-library/igdb/game/:appid', async (req, res) => {
try {
const appid = parseInt(req.params.appid, 10);
if (isNaN(appid)) {
res.status(400).json({ error: 'Ungueltige App-ID.' });
return;
}
// Find game name from any user's library
const data = loadData(ctx);
let gameName = '';
for (const user of Object.values(data.users)) {
const game = user.games.find(g => g.appid === appid);
if (game) {
gameName = game.name;
break;
}
}
if (!gameName) {
res.status(404).json({ error: 'Spiel nicht in der Bibliothek gefunden.' });
return;
}
const info = await lookupGame(appid, gameName);
if (!info) {
res.status(404).json({ error: 'Spiel nicht in IGDB gefunden.' });
return;
}
res.json(info);
} catch (err) {
console.error('[GameLibrary] IGDB game lookup error:', err);
res.status(500).json({ error: 'Fehler bei der IGDB-Abfrage.' });
}
});
// ── POST /api/game-library/refresh/:steamId ──
app.post('/api/game-library/refresh/:steamId', async (req, res) => {
try {
@ -358,6 +459,21 @@ const gameLibraryPlugin: Plugin = {
saveData(ctx, data);
broadcastUpdate(data);
// Fire-and-forget IGDB enrichment
enrichGames(games.map(g => ({ appid: g.appid, name: g.name }))).then(igdbMap => {
const data2 = loadData(ctx);
const user2 = data2.users[steamId];
if (user2) {
let count = 0;
for (const game of user2.games) {
const info = igdbMap.get(game.appid);
if (info) { game.igdb = info; count++; }
}
saveData(ctx, data2);
console.log(`[GameLibrary] IGDB auto-enrichment: ${count}/${games.length} games`);
}
}).catch(err => console.error('[GameLibrary] IGDB auto-enrichment error:', err));
console.log(`[GameLibrary] Aktualisiert: ${profile.personaName} (${steamId}) - ${games.length} Spiele`);
res.json({