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:
parent
89e655482b
commit
ee5c29dd7b
4 changed files with 859 additions and 189 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue