initial testing
Some checks failed
Deploy App / docker (ubuntu-latest, 2.44.0, 17, 3.8.5) (push) Failing after 1m11s

This commit is contained in:
Lee 2024-04-25 05:05:10 +01:00
commit 4582be43b3
34 changed files with 2188 additions and 0 deletions

51
.gitea/workflows/ci.yml Normal file

@ -0,0 +1,51 @@
name: Deploy App
on:
push:
branches: ["master"]
paths-ignore:
- .gitignore
- README.md
- LICENSE
- docker-compose.yml
jobs:
docker:
strategy:
matrix:
arch: ["ubuntu-latest"]
git-version: ["2.44.0"]
java-version: ["17"]
maven-version: ["3.8.5"]
runs-on: ${{ matrix.arch }}
# Steps to run
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@v4
# Setup Java and Maven
- name: Set up JDK and Maven
uses: s4u/setup-maven-action@v1.12.0
with:
java-version: ${{ matrix.java-version }}
distribution: "zulu"
maven-version: ${{ matrix.maven-version }}
# Run JUnit Tests
- name: Run Tests
run: mvn --batch-mode test -q
# Re-checkout to reset the FS before deploying to Dokku
- name: Checkout - Reset FS
uses: actions/checkout@v4
with:
fetch-depth: 0
# Deploy to Dokku
- name: Push to dokku
uses: dokku/github-action@master
with:
git_remote_url: "ssh://dokku@10.0.50.175:22/paste-backend"
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}

31
.gitignore vendored Normal file

@ -0,0 +1,31 @@
### ME template
*.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/
.idea_modules/
atlassian-ide-plugin.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
git.properties
pom.xml.versionsBackup
application.yml
target/

27
Dockerfile Normal file

@ -0,0 +1,27 @@
# Stage 1: Build the application
FROM maven:3.9.6-eclipse-temurin-17-alpine AS builder
# Set the working directory
WORKDIR /home/container
# Copy the current directory contents into the container at /home/container
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.11_9-jre-focal
# Set the working directory
WORKDIR /home/container
# Copy the built jar file from the builder stage
COPY --from=builder /home/container/target/Paste-Backend.jar .
# Make port 3000 available to the world outside this container
EXPOSE 3000
ENV PORT=3000
# Run the jar file
CMD java -jar ScoreSaberUtils-Backend.jar -Djava.awt.headless=true

9
LICENSE Normal file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 Liam (Fascinated)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

106
pom.xml Normal file

@ -0,0 +1,106 @@
<?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>ScoreSaberUtils</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- Build Steps -->
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<!-- Spring -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>build-info</id>
<goals>
<goal>build-info</goal>
</goals>
<configuration>
<additionalProperties>
<description>${project.description}</description>
</additionalProperties>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MongoDB for data storage -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Libraries -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.8</version>
</dependency>
<!-- Websockets -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo.spring3x</artifactId>
<version>4.12.6</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

6
renovate.json Normal file

@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

@ -0,0 +1,34 @@
package cc.fascinated.backend;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
@Log4j2(topic = "Main")
@SpringBootApplication
public class Main {
public static Gson GSON = new GsonBuilder().create();
@SneakyThrows
public static void main(String[] args) {
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
SpringApplication.run(Main.class, args); // Start the application
}
}

@ -0,0 +1,23 @@
package cc.fascinated.backend.common;
import lombok.experimental.UtilityClass;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Date;
@UtilityClass
public class DateUtils {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_INSTANT;
/**
* Gets the date from a string.
*
* @param date The date string.
* @return The date.
*/
public static Date getDateFromString(String date) {
return Date.from(Instant.from(FORMATTER.parse(date)));
}
}

