mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
WIP: Implemented game download functionality
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user