add beatleader score fetching and add icons to FC
Some checks failed
deploy / deploy (push) Failing after 2s

This commit is contained in:
Lee 2023-10-22 13:47:56 +01:00
parent 2e93a1b27f
commit 80e6c0da43
33 changed files with 979 additions and 124 deletions

View File

@ -1,3 +1,4 @@
import { AppProvider } from "@/components/AppProvider";
import { ssrSettings } from "@/ssrSettings";
import { Metadata } from "next";
import { Inter } from "next/font/google";
@ -40,7 +41,7 @@ export default function RootLayout({
/>
</div>
{children}
<AppProvider>{children}</AppProvider>
</body>
</html>
);

View File

@ -9,7 +9,7 @@ import Scores from "@/components/player/Scores";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { useSettingsStore } from "@/store/settingsStore";
import { SortType, SortTypes } from "@/types/SortTypes";
import { getPlayerInfo } from "@/utils/scoresaber/api";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
@ -59,7 +59,7 @@ export default function Player({ params }: { params: { id: string } }) {
return;
}
getPlayerInfo(params.id).then((playerResponse) => {
ScoreSaberAPI.getPlayerInfo(params.id).then((playerResponse) => {
if (!playerResponse) {
setError(true);
setErrorMessage("Failed to fetch player. Is the ID correct?");

View File

@ -8,7 +8,7 @@ import { Spinner } from "@/components/Spinner";
import PlayerRanking from "@/components/player/PlayerRanking";
import PlayerRankingMobile from "@/components/player/PlayerRankingMobile";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { fetchTopPlayers } from "@/utils/scoresaber/api";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { normalizedRegionName } from "@/utils/utils";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
@ -53,7 +53,7 @@ export default function RankingCountry({ params }: RankingCountryProps) {
const updatePage = useCallback(
(page: any) => {
console.log("Switching page to", page);
fetchTopPlayers(page, country).then((response) => {
ScoreSaberAPI.fetchTopPlayers(page, country).then((response) => {
if (!response) {
setError(true);
setErrorMessage("No players found");

View File

@ -8,7 +8,7 @@ import { Spinner } from "@/components/Spinner";
import PlayerRanking from "@/components/player/PlayerRanking";
import PlayerRankingMobile from "@/components/player/PlayerRankingMobile";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { fetchTopPlayers } from "@/utils/scoresaber/api";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
@ -46,7 +46,7 @@ export default function RankingGlobal() {
const updatePage = useCallback(
(page: any) => {
console.log("Switching page to", page);
fetchTopPlayers(page).then((response) => {
ScoreSaberAPI.fetchTopPlayers(page).then((response) => {
if (!response) {
setError(true);
setErrorMessage("No players found");

View File

@ -0,0 +1,24 @@
"use client";
import { useBeatLeaderScoresStore } from "@/store/beatLeaderScoresStore";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
type AppProviderProps = {
children: React.ReactNode;
};
export function AppProvider({ children }: AppProviderProps) {
return <>{children}</>;
}
const UPDATE_INTERVAL = 1000 * 60 * 15; // 15 minutes
useBeatLeaderScoresStore.getState().updatePlayerScores();
setInterval(() => {
useBeatLeaderScoresStore.getState().updatePlayerScores();
}, UPDATE_INTERVAL);
useScoresaberScoresStore.getState().updatePlayerScores();
setInterval(() => {
useScoresaberScoresStore.getState().updatePlayerScores();
}, UPDATE_INTERVAL);

View File

@ -1,5 +1,6 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { usePlayerScoresStore } from "@/store/playerScoresStore";
import { useBeatLeaderScoresStore } from "@/store/beatLeaderScoresStore";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { useSettingsStore } from "@/store/settingsStore";
import { formatNumber } from "@/utils/number";
import {
@ -28,7 +29,7 @@ type PlayerInfoProps = {
export default function PlayerInfo({ playerData }: PlayerInfoProps) {
const playerId = playerData.id;
const settingsStore = useStore(useSettingsStore, (store) => store);
const playerScoreStore = useStore(usePlayerScoresStore, (store) => store);
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
// Whether we have scores for this player in the local database
const hasLocalScores = playerScoreStore?.exists(playerId);
@ -50,39 +51,80 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
}
async function addProfile(isFriend: boolean) {
if (!usePlayerScoresStore.getState().exists(playerId)) {
const reponse = await playerScoreStore?.addPlayer(
playerId,
(page, totalPages) => {
const autoClose = page == totalPages ? 5000 : false;
const setupScoresaber = async () => {
if (!useScoresaberScoresStore.getState().exists(playerId)) {
const reponse = await playerScoreStore?.addPlayer(
playerId,
(page, totalPages) => {
const autoClose = page == totalPages ? 5000 : false;
if (page == 1) {
toastId.current = toast.info(
`Fetching scores ${page}/${totalPages}`,
{
autoClose: autoClose,
if (page == 1) {
toastId.current = toast.info(
`Fetching ScoreSaber scores ${page}/${totalPages}`,
{
autoClose: autoClose,
progress: page / totalPages,
},
);
} else {
toast.update(toastId.current, {
progress: page / totalPages,
},
);
} else {
toast.update(toastId.current, {
progress: page / totalPages,
render: `Fetching scores ${page}/${totalPages}`,
autoClose: autoClose,
});
}
render: `Fetching ScoreSaber scores ${page}/${totalPages}`,
autoClose: autoClose,
});
}
console.log(
`Fetching scores for ${playerId} (${page}/${totalPages})`,
);
},
);
if (reponse?.error) {
toast.error("Failed to fetch scores");
console.log(reponse.message);
return;
console.log(
`Fetching ScoreSaber scores for ${playerId} (${page}/${totalPages})`,
);
},
);
if (reponse?.error) {
toast.error("Failed to fetch scores");
console.log(reponse.message);
return;
}
}
}
};
const setupBeatleader = async () => {
if (!useBeatLeaderScoresStore.getState().exists(playerId)) {
const reponse = await playerScoreStore?.addPlayer(
playerId,
(page, totalPages) => {
const autoClose = page == totalPages ? 5000 : false;
if (page == 1) {
toastId.current = toast.info(
`Fetching BeatLeader scores ${page}/${totalPages}`,
{
autoClose: autoClose,
progress: page / totalPages,
},
);
} else {
toast.update(toastId.current, {
progress: page / totalPages,
render: `Fetching BeatLeader scores ${page}/${totalPages}`,
autoClose: autoClose,
});
}
console.log(
`Fetching BeatLeader scores for ${playerId} (${page}/${totalPages})`,
);
},
);
if (reponse?.error) {
toast.error("Failed to fetch scores");
console.log(reponse.message);
return;
}
}
};
await setupScoresaber();
await setupBeatleader();
if (!isFriend) {
toast.success(`Successfully set ${playerData.name} as your profile`);

View File

@ -1,7 +1,13 @@
import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberScore } from "@/schemas/scoresaber/score";
import { useBeatLeaderScoresStore } from "@/store/beatLeaderScoresStore";
import { formatNumber } from "@/utils/number";
import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid";
import {
CheckIcon,
GlobeAsiaAustraliaIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import clsx from "clsx";
import moment from "moment";
import Image from "next/image";
@ -9,11 +15,17 @@ import ScoreStatLabel from "./ScoreStatLabel";
type ScoreProps = {
score: ScoresaberScore;
player: ScoresaberPlayer;
leaderboard: ScoresaberLeaderboardInfo;
};
export default function Score({ score, leaderboard }: ScoreProps) {
export default function Score({ score, player, leaderboard }: ScoreProps) {
const isFullCombo = score.missedNotes + score.badCuts === 0;
const beatleaderScoreData = useBeatLeaderScoresStore
.getState()
.getScore(player.id, leaderboard.songHash);
console.log(beatleaderScoreData);
return (
<div className="grid grid-cols-1 pb-2 pt-2 first:pt-0 last:pb-0 md:grid-cols-[1.1fr_6fr_3fr] xl:md:grid-cols-[.95fr_6fr_3fr]">
@ -90,7 +102,13 @@ export default function Score({ score, leaderboard }: ScoreProps) {
"min-w-[2rem]",
isFullCombo ? "bg-green-500" : "bg-red-500",
)}
title={`${score.missedNotes} missed notes. ${score.badCuts} bad cuts.`}
icon={
isFullCombo ? (
<CheckIcon width={20} height={20} />
) : (
<XMarkIcon width={20} height={20} />
)
}
value={
isFullCombo
? "FC"

View File

@ -2,7 +2,7 @@ import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { useSettingsStore } from "@/store/settingsStore";
import { SortType, SortTypes } from "@/types/SortTypes";
import { fetchScores } from "@/utils/scoresaber/api";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import Card from "../Card";
@ -43,38 +43,40 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
const updateScoresPage = useCallback(
(sortType: SortType, page: any) => {
console.log(`Switching page to ${page} with sort ${sortType.value}`);
fetchScores(playerId, page, sortType.value, 10).then((scoresResponse) => {
if (!scoresResponse) {
setError(true);
setErrorMessage("No Scores");
setScores({ ...scores, loading: false });
return;
}
setScores({
...scores,
scores: scoresResponse.scores,
totalPages: scoresResponse.pageInfo.totalPages,
loading: false,
page: page,
sortType: sortType,
});
useSettingsStore.setState({
lastUsedSortType: sortType,
});
if (page > 1) {
router.push(
`/player/${playerId}?page=${page}&sort=${sortType.value}`,
{
scroll: false,
},
);
} else {
router.push(`/player/${playerId}?sort=${sortType.value}`, {
scroll: false,
ScoreSaberAPI.fetchScores(playerId, page, sortType.value, 10).then(
(scoresResponse) => {
if (!scoresResponse) {
setError(true);
setErrorMessage("No Scores");
setScores({ ...scores, loading: false });
return;
}
setScores({
...scores,
scores: scoresResponse.scores,
totalPages: scoresResponse.pageInfo.totalPages,
loading: false,
page: page,
sortType: sortType,
});
}
});
useSettingsStore.setState({
lastUsedSortType: sortType,
});
if (page > 1) {
router.push(
`/player/${playerId}?page=${page}&sort=${sortType.value}`,
{
scroll: false,
},
);
} else {
router.push(`/player/${playerId}?sort=${sortType.value}`, {
scroll: false,
});
}
},
);
},
[playerId, router, scores],
);
@ -125,7 +127,12 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
const { score, leaderboard } = scoreData;
return (
<Score key={id} score={score} leaderboard={leaderboard} />
<Score
key={id}
player={playerData}
score={score}
leaderboard={leaderboard}
/>
);
})
)}

View File

@ -2,7 +2,7 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { formatNumber } from "@/utils/number";
import { getPlayerInfo, searchByName } from "@/utils/scoresaber/api";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { useEffect, useState } from "react";
@ -27,14 +27,14 @@ export default function SearchPlayer() {
const id = search.split("/").pop();
if (id == undefined) return;
const player = await getPlayerInfo(id);
const player = await ScoreSaberAPI.getPlayerInfo(id);
if (player == undefined) return;
setPlayers([player]);
}
// Search by name
const players = await searchByName(search);
const players = await ScoreSaberAPI.searchByName(search);
if (players == undefined) return;
setPlayers(players);

View File

@ -0,0 +1,30 @@
import { BeatleaderModifierRating } from "./modifierRating";
import { BeatleaderModifier } from "./modifiers";
export type BeatleaderDifficulty = {
id: number;
value: number;
mode: number;
difficultyName: string;
modeName: string;
status: number;
modifierValues: BeatleaderModifier;
modifiersRating: BeatleaderModifierRating;
nominatedTime: number;
qualifiedTime: number;
rankedTime: number;
stars: number;
predictedAcc: number;
passRating: number;
accRating: number;
techRating: number;
type: number;
njs: number;
nps: number;
notes: number;
bombs: number;
walls: number;
maxScore: number;
duration: number;
requirements: number;
};

View File

@ -0,0 +1,16 @@
import { BeatleaderDifficulty } from "./difficulty";
import { BeatleaderSong } from "./song";
export type BeatleaderLeaderboard = {
id: string;
song: BeatleaderSong;
difficulty: BeatleaderDifficulty;
scores: null; // ??
changes: null; // ??
qualification: null; // ??
reweight: null; // ??
leaderboardGroup: null; // ??
plays: number;
clan: null; // ??
clanRankingContested: boolean;
};

View File

@ -0,0 +1,5 @@
export type BeatleaderMetadata = {
itemsPerPage: number;
page: number;
total: number;
};

View File

@ -0,0 +1,18 @@
export type BeatleaderModifierRating = {
id: number;
fsPredictedAcc: number;
fsPassRating: number;
fsAccRating: number;
fsTechRating: number;
fsStars: number;
ssPredictedAcc: number;
ssPassRating: number;
ssAccRating: number;
ssTechRating: number;
ssStars: number;
sfPredictedAcc: number;
sfPassRating: number;
sfAccRating: number;
sfTechRating: number;
sfStars: number;
};

View File

@ -0,0 +1,16 @@
export type BeatleaderModifier = {
modifierId: number;
da: number;
fs: number;
sf: number;
ss: number;
gn: number;
na: number;
nb: number;
nf: number;
no: number;
pm: number;
sc: number;
sa: number;
op: number;
};

View File

@ -0,0 +1,51 @@
import { BeatleaderLeaderboard } from "./leaderboard";
import { BeatleaderScoreImprovement } from "./scoreImprovement";
import { BeatleaderScoreOffsets } from "./scoreOffsets";
export type BeatleaderScore = {
myScore: null; // ??
validContexts: number;
leaderboard: BeatleaderLeaderboard;
contextExtensions: null; // ??
accLeft: number;
accRight: number;
id: number;
baseScore: number;
modifiedScore: number;
accuracy: number;
playerId: string;
pp: number;
bonusPp: number;
passPP: number;
accPP: number;
techPP: number;
rank: number;
country: string;
fcAccuracy: number;
fcPp: number;
weight: number;
replay: string;
modifiers: string;
badCuts: number;
missedNotes: number;
bombCuts: number;
wallsHit: number;
pauses: number;
fullCombo: boolean;
platform: string;
maxCombo: number;
maxStreak: number;
hmd: number;
controller: number;
leaderboardId: string;
timeset: string;
timepost: number;
replaysWatched: number;
playCount: number;
priority: number;
player: null; // ??
scoreImprovement: BeatleaderScoreImprovement;
rankVoting: null; // ??
metadata: null; // ??
offsets: BeatleaderScoreOffsets;
};

View File

@ -0,0 +1,19 @@
export type BeatleaderScoreImprovement = {
id: number;
timeset: number;
score: number;
accuracy: number;
pp: number;
bonusPp: number;
rank: number;
accRight: number;
accLeft: number;
averageRankedAccuracy: number;
totalPp: number;
totalRank: number;
badCuts: number;
missedNotes: number;
bombCuts: number;
wallsHit: number;
pauses: number;
};

View File

@ -0,0 +1,8 @@
export type BeatleaderScoreOffsets = {
id: number;
frames: number;
notes: number;
walls: number;
heights: number;
pauses: number;
};

View File

View File

@ -0,0 +1,5 @@
import { BeatleaderSmallerSong } from "./smallerSong";
export type BeatleaderSmallerLeaderboard = {
song: BeatleaderSmallerSong;
};

View File

@ -0,0 +1,14 @@
import { BeatleaderSmallerLeaderboard } from "./smallerLeaderboard";
import { BeatleaderSmallerScoreImprovement } from "./smallerScoreImprovement";
export type BeatleaderSmallerScore = {
id: number;
timepost: number;
accLeft: number;
accRight: number;
fcAccuracy: number;
wallsHit: number;
replay: string;
leaderboard: BeatleaderSmallerLeaderboard;
scoreImprovement: BeatleaderSmallerScoreImprovement | null;
};

View File

@ -0,0 +1,9 @@
export type BeatleaderSmallerScoreImprovement = {
score: number;
accuracy: number;
accRight: number;
accLeft: number;
badCuts: number;
missedNotes: number;
bombCuts: number;
};

View File

@ -0,0 +1,4 @@
export type BeatleaderSmallerSong = {
hash: string;
bpm: number;
};

View File

@ -0,0 +1,16 @@
export type BeatleaderSong = {
id: string;
hash: string;
name: string;
subName: string;
author: string;
mapperId: string;
coverImage: string;
fullCoverImage: string;
downloadUrl: string;
bpm: number;
duration: number;
tags: string;
uploadTime: number;
difficulties: null; // ??
};

View File

@ -0,0 +1,12 @@
import { ScoresaberDifficulty } from "../difficulty";
export type ScoresaberSmallerLeaderboardInfo = {
id: string;
songHash: string;
difficulty: ScoresaberDifficulty;
maxScore: number;
createdDate: string;
stars: number;
plays: number;
coverImage: string;
};

View File

@ -0,0 +1,7 @@
import { ScoresaberSmallerLeaderboardInfo } from "./smallerLeaderboard";
import { ScoresaberSmallerScore } from "./smallerScore";
export type ScoresaberSmallerPlayerScore = {
score: ScoresaberSmallerScore;
leaderboard: ScoresaberSmallerLeaderboardInfo;
};

View File

@ -0,0 +1,16 @@
export type ScoresaberSmallerScore = {
id: number;
rank: number;
baseScore: number;
modifiedScore: number;
pp: number;
weight: number;
modifiers: string;
multiplier: number;
badCuts: number;
missedNotes: number;
maxCombo: number;
fullCombo: boolean;
hmd: number;
timeSet: string;
};

View File

@ -0,0 +1,347 @@
"use client";
import { BeatleaderSmallerScore } from "@/schemas/beatleader/smaller/smallerScore";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { BeatLeaderAPI } from "@/utils/beatleader/api";
import moment from "moment";
import { toast } from "react-toastify";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { useSettingsStore } from "./settingsStore";
type Player = {
id: string;
scores: BeatleaderSmallerScore[];
};
interface BeatLeaderScoresStore {
lastUpdated: number;
players: Player[];
/**
* Sets when the player scores were last updated
*
* @param lastUpdated when the player scores were last updated
*/
setLastUpdated: (lastUpdated: number) => void;
/**
* Checks if the player exists
*
* @param playerId the player id
* @returns if the player exists
*/
exists: (playerId: string) => boolean;
/**
* Gets the given player
*
* @param playerId the player id
* @returns the player
*/
get(playerId: string): Player | undefined;
/**
* Gets the score for the given player and song hash
*
* @param playerId the player id
* @param songHash the song hash
*/
getScore(
playerId: string,
songHash: string,
): BeatleaderSmallerScore | undefined;
/**
* Adds the player to the local database
*
* @param playerId the player id
* @param callback a callback to call when a score page is fetched
* @returns if the player was added successfully
*/
addPlayer: (
playerId: string,
callback?: (page: number, totalPages: number) => void,
) => Promise<{
error: boolean;
message: string;
}>;
/**
* Refreshes the player scores and adds any new scores to the local database
*/
updatePlayerScores: () => void;
}
const UPDATE_INTERVAL = 1000 * 60 * 30; // 30 minutes
export const useBeatLeaderScoresStore = create<BeatLeaderScoresStore>()(
persist(
(set) => ({
lastUpdated: 0,
players: [],
setLastUpdated: (lastUpdated: number) => {
set({ lastUpdated });
},
exists: (playerId: string) => {
const players: Player[] = useBeatLeaderScoresStore.getState().players;
return players.some((player) => player.id == playerId);
},
get: (playerId: string) => {
const players: Player[] = useBeatLeaderScoresStore.getState().players;
return players.find((player) => player.id == playerId);
},
getScore: (playerId: string, songHash: string) => {
const player = useBeatLeaderScoresStore.getState().get(playerId);
if (player == undefined) return undefined;
return player.scores.find(
(score) => score.leaderboard.song.hash == songHash,
);
},
addPlayer: async (
playerId: string,
callback?: (
page: number,
totalPages: number,
leaderboardName: string,
) => void,
) => {
const players = useBeatLeaderScoresStore.getState().players;
// Check if the player already exists
if (useBeatLeaderScoresStore.getState().exists(playerId)) {
return {
error: true,
message: "Player already exists",
};
}
// Get all of the players scores
const scores = await BeatLeaderAPI.fetchAllScores(
playerId,
(page, totalPages) => {
if (callback) callback(page, totalPages, "BeatLeader");
},
);
if (scores == undefined) {
return {
error: true,
message: "Could not fetch beatleader scores for player",
};
}
let smallerScores = new Array<BeatleaderSmallerScore>();
for (const score of scores) {
// We have to do this to limit the amount of data we store
// so we don't exceed the local storage limit
smallerScores.push({
id: score.id,
accLeft: score.accLeft,
accRight: score.accRight,
fcAccuracy: score.fcAccuracy,
wallsHit: score.wallsHit,
replay: score.replay,
leaderboard: {
song: {
bpm: score.leaderboard.song.bpm,
hash: score.leaderboard.song.hash,
},
},
scoreImprovement:
score.scoreImprovement != null
? {
score: score.scoreImprovement.score,
accuracy: score.scoreImprovement.accuracy,
accRight: score.scoreImprovement.accRight,
accLeft: score.scoreImprovement.accLeft,
badCuts: score.scoreImprovement.badCuts,
missedNotes: score.scoreImprovement.missedNotes,
bombCuts: score.scoreImprovement.bombCuts,
}
: null,
timepost: score.timepost,
});
}
// Remove scores that are already in the database
const player = useBeatLeaderScoresStore.getState().get(playerId);
if (player) {
smallerScores = smallerScores.filter(
(score) => player.scores.findIndex((s) => s.id == score.id) == -1,
);
}
set({
lastUpdated: Date.now(),
players: [
...players,
{
id: playerId,
scores: smallerScores,
},
],
});
return {
error: false,
message: "Player added successfully",
};
},
updatePlayerScores: async () => {
const players = useBeatLeaderScoresStore.getState().players;
const friends = useSettingsStore.getState().friends;
let allPlayers = new Array<ScoresaberPlayer>();
for (const friend of friends) {
allPlayers.push(friend);
}
const localPlayer = useSettingsStore.getState().player;
if (localPlayer) {
allPlayers.push(localPlayer);
}
// add local player and friends if they don't exist
for (const player of allPlayers) {
if (useBeatLeaderScoresStore.getState().get(player.id) == undefined) {
toast.info(
`${
player.id == localPlayer?.id
? `You were`
: `Friend ${player.name} was`
} missing from the BeatLeader scores database, adding...`,
);
await useBeatLeaderScoresStore.getState().addPlayer(player.id);
toast.success(
`${
player.id == useSettingsStore.getState().player?.id
? `You were`
: `Friend ${player.name} was`
} added to the BeatLeader scores database`,
);
}
}
// Skip if we refreshed the scores recently
const timeUntilRefreshMs =
UPDATE_INTERVAL -
(Date.now() - useBeatLeaderScoresStore.getState().lastUpdated);
if (timeUntilRefreshMs > 0) {
console.log(
"Waiting",
moment.duration(timeUntilRefreshMs).humanize(),
"to refresh scores for players",
);
setTimeout(
() => useBeatLeaderScoresStore.getState().updatePlayerScores(),
timeUntilRefreshMs,
);
return;
}
// loop through all of the players and update their scores
for (const player of players) {
if (player == undefined) continue;
console.log(`Updating scores for ${player.id}...`);
let newPlayers = players;
let oldScores = player.scores;
// Sort the scores by date (newset to oldest), so we know when to stop searching for new scores
oldScores = oldScores.sort((a, b) => {
return a.timepost - b.timepost;
});
if (!oldScores.length) return;
const mostRecentScore = oldScores[0];
let search = true;
let page = 0;
let newScoresCount = 0;
while (search) {
page++;
const newScores = await BeatLeaderAPI.fetchScores(player.id, page);
if (newScores == undefined) continue;
for (const score of newScores.scores) {
if (mostRecentScore && score.id == mostRecentScore.id) {
search = false;
break;
}
// remove the old score
const oldScoreIndex = oldScores.findIndex(
(score) => score.id == score.id,
);
if (oldScoreIndex != -1) {
oldScores = oldScores.splice(oldScoreIndex, 1);
}
oldScores.push({
id: score.id,
accLeft: score.accLeft,
accRight: score.accRight,
fcAccuracy: score.fcAccuracy,
wallsHit: score.wallsHit,
replay: score.replay,
leaderboard: {
song: {
bpm: score.leaderboard.song.bpm,
hash: score.leaderboard.song.hash,
},
},
scoreImprovement:
score.scoreImprovement != null
? {
score: score.scoreImprovement.score,
accuracy: score.scoreImprovement.accuracy,
accRight: score.scoreImprovement.accRight,
accLeft: score.scoreImprovement.accLeft,
badCuts: score.scoreImprovement.badCuts,
missedNotes: score.scoreImprovement.missedNotes,
bombCuts: score.scoreImprovement.bombCuts,
}
: null,
timepost: score.timepost,
});
newScoresCount++;
}
}
// Remove the player if it already exists
newPlayers = newPlayers.filter((playerr) => playerr.id != player.id);
// Add the player
newPlayers.push({
id: player.id,
scores: oldScores,
});
if (newScoresCount > 0) {
console.log(
`Found ${newScoresCount} new beatleader scores for ${player.id}`,
);
}
set({
players: newPlayers,
lastUpdated: Date.now(),
});
console.log(friends);
}
},
}),
{
name: "beatleaderScores",
storage: createJSONStorage(() => localStorage),
version: 2,
migrate: (state: any, version: number) => {
state.scores = [];
return state;
},
},
),
);

View File

@ -1,8 +1,8 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { fetchAllScores, fetchScores } from "@/utils/scoresaber/api";
import { ScoresaberSmallerPlayerScore } from "@/schemas/scoresaber/smaller/smallerPlayerScore";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import moment from "moment";
import { toast } from "react-toastify";
import { create } from "zustand";
@ -12,11 +12,11 @@ import { useSettingsStore } from "./settingsStore";
type Player = {
id: string;
scores: {
scoresaber: ScoresaberPlayerScore[];
scoresaber: ScoresaberSmallerPlayerScore[];
};
};
interface PlayerScoresStore {
interface ScoreSaberScoresStore {
lastUpdated: number;
players: Player[];
@ -66,7 +66,7 @@ interface PlayerScoresStore {
const UPDATE_INTERVAL = 1000 * 60 * 30; // 30 minutes
export const usePlayerScoresStore = create<PlayerScoresStore>()(
export const useScoresaberScoresStore = create<ScoreSaberScoresStore>()(
persist(
(set) => ({
lastUpdated: 0,
@ -77,12 +77,12 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
},
exists: (playerId: string) => {
const players: Player[] = usePlayerScoresStore.getState().players;
const players: Player[] = useScoresaberScoresStore.getState().players;
return players.some((player) => player.id == playerId);
},
get: (playerId: string) => {
const players: Player[] = usePlayerScoresStore.getState().players;
const players: Player[] = useScoresaberScoresStore.getState().players;
return players.find((player) => player.id == playerId);
},
@ -90,10 +90,10 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
playerId: string,
callback?: (page: number, totalPages: number) => void,
) => {
const players = usePlayerScoresStore.getState().players;
const players = useScoresaberScoresStore.getState().players;
// Check if the player already exists
if (usePlayerScoresStore.getState().exists(playerId)) {
if (useScoresaberScoresStore.getState().exists(playerId)) {
return {
error: true,
message: "Player already exists",
@ -101,23 +101,53 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
}
// Get all of the players scores
let scores = await fetchAllScores(
let scores = await ScoreSaberAPI.fetchAllScores(
playerId,
"recent",
(page, totalPages) => {
if (callback) callback(page, totalPages);
},
);
if (scores == undefined) {
return {
error: true,
message: "Could not fetch scores for player",
};
}
let smallerScores = new Array<ScoresaberSmallerPlayerScore>();
for (const score of scores) {
smallerScores.push({
score: {
id: score.score.id,
rank: score.score.rank,
baseScore: score.score.baseScore,
modifiedScore: score.score.modifiedScore,
pp: score.score.pp,
weight: score.score.weight,
modifiers: score.score.modifiers,
multiplier: score.score.multiplier,
badCuts: score.score.badCuts,
missedNotes: score.score.missedNotes,
maxCombo: score.score.maxCombo,
fullCombo: score.score.fullCombo,
hmd: score.score.hmd,
timeSet: score.score.timeSet,
},
leaderboard: {
id: score.leaderboard.id,
songHash: score.leaderboard.songHash,
difficulty: score.leaderboard.difficulty,
maxScore: score.leaderboard.maxScore,
createdDate: score.leaderboard.createdDate,
stars: score.leaderboard.stars,
plays: score.leaderboard.plays,
coverImage: score.leaderboard.coverImage,
},
});
}
// Remove scores that are already in the database
const player = usePlayerScoresStore.getState().get(playerId);
const player = useScoresaberScoresStore.getState().get(playerId);
if (player) {
scores = scores.filter(
(score) =>
@ -145,7 +175,7 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
},
updatePlayerScores: async () => {
const players = usePlayerScoresStore.getState().players;
const players = useScoresaberScoresStore.getState().players;
const friends = useSettingsStore.getState().friends;
let allPlayers = new Array<ScoresaberPlayer>();
@ -159,21 +189,21 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
// add local player and friends if they don't exist
for (const player of allPlayers) {
if (usePlayerScoresStore.getState().get(player.id) == undefined) {
if (useScoresaberScoresStore.getState().get(player.id) == undefined) {
toast.info(
`${
player.id == localPlayer?.id
? `You were`
: `Friend ${player.name} was`
} missing from the scores database, adding...`,
} missing from the ScoreSaber scores database, adding...`,
);
await usePlayerScoresStore.getState().addPlayer(player.id);
await useScoresaberScoresStore.getState().addPlayer(player.id);
toast.success(
`${
player.id == useSettingsStore.getState().player?.id
? `You were`
: `Friend ${player.name} was`
} added to the scores database`,
} added to the ScoreSaber scores database`,
);
}
}
@ -181,7 +211,7 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
// Skip if we refreshed the scores recently
const timeUntilRefreshMs =
UPDATE_INTERVAL -
(Date.now() - usePlayerScoresStore.getState().lastUpdated);
(Date.now() - useScoresaberScoresStore.getState().lastUpdated);
if (timeUntilRefreshMs > 0) {
console.log(
"Waiting",
@ -189,12 +219,13 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
"to refresh scores for players",
);
setTimeout(
() => usePlayerScoresStore.getState().updatePlayerScores(),
() => useScoresaberScoresStore.getState().updatePlayerScores(),
timeUntilRefreshMs,
);
return;
}
// loop through all of the players and update their scores
for (const player of players) {
if (player == undefined) continue;
console.log(`Updating scores for ${player.id}...`);
@ -216,23 +247,50 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
let newScoresCount = 0;
while (search) {
page++;
const newScores = await fetchScores(player.id, page);
const newScores = await ScoreSaberAPI.fetchScores(player.id, page);
if (newScores == undefined) continue;
for (const newScore of newScores.scores) {
if (mostRecentScore && newScore.score.id == mostRecentScore.id) {
for (const score of newScores.scores) {
if (mostRecentScore && score.score.id == mostRecentScore.id) {
search = false;
break;
}
// remove the old score
const oldScoreIndex = oldScores.findIndex(
(score) => score.score.id == newScore.score.id,
(score) => score.score.id == score.score.id,
);
if (oldScoreIndex != -1) {
oldScores = oldScores.splice(oldScoreIndex, 1);
}
oldScores.push(newScore);
oldScores.push({
score: {
id: score.score.id,
rank: score.score.rank,
baseScore: score.score.baseScore,
modifiedScore: score.score.modifiedScore,
pp: score.score.pp,
weight: score.score.weight,
modifiers: score.score.modifiers,
multiplier: score.score.multiplier,
badCuts: score.score.badCuts,
missedNotes: score.score.missedNotes,
maxCombo: score.score.maxCombo,
fullCombo: score.score.fullCombo,
hmd: score.score.hmd,
timeSet: score.score.timeSet,
},
leaderboard: {
id: score.leaderboard.id,
songHash: score.leaderboard.songHash,
difficulty: score.leaderboard.difficulty,
maxScore: score.leaderboard.maxScore,
createdDate: score.leaderboard.createdDate,
stars: score.leaderboard.stars,
plays: score.leaderboard.plays,
coverImage: score.leaderboard.coverImage,
},
});
newScoresCount++;
}
}
@ -261,7 +319,7 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
},
}),
{
name: "playerScores",
name: "scoresaberScores",
storage: createJSONStorage(() => localStorage),
version: 1,
@ -282,9 +340,3 @@ export const usePlayerScoresStore = create<PlayerScoresStore>()(
},
),
);
// Update the player scores every 30 minutes
usePlayerScoresStore.getState().updatePlayerScores();
setInterval(() => {
usePlayerScoresStore.getState().updatePlayerScores();
}, UPDATE_INTERVAL);

View File

@ -2,7 +2,7 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { SortType, SortTypes } from "@/types/SortTypes";
import { getPlayerInfo } from "@/utils/scoresaber/api";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import moment from "moment";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
@ -48,7 +48,7 @@ export const useSettingsStore = create<SettingsStore>()(
return false;
}
const friend = await getPlayerInfo(friendId);
const friend = await ScoreSaberAPI.getPlayerInfo(friendId);
if (friend == undefined || friend == null) return false;
set({ friends: [...friends, friend] });

102
src/utils/beatleader/api.ts Normal file
View File

@ -0,0 +1,102 @@
import { BeatleaderScore } from "@/schemas/beatleader/score";
import { ssrSettings } from "@/ssrSettings";
import { FetchQueue } from "../fetchWithQueue";
import { formatString } from "../string";
// Create a fetch instance with a cache
const fetchQueue = new FetchQueue();
// Api endpoints
const API_URL = ssrSettings.proxy + "/https://api.beatleader.xyz";
const PLAYER_SCORES_URL =
API_URL + "/player/{}/scores?sortBy=date&order=0&page={}&count=100";
/**
* Get the players scores from the given page
*
* @param playerId the id of the player
* @param page the page to get the scores from
* @param searchType the type of search to perform
* @param limit the limit of scores to get
* @returns a list of scores
*/
async function fetchScores(
playerId: string,
page: number = 1,
limit: number = 100,
): Promise<
| {
scores: BeatleaderScore[];
pageInfo: {
totalScores: number;
page: number;
totalPages: number;
};
}
| undefined
> {
if (limit > 100) {
throw new Error("Limit cannot be greater than 100");
}
const response = await fetchQueue.fetch(
formatString(PLAYER_SCORES_URL, true, playerId, page),
);
const json = await response.json();
// Check if there was an error fetching the user data
console.log(json);
const metadata = json.metadata;
return {
scores: json.data as BeatleaderScore[],
pageInfo: {
totalScores: json.totalScores,
page: json.page,
totalPages: Math.ceil(json.totalScores / metadata.itemsPerPage),
},
};
}
/**
* Gets all of the players for the given player id
*
* @param playerId the id of the player
* @param searchType the type of search to perform
* @param callback a callback to call when a page is fetched
* @returns a list of scores
*/
async function fetchAllScores(
playerId: string,
callback?: (currentPage: number, totalPages: number) => void,
): Promise<BeatleaderScore[] | undefined> {
const scores = new Array();
let done = false,
page = 1;
do {
const response = await fetchScores(playerId, page);
if (response == undefined) {
done = true;
break;
}
const { scores: scoresFetched } = response;
if (scoresFetched.length === 0) {
done = true;
break;
}
scores.push(...scoresFetched);
if (callback) {
callback(page, response.pageInfo.totalPages);
}
page++;
} while (!done);
return scores as BeatleaderScore[];
}
export const BeatLeaderAPI = {
fetchScores,
fetchAllScores,
};

View File

@ -25,7 +25,18 @@ export class FetchQueue {
const response = await fetch(url);
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after")) * 1000;
const hasRetryAfter = response.headers.has("retry-after");
let retryAfter =
Number(
hasRetryAfter
? response.headers.get("retry-after")
: new Date(
response.headers.get("X-Rate-Limit-Reset") as string,
).getTime() / 1000,
) * 1000;
if (!retryAfter) {
retryAfter = 3_000; // default to 3 seconds if we can't get the reset time
}
this._queue.push(url);
await new Promise<void>((resolve) => setTimeout(resolve, retryAfter));
return this.fetch(this._queue.shift() as string);

View File

@ -28,7 +28,7 @@ const SearchType = {
* @param name the name to search
* @returns a list of players
*/
export async function searchByName(
async function searchByName(
name: string,
): Promise<ScoresaberPlayer[] | undefined> {
const response = await fetchQueue.fetch(
@ -50,7 +50,7 @@ export async function searchByName(
* @param playerId the id of the player
* @returns the player info
*/
export async function getPlayerInfo(
async function getPlayerInfo(
playerId: string,
): Promise<ScoresaberPlayer | undefined | null> {
const response = await fetchQueue.fetch(
@ -75,7 +75,7 @@ export async function getPlayerInfo(
* @param limit the limit of scores to get
* @returns a list of scores
*/
export async function fetchScores(
async function fetchScores(
playerId: string,
page: number = 1,
searchType: string = SearchType.RECENT,
@ -92,10 +92,7 @@ export async function fetchScores(
| undefined
> {
if (limit > 100) {
console.log(
"Scoresaber API only allows a limit of 100 scores per request, limiting to 100.",
);
limit = 100;
throw new Error("Limit cannot be greater than 100");
}
const response = await fetchQueue.fetch(
formatString(PLAYER_SCORES, true, playerId, limit, searchType, page),
@ -127,7 +124,7 @@ export async function fetchScores(
* @param callback a callback to call when a page is fetched
* @returns a list of scores
*/
export async function fetchAllScores(
async function fetchAllScores(
playerId: string,
searchType: string,
callback?: (currentPage: number, totalPages: number) => void,
@ -165,7 +162,7 @@ export async function fetchAllScores(
* @param country the country to get the players from
* @returns a list of players
*/
export async function fetchTopPlayers(
async function fetchTopPlayers(
page: number = 1,
country?: string,
): Promise<
@ -201,3 +198,11 @@ export async function fetchTopPlayers(
},
};
}
export const ScoreSaberAPI = {
searchByName,
getPlayerInfo,
fetchScores,
fetchAllScores,
fetchTopPlayers,
};

View File

@ -1,7 +1,7 @@
// Yoinked from https://github.com/Shurdoof/pp-calculator/blob/c24b5ca452119339928831d74e6d603fb17fd5ef/src/lib/pp/calculator.ts
// Thank for for this I have no fucking idea what the maths is doing but it works!
import { usePlayerScoresStore } from "@/store/playerScoresStore";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
export const WEIGHT_COEFFICIENT = 0.965;
@ -115,7 +115,7 @@ function calcRawPpAtIdx(
* @returns the pp boundary (+ per raw pp)
*/
export function calcPpBoundary(playerId: string, expectedPp = 1) {
const rankedScores = usePlayerScoresStore
const rankedScores = useScoresaberScoresStore
.getState()
.players.find((p) => p.id === playerId)
?.scores?.scoresaber.filter((s) => s.score.pp !== undefined);
@ -156,7 +156,7 @@ export function calcPpBoundary(playerId: string, expectedPp = 1) {
* @returns the highest pp play
*/
export function getHighestPpPlay(playerId: string) {
const rankedScores = usePlayerScoresStore
const rankedScores = useScoresaberScoresStore
.getState()
.players.find((p) => p.id === playerId)
?.scores?.scoresaber.filter((s) => s.score.pp !== undefined);
@ -176,7 +176,7 @@ export function getHighestPpPlay(playerId: string) {
* @param limit the amount of top scores to average (default: 20)
*/
export function getAveragePp(playerId: string, limit: number = 20) {
const rankedScores = usePlayerScoresStore
const rankedScores = useScoresaberScoresStore
.getState()
.players.find((p) => p.id === playerId)
?.scores?.scoresaber.filter((s) => s.score.pp !== undefined);