mono repo base

This commit is contained in:
Lee
2024-08-01 05:57:27 +01:00
parent 90ec976939
commit 47fc07f2dd
54 changed files with 91 additions and 68 deletions

31
API/.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
*.class
*.log
*.ctxt
.mtj.tmp/
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
replay_pid*
.idea
cmake-build-*/
.idea/**/mongoSettings.xml
*.iws
out/
build/
work/
target/
.idea_modules/
atlassian-ide-plugin.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
git.properties
pom.xml.versionsBackup
/docker/questdb/
/docker/mongodb/

27
API/Dockerfile Normal file
View File

@ -0,0 +1,27 @@
# Stage 1: Build the application
FROM maven:3.9.8-eclipse-temurin-17-alpine AS builder
# Set the working directory
WORKDIR /home/container
# Copy the source code
COPY . .
# Build the jar
RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C
# Stage 2: Create the final lightweight image
FROM eclipse-temurin:17.0.12_7-jre-focal
# Set the working directory
WORKDIR /home/container
# Copy the built jar file from the builder stage
COPY --from=builder /home/container/target/YetAnotherBeatSaberTracker-1.0.jar .
# Export the port
ENV PORT=7500
EXPOSE $PORT
# Run the jar file
CMD ["java", "-jar", "YetAnotherBeatSaberTracker-1.0.jar", "-Djava.awt.headless=true"]

118
API/pom.xml Normal file
View File

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cc.fascinated</groupId>
<artifactId>YetAnotherBeatSaberTracker</artifactId>
<version>1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<java.version>17</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
<dependencyManagement>
<dependencies>
<!-- https://mvnrepository.com/artifact/com.konghq/unirest-java-bom -->
<dependency>
<groupId>com.konghq</groupId>
<artifactId>unirest-java-bom</artifactId>
<version>4.4.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Dependencies -->
<dependency>
<groupId>com.konghq</groupId>
<artifactId>unirest-java-core</artifactId>
</dependency>
<dependency>
<groupId>com.konghq</groupId>
<artifactId>unirest-modules-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.questdb</groupId>
<artifactId>questdb</artifactId>
<version>8.1.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- Libraries -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>24.1.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,45 @@
package cc.fascinated;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
/**
* @author Fascinated (fascinated7)
*/
@EnableJpaRepositories(basePackages = "cc.fascinated.repository.couchdb")
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
@EnableScheduling
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
@Log4j2(topic = "Ember")
public class Main {
@SneakyThrows
public static void main(@NonNull String[] args) {
// Handle loading of our configuration file
File config = new File("application.yml");
if (!config.exists()) { // Saving the default config if it doesn't exist locally
Files.copy(Objects.requireNonNull(Main.class.getResourceAsStream("/application.yml")), config.toPath(), StandardCopyOption.REPLACE_EXISTING);
log.info("Saved the default configuration to '{}', please re-launch the application", // Log the default config being saved
config.getAbsolutePath()
);
return;
}
log.info("Found configuration at '{}'", config.getAbsolutePath()); // Log the found config
// Start the app
SpringApplication.run(Main.class, args);
}
}

View File

@ -0,0 +1,34 @@
package cc.fascinated.common;
import lombok.experimental.UtilityClass;
/**
* @author Fascinated (fascinated7)
*/
@UtilityClass
public class MathUtils {
/**
* Clamps a value between a minimum and maximum.
*
* @param value The value to clamp.
* @param min The minimum value.
* @param max The maximum value.
* @return The clamped value.
*/
public static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
/**
* Linearly interpolates between two values.
*
* @param a The first value.
* @param b The second value.
* @param t The interpolation value.
* @return The interpolated value.
*/
public static double lerp(double a, double b, double t) {
return a + t * (b - a);
}
}

View File

@ -0,0 +1,90 @@
package cc.fascinated.common;
import kong.unirest.core.Headers;
import kong.unirest.core.HttpResponse;
import kong.unirest.core.Unirest;
import lombok.extern.log4j.Log4j2;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@Log4j2(topic = "Request")
public class Request {
/**
* The rate limit headers.
*/
private static final List<String> rateLimitHeaders = List.of(
"X-RateLimit-Remaining",
"RateLimit-Remaining"
);
/**
* The rate limit reset headers.
*/
private static final List<String> rateLimitResetHeaders = List.of(
"X-RateLimit-Reset",
"RateLimit-Reset"
);
/**
* Sends a GET request to a URL.
*
* @param url the URL to send the request to
* @param clazz the class to parse the response to
* @param <T> the type of the response
* @return the response
*/
public static <T> HttpResponse<T> get(String url, Class<T> clazz) {
HttpResponse<T> response = Unirest.get(url).asObject(clazz);
int rateLimitRemaining = getRateLimitRemaining(response);
if (rateLimitRemaining == 0) {
long rateLimitReset = getRateLimitReset(response);
long timeLeft = rateLimitReset - System.currentTimeMillis();
try {
Thread.sleep(timeLeft);
} catch (InterruptedException e) {
log.error("Failed to sleep for rate limit reset", e);
}
response = Unirest.get(url).asObject(clazz);
}
return response;
}
/**
* Gets the rate limit remaining.
*
* @param response the response to get the rate limit remaining from
* @return the rate limit remaining
*/
public static int getRateLimitRemaining(HttpResponse<?> response) {
Headers headers = response.getHeaders();
for (String rateLimitHeader : rateLimitHeaders) {
if (headers.containsKey(rateLimitHeader)) {
return Integer.parseInt(headers.getFirst(rateLimitHeader));
}
}
return -1;
}
/**
* Gets the rate limit reset absolute time.
*
* @param response the response to get the rate limit reset time from
* @return the rate limit reset time
*/
public static long getRateLimitReset(HttpResponse<?> response) {
Headers headers = response.getHeaders();
for (String rateLimitResetHeader : rateLimitResetHeaders) {
if (headers.containsKey(rateLimitResetHeader)) {
long reset = Long.parseLong(headers.getFirst(rateLimitResetHeader));
if (reset < 86400) {// Assume it's in seconds left
return System.currentTimeMillis() + reset * 1000;
}
return reset * 1000; // Assume it's in seconds
}
}
return -1;
}
}

View File

@ -0,0 +1,23 @@
package cc.fascinated.common;
/**
* @author Fascinated (fascinated7)
*/
public class ScoreSaberUtils {
/**
* Parses the difficulty of a map.
*
* @param difficulty the difficulty of the map
* @return the parsed difficulty
*/
public static String parseDifficulty(int difficulty) {
return switch (difficulty) {
case 1 -> "Easy";
case 3 -> "Normal";
case 5 -> "Hard";
case 7 -> "Expert";
case 9 -> "Expert+";
default -> "Unknown";
};
}
}

View File

@ -0,0 +1,25 @@
package cc.fascinated.common;
import cc.fascinated.exception.impl.BadRequestException;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
public class UUIDUtils {
/**
* Parses a UUID from a string.
*
* @param id the string to parse
* @return the UUID
* @throws BadRequestException if the UUID is invalid
*/
public static UUID parseUUID(String id) {
try {
return UUID.fromString(id);
} catch (IllegalArgumentException e) {
throw new BadRequestException("Invalid UUID");
}
}
}

View File

