add map stats from beat saver
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m34s

This commit is contained in:
Lee 2024-10-23 15:33:25 +01:00
parent 62090b8054
commit 33b931b5f1
47 changed files with 835 additions and 289 deletions

@ -1,5 +1,5 @@
import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map";
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
export default class BeatSaverService {
/**
@ -12,28 +12,71 @@ export default class BeatSaverService {
let map = await BeatSaverMapModel.findById(hash);
if (map != undefined) {
const toObject = map.toObject() as BeatSaverMap;
if (toObject.unknownMap) {
if (toObject.notFound) {
return undefined;
}
return toObject;
}
const token = await beatsaverService.lookupMap(hash);
const uploader = token?.uploader;
const metadata = token?.metadata;
map = await BeatSaverMapModel.create(
token
? {
token && uploader && metadata
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
({
_id: hash,
bsr: token.id,
name: token.name,
description: token.description,
author: {
id: token.uploader.id,
id: uploader.id,
name: uploader.name,
avatar: uploader.avatar,
},
}
metadata: {
bpm: metadata.bpm,
duration: metadata.duration,
levelAuthorName: metadata.levelAuthorName,
songAuthorName: metadata.songAuthorName,
songName: metadata.songName,
songSubName: metadata.songSubName,
},
versions: token.versions.map(version => {
return {
hash: version.hash.toUpperCase(),
difficulties: version.diffs.map(diff => {
return {
njs: diff.njs,
offset: diff.offset,
notes: diff.notes,
bombs: diff.bombs,
obstacles: diff.obstacles,
nps: diff.nps,
characteristic: diff.characteristic,
difficulty: diff.difficulty,
events: diff.events,
chroma: diff.chroma,
mappingExtensions: diff.me,
noodleExtensions: diff.ne,
cinema: diff.cinema,
maxScore: diff.maxScore,
label: diff.label,
};
}),
createdAt: new Date(version.createdAt),
};
}),
lastRefreshed: new Date(),
} as BeatSaverMap)
: {
_id: hash,
unknownMap: true,
notFound: true,
}
);
if (map.unknownMap) {
if (map.notFound) {
return undefined;
}
return map.toObject() as BeatSaverMap;

@ -7,7 +7,7 @@ import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score
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";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
const leaderboardCache = new SSRCache({
ttl: 1000 * 60 * 60 * 24,

@ -13,7 +13,7 @@ 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 { BeatSaverMap } from "@ssr/common/model/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";
@ -188,7 +188,7 @@ export class ScoreService {
};
const difficulty = leaderboard.difficulty;
const difficultyKey = `${difficulty.difficultyName.replace("Plus", "+")}-${difficulty.modeName}`;
const difficultyKey = `${difficulty.difficultyName}-${difficulty.modeName}`;
const rawScoreImprovement = score.scoreImprovement;
const data = {
playerId: playerId,
@ -312,15 +312,15 @@ export class ScoreService {
if (score == undefined) {
continue;
}
const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (tokenLeaderboard == undefined) {
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (leaderboard == undefined) {
continue;
}
const additionalData = await this.getAdditionalScoreData(
id,
tokenLeaderboard.songHash,
`${tokenLeaderboard.difficulty.difficulty}-${tokenLeaderboard.difficulty.gameMode}`,
leaderboard.songHash,
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
);
if (additionalData !== undefined) {
@ -329,8 +329,8 @@ export class ScoreService {
scores.push({
score: score,
leaderboard: tokenLeaderboard,
beatSaver: await BeatSaverService.getMap(tokenLeaderboard.songHash),
leaderboard: leaderboard,
beatSaver: await BeatSaverService.getMap(leaderboard.songHash),
});
}
break;

@ -4,6 +4,7 @@ import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber
import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils";
import { parseDate } from "../../utils/time-utils";
import { LeaderboardStatus } from "../leaderboard-status";
import { MapCharacteristic } from "../../types/map-characteristic";
export default interface ScoreSaberLeaderboard extends Leaderboard {
/**
@ -41,7 +42,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
const difficulty: LeaderboardDifficulty = {
leaderboardId: token.difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
gameMode: token.difficulty.gameMode.replace("Solo", ""),
characteristic: token.difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
difficultyRaw: token.difficulty.difficultyRaw,
};
@ -66,7 +67,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
return {
leaderboardId: difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
gameMode: difficulty.gameMode.replace("Solo", ""),
characteristic: difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
difficultyRaw: difficulty.difficultyRaw,
};
})

@ -1,4 +1,5 @@
import { Difficulty } from "../score/difficulty";
import { MapDifficulty } from "../score/map-difficulty";
import { MapCharacteristic } from "../types/map-characteristic";
export default interface LeaderboardDifficulty {
/**
@ -9,12 +10,12 @@ export default interface LeaderboardDifficulty {
/**
* The difficulty of the leaderboard.
*/
difficulty: Difficulty;
difficulty: MapDifficulty;
/**
* The game mode of the leaderboard.
* The characteristic of the leaderboard.
*/
gameMode: string;
characteristic: MapCharacteristic;
/**
* The raw difficulty of the leaderboard.

@ -0,0 +1,27 @@
import { prop } from "@typegoose/typegoose";
export default class BeatSaverAuthor {
/**
* The id of the author.
*/
@prop({ required: true })
id: number;
/**
* The name of the mapper.
*/
@prop({ required: true })
name: string;
/**
* The avatar URL for the mapper.
*/
@prop({ required: true })
avatar: string;
constructor(id: number, name: string, avatar: string) {
this.id = id;
this.name = name;
this.avatar = avatar;
}
}