@ -0,0 +1,42 @@
package cc.fascinated.backend.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.experimental.UtilityClass;
@UtilityClass
public class IPUtils {
/**
* The headers that contain the IP.
*/
private static final String[] IP_HEADERS = new String[] {
"CF-Connecting-IP",
"X-Forwarded-For"
};
/**
* Get the real IP from the given request.
*
* @param request the request
* @return the real IP
*/
public static String getRealIp(HttpServletRequest request) {
String ip = request.getRemoteAddr();
for (String headerName : IP_HEADERS) {
String header = request.getHeader(headerName);
if (header == null) {
continue;
}
if (!header.contains(",")) { // Handle single IP
ip = header;
break;
}
// Handle multiple IPs
String[] ips = header.split(",");
for (String ipHeader : ips) {
ip = ipHeader;
break;
}
}
return ip;
}
}

@ -0,0 +1,19 @@
package cc.fascinated.backend.common;
public class Timer {
/**
* Schedules a task to run after a delay.
*
* @param runnable the task to run
* @param delay the delay before the task runs
*/
public static void scheduleRepeating(Runnable runnable, long delay, long period) {
new java.util.Timer().scheduleAtFixedRate(new java.util.TimerTask() {
@Override
public void run() {
runnable.run();
}
}, delay, period);
}
}

@ -0,0 +1,77 @@
package cc.fascinated.backend.common;
import cc.fascinated.backend.exception.impl.RateLimitException;
import lombok.experimental.UtilityClass;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@UtilityClass
public class WebRequest {
/**
* The web client.
*/
private static final RestClient CLIENT;
static {
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
requestFactory.setConnectTimeout(2500); // 2.5 seconds
CLIENT = RestClient.builder()
.requestFactory(requestFactory)
.build();
}
/**
* Gets a response from the given URL.
*
* @param url the url
* @return the response
* @param <T> the type of the response
*/
public static <T> T getAsEntity(String url, Class<T> clazz) throws RateLimitException {
ResponseEntity<T> responseEntity = CLIENT.get()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
if (responseEntity.getStatusCode().isError()) {
return null;
}
if (responseEntity.getStatusCode().isSameCodeAs(HttpStatus.TOO_MANY_REQUESTS)) {
throw new RateLimitException("Rate limit reached");
}
return responseEntity.getBody();
}
/**
* Gets a response from the given URL.
*
* @param url the url
* @return the response
*/
public static ResponseEntity<?> get(String url, Class<?> clazz) {
return CLIENT.get()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
}
/**
* Gets a response from the given URL.
*
* @param url the url
* @return the response
*/
public static ResponseEntity<?> head(String url, Class<?> clazz) {
return CLIENT.head()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
}
}

