initial testing
Some checks failed
Deploy App / docker (ubuntu-latest, 2.44.0, 17, 3.8.5) (push) Failing after 1m11s
Some checks failed
Deploy App / docker (ubuntu-latest, 2.44.0, 17, 3.8.5) (push) Failing after 1m11s
This commit is contained in:
34
src/main/java/cc/fascinated/backend/Main.java
Normal file
34
src/main/java/cc/fascinated/backend/Main.java
Normal file
@ -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
|
||||
}
|
||||
}
|
23
src/main/java/cc/fascinated/backend/common/DateUtils.java
Normal file
23
src/main/java/cc/fascinated/backend/common/DateUtils.java
Normal file
@ -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)));
|
||||
}
|
||||
}
|
42
src/main/java/cc/fascinated/backend/common/IPUtils.java
Normal file
42
src/main/java/cc/fascinated/backend/common/IPUtils.java
Normal file
@ -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;
|
||||
}
|
||||
}
|
19
src/main/java/cc/fascinated/backend/common/Timer.java
Normal file
19
src/main/java/cc/fascinated/backend/common/Timer.java
Normal file
@ -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);
|
||||
}
|
||||
}
|
77
src/main/java/cc/fascinated/backend/common/WebRequest.java
Normal file
77
src/main/java/cc/fascinated/backend/common/WebRequest.java
Normal file
@ -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);
|
||||
}
|
||||
}
|
25
src/main/java/cc/fascinated/backend/config/Config.java
Normal file
25
src/main/java/cc/fascinated/backend/config/Config.java
Normal file
@ -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;
|
||||
}
|
||||
}
|
213
src/main/java/cc/fascinated/backend/model/account/Account.java
Normal file
213
src/main/java/cc/fascinated/backend/model/account/Account.java
Normal file
@ -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();
|
||||
}
|
||||
}
|
200
src/main/java/cc/fascinated/backend/model/score/Score.java
Normal file
200
src/main/java/cc/fascinated/backend/model/score/Score.java
Normal file
@ -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());
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user