move score page fetching to the backend
Some checks failed
Deploy Backend / deploy (push) Successful in 2m26s
Deploy Website / deploy (push) Failing after 1m52s

This commit is contained in:
Lee
2024-10-17 15:30:14 +01:00
parent 118dc9d9f1
commit b3c124631a
78 changed files with 1150 additions and 494 deletions

View File

@ -0,0 +1,26 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard";
import LeaderboardService from "../service/leaderboard.service";
@Controller("/leaderboard")
export default class LeaderboardController {
@Get("/:leaderboard/:id", {
config: {},
params: t.Object({
id: t.String({ required: true }),
leaderboard: t.String({ required: true }),
}),
})
public async getLeaderboard({
params: { leaderboard, id },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
};
}): Promise<unknown> {
return await LeaderboardService.getLeaderboard(leaderboard, id);
}
}

View File

@ -1,8 +1,8 @@
import { Controller, Get } from "elysia-decorators";
import { PlayerService } from "../service/player.service";
import { t } from "elysia";
import { PlayerHistory } from "@ssr/common/types/player/player-history";
import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since";
import { PlayerHistory } from "@ssr/common/player/player-history";
import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since";
@Controller("/player")
export default class PlayerController {

View File

@ -0,0 +1,55 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard";
import { ScoreService } from "../service/score.service";
@Controller("/scores")
export default class ScoresController {
@Get("/player/:leaderboard/:id/:page/:sort", {
config: {},
params: t.Object({
leaderboard: t.String({ required: true }),
id: t.String({ required: true }),
page: t.Number({ required: true }),
sort: t.String({ required: true }),
}),
query: t.Object({
search: t.Optional(t.String()),
}),
})
public async getScores({
params: { leaderboard, id, page, sort },
query: { search },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
sort: string;
};
query: { search?: string };
}): Promise<unknown> {
return await ScoreService.getPlayerScores(leaderboard, id, page, sort, search);
}
@Get("/leaderboard/:leaderboard/:id/:page", {
config: {},
params: t.Object({
leaderboard: t.String({ required: true }),
id: t.String({ required: true }),
page: t.Number({ required: true }),
}),
})
public async getLeaderboardScores({
params: { leaderboard, id, page },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
};
query: { search?: string };
}): Promise<unknown> {
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
}
}

View File

@ -14,7 +14,6 @@ import { setLogLevel } from "@typegoose/typegoose";
import PlayerController from "./controller/player.controller";
import { PlayerService } from "./service/player.service";
import { cron } from "@elysiajs/cron";
import { PlayerDocument, PlayerModel } from "./model/player";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { delay } from "@ssr/common/utils/utils";
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
@ -22,6 +21,9 @@ import ImageController from "./controller/image.controller";
import ReplayController from "./controller/replay.controller";
import { ScoreService } from "./service/score.service";
import { Config } from "@ssr/common/config";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import ScoresController from "./controller/scores.controller";
import LeaderboardController from "./controller/leaderboard.controller";
// Load .env file
dotenv.config({
@ -159,7 +161,14 @@ app.use(
*/
app.use(
decorators({
controllers: [AppController, PlayerController, ImageController, ReplayController],
controllers: [
AppController,
PlayerController,
ImageController,
ReplayController,
ScoresController,
LeaderboardController,
],
})
);

View File

@ -1,116 +0,0 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import { PlayerHistory } from "@ssr/common/types/player/player-history";
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
/**
* The model for a player.
*/
@modelOptions({ options: { allowMixed: Severity.ALLOW } })
export class Player {
/**
* The id of the player.
*/
@prop()
public _id!: string;
/**
* The player's statistic history.
*/
@prop()
private statisticHistory?: Record<string, PlayerHistory>;
/**
* The date the player was last tracked.
*/
@prop()
public lastTracked?: Date;
/**
* The date the player was first tracked.
*/
@prop()
public trackedSince?: Date;
/**
* Gets the player's statistic history.
*/
public getStatisticHistory(): Record<string, PlayerHistory> {
if (this.statisticHistory === undefined) {
this.statisticHistory = {};
}
return this.statisticHistory;
}
/**
* Gets the player's history for a specific date.
*
* @param date the date to get the history for.
*/
public getHistoryByDate(date: Date): PlayerHistory {
if (this.statisticHistory === undefined) {
this.statisticHistory = {};
}
return this.getStatisticHistory()[formatDateMinimal(getMidnightAlignedDate(date))] || {};
}
/**
* Gets the player's history for the previous X days.
*
* @param days the number of days to get the history for.
*/
public getHistoryPreviousDays(days: number): Record<string, PlayerHistory> {
const statisticHistory = this.getStatisticHistory();
const history: Record<string, PlayerHistory> = {};
for (let i = 0; i < days; i++) {
const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i)));
const playerHistory = statisticHistory[date];
if (playerHistory !== undefined && Object.keys(playerHistory).length > 0) {
history[date] = playerHistory;
}
}
return history;
}
/**
* Sets the player's statistic history.
*
* @param date the date to set it for.
* @param history the history to set.
*/
public setStatisticHistory(date: Date, history: PlayerHistory) {
if (this.statisticHistory === undefined) {
this.statisticHistory = {};
}
this.statisticHistory[formatDateMinimal(getMidnightAlignedDate(date))] = history;
}
/**
* Sorts the player's statistic history by
* date in descending order. (oldest to newest)
*/
public sortStatisticHistory() {
if (this.statisticHistory === undefined) {
this.statisticHistory = {};
}
this.statisticHistory = Object.fromEntries(
Object.entries(this.statisticHistory).sort((a, b) => new Date(b[0]).getTime() - new Date(a[0]).getTime())
);
}
/**
* Gets the number of days tracked.
*
* @returns the number of days tracked.
*/
public getDaysTracked(): number {
return Object.keys(this.getStatisticHistory()).length;
}
}
// This type defines a Mongoose document based on Player.
export type PlayerDocument = Player & Document;
// This type ensures that PlayerModel returns Mongoose documents (PlayerDocument) that have Mongoose methods (save, remove, etc.)
export const PlayerModel: ReturnModelType<typeof Player> = getModelForClass(Player);