@ -1,13 +0,0 @@
import { prop } from "@typegoose/typegoose";
export default class BeatsaverAuthor {
/**
* The id of the author.
*/
@prop({ required: true })
id: number;
constructor(id: number) {
this.id = id;
}
}

@ -1,57 +0,0 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import BeatsaverAuthor from "./beatsaver-author";
/**
* The model for a BeatSaver map.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
export class BeatSaverMap {
/**
* The internal MongoDB ID (_id).
*/
@prop({ required: true })
private _id!: string;
/**
* The bsr code for the map.
* @private
*/
@prop({ required: false })
public bsr!: string;
/**
* The author of the map.
*/
@prop({ required: false, _id: false, type: () => BeatsaverAuthor })
public author!: BeatsaverAuthor;
/**
* True if the map is unknown on beatsaver.
*/
@prop({ required: false })
public unknownMap?: boolean;
/**
* Exposes `id` as a virtual field mapped from `_id`.
*/
public get id(): string {
return this._id;
}
}
export type BeatSaverMapDocument = BeatSaverMap & Document;
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);

@ -0,0 +1,128 @@
import { prop } from "@typegoose/typegoose";
import { MapDifficulty } from "../../score/map-difficulty";
export default class BeatSaverMapDifficulty {
/**
* The NJS of this difficulty.
*/
@prop({ required: true })
njs: number;
/**
* The NJS offset of this difficulty.
*/
@prop({ required: true })
offset: number;
/**
* The amount of notes in this difficulty.
*/
@prop({ required: true })
notes: number;
/**
* The amount of bombs in this difficulty.
*/
@prop({ required: true })
bombs: number;
/**
* The amount of obstacles in this difficulty.
*/
@prop({ required: true })
obstacles: number;
/**
* The notes per second in this difficulty.
*/
@prop({ required: true })
nps: number;
/**
* The characteristic of this difficulty.
*/
@prop({ required: true, enum: ["Standard", "Lawless"] })
characteristic: "Standard" | "Lawless";
/**
* The difficulty level.
*/
@prop({ required: true })
difficulty: MapDifficulty;
/**
* The amount of lighting events in this difficulty.
*/
@prop({ required: true })
events: number;
/**
* Whether this difficulty uses Chroma.
*/
@prop({ required: true, default: false })
chroma: boolean;
/**
* Does this difficulty use Mapping Extensions.
*/
@prop({ required: true, default: false })
mappingExtensions: boolean;
/**
* Does this difficulty use Noodle Extensions.
*/
@prop({ required: true, default: false })
noodleExtensions: boolean;
/**
* Whether this difficulty uses cinema mode.
*/
@prop({ required: true, default: false })
cinema: boolean;
/**
* The maximum score achievable in this difficulty.
*/
@prop({ required: true })
maxScore: number;
/**
* The custom label for this difficulty.
*/
@prop()
label: string;
constructor(
njs: number,
offset: number,
notes: number,
bombs: number,
obstacles: number,
nps: number,
characteristic: "Standard" | "Lawless",
difficulty: MapDifficulty,
events: number,
chroma: boolean,
mappingExtensions: boolean,
noodleExtensions: boolean,
cinema: boolean,
maxScore: number,
label: string
) {
this.njs = njs;
this.offset = offset;
this.notes = notes;
this.bombs = bombs;
this.obstacles = obstacles;
this.nps = nps;
this.characteristic = characteristic;
this.difficulty = difficulty;
this.events = events;
this.chroma = chroma;
this.mappingExtensions = mappingExtensions;
this.noodleExtensions = noodleExtensions;
this.cinema = cinema;
this.maxScore = maxScore;
this.label = label;
}
}

@ -0,0 +1,55 @@
import { prop } from "@typegoose/typegoose";
export default class BeatSaverMapMetadata {
/**
* The bpm of the song.
*/
@prop({ required: true })
bpm: number;
/**
* The song's length in seconds.
*/
@prop({ required: true })
duration: number;
/**
* The song's name.
*/
@prop({ required: true })
songName: string;
/**
* The song's sub name.
*/
@prop({ required: false })
songSubName: string;
/**
* The artist(s) name.
*/
@prop({ required: true })
songAuthorName: string;
/**
* The level mapper(s) name.
*/
@prop({ required: true })
levelAuthorName: string;
constructor(
bpm: number,
duration: number,
songName: string,
songSubName: string,
songAuthorName: string,
levelAuthorName: string
) {
this.bpm = bpm;
this.duration = duration;
this.songName = songName;
this.songSubName = songSubName;
this.songAuthorName = songAuthorName;
this.levelAuthorName = levelAuthorName;
}
}

@ -0,0 +1,31 @@
import { modelOptions, prop, Severity } from "@typegoose/typegoose";
import BeatSaverMapDifficulty from "./map-difficulty";
@modelOptions({
options: { allowMixed: Severity.ALLOW },
})
export default class BeatSaverMapVersion {
/**
* The hash of this map.
*/
@prop({ required: true })
hash: string;
/**
* The date the map was created.
*/
@prop({ required: true })
createdAt: Date;
/**
* The difficulties of this map.
*/
@prop({ required: true })
difficulties: BeatSaverMapDifficulty[];
constructor(hash: string, createdAt: Date, difficulties: BeatSaverMapDifficulty[]) {
this.hash = hash;
this.createdAt = createdAt;
this.difficulties = difficulties;
}
}

@ -0,0 +1,99 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import BeatSaverAuthor from "./author";
import BeatSaverMapVersion from "./map-version";
import BeatSaverMapMetadata from "./map-metadata";
/**
* The model for a BeatSaver map.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
collection: "beatsaver-maps",
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
export class BeatSaverMap {
/**
* The internal MongoDB ID (_id).
*/
@prop({ required: true })
protected _id!: string;
/**
* The name of the map.
*/
@prop({ required: false })
public name!: string;
/**
* The description of the map.
*/
@prop({ required: false })
public description!: string;
/**
* The bsr code for the map.
*/
@prop({ required: false })
public bsr!: string;
/**
* The author of the map.
*/
@prop({ required: false, _id: false, type: () => BeatSaverAuthor })
public author!: BeatSaverAuthor;
/**
* The versions of the map.
*/
@prop({ required: false, _id: false, type: () => [BeatSaverMapVersion] })
public versions!: BeatSaverMapVersion[];
/**
* The metadata of the map.
*/
@prop({ required: false, _id: false, type: () => BeatSaverMapMetadata })
public metadata!: BeatSaverMapMetadata;
/**
* True if the map is not found on beatsaver.
*/
@prop({ required: false })
public notFound?: boolean;
/**
* The last time the map data was refreshed.
*/
@prop({ required: true })
public lastRefreshed!: Date;
/**
* Exposes `id` as a virtual field mapped from `_id`.
*/
public get id(): string {
return this._id;
}
/**
* Should the map data be refreshed?
*
* @returns true if the map data should be refreshed
*/
public shouldRefresh(): boolean {
const now = new Date();
return now.getTime() - this.lastRefreshed.getTime() > 1000 * 60 * 60 * 24 * 3; // 3 days
}
}
export type BeatSaverMapDocument = BeatSaverMap & Document;
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);

@ -1,4 +1,4 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../model/beatsaver/map";
export type LeaderboardResponse<L> = {
/**

@ -1,5 +1,5 @@
import { Metadata } from "../types/metadata";
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../model/beatsaver/map";
export default interface LeaderboardScoresResponse<S, L> {
/**

@ -1 +0,0 @@
export type Difficulty = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+" | "Unknown";

@ -0,0 +1 @@
export type MapDifficulty = "Easy" | "Normal" | "Hard" | "Expert" | "ExpertPlus" | "Unknown";

@ -1,4 +1,4 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../model/beatsaver/map";
export interface PlayerScore<S, L> {
/**

@ -1,5 +1,5 @@
import Service from "../service";
import { BeatSaverMapToken } from "../../types/token/beatsaver/beat-saver-map-token";
import { BeatSaverMapToken } from "../../types/token/beatsaver/map";
const API_BASE = "https://api.beatsaver.com";
const LOOKUP_MAP_BY_HASH_ENDPOINT = `${API_BASE}/maps/hash/:query`;

@ -0,0 +1 @@
export type MapCharacteristic = "Standard" | "Lawless";

@ -1,13 +0,0 @@
import { Metadata } from "./metadata";
export type Page<T> = {
/**
* The data to return.
*/
data: T[];
/**
* The metadata of the page.
*/
metadata: Metadata;
};

@ -1,24 +0,0 @@
import BeatSaverAccountToken from "./beat-saver-account-token";
import BeatSaverMapMetadataToken from "./beat-saver-map-metadata-token";
import BeatSaverMapStatsToken from "./beat-saver-map-stats-token";
export interface BeatSaverMapToken {
id: string;
name: string;
description: string;
uploader: BeatSaverAccountToken;
metadata: BeatSaverMapMetadataToken;
stats: BeatSaverMapStatsToken;
uploaded: string;
automapper: boolean;
ranked: boolean;
qualified: boolean;
// todo: versions
createdAt: string;
updatedAt: string;
lastPublishedAt: string;
tags: string[];
declaredAi: string;
blRanked: boolean;
blQualified: boolean;
}

@ -0,0 +1,16 @@
export type MapDifficultyParitySummaryToken = {
/**
* The amount of parity errors.
*/
errors: number;
/**
* The amount of parity warnings.
*/
warns: number;
/**
* The amount of resets in the difficulty.
*/
resets: number;
};