@ -0,0 +1,33 @@
package cc.fascinated.config;
import lombok.NonNull;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author Fascinated (fascinated7)
*/
@Configuration
public class Config {
/**
* Allow all origins to access the API
*
* @return the WebMvcConfigurer
*/
@Bean
public WebMvcConfigurer configureCors() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(@NonNull CorsRegistry registry) {
// Allow all origins to access the API
registry.addMapping("/**")
.allowedOrigins("*") // Allow all origins
.allowedMethods("*") // Allow all methods
.allowedHeaders("*"); // Allow all headers
}
};
}
}

View File

@ -0,0 +1,71 @@
package cc.fascinated.controller;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.TrackedScoreService;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@RestController
@RequestMapping(value = "/scores")
public class ScoresController {
/**
* The tracked score service to use.
*/
@NonNull
private final TrackedScoreService trackedScoreService;
@Autowired
public ScoresController(@NonNull TrackedScoreService trackedScoreService) {
this.trackedScoreService = trackedScoreService;
}
/**
* A GET mapping to retrieve the top
* scores for a platform
*
* @param platform the platform to get the scores from
* @return the scores
* @throws BadRequestException if there were no scores found
*/
@ResponseBody
@GetMapping(value = "/top/{platform}")
public ResponseEntity<List<?>> getTopScores(@PathVariable String platform) {
return ResponseEntity.ok(trackedScoreService.getTopScores(Platform.Platforms.getPlatform(platform), 100));
}
/**
* A GET mapping to retrieve the total
* amount of scores for a platform
*
* @param platform the platform to get the scores from
* @return the amount of scores
* @throws BadRequestException if there were no scores found
*/
@ResponseBody
@GetMapping(value = "/count/{platform}")
public ResponseEntity<?> getScoresCount(@PathVariable String platform) {
return ResponseEntity.ok(trackedScoreService.getTotalScores(Platform.Platforms.getPlatform(platform)));
}
/**
* A GET mapping to retrieve the total
* amount of scores over pp thresholds
*
* @param platform the platform to get the scores from
* @return the amount of scores
* @throws BadRequestException if there were no scores found
*/
@ResponseBody
@GetMapping(value = "/ppthresholds/{platform}")
public ResponseEntity<?> getScoresOver(@PathVariable String platform) {
return ResponseEntity.ok(trackedScoreService.getScoresOver(Platform.Platforms.getPlatform(platform)));
}
}

View File

@ -0,0 +1,39 @@
package cc.fascinated.controller;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.user.User;
import cc.fascinated.services.UserService;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* @author Fascinated (fascinated7)
*/
@RestController
@RequestMapping(value = "/users")
public class UserController {
/**
* The user service to use
*/
@NonNull private final UserService userService;
@Autowired
public UserController(@NonNull UserService userService) {
this.userService = userService;
}
/**
* A GET mapping to retrieve a user by their steam id.
*
* @param id the id of the user
* @return the user
* @throws BadRequestException if the user is not found
*/
@ResponseBody
@GetMapping(value = "/{id}")
public ResponseEntity<User> getUser(@PathVariable String id) {
return ResponseEntity.ok(userService.getUser(id));
}
}

View File

@ -0,0 +1,45 @@
package cc.fascinated.exception;
import cc.fascinated.model.response.ErrorResponse;
import io.micrometer.common.lang.NonNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@ControllerAdvice
public final class ExceptionControllerAdvice {
/**
* Handle a raised exception.
*
* @param ex the raised exception
* @return the error response
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(@NonNull Exception ex) {
HttpStatus status = null; // Get the HTTP status
if (ex instanceof NoResourceFoundException) { // Not found
status = HttpStatus.NOT_FOUND;
} else if (ex instanceof UnsupportedOperationException) { // Not implemented
status = HttpStatus.NOT_IMPLEMENTED;
}
if (ex.getClass().isAnnotationPresent(ResponseStatus.class)) { // Get from the @ResponseStatus annotation
status = ex.getClass().getAnnotation(ResponseStatus.class).value();
}
String message = ex.getLocalizedMessage(); // Get the error message
if (message == null) { // Fallback
message = "An internal error has occurred.";
}
// Print the stack trace if no response status is present
if (status == null) {
ex.printStackTrace();
}
if (status == null) { // Fallback to 500
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
return new ResponseEntity<>(new ErrorResponse(status, message), status);
}
}

View File

@ -0,0 +1,12 @@
package cc.fascinated.exception.impl;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
}

View File

@ -0,0 +1,12 @@
package cc.fascinated.exception.impl;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public class InternalServerErrorException extends RuntimeException {
public InternalServerErrorException(String message) {
super(message);
}
}

View File

@ -0,0 +1,12 @@
package cc.fascinated.exception.impl;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}

View File

@ -0,0 +1,9 @@
package cc.fascinated.exception.impl;
import lombok.experimental.StandardException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@StandardException
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException { }

View File

@ -0,0 +1,29 @@
package cc.fascinated.model.platform;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Entity
@Table(name = "metrics")
public class DailyScoresSet {
/**
* The platform for the metric.
*/
@Id private String platform;
/**
* The amount of scores set for the day.
*/
private long scoresSet;
/**
* The day the scores were set.
*/
private Date timestamp;
}

View File

@ -0,0 +1,31 @@
package cc.fascinated.model.platform;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* @author Fascinated (fascinated7)
*/
@Entity
@Getter
@Setter
@Table(name = "metric")
public class TrackedPlatformMetric {
/**
* The platform for the metric.
*/
@Id private String platform;
/**
* The total amount of scores.
*/
private long totalScores;
/**
* The total amount of ranked scores.
*/
private long totalRankedScores;
}

View File

@ -0,0 +1,40 @@
package cc.fascinated.model.response;
import io.micrometer.common.lang.NonNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.springframework.http.HttpStatus;
import java.util.Date;
@Getter @ToString @EqualsAndHashCode
public class ErrorResponse {
/**
* The status code of this error.
*/
@NonNull
private final HttpStatus status;
/**
* The HTTP code of this error.
*/
private final int code;
/**
* The message of this error.
*/
@NonNull private final String message;
/**
* The timestamp this error occurred.
*/
@NonNull private final Date timestamp;
public ErrorResponse(@NonNull HttpStatus status, @NonNull String message) {
this.status = status;
code = status.value();
this.message = message;
timestamp = new Date();
}
}

View File

@ -0,0 +1,24 @@
package cc.fascinated.model.score;
import java.util.HashMap;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
public class ScoresOverResponse {
/**
* Scores over a certain pp threshold.
*/
public Map<Integer, Integer> scoresOver = new HashMap<>();
/**
* Adds scores over a certain pp threshold.
*
* @param pp the pp threshold
* @param scoreAmount the amount of scores over the pp threshold
*/
public void addScores(int pp, int scoreAmount) {
scoresOver.put(pp, scoreAmount);
}
}

View File

@ -0,0 +1,21 @@
package cc.fascinated.model.score;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class TotalScoresResponse {
/**
* The total number of scores
*/
private final int totalScores;
/**
* The total number of ranked scores
*/
private final int totalRankedScores;
}

View File

@ -0,0 +1,99 @@
package cc.fascinated.model.score;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Entity
@Getter
@Setter
@Table(name = "score")
public class TrackedScore {
/**
* The ID of the score.
*/
@Id
private String scoreId;
/**
* The ID of the player who set the score.
*/
private String playerId;
/**
* The ID of the leaderboard.
*/
private String leaderboardId;
/**
* The PP of the score.
*/
private Double pp;
/**
* The rank of the score.
*/
private Long rank;
/**
* The base score of the score.
*/
private Long score;
/**
* The modified score of the score.
*/
private Long modifiedScore;
/**
* The weight of the score.
*/
private Double weight;
/**
* The modifiers of the score.
*/
private String modifiers;
/**
* The multiplier of the score.
*/
private double multiplier;
/**
* The number of misses in the score.
*/
private Long missedNotes;
/**
* The number of bad cuts in the score.
*/
private Long badCuts;
/**
* The highest combo in the score.
*/
private Long maxCombo;
/**
* The accuracy of the score.
*/
private Double accuracy;
/**
* The difficulty the score was set on.
*/
private String difficulty;
/**
* The timestamp of the score.
*/
private Date timestamp;
}

