From 831ed42b7e48b703cbabf6f60ff8b320c15ab186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 18 Feb 2026 10:50:36 +0100 Subject: [PATCH] snapshot current state before gitea sync --- .env.1password | 2 + .gitignore | 4 + CODEX_REPORT.md | 52 ++++ GamePlaylist.io | 1 + GamePlaylistMaker | 1 + UBERSPACE.md | 62 ++++- deploy.sh | 54 ++++ package.json | 6 +- server/data/.gitkeep | 0 server/gog-api.mjs | 90 +++++++ server/gog-backend.mjs | 157 ++++++++++++ server/igdb-cache.mjs | 225 +++++++++++++++++ server/index.js | 70 +++++- server/steam-api.mjs | 4 +- src/pages/Library/LibraryPage.tsx | 17 +- src/pages/Settings/SettingsDetailPage.tsx | 285 ++++++++++++++++++---- src/pages/Settings/SettingsPage.tsx | 9 + src/services/ConfigService.ts | 6 + src/services/Database.ts | 24 ++ vite.config.ts | 7 + 20 files changed, 1015 insertions(+), 61 deletions(-) create mode 100644 .env.1password create mode 100644 CODEX_REPORT.md create mode 160000 GamePlaylist.io create mode 160000 GamePlaylistMaker create mode 100755 deploy.sh create mode 100644 server/data/.gitkeep create mode 100644 server/gog-api.mjs create mode 100644 server/gog-backend.mjs create mode 100644 server/igdb-cache.mjs diff --git a/.env.1password b/.env.1password new file mode 100644 index 0000000..c0feca9 --- /dev/null +++ b/.env.1password @@ -0,0 +1,2 @@ +TWITCH_CLIENT_ID=op://Private/WhatToPlay/TWITCH_CLIENT_ID +TWITCH_CLIENT_SECRET=op://Private/WhatToPlay/TWITCH_CLIENT_SECRET diff --git a/.gitignore b/.gitignore index b8529be..6826dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,14 @@ node_modules .env .env.* !.env.*.example +!.env.1password *.secret.* *.key *.pem +# IGDB cache (generated at runtime) +server/data/igdb-cache.json + # Build outputs dist build diff --git a/CODEX_REPORT.md b/CODEX_REPORT.md new file mode 100644 index 0000000..673766e --- /dev/null +++ b/CODEX_REPORT.md @@ -0,0 +1,52 @@ +# CODEX_REPORT + +Last updated: 2026-02-13 + +## Snapshot +- Product: "WhatToPlay" game library manager (PWA) aggregating libraries (Steam implemented; GOG WIP) with local persistence (IndexedDB). +- Frontend: React + TypeScript + Ionic (Vite). +- Backend: Node/Express in `server/` (Uberspace deployment; see `UBERSPACE.md`). +- Optional enrichment: IGDB canonical IDs via Twitch credentials (managed via 1Password CLI). + +## How To Run +- Install: `npm install` +- Dev: + - `npm run dev` (uses `op run --env-file=.env.1password -- vite`) + - `npm run dev:no-op` (runs Vite without 1Password, no IGDB enrichment) +- Tests: `npm test` (Node test runner over `server/**/*.test.mjs`) +- Deploy: `npm run deploy` (script is `./deploy.sh`; see `UBERSPACE.md`) + +## Current Working Tree +- Modified: + - `.gitignore`, `UBERSPACE.md`, `package.json`, `vite.config.ts` + - `server/index.js`, `server/steam-api.mjs` + - `src/pages/Library/LibraryPage.tsx` + - `src/pages/Settings/SettingsPage.tsx`, `src/pages/Settings/SettingsDetailPage.tsx` + - `src/services/ConfigService.ts`, `src/services/Database.ts` +- Untracked: + - `.env.1password` (intended to be safe to commit: 1Password references, not plaintext secrets) + - `deploy.sh` + - `server/data/` (currently contains `.gitkeep`) + - `server/gog-api.mjs`, `server/gog-backend.mjs`, `server/igdb-cache.mjs` + - `CODEX_REPORT.md` (this file) + +## What Changed Recently (Observed) +- Added GOG connect flow scaffolding in settings UI and backend endpoints (`/gog/auth`, `/gog/refresh`). +- Added IGDB enrichment/caching plumbing (cache stored under `server/data/`). +- Config storage now prefers IndexedDB with localStorage fallback (`src/services/ConfigService.ts`, `src/services/Database.ts`). + +## Plan +1. Make `npm test` deterministic and offline-safe: + - Current failure on this machine (Node `v25.6.1`): `npm test` fails with `Unable to deserialize cloned data due to invalid or unsupported version.` + - Tests also include optional live Steam API calls gated on `config.local.json`; replace with mocked `fetch` and fixtures. +2. Decide what should be committed vs local-only: + - Ensure `.env.1password`, `deploy.sh`, and new backend helpers are either committed intentionally or ignored. +3. Tighten backend security defaults: + - Avoid `ALLOWED_ORIGIN || "*"` in production. + - Restrict the catch-all proxy route (`app.all("/*")`) to a narrow allowlist or remove if not required. +4. Localization/UX hygiene: + - UI/strings currently mix German/English; align on an English-first source-of-truth and add localization scaffolding if desired. + +## Next actions +1. Fix the test runner failure and convert backend tests to pure unit tests (mocked network). +2. Add/ignore the current untracked files based on intent (deployment + backend helpers vs local-only). diff --git a/GamePlaylist.io b/GamePlaylist.io new file mode 160000 index 0000000..b9e8b6d --- /dev/null +++ b/GamePlaylist.io @@ -0,0 +1 @@ +Subproject commit b9e8b6d19c2dd87baa5bba1e8eaa5130f1dc21d2 diff --git a/GamePlaylistMaker b/GamePlaylistMaker new file mode 160000 index 0000000..f695642 --- /dev/null +++ b/GamePlaylistMaker @@ -0,0 +1 @@ +Subproject commit f695642da9f0052f6dc4e84b6aeb79d519b5dfe4 diff --git a/UBERSPACE.md b/UBERSPACE.md index 3de916d..b3d0d0d 100644 --- a/UBERSPACE.md +++ b/UBERSPACE.md @@ -125,21 +125,67 @@ Damit React Router bei direktem Aufruf von Unterseiten funktioniert, muss eine ` Die Datei liegt bereits in `public/.htaccess` und wird beim Build automatisch nach `dist/` kopiert. -## 4. Updates deployen +## 4. Secrets (1Password) + +Secrets werden über 1Password CLI (`op`) verwaltet. `.env.1password` enthält Referenzen auf 1Password-Einträge (keine echten Secrets). + +### Voraussetzung + +1Password CLI installiert und eingeloggt auf dem Deploy-Mac: +```bash +brew install --cask 1password-cli +``` + +In 1Password einen Eintrag "WhatToPlay" im Vault "Private" anlegen mit: +- `TWITCH_CLIENT_ID` — Twitch Developer App Client ID +- `TWITCH_CLIENT_SECRET` — Twitch Developer App Client Secret + +### Lokale Entwicklung ```bash -# Lokal -npm run build -rsync -avz --delete dist/ wtp:~/html/ +npm run dev # Startet Vite mit Secrets aus 1Password +npm run dev:no-op # Startet Vite ohne 1Password (kein IGDB-Enrichment) +``` -# Backend (auf dem Server) +### Einmalig: Server für EnvironmentFile konfigurieren + +Der systemd-Service muss die Env-Datei laden, die beim Deploy geschrieben wird: + +```bash ssh wtp -cd ~/whattoplay && git pull -cd server && npm install +mkdir -p ~/.config/systemd/user/whattoplay.service.d/ +cat > ~/.config/systemd/user/whattoplay.service.d/env.conf << 'EOF' +[Service] +EnvironmentFile=%h/whattoplay.env +EOF +systemctl --user daemon-reload systemctl --user restart whattoplay ``` -## 5. Domain (optional) +## 5. Updates deployen + +```bash +npm run deploy +``` + +Das Deploy-Script (`deploy.sh`) macht alles automatisch: +1. Frontend bauen (`npm run build`) +2. Frontend hochladen (`rsync → ~/html/`) +3. Backend hochladen (`rsync → ~/whattoplay/server/`) +4. Backend-Dependencies installieren +5. Secrets aus 1Password lesen und als `~/whattoplay.env` auf den Server schreiben +6. Service neustarten + +### Manuelles Deploy (ohne 1Password) + +```bash +npm run build +rsync -avz --delete dist/ wtp:~/html/ +rsync -avz --delete --exclude node_modules --exclude data/igdb-cache.json server/ wtp:~/whattoplay/server/ +ssh wtp "cd ~/whattoplay/server && npm install --production && systemctl --user restart whattoplay" +``` + +## 6. Domain (optional) ```bash uberspace web domain add your-domain.com diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..a5d7ab4 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +SERVER="wtp" +REMOTE_HTML="~/html/" +REMOTE_SERVER="~/whattoplay/server/" +ENV_FILE=".env.1password" + +echo "=== WhatToPlay Deploy ===" + +# 1. Build frontend +echo "" +echo "[1/5] Building frontend..." +npm run build + +# 2. Deploy frontend +echo "" +echo "[2/5] Deploying frontend..." +rsync -avz --delete dist/ "$SERVER:$REMOTE_HTML" + +# 3. Deploy backend +echo "" +echo "[3/5] Deploying backend..." +rsync -avz --delete \ + --exclude node_modules \ + --exclude data/igdb-cache.json \ + server/ "$SERVER:$REMOTE_SERVER" + +# 4. Install backend dependencies on server +echo "" +echo "[4/5] Installing backend dependencies..." +ssh "$SERVER" "cd $REMOTE_SERVER && npm install --production" + +# 5. Inject secrets from 1Password and restart +echo "" +echo "[5/5] Updating secrets and restarting service..." + +TWITCH_CLIENT_ID=$(op read "op://Private/WhatToPlay/TWITCH_CLIENT_ID") +TWITCH_CLIENT_SECRET=$(op read "op://Private/WhatToPlay/TWITCH_CLIENT_SECRET") + +ssh "$SERVER" "cat > ~/whattoplay.env << 'ENVEOF' +PORT=3000 +ALLOWED_ORIGIN=https://wtp.uber.space +TWITCH_CLIENT_ID=$TWITCH_CLIENT_ID +TWITCH_CLIENT_SECRET=$TWITCH_CLIENT_SECRET +ENVEOF +chmod 600 ~/whattoplay.env" + +ssh "$SERVER" "systemctl --user restart whattoplay" + +echo "" +echo "=== Deploy complete ===" +echo "Frontend: https://wtp.uber.space" +echo "Backend: https://wtp.uber.space/api/health" diff --git a/package.json b/package.json index d6b0a85..a33a236 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,12 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "op run --env-file=.env.1password -- vite", + "dev:no-op": "vite", "build": "vite build", "preview": "vite preview", - "test": "node --test server/**/*.test.mjs" + "test": "node --test server/**/*.test.mjs", + "deploy": "./deploy.sh" }, "dependencies": { "@ionic/react": "^8.0.0", diff --git a/server/data/.gitkeep b/server/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/gog-api.mjs b/server/gog-api.mjs new file mode 100644 index 0000000..1bd1c5d --- /dev/null +++ b/server/gog-api.mjs @@ -0,0 +1,90 @@ +/** + * GOG API Handler für Vite Dev Server + * Fungiert als Proxy um CORS-Probleme zu vermeiden + */ + +import { exchangeGogCode, fetchGogGames } from "./gog-backend.mjs"; +import { enrichGamesWithIgdb } from "./igdb-cache.mjs"; + +export async function handleGogAuth(req, res) { + if (req.method !== "POST") { + res.statusCode = 405; + res.end("Method Not Allowed"); + return; + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + + req.on("end", async () => { + try { + const payload = JSON.parse(body || "{}"); + const { code } = payload; + + if (!code) { + res.statusCode = 400; + res.end(JSON.stringify({ error: "code ist erforderlich" })); + return; + } + + const tokens = await exchangeGogCode(code); + + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(tokens)); + } catch (error) { + res.statusCode = 500; + res.end( + JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }), + ); + } + }); +} + +export async function handleGogRefresh(req, res) { + if (req.method !== "POST") { + res.statusCode = 405; + res.end("Method Not Allowed"); + return; + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + + req.on("end", async () => { + try { + const payload = JSON.parse(body || "{}"); + const { accessToken, refreshToken } = payload; + + if (!accessToken || !refreshToken) { + res.statusCode = 400; + res.end( + JSON.stringify({ + error: "accessToken und refreshToken sind erforderlich", + }), + ); + return; + } + + const result = await fetchGogGames(accessToken, refreshToken); + result.games = await enrichGamesWithIgdb(result.games); + + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(result)); + } catch (error) { + res.statusCode = 500; + res.end( + JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }), + ); + } + }); +} diff --git a/server/gog-backend.mjs b/server/gog-backend.mjs new file mode 100644 index 0000000..deda957 --- /dev/null +++ b/server/gog-backend.mjs @@ -0,0 +1,157 @@ +/** + * GOG Backend - Unofficial GOG API Integration + * Uses Galaxy client credentials (well-known, used by lgogdownloader etc.) + */ + +const CLIENT_ID = "46899977096215655"; +const CLIENT_SECRET = + "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"; +const REDIRECT_URI = + "https://embed.gog.com/on_login_success?origin=client"; + +/** + * Exchange authorization code for access + refresh tokens + * @param {string} code - Auth code from GOG login redirect + * @returns {Promise<{access_token: string, refresh_token: string, user_id: string, expires_in: number}>} + */ +export async function exchangeGogCode(code) { + if (!code) { + throw new Error("Authorization code ist erforderlich"); + } + + const url = new URL("https://auth.gog.com/token"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("client_secret", CLIENT_SECRET); + url.searchParams.set("grant_type", "authorization_code"); + url.searchParams.set("code", code); + url.searchParams.set("redirect_uri", REDIRECT_URI); + + const response = await fetch(url); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `GOG Token Exchange Error: ${response.status} ${text}`, + ); + } + + const data = await response.json(); + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + user_id: data.user_id, + expires_in: data.expires_in, + }; +} + +/** + * Refresh an expired access token + * @param {string} refreshToken + * @returns {Promise<{access_token: string, refresh_token: string, expires_in: number}>} + */ +async function refreshAccessToken(refreshToken) { + const url = new URL("https://auth.gog.com/token"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("client_secret", CLIENT_SECRET); + url.searchParams.set("grant_type", "refresh_token"); + url.searchParams.set("refresh_token", refreshToken); + + const response = await fetch(url); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GOG Token Refresh Error: ${response.status} ${text}`); + } + + const data = await response.json(); + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_in: data.expires_in, + }; +} + +/** + * Fetch all owned games from GOG + * @param {string} accessToken + * @param {string} refreshToken + * @returns {Promise<{games: Array, count: number, newAccessToken?: string, newRefreshToken?: string}>} + */ +export async function fetchGogGames(accessToken, refreshToken) { + if (!accessToken || !refreshToken) { + throw new Error("accessToken und refreshToken sind erforderlich"); + } + + let token = accessToken; + let newTokens = null; + + // Fetch first page to get totalPages + let page = 1; + let totalPages = 1; + const allProducts = []; + + while (page <= totalPages) { + const url = `https://embed.gog.com/account/getFilteredProducts?mediaType=1&page=${page}`; + + let response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + // Token expired — try refresh + if (response.status === 401 && !newTokens) { + console.log("[GOG] Token expired, refreshing..."); + newTokens = await refreshAccessToken(refreshToken); + token = newTokens.access_token; + + response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + } + + if (!response.ok) { + throw new Error( + `GOG API Error: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + totalPages = data.totalPages || 1; + allProducts.push(...(data.products || [])); + page++; + } + + // Transform to our Game format, skip products without title + const games = allProducts + .filter((product) => product.title) + .map((product) => ({ + id: `gog-${product.id}`, + title: product.title, + source: "gog", + sourceId: String(product.id), + platform: "PC", + url: product.url + ? `https://www.gog.com${product.url}` + : undefined, + })); + + return { + games, + count: games.length, + ...(newTokens && { + newAccessToken: newTokens.access_token, + newRefreshToken: newTokens.refresh_token, + }), + }; +} + +/** + * Returns the GOG auth URL for the user to open in their browser + */ +export function getGogAuthUrl() { + const url = new URL("https://auth.gog.com/auth"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("layout", "client2"); + return url.toString(); +} diff --git a/server/igdb-cache.mjs b/server/igdb-cache.mjs new file mode 100644 index 0000000..7f16d19 --- /dev/null +++ b/server/igdb-cache.mjs @@ -0,0 +1,225 @@ +/** + * IGDB Cache - Shared canonical game ID resolution + * Uses Twitch OAuth + IGDB external_games endpoint + * Cache is shared across all users (mappings are universal) + */ + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CACHE_FILE = join(__dirname, "data", "igdb-cache.json"); + +// IGDB external game categories +const CATEGORY_STEAM = 1; +const CATEGORY_GOG = 2; + +const SOURCE_TO_CATEGORY = { + steam: CATEGORY_STEAM, + gog: CATEGORY_GOG, +}; + +// In-memory cache: "steam:12345" → { igdbId: 67890 } +const cache = new Map(); + +// Twitch OAuth token state +let twitchToken = null; +let tokenExpiry = 0; + +/** + * Load cache from JSON file on disk + */ +export function loadCache() { + try { + const data = readFileSync(CACHE_FILE, "utf-8"); + const entries = JSON.parse(data); + for (const [key, value] of Object.entries(entries)) { + cache.set(key, value); + } + console.log(`[IGDB] Cache loaded: ${cache.size} entries`); + } catch { + console.log("[IGDB] No cache file found, starting fresh"); + } +} + +/** + * Save cache to JSON file on disk + */ +function saveCache() { + try { + mkdirSync(join(__dirname, "data"), { recursive: true }); + const obj = Object.fromEntries(cache); + writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2)); + } catch (err) { + console.error("[IGDB] Failed to save cache:", err.message); + } +} + +/** + * Get a valid Twitch access token (refreshes if expired) + */ +async function getIgdbToken() { + if (twitchToken && Date.now() < tokenExpiry) { + return twitchToken; + } + + const clientId = process.env.TWITCH_CLIENT_ID; + const clientSecret = process.env.TWITCH_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return null; + } + + const url = new URL("https://id.twitch.tv/oauth2/token"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("client_secret", clientSecret); + url.searchParams.set("grant_type", "client_credentials"); + + const response = await fetch(url, { method: "POST" }); + + if (!response.ok) { + console.error( + `[IGDB] Twitch auth failed: ${response.status} ${response.statusText}`, + ); + return null; + } + + const data = await response.json(); + twitchToken = data.access_token; + // Refresh 5 minutes before actual expiry + tokenExpiry = Date.now() + (data.expires_in - 300) * 1000; + console.log("[IGDB] Twitch token acquired"); + return twitchToken; +} + +/** + * Make an IGDB API request with Apicalypse query + */ +async function igdbRequest(endpoint, query) { + const token = await getIgdbToken(); + if (!token) return []; + + const response = await fetch(`https://api.igdb.com/v4${endpoint}`, { + method: "POST", + headers: { + "Client-ID": process.env.TWITCH_CLIENT_ID, + Authorization: `Bearer ${token}`, + "Content-Type": "text/plain", + }, + body: query, + }); + + if (!response.ok) { + const text = await response.text(); + console.error(`[IGDB] API error: ${response.status} ${text}`); + return []; + } + + return response.json(); +} + +/** + * Sleep helper for rate limiting (4 req/sec max) + */ +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Batch-resolve IGDB IDs for a list of source IDs + * @param {number} category - IGDB category (1=Steam, 2=GOG) + * @param {string[]} sourceIds - List of source-specific IDs + * @returns {Map} sourceId → igdbGameId + */ +async function batchResolve(category, sourceIds) { + const results = new Map(); + const BATCH_SIZE = 500; + + for (let i = 0; i < sourceIds.length; i += BATCH_SIZE) { + const batch = sourceIds.slice(i, i + BATCH_SIZE); + const uids = batch.map((id) => `"${id}"`).join(","); + const query = `fields game,uid; where category = ${category} & uid = (${uids}); limit ${BATCH_SIZE};`; + + const data = await igdbRequest("/external_games", query); + + for (const entry of data) { + if (entry.game && entry.uid) { + results.set(entry.uid, entry.game); + } + } + + // Rate limit: wait between batches + if (i + BATCH_SIZE < sourceIds.length) { + await sleep(260); + } + } + + return results; +} + +/** + * Enrich games with IGDB canonical IDs + * Graceful: if IGDB is unavailable or no credentials, games pass through unchanged + * @param {Array<{source: string, sourceId: string}>} games + * @returns {Promise} Games with canonicalId added where available + */ +export async function enrichGamesWithIgdb(games) { + // Check if IGDB credentials are configured + if (!process.env.TWITCH_CLIENT_ID || !process.env.TWITCH_CLIENT_SECRET) { + return games; + } + + // Find uncached games, grouped by source + const uncachedBySource = {}; + for (const game of games) { + const cacheKey = `${game.source}:${game.sourceId}`; + if (!cache.has(cacheKey) && SOURCE_TO_CATEGORY[game.source]) { + if (!uncachedBySource[game.source]) { + uncachedBySource[game.source] = []; + } + uncachedBySource[game.source].push(game.sourceId); + } + } + + // Batch-resolve uncached games from IGDB + let newEntries = 0; + try { + for (const [source, sourceIds] of Object.entries(uncachedBySource)) { + const category = SOURCE_TO_CATEGORY[source]; + console.log( + `[IGDB] Resolving ${sourceIds.length} ${source} games (category ${category})...`, + ); + + const resolved = await batchResolve(category, sourceIds); + + for (const [uid, igdbId] of resolved) { + cache.set(`${source}:${uid}`, { igdbId }); + newEntries++; + } + + // Mark unresolved games as null so we don't re-query them + for (const uid of sourceIds) { + if (!resolved.has(uid)) { + cache.set(`${source}:${uid}`, { igdbId: null }); + } + } + } + + if (newEntries > 0) { + console.log( + `[IGDB] Resolved ${newEntries} new games, cache now has ${cache.size} entries`, + ); + saveCache(); + } + } catch (err) { + console.error("[IGDB] Enrichment failed (non-fatal):", err.message); + } + + // Enrich games with canonicalId from cache + return games.map((game) => { + const cached = cache.get(`${game.source}:${game.sourceId}`); + if (cached?.igdbId) { + return { ...game, canonicalId: String(cached.igdbId) }; + } + return game; + }); +} diff --git a/server/index.js b/server/index.js index ebc7d72..d58de23 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,8 @@ import express from "express"; import cors from "cors"; import fetch from "node-fetch"; +import { exchangeGogCode, fetchGogGames } from "./gog-backend.mjs"; +import { enrichGamesWithIgdb, loadCache } from "./igdb-cache.mjs"; const app = express(); const PORT = process.env.PORT || 3000; @@ -14,6 +16,9 @@ app.use( app.use(express.json()); +// Load IGDB cache on startup +loadCache(); + // Health check app.get("/health", (req, res) => { res.json({ status: "ok" }); @@ -51,10 +56,20 @@ app.post("/steam/refresh", async (req, res) => { const data = await response.json(); console.log(`[Steam] Success! Games count: ${data.response?.game_count || 0}`); - // Transform Steam API response to match frontend expectations + const rawGames = data.response?.games || []; + + // Enrich with IGDB canonical IDs + const gamesForIgdb = rawGames.map((g) => ({ + ...g, + source: "steam", + sourceId: String(g.appid), + })); + const enriched = await enrichGamesWithIgdb(gamesForIgdb); + + // Return enriched games (source/sourceId/canonicalId included) const transformed = { - games: data.response?.games || [], - count: data.response?.game_count || 0, + games: enriched, + count: enriched.length, }; const responseSize = JSON.stringify(transformed).length; @@ -70,6 +85,55 @@ app.post("/steam/refresh", async (req, res) => { } }); +// GOG API: Exchange auth code for tokens +app.post("/gog/auth", async (req, res) => { + const { code } = req.body; + + console.log("[GOG] Starting code exchange"); + + if (!code) { + return res.status(400).json({ error: "Missing required field: code" }); + } + + try { + const tokens = await exchangeGogCode(code); + console.log(`[GOG] Token exchange successful, user: ${tokens.user_id}`); + res.json(tokens); + } catch (error) { + console.error("[GOG] Token exchange error:", error); + res.status(500).json({ + error: "GOG token exchange failed", + message: error.message, + }); + } +}); + +// GOG API: Refresh games +app.post("/gog/refresh", async (req, res) => { + const { accessToken, refreshToken } = req.body; + + console.log("[GOG] Starting game refresh"); + + if (!accessToken || !refreshToken) { + return res.status(400).json({ + error: "Missing required fields: accessToken and refreshToken", + }); + } + + try { + const result = await fetchGogGames(accessToken, refreshToken); + result.games = await enrichGamesWithIgdb(result.games); + console.log(`[GOG] Success! ${result.count} games fetched`); + res.json(result); + } catch (error) { + console.error("[GOG] Refresh error:", error); + res.status(500).json({ + error: "GOG refresh failed", + message: error.message, + }); + } +}); + // Fallback proxy for other Steam API calls app.all("/*", async (req, res) => { const path = req.url; diff --git a/server/steam-api.mjs b/server/steam-api.mjs index c1c4430..06feba7 100644 --- a/server/steam-api.mjs +++ b/server/steam-api.mjs @@ -4,6 +4,7 @@ */ import { fetchSteamGames } from "./steam-backend.mjs"; +import { enrichGamesWithIgdb } from "./igdb-cache.mjs"; export async function handleSteamRefresh(req, res) { if (req.method !== "POST") { @@ -41,10 +42,11 @@ export async function handleSteamRefresh(req, res) { } const { games, count } = await fetchSteamGames(apiKey, steamId); + const enriched = await enrichGamesWithIgdb(games); res.statusCode = 200; res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ games, count })); + res.end(JSON.stringify({ games: enriched, count: enriched.length })); } catch (error) { res.statusCode = 500; res.end( diff --git a/src/pages/Library/LibraryPage.tsx b/src/pages/Library/LibraryPage.tsx index 4a9e446..678d1af 100644 --- a/src/pages/Library/LibraryPage.tsx +++ b/src/pages/Library/LibraryPage.tsx @@ -33,7 +33,10 @@ const mergeGames = (allGames: Game[]) => { const map = new Map(); allGames.forEach((game) => { - const key = normalizeTitle(game.title); + if (!game.title) return; + + // Primary: canonicalId (IGDB), fallback: normalized title + const key = game.canonicalId || `title:${normalizeTitle(game.title)}`; const existing = map.get(key); if (!existing) { @@ -82,7 +85,7 @@ export default function LibraryPage() { ]); if (active) { - setGames(dbGames); + setGames(mergeGames(dbGames)); setFavoriteIds(new Set(favPlaylist?.gameIds ?? [])); setError(null); } @@ -111,8 +114,10 @@ export default function LibraryPage() { }, [games]); const filteredAndSortedGames = useMemo(() => { - let filtered = games.filter((game) => - game.title.toLowerCase().includes(searchText.toLowerCase()), + let filtered = games.filter( + (game) => + game.title && + game.title.toLowerCase().includes(searchText.toLowerCase()), ); filtered.sort((a, b) => { @@ -238,7 +243,7 @@ export default function LibraryPage() {

