gaming-hub/server/src/plugins/game-library/index.ts

536 lines
18 KiB
TypeScript
Raw Normal View History

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<string, SteamUser>; // 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<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) {
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(`<!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.' });
}
});
// ── 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;