cache scoresaber leaderboards
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 32s
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
Lee 2024-10-26 13:13:32 +01:00
parent a8eb2372cb
commit fe888d9fb6
14 changed files with 244 additions and 153 deletions

@ -3,6 +3,7 @@ import { AppStatistics } from "@ssr/common/types/backend/app-statistics";
import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score"; import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score";
import { AdditionalScoreDataModel } from "@ssr/common/model/additional-score-data/additional-score-data"; import { AdditionalScoreDataModel } from "@ssr/common/model/additional-score-data/additional-score-data";
import { BeatSaverMapModel } from "@ssr/common/model/beatsaver/map"; import { BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
import { ScoreSaberLeaderboardModel } from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
export class AppService { export class AppService {
/** /**
@ -13,12 +14,14 @@ export class AppService {
const trackedScores = await ScoreSaberScoreModel.countDocuments(); const trackedScores = await ScoreSaberScoreModel.countDocuments();
const additionalScoresData = await AdditionalScoreDataModel.countDocuments(); const additionalScoresData = await AdditionalScoreDataModel.countDocuments();
const cachedBeatSaverMaps = await BeatSaverMapModel.countDocuments(); const cachedBeatSaverMaps = await BeatSaverMapModel.countDocuments();
const cachedScoreSaberLeaderboards = await ScoreSaberLeaderboardModel.countDocuments();
return { return {
trackedPlayers, trackedPlayers,
trackedScores, trackedScores,
additionalScoresData, additionalScoresData,
cachedBeatSaverMaps, cachedBeatSaverMaps,
cachedScoreSaberLeaderboards,
}; };
} }
} }

@ -1,17 +1,15 @@
import { Leaderboards } from "@ssr/common/leaderboard"; import { Leaderboards } from "@ssr/common/leaderboard";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { SSRCache } from "@ssr/common/cache";
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response"; 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 ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { NotFoundError } from "elysia"; import { NotFoundError } from "elysia";
import BeatSaverService from "./beatsaver.service"; import BeatSaverService from "./beatsaver.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators"; import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
import ScoreSaberLeaderboard, {
const leaderboardCache = new SSRCache({ ScoreSaberLeaderboardModel,
ttl: 1000 * 60 * 60 * 24, } from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
}); import Leaderboard from "@ssr/common/model/leaderboard/leaderboard";
export default class LeaderboardService { export default class LeaderboardService {
/** /**
@ -21,16 +19,9 @@ export default class LeaderboardService {
* @param id the id * @param id the id
*/ */
private static async getLeaderboardToken<T>(leaderboard: Leaderboards, id: string): Promise<T | undefined> { 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) { switch (leaderboard) {
case "scoresaber": { case "scoresaber": {
const leaderboard = (await scoresaberService.lookupLeaderboard(id)) as T; return (await scoresaberService.lookupLeaderboard(id)) as T;
leaderboardCache.set(cacheKey, leaderboard);
return leaderboard;
} }
default: { default: {
return undefined; return undefined;
@ -51,14 +42,37 @@ export default class LeaderboardService {
switch (leaderboardName) { switch (leaderboardName) {
case "scoresaber": { case "scoresaber": {
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>( let foundLeaderboard = false;
leaderboardName, const cachedLeaderboard = await ScoreSaberLeaderboardModel.findById(id);
id if (cachedLeaderboard != null) {
); leaderboard = cachedLeaderboard as unknown as ScoreSaberLeaderboard;
if (leaderboardToken == undefined) { if (!leaderboard.shouldRefresh()) {
foundLeaderboard = true;
}
}
if (!foundLeaderboard) {
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
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}"`); throw new NotFoundError(`Leaderboard not found for "${id}"`);
} }
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash); beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
break; break;
} }

@ -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;
}

@ -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;
}

@ -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;
}

@ -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<typeof ScoreSaberLeaderboard> =
getModelForClass(ScoreSaberLeaderboard);

@ -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;
}

@ -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;
}
}

@ -44,7 +44,7 @@ export class ScoreSaberScoreInternal extends Score {
* @private * @private
*/ */
@Prop({ required: true }) @Prop({ required: true })
public readonly pp!: number; public pp!: number;
/** /**
* The weight of the score, or undefined if not ranked. * The weight of the score, or undefined if not ranked.

@ -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 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 { getDifficultyFromScoreSaberDifficulty } from "./utils/scoresaber-utils";
import { MapCharacteristic } from "./types/map-characteristic"; 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 { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate, parseDate } from "./utils/time-utils";
import ScoreSaberPlayerToken from "./types/token/scoresaber/score-saber-player-token"; import ScoreSaberPlayerToken from "./types/token/scoresaber/score-saber-player-token";
import ScoreSaberPlayer, { ScoreSaberBadge, ScoreSaberBio } from "./player/impl/scoresaber-player"; import ScoreSaberPlayer, { ScoreSaberBadge, ScoreSaberBio } from "./player/impl/scoresaber-player";

@ -18,4 +18,9 @@ export type AppStatistics = {
* The amount of cached BeatSaver maps. * The amount of cached BeatSaver maps.
*/ */
cachedBeatSaverMaps: number; cachedBeatSaverMaps: number;
/**
* The amount of cached ScoreSaber leaderboards.
*/
cachedScoreSaberLeaderboards: number;
}; };

@ -7,7 +7,7 @@ import LeaderboardScoresResponse from "../response/leaderboard-scores-response";
import { Page } from "../pagination"; import { Page } from "../pagination";
import { ScoreSaberScore } from "src/model/score/impl/scoresaber-score"; import { ScoreSaberScore } from "src/model/score/impl/scoresaber-score";
import { PlayerScore } from "../score/player-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 * Fetches the player's scores

@ -28,6 +28,7 @@ export default async function HomePage() {
<Statistic title="Tracked Scores" value={statistics.trackedScores} /> <Statistic title="Tracked Scores" value={statistics.trackedScores} />
<Statistic title="Additional Scores Data" value={statistics.additionalScoresData} /> <Statistic title="Additional Scores Data" value={statistics.additionalScoresData} />
<Statistic title="Cached BeatSaver Maps" value={statistics.cachedBeatSaverMaps} /> <Statistic title="Cached BeatSaver Maps" value={statistics.cachedBeatSaverMaps} />
<Statistic title="Cached ScoreSaber Leaderboards" value={statistics.cachedScoreSaberLeaderboards} />
</div> </div>
)} )}