Start implementation of persistence layer

This commit is contained in:
Simon Grimme
2022-07-15 17:24:42 +02:00
parent f321088636
commit 4991bfb461
13 changed files with 408 additions and 65 deletions
@@ -1,5 +1,6 @@
package de.grimsi.gameyfin.config;
import io.netty.handler.logging.LogLevel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Configuration;
@@ -10,6 +11,7 @@ import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.transport.logging.AdvancedByteBufFormat;
@Slf4j
@Configuration
@@ -17,16 +19,17 @@ public class WebClientConfig implements WebClientCustomizer {
@Override
public void customize(WebClient.Builder webClientBuilder) {
webClientBuilder.filter(logResponse());
webClientBuilder.filter(logRequest());
HttpClient httpClient = HttpClient.create()
.wiretap(this.getClass().getCanonicalName(), LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL) // Enable full request / response logging (active only in DEV profile)
.proxyWithSystemProperties(); // Enable use of system proxy
// Enable use of system proxy
webClientBuilder.clientConnector(new ReactorClientHttpConnector(HttpClient.create().proxyWithSystemProperties()));
webClientBuilder.clientConnector(new ReactorClientHttpConnector(httpClient));
}
/**
* This fixes the wrong Content-Type in responses of the IGDB API by overwriting it so the WebClient is able to parse it automatically
* They return "application/protobuf", correct would be "application/x-protobuf"
*
* @return the filter function
*/
public static ExchangeFilterFunction fixProtobufContentTypeInterceptor() {
@@ -34,20 +37,7 @@ public class WebClientConfig implements WebClientCustomizer {
Mono.just(clientResponse.mutate()
.headers(headers -> headers.remove(HttpHeaders.CONTENT_TYPE))
.header(HttpHeaders.CONTENT_TYPE, String.valueOf(ProtobufHttpMessageConverter.PROTOBUF))
.build()));
}
private ExchangeFilterFunction logResponse() {
return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
log.debug("Response: {}", clientResponse.statusCode());
return Mono.just(clientResponse);
});
}
private ExchangeFilterFunction logRequest() {
return (clientRequest, next) -> {
log.debug("Request: {} {}", clientRequest.method(), clientRequest.url());
return next.exchange(clientRequest);
};
.build())
);
}
}
@@ -0,0 +1,33 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class Category {
@Id
private String slug;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Category category = (Category) o;
return slug != null && Objects.equals(slug, category.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -0,0 +1,40 @@
package de.grimsi.gameyfin.entities;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class Company {
@Id
private String slug;
@Column(nullable = false)
private String name;
private Long logoId;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Company company = (Company) o;
return slug != null && Objects.equals(slug, company.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -0,0 +1,99 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.*;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
@Entity
@Builder
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class DetectedGame {
// Game properties
@Id
private String slug;
@Column(nullable = false)
private String title;
private String summary;
private Instant releaseDate;
private Integer userRating;
private Integer criticsRating;
private Integer totalRating;
@ManyToOne
private Category category;
private boolean offlineCoop;
private boolean onlineCoop;
private boolean lanSupport;
private int maxPlayers;
@Column(nullable = false)
private Long coverId;
@ElementCollection
private List<Long> screenshotIds;
@ElementCollection
private List<Long> videoIds;
@ManyToMany
@ToString.Exclude
private List<Company> companies;
@ManyToMany
@ToString.Exclude
private List<Genre> genres;
@ManyToMany
@ToString.Exclude
private List<Keyword> keywords;
@ManyToMany
@ToString.Exclude
private List<Theme> themes;
@ManyToMany
@ToString.Exclude
private List<PlayerPerspective> playerPerspectives;
// Technical properties
@Column(nullable = false)
private String path;
@Column(nullable = false)
private boolean isFolder;
@Column(columnDefinition = "boolean default false")
private boolean confirmedMatch;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
DetectedGame that = (DetectedGame) o;
return slug != null && Objects.equals(slug, that.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -1,4 +0,0 @@
package de.grimsi.gameyfin.entities;
public class Game {
}
@@ -0,0 +1,36 @@
package de.grimsi.gameyfin.entities;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class Genre {
@Id
private String slug;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Genre genre = (Genre) o;
return slug != null && Objects.equals(slug, genre.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -0,0 +1,33 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class Keyword {
@Id
private String slug;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Keyword keyword = (Keyword) o;
return slug != null && Objects.equals(slug, keyword.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -0,0 +1,33 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class PlayerPerspective {
@Id
private String slug;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
PlayerPerspective that = (PlayerPerspective) o;
return slug != null && Objects.equals(slug, that.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -0,0 +1,33 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class Theme {
@Id
private String slug;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Theme theme = (Theme) o;
return slug != null && Objects.equals(slug, theme.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -20,14 +20,16 @@ import java.util.Optional;
@Service
public class IgdbWrapper {
private static final int MAIN_GAME_CATEGORY_VALUE = 0;
@Value("${gameyfin.igdb.api.client-id}")
private String clientId;
@Value("${gameyfin.igdb.api.client-secret}")
private String clientSecret;
@Value("${gameyfin.igdb.config.preferred-platform}")
private int preferredPlatform;
@Value("${gameyfin.igdb.config.preferred-platforms:6}")
private String preferredPlatforms;
@Autowired
private WebClient.Builder webclientBuilder;
@@ -64,14 +66,10 @@ public class IgdbWrapper {
log.info("Successfully authenticated.");
}
public Igdb.Game findGameByTitle(String title) {
return searchForGameByTitle(title).orElseThrow(() -> new RuntimeException("Could not find game with title: \"%s\"".formatted(title)));
}
public Optional<Igdb.Game> getGameById(Long id) {
Igdb.GameResult gameResult = igdbApiClient.post()
.uri("games.pb")
.bodyValue("fields *; where id = %d; limit 1;".formatted(id))
.bodyValue("fields *; where id = %d & category = %d; limit 1;".formatted(id, MAIN_GAME_CATEGORY_VALUE))
.retrieve()
.bodyToMono(Igdb.GameResult.class)
.block();
@@ -81,23 +79,10 @@ public class IgdbWrapper {
return Optional.of(gameResult.getGames(0));
}
private void initIgdbClient() {
if (accessToken == null) {
authenticate();
}
igdbApiClient = webclientBuilder
.baseUrl("https://api.igdb.com/v4/")
.defaultHeader("Client-ID", clientId)
.defaultHeader("Authorization", "Bearer %s".formatted(accessToken.getAccessToken()))
.filter(WebClientConfig.fixProtobufContentTypeInterceptor())
.build();
}
private Optional<Igdb.Game> searchForGameByTitle(String searchTerm) {
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm) {
Igdb.GameResult gameResult = igdbApiClient.post()
.uri("games.pb")
.bodyValue("fields *; search \"%s\";".formatted(searchTerm))
.bodyValue("fields *; search \"%s\"; where platforms = (%s) & category = %d;".formatted(searchTerm, preferredPlatforms, MAIN_GAME_CATEGORY_VALUE))
.retrieve()
.bodyToMono(Igdb.GameResult.class)
.block();
@@ -106,6 +91,9 @@ public class IgdbWrapper {
List<Igdb.Game> games = gameResult.getGamesList();
// If we only get one game, we don't have to check for exact matches, so return it directly
if(games.size() == 1) return Optional.ofNullable(games.get(0));
// First check if there are any matches with the exact search term
// If no exact match has been found, check if there are matches where the name ends with the search term
// This will filter out most DLCs and similiar stuff, but will detect a game even when your search term is not exactly the title
@@ -122,4 +110,17 @@ public class IgdbWrapper {
return Optional.of(games.get(0));
}
private void initIgdbClient() {
if (accessToken == null) {
authenticate();
}
igdbApiClient = webclientBuilder
.baseUrl("https://api.igdb.com/v4/")
.defaultHeader("Client-ID", clientId)
.defaultHeader("Authorization", "Bearer %s".formatted(accessToken.getAccessToken()))
.filter(WebClientConfig.fixProtobufContentTypeInterceptor())
.build();
}
}
@@ -0,0 +1,60 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Category;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.util.ProtobufUtils;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
public class GameMapper {
public static DetectedGame toDetectedGame(Igdb.Game g, Path path) {
List<Igdb.MultiplayerMode> multiplayerModes = g.getMultiplayerModesList();
List<Long> screenshotIds = g.getScreenshotsList().stream().map(Igdb.Screenshot::getId).toList();
List<Long> videoIds = g.getVideosList().stream().map(Igdb.GameVideo::getId).toList();
return DetectedGame.builder()
.slug(g.getSlug())
.title(g.getName())
.summary(g.getSummary())
.releaseDate(ProtobufUtils.toInstant(g.getFirstReleaseDate()))
.userRating((int) g.getRating())
.criticsRating((int) g.getAggregatedRating())
.totalRating((int) g.getTotalRating())
//.category()
.offlineCoop(hasOfflineCoop(multiplayerModes))
.onlineCoop(hasOnlineCoop(multiplayerModes))
.lanSupport(hasLanSupport(multiplayerModes))
.maxPlayers(getMaxPlayers(multiplayerModes))
.coverId(g.getCover().getId())
.screenshotIds(screenshotIds)
.videoIds(videoIds)
//.companies()
//.genres()
//.keywords()
//.themes()
//.playerPerspectives()
.path(path.toString())
.isFolder(path.toFile().isDirectory())
.build();
}
private static boolean hasOfflineCoop(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getOfflinecoop);
}
private static boolean hasLanSupport(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getLancoop);
}
private static boolean hasOnlineCoop(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getOnlinecoop);
}
private static int getMaxPlayers(List<Igdb.MultiplayerMode> modes) {
return modes.stream().mapToInt(Igdb.MultiplayerMode::getOnlinecoopmax).max().orElse(0);
}
}
@@ -15,7 +15,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.Objects;
@RestController
public class GameyfinDevController {
@@ -28,13 +28,8 @@ public class GameyfinDevController {
@GetMapping(value = "/dev/findGameByTitle/{title}", produces = MediaType.APPLICATION_JSON_VALUE)
public GameDto findGameByTitle(@PathVariable("title") String title) {
Igdb.Game game;
try {
game = igdbWrapper.findGameByTitle(title);
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage());
}
Igdb.Game game = igdbWrapper.searchForGameByTitle(title)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Could not find game with title: \"%s\"".formatted(title)));
return GameDto.builder()
.name(game.getName())
@@ -44,15 +39,8 @@ public class GameyfinDevController {
@GetMapping(value = "/dev/getGameById/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public GameDto findGameByTitle(@PathVariable("id") Long id) {
Optional<Igdb.Game> gameOptional;
try {
gameOptional = igdbWrapper.getGameById(id);
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage());
}
Igdb.Game game = gameOptional.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with id %d not found".formatted(id)));
Igdb.Game game = igdbWrapper.getGameById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Could not find game with id: %d".formatted(id)));
return GameDto.builder()
.name(game.getName())
@@ -67,8 +55,9 @@ public class GameyfinDevController {
@GetMapping(value = "/dev/games", produces = MediaType.APPLICATION_JSON_VALUE)
public List<GameDto> getAllGames() {
return filesystemService.getGameFileNames().stream()
.map(t -> igdbWrapper.findGameByTitle(t))
return filesystemService.getGameFileNames().parallelStream()
.map(t -> igdbWrapper.searchForGameByTitle(t).orElse(null))
.filter(Objects::nonNull)
.map(g -> GameDto.builder()
.name(g.getName())
.releaseDate(ProtobufUtils.toInstant(g.getFirstReleaseDate()))
+1 -1
View File
@@ -6,7 +6,7 @@ gameyfin:
file-extensions: iso, zip, rar, 7z, exe
igdb:
config:
preferred-platform: 6
preferred-platforms: 6
api:
client-id: ""
client-secret: ""