- 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>
804 lines
29 KiB
TypeScript
804 lines
29 KiB
TypeScript
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';
|
|
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 ──
|
|
|
|
interface SteamGame {
|
|
appid: number;
|
|
name: string;
|
|
playtime_forever: number; // minutes
|
|
img_icon_url: string;
|
|
igdb?: IgdbGameInfo;
|
|
}
|
|
|
|
interface SteamUser {
|
|
steamId: string;
|
|
personaName: string;
|
|
avatarUrl: string;
|
|
profileUrl: string;
|
|
games: SteamGame[];
|
|
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
|
|
gogUsers: Record<string, GogUserData>; // keyed by gogUserId
|
|
profiles: Record<string, UserProfile>; // keyed by profileId
|
|
}
|
|
|
|
// ── 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: {}, gogUsers: {}, profiles: {} };
|
|
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,
|
|
}));
|
|
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 ──
|
|
|
|
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) {
|
|
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
|
|
const allUsers = Object.values(data.users);
|
|
const unenrichedUsers = allUsers.filter(u =>
|
|
u.games.some(g => !g.igdb),
|
|
);
|
|
if (unenrichedUsers.length > 0) {
|
|
console.log(`[GameLibrary] Auto-enriching ${unenrichedUsers.length} user(s) with IGDB data...`);
|
|
(async () => {
|
|
for (const user of unenrichedUsers) {
|
|
try {
|
|
const unenrichedGames = user.games.filter(g => !g.igdb).map(g => ({ appid: g.appid, name: g.name }));
|
|
console.log(`[GameLibrary] IGDB auto-enriching ${user.personaName}: ${unenrichedGames.length} games...`);
|
|
const igdbMap = await enrichGames(unenrichedGames);
|
|
// Reload fresh to avoid stale writes
|
|
const freshData = loadData(ctx);
|
|
const freshUser = freshData.users[user.steamId];
|
|
if (freshUser) {
|
|
let count = 0;
|
|
for (const game of freshUser.games) {
|
|
const info = igdbMap.get(game.appid);
|
|
if (info) { game.igdb = info; count++; }
|
|
}
|
|
saveData(ctx, freshData);
|
|
console.log(`[GameLibrary] IGDB startup enrichment for ${user.personaName}: ${count}/${unenrichedGames.length} games matched`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[GameLibrary] IGDB startup enrichment error for ${user.personaName}:`, err);
|
|
}
|
|
}
|
|
console.log('[GameLibrary] IGDB startup enrichment complete.');
|
|
})();
|
|
}
|
|
},
|
|
|
|
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(),
|
|
};
|
|
|
|
// 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);
|
|
|
|
// 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`);
|
|
|
|
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,
|
|
...(refGame.igdb ? { igdb: refGame.igdb } : {}),
|
|
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 });
|
|
});
|
|
|
|
// ── 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 {
|
|
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);
|
|
|
|
// 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({
|
|
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.' });
|
|
}
|
|
});
|
|
|
|
// ── 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;
|
|
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;
|