@ -0,0 +1,25 @@
package cc.fascinated.backend.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;
@Configuration
public class Config {
@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,37 @@
package cc.fascinated.backend.controller;
import cc.fascinated.backend.model.account.Account;
import cc.fascinated.backend.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping(value = "/")
public class AccountController {
private final AccountService accountService;
@Autowired
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@GetMapping(value = "/")
public ResponseEntity<?> home() {
return ResponseEntity.ok(Map.of(
"status", "OK"
));
}
@GetMapping(value = "/account/{id}")
public ResponseEntity<?> getAccount(@PathVariable String id) {
Account account = accountService.getAccount(id);
return ResponseEntity.ok(account);
}
}

@ -0,0 +1,45 @@
package cc.fascinated.backend.exception;
import cc.fascinated.backend.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,9 @@
package cc.fascinated.backend.exception.impl;
import lombok.experimental.StandardException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@StandardException
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException { }

@ -0,0 +1,12 @@
package cc.fascinated.backend.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.backend.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,54 @@
package cc.fascinated.backend.log;
import cc.fascinated.backend.common.IPUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
@ControllerAdvice
@Slf4j(topic = "Req Transaction")
public class TransactionLogger implements ResponseBodyAdvice<Object> {
@Override
public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType,
@NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType, @NonNull ServerHttpRequest rawRequest,
@NonNull ServerHttpResponse rawResponse) {
HttpServletRequest request = ((ServletServerHttpRequest) rawRequest).getServletRequest();
// Get the request ip ip
String ip = IPUtils.getRealIp(request);
// Getting params
Map<String, String> params = new HashMap<>();
for (Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
params.put(entry.getKey(), Arrays.toString(entry.getValue()));
}
// Logging the request
log.info(String.format("[Req] %s | %s | '%s', params=%s",
request.getMethod(),
ip,
request.getRequestURI(),
params
));
return body;
}
@Override
public boolean supports(@NonNull MethodParameter returnType, @NonNull Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
}

@ -0,0 +1,213 @@
package cc.fascinated.backend.model.account;
import cc.fascinated.backend.common.DateUtils;
import cc.fascinated.backend.model.token.ScoreSaberAccountToken;
import io.micrometer.common.lang.NonNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.SneakyThrows;
import org.springframework.data.annotation.Id;
import java.util.Date;
@AllArgsConstructor
@Getter @Setter
public class Account {
/**
* The id for this ScoreSaber account.
*/
@Id @NonNull
private final 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 Bio bio;
/**
* The country for this account.
*/
private String country;
/**
* The PP for this account.
*/
private double performancePoints;
/**
* The rank for this account.
*/
private int rank;
/**
* The country rank for this account.
*/
private int countryRank;
/**
* The role for this account.
* todo: make this an enum
*/
private String role;
/**
* The badges for this account.
*/
private Badge[] badges;
/**
* The history of the rank for this account.
*/
private int[] rankHistory;
/**
* The permissions for this account.
*/
private int permission;
/**
* 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;
/**
* When the account joined ScoreSaber.
*/
private Date firstSeen;
/**
* Gets the account from the given token.
*
* @param token The token.
* @return The account.
*/
@SneakyThrows
public static Account fromToken(ScoreSaberAccountToken token) {
int[] rankHistory = new int[token.getHistories().split(",").length];
for (int i = 0; i < rankHistory.length; i++) {
rankHistory[i] = Integer.parseInt(token.getHistories().split(",")[i]);
}
// Convert the token to an account.
return new Account(
token.getId(),
token.getName(),
token.getProfilePicture(),
Bio.fromRaw(token.getBio()),
token.getCountry(),
token.getPp(),
token.getRank(),
token.getCountryRank(),
token.getRole(),
token.getBadges(),
rankHistory,
token.getPermissions(),
token.isBanned(),
token.isInactive(),
token.getScoreStats(),
DateUtils.getDateFromString(token.getFirstSeen())
);
}
/**
* The bio for this account.
*/
@AllArgsConstructor @Getter
public static class Bio {
/**
* The raw bio.
*/
private String[] raw;
/**
* The clean bio with no HTML tags.
*/
private String[] clean;
/**
* Gets the bio from the raw string.
*
* @param raw The raw bio.
* @return The bio.
*/
public static Bio fromRaw(String raw) {
return new Bio(
raw.split("\n"),
raw.replaceAll("<[^>]*>", "").split("\n")
);
}
}
/**
* The badge for this account.
*/
@AllArgsConstructor @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.
*/
@AllArgsConstructor @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,193 @@
package cc.fascinated.backend.model.leaderboard;
import cc.fascinated.backend.common.DateUtils;
import cc.fascinated.backend.model.score.Score;
import cc.fascinated.backend.model.token.ScoreSaberLeaderboardToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Date;
/**
* A leaderboard for a song.
*/
@AllArgsConstructor @Getter
public class Leaderboard {
/**
* The ID of the leaderboard.
*/
private final String id;
/**
* The hash of the song.
*/
private final 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 Date createdDate;
/**
* The date the song was ranked.
*/
private Date rankedDate;
/**
* The date the song was qualified.
*/
private Date qualifiedDate;
/**
* The date the song's status was changed to loved.
*/
private Date 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;
/**
* Gets the leaderboard from a leaderboard token.
*
* @param token The token.
* @return The leaderboard.
*/
public static Leaderboard fromToken(ScoreSaberLeaderboardToken token) {
return new Leaderboard(
token.getId(),
token.getSongHash(),
token.getSongName(),
token.getSongSubName(),
token.getSongAuthorName(),
token.getLevelAuthorName(),
Difficulty.fromToken(token.getDifficulty()),
token.getMaxScore(),
token.getCreatedDate() == null ? null : DateUtils.getDateFromString(token.getCreatedDate()),
token.getRankedDate() == null ? null : DateUtils.getDateFromString(token.getRankedDate()),
token.getQualifiedDate() == null ? null : DateUtils.getDateFromString(token.getQualifiedDate()),
token.getLovedDate() == null ? null : DateUtils.getDateFromString(token.getLovedDate()),
token.isRanked(),
token.isQualified(),
token.isLoved(),
token.getMaxPP(),
token.getStars(),
token.getPlays(),
token.getDailyPlays(),
token.isPositiveModifiers(),
token.getCoverImage()
);
}
/**
* A difficulty for a leaderboard.
*/
@AllArgsConstructor @Getter
public static class Difficulty {
/**
* The ID of the difficulty.
*/
private final int id;
/**
* The name of the difficulty.
*/
private final Score.Difficulty difficulty;
/**
* The raw name of the difficulty.
*/
private final String rawDifficulty;
/**
* The gamemode of the difficulty.
*/
private final String gamemode;
/**
* Gets the difficulty from a token.
*
* @param token The token.
* @return The difficulty.
*/
public static Difficulty fromToken(ScoreSaberLeaderboardToken.Difficulty token) {
return new Difficulty(
token.getLeaderboardId(),
Score.Difficulty.fromId(token.getDifficulty()),
token.getDifficultyRaw(),
token.getGameMode()
);
}
}
}

@ -0,0 +1,40 @@
package cc.fascinated.backend.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,200 @@
package cc.fascinated.backend.model.score;
import cc.fascinated.backend.common.DateUtils;
import cc.fascinated.backend.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.backend.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.backend.model.token.ScoreSaberScoreToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@AllArgsConstructor @Getter @Setter
public class Score {
/**
* The id for this score.
*/
@Id
private String id;
/**
* 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 int weight;
/**
* The modifiers for this score.
*/
private List<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 Date 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;
/**
* The previous scores for this score.
*/
private List<Score> previousScores;
/**
* The account who set this score.
*/
private String accountId;
/**
* The leaderboard id for this score.
*/
private String leaderboardId;
/**
* The difficulty this score was set on.
*/
private Difficulty difficulty;
/**
* Gets a score from the given token.
*
* @param token The token.
* @return The score.
*/
public static Score fromToken(String playerId, ScoreSaberPlayerScoreToken token) {
ScoreSaberScoreToken score = token.getScore();
ScoreSaberLeaderboardToken leaderboard = token.getLeaderboard();
List<String> modifiers = new ArrayList<>(List.of(score.getModifiers().split(",")));
// If the token's modifiers aren't a list, add the only modifier.
if (modifiers.isEmpty() && !score.getModifiers().isEmpty()) {
modifiers.add(score.getModifiers());
}
// Return the score.
return new Score(
score.getId(),
score.getRank(),
score.getBaseScore(),
score.getModifiedScore(),
score.getPp(),
score.getWeight(),
modifiers,
score.getMultiplier(),
score.getBadCuts(),
score.getMissedNotes(),
score.getMaxCombo(),
score.isFullCombo(),
score.getHmd(),
DateUtils.getDateFromString(score.getTimeSet()),
score.isHasReplay(),
score.getDeviceHmd(),
score.getDeviceControllerLeft(),
score.getDeviceControllerRight(),
new ArrayList<>(),
playerId,
leaderboard.getId(),
Difficulty.fromId(leaderboard.getDifficulty().getDifficulty())
);
}
@AllArgsConstructor @Getter
public enum Difficulty {
EASY(1),
NORMAL(3),
HARD(5),
EXPERT(7),
EXPERT_PLUS(9);
/**
* The ScoreSaber difficulty id.
*/
private final int id;
/**
* Gets the difficulty from the given id.
*
* @param id The id.
* @return The difficulty.
*/
public static Difficulty fromId(int id) {
for (Difficulty difficulty : values()) {
if (difficulty.getId() == id) {
return difficulty;
}
}
return null;
}
}
}

@ -0,0 +1,87 @@
package cc.fascinated.backend.model.token;
import cc.fascinated.backend.model.account.Account;
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 Account.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 Account.ScoreStats scoreStats;
/**
* The first time this account was seen.
*/
private String firstSeen;
}

@ -0,0 +1,145 @@
package cc.fascinated.backend.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.backend.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.backend.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.backend.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 int 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.backend.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,9 @@
package cc.fascinated.backend.repository;
import cc.fascinated.backend.model.account.Account;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* A repository for {@link Account}s.
*/
public interface AccountRepository extends MongoRepository<Account, String> { }

@ -0,0 +1,9 @@
package cc.fascinated.backend.repository;
import cc.fascinated.backend.model.leaderboard.Leaderboard;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* A repository for {@link Leaderboard}s.
*/
public interface LeaderboardRepository extends MongoRepository<Leaderboard, String> { }

@ -0,0 +1,31 @@
package cc.fascinated.backend.repository;
import cc.fascinated.backend.model.score.Score;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List;
/**
* A repository for {@link Score}s.
*/
public interface ScoreRepository extends MongoRepository<Score, String> {
/**
* Gets the scores for an account.
*
* @param accountId The id of the account.
* @return The scores for the account.
*/
@Query("{ 'accountId' : ?0 }")
List<Score> getScoresForAccount(String accountId);
/**
* Gets the scores sorted by the newest for an account.
*
* @param accountId The id of the account.
* @return The scores.
*/
@Query(value = "{ 'accountId' : ?0 }", sort = "{ 'timeSet' : -1 }")
List<Score> getScoresSortedByNewest(String accountId);
}

@ -0,0 +1,99 @@
package cc.fascinated.backend.service;
import cc.fascinated.backend.common.Timer;
import cc.fascinated.backend.model.account.Account;
import cc.fascinated.backend.model.token.ScoreSaberAccountToken;
import cc.fascinated.backend.repository.AccountRepository;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Service @Log4j2(topic = "Account Service")
public class AccountService {
/**
* How often the account should be updated.
*/
private static final long UPDATE_INTERVAL = TimeUnit.HOURS.toMillis(1);
/**
* The {@link AccountRepository} instance.
*/
private final AccountRepository accountRepository;
/**
* The {@link ScoreSaberService} instance.
*/
private final ScoreSaberService scoreSaberService;
@Autowired
public AccountService(AccountRepository accountRepository, ScoreSaberService scoreSaberService) {
this.accountRepository = accountRepository;
this.scoreSaberService = scoreSaberService;
// todo: Schedule the account update task.
Timer.scheduleRepeating(() -> {
List<Account> accounts = accountRepository.findAll();
log.info("Updating accounts.");
for (Account account : accounts) {
updateAccount(account);
}
log.info("Updated {} accounts.", accounts.size());
}, 0, UPDATE_INTERVAL);
}
/**
* Gets the ScoreSaber account.
* <p>
* If the account is not found in the database,
* it will be fetched from the ScoreSaber API,
* fetch all the scores for the account then
* save the account to the database.
* </p>
*
* @param id The id of the account.
* @return The account.
*/
public Account getAccount(String id) {
log.info("Fetching account '{}'.", id);
Optional<Account> optionalAccount = accountRepository.findById(id);
if (optionalAccount.isEmpty()) {
log.info("Account '{}' not found in the database. Fetching from ScoreSaber API.", id);
Account account = Account.fromToken(scoreSaberService.getAccount(id));
updateAccount(account); // Fetch the scores for the account.
accountRepository.save(account); // Save the account to the database.
return account;
}
log.info("Account '{}' found in the database.", id);
return optionalAccount.get();
}
/**
* Fetches the account from the ScoreSaber API
* and saves it to the database.
*
* @param account The account.
*/
public void updateAccount(Account account) {
String id = account.getId();
// Fetch the account from the ScoreSaber API.
ScoreSaberAccountToken accountToken = scoreSaberService.getAccount(id); // Fetch the account from the ScoreSaber API.
if (accountToken == null) {
log.warn("Account '{}' not found in the ScoreSaber API.", id);
return;
}
// Update the account with the new token.
Account updatedAccount = Account.fromToken(accountToken);
account = accountRepository.save(updatedAccount); // Save the account to the database.
// Fetch the scores for the account.
scoreSaberService.updateScores(account);
}
}

@ -0,0 +1,318 @@
package cc.fascinated.backend.service;
import cc.fascinated.backend.Main;
import cc.fascinated.backend.common.DateUtils;
import cc.fascinated.backend.common.Timer;
import cc.fascinated.backend.common.WebRequest;
import cc.fascinated.backend.exception.impl.RateLimitException;
import cc.fascinated.backend.exception.impl.ResourceNotFoundException;
import cc.fascinated.backend.model.account.Account;
import cc.fascinated.backend.model.leaderboard.Leaderboard;
import cc.fascinated.backend.model.score.Score;
import cc.fascinated.backend.model.token.*;
import cc.fascinated.backend.repository.AccountRepository;
import cc.fascinated.backend.repository.LeaderboardRepository;
import cc.fascinated.backend.repository.ScoreRepository;
import com.google.gson.JsonObject;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
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;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Service @Log4j2(topic = "ScoreSaber Service")
public class ScoreSaberService extends TextWebSocketHandler {
private static final long LEADERBOARD_UPDATE_INTERVAL = TimeUnit.HOURS.toMillis(24);
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_PLAYER_SCORES_ENDPOINT = SCORESABER_API + "player/%s/scores?limit=100&sort=%s&page=%s&withMetadata=true";
private static final String GET_LEADERBOARD_ENDPOINT = SCORESABER_API + "leaderboard/by-id/%s/info";
/**
* The {@link ScoreRepository} instance.
*/
private final ScoreRepository scoreRepository;
/**
* The {@link LeaderboardRepository} instance.
*/
private final LeaderboardRepository leaderboardRepository;
/**
* The {@link AccountRepository} instance.
*/
private final AccountRepository accountRepository;
@SneakyThrows @Autowired
public ScoreSaberService(ScoreRepository scoreRepository, LeaderboardRepository leaderboardRepository, AccountRepository accountRepository) {
this.scoreRepository = scoreRepository;
this.leaderboardRepository = leaderboardRepository;
this.accountRepository = accountRepository;
connectWebSocket(); // Connect to the ScoreSaber WebSocket.
Timer.scheduleRepeating(this::updateLeaderboards, LEADERBOARD_UPDATE_INTERVAL, LEADERBOARD_UPDATE_INTERVAL);
}
/**
* Gets the account from the ScoreSaber API.
*
* @param id The id of the account.
* @return The account.
* @throws ResourceNotFoundException If the account is not found.
* @throws RateLimitException If the ScoreSaber rate limit is reached.
*/
public ScoreSaberAccountToken getAccount(String id) {
ScoreSaberAccountToken account = WebRequest.getAsEntity(String.format(GET_PLAYER_ENDPOINT, id), ScoreSaberAccountToken.class);
if (account == null) { // Check if the account doesn't exist.
throw new ResourceNotFoundException("Account with id '%s' not found.".formatted(id));
}
return account;
}
/**
* Gets the scores for the account.
*
* @param account The account.
* @param page The page to get the scores from.
* @return The scores.
*/
public ScoreSaberScoresPageToken getPageScores(Account account, int page) {
log.info("Fetching scores for account '{}' from page {}.", account.getId(), page);
ScoreSaberScoresPageToken pageToken = WebRequest.getAsEntity(String.format(GET_PLAYER_SCORES_ENDPOINT, account.getId(), "recent", page), ScoreSaberScoresPageToken.class);
if (pageToken == null) { // Check if the page doesn't exist.
return null;
}
// Sort the scores by newest time set.
pageToken.setPlayerScores(Arrays.stream(pageToken.getPlayerScores())
.sorted((a, b) -> DateUtils.getDateFromString(b.getScore().getTimeSet()).compareTo(DateUtils.getDateFromString(a.getScore().getTimeSet())))
.toArray(ScoreSaberPlayerScoreToken[]::new));
return pageToken;
}
/**
* Gets the scores for the account.
*
* @param account The account.
* @return The scores.
*/
public List<ScoreSaberScoresPageToken> getScores(Account account) {
List<ScoreSaberScoresPageToken> scores = new ArrayList<>(List.of(getPageScores(account, 1)));
ScoreSaberPageMetadataToken metadata = scores.get(0).getMetadata();
int totalPages = (int) Math.ceil((double) metadata.getTotal() / metadata.getItemsPerPage());
log.info("Fetching {} pages of scores for account '{}'.", totalPages, account.getId());
for (int i = 2; i <= totalPages; i++) {
scores.add(getPageScores(account, i));
}
return scores;
}
/**
* Fetch the scores until the specified score id.
*
* @param account The account.
* @param scoreUntil The score to fetch until.
* @return The scores.
*/
public List<ScoreSaberPlayerScoreToken> getScoreUntil(Account account, Score scoreUntil) {
List<ScoreSaberPlayerScoreToken> scores = new ArrayList<>();
int page = 1;
do {
ScoreSaberScoresPageToken pageToken = getPageScores(account, page);
for (ScoreSaberPlayerScoreToken score : pageToken.getPlayerScores()) {
// If the score isn't the same as the scoreUntil, add it to the list.
if (!DateUtils.getDateFromString(score.getScore().getTimeSet()).equals(scoreUntil.getTimeSet())) {
scores.add(score);
}
if (score.getScore().getId().equals(scoreUntil.getId())) {
// If the current score matches the specified scoreUntil, stop fetching.
return scores;
}
}
page++;
} while (true);
}
/**
* Fetches the scores for the account.
*
* @param account The account.
*/
public void updateScores(Account account) {
String id = account.getId();
// Fetch the scores for the account.
List<Score> scores = scoreRepository.getScoresForAccount(id);
if (scores.isEmpty()) {
log.warn("Account '{}' has no scores, fetching them.", id);
List<ScoreSaberScoresPageToken> scoresPageTokens = this.getScores(account);
List<Score> newScores = new ArrayList<>();
List<Leaderboard> leaderboardToSave = new ArrayList<>();
for (ScoreSaberScoresPageToken page : scoresPageTokens) {
for (ScoreSaberPlayerScoreToken score : page.getPlayerScores()) {
newScores.add(Score.fromToken(id, score));
leaderboardToSave.add(Leaderboard.fromToken(score.getLeaderboard()));
}
}
// Save the leaderboards if they are missing.
for (Leaderboard leaderboard : leaderboardToSave) {
if (leaderboardRepository.findById(leaderboard.getId()).isEmpty()) {
leaderboardRepository.save(leaderboard);
}
}
scoreRepository.saveAll(newScores); // Save the player's scores.
log.info("Found {} scores for account '{}'.", newScores.size(), id);
return;
}
long start = System.currentTimeMillis();
log.info("Fetching new scores for account '{}'.", id);
Score latestScore = scoreRepository.getScoresSortedByNewest(id).get(0);
List<ScoreSaberPlayerScoreToken> newScores = this.getScoreUntil(account, latestScore);
if (newScores.isEmpty()) {
log.info("No new scores found for account '{}'.", id);
return;
}
int newScoreCount = 0;
for (ScoreSaberPlayerScoreToken newScore : newScores) {
if (saveScore(account, newScore)) {
newScoreCount++;
}
}
log.info("Found {} new scores for account '{}'. (took: {}ms)", newScoreCount, id, System.currentTimeMillis() - start);
}
/**
* Saves the score for the account.
*
* @param account The account.
* @param score The score to save.
* @return Whether the score was saved.
*/
private boolean saveScore(Account account, ScoreSaberPlayerScoreToken score) {
boolean didSave = false;
Leaderboard newScoreLeaderboard = Leaderboard.fromToken(score.getLeaderboard());
Score oldScore = scoreRepository.findById(score.getScore().getId()).orElse(null);
// The score has an old score.
if (oldScore != null) {
Leaderboard oldScoreLeaderboard = leaderboardRepository.findById(oldScore.getLeaderboardId()).orElse(null);
if (oldScoreLeaderboard != null && oldScoreLeaderboard.getId().equals(newScoreLeaderboard.getId())) {
// If it matches, add the new score and retain information about the old score.
Score scoreSet = Score.fromToken(account.getId(), score);
oldScore.setPreviousScores(null); // We don't want nested previous scores.
scoreSet.getPreviousScores().add(oldScore);
scoreRepository.delete(oldScore); // Delete the old score.
scoreRepository.save(scoreSet); // Save the new score.
didSave = true;
}
} else {
// The score is new
scoreRepository.save(Score.fromToken(account.getId(), score)); // Save the new score.
didSave = true;
}
// Check if the leaderboard doesn't already exist.
if (leaderboardRepository.findById(newScoreLeaderboard.getId()).isEmpty()) {
leaderboardRepository.save(newScoreLeaderboard); // Save the leaderboard.
}
return didSave;
}
/**
* Updates the leaderboards.
*/
private void updateLeaderboards() {
log.info("Updating leaderboards.");
List<String> leaderboardIds = new ArrayList<>();
// Get all the unique leaderboard ids.
for (Score score : scoreRepository.findAll()) {
leaderboardIds.add(score.getLeaderboardId());
}
// Fetch the leaderboards.
for (String leaderboardId : leaderboardIds) {
ScoreSaberLeaderboardToken leaderboard = WebRequest.getAsEntity(String.format(GET_LEADERBOARD_ENDPOINT, leaderboardId),
ScoreSaberLeaderboardToken.class);
// No leaderboard found.
if (leaderboard == null) {
log.warn("Leaderboard '{}' not found.", leaderboardId);
continue;
}
// Save the leaderboard.
leaderboardRepository.save(Leaderboard.fromToken(leaderboard));
}
log.info("Updated {} leaderboards.", leaderboardIds.size());
}
/**
* Connects to the ScoreSaber WebSocket.
*/
@SneakyThrows
private void connectWebSocket() {
new StandardWebSocketClient().execute(this, "wss://scoresaber.com/ws").get();
}
@Override @SneakyThrows
protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {
// Ignore the connection message.
if (message.getPayload().equals("Connected to the ScoreSaber WSS")) {
return;
}
try {
JsonObject json = Main.GSON.fromJson(message.getPayload(), JsonObject.class);
String command = json.get("commandName").getAsString();
JsonObject data = json.get("commandData").getAsJsonObject();
if (command.equals("score")) {
ScoreSaberPlayerScoreToken score = Main.GSON.fromJson(data, ScoreSaberPlayerScoreToken.class);
ScoreSaberScoreToken.LeaderboardPlayerInfo playerInfo = score.getScore().getLeaderboardPlayerInfo();
// Fetch the account.
Optional<Account> account = accountRepository.findById(playerInfo.getId());
if (account.isEmpty()) {
// We don't track this account, so ignore it.
return;
}
// Save the score.
saveScore(account.get(), score);
log.info("Saved websocket score '{}' for account '{}'.", score.getScore().getId(), playerInfo.getName());
}
} catch (Exception ex) {
log.error("An error occurred while handling the message.", ex);
}
}
@Override
public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) {
log.info("Disconnected from the ScoreSaber WSS.");
connectWebSocket(); // Reconnect to the WebSocket.
}
}

@ -0,0 +1,35 @@
package cc.fascinated.backend;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class AccountControllerTests {
@Autowired
private MockMvc mockMvc;
@Test
public void ensureAccountRetrieveSuccess() throws Exception {
mockMvc.perform(get("/account/76561198449412074")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
}
@Test
public void ensureAccountRetrieveFailure() throws Exception {
mockMvc.perform(get("/account/432747328774289348237984723984")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
}