Game Library: Multi-Platform Profile System + GOG Integration

- Neues Profile-System: User können Steam und GOG verknüpfen
- GOG OAuth2 Login-Flow (auth.gog.com)
- GOG API Service (gog.ts): Token-Management, Spieleliste, User-Info
- Server: Profile-Datenmodell, Migration bestehender Steam-Users
- Frontend: Login-Bar (Steam + GOG), Profile-Chips mit Platform-Badges
- Cross-Platform Game-Merging mit Deduplizierung
- Profile-Detail mit "GOG verknüpfen" Option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-08 12:34:43 +01:00
parent 89e655482b
commit ee5c29dd7b
4 changed files with 859 additions and 189 deletions

View file

@ -3,7 +3,9 @@ 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 crypto from 'node:crypto';
import { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.js';
import { getGogAuthUrl, exchangeGogCode, refreshGogToken, fetchGogUserInfo, fetchGogGames, type GogGame } from './gog.js';
// ── Types ──
@ -24,8 +26,30 @@ interface SteamUser {
lastUpdated: string; // ISO date
}
interface GogUserData {
gogUserId: string;
username: string;
avatarUrl: string;
games: Array<{ gogId: number; title: string; image: string; slug: string; igdb?: IgdbGameInfo }>;
accessToken: string;
refreshToken: string;
tokenExpiresAt: number;
lastUpdated: string;
}
interface UserProfile {
id: string;
displayName: string;
avatarUrl: string;
steamId?: string;
gogUserId?: string;
lastUpdated: string;
}
interface GameLibraryData {
users: Record<string, SteamUser>; // keyed by steamId
users: Record<string, SteamUser>; // keyed by steamId
gogUsers: Record<string, GogUserData>; // keyed by gogUserId
profiles: Record<string, UserProfile>; // keyed by profileId
}
// ── Constants ──
@ -43,7 +67,7 @@ function loadData(ctx: PluginContext): GameLibraryData {
const raw = fs.readFileSync(getDataPath(ctx), 'utf-8');
return JSON.parse(raw) as GameLibraryData;
} catch {
const empty: GameLibraryData = { users: {} };
const empty: GameLibraryData = { users: {}, gogUsers: {}, profiles: {} };
saveData(ctx, empty);
return empty;
}
@ -71,7 +95,16 @@ function broadcastUpdate(data: GameLibraryData): void {
gameCount: u.games.length,
lastUpdated: u.lastUpdated,
}));
sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users });
const profiles = Object.values(data.profiles || {}).map(p => ({
id: p.id,
displayName: p.displayName,
avatarUrl: p.avatarUrl,
steamId: p.steamId,
gogUserId: p.gogUserId,
totalGames: (p.steamId && data.users[p.steamId] ? data.users[p.steamId].games.length : 0) +
(p.gogUserId && data.gogUsers?.[p.gogUserId] ? data.gogUsers[p.gogUserId].games.length : 0),
}));
sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users, profiles });
}
// ── Steam API Helpers ──
@ -119,6 +152,27 @@ const gameLibraryPlugin: Plugin = {
async init(ctx) {
const data = loadData(ctx); // ensure file exists
// Migrate: ensure gogUsers and profiles exist
if (!data.profiles) data.profiles = {};
if (!data.gogUsers) data.gogUsers = {};
// Migrate existing Steam users to profiles
const existingProfileSteamIds = new Set(Object.values(data.profiles).map(p => p.steamId).filter(Boolean));
for (const user of Object.values(data.users)) {
if (!existingProfileSteamIds.has(user.steamId)) {
const profileId = crypto.randomUUID();
data.profiles[profileId] = {
id: profileId,
displayName: user.personaName,
avatarUrl: user.avatarUrl,
steamId: user.steamId,
lastUpdated: user.lastUpdated,
};
}
}
saveData(ctx, data);
console.log('[GameLibrary] Initialized');
// Fire-and-forget: auto-enrich all existing users with IGDB data
@ -217,6 +271,21 @@ const gameLibraryPlugin: Plugin = {
games,
lastUpdated: new Date().toISOString(),
};
// Create or update profile for this Steam user
if (!data.profiles) data.profiles = {};
const existingProfile = Object.values(data.profiles).find(p => p.steamId === steamId);
if (!existingProfile) {
const profileId = crypto.randomUUID();
data.profiles[profileId] = {
id: profileId,
displayName: profile.personaName,
avatarUrl: profile.avatarUrl,
steamId,
lastUpdated: new Date().toISOString(),
};
}
saveData(ctx, data);
broadcastUpdate(data);
@ -523,6 +592,173 @@ const gameLibraryPlugin: Plugin = {
}
});
// ── GET /api/game-library/profiles ──
app.get('/api/game-library/profiles', (_req, res) => {
const data = loadData(ctx);
const profiles = Object.values(data.profiles).map(p => {
const steam = p.steamId ? data.users[p.steamId] : null;
const gog = p.gogUserId ? data.gogUsers[p.gogUserId] : null;
return {
id: p.id,
displayName: p.displayName,
avatarUrl: p.avatarUrl,
platforms: {
steam: steam ? { steamId: steam.steamId, personaName: steam.personaName, gameCount: steam.games.length } : null,
gog: gog ? { gogUserId: gog.gogUserId, username: gog.username, gameCount: gog.games.length } : null,
},
totalGames: (steam?.games.length || 0) + (gog?.games.length || 0),
lastUpdated: p.lastUpdated,
};
});
res.json({ profiles });
});
// ── GOG Login ──
app.get('/api/game-library/gog/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 redirectUri = `${realm}/api/game-library/gog/callback`;
const linkTo = req.query.linkTo ? `&state=${req.query.linkTo}` : '';
res.redirect(getGogAuthUrl(redirectUri) + linkTo);
});
// ── GOG Callback ──
app.get('/api/game-library/gog/callback', async (req, res) => {
try {
const code = String(req.query.code || '');
const linkToProfileId = String(req.query.state || '');
if (!code) {
res.status(400).send(errorPage('GOG-Authentifizierung fehlgeschlagen', 'Kein Authorization-Code erhalten.'));
return;
}
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
const redirectUri = `${proto}://${host}/api/game-library/gog/callback`;
// Exchange code for tokens
const tokens = await exchangeGogCode(code, redirectUri);
// Fetch user info + games
const [userInfo, games] = await Promise.all([
fetchGogUserInfo(tokens.accessToken),
fetchGogGames(tokens.accessToken),
]);
const data = loadData(ctx);
if (!data.gogUsers) data.gogUsers = {};
if (!data.profiles) data.profiles = {};
// Store GOG user
data.gogUsers[userInfo.userId] = {
gogUserId: userInfo.userId,
username: userInfo.username,
avatarUrl: userInfo.avatarUrl,
games,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenExpiresAt: tokens.expiresAt,
lastUpdated: new Date().toISOString(),
};
// Link to existing profile or create new one
let profileName = userInfo.username;
if (linkToProfileId && data.profiles[linkToProfileId]) {
// Link GOG to existing profile
data.profiles[linkToProfileId].gogUserId = userInfo.userId;
data.profiles[linkToProfileId].lastUpdated = new Date().toISOString();
profileName = data.profiles[linkToProfileId].displayName;
} else {
// Check if GOG user already has a profile
const existingProfile = Object.values(data.profiles).find(p => p.gogUserId === userInfo.userId);
if (!existingProfile) {
const profileId = crypto.randomUUID();
data.profiles[profileId] = {
id: profileId,
displayName: userInfo.username,
avatarUrl: userInfo.avatarUrl,
gogUserId: userInfo.userId,
lastUpdated: new Date().toISOString(),
};
}
}
saveData(ctx, data);
broadcastUpdate(data);
console.log(`[GameLibrary] GOG verknuepft: ${profileName} (${userInfo.userId}) - ${games.length} Spiele`);
res.send(`<!DOCTYPE html><html><head><title>GOG 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:#a855f7}</style></head><body><div><h2>GOG verbunden!</h2><p>${profileName}: ${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] GOG Callback error:', err);
res.status(500).send(errorPage('GOG-Fehler', 'Ein unerwarteter Fehler ist aufgetreten.'));
}
});
// ── GET /api/game-library/profile/:profileId/games ──
app.get('/api/game-library/profile/:profileId/games', (req, res) => {
const data = loadData(ctx);
const profile = data.profiles[req.params.profileId];
if (!profile) { res.status(404).json({ error: 'Profil nicht gefunden.' }); return; }
const mergedGames: Array<{
name: string;
platform: 'steam' | 'gog';
appid?: number;
gogId?: number;
playtime_forever?: number;
img_icon_url?: string;
image?: string;
igdb?: IgdbGameInfo;
}> = [];
// Add Steam games
if (profile.steamId && data.users[profile.steamId]) {
for (const g of data.users[profile.steamId].games) {
mergedGames.push({
name: g.name,
platform: 'steam',
appid: g.appid,
playtime_forever: g.playtime_forever,
img_icon_url: g.img_icon_url,
igdb: g.igdb,
});
}
}
// Add GOG games (avoid duplicates by name)
if (profile.gogUserId && data.gogUsers[profile.gogUserId]) {
const steamNames = new Set(mergedGames.map(g => g.name.toLowerCase()));
for (const g of data.gogUsers[profile.gogUserId].games) {
if (!steamNames.has(g.title.toLowerCase())) {
mergedGames.push({
name: g.title,
platform: 'gog',
gogId: g.gogId,
image: g.image,
igdb: g.igdb,
});
}
}
}
// Sort by playtime (Steam first since they have playtime), then by name
mergedGames.sort((a, b) => (b.playtime_forever || 0) - (a.playtime_forever || 0));
res.json({
profile: {
id: profile.id,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
steamId: profile.steamId,
gogUserId: profile.gogUserId,
},
games: mergedGames,
totalGames: mergedGames.length,
});
});
// ── DELETE /api/game-library/user/:steamId ──
app.delete('/api/game-library/user/:steamId', (req, res) => {
const { steamId } = req.params;