View File

@ -0,0 +1,138 @@
package cc.fascinated.model.token;
import lombok.Getter;
@Getter
public class ScoreSaberAccountToken {
/**
* The id for this ScoreSaber account.
*/
private String id;
/**
* The name for this account.
*/
private String name;
/**
* The profile picture for this account.
*/
private String profilePicture;
/**
* The bio for this account.
*/
private String bio;
/**
* The country for this account.
*/
private String country;
/**
* The PP for this account.
*/
private double pp;
/**
* The rank for this account.
*/
private int rank;
/**
* The country rank for this account.
*/
private int countryRank;
/**
* The role for this account.
*/
private String role;
/**
* The badges for this account.
*/
private Badge[] badges;
/**
* The history of the rank for this account.
*/
private String histories;
/**
* The permissions for this account.
*/
private int permissions;
/**
* The banned status for this account.
*/
private boolean banned;
/**
* The inactive status for this account.
*/
private boolean inactive;
/**
* The score stats for this account.
*/
private ScoreStats scoreStats;
/**
* The first time this account was seen.
*/
private String firstSeen;
/**
* The badge for this account.
*/
@Getter
public static class Badge {
/**
* The image for this badge.
*/
private String image;
/**
* The description for this badge.
*/
private String description;
}
/**
* The score stats for this account.
*/
@Getter
public static class ScoreStats {
/**
* The total score for this account.
*/
private long totalScore;
/**
* The total ranked score for this account.
*/
private long totalRankedScore;
/**
* The average ranked accuracy for this account.
*/
private double averageRankedAccuracy;
/**
* The total play count for this account.
*/
private int totalPlayCount;
/**
* The ranked play count for this account.
*/
private int rankedPlayCount;
/**
* The replays watched for this account.
*/
private int replaysWatched;
}
}

View File

@ -0,0 +1,19 @@
package cc.fascinated.model.token;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter @Setter
@ToString
public class ScoreSaberLeaderboardPageToken {
/**
* The scores on this page.
*/
private ScoreSaberLeaderboardToken[] leaderboards;
/**
* The metadata for this page.
*/
private ScoreSaberPageMetadataToken metadata;
}

View File

@ -0,0 +1,150 @@
package cc.fascinated.model.token;
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.List;
@Getter
@ToString
@Document("scoresaber_leaderboard")
public class ScoreSaberLeaderboardToken {
/**
* The ID of the leaderboard.
*/
@Id
private String id;
/**
* The hash of the song.
*/
private String songHash;
/**
* The name of the song.
*/
private String songName;
/**
* The sub name of the song.
*/
private String songSubName;
/**
* The author of the song.
*/
private String songAuthorName;
/**
* The mapper of the song.
*/
private String levelAuthorName;
/**
* The difficulty of the song.
*/
private Difficulty difficulty;
/**
* The maximum score of the song.
*/
private int maxScore;
/**
* The date the leaderboard was created.
*/
private String createdDate;
/**
* The date the song was ranked.
*/
private String rankedDate;
/**
* The date the song was qualified.
*/
private String qualifiedDate;
/**
* The date the song's status was changed to loved.
*/
private String lovedDate;
/**
* Whether this leaderboard is ranked.
*/
private boolean ranked;
/**
* Whether this leaderboard is qualified to be ranked.
*/
private boolean qualified;
/**
* Whether this leaderboard is in a loved state.
*/
private boolean loved;
/**
* The maximum PP for this leaderboard.
*/
private int maxPP;
/**
* The star rating for this leaderboard.
*/
private double stars;
/**
* The amount of plays for this leaderboard.
*/
private int plays;
/**
* The amount of daily plays for this leaderboard.
*/
private int dailyPlays;
/**
* Whether this leaderboard has positive modifiers.
*/
private boolean positiveModifiers;
/**
* The cover image for this leaderboard.
*/
private String coverImage;
/**
* The difficulties for this leaderboard.
*/
private List<Difficulty> difficulties;
/**
* The difficulty of the leaderboard.
*/
@Getter
public static class Difficulty {
/**
* The leaderboard ID.
*/
private int leaderboardId;
/**
* The difficulty of the leaderboard.
*/
private int difficulty;
/**
* The game mode of the leaderboard.
*/
private String gameMode;
/**
* The difficulty raw of the leaderboard.
*/
private String difficultyRaw;
}
}

View File

@ -0,0 +1,22 @@
package cc.fascinated.model.token;
import lombok.Getter;
import lombok.ToString;
@Getter @ToString
public class ScoreSaberPageMetadataToken {
/**
* The total amount of scores.
*/
private int total;
/**
* The current page.
*/
private int page;
/**
* The amount of scores per page.
*/
private int itemsPerPage;
}

View File

@ -0,0 +1,17 @@
package cc.fascinated.model.token;
import lombok.Getter;
import lombok.ToString;
@Getter @ToString
public class ScoreSaberPlayerScoreToken {
/**
* The score that was set.
*/
private ScoreSaberScoreToken score;
/**
* The leaderboard that the score was set on.
*/
private ScoreSaberLeaderboardToken leaderboard;
}

View File

@ -0,0 +1,135 @@
package cc.fascinated.model.token;
import lombok.Getter;
import lombok.ToString;
@Getter @ToString
public class ScoreSaberScoreToken {
/**
* The id for this score.
*/
private String id;
/**
* The player info for this score.
*/
private LeaderboardPlayerInfo leaderboardPlayerInfo;
/**
* The rank of this score.
*/
private int rank;
/**
* The base score for this score.
*/
private int baseScore;
/**
* The modified score for this score.
*/
private int modifiedScore;
/**
* The PP for this score.
*/
private double pp;
/**
* The weight for this score.
*/
private double weight;
/**
* The modifiers for this score.
*/
private String modifiers;
/**
* The multiplier for this score.
*/
private int multiplier;
/**
* How many bad cuts this score has.
*/
private int badCuts;
/**
* How many misses this score has.
*/
private int missedNotes;
/**
* The maximum combo for this score.
*/
private int maxCombo;
/**
* Whether this score was a full combo.
*/
private boolean fullCombo;
/**
* The HMD that was used to set this score.
*/
private int hmd;
/**
* The time set for this score.
*/
private String timeSet;
/**
* Whether this score has a replay.
*/
private boolean hasReplay;
/**
* The full HMD name that was used to set this score.
*/
private String deviceHmd;
/**
* The controller that was used on the left hand.
*/
private String deviceControllerLeft;
/**
* The controller that was used on the right hand.
*/
private String deviceControllerRight;
@Getter
public class LeaderboardPlayerInfo {
/**
* The ID of the player.
*/
private String id;
/**
* The name of the player.
*/
private String name;
/**
* The profile picture of the player.
*/
private String profilePicture;
/**
* The country of the player.
*/
private String country;
/**
* The permissions for the player.
*/
private int permissions;
/**
* The role for the player.
*/
private String role;
}
}

View File

@ -0,0 +1,19 @@
package cc.fascinated.model.token;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter @Setter
@ToString
public class ScoreSaberScoresPageToken {
/**
* The scores on this page.
*/
private ScoreSaberPlayerScoreToken[] playerScores;
/**
* The metadata for this page.
*/
private ScoreSaberPageMetadataToken metadata;
}

View File

@ -0,0 +1,22 @@
package cc.fascinated.model.token;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class ScoreSaberWebsocketDataToken {
/**
* The name of the command.
*/
private final String commandName;
/**
* The data of the command.
*/
private final ObjectNode commandData;
}

View File

@ -0,0 +1,46 @@
package cc.fascinated.model.user;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
@RequiredArgsConstructor
@Getter
@Setter
@ToString
public class User {
/**
* The ID of the user.
*/
@Id
private final UUID id;
/**
* The username of the user.
* <p>
* Usually their Steam name.
* </p>
*/
private String username;
/**
* The ID of the users steam profile.
*/
private String steamId;
/**
* Whether the user has logged into the website.
* <p>
* This is used to determine if we should track their profiles or not.
* If they haven't logged in, we don't want to track their profiles.
* </p>
*/
public boolean hasLoggedIn;
}

View File

@ -0,0 +1,27 @@
package cc.fascinated.platform;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class CurvePoint {
/**
* The acc at the curve point.
* <p>
* Acc is divided by 100 to get the actual value.
* </p>
*/
private final double acc;
/**
* The multiplier of the curve point.
* <p>
* This is the multiplier for the pp calculation.
* </p>
*/
private final double multiplier;
}

View File

@ -0,0 +1,114 @@
package cc.fascinated.platform;
import cc.fascinated.exception.impl.BadRequestException;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
@RequiredArgsConstructor
@Getter
@Setter
public abstract class Platform {
/**
* The name of the platform.
*/
private final Platforms platform;
/**
* The previous curve version for this
* platform in the database.
*/
private int previousCurveVersion;
/**
* The current curve version for getting
* pp from a star count.
*/
private final int currentCurveVersion;
/**
* The curve points for each curve version.
*/
private final Map<Integer, CurvePoint[]> curvePoints;
/**
* Checks the curve version to see if it exists.
*
* @param curveVersion the curve version to check
* @throws BadRequestException if the curve version does not exist
*/
public void checkCurveVersion(int curveVersion) {
if (!curvePoints.containsKey(curveVersion)) {
throw new BadRequestException("Curve version '%s' for platform '%s' was not found.".formatted(curveVersion, platform.getPlatformName()));
}
}
/**
* Gets the curve points for a curve version.
*
* @param curveVersion the curve version to get the curve points for
* @return the curve points
*/
public CurvePoint[] getCurve(int curveVersion) {
this.checkCurveVersion(curveVersion);
return curvePoints.get(curveVersion);
}
/**
* Gets the PP amount from the star count.
*
* @param stars the amount of stars
* @return the pp amount
*/
public abstract double getPp(double stars, double accuracy);
/**
* Called every 10 minutes to update
* the players data in QuestDB.
*/
public abstract void updatePlayers();
/**
* Called every 10 minutes to update
* the metrics for total scores, etc.
*/
public abstract void updateMetrics();
/**
* Called every day at midnight to update
* the leaderboards.
*/
public abstract void updateLeaderboards();
@AllArgsConstructor
@Getter
public enum Platforms {
SCORESABER("scoresaber");
/**
* The internal name of the platform.
*/
private final String platformName;
/**
* Gets a platform by its name.
*
* @param platform the name of the platform
* @return the platform
*/
public static Platforms getPlatform(String platform) {
for (Platforms platforms : Platforms.values()) {
if (platforms.getPlatformName().equalsIgnoreCase(platform)) {
return platforms;
}
}
throw new BadRequestException("Platform '" + platform + "' was not found.");
}
}
}

View File

@ -0,0 +1,266 @@
package cc.fascinated.platform.impl;
import cc.fascinated.common.MathUtils;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User;
import cc.fascinated.platform.CurvePoint;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.QuestDBService;
import cc.fascinated.services.ScoreSaberService;
import cc.fascinated.services.TrackedScoreService;
import cc.fascinated.services.UserService;
import io.questdb.client.Sender;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
@Component
@Log4j2
public class ScoreSaberPlatform extends Platform {
/**
* Delay in ms for requests per minute.
*/
private static final long UPDATE_DELAY = 1000L / 250L; // 150 requests per minute
/**
* The base multiplier for stars.
*/
private final double starMultiplier = 42.114296;
/**
* The ScoreSaber service to use
*/
@NonNull
private final ScoreSaberService scoreSaberService;
/**
* The user service to use
*/
@NonNull
private final UserService userService;
/**
* The Influx service to use
*/
@NonNull
private final QuestDBService questDBService;
/**
* The tracked score service to use
*/
@NonNull
private final TrackedScoreService trackedScoreService;
@Autowired
public ScoreSaberPlatform(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService, @NonNull QuestDBService questDBService,
@NonNull TrackedScoreService trackedScoreService) {
super(Platforms.SCORESABER, 1, Map.of(
1, new CurvePoint[]{
new CurvePoint(0, 0),
new CurvePoint(0.6, 0.18223233667439062),
new CurvePoint(0.65, 0.5866010012767576),
new CurvePoint(0.7, 0.6125565959114954),
new CurvePoint(0.75, 0.6451808210101443),
new CurvePoint(0.8, 0.6872268862950283),
new CurvePoint(0.825, 0.7150465663454271),
new CurvePoint(0.85, 0.7462290664143185),
new CurvePoint(0.875, 0.7816934560296046),
new CurvePoint(0.9, 0.825756123560842),
new CurvePoint(0.91, 0.8488375988124467),
new CurvePoint(0.92, 0.8728710341448851),
new CurvePoint(0.93, 0.9039994071865736),
new CurvePoint(0.94, 0.9417362980580238),
new CurvePoint(0.95, 1),
new CurvePoint(0.955, 1.0388633331418984),
new CurvePoint(0.96, 1.0871883573850478),
new CurvePoint(0.965, 1.1552120359501035),
new CurvePoint(0.97, 1.2485807759957321),
new CurvePoint(0.9725, 1.3090333065057616),
new CurvePoint(0.975, 1.3807102743105126),
new CurvePoint(0.9775, 1.4664726399289512),
new CurvePoint(0.98, 1.5702410055532239),
new CurvePoint(0.9825, 1.697536248647543),
new CurvePoint(0.985, 1.8563887693647105),
new CurvePoint(0.9875, 2.058947159052738),
new CurvePoint(0.99, 2.324506282149922),
new CurvePoint(0.99125, 2.4902905794106913),
new CurvePoint(0.9925, 2.685667856592722),
new CurvePoint(0.99375, 2.9190155639254955),
new CurvePoint(0.995, 3.2022017597337955),
new CurvePoint(0.99625, 3.5526145337555373),
new CurvePoint(0.9975, 3.996793606763322),
new CurvePoint(0.99825, 4.325027383589547),
new CurvePoint(0.999, 4.715470646416203),
new CurvePoint(0.9995, 5.019543595874787),
new CurvePoint(1, 5.367394282890631),
}
));
this.scoreSaberService = scoreSaberService;
this.userService = userService;
this.questDBService = questDBService;
this.trackedScoreService = trackedScoreService;
}
/**
* Gets the modifier for the given accuracy.
*
* @param accuracy The accuracy.
* @return The modifier.
*/
public double getModifier(double accuracy) {
accuracy = MathUtils.clamp(accuracy, 0, 100) / 100;
CurvePoint[] curve = this.getCurve(this.getCurrentCurveVersion());
if (accuracy <= 0) {
return 0;
}
if (accuracy >= 1) {
return curve[curve.length - 1].getMultiplier();
}
for (int i = 0; i < curve.length - 1; i++) {
CurvePoint point = curve[i];
CurvePoint nextPoint = curve[i + 1];
if (accuracy >= point.getAcc() && accuracy <= nextPoint.getAcc()) {
return MathUtils.lerp(point.getMultiplier(), nextPoint.getMultiplier(), (accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc()));
}
}
return 0;
}
@Override
public double getPp(double stars, double accuracy) {
if (accuracy <= 1) { // Convert the accuracy to a percentage
accuracy *= 100;
}
double pp = stars * this.starMultiplier;
return this.getModifier(accuracy) * pp;
}
@Override
public void updatePlayers() {
for (User user : this.userService.getUsers()) {
if (!user.isHasLoggedIn()) { // Check if the user has linked their account
continue;
}
ScoreSaberAccountToken account = scoreSaberService.getAccount(user); // Get the account from the ScoreSaber API
try (Sender sender = questDBService.getSender()) {
sender.table("player")
.symbol("platform", this.getPlatform().getPlatformName())
.symbol("user_id", user.getId().toString())
.doubleColumn("pp", account.getPp())
.longColumn("rank", account.getRank())
.longColumn("country_rank", account.getCountryRank())
.longColumn("total_score", account.getScoreStats().getTotalScore())
.longColumn("total_ranked_score", account.getScoreStats().getTotalRankedScore())
.doubleColumn("average_ranked_accuracy", account.getScoreStats().getAverageRankedAccuracy())
.longColumn("total_play_count", account.getScoreStats().getTotalPlayCount())
.longColumn("ranked_play_count", account.getScoreStats().getRankedPlayCount())
.atNow();
}
}
}
@Override
public void updateMetrics() {
try (Sender sender = questDBService.getSender()) {
TotalScoresResponse totalScores = trackedScoreService.getTotalScores(this.getPlatform());
sender.table("metrics")
.symbol("platform", this.getPlatform().getPlatformName())
.longColumn("total_scores", totalScores.getTotalScores())
.longColumn("total_ranked_scores", totalScores.getTotalRankedScores())
.atNow();
}
}
@Override
public void updateLeaderboards() {
// TODO: PUSH THIS
List<TrackedScore> scores = this.trackedScoreService.getTrackedScores(this.getPlatform(), true);
Map<String, ScoreSaberLeaderboardToken> leaderboards = new HashMap<>();
for (ScoreSaberLeaderboardPageToken rankedLeaderboard : this.scoreSaberService.getRankedLeaderboards()) {
for (ScoreSaberLeaderboardToken leaderboard : rankedLeaderboard.getLeaderboards()) {
leaderboards.put(leaderboard.getId(), leaderboard);
}
}
// Add any missing leaderboards
for (TrackedScore score : scores) {
if (leaderboards.containsKey(score.getLeaderboardId())) {
continue;
}
ScoreSaberLeaderboardToken leaderboard = this.scoreSaberService.getLeaderboard(score.getLeaderboardId(), true);
leaderboards.put(leaderboard.getId(), leaderboard);
}
log.info("Updating {} leaderboards for platform '{}'",
leaderboards.size(),
this.getPlatform().getPlatformName()
);
// Update the leaderboards
int finished = 0;
for (Map.Entry<String, ScoreSaberLeaderboardToken> leaderboardEntry : leaderboards.entrySet()) {
String id = leaderboardEntry.getKey();
ScoreSaberLeaderboardToken leaderboard = leaderboardEntry.getValue();
if (finished > 0) {
// Sleep to prevent rate limiting
try {
Thread.sleep(UPDATE_DELAY);
} catch (InterruptedException e) {
log.error("Failed to sleep for rate limit reset", e);
}
}
try {
List<TrackedScore> toUpdate = scores.stream().filter(score -> {
if (!score.getLeaderboardId().equals(id)) { // Check if the leaderboard ID matches
return false;
}
double pp = this.getPp(leaderboard.getStars(), score.getAccuracy());
return pp != (score.getPp() == null ? 0D : score.getPp()); // Check if the pp has changed
}).toList();
for (TrackedScore score : toUpdate) { // Update the scores
if (leaderboard.getStars() == 0) { // The leaderboard was unranked
score.setPp(0D);
}
double pp = this.getPp(leaderboard.getStars(), score.getAccuracy());
score.setPp(pp);
}
if (!toUpdate.isEmpty()) { // Save the scores
this.trackedScoreService.updateScores(toUpdate.toArray(TrackedScore[]::new));
}
finished++;
if (finished % 100 == 0 || finished == leaderboards.size()) {
log.info("Updated {}/{} leaderboards for platform '{}'",
finished,
leaderboards.size(),
this.getPlatform().getPlatformName()
);
}
} catch (Exception ex) {
log.error("An error occurred while updating leaderboard '{}'", id, ex);
}
}
}
}

View File

@ -0,0 +1,21 @@
package cc.fascinated.repository.couchdb;
import cc.fascinated.model.platform.TrackedPlatformMetric;
import org.springframework.data.repository.CrudRepository;
/**
* @author Fascinated (fascinated7)
*/
public interface MetricsRepository extends CrudRepository<TrackedPlatformMetric, String> {
/**
* SELECT
* platform,
* last(total_scores) - first(total_scores) AS scores_set,
* timestamp
* FROM metrics
* TIMESTAMP(timestamp)
* SAMPLE BY 1d
* ORDER BY timestamp DESC;
*/
}

View File

@ -0,0 +1,90 @@
package cc.fascinated.repository.couchdb;
import cc.fascinated.model.score.TrackedScore;
import jakarta.transaction.Transactional;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
public interface TrackedScoreRepository extends CrudRepository<TrackedScore, String> {
/**
* Ensures that the deduplication of the scores is done.
*/
@Modifying @Transactional
@Query(value = "ALTER TABLE score DEDUP ENABLE UPSERT KEYS(timestamp, score_id)", nativeQuery = true)
void ensureDeduplication();
/**
* Updates the pp of a score.
*
* @param scoreId the ID of the score
* @param pp the new pp of the score
*/
@Modifying @Transactional
@Query(value = "UPDATE score SET pp = :pp WHERE score_id = :scoreId", nativeQuery = true)
void updateScorePp(@Param("scoreId") String scoreId, @Param("pp") double pp);
/**
* Gets a list of top tracked scores
* sorted by pp from the platform
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
@Query(value = "SELECT * FROM score WHERE platform = :platform AND pp > 0 ORDER BY pp DESC LIMIT :amount", nativeQuery = true)
List<TrackedScore> findTopRankedScores(@Param("platform") String platform, @Param("amount") int amount);
/**
* Gets all tracked scores from a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT * FROM score WHERE platform = :platform", nativeQuery = true)
List<TrackedScore> findAllByPlatform(String platform);
/**
* Gets all tracked scores from a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT * FROM score WHERE platform = :platform AND pp > 0", nativeQuery = true)
List<TrackedScore> findAllByPlatformRankedOnly(String platform);
/**
* Gets the total amount of scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the total amount of scores for the platform
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform", nativeQuery = true)
int countTotalScores(@Param("platform") String platform);
/**
* Gets the total amount of ranked scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the total amount of ranked scores for the platform
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform AND pp > 0", nativeQuery = true)
int countTotalRankedScores(@Param("platform") String platform);
/**
* Gets all scores for a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform AND pp > :pp", nativeQuery = true)
int getScoreCountOverPpThreshold(@Param("platform") String platform, @Param("pp") double pp);
}

View File

@ -0,0 +1,10 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* @author Fascinated (fascinated7)
*/
public interface ScoreSaberLeaderboardRepository extends MongoRepository<ScoreSaberLeaderboardToken, String> {
}

View File

@ -0,0 +1,20 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.user.User;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
public interface UserRepository extends MongoRepository<User, UUID> {
/**
* Finds a user by their steam id.
*
* @param steamId the steam id of the user
* @return the user
*/
Optional<User> findBySteamId(String steamId);
}

View File

@ -0,0 +1,31 @@
package cc.fascinated.services;
import com.mongodb.client.MongoCollection;
import org.bson.Document;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.stereotype.Service;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class MongoService {
public static MongoService INSTANCE;
private final MongoTemplate mongoTemplate;
@Autowired
public MongoService(MongoTemplate mongo) {
INSTANCE = this;
this.mongoTemplate = mongo;
}
/**
* Get the platforms collection
*
* @return The platforms collection
*/
public MongoCollection<Document> getPlatformsCollection() {
return mongoTemplate.getCollection("platforms");
}
}

View File

@ -0,0 +1,10 @@
package cc.fascinated.services;
import org.springframework.stereotype.Service;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class PlatformMetricsService {
}

View File

@ -0,0 +1,150 @@
package cc.fascinated.services;
import cc.fascinated.platform.Platform;
import cc.fascinated.platform.impl.ScoreSaberPlatform;
import cc.fascinated.repository.couchdb.TrackedScoreRepository;
import com.mongodb.client.model.Filters;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.bson.Document;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2(topic = "PlatformService")
public class PlatformService {
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1);
/**
* The loaded platforms.
*/
private final List<Platform> platforms = new ArrayList<>();
@Autowired
public PlatformService(@NonNull ApplicationContext context) {
log.info("Registering platforms...");
registerPlatform(context.getBean(ScoreSaberPlatform.class));
log.info("Loaded %s platforms.".formatted(this.platforms.size()));
}
/**
* Updates the platform metrics.
* <p>
* This method is scheduled to run every minute.
* </p>
*/
@Scheduled(cron = "0 */5 * * * *")
public void updateMetrics() {
log.info("Updating %s platform metrics...".formatted(this.platforms.size()));
for (Platform platform : this.platforms) {
platform.updateMetrics();
}
log.info("Finished updating platform metrics.");
}
/**
* Updates the platform players.
* <p>
* This method is scheduled to run every 15 minutes.
* </p>
*/
@Scheduled(cron = "0 */15 * * * *")
public void updateScores() {
log.info("Updating %s platform players...".formatted(this.platforms.size()));
for (Platform platform : this.platforms) {
platform.updatePlayers();
}
log.info("Finished updating platform players.");
}
/**
* Refreshes the platform leaderboards.
* <p>
* This method is scheduled to run every day at midnight.
* This is to ensure that the leaderboards are up-to-date.
* </p>
*/
@Scheduled(cron = "0 0 0 * * *")
public void refreshPlatformLeaderboards() {
log.info("Refreshing platform leaderboards...");
EXECUTOR_SERVICE.execute(() -> {
for (Platform platform : this.platforms) {
platform.updateLeaderboards();
}
});
log.info("Finished refreshing platform leaderboards.");
}
/**
* Registers a platform.
*
* @param platform the platform to register
*/
public void registerPlatform(Platform platform) {
this.platforms.add(platform);
log.info(" - Registered platform '%s'".formatted(platform.getPlatform().getPlatformName()));
// Find the platform in the database
Document document = MongoService.INSTANCE.getPlatformsCollection().find(Filters.eq("_id", platform.getPlatform().getPlatformName())).first();
if (document == null) { // The platform was not found
document = new Document("_id", platform.getPlatform().getPlatformName());
MongoService.INSTANCE.getPlatformsCollection().insertOne(document);
}
// Set the previous curve version
platform.setPreviousCurveVersion(document.getInteger("currentCurveVersion", platform.getCurrentCurveVersion()));
// The curve was updated
if (platform.getPreviousCurveVersion() != platform.getCurrentCurveVersion()) {
log.info(" - Updated previous curve version for platform '%s' to '%s'".formatted(
platform.getPlatform().getPlatformName(),
platform.getPreviousCurveVersion()
));
log.info("Updating scores for platform '%s'...".formatted(platform.getPlatform().getPlatformName()));
Document finalDocument = document;
EXECUTOR_SERVICE.execute(() -> {
platform.updateLeaderboards();
this.savePlatform(platform, finalDocument);
}); // Update the leaderboards
} else {
this.savePlatform(platform, document);
}
}
/**
* Saves the platform.
*
* @param platform the platform to save
* @param document the document to save
*/
public void savePlatform(Platform platform, Document document) {
document.put("currentCurveVersion", platform.getCurrentCurveVersion());
MongoService.INSTANCE.getPlatformsCollection().replaceOne(Filters.eq("_id", platform.getPlatform().getPlatformName()), document);
}
/**
* Gets the ScoreSaber platform.
*
* @return the ScoreSaber platform
*/
public ScoreSaberPlatform getScoreSaberPlatform() {
for (Platform platform : this.platforms) {
if (platform.getPlatform().getPlatformName().equalsIgnoreCase(Platform.Platforms.SCORESABER.getPlatformName())) {
return (ScoreSaberPlatform) platform;
}
}
return null;
}
}

View File

@ -0,0 +1,55 @@
package cc.fascinated.services;
import io.questdb.client.Sender;
import lombok.Getter;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Getter
@Log4j2(topic = "QuestDB Service")
public class QuestDBService {
/*
* The host of the QuestDB instance.
*/
private final String host;
/*
* The username of the QuestDB instance.
*/
private final String username;
/*
* The password of the QuestDB instance.
*/
private final String password;
@Autowired
public QuestDBService(@Value("${questdb.host}") @NonNull String host,
@Value("${questdb.username}") @NonNull String username,
@Value("${questdb.password}") @NonNull String password
) {
this.host = host;
this.username = username;
this.password = password;
}
/**
* Gets a new sender instance for QuestDB.
*
* @return the sender
*/
public Sender getSender() {
return Sender.builder(Sender.Transport.HTTP)
.address(host) // set host
.httpUsernamePassword(username, password) // set username and password
.retryTimeoutMillis(3000) // 3 seconds
.build();
}
}

View File

@ -0,0 +1,142 @@
package cc.fascinated.services;
import cc.fascinated.common.Request;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User;
import cc.fascinated.repository.mongo.ScoreSaberLeaderboardRepository;
import kong.unirest.core.HttpResponse;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2(topic = "ScoreSaber Service")
public class ScoreSaberService {
private static final String SCORESABER_API = "https://scoresaber.com/api/";
private static final String GET_PLAYER_ENDPOINT = SCORESABER_API + "player/%s/full";
private static final String GET_LEADERBOARD_ENDPOINT = SCORESABER_API + "leaderboard/by-id/%s/info";
private static final String GET_RANKED_LEADERBOARDS_ENDPOINT = SCORESABER_API + "leaderboards?ranked=true&page=%s&withMetadata=true";
/**
* The ScoreSaber leaderboard repository to use.
*/
@NonNull
private final ScoreSaberLeaderboardRepository leaderboardRepository;
@Autowired
public ScoreSaberService(@NonNull ScoreSaberLeaderboardRepository leaderboardRepository) {
this.leaderboardRepository = leaderboardRepository;
}
/**
* Gets the account for a user.
*
* @param user the user to get the account for
* @return the ScoreSaber account
* @throws BadRequestException if an error occurred while getting the account
*/
public ScoreSaberAccountToken getAccount(User user) {
if (user.getSteamId() == null) {
throw new BadRequestException("%s does not have a linked ScoreSaber account".formatted(user.getUsername()));
}
HttpResponse<ScoreSaberAccountToken> response = Request.get(GET_PLAYER_ENDPOINT.formatted(user.getSteamId()), ScoreSaberAccountToken.class);
if (response.getParsingError().isPresent()) { // Failed to parse the response
throw new BadRequestException("Failed to parse ScoreSaber account for '%s'".formatted(user.getUsername()));
}
if (response.getStatus() != 200) { // The response was not successful
throw new BadRequestException("Failed to get ScoreSaber account for '%s'".formatted(user.getUsername()));
}
return response.getBody();
}
/**
* Gets a leaderboard for a leaderboard id.
*
* @param leaderboardId the leaderboard id to get the leaderboard for
* @return the ScoreSaber leaderboard
* @throws BadRequestException if an error occurred while getting the leaderboard
*/
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId, boolean bypassCache) {
Optional<ScoreSaberLeaderboardToken> leaderboardOptional = leaderboardRepository.findById(leaderboardId);
if (leaderboardOptional.isPresent() && !bypassCache) { // The leaderboard is cached
return leaderboardOptional.get();
}
HttpResponse<ScoreSaberLeaderboardToken> response = Request.get(GET_LEADERBOARD_ENDPOINT.formatted(leaderboardId), ScoreSaberLeaderboardToken.class);
if (response.getParsingError().isPresent()) { // Failed to parse the response
throw new BadRequestException("Failed to parse ScoreSaber leaderboard for '%s'".formatted(leaderboardId));
}
if (response.getStatus() != 200) { // The response was not successful
throw new BadRequestException("Failed to get ScoreSaber leaderboard for '%s'".formatted(leaderboardId));
}
ScoreSaberLeaderboardToken leaderboard = response.getBody();
leaderboardRepository.save(leaderboard);
return leaderboard;
}
/**
* Gets a list of all the ranked leaderboards.
*
* @return the ranked leaderboards
* @throws BadRequestException if an error occurred while getting the leaderboards
*/
public List<ScoreSaberLeaderboardPageToken> getRankedLeaderboards() {
log.info("Getting all ranked leaderboards...");
List<ScoreSaberLeaderboardPageToken> pages = new LinkedList<>();
int page = 1;
do {
log.info("Getting ranked leaderboard page '%s'...".formatted(page));
ScoreSaberLeaderboardPageToken pageToken = getRankedLeaderboards(page);
pages.add(pageToken);
for (ScoreSaberLeaderboardToken leaderboard : pageToken.getLeaderboards()) {
this.leaderboardRepository.save(leaderboard);
}
page++;
} while (page <= ((pages.get(0).getMetadata().getTotal() / pages.get(0).getMetadata().getItemsPerPage()) + 1));
log.info("Finished getting all ranked leaderboards, found '{}' pages.", pages.size());
return pages;
}
/**
* Gets a list of the ranked leaderboards for a page.
*
* @param page the page to get the leaderboards for
* @return the ranked leaderboards
* @throws BadRequestException if an error occurred while getting the leaderboards
*/
public ScoreSaberLeaderboardPageToken getRankedLeaderboards(int page) {
HttpResponse<ScoreSaberLeaderboardPageToken> response = Request.get(GET_RANKED_LEADERBOARDS_ENDPOINT.formatted(page), ScoreSaberLeaderboardPageToken.class);
if (response.getParsingError().isPresent()) { // Failed to parse the response
throw new BadRequestException("Failed to parse ScoreSaber leaderboard page for page '%s'".formatted(page));
}
if (response.getStatus() != 200) { // The response was not successful
throw new BadRequestException("Failed to get ScoreSaber leaderboard page for page '%s'".formatted(page));
}
return response.getBody();
}
/**
* Gets a leaderboard for a leaderboard id.
*
* @param leaderboardId the leaderboard id to get the leaderboard for
* @return the ScoreSaber leaderboard
* @throws BadRequestException if an error occurred while getting the leaderboard
*/
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId) {
return getLeaderboard(leaderboardId, false);
}
}

View File

@ -0,0 +1,117 @@
package cc.fascinated.services;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.score.ScoresOverResponse;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.platform.Platform;
import cc.fascinated.repository.couchdb.TrackedScoreRepository;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class TrackedScoreService {
/**
* The scores over thresholds.
*/
private static final int[] SCORES_OVER = {1000, 900, 800, 700, 600, 500, 400, 300, 200, 100};
/**
* The tracked score repository to use.
*/
@NonNull
private final TrackedScoreRepository trackedScoreRepository;
@Autowired
public TrackedScoreService(@NonNull TrackedScoreRepository trackedScoreRepository) {
this.trackedScoreRepository = trackedScoreRepository;
this.trackedScoreRepository.ensureDeduplication();
}
/**
* Gets a list of top tracked scores
* sorted by pp from the platform
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
public List<TrackedScore> getTopScores(Platform.Platforms platform, int amount) {
List<TrackedScore> scores = trackedScoreRepository.findTopRankedScores(platform.getPlatformName(), amount);
if (scores.isEmpty()) {
throw new BadRequestException("No scores found for platform " + platform.getPlatformName());
}
return scores;
}
/**
* Gets the amount of scores over pp thresholds.
*
* @param platform the platform to get the scores from
* @return the scores over pp thresholds
*/
public ScoresOverResponse getScoresOver(Platform.Platforms platform) {
ScoresOverResponse scoresOverResponse = new ScoresOverResponse();
for (int i : SCORES_OVER) {
scoresOverResponse.addScores(i, trackedScoreRepository.getScoreCountOverPpThreshold(platform.getPlatformName(), i));
}
return scoresOverResponse;
}
/**
* Gets the total amount of scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the total amount of scores for the platform
*/
public TotalScoresResponse getTotalScores(Platform.Platforms platform) {
return new TotalScoresResponse(
trackedScoreRepository.countTotalScores(platform.getPlatformName()),
trackedScoreRepository.countTotalRankedScores(platform.getPlatformName())
);
}
/**
* Gets a list of tracked scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the tracked scores
*/
public List<TrackedScore> getTrackedScores(Platform.Platforms platform, boolean onlyRanked) {
if (onlyRanked) {
return trackedScoreRepository.findAllByPlatformRankedOnly(platform.getPlatformName());
}
return trackedScoreRepository.findAllByPlatform(platform.getPlatformName());
}
/**
* Saves a list of tracked scores.
*
* @param scores the scores to save
*/
public void updateScores(TrackedScore... scores) {
for (TrackedScore score : scores) {
this.trackedScoreRepository.updateScorePp(score.getScoreId(), score.getPp());
}
}
/**
* Deletes a list of tracked scores.
*
* @param scores the scores to delete
*/
public void deleteScores(TrackedScore... scores) {
for (TrackedScore score : scores) {
this.trackedScoreRepository.delete(score);
}
}
}

View File

