Compare commits

..

No commits in common. "b68de0552fce8302754c4d2fe0d4ed4feee46ca4" and "3a2a876f745bd717df659416a43878572f7ae1f4" have entirely different histories.

16 changed files with 154 additions and 252 deletions

BIN
bun.lockb

Binary file not shown.

@ -3,7 +3,6 @@ import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard"; import { Leaderboards } from "@ssr/common/leaderboard";
import { TopScoresResponse } from "@ssr/common/response/top-scores-response"; import { TopScoresResponse } from "@ssr/common/response/top-scores-response";
import { ScoreService } from "../service/score.service"; import { ScoreService } from "../service/score.service";
import { Timeframe } from "@ssr/common/timeframe";
@Controller("/scores") @Controller("/scores")
export default class ScoresController { export default class ScoresController {
@ -78,30 +77,11 @@ export default class ScoresController {
@Get("/top", { @Get("/top", {
config: {}, config: {},
query: t.Object({
limit: t.Number({ required: true }),
timeframe: t.String({ required: true }),
}),
}) })
public async getTopScores({ public async getTopScores(): Promise<TopScoresResponse> {
query: { limit, timeframe }, const scores = await ScoreService.getTopScores();
}: {
query: { limit: number; timeframe: Timeframe };
}): Promise<TopScoresResponse> {
if (limit <= 0) {
limit = 1;
} else if (limit > 100) {
limit = 100;
}
if ((timeframe.toLowerCase() as keyof Timeframe) === undefined) {
timeframe = "all";
}
const scores = await ScoreService.getTopScores(limit, timeframe);
return { return {
scores, scores,
timeframe,
limit,
}; };
} }
} }

@ -75,7 +75,7 @@ app.use(
app.use( app.use(
cron({ cron({
name: "player-scores-tracker-cron", name: "player-scores-tracker-cron",
pattern: "0 4 * * *", // Every day at 04:00 pattern: "*/1 * * * *", // Every day at 04:00
timezone: "Europe/London", // UTC time timezone: "Europe/London", // UTC time
protect: true, protect: true,
run: async () => { run: async () => {

@ -264,6 +264,7 @@ export class PlayerService {
* @private * @private
*/ */
private static async refreshPlayerScoreSaberScores(player: PlayerDocument) { private static async refreshPlayerScoreSaberScores(player: PlayerDocument) {
console.log(player);
console.log(`Refreshing scores for ${player.id}...`); console.log(`Refreshing scores for ${player.id}...`);
let page = 1; let page = 1;
let hasMorePages = true; let hasMorePages = true;

@ -38,9 +38,6 @@ import { MapCharacteristic } from "@ssr/common/types/map-characteristic";
import { Page, Pagination } from "@ssr/common/pagination"; import { Page, Pagination } from "@ssr/common/pagination";
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import Leaderboard from "@ssr/common/model/leaderboard/leaderboard"; import Leaderboard from "@ssr/common/model/leaderboard/leaderboard";
import { Timeframe } from "@ssr/common/timeframe";
import { getDaysAgoDate } from "@ssr/common/utils/time-utils";
import { PlayerService } from "./player.service";
const playerScoresCache = new SSRCache({ const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute ttl: 1000 * 60, // 1 minute
@ -192,12 +189,9 @@ export class ScoreService {
if (player == undefined) { if (player == undefined) {
return; return;
} }
// Update player name // Update player name
if (playerName !== "Unknown") { player.name = playerName;
player.name = playerName; await player.save();
await player.save();
}
// The score has already been tracked, so ignore it. // The score has already been tracked, so ignore it.
if ( if (
@ -209,8 +203,9 @@ export class ScoreService {
score.score score.score
)) !== null )) !== null
) { ) {
console.log( await logToChannel(
`ScoreSaber score already tracked for "${playerName}"(${playerId}), difficulty: ${score.difficulty}, score: ${score.score}, leaderboard: ${leaderboard.id}, ignoring...` DiscordChannels.backendLogs,
new EmbedBuilder().setDescription(`Score ${score.scoreId} already tracked`)
); );
return; return;
} }
@ -308,67 +303,97 @@ export class ScoreService {
* Gets the top tracked scores. * Gets the top tracked scores.
* *
* @param amount the amount of scores to get * @param amount the amount of scores to get
* @param timeframe the timeframe to filter by
* @returns the top scores * @returns the top scores
*/ */
public static async getTopScores(amount: number = 100, timeframe: Timeframe) { public static async getTopScores(amount: number = 100) {
console.log(`Getting top scores for timeframe: ${timeframe}, limit: ${amount}...`);
const before = Date.now();
let daysAgo = -1;
if (timeframe === "daily") {
daysAgo = 1;
} else if (timeframe === "weekly") {
daysAgo = 8;
} else if (timeframe === "monthly") {
daysAgo = 31;
}
const date: Date = daysAgo == -1 ? new Date(0) : getDaysAgoDate(daysAgo);
const foundScores = await ScoreSaberScoreModel.aggregate([ const foundScores = await ScoreSaberScoreModel.aggregate([
{ $match: { timestamp: { $gte: date } } }, // Start sorting by timestamp descending using the new compound index
{ $sort: { timestamp: -1 } }, { $sort: { leaderboardId: 1, playerId: 1, timestamp: -1 } },
{ {
$group: { $group: {
_id: { leaderboardId: "$leaderboardId", playerId: "$playerId" }, _id: { leaderboardId: "$leaderboardId", playerId: "$playerId" },
score: { $first: "$$ROOT" }, latestScore: { $first: "$$ROOT" }, // Retrieve the latest score per group
}, },
}, },
{ $sort: { "score.pp": -1 } }, // Sort by pp of the latest scores in descending order
{ $sort: { "latestScore.pp": -1 } },
{ $limit: amount }, { $limit: amount },
]); ]);
const scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[] = []; // Collect unique leaderboard IDs
for (const { score: scoreData } of foundScores) { const leaderboardIds = [...new Set(foundScores.map(s => s.latestScore.leaderboardId))];
const score = new ScoreSaberScoreModel(scoreData).toObject() as ScoreSaberScore; const leaderboardMap = await this.fetchLeaderboardsInBatch(leaderboardIds);
const leaderboard = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
"scoresaber", // Collect player IDs for batch retrieval
score.leaderboardId + "" const playerIds = foundScores.map(result => result.latestScore.playerId);
); const players = await PlayerModel.find({ _id: { $in: playerIds } }).exec();
if (!leaderboard) { const playerMap = new Map(players.map(player => [player._id.toString(), player]));
continue;
// Prepare to fetch additional data concurrently
const scoreDataPromises = foundScores.map(async result => {
const score: ScoreSaberScore = result.latestScore;
const leaderboardResponse = leaderboardMap[score.leaderboardId];
if (!leaderboardResponse) {
return null; // Skip if leaderboard data is not available
} }
try {
const player = await PlayerService.getPlayer(score.playerId); const { leaderboard, beatsaver } = leaderboardResponse;
if (player !== undefined) {
score.playerInfo = { // Fetch additional data concurrently
id: player.id, const [additionalData, previousScore] = await Promise.all([
name: player.name, this.getAdditionalScoreData(
}; score.playerId,
} leaderboard.songHash,
} catch { `${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
),
this.getPreviousScore(score.playerId, leaderboard.id + "", score.timestamp),
]);
// Attach additional and previous score data if available
if (additionalData) score.additionalData = additionalData;
if (previousScore) score.previousScore = previousScore;
// Attach player info if available
const player = playerMap.get(score.playerId.toString());
if (player) {
score.playerInfo = { score.playerInfo = {
id: score.playerId, id: player._id,
name: player.name,
}; };
} }
scores.push({ return {
score: score, score: score as ScoreSaberScore,
leaderboard: leaderboard.leaderboard, leaderboard: leaderboard,
beatSaver: leaderboard.beatsaver, beatSaver: beatsaver,
}); };
} });
console.log(`Got ${scores.length} scores in ${Date.now() - before}ms (timeframe: ${timeframe}, limit: ${amount})`); return (await Promise.all(scoreDataPromises)).filter(score => score !== null);
return scores; }
/**
* Fetches leaderboards in a batch.
*
* @param leaderboardIds the ids of the leaderboards
* @returns the fetched leaderboards
* @private
*/
private static async fetchLeaderboardsInBatch(leaderboardIds: string[]) {
// Remove duplicates from leaderboardIds
const uniqueLeaderboardIds = Array.from(new Set(leaderboardIds));
const leaderboardResponses = await Promise.all(
uniqueLeaderboardIds.map(id => LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>("scoresaber", id))
);
return leaderboardResponses.reduce(
(map, response) => {
if (response) map[response.leaderboard.id] = response;
return map;
},
{} as Record<string, { leaderboard: ScoreSaberLeaderboard; beatsaver?: BeatSaverMap }>
);
} }
/** /**
@ -463,6 +488,8 @@ export class ScoreService {
const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId); const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId);
if (!score) return undefined; if (!score) return undefined;
console.log("boobs");
// Fetch additional data, previous score, and BeatSaver map concurrently // Fetch additional data, previous score, and BeatSaver map concurrently
const [additionalData, previousScore, beatSaverMap] = await Promise.all([ const [additionalData, previousScore, beatSaverMap] = await Promise.all([
this.getAdditionalScoreData( this.getAdditionalScoreData(

@ -1,4 +1,4 @@
import { getModelForClass, modelOptions, plugin, Prop, ReturnModelType, Severity } from "@typegoose/typegoose"; import { getModelForClass, index, modelOptions, plugin, Prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import Score from "../score"; import Score from "../score";
import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token"; import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
import { Document } from "mongoose"; import { Document } from "mongoose";
@ -20,6 +20,7 @@ import { PreviousScore } from "../previous-score";
}, },
}, },
}) })
@index({ leaderboardId: 1, playerId: 1, timestamp: -1 }) // Compound index for optimized queries
@plugin(AutoIncrementID, { @plugin(AutoIncrementID, {
field: "_id", field: "_id",
startAt: 1, startAt: 1,

@ -1,5 +1,5 @@
import ScoreSaberPlayer from "./impl/scoresaber-player"; import ScoreSaberPlayer from "./impl/scoresaber-player";
import { Timeframe } from "../timeframe"; import { ChangeRange } from "./player";
export type PlayerStatValue = { export type PlayerStatValue = {
/** /**
@ -10,7 +10,7 @@ export type PlayerStatValue = {
/** /**
* The value of the stat. * The value of the stat.
*/ */
value: (player: ScoreSaberPlayer, range: Timeframe) => number | undefined; value: (player: ScoreSaberPlayer, range: ChangeRange) => number | undefined;
}; };
export type PlayerStatChangeType = export type PlayerStatChangeType =

@ -1,5 +1,4 @@
import { PlayerHistory } from "./player-history"; import { PlayerHistory } from "./player-history";
import { Timeframe } from "../timeframe";
export default class Player { export default class Player {
/** /**
@ -56,6 +55,7 @@ export default class Player {
} }
} }
export type ChangeRange = "daily" | "weekly" | "monthly";
export type StatisticChange = { export type StatisticChange = {
[key in Timeframe]: PlayerHistory; [key in ChangeRange]: PlayerHistory;
}; };

@ -1,21 +1,10 @@
import { ScoreSaberLeaderboard } from "src/model/leaderboard/impl/scoresaber-leaderboard"; import { ScoreSaberLeaderboard } from "src/model/leaderboard/impl/scoresaber-leaderboard";
import { ScoreSaberScore } from "../model/score/impl/scoresaber-score"; import { ScoreSaberScore } from "../model/score/impl/scoresaber-score";
import { PlayerScore } from "../score/player-score"; import { PlayerScore } from "../score/player-score";
import { Timeframe } from "../timeframe";
export type TopScoresResponse = { export type TopScoresResponse = {
/** /**
* The top scores. * The top scores.
*/ */
scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[]; scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[];
/**
* The timeframe returned.
*/
timeframe: Timeframe;
/**
* The amount of scores to fetch.
*/
limit: number;
}; };

@ -1 +0,0 @@
export type Timeframe = "daily" | "weekly" | "monthly" | "all";

@ -1,23 +0,0 @@
import { Metadata } from "next";
import { Timeframe } from "@ssr/common/timeframe";
import { TopScoresData } from "@/components/score/top/top-scores-data";
export const metadata: Metadata = {
title: "Top Scores",
openGraph: {
title: "ScoreSaber Reloaded - Top Scores",
description: "View the top 50 scores set by players on ScoreSaber.",
},
};
type TopScoresPageProps = {
params: Promise<{
timeframe: Timeframe;
}>;
};
export default async function TopScoresPage({ params }: TopScoresPageProps) {
const { timeframe } = await params;
return <TopScoresData timeframe={timeframe} />;
}

@ -0,0 +1,59 @@
import { Metadata } from "next";
import Card from "@/components/card";
import { kyFetch } from "@ssr/common/utils/utils";
import { Config } from "@ssr/common/config";
import { TopScoresResponse } from "@ssr/common/response/top-scores-response";
import Score from "@/components/score/score";
import Link from "next/link";
export const metadata: Metadata = {
title: "Top Scores",
openGraph: {
title: "ScoreSaber Reloaded - Top Scores",
description: "View the top 100 scores set by players on ScoreSaber.",
},
};
export default async function TopScoresPage() {
const scores = await kyFetch<TopScoresResponse>(`${Config.apiUrl}/scores/top`);
return (
<Card className="flex flex-col gap-2 w-full xl:w-[75%]">
<div>
<p className="font-semibold'">Top 100 ScoreSaber Scores</p>
<p className="text-gray-400">This will only show scores that have been tracked.</p>
</div>
{!scores ? (
<p>No scores found</p>
) : (
<div className="flex flex-col gap-2 divide-y divide-border">
{scores.scores.map(({ score, leaderboard, beatSaver }, index) => {
const player = score.playerInfo;
const name = score.playerInfo ? player.name || player.id : score.playerId;
return (
<div key={index} className="flex flex-col pt-2">
<p className="text-sm">
Set by{" "}
<Link href={`/player/${player.id}`}>
<span className="text-ssr hover:brightness-[66%] transition-all transform-gpu">{name}</span>
</Link>
</p>
<Score
score={score}
leaderboard={leaderboard}
beatSaverMap={beatSaver}
settings={{
hideLeaderboardDropdown: true,
hideAccuracyChanger: true,
}}
/>
</div>
);
})}
</div>
)}
</Card>
);
}

@ -44,7 +44,7 @@ const items: NavbarItem[] = [
}, },
{ {
name: "Top Scores", name: "Top Scores",
link: "/scores/top/weekly", link: "/scores/top",
openInNewTab: false, openInNewTab: false,
}, },
]; ];

@ -1,6 +0,0 @@
import { ArrowPathIcon } from "@heroicons/react/24/solid";
import * as React from "react";
export function LoadingIcon() {
return <ArrowPathIcon className="w-5 h-5 animate-spin" />;
}

@ -1,125 +0,0 @@
"use client";
import Card from "@/components/card";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import Score from "@/components/score/score";
import { useEffect, useState } from "react";
import { Timeframe } from "@ssr/common/timeframe";
import { TopScoresResponse } from "@ssr/common/response/top-scores-response";
import { Config } from "@ssr/common/config";
import { kyFetch } from "@ssr/common/utils/utils";
import { useQuery } from "@tanstack/react-query";
import { LoadingIcon } from "@/components/loading-icon";
import { capitalizeFirstLetter } from "@/common/string-utils";
type TimeframesType = {
timeframe: Timeframe;
display: string;
};
const timeframes: TimeframesType[] = [
{
timeframe: "daily",
display: "Today",
},
{
timeframe: "weekly",
display: "This Week",
},
{
timeframe: "monthly",
display: "This Month",
},
{
timeframe: "all",
display: "All Time",
},
];
type TopScoresDataProps = {
timeframe: Timeframe;
};
export function TopScoresData({ timeframe }: TopScoresDataProps) {
const [selectedTimeframe, setSelectedTimeframe] = useState<Timeframe>(timeframe);
const [scores, setScores] = useState<TopScoresResponse | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["top-scores", selectedTimeframe],
queryFn: async () => {
return kyFetch<TopScoresResponse>(`${Config.apiUrl}/scores/top?limit=50&timeframe=${selectedTimeframe}`);
},
});
useEffect(() => {
// Update the URL
window.history.replaceState(null, "", `/scores/top/${selectedTimeframe}`);
}, [selectedTimeframe]);
useEffect(() => {
if (data) {
setScores(data);
}
}, [data]);
return (
<Card className="flex flex-col gap-2 w-full xl:w-[75%] justify-center">
<div className="flex flex-row flex-wrap gap-2 justify-center">
{timeframes.map((timeframe, index) => {
return (
<Button
key={index}
className="w-32"
variant={selectedTimeframe === timeframe.timeframe ? "default" : "outline"}
onClick={() => {
setScores(null);
setSelectedTimeframe(timeframe.timeframe);
}}
>
{timeframe.display}
</Button>
);
})}
</div>
<div className="flex justify-center flex-col text-center">
<p className="font-semibold'">Top 50 ScoreSaber Scores ({capitalizeFirstLetter(selectedTimeframe)})</p>
<p className="text-gray-400">This will only show scores that have been tracked.</p>
</div>
{(isLoading || !scores) && (
<div className="flex justify-center items-center">
<LoadingIcon />
</div>
)}
{scores && !isLoading && (
<div className="flex flex-col gap-2 divide-y divide-border">
{scores.scores.map(({ score, leaderboard, beatSaver }, index) => {
const player = score.playerInfo;
const name = score.playerInfo ? player.name || player.id : score.playerId;
return (
<div key={index} className="flex flex-col pt-2">
<p className="text-sm">
Set by{" "}
<Link href={`/player/${player.id}`}>
<span className="text-ssr hover:brightness-[66%] transition-all transform-gpu">{name}</span>
</Link>
</p>
<Score
score={score}
leaderboard={leaderboard}
beatSaverMap={beatSaver}
settings={{
hideLeaderboardDropdown: true,
hideAccuracyChanger: true,
}}
/>
</div>
);
})}
</div>
)}
</Card>
);
}

@ -3,8 +3,8 @@ import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils
import { capitalizeFirstLetter } from "@/common/string-utils"; import { capitalizeFirstLetter } from "@/common/string-utils";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import { ReactElement } from "react"; import { ReactElement } from "react";
import { ChangeRange } from "@ssr/common/player/player";
import { PlayerStatValue } from "@ssr/common/player/player-stat-change"; import { PlayerStatValue } from "@ssr/common/player/player-stat-change";
import { Timeframe } from "@ssr/common/timeframe";
type ChangeOverTimeProps = { type ChangeOverTimeProps = {
/** /**
@ -40,7 +40,7 @@ export function ChangeOverTime({ player, type, children }: ChangeOverTimeProps)
}; };
// Renders the change for a given time frame // Renders the change for a given time frame
const renderChange = (value: number | undefined, range: Timeframe) => ( const renderChange = (value: number | undefined, range: ChangeRange) => (
<p> <p>
{capitalizeFirstLetter(range)} Change:{" "} {capitalizeFirstLetter(range)} Change:{" "}
<span className={value === undefined ? "" : value >= 0 ? (value === 0 ? "" : "text-green-500") : "text-red-500"}> <span className={value === undefined ? "" : value >= 0 ? (value === 0 ? "" : "text-green-500") : "text-red-500"}>