WIP: Implement frontend

This commit is contained in:
Simon Grimme
2022-07-21 00:29:00 +02:00
parent 6b89690180
commit cc1e02a1ca
109 changed files with 22662 additions and 670 deletions
@@ -0,0 +1,13 @@
package de.grimsi.gameyfin;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GameyfinApplication {
public static void main(String[] args) {
SpringApplication.run(GameyfinApplication.class, args);
}
}
@@ -0,0 +1,65 @@
package de.grimsi.gameyfin.config;
import io.github.resilience4j.bulkhead.Bulkhead;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
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;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.http.converter.protobuf.ProtobufHttpMessageConverter;
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;
import java.time.Duration;
@Slf4j
@Configuration
public class WebClientConfig implements WebClientCustomizer {
// The IGDB API has a rate limit of 4 req/s
public static final RateLimiter IGDB_RATE_LIMITER = RateLimiter.of("igdb-rate-limiter",
RateLimiterConfig.custom()
.limitForPeriod(4)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ofMinutes(1))
.build());
// According to the docs, there is a maximum of 8 concurrent requests, but in my tests the actual limit was 4
// and even then it sometimes failed, so I set it to 3 to be sure
public static final Bulkhead IGDB_CONCURRENCY_LIMITER = Bulkhead.of("igdb-concurrency-limiter",
BulkheadConfig.custom()
.maxConcurrentCalls(2)
.maxWaitDuration(Duration.ofMinutes(1))
.build());
@Override
public void customize(WebClient.Builder webClientBuilder) {
HttpClient httpClient = HttpClient.create()
.wiretap(this.getClass().getCanonicalName(), LogLevel.TRACE, AdvancedByteBufFormat.TEXTUAL) // Enable full request / response logging in TRACE
.proxyWithSystemProperties(); // Enable use of system proxy
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() {
return ExchangeFilterFunction.ofResponseProcessor(clientResponse ->
Mono.just(clientResponse.mutate()
.headers(headers -> headers.remove(HttpHeaders.CONTENT_TYPE))
.header(HttpHeaders.CONTENT_TYPE, String.valueOf(ProtobufHttpMessageConverter.PROTOBUF))
.build())
);
}
}
@@ -0,0 +1,24 @@
package de.grimsi.gameyfin.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.File;
import java.time.Instant;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GameDto {
private String name;
private String publisher;
private String slug;
private Instant releaseDate;
private List<File> files;
private Long fileSize;
}
@@ -0,0 +1,39 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Builder
@Getter
@Setter
@ToString
@AllArgsConstructor
@RequiredArgsConstructor
public class Company {
@Id
private String slug;
@Column(nullable = false)
private String name;
private String 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,101 @@
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
@AllArgsConstructor
@RequiredArgsConstructor
public class DetectedGame {
// Game properties
@Id
private String slug;
@Column(nullable = false)
private String title;
@Lob
@Column(columnDefinition="CLOB")
private String summary;
private Instant releaseDate;
private Integer userRating;
private Integer criticsRating;
private Integer totalRating;
private String category;
private boolean offlineCoop;
private boolean onlineCoop;
private boolean lanSupport;
private int maxPlayers;
@Column(nullable = false)
private String coverId;
@ElementCollection
private List<String> screenshotIds;
@ElementCollection
private List<String> videoIds;
@ManyToMany(cascade = CascadeType.MERGE)
@ToString.Exclude
private List<Company> companies;
@ManyToMany(cascade = CascadeType.MERGE)
@ToString.Exclude
private List<Genre> genres;
@ManyToMany(cascade = CascadeType.MERGE)
@ToString.Exclude
private List<Keyword> keywords;
@ManyToMany(cascade = CascadeType.MERGE)
@ToString.Exclude
private List<Theme> themes;
@ManyToMany(cascade = CascadeType.MERGE)
@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();
}
}
@@ -0,0 +1,35 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Builder
@Getter
@Setter
@ToString
@AllArgsConstructor
@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,35 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Builder
@Getter
@Setter
@ToString
@AllArgsConstructor
@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,35 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Builder
@Getter
@Setter
@ToString
@AllArgsConstructor
@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,35 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Objects;
@Entity
@Builder
@Getter
@Setter
@ToString
@AllArgsConstructor
@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();
}
}
@@ -0,0 +1,38 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import javax.persistence.*;
import java.util.Objects;
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
public class UnmappableFile {
public UnmappableFile(String path) {
this.path = path;
}
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String path;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
UnmappableFile that = (UnmappableFile) o;
return path != null && Objects.equals(path, that.path);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -0,0 +1,27 @@
package de.grimsi.gameyfin.igdb;
import java.util.List;
public class IgdbApiProperties {
public static final String ENPOINT_GAMES_PROTOBUF = "games.pb";
private static final List<String> GAME_QUERY_FIELDS = List.of(
"slug", "name", "summary", "first_release_date", "rating", "aggregated_rating", "total_rating", "category",
"multiplayer_modes.lancoop", "multiplayer_modes.onlinecoop", "multiplayer_modes.offlinecoop", "multiplayer_modes.onlinemax",
"cover.image_id", "screenshots.image_id", "videos.video_id",
"involved_companies.company.slug", "involved_companies.company.name", "involved_companies.company.logo.image_id",
"genres.slug", "genres.name",
"keywords.slug", "keywords.name",
"themes.slug", "themes.name",
"player_perspectives.slug", "player_perspectives.name"
);
public static final String GAME_QUERY_FIELDS_STRING = String.join(",", GAME_QUERY_FIELDS);
public static final String IMAGES_BASE_URL = "https://images.igdb.com/igdb/image/upload/";
public static final String COVER_IMAGE_SIZE = "cover_big";
public static final String SCREENSHOT_IMAGE_SIZE = "screenshot_med";
public static final String LOGO_IMAGE_SIZE = "logo_med";
}
@@ -0,0 +1,150 @@
package de.grimsi.gameyfin.igdb;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.config.WebClientConfig;
import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto;
import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator;
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriComponentsBuilder;
import javax.annotation.PostConstruct;
import java.net.URI;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
public class IgdbWrapper {
@Value("${gameyfin.igdb.api.client-id}")
private String clientId;
@Value("${gameyfin.igdb.api.client-secret}")
private String clientSecret;
@Value("${gameyfin.igdb.config.preferred-platforms:6}")
private String preferredPlatforms;
@Autowired
private WebClient.Builder webclientBuilder;
private WebClient twitchApiClient;
private WebClient igdbApiClient;
private TwitchOAuthTokenDto accessToken;
@PostConstruct
public void init() {
twitchApiClient = webclientBuilder.build();
authenticate();
initIgdbClient();
}
public void authenticate() {
log.info("Authenticating on Twitch API...");
URI url = UriComponentsBuilder
.fromHttpUrl("https://id.twitch.tv/oauth2/token?client_id={client_id}&client_secret={client_secret}&grant_type=client_credentials")
.buildAndExpand(clientId, clientSecret)
.toUri();
this.accessToken = twitchApiClient
.post()
.uri(url)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(TwitchOAuthTokenDto.class)
.block();
log.info("Successfully authenticated.");
}
public Optional<Igdb.Game> getGameById(Long id) {
Igdb.GameResult gameResult = queryIgdbApi(
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
"fields %s; where id = %d; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, id),
Igdb.GameResult.class
);
if (gameResult == null) return Optional.empty();
return Optional.of(gameResult.getGames(0));
}
public Optional<Igdb.Game> getGameBySlug(String slug) {
Igdb.GameResult gameResult = queryIgdbApi(
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
"fields %s; where slug = \"%s\"; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, slug),
Igdb.GameResult.class
);
if (gameResult == null) return Optional.empty();
return Optional.of(gameResult.getGames(0));
}
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm) {
Igdb.GameResult gameResult = queryIgdbApi(
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
"search \"%s\"; fields %s; where platforms = (%s);"
.formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING, preferredPlatforms),
Igdb.GameResult.class
);
if (gameResult == null) {
log.warn("Could not find game for title '{}'", searchTerm);
return Optional.empty();
}
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
// If that also returns nothing, just return the first search result
//
// Example: Searching for "Rainbow Six Siege" will result in returning "Tom Clancy's Rainbow Six Siege" (the game we want)
// If we just used the first result from IGDB we would get something like "Tom Clancy's Rainbow Six Siege Demon Veil" as a result
Optional<Igdb.Game> srExactTitleMatch = games.stream().filter(s -> s.getName().equals(searchTerm)).findFirst();
if (srExactTitleMatch.isPresent()) return srExactTitleMatch;
Optional<Igdb.Game> srTitleEndsWithMatch = games.stream().filter(s -> s.getName().endsWith(searchTerm)).findFirst();
if (srTitleEndsWithMatch.isPresent()) return srTitleEndsWithMatch;
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();
}
private <T> T queryIgdbApi(String endpoint, String query, Class<T> responseClass) {
return igdbApiClient.post()
.uri(endpoint)
.bodyValue(query)
.retrieve()
.bodyToMono(responseClass)
.transformDeferred(BulkheadOperator.of(WebClientConfig.IGDB_CONCURRENCY_LIMITER))
.transformDeferred(RateLimiterOperator.of(WebClientConfig.IGDB_RATE_LIMITER))
.block();
}
}
@@ -0,0 +1,15 @@
package de.grimsi.gameyfin.igdb.dto;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class TwitchOAuthTokenDto {
private String accessToken;
private Long expiresIn;
private String tokenType;
}
@@ -0,0 +1,21 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Company;
import java.util.List;
public class CompanyMapper {
public static Company toCompany(Igdb.InvolvedCompany c) {
return Company.builder()
.slug(c.getCompany().getSlug())
.name(c.getCompany().getName())
.logoId(c.getCompany().getLogo().getImageId())
.build();
}
public static List<Company> toCompanies(List<Igdb.InvolvedCompany> c) {
return c.stream().map(CompanyMapper::toCompany).toList();
}
}
@@ -0,0 +1,58 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.util.ProtobufUtils;
import java.nio.file.Path;
import java.util.List;
public class GameMapper {
public static DetectedGame toDetectedGame(Igdb.Game g, Path path) {
List<Igdb.MultiplayerMode> multiplayerModes = g.getMultiplayerModesList();
List<String> screenshotIds = g.getScreenshotsList().stream().map(Igdb.Screenshot::getImageId).toList();
List<String> videoIds = g.getVideosList().stream().map(Igdb.GameVideo::getVideoId).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(g.getCategory().name())
.offlineCoop(hasOfflineCoop(multiplayerModes))
.onlineCoop(hasOnlineCoop(multiplayerModes))
.lanSupport(hasLanSupport(multiplayerModes))
.maxPlayers(getMaxPlayers(multiplayerModes))
.coverId(g.getCover().getImageId())
.screenshotIds(screenshotIds)
.videoIds(videoIds)
.companies(CompanyMapper.toCompanies(g.getInvolvedCompaniesList()))
.genres(GenreMapper.toGenres(g.getGenresList()))
.keywords(KeywordMapper.toKeywords(g.getKeywordsList()))
.themes(ThemeMapper.toThemes(g.getThemesList()))
.playerPerspectives(PlayerPerspectiveMapper.toPlayerPerspectives(g.getPlayerPerspectivesList()))
.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);
}
}
@@ -0,0 +1,20 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Genre;
import java.util.List;
public class GenreMapper {
public static Genre toGenre(Igdb.Genre g) {
return Genre.builder()
.slug(g.getSlug())
.name(g.getName())
.build();
}
public static List<Genre> toGenres(List<Igdb.Genre> g) {
return g.stream().map(GenreMapper::toGenre).toList();
}
}
@@ -0,0 +1,19 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Keyword;
import java.util.List;
public class KeywordMapper {
public static Keyword toKeyword(Igdb.Keyword g) {
return Keyword.builder()
.slug(g.getSlug())
.name(g.getName())
.build();
}
public static List<Keyword> toKeywords(List<Igdb.Keyword> g) {
return g.stream().map(KeywordMapper::toKeyword).toList();
}
}
@@ -0,0 +1,20 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.PlayerPerspective;
import java.util.List;
public class PlayerPerspectiveMapper {
public static PlayerPerspective toPlayerPerspective(Igdb.PlayerPerspective g) {
return PlayerPerspective.builder()
.slug(g.getSlug())
.name(g.getName())
.build();
}
public static List<PlayerPerspective> toPlayerPerspectives(List<Igdb.PlayerPerspective> g) {
return g.stream().map(PlayerPerspectiveMapper::toPlayerPerspective).toList();
}
}
@@ -0,0 +1,19 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Theme;
import java.util.List;
public class ThemeMapper {
public static Theme toTheme(Igdb.Theme g) {
return Theme.builder()
.slug(g.getSlug())
.name(g.getName())
.build();
}
public static List<Theme> toThemes(List<Igdb.Theme> g) {
return g.stream().map(ThemeMapper::toTheme).toList();
}
}
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.Company;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CompanyRepository extends JpaRepository<Company, String> {
}
@@ -0,0 +1,22 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.DetectedGame;
import org.springframework.data.jpa.repository.JpaRepository;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
public interface DetectedGameRepository extends JpaRepository<DetectedGame, String> {
boolean existsByPath(String path);
boolean existsBySlug(String slug);
List<DetectedGame> getAllByPathNotIn(Collection<String> paths);
default List<DetectedGame> getAllByPathNotIn(List<Path> paths) {
List<String> pathStrings = paths.stream().map(Path::toString).toList();
return getAllByPathNotIn(pathStrings);
}
}
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.Genre;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GenreRepository extends JpaRepository<Genre, String> {
}
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.Keyword;
import org.springframework.data.jpa.repository.JpaRepository;
public interface KeywordRepository extends JpaRepository<Keyword, String> {
}
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.PlayerPerspective;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PlayerPerspectiveRepository extends JpaRepository<PlayerPerspective, String> {
}
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.Keyword;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ThemeRepository extends JpaRepository<Keyword, String> {
}
@@ -0,0 +1,20 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.UnmappableFile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
public interface UnmappableFileRepository extends JpaRepository<UnmappableFile, Long> {
boolean existsByPath(String path);
List<UnmappableFile> getAllByPathNotIn(Collection<String> paths);
default List<UnmappableFile> getAllByPathNotIn(List<Path> paths) {
List<String> pathStrings = paths.stream().map(Path::toString).toList();
return getAllByPathNotIn(pathStrings);
}
}
@@ -0,0 +1,33 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.service.GameService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* This controller handles logic related to detected games.
*/
@RestController
@RequestMapping("/v1/games")
@RequiredArgsConstructor
public class GamesController {
private final GameService gameService;
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<DetectedGame> getAllGames() {
return gameService.getAllDetectedGames();
}
@GetMapping(value = "/game-mappings", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, String> getGameMappings() {
return gameService.getAllMappings();
}
}
@@ -0,0 +1,26 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.service.FilesystemService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
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;
/**
* This controller handles functionality for images.
*/
@RestController
@RequestMapping("/v1/images")
@RequiredArgsConstructor
public class ImageController {
private final FilesystemService filesystemService;
@GetMapping(value = "/{imageId}", produces = MediaType.IMAGE_PNG_VALUE)
public Resource getCoverImageForGame(@PathVariable String imageId) {
return filesystemService.getImage(imageId);
}
}
@@ -0,0 +1,42 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.service.FilesystemService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.file.Path;
import java.util.List;
/**
* This controller handles functionality of the library.
*/
@RestController
@RequestMapping("/v1/library")
@RequiredArgsConstructor
public class LibraryController {
private final FilesystemService filesystemService;
@GetMapping(value = "/scan", produces = MediaType.APPLICATION_JSON_VALUE)
public void scanLibrary(@RequestParam(value = "download_images", defaultValue = "true") boolean downloadImages) {
filesystemService.scanGameLibrary();
if(downloadImages) downloadImages();
}
@GetMapping(value = "/download_images")
public void downloadImages() {
filesystemService.downloadGameCovers();
filesystemService.downloadGameScreenshots();
filesystemService.downloadCompanyLogos();
}
@GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE)
public List<String> getAllFiles() {
return filesystemService.getGameFiles().stream().map(Path::toString).toList();
}
}
@@ -0,0 +1,29 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.UnmappableFile;
import de.grimsi.gameyfin.service.GameService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/v1/unmapped-files")
@RequiredArgsConstructor
public class UnmappedFileController {
private final GameService gameService;
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<UnmappableFile> getUnmappedFiles() {
return gameService.getAllUnmappedFiles();
}
@PostMapping(value = "/{unmappedFileId}/map-to/{igdbSlug}", produces = MediaType.APPLICATION_JSON_VALUE)
public DetectedGame mapGameManually(@PathVariable Long unmappedFileId, @PathVariable String igdbSlug) {
return gameService.mapUnmappedFile(unmappedFileId, igdbSlug);
}
}
@@ -0,0 +1,259 @@
package de.grimsi.gameyfin.service;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Company;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.UnmappableFile;
import de.grimsi.gameyfin.igdb.IgdbApiProperties;
import de.grimsi.gameyfin.igdb.IgdbWrapper;
import de.grimsi.gameyfin.mapper.GameMapper;
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StopWatch;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
@Service
public class FilesystemService {
@Value("${gameyfin.root}")
private String rootFolderPath;
@Value("${gameyfin.cache}")
private String cacheFolderPath;
@Value("${gameyfin.file-extensions}")
private List<String> possibleGameFileExtensions;
@Autowired
private IgdbWrapper igdbWrapper;
@Autowired
private DetectedGameRepository detectedGameRepository;
@Autowired
private UnmappableFileRepository unmappableFileRepository;
@Autowired
private WebClient.Builder webclientBuilder;
private WebClient igdbImageClient;
@PostConstruct
public void init() {
igdbImageClient = webclientBuilder.baseUrl(IgdbApiProperties.IMAGES_BASE_URL).build();
}
public List<Path> getGameFiles() {
Path rootFolder = Path.of(rootFolderPath);
try (Stream<Path> stream = Files.list(rootFolder)) {
// return all sub-folders (non-recursive) and files that have an extension that indicates that they are a downloadable file
return stream
.filter(p -> Files.isDirectory(p) || possibleGameFileExtensions.contains(FilenameUtils.getExtension(p.getFileName().toString())))
.toList();
} catch (IOException e) {
throw new RuntimeException("Error while opening root folder", e);
}
}
public void scanGameLibrary() {
StopWatch stopWatch = new StopWatch();
log.info("Starting scan...");
stopWatch.start();
AtomicInteger newUnmappedFilesCounter = new AtomicInteger();
List<Path> gameFiles = getGameFiles();
// Check if any games that are in the library have been removed from the file system
// This would include renamed files, but they will be re-detected by the next step
List<DetectedGame> deletedGames = detectedGameRepository.getAllByPathNotIn(gameFiles);
detectedGameRepository.deleteAll(deletedGames);
deletedGames.forEach(g -> log.info("Game '{}' has been moved or deleted.", g.getPath()));
// Now check if there are any unmapped files that have been removed from the file system
List<UnmappableFile> deletedUnmappableFiles = unmappableFileRepository.getAllByPathNotIn(gameFiles);
unmappableFileRepository.deleteAll(deletedUnmappableFiles);
deletedUnmappableFiles.forEach(g -> log.info("Unmapped file '{}' has been moved or deleted.", g.getPath()));
// Filter out the games we already know and the ones we already tried to map to a game without success
gameFiles = gameFiles.stream()
.filter(g -> !detectedGameRepository.existsByPath(g.toString()))
.filter(g -> !unmappableFileRepository.existsByPath(g.toString()))
.peek(p -> log.info("Found new potential game: {}", p))
.toList();
// For each new game, load the info from IGDB
// If a game is not found on IGDB, add it to the list of unmapped files so we won't query the API later on for the same path
// If a game is not found on IGDB, blacklist the path, so we won't query the API later for the same path
List<DetectedGame> newDetectedGames = gameFiles.parallelStream()
.map(p -> {
Optional<Igdb.Game> optionalGame = igdbWrapper.searchForGameByTitle(getFilename(p));
return optionalGame.map(game -> Map.entry(p, game)).or(() -> {
unmappableFileRepository.save(new UnmappableFile(p.toString()));
newUnmappedFilesCounter.getAndIncrement();
log.info("Added path '{}' to list of unmapped files", p);
return Optional.empty();
});
})
.filter(Optional::isPresent)
.map(Optional::get)
.peek(e -> log.info("Mapped file '{}' to game '{}' (slug: {})", e.getKey(), e.getValue().getName(), e.getValue().getSlug()))
.map(e -> GameMapper.toDetectedGame(e.getValue(), e.getKey()))
.toList();
newDetectedGames = detectedGameRepository.saveAll(newDetectedGames);
stopWatch.stop();
log.info("Scan finished in {} seconds: Found {} new games, deleted {} games, could not map {} files/folders, {} games total.",
(int) stopWatch.getTotalTimeSeconds(), newDetectedGames.size(), deletedGames.size() + deletedUnmappableFiles.size(), newUnmappedFilesCounter.get(), detectedGameRepository.count());
}
public void downloadGameCovers() {
StopWatch stopWatch = new StopWatch();
log.info("Starting game cover download...");
stopWatch.start();
MultiValueMap<String, String> gameToImageIds = new LinkedMultiValueMap<>(
detectedGameRepository.findAll().stream()
.collect(Collectors.toMap(DetectedGame::getTitle, g -> Collections.singletonList(g.getCoverId()))));
int downloadCount = downloadImagesIntoCache(gameToImageIds, IgdbApiProperties.COVER_IMAGE_SIZE, "cover", "game");
stopWatch.stop();
log.info("Downloaded {} covers in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
}
public void downloadGameScreenshots() {
StopWatch stopWatch = new StopWatch();
log.info("Starting game screenshot download...");
stopWatch.start();
MultiValueMap<String, String> gamesToImageIds = new LinkedMultiValueMap<>(
detectedGameRepository.findAll().stream()
.collect(Collectors.toMap(DetectedGame::getTitle, DetectedGame::getScreenshotIds)));
int downloadCount = downloadImagesIntoCache(gamesToImageIds, IgdbApiProperties.SCREENSHOT_IMAGE_SIZE, "screenshot", "game");
stopWatch.stop();
log.info("Downloaded {} screenshots in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
}
public void downloadCompanyLogos() {
StopWatch stopWatch = new StopWatch();
log.info("Starting company logo download...");
stopWatch.start();
Map<String, List<String>> companyToLogoIdMap = detectedGameRepository.findAll().stream()
.flatMap(g -> g.getCompanies().stream())
.collect(Collectors.toMap(Company::getName, c -> Collections.singletonList(c.getLogoId()), (c1, c2) -> c1));
MultiValueMap<String, String> companiesToLogoIds = new LinkedMultiValueMap<>(companyToLogoIdMap);
int downloadCount = downloadImagesIntoCache(companiesToLogoIds, IgdbApiProperties.LOGO_IMAGE_SIZE, "logo", "company");
stopWatch.stop();
log.info("Downloaded {} company logos in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
}
public Resource getImage(String imageId) {
String filename = "%s.png".formatted(imageId);
try {
return new ByteArrayResource(Files.readAllBytes(Paths.get("%s/%s".formatted(cacheFolderPath, filename))));
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Could not find image file %s".formatted(filename));
}
}
private String getFilename(Path p) {
return FilenameUtils.getBaseName(p.toString());
}
private int downloadImagesIntoCache(MultiValueMap<String, String> entityToImageIds, String imageSize, String imageType, String entityType) {
AtomicInteger downloadCounter = new AtomicInteger();
Path cacheFolder = Path.of(cacheFolderPath);
try {
Files.createDirectories(cacheFolder);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not create cache folder.");
}
entityToImageIds.entrySet().parallelStream().forEach(entry ->
entry.getValue().forEach(imageId -> {
if (!StringUtils.hasText(imageId)) return;
String imgFileName = "%s.png".formatted(imageId);
String imgUrl = "t_%s/%s".formatted(imageSize, imgFileName);
if (Files.exists(Path.of(cacheFolderPath, imgFileName))) {
log.debug("{} for {} '{}' already downloaded ({}), skipping.",
imageType.substring(0, 1).toUpperCase() + imageType.substring(1).toLowerCase(),
entityType,
entry.getKey(),
imgFileName);
return;
}
Flux<DataBuffer> dataBuffer = igdbImageClient.get()
.uri(imgUrl)
.retrieve()
.bodyToFlux(DataBuffer.class);
try {
DataBufferUtils.write(dataBuffer, cacheFolder.resolve(imgFileName), StandardOpenOption.CREATE)
.share().block();
} catch (WebClientResponseException e) {
if (e.getStatusCode().is4xxClientError()) {
log.error("Could not download {} for {} '{}' from {}: {}", imageType, entityType, entry.getKey(), IgdbApiProperties.IMAGES_BASE_URL + imgUrl, e.getStatusCode());
}
}
downloadCounter.getAndIncrement();
log.info("Downloaded {} for {} '{}' from {}", imageType, entityType, entry.getKey(), IgdbApiProperties.IMAGES_BASE_URL + imgUrl);
}));
return downloadCounter.get();
}
}
@@ -0,0 +1,62 @@
package de.grimsi.gameyfin.service;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.UnmappableFile;
import de.grimsi.gameyfin.igdb.IgdbWrapper;
import de.grimsi.gameyfin.mapper.GameMapper;
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class GameService {
@Autowired
private IgdbWrapper igdbWrapper;
@Autowired
private DetectedGameRepository detectedGameRepository;
@Autowired
private UnmappableFileRepository unmappableFileRepository;
public List<DetectedGame> getAllDetectedGames() {
return detectedGameRepository.findAll();
}
public List<UnmappableFile> getAllUnmappedFiles() {
return unmappableFileRepository.findAll();
}
public Map<String, String> getAllMappings() {
return detectedGameRepository.findAll().stream().collect(Collectors.toMap(DetectedGame::getPath, DetectedGame::getTitle));
}
public DetectedGame mapUnmappedFile(Long unmappedGameId, String igdbSlug) {
UnmappableFile unmappableFile = unmappableFileRepository.findById(unmappedGameId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Unmapped file with id '%d' does not exist.".formatted(unmappedGameId)));
if(detectedGameRepository.existsBySlug(igdbSlug))
throw new ResponseStatusException(HttpStatus.CONFLICT, "Game with slug '%s' already exists in database.".formatted(igdbSlug));
Igdb.Game igdbGame = igdbWrapper.getGameBySlug(igdbSlug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(igdbSlug)));
DetectedGame game = GameMapper.toDetectedGame(igdbGame, Path.of(unmappableFile.getPath()));
game = detectedGameRepository.save(game);
unmappableFileRepository.delete(unmappableFile);
return game;
}
}
@@ -0,0 +1,11 @@
package de.grimsi.gameyfin.util;
import com.google.protobuf.Timestamp;
import java.time.Instant;
public class ProtobufUtils {
public static Instant toInstant(Timestamp t) {
return Instant.ofEpochSecond(t.getSeconds(), t.getNanos());
}
}