@ -0,0 +1,79 @@
package cc.fascinated.services;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.user.User;
import cc.fascinated.repository.mongo.UserRepository;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class UserService {
/**
* The user repository to use
*/
@NonNull private final UserRepository userRepository;
@Autowired
public UserService(@NonNull UserRepository userRepository) {
this.userRepository = userRepository;
}
/**
* Gets a user by their id
*
* @param steamId the id of the user's steam profile
* @return the user
* @throws BadRequestException if the user is not found
*/
public User getUser(String steamId) {
if (!this.validateSteamId(steamId)) {
throw new BadRequestException("Invalid steam id");
}
Optional<User> userOptional = this.userRepository.findBySteamId(steamId);
if (userOptional.isEmpty()) {
// todo: check the steam API to see if the user exists
User user = new User(UUID.randomUUID());
user.setSteamId(steamId);
return userRepository.save(user);
}
return userOptional.get();
}
/**
* Creates a user in the database
*
* @param user the user to create
* @return the created user
*/
public User createUser(User user) {
return this.userRepository.save(user);
}
/**
* Gets all users in the database
*
* @return all users
*/
public List<User> getUsers() {
return (List<User>) this.userRepository.findAll();
}
/**
* Validates a steam id
*
* @param steamId the steam id to validate
* @return if the steam id is valid
*/
public boolean validateSteamId(String steamId) {
return steamId != null && steamId.length() == 17;
}
}

View File

@ -0,0 +1,71 @@
package cc.fascinated.websocket;
import lombok.Getter;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;
/**
* @author Fascinated (fascinated7)
*/
@Log4j2
@Getter
public abstract class Websocket extends TextWebSocketHandler {
/**
* The name of the WebSocket.
*/
private final String name;
/**
* The URL of the WebSocket.
*/
private final String url;
/**
* The WebSocket session.
*/
private WebSocketSession webSocketSession;
public Websocket(@NonNull String name, @NonNull String url) {
this.name = name;
this.url = url;
connectWebSocket(); // Connect to the WebSocket.
}
/**
* Handles a message received from the WebSocket.
*
* @param message the message received
*/
public abstract void handleMessage(@NonNull TextMessage message);
@Override
protected final void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {
this.handleMessage(message);
}
/**
* Connects to the ScoreSaber WebSocket.
*/
@SneakyThrows
private void connectWebSocket() {
log.info("Connecting to the {}", this.getName());
this.webSocketSession = new StandardWebSocketClient().execute(this, this.getUrl()).get();
}
@Override
public final void afterConnectionEstablished(@NonNull WebSocketSession session) {
log.info("Connected to the {}", this.getName());
}
@Override
public final void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) {
log.info("Disconnected from the {}", this.getName());
connectWebSocket(); // Reconnect to the WebSocket.
}
}

View File

@ -0,0 +1,110 @@
package cc.fascinated.websocket.impl;
import cc.fascinated.common.ScoreSaberUtils;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken;
import cc.fascinated.model.token.ScoreSaberWebsocketDataToken;
import cc.fascinated.platform.Platform;
import cc.fascinated.platform.impl.ScoreSaberPlatform;
import cc.fascinated.services.PlatformService;
import cc.fascinated.services.QuestDBService;
import cc.fascinated.services.UserService;
import cc.fascinated.websocket.Websocket;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.questdb.client.Sender;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
/**
* @author Fascinated (fascinated7)
*/
@Component
@Log4j2(topic = "ScoreSaber Websocket")
public class ScoreSaberWebsocket extends Websocket {
/**
* The Jackson deserializer to use.
*/
private final ObjectMapper objectMapper;
/**
* The user service to use
*/
private final UserService userService;
/**
* The Influx service to use
*/
private final QuestDBService questDBService;
/**
* The platform service to use
*/
private final PlatformService platformService;
@Autowired
public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull QuestDBService questDBService,
@NonNull PlatformService platformService) {
super("ScoreSaber", "wss://scoresaber.com/ws");
this.objectMapper = objectMapper;
this.userService = userService;
this.questDBService = questDBService;
this.platformService = platformService;
}
@Override @SneakyThrows
public void handleMessage(@NonNull TextMessage message) {
String payload = message.getPayload();
if (payload.equals("Connected to the ScoreSaber WSS")) { // Ignore the connection message
return;
}
ScoreSaberWebsocketDataToken response = this.objectMapper.readValue(payload, ScoreSaberWebsocketDataToken.class);
if (!response.getCommandName().equals("score")) { // Ignore non-score messages
return;
}
// Decode the message using Jackson
ScoreSaberPlayerScoreToken scoreToken = this.objectMapper.readValue(response.getCommandData().toString(), ScoreSaberPlayerScoreToken.class);
ScoreSaberScoreToken score = scoreToken.getScore();
ScoreSaberLeaderboardToken leaderboard = scoreToken.getLeaderboard();
ScoreSaberScoreToken.LeaderboardPlayerInfo player = score.getLeaderboardPlayerInfo();
if (!userService.validateSteamId(player.getId())) { // Validate the Steam ID
return;
}
double accuracy = ((double) score.getBaseScore() / leaderboard.getMaxScore()) * 100;
String difficulty = ScoreSaberUtils.parseDifficulty(leaderboard.getDifficulty().getDifficulty());
double pp = platformService.getScoreSaberPlatform().getPp(leaderboard.getStars(), accuracy); // Recalculate the PP
try (Sender sender = questDBService.getSender()) {
sender.table("score")
.symbol("platform", Platform.Platforms.SCORESABER.getPlatformName())
// Player information
.symbol("player_id", player.getId())
// Score information
.symbol("leaderboard_id", leaderboard.getId())
.symbol("score_id", score.getId())
.doubleColumn("pp", pp)
.longColumn("rank", score.getRank())
.longColumn("score", score.getBaseScore())
.longColumn("modified_score", score.getModifiedScore())
.doubleColumn("weight", score.getWeight())
.stringColumn("modifiers", score.getModifiers())
.doubleColumn("multiplier", score.getMultiplier())
.longColumn("missed_notes", score.getMissedNotes())
.longColumn("bad_cuts", score.getBadCuts())
.longColumn("max_combo", score.getMaxCombo())
.doubleColumn("accuracy", accuracy)
.stringColumn("difficulty", difficulty)
.atNow();
}
log.info("Tracked score for {} with a score of {} and {}pp on {} with a rank of {}",
player.getId(), score.getBaseScore(), score.getPp(), leaderboard.getId(), score.getRank());
}
}

View File

@ -0,0 +1,48 @@
# Server Configuration
server:
address: 0.0.0.0
port: 7500
# Spring Configuration
spring:
data:
# MongoDB Configuration
mongodb:
uri: "mongodb://bs-tracker:p4$$w0rd@localhost:27017"
database: "bs-tracker"
auto-index-creation: true # Automatically create collection indexes
datasource:
url: jdbc:postgresql://localhost:5432/<YOUR_DATABASE_NAME>
username: <YOUR_USERNAME>
password: <YOUR_PASSWORD>
jpa:
hibernate:
ddl-auto: <create | create-drop | update | validate | none>
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
# Don't serialize null values by default with Jackson
jackson:
default-property-inclusion: non_null
# QuestDB Configuration
questdb:
host: localhost:9000
username: admin
password: quest
# DO NOT TOUCH BELOW
management:
# Disable all actuator endpoints
endpoints:
web:
exposure:
exclude:
- "*"
# Disable default metrics
influx:
metrics:
export:
enabled: false