com.squareup.okhttp3
okhttp
diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/FileSystemProviderConfig.java b/backend/src/main/java/de/grimsi/gameyfin/config/FileSystemProviderConfig.java
new file mode 100644
index 0000000..61c4f59
--- /dev/null
+++ b/backend/src/main/java/de/grimsi/gameyfin/config/FileSystemProviderConfig.java
@@ -0,0 +1,23 @@
+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();
+ }
+}
diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/FilesystemConfig.java b/backend/src/main/java/de/grimsi/gameyfin/config/FilesystemConfig.java
index 71f9ba9..cdcba6d 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/config/FilesystemConfig.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/config/FilesystemConfig.java
@@ -1,109 +1,64 @@
package de.grimsi.gameyfin.config;
-import org.springframework.beans.factory.annotation.Autowired;
+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.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.context.event.EventListener;
-import org.springframework.core.env.*;
-import org.springframework.util.StringUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.core.env.ConfigurableEnvironment;
-import javax.sql.DataSource;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.Arrays;
import java.util.Locale;
-import java.util.Properties;
-import java.util.stream.StreamSupport;
+/**
+ * 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 {
- private static final String INTERNAL_FOLDER_NAME = ".gameyfin";
+ @Value("${gameyfin.folders.data}")
+ private String dataFolderPath;
- @Value("#{'${gameyfin.sources}'.split(',')[0]}")
- private String firstLibraryPath;
+ private final FilesystemService filesystemService;
- @Value("${gameyfin.db}")
- private String dbPath;
-
- @Value("${gameyfin.cache}")
- private String cachePath;
-
- @Autowired
- Environment env;
+ /**
+ * 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
+ *
+ * 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 = Paths.get("%s/%s".formatted(firstLibraryPath, INTERNAL_FOLDER_NAME));
+ Path internalFolder = filesystemService.getPath(dataFolderPath);
if (!Files.exists(internalFolder) || !Files.isDirectory(internalFolder)) return;
Files.setAttribute(internalFolder, "dos:hidden", Boolean.TRUE, LinkOption.NOFOLLOW_LINKS);
}
- @Autowired
- public void setConfigurableEnvironment(ConfigurableEnvironment env) {
- Properties props = new Properties();
-
- if (!StringUtils.hasText(dbPath)) {
- props.setProperty("gameyfin.db", "%s/%s/db".formatted(firstLibraryPath, INTERNAL_FOLDER_NAME));
- }
-
- if (!StringUtils.hasText(cachePath)) {
- props.setProperty("gameyfin.cache", "%s/%s/cache".formatted(firstLibraryPath, 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(ps -> ps instanceof EnumerablePropertySource)
- .map(ps -> ((EnumerablePropertySource>) ps).getPropertyNames())
- .flatMap(Arrays::stream)
- .forEach(propName -> props.setProperty(propName, env.getProperty(propName)));
-
- return props;
- }
-
private boolean isRunningOnWindows() {
return System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("windows");
}
-
}
diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/GameyfinFolderConfig.java b/backend/src/main/java/de/grimsi/gameyfin/config/GameyfinFolderConfig.java
new file mode 100644
index 0000000..9ec685d
--- /dev/null
+++ b/backend/src/main/java/de/grimsi/gameyfin/config/GameyfinFolderConfig.java
@@ -0,0 +1,99 @@
+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 {
+
+ private static final String INTERNAL_FOLDER_NAME = ".gameyfin";
+
+ /**
+ * 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;
+ }
+}
diff --git a/backend/src/main/java/de/grimsi/gameyfin/dto/LibraryScanRequestDto.java b/backend/src/main/java/de/grimsi/gameyfin/dto/LibraryScanRequestDto.java
index 66da3ec..f2a8cbb 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/dto/LibraryScanRequestDto.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/dto/LibraryScanRequestDto.java
@@ -1,8 +1,12 @@
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;
diff --git a/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java b/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java
index 1db42df..cd82c26 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java
@@ -27,7 +27,7 @@ public class DetectedGame {
private String title;
@Lob
- @Column(columnDefinition="CLOB")
+ @Column(columnDefinition = "CLOB")
private String summary;
private Instant releaseDate;
@@ -81,8 +81,9 @@ public class DetectedGame {
@ToString.Exclude
private List platforms;
- @ManyToOne
+ @ManyToOne(cascade = CascadeType.MERGE)
@JoinColumn(name = "library")
+ @ToString.Exclude
private Library library;
// Technical properties
diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java
index 55dccfb..85cfb7e 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java
@@ -4,7 +4,7 @@ import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.config.WebClientConfig;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.entities.Platform;
-import de.grimsi.gameyfin.igdb.IgdbApiQueryBuilder.Condition;
+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;
@@ -159,11 +159,23 @@ public class IgdbWrapper {
if (hasBrackets.find()) {
String searchTermWithoutBrackets = searchTerm.split(brackets.pattern())[0].trim();
- log.warn("Trying again with search term '{}'", searchTermWithoutBrackets);
+ 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);
}
-
- return Optional.empty();
+ log.warn("Using slug as last resort for " + searchTerm + ": " + searchTerm.replace(" ", "-").toLowerCase());
+ return getGameBySlug(searchTerm.replace(" ", "-").toLowerCase());
}
List games = gameResult.getGamesList();
@@ -189,6 +201,37 @@ public class IgdbWrapper {
return Optional.of(games.get(0));
}
+ public List 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 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();
@@ -212,35 +255,4 @@ public class IgdbWrapper {
.transformDeferred(RateLimiterOperator.of(webClientConfig.getIgdbRateLimiter()))
.block();
}
-
- public List 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 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 null;
-
- return platformResult.getPlatformsList().stream().map(PlatformMapper::toPlatform).findFirst().orElse(null);
- }
}
diff --git a/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java b/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java
index 09a9eaf..40ba374 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java
@@ -1,15 +1,12 @@
package de.grimsi.gameyfin.repositories;
import de.grimsi.gameyfin.entities.DetectedGame;
-import de.grimsi.gameyfin.entities.UnmappableFile;
import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.Query;
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;
@@ -24,6 +21,7 @@ public interface DetectedGameRepository extends JpaRepository findByPathStartsWithAndLibraryIsNull(String path);
List getAllByPathNotIn(Collection paths);
+
List getAllByPathNotInAndPathStartsWith(Collection paths, String libraryPath);
default List getAllByPathNotInAndPathStartsWith(List paths, String libraryPath) {
@@ -31,7 +29,4 @@ public interface DetectedGameRepository extends JpaRepository getCoverImageForGame(@PathVariable String imageId) {
+ public ResponseEntity getImage(@PathVariable String imageId) {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())
.body(downloadService.sendImageToClient(imageId));
diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java b/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java
index c29fe20..c01f902 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java
@@ -29,17 +29,17 @@ public class DownloadService {
private final FilesystemService filesystemService;
public String getDownloadFileName(DetectedGame g) {
- Path path = Path.of(g.getPath());
+ Path path = filesystemService.getPath(g.getPath());
- if (!path.toFile().isDirectory()) return getFilenameWithExtension(path);
+ if (!Files.isDirectory(path)) return getFilenameWithExtension(path);
return getFilenameWithExtension(path) + ".zip";
}
public long getDownloadFileSize(DetectedGame game) {
- Path path = Path.of(game.getPath());
+ Path path = filesystemService.getPath(game.getPath());
try {
- if (!path.toFile().isDirectory()) {
+ if (!Files.isDirectory(path)) {
long fileSize = filesystemService.getSizeOnDisk(path);
log.info("Calculated file size for {} ({} MB).", path, Math.divideExact(fileSize, 1000000L));
return fileSize;
@@ -65,7 +65,7 @@ public class DownloadService {
stopWatch.start();
- Path path = Path.of(game.getPath());
+ Path path = filesystemService.getPath(game.getPath());
try {
if (path.toFile().isDirectory()) {
diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/FilesystemService.java b/backend/src/main/java/de/grimsi/gameyfin/service/FilesystemService.java
index 25e551a..5c8632a 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/service/FilesystemService.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/service/FilesystemService.java
@@ -1,5 +1,6 @@
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;
@@ -11,33 +12,55 @@ import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
-import javax.annotation.PostConstruct;
import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.StandardOpenOption;
+import java.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;
- @PostConstruct
- public void createCacheFolder() throws IOException {
- Files.createDirectories(Path.of(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, String filename) {
- DataBufferUtils.write(dataBuffer, Path.of(cacheFolderPath).resolve(filename), StandardOpenOption.CREATE)
+ DataBufferUtils.write(dataBuffer, getPath(cacheFolderPath).resolve(filename), StandardOpenOption.CREATE)
.share().block();
}
public ByteArrayResource getFileFromCache(String filename) {
try {
- return new ByteArrayResource(Files.readAllBytes(Paths.get("%s/%s".formatted(cacheFolderPath, filename))));
+ 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));
}
@@ -79,6 +102,6 @@ public class FilesystemService {
}
private Path getPathFromFilename(String filename) {
- return Path.of(cacheFolderPath, filename);
+ return getPath(cacheFolderPath, filename);
}
}
diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java
index 8b6ccce..62427ce 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java
@@ -32,15 +32,16 @@ public class GameService {
private final GameMapper gameMapper;
private final DetectedGameRepository detectedGameRepository;
private final UnmappableFileRepository unmappableFileRepository;
-
private final LibraryRepository libraryRepository;
+ private final FilesystemService filesystemService;
public List 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)));
+ return detectedGameRepository.findById(slug)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' not found in library.".formatted(slug)));
}
public List getAllUnmappedFiles() {
@@ -62,7 +63,7 @@ public class GameService {
// so it doesn't get re-indexed on the next library scan
unmappableFileRepository.save(new UnmappableFile(gameToBeDeleted.getPath()));
- detectedGameRepository.deleteById(slug);
+ detectedGameRepository.delete(gameToBeDeleted);
}
public void deleteUnmappedFile(Long id) {
@@ -81,12 +82,13 @@ public class GameService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Game with slug '%s' already exists in database.".formatted(slug));
Optional optionalUnmappableFile = unmappableFileRepository.findByPath(path);
- Optional optionalDetectedGame = detectedGameRepository.findByPath(path);
if (optionalUnmappableFile.isPresent()) {
return mapUnmappableFile(optionalUnmappableFile.get(), slug);
}
+ Optional optionalDetectedGame = detectedGameRepository.findByPath(path);
+
if (optionalDetectedGame.isPresent()) {
return mapDetectedGame(optionalDetectedGame.get(), slug);
}
@@ -106,30 +108,23 @@ public class GameService {
}
private DetectedGame mapUnmappableFile(UnmappableFile unmappableFile, 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)));
-
- Path path = Path.of(unmappableFile.getPath());
- // parent file should equal to the library
- File libraryPath = path.toFile().getParentFile();
- Library library = libraryRepository.findByPath(libraryPath.toString()).orElse(null);
- DetectedGame game = gameMapper.toDetectedGame(igdbGame, path, library);
- game.setConfirmedMatch(true);
-
- game = detectedGameRepository.save(game);
-
+ DetectedGame game = mapPathToGame(filesystemService.getPath(unmappableFile.getPath()), slug);
unmappableFileRepository.delete(unmappableFile);
-
return game;
}
private DetectedGame mapDetectedGame(DetectedGame existingGame, 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)));
+ DetectedGame game = mapPathToGame(filesystemService.getPath(existingGame.getPath()), slug);
+ detectedGameRepository.delete(existingGame);
+ return game;
+ }
- Path path = Path.of(existingGame.getPath());
- // parent file should equal to the library
- File libraryPath = path.toFile().getParentFile();
+ 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);
diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/ImageService.java b/backend/src/main/java/de/grimsi/gameyfin/service/ImageService.java
index b492125..f92eaf2 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/service/ImageService.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/service/ImageService.java
@@ -3,10 +3,8 @@ package de.grimsi.gameyfin.service;
import de.grimsi.gameyfin.entities.Company;
import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.igdb.IgdbApiProperties;
-import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
@@ -29,11 +27,8 @@ import java.util.stream.Collectors;
@Service
public class ImageService {
- @Value("${gameyfin.cache}")
- private String cacheFolderPath;
-
private final FilesystemService filesystemService;
- private final DetectedGameRepository detectedGameRepository;
+ private final GameService gameService;
private final WebClient.Builder webclientBuilder;
private WebClient igdbImageClient;
@@ -49,7 +44,7 @@ public class ImageService {
stopWatch.start();
MultiValueMap gameToImageIds = new LinkedMultiValueMap<>(
- detectedGameRepository.findAll().stream()
+ gameService.getAllDetectedGames().stream()
.collect(Collectors.toMap(DetectedGame::getSlug, g -> Collections.singletonList(g.getCoverId()))));
int downloadCount = saveImagesIntoCache(gameToImageIds, IgdbApiProperties.COVER_IMAGE_SIZE, "cover", "game");
@@ -67,7 +62,7 @@ public class ImageService {
stopWatch.start();
MultiValueMap gamesToImageIds = new LinkedMultiValueMap<>(
- detectedGameRepository.findAll().stream()
+ gameService.getAllDetectedGames().stream()
.collect(Collectors.toMap(DetectedGame::getSlug, DetectedGame::getScreenshotIds)));
int downloadCount = saveImagesIntoCache(gamesToImageIds, IgdbApiProperties.SCREENSHOT_IMAGE_SIZE, "screenshot", "game");
@@ -84,7 +79,7 @@ public class ImageService {
log.info("Starting company logo download...");
stopWatch.start();
- Map> companyToLogoIdMap = detectedGameRepository.findAll().stream()
+ Map> companyToLogoIdMap = gameService.getAllDetectedGames().stream()
.flatMap(g -> g.getCompanies().stream())
.collect(Collectors.toMap(Company::getSlug, c -> Collections.singletonList(c.getLogoId()), (c1, c2) -> c1));
diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java b/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java
index cdb3e4b..4f9f3c6 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java
@@ -31,7 +31,7 @@ import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
-import static de.grimsi.gameyfin.util.FilenameUtil.getFilenameWithoutExtension;
+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;
@@ -51,6 +51,7 @@ public class LibraryService {
private final UnmappableFileRepository unmappableFileRepository;
private final LibraryRepository libraryRepository;
private final PlatformRepository platformRepository;
+ private final FilesystemService filesystemService;
public List getGameFiles() {
return getGameFiles(null);
@@ -96,10 +97,6 @@ public class LibraryService {
return gamefiles;
}
- private static Predicate allPathsOrSpecific(String path) {
- return p -> isBlank(path) || p.equals(Path.of(path));
- }
-
public LibraryScanResult scanGameLibrary(Library library) {
StopWatch stopWatch = new StopWatch();
@@ -141,7 +138,7 @@ public class LibraryService {
// If a game is not found on IGDB, blacklist the path, so we won't query the API later for the same path
List newDetectedGames = gameFiles.parallelStream()
.map(p -> {
- Optional optionalGame = igdbWrapper.searchForGameByTitle(getFilenameWithoutExtension(p), platformsFilter);
+ Optional 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());
@@ -238,10 +235,14 @@ public class LibraryService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Could not find library for path %s".formatted(path)));
Set platformSlugs = Arrays.stream(slugs.split(",")).collect(toSet());
+
List platforms = platformSlugs.stream()
- .map(slug -> platformRepository.findBySlug(slug).
- orElseGet(() -> igdbWrapper.getPlatformBySlug(slug)))
- .filter(Objects::nonNull)
+ .map(slug -> {
+ Optional p = platformRepository.findBySlug(slug);
+ return p.isPresent() ? p : igdbWrapper.getPlatformBySlug(slug);
+ })
+ .filter(Optional::isPresent)
+ .map(Optional::get)
.collect(toList());
library.setPlatforms(platforms);
@@ -249,4 +250,8 @@ public class LibraryService {
return library;
}
+
+ private Predicate allPathsOrSpecific(String path) {
+ return p -> isBlank(path) || p.equals(filesystemService.getPath(path));
+ }
}
diff --git a/backend/src/main/java/de/grimsi/gameyfin/util/FilenameUtil.java b/backend/src/main/java/de/grimsi/gameyfin/util/FilenameUtil.java
index e8ec6a1..308e544 100644
--- a/backend/src/main/java/de/grimsi/gameyfin/util/FilenameUtil.java
+++ b/backend/src/main/java/de/grimsi/gameyfin/util/FilenameUtil.java
@@ -7,16 +7,30 @@ 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 possibleGameFileExtensions;
+ private static List possibleGameFileSuffixes;
+ // matches v1.1.1 v1.1 v1 version numbers
+ private static final Pattern versionPattern = Pattern.compile("v(\\d+\\.)?(\\d+\\.)?(\\d+)");
+ private static final Pattern trailingNoisePattern = Pattern.compile("( |\\(\\)|\\[\\]|[-_.])+$");
+ private static final Pattern headingNoisePattern = Pattern.compile("^( |\\(\\)|\\[\\]|[-_.])+");
@Value("${gameyfin.file-extensions}")
public void setPossibleGameFileExtensions(List possibleGameFileExtensions) {
FilenameUtil.possibleGameFileExtensions = possibleGameFileExtensions;
}
+
+ @Value("${gameyfin.file-suffixes}")
+ public void setPossibleGameFileSuffixes(List possibleGameFileSuffixes) {
+ // Sort in descending length, so for example "windows" gets checked before "win"
+ possibleGameFileSuffixes.sort((s1,s2) -> Integer.compare(s2.length(), s1.length()));
+ FilenameUtil.possibleGameFileSuffixes = possibleGameFileSuffixes;
+ }
public static String getFilenameWithoutExtension(Path p) {
@@ -34,5 +48,26 @@ public class FilenameUtil {
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;
+ }
}
diff --git a/backend/src/main/resources/config/gameyfin.yml b/backend/src/main/resources/config/gameyfin.yml
index e82b5ac..00bb205 100644
--- a/backend/src/main/resources/config/gameyfin.yml
+++ b/backend/src/main/resources/config/gameyfin.yml
@@ -1,7 +1,8 @@
gameyfin:
- db: ""
- cache: ""
+ folders:
+ data: ""
file-extensions: iso, zip, rar, 7z, exe
+ file-suffixes: windows, win, english, win32, win64, opengl, stable
igdb:
api:
endpoints:
@@ -12,4 +13,4 @@ gameyfin:
max-concurrent-requests: 2
max-requests-per-second: 4
config:
- preferred-platforms: 6
+ 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
diff --git a/backend/src/main/resources/config/secure.yml b/backend/src/main/resources/config/secure.yml
index cf80710..d400a0a 100644
--- a/backend/src/main/resources/config/secure.yml
+++ b/backend/src/main/resources/config/secure.yml
@@ -24,4 +24,7 @@ management:
health:
enabled: true
endpoints:
- enabled-by-default: false
\ No newline at end of file
+ enabled-by-default: false
+
+gameyfin:
+ internal-folder: .gameyfin
diff --git a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java
index 7d20b82..2c64f52 100644
--- a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java
+++ b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java
@@ -6,6 +6,7 @@ import com.google.protobuf.Timestamp;
import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.config.WebClientConfig;
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;
@@ -32,6 +33,7 @@ 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;
@@ -298,6 +300,67 @@ class IgdbWrapperTest {
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 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 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());
diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java
index 129c388..ea5bcaf 100644
--- a/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java
+++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java
@@ -13,7 +13,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class CompanyMapperTest extends RandomMapperTest {
@Test
- @Disabled
void toCompany() {
Igdb.InvolvedCompany input = generateRandomInput();
@@ -25,7 +24,6 @@ class CompanyMapperTest extends RandomMapperTest
}
@Test
- @Disabled
void toCompanies() {
List input = List.of(generateRandomInput(), generateRandomInput(), generateRandomInput());
@@ -37,18 +35,4 @@ class CompanyMapperTest extends RandomMapperTest
assertThat(output.get(i).getLogoId()).isEqualTo(input.get(i).getCompany().getLogo().getImageId());
}
}
-
- private static Igdb.InvolvedCompany generateRandomInvolvedCompany() {
- Igdb.Company c = Igdb.Company.newBuilder()
- .setSlug(UUID.randomUUID().toString())
- .setName(UUID.randomUUID().toString())
- .setLogo(Igdb.CompanyLogo.newBuilder()
- .setImageId(UUID.randomUUID().toString())
- .build())
- .build();
-
- return Igdb.InvolvedCompany.newBuilder()
- .setCompany(c)
- .build();
- }
}
diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java
index e60cab8..22a79e5 100644
--- a/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java
+++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java
@@ -12,7 +12,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class GenreMapperTest extends RandomMapperTest {
@Test
- @Disabled
void toGenre() {
Igdb.Genre input = generateRandomInput();
@@ -23,7 +22,6 @@ class GenreMapperTest extends RandomMapperTest {
}
@Test
- @Disabled
void toGenres() {
List input = List.of(generateRandomInput(), generateRandomInput(), generateRandomInput());
diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java
index 172b853..fba494b 100644
--- a/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java
+++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java
@@ -10,7 +10,7 @@ import java.util.List;
public class RandomMapperTest {
private static final int DEFAULT_COUNT = 5;
- private final EasyRandom easyRandom = new EasyRandom();
+ protected final EasyRandom easyRandom = new EasyRandom();
private final Class inputClass;
private final Class