Start development of v2

This commit is contained in:
grimsi
2024-02-04 12:21:07 +01:00
parent 8baf2e776b
commit fc84f92e23
253 changed files with 479 additions and 24885 deletions
+16 -9
View File
@@ -1,8 +1,10 @@
node_modules
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
@@ -12,12 +14,18 @@ target/
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
@@ -25,16 +33,15 @@ target/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
/.mvn/
### Kotlin ###
.kotlin
### Custom ###
/data/
/backend/src/main/resources/static/
/docker/docker-compose.yml
/.gameyfin/
/.gameyfin/
-5
View File
@@ -1,5 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Angular Application" type="JavascriptDebugType" uri="http://localhost:4200" useFirstLineBreakpoints="true">
<method v="2" />
</configuration>
</component>
-12
View File
@@ -1,12 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Angular CLI Server" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/frontend/package.json" />
<command value="run" />
<scripts>
<script value="start" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>
+24 -27
View File
@@ -1,46 +1,43 @@
<div align="center">
<h2>Note: v2 is in a very early stage</h2>
<h3>Most features are missing, existing features will probably break!</h3>
</br></br></br>
<img src="assets/Gameyfin_Logo_White_Border.svg" height="128px" width="auto" alt="Gameyfin Logo">
<h1>Gameyfin</h1>
<p align="center">A simple game library manager.</p>
<p align="center">A simple game library manager. Now even better</p>
</div>
# Overview
Name and functionality inspired by [Jellyfin](https://jellyfin.org/).
## Video
Click [this link](https://youtu.be/BSaccEm0tpo) to watch how to install and set up Gameyfin on your machine.
## Features
* Automatically scans your game library folder and downloads additional metadata from IGDB
* Access your library via your Web-Browser
* Download games directly from your browser
* LAN-friendly (everything is cached locally)
* Native Docker support (alternatively it's only one .jar file to run on bare metal)
* Light and dark theme
Automatically scans and indexes your game libraries
⬇️ Access your library via your web browser & download games directly from there
👥 Share your library with friends & family
⚛️ LAN-friendly (everything is cached locally)
🐋 Runs in a container or as single <binary file / JAR file> on bare metal
🌈 Themes (including light and dark mode)
🔌 Easily expandable with plugins
🔒 Integrates into your SSO solution via OAuth2 / OpenID Connect
## Preview
## Screenshots
https://user-images.githubusercontent.com/9295182/197277953-d69464a4-d280-407b-9274-ae62e6917981.mp4
`Work in progress`
## Installation
### General
`Work in progress`
Since Gameyfin loads information from IGDB, you need to register yourself there. Follow [this guide](https://api-docs.igdb.com/#account-creation).
## Contribute to Gameyfin
### Docker
`Work in progress`
1. Download the `docker-compose.example.yml` file from this repository and rename it to just `docker-compose.yml`
2. Edit the configuration values to your liking
3. Run `docker-compose up -d`
### Bare metal
1. Make sure you have a JRE or JDK with version 18 or greater installed
2. Download the latest `gameyfin.jar` and `gameyfin.properties` file from the releases page
3. Edit the config options in the `gameyfin.properties` file
4. Use the following command to start Gameyfin: `java -jar gameyfin.jar`
5. Open the address of your Gameyfin host in your browser, Gameyfin runs under port 8080 by default
Gameyfin v2 is written in Kotlin and uses the following libraries/frameworks:
* Spring Boot 3 for the backend
* Vaadin for the frontend
* PF4J for the plugin system
* H2 database for persistence
* Micrometer and Prometheus for monitoring
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

-288
View File
@@ -1,288 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<artifactId>gameyfin</artifactId>
<groupId>de.grimsi</groupId>
<version>1.4.6-SNAPSHOT</version>
</parent>
<artifactId>gameyfin-backend</artifactId>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<springdoc-openapi-ui.version>1.7.0</springdoc-openapi-ui.version>
<resilience4j.version>2.2.0</resilience4j.version>
<commons-io.version>2.15.1</commons-io.version>
<commons-compress.version>1.21</commons-compress.version>
<protoc.plugin.version>3.11.4</protoc.plugin.version>
<protobuf-java.version>3.25.2</protobuf-java.version>
<easy-random.version>5.0.0</easy-random.version>
<easy-random-protobuf.version>0.4.0</easy-random-protobuf.version>
<jacoco-maven-plugin.version>0.8.11</jacoco-maven-plugin.version>
<!-- Use older version because the newer versions have problems with non-ASCII characters in properties files (umlauts etc.) -->
<maven-resources-plugin.version>3.3.1</maven-resources-plugin.version>
</properties>
<dependencies>
<!-- Frontend -->
<dependency>
<groupId>de.grimsi</groupId>
<artifactId>gameyfin-frontend</artifactId>
<version>${project.parent.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>${springdoc-openapi-ui.version}</version>
</dependency>
<!-- Webclient Rate limiter -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-reactor</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-ratelimiter</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-bulkhead</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Persistence -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
<!-- File handling -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${commons-io.version}</version>
</dependency>
<!-- Protobuf dependencies -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>${protobuf-java.version}</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>${protobuf-java.version}</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jeasy</groupId>
<artifactId>easy-random-core</artifactId>
<version>${easy-random.version}</version>
<scope>test</scope>
</dependency>
<!-- Temporarily disabled due to a StackOverflow bug: https://github.com/murdos/easy-random-protobuf/issues/136
<dependency>
<groupId>io.github.murdos</groupId>
<artifactId>easy-random-protobuf</artifactId>
<version>${easy-random-protobuf.version}</version>
<scope>test</scope>
</dependency>-->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.jimfs</groupId>
<artifactId>jimfs</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
<!-- Dev -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<finalName>gameyfin-${project.parent.version}</finalName>
<resources>
<resource>
<directory>${basedir}/src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.yaml</include>
<include>**/*.sql</include>
<include>**/*.txt</include>
<include>**/*.json</include>
<include>**/*.js</include>
<include>**/*.css</include>
<include>**/*.html</include>
<include>**/*.ico</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<!-- Import the compiled frontend -->
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>${maven-resources-plugin.version}</version>
<executions>
<execution>
<id>copy-resources</id>
<phase>validate</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/src/main/resources/static</outputDirectory>
<resources>
<resource>
<directory>${project.parent.basedir}/frontend/dist/frontend/</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<!-- Protobuf source generation plugin -->
<plugin>
<groupId>com.github.os72</groupId>
<artifactId>protoc-jar-maven-plugin</artifactId>
<version>${protoc.plugin.version}</version>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<includeStdTypes>true</includeStdTypes>
<optimizeCodegen>false</optimizeCodegen>
<inputDirectories>
<include>${project.basedir}/src/main/resources/proto</include>
</inputDirectories>
<outputTargets>
<outputTarget>
<type>java</type>
<outputDirectory>${project.build.directory}/generated-sources/protobuf
</outputDirectory>
</outputTarget>
</outputTargets>
</configuration>
</execution>
</executions>
</plugin>
<!-- JaCoCo coverage report -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco-maven-plugin.version}</version>
<executions>
<execution>
<id>jacoco-initialize</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-site</id>
<phase>package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,18 +0,0 @@
package de.grimsi.gameyfin;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class GameyfinApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(GameyfinApplication.class)
.properties( "file.encoding=UTF-8", "spring.config.name=application,gameyfin,database,secure")
.build()
.run(args);
}
}
@@ -1,23 +0,0 @@
package de.grimsi.gameyfin.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
/**
* This class holds configuration for the default {@link java.nio.file.spi.FileSystemProvider} used by Gameyfin.
*/
@Configuration
public class FileSystemProviderConfig {
/**
* Configures the default {@link FileSystem} to be used.
* This makes it easier to mock certain classes in unit tests.
* @return the default FileSystem
*/
@Bean
public FileSystem defaultFileSystem() {
return FileSystems.getDefault();
}
}
@@ -1,64 +0,0 @@
package de.grimsi.gameyfin.config;
import de.grimsi.gameyfin.service.FilesystemService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Locale;
/**
* This class contains logic to the configuration of the filesystem which Gameyfin works on.
* It handles creating required folders, setting up paths for Gameyfin and setting the correct properties of those folders.
*/
@Configuration
@RequiredArgsConstructor
public class FilesystemConfig {
@Value("${gameyfin.folders.data}")
private String dataFolderPath;
private final FilesystemService filesystemService;
/**
* This will create the cache folder for Gameyfin.
* The path of this folder is specified in the "gameyfin.cache" parameter which is either:
* 1. Derived from the first configured library folder path or
* 2. Explicitly set by the user
* <p>
* For more details see {@link GameyfinFolderConfig#setConfigurableEnvironment(ConfigurableEnvironment)}
*/
@EventListener(ApplicationReadyEvent.class)
@Order(1)
public void createCacheFolder() {
filesystemService.createCacheFolder();
}
/**
* This will make sure that the internal folder (".gameyfin") is marked as hidden on DOS/Windows-based systems.
* On UNIX-based systems files and folders starting with a dot are hidden
*/
@EventListener(ApplicationReadyEvent.class)
@Order(2)
public void hideInternalFolderOnDOS() throws IOException {
if (!isRunningOnWindows()) return;
Path internalFolder = filesystemService.getPath(dataFolderPath);
if (!Files.exists(internalFolder) || !Files.isDirectory(internalFolder)) return;
Files.setAttribute(internalFolder, "dos:hidden", Boolean.TRUE, LinkOption.NOFOLLOW_LINKS);
}
private boolean isRunningOnWindows() {
return System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("windows");
}
}
@@ -1,29 +0,0 @@
package de.grimsi.gameyfin.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
@Configuration
public class FrontendConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(@NonNull String resourcePath, @NonNull Resource location) throws IOException {
Resource requestedResource = location.createRelative(resourcePath);
return requestedResource.exists() && requestedResource.isReadable() ? requestedResource : new ClassPathResource("/static/index.html");
}
}
);
}
}
@@ -1,101 +0,0 @@
package de.grimsi.gameyfin.config;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.*;
import org.springframework.util.StringUtils;
import javax.sql.DataSource;
import java.util.Arrays;
import java.util.Properties;
import java.util.stream.StreamSupport;
/**
* This class handles the creation of all folders used by Gameyfin.
* It also stores their paths in Spring environment variables.
*/
@Configuration
@RequiredArgsConstructor
public class GameyfinFolderConfig {
@Value("${gameyfin.internal-folder}")
private String INTERNAL_FOLDER_NAME;
/**
* The following SpEL expression will:
* 1. Split the comma-seperated string contained in "gameyfin.sources" into elements
* 2. Take the first element
* 3. Assign its value to the variable
*/
@Value("#{'${gameyfin.sources}'.split(',')[0]}")
private String firstLibraryPath;
@Value("${gameyfin.folders.data}")
private String dataFolderPath;
private final Environment env;
/**
* Dynamically sets the "gameyfin.db" and "gameyfin.cache" properties
* if the "gameyfin.folders.data" property is *not* set by the user (default).
*
* @param env - The application environment, provided by the Spring container
*/
@Autowired
public void setConfigurableEnvironment(ConfigurableEnvironment env) {
Properties props = new Properties();
if (!StringUtils.hasText(dataFolderPath)) {
//set the data folder property, so it can be referenced at runtime
props.setProperty("gameyfin.folders.data", "%s/%s".formatted(firstLibraryPath, INTERNAL_FOLDER_NAME));
props.setProperty("gameyfin.db", "%s/%s/db".formatted(firstLibraryPath, INTERNAL_FOLDER_NAME));
props.setProperty("gameyfin.cache", "%s/%s/cache".formatted(firstLibraryPath, INTERNAL_FOLDER_NAME));
} else {
props.setProperty("gameyfin.db", "%s/%s/db".formatted(dataFolderPath, INTERNAL_FOLDER_NAME));
props.setProperty("gameyfin.cache", "%s/%s/cache".formatted(dataFolderPath, INTERNAL_FOLDER_NAME));
}
env.getPropertySources().addFirst(new PropertiesPropertySource("gameyfinFilesystemProperties", props));
}
/**
* This bean is needed so Spring initializes the data source after we are done messing with the configuration environment
*
* @return DataSource
*/
@ConfigurationProperties(prefix = "spring.datasource")
@Bean
@Primary
public DataSource getDataSource() {
Properties properties = loadAllProperties();
return DataSourceBuilder
.create()
.url(properties.getProperty("spring.datasource.url"))
.build();
}
private Properties loadAllProperties() {
Properties props = new Properties();
MutablePropertySources propSrcs = ((AbstractEnvironment) env).getPropertySources();
StreamSupport.stream(propSrcs.spliterator(), false)
.filter(EnumerablePropertySource.class::isInstance)
.map(ps -> ((EnumerablePropertySource<?>) ps).getPropertyNames())
.flatMap(Arrays::stream)
.forEach(propName -> props.setProperty(propName, env.getProperty(propName)));
return props;
}
}
@@ -1,25 +0,0 @@
package de.grimsi.gameyfin.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PropertiesLoaderUtils;
import java.util.Objects;
@Configuration
public class SecureProperties {
@Autowired
public void setConfigurableEnvironment(ConfigurableEnvironment env) {
try {
Resource resource = new ClassPathResource("/config/secure.yml");
env.getPropertySources().addFirst(new PropertiesPropertySource(Objects.requireNonNull(resource.getFilename()), PropertiesLoaderUtils.loadProperties(resource)));
} catch (Exception ex) {
throw new RuntimeException(ex.getMessage(), ex);
}
}
}
@@ -1,59 +0,0 @@
package de.grimsi.gameyfin.config;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
@Value("${gameyfin.user}")
private String username;
@Value("${gameyfin.password}")
private String password;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
http.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable));
http.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user = User
.builder()
.username(username)
.password("{noop}" + password) // FIXME: not very secure
.authorities("ADMIN_API_ACCESS")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
return source;
}
}
@@ -1,75 +0,0 @@
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.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
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
@Getter
@Configuration
public class WebClientConfig implements WebClientCustomizer {
private final RateLimiter igdbRateLimiter;
private final Bulkhead igdbConcurrencyLimiter;
public WebClientConfig(@Value("${gameyfin.igdb.api.max-concurrent-requests}") int maxConcurrentRequestsToIgdb,
@Value("${gameyfin.igdb.api.max-requests-per-second}") int maxRequestsPerSecondToIgdb) {
log.info("IGDB API connection properties: max. {} req/s, max. {} concurrent requests", maxRequestsPerSecondToIgdb, maxConcurrentRequestsToIgdb);
igdbRateLimiter = RateLimiter.of("igdb-rate-limiter",
RateLimiterConfig.custom()
.limitForPeriod(maxRequestsPerSecondToIgdb)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ofMinutes(1))
.build());
igdbConcurrencyLimiter = Bulkhead.of("igdb-concurrency-limiter",
BulkheadConfig.custom()
.maxConcurrentCalls(maxConcurrentRequestsToIgdb)
.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())
);
}
}
@@ -1,24 +0,0 @@
package de.grimsi.gameyfin.config.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
// see https://stackoverflow.com/questions/26699385/spring-boot-yaml-configuration-for-a-list-of-strings
@ConfigurationProperties("gameyfin")
// Technically SonarQube is correct, but I like to keep the lowercase method names since it correlates better with the config keys
@SuppressWarnings("java:S3010")
public record GameyfinProperties(
folders folders,
List<String> fileExtensions,
List<String> fileSuffixes,
igdb igdb) {
public record folders(String data) {}
public record igdb(config config) {
public record config(List<Integer> preferredPlatforms) {}
}
}
@@ -1,20 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AutocompleteSuggestionDto {
private String slug;
private String title;
private Instant releaseDate;
private List<String> platforms;
}
@@ -1,12 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class GameOverviewDto {
private String slug;
private String title;
private String coverId;
}
@@ -1,10 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.Data;
@Data
public class ImageDownloadResultDto {
private int coverDownloads;
private int screenshotDownloads;
private int companyLogoDownloads;
}
@@ -1,13 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LibraryScanRequestDto {
private String path;
private boolean downloadImages;
}
@@ -1,13 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class LibraryScanResult {
private int newGames;
private int deletedGames;
private int newUnmappableFiles;
private int totalGames;
}
@@ -1,21 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LibraryScanResultDto {
private int newGames;
private int deletedGames;
private int newUnmappableFiles;
private int totalGames;
private int coverDownloads;
private int screenshotDownloads;
private int companyLogoDownloads;
private int scanDuration;
}
@@ -1,9 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.Data;
@Data
public class PathToSlugDto {
private String path;
private String slug;
}
@@ -1,39 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.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();
}
}
@@ -1,114 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.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;
@ManyToMany(cascade = CascadeType.MERGE)
@ToString.Exclude
private List<Platform> platforms;
@ManyToOne(cascade = CascadeType.MERGE)
@JoinColumn(name = "library")
@ToString.Exclude
private Library library;
// Technical properties
@Column(nullable = false)
private String path;
@Column(nullable = false)
private long diskSize;
@Column(columnDefinition = "boolean default false")
private boolean confirmedMatch;
@CreationTimestamp
private Instant addedToLibrary;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
DetectedGame that = (DetectedGame) o;
return slug != null && Objects.equals(slug, that.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -1,35 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import jakarta.persistence.Entity;
import jakarta.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();
}
}
@@ -1,35 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import jakarta.persistence.Entity;
import jakarta.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();
}
}
@@ -1,38 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import jakarta.persistence.*;
import java.util.List;
import java.util.Objects;
@Entity
@Builder
@Getter
@Setter
@ToString
@AllArgsConstructor
@RequiredArgsConstructor
public class Library {
@Id
private String path;
@ManyToMany(cascade = CascadeType.MERGE)
@ToString.Exclude
private List<Platform> platforms;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Library library = (Library) o;
return path != null && Objects.equals(path, library.path);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -1,39 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.util.Objects;
@Entity
@Builder
@Getter
@Setter
@ToString
@AllArgsConstructor
@RequiredArgsConstructor
public class Platform {
@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;
Platform platform = (Platform) o;
return slug != null && Objects.equals(slug, platform.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@@ -1,35 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import jakarta.persistence.Entity;
import jakarta.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();
}
}
@@ -1,35 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import jakarta.persistence.Entity;
import jakarta.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();
}
}
@@ -1,40 +0,0 @@
package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import jakarta.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, generator = "HIBERNATE_SEQUENCE")
@SequenceGenerator(name = "HIBERNATE_SEQUENCE", allocationSize = 1)
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();
}
}
@@ -1,7 +0,0 @@
package de.grimsi.gameyfin.exceptions;
public class DownloadAbortedException extends RuntimeException {
public DownloadAbortedException() {
super();
}
}
@@ -1,29 +0,0 @@
package de.grimsi.gameyfin.igdb;
import java.util.List;
public class IgdbApiProperties {
public static final String ENDPOINT_GAMES_PROTOBUF = "games.pb";
public static final String ENDPOINT_PLATFORMS_PROTOBUF = "platforms.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",
"platforms.slug", "platforms.name", "platforms.platform_logo.image_id"
);
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";
}
@@ -1,448 +0,0 @@
package de.grimsi.gameyfin.igdb;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* Builder for fluent building of igdb api queries.
*/
public class IgdbApiQueryBuilder {
private final StringBuilder stringBuilder;
private String search;
private String fields;
private String limit;
private String where;
private String sort;
public IgdbApiQueryBuilder() {
this.stringBuilder = new StringBuilder();
this.fields = "fields *;";
this.search = "";
this.limit = "";
this.where = "";
this.sort = "";
}
/**
* Creates a {@link Condition} that are concatenated through the `&` operator.
* This condition produces `(condition & condition & condition)`.
*
* @param conditions multiple conditions
* @return an {@link AndCondition}
*/
public static Condition and(Condition... conditions) {
return new AndCondition(conditions);
}
/**
* Creates a {@link Condition} that are concatenated through the `|` operator.
* This condition produces `(condition | condition | condition)`.
*
* @param conditions multiple conditions
* @return an {@link OrCondition}
*/
public static Condition or(Condition... conditions) {
return new OrCondition(conditions);
}
/**
* Creates a {@link Condition} to look for string values in a list.
* This condition produces `field = ("val1","val2")`.
*
* @param field a field to search through
* @param values the string values
* @return an {@link InCondition}
*/
public static Condition in(String field, String... values) {
return new InCondition(field, values);
}
/**
* Creates a {@link Condition} to look for number values in a list.
* This condition produces `field = (1,2,3)`.
*
* @param field a field to search through
* @param values the number values
* @return an {@link InCondition}
*/
public static Condition in(String field, Number... values) {
return new InCondition(field, values);
}
/**
* Creates a {@link Condition} to look for values in a collection.
* This condition produces `field = ("val1","val2")` if a collection of strings is passed.
* This condition produces `field = (1,2,3)` if a collection of numbers is passed.
*
* @param field a field to search through.
* @param values a collection of values.
* @return an {@link InCondition}.
*/
public static Condition in(String field, Collection<?> values) {
return new InCondition(field, values.toArray(new Object[0]));
}
/**
* Creates a {@link Condition} to filter for matching string values.
* This condition produces `field = "value"`.
*
* @param field a field to search through.
* @param value a value for comparison.
* @return an {@link InCondition}.
*/
public static Condition equal(String field, String value) {
return new EqualsCondition(field, value);
}
/**
* Creates a {@link Condition} to filter for matching number values.
* This condition produces `field = 123`.
*
* @param field a field to search through.
* @param value a value for comparison.
* @return an {@link InCondition}.
*/
public static Condition equal(String field, Number value) {
return new EqualsCondition(field, value);
}
/**
* Creates a {@link Condition} to filter for non-matching string values.
* This condition produces `field != "value"`.
*
* @param field a field to search through.
* @param value a value for comparison.
* @return an {@link InCondition}.
*/
public static Condition not(String field, String value) {
return new NotCondition(field, value);
}
/**
* Creates a {@link Condition} to filter for non-matching number values.
* This condition produces `field != 123`.
*
* @param field a field to search through.
* @param value a value for comparison.
* @return an {@link InCondition}.
*/
public static Condition not(String field, Number value) {
return new NotCondition(field, value);
}
/**
* Creates a {@link Condition} to check if a value is bigger than the given value.
* This condition produces `field > 123`.
*
* @param field a field to search through.
* @param value a value for comparison.
* @return an {@link InCondition}.
*/
public static Condition greater(String field, Number value) {
return new GreaterThanCondition(field, value);
}
/**
* Creates a {@link Condition} to check if a value is bigger or equal to the given value.
* This condition produces `field >= 123`.
*
* @param field a field to search through.
* @param value a value for comparison.
* @return an {@link InCondition}.
*/
public static Condition greaterEquals(String field, Number value) {
return new GreaterEqualsCondition(field, value);
}
/**
* Creates a {@link Condition} to check if a value is smaller than the given value.
* This condition produces `field < 123`.
*
* @param field a field to search through.
* @param value a value for comparison.
* @return an {@link InCondition}.
*/
public static Condition lesser(String field, Number value) {
return new LessThanCondition(field, value);
}
/**
* Creates a {@link Condition} to check if a value is smaller or equal to the given value.
* This condition produces `field <= 123`.
*
* @param field a field to search through.
* @param value a value for comparison.
* @return an {@link InCondition}.
*/
public static Condition lesserEquals(String field, Number value) {
return new LesserEqualsCondition(field, value);
}
/**
* Builds the query string.
*
* @return an igdb compatible query
*/
public String build() {
stringBuilder.append(search);
stringBuilder.append(fields);
stringBuilder.append(limit);
stringBuilder.append(where);
stringBuilder.append(sort);
return stringBuilder.toString();
}
/**
* Adds the `search "xyz";` query param.
*
* @param searchTerm a term to search for.
* @return the builder
*/
public IgdbApiQueryBuilder search(String searchTerm) {
this.search = "search \"%s\";".formatted(searchTerm);
return this;
}
/**
* Adds the `fields abc,xyz;` query param.
*
* @param fields fields that should be returned (defaults to *).
* @return the builder
*/
public IgdbApiQueryBuilder fields(String fields) {
this.fields = "fields %s;".formatted(fields);
return this;
}
/**
* Adds the `limit 1234;` query param.
*
* @param limit how many results should be returned.
* @return the builder
*/
public IgdbApiQueryBuilder limit(int limit) {
this.limit = "limit %d;".formatted(limit);
return this;
}
/**
* Adds the `where xyz;` query param.
*
* @param condition a {@link Condition} object containing all conditions to filter the igdb db.
* @return the builder
*/
public IgdbApiQueryBuilder where(Condition condition) {
this.where = "where %s;".formatted(condition.build());
return this;
}
/**
* Adds the `sort xyz asc;` query param.
*
* @param field a term to search for.
* @param order the {@link SortOrder} (either ASC or DESC).
* @return the builder
*/
public IgdbApiQueryBuilder sort(String field, SortOrder order) {
this.sort = "sort %s %s;".formatted(field, order.value);
return this;
}
/**
* Sort order enum for sorting query result.
*/
public enum SortOrder {
ASC("asc"), DESC("desc");
public final String value;
SortOrder(String value) {
this.value = value;
}
}
/**
* Abstract condition object.
*/
public abstract static class Condition {
protected static String wrap(String conditions) {
return "(%s)".formatted(conditions);
}
public abstract String build();
}
/**
* InCondition
*/
public static class InCondition extends Condition {
private static final String PATTERN = "%s = (%s)";
private final String field;
private final String in;
public InCondition(String field, Object[] values) {
this.field = field;
if (Arrays.stream(values).anyMatch(String.class::isInstance))
this.in = Arrays.stream(values).map("\"%s\""::formatted).collect(Collectors.joining(","));
else if (Arrays.stream(values).anyMatch(Number.class::isInstance))
this.in = Arrays.stream(values).map("%d"::formatted).collect(Collectors.joining(","));
else this.in = null;
}
@Override
public String build() {
return PATTERN.formatted(field, in);
}
}
/**
* NotCondition
*/
public static class NotCondition extends OperatorCondition {
private static final String OPERATOR = "!=";
public NotCondition(String field, String value) {
super(field, OPERATOR, value);
}
public NotCondition(String field, Number value) {
super(field, OPERATOR, value);
}
}
/**
* EqualsCondition
*/
public static class EqualsCondition extends OperatorCondition {
private static final String OPERATOR = "=";
public EqualsCondition(String field, String value) {
super(field, OPERATOR, value);
}
public EqualsCondition(String field, Number value) {
super(field, OPERATOR, value);
}
}
/**
* GreaterThanCondition
*/
public static class GreaterThanCondition extends OperatorCondition {
private static final String OPERATOR = ">";
public GreaterThanCondition(String field, Number value) {
super(field, OPERATOR, value);
}
}
/**
* GreaterEqualsCondition
*/
public static class GreaterEqualsCondition extends OperatorCondition {
private static final String OPERATOR = ">=";
public GreaterEqualsCondition(String field, Number value) {
super(field, OPERATOR, value);
}
}
/**
* LessThanCondition
*/
public static class LessThanCondition extends OperatorCondition {
private static final String OPERATOR = "<";
public LessThanCondition(String field, Number value) {
super(field, OPERATOR, value);
}
}
/**
* LesserEqualsCondition
*/
public static class LesserEqualsCondition extends OperatorCondition {
private static final String OPERATOR = "<=";
public LesserEqualsCondition(String field, Number value) {
super(field, OPERATOR, value);
}
}
/**
* OperatorCondition for inheritance
*/
public static class OperatorCondition extends Condition {
private static final String PATTERN = "%s %s %s";
private static final String ESCAPED_STRING = "\"%s\"";
private static final String DIGITS = "%s";
private final String field;
private final String value;
private final String operator;
public OperatorCondition(String field, String operator, String value) {
this.field = field;
this.operator = operator;
this.value = value != null ? ESCAPED_STRING.formatted(value) : null;
}
public OperatorCondition(String field, String operator, Number value) {
this.field = field;
this.operator = operator;
this.value = value != null ? DIGITS.formatted(value) : null;
}
public OperatorCondition() {
throw new UnsupportedOperationException();
}
@Override
public String build() {
return PATTERN.formatted(field, operator, value);
}
}
/**
* AndCondition
*/
public static class AndCondition extends Condition {
private static final String AND = " & ";
private final Condition[] conditions;
public AndCondition(Condition[] conditions) {
this.conditions = conditions;
}
@Override
public String build() {
return wrap(Arrays.stream(conditions).map(Condition::build).collect(Collectors.joining(AND)));
}
}
/**
* OrCondition
*/
public static class OrCondition extends Condition {
private static final String OR = " | ";
private final Condition[] conditions;
public OrCondition(Condition[] conditions) {
this.conditions = conditions;
}
@Override
public String build() {
return wrap(Arrays.stream(conditions).map(Condition::build).collect(Collectors.joining(OR)));
}
}
}
@@ -1,259 +0,0 @@
package de.grimsi.gameyfin.igdb;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.config.WebClientConfig;
import de.grimsi.gameyfin.config.properties.GameyfinProperties;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.entities.Platform;
import de.grimsi.gameyfin.igdb.IgdbApiQueryBuilder.*;
import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto;
import de.grimsi.gameyfin.mapper.GameMapper;
import de.grimsi.gameyfin.mapper.PlatformMapper;
import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator;
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 jakarta.annotation.PostConstruct;
import java.net.URI;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static de.grimsi.gameyfin.igdb.IgdbApiProperties.GAME_QUERY_FIELDS_STRING;
import static de.grimsi.gameyfin.igdb.IgdbApiQueryBuilder.*;
import static org.apache.commons.lang3.ObjectUtils.isNotEmpty;
@Slf4j
@RequiredArgsConstructor
@Service
public class IgdbWrapper {
private final WebClient.Builder webclientBuilder;
private final WebClientConfig webClientConfig;
private final GameMapper gameMapper;
private final GameyfinProperties gameyfinProperties;
@Value("${gameyfin.igdb.api.client-id}")
private String clientId;
@Value("${gameyfin.igdb.api.client-secret}")
private String clientSecret;
@Value("${gameyfin.igdb.api.endpoints.base}")
private String igdbApiBaseUrl;
@Value("${gameyfin.igdb.api.endpoints.auth}")
private String twitchAuthUrl;
private WebClient twitchApiClient;
private WebClient igdbApiClient;
private TwitchOAuthTokenDto accessToken;
@PostConstruct
public void init() {
twitchApiClient = webclientBuilder.build();
authenticate();
initIgdbClient();
}
private void authenticate() {
log.info("Authenticating on Twitch API...");
URI url = UriComponentsBuilder
.fromHttpUrl(twitchAuthUrl)
.query("client_id={client_id}").query("client_secret={client_secret}").query("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) {
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
Igdb.GameResult gameResult = queryIgdbApi(
IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF,
queryBuilder.fields(GAME_QUERY_FIELDS_STRING)
.where(equal("id", id))
.limit(1)
.build(),
Igdb.GameResult.class
);
if (gameResult == null) return Optional.empty();
return Optional.of(gameResult.getGames(0));
}
public Optional<Igdb.Game> getGameBySlug(String slug) {
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
Igdb.GameResult gameResult = queryIgdbApi(
IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF,
queryBuilder.fields(GAME_QUERY_FIELDS_STRING)
.where(equal("slug", slug))
.limit(1)
.build(),
Igdb.GameResult.class
);
if (gameResult == null) return Optional.empty();
return Optional.of(gameResult.getGames(0));
}
public List<AutocompleteSuggestionDto> findPossibleMatchingTitles(String searchTerm, int limit) {
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
Igdb.GameResult gameResult = queryIgdbApi(
IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF,
queryBuilder.search(searchTerm)
.fields("slug,name,first_release_date,platforms.name")
.where(in("platforms", gameyfinProperties.igdb().config().preferredPlatforms()))
.limit(limit)
.build(),
Igdb.GameResult.class
);
if (gameResult == null) return Collections.emptyList();
return gameResult.getGamesList().stream().map(gameMapper::toAutocompleteSuggestionDto).toList();
}
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm) {
return searchForGameByTitle(searchTerm, List.of());
}
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm, Collection<String> platformSlugs) {
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
Condition platforms = isNotEmpty(platformSlugs) ?
and(in("platforms", gameyfinProperties.igdb().config().preferredPlatforms()), in("platforms.slug", platformSlugs)) :
in("platforms", gameyfinProperties.igdb().config().preferredPlatforms());
Igdb.GameResult gameResult = queryIgdbApi(
IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF,
queryBuilder.search(searchTerm)
.fields(GAME_QUERY_FIELDS_STRING)
.where(platforms)
.build(),
Igdb.GameResult.class
);
if (gameResult == null) {
log.warn("Could not find game for title '{}'", searchTerm);
// Try to remove brackets (and their content) at the end of the search term and search again
// Although this process is recursive, we will only end up with a maximum recursion depth of two
Pattern brackets = Pattern.compile("[()<>{}\\[\\]]");
Matcher hasBrackets = brackets.matcher(searchTerm);
if (hasBrackets.find()) {
String searchTermWithoutBrackets = searchTerm.split(brackets.pattern())[0].trim();
log.warn("Removed brackets, trying again with search term '{}'", searchTermWithoutBrackets);
return searchForGameByTitle(searchTermWithoutBrackets, platformSlugs);
} else if (searchTerm.contains("-") || searchTerm.contains(".") || searchTerm.contains("_") || searchTerm.contains("INTERNAL") || searchTerm.contains("internal") || searchTerm.contains("REPACK") || searchTerm.contains("PROPER") || searchTerm.contains("repack") || searchTerm.contains("proper")) {
String searchTermWithoutDash = searchTerm;
if (searchTermWithoutDash.contains("-")) {
searchTermWithoutDash = searchTermWithoutDash.substring(0, searchTermWithoutDash.lastIndexOf('-')).trim();
}
searchTermWithoutDash = searchTermWithoutDash.replace(".", " ");
searchTermWithoutDash = searchTermWithoutDash.replace("_", " ");
searchTermWithoutDash = searchTermWithoutDash.replaceAll("(?i)repack", " ");
searchTermWithoutDash = searchTermWithoutDash.replaceAll("(?i)internal", " ");
searchTermWithoutDash = searchTermWithoutDash.replaceAll("(?i)proper", " ");
log.warn("Removed release stuff, trying again with search term '{}'", searchTermWithoutDash);
return searchForGameByTitle(searchTermWithoutDash, platformSlugs);
}
log.warn("Using slug as last resort for " + searchTerm + ": " + searchTerm.replace(" ", "-").toLowerCase());
return getGameBySlug(searchTerm.replace(" ", "-").toLowerCase());
}
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;
// Just return the first result and hope that IGDBs search algorithm is somewhat helpful this time
return Optional.of(games.get(0));
}
public List<Platform> findPlatforms(String searchTerm, int limit) {
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
Igdb.PlatformResult platformResult = queryIgdbApi(
IgdbApiProperties.ENDPOINT_PLATFORMS_PROTOBUF,
queryBuilder.search(searchTerm)
.fields("slug,name")
.limit(limit)
.build(),
Igdb.PlatformResult.class
);
if (platformResult == null) return Collections.emptyList();
return platformResult.getPlatformsList().stream().map(PlatformMapper::toPlatform).toList();
}
public Optional<Platform> getPlatformBySlug(String slug) {
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
Igdb.PlatformResult platformResult = queryIgdbApi(
IgdbApiProperties.ENDPOINT_PLATFORMS_PROTOBUF,
queryBuilder.fields("slug,name,platform_logo")
.where(equal("slug", slug))
.build(),
Igdb.PlatformResult.class
);
if (platformResult == null) return Optional.empty();
return platformResult.getPlatformsList().stream().map(PlatformMapper::toPlatform).findFirst();
}
private void initIgdbClient() {
if (accessToken == null) {
authenticate();
}
igdbApiClient = webclientBuilder
.baseUrl(igdbApiBaseUrl)
.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.getIgdbConcurrencyLimiter()))
.transformDeferred(RateLimiterOperator.of(webClientConfig.getIgdbRateLimiter()))
.block();
}
}
@@ -1,17 +0,0 @@
package de.grimsi.gameyfin.igdb.dto;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class TwitchOAuthTokenDto {
private String accessToken;
private Long expiresIn;
private String tokenType;
}
@@ -1,21 +0,0 @@
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();
}
}
@@ -1,127 +0,0 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.dto.GameOverviewDto;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.Library;
import de.grimsi.gameyfin.service.FilesystemService;
import de.grimsi.gameyfin.service.LibraryService;
import de.grimsi.gameyfin.util.ProtobufUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.util.StringUtils;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import static java.util.stream.Collectors.toList;
@Slf4j
@RequiredArgsConstructor
@Component
public class GameMapper {
private final FilesystemService filesystemService;
public DetectedGame toDetectedGame(Igdb.Game g, Path path, Library library) {
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(ProtobufUtil.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(getCoverId(g))
.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()))
.platforms(PlatformMapper.toPlatforms(g.getPlatformsList()))
.path(path.toString())
.library(library)
.diskSize(calculateDiskSize(g, path))
.addedToLibrary(Instant.now())
.build();
}
public GameOverviewDto toGameOverviewDto(DetectedGame game) {
return GameOverviewDto.builder()
.slug(game.getSlug())
.title(game.getTitle())
.coverId(game.getCoverId())
.build();
}
public AutocompleteSuggestionDto toAutocompleteSuggestionDto(Igdb.Game game) {
return AutocompleteSuggestionDto.builder()
.slug(game.getSlug())
.title(game.getName())
.releaseDate(ProtobufUtil.toInstant(game.getFirstReleaseDate()))
.platforms(game.getPlatformsList().stream().map(Igdb.Platform::getName).toList())
.build();
}
private String getCoverId(Igdb.Game g) {
String coverId = g.getCover().getImageId();
if (StringUtils.hasText(coverId)) return coverId;
return "nocover";
}
private boolean hasOfflineCoop(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getOfflinecoop);
}
private boolean hasLanSupport(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getLancoop);
}
private boolean hasOnlineCoop(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getOnlinecoop);
}
private int getMaxPlayers(List<Igdb.MultiplayerMode> modes) {
return modes.stream().mapToInt(Igdb.MultiplayerMode::getOnlinecoopmax).max().orElse(0);
}
private long calculateDiskSize(Igdb.Game g, Path path) {
StopWatch stopWatch = new StopWatch();
log.info("Calculating disk size for game '{}'...", g.getName());
stopWatch.start();
long fileSize;
try {
fileSize = filesystemService.getSizeOnDisk(path);
} catch (IOException e) {
log.error("Error while calculating disk size for game '{}'", g.getName());
fileSize = -1L;
}
stopWatch.stop();
log.info("Calculated disk size for game '{}' in {} seconds", g.getName(), (int) stopWatch.getTotalTimeSeconds());
return fileSize;
}
}
@@ -1,20 +0,0 @@
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();
}
}
@@ -1,19 +0,0 @@
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();
}
}
@@ -1,21 +0,0 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Platform;
import java.util.List;
public class PlatformMapper {
public static Platform toPlatform(Igdb.Platform c) {
return Platform.builder()
.slug(c.getSlug())
.name(c.getName())
.logoId(c.getPlatformLogo().getImageId())
.build();
}
public static List<Platform> toPlatforms(List<Igdb.Platform> c) {
return c.stream().map(PlatformMapper::toPlatform).toList();
}
}
@@ -1,20 +0,0 @@
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();
}
}
@@ -1,19 +0,0 @@
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();
}
}
@@ -1,7 +0,0 @@
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> {
}
@@ -1,32 +0,0 @@
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;
import java.util.Optional;
import static org.apache.commons.lang3.StringUtils.isBlank;
public interface DetectedGameRepository extends JpaRepository<DetectedGame, String> {
boolean existsByPath(String path);
boolean existsBySlug(String slug);
Optional<DetectedGame> findByPath(String path);
List<DetectedGame> findByPathStartsWithAndLibraryIsNull(String path);
List<DetectedGame> getAllByPathNotIn(Collection<String> paths);
List<DetectedGame> getAllByPathNotInAndPathStartsWith(Collection<String> paths, String libraryPath);
default List<DetectedGame> getAllByPathNotInAndPathStartsWith(List<Path> paths, String libraryPath) {
List<String> pathStrings = paths.stream().map(Path::toString).toList();
// get games that are not in the paths list but are starting with libraryPath if libraryPath is not empty
return isBlank(libraryPath) ? getAllByPathNotIn(pathStrings) : getAllByPathNotInAndPathStartsWith(pathStrings, libraryPath);
}
}
@@ -1,7 +0,0 @@
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> {
}
@@ -1,7 +0,0 @@
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> {
}
@@ -1,13 +0,0 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.Library;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface LibraryRepository extends JpaRepository<Library, String> {
boolean existsByPathIgnoreCase(String path);
Optional<Library> findByPath(String path);
}
@@ -1,13 +0,0 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.Platform;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PlatformRepository extends JpaRepository<Platform, String> {
boolean existsBySlug(String slug);
Optional<Platform> findBySlug(String slug);
}
@@ -1,7 +0,0 @@
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> {
}
@@ -1,7 +0,0 @@
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> {
}
@@ -1,30 +0,0 @@
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;
import java.util.Optional;
import java.util.function.Predicate;
import static org.apache.commons.lang3.StringUtils.isBlank;
public interface UnmappableFileRepository extends JpaRepository<UnmappableFile, Long> {
boolean existsByPath(String path);
List<UnmappableFile> getAllByPathNotIn(Collection<String> paths);
List<UnmappableFile> getAllByPathNotInAndPathStartsWith(Collection<String> paths, String libraryPath);
Optional<UnmappableFile> findByPath(String path);
default List<UnmappableFile> getAllByPathNotInAndPathStartsWith(List<Path> paths, String libraryPath) {
List<String> pathStrings = paths.stream().map(Path::toString).toList();
// get unmapped files that are not in the paths list but are starting with libraryPath if libraryPath is not empty
return isBlank(libraryPath) ? getAllByPathNotIn(pathStrings) : getAllByPathNotInAndPathStartsWith(pathStrings, libraryPath);
}
}
@@ -1,80 +0,0 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.dto.GameOverviewDto;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.service.DownloadService;
import de.grimsi.gameyfin.service.GameService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
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;
private final DownloadService downloadService;
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<DetectedGame> getAllGames() {
return gameService.getAllDetectedGames();
}
@GetMapping(value = "/game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE)
public DetectedGame getGame(@PathVariable String slug) {
return gameService.getDetectedGame(slug);
}
@GetMapping(value = "/game-overviews", produces = MediaType.APPLICATION_JSON_VALUE)
public List<GameOverviewDto> getGameOverviews() {
return gameService.getGameOverviews();
}
@GetMapping(value = "/game-mappings", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, String> getGameMappings() {
return gameService.getAllMappings();
}
@GetMapping(value = "/game/{slug}/download")
public ResponseEntity<StreamingResponseBody> downloadGameFiles(@PathVariable String slug) {
DetectedGame game = gameService.getDetectedGame(slug);
String downloadFileName = downloadService.getDownloadFileName(game);
long downloadFileSize = downloadService.getDownloadFileSize(game);
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"%s\"".formatted(downloadFileName));
headers.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate");
headers.add(HttpHeaders.PRAGMA, "no-cache");
headers.add(HttpHeaders.EXPIRES, "0");
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
if (downloadFileSize > 0) {
headers.setContentLength(downloadFileSize);
}
return ResponseEntity
.ok()
.headers(headers)
.body(out -> downloadService.sendGamefilesToClient(game, out));
}
@GetMapping(value = "/game/{slug}/refresh", produces = MediaType.APPLICATION_JSON_VALUE)
public DetectedGame refreshGame(@PathVariable String slug) {
return gameService.refreshGame(slug);
}
}
@@ -1,32 +0,0 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.service.DownloadService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* This controller handles functionality for images.
*/
@RestController
@RequestMapping("/v1/images")
@RequiredArgsConstructor
public class ImageController {
private final DownloadService downloadService;
@GetMapping(value = "/{imageId}", produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<Resource> getImage(@PathVariable String imageId) {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())
.body(downloadService.sendImageToClient(imageId));
}
}
@@ -1,81 +0,0 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.dto.*;
import de.grimsi.gameyfin.entities.Library;
import de.grimsi.gameyfin.service.ImageService;
import de.grimsi.gameyfin.service.LibraryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.StopWatch;
import org.springframework.web.bind.annotation.*;
import java.nio.file.Path;
import java.util.List;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
/**
* This controller handles functionality of the library.
*/
@RestController
@RequestMapping("/v1/library")
@PreAuthorize("hasAuthority('ADMIN_API_ACCESS')")
@RequiredArgsConstructor
@Slf4j
public class LibraryController {
private final LibraryService libraryService;
private final ImageService imageService;
@PostMapping(value = "/scan", produces = MediaType.APPLICATION_JSON_VALUE)
public LibraryScanResultDto scanLibraries(@RequestBody LibraryScanRequestDto libraryScanRequest) {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
LibraryScanResultDto lscDto = new LibraryScanResultDto();
String path = libraryScanRequest.getPath();
List<Library> libraries = isNotBlank(path) ? List.of(libraryService.getLibrary(path)) : libraryService.getLibraries();
List<LibraryScanResult> libraryScanResults = libraries.stream().map(libraryService::scanGameLibrary).toList();
lscDto.setNewGames(libraryScanResults.stream().map(LibraryScanResult::getNewGames).reduce(0, Integer::sum));
lscDto.setDeletedGames(libraryScanResults.stream().map(LibraryScanResult::getDeletedGames).reduce(0, Integer::sum));
lscDto.setNewUnmappableFiles(libraryScanResults.stream().map(LibraryScanResult::getNewUnmappableFiles).reduce(0, Integer::sum));
lscDto.setTotalGames(libraryScanResults.stream().map(LibraryScanResult::getTotalGames).reduce(0, Integer::sum));
if (libraryScanRequest.isDownloadImages()) {
ImageDownloadResultDto idrDto = downloadImages();
lscDto.setCoverDownloads(idrDto.getCoverDownloads());
lscDto.setScreenshotDownloads(idrDto.getScreenshotDownloads());
lscDto.setCompanyLogoDownloads(idrDto.getCompanyLogoDownloads());
}
stopWatch.stop();
lscDto.setScanDuration((int) stopWatch.getTotalTimeSeconds());
log.info("Library scan completed in {} seconds.", (int) stopWatch.getTotalTimeSeconds());
return lscDto;
}
@GetMapping(value = "/download-images")
public ImageDownloadResultDto downloadImages() {
ImageDownloadResultDto idrDto = new ImageDownloadResultDto();
idrDto.setCoverDownloads(imageService.downloadGameCoversFromIgdb());
idrDto.setScreenshotDownloads(imageService.downloadGameScreenshotsFromIgdb());
idrDto.setCompanyLogoDownloads(imageService.downloadCompanyLogosFromIgdb());
log.info("Downloading images completed.");
return idrDto;
}
@GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE)
public List<String> getAllFiles() {
return libraryService.getGameFiles().stream().map(Path::toString).toList();
}
}
@@ -1,81 +0,0 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.dto.PathToSlugDto;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.Library;
import de.grimsi.gameyfin.entities.Platform;
import de.grimsi.gameyfin.entities.UnmappableFile;
import de.grimsi.gameyfin.service.GameService;
import de.grimsi.gameyfin.service.ImageService;
import de.grimsi.gameyfin.service.LibraryService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/v1/library-management")
@PreAuthorize("hasAuthority('ADMIN_API_ACCESS')")
@RequiredArgsConstructor
public class LibraryManagementController {
private final GameService gameService;
private final ImageService imageService;
private final LibraryService libraryService;
@DeleteMapping(value = "/delete-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE)
public void deleteGame(@PathVariable String slug) {
gameService.deleteGame(slug);
}
@DeleteMapping(value = "/delete-unmapped-file/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public void deleteUnmappedFile(@PathVariable Long id) {
gameService.deleteUnmappedFile(id);
}
@GetMapping(value = "/confirm-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE)
public DetectedGame confirmMatch(@PathVariable String slug, @RequestParam(required = false, defaultValue = "true") boolean confirm) {
return gameService.confirmGame(slug, confirm);
}
@PostMapping(value = "/map-path", produces = MediaType.APPLICATION_JSON_VALUE)
public DetectedGame manuallyMapPathToSlug(@RequestBody PathToSlugDto pathToSlugDto) {
DetectedGame game = gameService.mapPathToGame(pathToSlugDto.getPath(), pathToSlugDto.getSlug());
imageService.downloadGameCoversFromIgdb();
imageService.downloadGameScreenshotsFromIgdb();
imageService.downloadCompanyLogosFromIgdb();
return game;
}
@GetMapping(value = "/unmapped-files", produces = MediaType.APPLICATION_JSON_VALUE)
public List<UnmappableFile> getUnmappedFiles() {
return gameService.getAllUnmappedFiles();
}
@GetMapping(value = "/autocomplete-suggestions", produces = MediaType.APPLICATION_JSON_VALUE)
public List<AutocompleteSuggestionDto> getAutocompleteSuggestions(@RequestParam String searchTerm, @RequestParam(required = false, defaultValue = "10") int limit) {
return libraryService.getAutocompleteSuggestions(searchTerm, limit);
}
@GetMapping(value = "/platforms", produces = MediaType.APPLICATION_JSON_VALUE)
public List<Platform> getPlatforms(@RequestParam String searchTerm, @RequestParam(required = false, defaultValue = "10") int limit) {
return libraryService.getPlatforms(searchTerm, limit);
}
@GetMapping(value = "/libraries", produces = MediaType.APPLICATION_JSON_VALUE)
public List<Library> getLibraries() {
return libraryService.getOrCreateLibraries();
}
@PostMapping(value = "/map-library", produces = MediaType.APPLICATION_JSON_VALUE)
public Library mapPathToPlatform(@RequestBody PathToSlugDto pathToSlugDto) {
return libraryService.mapPlatformsToLibrary(pathToSlugDto.getPath(), pathToSlugDto.getSlug());
}
}
@@ -1,125 +0,0 @@
package de.grimsi.gameyfin.service;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.exceptions.DownloadAbortedException;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.ClientAbortException;
import org.apache.commons.io.FileUtils;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StopWatch;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static de.grimsi.gameyfin.util.FilenameUtil.getFilenameWithExtension;
@Slf4j
@Service
@RequiredArgsConstructor
public class DownloadService {
private final FilesystemService filesystemService;
public String getDownloadFileName(DetectedGame g) {
Path path = filesystemService.getPath(g.getPath());
if (!Files.isDirectory(path)) return getFilenameWithExtension(path);
return getFilenameWithExtension(path) + ".zip";
}
public long getDownloadFileSize(DetectedGame game) {
Path path = filesystemService.getPath(game.getPath());
try {
if (!Files.isDirectory(path)) {
long fileSize = filesystemService.getSizeOnDisk(path);
log.info("Calculated file size for {} ({} MB).", path, Math.divideExact(fileSize, 1000000L));
return fileSize;
} else {
// return zero since we cannot set content length for ZipOutputStreams that are used to archive directories
return 0;
}
} catch (IOException e) {
throw new DownloadAbortedException();
}
}
public Resource sendImageToClient(String imageId) {
String filename = "%s.png".formatted(imageId);
return filesystemService.getFileFromCache(filename);
}
public void sendGamefilesToClient(DetectedGame game, OutputStream outputStream) {
StopWatch stopWatch = new StopWatch();
log.info("Starting game file download for {}...", game.getTitle());
stopWatch.start();
Path path = filesystemService.getPath(game.getPath());
try {
if (path.toFile().isDirectory()) {
sendGamefilesAsZipToClient(path, outputStream);
} else {
sendGamefileToClient(path, outputStream);
}
} catch (DownloadAbortedException e) {
stopWatch.stop();
log.info("Download of game {} was aborted by client after {} seconds", game.getTitle(), (int) stopWatch.getTotalTimeSeconds());
return;
}
stopWatch.stop();
log.info("Downloaded game files of {} in {} seconds.", game.getTitle(), (int) stopWatch.getTotalTimeSeconds());
}
private void sendGamefileToClient(Path path, OutputStream outputStream) {
try {
Files.copy(path, outputStream);
} catch (ClientAbortException e) {
// Aborted downloads will be handled gracefully
throw new DownloadAbortedException();
} catch (IOException e) {
log.error("Error while downloading file:", e);
}
}
private void sendGamefilesAsZipToClient(Path path, OutputStream outputStream) {
log.info("Archiving game path {} for download...", path);
ZipOutputStream zos = new ZipOutputStream(outputStream) {{
def.setLevel(Deflater.NO_COMPRESSION);
}};
try {
Files.walkFileTree(path, new SimpleFileVisitor<>() {
@SneakyThrows
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
zos.putNextEntry(new ZipEntry(path.relativize(file).toString()));
log.debug("Adding file {} to archive...", file);
Files.copy(file, zos);
zos.closeEntry();
return FileVisitResult.CONTINUE;
}
});
zos.close();
} catch (ClientAbortException e) {
// Aborted downloads will be handled gracefully
throw new DownloadAbortedException();
} catch (IOException e) {
log.error("Error while zipping files:", e);
}
}
}
@@ -1,107 +0,0 @@
package de.grimsi.gameyfin.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
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.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.net.URI;
import java.nio.file.*;
/**
* This class handles all filesystem operations for Gameyfin.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FilesystemService {
@Value("${gameyfin.cache}")
private String cacheFolderPath;
private final FileSystem fileSystem;
/**
* Returns the given path on the configured filesystem.
* Basically just another way of doing {@link Path#of(String, String...)}, but easier to mock.
* @return The path
*/
public Path getPath(String first, String... more) {
return fileSystem.getPath(first, more);
}
/**
* This method will create the folder specified in the "gameyfin.cache" property.
* If the folder already exists, nothing will happen.
*/
public void createCacheFolder() {
log.debug("Creating cache folder...");
try {
Files.createDirectories(getPath(cacheFolderPath));
log.debug("Cache folder created.");
} catch (IOException e) {
log.error("Error while creating the cache folder.", e);
}
}
public void saveFileToCache(Flux<DataBuffer> dataBuffer, String filename) {
DataBufferUtils.write(dataBuffer, getPath(cacheFolderPath).resolve(filename), StandardOpenOption.CREATE)
.share().block();
}
public ByteArrayResource getFileFromCache(String filename) {
try {
return new ByteArrayResource(Files.readAllBytes(getPath(cacheFolderPath, filename)));
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Could not find image file %s".formatted(filename));
}
}
public void deleteFileFromCache(String filename) {
try {
Files.delete(getPathFromFilename(filename));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public boolean isCachedFileCorrupt(String filename) {
try {
return Files.size(getPathFromFilename(filename)) == 0L;
} catch (IOException e) {
log.error("Could not determine file size of '{}'", filename);
return true;
}
}
public boolean doesCachedFileExist(String filename) {
return Files.exists(getPathFromFilename(filename));
}
public long getSizeOnDisk(Path path) throws IOException {
if (Files.isDirectory(path)) {
// Some benchmarks I did have shown that trying to parallelize this process makes it slower instead of faster
return FileUtils.sizeOfDirectory(path.toFile());
} else {
try {
return Files.size(path);
} catch (IOException e) {
log.error("Error while calculating size of file '{}'.", path);
throw e;
}
}
}
private Path getPathFromFilename(String filename) {
return getPath(cacheFolderPath, filename);
}
}
@@ -1,134 +0,0 @@
package de.grimsi.gameyfin.service;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.dto.GameOverviewDto;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.Library;
import de.grimsi.gameyfin.entities.UnmappableFile;
import de.grimsi.gameyfin.igdb.IgdbWrapper;
import de.grimsi.gameyfin.mapper.GameMapper;
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import de.grimsi.gameyfin.repositories.LibraryRepository;
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.io.File;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Slf4j
@Service
public class GameService {
private final IgdbWrapper igdbWrapper;
private final GameMapper gameMapper;
private final DetectedGameRepository detectedGameRepository;
private final UnmappableFileRepository unmappableFileRepository;
private final LibraryRepository libraryRepository;
private final FilesystemService filesystemService;
public List<DetectedGame> getAllDetectedGames() {
return detectedGameRepository.findAll();
}
public DetectedGame getDetectedGame(String slug) {
return detectedGameRepository.findById(slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' not found in library.".formatted(slug)));
}
public List<UnmappableFile> getAllUnmappedFiles() {
return unmappableFileRepository.findAll();
}
public Map<String, String> getAllMappings() {
return detectedGameRepository.findAll().stream().collect(Collectors.toMap(DetectedGame::getPath, DetectedGame::getTitle));
}
public List<GameOverviewDto> getGameOverviews() {
return detectedGameRepository.findAll().stream().map(gameMapper::toGameOverviewDto).toList();
}
public void deleteGame(String slug) {
DetectedGame gameToBeDeleted = getDetectedGame(slug);
// Add the path of the game to be deleted to the unmappable files,
// so it doesn't get re-indexed on the next library scan
unmappableFileRepository.save(new UnmappableFile(gameToBeDeleted.getPath()));
detectedGameRepository.delete(gameToBeDeleted);
}
public void deleteUnmappedFile(Long id) {
unmappableFileRepository.deleteById(id);
}
public DetectedGame confirmGame(String slug, boolean confirm) {
DetectedGame g = getDetectedGame(slug);
g.setConfirmedMatch(confirm);
return detectedGameRepository.save(g);
}
public DetectedGame mapPathToGame(String path, String slug) {
if (detectedGameRepository.existsBySlug(slug))
throw new ResponseStatusException(HttpStatus.CONFLICT, "Game with slug '%s' already exists in database.".formatted(slug));
Optional<UnmappableFile> optionalUnmappableFile = unmappableFileRepository.findByPath(path);
if (optionalUnmappableFile.isPresent()) {
return mapUnmappableFile(optionalUnmappableFile.get(), slug);
}
Optional<DetectedGame> optionalDetectedGame = detectedGameRepository.findByPath(path);
if (optionalDetectedGame.isPresent()) {
return mapDetectedGame(optionalDetectedGame.get(), slug);
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Path '%s' not in database".formatted(path));
}
public DetectedGame refreshGame(String slug) {
Optional<DetectedGame> optionalDetectedGame = detectedGameRepository.findById(slug);
if (optionalDetectedGame.isPresent()) {
log.info("Refreshing game with slug '{}'", slug);
return mapDetectedGame(optionalDetectedGame.get(), slug);
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' not found in database".formatted(slug));
}
private DetectedGame mapUnmappableFile(UnmappableFile unmappableFile, String slug) {
DetectedGame game = mapPathToGame(filesystemService.getPath(unmappableFile.getPath()), slug);
unmappableFileRepository.delete(unmappableFile);
return game;
}
private DetectedGame mapDetectedGame(DetectedGame existingGame, String slug) {
DetectedGame game = mapPathToGame(filesystemService.getPath(existingGame.getPath()), slug);
detectedGameRepository.delete(existingGame);
return game;
}
private DetectedGame mapPathToGame(Path path, String slug) {
Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug)));
// Parent folder should be the library
Path libraryPath = path.getParent();
Library library = libraryRepository.findByPath(libraryPath.toString()).orElse(null);
DetectedGame game = gameMapper.toDetectedGame(igdbGame, path, library);
game.setConfirmedMatch(true);
return detectedGameRepository.save(game);
}
}
@@ -1,139 +0,0 @@
package de.grimsi.gameyfin.service;
import de.grimsi.gameyfin.entities.Company;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.igdb.IgdbApiProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;
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 reactor.core.publisher.Flux;
import jakarta.annotation.PostConstruct;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@Slf4j
@RequiredArgsConstructor
@Service
public class ImageService {
private final FilesystemService filesystemService;
private final GameService gameService;
private final WebClient.Builder webclientBuilder;
private WebClient igdbImageClient;
@PostConstruct
public void init() {
igdbImageClient = webclientBuilder.baseUrl(IgdbApiProperties.IMAGES_BASE_URL).build();
}
public int downloadGameCoversFromIgdb() {
StopWatch stopWatch = new StopWatch();
log.info("Starting game cover download...");
stopWatch.start();
MultiValueMap<String, String> gameToImageIds = new LinkedMultiValueMap<>(
gameService.getAllDetectedGames().stream()
.collect(Collectors.toMap(DetectedGame::getSlug, g -> Collections.singletonList(g.getCoverId()))));
int downloadCount = saveImagesIntoCache(gameToImageIds, IgdbApiProperties.COVER_IMAGE_SIZE, "cover", "game");
stopWatch.stop();
log.info("Downloaded {} covers in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
return downloadCount;
}
public int downloadGameScreenshotsFromIgdb() {
StopWatch stopWatch = new StopWatch();
log.info("Starting game screenshot download...");
stopWatch.start();
MultiValueMap<String, String> gamesToImageIds = new LinkedMultiValueMap<>(
gameService.getAllDetectedGames().stream()
.collect(Collectors.toMap(DetectedGame::getSlug, DetectedGame::getScreenshotIds)));
int downloadCount = saveImagesIntoCache(gamesToImageIds, IgdbApiProperties.SCREENSHOT_IMAGE_SIZE, "screenshot", "game");
stopWatch.stop();
log.info("Downloaded {} screenshots in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
return downloadCount;
}
public int downloadCompanyLogosFromIgdb() {
StopWatch stopWatch = new StopWatch();
log.info("Starting company logo download...");
stopWatch.start();
Map<String, List<String>> companyToLogoIdMap = gameService.getAllDetectedGames().stream()
.flatMap(g -> g.getCompanies().stream())
.collect(Collectors.toMap(Company::getSlug, c -> Collections.singletonList(c.getLogoId()), (c1, c2) -> c1));
MultiValueMap<String, String> companiesToLogoIds = new LinkedMultiValueMap<>(companyToLogoIdMap);
int downloadCount = saveImagesIntoCache(companiesToLogoIds, IgdbApiProperties.LOGO_IMAGE_SIZE, "logo", "company");
stopWatch.stop();
log.info("Downloaded {} company logos in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
return downloadCount;
}
private int saveImagesIntoCache(MultiValueMap<String, String> entityToImageIds, String imageSize, String imageType, String entityType) {
AtomicInteger downloadCounter = new AtomicInteger();
entityToImageIds.forEach((key, value) -> value.forEach(imageId -> {
if (!StringUtils.hasText(imageId)) return;
String imgFileName = "%s.png".formatted(imageId);
String imgUrl = "t_%s/%s".formatted(imageSize, imgFileName);
if (filesystemService.doesCachedFileExist(imgFileName)) {
if (filesystemService.isCachedFileCorrupt(imgFileName)) {
log.info("File '{}' is corrupt, retrying download...", imgFileName);
filesystemService.deleteFileFromCache(imgFileName);
} else {
log.debug("{} for {} '{}' already downloaded ({}), skipping.",
imageType.substring(0, 1).toUpperCase() + imageType.substring(1).toLowerCase(),
entityType,
key,
imgFileName);
return;
}
}
Flux<DataBuffer> dataBuffer = igdbImageClient.get()
.uri(imgUrl)
.retrieve()
.bodyToFlux(DataBuffer.class);
try {
filesystemService.saveFileToCache(dataBuffer, imgFileName);
} catch (WebClientResponseException e) {
if (e.getStatusCode().is4xxClientError()) {
log.error("Could not download {} for {} '{}' from {}: {}", imageType, entityType, key, IgdbApiProperties.IMAGES_BASE_URL + imgUrl, e.getStatusCode());
}
}
downloadCounter.getAndIncrement();
log.info("Downloaded {} for {} '{}' from {}", imageType, entityType, key, IgdbApiProperties.IMAGES_BASE_URL + imgUrl);
}));
return downloadCounter.get();
}
}
@@ -1,257 +0,0 @@
package de.grimsi.gameyfin.service;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.dto.LibraryScanResult;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.Library;
import de.grimsi.gameyfin.entities.Platform;
import de.grimsi.gameyfin.entities.UnmappableFile;
import de.grimsi.gameyfin.igdb.IgdbWrapper;
import de.grimsi.gameyfin.mapper.GameMapper;
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import de.grimsi.gameyfin.repositories.LibraryRepository;
import de.grimsi.gameyfin.repositories.PlatformRepository;
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StopWatch;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static de.grimsi.gameyfin.util.FilenameUtil.getFilenameWithoutAdditions;
import static de.grimsi.gameyfin.util.FilenameUtil.hasGameArchiveExtension;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import static org.apache.commons.lang3.StringUtils.isBlank;
@Slf4j
@Service
@RequiredArgsConstructor
public class LibraryService {
@Value("${gameyfin.sources}")
private List<String> libraryFolders;
private final IgdbWrapper igdbWrapper;
private final GameMapper gameMapper;
private final DetectedGameRepository detectedGameRepository;
private final UnmappableFileRepository unmappableFileRepository;
private final LibraryRepository libraryRepository;
private final PlatformRepository platformRepository;
private final FilesystemService filesystemService;
public List<Path> getGameFiles() {
return getGameFiles(null);
}
public List<Path> getGameFiles(String path) {
List<Path> gamefiles = new ArrayList<>();
libraryFolders.stream().map(Path::of).filter(allPathsOrSpecific(path)).forEach(
folder -> {
try (Stream<Path> stream = Files.list(folder)) {
// return all sub-folders (non-recursive) and files that have an extension that indicates that they are a downloadable file
List<Path> gameFilesFromThisFolder = stream
.filter(p -> Files.isDirectory(p) || hasGameArchiveExtension(p))
// filter out all hidden files and folders
.filter(p -> {
try {
return !(Files.isHidden(p));
} catch (IOException e) {
throw new RuntimeException("Error while checking if '%s' is hidden.".formatted(p), e);
}
})
// filter out all empty directories
.filter(p -> {
if (!Files.isDirectory(p)) return true;
try (DirectoryStream<Path> s = Files.newDirectoryStream(p)) {
return s.iterator().hasNext();
} catch (IOException e) {
throw new RuntimeException("Error while checking if folder '%s' is empty.".formatted(p), e);
}
})
.toList();
gamefiles.addAll(gameFilesFromThisFolder);
} catch (IOException e) {
throw new RuntimeException("Error while opening library folder '%s'".formatted(folder), e);
}
}
);
return gamefiles;
}
public LibraryScanResult scanGameLibrary(Library library) {
StopWatch stopWatch = new StopWatch();
log.info("Starting scan...");
stopWatch.start();
AtomicInteger newUnmappedFilesCounter = new AtomicInteger();
String libraryPath = library.getPath();
List<Path> gameFiles = getGameFiles(libraryPath);
// 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.getAllByPathNotInAndPathStartsWith(gameFiles, libraryPath);
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.getAllByPathNotInAndPathStartsWith(gameFiles, libraryPath);
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();
// Check if library has assigned platforms, so we can search for matching games by specific platforms to get a more accurate match
Set<String> platformsFilter = libraryRepository.findByPath(libraryPath).map(Library::getPlatforms)
.map(platforms -> platforms.stream()
.map(Platform::getSlug)
.collect(Collectors.toSet()))
.orElse(Set.of());
// 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.stream()
.map(p -> {
Optional<Igdb.Game> optionalGame = igdbWrapper.searchForGameByTitle(getFilenameWithoutAdditions(p), platformsFilter);
if (optionalGame.isPresent() && detectedGameRepository.existsBySlug(optionalGame.get().getSlug())) {
log.warn("Game with slug '{}' already exists in database", optionalGame.get().getSlug());
optionalGame = Optional.empty();
}
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(), library))
.collect(toList());
List<DetectedGame> duplicateGames = getDuplicates(newDetectedGames);
newUnmappedFilesCounter.getAndAdd(duplicateGames.size());
newDetectedGames.removeAll(duplicateGames);
try {
newDetectedGames = detectedGameRepository.saveAll(newDetectedGames);
} catch (Exception e) {
log.error("Could not save {} detected games!", newDetectedGames.size());
List<UnmappableFile> unmappableFiles = newDetectedGames.stream()
.map(game -> new UnmappableFile(game.getPath())).toList();
unmappableFileRepository.saveAll(unmappableFiles);
}
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());
return LibraryScanResult.builder()
.newGames(newDetectedGames.size())
.deletedGames(deletedGames.size() + deletedUnmappableFiles.size())
.newUnmappableFiles(newUnmappedFilesCounter.get())
.totalGames((int) detectedGameRepository.count())
.build();
}
public List<AutocompleteSuggestionDto> getAutocompleteSuggestions(String searchTerm, int limit) {
return igdbWrapper.findPossibleMatchingTitles(searchTerm, limit);
}
private List<DetectedGame> getDuplicates(List<DetectedGame> gamesToFilter) {
return gamesToFilter.stream().filter(g -> Collections.frequency(gamesToFilter, g) > 1)
.peek(d -> {
log.warn("Found duplicate for game '{}' under path '{}'. Mapping must be done manually.", d.getTitle(), d.getPath());
unmappableFileRepository.save(new UnmappableFile(d.getPath()));
})
.toList();
}
public List<Library> getLibraries() {
return libraryRepository.findAll();
}
public Library getLibrary(String path) {
return libraryRepository.findByPath(path).orElse(null);
}
public List<Library> getOrCreateLibraries() {
libraryFolders.stream().map(Path::of)
.filter(path -> path.toFile().isDirectory()) // check if path is a valid directory
.filter(path -> !libraryRepository.existsByPathIgnoreCase(path.toString()))
.forEach(path -> {
// save new paths as library without platforms
Library library = new Library(path.toString(), List.of());
libraryRepository.save(library);
});
List<Library> libraries = libraryRepository.findAll();
libraries.forEach(library -> {
// remap existing games to this library as well
List<DetectedGame> gamesWithoutLibraryAssignment =
detectedGameRepository.findByPathStartsWithAndLibraryIsNull(library.getPath());
gamesWithoutLibraryAssignment.forEach(game -> game.setLibrary(library));
detectedGameRepository.saveAll(gamesWithoutLibraryAssignment);
});
return libraries;
}
public List<Platform> getPlatforms(String searchTerm, int limit) {
return igdbWrapper.findPlatforms(searchTerm, limit);
}
public Library mapPlatformsToLibrary(String path, String slugs) {
Library library = libraryRepository.findByPath(path)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Could not find library for path %s".formatted(path)));
Set<String> platformSlugs = Arrays.stream(slugs.split(",")).collect(toSet());
List<Platform> platforms = platformSlugs.stream()
.map(slug -> {
Optional<Platform> p = platformRepository.findBySlug(slug);
return p.isPresent() ? p : igdbWrapper.getPlatformBySlug(slug);
})
.filter(Optional::isPresent)
.map(Optional::get)
.collect(toList());
library.setPlatforms(platforms);
libraryRepository.save(library);
return library;
}
private Predicate<Path> allPathsOrSpecific(String path) {
return p -> isBlank(path) || p.equals(filesystemService.getPath(path));
}
}
@@ -1,75 +0,0 @@
package de.grimsi.gameyfin.util;
import de.grimsi.gameyfin.config.properties.GameyfinProperties;
import org.apache.commons.io.FilenameUtils;
import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class FilenameUtil {
private static List<String> possibleGameFileExtensions;
private static List<String> possibleGameFileSuffixes;
// matches v1.1.1 v1.1 v1 version numbers
private static final Pattern versionPattern = Pattern.compile("v(\\d+\\.)?(\\d+\\.)?(\\d+)");
// Suppress SONAR detecting this as a potential stack overflow
// SONAR is correct, but I honestly don't know how to fix it
// Also it would require 6k+ character long filenames to really overflow the JVM stack
@SuppressWarnings("java:S5998")
private static final Pattern trailingNoisePattern = Pattern.compile("( |\\(\\)|\\[]|[-_.])+$");
@SuppressWarnings("java:S5998")
private static final Pattern headingNoisePattern = Pattern.compile("^( |\\(\\)|\\[]|[-_.])+");
public FilenameUtil(GameyfinProperties gameyfinProperties) {
possibleGameFileExtensions = gameyfinProperties.fileExtensions();
// Sort in descending length, so for example "windows" gets checked before "win"
FilenameUtil.possibleGameFileSuffixes = gameyfinProperties.fileSuffixes();
possibleGameFileSuffixes.sort((s1,s2) -> Integer.compare(s2.length(), s1.length()));
}
public static String getFilenameWithoutExtension(Path p) {
// If the path points to a folder, return the folder name
// Folders like "Counter Strike 1.6" would otherwise be returned as "Counter Strike 1"
if (Files.isDirectory(p)) return FilenameUtils.getName(p.toString());
return FilenameUtils.getBaseName(p.toString());
}
public static String getFilenameWithExtension(Path p) {
return FilenameUtils.getName(p.toString());
}
public static boolean hasGameArchiveExtension(Path p) {
return possibleGameFileExtensions.contains(FilenameUtils.getExtension(p.getFileName().toString()));
}
public static String getFilenameWithoutAdditions(Path p) {
String name = getFilenameWithoutExtension(p).toLowerCase();
for(String suffix : possibleGameFileSuffixes) {
name = name.replace(suffix, "");
}
name = removePattern(name, versionPattern);
name = removePattern(name, trailingNoisePattern);
name = removePattern(name, headingNoisePattern);
// sanity check to never return an empty name
return name.isBlank() ? getFilenameWithoutExtension(p) : name;
}
public static String removePattern(String string, Pattern pattern) {
Matcher matcher = pattern.matcher(string);
if (matcher.find()) {
return matcher.replaceAll("");
}
return string;
}
}
@@ -1,11 +0,0 @@
package de.grimsi.gameyfin.util;
import com.google.protobuf.Timestamp;
import java.time.Instant;
public class ProtobufUtil {
public static Instant toInstant(Timestamp t) {
return Instant.ofEpochSecond(t.getSeconds(), t.getNanos());
}
}
@@ -1,9 +0,0 @@
{
"properties": [
{
"name": "gameyfin.sources",
"type": "java.lang.String[]",
"description": "List of directories Gameyfin should scan for games."
}
]
}
@@ -1,11 +0,0 @@
gameyfin:
user: admin
password: password
logging:
level:
de.grimsi: debug
# org.springframework.web.reactive.function.client.ExchangeFunctions: debug
org.apache.catalina.core.ContainerBase: info
spring.mvc.log-request-details: true
@@ -1,5 +0,0 @@
# General
logging.level:
root: info
# Hides an error log on the first aborted download
org.apache.catalina.core.ContainerBase: off
-10
View File
@@ -1,10 +0,0 @@
${AnsiColor.GREEN}
_____ ___ _
/ ___/ ___ _ __ _ ___ __ __ / _/ (_) ___
/ (_ / / _ `/ / ' \/ -_) / // / / _/ / / / _ \
\___/ \_,_/ /_/_/_/\__/ \_, / /_/ /_/ /_//_/
/___/
${AnsiColor.WHITE}
${application.name} ${application.version}
Powered by Spring Boot ${spring-boot.version}
@@ -1,20 +0,0 @@
spring:
jpa:
open-in-view: true
properties:
hibernate:
enable_lazy_load_no_trans: true
event:
merge:
entity_copy_observer: allow
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: none
flyway:
baseline-on-migrate: true
datasource:
username: gfadmin
password: gameyfin
db-name: gameyfin_db
url: jdbc:h2:file:${gameyfin.db}/${spring.datasource.db-name}
driverClassName: org.h2.Driver
@@ -1,301 +0,0 @@
gameyfin:
folders:
data: ""
file-extensions:
- 'rar' # Compressed Archive -- Use WinRAR or 7-Zip to open and extract.
- '7z' # Compressed Archive -- Use WinRAR or 7-Zip to open and extract.
- '7zip' # Compressed Archive -- Use WinRAR or 7-Zip to open and extract.
- 'zip' # Compressed Archive -- Use WinRAR or 7-Zip to open and extract.
- 'tar.gz' # Compressed Archive -- Use WinRAR or 7-Zip to open and extract.
- 'n.gz' # Compressed Archive -- Use WinRAR or 7-Zip to open and extract.
- 'tar' # Archive -- Use 7-Zip to open and extract.
- '001' # Split Archive -- Use WinRAR or 7-Zip to open and extract.
- 'part1' # Split Archive -- Use WinRAR or 7-Zip to open and extract.
- 'r01' # Split Archive -- Use WinRAR or 7-Zip to open and extract.
- 'gzip' # Compressed Archive -- Use 7-Zip to open and extract. Can be loaded by PCSX2 directly.
- 'iso' # Disk Image for Multiple systems
- 'img' # Disk Image for Multiple systems
- 'ccd' # CloneCD Control File for Multiple systems -- Usually comes with .img files.
- 'bin' # Binary File for Multiple systems -- Usually accompanied with, and used by, a cue sheet.
- 'cue' # Cue Sheet for Multiple systems -- Metadata file for .bin/.iso/.img tracks.
- 'chd' # Compressed Hard Disk for Multiple systems -- Originally made for MAME, now used by multiple emulators
- 'mdf' # Media Descriptor File for Multiple systems -- Disc image made from Alcohol 120%
- 'mds' # Media Descriptor Sheet for Multiple systems -- Similar to .cue or .ccd, but for .mdf files.
- 'ecm' # Error Code Modeler for PlayStation -- Compressed PS1 disc image, use Unecm to decompress it.
- 'cso' # Compressed ISO for PS2/PSP/GC/Wii -- Compressed disc image, see here to convert to and from .iso
- 'gcz' # Compressed Dolphin ISO for GC/Wii -- Compressed Wii/GC disc image, see here to convert to and from .iso or Compressed GameCube Disc Image for GameCube -- Can be either a compressed .gcm or .iso
- 'rvz' # Modern Dolphin Format for GC/Wii -- A new compressed format developed by the Dolphin team able to efficiently compress junk/padding data, allowing both small file sizes and archival quality.
- 'cdi' # CD Image for Dreamcast -- Typically used with Dreamcast, CDI is a compressed GDI
- 'gdi' # CD Image for Dreamcast -- Typically used with Dreamcast, CDI is a compressed GDI
- 'sbI' # CD Image -- Sub-channel data. Generally used in the case of PS1 for Digital Rights Management
- 'fds' # Famicom Disk System for NES
- 'ndd' # Nintendo 64DD for N64
- 'wav' # Waveform Audio File Format for Tape Based Systems -- Raw data in audio format†
- 'tap' # Tape File for Tape Based Systems -- Raw tape data usable by appropriate emulators
- 'tzx' # ZX Spectrum Tape File for Compatible Tape Based Systems (ZXS, CPC, C64, etc.) -- Prefered file type for all emulators, perfect representation
- 'cdc' # CPC Digital Tape for Amstrad CPC -- Identical to the .tzx file but useable only by Amstrad CPC emulators
- 'cas' # PC-6000 and PC-6601 Cassette for NEC PC-6000 series
- 'nes' # NES ROM for NES -- .unf and .unif correspond to the now deprecated Universal NES Image Format.
- 'nez' # NES ROM for NES -- .unf and .unif correspond to the now deprecated Universal NES Image Format.
- 'unf' # NES ROM for NES -- .unf and .unif correspond to the now deprecated Universal NES Image Format.
- 'unif' # NES ROM for NES -- .unf and .unif correspond to the now deprecated Universal NES Image Format.
- 'smc' # Super Magicom for SNES -- Headered ROMs dumped from a Super Magicom copier.
- 'sfc' # Super Famicom ROM for SNES -- Headerless SNES ROM
- 'md' # Multi Game Doctor for Genesis -- ROMs dumped from a Multi Game Doctor
- 'smd' # Super Magic Drive for Genesis -- Headered ROMs dumped from a Super Magic Drive
- 'gen' # Genesis ROM for Genesis -- Headerless Genesis ROM
- 'gg' # Game Gear ROM for Game Gear
- 'z64' # Zip Diskette N64 ROM for Nintendo 64 -- Headered N64 ROMS dumped with a Mr. Backup Z64
- 'v64' # Doctor V64 for Nintendo 64 -- Headered N64 ROMS dumped with a Doctor V64
- 'n64' # N64 ROM for Nintendo 64
- 'gb' # Game Boy ROM for Game Boy
- 'gbc' # Game Boy Color ROM for Game Boy Color
- 'gba' # Game Boy Advance ROM for Game Boy Advance
- 'gcm' # GameCube Master Image for GameCube -- GC disc dumped the way it's read
- 'nds' # Nintendo DS ROM for Nintendo DS or Game Boy Advance ROM for Game Boy Advance
- 'srl' # Nintendo DS ROM for Nintendo DS
- 'dsi' # DSiWare ROM for DSiWare -- Different from regular DS ROMs.
- 'app' # DSiWare ROM for DSiWare -- Different from regular DS ROMs.
- 'ids' # iQue DS ROM for iQue/Nintendo DS -- The same thing as regular Nintendo DS ROMs, but for the Chinese iQue DS.
- 'wbfs' # Wii Backup File System for Wii -- Use WBFS Manager to convert to and from .iso
- 'wad' # Wii Application Data for Wii or "Where's all the data files?" for Doom -- Used for Wii channels or Doom
- 'cia' # CTR Importable Archive for 3DS
- '3ds' # Nintendo 3DS ROM for 3DS
- 'nsp' # Nintendo Switch ROM for Nintendo Switch -- Dump of SD and NAND games, Updates
- 'xci' # Nintendo Switch ROM for Nintendo Switch -- Dump of a game cartridge
- 'ngp' # NGP/C ROM for Neo Geo Pocket/Color
- 'ngc' # NGP/C ROM for Neo Geo Pocket/Color
- 'pce' # PC Engine ROM for PC Engine
- 'vpk' # PlayStation Vita ROM for PlayStation Vita -- Compressed files forming a PlayStation Vita ROM
- 'vb' # Virtual Boy ROM for Virtual Boy -- Also used for Visual Basic source files
- 'ws' # WonderSwan (Color) ROM for WonderSwan/Color
- 'wsc' # WonderSwan (Color) ROM for WonderSwan/Color
- 'ipa' # Apple iPhone App Data for iOS
- 'apk' # Android App Data for Android
- 'obb' # Android App Data Resources for Android
- 'elf' # Executable and Linkable Format for PS2/PS3/GC/Wii -- Typically a homebrew/small application file
- 'pbp' # Perl Builder File for PlayStation Portable -- Game and homebrew eboot file, also used for PS1 ISO storage (eboot.pbp)
- 'dol' # Dolphin File for GameCube/Wii -- Executable
- 'xbe' # Xbox Executable for Xbox/X360 -- Xbox Executable. Usually comes with a folder with game data. X360 ones can be loaded by Xenia.
- 'xex' # Xbox Executable for Xbox/X360 -- Xbox Executable. Usually comes with a folder with game data. X360 ones can be loaded by Xenia.
- 'cfg' # Configuration File for Multiple systems -- Use a text editor such as notepad/Notepad++ to edit
- 'ini' # Configuration File for Multiple systems -- Use a text editor such as notepad/Notepad++ to edit
- 'dll' # Dynamic Link Library for Multiple systems -- Typically used for plugins/emulation cores in emulators
- 'so' # Shared Library for Multiple systems -- Typically used for plugins/emulation cores in emulators
- 'xml' # Extensible Markup Language for MAME/MESS -- Contains various information about emulated systems and ROMs.
- 'hsi' # XML-like for MESS -- Contains various information about ROMs.
- 'lay' # Layout for MAME/MESS -- XML-like file used to describe the visual layout of artwork overlays and placement of emulation input/output.
- 'nv' # Non-Volatile RAM for MAME/MESS -- Created by default to store nvram of emulated systems or software in the NVRAM folder.
- 'm3u' # Playlist file for Multiple systems -- Used to play multiple discs back to back, automates disc swapping.
file-suffixes: windows, win, english, win32, win64, opengl, stable
igdb:
api:
endpoints:
auth: https://id.twitch.tv/oauth2/token
base: https://api.igdb.com/v4/
client-id:
client-secret:
max-concurrent-requests: 2
max-requests-per-second: 4
config:
preferred-platforms:
- 3 # Linux or GNU/Linux
- 4 # Nintendo 64 or N64
- 5 # Wii or Revolution
- 6 # PC (Microsoft Windows) or mswin
- 7 # PlayStation or PSX, PSOne, PS
- 8 # PlayStation 2 or PS2
- 9 # PlayStation 3 or PS3
- 11 # Xbox
- 12 # Xbox 360 or X360
# - 13 # DOS or PC DOS
- 14 # Mac or Mac OS
# - 15 # Commodore C64/128/MAX or C64/C128/MAX
# - 16 # Amiga
- 18 # Nintendo Entertainment System or NES
- 19 # Super Nintendo Entertainment System or SNES, Super Nintendo
- 20 # Nintendo DS or NDS
- 21 # Nintendo GameCube or GCN
- 22 # Game Boy Color or GBC
- 23 # Dreamcast or DC
- 24 # Game Boy Advance or GBA
# - 25 # Amstrad CPC or Colour Personal Computer
# - 26 # ZX Spectrum
# - 27 # MSX
- 29 # Sega Mega Drive/Genesis or Sega Genesis
# - 30 # Sega 32X
- 32 # Sega Saturn or JVC Saturn, Hi-Saturn, Samsung Saturn, V-Saturn
- 33 # Game Boy or GB
# - 34 # Android or Infocusa3
- 35 # Sega Game Gear or GG
- 37 # Nintendo 3DS or 3DS
- 38 # PlayStation Portable or PSP
# - 39 # iOS
- 41 # Wii U
# - 42 # N-Gage or NGage
# - 44 # Tapwave Zodiac
- 46 # PlayStation Vita or PS Vita
- 47 # Virtual Console
- 48 # PlayStation 4 or PS4
# - 49 # Xbox One or XONE
# - 50 # 3DO Interactive Multiplayer or 3DO
# - 51 # Family Computer Disk System or Famicom Disk System, FDS
# - 52 # Arcade
# - 53 # MSX2
# - 55 # Legacy Mobile Device or Legacy Cellphone
# - 57 # WonderSwan or WS
- 58 # Super Famicom or SFC
# - 59 # Atari 2600 or Atari VCS
# - 60 # Atari 7800 or Atari 7800 ProSystem
# - 61 # Atari Lynx
# - 62 # Atari Jaguar
# - 63 # Atari ST/STE
- 64 # Sega Master System/Mark III or SMS, Mark III
# - 65 # Atari 8-bit
# - 66 # Atari 5200 or Atari 5200 SuperSystem
# - 67 # Intellivision
# - 68 # ColecoVision
# - 69 # BBC Microcomputer System or BBC Micro
# - 70 # Vectrex
# - 71 # Commodore VIC-20
# - 72 # Ouya
# - 73 # BlackBerry OS
# - 74 # Windows Phone or WP
# - 75 # Apple II or apple ][
# - 77 # Sharp X1
# - 78 # Sega CD or Mega CD
# - 79 # Neo Geo MVS or Neo Geo Multi Video System
# - 80 # Neo Geo AES or AES
# - 82 # Web browser or Internet
# - 84 # SG-1000 or Sega Game 1000
# - 85 # Donner Model 30
# - 86 # TurboGrafx-16/PC Engine
# - 87 # Virtual Boy or VB
# - 88 # Odyssey or Magnavox Odyssey; Odysee; Odisea; Odissea
# - 89 # Microvision
# - 90 # Commodore PET
# - 91 # Bally Astrocade or Bally Arcade
# - 93 # Commodore 16 or C16
# - 94 # Commodore Plus/4
# - 95 # PDP-1 or Programmed Data Processor-1
# - 96 # PDP-10
# - 97 # PDP-8
# - 98 # DEC GT40
# - 99 # Family Computer or Famicom
# - 100 # Analogue electronics
# - 101 # Ferranti Nimrod Computer
# - 102 # EDSAC or Electronic Delay Storage Automatic Calculator
# - 103 # PDP-7
# - 104 # HP 2100
# - 105 # HP 3000
# - 106 # SDS Sigma 7
# - 107 # Call-A-Computer time-shared mainframe computer system
# - 108 # PDP-11
# - 109 # CDC Cyber 70
# - 110 # PLATO or Programmed Logic for Automatic Teaching Operations
# - 111 # Imlac PDS-1
# - 112 # Microcomputer
# - 113 # OnLive Game System
# - 114 # Amiga CD32
# - 115 # Apple IIGS
# - 116 # Acorn Archimedes
# - 117 # Philips CD-i
# - 118 # FM Towns
# - 119 # Neo Geo Pocket or NGP
# - 120 # Neo Geo Pocket Color or NGPC
# - 121 # Sharp X68000
# - 122 # Nuon
# - 123 # WonderSwan Color or WSC
# - 124 # SwanCrystal
# - 125 # PC-8801
# - 126 # TRS-80
# - 127 # Fairchild Channel F
# - 128 # PC Engine SuperGrafx
# - 129 # Texas Instruments TI-99 or Texas Instruments TI-99/4A
- 130 # Nintendo Switch or NX
# - 131 # Nintendo PlayStation or Nintendo Super Disc
# - 132 # Amazon Fire TV
# - 133 # Odyssey 2 / Videopac G7000 or Magnavox Odyssey²
# - 134 # Acorn Electron
# - 135 # Hyper Neo Geo 64
# - 136 # Neo Geo CD or NGCD
- 137 # New Nintendo 3DS or n3DS
# - 138 # VC 4000
# - 139 # 1292 Advanced Programmable Video System
# - 140 # AY-3-8500
# - 141 # AY-3-8610
# - 142 # PC-50X Family
# - 143 # AY-3-8760
# - 144 # AY-3-8710
# - 145 # AY-3-8603
# - 146 # AY-3-8605
# - 147 # AY-3-8606
# - 148 # AY-3-8607
# - 149 # PC-98
# - 150 # Turbografx-16/PC Engine CD or TG-16CD/PCECD
# - 151 # TRS-80 Color Computer or Tandy Color Computer
# - 152 # FM-7 or Fujitsu Micro 7
# - 153 # Dragon 32/64
# - 154 # Amstrad PCW
# - 155 # Tatung Einstein
# - 156 # Thomson MO5
# - 157 # NEC PC-6000 Series
# - 158 # Commodore CDTV or Commodore Dynamic Total Vision
- 159 # Nintendo DSi
# - 161 # Windows Mixed Reality or WMR
# - 162 # Oculus VR
# - 163 # SteamVR
# - 164 # Daydream
- 165 # PlayStation VR or PSVR
# - 166 # Pokémon mini
# - 167 # PlayStation 5 or PS5
# - 169 # Xbox Series X|S or XSX
# - 170 # Google Stadia or Stadia
# - 203 # DUPLICATE Stadia
# - 236 # Exidy Sorcerer
# - 237 # Sol-20
# - 238 # DVD Player or Digital Versatile Disc Player
# - 239 # Blu-ray Player
# - 240 # Zeebo
# - 274 # PC-FX
# - 306 # Satellaview
# - 307 # Game & Watch or Tricotronic, GW, G&W
# - 308 # Playdia
# - 309 # Evercade
# - 339 # Sega Pico or Kids Computer Pico
# - 372 # OOParts
# - 373 # Sinclair ZX81 or ZX81
# - 374 # Sharp MZ-2200
# - 375 # Epoch Cassette Vision
# - 376 # Epoch Super Cassette Vision or YENO Super Cassette Vision
# - 377 # Plug & Play or TV Game
# - 378 # Gamate or Super Boy
# - 379 # Game.com or Tiger Game.com
# - 380 # Casio Loopy or Loopy
# - 381 # Playdate
# - 382 # Intellivision Amico
# - 384 # Oculus Quest or Quest
# - 385 # Oculus Rift or Rift
# - 386 # Meta Quest 2 or Quest 2
# - 387 # Oculus Go or Go
# - 388 # Gear VR or Samsung Gear VR
# - 389 # AirConsole
# - 390 # PlayStation VR2 or PSVR2
# - 405 # Windows Mobile or Pocket PC
# - 406 # Sinclair QL or Sinclair Quantum Leap
# - 407 # HyperScan
# - 408 # Mega Duck/Cougar Boy or WG-108
# - 409 # Legacy Computer
# - 410 # Atari Jaguar CD or Jag CD
# - 411 # Handheld Electronic LCD or Handheld LCD Game
# - 412 # Leapster or Leapster Learning Game System
# - 413 # Leapster Explorer/LeadPad Explorer
# - 414 # LeapTV or LeapTV VCD
# - 415 # Watara/QuickShot Supervision
# - 416 # Nintendo 64DD or 64DD
# - 417 # Palm OS or Garnet OS
# - 438 # Arduboy
# - 439 # V.Smile or V.SMILE TV LEARNING SYSTEM
# - 440 # Visual Memory Unit / Visual Memory System or VMU / VMS
# - 441 # PocketStation
# - 471 # Meta Quest 3
@@ -1,30 +0,0 @@
application:
version: '@project.version@'
name: Gameyfin
server:
servlet:
context-path: /
error:
include-stacktrace: never
spring:
jackson:
deserialization:
fail-on-unknown-properties: false
default-property-inclusion: non_null
mapper:
accept-case-insensitive-enums: true
mvc:
async:
request-timeout: -1
management:
endpoint:
health:
enabled: true
endpoints:
enabled-by-default: false
gameyfin:
internal-folder: .gameyfin
@@ -1,148 +0,0 @@
-- Automatically generated by JPA
-- Hibernate sequence
create sequence HIBERNATE_SEQUENCE start with 1 increment by 1;
-- Detected Games
create table DETECTED_GAME
(
slug varchar(255) not null,
category varchar(255),
confirmed_match boolean default false,
cover_id varchar(255) not null,
critics_rating integer,
disk_size bigint not null,
lan_support boolean not null,
max_players integer not null,
offline_coop boolean not null,
online_coop boolean not null,
path varchar(255) not null,
release_date timestamp,
summary CLOB,
title varchar(255) not null,
total_rating integer,
user_rating integer,
primary key (slug)
);
-- Companies
create table COMPANY
(
slug varchar(255) not null,
logo_id varchar(255),
name varchar(255) not null,
primary key (slug)
);
-- Genres
create table GENRE
(
slug varchar(255) not null,
name varchar(255),
primary key (slug)
);
-- Themes
create table THEME
(
slug varchar(255) not null,
name varchar(255),
primary key (slug)
);
-- Keywords
create table KEYWORD
(
slug varchar(255) not null,
name varchar(255),
primary key (slug)
);
-- Player Perspectives
create table PLAYER_PERSPECTIVE
(
slug varchar(255) not null,
name varchar(255),
primary key (slug)
);
-- Unmappable files
create table UNMAPPABLE_FILE
(
id bigint not null,
path varchar(255),
primary key (id)
);
-- Game <-> Companies
create table DETECTED_GAME_COMPANIES
(
detected_game_slug varchar(255) not null,
companies_slug varchar(255) not null
);
alter table DETECTED_GAME_COMPANIES
add constraint companies_company_slug foreign key (companies_slug) references company;
alter table DETECTED_GAME_COMPANIES
add constraint companies_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Genres
create table DETECTED_GAME_GENRES
(
detected_game_slug varchar(255) not null,
genres_slug varchar(255) not null
);
alter table DETECTED_GAME_GENRES
add constraint genres_genre_slug foreign key (genres_slug) references genre;
alter table DETECTED_GAME_GENRES
add constraint genres_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Themes
create table DETECTED_GAME_THEMES
(
detected_game_slug varchar(255) not null,
themes_slug varchar(255) not null
);
alter table DETECTED_GAME_THEMES
add constraint themes_theme_slug foreign key (themes_slug) references theme;
alter table DETECTED_GAME_THEMES
add constraint themes_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Keywords
create table DETECTED_GAME_KEYWORDS
(
detected_game_slug varchar(255) not null,
keywords_slug varchar(255) not null
);
alter table DETECTED_GAME_KEYWORDS
add constraint keywords_keyword_slug foreign key (keywords_slug) references keyword;
alter table DETECTED_GAME_KEYWORDS
add constraint keywords_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Player Perspectives
create table DETECTED_GAME_PLAYER_PERSPECTIVES
(
detected_game_slug varchar(255) not null,
player_perspectives_slug varchar(255) not null
);
alter table DETECTED_GAME_PLAYER_PERSPECTIVES
add constraint player_perspectives_player_perspective_slug foreign key (player_perspectives_slug) references player_perspective;
alter table DETECTED_GAME_PLAYER_PERSPECTIVES
add constraint player_perspectives_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Videos
create table DETECTED_GAME_VIDEO_IDS
(
detected_game_slug varchar(255) not null,
video_ids varchar(255)
);
alter table DETECTED_GAME_VIDEO_IDS
add constraint video_ids_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Screenshots
create table DETECTED_GAME_SCREENSHOT_IDS
(
detected_game_slug varchar(255) not null,
screenshot_ids varchar(255)
);
alter table DETECTED_GAME_SCREENSHOT_IDS
add constraint screenshot_ids_detected_game_slug foreign key (detected_game_slug) references detected_game;
@@ -1,4 +0,0 @@
-- Add field "addedToLibrary" to the "DetectedGame" table with the default value of CURRENT_TIMESTAMP()
alter table DETECTED_GAME
add added_to_library timestamp not null default CURRENT_TIMESTAMP()
@@ -1,45 +0,0 @@
-- Add platforms
-- Platforms
CREATE TABLE platform
(
slug VARCHAR(255) NOT NULL,
name VARCHAR(255),
logo_id VARCHAR(255),
PRIMARY KEY (slug)
);
-- Game <-> Platforms
CREATE TABLE detected_game_platforms
(
detected_game_slug VARCHAR(255) NOT NULL,
platforms_slug VARCHAR(255) NOT NULL
);
ALTER TABLE detected_game_platforms
ADD CONSTRAINT platforms_platform_slug FOREIGN KEY (platforms_slug) REFERENCES platform;
ALTER TABLE detected_game_platforms
ADD CONSTRAINT platforms_detected_game_slug FOREIGN KEY (detected_game_slug) REFERENCES detected_game;
-- Add libraries
-- Libraries
CREATE TABLE library
(
path VARCHAR(255) NOT NULL,
PRIMARY KEY (path)
);
-- Library <-> Platforms
CREATE TABLE library_platforms
(
library_path VARCHAR(255) NOT NULL,
platforms_slug VARCHAR(255) NOT NULL
);
ALTER TABLE library_platforms
ADD CONSTRAINT libraries_platform_slug FOREIGN KEY (platforms_slug) REFERENCES platform;
ALTER TABLE library_platforms
ADD CONSTRAINT libraries_library_path FOREIGN KEY (library_path) REFERENCES library;
-- Library <-> Game
ALTER TABLE detected_game
ADD library VARCHAR(255);
File diff suppressed because it is too large Load Diff
@@ -1,373 +0,0 @@
package de.grimsi.gameyfin.igdb;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.protobuf.Message;
import com.google.protobuf.Timestamp;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.config.WebClientConfig;
import de.grimsi.gameyfin.config.properties.GameyfinProperties;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.entities.Platform;
import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto;
import de.grimsi.gameyfin.mapper.GameMapper;
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 okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import okio.Buffer;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.reactive.function.client.WebClient;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import static de.grimsi.gameyfin.igdb.IgdbApiQueryBuilder.equal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IgdbWrapperTest {
private static final MockWebServer igdbApiMock = new MockWebServer();
private static final MockWebServer twitchApiMock = new MockWebServer();
private static final EasyRandom easyRandom = new EasyRandom();
private static final ObjectMapper objectMapper = new ObjectMapper();
private static IgdbWrapper target;
@BeforeAll
static void setup() throws IOException, InterruptedException {
WebClientConfig webClientConfigMock = mock(WebClientConfig.class);
GameMapper gameMapperMock = mock(GameMapper.class);
GameyfinProperties gameyfinPropertiesMock = mock(GameyfinProperties.class, Mockito.RETURNS_DEEP_STUBS);
target = new IgdbWrapper(WebClient.builder(), webClientConfigMock, gameMapperMock, gameyfinPropertiesMock);
igdbApiMock.start();
twitchApiMock.start();
ReflectionTestUtils.setField(target, "clientId", "client_id_value");
ReflectionTestUtils.setField(target, "clientSecret", "client_secret_value");
ReflectionTestUtils.setField(target, "igdbApiBaseUrl", "http://localhost:%s".formatted(igdbApiMock.getPort()));
ReflectionTestUtils.setField(target, "twitchAuthUrl", "http://localhost:%s/oauth2/token".formatted(twitchApiMock.getPort()));
when(gameyfinPropertiesMock.igdb().config().preferredPlatforms()).thenReturn(List.of(6));
when(webClientConfigMock.getIgdbConcurrencyLimiter()).thenReturn(Bulkhead.of("test_bulkhead", BulkheadConfig.ofDefaults()));
when(webClientConfigMock.getIgdbRateLimiter()).thenReturn(RateLimiter.of("test_ratelimiter", RateLimiterConfig.ofDefaults()));
TwitchOAuthTokenDto mockToken = easyRandom.nextObject(TwitchOAuthTokenDto.class);
twitchApiMock.enqueue(new MockResponse()
.setBody(objectMapper.writeValueAsString(mockToken))
.addHeader("Content-Type", "application/json"));
target.init();
RecordedRequest r = twitchApiMock.takeRequest();
assertThat(r.getRequestUrl()).isNotNull();
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/oauth2/token");
assertThat(r.getRequestUrl().queryParameter("client_id")).isEqualTo("client_id_value");
assertThat(r.getRequestUrl().queryParameter("client_secret")).isEqualTo("client_secret_value");
assertThat(r.getRequestUrl().queryParameter("grant_type")).isEqualTo("client_credentials");
twitchApiMock.shutdown();
}
@AfterAll
static void tearDown() throws IOException {
igdbApiMock.shutdown();
}
@Test
void getGameById() throws InterruptedException {
//Igdb.GameResult gameResult = easyRandom.nextObject(Igdb.GameResult.class);
Igdb.GameResult gameResult = Igdb.GameResult.newBuilder()
.addAllGames(List.of(
Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build(),
Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build(),
Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build()))
.build();
Long gameId = gameResult.getGames(0).getId();
igdbApiMock.enqueue(new MockResponse()
.setBody(toBuffer(gameResult))
.setHeader("Content-Type", "application/protobuf")
);
Optional<Igdb.Game> gameOptional = target.getGameById(gameId);
assertThat(gameOptional).isPresent();
Igdb.Game game = gameOptional.get();
assertThat(game.getId()).isEqualTo(gameId);
RecordedRequest r = igdbApiMock.takeRequest();
assertThat(r.getRequestUrl()).isNotNull();
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
String expectedQuery = "fields %s;limit 1;where id = %d;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameId);
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
}
@Test
void getGameBySlug() throws InterruptedException {
Igdb.GameResult gameResult = Igdb.GameResult.newBuilder()
.addAllGames(List.of(
Igdb.Game.newBuilder().setSlug("game_slug_1").build(),
Igdb.Game.newBuilder().setSlug("game_slug_2").build(),
Igdb.Game.newBuilder().setSlug("game_slug_3").build()))
.build();
String gameSlug = gameResult.getGames(0).getSlug();
igdbApiMock.enqueue(new MockResponse()
.setBody(toBuffer(gameResult))
.setHeader("Content-Type", "application/protobuf")
);
Optional<Igdb.Game> gameOptional = target.getGameBySlug("game_slug_1");
assertThat(gameOptional).isPresent();
Igdb.Game game = gameOptional.get();
assertThat(game.getSlug()).isEqualTo(gameSlug);
RecordedRequest r = igdbApiMock.takeRequest();
assertThat(r.getRequestUrl()).isNotNull();
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
String expectedQuery = "fields %s;limit 1;where slug = \"%s\";".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameSlug);
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
}
@Test
void findPossibleMatchingTitles() throws InterruptedException {
Igdb.GameResult gameResult = Igdb.GameResult.newBuilder()
.addAllGames(List.of(
Igdb.Game.newBuilder().setName("title_1").build(),
Igdb.Game.newBuilder().setName("title_2").build(),
Igdb.Game.newBuilder().setName("title_3").build()))
.build();
String gameTitle = gameResult.getGames(0).getName();
igdbApiMock.enqueue(new MockResponse()
.setBody(toBuffer(gameResult))
.setHeader("Content-Type", "application/protobuf")
);
List<AutocompleteSuggestionDto> suggestions = target.findPossibleMatchingTitles(gameTitle, gameResult.getGamesCount());
assertThat(suggestions).hasSize(gameResult.getGamesCount());
RecordedRequest r = igdbApiMock.takeRequest();
assertThat(r.getRequestUrl()).isNotNull();
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
String expectedQuery = "search \"%s\";fields slug,name,first_release_date,platforms.name;limit %d;where platforms = (6);".formatted(gameTitle, gameResult.getGamesCount());
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
}
@Test
void searchForGameByTitle_exactMatch() throws InterruptedException {
Igdb.GameResult gameResult = Igdb.GameResult.newBuilder()
.addAllGames(List.of(
Igdb.Game.newBuilder().setName("title_1").build(),
Igdb.Game.newBuilder().setName("title_2").build(),
Igdb.Game.newBuilder().setName("title_3").build()))
.build();
String searchTerm = gameResult.getGames(0).getName();
igdbApiMock.enqueue(new MockResponse()
.setBody(toBuffer(gameResult))
.setHeader("Content-Type", "application/protobuf")
);
Optional<Igdb.Game> gameOptional = target.searchForGameByTitle(searchTerm);
assertThat(gameOptional).isPresent();
Igdb.Game game = gameOptional.get();
assertThat(game.getName()).isEqualTo(searchTerm);
RecordedRequest r = igdbApiMock.takeRequest();
assertThat(r.getRequestUrl()).isNotNull();
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
String expectedQuery = "search \"%s\";fields %s;where platforms = (6);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
}
@Test
void searchForGameByTitle_EndsWith() throws InterruptedException {
Igdb.GameResult gameResult = Igdb.GameResult.newBuilder()
.addAllGames(List.of(
Igdb.Game.newBuilder().setName("some_prefix title_1").build(),
Igdb.Game.newBuilder().setName("title_2)").build(),
Igdb.Game.newBuilder().setName("title_3").build()))
.build();
String searchTerm = "title_1";
igdbApiMock.enqueue(new MockResponse()
.setBody(toBuffer(gameResult))
.setHeader("Content-Type", "application/protobuf")
);
Optional<Igdb.Game> gameOptional = target.searchForGameByTitle(searchTerm);
assertThat(gameOptional).isPresent();
Igdb.Game game = gameOptional.get();
assertThat(game.getName()).isEqualTo(gameResult.getGames(0).getName());
RecordedRequest r = igdbApiMock.takeRequest();
assertThat(r.getRequestUrl()).isNotNull();
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
String expectedQuery = "search \"%s\";fields %s;where platforms = (6);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
}
@Test
void searchForGameByTitle_Brackets() throws InterruptedException {
Igdb.GameResult gameResult = Igdb.GameResult.newBuilder()
.addAllGames(List.of(
Igdb.Game.newBuilder().setName("title_1").build(),
Igdb.Game.newBuilder().setName("title_2").build(),
Igdb.Game.newBuilder().setName("title_3").build()))
.build();
String searchTerm = gameResult.getGames(0).getName() + " (Text in brackets should be ignored)";
// First request should result in an empty response
igdbApiMock.enqueue(new MockResponse().setHeader("Content-Type", "application/protobuf"));
// Second request should contain the same query, but with brackets removed
igdbApiMock.enqueue(new MockResponse()
.setBody(toBuffer(gameResult))
.setHeader("Content-Type", "application/protobuf")
);
Optional<Igdb.Game> gameOptional = target.searchForGameByTitle(searchTerm);
assertThat(gameOptional).isPresent();
Igdb.Game game = gameOptional.get();
// Result should be game with title equal to search term with brackets removed
assertThat(game.getName()).isEqualTo(gameResult.getGames(0).getName());
// First query (should contain brackets)
RecordedRequest r1 = igdbApiMock.takeRequest();
assertThat(r1.getRequestUrl()).isNotNull();
assertThat(r1.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
String r1_expectedQuery = "search \"%s\";fields %s;where platforms = (6);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
assertThat(r1.getBody().readUtf8()).isEqualTo(r1_expectedQuery);
// Second query (should not contain brackets)
RecordedRequest r2 = igdbApiMock.takeRequest();
assertThat(r2.getRequestUrl()).isNotNull();
assertThat(r2.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
String r2_expectedQuery = "search \"%s\";fields %s;where platforms = (6);".formatted(gameResult.getGames(0).getName(), IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
assertThat(r2.getBody().readUtf8()).isEqualTo(r2_expectedQuery);
}
@Test
void findPlatforms() throws InterruptedException {
Igdb.PlatformResult platformResult = Igdb.PlatformResult.newBuilder()
.addAllPlatforms(List.of(
Igdb.Platform.newBuilder().setSlug("platform_1").setName("Platform 1").build(),
Igdb.Platform.newBuilder().setSlug("platform_2").setName("Platform 2").build(),
Igdb.Platform.newBuilder().setSlug("platform_3").setName("Platform 3").build()))
.build();
String searchTerm = platformResult.getPlatforms(0).getSlug();
int limit = 10;
igdbApiMock.enqueue(new MockResponse()
.setBody(toBuffer(platformResult))
.setHeader("Content-Type", "application/protobuf")
);
List<Platform> result = target.findPlatforms(searchTerm, limit);
assertThat(result.get(0).getSlug()).isEqualTo(searchTerm);
RecordedRequest r = igdbApiMock.takeRequest();
assertThat(r.getRequestUrl()).isNotNull();
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_PLATFORMS_PROTOBUF));
String expectedQuery = "search \"%s\";fields slug,name;limit %s;".formatted(searchTerm, limit);
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
}
@Test
void getPlatformBySlug() throws InterruptedException {
Igdb.PlatformResult platformResult = Igdb.PlatformResult.newBuilder()
.addAllPlatforms(List.of(
Igdb.Platform.newBuilder().setSlug("platform_1").setName("Platform 1").build()))
.build();
String slug = platformResult.getPlatforms(0).getSlug();
igdbApiMock.enqueue(new MockResponse()
.setBody(toBuffer(platformResult))
.setHeader("Content-Type", "application/protobuf")
);
Optional<Platform> result = target.getPlatformBySlug(slug);
assertThat(result).isPresent();
Platform platform = result.get();
assertThat(platform.getSlug()).isEqualTo(slug);
RecordedRequest r = igdbApiMock.takeRequest();
assertThat(r.getRequestUrl()).isNotNull();
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_PLATFORMS_PROTOBUF));
String expectedQuery = "fields slug,name,platform_logo;where slug = \"%s\";".formatted(slug);
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
}
private static Buffer toBuffer(Message input) {
Buffer b = new Buffer();
b.write(input.toByteArray());
return b;
}
}
@@ -1,38 +0,0 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Company;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class CompanyMapperTest extends RandomMapperTest<Igdb.InvolvedCompany, Company> {
@Test
void toCompany() {
Igdb.InvolvedCompany input = generateRandomInput();
Company output = CompanyMapper.toCompany(input);
assertThat(output.getSlug()).isEqualTo(input.getCompany().getSlug());
assertThat(output.getName()).isEqualTo(input.getCompany().getName());
assertThat(output.getLogoId()).isEqualTo(input.getCompany().getLogo().getImageId());
}
@Test
void toCompanies() {
List<Igdb.InvolvedCompany> input = List.of(generateRandomInput(), generateRandomInput(), generateRandomInput());
List<Company> output = CompanyMapper.toCompanies(input);
for (int i = 0; i < output.size(); i++) {
assertThat(output.get(i).getSlug()).isEqualTo(input.get(i).getCompany().getSlug());
assertThat(output.get(i).getName()).isEqualTo(input.get(i).getCompany().getName());
assertThat(output.get(i).getLogoId()).isEqualTo(input.get(i).getCompany().getLogo().getImageId());
}
}
}
@@ -1,35 +0,0 @@
package de.grimsi.gameyfin.mapper;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.entities.Genre;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class GenreMapperTest extends RandomMapperTest<Igdb.Genre, Genre> {
@Test
void toGenre() {
Igdb.Genre input = generateRandomInput();
Genre output = GenreMapper.toGenre(input);
assertThat(output.getSlug()).isEqualTo(input.getSlug());
assertThat(output.getName()).isEqualTo(input.getName());
}
@Test
void toGenres() {
List<Igdb.Genre> input = List.of(generateRandomInput(), generateRandomInput(), generateRandomInput());
List<Genre> output = GenreMapper.toGenres(input);
for (int i = 0; i < output.size(); i++) {
assertThat(output.get(i).getSlug()).isEqualTo(input.get(i).getSlug());
assertThat(output.get(i).getName()).isEqualTo(input.get(i).getName());
}
}
}
@@ -1,49 +0,0 @@
package de.grimsi.gameyfin.mapper;
import com.google.protobuf.Message;
import org.jeasy.random.EasyRandom;
import org.jeasy.random.EasyRandomParameters;
import org.springframework.core.GenericTypeResolver;
import java.util.List;
public class RandomMapperTest<Input extends Message, Output> {
private static final int DEFAULT_COUNT = 5;
protected final EasyRandom easyRandom = new EasyRandom();
private final Class<Input> inputClass;
private final Class<Output> outputClass;
@SuppressWarnings("unchecked")
public RandomMapperTest() {
Class<?>[] typeArguments = GenericTypeResolver.resolveTypeArguments(getClass(), RandomMapperTest.class);
assert typeArguments != null;
inputClass = (Class<Input>) typeArguments[0];
outputClass = (Class<Output>) typeArguments[1];
}
protected Input generateRandomInput() {
return easyRandom.nextObject(inputClass);
}
protected List<Input> generateRandomInputs() {
return easyRandom.objects(inputClass, DEFAULT_COUNT).toList();
}
protected List<Input> generateRandomInputs(int count) {
return easyRandom.objects(inputClass, count).toList();
}
protected Output generateRandomOutput() {
return easyRandom.nextObject(outputClass);
}
protected List<Output> generateRandomOutputs() {
return easyRandom.objects(outputClass, DEFAULT_COUNT).toList();
}
protected List<Output> generateRandomOutputs(int count) {
return easyRandom.objects(outputClass, count).toList();
}
}
@@ -1,114 +0,0 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.service.FilesystemService;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class DetectedGameRepositoryTest {
@Autowired
private DetectedGameRepository target;
private final EasyRandom easyRandom = new EasyRandom();
@BeforeEach
void dropTable() {
target.deleteAll();
}
@Test
void existsByPath() {
String path = "some/random/path";
DetectedGame input = DetectedGame.builder()
.slug("slug")
.title("title")
.coverId("coverId")
.path(path)
.build();
assertThat(target.existsByPath(path)).isFalse();
target.save(input);
assertThat(target.existsByPath(path)).isTrue();
}
@Test
void existsBySlug() {
String slug = "some-random-slug";
DetectedGame input = DetectedGame.builder()
.slug(slug)
.title("title")
.coverId("coverId")
.path("path")
.build();
assertThat(target.existsBySlug(slug)).isFalse();
target.save(input);
assertThat(target.existsBySlug(slug)).isTrue();
}
@Test
void findByPath() {
String path = "some/random/path";
DetectedGame input = DetectedGame.builder()
.slug("slug")
.title("title")
.coverId("coverId")
.path(path)
.build();
assertThat(target.findByPath(path)).isEmpty();
target.save(input);
Optional<DetectedGame> optionalResult = target.findByPath(path);
assertThat(optionalResult).isPresent();
DetectedGame result = optionalResult.get();
assertThat(result).isEqualTo(input);
}
@ParameterizedTest
@ValueSource(strings = {"", "some/random/library/path/"})
void getAllByPathNotInAndPathStartsWith(String library) {
String libraryPath = Path.of(library).toString();
String otherLibraryPath = Path.of("another/random/library/path/").toString();
List<DetectedGame> detectedGames = easyRandom.objects(DetectedGame.class, 2).peek(g -> g.setPath(Path.of(libraryPath, g.getPath()).toString())).toList();
List<DetectedGame> detectedGamesDifferentLibrary = easyRandom.objects(DetectedGame.class, 2).peek(g -> g.setPath(Path.of(otherLibraryPath, g.getPath()).toString())).toList();
List<DetectedGame> deletedGames = easyRandom.objects(DetectedGame.class, 2).peek(g -> g.setPath(Path.of(libraryPath, g.getPath()).toString())).toList();
List<Path> gamePaths = detectedGames.stream().map(DetectedGame::getPath).map(Path::of).collect(Collectors.toList());
gamePaths.addAll(detectedGamesDifferentLibrary.stream().map(DetectedGame::getPath).map(Path::of).toList());
target.saveAll(detectedGames);
target.saveAll(detectedGamesDifferentLibrary);
assertThat(target.getAllByPathNotInAndPathStartsWith(gamePaths, libraryPath)).isEmpty();
target.saveAll(deletedGames);
List<DetectedGame> result = target.getAllByPathNotInAndPathStartsWith(gamePaths, libraryPath);
assertThat(result)
.hasSize(2)
.containsOnlyOnceElementsOf(deletedGames);
}
}
@@ -1,48 +0,0 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.Library;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.Locale;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class LibraryRepositoryTest {
@Autowired
private LibraryRepository target;
@Test
void existsByPathIgnoreCase() {
String path = "Some/Random/Path";
Library input = Library.builder().path(path).build();
assertThat(target.existsByPathIgnoreCase(path)).isFalse();
target.save(input);
assertThat(target.existsByPathIgnoreCase(path)).isTrue();
assertThat(target.existsByPathIgnoreCase(path.toLowerCase(Locale.ENGLISH))).isTrue();
assertThat(target.existsByPathIgnoreCase(path.toUpperCase(Locale.ENGLISH))).isTrue();
}
@Test
void findByPath() {
String path = "Some/Random/Path";
Library input = Library.builder().path(path).build();
target.save(input);
Optional<Library> optionalResult = target.findByPath(path);
assertThat(optionalResult).isPresent();
Library result = optionalResult.get();
assertThat(result).isEqualTo(input);
}
}
@@ -1,86 +0,0 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.UnmappableFile;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class UnmappableFileRepositoryTest {
@Autowired
private UnmappableFileRepository target;
private final EasyRandom easyRandom = new EasyRandom();
@BeforeEach
void dropTable() {
target.deleteAll();
}
@Test
void existsByPath() {
String path = "some/random/path";
UnmappableFile input = new UnmappableFile(path);
assertThat(target.existsByPath(path)).isFalse();
target.save(input);
assertThat(target.existsByPath(path)).isTrue();
}
@Test
void findByPath() {
String path = "some/random/path";
UnmappableFile input = new UnmappableFile(path);
assertThat(target.findByPath(path)).isEmpty();
target.save(input);
Optional<UnmappableFile> optionalResult = target.findByPath(path);
assertThat(optionalResult).isPresent();
UnmappableFile result = optionalResult.get();
assertThat(result).isEqualTo(input);
}
@ParameterizedTest
@ValueSource(strings = {"", "some/random/library/path/"})
void getAllByPathNotInAndPathStartsWith(String library) {
String libraryPath = Path.of(library).toString();
String otherLibraryPath = Path.of("another/random/library/path/").toString();
List<UnmappableFile> UnmappableFiles = easyRandom.objects(UnmappableFile.class, 2).peek(g -> g.setPath(Path.of(libraryPath, g.getPath()).toString())).toList();
List<UnmappableFile> UnmappableFilesDifferentLibrary = easyRandom.objects(UnmappableFile.class, 2).peek(g -> g.setPath(Path.of(otherLibraryPath, g.getPath()).toString())).toList();
List<UnmappableFile> deletedGames = easyRandom.objects(UnmappableFile.class, 2).peek(g -> g.setPath(Path.of(libraryPath, g.getPath()).toString())).toList();
List<Path> gamePaths = UnmappableFiles.stream().map(UnmappableFile::getPath).map(Path::of).collect(Collectors.toList());
gamePaths.addAll(UnmappableFilesDifferentLibrary.stream().map(UnmappableFile::getPath).map(Path::of).toList());
target.saveAll(UnmappableFiles);
target.saveAll(UnmappableFilesDifferentLibrary);
assertThat(target.getAllByPathNotInAndPathStartsWith(gamePaths, libraryPath)).isEmpty();
target.saveAll(deletedGames);
List<UnmappableFile> result = target.getAllByPathNotInAndPathStartsWith(gamePaths, libraryPath);
assertThat(result)
.hasSize(2)
.containsOnlyOnceElementsOf(deletedGames);
}
}
@@ -1,159 +0,0 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.dto.GameOverviewDto;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.service.DownloadService;
import de.grimsi.gameyfin.service.GameService;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.OutputStream;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.in;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GamesControllerTest {
@InjectMocks
private GamesController target;
@Mock
private GameService gameServiceMock;
@Mock
private DownloadService downloadServiceMock;
private final EasyRandom easyRandom = new EasyRandom();
@Test
void getAllGames() {
List<DetectedGame> input = easyRandom.objects(DetectedGame.class, 5).toList();
when(gameServiceMock.getAllDetectedGames()).thenReturn(input);
List<DetectedGame> result = target.getAllGames();
verify(gameServiceMock, times(1)).getAllDetectedGames();
assertThat(result).hasSameElementsAs(input);
}
@Test
void getGame() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
String slug = input.getSlug();
when(gameServiceMock.getDetectedGame(slug)).thenReturn(input);
DetectedGame result = target.getGame(slug);
verify(gameServiceMock, times(1)).getDetectedGame(slug);
assertThat(result).isEqualTo(input);
}
@Test
void getGameOverviews() {
List<GameOverviewDto> input = easyRandom.objects(GameOverviewDto.class, 5).toList();
when(gameServiceMock.getGameOverviews()).thenReturn(input);
List<GameOverviewDto> result = target.getGameOverviews();
verify(gameServiceMock, times(1)).getGameOverviews();
assertThat(result).hasSameElementsAs(input);
}
@Test
void getGameMappings() {
Map<String, String> input = easyRandom.objects(String.class, 5).collect(Collectors.toMap(String::toLowerCase, String::toUpperCase));
when(gameServiceMock.getAllMappings()).thenReturn(input);
Map<String, String> result = target.getGameMappings();
verify(gameServiceMock, times(1)).getAllMappings();
assertThat(result).isEqualTo(input);
}
@Test
void downloadGameFiles_File() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
String slug = input.getSlug();
String downloadFilename = input.getSlug();
long downloadFileSize = 1337L;
when(gameServiceMock.getDetectedGame(slug)).thenReturn(input);
when(downloadServiceMock.getDownloadFileName(input)).thenReturn(downloadFilename);
when(downloadServiceMock.getDownloadFileSize(input)).thenReturn(downloadFileSize);
ResponseEntity<StreamingResponseBody> result = target.downloadGameFiles(slug);
HttpHeaders expectedHeaders = new HttpHeaders();
expectedHeaders.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"%s\"".formatted(downloadFilename));
expectedHeaders.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate");
expectedHeaders.add(HttpHeaders.PRAGMA, "no-cache");
expectedHeaders.add(HttpHeaders.EXPIRES, "0");
expectedHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
expectedHeaders.setContentLength(downloadFileSize);
verify(gameServiceMock, times(1)).getDetectedGame(slug);
verify(downloadServiceMock, times(1)).getDownloadFileName(input);
verify(downloadServiceMock, times(1)).getDownloadFileSize(input);
assertThat(result.getHeaders()).isEqualTo(expectedHeaders);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void downloadGameFiles_Zip() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
String slug = input.getSlug();
String downloadFilename = input.getSlug() + ".zip";
long downloadFileSize = 0L;
when(gameServiceMock.getDetectedGame(slug)).thenReturn(input);
when(downloadServiceMock.getDownloadFileName(input)).thenReturn(downloadFilename);
when(downloadServiceMock.getDownloadFileSize(input)).thenReturn(downloadFileSize);
ResponseEntity<StreamingResponseBody> result = target.downloadGameFiles(slug);
HttpHeaders expectedHeaders = new HttpHeaders();
expectedHeaders.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"%s\"".formatted(downloadFilename));
expectedHeaders.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate");
expectedHeaders.add(HttpHeaders.PRAGMA, "no-cache");
expectedHeaders.add(HttpHeaders.EXPIRES, "0");
expectedHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
verify(gameServiceMock, times(1)).getDetectedGame(slug);
verify(downloadServiceMock, times(1)).getDownloadFileName(input);
verify(downloadServiceMock, times(1)).getDownloadFileSize(input);
assertThat(result.getHeaders()).isEqualTo(expectedHeaders);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void refreshGame() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
String slug = input.getSlug();
when(gameServiceMock.refreshGame(slug)).thenReturn(input);
DetectedGame result = target.refreshGame(slug);
verify(gameServiceMock, times(1)).refreshGame(slug);
assertThat(result).isEqualTo(input);
}
}
@@ -1,42 +0,0 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.service.DownloadService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.in;
import static org.mockito.Mockito.*;
import static reactor.core.publisher.Mono.when;
@ExtendWith(MockitoExtension.class)
class ImageControllerTest {
@InjectMocks
private ImageController target;
@Mock
private DownloadService downloadServiceMock;
@Test
void getImage() {
byte[] content = "content".getBytes();
Resource resource = new ByteArrayResource(content);
String input = "imageId";
doReturn(resource).when(downloadServiceMock).sendImageToClient(input);
ResponseEntity<Resource> result = target.getImage(input);
verify(downloadServiceMock, times(1)).sendImageToClient(input);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).isEqualTo(resource);
}
}
@@ -1,180 +0,0 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.dto.ImageDownloadResultDto;
import de.grimsi.gameyfin.dto.LibraryScanRequestDto;
import de.grimsi.gameyfin.dto.LibraryScanResult;
import de.grimsi.gameyfin.dto.LibraryScanResultDto;
import de.grimsi.gameyfin.entities.Library;
import de.grimsi.gameyfin.service.ImageService;
import de.grimsi.gameyfin.service.LibraryService;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LibraryControllerTest {
@InjectMocks
private LibraryController target;
@Mock
private LibraryService libraryServiceMock;
@Mock
private ImageService imageServiceMock;
private final EasyRandom easyRandom = new EasyRandom();
@Test
void scanLibraries_All_NoImages() {
int libraryCount = 5;
LibraryScanRequestDto input = new LibraryScanRequestDto("", false);
List<Library> libraries = easyRandom.objects(Library.class, libraryCount).toList();
LibraryScanResult lsr = easyRandom.nextObject(LibraryScanResult.class);
when(libraryServiceMock.getLibraries()).thenReturn(libraries);
when(libraryServiceMock.scanGameLibrary(any(Library.class))).thenReturn(lsr);
LibraryScanResultDto result = target.scanLibraries(input);
verify(libraryServiceMock, times(1)).getLibraries();
verify(libraryServiceMock, never()).getLibrary(any(String.class));
verify(libraryServiceMock, times(libraryCount)).scanGameLibrary(any(Library.class));
verify(imageServiceMock, never()).downloadCompanyLogosFromIgdb();
verify(imageServiceMock, never()).downloadGameCoversFromIgdb();
verify(imageServiceMock, never()).downloadGameScreenshotsFromIgdb();
assertThat(result.getNewGames()).isEqualTo(lsr.getNewGames() * libraries.size());
assertThat(result.getDeletedGames()).isEqualTo(lsr.getDeletedGames() * libraries.size());
assertThat(result.getNewUnmappableFiles()).isEqualTo(lsr.getNewUnmappableFiles() * libraries.size());
assertThat(result.getTotalGames()).isEqualTo(lsr.getTotalGames() * libraries.size());
assertThat(result.getCoverDownloads()).isZero();
assertThat(result.getScreenshotDownloads()).isZero();
assertThat(result.getCompanyLogoDownloads()).isZero();
}
@Test
void scanLibraries_Single_NoImages() {
String libraryPath = "some/random/path";
LibraryScanRequestDto input = new LibraryScanRequestDto(libraryPath, false);
Library library = easyRandom.nextObject(Library.class);
LibraryScanResult lsr = easyRandom.nextObject(LibraryScanResult.class);
when(libraryServiceMock.getLibrary(libraryPath)).thenReturn(library);
when(libraryServiceMock.scanGameLibrary(any(Library.class))).thenReturn(lsr);
LibraryScanResultDto result = target.scanLibraries(input);
verify(libraryServiceMock, never()).getLibraries();
verify(libraryServiceMock, times(1)).getLibrary(libraryPath);
verify(libraryServiceMock, times(1)).scanGameLibrary(any(Library.class));
verify(imageServiceMock, never()).downloadCompanyLogosFromIgdb();
verify(imageServiceMock, never()).downloadGameCoversFromIgdb();
verify(imageServiceMock, never()).downloadGameScreenshotsFromIgdb();
assertThat(result.getNewGames()).isEqualTo(lsr.getNewGames());
assertThat(result.getDeletedGames()).isEqualTo(lsr.getDeletedGames());
assertThat(result.getNewUnmappableFiles()).isEqualTo(lsr.getNewUnmappableFiles());
assertThat(result.getTotalGames()).isEqualTo(lsr.getTotalGames());
assertThat(result.getCoverDownloads()).isZero();
assertThat(result.getScreenshotDownloads()).isZero();
assertThat(result.getCompanyLogoDownloads()).isZero();
}
@Test
void scanLibraries_All_DownloadImages() {
int libraryCount = 5;
LibraryScanRequestDto input = new LibraryScanRequestDto("", true);
List<Library> libraries = easyRandom.objects(Library.class, libraryCount).toList();
LibraryScanResult lsr = easyRandom.nextObject(LibraryScanResult.class);
when(libraryServiceMock.getLibraries()).thenReturn(libraries);
when(libraryServiceMock.scanGameLibrary(any(Library.class))).thenReturn(lsr);
when(imageServiceMock.downloadGameCoversFromIgdb()).thenReturn(1);
when(imageServiceMock.downloadGameScreenshotsFromIgdb()).thenReturn(1);
when(imageServiceMock.downloadCompanyLogosFromIgdb()).thenReturn(1);
LibraryScanResultDto result = target.scanLibraries(input);
verify(libraryServiceMock, times(1)).getLibraries();
verify(libraryServiceMock, never()).getLibrary(any(String.class));
verify(libraryServiceMock, times(libraryCount)).scanGameLibrary(any(Library.class));
verify(imageServiceMock, times(1)).downloadCompanyLogosFromIgdb();
verify(imageServiceMock, times(1)).downloadGameCoversFromIgdb();
verify(imageServiceMock, times(1)).downloadGameScreenshotsFromIgdb();
assertThat(result.getNewGames()).isEqualTo(lsr.getNewGames() * libraries.size());
assertThat(result.getDeletedGames()).isEqualTo(lsr.getDeletedGames() * libraries.size());
assertThat(result.getNewUnmappableFiles()).isEqualTo(lsr.getNewUnmappableFiles() * libraries.size());
assertThat(result.getTotalGames()).isEqualTo(lsr.getTotalGames() * libraries.size());
assertThat(result.getCoverDownloads()).isEqualTo(1);
assertThat(result.getScreenshotDownloads()).isEqualTo(1);
assertThat(result.getCompanyLogoDownloads()).isEqualTo(1);
}
@Test
void scanLibraries_Single_DownloadImages() {
String libraryPath = "some/random/path";
LibraryScanRequestDto input = new LibraryScanRequestDto(libraryPath, true);
Library library = easyRandom.nextObject(Library.class);
LibraryScanResult lsr = easyRandom.nextObject(LibraryScanResult.class);
when(libraryServiceMock.getLibrary(libraryPath)).thenReturn(library);
when(libraryServiceMock.scanGameLibrary(any(Library.class))).thenReturn(lsr);
when(imageServiceMock.downloadGameCoversFromIgdb()).thenReturn(1);
when(imageServiceMock.downloadGameScreenshotsFromIgdb()).thenReturn(1);
when(imageServiceMock.downloadCompanyLogosFromIgdb()).thenReturn(1);
LibraryScanResultDto result = target.scanLibraries(input);
verify(libraryServiceMock, never()).getLibraries();
verify(libraryServiceMock, times(1)).getLibrary(libraryPath);
verify(libraryServiceMock, times(1)).scanGameLibrary(any(Library.class));
verify(imageServiceMock, times(1)).downloadCompanyLogosFromIgdb();
verify(imageServiceMock, times(1)).downloadGameCoversFromIgdb();
verify(imageServiceMock, times(1)).downloadGameScreenshotsFromIgdb();
assertThat(result.getNewGames()).isEqualTo(lsr.getNewGames());
assertThat(result.getDeletedGames()).isEqualTo(lsr.getDeletedGames());
assertThat(result.getNewUnmappableFiles()).isEqualTo(lsr.getNewUnmappableFiles());
assertThat(result.getTotalGames()).isEqualTo(lsr.getTotalGames());
assertThat(result.getCoverDownloads()).isEqualTo(1);
assertThat(result.getScreenshotDownloads()).isEqualTo(1);
assertThat(result.getCompanyLogoDownloads()).isEqualTo(1);
}
@Test
void downloadImages() {
when(imageServiceMock.downloadGameCoversFromIgdb()).thenReturn(1);
when(imageServiceMock.downloadGameScreenshotsFromIgdb()).thenReturn(1);
when(imageServiceMock.downloadCompanyLogosFromIgdb()).thenReturn(1);
ImageDownloadResultDto result = target.downloadImages();
verify(imageServiceMock, times(1)).downloadCompanyLogosFromIgdb();
verify(imageServiceMock, times(1)).downloadGameCoversFromIgdb();
verify(imageServiceMock, times(1)).downloadGameScreenshotsFromIgdb();
assertThat(result.getScreenshotDownloads()).isOne();
assertThat(result.getCoverDownloads()).isOne();
assertThat(result.getCompanyLogoDownloads()).isOne();
}
@Test
void getAllFiles() {
List<Path> gameFiles = easyRandom.objects(String.class, 5).map(Path::of).toList();
when(libraryServiceMock.getGameFiles()).thenReturn(gameFiles);
List<String> result = target.getAllFiles();
verify(libraryServiceMock, times(1)).getGameFiles();
assertThat(result).hasSameElementsAs(gameFiles.stream().map(Path::toString).toList());
}
}
@@ -1,141 +0,0 @@
package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.dto.PathToSlugDto;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.Library;
import de.grimsi.gameyfin.entities.Platform;
import de.grimsi.gameyfin.service.GameService;
import de.grimsi.gameyfin.service.ImageService;
import de.grimsi.gameyfin.service.LibraryService;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LibraryManagementControllerTest {
@InjectMocks
private LibraryManagementController target;
@Mock
private GameService gameServiceMock;
@Mock
private ImageService imageServiceMock;
@Mock
private LibraryService libraryServiceMock;
private final EasyRandom easyRandom = new EasyRandom();
@Test
void deleteGame() {
String slug = easyRandom.nextObject(String.class);
target.deleteGame(slug);
verify(gameServiceMock, times(1)).deleteGame(slug);
}
@Test
void deleteUnmappedFile() {
Long id = easyRandom.nextLong();
target.deleteUnmappedFile(id);
verify(gameServiceMock, times(1)).deleteUnmappedFile(id);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void confirmMatch(boolean confirm) {
String slug = easyRandom.nextObject(String.class);
target.confirmMatch(slug, confirm);
verify(gameServiceMock, times(1)).confirmGame(slug, confirm);
}
@Test
void manuallyMapPathToSlug() {
PathToSlugDto input = easyRandom.nextObject(PathToSlugDto.class);
DetectedGame game = easyRandom.nextObject(DetectedGame.class);
when(gameServiceMock.mapPathToGame(input.getPath(), input.getSlug())).thenReturn(game);
DetectedGame result = target.manuallyMapPathToSlug(input);
verify(gameServiceMock, times(1)).mapPathToGame(input.getPath(), input.getSlug());
verify(imageServiceMock, times(1)).downloadGameScreenshotsFromIgdb();
verify(imageServiceMock, times(1)).downloadGameCoversFromIgdb();
verify(imageServiceMock, times(1)).downloadCompanyLogosFromIgdb();
assertThat(result).isEqualTo(game);
}
@Test
void getUnmappedFiles() {
target.getUnmappedFiles();
verify(gameServiceMock, times(1)).getAllUnmappedFiles();
}
@Test
void getAutocompleteSuggestions() {
String searchTerm = easyRandom.nextObject(String.class);
int limit = 10;
List<AutocompleteSuggestionDto> a = easyRandom.objects(AutocompleteSuggestionDto.class, limit).toList();
when(libraryServiceMock.getAutocompleteSuggestions(searchTerm, limit)).thenReturn(a);
List<AutocompleteSuggestionDto> result = target.getAutocompleteSuggestions(searchTerm, limit);
verify(libraryServiceMock, times(1)).getAutocompleteSuggestions(searchTerm, limit);
assertThat(result).isEqualTo(a);
}
@Test
void getPlatforms() {
String searchTerm = easyRandom.nextObject(String.class);
int limit = 10;
List<Platform> p = easyRandom.objects(Platform.class, limit).toList();
when(libraryServiceMock.getPlatforms(searchTerm, limit)).thenReturn(p);
List<Platform> result = target.getPlatforms(searchTerm, limit);
verify(libraryServiceMock, times(1)).getPlatforms(searchTerm, limit);
assertThat(result).isEqualTo(p);
}
@Test
void getLibraries() {
target.getLibraries();
verify(libraryServiceMock, times(1)).getOrCreateLibraries();
}
@Test
void mapPathToPlatform() {
PathToSlugDto input = easyRandom.nextObject(PathToSlugDto.class);
Library l = easyRandom.nextObject(Library.class);
when(libraryServiceMock.mapPlatformsToLibrary(input.getPath(), input.getSlug())).thenReturn(l);
Library result = target.mapPathToPlatform(input);
verify(libraryServiceMock, times(1)).mapPlatformsToLibrary(input.getPath(), input.getSlug());
assertThat(result).isEqualTo(l);
}
}
@@ -1,86 +0,0 @@
package de.grimsi.gameyfin.service;
import de.grimsi.gameyfin.entities.DetectedGame;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DownloadServiceTest {
@Mock
private FilesystemService filesystemServiceMock;
@InjectMocks
private DownloadService target;
private final EasyRandom easyRandom = new EasyRandom();
@Test
void getDownloadFileName_File() {
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
filesMock.when(() -> Files.isDirectory(any())).thenReturn(false);
when(filesystemServiceMock.getPath(any())).thenReturn(Path.of(input.getPath()));
String result = target.getDownloadFileName(input);
assertThat(result).isEqualTo(input.getPath());
}
}
@Test
void getDownloadFileName_Folder() {
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
filesMock.when(() -> Files.isDirectory(any())).thenReturn(true);
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
when(filesystemServiceMock.getPath(any())).thenReturn(Path.of(input.getPath()));
String result = target.getDownloadFileName(input);
assertThat(result).isEqualTo("%s.zip".formatted(input.getPath()));
}
}
@Test
void getDownloadFileSize_File() throws IOException {
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
filesMock.when(() -> Files.isDirectory(any())).thenReturn(false);
when(filesystemServiceMock.getSizeOnDisk(any())).thenReturn(1337L);
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
Long result = target.getDownloadFileSize(input);
assertThat(result).isEqualTo(1337L);
}
}
@Test
void getDownloadFileSize_Folder() {
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
filesMock.when(() -> Files.isDirectory(any())).thenReturn(true);
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
Long result = target.getDownloadFileSize(input);
assertThat(result).isZero();
}
}
}
@@ -1,253 +0,0 @@
package de.grimsi.gameyfin.service;
import com.google.common.jimfs.Configuration;
import com.google.common.jimfs.Jimfs;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.buffer.*;
import org.springframework.test.util.ReflectionTestUtils;
import reactor.core.publisher.Flux;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Named.named;
import static org.junit.jupiter.params.provider.Arguments.arguments;
@ExtendWith(MockitoExtension.class)
class FilesystemServiceTest {
@InjectMocks
private FilesystemService target;
private final EasyRandom easyRandom = new EasyRandom();
private static final FileSystem unixFS = Jimfs.newFileSystem(Configuration.unix());
private static final FileSystem osxFS = Jimfs.newFileSystem(Configuration.osX());
private static final FileSystem winFS = Jimfs.newFileSystem(Configuration.windows());
private static final String CACHE_PATH = "path/to/cache";
void setup(FileSystem fileSystem) {
ReflectionTestUtils.setField(target, "fileSystem", fileSystem);
ReflectionTestUtils.setField(target, "cacheFolderPath", CACHE_PATH);
target.createCacheFolder();
}
@AfterAll
static void closeFileSystems() throws IOException {
unixFS.close();
osxFS.close();
winFS.close();
}
@ParameterizedTest
@MethodSource("fileSystems")
void getPath(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String testPath = "some/random/path";
Path input = fileSystem.getPath(testPath);
Files.createDirectories(input);
Path result = target.getPath(testPath);
assertThat(result).isEqualTo(input);
Files.deleteIfExists(input);
}
@ParameterizedTest
@MethodSource("fileSystems")
void createCacheFolder(FileSystem fileSystem) throws IOException {
setup(fileSystem);
Path cache = fileSystem.getPath(CACHE_PATH);
Files.deleteIfExists(cache);
assertThat(Files.exists(cache)).isFalse();
target.createCacheFolder();
assertThat(Files.exists(cache)).isTrue();
}
@ParameterizedTest
@MethodSource("fileSystems")
void saveFileToCache(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String fileName = easyRandom.nextObject(String.class);
byte[] fileContent = new byte[1024];
easyRandom.nextBytes(fileContent);
Path savedFilePath = fileSystem.getPath(CACHE_PATH, fileName);
try(InputStream i = new ByteArrayInputStream(fileContent)) {
DataBufferFactory dbFactory = new DefaultDataBufferFactory();
Flux<DataBuffer> d = DataBufferUtils.readInputStream(() -> i, dbFactory, 1);
target.saveFileToCache(d, fileName);
assertThat(Files.readAllBytes(savedFilePath)).isEqualTo(fileContent);
}
Files.deleteIfExists(savedFilePath);
}
@ParameterizedTest
@MethodSource("fileSystems")
void getFileFromCache(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String fileName = easyRandom.nextObject(String.class);
byte[] fileContent = new byte[1024];
easyRandom.nextBytes(fileContent);
Path savedFilePath = fileSystem.getPath(CACHE_PATH, fileName);
Files.write(savedFilePath, fileContent);
ByteArrayResource expected = new ByteArrayResource(fileContent);
assertThat(target.getFileFromCache(fileName)).isEqualTo(expected);
Files.deleteIfExists(savedFilePath);
}
@ParameterizedTest
@MethodSource("fileSystems")
void deleteFileFromCache(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String fileName = easyRandom.nextObject(String.class);
byte[] fileContent = new byte[1024];
easyRandom.nextBytes(fileContent);
Path savedFilePath = fileSystem.getPath(CACHE_PATH, fileName);
Files.write(savedFilePath, fileContent);
assertThat(Files.exists(savedFilePath)).isTrue();
target.deleteFileFromCache(fileName);
assertThat(Files.exists(savedFilePath)).isFalse();
Files.deleteIfExists(savedFilePath);
}
@ParameterizedTest
@MethodSource("fileSystems")
void isCachedFileCorrupt_True(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String fileName = easyRandom.nextObject(String.class);
Path savedFilePath = fileSystem.getPath(CACHE_PATH, fileName);
Files.write(savedFilePath, new byte[0]);
assertThat(target.isCachedFileCorrupt(fileName)).isTrue();
Files.deleteIfExists(savedFilePath);
}
@ParameterizedTest
@MethodSource("fileSystems")
void isCachedFileCorrupt_False(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String fileName = easyRandom.nextObject(String.class);
byte[] fileContent = new byte[1024];
easyRandom.nextBytes(fileContent);
Path savedFilePath = fileSystem.getPath(CACHE_PATH, fileName);
Files.write(savedFilePath, fileContent);
assertThat(target.isCachedFileCorrupt(fileName)).isFalse();
Files.deleteIfExists(savedFilePath);
}
@ParameterizedTest
@MethodSource("fileSystems")
void doesCachedFileExist(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String fileName = easyRandom.nextObject(String.class);
byte[] fileContent = new byte[1024];
easyRandom.nextBytes(fileContent);
Path savedFilePath = fileSystem.getPath(CACHE_PATH, fileName);
assertThat(target.doesCachedFileExist(fileName)).isFalse();
Files.write(savedFilePath, fileContent);
assertThat(target.doesCachedFileExist(fileName)).isTrue();
Files.deleteIfExists(savedFilePath);
}
@Disabled("Due to JimFS not supporting the \"Path.toFile()\" call")
@ParameterizedTest
@MethodSource("fileSystems")
void getSizeOnDisk_Directory(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String directoryName = easyRandom.nextObject(String.class);
int fileSize = 1024;
int fileCount = 5;
Files.createDirectories(fileSystem.getPath(directoryName));
for(int i = 0; i < fileCount; i++) {
String fileName = easyRandom.nextObject(String.class);
byte[] fileContent = new byte[fileSize];
easyRandom.nextBytes(fileContent);
Files.write(fileSystem.getPath(directoryName, fileName), fileContent);
}
long directorySize = target.getSizeOnDisk(fileSystem.getPath(directoryName));
assertThat(directorySize).isEqualTo(fileSize * fileCount);
}
@Disabled("Due to JimFS not supporting the \"Path.toFile()\" call")
@ParameterizedTest
@MethodSource("fileSystems")
void getSizeOnDisk_File(FileSystem fileSystem) throws IOException {
setup(fileSystem);
String directoryName = easyRandom.nextObject(String.class);
int fileSize = 1024;
String fileName = easyRandom.nextObject(String.class);
byte[] fileContent = new byte[fileSize];
easyRandom.nextBytes(fileContent);
Files.write(fileSystem.getPath(directoryName, fileName), fileContent);
long directorySize = target.getSizeOnDisk(fileSystem.getPath(directoryName));
assertThat(directorySize).isEqualTo(fileSize);
}
private static Stream<Arguments> fileSystems() {
return Stream.of(
arguments(named("Unix", unixFS)),
arguments(named("OSX", osxFS)),
arguments(named("Windows", winFS))
);
}
}
@@ -1,317 +0,0 @@
package de.grimsi.gameyfin.service;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.dto.GameOverviewDto;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.Library;
import de.grimsi.gameyfin.entities.Platform;
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.LibraryRepository;
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.in;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class GameServiceTest {
private final EasyRandom easyRandom = new EasyRandom();
@InjectMocks
private GameService target;
@Mock
private IgdbWrapper igdbWrapperMock;
@Mock
private GameMapper gameMapperMock;
@Mock
private DetectedGameRepository detectedGameRepositoryMock;
@Mock
private UnmappableFileRepository unmappableFileRepositoryMock;
@Mock
private LibraryRepository libraryRepositoryMock;
@Mock
private FilesystemService filesystemServiceMock;
@Test
void getAllDetectedGames() {
List<DetectedGame> input = easyRandom.objects(DetectedGame.class, 5).toList();
when(detectedGameRepositoryMock.findAll()).thenReturn(input);
List<DetectedGame> result = target.getAllDetectedGames();
assertThat(result).hasSameElementsAs(input);
verify(detectedGameRepositoryMock, times(1)).findAll();
}
@Test
void getDetectedGame() {
String slug = easyRandom.nextObject(String.class);
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
when(detectedGameRepositoryMock.findById(slug)).thenReturn(Optional.of(input));
DetectedGame result = target.getDetectedGame(slug);
assertThat(result).isEqualTo(input);
verify(detectedGameRepositoryMock, times(1)).findById(slug);
}
@Test
void getDetectedGame_NotFound() {
String slug = easyRandom.nextObject(String.class);
when(detectedGameRepositoryMock.findById(slug)).thenReturn(Optional.empty());
ResponseStatusException e = assertThrows(ResponseStatusException.class, () -> target.getDetectedGame(slug));
assertThat(e.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(detectedGameRepositoryMock, times(1)).findById(slug);
}
@Test
void getAllUnmappedFiles() {
List<UnmappableFile> input = easyRandom.objects(UnmappableFile.class, 5).toList();
when(unmappableFileRepositoryMock.findAll()).thenReturn(input);
List<UnmappableFile> result = target.getAllUnmappedFiles();
assertThat(result).hasSameElementsAs(input);
verify(unmappableFileRepositoryMock, times(1)).findAll();
}
@Test
void getAllMappings() {
Stream<DetectedGame> gameStream = easyRandom.objects(DetectedGame.class, 5);
List<DetectedGame> games = gameStream.toList();
Map<String, String> input = games.stream().collect(Collectors.toMap(DetectedGame::getPath, DetectedGame::getTitle));
when(detectedGameRepositoryMock.findAll()).thenReturn(games);
Map<String, String> result = target.getAllMappings();
assertThat(result).containsAllEntriesOf(input);
verify(detectedGameRepositoryMock, times(1)).findAll();
}
@Test
void getGameOverviews() {
Stream<DetectedGame> gameStream = easyRandom.objects(DetectedGame.class, 5);
List<DetectedGame> games = gameStream.toList();
List<GameOverviewDto> input = games.stream()
.map(d -> GameOverviewDto.builder()
.coverId(d.getCoverId())
.slug(d.getSlug())
.title(d.getTitle())
.build())
.toList();
when(detectedGameRepositoryMock.findAll()).thenReturn(games);
when(gameMapperMock.toGameOverviewDto(any())).thenCallRealMethod();
List<GameOverviewDto> result = target.getGameOverviews();
assertThat(result).hasSameElementsAs(input);
verify(detectedGameRepositoryMock, times(1)).findAll();
}
@Test
void deleteGame() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
when(detectedGameRepositoryMock.findById(input.getSlug())).thenReturn(Optional.of(input));
target.deleteGame(input.getSlug());
verify(detectedGameRepositoryMock, times(1)).findById(input.getSlug());
verify(unmappableFileRepositoryMock, times(1)).save(new UnmappableFile(input.getPath()));
verify(detectedGameRepositoryMock, times(1)).delete(input);
}
@Test
void deleteUnmappedFile() {
Long input = easyRandom.nextLong();
target.deleteUnmappedFile(input);
verify(unmappableFileRepositoryMock, times(1)).deleteById(input);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void confirmGame(boolean confirmMatch) {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
input.setConfirmedMatch(!confirmMatch);
when(detectedGameRepositoryMock.findById(input.getSlug())).thenReturn(Optional.of(input));
when(detectedGameRepositoryMock.save(any(DetectedGame.class))).thenAnswer(invocation -> invocation.getArgument(0, DetectedGame.class));
DetectedGame result = target.confirmGame(input.getSlug(), confirmMatch);
assertThat(result).usingRecursiveComparison()
.ignoringFields("confirmedMatch")
.isEqualTo(input);
assertThat(result.isConfirmedMatch()).isEqualTo(confirmMatch);
verify(detectedGameRepositoryMock, times(1)).save(result);
}
@Test
void mapPathToGame_UnmappableFile() {
DetectedGame mockedDetectedGame = easyRandom.nextObject(DetectedGame.class);
mockedDetectedGame.setConfirmedMatch(false);
UnmappableFile input = new UnmappableFile(mockedDetectedGame.getPath());
String slug = easyRandom.nextObject(String.class);
Library mockedLibrary = Library.builder()
.path(input.getPath())
.platforms(easyRandom.objects(Platform.class, 5).toList())
.build();
when(detectedGameRepositoryMock.existsBySlug(slug)).thenReturn(false);
when(detectedGameRepositoryMock.save(any(DetectedGame.class))).thenAnswer(invocation -> invocation.getArgument(0, DetectedGame.class));
when(unmappableFileRepositoryMock.findByPath(input.getPath())).thenReturn(Optional.of(input));
when(filesystemServiceMock.getPath(input.getPath())).thenReturn(Path.of("parent", input.getPath()));
when(igdbWrapperMock.getGameBySlug(slug)).thenReturn(Optional.of(Igdb.Game.newBuilder().build()));
when(libraryRepositoryMock.findByPath(any())).thenReturn(Optional.of(mockedLibrary));
when(gameMapperMock.toDetectedGame(any(Igdb.Game.class), any(Path.class), any(Library.class))).thenReturn(mockedDetectedGame);
DetectedGame result = target.mapPathToGame(input.getPath(), slug);
verify(detectedGameRepositoryMock, times(1)).existsBySlug(slug);
verify(detectedGameRepositoryMock, never()).findByPath(input.getPath());
verify(unmappableFileRepositoryMock, times(1)).findByPath(input.getPath());
assertThat(result).usingRecursiveComparison()
.ignoringFields("confirmedMatch")
.isEqualTo(mockedDetectedGame);
assertThat(result.isConfirmedMatch()).isTrue();
}
@Test
void mapPathToGame_DetectedGame() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
input.setConfirmedMatch(false);
String slug = easyRandom.nextObject(String.class);
Library mockedLibrary = Library.builder()
.path(input.getPath())
.platforms(easyRandom.objects(Platform.class, 5).toList())
.build();
when(detectedGameRepositoryMock.existsBySlug(slug)).thenReturn(false);
when(detectedGameRepositoryMock.save(any(DetectedGame.class))).thenAnswer(invocation -> invocation.getArgument(0, DetectedGame.class));
when(detectedGameRepositoryMock.findByPath(input.getPath())).thenReturn(Optional.of(input));
when(unmappableFileRepositoryMock.findByPath(input.getPath())).thenReturn(Optional.empty());
when(filesystemServiceMock.getPath(input.getPath())).thenReturn(Path.of("parent", input.getPath()));
when(igdbWrapperMock.getGameBySlug(slug)).thenReturn(Optional.of(Igdb.Game.newBuilder().build()));
when(libraryRepositoryMock.findByPath(any())).thenReturn(Optional.of(mockedLibrary));
when(gameMapperMock.toDetectedGame(any(Igdb.Game.class), any(Path.class), any(Library.class))).thenReturn(input);
DetectedGame result = target.mapPathToGame(input.getPath(), slug);
verify(detectedGameRepositoryMock, times(1)).existsBySlug(slug);
verify(detectedGameRepositoryMock, times(1)).findByPath(input.getPath());
verify(unmappableFileRepositoryMock, times(1)).findByPath(input.getPath());
assertThat(result).usingRecursiveComparison()
.ignoringFields("confirmedMatch")
.isEqualTo(input);
assertThat(result.isConfirmedMatch()).isTrue();
}
@Test
void mapPathToGame_SlugAlreadyInDatabase() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
when(detectedGameRepositoryMock.existsBySlug(input.getSlug())).thenReturn(true);
ResponseStatusException e = assertThrows(ResponseStatusException.class, () -> target.mapPathToGame(input.getPath(), input.getSlug()));
assertThat(e.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
verify(detectedGameRepositoryMock, times(1)).existsBySlug(input.getSlug());
verify(detectedGameRepositoryMock, never()).findByPath(input.getPath());
verify(unmappableFileRepositoryMock, never()).findByPath(input.getPath());
}
@Test
void mapPathToGame_PathNotInDatabase() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
when(detectedGameRepositoryMock.existsBySlug(input.getSlug())).thenReturn(false);
when(detectedGameRepositoryMock.findByPath(input.getPath())).thenReturn(Optional.empty());
when(unmappableFileRepositoryMock.findByPath(input.getPath())).thenReturn(Optional.empty());
ResponseStatusException e = assertThrows(ResponseStatusException.class, () -> target.mapPathToGame(input.getPath(), input.getSlug()));
assertThat(e.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(detectedGameRepositoryMock, times(1)).existsBySlug(input.getSlug());
verify(detectedGameRepositoryMock, times(1)).findByPath(input.getPath());
verify(unmappableFileRepositoryMock, times(1)).findByPath(input.getPath());
}
@Test
void refreshGame() {
DetectedGame input = easyRandom.nextObject(DetectedGame.class);
input.setConfirmedMatch(true);
Library mockedLibrary = Library.builder()
.path(input.getPath())
.platforms(easyRandom.objects(Platform.class, 5).toList())
.build();
when(detectedGameRepositoryMock.findById(input.getSlug())).thenReturn(Optional.of(input));
when(detectedGameRepositoryMock.save(any(DetectedGame.class))).thenAnswer(invocation -> invocation.getArgument(0, DetectedGame.class));
when(filesystemServiceMock.getPath(input.getPath())).thenReturn(Path.of("parent", input.getPath()));
when(igdbWrapperMock.getGameBySlug(input.getSlug())).thenReturn(Optional.of(Igdb.Game.newBuilder().build()));
when(libraryRepositoryMock.findByPath(any())).thenReturn(Optional.of(mockedLibrary));
when(gameMapperMock.toDetectedGame(any(Igdb.Game.class), any(Path.class), any(Library.class))).thenReturn(input);
DetectedGame result = target.refreshGame(input.getSlug());
assertThat(result).usingRecursiveComparison()
.ignoringFields("confirmedMatch")
.isEqualTo(input);
assertThat(result.isConfirmedMatch()).isTrue();
}
@Test
void refreshGame_NotFound() {
String slug = easyRandom.nextObject(String.class);
when(detectedGameRepositoryMock.findById(slug)).thenReturn(Optional.empty());
ResponseStatusException e = assertThrows(ResponseStatusException.class, () -> target.refreshGame(slug));
assertThat(e.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(detectedGameRepositoryMock, times(1)).findById(slug);
}
}
@@ -1,86 +0,0 @@
package de.grimsi.gameyfin.service;
import de.grimsi.gameyfin.entities.Company;
import de.grimsi.gameyfin.entities.DetectedGame;
import org.jeasy.random.EasyRandom;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ImageServiceTest {
private final EasyRandom easyRandom = new EasyRandom();
private FilesystemService filesystemServiceMock;
private GameService gameServiceMock;
private ImageService target;
@BeforeEach
void beforeEach() {
WebClient.Builder webClientBuilderMock = mock(WebClient.Builder.class);
gameServiceMock = mock(GameService.class);
filesystemServiceMock = mock(FilesystemService.class);
target = new ImageService(filesystemServiceMock, gameServiceMock, webClientBuilderMock);
ReflectionTestUtils.setField(target, "webclientBuilder", webClientBuilderMock);
when(webClientBuilderMock.baseUrl(any(String.class))).thenReturn(WebClient.builder());
target.init();
}
@Test
void downloadGameCoversFromIgdb() {
List<DetectedGame> detectedGames = easyRandom.objects(DetectedGame.class, 5).toList();
when(gameServiceMock.getAllDetectedGames()).thenReturn(detectedGames);
target.downloadGameCoversFromIgdb();
verify(gameServiceMock, times(1)).getAllDetectedGames();
verify(filesystemServiceMock, times(detectedGames.size())).saveFileToCache(any(), any());
}
@Test
void downloadGameScreenshotsFromIgdb() {
List<DetectedGame> detectedGames = easyRandom.objects(DetectedGame.class, 5).toList();
List<String> screenshotIds = detectedGames.stream().flatMap(d -> d.getScreenshotIds().stream()).toList();
when(gameServiceMock.getAllDetectedGames()).thenReturn(detectedGames);
target.downloadGameScreenshotsFromIgdb();
verify(gameServiceMock, times(1)).getAllDetectedGames();
verify(filesystemServiceMock, times(screenshotIds.size())).saveFileToCache(any(), any());
}
@Test
void downloadCompanyLogosFromIgdb() {
List<DetectedGame> detectedGames = easyRandom.objects(DetectedGame.class, 5).toList();
Set<String> companyLogoIds = detectedGames.stream().flatMap(d -> d.getCompanies().stream())
.map(Company::getLogoId).collect(Collectors.toUnmodifiableSet());
when(gameServiceMock.getAllDetectedGames()).thenReturn(detectedGames);
target.downloadCompanyLogosFromIgdb();
verify(gameServiceMock, times(1)).getAllDetectedGames();
verify(filesystemServiceMock, times(companyLogoIds.size())).saveFileToCache(any(), any());
}
}
@@ -1,179 +0,0 @@
package de.grimsi.gameyfin.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Named.named;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import de.grimsi.gameyfin.config.properties.GameyfinProperties;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import com.google.common.jimfs.Configuration;
import com.google.common.jimfs.Jimfs;
class FilenameUtilTest {
private static final GameyfinProperties gameyfinPropertiesMock = mock(GameyfinProperties.class);
private static final FileSystem unixFS = Jimfs.newFileSystem(Configuration.unix());
private static final FileSystem osxFS = Jimfs.newFileSystem(Configuration.osX());
private static final FileSystem winFS = Jimfs.newFileSystem(Configuration.windows());
private static final List<String> gameFileExtensions = List.of("extension_1", "extension_2", "extension_3");
private static final List<String> possibleGameFileSuffixes = Arrays.asList("windows, win, english, win32, win64, opengl, stable".split(", "));
@BeforeAll
static void init() {
when(gameyfinPropertiesMock.fileExtensions()).thenReturn(gameFileExtensions);
when(gameyfinPropertiesMock.fileSuffixes()).thenReturn(possibleGameFileSuffixes);
FilenameUtil filenameUtil = new FilenameUtil(gameyfinPropertiesMock);
}
@AfterAll
static void closeFileSystems() throws IOException {
unixFS.close();
osxFS.close();
winFS.close();
}
@ParameterizedTest
@MethodSource("fileSystems")
void getFilenameWithoutExtension_File(FileSystem fileSystem) throws IOException {
String filename = "example_file";
Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0)));
Files.createFile(p);
String result = FilenameUtil.getFilenameWithoutExtension(p);
assertThat(result).isEqualTo(filename);
Files.deleteIfExists(p);
}
@ParameterizedTest
@MethodSource("fileSystems")
void getFilenameWithoutExtension_Folder(FileSystem fileSystem) throws IOException {
String filename = "example_folder";
Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0)));
Files.createDirectory(p);
String result = FilenameUtil.getFilenameWithoutExtension(p);
assertThat(result).isEqualTo("%s.%s".formatted(filename, gameFileExtensions.get(0)));
Files.deleteIfExists(p);
}
@ParameterizedTest
@MethodSource("fileSystems")
void getFilenameWithExtension(FileSystem fileSystem) throws IOException {
String filename = "example_file";
Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0)));
Files.createFile(p);
String result = FilenameUtil.getFilenameWithExtension(p);
assertThat(result).isEqualTo("%s.%s".formatted(filename, gameFileExtensions.get(0)));
Files.deleteIfExists(p);
}
@ParameterizedTest
@MethodSource("fileSystems")
void hasGameArchiveExtension_gameArchive(FileSystem fileSystem) throws IOException {
String filename = "example_file";
Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0)));
Files.createFile(p);
assertThat(FilenameUtil.hasGameArchiveExtension(p)).isTrue();
Files.deleteIfExists(p);
}
@ParameterizedTest
@MethodSource("fileSystems")
void hasGameArchiveExtension_notGameArchive(FileSystem fileSystem) throws IOException {
String filename = "example_file";
Path p = fileSystem.getPath("%s.%s".formatted(filename, "some_other_extension"));
Files.createFile(p);
assertThat(FilenameUtil.hasGameArchiveExtension(p)).isFalse();
Files.deleteIfExists(p);
}
@ParameterizedTest
@MethodSource("exampleFilenames")
void removeFileSuffixes(String filename) {
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s".formatted(filename, "-win"))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s".formatted(filename, "-v1.05.4"))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s".formatted(filename, "-win32"))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s".formatted(filename, "-win-opengl(windows)"))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s".formatted(filename, "-windows-stable"))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s".formatted(filename, "[windows]"))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s".formatted(filename, "[stable]"))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s".formatted(filename, "(opengl)"))));
}
@ParameterizedTest
@MethodSource("exampleFilenames")
void removeFileSuffixesFileExtensions(String filename) {
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s.%s".formatted(filename, "-win", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s.%s".formatted(filename, "-v1.05.4", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s.%s".formatted(filename, "-win32", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s.%s".formatted(filename, "-win-opengl(windows)", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s.%s".formatted(filename, "-windows-stable", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s.%s".formatted(filename, "[windows]", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s.%s".formatted(filename, "[stable]", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath("%s.%s.%s".formatted(filename, "(opengl)", gameFileExtensions.get(0)))));
}
@ParameterizedTest
@MethodSource("exampleFilenames")
void removeFileSuffixesWithAddedSpaces(String filename) {
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath(" %s.%s .%s".formatted(filename, "-win", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath(" %s.%s .%s".formatted(filename, "-v1.05.4", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath(" %s.%s .%s".formatted(filename, "-win32", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath(" %s.%s .%s".formatted(filename, "-win-opengl(windows)", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath(" %s.%s .%s".formatted(filename, "-windows-stable", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath(" %s.%s .%s".formatted(filename, "[windows]", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath(" %s.%s .%s".formatted(filename, "[stable]", gameFileExtensions.get(0)))));
assertEquals(filename, FilenameUtil.getFilenameWithoutAdditions(unixFS.getPath(" %s.%s .%s".formatted(filename, "(opengl)", gameFileExtensions.get(0)))));
}
private static Stream<Arguments> fileSystems() {
return Stream.of(
arguments(named("Unix", unixFS)),
arguments(named("OSX", osxFS)),
arguments(named("Windows", winFS))
);
}
private static Stream<Arguments> exampleFilenames() {
return Stream.of(
arguments(named("example_file", "example_file")),
arguments(named("example-file", "example-file")),
arguments(named("example file", "example file"))
);
}
}
@@ -1,20 +0,0 @@
package de.grimsi.gameyfin.util;
import com.google.protobuf.Timestamp;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class ProtobufUtilTest {
@Test
void toInstant() {
Timestamp t = Timestamp.newBuilder().setSeconds(1).build();
Instant i = ProtobufUtil.toInstant(t);
assertThat(i.getEpochSecond()).isEqualTo(1);
}
}
@@ -1,9 +0,0 @@
gameyfin:
igdb:
api:
client-id: igdb_client_id
client-secret: igdb_client_secret
spring:
datasource:
url: jdbc:h2:mem:${spring.datasource.db-name}
@@ -1 +0,0 @@
mock-maker-inline
+63
View File
@@ -0,0 +1,63 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "3.2.2"
id("io.spring.dependency-management") version "1.1.4"
id("com.vaadin") version "24.3.3"
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
kotlin("plugin.jpa") version "1.9.22"
}
group = "de.grimsi"
version = "0.0.1-SNAPSHOT"
java {
sourceCompatibility = JavaVersion.VERSION_21
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
}
extra["vaadinVersion"] = "24.3.3"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.vaadin:vaadin-spring-boot-starter")
implementation("org.jetbrains.kotlin:kotlin-reflect")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}
dependencyManagement {
imports {
mavenBom("com.vaadin:vaadin-bom:${property("vaadinVersion")}")
}
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "21"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
-30
View File
@@ -1,30 +0,0 @@
# Uncomment if you want to change the port from 8080
# server.port=8081
# Gameyfin admin interface username and password
gameyfin.user=<your username here>
gameyfin.password=<your password here>
# Gameyfin source folders
gameyfin.sources=<comma-seperated list of root folders of your game libraries>
# Uncomment if you want to specify the Gameyfin database path (default is <game library root>/.gameyfin/db)
# gameyfin.db=<custom path to db file>
# Uncomment if you want to specify the Gameyfin cache path (default is <game library root>/.gameyfin/cache)
# gameyfin.cache=<custom path to cache folder>
# Your twitch client-id and client-secret
gameyfin.igdb.api.client-id=<your twitch client-id here>
gameyfin.igdb.api.client-secret=<your twitch client-secret here>
# Preferred platforms
# PC (Microsoft Windows) (id=6), Dreamcast (id=23), Game Boy (id=33), Game Boy Advance (id=24), Game Boy Color (id=22),
# Linux (id=3), Mac (id=14), New Nintendo 3DS (id=137), Nintendo 3DS (id=37), Nintendo 64 (id=4), Nintendo DS (id=20),
# Nintendo DSi (id=159), Nintendo Entertainment System (id=18), Nintendo GameCube (id=21), Nintendo PlayStation (id=131),
# Nintendo Switch (id=130), PlayStation (id=7), PlayStation 2 (id=8), PlayStation 3 (id=9), PlayStation 4 (id=48),
# PlayStation Portable (id=38), PlayStation Vita (id=46), PlayStation VR (id=165), Sega Game Gear (id=35),
# Sega Master System/Mark III (id=64), Sega Mega Drive/Genesis (id=29), Sega Saturn (id=32), SteamVR (id=163),
# Super Famicom (id=58), Super Nintendo Entertainment System (id=19), Virtual Console (Nintendo) (id=47), Wii (id=5),
# Wii U (id=41), Xbox (id=11), Xbox 360 (id=12)
gameyfin.igdb.config.preferred-platforms=6,23,33,24,22,3,14,137,37,4,20,159,18,21,130,7,8,9,48,38,46,165,35,64,29,32,58,19,47,5,41,11,12
@@ -1,17 +0,0 @@
version: "3"
services:
gameyfin:
image: grimsi/gameyfin:latest
container_name: gameyfin
environment:
- GAMEYFIN_USER=<your username here>
- GAMEYFIN_PASSWORD=<your password here>
- GAMEYFIN_IGDB_API_CLIENT_ID=<your twitch client-id here>
- GAMEYFIN_IGDB_API_CLIENT_SECRET=<your twitch client-secret here>
- GAMEYFIN_SOURCES=/opt/gameyfin-library/library-1,/opt/gameyfin-library/library-2,/opt/gameyfin-library/library-3
volumes:
- <Path on your host to the 1st source folder>:/opt/gameyfin-library/library-1
- <Path on your host to the 2nd source folder>:/opt/gameyfin-library/library-2
- <Path on your host to the 3rd source folder>:/opt/gameyfin-library/library-3
ports:
- "8080:8080"

Some files were not shown because too many files have changed in this diff Show More