2026-03-07 23:31:35 +01:00
|
|
|
import type express from 'express';
|
|
|
|
|
import type { Plugin, PluginContext } from '../../core/plugin.js';
|
|
|
|
|
import { sseBroadcast } from '../../core/sse.js';
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import path from 'node:path';
|
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>
2026-03-08 01:48:15 +01:00
|
|
|
import { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.js';
|
2026-03-07 23:31:35 +01:00
|
|
|
|
|
|
|
|
// ── Types ──
|
|
|
|
|
|
|
|
|
|
interface SteamGame {
|
|
|
|
|
appid: number;
|
|
|
|
|
name: string;
|
|
|
|
|
playtime_forever: number; // minutes
|
|
|
|
|
img_icon_url: string;
|
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>
2026-03-08 01:48:15 +01:00
|
|
|
igdb?: IgdbGameInfo;
|
2026-03-07 23:31:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SteamUser {
|
|
|
|
|
steamId: string;
|
|
|
|
|
personaName: string;
|
|
|
|
|
avatarUrl: string;
|
|
|
|
|
profileUrl: string;
|
|
|
|
|
games: SteamGame[];
|
|
|
|
|
lastUpdated: string; // ISO date
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface GameLibraryData {
|
|
|
|
|
users: Record<string, SteamUser>; // keyed by steamId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Constants ──
|
|
|
|
|
|
|
|
|
|
const STEAM_API_KEY = process.env.STEAM_API_KEY || '2481D43449821AA4FF66502A9166DF32';
|
|
|
|
|
|
|
|
|
|
// ── Data Persistence ──
|
|
|
|
|
|
|
|
|
|
function getDataPath(ctx: PluginContext): string {
|
|
|
|
|
return `${ctx.dataDir}/game-library.json`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadData(ctx: PluginContext): GameLibraryData {
|
|
|
|
|
try {
|
|
|
|
|
const raw = fs.readFileSync(getDataPath(ctx), 'utf-8');
|
|
|
|
|
return JSON.parse(raw) as GameLibraryData;
|
|
|
|
|
} catch {
|
|
|
|
|
const empty: GameLibraryData = { users: {} };
|
|
|
|
|
saveData(ctx, empty);
|
|
|
|
|
return empty;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveData(ctx: PluginContext, data: GameLibraryData): void {
|
|
|
|
|
try {
|
|
|
|
|
const dir = path.dirname(getDataPath(ctx));
|
|
|
|
|
if (!fs.existsSync(dir)) {
|
|
|
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(getDataPath(ctx), JSON.stringify(data, null, 2), 'utf-8');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[GameLibrary] Failed to save data:', err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── SSE Broadcast ──
|
|
|
|
|
|
|
|
|
|
function broadcastUpdate(data: GameLibraryData): void {
|
|
|
|
|
const users = Object.values(data.users).map(u => ({
|
|
|
|
|
steamId: u.steamId,
|
|
|
|
|
personaName: u.personaName,
|
|
|
|
|
avatarUrl: u.avatarUrl,
|
|
|
|
|
gameCount: u.games.length,
|
|
|
|
|
lastUpdated: u.lastUpdated,
|
|
|
|
|
}));
|
|
|
|
|
sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Steam API Helpers ──
|
|
|
|
|
|
|
|
|
|
async function fetchSteamProfile(steamId: string): Promise<{ personaName: string; avatarUrl: string; profileUrl: string }> {
|
|
|
|
|
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${STEAM_API_KEY}&steamids=${steamId}`;
|
|
|
|
|
const resp = await fetch(url);
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
throw new Error(`Steam API error: ${resp.status} ${resp.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
const json = await resp.json() as any;
|
|
|
|
|
const player = json?.response?.players?.[0];
|
|
|
|
|
if (!player) {
|
|
|
|
|
throw new Error(`Steam user not found: ${steamId}`);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
personaName: player.personaname || steamId,
|
|
|
|
|
avatarUrl: player.avatarfull || '',
|
|
|
|
|
profileUrl: player.profileurl || '',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchSteamGames(steamId: string): Promise<SteamGame[]> {
|
|
|
|
|
const url = `https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key=${STEAM_API_KEY}&steamid=${steamId}&include_appinfo=true&include_played_free_games=true&format=json`;
|
|
|
|
|
const resp = await fetch(url);
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
throw new Error(`Steam API error: ${resp.status} ${resp.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
const json = await resp.json() as any;
|
|
|
|
|
const games: SteamGame[] = (json?.response?.games || []).map((g: any) => ({
|
|
|
|
|
appid: g.appid,
|
|
|
|
|
name: g.name || '',
|
|
|
|
|
playtime_forever: g.playtime_forever || 0,
|
|
|
|
|
img_icon_url: g.img_icon_url || '',
|
|
|
|
|
}));
|
|
|
|
|
return games;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Plugin ──
|
|
|
|
|
|
|
|
|
|
const gameLibraryPlugin: Plugin = {
|
|
|
|
|
name: 'game-library',
|
|
|
|
|
version: '1.0.0',
|
|
|
|
|
description: 'Steam Spielebibliothek',
|
|
|
|
|
|
|
|
|
|
async init(ctx) {
|
|
|
|
|
loadData(ctx); // ensure file exists
|
|
|
|
|
console.log('[GameLibrary] Initialized');
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
registerRoutes(app: express.Application, ctx: PluginContext) {
|
|
|
|
|
// ── Steam OpenID Login ──
|
|
|
|
|
app.get('/api/game-library/steam/login', (_req, res) => {
|
|
|
|
|
const proto = _req.headers['x-forwarded-proto'] || _req.protocol || 'http';
|
|
|
|
|
const host = _req.headers['x-forwarded-host'] || _req.headers.host || 'localhost';
|
|
|
|
|
const realm = `${proto}://${host}`;
|
|
|
|
|
const returnTo = `${realm}/api/game-library/steam/callback`;
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
|
|
|
|
'openid.mode': 'checkid_setup',
|
|
|
|
|
'openid.return_to': returnTo,
|
|
|
|
|
'openid.realm': realm,
|
|
|
|
|
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
|
|
|
|
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Steam OpenID Callback ──
|
|
|
|
|
app.get('/api/game-library/steam/callback', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const claimedId = String(req.query['openid.claimed_id'] || '');
|
|
|
|
|
const steamIdMatch = claimedId.match(/\/id\/(\d+)$/);
|
|
|
|
|
if (!steamIdMatch) {
|
|
|
|
|
res.status(400).send(errorPage('Steam-Authentifizierung fehlgeschlagen', 'Keine gueltige Steam-ID erhalten.'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const steamId = steamIdMatch[1];
|
|
|
|
|
|
|
|
|
|
// Verify authentication with Steam
|
|
|
|
|
const verifyParams = new URLSearchParams();
|
|
|
|
|
for (const [key, value] of Object.entries(req.query)) {
|
|
|
|
|
verifyParams.set(key, String(value));
|
|
|
|
|
}
|
|
|
|
|
verifyParams.set('openid.mode', 'check_authentication');
|
|
|
|
|
|
|
|
|
|
const verifyResp = await fetch(`https://steamcommunity.com/openid/login?${verifyParams.toString()}`);
|
|
|
|
|
const verifyText = await verifyResp.text();
|
|
|
|
|
|
|
|
|
|
if (!verifyText.includes('is_valid:true')) {
|
|
|
|
|
res.status(403).send(errorPage('Verifizierung fehlgeschlagen', 'Steam konnte die Anmeldung nicht verifizieren.'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch profile and games in parallel
|
|
|
|
|
const [profile, games] = await Promise.all([
|
|
|
|
|
fetchSteamProfile(steamId),
|
|
|
|
|
fetchSteamGames(steamId),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Store user data
|
|
|
|
|
const data = loadData(ctx);
|
|
|
|
|
data.users[steamId] = {
|
|
|
|
|
steamId,
|
|
|
|
|
personaName: profile.personaName,
|
|
|
|
|
avatarUrl: profile.avatarUrl,
|
|
|
|
|
profileUrl: profile.profileUrl,
|
|
|
|
|
games,
|
|
|
|
|
lastUpdated: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
saveData(ctx, data);
|
|
|
|
|
broadcastUpdate(data);
|
|
|
|
|
|
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>
2026-03-08 01:48:15 +01:00
|
|
|
// 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));
|
|
|
|
|
|
2026-03-07 23:31:35 +01:00
|
|
|
const personaName = profile.personaName;
|
|
|
|
|
console.log(`[GameLibrary] Steam-Benutzer verknuepft: ${personaName} (${steamId}) - ${games.length} Spiele`);
|
|
|
|
|
|
|
|
|
|
res.send(`<!DOCTYPE html><html><head><title>Steam verbunden</title><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#2ecc71}</style></head><body><div><h2>Steam verbunden!</h2><p>${personaName} wurde erfolgreich verknuepft.</p><p>${games.length} Spiele geladen.</p><p>Du kannst dieses Fenster schliessen.</p><script>setTimeout(()=>window.close(),3000)</script></div></body></html>`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[GameLibrary] Callback error:', err);
|
|
|
|
|
res.status(500).send(errorPage('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── GET /api/game-library/users ──
|
|
|
|
|
app.get('/api/game-library/users', (_req, res) => {
|
|
|
|
|
const data = loadData(ctx);
|
|
|
|
|
const users = Object.values(data.users).map(u => ({
|
|
|
|
|
steamId: u.steamId,
|
|
|
|
|
personaName: u.personaName,
|
|
|
|
|
avatarUrl: u.avatarUrl,
|
|
|
|
|
gameCount: u.games.length,
|
|
|
|
|
lastUpdated: u.lastUpdated,
|
|
|
|
|
}));
|
|
|
|
|
res.json({ users });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── GET /api/game-library/user/:steamId ──
|
|
|
|
|
app.get('/api/game-library/user/:steamId', (req, res) => {
|
|
|
|
|
const data = loadData(ctx);
|
|
|
|
|
const user = data.users[req.params.steamId];
|
|
|
|
|
if (!user) {
|
|
|
|
|
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Sort games by playtime descending
|
|
|
|
|
const sortedGames = [...user.games].sort((a, b) => b.playtime_forever - a.playtime_forever);
|
|
|
|
|
res.json({
|
|
|
|
|
steamId: user.steamId,
|
|
|
|
|
personaName: user.personaName,
|
|
|
|
|
avatarUrl: user.avatarUrl,
|
|
|
|
|
profileUrl: user.profileUrl,
|
|
|
|
|
gameCount: sortedGames.length,
|
|
|
|
|
lastUpdated: user.lastUpdated,
|
|
|
|
|
games: sortedGames,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── GET /api/game-library/common-games?users=id1,id2,... ──
|
|
|
|
|
app.get('/api/game-library/common-games', (req, res) => {
|
|
|
|
|
const userIds = String(req.query.users || '').split(',').filter(Boolean);
|
|
|
|
|
if (userIds.length < 2) {
|
|
|
|
|
res.status(400).json({ error: 'Mindestens zwei Steam-IDs erforderlich (users=id1,id2).' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = loadData(ctx);
|
|
|
|
|
|
|
|
|
|
// Validate all users exist
|
|
|
|
|
for (const id of userIds) {
|
|
|
|
|
if (!data.users[id]) {
|
|
|
|
|
res.status(404).json({ error: `Benutzer ${id} nicht gefunden.` });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build game sets per user: Map<appid, SteamGame>
|
|
|
|
|
const userGameMaps = userIds.map(id => {
|
|
|
|
|
const map = new Map<number, SteamGame>();
|
|
|
|
|
for (const game of data.users[id].games) {
|
|
|
|
|
map.set(game.appid, game);
|
|
|
|
|
}
|
|
|
|
|
return { userId: id, gameMap: map };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Find intersection: games present in ALL users
|
|
|
|
|
const firstUserGames = userGameMaps[0].gameMap;
|
|
|
|
|
const commonAppIds: number[] = [];
|
|
|
|
|
|
|
|
|
|
for (const appid of firstUserGames.keys()) {
|
|
|
|
|
const allOwn = userGameMaps.every(u => u.gameMap.has(appid));
|
|
|
|
|
if (allOwn) {
|
|
|
|
|
commonAppIds.push(appid);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build result with owner info
|
|
|
|
|
const games = commonAppIds.map(appid => {
|
|
|
|
|
const refGame = firstUserGames.get(appid)!;
|
|
|
|
|
const owners = userIds.map(id => {
|
|
|
|
|
const user = data.users[id];
|
|
|
|
|
const userGame = user.games.find(g => g.appid === appid);
|
|
|
|
|
return {
|
|
|
|
|
steamId: user.steamId,
|
|
|
|
|
personaName: user.personaName,
|
|
|
|
|
playtime_forever: userGame?.playtime_forever || 0,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
appid: refGame.appid,
|
|
|
|
|
name: refGame.name,
|
|
|
|
|
img_icon_url: refGame.img_icon_url,
|
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>
2026-03-08 01:48:15 +01:00
|
|
|
...(refGame.igdb ? { igdb: refGame.igdb } : {}),
|
2026-03-07 23:31:35 +01:00
|
|
|
owners,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Sort by name
|
|
|
|
|
games.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
|
|
|
|
|
res.json({ games, count: games.length });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── GET /api/game-library/search?q=term ──
|
|
|
|
|
app.get('/api/game-library/search', (req, res) => {
|
|
|
|
|
const query = String(req.query.q || '').trim().toLowerCase();
|
|
|
|
|
if (!query) {
|
|
|
|
|
res.status(400).json({ error: 'Suchbegriff erforderlich (q=...).' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = loadData(ctx);
|
|
|
|
|
const allUsers = Object.values(data.users);
|
|
|
|
|
|
|
|
|
|
// Collect all unique games matching the query
|
|
|
|
|
const gameMap = new Map<number, { appid: number; name: string; img_icon_url: string; owners: { steamId: string; personaName: string }[] }>();
|
|
|
|
|
|
|
|
|
|
for (const user of allUsers) {
|
|
|
|
|
for (const game of user.games) {
|
|
|
|
|
if (game.name.toLowerCase().includes(query)) {
|
|
|
|
|
if (!gameMap.has(game.appid)) {
|
|
|
|
|
gameMap.set(game.appid, {
|
|
|
|
|
appid: game.appid,
|
|
|
|
|
name: game.name,
|
|
|
|
|
img_icon_url: game.img_icon_url,
|
|
|
|
|
owners: [],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
gameMap.get(game.appid)!.owners.push({
|
|
|
|
|
steamId: user.steamId,
|
|
|
|
|
personaName: user.personaName,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const games = [...gameMap.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
res.json({ games, count: games.length });
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-03-08 01:48:15 +01:00
|
|
|
// ── 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.' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-07 23:31:35 +01:00
|
|
|
// ── POST /api/game-library/refresh/:steamId ──
|
|
|
|
|
app.post('/api/game-library/refresh/:steamId', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { steamId } = req.params;
|
|
|
|
|
const data = loadData(ctx);
|
|
|
|
|
const user = data.users[steamId];
|
|
|
|
|
if (!user) {
|
|
|
|
|
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-fetch profile and games in parallel
|
|
|
|
|
const [profile, games] = await Promise.all([
|
|
|
|
|
fetchSteamProfile(steamId),
|
|
|
|
|
fetchSteamGames(steamId),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
data.users[steamId] = {
|
|
|
|
|
steamId,
|
|
|
|
|
personaName: profile.personaName,
|
|
|
|
|
avatarUrl: profile.avatarUrl,
|
|
|
|
|
profileUrl: profile.profileUrl,
|
|
|
|
|
games,
|
|
|
|
|
lastUpdated: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
saveData(ctx, data);
|
|
|
|
|
broadcastUpdate(data);
|
|
|
|
|
|
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>
2026-03-08 01:48:15 +01:00
|
|
|
// 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));
|
|
|
|
|
|
2026-03-07 23:31:35 +01:00
|
|
|
console.log(`[GameLibrary] Aktualisiert: ${profile.personaName} (${steamId}) - ${games.length} Spiele`);
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
steamId,
|
|
|
|
|
personaName: profile.personaName,
|
|
|
|
|
avatarUrl: profile.avatarUrl,
|
|
|
|
|
profileUrl: profile.profileUrl,
|
|
|
|
|
gameCount: games.length,
|
|
|
|
|
lastUpdated: data.users[steamId].lastUpdated,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[GameLibrary] Refresh error:', err);
|
|
|
|
|
res.status(500).json({ error: 'Fehler beim Aktualisieren der Spielebibliothek.' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── DELETE /api/game-library/user/:steamId ──
|
|
|
|
|
app.delete('/api/game-library/user/:steamId', (req, res) => {
|
|
|
|
|
const { steamId } = req.params;
|
|
|
|
|
const data = loadData(ctx);
|
|
|
|
|
const user = data.users[steamId];
|
|
|
|
|
if (!user) {
|
|
|
|
|
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const personaName = user.personaName;
|
|
|
|
|
delete data.users[steamId];
|
|
|
|
|
saveData(ctx, data);
|
|
|
|
|
broadcastUpdate(data);
|
|
|
|
|
|
|
|
|
|
console.log(`[GameLibrary] Benutzer entfernt: ${personaName} (${steamId})`);
|
|
|
|
|
res.json({ success: true, message: `${personaName} wurde entfernt.` });
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getSnapshot(ctx) {
|
|
|
|
|
const data = loadData(ctx);
|
|
|
|
|
return {
|
|
|
|
|
'game-library': {
|
|
|
|
|
users: Object.values(data.users).map(u => ({
|
|
|
|
|
steamId: u.steamId,
|
|
|
|
|
personaName: u.personaName,
|
|
|
|
|
avatarUrl: u.avatarUrl,
|
|
|
|
|
gameCount: u.games.length,
|
|
|
|
|
lastUpdated: u.lastUpdated,
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Helper: Error HTML Page ──
|
|
|
|
|
|
|
|
|
|
function errorPage(title: string, message: string): string {
|
|
|
|
|
return `<!DOCTYPE html><html><head><title>${title}</title><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#e74c3c}</style></head><body><div><h2>${title}</h2><p>${message}</p><p>Du kannst dieses Fenster schliessen.</p><script>setTimeout(()=>window.close(),5000)</script></div></body></html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default gameLibraryPlugin;
|