snapshot current state before gitea sync

This commit is contained in:
2026-02-18 10:50:36 +01:00
parent bc22a6b5a0
commit 831ed42b7e
20 changed files with 1015 additions and 61 deletions

2
.env.1password Normal file
View File

@@ -0,0 +1,2 @@
TWITCH_CLIENT_ID=op://Private/WhatToPlay/TWITCH_CLIENT_ID
TWITCH_CLIENT_SECRET=op://Private/WhatToPlay/TWITCH_CLIENT_SECRET

4
.gitignore vendored
View File

@@ -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

52
CODEX_REPORT.md Normal file
View File

@@ -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).

1
GamePlaylist.io Submodule

Submodule GamePlaylist.io added at b9e8b6d19c

1
GamePlaylistMaker Submodule

Submodule GamePlaylistMaker added at f695642da9

View File

@@ -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

54
deploy.sh Executable file
View File

@@ -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"

View File

@@ -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",

0
server/data/.gitkeep Normal file
View File

90
server/gog-api.mjs Normal file
View File

@@ -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),
}),
);
}
});
}

157
server/gog-backend.mjs Normal file
View File

@@ -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();
}

225
server/igdb-cache.mjs Normal file
View File

@@ -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<string, number>} 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<Array>} 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;
});
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -33,7 +33,10 @@ const mergeGames = (allGames: Game[]) => {
const map = new Map<string, Game>();
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() {
<div>
<h1>Spielebibliothek</h1>
<p>
Deine Spiele aus Steam.
Deine Spiele aus allen Quellen.
</p>
</div>
<div className="hero-stats">
@@ -256,7 +261,7 @@ export default function LibraryPage() {
{loading ? (
<div className="state">
<IonSpinner name="crescent" />
<p>Lade Steam-Daten </p>
<p>Lade Spiele </p>
</div>
) : error ? (
<div className="state error">

View File

@@ -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<SettingsRouteParams>();
@@ -58,6 +67,7 @@ export default function SettingsDetailPage() {
const [showAlert, setShowAlert] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [apiOutput, setApiOutput] = useState<string>("");
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 ? (
<>
<IonList inset>
<IonItem>
<IonLabel>
<h2>Verbunden</h2>
<p>User ID: {config.gog.userId || "Unbekannt"}</p>
</IonLabel>
</IonItem>
</IonList>
<div className="settings-detail-actions">
<IonButton
expand="block"
onClick={() => handleManualRefresh("gog")}
>
<IonIcon slot="start" icon={refreshOutline} />
Spiele aktualisieren
</IonButton>
<IonButton
expand="block"
fill="outline"
color="danger"
onClick={handleGogDisconnect}
>
<IonIcon slot="start" icon={logOutOutline} />
Verbindung trennen
</IonButton>
</div>
</>
) : (
<>
<IonList inset>
<IonItem>
<IonLabel className="ion-text-wrap">
<p>
1. Klicke auf "Bei GOG anmelden" und logge dich ein.
</p>
<p>
2. Nach dem Login landest du auf einer Seite mit einer
URL die <strong>code=...</strong> enthält.
</p>
<p>
3. Kopiere den Wert nach <strong>code=</strong> und
füge ihn unten ein.
</p>
</IonLabel>
</IonItem>
</IonList>
<div className="settings-detail-actions">
<IonButton
expand="block"
fill="outline"
onClick={() => window.open(GOG_AUTH_URL, "_blank")}
>
<IonIcon slot="start" icon={linkOutline} />
Bei GOG anmelden
</IonButton>
</div>
<IonList inset>
<IonItem>
<IonLabel position="stacked">Authorization Code</IonLabel>
<IonInput
type="text"
placeholder="Code aus der URL einfügen"
value={gogCode}
onIonInput={(e) =>
setGogCode(e.detail.value || "")
}
/>
</IonItem>
</IonList>
<div className="settings-detail-actions">
<IonButton
expand="block"
onClick={handleGogConnect}
disabled={!gogCode.trim()}
>
<IonIcon slot="start" icon={saveOutline} />
Verbinden
</IonButton>
</div>
</>
)}
</>
)}
{serviceId === "data" && (
<>
<IonList inset>

View File

@@ -43,6 +43,15 @@ export default function SettingsPage() {
<IonLabel>Steam</IonLabel>
<IonNote slot="end">API Key · Steam ID</IonNote>
</IonItem>
<IonItem
routerLink="/settings/gog"
routerDirection="forward"
detail
>
<IonIcon slot="start" icon={gameControllerOutline} />
<IonLabel>GOG</IonLabel>
<IonNote slot="end">OAuth Login</IonNote>
</IonItem>
</IonList>
<IonList inset>

View File

@@ -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";

View File

@@ -154,6 +154,30 @@ class Database {
});
}
async saveGamesBySource(source: string, games: Game[]): Promise<void> {
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<Game[]> {
if (!this.db) await this.init();

View File

@@ -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();
});
},