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

290 lines
8.7 KiB
TypeScript
Raw Normal View History

// ──────────────────────────────────────────────────────────────────────────────
// GOG (Good Old Games) API service
// Uses GOG OAuth2 for user authentication. All paginated requests include a
// 500 ms pause between pages to respect rate-limits.
// ──────────────────────────────────────────────────────────────────────────────
const GOG_CLIENT_ID = '46899977096215655';
const GOG_CLIENT_SECRET =
'9d85c43b1482497dbbce61f6e4aa173a433796eebd2c1f0f7f015c4c2e57571';
const GOG_AUTH_URL = 'https://auth.gog.com/auth';
const GOG_TOKEN_URL = 'https://auth.gog.com/token';
const GOG_EMBED_URL = 'https://embed.gog.com';
const GOG_REDIRECT_URI = 'https://embed.gog.com/on_login_success?origin=client';
// ── Types ────────────────────────────────────────────────────────────────────
export interface GogTokens {
accessToken: string;
refreshToken: string;
expiresAt: number; // unix-ms
userId: string;
}
export interface GogUserInfo {
userId: string;
username: string;
avatarUrl: string;
}
export interface GogGame {
gogId: number;
title: string;
image: string; // cover URL from GOG
slug: string;
}
// ── OAuth helpers ────────────────────────────────────────────────────────────
/**
* Build the GOG OAuth authorization URL that the user should visit to grant
* access. After approval, GOG redirects to `embed.gog.com/on_login_success`
* with a `code` query parameter that the user copies back into the app.
*/
export function getGogAuthUrl(): string {
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
redirect_uri: GOG_REDIRECT_URI,
response_type: 'code',
layout: 'client2',
});
return `${GOG_AUTH_URL}?${params.toString()}`;
}
/**
* Exchange an authorization code for GOG access + refresh tokens.
* Uses the fixed GOG_REDIRECT_URI that matches the OAuth registration.
*/
export async function exchangeGogCode(
code: string,
): Promise<GogTokens> {
console.log('[GOG] Exchanging authorization code for tokens...');
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
client_secret: GOG_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: GOG_REDIRECT_URI,
});
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] Token exchange failed (${res.status}): ${text}`);
throw new Error(`[GOG] Token exchange failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
user_id: string;
token_type: string;
};
const tokens: GogTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
userId: data.user_id,
};
console.log(
`[GOG] Tokens acquired for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
);
return tokens;
}
/**
* Obtain a fresh set of tokens using an existing refresh token.
*/
export async function refreshGogToken(
refreshToken: string,
): Promise<GogTokens> {
console.log('[GOG] Refreshing access token...');
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
client_secret: GOG_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] Token refresh failed (${res.status}): ${text}`);
throw new Error(`[GOG] Token refresh failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
user_id: string;
token_type: string;
};
const tokens: GogTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
userId: data.user_id,
};
console.log(
`[GOG] Token refreshed for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
);
return tokens;
}
// ── User info ────────────────────────────────────────────────────────────────
/**
* Fetch basic user profile information from the GOG embed API.
*/
export async function fetchGogUserInfo(
accessToken: string,
): Promise<GogUserInfo> {
console.log('[GOG] Fetching user info...');
const res = await fetch(`${GOG_EMBED_URL}/userData.json`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] User info request failed (${res.status}): ${text}`);
throw new Error(`[GOG] User info request failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
userId: string;
username: string;
galaxyUserId: string;
avatar: string;
};
const userInfo: GogUserInfo = {
userId: data.galaxyUserId || data.userId,
username: data.username,
avatarUrl: data.avatar ? `https:${data.avatar}` : '',
};
console.log(`[GOG] User info fetched: ${userInfo.username}`);
return userInfo;
}
// ── Game library ─────────────────────────────────────────────────────────────
/**
* Fetch the complete list of owned GOG games for the authenticated user.
*
* The GOG embed API paginates results (one page at a time). We iterate
* through all pages with a 500 ms pause between requests to stay friendly.
*/
export async function fetchGogGames(
accessToken: string,
): Promise<GogGame[]> {
console.log('[GOG] Fetching game library...');
const allGames: GogGame[] = [];
let currentPage = 1;
let totalPages = 1;
do {
const url = `${GOG_EMBED_URL}/account/getFilteredProducts?mediaType=1&page=${currentPage}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
const text = await res.text();
console.error(
`[GOG] Game library request failed on page ${currentPage} (${res.status}): ${text}`,
);
throw new Error(
`[GOG] Game library request failed (${res.status}): ${text}`,
);
}
const data = (await res.json()) as {
totalPages: number;
products: Array<{
id: number;
title: string;
image: string;
slug: string;
}>;
};
totalPages = data.totalPages;
for (const product of data.products) {
allGames.push({
gogId: product.id,
title: product.title,
image: product.image ? `https:${product.image}` : '',
slug: product.slug,
});
}
console.log(
`[GOG] Page ${currentPage}/${totalPages} fetched (${data.products.length} games)`,
);
currentPage++;
// Rate-limit: 500 ms between page requests
if (currentPage <= totalPages) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
} while (currentPage <= totalPages);
console.log(`[GOG] Library complete: ${allGames.length} games total`);
return allGames;
}
// ── Cover URL helper ─────────────────────────────────────────────────────────
/**
* Convert a GOG image base path into a full HTTPS cover URL.
*
* GOG images are returned as protocol-relative paths like
* `//images-1.gog.com/xxx`. This helper ensures HTTPS and optionally
* appends a size suffix (e.g. `_196.jpg`).
*
* @param imageBase The raw image path returned by the GOG API
* @param size Optional size suffix to append (e.g. `_196.jpg`)
*/
export function gogCoverUrl(imageBase: string, size?: string): string {
let url = imageBase;
// Ensure HTTPS prefix
if (url.startsWith('//')) {
url = `https:${url}`;
} else if (!url.startsWith('http')) {
url = `https://${url}`;
}
// Append size suffix if provided
if (size) {
url = `${url}${size}`;
}
return url;
}