diff --git a/projects/backend/src/service/app.service.ts b/projects/backend/src/service/app.service.ts index 1790f9f..83387f4 100644 --- a/projects/backend/src/service/app.service.ts +++ b/projects/backend/src/service/app.service.ts @@ -3,6 +3,7 @@ import { AppStatistics } from "@ssr/common/types/backend/app-statistics"; import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score"; import { AdditionalScoreDataModel } from "@ssr/common/model/additional-score-data/additional-score-data"; import { BeatSaverMapModel } from "@ssr/common/model/beatsaver/map"; +import { ScoreSaberLeaderboardModel } from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard"; export class AppService { /** @@ -13,12 +14,14 @@ export class AppService { const trackedScores = await ScoreSaberScoreModel.countDocuments(); const additionalScoresData = await AdditionalScoreDataModel.countDocuments(); const cachedBeatSaverMaps = await BeatSaverMapModel.countDocuments(); + const cachedScoreSaberLeaderboards = await ScoreSaberLeaderboardModel.countDocuments(); return { trackedPlayers, trackedScores, additionalScoresData, cachedBeatSaverMaps, + cachedScoreSaberLeaderboards, }; } } diff --git a/projects/backend/src/service/leaderboard.service.ts b/projects/backend/src/service/leaderboard.service.ts index af2ab23..9456fac 100644 --- a/projects/backend/src/service/leaderboard.service.ts +++ b/projects/backend/src/service/leaderboard.service.ts @@ -1,17 +1,15 @@ 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 BeatSaverService from "./beatsaver.service"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map"; import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators"; - -const leaderboardCache = new SSRCache({ - ttl: 1000 * 60 * 60 * 24, -}); +import ScoreSaberLeaderboard, { + ScoreSaberLeaderboardModel, +} from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard"; +import Leaderboard from "@ssr/common/model/leaderboard/leaderboard"; export default class LeaderboardService { /** @@ -21,16 +19,9 @@ export default class LeaderboardService { * @param id the id */ private static async getLeaderboardToken(leaderboard: Leaderboards, id: string): Promise { - 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; + return (await scoresaberService.lookupLeaderboard(id)) as T; } default: { return undefined; @@ -51,14 +42,37 @@ export default class LeaderboardService { switch (leaderboardName) { case "scoresaber": { - const leaderboardToken = await LeaderboardService.getLeaderboardToken( - leaderboardName, - id - ); - if (leaderboardToken == undefined) { + let foundLeaderboard = false; + const cachedLeaderboard = await ScoreSaberLeaderboardModel.findById(id); + if (cachedLeaderboard != null) { + leaderboard = cachedLeaderboard as unknown as ScoreSaberLeaderboard; + if (!leaderboard.shouldRefresh()) { + foundLeaderboard = true; + } + } + + if (!foundLeaderboard) { + const leaderboardToken = await LeaderboardService.getLeaderboardToken( + leaderboardName, + id + ); + if (leaderboardToken == undefined) { + throw new NotFoundError(`Leaderboard not found for "${id}"`); + } + + leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken); + leaderboard.lastRefreshed = new Date(); + + await ScoreSaberLeaderboardModel.findOneAndUpdate({ _id: id }, leaderboard, { + upsert: true, + new: true, + setDefaultsOnInsert: true, + }); + } + if (leaderboard == undefined) { throw new NotFoundError(`Leaderboard not found for "${id}"`); } - leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken); + beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash); break; } diff --git a/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts b/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts deleted file mode 100644 index 18400bf..0000000 --- a/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts +++ /dev/null @@ -1,29 +0,0 @@ -import Leaderboard from "../leaderboard"; -import { LeaderboardStatus } from "../leaderboard-status"; - -export default interface ScoreSaberLeaderboard extends Leaderboard { - /** - * The star count for the leaderboard. - */ - readonly stars: number; - - /** - * The total amount of plays. - */ - readonly plays: number; - - /** - * The amount of plays today. - */ - readonly dailyPlays: number; - - /** - * Whether this leaderboard is qualified to be ranked. - */ - readonly qualified: boolean; - - /** - * The status of the map. - */ - readonly status: LeaderboardStatus; -} diff --git a/projects/common/src/leaderboard/leaderboard-difficulty.ts b/projects/common/src/leaderboard/leaderboard-difficulty.ts deleted file mode 100644 index 316c5a3..0000000 --- a/projects/common/src/leaderboard/leaderboard-difficulty.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { MapDifficulty } from "../score/map-difficulty"; -import { MapCharacteristic } from "../types/map-characteristic"; - -export default interface LeaderboardDifficulty { - /** - * The id of the leaderboard. - */ - leaderboardId: number; - - /** - * The difficulty of the leaderboard. - */ - difficulty: MapDifficulty; - - /** - * The characteristic of the leaderboard. - */ - characteristic: MapCharacteristic; - - /** - * The raw difficulty of the leaderboard. - */ - difficultyRaw: string; -} diff --git a/projects/common/src/leaderboard/leaderboard.ts b/projects/common/src/leaderboard/leaderboard.ts deleted file mode 100644 index b2e4132..0000000 --- a/projects/common/src/leaderboard/leaderboard.ts +++ /dev/null @@ -1,75 +0,0 @@ -import LeaderboardDifficulty from "./leaderboard-difficulty"; - -export default interface Leaderboard { - /** - * The id of the leaderboard. - * @private - */ - readonly id: number; - - /** - * The hash of the song this leaderboard is for. - * @private - */ - readonly songHash: string; - - /** - * The name of the song this leaderboard is for. - * @private - */ - readonly songName: string; - - /** - * The sub name of the leaderboard. - * @private - */ - readonly songSubName: string; - - /** - * The author of the song this leaderboard is for. - * @private - */ - readonly songAuthorName: string; - - /** - * The author of the level this leaderboard is for. - * @private - */ - readonly levelAuthorName: string; - - /** - * The difficulty of the leaderboard. - * @private - */ - readonly difficulty: LeaderboardDifficulty; - - /** - * The difficulties of the leaderboard. - * @private - */ - readonly difficulties: LeaderboardDifficulty[]; - - /** - * The maximum score of the leaderboard. - * @private - */ - readonly maxScore: number; - - /** - * Whether the leaderboard is ranked. - * @private - */ - readonly ranked: boolean; - - /** - * The link to the song art. - * @private - */ - readonly songArt: string; - - /** - * The date the leaderboard was created. - * @private - */ - readonly timestamp: Date; -} diff --git a/projects/common/src/model/leaderboard/impl/scoresaber-leaderboard.ts b/projects/common/src/model/leaderboard/impl/scoresaber-leaderboard.ts new file mode 100644 index 0000000..df6a20d --- /dev/null +++ b/projects/common/src/model/leaderboard/impl/scoresaber-leaderboard.ts @@ -0,0 +1,55 @@ +import Leaderboard from "../leaderboard"; +import { type LeaderboardStatus } from "../leaderboard-status"; +import { getModelForClass, modelOptions, Prop, ReturnModelType, Severity } from "@typegoose/typegoose"; +import { Document } from "mongoose"; + +@modelOptions({ + options: { allowMixed: Severity.ALLOW }, + schemaOptions: { + collection: "scoresaber-leaderboards", + toObject: { + virtuals: true, + transform: function (_, ret) { + ret.id = ret._id; + delete ret._id; + delete ret.__v; + return ret; + }, + }, + }, +}) +export default class ScoreSaberLeaderboard extends Leaderboard { + /** + * The star count for the leaderboard. + */ + @Prop({ required: true }) + readonly stars!: number; + + /** + * The total amount of plays. + */ + @Prop({ required: true }) + readonly plays!: number; + + /** + * The amount of plays today. + */ + @Prop({ required: true }) + readonly dailyPlays!: number; + + /** + * Whether this leaderboard is qualified to be ranked. + */ + @Prop({ required: true }) + readonly qualified!: boolean; + + /** + * The status of the map. + */ + @Prop({ required: true }) + readonly status!: LeaderboardStatus; +} + +export type ScoreSaberLeaderboardDocument = ScoreSaberLeaderboard & Document; +export const ScoreSaberLeaderboardModel: ReturnModelType = + getModelForClass(ScoreSaberLeaderboard); diff --git a/projects/common/src/model/leaderboard/leaderboard-difficulty.ts b/projects/common/src/model/leaderboard/leaderboard-difficulty.ts new file mode 100644 index 0000000..26eae6a --- /dev/null +++ b/projects/common/src/model/leaderboard/leaderboard-difficulty.ts @@ -0,0 +1,29 @@ +import { type MapDifficulty } from "../../score/map-difficulty"; +import { type MapCharacteristic } from "../../types/map-characteristic"; +import { Prop } from "@typegoose/typegoose"; + +export default class LeaderboardDifficulty { + /** + * The id of the leaderboard. + */ + @Prop({ required: true }) + leaderboardId!: number; + + /** + * The difficulty of the leaderboard. + */ + @Prop({ required: true }) + difficulty!: MapDifficulty; + + /** + * The characteristic of the leaderboard. + */ + @Prop({ required: true }) + characteristic!: MapCharacteristic; + + /** + * The raw difficulty of the leaderboard. + */ + @Prop({ required: true }) + difficultyRaw!: string; +} diff --git a/projects/common/src/leaderboard/leaderboard-status.ts b/projects/common/src/model/leaderboard/leaderboard-status.ts similarity index 100% rename from projects/common/src/leaderboard/leaderboard-status.ts rename to projects/common/src/model/leaderboard/leaderboard-status.ts diff --git a/projects/common/src/model/leaderboard/leaderboard.ts b/projects/common/src/model/leaderboard/leaderboard.ts new file mode 100644 index 0000000..33d9087 --- /dev/null +++ b/projects/common/src/model/leaderboard/leaderboard.ts @@ -0,0 +1,112 @@ +import LeaderboardDifficulty from "./leaderboard-difficulty"; +import { Prop } from "@typegoose/typegoose"; + +export default class Leaderboard { + /** + * The id of the leaderboard. + * @private + */ + @Prop({ required: true }) + private readonly _id!: number; + + /** + * The hash of the song this leaderboard is for. + * @private + */ + @Prop({ required: true }) + readonly songHash!: string; + + /** + * The name of the song this leaderboard is for. + * @private + */ + @Prop({ required: true }) + readonly songName!: string; + + /** + * The sub name of the leaderboard. + * @private + */ + @Prop({ required: true }) + readonly songSubName!: string; + + /** + * The author of the song this leaderboard is for. + * @private + */ + @Prop({ required: true }) + readonly songAuthorName!: string; + + /** + * The author of the level this leaderboard is for. + * @private + */ + @Prop({ required: true }) + readonly levelAuthorName!: string; + + /** + * The difficulty of the leaderboard. + * @private + */ + @Prop({ required: true, _id: false, type: () => LeaderboardDifficulty }) + readonly difficulty!: LeaderboardDifficulty; + + /** + * The difficulties of the leaderboard. + * @private + */ + @Prop({ required: true, _id: false, type: () => [LeaderboardDifficulty] }) + readonly difficulties!: LeaderboardDifficulty[]; + + /** + * The maximum score of the leaderboard. + * @private + */ + @Prop({ required: true }) + readonly maxScore!: number; + + /** + * Whether the leaderboard is ranked. + * @private + */ + @Prop({ required: true }) + readonly ranked!: boolean; + + /** + * The link to the song art. + * @private + */ + @Prop({ required: true }) + readonly songArt!: string; + + /** + * The date the leaderboard was created. + * @private + */ + @Prop({ required: true }) + readonly timestamp!: Date; + + /** + * The date the leaderboard was last refreshed. + * @private + */ + @Prop({ required: true }) + lastRefreshed?: Date; + + /** + * Should the map data be refreshed? + * + * @returns true if the map data should be refreshed + */ + public shouldRefresh(): boolean { + if (!this.lastRefreshed) { + return true; + } + const now = new Date(); + return now.getTime() - this.lastRefreshed.getTime() > 1000 * 60 * 60 * 24; // 1 day + } + + get id(): number { + return this._id; + } +} diff --git a/projects/common/src/model/score/impl/scoresaber-score.ts b/projects/common/src/model/score/impl/scoresaber-score.ts index d3df69e..9b3418a 100644 --- a/projects/common/src/model/score/impl/scoresaber-score.ts +++ b/projects/common/src/model/score/impl/scoresaber-score.ts @@ -44,7 +44,7 @@ export class ScoreSaberScoreInternal extends Score { * @private */ @Prop({ required: true }) - public readonly pp!: number; + public pp!: number; /** * The weight of the score, or undefined if not ranked. diff --git a/projects/common/src/token-creators.ts b/projects/common/src/token-creators.ts index d94c75d..88be4b6 100644 --- a/projects/common/src/token-creators.ts +++ b/projects/common/src/token-creators.ts @@ -1,9 +1,9 @@ -import ScoreSaberLeaderboard from "./leaderboard/impl/scoresaber-leaderboard"; +import ScoreSaberLeaderboard from "./model/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboardToken from "./types/token/scoresaber/score-saber-leaderboard-token"; -import LeaderboardDifficulty from "./leaderboard/leaderboard-difficulty"; +import LeaderboardDifficulty from "./model/leaderboard/leaderboard-difficulty"; import { getDifficultyFromScoreSaberDifficulty } from "./utils/scoresaber-utils"; import { MapCharacteristic } from "./types/map-characteristic"; -import { LeaderboardStatus } from "./leaderboard/leaderboard-status"; +import { LeaderboardStatus } from "./model/leaderboard/leaderboard-status"; import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate, parseDate } from "./utils/time-utils"; import ScoreSaberPlayerToken from "./types/token/scoresaber/score-saber-player-token"; import ScoreSaberPlayer, { ScoreSaberBadge, ScoreSaberBio } from "./player/impl/scoresaber-player"; diff --git a/projects/common/src/types/backend/app-statistics.ts b/projects/common/src/types/backend/app-statistics.ts index 69ffafc..6c4163d 100644 --- a/projects/common/src/types/backend/app-statistics.ts +++ b/projects/common/src/types/backend/app-statistics.ts @@ -18,4 +18,9 @@ export type AppStatistics = { * The amount of cached BeatSaver maps. */ cachedBeatSaverMaps: number; + + /** + * The amount of cached ScoreSaber leaderboards. + */ + cachedScoreSaberLeaderboards: number; }; diff --git a/projects/common/src/utils/score-utils.ts b/projects/common/src/utils/score-utils.ts index 7059082..a29828c 100644 --- a/projects/common/src/utils/score-utils.ts +++ b/projects/common/src/utils/score-utils.ts @@ -7,7 +7,7 @@ import LeaderboardScoresResponse from "../response/leaderboard-scores-response"; import { Page } from "../pagination"; import { ScoreSaberScore } from "src/model/score/impl/scoresaber-score"; import { PlayerScore } from "../score/player-score"; -import ScoreSaberLeaderboard from "../leaderboard/impl/scoresaber-leaderboard"; +import ScoreSaberLeaderboard from "../model/leaderboard/impl/scoresaber-leaderboard"; /** * Fetches the player's scores diff --git a/projects/website/src/app/(pages)/page.tsx b/projects/website/src/app/(pages)/page.tsx index d271dc3..58d02bd 100644 --- a/projects/website/src/app/(pages)/page.tsx +++ b/projects/website/src/app/(pages)/page.tsx @@ -28,6 +28,7 @@ export default async function HomePage() { + )}