games: automatic CS2 / PUBG / R6 match history — games.matches, match_players, seasons, snapshots, accounts; in-app poller (Leetify, FACEIT, Steam share codes, PUBG API, Ubisoft) every 20 min plus scripts/games-ingest.ts for backfills; match history, stats, maps and seasons on the per-game pages
All checks were successful
Deploy production / deploy (push) Successful in 1m6s

This commit is contained in:
2026-10-25 16:00:48 +00:00
parent 9114b5433b
commit 80803ef5ac
24 changed files with 30747 additions and 5 deletions

View File

@@ -0,0 +1,97 @@
import { pollFaceit } from './faceit';
import { pollLeetify } from './leetify';
import { pollPubg } from './pubg';
import { pollSteamShareCodes } from './steam';
import { pollUbisoft } from './ubisoft';
import { hoursSince } from './store';
import type { Account, Db, GameEnv, RunOptions, SourceResult } from './types';
// One ingest pass over every enabled games.accounts row. Order matters: Leetify first so the
// Steam share-code chain can start from a code Leetify gave us.
export const SOURCE_ORDER = ['leetify', 'faceit', 'steam', 'pubg', 'ubisoft'] as const;
/** Minimum hours between polls per source (the poller ticks every 20 minutes). */
const DUE_HOURS: Record<string, number> = {
leetify: 0.3,
faceit: 6,
steam: 0.3,
pubg: 0.3,
ubisoft: 0.3
};
/** Which keys each source needs; shown (names only) on the owner's view of the pages. */
export function missingKeys(env: GameEnv): Record<string, string[]> {
const need = (...k: (keyof GameEnv)[]) => k.filter((x) => !env[x]);
return {
leetify: [],
faceit: [],
steam: need('STEAM_WEB_API_KEY', 'CS2_AUTH_CODE'),
pubg: need('PUBG_API_KEY'),
ubisoft: need('UBI_EMAIL', 'UBI_PASSWORD')
};
}
export async function runIngest(
sql: Db,
env: GameEnv,
opts: RunOptions = {}
): Promise<SourceResult[]> {
const accounts = await sql<Account[]>`
select id, game, source, external_id as "externalId", name, state, last_success_at as "lastSuccessAt"
from games.accounts where enabled order by id`;
const results: SourceResult[] = [];
for (const source of SOURCE_ORDER) {
if (opts.only?.length && !opts.only.includes(source)) continue;
for (const account of accounts.filter((a) => a.source === source)) {
const label = account.name ?? account.externalId;
if (!opts.backfill && !opts.force && hoursSince(account.lastSuccessAt) < DUE_HOURS[source])
continue;
if (missingKeys(env)[source]?.length) {
results.push({
game: account.game,
source,
account: label,
status: 'skipped',
added: 0,
note: `needs ${missingKeys(env)[source].join(', ')}`
});
continue;
}
await sql`update games.accounts set last_polled_at = now() where id = ${account.id}`;
try {
const r =
source === 'leetify'
? await pollLeetify(sql, account, env, opts)
: source === 'faceit'
? await pollFaceit(sql, account, opts)
: source === 'steam'
? await pollSteamShareCodes(sql, account, env)
: source === 'pubg'
? await pollPubg(sql, account, env, opts)
: await pollUbisoft(sql, account, env, opts);
const skipped = 'skipped' in r && r.skipped;
if (!skipped)
await sql`update games.accounts set last_success_at = now(), last_error = null where id = ${account.id}`;
results.push({
game: account.game,
source,
account: label,
status: skipped ? 'skipped' : 'ok',
added: 'added' in r ? (r.added ?? 0) : 0,
note: r.note
});
} catch (e) {
const message = (e as Error).message.slice(0, 300);
await sql`update games.accounts set last_error = ${message} where id = ${account.id}`;
results.push({
game: account.game,
source,
account: label,
status: 'error',
added: 0,
note: message
});
}
}
}
return results;
}