move score page fetching to the backend
This commit is contained in:
26
projects/backend/src/controller/leaderboard.controller.ts
Normal file
26
projects/backend/src/controller/leaderboard.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
55
projects/backend/src/controller/scores.controller.ts
Normal file
55
projects/backend/src/controller/scores.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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,
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -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);
|
@ -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 {
|
||||
|
30
projects/backend/src/service/beatsaver.service.ts
Normal file
30
projects/backend/src/service/beatsaver.service.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
78
projects/backend/src/service/leaderboard.service.ts
Normal file
78
projects/backend/src/service/leaderboard.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,6 @@
|
||||
"skipLibCheck": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"jsx": "react"
|
||||
}
|
||||
"jsx": "react",
|
||||
},
|
||||
}
|
||||
|
Reference in New Issue
Block a user