WIP: Implemented game download functionality

This commit is contained in:
grimsi
2022-07-24 20:01:28 +02:00
parent eab1cf629c
commit e1a285a77d
10 changed files with 180 additions and 34 deletions
+6
View File
@@ -20,6 +20,7 @@
<protoc.plugin.version>3.11.4</protoc.plugin.version>
<protobuf-java.version>3.21.2</protobuf-java.version>
<gameyfin-frontend.version>0.0.1-SNAPSHOT</gameyfin-frontend.version>
<commons-compress.version>1.21</commons-compress.version>
</properties>
<dependencies>
@@ -75,6 +76,11 @@
<artifactId>commons-io</artifactId>
<version>${commons-io.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>${commons-compress.version}</version>
</dependency>
<!-- Protobuf dependencies -->
<dependency>
@@ -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;
@@ -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();
}
@@ -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<DetectedGame> getAllGames() {
return gameService.getAllDetectedGames();
@@ -43,5 +45,17 @@ public class GamesController {
return gameService.getAllMappings();
}
@GetMapping(value="/game/{slug}/download")
public ResponseEntity<StreamingResponseBody> 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));
}
}
@@ -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<Path> 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<Path> optionalGameArchive;
try (Stream<Path> 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<DetectedGame> newDetectedGames = gameFiles.parallelStream()
.map(p -> {
Optional<Igdb.Game> optionalGame = igdbWrapper.searchForGameByTitle(getFilename(p));
Optional<Igdb.Game> 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<Path> optionalGameArchive;
try (Stream<Path> 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<String, String> entityToImageIds, String imageSize, String imageType, String entityType) {
AtomicInteger downloadCounter = new AtomicInteger();
Path cacheFolder = Path.of(cacheFolderPath);
@@ -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
@@ -1,26 +1,32 @@
<div fxLayout="row" fxLayoutAlign="center" style="margin-top: 16px;">
<div fxLayout="column" fxFlex="0 1 70" fxLayoutGap="16px" fxFlex.lt-xl="95">
<div fxLayout="row" fxLayoutGap="16px" style="background: aqua">
<div fxLayout="row" fxLayoutGap="16px">
<img src="v1/images/{{game.coverId}}" alt="Game cover">
<div fxFlex="30" fxLayout="column" id="game-details" style="background: darkolivegreen">
<div fxFlex="30" fxLayout="column" id="game-details">
<h2>{{game.title}}</h2>
<p id="game-summary">{{game.summary}}</p>
</div>
<div fxLayout="column" fxFlex style="background: coral">
<div fxLayout="column" fxFlex>
<button mat-raised-button (click)="downloadGame()">
Download
</button>
</div>
</div>
<div fxLayout="column" fxLayoutGap="16px">
<h2>Screenshots</h2>
<div fxLayout="row wrap" fxLayoutGap="8px">
<div *ngFor="let screenshotId of game.screenshotIds">
<game-screenshot [screenshotId]="screenshotId"></game-screenshot>
<div *ngIf="game.screenshotIds !== undefined && game.screenshotIds.length > 0">
<h2>Screenshots</h2>
<div fxLayout="row wrap" fxLayoutGap="8px">
<div *ngFor="let screenshotId of game.screenshotIds">
<game-screenshot [screenshotId]="screenshotId"></game-screenshot>
</div>
</div>
</div>
<h2>Videos</h2>
<div fxLayout="row wrap" fxLayoutGap="8px">
<div *ngFor="let videoId of game.videoIds">
<game-video [videoId]="videoId" [width]="555" [height]="312"></game-video>
<div *ngIf="game.videoIds !== undefined && game.videoIds.length > 0">
<h2>Videos</h2>
<div fxLayout="row wrap" fxLayoutGap="8px">
<div *ngFor="let videoId of game.videoIds">
<game-video [videoId]="videoId" [width]="555" [height]="312"></game-video>
</div>
</div>
</div>
</div>
@@ -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);
}
}
@@ -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})
})
}
@@ -23,6 +23,10 @@ export class GamesService implements GamesApi {
return this.http.get<DetectedGameDto>(`${this.apiPath}/game/${slug}`);
}
downloadGame(slug: String): void {
window.open(`v1/${this.apiPath}/game/${slug}/download`, '_top');
}
getGameOverviews(): Observable<GameOverviewDto[]> {
return this.http.get<GameOverviewDto[]>(`${this.apiPath}/game-overviews`);
}