279 lines
8.3 KiB
TypeScript
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);
|
|
}
|