Release 1.2.0

This commit is contained in:
Simon
2022-08-23 13:23:17 +02:00
committed by GitHub
35 changed files with 633 additions and 299 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<artifactId>gameyfin</artifactId> <artifactId>gameyfin</artifactId>
<groupId>de.grimsi</groupId> <groupId>de.grimsi</groupId>
<version>1.1.3</version> <version>1.2.0</version>
</parent> </parent>
<artifactId>gameyfin-backend</artifactId> <artifactId>gameyfin-backend</artifactId>
@@ -9,6 +9,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
import org.springframework.core.env.*; import org.springframework.core.env.*;
import org.springframework.util.PropertyPlaceholderHelper; import org.springframework.util.PropertyPlaceholderHelper;
import org.springframework.util.StringUtils;
import javax.sql.DataSource; import javax.sql.DataSource;
import java.util.Arrays; import java.util.Arrays;
@@ -21,14 +22,27 @@ public class FilesystemConfig {
@Value("#{'${gameyfin.sources}'.split(',')[0]}") @Value("#{'${gameyfin.sources}'.split(',')[0]}")
private String firstLibraryPath; private String firstLibraryPath;
@Value("${gameyfin.db}")
private String dbPath;
@Value("${gameyfin.cache}")
private String cachePath;
@Autowired @Autowired
Environment env; Environment env;
@Autowired @Autowired
public void setConfigurableEnvironment(ConfigurableEnvironment env) { public void setConfigurableEnvironment(ConfigurableEnvironment env) {
Properties props = new Properties(); Properties props = new Properties();
if(!StringUtils.hasText(dbPath)) {
props.setProperty("gameyfin.db", "%s/.gameyfin/db".formatted(firstLibraryPath)); props.setProperty("gameyfin.db", "%s/.gameyfin/db".formatted(firstLibraryPath));
}
if(!StringUtils.hasText(cachePath)) {
props.setProperty("gameyfin.cache", "%s/.gameyfin/cache".formatted(firstLibraryPath)); props.setProperty("gameyfin.cache", "%s/.gameyfin/cache".formatted(firstLibraryPath));
}
env.getPropertySources().addFirst(new PropertiesPropertySource("gameyfinFilesystemProperties", props)); env.getPropertySources().addFirst(new PropertiesPropertySource("gameyfinFilesystemProperties", props));
} }
@@ -0,0 +1,10 @@
package de.grimsi.gameyfin.dto;
import lombok.Data;
@Data
public class ImageDownloadResultDto {
private int coverDownloads;
private int screenshotDownloads;
private int companyLogoDownloads;
}
@@ -0,0 +1,13 @@
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;
}
@@ -0,0 +1,21 @@
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;
}
@@ -7,6 +7,7 @@ import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto;
import de.grimsi.gameyfin.mapper.GameMapper; import de.grimsi.gameyfin.mapper.GameMapper;
import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator; import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator;
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -24,6 +25,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@Slf4j @Slf4j
@RequiredArgsConstructor
@Service @Service
public class IgdbWrapper { public class IgdbWrapper {
@Value("${gameyfin.igdb.api.client-id}") @Value("${gameyfin.igdb.api.client-id}")
@@ -35,11 +37,9 @@ public class IgdbWrapper {
@Value("${gameyfin.igdb.config.preferred-platforms:6}") @Value("${gameyfin.igdb.config.preferred-platforms:6}")
private String preferredPlatforms; private String preferredPlatforms;
@Autowired private final WebClient.Builder webclientBuilder;
private WebClient.Builder webclientBuilder; private final WebClientConfig webClientConfig;
private final GameMapper gameMapper;
@Autowired
private WebClientConfig webClientConfig;
private WebClient twitchApiClient; private WebClient twitchApiClient;
@@ -106,7 +106,7 @@ public class IgdbWrapper {
if(gameResult == null) return Collections.emptyList(); if(gameResult == null) return Collections.emptyList();
return gameResult.getGamesList().stream().map(GameMapper::toAutocompleteSuggestionDto).toList(); return gameResult.getGamesList().stream().map(gameMapper::toAutocompleteSuggestionDto).toList();
} }
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm) { public Optional<Igdb.Game> searchForGameByTitle(String searchTerm) {
@@ -4,6 +4,7 @@ import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto; import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.dto.GameOverviewDto; import de.grimsi.gameyfin.dto.GameOverviewDto;
import de.grimsi.gameyfin.entities.DetectedGame; import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.service.FilesystemService;
import de.grimsi.gameyfin.service.LibraryService; import de.grimsi.gameyfin.service.LibraryService;
import de.grimsi.gameyfin.util.ProtobufUtil; import de.grimsi.gameyfin.util.ProtobufUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -24,9 +25,13 @@ import java.util.List;
import java.util.stream.Stream; import java.util.stream.Stream;
@Slf4j @Slf4j
@RequiredArgsConstructor
@Component
public class GameMapper { public class GameMapper {
public static DetectedGame toDetectedGame(Igdb.Game g, Path path) { private final FilesystemService filesystemService;
public DetectedGame toDetectedGame(Igdb.Game g, Path path) {
List<Igdb.MultiplayerMode> multiplayerModes = g.getMultiplayerModesList(); List<Igdb.MultiplayerMode> multiplayerModes = g.getMultiplayerModesList();
List<String> screenshotIds = g.getScreenshotsList().stream().map(Igdb.Screenshot::getImageId).toList(); List<String> screenshotIds = g.getScreenshotsList().stream().map(Igdb.Screenshot::getImageId).toList();
List<String> videoIds = g.getVideosList().stream().map(Igdb.GameVideo::getVideoId).toList(); List<String> videoIds = g.getVideosList().stream().map(Igdb.GameVideo::getVideoId).toList();
@@ -58,7 +63,7 @@ public class GameMapper {
.build(); .build();
} }
public static GameOverviewDto toGameOverviewDto(DetectedGame game) { public GameOverviewDto toGameOverviewDto(DetectedGame game) {
return GameOverviewDto.builder() return GameOverviewDto.builder()
.slug(game.getSlug()) .slug(game.getSlug())
.title(game.getTitle()) .title(game.getTitle())
@@ -66,7 +71,7 @@ public class GameMapper {
.build(); .build();
} }
public static AutocompleteSuggestionDto toAutocompleteSuggestionDto(Igdb.Game game) { public AutocompleteSuggestionDto toAutocompleteSuggestionDto(Igdb.Game game) {
return AutocompleteSuggestionDto.builder() return AutocompleteSuggestionDto.builder()
.slug(game.getSlug()) .slug(game.getSlug())
.title(game.getName()) .title(game.getName())
@@ -74,7 +79,7 @@ public class GameMapper {
.build(); .build();
} }
private static String getCoverId(Igdb.Game g) { private String getCoverId(Igdb.Game g) {
String coverId = g.getCover().getImageId(); String coverId = g.getCover().getImageId();
if(StringUtils.hasText(coverId)) return coverId; if(StringUtils.hasText(coverId)) return coverId;
@@ -82,23 +87,23 @@ public class GameMapper {
return "nocover"; return "nocover";
} }
private static boolean hasOfflineCoop(List<Igdb.MultiplayerMode> modes) { private boolean hasOfflineCoop(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getOfflinecoop); return modes.stream().anyMatch(Igdb.MultiplayerMode::getOfflinecoop);
} }
private static boolean hasLanSupport(List<Igdb.MultiplayerMode> modes) { private boolean hasLanSupport(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getLancoop); return modes.stream().anyMatch(Igdb.MultiplayerMode::getLancoop);
} }
private static boolean hasOnlineCoop(List<Igdb.MultiplayerMode> modes) { private boolean hasOnlineCoop(List<Igdb.MultiplayerMode> modes) {
return modes.stream().anyMatch(Igdb.MultiplayerMode::getOnlinecoop); return modes.stream().anyMatch(Igdb.MultiplayerMode::getOnlinecoop);
} }
private static int getMaxPlayers(List<Igdb.MultiplayerMode> modes) { private int getMaxPlayers(List<Igdb.MultiplayerMode> modes) {
return modes.stream().mapToInt(Igdb.MultiplayerMode::getOnlinecoopmax).max().orElse(0); return modes.stream().mapToInt(Igdb.MultiplayerMode::getOnlinecoopmax).max().orElse(0);
} }
private static long calculateDiskSize(Igdb.Game g, Path path) { private long calculateDiskSize(Igdb.Game g, Path path) {
StopWatch stopWatch = new StopWatch(); StopWatch stopWatch = new StopWatch();
log.info("Calculating disk size for game '{}'...", g.getName()); log.info("Calculating disk size for game '{}'...", g.getName());
@@ -106,17 +111,12 @@ public class GameMapper {
long fileSize; long fileSize;
if(Files.isDirectory(path)) {
// Some benchmarks I did have shown that trying to parallelize this process makes it slower instead of faster
fileSize = FileUtils.sizeOfDirectory(path.toFile());
} else {
try { try {
fileSize = Files.size(path); fileSize = filesystemService.getSizeOnDisk(path);
} catch(IOException e) { } catch(IOException e) {
log.error("Error while calculating size of file '{}'.", path); log.error("Error while calculating disk size for game '{}'", g.getName());
fileSize = -1L; fileSize = -1L;
} }
}
stopWatch.stop(); stopWatch.stop();
@@ -54,7 +54,7 @@ public class GamesController {
return ResponseEntity return ResponseEntity
.ok() .ok()
.header("Content-Disposition", "attachment; filename=\"%s\"".formatted(downloadFileName)) .header("Content-Disposition", "attachment; filename=\"%s\"".formatted(downloadFileName))
.body(out -> downloadService.downloadGameFiles(game, out)); .body(out -> downloadService.sendGamefilesToClient(game, out));
} }
} }
@@ -27,6 +27,6 @@ public class ImageController {
public ResponseEntity<Resource> getCoverImageForGame(@PathVariable String imageId) { public ResponseEntity<Resource> getCoverImageForGame(@PathVariable String imageId) {
return ResponseEntity.ok() return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()) .cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())
.body(downloadService.downloadImage(imageId)); .body(downloadService.sendImageToClient(imageId));
} }
} }
@@ -1,11 +1,16 @@
package de.grimsi.gameyfin.rest; package de.grimsi.gameyfin.rest;
import de.grimsi.gameyfin.dto.ImageDownloadResultDto;
import de.grimsi.gameyfin.dto.LibraryScanResult;
import de.grimsi.gameyfin.dto.LibraryScanResultDto;
import de.grimsi.gameyfin.service.DownloadService; import de.grimsi.gameyfin.service.DownloadService;
import de.grimsi.gameyfin.service.ImageService;
import de.grimsi.gameyfin.service.LibraryService; import de.grimsi.gameyfin.service.LibraryService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.StopWatch;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -25,22 +30,48 @@ import java.util.List;
public class LibraryController { public class LibraryController {
private final LibraryService libraryService; private final LibraryService libraryService;
private final DownloadService downloadService; private final ImageService imageService;
@GetMapping(value = "/scan", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/scan", produces = MediaType.APPLICATION_JSON_VALUE)
public void scanLibrary(@RequestParam(value = "download_images", defaultValue = "true") boolean downloadImages) { public LibraryScanResultDto scanLibrary(@RequestParam(value = "download_images", defaultValue = "true") boolean downloadImages) {
libraryService.scanGameLibrary(); StopWatch stopWatch = new StopWatch();
stopWatch.start();
if(downloadImages) downloadImages(); LibraryScanResultDto lscDto = new LibraryScanResultDto();
LibraryScanResult lsc = libraryService.scanGameLibrary();
lscDto.setNewGames(lsc.getNewGames());
lscDto.setDeletedGames(lsc.getDeletedGames());
lscDto.setNewUnmappableFiles(lsc.getNewUnmappableFiles());
lscDto.setTotalGames(lsc.getTotalGames());
if(downloadImages) {
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") @GetMapping(value = "/download-images")
public void downloadImages() { public ImageDownloadResultDto downloadImages() {
downloadService.downloadGameCoversFromIgdb(); ImageDownloadResultDto idrDto = new ImageDownloadResultDto();
downloadService.downloadGameScreenshotsFromIgdb();
downloadService.downloadCompanyLogosFromIgdb(); idrDto.setCoverDownloads(imageService.downloadGameCoversFromIgdb());
idrDto.setScreenshotDownloads(imageService.downloadGameScreenshotsFromIgdb());
idrDto.setCompanyLogoDownloads(imageService.downloadCompanyLogosFromIgdb());
log.info("Downloading images completed."); log.info("Downloading images completed.");
return idrDto;
} }
@GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE)
@@ -8,6 +8,7 @@ import de.grimsi.gameyfin.entities.UnmappableFile;
import de.grimsi.gameyfin.repositories.DetectedGameRepository; import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import de.grimsi.gameyfin.service.DownloadService; import de.grimsi.gameyfin.service.DownloadService;
import de.grimsi.gameyfin.service.GameService; import de.grimsi.gameyfin.service.GameService;
import de.grimsi.gameyfin.service.ImageService;
import de.grimsi.gameyfin.service.LibraryService; import de.grimsi.gameyfin.service.LibraryService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -23,8 +24,7 @@ import java.util.List;
public class LibraryManagementController { public class LibraryManagementController {
private final GameService gameService; private final GameService gameService;
private final DownloadService downloadService; private final ImageService imageService;
private final LibraryService libraryService; private final LibraryService libraryService;
@DeleteMapping(value = "/delete-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE) @DeleteMapping(value = "/delete-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE)
@@ -46,9 +46,9 @@ public class LibraryManagementController {
public DetectedGame manuallyMapPathToSlug(@RequestBody PathToSlugDto pathToSlugDto) { public DetectedGame manuallyMapPathToSlug(@RequestBody PathToSlugDto pathToSlugDto) {
DetectedGame game = gameService.mapPathToGame(pathToSlugDto.getPath(), pathToSlugDto.getSlug()); DetectedGame game = gameService.mapPathToGame(pathToSlugDto.getPath(), pathToSlugDto.getSlug());
downloadService.downloadGameCoversFromIgdb(); imageService.downloadGameCoversFromIgdb();
downloadService.downloadGameScreenshotsFromIgdb(); imageService.downloadGameScreenshotsFromIgdb();
downloadService.downloadCompanyLogosFromIgdb(); imageService.downloadCompanyLogosFromIgdb();
return game; return game;
} }
@@ -1,41 +1,19 @@
package de.grimsi.gameyfin.service; package de.grimsi.gameyfin.service;
import de.grimsi.gameyfin.entities.Company;
import de.grimsi.gameyfin.entities.DetectedGame; import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.exceptions.DownloadAbortedException; import de.grimsi.gameyfin.exceptions.DownloadAbortedException;
import de.grimsi.gameyfin.igdb.IgdbApiProperties;
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.ClientAbortException; import org.apache.catalina.connector.ClientAbortException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StopWatch; import org.springframework.util.StopWatch;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import javax.annotation.PostConstruct;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.file.*; import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.zip.Deflater; import java.util.zip.Deflater;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@@ -47,19 +25,7 @@ import static de.grimsi.gameyfin.util.FilenameUtil.getFilenameWithExtension;
@RequiredArgsConstructor @RequiredArgsConstructor
public class DownloadService { public class DownloadService {
@Value("${gameyfin.cache}") private final FilesystemService filesystemService;
private String cacheFolderPath;
private final DetectedGameRepository detectedGameRepository;
@Autowired
private WebClient.Builder webclientBuilder;
private WebClient igdbImageClient;
@PostConstruct
public void init() {
igdbImageClient = webclientBuilder.baseUrl(IgdbApiProperties.IMAGES_BASE_URL).build();
}
public String getDownloadFileName(DetectedGame g) { public String getDownloadFileName(DetectedGame g) {
Path path = Path.of(g.getPath()); Path path = Path.of(g.getPath());
@@ -68,17 +34,12 @@ public class DownloadService {
return getFilenameWithExtension(path) + ".zip"; return getFilenameWithExtension(path) + ".zip";
} }
public Resource downloadImage(String imageId) { public Resource sendImageToClient(String imageId) {
String filename = "%s.png".formatted(imageId); String filename = "%s.png".formatted(imageId);
return filesystemService.getFileFromCache(filename);
try {
return new ByteArrayResource(Files.readAllBytes(Paths.get("%s/%s".formatted(cacheFolderPath, filename))));
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Could not find image file %s".formatted(filename));
}
} }
public void downloadGameFiles(DetectedGame game, OutputStream outputStream) { public void sendGamefilesToClient(DetectedGame game, OutputStream outputStream) {
StopWatch stopWatch = new StopWatch(); StopWatch stopWatch = new StopWatch();
@@ -88,89 +49,35 @@ public class DownloadService {
Path path = Path.of(game.getPath()); Path path = Path.of(game.getPath());
if (path.toFile().isDirectory()) {
try { try {
downloadFilesAsZip(path, outputStream); if (path.toFile().isDirectory()) {
sendGamefilesAsZipToClient(path, outputStream);
} else {
sendGamefileToClient(path, outputStream);
}
} catch (DownloadAbortedException e) { } catch (DownloadAbortedException e) {
stopWatch.stop(); stopWatch.stop();
log.info("Download of game {} was aborted by client after {} seconds", game.getTitle(), (int) stopWatch.getTotalTimeSeconds()); log.info("Download of game {} was aborted by client after {} seconds", game.getTitle(), (int) stopWatch.getTotalTimeSeconds());
return; return;
} }
} else {
downloadFile(path, outputStream);
}
stopWatch.stop(); stopWatch.stop();
log.info("Downloaded game files of {} in {} seconds.", game.getTitle(), (int) stopWatch.getTotalTimeSeconds()); log.info("Downloaded game files of {} in {} seconds.", game.getTitle(), (int) stopWatch.getTotalTimeSeconds());
} }
private void sendGamefileToClient(Path path, OutputStream outputStream) {
public void downloadGameCoversFromIgdb() {
StopWatch stopWatch = new StopWatch();
log.info("Starting game cover download...");
stopWatch.start();
MultiValueMap<String, String> gameToImageIds = new LinkedMultiValueMap<>(
detectedGameRepository.findAll().stream()
.collect(Collectors.toMap(DetectedGame::getSlug, g -> Collections.singletonList(g.getCoverId()))));
int downloadCount = downloadImagesIntoCache(gameToImageIds, IgdbApiProperties.COVER_IMAGE_SIZE, "cover", "game");
stopWatch.stop();
log.info("Downloaded {} covers in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
}
public void downloadGameScreenshotsFromIgdb() {
StopWatch stopWatch = new StopWatch();
log.info("Starting game screenshot download...");
stopWatch.start();
MultiValueMap<String, String> gamesToImageIds = new LinkedMultiValueMap<>(
detectedGameRepository.findAll().stream()
.collect(Collectors.toMap(DetectedGame::getSlug, DetectedGame::getScreenshotIds)));
int downloadCount = downloadImagesIntoCache(gamesToImageIds, IgdbApiProperties.SCREENSHOT_IMAGE_SIZE, "screenshot", "game");
stopWatch.stop();
log.info("Downloaded {} screenshots in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
}
public void downloadCompanyLogosFromIgdb() {
StopWatch stopWatch = new StopWatch();
log.info("Starting company logo download...");
stopWatch.start();
Map<String, List<String>> companyToLogoIdMap = detectedGameRepository.findAll().stream()
.flatMap(g -> g.getCompanies().stream())
.collect(Collectors.toMap(Company::getSlug, c -> Collections.singletonList(c.getLogoId()), (c1, c2) -> c1));
MultiValueMap<String, String> companiesToLogoIds = new LinkedMultiValueMap<>(companyToLogoIdMap);
int downloadCount = downloadImagesIntoCache(companiesToLogoIds, IgdbApiProperties.LOGO_IMAGE_SIZE, "logo", "company");
stopWatch.stop();
log.info("Downloaded {} company logos in {} seconds.", downloadCount, (int) stopWatch.getTotalTimeSeconds());
}
private void downloadFile(Path path, OutputStream outputStream) {
try { try {
Files.copy(path, outputStream); Files.copy(path, outputStream);
} catch (ClientAbortException e) {
// Aborted downloads will be handled gracefully
throw new DownloadAbortedException();
} catch (IOException e) { } catch (IOException e) {
log.error("Error while downloading file:", e); log.error("Error while downloading file:", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not load file '%s'.".formatted(path));
} }
} }
private void downloadFilesAsZip(Path path, OutputStream outputStream) { private void sendGamefilesAsZipToClient(Path path, OutputStream outputStream) {
ZipOutputStream zos = new ZipOutputStream(outputStream) {{ ZipOutputStream zos = new ZipOutputStream(outputStream) {{
def.setLevel(Deflater.NO_COMPRESSION); def.setLevel(Deflater.NO_COMPRESSION);
}}; }};
@@ -195,65 +102,4 @@ public class DownloadService {
log.error("Error while zipping files:", e); log.error("Error while zipping files:", e);
} }
} }
private int downloadImagesIntoCache(MultiValueMap<String, String> entityToImageIds, String imageSize, String imageType, String entityType) {
AtomicInteger downloadCounter = new AtomicInteger();
Path cacheFolder = Path.of(cacheFolderPath);
try {
Files.createDirectories(cacheFolder);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not create cache folder.");
}
entityToImageIds.entrySet().parallelStream().forEach(entry ->
entry.getValue().forEach(imageId -> {
if (!StringUtils.hasText(imageId)) return;
String imgFileName = "%s.png".formatted(imageId);
String imgUrl = "t_%s/%s".formatted(imageSize, imgFileName);
if (Files.exists(Path.of(cacheFolderPath, imgFileName))) {
Path existingImageFile = Path.of(cacheFolderPath, imgFileName);
try {
if(Files.size(existingImageFile) == 0L) {
log.info("File '{}' is corrupt, retrying download...", imgFileName);
Files.delete(existingImageFile);
} else {
log.debug("{} for {} '{}' already downloaded ({}), skipping.",
imageType.substring(0, 1).toUpperCase() + imageType.substring(1).toLowerCase(),
entityType,
entry.getKey(),
imgFileName);
return;
}
} catch (IOException e) {
log.error("Error while checking file '{}'.", existingImageFile);
}
}
Flux<DataBuffer> dataBuffer = igdbImageClient.get()
.uri(imgUrl)
.retrieve()
.bodyToFlux(DataBuffer.class);
try {
DataBufferUtils.write(dataBuffer, cacheFolder.resolve(imgFileName), StandardOpenOption.CREATE)
.share().block();
} catch (WebClientResponseException e) {
if (e.getStatusCode().is4xxClientError()) {
log.error("Could not download {} for {} '{}' from {}: {}", imageType, entityType, entry.getKey(), IgdbApiProperties.IMAGES_BASE_URL + imgUrl, e.getStatusCode());
}
}
downloadCounter.getAndIncrement();
log.info("Downloaded {} for {} '{}' from {}", imageType, entityType, entry.getKey(), IgdbApiProperties.IMAGES_BASE_URL + imgUrl);
}));
return downloadCounter.get();
}
} }
@@ -0,0 +1,84 @@
package de.grimsi.gameyfin.service;
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 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;
@Slf4j
@Service
public class FilesystemService {
@Value("${gameyfin.cache}")
private String cacheFolderPath;
@PostConstruct
public void createCacheFolder() throws IOException {
Files.createDirectories(Path.of(cacheFolderPath));
}
public void saveFileToCache(Flux<DataBuffer> dataBuffer, String filename) {
DataBufferUtils.write(dataBuffer, Path.of(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))));
} 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 Path.of(cacheFolderPath, filename);
}
}
@@ -8,7 +8,7 @@ import de.grimsi.gameyfin.igdb.IgdbWrapper;
import de.grimsi.gameyfin.mapper.GameMapper; import de.grimsi.gameyfin.mapper.GameMapper;
import de.grimsi.gameyfin.repositories.DetectedGameRepository; import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import de.grimsi.gameyfin.repositories.UnmappableFileRepository; import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
import org.springframework.beans.factory.annotation.Autowired; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -19,17 +19,14 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service @Service
public class GameService { public class GameService {
@Autowired private final IgdbWrapper igdbWrapper;
private IgdbWrapper igdbWrapper; private final GameMapper gameMapper;
private final DetectedGameRepository detectedGameRepository;
@Autowired private final UnmappableFileRepository unmappableFileRepository;
private DetectedGameRepository detectedGameRepository;
@Autowired
private UnmappableFileRepository unmappableFileRepository;
public List<DetectedGame> getAllDetectedGames() { public List<DetectedGame> getAllDetectedGames() {
return detectedGameRepository.findAll(); return detectedGameRepository.findAll();
@@ -48,13 +45,13 @@ public class GameService {
} }
public List<GameOverviewDto> getGameOverviews() { public List<GameOverviewDto> getGameOverviews() {
return detectedGameRepository.findAll().stream().map(GameMapper::toGameOverviewDto).toList(); return detectedGameRepository.findAll().stream().map(gameMapper::toGameOverviewDto).toList();
} }
public void deleteGame(String slug) { public void deleteGame(String slug) {
DetectedGame gameToBeDeleted = getDetectedGame(slug); DetectedGame gameToBeDeleted = getDetectedGame(slug);
// Add the path of the game to be deleted to the unmappable files // 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 // so it doesn't get re-indexed on the next library scan
unmappableFileRepository.save(new UnmappableFile(gameToBeDeleted.getPath())); unmappableFileRepository.save(new UnmappableFile(gameToBeDeleted.getPath()));
@@ -94,7 +91,7 @@ public class GameService {
Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug) Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug))); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug)));
DetectedGame game = GameMapper.toDetectedGame(igdbGame, Path.of(unmappableFile.getPath())); DetectedGame game = gameMapper.toDetectedGame(igdbGame, Path.of(unmappableFile.getPath()));
game = detectedGameRepository.save(game); game = detectedGameRepository.save(game);
unmappableFileRepository.delete(unmappableFile); unmappableFileRepository.delete(unmappableFile);
@@ -106,7 +103,7 @@ public class GameService {
Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug) Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug))); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug)));
DetectedGame game = GameMapper.toDetectedGame(igdbGame, Path.of(existingGame.getPath())); DetectedGame game = gameMapper.toDetectedGame(igdbGame, Path.of(existingGame.getPath()));
game = detectedGameRepository.save(game); game = detectedGameRepository.save(game);
detectedGameRepository.deleteById(existingGame.getSlug()); detectedGameRepository.deleteById(existingGame.getSlug());
@@ -0,0 +1,145 @@
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;
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 javax.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 {
@Value("${gameyfin.cache}")
private String cacheFolderPath;
private final FilesystemService filesystemService;
private final DetectedGameRepository detectedGameRepository;
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<>(
detectedGameRepository.findAll().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<>(
detectedGameRepository.findAll().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 = detectedGameRepository.findAll().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.entrySet().parallelStream().forEach(entry ->
entry.getValue().forEach(imageId -> {
if (!StringUtils.hasText(imageId)) return;
String imgFileName = "%s.png".formatted(imageId);
String imgUrl = "t_%s/%s".formatted(imageSize, imgFileName);
if (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,
entry.getKey(),
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, entry.getKey(), IgdbApiProperties.IMAGES_BASE_URL + imgUrl, e.getStatusCode());
}
}
downloadCounter.getAndIncrement();
log.info("Downloaded {} for {} '{}' from {}", imageType, entityType, entry.getKey(), IgdbApiProperties.IMAGES_BASE_URL + imgUrl);
}));
return downloadCounter.get();
}
}
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.service;
import com.igdb.proto.Igdb; import com.igdb.proto.Igdb;
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto; import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
import de.grimsi.gameyfin.dto.LibraryScanResult;
import de.grimsi.gameyfin.entities.DetectedGame; import de.grimsi.gameyfin.entities.DetectedGame;
import de.grimsi.gameyfin.entities.UnmappableFile; import de.grimsi.gameyfin.entities.UnmappableFile;
import de.grimsi.gameyfin.igdb.IgdbWrapper; import de.grimsi.gameyfin.igdb.IgdbWrapper;
@@ -32,7 +33,9 @@ public class LibraryService {
@Value("${gameyfin.sources}") @Value("${gameyfin.sources}")
private List<String> libraryFolders; private List<String> libraryFolders;
private final IgdbWrapper igdbWrapper; private final IgdbWrapper igdbWrapper;
private final GameMapper gameMapper;
private final DetectedGameRepository detectedGameRepository; private final DetectedGameRepository detectedGameRepository;
private final UnmappableFileRepository unmappableFileRepository; private final UnmappableFileRepository unmappableFileRepository;
@@ -55,7 +58,7 @@ public class LibraryService {
return gamefiles; return gamefiles;
} }
public void scanGameLibrary() { public LibraryScanResult scanGameLibrary() {
StopWatch stopWatch = new StopWatch(); StopWatch stopWatch = new StopWatch();
log.info("Starting scan..."); log.info("Starting scan...");
@@ -105,7 +108,7 @@ public class LibraryService {
.filter(Optional::isPresent) .filter(Optional::isPresent)
.map(Optional::get) .map(Optional::get)
.peek(e -> log.info("Mapped file '{}' to game '{}' (slug: {})", e.getKey(), e.getValue().getName(), e.getValue().getSlug())) .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())) .map(e -> gameMapper.toDetectedGame(e.getValue(), e.getKey()))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<DetectedGame> duplicateGames = getDuplicates(newDetectedGames); List<DetectedGame> duplicateGames = getDuplicates(newDetectedGames);
@@ -118,6 +121,13 @@ public class LibraryService {
log.info("Scan finished in {} seconds: Found {} new games, deleted {} games, could not map {} files/folders, {} games total.", 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()); (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) { public List<AutocompleteSuggestionDto> getAutocompleteSuggestions(String searchTerm, int limit) {
@@ -4,6 +4,7 @@ import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
@@ -21,7 +22,7 @@ public class FilenameUtil {
// If the path points to a folder, return the folder name // 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" // Folders like "Counter Strike 1.6" would otherwise be returned as "Counter Strike 1"
if(p.toFile().isDirectory()) return FilenameUtils.getName(p.toString()); if(Files.isDirectory(p)) return FilenameUtils.getName(p.toString());
return FilenameUtils.getBaseName(p.toString()); return FilenameUtils.getBaseName(p.toString());
} }
@@ -1,3 +1,5 @@
# General # General
logging.level: logging.level:
root: info root: info
# Hides an error log on the first aborted download
org.apache.catalina.core.ContainerBase: off
@@ -1,4 +1,6 @@
gameyfin: gameyfin:
db: ""
cache: ""
file-extensions: iso, zip, rar, 7z, exe file-extensions: iso, zip, rar, 7z, exe
igdb: igdb:
api: api:
+2 -2
View File
@@ -37,8 +37,8 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kb", "maximumWarning": "1mb",
"maximumError": "1mb" "maximumError": "2mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
+1 -1
View File
@@ -5,7 +5,7 @@
<parent> <parent>
<artifactId>gameyfin</artifactId> <artifactId>gameyfin</artifactId>
<groupId>de.grimsi</groupId> <groupId>de.grimsi</groupId>
<version>1.1.3</version> <version>1.2.0</version>
</parent> </parent>
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
+6 -3
View File
@@ -1,8 +1,11 @@
import {Observable} from "rxjs"; import {Observable} from "rxjs";
import {HttpResponse} from "@angular/common/http"; import {LibraryScanResultDto} from "../models/dtos/LibraryScanResultDto";
import {ImageDownloadResultDto} from "../models/dtos/ImageDownloadResultDto";
export interface LibraryApi { export interface LibraryApi {
scanLibrary(): Observable<HttpResponse<Response>>; scanLibrary(): Observable<LibraryScanResultDto>;
downloadImages(): Observable<HttpResponse<Response>>;
downloadImages(): Observable<ImageDownloadResultDto>;
getFiles(): Observable<string[]>; getFiles(): Observable<string[]>;
} }
+5 -1
View File
@@ -51,6 +51,8 @@ import { NgModelChangeDebouncedDirective } from './directives/ng-model-change-de
import { FooterComponent } from './components/footer/footer.component'; import { FooterComponent } from './components/footer/footer.component';
import {MatExpansionModule} from "@angular/material/expansion"; import {MatExpansionModule} from "@angular/material/expansion";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatProgressBarModule} from "@angular/material/progress-bar";
import { ProgressBarColorDirective } from './directives/progress-bar-color.directive';
@NgModule({ @NgModule({
declarations: [ declarations: [
@@ -69,6 +71,7 @@ import {MatSelectModule} from "@angular/material/select";
MappedGamesTableComponent, MappedGamesTableComponent,
UnmappedFilesTableComponent, UnmappedFilesTableComponent,
NgModelChangeDebouncedDirective, NgModelChangeDebouncedDirective,
ProgressBarColorDirective,
FooterComponent FooterComponent
], ],
imports: [ imports: [
@@ -108,7 +111,8 @@ import {MatSelectModule} from "@angular/material/select";
MatListModule, MatListModule,
MatAutocompleteModule, MatAutocompleteModule,
MatExpansionModule, MatExpansionModule,
MatSelectModule MatSelectModule,
MatProgressBarModule,
], ],
providers: [ providers: [
{ {
@@ -1,5 +1,5 @@
<a routerLink="/game/{{game.slug}}"> <a routerLink="/game/{{game.slug}}">
<div class="game-cover-container shine" [style.background-image]="'url(v1/images/' + game.coverId + ')'" fxLayoutAlign="center end"> <div class="game-cover-container shine enlarge" [style.background-image]="'url(v1/images/' + game.coverId + ')'" fxLayoutAlign="center end">
<h2 *ngIf="game.coverId === 'nocover'" class="no-link-stlying">{{game.title}}</h2> <h2 *ngIf="game.coverId === 'nocover'" class="no-link-stlying">{{game.title}}</h2>
</div> </div>
</a> </a>
@@ -6,12 +6,15 @@
width: 264px; width: 264px;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center bottom; background-position: center bottom;
@include mat.elevation-transition();
@include mat.elevation(4); @include mat.elevation(4);
}
&:hover { .enlarge {
@include mat.elevation(24); transition: transform 280ms ease-out;
&:hover,
&:focus {
transform: scale(1.05);
} }
} }
@@ -1,18 +1,33 @@
<div fxLayout="row" fxLayoutAlign="center" style="margin-top: 16px;" *ngIf="this.game !== null && this.game !== undefined"> <div fxLayout="row" fxLayoutAlign="center" style="margin-top: 16px;"
<div fxLayout="column" fxFlex="0 1 70" fxLayoutGap="16px" fxFlex.lt-xl="95"> *ngIf="this.game !== null && this.game !== undefined">
<div fxLayout="column" fxFlex="0 1 75" fxLayoutGap="16px" fxFlex.lt-lg="95">
<div fxLayout="row" fxLayout.lt-lg="column" fxLayoutGap="16px"> <div fxLayout="row" fxLayout.lt-lg="column" fxLayoutGap="16px">
<div fxLayoutAlign.lt-lg="center"> <mat-card fxFlex="60" fxLayout="row" fxLayout.lt-lg="column" fxLayoutGap="16px">
<img style="max-width: 264px;" src="v1/images/{{game.coverId}}" alt="Game cover"> <div fxLayoutAlign="start" fxLayoutAlign.lt-lg="center">
<img src="v1/images/{{game.coverId}}" style="max-height: 352px" alt="Game cover">
</div> </div>
<div fxFlex="40" fxLayout="column" id="game-details"> <div fxLayout="column" fxLayoutGap="8px" fxLayoutAlign="space-between">
<h1>{{game.title}}</h1> <div fxLayoutGap="8px">
<h3>Rating: {{game.totalRating}}/100</h3> <h1 style="display: inline-block">{{game.title}}</h1>
<h3>Release: {{game.releaseDate | date: 'longDate'}}</h3> <h3 style="display: inline-block; font-style: italic">{{game.releaseDate | date: 'yyyy'}}</h3>
<p id="game-summary">{{game.summary}}</p>
<h2>Description</h2>
<p>{{game.summary}}</p>
</div> </div>
<div *ngIf="companiesWithLogo.length > 0">
<h2>Developed by</h2>
<div fxLayout="row wrap" fxLayoutGap="8px grid">
<div *ngFor="let company of companiesWithLogo">
<img style="height: 52px;" src="v1/images/{{company.logoId}}" alt="{{company.name}}" [matTooltip]="company.name">
</div>
</div>
</div>
</div>
</mat-card>
<div fxFlex><!-- Spacer --></div> <div fxFlex><!-- Spacer --></div>
<div fxLayout="column" fxFlex="40" fxLayoutGap="16px"> <div fxLayout="column" fxFlex="40" fxLayoutGap="16px">
@@ -28,34 +43,55 @@
<div *ngIf="game.genres !== undefined && game.genres.length > 0"> <div *ngIf="game.genres !== undefined && game.genres.length > 0">
<h2>Genres</h2> <h2>Genres</h2>
<mat-chip-list> <mat-chip-list>
<mat-chip *ngFor="let genre of game.genres" (click)="goToLibraryWithFilter('genres', genre.slug)">{{genre.name}}</mat-chip> <mat-chip *ngFor="let genre of game.genres"
(click)="goToLibraryWithFilter('genres', genre.slug)">{{genre.name}}</mat-chip>
</mat-chip-list> </mat-chip-list>
</div> </div>
<div *ngIf="game.themes !== undefined && game.themes.length > 0"> <div *ngIf="game.themes !== undefined && game.themes.length > 0">
<h2>Themes</h2> <h2>Themes</h2>
<mat-chip-list> <mat-chip-list>
<mat-chip *ngFor="let theme of game.themes" (click)="goToLibraryWithFilter('themes', theme.slug)">{{theme.name}}</mat-chip> <mat-chip *ngFor="let theme of game.themes"
(click)="goToLibraryWithFilter('themes', theme.slug)">{{theme.name}}</mat-chip>
</mat-chip-list> </mat-chip-list>
</div> </div>
</div> </div>
<div fxFlex fxLayout="row" fxLayoutGap="16px">
<div fxFlex="40" *ngIf="game.criticsRating !== undefined && game.criticsRating > 0">
<h2>Critics Rating <span style="font-weight: normal; font-size: medium">({{game.criticsRating}}/100)</span>
</h2>
<mat-progress-bar mode="determinate" [value]="game.criticsRating"
[progressBarColor]="mapRatingToColor(game.criticsRating)"></mat-progress-bar>
</div>
<div fxFlex="40" *ngIf="game.userRating !== undefined && game.userRating > 0">
<h2>User Rating <span style="font-weight: normal; font-size: medium">({{game.userRating}}/100)</span></h2>
<mat-progress-bar mode="determinate" [value]="game.userRating"
[progressBarColor]="mapRatingToColor(game.userRating)"></mat-progress-bar>
</div>
</div>
</div> </div>
</div> </div>
<div fxLayout="column" fxLayoutGap="16px"> <div id="game-media" fxLayout="column" fxLayoutGap="16px">
<div *ngIf="game.screenshotIds !== undefined && game.screenshotIds.length > 0"> <div *ngIf="game.screenshotIds !== undefined && game.screenshotIds.length > 0">
<h2>Screenshots</h2> <h2>Screenshots</h2>
<div fxLayout="row wrap" fxLayoutGap="8px grid"> <mat-grid-list [cols]="gridColumnCount" gutterSize="8px" rowHeight="312px">
<game-screenshot *ngFor="let screenshotId of game.screenshotIds" [screenshotId]="screenshotId" style="margin-bottom: -6px"></game-screenshot> <mat-grid-tile *ngFor="let screenshotId of game.screenshotIds">
</div> <game-screenshot [screenshotId]="screenshotId"></game-screenshot>
</mat-grid-tile>
</mat-grid-list>
</div> </div>
<div *ngIf="game.videoIds !== undefined && game.videoIds.length > 0"> <div *ngIf="game.videoIds !== undefined && game.videoIds.length > 0">
<h2>Videos</h2> <h2>Videos</h2>
<div fxLayout="row wrap" fxLayoutGap="8px grid"> <mat-grid-list [cols]="gridColumnCount" gutterSize="8px" rowHeight="312px">
<game-video *ngFor="let videoId of game.videoIds" [videoId]="videoId" [width]="555" [height]="312"></game-video> <mat-grid-tile *ngFor="let videoId of game.videoIds" style="width: 555px; height: 312px;">
</div> <game-video [videoId]="videoId" [width]="555" [height]="312"></game-video>
</mat-grid-tile>
</mat-grid-list>
</div> </div>
</div> </div>
@@ -1,23 +1,32 @@
import {Component, OnInit} from '@angular/core'; import {Component, HostListener} from '@angular/core';
import {ActivatedRoute, Params, Router} from "@angular/router"; import {ActivatedRoute, Params, Router} from "@angular/router";
import {DetectedGameDto} from "../../models/dtos/DetectedGameDto"; import {DetectedGameDto} from "../../models/dtos/DetectedGameDto";
import {GamesService} from "../../services/games.service"; import {GamesService} from "../../services/games.service";
import {CompanyDto} from "../../models/dtos/CompanyDto";
@Component({ @Component({
selector: 'app-game-detail-view', selector: 'app-game-detail-view',
templateUrl: './game-detail-view.component.html', templateUrl: './game-detail-view.component.html',
styleUrls: ['./game-detail-view.component.scss'] styleUrls: ['./game-detail-view.component.scss']
}) })
export class GameDetailViewComponent implements OnInit { export class GameDetailViewComponent {
game!: DetectedGameDto; game!: DetectedGameDto;
companiesWithLogo: CompanyDto[]= [];
gridColumnCount: number;
constructor(private route: ActivatedRoute, constructor(private route: ActivatedRoute,
private router: Router, private router: Router,
private gamesService: GamesService) { private gamesService: GamesService) {
this.route.params.subscribe( params => { this.gamesService.getGame(this.route.snapshot.params['slug']).subscribe({
this.gamesService.getGame(params['slug']).subscribe({ next: game => {
next: game => this.game = game, this.game = game;
if(game.companies !== undefined) {
this.companiesWithLogo = game.companies.filter(c => c.logoId !== undefined && c.logoId.length > 0);
}
},
error: error => { error: error => {
if (error.status === 404) { if (error.status === 404) {
this.router.navigate(['/library']); this.router.navigate(['/library']);
@@ -26,10 +35,13 @@ export class GameDetailViewComponent implements OnInit {
} }
} }
}); });
});
this.gridColumnCount = this.calculateColumnCount();
} }
ngOnInit(): void { @HostListener('window:resize', ['$event'])
onResize() {
this.gridColumnCount = this.calculateColumnCount();
} }
public downloadGame(): void { public downloadGame(): void {
@@ -62,4 +74,22 @@ export class GameDetailViewComponent implements OnInit {
this.router.navigate(['/library'], {queryParams: params}); this.router.navigate(['/library'], {queryParams: params});
} }
mapRatingToColor(rating: number): string {
if (rating >= 75) return '#388e3c';
if (rating >= 50) return '#fbc02d';
if (rating >= 25) return '#f57c00';
return '#d32f2f';
}
private calculateColumnCount(): number {
const elementWidth: number = 555;
const containerWidth: number | undefined = document.getElementById('game-media')?.offsetWidth;
const defaultColumnCount = 3;
if (containerWidth === undefined) return defaultColumnCount
if (containerWidth < elementWidth) return 1;
return Math.floor(containerWidth / elementWidth);
}
} }
@@ -1,7 +1,6 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {LibraryService} from "../../services/library.service"; import {LibraryService} from "../../services/library.service";
import {MatSnackBar} from '@angular/material/snack-bar'; import {MatSnackBar} from '@angular/material/snack-bar';
import {timeInterval} from "rxjs";
import {Router} from "@angular/router"; import {Router} from "@angular/router";
import {GamesService} from "../../services/games.service"; import {GamesService} from "../../services/games.service";
import {ThemingService} from "../../services/theming.service"; import {ThemingService} from "../../services/theming.service";
@@ -26,11 +25,27 @@ export class HeaderComponent {
} }
scanLibrary(): void { scanLibrary(): void {
this.libraryService.scanLibrary().pipe(timeInterval()).subscribe({ this.libraryService.scanLibrary().subscribe({
next: value => { next: result => {
// Refresh the current page "angular style" // Refresh the current page "angular style"
this.router.navigate([this.router.url]).then(() => this.router.navigate([this.router.url]).then(() => {
this.snackBar.open(`Library scan completed in ${Math.trunc(value.interval / 1000)} seconds.`, undefined, {duration: 5000}) const snackBarDuration: number = 10000;
let snackbarContent: string = 'Library scan completed in ' + result.scanDuration + ' seconds:\n' +
'- ' + result.newGames + ' new games\n' +
'- ' + result.deletedGames + ' games removed\n' +
'- ' + result.newUnmappableFiles + ' files/folders could not be mapped\n' +
'- ' + result.totalGames + ' games currently in your library';
if (result.companyLogoDownloads !== undefined && result.coverDownloads !== undefined && result.screenshotDownloads !== undefined) {
snackbarContent = snackbarContent.concat('\n' +
'- ' + result.coverDownloads + ' covers downloaded\n' +
'- ' + result.screenshotDownloads + ' screenshots downloaded\n' +
'- ' + result.companyLogoDownloads + ' company logos downloaded');
}
this.snackBar.open(snackbarContent, undefined, {duration: snackBarDuration});
}
) )
}, },
error: error => this.snackBar.open(`Error while scanning library: ${error.error.message}`, undefined, {duration: 5000}) error: error => this.snackBar.open(`Error while scanning library: ${error.error.message}`, undefined, {duration: 5000})
@@ -11,14 +11,16 @@
</div> </div>
<div fxLayout="column" fxLayoutAlign="center center"> <div fxLayout="column" fxLayoutAlign="center center">
<mat-icon fontSet="material-icons-outlined" color="primary" style="font-size: 128px; height: 128px; width: 128px;"> <mat-icon fontSet="material-icons-outlined" color="primary"
style="font-size: 128px; height: 128px; width: 128px;">
videogame_asset_off videogame_asset_off
</mat-icon> </mat-icon>
<h1>Your game library is empty!</h1> <h1>Your game library is empty!</h1>
</div> </div>
</div> </div>
<div class="content" fxLayout="row" fxLayout.lt-lg="column" fxFlexFill="100" *ngIf="!this.loading && !this.gameLibraryIsEmpty"> <div class="content" fxLayout="row" fxLayout.lt-lg="column" fxFlexFill="100"
*ngIf="!this.loading && !this.gameLibraryIsEmpty">
<div fxFlex="10" fxHide fxShow.gt-md><!--SPACER--></div> <div fxFlex="10" fxHide fxShow.gt-md><!--SPACER--></div>
<div fxFlex.gt-md="0 1 15" fxLayout="column" fxLayoutGap="16px" fxLayoutAlign.lt-lg="start center" <div fxFlex.gt-md="0 1 15" fxLayout="column" fxLayoutGap="16px" fxLayoutAlign.lt-lg="start center"
@@ -99,13 +101,15 @@
</div> </div>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel *ngIf="availablePlayerPerspectives.length > 0" [expanded]="activePlayerPerspectiveFilters.length > 0"> <mat-expansion-panel *ngIf="availablePlayerPerspectives.length > 0"
[expanded]="activePlayerPerspectiveFilters.length > 0">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<h3 class="filter-category-title">Player Perspectives</h3> <h3 class="filter-category-title">Player Perspectives</h3>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<div fxLayout="column"> <div fxLayout="column">
<mat-checkbox *ngFor="let playerPerspective of availablePlayerPerspectives" (change)="togglePlayerPerspectiveFilter(playerPerspective.slug)" <mat-checkbox *ngFor="let playerPerspective of availablePlayerPerspectives"
(change)="togglePlayerPerspectiveFilter(playerPerspective.slug)"
[checked]="activePlayerPerspectiveFilters.includes(playerPerspective.slug)" [checked]="activePlayerPerspectiveFilters.includes(playerPerspective.slug)"
color="primary">{{playerPerspective.name}}</mat-checkbox> color="primary">{{playerPerspective.name}}</mat-checkbox>
</div> </div>
@@ -114,12 +118,16 @@
<div fxFlex="0 1 1"><!--SPACER--></div> <div fxFlex="0 1 1"><!--SPACER--></div>
<div fxFlex fxLayout="column">
<div fxFlex fxLayout="row wrap" fxLayoutGap="16px grid"> <div fxFlex fxLayout="row wrap" fxLayoutGap="16px grid">
<div *ngFor="let game of games"> <div *ngFor="let game of games">
<game-cover [game]="game"></game-cover> <game-cover [game]="game"></game-cover>
</div> </div>
</div> </div>
<div fxFlex><!--SPACER--></div>
</div>
<div fxFlex="0 1 10" fxHide fxShow.gt-lg><!--SPACER--></div> <div fxFlex="0 1 10" fxHide fxShow.gt-lg><!--SPACER--></div>
</div> </div>
</div> </div>
@@ -0,0 +1,8 @@
import { ProgressBarColorDirective } from './progress-bar-color.directive';
describe('ProgressBarColorDirective', () => {
it('should create an instance', () => {
const directive = new ProgressBarColorDirective();
expect(directive).toBeTruthy();
});
});
@@ -0,0 +1,34 @@
import { Directive, Input, OnChanges, SimpleChanges, ElementRef } from '@angular/core';
@Directive({
selector: '[progressBarColor]'
})
export class ProgressBarColorDirective implements OnChanges{
static counter = 0;
@Input() progressBarColor!: string;
styleEl:HTMLStyleElement = document.createElement('style');
//generate unique attribule which we will use to minimise the scope of our dynamic style
uniqueAttr = `app-progress-bar-color-${ProgressBarColorDirective.counter++}`;
constructor(private el: ElementRef) {
const nativeEl: HTMLElement = this.el.nativeElement;
nativeEl.setAttribute(this.uniqueAttr,'');
nativeEl.appendChild(this.styleEl);
}
ngOnChanges(changes: SimpleChanges): void{
this.updateColor();
}
updateColor(): void{
// update dynamic style with the uniqueAttr
this.styleEl.innerText = `
[${this.uniqueAttr}] .mat-progress-bar-fill::after {
background-color: ${this.progressBarColor};
}
`;
}
}
@@ -0,0 +1,5 @@
export class ImageDownloadResultDto {
coverDownloads!: number;
screenshotDownloads!: number;
companyLogoDownloads!: number;
}
@@ -0,0 +1,10 @@
export class LibraryScanResultDto {
newGames!: number;
deletedGames!: number;
newUnmappableFiles!: number;
totalGames!: number;
coverDownloads!: number;
screenshotDownloads!: number;
companyLogoDownloads!: number;
scanDuration!: number;
}
+7 -5
View File
@@ -1,7 +1,9 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {HttpClient, HttpResponse} from "@angular/common/http"; import {HttpClient} from "@angular/common/http";
import {Observable} from "rxjs"; import {Observable} from "rxjs";
import {LibraryApi} from "../api/LibraryApi"; import {LibraryApi} from "../api/LibraryApi";
import {LibraryScanResultDto} from "../models/dtos/LibraryScanResultDto";
import {ImageDownloadResultDto} from "../models/dtos/ImageDownloadResultDto";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -13,12 +15,12 @@ export class LibraryService implements LibraryApi {
constructor(private http: HttpClient) { constructor(private http: HttpClient) {
} }
scanLibrary(): Observable<HttpResponse<Response>> { scanLibrary(): Observable<LibraryScanResultDto> {
return this.http.get<HttpResponse<Response>>(`${this.apiPath}/scan`); return this.http.get<LibraryScanResultDto>(`${this.apiPath}/scan`);
} }
downloadImages(): Observable<HttpResponse<Response>> { downloadImages(): Observable<ImageDownloadResultDto> {
return this.http.get<HttpResponse<Response>>(`${this.apiPath}/download-images`); return this.http.get<ImageDownloadResultDto>(`${this.apiPath}/download-images`);
} }
getFiles(): Observable<string[]> { getFiles(): Observable<string[]> {
+1 -1
View File
@@ -5,7 +5,7 @@
<groupId>de.grimsi</groupId> <groupId>de.grimsi</groupId>
<artifactId>gameyfin</artifactId> <artifactId>gameyfin</artifactId>
<version>1.1.3</version> <version>1.2.0</version>
<name>gameyfin</name> <name>gameyfin</name>
<description>gameyfin</description> <description>gameyfin</description>