many many many many changes

This commit is contained in:
Lee 2023-10-19 14:17:55 +01:00
parent 6acf6e8635
commit a031451fa3
36 changed files with 2743 additions and 174 deletions

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

@ -0,0 +1,21 @@
name: "deploy"
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-22.04
steps:
- name: Cloning repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Push to dokku
uses: dokku/github-action@master
with:
git_remote_url: "ssh://dokku@10.0.3.39:22/scoresaber-reloadedv2"
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}

40
Dockerfile Normal file

@ -0,0 +1,40 @@
FROM fascinated/docker-images:node-latest AS base
# Install depends
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json* package-lock.yaml* ./
RUN npm install --production --frozen-lockfile --quiet
# Build from source
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm build
# Run the app
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
USER nextjs
EXPOSE 80
ENV HOSTNAME "0.0.0.0"
ENV PORT 80
CMD ["npm", "start"]

@ -1,5 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
generateEtags: true,
compress: true,
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
@ -8,6 +10,12 @@ const nextConfig = {
port: "", port: "",
pathname: "/**", pathname: "/**",
}, },
{
protocol: "https",
hostname: "cdn.scoresaber.com",
port: "",
pathname: "/**",
},
], ],
}, },
}; };

1217
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -10,16 +10,22 @@
}, },
"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",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"encoding": "^0.1.13",
"mongoose": "^7.6.3",
"next": "13.5.5", "next": "13.5.5",
"node-fetch-cache": "^3.1.3",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"@trigger.dev/sdk": "^2.2.0", "winston": "^3.11.0"
"@trigger.dev/nextjs": "^2.2.0",
"@trigger.dev/react": "^2.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/node-fetch-cache": "^3.0.3",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",

@ -1,12 +1,38 @@
import { connectMongo } from "@/database/mongo";
import { PlayerSchema } from "@/database/schemas/player";
import { triggerClient } from "@/trigger"; import { triggerClient } from "@/trigger";
import * as Utils from "@/utils/numberUtils";
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const id = searchParams.get("id"); const id = searchParams.get("id");
if (!id) { if (!id) {
return Response.json({ message: "No player provided" }); // 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({ triggerClient.sendEvent({
name: "user.add", name: "user.add",
payload: { payload: {
@ -14,5 +40,8 @@ export async function GET(request: Request) {
}, },
}); });
return Response.json({ message: "Hello from Next.js!" }); return Response.json({
error: false,
message: "We're setting up your account",
});
} }

@ -0,0 +1,19 @@
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,8 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import Image from "next/image";
import "./globals.css"; import "./globals.css";
const inter = Inter({ subsets: ["latin"] }); const font = Inter({ subsets: ["latin-ext"], weight: "500" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@ -18,7 +19,20 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en"> <html lang="en">
<body className={inter.className}>{children}</body> <body className={font.className}>
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
<Image
alt="Background image"
src={"https://cdn.fascinated.cc/W9jC5MLf.jpg"}
layout="fill"
objectFit="cover"
objectPosition="center"
quality={100}
/>
</div>
{children}
</body>
</html> </html>
); );
} }

