beatsaber-overlay/src/client/beatleader.ts

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;
}