add scores to player view
This commit is contained in:
parent
e065c974f2
commit
6e9b42afb5
@ -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
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>
|
||||||
|
67
src/components/Pagination.tsx
Normal file
67
src/components/Pagination.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
19
src/components/ScoreStatLabel.tsx
Normal file
19
src/components/ScoreStatLabel.tsx
Normal file
@ -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);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user