@ -1,7 +1,7 @@
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import Container from "@/components/Container"; import Container from "@/components/Container";
import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
import SearchPlayer from "@/components/SearchPlayer";
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -12,7 +12,7 @@ export default function Home() {
return ( return (
<main> <main>
<Container> <Container>
<div className="mt-2 bg-neutral-800 w-full flex flex-col items-center justify-center rounded-sm"> <div className="mt-2 flex w-full flex-col items-center justify-center rounded-sm bg-neutral-800">
<Avatar <Avatar
className="m-6" className="m-6"
label="Player Avatar" label="Player Avatar"
@ -22,20 +22,7 @@ export default function Home() {
<p className="text-xl">Stranger</p> <p className="text-xl">Stranger</p>
<p className="text mt-2">Find a player profile</p> <p className="text mt-2">Find a player profile</p>
<form className="mt-6 flex gap-2"> <SearchPlayer />
<input
className="bg-transparent text-xs outline-none min-w-[14rem] border-b"
type="text"
placeholder="Enter a name or ScoreSaber profile..."
/>
<button className="bg-blue-600 hover:opacity-80 transition-all transform-gpu rounded-md p-1">
<MagnifyingGlassIcon
className="font-black"
width={18}
height={18}
/>
</button>
</form>
<div className="mb-6"></div> <div className="mb-6"></div>
</div> </div>

@ -4,12 +4,14 @@ import Image from "next/image";
interface AvatarProps { interface AvatarProps {
label: string; label: string;
url: string; url: string;
className: string; size?: number;
className?: string;
} }
export default function Avatar({ export default function Avatar({
label = "Avatar", label = "Avatar",
url, url,
size = 150,
className, className,
}: AvatarProps) { }: AvatarProps) {
return ( return (
@ -18,8 +20,8 @@ export default function Avatar({
className={clsx("rounded-full", className)} className={clsx("rounded-full", className)}
alt={label} alt={label}
src={url} src={url}
width={150} width={size}
height={150} height={size}
priority priority
/> />
</> </>

@ -3,7 +3,7 @@ import Navbar from "./Navbar";
export default function Container({ children }: { children: React.ReactNode }) { export default function Container({ children }: { children: React.ReactNode }) {
return ( return (
<> <>
<div className="md:max-w-[1200px] m-auto flex flex-col items-center justify-center"> <div className="m-auto flex flex-col items-center justify-center opacity-90 md:max-w-[1200px]">
<Navbar></Navbar> <Navbar></Navbar>
{children} {children}
</div> </div>

@ -0,0 +1,75 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { useEffect, useState } from "react";
import Avatar from "./Avatar";
export default function SearchPlayer() {
const [search, setSearch] = useState("");
const [players, setPlayers] = useState([] as ScoresaberPlayer[]);
useEffect(() => {
// Don't search if the query is too short
if (search.length < 4) {
setPlayers([]); // Clear players
return;
}
searchPlayer(search);
}, [search]);
function searchPlayer(search: string) {
fetch(`/api/player/search?name=${search}`).then(async (reponse) => {
const json = await reponse.json();
if (json.error || !json.players) {
setPlayers([]); // Clear players
}
setPlayers(json.players); // Set players
});
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
// Take the user to the first account
if (players.length > 0) {
window.location.href = `/player/${players[0].id}`;
}
}
return (
<form className="mt-6 flex gap-2" onSubmit={handleSubmit}>
<input
className="min-w-[14rem] border-b bg-transparent text-xs outline-none"
type="text"
placeholder="Enter a name or ScoreSaber profile..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button className="transform-gpu rounded-md bg-blue-600 p-1 transition-all hover:opacity-80">
<MagnifyingGlassIcon className="font-black" width={18} height={18} />
</button>
<div
className={clsx(
"absolute z-20 mt-7 flex min-w-[14rem] flex-col divide-y rounded-sm bg-neutral-700 shadow-sm",
players.length > 0 ? "flex" : "hidden",
)}
>
{players.map((player: ScoresaberPlayer) => (
<a
key={player.id}
className="flex min-w-[14rem] items-center gap-2 rounded-sm p-2 transition-all hover:bg-neutral-600"
href={`/player/${player.id}`}
>
<Avatar label="Account" size={40} url={player.profilePicture} />
<p className="truncate">{player.name}</p>
</a>
))}
</div>
</form>
);
}

21
src/database/mongo.ts Normal file

@ -0,0 +1,21 @@
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);
}

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

@ -0,0 +1,29 @@
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,
});

@ -0,0 +1,38 @@
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);

@ -0,0 +1,26 @@
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);

@ -0,0 +1,90 @@
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,3 +1,4 @@
// export all your job files here // export all your job files here
export * from "./fetchNewScores";
export * from "./setupUser"; export * from "./setupUser";

@ -1,4 +1,11 @@
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 { 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"; import { eventTrigger } from "@trigger.dev/sdk";
triggerClient.defineJob({ triggerClient.defineJob({
@ -10,7 +17,69 @@ triggerClient.defineJob({
}), }),
run: async (payload, io, ctx) => { run: async (payload, io, ctx) => {
const { id } = payload; 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 ${id}`); 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}"`);
}, },
}); });

5
src/logger.ts Normal file

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

@ -0,0 +1,4 @@
export type ScoresaberBadge = {
description: string;
image: string;
};

@ -0,0 +1,6 @@
export type ScoresaberDifficulty = {
leaderboardId: number;
difficulty: number;
gameMode: string;
difficultyRaw: string;
};

@ -0,0 +1,3 @@
export type ScoresaberError = {
message: string;
};

@ -0,0 +1,28 @@
import { ScoresaberDifficulty } from "./difficulty";
import { ScoresaberScore } from "./score";
export type ScoresaberLeaderboardInfo = {
id: string;
songHash: string;
songName: string;
songSubName: string;
songAuthorName: string;
levelAuthorName: string;
difficulty: ScoresaberDifficulty;
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;
playerScore: ScoresaberScore[];
difficulties: ScoresaberDifficulty[];
};

@ -0,0 +1,19 @@
import { ScoresaberBadge } from "./badge";
import { ScoresaberScoreStats } from "./scoreStats";
export type ScoresaberPlayer = {
id: string;
name: string;
profilePicture: string;
country: string;
pp: number;
rank: number;
countryRank: number;
role: string;
badges: ScoresaberBadge[];
histories: string;
scoreStats: ScoresaberScoreStats[];
permissions: number;
banned: boolean;
inactive: boolean;
};

@ -0,0 +1,7 @@
import { ScoresaberLeaderboardInfo } from "./leaderboard";
import { ScoresaberScore } from "./score";
export type ScoresaberPlayerScore = {
score: ScoresaberScore;
leaderboard: ScoresaberLeaderboardInfo;
};

@ -0,0 +1,18 @@
export type ScoresaberScore = {
id: number;
leaderboardPlayerInfo: 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;
};

@ -0,0 +1,8 @@
export type ScoresaberScoreStats = {
totalScore: number;
totalRankedScore: number;
averageRankedAccuracy: number;
totalPlayCount: number;
rankedPlayCount: number;
replaysWatched: number;
};

@ -0,0 +1,59 @@
import { fetchBuilder, MemoryCache } from "node-fetch-cache";
export class FetchQueue {
private _fetch;
private _queue: string[];
private _rateLimitReset: number;
constructor(ttl: number) {
this._fetch = fetchBuilder.withCache(
new MemoryCache({
ttl: ttl,
}),
);
this._queue = [];
this._rateLimitReset = Date.now();
}
/**
* Fetches the given url, and handles rate limiting
* re-requesting if the rate limit is exceeded.
*
* @param url the url to fetch
* @returns the response
*/
public async fetch(url: string): Promise<any> {
const now = Date.now();
if (now < this._rateLimitReset) {
this._queue.push(url);
await new Promise<void>((resolve) =>
setTimeout(resolve, this._rateLimitReset - now),
);
}
const response = await this._fetch(url);
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after")) * 1000;
this._queue.push(url);
await new Promise<void>((resolve) => setTimeout(resolve, retryAfter));
return this.fetch(this._queue.shift() as string);
}
if (response.headers.has("x-ratelimit-remaining")) {
const remaining = Number(response.headers.get("x-ratelimit-remaining"));
if (remaining === 0) {
const reset = Number(response.headers.get("x-ratelimit-reset")) * 1000;
this._queue.push(url);
await new Promise<void>((resolve) => setTimeout(resolve, reset - now));
return this.fetch(this._queue.shift() as string);
}
}
if (this._queue.length > 0) {
const nextUrl = this._queue.shift();
return this.fetch(nextUrl as string);
}
return response;
}
}

9
src/utils/numberUtils.ts Normal file

@ -0,0 +1,9 @@
/**
* Checks if the given value is an number.
*
* @param value the number
* @returns true if value is a number, otherwise false
*/
export function isNumber(value: any): boolean {
return !isNaN(value);
}

@ -0,0 +1,90 @@
import { logger } from "@/logger";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { fetchBuilder, MemoryCache } from "node-fetch-cache";
import { formatString } from "../string";
// Create a fetch instance with a cache
const fetch = fetchBuilder.withCache(
new MemoryCache({
ttl: 15 * 60 * 1000, // 15 minutes
}),
);
// Api endpoints
const API_URL = "https://scoresaber.com/api";
const SEARCH_PLAYER_URL =
API_URL + "/players?search={}&page=1&withMetadata=false";
const PLAYER_SCORES =
API_URL + "/player/{}/scores?limit={}&sort={}&page={}&withMetadata=true";
const SearchType = {
RECENT: "recent",
TOP: "top",
};
/**
* Search for a list of players by name
*
* @param name the name to search
* @returns a list of players
*/
export async function searchByName(
name: string,
): Promise<ScoresaberPlayer[] | undefined> {
const response = await fetch(formatString(SEARCH_PLAYER_URL, name));
const json = await response.json();
// Check if there was an error fetching the user data
if (json.errorMessage) {
return undefined;
}
return json.players as ScoresaberPlayer[];
}
export async function fetchScores(
playerId: string,
page: number = 1,
searchType: string = SearchType.RECENT,
limit: number = 100,
): Promise<ScoresaberPlayerScore[] | undefined> {
if (limit > 100) {
logger.warn(
"Scoresaber API only allows a limit of 100 scores per request, limiting to 100.",
);
limit = 100;
}
const response = await fetch(
formatString(PLAYER_SCORES, playerId, limit, searchType, page),
);
const json = await response.json();
// Check if there was an error fetching the user data
if (json.errorMessage) {
return undefined;
}
return json.playerScores as ScoresaberPlayerScore[];
}
export async function fetchAllScores(
playerId: string,
searchType: string,
): Promise<ScoresaberPlayerScore[] | undefined> {
const scores = new Array();
let done = false,
page = 1;
do {
const response = await fetchScores(playerId, page, searchType);
if (response == undefined || response.length === 0) {
done = true;
break;
}
scores.push(...response);
page++;
} while (!done);
return scores as ScoresaberPlayerScore[];
}

@ -0,0 +1,92 @@
import { ScoreSaberLeaderboard } from "@/database/schemas/scoresaberLeaderboard";
import { ScoresaberScore } from "@/database/schemas/scoresaberScore";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
export async function createScore(
playerId: string,
scoreSaberScore: ScoresaberPlayerScore,
) {
const score = scoreSaberScore.score;
const leaderboard = scoreSaberScore.leaderboard;
await ScoresaberScore.create({
_id: score.id,
playerId: playerId,
leaderboardId: leaderboard.id,
rank: score.rank,
baseScore: score.baseScore,
modifiedScore: score.modifiedScore,
pp: score.pp,
weight: score.weight,
modifiers: score.modifiers,
multiplier: score.multiplier,
badCuts: score.badCuts,
missedNotes: score.missedNotes,
maxCombo: score.maxCombo,
fullCombo: score.fullCombo,
hmd: score.hmd,
hasReply: score.hasReply,
timeSet: new Date(score.timeSet).getTime(),
});
await ScoreSaberLeaderboard.updateOne(
{ _id: leaderboard.id },
{
_id: leaderboard.id,
songHash: leaderboard.songHash,
songName: leaderboard.songName,
songSubName: leaderboard.songSubName,
songAuthorName: leaderboard.songAuthorName,
levelAuthorName: leaderboard.levelAuthorName,
difficulty: leaderboard.difficulty,
maxScore: leaderboard.maxScore,
createdDate: leaderboard.createdDate,
rankedDate: leaderboard.rankedDate,
qualifiedDate: leaderboard.qualifiedDate,
lovedDate: leaderboard.lovedDate,
ranked: leaderboard.ranked,
qualified: leaderboard.qualified,
loved: leaderboard.loved,
maxPP: leaderboard.maxPP,
stars: leaderboard.stars,
positiveModifiers: leaderboard.positiveModifiers,
plays: leaderboard.plays,
dailyPlays: leaderboard.dailyPlays,
coverImage: leaderboard.coverImage,
difficulties: leaderboard.difficulties,
},
{ upsert: true },
);
}
export async function updateScore(
playerId: string,
scoreSaberScore: ScoresaberPlayerScore,
) {
const score = scoreSaberScore.score;
const leaderboard = scoreSaberScore.leaderboard;
// Delete the old score
await ScoresaberScore.deleteOne({ _id: score.id });
// Create the new score
await ScoresaberScore.create({
_id: score.id,
playerId: playerId,
leaderboardId: leaderboard.id,
rank: score.rank,
baseScore: score.baseScore,
modifiedScore: score.modifiedScore,
pp: score.pp,
weight: score.weight,
modifiers: score.modifiers,
multiplier: score.multiplier,
badCuts: score.badCuts,
missedNotes: score.missedNotes,
maxCombo: score.maxCombo,
fullCombo: score.fullCombo,
hmd: score.hmd,
hasReply: score.hasReply,
timeSet: score.timeSet,
});
}

18
src/utils/string.ts Normal file

@ -0,0 +1,18 @@
/**
* Formats a string with the given arguments.
*
* @param str the string to check
* @param args the arguments to replace
* @returns the formatted string
*/
export function formatString(str: string, ...args: any[]): string {
return str.replace(/{}/g, (match) => {
// If there are no arguments, return the match
if (args.length === 0) {
return match;
}
// Otherwise, return the next argument
return String(args.shift());
});
}

782
yarn.lock

File diff suppressed because it is too large Load Diff