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:
parent
aec1142bff
commit
b404c20eca
6 changed files with 785 additions and 25 deletions
314
server/src/plugins/game-library/igdb.ts
Normal file
314
server/src/plugins/game-library/igdb.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue