420 lines
14 KiB
TypeScript
420 lines
14 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';
|
||
|
|
|
||
|
|
// ── Types ──
|
||
|
|
|
||
|
|
interface SteamGame {
|
||
|
|
appid: number;
|
||
|
|
name: string;
|
||
|
|
playtime_forever: number; // minutes
|
||
|
|
img_icon_url: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
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,
|
||
|
|
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 });
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── 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);
|
||
|
|
|
||
|
|
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;
|