Spielebibliothek

- Deine Spiele aus Steam. + Deine Spiele aus allen Quellen.

@@ -256,7 +261,7 @@ export default function LibraryPage() { {loading ? (
-

Lade Steam-Daten …

+

Lade Spiele …

) : error ? (
diff --git a/src/pages/Settings/SettingsDetailPage.tsx b/src/pages/Settings/SettingsDetailPage.tsx index ff78d3d..cfcab1e 100644 --- a/src/pages/Settings/SettingsDetailPage.tsx +++ b/src/pages/Settings/SettingsDetailPage.tsx @@ -17,6 +17,8 @@ import { IonToolbar, } from "@ionic/react"; import { + linkOutline, + logOutOutline, refreshOutline, saveOutline, settingsOutline, @@ -38,11 +40,18 @@ interface SettingsRouteParams { serviceId: string; } +const GOG_AUTH_URL = + "https://auth.gog.com/auth?client_id=46899977096215655&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code&layout=client2"; + const SERVICE_META = { steam: { title: "Steam", description: "Deine Steam-Bibliothek", }, + gog: { + title: "GOG", + description: "Deine GOG-Bibliothek", + }, data: { title: "Datenverwaltung", description: "Export, Import und Reset", @@ -50,7 +59,7 @@ const SERVICE_META = { } as const; type ServiceId = keyof typeof SERVICE_META; -const PROVIDER_IDS = ["steam"] as const; +const PROVIDER_IDS = ["steam", "gog"] as const; export default function SettingsDetailPage() { const { serviceId } = useParams(); @@ -58,6 +67,7 @@ export default function SettingsDetailPage() { const [showAlert, setShowAlert] = useState(false); const [alertMessage, setAlertMessage] = useState(""); const [apiOutput, setApiOutput] = useState(""); + const [gogCode, setGogCode] = useState(""); const meta = useMemo(() => SERVICE_META[serviceId as ServiceId], [serviceId]); @@ -88,14 +98,76 @@ export default function SettingsDetailPage() { } }; - const handleManualRefresh = async (service: keyof ServiceConfig) => { + const handleGogConnect = async () => { + if (!gogCode.trim()) { + setAlertMessage("Bitte den Code aus der URL eingeben"); + setShowAlert(true); + return; + } + + setApiOutput("Tausche Code gegen Token..."); + + try { + const apiUrl = ConfigService.getApiUrl("/api/gog/auth"); + const response = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: gogCode.trim() }), + }); + + if (!response.ok) { + const errorText = await response.text(); + setApiOutput(`❌ Fehler: ${response.status}\n${errorText}`); + setAlertMessage("GOG Verbindung fehlgeschlagen"); + setShowAlert(true); + return; + } + + const tokens = await response.json(); + + const updatedConfig = { + ...config, + gog: { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + userId: tokens.user_id, + }, + }; + setConfig(updatedConfig); + await ConfigService.saveConfig(updatedConfig); + setGogCode(""); + setApiOutput("✓ Verbunden! Spiele werden abgerufen..."); + + // Automatically fetch games after connecting + await handleManualRefresh("gog", updatedConfig); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + setApiOutput(`❌ Fehler: ${errorMsg}`); + setAlertMessage("GOG Verbindung fehlgeschlagen"); + setShowAlert(true); + } + }; + + const handleGogDisconnect = async () => { + const updatedConfig = { ...config }; + delete updatedConfig.gog; + setConfig(updatedConfig); + await ConfigService.saveConfig(updatedConfig); + setApiOutput(""); + setAlertMessage("✓ GOG Verbindung getrennt"); + setShowAlert(true); + }; + + const handleManualRefresh = async ( + service: keyof ServiceConfig, + configOverride?: ServiceConfig, + ) => { + const currentConfig = configOverride || config; setApiOutput("Rufe API auf..."); try { - let result: { games: any[]; count: number } | null = null; - if (service === "steam") { - const steamConfig = config.steam; + const steamConfig = currentConfig.steam; if (!steamConfig?.apiKey || !steamConfig?.steamId) { setApiOutput("❌ Fehler: Steam API Key und Steam ID erforderlich"); setAlertMessage("Bitte zuerst Steam-Zugangsdaten eingeben"); @@ -104,10 +176,8 @@ export default function SettingsDetailPage() { } const apiUrl = ConfigService.getApiUrl("/api/steam/refresh"); - console.log("[Frontend] Calling Steam API:", apiUrl); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout + const timeoutId = setTimeout(() => controller.abort(), 60000); try { const response = await fetch(apiUrl, { @@ -121,64 +191,109 @@ export default function SettingsDetailPage() { }); clearTimeout(timeoutId); - console.log("[Frontend] Got response:", response.status, response.statusText); - if (!response.ok) { const errorText = await response.text(); - console.error("[Frontend] API error:", errorText); setApiOutput(`❌ API Fehler: ${response.status}\n${errorText}`); setAlertMessage("Steam Refresh fehlgeschlagen"); setShowAlert(true); return; } - console.log("[Frontend] Parsing JSON response..."); - result = await response.json(); - console.log("[Frontend] Parsed result:", { - count: result.count, - gamesLength: result.games?.length, - }); - } catch (fetchError) { + const result = await response.json(); + + const transformedGames = result.games.map((steamGame: any) => ({ + id: `steam-${steamGame.appid}`, + title: steamGame.name, + source: "steam", + sourceId: String(steamGame.appid), + platform: "PC", + playtimeHours: steamGame.playtime_forever + ? Math.round(steamGame.playtime_forever / 60) + : 0, + url: `https://store.steampowered.com/app/${steamGame.appid}`, + ...(steamGame.canonicalId && { + canonicalId: steamGame.canonicalId, + }), + })); + + await db.saveGamesBySource("steam", transformedGames); + + const updatedConfig = { + ...currentConfig, + steam: { + ...currentConfig.steam, + lastRefresh: new Date().toISOString(), + }, + }; + setConfig(updatedConfig); + await ConfigService.saveConfig(updatedConfig); + + setApiOutput( + `✓ ${result.count} Spiele abgerufen\n\nBeispiel:\n${JSON.stringify(transformedGames.slice(0, 2), null, 2)}`, + ); + setAlertMessage(`✓ ${result.count} Spiele aktualisiert`); + setShowAlert(true); + } catch (fetchError: any) { clearTimeout(timeoutId); - if (fetchError.name === 'AbortError') { - throw new Error('Request timeout - Steam API took too long to respond'); + if (fetchError.name === "AbortError") { + throw new Error( + "Request timeout - Steam API took too long to respond", + ); } throw fetchError; } - } + } else if (service === "gog") { + const gogConfig = currentConfig.gog; + if (!gogConfig?.accessToken || !gogConfig?.refreshToken) { + setApiOutput("❌ Fehler: Bitte zuerst mit GOG verbinden"); + setAlertMessage("Bitte zuerst mit GOG verbinden"); + setShowAlert(true); + return; + } - if (result) { - console.log("[Frontend] Saving games to database..."); + const apiUrl = ConfigService.getApiUrl("/api/gog/refresh"); + const response = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accessToken: gogConfig.accessToken, + refreshToken: gogConfig.refreshToken, + }), + }); - // Transform Steam games to match our Game interface - const transformedGames = result.games.map((steamGame: any) => ({ - id: `steam-${steamGame.appid}`, - title: steamGame.name, - source: 'steam', - sourceId: String(steamGame.appid), - platform: 'steam', - playtimeHours: steamGame.playtime_forever ? Math.round(steamGame.playtime_forever / 60) : 0, - url: `https://store.steampowered.com/app/${steamGame.appid}`, - })); + if (!response.ok) { + const errorText = await response.text(); + setApiOutput(`❌ API Fehler: ${response.status}\n${errorText}`); + setAlertMessage("GOG Refresh fehlgeschlagen"); + setShowAlert(true); + return; + } - await db.saveGames(transformedGames); - console.log("[Frontend] Games saved successfully"); + const result = await response.json(); + await db.saveGamesBySource("gog", result.games); + + // Update tokens if refreshed const updatedConfig = { - ...config, - [service]: { - ...config[service], + ...currentConfig, + gog: { + ...currentConfig.gog, lastRefresh: new Date().toISOString(), + ...(result.newAccessToken && { + accessToken: result.newAccessToken, + }), + ...(result.newRefreshToken && { + refreshToken: result.newRefreshToken, + }), }, }; setConfig(updatedConfig); await ConfigService.saveConfig(updatedConfig); - console.log("[Frontend] Update complete, showing results"); setApiOutput( - `✓ ${result.count} Spiele abgerufen\n\nBeispiel:\n${JSON.stringify(transformedGames.slice(0, 2), null, 2)}`, + `✓ ${result.count} Spiele abgerufen\n\nBeispiel:\n${JSON.stringify(result.games.slice(0, 2), null, 2)}`, ); - setAlertMessage(`✓ ${result.count} Spiele aktualisiert`); + setAlertMessage(`✓ ${result.count} GOG-Spiele aktualisiert`); setShowAlert(true); } } catch (error) { @@ -341,6 +456,94 @@ export default function SettingsDetailPage() { )} + {serviceId === "gog" && ( + <> + {config.gog?.refreshToken ? ( + <> + + + +

Verbunden

+

User ID: {config.gog.userId || "Unbekannt"}

+
+
+
+
+ handleManualRefresh("gog")} + > + + Spiele aktualisieren + + + + Verbindung trennen + +
+ + ) : ( + <> + + + +

+ 1. Klicke auf "Bei GOG anmelden" und logge dich ein. +

+

+ 2. Nach dem Login landest du auf einer Seite mit einer + URL die code=... enthält. +

+

+ 3. Kopiere den Wert nach code= und + füge ihn unten ein. +

+
+
+
+
+ window.open(GOG_AUTH_URL, "_blank")} + > + + Bei GOG anmelden + +
+ + + Authorization Code + + setGogCode(e.detail.value || "") + } + /> + + +
+ + + Verbinden + +
+ + )} + + )} + {serviceId === "data" && ( <> diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 9b1dda6..cc7470c 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -43,6 +43,15 @@ export default function SettingsPage() { Steam API Key · Steam ID + + + GOG + OAuth Login + diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index a19b817..9dd3715 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -10,6 +10,12 @@ export interface ServiceConfig { steamId?: string; lastRefresh?: string; }; + gog?: { + accessToken?: string; + refreshToken?: string; + userId?: string; + lastRefresh?: string; + }; } const STORAGE_KEY = "whattoplay_config"; diff --git a/src/services/Database.ts b/src/services/Database.ts index 8c456c0..44131db 100644 --- a/src/services/Database.ts +++ b/src/services/Database.ts @@ -154,6 +154,30 @@ class Database { }); } + async saveGamesBySource(source: string, games: Game[]): Promise { + if (!this.db) await this.init(); + + // Step 1: Find IDs of existing games for this source + const existingGames = await this.getGamesBySource(source); + const existingIds = existingGames.map((g) => g.id); + + // Step 2: Delete old and add new in one transaction + return new Promise((resolve, reject) => { + const tx = this.db!.transaction("games", "readwrite"); + const store = tx.objectStore("games"); + + for (const id of existingIds) { + store.delete(id); + } + for (const game of games) { + store.put(game); + } + + tx.onerror = () => reject(tx.error); + tx.oncomplete = () => resolve(); + }); + } + async getGames(): Promise { if (!this.db) await this.init(); diff --git a/vite.config.ts b/vite.config.ts index a8c497c..cfad577 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import react from "@vitejs/plugin-react"; import { defineConfig, loadEnv } from "vite"; import { handleSteamRefresh } from "./server/steam-api.mjs"; +import { handleGogAuth, handleGogRefresh } from "./server/gog-api.mjs"; const apiMiddlewarePlugin = { name: "api-middleware", @@ -10,6 +11,12 @@ const apiMiddlewarePlugin = { if (url.startsWith("/api/steam/refresh")) { return handleSteamRefresh(req, res); } + if (url.startsWith("/api/gog/auth")) { + return handleGogAuth(req, res); + } + if (url.startsWith("/api/gog/refresh")) { + return handleGogRefresh(req, res); + } next(); }); },