mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 00:30:02 +00:00
Release 1.3.2 (#74)
* Fixes #71 * [GH-61] Fix manual mapping leading to duplicates in DB * [GH-73] Fix Gameyfin only detecting PC games * Improve game title matching (#77) * Implement some filename suffix logic Removes some common file suffixes from files downloaded from for example itch.io. Also removes trailing/leading whitespace/-/_/./() and version numbers starting with a "v" like "v1.2.3". * Add edge cases for game titles (#76) * Fix SONAR code smells Co-authored-by: tr7zw <tr7zw@live.de> Co-authored-by: Pfuenzle <dark.leon64@gmail.com> * Validate some combinations of filename with added suffixes (#79) Also fixes a bug of not removing trailing empty []. * Improve test coverage (#70) * Implemented missing testcases for IgdbWrapper Refactored getPlatformBySlug to return Optional<> * Fixed SONAR findings * Implemented integration tests for the DB * Started implementing tests for controller * Finished GamesControllerTest * Added ImageControllerTest * Implemented LibraryControllerTest * Add LibraryManagementControllerTest * Updated some dependencies * Add DownloadServiceTest * Introduced "gameyfin.data" property to specify a folder for both cache and DB. De-facto removed "gameyfin.db" and "gameyfin.cache" properties Refactored file-system code to be cleaner and easier to test * Refactored filesystem code Implemented FilesystemServiceTest * Fix SONAR code smells * Implemented GameServiceTest * Implemented ImageServiceTest * Fix website scroll position when clicking on game covers in the library view (#94) Fixes #81 * Expansion panels are now not collapsing when last active filter is de-selected (#95) Fixes #86 --------- Co-authored-by: tr7zw <tr7zw@live.de> Co-authored-by: Pfuenzle <dark.leon64@gmail.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* <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 = 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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Platform> platforms;
|
||||
|
||||
@ManyToOne
|
||||
@ManyToOne(cascade = CascadeType.MERGE)
|
||||
@JoinColumn(name = "library")
|
||||
@ToString.Exclude
|
||||
private Library library;
|
||||
|
||||
// Technical properties
|
||||
|
||||
@@ -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<Igdb.Game> games = gameResult.getGamesList();
|
||||
@@ -189,6 +201,37 @@ public class IgdbWrapper {
|
||||
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();
|
||||
@@ -212,35 +255,4 @@ public class IgdbWrapper {
|
||||
.transformDeferred(RateLimiterOperator.of(webClientConfig.getIgdbRateLimiter()))
|
||||
.block();
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DetectedGame, Stri
|
||||
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) {
|
||||
@@ -31,7 +29,4 @@ public interface DetectedGameRepository extends JpaRepository<DetectedGame, Stri
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@ import de.grimsi.gameyfin.entities.DetectedGame;
|
||||
import de.grimsi.gameyfin.service.DownloadService;
|
||||
import de.grimsi.gameyfin.service.GameService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public class ImageController {
|
||||
private final DownloadService downloadService;
|
||||
|
||||
@GetMapping(value = "/{imageId}", produces = MediaType.IMAGE_PNG_VALUE)
|
||||
public ResponseEntity<Resource> getCoverImageForGame(@PathVariable String imageId) {
|
||||
public ResponseEntity<Resource> getImage(@PathVariable String imageId) {
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())
|
||||
.body(downloadService.sendImageToClient(imageId));
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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)));
|
||||
return detectedGameRepository.findById(slug)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' not found in library.".formatted(slug)));
|
||||
}
|
||||
|
||||
public List<UnmappableFile> 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<UnmappableFile> optionalUnmappableFile = unmappableFileRepository.findByPath(path);
|
||||
Optional<DetectedGame> optionalDetectedGame = detectedGameRepository.findByPath(path);
|
||||
|
||||
if (optionalUnmappableFile.isPresent()) {
|
||||
return mapUnmappableFile(optionalUnmappableFile.get(), slug);
|
||||
}
|
||||
|
||||
Optional<DetectedGame> 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);
|
||||
|
||||
@@ -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<String, String> 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<String, String> 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<String, List<String>> companyToLogoIdMap = detectedGameRepository.findAll().stream()
|
||||
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));
|
||||
|
||||
|
||||
@@ -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<Path> getGameFiles() {
|
||||
return getGameFiles(null);
|
||||
@@ -96,10 +97,6 @@ public class LibraryService {
|
||||
return gamefiles;
|
||||
}
|
||||
|
||||
private static Predicate<Path> 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<DetectedGame> newDetectedGames = gameFiles.parallelStream()
|
||||
.map(p -> {
|
||||
Optional<Igdb.Game> optionalGame = igdbWrapper.searchForGameByTitle(getFilenameWithoutExtension(p), platformsFilter);
|
||||
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());
|
||||
@@ -238,10 +235,14 @@ public class LibraryService {
|
||||
.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 -> platformRepository.findBySlug(slug).
|
||||
orElseGet(() -> igdbWrapper.getPlatformBySlug(slug)))
|
||||
.filter(Objects::nonNull)
|
||||
.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);
|
||||
@@ -249,4 +250,8 @@ public class LibraryService {
|
||||
|
||||
return library;
|
||||
}
|
||||
|
||||
private Predicate<Path> allPathsOrSpecific(String path) {
|
||||
return p -> isBlank(path) || p.equals(filesystemService.getPath(path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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+)");
|
||||
private static final Pattern trailingNoisePattern = Pattern.compile("( |\\(\\)|\\[\\]|[-_.])+$");
|
||||
private static final Pattern headingNoisePattern = Pattern.compile("^( |\\(\\)|\\[\\]|[-_.])+");
|
||||
|
||||
@Value("${gameyfin.file-extensions}")
|
||||
public void setPossibleGameFileExtensions(List<String> possibleGameFileExtensions) {
|
||||
FilenameUtil.possibleGameFileExtensions = possibleGameFileExtensions;
|
||||
}
|
||||
|
||||
@Value("${gameyfin.file-suffixes}")
|
||||
public void setPossibleGameFileSuffixes(List<String> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,4 +24,7 @@ management:
|
||||
health:
|
||||
enabled: true
|
||||
endpoints:
|
||||
enabled-by-default: false
|
||||
enabled-by-default: false
|
||||
|
||||
gameyfin:
|
||||
internal-folder: .gameyfin
|
||||
|
||||
Reference in New Issue
Block a user