@ -0,0 +1,90 @@
import { MapDifficulty } from "../../../score/map-difficulty";
import { MapDifficultyParitySummaryToken } from "./difficulty-parity-summary";
export type BeatSaverMapDifficultyToken = {
/**
* The NJS of this difficulty.
*/
njs: number;
/**
* The NJS offset of this difficulty.
*/
offset: number;
/**
* The amount of notes in this difficulty.
*/
notes: number;
/**
* The amount of bombs in this difficulty.
*/
bombs: number;
/**
* The amount of obstacles in this difficulty.
*/
obstacles: number;
/**
* The notes per second in this difficulty.
*/
nps: number;
/**
* The length of this difficulty in seconds.
*/
length: number;
/**
* The characteristic of this difficulty.
*/
characteristic: "Standard" | "Lawless";
/**
* The difficulty of this difficulty.
*/
difficulty: MapDifficulty;
/**
* The amount of lighting events in this difficulty.
*/
events: number;
/**
* Whether this difficulty uses Chroma.
*/
chroma: boolean;
/**
* Quite frankly I have no fucking idea what these are.
*/
me: boolean;
ne: boolean;
/**
* Does this difficulty use cinema?
*/
cinema: boolean;
/**
* The length of this difficulty in seconds.
*/
seconds: number;
/**
* The parity summary of this difficulty.
*/
paritySummary: MapDifficultyParitySummaryToken;
/**
* The maximum score of this difficulty.
*/
maxScore: number;
/**
* The custom difficulty label.
*/
label: string;
};

@ -0,0 +1,43 @@
import { BeatSaverMapDifficultyToken } from "./map-difficulty";
export type BeatSaverMapVersionToken = {
/**
* The hash of the map.
*/
hash: string;
/**
* The stage of the map.
*/
stage: "Published"; // todo: find the rest of these
/**
* The date the map was created.
*/
createdAt: string;
/**
* The sage score of the map. (no idea what this is x.x)
*/
sageScore: number;
/**
* The difficulties in the map.
*/
diffs: BeatSaverMapDifficultyToken[];
/**
* The URL to the download of the map.
*/
downloadURL: string;
/**
* The URL to the cover image.
*/
coverURL: string;
/**
* The URL to the preview of the map.
*/
previewURL: string;
};

@ -0,0 +1,96 @@
import BeatSaverAccountToken from "./account";
import BeatSaverMapMetadataToken from "./map-metadata";
import BeatSaverMapStatsToken from "./map-stats";
import { BeatSaverMapVersionToken } from "./map-version";
export interface BeatSaverMapToken {
/**
* The id of the map.
*/
id: string;
/**
* The name of the map.
*/
name: string;
/**
* The description of the map.
*/
description: string;
/**
* The uploader of the map.
*/
uploader: BeatSaverAccountToken;
/**
* The metadata of the map.
*/
metadata: BeatSaverMapMetadataToken;
/**
* The stats of the map.
*/
stats: BeatSaverMapStatsToken;
/**
* The date the map was uploaded.
*/
uploaded: string;
/**
* Whether the map was mapped by an automapper.
*/
automapper: boolean;
/**
* Whether the map is ranked on ScoreSaber.
*/
ranked: boolean;
/**
* Whether the map is qualified on ScoreSaber.
*/
qualified: boolean;
/**
* The versions of the map.
*/
versions: BeatSaverMapVersionToken[];
/**
* The date the map was created.
*/
createdAt: string;
/**
* The date the map was last updated.
*/
updatedAt: string;
/**
* The date the map was last published.
*/
lastPublishedAt: string;
/**
* The tags of the map.
*/
tags: string[];
/**
* Whether the map is declared to be mapped by an AI.
*/
declaredAi: string;
/**
* Whether the map is ranked on BeatLeader.
*/
blRanked: boolean;
/**
* Whether the map is qualified on BeatLeader.
*/
blQualified: boolean;
}

@ -1,7 +1,8 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../model/beatsaver/map";
import { MapDifficulty } from "../score/map-difficulty";
/**
* Gets the beatSaver mapper profile url.
* Gets the BeatSaver mapper profile url.
*
* @param map the beatsaver map
* @returns the beatsaver mapper profile url
@ -9,3 +10,18 @@ import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
export function getBeatSaverMapperProfileUrl(map?: BeatSaverMap) {
return map != undefined ? `https://beatsaver.com/profile/${map?.author.id}` : undefined;
}
/**
* Gets a BeatSaver difficulty from a map.
*
* @param map the map to get the difficulty from
* @param hash the hash of the map
* @param difficulty the difficulty to get
*/
export function getBeatSaverDifficulty(map: BeatSaverMap, hash: string, difficulty: MapDifficulty) {
const version = map.versions.find(v => v.hash === hash);
if (version == undefined) {
return undefined;
}
return version.difficulties.find(d => d.difficulty === difficulty);
}

