diff --git a/backend/pom.xml b/backend/pom.xml index 556ff3b..9502c18 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -20,6 +20,7 @@ 3.11.4 3.21.2 0.0.1-SNAPSHOT + 1.21 @@ -75,6 +76,11 @@ commons-io ${commons-io.version} + + org.apache.commons + commons-compress + ${commons-compress.version} + diff --git a/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java b/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java index 18f846b..0268660 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java +++ b/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java @@ -80,9 +80,6 @@ public class DetectedGame { @Column(nullable = false) private String path; - @Column(nullable = false) - private boolean isFolder; - @Column(columnDefinition = "boolean default false") private boolean confirmedMatch; diff --git a/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java b/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java index e9ac70d..436e106 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java @@ -37,7 +37,6 @@ public class GameMapper { .themes(ThemeMapper.toThemes(g.getThemesList())) .playerPerspectives(PlayerPerspectiveMapper.toPlayerPerspectives(g.getPlayerPerspectivesList())) .path(path.toString()) - .isFolder(path.toFile().isDirectory()) .build(); } diff --git a/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java b/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java index 321138f..bec791f 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java @@ -2,13 +2,13 @@ package de.grimsi.gameyfin.rest; import de.grimsi.gameyfin.dto.GameOverviewDto; import de.grimsi.gameyfin.entities.DetectedGame; +import de.grimsi.gameyfin.service.FilesystemService; import de.grimsi.gameyfin.service.GameService; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import java.util.List; import java.util.Map; @@ -23,6 +23,8 @@ public class GamesController { private final GameService gameService; + private final FilesystemService filesystemService; + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) public List getAllGames() { return gameService.getAllDetectedGames(); @@ -43,5 +45,17 @@ public class GamesController { return gameService.getAllMappings(); } + @GetMapping(value="/game/{slug}/download") + public ResponseEntity downloadGameFiles(@PathVariable String slug) { + + DetectedGame game = gameService.getDetectedGame(slug); + + String downloadFileName = filesystemService.getDownloadFileName(game); + + return ResponseEntity + .ok() + .header("Content-Disposition", "attachment; filename=\"%s\"".formatted(downloadFileName)) + .body(out -> filesystemService.downloadGameFiles(game, out)); + } } diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/FilesystemService.java b/backend/src/main/java/de/grimsi/gameyfin/service/FilesystemService.java index 152de0a..3a9bf36 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/FilesystemService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/FilesystemService.java @@ -9,7 +9,13 @@ import de.grimsi.gameyfin.igdb.IgdbWrapper; import de.grimsi.gameyfin.mapper.GameMapper; import de.grimsi.gameyfin.repositories.DetectedGameRepository; import de.grimsi.gameyfin.repositories.UnmappableFileRepository; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.compress.archivers.zip.ParallelScatterZipCreator; +import org.apache.commons.compress.archivers.zip.Zip64Mode; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.apache.commons.compress.parallel.InputStreamSupplier; import org.apache.commons.io.FilenameUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -30,17 +36,16 @@ import reactor.core.publisher.Flux; import javax.annotation.PostConstruct; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.zip.ZipEntry; @Slf4j @Service @@ -80,13 +85,33 @@ public class FilesystemService { try (Stream stream = Files.list(rootFolder)) { // return all sub-folders (non-recursive) and files that have an extension that indicates that they are a downloadable file return stream - .filter(p -> Files.isDirectory(p) || possibleGameFileExtensions.contains(FilenameUtils.getExtension(p.getFileName().toString()))) + .filter(p -> Files.isDirectory(p) || hasGameArchiveExtension(p)) .toList(); } catch (IOException e) { throw new RuntimeException("Error while opening root folder", e); } } + public String getDownloadFileName(DetectedGame g) { + Path path = Path.of(g.getPath()); + + if(!path.toFile().isDirectory()) return getFilenameWithExtension(path); + + Optional optionalGameArchive; + try (Stream filesStream = Files.list(path)) { + optionalGameArchive = filesStream.filter(this::hasGameArchiveExtension).findFirst(); + } catch (IOException e) { + log.error("Error while accessing folder:", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error while accessing folder '%s'.".formatted(path)); + } + + if(optionalGameArchive.isPresent()) { + return getFilenameWithExtension(optionalGameArchive.get()); + } + + return getFilenameWithExtension(path) + ".zip"; + } + public void scanGameLibrary() { StopWatch stopWatch = new StopWatch(); @@ -120,7 +145,7 @@ public class FilesystemService { // If a game is not found on IGDB, blacklist the path, so we won't query the API later for the same path List newDetectedGames = gameFiles.parallelStream() .map(p -> { - Optional optionalGame = igdbWrapper.searchForGameByTitle(getFilename(p)); + Optional optionalGame = igdbWrapper.searchForGameByTitle(getFilenameWithoutExtension(p)); return optionalGame.map(game -> Map.entry(p, game)).or(() -> { unmappableFileRepository.save(new UnmappableFile(p.toString())); newUnmappedFilesCounter.getAndIncrement(); @@ -205,10 +230,99 @@ public class FilesystemService { } } - private String getFilename(Path p) { + public void downloadGameFiles(DetectedGame game, OutputStream outputStream) { + + StopWatch stopWatch = new StopWatch(); + + log.info("Starting game file download..."); + stopWatch.start(); + + Path path = Path.of(game.getPath()); + + if(path.toFile().isDirectory()) { + downloadFromFolder(path, outputStream); + } else { + downloadFile(path, outputStream); + } + + stopWatch.stop(); + + log.info("Downloaded game files of {} in {} seconds.", game.getTitle(), (int) stopWatch.getTotalTimeSeconds()); + } + + private void downloadFile(Path path, OutputStream outputStream) { + try { + Files.copy(path, outputStream); + } catch (IOException e) { + log.error("Error while downloading file:", e); + } + } + + private void downloadFromFolder(Path path, OutputStream outputStream) { + Optional optionalGameArchive; + + try (Stream filesStream = Files.list(path)) { + optionalGameArchive = filesStream.filter(this::hasGameArchiveExtension).findFirst(); + } catch (IOException e) { + log.error("Error while accessing folder:", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error while accessing folder '%s'.".formatted(path)); + } + + if(optionalGameArchive.isPresent()) { + downloadFile(optionalGameArchive.get(), outputStream); + } else { + downloadFilesAsZip(path, outputStream); + } + } + + private void downloadFilesAsZip(Path path, OutputStream outputStream) { + final ParallelScatterZipCreator scatterZipCreator = new ParallelScatterZipCreator(); + + ZipArchiveOutputStream zipArchiveOutputStream; + + zipArchiveOutputStream = new ZipArchiveOutputStream(outputStream); + zipArchiveOutputStream.setUseZip64(Zip64Mode.AsNeeded); + + try { + Files.walkFileTree(path, new SimpleFileVisitor<>() { + @SneakyThrows + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + InputStreamSupplier streamSupplier = () -> { + InputStream is = null; + try { + is = Files.newInputStream(file); + } catch (IOException e) { + e.printStackTrace(); + } + return is; + }; + + ZipArchiveEntry zipArchiveEntry = new ZipArchiveEntry(path.relativize(file).toString()); + zipArchiveEntry.setMethod(ZipEntry.STORED); + scatterZipCreator.addArchiveEntry(zipArchiveEntry, streamSupplier); + + return FileVisitResult.CONTINUE; + } + }); + scatterZipCreator.writeTo(zipArchiveOutputStream); + zipArchiveOutputStream.close(); + } catch (IOException | InterruptedException | ExecutionException e) { + log.error("Error while zipping files:", e); + } + } + + private String getFilenameWithoutExtension(Path p) { return FilenameUtils.getBaseName(p.toString()); } + private String getFilenameWithExtension(Path p) { + return FilenameUtils.getName(p.toString()); + } + + private boolean hasGameArchiveExtension(Path p) { + return possibleGameFileExtensions.contains(FilenameUtils.getExtension(p.getFileName().toString())); + } + private int downloadImagesIntoCache(MultiValueMap entityToImageIds, String imageSize, String imageType, String entityType) { AtomicInteger downloadCounter = new AtomicInteger(); Path cacheFolder = Path.of(cacheFolderPath); diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 2ca1b3b..5726cc2 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -1,6 +1,7 @@ gameyfin: #root: C:\Projects\privat\gameyfin-library - root: \\NAS-Simon\Öffentlich\Spiele + #root: \\NAS-Simon\Öffentlich\Spiele + root: C:\gameyfin-library cache: ${gameyfin.root}\.gameyfin\cache #db: ${gameyfin.root}\.gameyfin\db db: ./data diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.html b/frontend/src/app/components/game-detail-view/game-detail-view.component.html index d072a8e..b6a3461 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.html +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.html @@ -1,26 +1,32 @@
-
+
Game cover -
+

{{game.title}}

{{game.summary}}

-
- +
+
-

Screenshots

-
-
- +
+

Screenshots

+
+
+ +
-

Videos

-
-
- +
+

Videos

+
+
+ +
diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.ts b/frontend/src/app/components/game-detail-view/game-detail-view.component.ts index 64921a9..89e99b8 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.ts +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.ts @@ -3,6 +3,7 @@ import {ActivatedRoute, Router} from "@angular/router"; import {DetectedGameDto} from "../../models/dtos/DetectedGameDto"; import {GamesService} from "../../services/games.service"; import {HttpErrorResponse} from "@angular/common/http"; +import {takeWhile} from "rxjs"; @Component({ selector: 'app-game-detail-view', @@ -33,4 +34,8 @@ export class GameDetailViewComponent implements OnInit { ngOnInit(): void { } + downloadGame(): void { + this.gamesService.downloadGame(this.game.slug); + } + } diff --git a/frontend/src/app/components/header/header.component.ts b/frontend/src/app/components/header/header.component.ts index 349f08d..9f19dcd 100644 --- a/frontend/src/app/components/header/header.component.ts +++ b/frontend/src/app/components/header/header.component.ts @@ -18,8 +18,8 @@ export class HeaderComponent { reloadLibrary(): void { this.libraryService.scanLibrary().pipe(timeInterval()).subscribe({ - next: value => this.snackBar.open(`Library scan completed in ${Math.trunc(value.interval / 1000)} seconds.`), - error: error => this.snackBar.open(`Error while scanning library: ${error}`) + next: value => this.snackBar.open(`Library scan completed in ${Math.trunc(value.interval / 1000)} seconds.`, undefined, {duration: 2000}), + error: error => this.snackBar.open(`Error while scanning library: ${error}`, undefined, {duration: 5000}) }) } diff --git a/frontend/src/app/services/games.service.ts b/frontend/src/app/services/games.service.ts index b7fadbf..00b88cf 100644 --- a/frontend/src/app/services/games.service.ts +++ b/frontend/src/app/services/games.service.ts @@ -23,6 +23,10 @@ export class GamesService implements GamesApi { return this.http.get(`${this.apiPath}/game/${slug}`); } + downloadGame(slug: String): void { + window.open(`v1/${this.apiPath}/game/${slug}/download`, '_top'); + } + getGameOverviews(): Observable { return this.http.get(`${this.apiPath}/game-overviews`); }