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 { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.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 GameLibraryData { users: Record; // keyed by steamId } // ── 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: {} }; 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, })); sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users }); } // ── 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) { loadData(ctx); // ensure file exists console.log('[GameLibrary] Initialized'); }, 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(), }; 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.' }); } }); // ── 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;