import type { BeatLeaderFollower, BeatLeaderLeaderboard, BeatLeaderLeaderboardsByHashResponse, BeatLeaderPlayer, BeatLeaderScore, FriendMode, } from "./types.ts"; interface BeatLeaderLeaderboardScoresResponse { scores?: BeatLeaderScore[]; } const BASE_URL = "https://api.beatleader.com"; const PAGE_SIZE = 100; /** * `/leaderboard/{id}` uses `page` + `count` like v5 scores. Without `count`, the API default page * size is small, so `batch.length < PAGE_SIZE` stopped pagination after the first page. * `MAX_LEADERBOARD_SCORE_PAGES` bounds total requests for pathological maps. */ const MAX_LEADERBOARD_SCORE_PAGES = 2000; const USE_RUNTIME_PROXY = typeof document !== "undefined"; function beatleaderUrl(path: string): string { if (USE_RUNTIME_PROXY) { return `/api/beatleader?path=${encodeURIComponent(path)}`; } return `${BASE_URL}${path}`; } export async function fetchBLLeaderboardsByHash(hash: string): Promise { const path = `/leaderboards/hash/${encodeURIComponent(hash)}`; try { const res = await fetch(beatleaderUrl(path)); if (!res.ok) return []; const data = await res.json() as BeatLeaderLeaderboardsByHashResponse | BeatLeaderLeaderboard[]; return Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : []; } catch { return []; } } export async function fetchBeatLeaderPlayer(playerId: string): Promise<{ id: string; avatar: string | null } | null> { const path = `/player/${encodeURIComponent(playerId)}`; try { const res = await fetch(beatleaderUrl(path)); if (!res.ok) return null; const data = await res.json() as BeatLeaderPlayer; const id = data.id == null ? playerId : String(data.id); const avatar = typeof data.avatar === "string" ? data.avatar.trim() || null : null; return { id, avatar }; } catch { return null; } } async function resolveBeatLeaderPlayerId(playerId: string): Promise { const p = await fetchBeatLeaderPlayer(playerId); return p?.id ?? playerId; } async function fetchLeaderboardScoresById( leaderboardId: string, maxPages = MAX_LEADERBOARD_SCORE_PAGES, ): Promise { const scores: BeatLeaderScore[] = []; const pageSize = PAGE_SIZE; let page = 1; for (;;) { if (page > maxPages) break; const qs = new URLSearchParams({ leaderboardContext: "general", page: String(page), sortBy: "rank", order: "desc", count: String(pageSize), }); const path = `/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`; const url = beatleaderUrl(path); let res: Response; try { res = await fetch(url); } catch { break; } if (!res.ok) break; const payload = await res.json() as BeatLeaderLeaderboardScoresResponse; const batch = Array.isArray(payload.scores) ? payload.scores : []; if (batch.length === 0) break; scores.push(...batch); if (batch.length < pageSize) break; page += 1; } return scores; } export async function fetchAllMapScoresByHash( hash: string, leaderboards: BeatLeaderLeaderboard[], maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES, ): Promise { const requests = leaderboards.map((lb) => { const leaderboardId = lb.id == null ? null : String(lb.id); if (!leaderboardId) return Promise.resolve([]); return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard); }); const batches = await Promise.all(requests); return batches.flat(); } async function fetchFollowersPage( playerId: string, type: "Followers" | "Following", page: number, count: number, ): Promise { const qs = new URLSearchParams({ type, page: String(page), count: String(count), }); const path = `/player/${encodeURIComponent(playerId)}/followers?${qs}`; const url = beatleaderUrl(path); try { const response = await fetch(url); if (!response.ok) return []; const data = await response.json() as BeatLeaderFollower[]; return Array.isArray(data) ? data : []; } catch { return []; } } async function fetchAllFollowers( playerId: string, type: "Followers" | "Following", maxPages = 100, ): Promise { const all: BeatLeaderFollower[] = []; for (let page = 1; page <= maxPages; page += 1) { const batch = await fetchFollowersPage(playerId, type, page, PAGE_SIZE); if (batch.length === 0) break; all.push(...batch); if (batch.length < PAGE_SIZE) break; } return all; } function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower { return { ...entry, id: String(entry.id), }; } /** Friend list for the given mode, with `avatar` / `name` from BeatLeader follower payloads. */ export async function fetchFriends(playerId: string, mode: FriendMode, maxPages = 100): Promise { const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const [following, followers] = await Promise.all([ fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Followers", maxPages), ]); const followingIds = new Set(following.map((entry) => String(entry.id))); if (mode === "following") { return following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); } if (mode === "followers") { return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); } return followers .filter((entry) => followingIds.has(String(entry.id))) .map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); } export function normalizeAccuracy(value: number | null | undefined): number | null { if (typeof value !== "number" || !Number.isFinite(value)) return null; return value <= 1 ? value * 100 : value; }