add scores to player view

This commit is contained in:
Lee 2023-10-20 10:50:19 +01:00
parent e065c974f2
commit 6e9b42afb5
26 changed files with 329 additions and 1807 deletions

@ -1,5 +0,0 @@
TRIGGER_API_KEY=set me
TRIGGER_API_URL=https://trigger.example.com
NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY=set me
MONGODB_URI=mongodb://localhost:27017/ssr

706
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -10,20 +10,16 @@
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.0.18",
"@trigger.dev/nextjs": "^2.2.0",
"@trigger.dev/react": "^2.2.0",
"@trigger.dev/sdk": "^2.2.0",
"bluebird": "^3.7.2", "bluebird": "^3.7.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"encoding": "^0.1.13", "encoding": "^0.1.13",
"mongoose": "^7.6.3", "moment": "^2.29.4",
"next": "13.5.6", "next": "13.5.6",
"node-fetch-cache": "^3.1.3", "node-fetch-cache": "^3.1.3",
"react": "^18", "react": "^18",
"react-country-flag": "^3.1.0", "react-country-flag": "^3.1.0",
"react-dom": "^18", "react-dom": "^18",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"winston": "^3.11.0",
"zustand": "^4.4.3" "zustand": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@ -39,8 +35,5 @@
"prettier-plugin-tailwindcss": "^0.5.6", "prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "^5" "typescript": "^5"
},
"trigger.dev": {
"endpointId": "scoresaber-reloaded-3SPH"
} }
} }

@ -1,47 +0,0 @@
import { connectMongo } from "@/database/mongo";
import { PlayerSchema } from "@/database/schemas/player";
import { triggerClient } from "@/trigger";
import * as Utils from "@/utils/numberUtils";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
// Checks if there was an account provided
return Response.json({ error: true, message: "No player provided" });
}
// Simple account id validation
const isNumber = Utils.isNumber(id);
if (!isNumber) {
return Response.json({
error: true,
message: "Provided account id is not a number",
});
}
// Ensure we're connected to the database
await connectMongo();
// Checks if the player is already in the database
const player = await PlayerSchema.findById(id);
if (player !== null) {
return Response.json({
error: true,
message: "Account already exists",
});
}
// Send the event to Trigger to setup the user
triggerClient.sendEvent({
name: "user.add",
payload: {
id: id,
},
});
return Response.json({
error: false,
message: "We're setting up your account",
});
}

@ -1,19 +0,0 @@
import { getPlayerInfo } from "@/utils/scoresaber/api";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return Response.json({ error: true, message: "No player provided" });
}
const player = await getPlayerInfo(id);
if (player == undefined) {
return Response.json({
error: true,
message: "No players with that ID were found",
});
}
return Response.json({ error: false, player: player });
}

@ -1,23 +0,0 @@
import { fetchScores } from "@/utils/scoresaber/api";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const page = searchParams.get("page");
if (!id) {
return Response.json({ error: true, message: "No player provided" });
}
if (!page) {
return Response.json({ error: true, message: "No page provided" });
}
const scores = await fetchScores(id, Number.parseInt(page), "recent", 8);
if (scores == undefined) {
return Response.json({
error: true,
message: "No players with that ID were found",
});
}
return Response.json({ error: false, scores: scores });
}

@ -1,19 +0,0 @@
import { searchByName } from "@/utils/scoresaber/api";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const name = searchParams.get("name");
if (!name) {
return Response.json({ error: true, message: "No player provided" });
}
const players = await searchByName(name);
if (players === undefined) {
return Response.json({
error: true,
message: "No players with that name were found",
});
}
return Response.json({ error: false, players: players });
}

@ -1,7 +0,0 @@
import { triggerClient } from "@/trigger";
import { createAppRoute } from "@trigger.dev/nextjs";
import "@/jobs";
//this route is used to send and receive data with Trigger.dev
export const { POST, dynamic } = createAppRoute(triggerClient);

