snapshot current state before gitea sync
This commit is contained in:
2
.env.1password
Normal file
2
.env.1password
Normal 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
4
.gitignore
vendored
@@ -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
52
CODEX_REPORT.md
Normal 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
1
GamePlaylist.io
Submodule
Submodule GamePlaylist.io added at b9e8b6d19c
1
GamePlaylistMaker
Submodule
1
GamePlaylistMaker
Submodule
Submodule GamePlaylistMaker added at f695642da9
62
UBERSPACE.md
62
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
|
||||
|
||||
54
deploy.sh
Executable file
54
deploy.sh
Executable 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"
|
||||
@@ -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
0
server/data/.gitkeep
Normal file
90
server/gog-api.mjs
Normal file
90
server/gog-api.mjs
Normal 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
157
server/gog-backend.mjs
Normal 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
225
server/igdb-cache.mjs
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user