diff --git a/src/main/java/de/grimsi/gameyfin/config/WebClientConfig.java b/src/main/java/de/grimsi/gameyfin/config/WebClientConfig.java index 8cc2815..8efabac 100644 --- a/src/main/java/de/grimsi/gameyfin/config/WebClientConfig.java +++ b/src/main/java/de/grimsi/gameyfin/config/WebClientConfig.java @@ -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()) + ); } } diff --git a/src/main/java/de/grimsi/gameyfin/entities/Category.java b/src/main/java/de/grimsi/gameyfin/entities/Category.java new file mode 100644 index 0000000..d292123 --- /dev/null +++ b/src/main/java/de/grimsi/gameyfin/entities/Category.java @@ -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(); + } +} diff --git a/src/main/java/de/grimsi/gameyfin/entities/Company.java b/src/main/java/de/grimsi/gameyfin/entities/Company.java new file mode 100644 index 0000000..ca8a541 --- /dev/null +++ b/src/main/java/de/grimsi/gameyfin/entities/Company.java @@ -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(); + } +} diff --git a/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java b/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java new file mode 100644 index 0000000..751913e --- /dev/null +++ b/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java @@ -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 screenshotIds; + + @ElementCollection + private List videoIds; + + @ManyToMany + @ToString.Exclude + private List companies; + + @ManyToMany + @ToString.Exclude + private List genres; + + @ManyToMany + @ToString.Exclude + private List keywords; + + @ManyToMany + @ToString.Exclude + private List themes; + + @ManyToMany + @ToString.Exclude + private List 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(); + } +} diff --git a/src/main/java/de/grimsi/gameyfin/entities/Game.java b/src/main/java/de/grimsi/gameyfin/entities/Game.java deleted file mode 100644 index f163eb2..0000000 --- a/src/main/java/de/grimsi/gameyfin/entities/Game.java +++ /dev/null @@ -1,4 +0,0 @@ -package de.grimsi.gameyfin.entities; - -public class Game { -} diff --git a/src/main/java/de/grimsi/gameyfin/entities/Genre.java b/src/main/java/de/grimsi/gameyfin/entities/Genre.java new file mode 100644 index 0000000..68e6987 --- /dev/null +++ b/src/main/java/de/grimsi/gameyfin/entities/Genre.java @@ -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(); + } +} diff --git a/src/main/java/de/grimsi/gameyfin/entities/Keyword.java b/src/main/java/de/grimsi/gameyfin/entities/Keyword.java new file mode 100644 index 0000000..1586862 --- /dev/null +++ b/src/main/java/de/grimsi/gameyfin/entities/Keyword.java @@ -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(); + } +} diff --git a/src/main/java/de/grimsi/gameyfin/entities/PlayerPerspective.java b/src/main/java/de/grimsi/gameyfin/entities/PlayerPerspective.java new file mode 100644 index 0000000..876109b --- /dev/null +++ b/src/main/java/de/grimsi/gameyfin/entities/PlayerPerspective.java @@ -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(); + } +} diff --git a/src/main/java/de/grimsi/gameyfin/entities/Theme.java b/src/main/java/de/grimsi/gameyfin/entities/Theme.java new file mode 100644 index 0000000..aadb122 --- /dev/null +++ b/src/main/java/de/grimsi/gameyfin/entities/Theme.java @@ -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(); + } +} diff --git a/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java b/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java index 2c0ecde..84e46f9 100644 --- a/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java +++ b/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java @@ -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 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 searchForGameByTitle(String searchTerm) { + public Optional 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 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(); + } } diff --git a/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java b/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java new file mode 100644 index 0000000..23ce997 --- /dev/null +++ b/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java @@ -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 multiplayerModes = g.getMultiplayerModesList(); + List screenshotIds = g.getScreenshotsList().stream().map(Igdb.Screenshot::getId).toList(); + List 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 modes) { + return modes.stream().anyMatch(Igdb.MultiplayerMode::getOfflinecoop); + } + + private static boolean hasLanSupport(List modes) { + return modes.stream().anyMatch(Igdb.MultiplayerMode::getLancoop); + } + + private static boolean hasOnlineCoop(List modes) { + return modes.stream().anyMatch(Igdb.MultiplayerMode::getOnlinecoop); + } + + private static int getMaxPlayers(List modes) { + return modes.stream().mapToInt(Igdb.MultiplayerMode::getOnlinecoopmax).max().orElse(0); + } +} diff --git a/src/main/java/de/grimsi/gameyfin/rest/GameyfinDevController.java b/src/main/java/de/grimsi/gameyfin/rest/GameyfinDevController.java index 4443ce4..2e380e5 100644 --- a/src/main/java/de/grimsi/gameyfin/rest/GameyfinDevController.java +++ b/src/main/java/de/grimsi/gameyfin/rest/GameyfinDevController.java @@ -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 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 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())) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 49e3afc..afc5f26 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: "" \ No newline at end of file