183 lines
5.6 KiB
TypeScript
183 lines
5.6 KiB
TypeScript
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<BeatLeaderLeaderboard[]> {
|
|
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<string> {
|
|
const p = await fetchBeatLeaderPlayer(playerId);
|
|
return p?.id ?? playerId;
|
|
}
|
|
|
|
async function fetchLeaderboardScoresById(
|
|
leaderboardId: string,
|
|
maxPages = MAX_LEADERBOARD_SCORE_PAGES,
|
|
): Promise<BeatLeaderScore[]> {
|
|
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<BeatLeaderScore[]> {
|
|
const requests = leaderboards.map((lb) => {
|
|
const leaderboardId = lb.id == null ? null : String(lb.id);
|
|
if (!leaderboardId) return Promise.resolve<BeatLeaderScore[]>([]);
|
|
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<BeatLeaderFollower[]> {
|
|
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<BeatLeaderFollower[]> {
|
|
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<BeatLeaderFollower[]> {
|
|
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;
|
|
}
|