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

@ -0,0 +1,288 @@
// ──────────────────────────────────────────────────────────────────────────────
// GOG (Good Old Games) API service
// Uses GOG OAuth2 for user authentication. All paginated requests include a
// 500 ms pause between pages to respect rate-limits.
// ──────────────────────────────────────────────────────────────────────────────
const GOG_CLIENT_ID = '46899977096215655';
const GOG_CLIENT_SECRET =
'9d85c43b1482497dbbce61f6e4aa173a433796eebd2c1f0f7f015c4c2e57571';
const GOG_AUTH_URL = 'https://auth.gog.com/auth';
const GOG_TOKEN_URL = 'https://auth.gog.com/token';
const GOG_EMBED_URL = 'https://embed.gog.com';
// ── Types ────────────────────────────────────────────────────────────────────
export interface GogTokens {
accessToken: string;
refreshToken: string;
expiresAt: number; // unix-ms
userId: string;
}
export interface GogUserInfo {
userId: string;
username: string;
avatarUrl: string;
}
export interface GogGame {
gogId: number;
title: string;
image: string; // cover URL from GOG
slug: string;
}
// ── OAuth helpers ────────────────────────────────────────────────────────────
/**
* Build the GOG OAuth authorization URL that the user should visit to grant
* access. After approval, GOG will redirect to `redirectUri` with a `code`
* query parameter.
*/
export function getGogAuthUrl(redirectUri: string): string {
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
layout: 'client2',
});
return `${GOG_AUTH_URL}?${params.toString()}`;
}
/**
* Exchange an authorization code for GOG access + refresh tokens.
*/
export async function exchangeGogCode(
code: string,
redirectUri: string,
): Promise<GogTokens> {
console.log('[GOG] Exchanging authorization code for tokens...');
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
client_secret: GOG_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
});
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] Token exchange failed (${res.status}): ${text}`);
throw new Error(`[GOG] Token exchange failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
user_id: string;
token_type: string;
};
const tokens: GogTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
userId: data.user_id,
};
console.log(
`[GOG] Tokens acquired for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
);
return tokens;
}
/**
* Obtain a fresh set of tokens using an existing refresh token.
*/
export async function refreshGogToken(
refreshToken: string,
): Promise<GogTokens> {
console.log('[GOG] Refreshing access token...');
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
client_secret: GOG_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] Token refresh failed (${res.status}): ${text}`);
throw new Error(`[GOG] Token refresh failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
user_id: string;
token_type: string;
};
const tokens: GogTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
userId: data.user_id,
};
console.log(
`[GOG] Token refreshed for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
);
return tokens;
}
// ── User info ────────────────────────────────────────────────────────────────
/**
* Fetch basic user profile information from the GOG embed API.
*/
export async function fetchGogUserInfo(
accessToken: string,
): Promise<GogUserInfo> {
console.log('[GOG] Fetching user info...');
const res = await fetch(`${GOG_EMBED_URL}/userData.json`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] User info request failed (${res.status}): ${text}`);
throw new Error(`[GOG] User info request failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
userId: string;
username: string;
galaxyUserId: string;
avatar: string;
};
const userInfo: GogUserInfo = {
userId: data.galaxyUserId || data.userId,
username: data.username,
avatarUrl: data.avatar ? `https:${data.avatar}` : '',
};
console.log(`[GOG] User info fetched: ${userInfo.username}`);
return userInfo;
}
// ── Game library ─────────────────────────────────────────────────────────────
/**
* Fetch the complete list of owned GOG games for the authenticated user.
*
* The GOG embed API paginates results (one page at a time). We iterate
* through all pages with a 500 ms pause between requests to stay friendly.
*/
export async function fetchGogGames(
accessToken: string,
): Promise<GogGame[]> {
console.log('[GOG] Fetching game library...');
const allGames: GogGame[] = [];
let currentPage = 1;
let totalPages = 1;
do {
const url = `${GOG_EMBED_URL}/account/getFilteredProducts?mediaType=1&page=${currentPage}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
const text = await res.text();
console.error(
`[GOG] Game library request failed on page ${currentPage} (${res.status}): ${text}`,
);
throw new Error(
`[GOG] Game library request failed (${res.status}): ${text}`,
);
}
const data = (await res.json()) as {
totalPages: number;
products: Array<{
id: number;
title: string;
image: string;
slug: string;
}>;
};
totalPages = data.totalPages;
for (const product of data.products) {
allGames.push({
gogId: product.id,
title: product.title,
image: product.image ? `https:${product.image}` : '',
slug: product.slug,
});
}
console.log(
`[GOG] Page ${currentPage}/${totalPages} fetched (${data.products.length} games)`,
);
currentPage++;
// Rate-limit: 500 ms between page requests
if (currentPage <= totalPages) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
} while (currentPage <= totalPages);
console.log(`[GOG] Library complete: ${allGames.length} games total`);
return allGames;
}
// ── Cover URL helper ─────────────────────────────────────────────────────────
/**
* Convert a GOG image base path into a full HTTPS cover URL.
*
* GOG images are returned as protocol-relative paths like
* `//images-1.gog.com/xxx`. This helper ensures HTTPS and optionally
* appends a size suffix (e.g. `_196.jpg`).
*
* @param imageBase The raw image path returned by the GOG API
* @param size Optional size suffix to append (e.g. `_196.jpg`)
*/
export function gogCoverUrl(imageBase: string, size?: string): string {
let url = imageBase;
// Ensure HTTPS prefix
if (url.startsWith('//')) {
url = `https:${url}`;
} else if (!url.startsWith('http')) {
url = `https://${url}`;
}
// Append size suffix if provided
if (size) {
url = `${url}${size}`;
}
return url;
}

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;