@ -60,7 +60,7 @@ export function sortPlayerHistory(history: Map<string, PlayerHistory>) {
* @param id the player id
*/
export async function trackPlayer(id: string) {
await kyFetch(`${Config.apiUrl}/player/history/1/${id}?createIfMissing=true`);
await kyFetch(`${Config.apiUrl}/player/history/${id}/1?createIfMissing=true`);
}
/**

@ -1,11 +1,11 @@
import { Difficulty } from "../score/difficulty";
import { MapDifficulty } from "../score/map-difficulty";
/**
* Formats the ScoreSaber difficulty number
*
* @param diff the diffuiclity number
*/
export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty {
export function getDifficultyFromScoreSaberDifficulty(diff: number): MapDifficulty {
switch (diff) {
case 1: {
return "Easy";
@ -20,7 +20,7 @@ export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty
return "Expert";
}
case 9: {
return "Expert+";
return "ExpertPlus";
}
default: {
return "Unknown";

@ -134,3 +134,20 @@ export function getDaysAgo(date: Date): number {
export function parseDate(date: string): Date {
return new Date(date);
}
/**
* Formats the time in the format "MM:SS"
*
* @param seconds the time to format in seconds
* @returns the formatted time in "MM:SS" format
*/
export function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
// Zero pad minutes and seconds to ensure two digits
const formattedMinutes = minutes < 10 ? `0${minutes}` : `${minutes}`;
const formattedSeconds = remainingSeconds < 10 ? `0${remainingSeconds}` : `${remainingSeconds}`;
return `${formattedMinutes}:${formattedSeconds}`;
}

@ -5,7 +5,6 @@ import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AnimatePresence } from "framer-motion";
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import BackgroundCover from "../components/background-cover";
@ -79,7 +78,6 @@ export default function RootLayout({
<OfflineNetwork>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
<QueryProvider>
<AnimatePresence>
<ApiHealth />
<main className="flex flex-col min-h-screen gap-2 text-white w-full">
<NavBar />
@ -88,7 +86,6 @@ export default function RootLayout({
</div>
<Footer />
</main>
</AnimatePresence>
</QueryProvider>
</ThemeProvider>
</OfflineNetwork>

@ -1,17 +1,23 @@
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
type Difficulty = {
name: DifficultyName;
gamemode?: string;
/**
* The name of the difficulty
*/
name: MapDifficulty;
/**
* The color of the difficulty
*/
color: string;
};
type DifficultyName = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+";
const difficulties: Difficulty[] = [
{ name: "Easy", color: "#3cb371" },
{ name: "Normal", color: "#59b0f4" },
{ name: "Hard", color: "#FF6347" },
{ name: "Expert", color: "#bf2a42" },
{ name: "Expert+", color: "#8f48db" },
{ name: "ExpertPlus", color: "#8f48db" },
];
export type ScoreBadge = {
@ -22,7 +28,7 @@ export type ScoreBadge = {
};
const scoreBadges: ScoreBadge[] = [
{ name: "SS+", min: 95, max: null, color: getDifficulty("Expert+")!.color },
{ name: "SS+", min: 95, max: null, color: getDifficulty("ExpertPlus")!.color },
{ name: "SS", min: 90, max: 95, color: getDifficulty("Expert")!.color },
{ name: "S+", min: 85, max: 90, color: getDifficulty("Hard")!.color },
{ name: "S", min: 80, max: 85, color: getDifficulty("Normal")!.color },
@ -57,45 +63,16 @@ export function getScoreBadgeFromAccuracy(acc: number): ScoreBadge {
return scoreBadges[scoreBadges.length - 1];
}
/**
* Parses a raw difficulty into a {@link Difficulty}
* Example: _Easy_SoloStandard -> { name: "Easy", type: "Standard", color: "#59b0f4" }
*
* @param rawDifficulty the raw difficulty to parse
* @return the parsed difficulty
*/
export function getDifficultyFromRawDifficulty(rawDifficulty: string): Difficulty {
const [name, ...type] = rawDifficulty
.replace("Plus", "+") // Replaces Plus with + so we can match it to our difficulty names
.replace("Solo", "") // Removes "Solo"
.replace(/^_+|_+$/g, "") // Removes leading and trailing underscores
.split("_");
const difficulty = difficulties.find(d => d.name === name);
if (!difficulty) {
throw new Error(`Unknown difficulty: ${rawDifficulty}`);
}
return {
...difficulty,
gamemode: type.join("_"),
};
}
/**
* Gets a {@link Difficulty} from its name
*
* @param diff the name of the difficulty
* @returns the difficulty
*/
export function getDifficulty(diff: DifficultyName) {
return difficulties.find(d => d.name === diff);
}
/**
* Turns the difficulty of a song into a color
*
* @param diff the difficulty to get the color for
* @returns the color for the difficulty
*/
export function songDifficultyToColor(diff: string) {
return getDifficultyFromRawDifficulty(diff).color;
export function getDifficulty(diff: MapDifficulty) {
const difficulty = difficulties.find(d => d.name === diff);
if (!difficulty) {
throw new Error(`Unknown difficulty: ${diff}`);
}
return difficulty;
}

@ -14,14 +14,7 @@ import {
import { ChevronDoubleLeftIcon, ChevronDoubleRightIcon } from "@heroicons/react/16/solid";
type PaginationItemWrapperProps = {
/**
* Whether a page is currently loading.
*/
isLoadingPage: boolean;
/**
* The children to render.
*/
children: React.ReactNode;
};
@ -38,34 +31,11 @@ function PaginationItemWrapper({ isLoadingPage, children }: PaginationItemWrappe
}
type Props = {
/**
* If true, the pagination will be rendered as a mobile-friendly pagination.
*/
mobilePagination: boolean;
/**
* The current page.
*/
page: number;
/**
* The total number of pages.
*/
totalPages: number;
/**
* The page to show a loading icon on.
*/
loadingPage: number | undefined;
/**
* Callback function that is called when the user clicks on a page number.
*/
onPageChange: (page: number) => void;
/**
* Optional callback to generate the URL for each page.
*/
generatePageUrl?: (page: number) => string;
};
@ -89,15 +59,12 @@ export default function Pagination({
if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) {
return;
}
setCurrentPage(newPage);
onPageChange(newPage);
};
const handleLinkClick = (newPage: number, event: React.MouseEvent) => {
event.preventDefault(); // Prevent default navigation behavior
// Check if the new page is valid
event.preventDefault();
if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) {
return;
}
@ -116,26 +83,26 @@ export default function Pagination({
if (startPage > 1) {
pageNumbers.push(
<>
<PaginationItemWrapper key="start" isLoadingPage={isLoading}>
<PaginationItemWrapper key={`start-1`} isLoadingPage={isLoading}>
{!mobilePagination && (
<PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}>
1
</PaginationLink>
)}
</PaginationItemWrapper>
{startPage > 2 && !mobilePagination && (
<PaginationItemWrapper key="ellipsis-start" isLoadingPage={isLoading}>
);
if (startPage > 2 && !mobilePagination) {
pageNumbers.push(
<PaginationItemWrapper key={`ellipsis-start`} isLoadingPage={isLoading}>
<PaginationEllipsis />
</PaginationItemWrapper>
)}
</>
);
}
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(
<PaginationItemWrapper key={i} isLoadingPage={isLoading}>
<PaginationItemWrapper key={`page-${i}`} isLoadingPage={isLoading}>
<PaginationLink
isActive={i === currentPage}
href={generatePageUrl ? generatePageUrl(i) : ""}
@ -153,17 +120,15 @@ export default function Pagination({
return (
<ShadCnPagination className="select-none">
<PaginationContent>
{/* ">>" before the Previous button in mobile mode */}
{mobilePagination && (
<PaginationItemWrapper key="mobile-start" isLoadingPage={isLoading}>
<PaginationItemWrapper key={`mobile-start`} isLoadingPage={isLoading}>
<PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}>
<ChevronDoubleLeftIcon className="h-4 w-4" />
</PaginationLink>
</PaginationItemWrapper>
)}
{/* Previous button - disabled on the first page */}
<PaginationItemWrapper isLoadingPage={isLoading}>
<PaginationItemWrapper key={`previous`} isLoadingPage={isLoading}>
<PaginationPrevious
href={currentPage > 1 && generatePageUrl ? generatePageUrl(currentPage - 1) : ""}
onClick={e => handleLinkClick(currentPage - 1, e)}
@ -176,10 +141,10 @@ export default function Pagination({
{!mobilePagination && currentPage < totalPages && totalPages - currentPage > 2 && (
<>
<PaginationItemWrapper key="ellipsis-start" isLoadingPage={isLoading}>
<PaginationItemWrapper key={`ellipsis-end`} isLoadingPage={isLoading}>
<PaginationEllipsis />
</PaginationItemWrapper>
<PaginationItemWrapper key="end" isLoadingPage={isLoading}>
<PaginationItemWrapper key={`end`} isLoadingPage={isLoading}>
<PaginationLink
href={generatePageUrl ? generatePageUrl(totalPages) : ""}
onClick={e => handleLinkClick(totalPages, e)}
@ -190,8 +155,7 @@ export default function Pagination({
</>
)}
{/* Next button - disabled on the last page */}
<PaginationItemWrapper isLoadingPage={isLoading}>
<PaginationItemWrapper key={`next`} isLoadingPage={isLoading}>
<PaginationNext
href={currentPage < totalPages && generatePageUrl ? generatePageUrl(currentPage + 1) : ""}
onClick={e => handleLinkClick(currentPage + 1, e)}
@ -200,9 +164,8 @@ export default function Pagination({
/>
</PaginationItemWrapper>
{/* ">>" after the Next button in mobile mode */}
{mobilePagination && (
<PaginationItemWrapper key="mobile-end" isLoadingPage={isLoading}>
<PaginationItemWrapper key={`mobile-end`} isLoadingPage={isLoading}>
<PaginationLink
href={generatePageUrl ? generatePageUrl(totalPages) : ""}
onClick={e => handleLinkClick(totalPages, e)}

@ -66,11 +66,11 @@ export default function SearchPlayer() {
{results !== undefined && (
<ScrollArea>
<div className="flex flex-col gap-1 max-h-60">
{results?.map(player => {
{results?.map((player, index) => {
return (
<Link
href={`/player/${player.id}`}
key={player.id}
key={index}
className="bg-secondary p-2 rounded-md flex gap-2 items-center hover:brightness-75 transition-all transform-gpu"
>
<Avatar>

@ -3,7 +3,7 @@ import Image from "next/image";
import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count";
import ScoreButtons from "@/components/score/score-buttons";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../../../../common/src/model/beatsaver/map";
import { getBeatSaverMapperProfileUrl } from "@ssr/common/utils/beatsaver.util";
import FallbackLink from "@/components/fallback-link";
import { formatNumber } from "@ssr/common/utils/number-utils";

@ -8,7 +8,7 @@ import Pagination from "../input/pagination";
import LeaderboardScore from "./leaderboard-score";
import { scoreAnimation } from "@/components/score/score-animation";
import { Button } from "@/components/ui/button";
import { getDifficultyFromRawDifficulty } from "@/common/song-utils";
import { getDifficulty } from "@/common/song-utils";
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
@ -140,28 +140,26 @@ export default function LeaderboardScores({
{showDifficulties && (
<div className="flex gap-2 justify-center items-center flex-wrap">
{leaderboard.difficulties.map(({ difficultyRaw, leaderboardId }) => {
const difficulty = getDifficultyFromRawDifficulty(difficultyRaw);
// todo: add support for other gamemodes?
if (difficulty.gamemode !== "Standard") {
{leaderboard.difficulties.map(({ difficulty, characteristic, leaderboardId }, index) => {
if (characteristic !== "Standard") {
return null;
}
const isSelected = leaderboardId === selectedLeaderboardId;
return (
<Button
key={difficultyRaw}
key={index}
variant={isSelected ? "default" : "outline"}
onClick={() => {
handleLeaderboardChange(leaderboardId);
}}
className={`border ${isSelected ? "bg-primary/5 font-bold" : ""}`}
style={{
color: getDifficultyFromRawDifficulty(difficultyRaw).color,
borderColor: getDifficultyFromRawDifficulty(difficultyRaw).color,
color: getDifficulty(difficulty).color,
borderColor: getDifficulty(difficulty).color,
}}
>
{difficulty.name}
{difficulty}
</Button>
);
})}

@ -1,4 +1,4 @@
import { songDifficultyToColor } from "@/common/song-utils";
import { getDifficulty } from "@/common/song-utils";
import { StarIcon } from "@heroicons/react/24/solid";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
@ -18,7 +18,7 @@ export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCou
<div
className="w-fit h-[20px] rounded-sm flex justify-center items-center text-xs cursor-default"
style={{
backgroundColor: songDifficultyToColor(leaderboard.difficulty.difficultyRaw) + "f0", // Transparency value (in hex 0-255)
backgroundColor: getDifficulty(leaderboard.difficulty.difficulty).color + "f0", // Transparency value (in hex 0-255)
}}
>
<div className="flex gap-1 items-center justify-center p-1">

@ -26,7 +26,7 @@ export default function FriendsButton() {
</PopoverTrigger>
<PopoverContent className="p-2">
{friends && friends.length > 0 ? (
friends.map(friend => <Friend player={friend} key={friend.id} onClick={() => setOpen(false)} />)
friends.map((friend, index) => <Friend player={friend} key={index} onClick={() => setOpen(false)} />)
) : (
<div className="text-sm flex flex-col gap-2 justify-center items-center">
<p>You don&#39;t have any friends :(</p>

@ -0,0 +1,53 @@
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import StatValue from "@/components/stat-value";
import { getBeatSaverDifficulty } from "@ssr/common/utils/beatsaver.util";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { formatTime } from "@ssr/common/utils/time-utils";
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
import { BombIcon, BrickWallIcon, DrumIcon, MusicIcon, TimerIcon } from "lucide-react";
import { BsSpeedometer } from "react-icons/bs";
import { CubeIcon } from "@heroicons/react/24/solid";
type MapAndScoreData = {
/**
* The leaderboard that the score was set on.
*/
leaderboard: ScoreSaberLeaderboard;
/**
* The map that the score was set on.
*/
beatSaver?: BeatSaverMap;
};
export function MapStats({ leaderboard, beatSaver }: MapAndScoreData) {
const metadata = beatSaver?.metadata;
const mapDiff = beatSaver
? getBeatSaverDifficulty(beatSaver, leaderboard.songHash, leaderboard.difficulty.difficulty)
: undefined;
return (
<div className="flex flex-col gap-2">
{/* Map Stats */}
{mapDiff && metadata && (
<div className="flex flex-wrap gap-2 justify-center">
<StatValue name="Length" icon={<TimerIcon className="w-4 h-4" />} value={formatTime(metadata.duration)} />
<StatValue name="BPM" icon={<MusicIcon className="w-4 h-4" />} value={formatNumberWithCommas(metadata.bpm)} />
<StatValue name="NPS" icon={<DrumIcon className="w-4 h-4" />} value={mapDiff.nps.toFixed(2)} />
<StatValue name="NJS" icon={<BsSpeedometer className="w-4 h-4" />} value={mapDiff.njs.toFixed(2)} />
<StatValue
name="Notes"
icon={<CubeIcon className="w-4 h-4" />}
value={formatNumberWithCommas(mapDiff.notes)}
/>
<StatValue
name="Bombs"
icon={<BombIcon className="w-4 h-4" />}
value={formatNumberWithCommas(mapDiff.bombs)}
/>
<StatValue name="Obstacles" icon={<BrickWallIcon className="w-4 h-4" />} value={mapDiff.obstacles} />
</div>
)}
</div>
);
}

@ -12,7 +12,7 @@ import clsx from "clsx";
import ScoreEditorButton from "@/components/score/score-editor-button";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import BeatSaberPepeLogo from "@/components/logos/beatsaber-pepe-logo";
type Props = {

@ -21,7 +21,7 @@ type ScoreModifiersProps = {
export function ScoreModifiers({ score, type, limit }: ScoreModifiersProps) {
const modifiers = score.modifiers;
if (modifiers.length === 0) {
return <p>-</p>;
return <span>-</span>;
}
switch (type) {

@ -2,10 +2,10 @@ import FallbackLink from "@/components/fallback-link";
import Tooltip from "@/components/tooltip";
import { StarIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import { songDifficultyToColor } from "@/common/song-utils";
import Link from "next/link";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { getDifficulty } from "@/common/song-utils";
type Props = {
leaderboard: ScoreSaberLeaderboard;
@ -14,25 +14,25 @@ type Props = {
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
const mappersProfile =
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.author.id}` : undefined;
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap.author.id}` : undefined;
const starCount = leaderboard.stars;
const difficulty = leaderboard.difficulty;
const difficulty = leaderboard.difficulty.difficulty.replace("Plus", "+");
return (
<div className="flex gap-3 items-center">
<div className="relative flex justify-center h-[64px]">
<Tooltip
display={
<>
<p>Difficulty: {difficulty.difficulty}</p>
<div>
<p>Difficulty: {difficulty}</p>
{starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
</>
</div>
}
>
<div
className="absolute w-full h-[18px] bottom-0 right-0 rounded-sm flex justify-center items-center text-[0.70rem] cursor-default"
style={{
backgroundColor: songDifficultyToColor(difficulty.difficultyRaw) + "f0", // Transparency value (in hex 0-255)
backgroundColor: getDifficulty(leaderboard.difficulty.difficulty).color + "f0", // Transparency value (in hex 0-255)
}}
>
{starCount > 0 ? (
@ -41,7 +41,7 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
<StarIcon className="w-[14px] h-[14px]" />
</div>
) : (
<p>{difficulty.difficulty}</p>
<p>{difficulty}</p>
)}
</div>
</Tooltip>

@ -3,7 +3,7 @@
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { useEffect, useState } from "react";
import ScoreButtons from "./score-buttons";
import ScoreSongInfo from "./score-info";
import ScoreSongInfo from "./score-song-info";
import ScoreRankInfo from "./score-rank-info";
import ScoreStats from "./score-stats";
import { motion } from "framer-motion";
@ -11,10 +11,10 @@ import { getPageFromRank } from "@ssr/common/utils/utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { useIsMobile } from "@/hooks/use-is-mobile";
import Card from "@/components/card";
import StatValue from "@/components/stat-value";
import { MapStats } from "@/components/score/map-stats";
type Props = {
/**
@ -106,11 +106,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
className="w-full mt-2"
>
<Card className="flex gap-4 w-full relative border border-input">
{score.additionalData && (
<div className="flex w-full items-center justify-center gap-2">
<StatValue name="Pauses" value={score.additionalData.pauses} />
</div>
)}
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
<LeaderboardScores
initialPage={getPageFromRank(score.rank, 12)}

@ -6,6 +6,11 @@ type Props = {
*/
name?: string;
/**
* The icon for the stat.
*/
icon?: React.ReactNode;
/**
* The background color of the stat.
*/
@ -17,7 +22,7 @@ type Props = {
value: React.ReactNode;
};
export default function StatValue({ name, color, value }: Props) {
export default function StatValue({ name, icon, color, value }: Props) {
return (
<div
className={clsx(
@ -28,6 +33,7 @@ export default function StatValue({ name, color, value }: Props) {
backgroundColor: (!color?.includes("bg") && color) || undefined,
}}
>
{icon}
{name && (
<>
<p>{name}</p>

@ -37,8 +37,7 @@ export default function Tooltip({ children, display, asChild = true, side = "top
return (
<ShadCnTooltip>
<TooltipTrigger className={className} asChild={asChild}>
<button
type="button"
<div
className={cn("cursor-default", className)}
onClick={() => setOpen(!open)}
onMouseEnter={() => setOpen(true)}
@ -46,7 +45,7 @@ export default function Tooltip({ children, display, asChild = true, side = "top
onTouchStart={() => setOpen(!open)}
>
{children}
</button>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[350px]" side={side}>
{display}