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; // keyed by steamId gogUsers: Record; // keyed by gogUserId profiles: Record; // 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 { 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(`Steam verbunden

Steam verbunden!

${personaName} wurde erfolgreich verknuepft.

${games.length} Spiele geladen.

Du kannst dieses Fenster schliessen.

`); } 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 const userGameMaps = userIds.map(id => { const map = new Map(); 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(); 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(`GOG verbunden

GOG verbunden!

${profileName}: ${games.length} Spiele geladen.

Du kannst dieses Fenster schliessen.

`); } 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 `${title}

${title}

${message}

Du kannst dieses Fenster schliessen.

`; } export default gameLibraryPlugin;