mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-14 16:20:04 +00:00
WIP: Implement frontend
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
gameyfin:
|
||||
#root: C:\Projects\privat\gameyfin-library
|
||||
root: \\NAS-Simon\Öffentlich\Spiele
|
||||
cache: ${gameyfin.root}\.gameyfin\cache
|
||||
db: ${gameyfin.root}\.gameyfin\db # Currently unused
|
||||
igdb:
|
||||
api:
|
||||
client-id: 23l3l5qshx4dwjuao6yb8jyf1qrd08
|
||||
client-secret: hf4iivmkzgne552j17p2d64xm03die
|
||||
|
||||
logging:
|
||||
level:
|
||||
de.grimsi: debug
|
||||
# org.springframework.web.reactive.function.client.ExchangeFunctions: debug
|
||||
|
||||
spring.mvc.log-request-details: true
|
||||
@@ -0,0 +1,28 @@
|
||||
server:
|
||||
port: 8080
|
||||
error.include-stacktrace: never
|
||||
|
||||
spring:
|
||||
jackson.default-property-inclusion: non_null
|
||||
datasource.db-name: gameyfin_db
|
||||
datasource.url: jdbc:h2:file:./data/${spring.datasource.db-name};AUTO_SERVER=TRUE
|
||||
datasource.username: gfadmin
|
||||
datasource.password: gameyfin
|
||||
datasource.driverClassName: org.h2.Driver
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.H2Dialect
|
||||
hibernate.ddl-auto: update
|
||||
open-in-view: true
|
||||
properties:
|
||||
hibernate:
|
||||
event.merge.entity_copy_observer: allow
|
||||
|
||||
gameyfin:
|
||||
root: ""
|
||||
file-extensions: iso, zip, rar, 7z, exe
|
||||
igdb:
|
||||
config:
|
||||
preferred-platforms: 6
|
||||
api:
|
||||
client-id: ""
|
||||
client-secret: ""
|
||||
@@ -0,0 +1,907 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package com.igdb.proto;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
option java_outer_classname = "Igdb";
|
||||
//option java_multiple_files = true; // Must be true because of private access in files.
|
||||
option optimize_for = CODE_SIZE;
|
||||
|
||||
message Count {
|
||||
int64 count = 1;
|
||||
}
|
||||
|
||||
message MultiQueryResult {
|
||||
string name = 1;
|
||||
repeated bytes results = 2;
|
||||
int64 count = 3;
|
||||
}
|
||||
|
||||
message MultiQueryResultArray {
|
||||
repeated MultiQueryResult result = 1;
|
||||
}
|
||||
|
||||
message AgeRatingResult {
|
||||
repeated AgeRating ageratings = 1;
|
||||
}
|
||||
|
||||
message AgeRating {
|
||||
uint64 id = 1;
|
||||
AgeRatingCategoryEnum category = 2;
|
||||
repeated AgeRatingContentDescription content_descriptions = 3;
|
||||
AgeRatingRatingEnum rating = 4;
|
||||
string rating_cover_url = 5;
|
||||
string synopsis = 6;
|
||||
string checksum = 7;
|
||||
}
|
||||
|
||||
|
||||
enum AgeRatingCategoryEnum {
|
||||
AGERATING_CATEGORY_NULL = 0;
|
||||
ESRB = 1;
|
||||
PEGI = 2;
|
||||
CERO = 3;
|
||||
USK = 4;
|
||||
GRAC = 5;
|
||||
CLASS_IND = 6;
|
||||
ACB = 7;
|
||||
}
|
||||
|
||||
|
||||
enum AgeRatingRatingEnum {
|
||||
AGERATING_RATING_NULL = 0;
|
||||
THREE = 1;
|
||||
SEVEN = 2;
|
||||
TWELVE = 3;
|
||||
SIXTEEN = 4;
|
||||
EIGHTEEN = 5;
|
||||
RP = 6;
|
||||
EC = 7;
|
||||
E = 8;
|
||||
E10 = 9;
|
||||
T = 10;
|
||||
M = 11;
|
||||
AO = 12;
|
||||
CERO_A = 13;
|
||||
CERO_B = 14;
|
||||
CERO_C = 15;
|
||||
CERO_D = 16;
|
||||
CERO_Z = 17;
|
||||
USK_0 = 18;
|
||||
USK_6 = 19;
|
||||
USK_12 = 20;
|
||||
USK_18 = 21;
|
||||
GRAC_ALL = 22;
|
||||
GRAC_TWELVE = 23;
|
||||
GRAC_FIFTEEN = 24;
|
||||
GRAC_EIGHTEEN = 25;
|
||||
GRAC_TESTING = 26;
|
||||
CLASS_IND_L = 27;
|
||||
CLASS_IND_TEN = 28;
|
||||
CLASS_IND_TWELVE = 29;
|
||||
CLASS_IND_FOURTEEN = 30;
|
||||
CLASS_IND_SIXTEEN = 31;
|
||||
CLASS_IND_EIGHTEEN = 32;
|
||||
ACB_G = 33;
|
||||
ACB_PG = 34;
|
||||
ACB_M = 35;
|
||||
ACB_MA15 = 36;
|
||||
ACB_R18 = 37;
|
||||
ACB_RC = 38;
|
||||
}
|
||||
|
||||
message AgeRatingContentDescriptionResult {
|
||||
repeated AgeRatingContentDescription ageratingcontentdescriptions = 1;
|
||||
}
|
||||
|
||||
message AgeRatingContentDescription {
|
||||
uint64 id = 1;
|
||||
AgeRatingRatingEnum category = 2;
|
||||
string description = 3;
|
||||
string checksum = 4;
|
||||
}
|
||||
|
||||
message AlternativeNameResult {
|
||||
repeated AlternativeName alternativenames = 1;
|
||||
}
|
||||
|
||||
message AlternativeName {
|
||||
uint64 id = 1;
|
||||
string comment = 2;
|
||||
Game game = 3;
|
||||
string name = 4;
|
||||
string checksum = 5;
|
||||
}
|
||||
|
||||
message ArtworkResult {
|
||||
repeated Artwork artworks = 1;
|
||||
}
|
||||
|
||||
message Artwork {
|
||||
uint64 id = 1;
|
||||
bool alpha_channel = 2;
|
||||
bool animated = 3;
|
||||
Game game = 4;
|
||||
int32 height = 5;
|
||||
string image_id = 6;
|
||||
string url = 7;
|
||||
int32 width = 8;
|
||||
string checksum = 9;
|
||||
}
|
||||
|
||||
message CharacterResult {
|
||||
repeated Character characters = 1;
|
||||
}
|
||||
|
||||
message Character {
|
||||
uint64 id = 1;
|
||||
repeated string akas = 2;
|
||||
string country_name = 3;
|
||||
google.protobuf.Timestamp created_at = 4;
|
||||
string description = 5;
|
||||
repeated Game games = 6;
|
||||
GenderGenderEnum gender = 7;
|
||||
CharacterMugShot mug_shot = 8;
|
||||
string name = 9;
|
||||
string slug = 10;
|
||||
CharacterSpeciesEnum species = 11;
|
||||
google.protobuf.Timestamp updated_at = 12;
|
||||
string url = 13;
|
||||
string checksum = 14;
|
||||
}
|
||||
|
||||
|
||||
enum GenderGenderEnum {
|
||||
MALE = 0;
|
||||
FEMALE = 1;
|
||||
OTHER = 2;
|
||||
}
|
||||
|
||||
|
||||
enum CharacterSpeciesEnum {
|
||||
CHARACTER_SPECIES_NULL = 0;
|
||||
HUMAN = 1;
|
||||
ALIEN = 2;
|
||||
ANIMAL = 3;
|
||||
ANDROID = 4;
|
||||
UNKNOWN = 5;
|
||||
}
|
||||
|
||||
message CharacterMugShotResult {
|
||||
repeated CharacterMugShot charactermugshots = 1;
|
||||
}
|
||||
|
||||
message CharacterMugShot {
|
||||
uint64 id = 1;
|
||||
bool alpha_channel = 2;
|
||||
bool animated = 3;
|
||||
int32 height = 4;
|
||||
string image_id = 5;
|
||||
string url = 6;
|
||||
int32 width = 7;
|
||||
string checksum = 8;
|
||||
}
|
||||
|
||||
message CollectionResult {
|
||||
repeated Collection collections = 1;
|
||||
}
|
||||
|
||||
message Collection {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
repeated Game games = 3;
|
||||
string name = 4;
|
||||
string slug = 5;
|
||||
google.protobuf.Timestamp updated_at = 6;
|
||||
string url = 7;
|
||||
string checksum = 8;
|
||||
}
|
||||
|
||||
message CompanyResult {
|
||||
repeated Company companies = 1;
|
||||
}
|
||||
|
||||
message Company {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp change_date = 2;
|
||||
DateFormatChangeDateCategoryEnum change_date_category = 3;
|
||||
Company changed_company_id = 4;
|
||||
int32 country = 5;
|
||||
google.protobuf.Timestamp created_at = 6;
|
||||
string description = 7;
|
||||
repeated Game developed = 8;
|
||||
CompanyLogo logo = 9;
|
||||
string name = 10;
|
||||
Company parent = 11;
|
||||
repeated Game published = 12;
|
||||
string slug = 13;
|
||||
google.protobuf.Timestamp start_date = 14;
|
||||
DateFormatChangeDateCategoryEnum start_date_category = 15;
|
||||
google.protobuf.Timestamp updated_at = 16;
|
||||
string url = 17;
|
||||
repeated CompanyWebsite websites = 18;
|
||||
string checksum = 19;
|
||||
}
|
||||
|
||||
|
||||
enum DateFormatChangeDateCategoryEnum {
|
||||
YYYYMMMMDD = 0;
|
||||
YYYYMMMM = 1;
|
||||
YYYY = 2;
|
||||
YYYYQ1 = 3;
|
||||
YYYYQ2 = 4;
|
||||
YYYYQ3 = 5;
|
||||
YYYYQ4 = 6;
|
||||
TBD = 7;
|
||||
}
|
||||
|
||||
message CompanyLogoResult {
|
||||
repeated CompanyLogo companylogos = 1;
|
||||
}
|
||||
|
||||
message CompanyLogo {
|
||||
uint64 id = 1;
|
||||
bool alpha_channel = 2;
|
||||
bool animated = 3;
|
||||
int32 height = 4;
|
||||
string image_id = 5;
|
||||
string url = 6;
|
||||
int32 width = 7;
|
||||
string checksum = 8;
|
||||
}
|
||||
|
||||
message CompanyWebsiteResult {
|
||||
repeated CompanyWebsite companywebsites = 1;
|
||||
}
|
||||
|
||||
message CompanyWebsite {
|
||||
uint64 id = 1;
|
||||
WebsiteCategoryEnum category = 2;
|
||||
bool trusted = 3;
|
||||
string url = 4;
|
||||
string checksum = 5;
|
||||
}
|
||||
|
||||
|
||||
enum WebsiteCategoryEnum {
|
||||
WEBSITE_CATEGORY_NULL = 0;
|
||||
WEBSITE_OFFICIAL = 1;
|
||||
WEBSITE_WIKIA = 2;
|
||||
WEBSITE_WIKIPEDIA = 3;
|
||||
WEBSITE_FACEBOOK = 4;
|
||||
WEBSITE_TWITTER = 5;
|
||||
WEBSITE_TWITCH = 6;
|
||||
WEBSITE_INSTAGRAM = 8;
|
||||
WEBSITE_YOUTUBE = 9;
|
||||
WEBSITE_IPHONE = 10;
|
||||
WEBSITE_IPAD = 11;
|
||||
WEBSITE_ANDROID = 12;
|
||||
WEBSITE_STEAM = 13;
|
||||
WEBSITE_REDDIT = 14;
|
||||
WEBSITE_ITCH = 15;
|
||||
WEBSITE_EPICGAMES = 16;
|
||||
WEBSITE_GOG = 17;
|
||||
WEBSITE_DISCORD = 18;
|
||||
}
|
||||
|
||||
message CoverResult {
|
||||
repeated Cover covers = 1;
|
||||
}
|
||||
|
||||
message Cover {
|
||||
uint64 id = 1;
|
||||
bool alpha_channel = 2;
|
||||
bool animated = 3;
|
||||
Game game = 4;
|
||||
int32 height = 5;
|
||||
string image_id = 6;
|
||||
string url = 7;
|
||||
int32 width = 8;
|
||||
string checksum = 9;
|
||||
}
|
||||
|
||||
message ExternalGameResult {
|
||||
repeated ExternalGame externalgames = 1;
|
||||
}
|
||||
|
||||
message ExternalGame {
|
||||
uint64 id = 1;
|
||||
ExternalGameCategoryEnum category = 2;
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
Game game = 4;
|
||||
string name = 5;
|
||||
string uid = 6;
|
||||
google.protobuf.Timestamp updated_at = 7;
|
||||
string url = 8;
|
||||
int32 year = 9;
|
||||
ExternalGameMediaEnum media = 10;
|
||||
Platform platform = 11;
|
||||
repeated int32 countries = 12;
|
||||
string checksum = 13;
|
||||
}
|
||||
|
||||
|
||||
enum ExternalGameCategoryEnum {
|
||||
EXTERNALGAME_CATEGORY_NULL = 0;
|
||||
EXTERNALGAME_STEAM = 1;
|
||||
EXTERNALGAME_GOG = 5;
|
||||
EXTERNALGAME_YOUTUBE = 10;
|
||||
EXTERNALGAME_MICROSOFT = 11;
|
||||
EXTERNALGAME_APPLE = 13;
|
||||
EXTERNALGAME_TWITCH = 14;
|
||||
EXTERNALGAME_ANDROID = 15;
|
||||
EXTERNALGAME_AMAZON_ASIN = 20;
|
||||
EXTERNALGAME_AMAZON_LUNA = 22;
|
||||
EXTERNALGAME_AMAZON_ADG = 23;
|
||||
EXTERNALGAME_EPIC_GAME_STORE = 26;
|
||||
EXTERNALGAME_OCULUS = 28;
|
||||
}
|
||||
|
||||
|
||||
enum ExternalGameMediaEnum {
|
||||
EXTERNALGAME_MEDIA_NULL = 0;
|
||||
EXTERNALGAME_DIGITAL = 1;
|
||||
EXTERNALGAME_PHYSICAL = 2;
|
||||
}
|
||||
|
||||
message FranchiseResult {
|
||||
repeated Franchise franchises = 1;
|
||||
}
|
||||
|
||||
message Franchise {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
repeated Game games = 3;
|
||||
string name = 4;
|
||||
string slug = 5;
|
||||
google.protobuf.Timestamp updated_at = 6;
|
||||
string url = 7;
|
||||
string checksum = 8;
|
||||
}
|
||||
|
||||
message GameResult {
|
||||
repeated Game games = 1;
|
||||
}
|
||||
|
||||
message Game {
|
||||
uint64 id = 1;
|
||||
repeated AgeRating age_ratings = 2;
|
||||
double aggregated_rating = 3;
|
||||
int32 aggregated_rating_count = 4;
|
||||
repeated AlternativeName alternative_names = 5;
|
||||
repeated Artwork artworks = 6;
|
||||
repeated Game bundles = 7;
|
||||
GameCategoryEnum category = 8;
|
||||
Collection collection = 9;
|
||||
Cover cover = 10;
|
||||
google.protobuf.Timestamp created_at = 11;
|
||||
repeated Game dlcs = 12;
|
||||
repeated Game expansions = 13;
|
||||
repeated ExternalGame external_games = 14;
|
||||
google.protobuf.Timestamp first_release_date = 15;
|
||||
int32 follows = 16;
|
||||
Franchise franchise = 17;
|
||||
repeated Franchise franchises = 18;
|
||||
repeated GameEngine game_engines = 19;
|
||||
repeated GameMode game_modes = 20;
|
||||
repeated Genre genres = 21;
|
||||
int32 hypes = 22;
|
||||
repeated InvolvedCompany involved_companies = 23;
|
||||
repeated Keyword keywords = 24;
|
||||
repeated MultiplayerMode multiplayer_modes = 25;
|
||||
string name = 26;
|
||||
Game parent_game = 27;
|
||||
repeated Platform platforms = 28;
|
||||
repeated PlayerPerspective player_perspectives = 29;
|
||||
double rating = 30;
|
||||
int32 rating_count = 31;
|
||||
repeated ReleaseDate release_dates = 32;
|
||||
repeated Screenshot screenshots = 33;
|
||||
repeated Game similar_games = 34;
|
||||
string slug = 35;
|
||||
repeated Game standalone_expansions = 36;
|
||||
GameStatusEnum status = 37;
|
||||
string storyline = 38;
|
||||
string summary = 39;
|
||||
repeated int32 tags = 40;
|
||||
repeated Theme themes = 41;
|
||||
double total_rating = 42;
|
||||
int32 total_rating_count = 43;
|
||||
google.protobuf.Timestamp updated_at = 44;
|
||||
string url = 45;
|
||||
Game version_parent = 46;
|
||||
string version_title = 47;
|
||||
repeated GameVideo videos = 48;
|
||||
repeated Website websites = 49;
|
||||
string checksum = 50;
|
||||
repeated Game remakes = 51;
|
||||
repeated Game remasters = 52;
|
||||
repeated Game expanded_games = 53;
|
||||
repeated Game ports = 54;
|
||||
repeated Game forks = 55;
|
||||
}
|
||||
|
||||
|
||||
enum GameCategoryEnum {
|
||||
MAIN_GAME = 0;
|
||||
DLC_ADDON = 1;
|
||||
EXPANSION = 2;
|
||||
BUNDLE = 3;
|
||||
STANDALONE_EXPANSION = 4;
|
||||
MOD = 5;
|
||||
EPISODE = 6;
|
||||
SEASON = 7;
|
||||
REMAKE = 8;
|
||||
REMASTER = 9;
|
||||
EXPANDED_GAME = 10;
|
||||
PORT = 11;
|
||||
FORK = 12;
|
||||
}
|
||||
|
||||
|
||||
enum GameStatusEnum {
|
||||
RELEASED = 0;
|
||||
ALPHA = 2;
|
||||
BETA = 3;
|
||||
EARLY_ACCESS = 4;
|
||||
OFFLINE = 5;
|
||||
CANCELLED = 6;
|
||||
RUMORED = 7;
|
||||
DELISTED = 8;
|
||||
}
|
||||
|
||||
message GameEngineResult {
|
||||
repeated GameEngine gameengines = 1;
|
||||
}
|
||||
|
||||
message GameEngine {
|
||||
uint64 id = 1;
|
||||
repeated Company companies = 2;
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
string description = 4;
|
||||
GameEngineLogo logo = 5;
|
||||
string name = 6;
|
||||
repeated Platform platforms = 7;
|
||||
string slug = 8;
|
||||
google.protobuf.Timestamp updated_at = 9;
|
||||
string url = 10;
|
||||
string checksum = 11;
|
||||
}
|
||||
|
||||
message GameEngineLogoResult {
|
||||
repeated GameEngineLogo gameenginelogos = 1;
|
||||
}
|
||||
|
||||
message GameEngineLogo {
|
||||
uint64 id = 1;
|
||||
bool alpha_channel = 2;
|
||||
bool animated = 3;
|
||||
int32 height = 4;
|
||||
string image_id = 5;
|
||||
string url = 6;
|
||||
int32 width = 7;
|
||||
string checksum = 8;
|
||||
}
|
||||
|
||||
message GameModeResult {
|
||||
repeated GameMode gamemodes = 1;
|
||||
}
|
||||
|
||||
message GameMode {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
string name = 3;
|
||||
string slug = 4;
|
||||
google.protobuf.Timestamp updated_at = 5;
|
||||
string url = 6;
|
||||
string checksum = 7;
|
||||
}
|
||||
|
||||
message GameVersionResult {
|
||||
repeated GameVersion gameversions = 1;
|
||||
}
|
||||
|
||||
message GameVersion {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
repeated GameVersionFeature features = 3;
|
||||
Game game = 4;
|
||||
repeated Game games = 5;
|
||||
google.protobuf.Timestamp updated_at = 6;
|
||||
string url = 7;
|
||||
string checksum = 8;
|
||||
}
|
||||
|
||||
message GameVersionFeatureResult {
|
||||
repeated GameVersionFeature gameversionfeatures = 1;
|
||||
}
|
||||
|
||||
message GameVersionFeature {
|
||||
uint64 id = 1;
|
||||
GameVersionFeatureCategoryEnum category = 2;
|
||||
string description = 3;
|
||||
int32 position = 4;
|
||||
string title = 5;
|
||||
repeated GameVersionFeatureValue values = 6;
|
||||
string checksum = 7;
|
||||
}
|
||||
|
||||
|
||||
enum GameVersionFeatureCategoryEnum {
|
||||
BOOLEAN = 0;
|
||||
DESCRIPTION = 1;
|
||||
}
|
||||
|
||||
message GameVersionFeatureValueResult {
|
||||
repeated GameVersionFeatureValue gameversionfeaturevalues = 1;
|
||||
}
|
||||
|
||||
message GameVersionFeatureValue {
|
||||
uint64 id = 1;
|
||||
Game game = 2;
|
||||
GameVersionFeature game_feature = 3;
|
||||
GameVersionFeatureValueIncludedFeatureEnum included_feature = 4;
|
||||
string note = 5;
|
||||
string checksum = 6;
|
||||
}
|
||||
|
||||
|
||||
enum GameVersionFeatureValueIncludedFeatureEnum {
|
||||
NOT_INCLUDED = 0;
|
||||
INCLUDED = 1;
|
||||
PRE_ORDER_ONLY = 2;
|
||||
}
|
||||
|
||||
message GameVideoResult {
|
||||
repeated GameVideo gamevideos = 1;
|
||||
}
|
||||
|
||||
message GameVideo {
|
||||
uint64 id = 1;
|
||||
Game game = 2;
|
||||
string name = 3;
|
||||
string video_id = 4;
|
||||
string checksum = 5;
|
||||
}
|
||||
|
||||
message GenreResult {
|
||||
repeated Genre genres = 1;
|
||||
}
|
||||
|
||||
message Genre {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
string name = 3;
|
||||
string slug = 4;
|
||||
google.protobuf.Timestamp updated_at = 5;
|
||||
string url = 6;
|
||||
string checksum = 7;
|
||||
}
|
||||
|
||||
message InvolvedCompanyResult {
|
||||
repeated InvolvedCompany involvedcompanies = 1;
|
||||
}
|
||||
|
||||
message InvolvedCompany {
|
||||
uint64 id = 1;
|
||||
Company company = 2;
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
bool developer = 4;
|
||||
Game game = 5;
|
||||
bool porting = 6;
|
||||
bool publisher = 7;
|
||||
bool supporting = 8;
|
||||
google.protobuf.Timestamp updated_at = 9;
|
||||
string checksum = 10;
|
||||
}
|
||||
|
||||
message KeywordResult {
|
||||
repeated Keyword keywords = 1;
|
||||
}
|
||||
|
||||
message Keyword {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
string name = 3;
|
||||
string slug = 4;
|
||||
google.protobuf.Timestamp updated_at = 5;
|
||||
string url = 6;
|
||||
string checksum = 7;
|
||||
}
|
||||
|
||||
message MultiplayerModeResult {
|
||||
repeated MultiplayerMode multiplayermodes = 1;
|
||||
}
|
||||
|
||||
message MultiplayerMode {
|
||||
uint64 id = 1;
|
||||
bool campaigncoop = 2;
|
||||
bool dropin = 3;
|
||||
Game game = 4;
|
||||
bool lancoop = 5;
|
||||
bool offlinecoop = 6;
|
||||
int32 offlinecoopmax = 7;
|
||||
int32 offlinemax = 8;
|
||||
bool onlinecoop = 9;
|
||||
int32 onlinecoopmax = 10;
|
||||
int32 onlinemax = 11;
|
||||
Platform platform = 12;
|
||||
bool splitscreen = 13;
|
||||
bool splitscreenonline = 14;
|
||||
string checksum = 15;
|
||||
}
|
||||
|
||||
message PlatformResult {
|
||||
repeated Platform platforms = 1;
|
||||
}
|
||||
|
||||
message Platform {
|
||||
uint64 id = 1;
|
||||
string abbreviation = 2;
|
||||
string alternative_name = 3;
|
||||
PlatformCategoryEnum category = 4;
|
||||
google.protobuf.Timestamp created_at = 5;
|
||||
int32 generation = 6;
|
||||
string name = 7;
|
||||
PlatformLogo platform_logo = 8;
|
||||
PlatformFamily platform_family = 9;
|
||||
string slug = 10;
|
||||
string summary = 11;
|
||||
google.protobuf.Timestamp updated_at = 12;
|
||||
string url = 13;
|
||||
repeated PlatformVersion versions = 14;
|
||||
repeated PlatformWebsite websites = 15;
|
||||
string checksum = 16;
|
||||
}
|
||||
|
||||
|
||||
enum PlatformCategoryEnum {
|
||||
PLATFORM_CATEGORY_NULL = 0;
|
||||
CONSOLE = 1;
|
||||
ARCADE = 2;
|
||||
PLATFORM = 3;
|
||||
OPERATING_SYSTEM = 4;
|
||||
PORTABLE_CONSOLE = 5;
|
||||
COMPUTER = 6;
|
||||
}
|
||||
|
||||
message PlatformFamilyResult {
|
||||
repeated PlatformFamily platformfamilies = 1;
|
||||
}
|
||||
|
||||
message PlatformFamily {
|
||||
uint64 id = 1;
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
string checksum = 4;
|
||||
}
|
||||
|
||||
message PlatformLogoResult {
|
||||
repeated PlatformLogo platformlogos = 1;
|
||||
}
|
||||
|
||||
message PlatformLogo {
|
||||
uint64 id = 1;
|
||||
bool alpha_channel = 2;
|
||||
bool animated = 3;
|
||||
int32 height = 4;
|
||||
string image_id = 5;
|
||||
string url = 6;
|
||||
int32 width = 7;
|
||||
string checksum = 8;
|
||||
}
|
||||
|
||||
message PlatformVersionResult {
|
||||
repeated PlatformVersion platformversions = 1;
|
||||
}
|
||||
|
||||
message PlatformVersion {
|
||||
uint64 id = 1;
|
||||
repeated PlatformVersionCompany companies = 2;
|
||||
string connectivity = 3;
|
||||
string cpu = 4;
|
||||
string graphics = 5;
|
||||
PlatformVersionCompany main_manufacturer = 6;
|
||||
string media = 7;
|
||||
string memory = 8;
|
||||
string name = 9;
|
||||
string online = 10;
|
||||
string os = 11;
|
||||
string output = 12;
|
||||
PlatformLogo platform_logo = 13;
|
||||
repeated PlatformVersionReleaseDate platform_version_release_dates = 14;
|
||||
string resolutions = 15;
|
||||
string slug = 16;
|
||||
string sound = 17;
|
||||
string storage = 18;
|
||||
string summary = 19;
|
||||
string url = 20;
|
||||
string checksum = 21;
|
||||
}
|
||||
|
||||
message PlatformVersionCompanyResult {
|
||||
repeated PlatformVersionCompany platformversioncompanies = 1;
|
||||
}
|
||||
|
||||
message PlatformVersionCompany {
|
||||
uint64 id = 1;
|
||||
string comment = 2;
|
||||
Company company = 3;
|
||||
bool developer = 4;
|
||||
bool manufacturer = 5;
|
||||
string checksum = 6;
|
||||
}
|
||||
|
||||
message PlatformVersionReleaseDateResult {
|
||||
repeated PlatformVersionReleaseDate platformversionreleasedates = 1;
|
||||
}
|
||||
|
||||
message PlatformVersionReleaseDate {
|
||||
uint64 id = 1;
|
||||
DateFormatChangeDateCategoryEnum category = 2;
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
google.protobuf.Timestamp date = 4;
|
||||
string human = 5;
|
||||
int32 m = 6;
|
||||
PlatformVersion platform_version = 7;
|
||||
RegionRegionEnum region = 8;
|
||||
google.protobuf.Timestamp updated_at = 9;
|
||||
int32 y = 10;
|
||||
string checksum = 11;
|
||||
}
|
||||
|
||||
|
||||
enum RegionRegionEnum {
|
||||
REGION_REGION_NULL = 0;
|
||||
EUROPE = 1;
|
||||
NORTH_AMERICA = 2;
|
||||
AUSTRALIA = 3;
|
||||
NEW_ZEALAND = 4;
|
||||
JAPAN = 5;
|
||||
CHINA = 6;
|
||||
ASIA = 7;
|
||||
WORLDWIDE = 8;
|
||||
KOREA = 9;
|
||||
BRAZIL = 10;
|
||||
}
|
||||
|
||||
message PlatformWebsiteResult {
|
||||
repeated PlatformWebsite platformwebsites = 1;
|
||||
}
|
||||
|
||||
message PlatformWebsite {
|
||||
uint64 id = 1;
|
||||
WebsiteCategoryEnum category = 2;
|
||||
bool trusted = 3;
|
||||
string url = 4;
|
||||
string checksum = 5;
|
||||
}
|
||||
|
||||
message PlayerPerspectiveResult {
|
||||
repeated PlayerPerspective playerperspectives = 1;
|
||||
}
|
||||
|
||||
message PlayerPerspective {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
string name = 3;
|
||||
string slug = 4;
|
||||
google.protobuf.Timestamp updated_at = 5;
|
||||
string url = 6;
|
||||
string checksum = 7;
|
||||
}
|
||||
|
||||
message ReleaseDateResult {
|
||||
repeated ReleaseDate releasedates = 1;
|
||||
}
|
||||
|
||||
message ReleaseDate {
|
||||
uint64 id = 1;
|
||||
DateFormatChangeDateCategoryEnum category = 2;
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
google.protobuf.Timestamp date = 4;
|
||||
Game game = 5;
|
||||
string human = 6;
|
||||
int32 m = 7;
|
||||
Platform platform = 8;
|
||||
RegionRegionEnum region = 9;
|
||||
google.protobuf.Timestamp updated_at = 10;
|
||||
int32 y = 11;
|
||||
string checksum = 12;
|
||||
}
|
||||
|
||||
message ScreenshotResult {
|
||||
repeated Screenshot screenshots = 1;
|
||||
}
|
||||
|
||||
message Screenshot {
|
||||
uint64 id = 1;
|
||||
bool alpha_channel = 2;
|
||||
bool animated = 3;
|
||||
Game game = 4;
|
||||
int32 height = 5;
|
||||
string image_id = 6;
|
||||
string url = 7;
|
||||
int32 width = 8;
|
||||
string checksum = 9;
|
||||
}
|
||||
|
||||
message SearchResult {
|
||||
repeated Search searches = 1;
|
||||
}
|
||||
|
||||
message Search {
|
||||
uint64 id = 1;
|
||||
string alternative_name = 2;
|
||||
Character character = 3;
|
||||
Collection collection = 4;
|
||||
Company company = 5;
|
||||
string description = 6;
|
||||
Game game = 7;
|
||||
string name = 8;
|
||||
Platform platform = 9;
|
||||
google.protobuf.Timestamp published_at = 10;
|
||||
TestDummy test_dummy = 11;
|
||||
Theme theme = 12;
|
||||
string checksum = 13;
|
||||
}
|
||||
|
||||
message TestDummyResult {
|
||||
repeated TestDummy testdummies = 1;
|
||||
}
|
||||
|
||||
message TestDummy {
|
||||
uint64 id = 1;
|
||||
bool bool_value = 2;
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
TestDummyEnumTestEnum enum_test = 4;
|
||||
double float_value = 5;
|
||||
Game game = 6;
|
||||
repeated int32 integer_array = 7;
|
||||
int32 integer_value = 8;
|
||||
string name = 9;
|
||||
int32 new_integer_value = 10;
|
||||
bool private = 11;
|
||||
string slug = 12;
|
||||
repeated string string_array = 13;
|
||||
repeated TestDummy test_dummies = 14;
|
||||
TestDummy test_dummy = 15;
|
||||
google.protobuf.Timestamp updated_at = 16;
|
||||
string url = 17;
|
||||
string checksum = 18;
|
||||
}
|
||||
|
||||
|
||||
enum TestDummyEnumTestEnum {
|
||||
TESTDUMMY_ENUM_TEST_NULL = 0;
|
||||
ENUM1 = 1;
|
||||
ENUM2 = 2;
|
||||
}
|
||||
|
||||
message ThemeResult {
|
||||
repeated Theme themes = 1;
|
||||
}
|
||||
|
||||
message Theme {
|
||||
uint64 id = 1;
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
string name = 3;
|
||||
string slug = 4;
|
||||
google.protobuf.Timestamp updated_at = 5;
|
||||
string url = 6;
|
||||
string checksum = 7;
|
||||
}
|
||||
|
||||
message WebsiteResult {
|
||||
repeated Website websites = 1;
|
||||
}
|
||||
|
||||
message Website {
|
||||
uint64 id = 1;
|
||||
WebsiteCategoryEnum category = 2;
|
||||
Game game = 3;
|
||||
bool trusted = 4;
|
||||
string url = 5;
|
||||
string checksum = 6;
|
||||
}
|
||||
Reference in New Issue
Block a user