This commit is contained in:
Lee 2024-07-23 12:22:06 +01:00
commit 7262188add
44 changed files with 1966 additions and 0 deletions

0
.gitea/workflows/ci.yml Normal file

31
.gitignore vendored Normal 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/

3
.idea/.gitignore generated vendored Normal file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

7
.idea/encodings.xml generated Normal file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

6
.idea/git_toolbox_blame.xml generated Normal file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxBlameSettings">
<option name="version" value="2" />
</component>
</project>

14
.idea/misc.xml generated Normal file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="azul-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

7
.idea/vcs.xml generated Normal file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/docker" vcs="Git" />
</component>
</project>

17
docker/docker-compose.yml Normal file

@ -0,0 +1,17 @@
version: '3.8'
services:
mongodb:
image: mongo:latest
container_name: mongodb
ports:
- "27000:27017"
volumes:
- ./mongodb:/data/db
questdb:
image: questdb/questdb:8.0.3
ports:
- "8812:8812"
- "9000:9000"
volumes:
- "./questdb:/var/lib/questdb"

118
pom.xml Normal 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-SNAPSHOT</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.0</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.0.3</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>

@ -0,0 +1,43 @@
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);
}
}

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

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

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

@ -0,0 +1,58 @@
package cc.fascinated.controller;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.score.TrackedScoreDTO;
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
* 100 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<TrackedScoreDTO>> 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 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)));
}
}

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

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

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

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

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

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

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

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

@ -0,0 +1,96 @@
package cc.fascinated.model.score;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@Entity
@Getter
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;
/**
* Gets the Tracked Score as a DTO
*/
public TrackedScoreDTO getAsDTO() {
return new TrackedScoreDTO(scoreId, playerId, leaderboardId, pp, rank, score, missedNotes, accuracy);
}
}

@ -0,0 +1,53 @@
package cc.fascinated.model.score;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class TrackedScoreDTO {
/**
* 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 number of misses in the score.
*/
private Long missedNotes;
/**
* The accuracy of the score.
*/
private Double accuracy;
}

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

@ -0,0 +1,145 @@
package cc.fascinated.model.token;
import lombok.Getter;
import lombok.ToString;
import java.util.List;
@Getter @ToString
public class ScoreSaberLeaderboardToken {
/**
* The ID of the leaderboard.
*/
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;
}
}

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

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

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

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

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

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

@ -0,0 +1,55 @@
package cc.fascinated.platform;
import cc.fascinated.exception.impl.BadRequestException;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public abstract class Platform {
/**
* The name of the platform.
*/
private final Platforms platform;
/**
* 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();
@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.");
}
}
}

@ -0,0 +1,93 @@
package cc.fascinated.platform.impl;
import cc.fascinated.model.score.TotalScoresMetric;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.user.User;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* @author Fascinated (fascinated7)
*/
@Component
public class ScoreSaberPlatform extends Platform {
/**
* 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);
this.scoreSaberService = scoreSaberService;
this.userService = userService;
this.questDBService = questDBService;
this.trackedScoreService = trackedScoreService;
}
@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()) {
TotalScoresMetric 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();
}
}
}

@ -0,0 +1,44 @@
package cc.fascinated.repository.couchdb;
import cc.fascinated.model.score.TrackedScore;
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> {
/**
* 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 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);
}

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

@ -0,0 +1,50 @@
package cc.fascinated.services;
import cc.fascinated.platform.Platform;
import cc.fascinated.platform.impl.ScoreSaberPlatform;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
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;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2(topic = "PlatformService")
public class PlatformService {
/**
* The loaded platforms.
*/
private final List<Platform> platforms = new ArrayList<>();
@Autowired
public PlatformService(@NonNull ApplicationContext context) {
registerPlatform(context.getBean(ScoreSaberPlatform.class));
}
@Scheduled(cron = "0 */1 * * * *")
public void updatePlatforms() {
log.info("Updating %s platforms...".formatted(this.platforms.size()));
for (Platform platform : this.platforms) {
platform.updatePlayers();
platform.updateMetrics();
}
log.info("Finished updating platforms.");
}
/**
* Registers a platform.
*
* @param platform the platform to register
*/
public void registerPlatform(Platform platform) {
this.platforms.add(platform);
}
}

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

@ -0,0 +1,41 @@
package cc.fascinated.services;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.user.User;
import kong.unirest.core.HttpResponse;
import kong.unirest.core.Unirest;
import org.springframework.stereotype.Service;
/**
* @author Fascinated (fascinated7)
*/
@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";
/**
* Gets the ScoreSaber 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 = Unirest.get(GET_PLAYER_ENDPOINT.formatted(user.getSteamId()))
.asObject(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();
}
}

@ -0,0 +1,59 @@
package cc.fascinated.services;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.score.TotalScoresMetric;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.model.score.TrackedScoreDTO;
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 tracked score repository to use.
*/
@NonNull private final TrackedScoreRepository trackedScoreRepository;
@Autowired
public TrackedScoreService(@NonNull TrackedScoreRepository trackedScoreRepository) {
this.trackedScoreRepository = trackedScoreRepository;
}
/**
* 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<TrackedScoreDTO> 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.stream().map(TrackedScore::getAsDTO).toList();
}
/**
* 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 TotalScoresMetric getTotalScores(Platform.Platforms platform) {
return new TotalScoresMetric(
trackedScoreRepository.countTotalScores(platform.getPlatformName()),
trackedScoreRepository.countTotalRankedScores(platform.getPlatformName())
);
}
}

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

@ -0,0 +1,53 @@
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.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;
/**
* @author Fascinated (fascinated7)
*/
@Log4j2
@Getter
public class Websocket extends TextWebSocketHandler {
/**
* The name of the WebSocket.
*/
private final String name;
/**
* The URL of the WebSocket.
*/
private final String url;
public Websocket(@NonNull String name, @NonNull String url) {
this.name = name;
this.url = url;
connectWebSocket(); // Connect to the WebSocket.
}
/**
* Connects to the ScoreSaber WebSocket.
*/
@SneakyThrows
private void connectWebSocket() {
log.info("Connecting to the {}", this.getName());
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.
}
}

@ -0,0 +1,101 @@
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.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.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
/**
* @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;
@Autowired
public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull QuestDBService questDBService) {
super("ScoreSaber", "wss://scoresaber.com/ws");
this.objectMapper = objectMapper;
this.userService = userService;
this.questDBService = questDBService;
}
@Override @SneakyThrows
protected void handleTextMessage(@NotNull WebSocketSession session, @NotNull 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());
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", score.getPp())
.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 '%s' on '%s'".formatted(player.getName(), leaderboard.getSongName()));
}
}

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