mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +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>
|
<protoc.plugin.version>3.11.4</protoc.plugin.version>
|
||||||
<protobuf-java.version>3.21.2</protobuf-java.version>
|
<protobuf-java.version>3.21.2</protobuf-java.version>
|
||||||
<gameyfin-frontend.version>0.0.1-SNAPSHOT</gameyfin-frontend.version>
|
<gameyfin-frontend.version>0.0.1-SNAPSHOT</gameyfin-frontend.version>
|
||||||
|
<commons-compress.version>1.21</commons-compress.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -75,6 +76,11 @@
|
|||||||
<artifactId>commons-io</artifactId>
|
<artifactId>commons-io</artifactId>
|
||||||
<version>${commons-io.version}</version>
|
<version>${commons-io.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-compress</artifactId>
|
||||||
|
<version>${commons-compress.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Protobuf dependencies -->
|
<!-- Protobuf dependencies -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ public class DetectedGame {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String path;
|
private String path;
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private boolean isFolder;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "boolean default false")
|
@Column(columnDefinition = "boolean default false")
|
||||||
private boolean confirmedMatch;
|
private boolean confirmedMatch;
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ public class GameMapper {
|
|||||||
.themes(ThemeMapper.toThemes(g.getThemesList()))
|
.themes(ThemeMapper.toThemes(g.getThemesList()))
|
||||||
.playerPerspectives(PlayerPerspectiveMapper.toPlayerPerspectives(g.getPlayerPerspectivesList()))
|
.playerPerspectives(PlayerPerspectiveMapper.toPlayerPerspectives(g.getPlayerPerspectivesList()))
|
||||||
.path(path.toString())
|
.path(path.toString())
|
||||||
.isFolder(path.toFile().isDirectory())
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ package de.grimsi.gameyfin.rest;
|
|||||||
|
|
||||||
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.GameService;
|
import de.grimsi.gameyfin.service.GameService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -23,6 +23,8 @@ public class GamesController {
|
|||||||
|
|
||||||
private final GameService gameService;
|
private final GameService gameService;
|
||||||
|
|
||||||
|
private final FilesystemService filesystemService;
|
||||||
|
|
||||||
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public List<DetectedGame> getAllGames() {
|
public List<DetectedGame> getAllGames() {
|
||||||
return gameService.getAllDetectedGames();
|
return gameService.getAllDetectedGames();
|
||||||
@@ -43,5 +45,17 @@ public class GamesController {
|
|||||||
return gameService.getAllMappings();
|
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.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 lombok.SneakyThrows;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.apache.commons.io.FilenameUtils;
|
||||||
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;
|
||||||
@@ -30,17 +36,16 @@ import reactor.core.publisher.Flux;
|
|||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Path;
|
import java.io.OutputStream;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.*;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
import java.util.Collections;
|
import java.util.*;
|
||||||
import java.util.List;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -80,13 +85,33 @@ public class FilesystemService {
|
|||||||
try (Stream<Path> stream = Files.list(rootFolder)) {
|
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 all sub-folders (non-recursive) and files that have an extension that indicates that they are a downloadable file
|
||||||
return stream
|
return stream
|
||||||
.filter(p -> Files.isDirectory(p) || possibleGameFileExtensions.contains(FilenameUtils.getExtension(p.getFileName().toString())))
|
.filter(p -> Files.isDirectory(p) || hasGameArchiveExtension(p))
|
||||||
.toList();
|
.toList();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Error while opening root folder", 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() {
|
public void scanGameLibrary() {
|
||||||
StopWatch stopWatch = new StopWatch();
|
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
|
// 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()
|
List<DetectedGame> newDetectedGames = gameFiles.parallelStream()
|
||||||
.map(p -> {
|
.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(() -> {
|
return optionalGame.map(game -> Map.entry(p, game)).or(() -> {
|
||||||
unmappableFileRepository.save(new UnmappableFile(p.toString()));
|
unmappableFileRepository.save(new UnmappableFile(p.toString()));
|
||||||
newUnmappedFilesCounter.getAndIncrement();
|
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());
|
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) {
|
private int downloadImagesIntoCache(MultiValueMap<String, String> entityToImageIds, String imageSize, String imageType, String entityType) {
|
||||||
AtomicInteger downloadCounter = new AtomicInteger();
|
AtomicInteger downloadCounter = new AtomicInteger();
|
||||||
Path cacheFolder = Path.of(cacheFolderPath);
|
Path cacheFolder = Path.of(cacheFolderPath);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
gameyfin:
|
gameyfin:
|
||||||
#root: C:\Projects\privat\gameyfin-library
|
#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
|
cache: ${gameyfin.root}\.gameyfin\cache
|
||||||
#db: ${gameyfin.root}\.gameyfin\db
|
#db: ${gameyfin.root}\.gameyfin\db
|
||||||
db: ./data
|
db: ./data
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
<div fxLayout="row" fxLayoutAlign="center" style="margin-top: 16px;">
|
<div fxLayout="row" fxLayoutAlign="center" style="margin-top: 16px;">
|
||||||
<div fxLayout="column" fxFlex="0 1 70" fxLayoutGap="16px" fxFlex.lt-xl="95">
|
<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">
|
<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>
|
<h2>{{game.title}}</h2>
|
||||||
<p id="game-summary">{{game.summary}}</p>
|
<p id="game-summary">{{game.summary}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div fxLayout="column" fxFlex style="background: coral">
|
<div fxLayout="column" fxFlex>
|
||||||
|
<button mat-raised-button (click)="downloadGame()">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div fxLayout="column" fxLayoutGap="16px">
|
<div fxLayout="column" fxLayoutGap="16px">
|
||||||
<h2>Screenshots</h2>
|
<div *ngIf="game.screenshotIds !== undefined && game.screenshotIds.length > 0">
|
||||||
<div fxLayout="row wrap" fxLayoutGap="8px">
|
<h2>Screenshots</h2>
|
||||||
<div *ngFor="let screenshotId of game.screenshotIds">
|
<div fxLayout="row wrap" fxLayoutGap="8px">
|
||||||
<game-screenshot [screenshotId]="screenshotId"></game-screenshot>
|
<div *ngFor="let screenshotId of game.screenshotIds">
|
||||||
|
<game-screenshot [screenshotId]="screenshotId"></game-screenshot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Videos</h2>
|
<div *ngIf="game.videoIds !== undefined && game.videoIds.length > 0">
|
||||||
<div fxLayout="row wrap" fxLayoutGap="8px">
|
<h2>Videos</h2>
|
||||||
<div *ngFor="let videoId of game.videoIds">
|
<div fxLayout="row wrap" fxLayoutGap="8px">
|
||||||
<game-video [videoId]="videoId" [width]="555" [height]="312"></game-video>
|
<div *ngFor="let videoId of game.videoIds">
|
||||||
|
<game-video [videoId]="videoId" [width]="555" [height]="312"></game-video>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {ActivatedRoute, 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 {HttpErrorResponse} from "@angular/common/http";
|
import {HttpErrorResponse} from "@angular/common/http";
|
||||||
|
import {takeWhile} from "rxjs";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-game-detail-view',
|
selector: 'app-game-detail-view',
|
||||||
@@ -33,4 +34,8 @@ export class GameDetailViewComponent implements OnInit {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadGame(): void {
|
||||||
|
this.gamesService.downloadGame(this.game.slug);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export class HeaderComponent {
|
|||||||
|
|
||||||
reloadLibrary(): void {
|
reloadLibrary(): void {
|
||||||
this.libraryService.scanLibrary().pipe(timeInterval()).subscribe({
|
this.libraryService.scanLibrary().pipe(timeInterval()).subscribe({
|
||||||
next: value => this.snackBar.open(`Library scan completed in ${Math.trunc(value.interval / 1000)} seconds.`),
|
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}`)
|
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}`);
|
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[]> {
|
getGameOverviews(): Observable<GameOverviewDto[]> {
|
||||||
return this.http.get<GameOverviewDto[]>(`${this.apiPath}/game-overviews`);
|
return this.http.get<GameOverviewDto[]>(`${this.apiPath}/game-overviews`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user