279 lines
8.3 KiB
TypeScript

/** 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<string, CacheEntry> = 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<Response> {
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<string, string> } = {},
ttlMs = CACHE_TTL_MS,
): Promise<unknown> {
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<string, string> | undefined {
if (this.accessToken) {
return { Authorization: `Bearer ${this.accessToken}` };
}
if (this.websiteCookieHeader) {
return { [WEBSITE_COOKIE_HEADER]: this.websiteCookieHeader };
}
return undefined;
}
getPlayer(playerId: string): Promise<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<string, unknown> | null {
return v != null && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: 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);
}