@ -3,70 +3,103 @@
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import Container from "@/components/Container"; import Container from "@/components/Container";
import Label from "@/components/Label"; import Label from "@/components/Label";
import Pagination from "@/components/Pagination";
import ScoreStatLabel from "@/components/ScoreStatLabel";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { ScoresaberScore } from "@/schemas/scoresaber/score"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { formatNumber } from "@/utils/number"; import { formatNumber } from "@/utils/number";
import { fetchScores, getPlayerInfo } from "@/utils/scoresaber/api";
import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid"; import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid";
import moment from "moment";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import ReactCountryFlag from "react-country-flag"; import ReactCountryFlag from "react-country-flag";
type PageInfo = {
loading: boolean;
page: number;
totalPages: number;
sortType: string;
scores: ScoresaberPlayerScore[];
};
type PlayerInfo = {
loading: boolean;
player: ScoresaberPlayer | undefined;
};
export default function Player({ params }: { params: { id: string } }) { export default function Player({ params }: { params: { id: string } }) {
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [loadingPlayer, setLoadingPlayer] = useState(true);
const [playerData, setPlayerData] = useState<any>(undefined);
const [loadingScores, setLoadingScores] = useState(true); const [player, setPlayer] = useState<PlayerInfo>({
const [playerScores, setPlayerScores] = useState<ScoresaberScore[]>([]); loading: true,
player: undefined,
});
const [scores, setScores] = useState<PageInfo>({
loading: true,
page: 1,
totalPages: 1,
sortType: "recent",
scores: [],
});
const updateScoresPage = useCallback(
(page: any) => {
console.log("Switching page to", page);
fetchScores(params.id, page, scores.sortType, 10).then(
(scoresResponse) => {
if (!scoresResponse) {
setError(true);
setErrorMessage("Failed to fetch scores");
setScores({ ...scores, loading: false });
return;
}
setScores({
...scores,
scores: scoresResponse.scores,
totalPages: scoresResponse.pageInfo.totalPages,
loading: false,
page: page,
});
},
);
},
[params.id, scores],
);
useEffect(() => { useEffect(() => {
if (!params.id) { if (!params.id) {
setError(true); setError(true);
setLoadingPlayer(false); setPlayer({ ...player, loading: false });
return; return;
} }
if (error || !loadingPlayer) { if (error || !player.loading) {
return; return;
} }
fetch("/api/player/get?id=" + params.id).then(async (response) => {
const json = await response.json();
if (json.error == true) { getPlayerInfo(params.id).then((playerResponse) => {
if (!playerResponse) {
setError(true); setError(true);
setErrorMessage(json.message); setErrorMessage("Failed to fetch player");
setLoadingPlayer(false); setPlayer({ ...player, loading: false });
return; return;
} }
setPlayer({ ...player, player: playerResponse, loading: false });
setPlayerData(json.player); updateScoresPage(1);
setLoadingPlayer(false);
fetch(`/api/player/scoresaber/scores/get?id=${params.id}&page=1`).then(
async (response) => {
const json = await response.json();
console.log(json);
if (json.error == true) {
setLoadingScores(false);
return;
}
setPlayerScores(json.scores);
setLoadingScores(false);
},
);
}); });
}, [error, loadingPlayer, params.id]); }, [error, params.id, player, scores, updateScoresPage]);
if (loadingPlayer || error || !playerData) { if (player.loading || error || !player.player) {
return ( return (
<main> <main>
<Container> <Container>
<div className="mt-2 flex w-full flex-col justify-center rounded-sm bg-neutral-800"> <div className="mt-2 flex w-full flex-col justify-center rounded-sm bg-neutral-800">
<div className="p-3 text-center"> <div className="p-3 text-center">
<div role="status"> <div role="status">
{loadingPlayer && <Spinner />} {player.loading && <Spinner />}
{error && ( {error && (
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-2">
@ -88,6 +121,8 @@ export default function Player({ params }: { params: { id: string } }) {
); );
} }
const playerData = player.player;
return ( return (
<main> <main>
<Container> <Container>
@ -151,18 +186,78 @@ export default function Player({ params }: { params: { id: string } }) {
{/* Scores */} {/* Scores */}
<div className="mt-2 flex w-full flex-row justify-center rounded-sm bg-neutral-800 xs:flex-col"> <div className="mt-2 flex w-full flex-row justify-center rounded-sm bg-neutral-800 xs:flex-col">
<div className="p-3"> <div className="p-3">
{loadingScores ? ( {scores.loading ? (
<div className="flex justify-center"> <div className="flex justify-center">
<Spinner /> <Spinner />
</div> </div>
) : ( ) : (
<> <div className="grid grid-cols-1 divide-y divide-gray-500">
{playerScores.map((score, id) => { {!scores.loading && scores.scores.length == 0 ? (
return <>hi</>; <p className="text-red-400">No Scores</p>
})} ) : (
</> scores.scores.map((scoreData, id) => {
const { score, leaderboard } = scoreData;
return (
<div
className="grid grid-cols-[.9fr_6fr_3fr] p-2"
key={id}
>
<div className="flex flex-col items-center justify-center">
<p>#{score.rank}</p>
<p className="text-sm text-gray-300">
{moment(score.timeSet).fromNow()}
</p>
</div>
<div className="flex w-full items-center gap-2">
<Image
src={leaderboard.coverImage}
alt={leaderboard.songName}
className="h-fit rounded-md"
width={60}
height={60}
/>
<div className="text-blue-500">
<p>{leaderboard.songName}</p>
<p>
{leaderboard.songAuthorName}{" "}
<span className="text-gray-200">
{leaderboard.levelAuthorName}
</span>
</p>
</div>
</div>
<div className="flex flex-col items-end p-1">
<div className="flex flex-row gap-2">
<ScoreStatLabel
value={formatNumber(score.pp.toFixed(2)) + "pp"}
className="bg-blue-500"
/>
<ScoreStatLabel
value={score.modifiedScore.toFixed(0)}
/>
</div>
</div>
</div>
);
})
)}
</div>
)} )}
</div> </div>
{/* Pagination */}
<div className="flex w-full flex-row justify-center rounded-sm bg-neutral-800 xs:flex-col">
<div className="p-3">
<Pagination
currentPage={scores.page}
totalPages={scores.totalPages}
onPageChange={(page) => {
updateScoresPage(page);
}}
/>
</div>
</div>
</div> </div>
</Container> </Container>
</main> </main>

@ -0,0 +1,67 @@
import {
ArrowUturnLeftIcon,
ArrowUturnRightIcon,
} from "@heroicons/react/20/solid";
type PaginationProps = {
currentPage: number;
totalPages: number;
onPageChange: (pageNumber: number) => void;
};
export default function Pagination(props: PaginationProps) {
const { currentPage, totalPages, onPageChange } = props;
// Calculate the range of page numbers to display
const rangeStart = Math.max(1, currentPage - 2);
const rangeEnd = Math.min(totalPages, currentPage + 2);
// Generate an array of page numbers to display
const pageNumbers = [];
for (let i = rangeStart; i <= rangeEnd; i++) {
pageNumbers.push(i);
}
return (
<div className="flex justify-center text-white">
<nav>
<ul className="flex items-center gap-2">
{currentPage > 1 && (
<li className="rounded-md bg-neutral-700 hover:opacity-80">
<button
className="px-3 py-1"
onClick={() => onPageChange(currentPage - 1)}
>
<ArrowUturnLeftIcon width={20} height={20} />
</button>
</li>
)}
{pageNumbers.map((pageNumber) => (
<li key={pageNumber}>
<button
className={`rounded-md px-3 py-1 ${
pageNumber === currentPage
? "bg-blue-500 text-white"
: "bg-neutral-700 hover:opacity-80"
}`}
onClick={() => onPageChange(pageNumber)}
>
{pageNumber}
</button>
</li>
))}
{currentPage < totalPages && (
<li className="rounded-md bg-neutral-700 hover:opacity-80">
<button
className="px-3 py-1"
onClick={() => onPageChange(currentPage + 1)}
>
<ArrowUturnRightIcon width={20} height={20} />
</button>
</li>
)}
</ul>
</nav>
</div>
);
}

@ -0,0 +1,19 @@
import clsx from "clsx";
type LabelProps = {
value: string;
className?: string;
};
export default function ScoreStatLabel({
value,
className = "bg-neutral-700",
}: LabelProps) {
return (
<div className={clsx("flex flex-col justify-center rounded-md", className)}>
<div className="p4-[0.3rem] flex items-center gap-2 pb-[0.2rem] pl-[0.3rem] pr-[0.3rem] pt-[0.2rem]">
<p>{value}</p>
</div>
</div>
);
}

@ -1,21 +0,0 @@
import mongoose from "mongoose";
/**
* Creates a connection to Mongo
*/
export function connectMongo() {
const mongoUri = process.env.MONGODB_URI;
// Validate the mongo connection string
if (!mongoUri || typeof mongoUri !== "string") {
throw new Error("MONGO_URI is invalid");
}
// Check if mongoose is already connected
if (mongoose.connection.readyState) {
return;
}
// Connect to mongo
return mongoose.connect(mongoUri);
}

@ -1,15 +0,0 @@
import mongoose from "mongoose";
import { ScoresaberSchema } from "./scoresaberAccount";
const { Schema } = mongoose;
const playerSchema = new Schema({
_id: String,
profilePicture: String,
name: String,
country: String,
scoresaber: ScoresaberSchema,
});
export const PlayerSchema =
mongoose.models.Player || mongoose.model("Player", playerSchema);

@ -1,29 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const badgeSchema = new Schema({
image: String,
description: String,
});
const scoreStatsSchema = new Schema({
totalScore: Number,
totalRankedScore: Number,
averageRankedAccuracy: Number,
totalPlayCount: Number,
rankedPlayCount: Number,
replaysWatched: Number,
});
export const ScoresaberSchema = new Schema({
pp: Number,
rank: Number,
countryRank: Number,
role: String,
badges: [badgeSchema],
histories: String,
scoreStats: scoreStatsSchema,
permissions: Number,
banned: Boolean,
inactive: Boolean,
});

@ -1,38 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const scoresaberLeaderboardDifficulty = new Schema({
leaderboardId: Number,
difficulty: Number,
gameMode: String,
difficultyRaw: String,
});
const scoresaberLeaderboard = new Schema({
_id: String,
songHash: String,
songName: String,
songSubName: String,
songAuthorName: String,
levelAuthorName: String,
difficulty: scoresaberLeaderboardDifficulty,
maxScore: Number,
createdDate: String,
rankedDate: [String],
qualifiedDate: [String],
lovedDate: [String],
ranked: Boolean,
qualified: Boolean,
loved: Boolean,
maxPP: Number,
stars: Number,
positiveModifiers: Boolean,
plays: Number,
dailyPlays: Number,
coverImage: String,
difficulties: [scoresaberLeaderboardDifficulty],
});
export const ScoreSaberLeaderboard =
mongoose.models.ScoreSaberLeaderboard ||
mongoose.model("ScoreSaberLeaderboard", scoresaberLeaderboard);

@ -1,26 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const scoresaberScore = new Schema({
_id: String,
playerId: String,
leaderboardId: String,
rank: Number,
baseScore: Number,
modifiedScore: Number,
pp: Number,
weight: Number,
modifiers: String,
multiplier: Number,
badCuts: Number,
missedNotes: Number,
maxCombo: Number,
fullCombo: Boolean,
hmd: Number,
hasReply: Boolean,
timeSet: String,
});
export const ScoresaberScore =
mongoose.models.ScoreSaberScores ||
mongoose.model("ScoreSaberScores", scoresaberScore);

@ -1,90 +0,0 @@
import { connectMongo } from "@/database/mongo";
import { PlayerSchema } from "@/database/schemas/player";
import { ScoresaberScore } from "@/database/schemas/scoresaberScore";
import { triggerClient } from "@/trigger";
import { fetchScores } from "@/utils/scoresaber/api";
import { createScore, updateScore } from "@/utils/scoresaber/db";
import { cronTrigger } from "@trigger.dev/sdk";
triggerClient.defineJob({
id: "fetch-new-scores",
name: "Scores: Fetch all new scores for players",
version: "0.0.1",
trigger: cronTrigger({
cron: "*/15 * * * *", // Fetch new scores every 15 minutes
}),
// trigger: eventTrigger({
// name: "user.add",
// }),
run: async (payload, io, ctx) => {
await io.logger.info("Scores: Fetching all new scores for players");
// Ensure we're connected to the database
await connectMongo();
const players = await PlayerSchema.find().select("_id"); // Get all players
for (const player of players) {
// Loop through all players
await io.logger.info(
`Scores: Fetching new scores for player: "${player._id}"`,
);
// Get the old scores for the player
const oldScores = await ScoresaberScore.find({ playerId: player._id })
.select("_id")
.select("timeSet")
.sort("-timeSet")
.limit(100) // Limit to 100 scores so we don't violate the db
.exec();
const mostRecentScore = oldScores[0];
console.log(mostRecentScore);
let search = true;
let page = 0;
let newScoresCount = 0;
while (search === true) {
const newScores = await fetchScores(player._id, page++);
if (newScores === undefined) {
search = false;
io.logger.warn(
`Scores: Failed to fetch scores for player: "${player._id}"`,
);
break;
}
// Check if any scores were returned
if (newScores.length === 0) {
search = false;
break;
}
// Loop through the page of scores
for (const scoreData of newScores) {
const score = scoreData.score;
const leaderboard = scoreData.leaderboard;
// Check if the latest score is the same as the most recent score
// If it is, we've reached the end of the new scores
if (score.id == mostRecentScore._id) {
search = false;
break;
}
const hasScoreOnLeaderboard = await ScoresaberScore.exists({
leaderboardId: leaderboard.id,
});
if (!hasScoreOnLeaderboard) {
await createScore(player.id, scoreData);
} else {
await updateScore(player.id, scoreData);
}
newScoresCount++;
}
}
io.logger.info(
`Scores: Fetched ${newScoresCount} new scores for player: "${player._id}"`,
);
}
},
});

@ -1,5 +0,0 @@
// export all your job files here
export * from "./fetchNewScores";
export * from "./setupUser";
export * from "./updateUsers";

@ -1,85 +0,0 @@
import { connectMongo } from "@/database/mongo";
import { PlayerSchema } from "@/database/schemas/player";
import { ScoresaberError } from "@/schemas/scoresaber/error";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { triggerClient } from "@/trigger";
import * as Utils from "@/utils/numberUtils";
import { fetchAllScores } from "@/utils/scoresaber/api";
import { createScore } from "@/utils/scoresaber/db";
import { eventTrigger } from "@trigger.dev/sdk";
triggerClient.defineJob({
id: "setup-user",
name: "Setup User: Add first time user to the database",
version: "0.0.1",
trigger: eventTrigger({
name: "user.add",
}),
run: async (payload, io, ctx) => {
const { id } = payload;
const isNumber = Utils.isNumber(id);
if (!isNumber) {
await io.logger.warn(`Setup User: Failed - Invalid account id: "${id}"`);
return;
}
await io.logger.info(`Setup User: Running for account: "${id}"`);
const resposnse = await io.backgroundFetch<
ScoresaberPlayer | ScoresaberError
>("fetch-user-data", `https://scoresaber.com/api/player/${id}/full`);
// Check if there was an error fetching the user data
const error = resposnse as ScoresaberError;
if (error.message !== undefined) {
await io.logger.error(
`Setup User: Failed - Error fetching user data: "${error.message}"`,
);
return;
}
const user = resposnse as ScoresaberPlayer;
await connectMongo(); // Ensure we're connected to the database
const player = await PlayerSchema.findOne({ id: user.id });
if (player !== null) {
await io.logger.info(
`Setup User: Failed - Player already exists: "${player.id}"`,
);
return;
}
await io.logger.info(`Setup User: Creating player: "${user.id}"`);
const newPlayer = await PlayerSchema.create({
_id: user.id,
avatar: user.profilePicture,
name: user.name,
country: user.country,
scoresaber: {
pp: user.pp,
rank: user.rank,
countryRank: user.countryRank,
role: user.role,
badges: user.badges,
histories: user.histories,
scoreStats: user.scoreStats,
permissions: user.permissions,
inactive: user.inactive,
},
}); // Save the player to the database
io.logger.info(`Setup User: Created player: "${user.id}"`);
io.logger.info(`Setup User: Fetching scores for player: "${user.id}"`);
const scores = await fetchAllScores(newPlayer.id, "recent");
if (scores == undefined) {
await io.logger.error(`Setup User: Failed - Error fetching scores`);
return;
}
for (const scoreSaberScore of scores) {
createScore(user.id, scoreSaberScore);
}
io.logger.info(`Setup User: Fetched scores for player: "${user.id}"`);
},
});

@ -1,64 +0,0 @@
import { connectMongo } from "@/database/mongo";
import { PlayerSchema } from "@/database/schemas/player";
import { triggerClient } from "@/trigger";
import { getPlayerInfo } from "@/utils/scoresaber/api";
import { cronTrigger } from "@trigger.dev/sdk";
triggerClient.defineJob({
id: "update-users-scoresaber",
name: "Users: Fetch all user data from scoresaber",
version: "0.0.1",
trigger: cronTrigger({
cron: "0 * * * *", // Fetch new data every hour
}),
run: async (payload, io, ctx) => {
io.logger.info("Users: Fetching all user data from scoresaber");
// Ensure we're connected to the database
await connectMongo();
let usersUpdated = 0;
const players = await PlayerSchema.find().select("_id"); // Get all players
for (const player of players) {
const newData: any = getPlayerInfo(player._id, true);
if (newData === undefined || newData === null) {
io.logger.warn(
`Users: Failed to fetch data for player: "${player._id}"`,
);
continue;
}
const oldData = await PlayerSchema.findById(player._id);
if (oldData === null) {
io.logger.warn(`Users: Failed to find player: "${player._id}"`);
continue;
}
// Check if the data has changed
if (oldData.scoresaber.pp === newData.pp) {
continue;
}
// Update the player data
await PlayerSchema.findByIdAndUpdate(player._id, {
$set: {
name: newData.name,
country: newData.country,
profilePicture: newData.profilePicture,
"scoresaber.pp": newData.pp,
"scoresaber.rank": newData.rank,
"scoresaber.countryRank": newData.countryRank,
"scoresaber.role": newData.role,
"scoresaber.badges": newData.badges,
"scoresaber.histories": newData.histories,
"scoresaber.scoreStats": newData.scoreStats,
"scoresaber.permission": newData.permission,
"scoresaber.inactive": newData.inactive,
},
});
usersUpdated++;
}
io.logger.info(`Users: Updated ${usersUpdated} users`);
},
});

@ -1,5 +0,0 @@
import winston from "winston";
export const logger = winston.createLogger({
transports: [new winston.transports.Console()],
});

@ -1,7 +0,0 @@
import { TriggerClient } from "@trigger.dev/sdk";
export const triggerClient = new TriggerClient({
id: "scoresaber-reloaded-3SPH",
apiKey: process.env.TRIGGER_API_KEY,
apiUrl: process.env.TRIGGER_API_URL,
});

@ -1,16 +1,8 @@
import { fetchBuilder, MemoryCache } from "node-fetch-cache";
export class FetchQueue { export class FetchQueue {
private _fetch;
private _queue: string[]; private _queue: string[];
private _rateLimitReset: number; private _rateLimitReset: number;
constructor(ttl: number) { constructor() {
this._fetch = fetchBuilder.withCache(
new MemoryCache({
ttl: ttl,
}),
);
this._queue = []; this._queue = [];
this._rateLimitReset = Date.now(); this._rateLimitReset = Date.now();
} }
@ -31,7 +23,7 @@ export class FetchQueue {
); );
} }
const response = await this._fetch(url); const response = await fetch(url);
if (response.status === 429) { if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after")) * 1000; const retryAfter = Number(response.headers.get("retry-after")) * 1000;
this._queue.push(url); this._queue.push(url);

@ -4,7 +4,7 @@
* @param number the number to format * @param number the number to format
* @returns the formatted number * @returns the formatted number
*/ */
export function formatNumber(number: number) { export function formatNumber(number: any) {
if (number === undefined) { if (number === undefined) {
return ""; return "";
} }

@ -1,12 +1,10 @@
import { connectMongo } from "@/database/mongo";
import { logger } from "@/logger";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore"; import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { FetchQueue } from "../fetchWithQueue"; import { FetchQueue } from "../fetchWithQueue";
import { formatString } from "../string"; import { formatString } from "../string";
// Create a fetch instance with a cache // Create a fetch instance with a cache
const fetchQueue = new FetchQueue(15 * 60 * 1000); const fetchQueue = new FetchQueue();
// Api endpoints // Api endpoints
const API_URL = "https://scoresaber.com/api"; const API_URL = "https://scoresaber.com/api";
@ -51,9 +49,7 @@ export async function searchByName(
*/ */
export async function getPlayerInfo( export async function getPlayerInfo(
playerId: string, playerId: string,
apiOnly = false,
): Promise<ScoresaberPlayer | undefined | null> { ): Promise<ScoresaberPlayer | undefined | null> {
await connectMongo();
const response = await fetchQueue.fetch( const response = await fetchQueue.fetch(
formatString(GET_PLAYER_DATA_FULL, playerId), formatString(GET_PLAYER_DATA_FULL, playerId),
); );
@ -81,9 +77,19 @@ export async function fetchScores(
page: number = 1, page: number = 1,
searchType: string = SearchType.RECENT, searchType: string = SearchType.RECENT,
limit: number = 100, limit: number = 100,
): Promise<ScoresaberPlayerScore[] | undefined> { ): Promise<
| {
scores: ScoresaberPlayerScore[];
pageInfo: {
totalScores: number;
page: number;
totalPages: number;
};
}
| undefined
> {
if (limit > 100) { if (limit > 100) {
logger.warn( console.log(
"Scoresaber API only allows a limit of 100 scores per request, limiting to 100.", "Scoresaber API only allows a limit of 100 scores per request, limiting to 100.",
); );
limit = 100; limit = 100;
@ -98,7 +104,16 @@ export async function fetchScores(
return undefined; return undefined;
} }
return json.playerScores as ScoresaberPlayerScore[]; const scores = json.playerScores as ScoresaberPlayerScore[];
const metadata = json.metadata;
return {
scores: scores,
pageInfo: {
totalScores: metadata.total,
page: metadata.page,
totalPages: Math.ceil(metadata.total / metadata.itemsPerPage),
},
};
} }
export async function fetchAllScores( export async function fetchAllScores(
@ -111,11 +126,16 @@ export async function fetchAllScores(
page = 1; page = 1;
do { do {
const response = await fetchScores(playerId, page, searchType); const response = await fetchScores(playerId, page, searchType);
if (response == undefined || response.length === 0) { if (response == undefined) {
done = true; done = true;
break; break;
} }
scores.push(...response); const { scores } = response;
if (scores.length === 0) {
done = true;
break;
}
scores.push(...scores);
page++; page++;
} while (!done); } while (!done);

601
yarn.lock

File diff suppressed because it is too large Load Diff