/** Public BeatLeader API client patterns mirrored from plebsaber.stream `src/lib/server/beatleader.ts`. */ export const BEATLEADER_BASE_URL = "https://api.beatleader.com"; const CACHE_TTL_MS = 5 * 60 * 1000; const MAX_RETRIES = 5; const INITIAL_BACKOFF_MS = 1000; const MAX_BACKOFF_MS = 60_000; const BACKOFF_FACTOR = 2; type CacheEntry = { expiresAt: number; data: unknown }; const responseCache: Map = new Map(); const WEBSITE_COOKIE_HEADER = "Cookie"; export class RateLimitError extends Error { readonly status = 429; readonly retryAfterMs?: number; constructor(message: string, retryAfterMs?: number) { super(message); this.name = "RateLimitError"; this.retryAfterMs = retryAfterMs; } } async function requestWith429Retry( fetchFn: typeof fetch, url: string, init?: RequestInit, ): Promise { let attempt = 0; let backoffMs = INITIAL_BACKOFF_MS; while (true) { try { const res = await fetchFn(url, init); if (res.status === 429) { attempt += 1; const retryAfterHeader = res.headers.get("Retry-After"); const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN; const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs; if (attempt > MAX_RETRIES) { throw new RateLimitError("BeatLeader rate limit exceeded", waitMs); } await new Promise((r) => setTimeout(r, waitMs)); backoffMs = Math.min(backoffMs * BACKOFF_FACTOR, MAX_BACKOFF_MS); continue; } return res; } catch (err) { attempt += 1; if (attempt > MAX_RETRIES) throw err; await new Promise((r) => setTimeout(r, backoffMs)); backoffMs = Math.min(backoffMs * BACKOFF_FACTOR, MAX_BACKOFF_MS); } } } async function fetchJsonCached( fetchFn: typeof fetch, url: string, options: { headers?: Record } = {}, ttlMs = CACHE_TTL_MS, ): Promise { const now = Date.now(); const auth = Boolean( options.headers && (options.headers["Authorization"] ?? options.headers[WEBSITE_COOKIE_HEADER]), ); const cacheKey = auth ? null : url; if (cacheKey) { const cached = responseCache.get(cacheKey); if (cached && cached.expiresAt > now) { return cached.data; } } const res = await requestWith429Retry(fetchFn, url, { headers: options.headers, }); if (!res.ok) { throw new Error(`BeatLeader request failed ${res.status} ${url}`); } const data = await res.json(); if (cacheKey) { responseCache.set(cacheKey, { expiresAt: now + ttlMs, data }); } return data; } export type QueryParams = Record< string, string | number | boolean | undefined | null >; export function buildQuery(params: QueryParams): string { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null || value === "") continue; searchParams.set(key, String(value)); } const qs = searchParams.toString(); return qs ? `?${qs}` : ""; } /** Optional auth: Bearer preferred over website Cookie (matches plebsaber). */ export class BeatLeaderAPI { private readonly fetchFn: typeof fetch; private readonly accessToken?: string; private readonly websiteCookieHeader?: string; constructor( fetchFn: typeof fetch, accessToken?: string, websiteCookieHeader?: string, ) { this.fetchFn = fetchFn; this.accessToken = accessToken; this.websiteCookieHeader = websiteCookieHeader; } private buildHeaders(): Record | undefined { if (this.accessToken) { return { Authorization: `Bearer ${this.accessToken}` }; } if (this.websiteCookieHeader) { return { [WEBSITE_COOKIE_HEADER]: this.websiteCookieHeader }; } return undefined; } getPlayer(playerId: string): Promise { const url = `${BEATLEADER_BASE_URL}/player/${encodeURIComponent(playerId)}`; return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() }); } getPlayerScores(playerId: string, params: { page?: number; count?: number; leaderboardContext?: string; sortBy?: string | number; order?: "asc" | "desc" | string; search?: string; diff?: string; mode?: string; requirements?: string; type?: string; hmd?: string; modifiers?: string; stars_from?: string | number; stars_to?: string | number; eventId?: string | number; includeIO?: boolean; } = {}): Promise { const query = buildQuery(params); const url = `${BEATLEADER_BASE_URL}/player/${ encodeURIComponent(playerId) }/scores${query}`; return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() }); } getLeaderboard( hash: string, options: { diff?: string; mode?: string; page?: number; count?: number } = {}, ): Promise { const diff = options.diff ?? "ExpertPlus"; const mode = options.mode ?? "Standard"; const query = buildQuery({ page: options.page, count: options.count }); const url = `${BEATLEADER_BASE_URL}/v5/scores/${encodeURIComponent(hash)}/${ encodeURIComponent(diff) }/${encodeURIComponent(mode)}${query}`; return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() }); } getRankedLeaderboards( params: { stars_from?: number; stars_to?: number; page?: number; count?: number; } = {}, ): Promise { const query = buildQuery({ page: params.page, count: params.count, type: "ranked", stars_from: params.stars_from, stars_to: params.stars_to, }); const url = `${BEATLEADER_BASE_URL}/leaderboards${query}`; return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() }); } getUser(): Promise { const url = `${BEATLEADER_BASE_URL}/user`; return fetchJsonCached( this.fetchFn, url, { headers: this.buildHeaders() }, 30_000, ); } /** Leaderboards for all difficulties/modes tied to one map hash. */ getLeaderboardsByHash(hash: string): Promise { const url = `${BEATLEADER_BASE_URL}/leaderboards/hash/${ encodeURIComponent(hash) }`; return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() }); } } export function createBeatLeaderAPI( fetchFn: typeof fetch, accessToken?: string, websiteCookieHeader?: string, ): BeatLeaderAPI { return new BeatLeaderAPI(fetchFn, accessToken, websiteCookieHeader); } /** Normalized row for curator UI — derived from `/leaderboards/hash/{hash}` payload. */ export type BeatLeaderMapDifficultySummary = { difficultyName?: string; modeName?: string; stars?: number; accRating?: number; passRating?: number; techRating?: number; status?: number; }; function asRecord(v: unknown): Record | null { return v != null && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; } /** Extract leaderboards array from various BeatLeader response shapes. */ export function leaderboardsArrayFromHashPayload(data: unknown): unknown[] { const r = asRecord(data); if (!r) return []; const lb = r["leaderboards"]; if (Array.isArray(lb)) return lb; if (Array.isArray(data)) return data; return []; } export function summarizeLeaderboardEntry( entry: unknown, ): BeatLeaderMapDifficultySummary { const r = asRecord(entry); if (!r) return {}; const diff = asRecord(r["difficulty"]); const difficultyName = (diff?.["difficultyName"] ?? diff?.["name"]) as | string | undefined; const modeName = (diff?.["modeName"] ?? r["modeName"]) as string | undefined; return { difficultyName: typeof difficultyName === "string" ? difficultyName : undefined, modeName: typeof modeName === "string" ? modeName : "Standard", stars: (diff?.["stars"] ?? r["stars"]) as number | undefined, accRating: diff?.["accRating"] as number | undefined, passRating: diff?.["passRating"] as number | undefined, techRating: diff?.["techRating"] as number | undefined, status: diff?.["status"] as number | undefined, }; } export function summarizeAllFromHashResponse( data: unknown, ): BeatLeaderMapDifficultySummary[] { return leaderboardsArrayFromHashPayload(data).map(summarizeLeaderboardEntry); }