View File

@ -1,4 +1,4 @@
import { PlayerModel } from "../model/player";
import { PlayerModel } from "@ssr/common/model/player";
import { AppStatistics } from "@ssr/common/types/backend/app-statistics";
export class AppService {

View File

@ -0,0 +1,30 @@
import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map";
export default class BeatSaverService {
/**
* Gets a map by its hash.
*
* @param hash the hash of the map
* @returns the beatsaver map
*/
public static async getMap(hash: string): Promise<BeatSaverMap | undefined> {
let map = await BeatSaverMapModel.findById(hash);
if (map != undefined) {
return map.toObject() as BeatSaverMap;
}
const token = await beatsaverService.lookupMap(hash);
if (token == undefined) {
return undefined;
}
map = await BeatSaverMapModel.create({
_id: hash,
bsr: token.id,
author: {
id: token.uploader.id,
},
});
return map.toObject() as BeatSaverMap;
}
}

View File

@ -7,7 +7,7 @@ import { StarIcon } from "../../components/star-icon";
import { GlobeIcon } from "../../components/globe-icon";
import NodeCache from "node-cache";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
import { Jimp } from "jimp";
import { extractColors } from "extract-colors";
import { Config } from "@ssr/common/config";

View File

@ -0,0 +1,78 @@
import { Leaderboards } from "@ssr/common/leaderboard";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { SSRCache } from "@ssr/common/cache";
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { NotFoundError } from "elysia";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import BeatSaverService from "./beatsaver.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
const leaderboardCache = new SSRCache({
ttl: 1000 * 60 * 60 * 24,
});
export default class LeaderboardService {
/**
* Gets the leaderboard.
*
* @param leaderboard the leaderboard
* @param id the id
*/
private static async getLeaderboardToken<T>(leaderboard: Leaderboards, id: string): Promise<T | undefined> {
const cacheKey = `${leaderboard}-${id}`;
if (leaderboardCache.has(cacheKey)) {
return leaderboardCache.get(cacheKey) as T;
}
switch (leaderboard) {
case "scoresaber": {
const leaderboard = (await scoresaberService.lookupLeaderboard(id)) as T;
leaderboardCache.set(cacheKey, leaderboard);
return leaderboard;
}
default: {
return undefined;
}
}
}
/**
* Gets a leaderboard.
*
* @param leaderboardName the leaderboard to get
* @param id the players id
* @returns the scores
*/
public static async getLeaderboard(
leaderboardName: Leaderboards,
id: string
): Promise<LeaderboardResponse<Leaderboard>> {
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
switch (leaderboardName) {
case "scoresaber": {
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
leaderboardName,
id
);
if (leaderboardToken == undefined) {
throw new NotFoundError(`Leaderboard not found for "${id}"`);
}
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
return {
leaderboard: leaderboard,
beatsaver: beatSaverMap,
};
}
}

View File

@ -1,4 +1,4 @@
import { PlayerDocument, PlayerModel } from "../model/player";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import { NotFoundError } from "../error/not-found-error";
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";

View File

@ -5,6 +5,21 @@ import { MessageBuilder, Webhook } from "discord-webhook-node";
import { formatPp } from "@ssr/common/utils/number-utils";
import { isProduction } from "@ssr/common/utils/utils";
import { Config } from "@ssr/common/config";
import { Metadata } from "@ssr/common/types/metadata";
import { NotFoundError } from "elysia";
import BeatSaverService from "./beatsaver.service";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { getScoreSaberScoreFromToken } from "@ssr/common/score/impl/scoresaber-score";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { ScoreSort } from "@ssr/common/score/score-sort";
import { Leaderboards } from "@ssr/common/leaderboard";
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
import LeaderboardService from "./leaderboard.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { PlayerScore } from "@ssr/common/score/player-score";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import Score from "@ssr/common/score/score";
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
export class ScoreService {
/**
@ -45,4 +60,137 @@ export class ScoreService {
embed.setColor("#00ff00");
await hook.send(embed);
}
/**
* Gets scores for a player.
*
* @param leaderboardName the leaderboard to get the scores from
* @param id the players id
* @param page the page to get
* @param sort the sort to use
* @param search the search to use
* @returns the scores
*/
public static async getPlayerScores(
leaderboardName: Leaderboards,
id: string,
page: number,
sort: string,
search?: string
): Promise<PlayerScoresResponse<unknown, unknown>> {
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
let beatSaverMap: BeatSaverMap | undefined;
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) {
case "scoresaber": {
const leaderboardScores = await scoresaberService.lookupPlayerScores({
playerId: id,
page: page,
sort: sort as ScoreSort,
search: search,
});
if (leaderboardScores == undefined) {
throw new NotFoundError(
`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}", sort "${sort}", search "${search}"`
);
}
for (const token of leaderboardScores.playerScores) {
const score = getScoreSaberScoreFromToken(token.score);
if (score == undefined) {
continue;
}
const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (tokenLeaderboard == undefined) {
continue;
}
beatSaverMap = await BeatSaverService.getMap(tokenLeaderboard.songHash);
scores.push({
score: score,
leaderboard: tokenLeaderboard,
beatSaver: beatSaverMap,
});
}
metadata = new Metadata(
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
leaderboardScores.metadata.total,
leaderboardScores.metadata.page,
leaderboardScores.metadata.itemsPerPage
);
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
return {
scores: scores,
metadata: metadata,
};
}
/**
* Gets scores for a leaderboard.
*
* @param leaderboardName the leaderboard to get the scores from
* @param id the leaderboard id
* @param page the page to get
* @returns the scores
*/
public static async getLeaderboardScores(
leaderboardName: Leaderboards,
id: string,
page: number
): Promise<LeaderboardScoresResponse<unknown>> {
const scores: Score[] = [];
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) {
case "scoresaber": {
const leaderboardScores = await scoresaberService.lookupLeaderboardScores(id, page);
if (leaderboardScores == undefined) {
throw new NotFoundError(`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}""`);
}
const leaderboardResponse = await LeaderboardService.getLeaderboard(leaderboardName, id);
if (leaderboardResponse == undefined) {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
leaderboard = leaderboardResponse.leaderboard;
beatSaverMap = leaderboardResponse.beatsaver;
for (const token of leaderboardScores.scores) {
const score = getScoreSaberScoreFromToken(token);
if (score == undefined) {
continue;
}
scores.push(score);
}
metadata = new Metadata(
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
leaderboardScores.metadata.total,
leaderboardScores.metadata.page,
leaderboardScores.metadata.itemsPerPage
);
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
return {
scores: scores,
leaderboard: leaderboard,
beatSaver: beatSaverMap,
metadata: metadata,
};
}
}

View File

@ -10,6 +10,6 @@
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "react"
}
"jsx": "react",
},
}