From cb7c8c8e004b933946ddf7e46a7e946853f5951d Mon Sep 17 00:00:00 2001 From: shawly Date: Tue, 18 Oct 2022 22:05:16 +0200 Subject: [PATCH 1/5] feat(download): add file size calculation for DownloadService This allows the browser to show file size and a time estimate for downloading files. Does not work for ZipOutputStreams though since dir size doesn't match the zip file size. Also added no-cache headers so browser won't start caching downloads. --- .../grimsi/gameyfin/rest/GamesController.java | 18 ++++++++++++++++- .../gameyfin/service/DownloadService.java | 20 +++++++++++++++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 4 files changed, 40 insertions(+), 4 deletions(-) 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 156988a..efb1186 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java @@ -5,11 +5,16 @@ import de.grimsi.gameyfin.entities.DetectedGame; import de.grimsi.gameyfin.service.DownloadService; import de.grimsi.gameyfin.service.GameService; import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.Map; @@ -50,10 +55,21 @@ public class GamesController { DetectedGame game = gameService.getDetectedGame(slug); String downloadFileName = downloadService.getDownloadFileName(game); + long downloadFileSize = downloadService.getDownloadFileSize(game); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"%s\"".formatted(downloadFileName)); + headers.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); + headers.add(HttpHeaders.PRAGMA, "no-cache"); + headers.add(HttpHeaders.EXPIRES, "0"); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + if (downloadFileSize > 0) { + headers.setContentLength(downloadFileSize); + } return ResponseEntity .ok() - .header("Content-Disposition", "attachment; filename=\"%s\"".formatted(downloadFileName)) + .headers(headers) .body(out -> downloadService.sendGamefilesToClient(game, out)); } diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java b/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java index 563e522..c29fe20 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.catalina.connector.ClientAbortException; +import org.apache.commons.io.FileUtils; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.util.StopWatch; @@ -34,6 +35,23 @@ public class DownloadService { return getFilenameWithExtension(path) + ".zip"; } + public long getDownloadFileSize(DetectedGame game) { + Path path = Path.of(game.getPath()); + + try { + if (!path.toFile().isDirectory()) { + long fileSize = filesystemService.getSizeOnDisk(path); + log.info("Calculated file size for {} ({} MB).", path, Math.divideExact(fileSize, 1000000L)); + return fileSize; + } else { + // return zero since we cannot set content length for ZipOutputStreams that are used to archive directories + return 0; + } + } catch (IOException e) { + throw new DownloadAbortedException(); + } + } + public Resource sendImageToClient(String imageId) { String filename = "%s.png".formatted(imageId); return filesystemService.getFileFromCache(filename); @@ -78,6 +96,7 @@ public class DownloadService { } private void sendGamefilesAsZipToClient(Path path, OutputStream outputStream) { + log.info("Archiving game path {} for download...", path); ZipOutputStream zos = new ZipOutputStream(outputStream) {{ def.setLevel(Deflater.NO_COMPRESSION); }}; @@ -87,6 +106,7 @@ public class DownloadService { @SneakyThrows public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { zos.putNextEntry(new ZipEntry(path.relativize(file).toString())); + log.debug("Adding file {} to archive...", file); Files.copy(file, zos); zos.closeEntry(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef089f2..0c909b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "dependencies": { "@angular/animations": "^14.0.0", "@angular/cdk": "^14.1.0", diff --git a/frontend/package.json b/frontend/package.json index cf5b2db..6734fcf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "scripts": { "ng": "ng", "start": "ng serve", From 130ec4565d3abbf37a719afa7aaac84f19d611bb Mon Sep 17 00:00:00 2001 From: shawly Date: Wed, 19 Oct 2022 23:35:24 +0200 Subject: [PATCH 2/5] feat(refresh): added refresh button on game detail view to refresh metadata Resolves grimsi/gameyfin#45 --- .../grimsi/gameyfin/rest/GamesController.java | 7 ++++++- .../grimsi/gameyfin/service/GameService.java | 15 +++++++++++++-- frontend/src/app/api/GamesApi.ts | 1 + .../game-detail-view.component.html | 3 +++ .../game-detail-view.component.ts | 18 ++++++++++++++++++ frontend/src/app/services/games.service.ts | 4 ++++ 6 files changed, 45 insertions(+), 3 deletions(-) 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 156988a..25661f8 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java @@ -44,7 +44,7 @@ public class GamesController { return gameService.getAllMappings(); } - @GetMapping(value="/game/{slug}/download") + @GetMapping(value = "/game/{slug}/download") public ResponseEntity downloadGameFiles(@PathVariable String slug) { DetectedGame game = gameService.getDetectedGame(slug); @@ -57,4 +57,9 @@ public class GamesController { .body(out -> downloadService.sendGamefilesToClient(game, out)); } + @GetMapping(value = "/game/{slug}/refresh", produces = MediaType.APPLICATION_JSON_VALUE) + public DetectedGame refreshGame(@PathVariable String slug) { + return gameService.refreshGame(slug); + } + } diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java index 3fd2a82..0c36729 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java @@ -9,6 +9,7 @@ import de.grimsi.gameyfin.mapper.GameMapper; import de.grimsi.gameyfin.repositories.DetectedGameRepository; import de.grimsi.gameyfin.repositories.UnmappableFileRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -20,6 +21,7 @@ import java.util.Optional; import java.util.stream.Collectors; @RequiredArgsConstructor +@Slf4j @Service public class GameService { @@ -87,6 +89,17 @@ public class GameService { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Path '%s' not in database".formatted(path)); } + public DetectedGame refreshGame(String slug) { + Optional optionalDetectedGame = detectedGameRepository.findById(slug); + + if (optionalDetectedGame.isPresent()) { + log.info("Refreshing game with slug '{}'", slug); + return mapDetectedGame(optionalDetectedGame.get(), slug); + } + + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' not found in database".formatted(slug)); + } + private DetectedGame mapUnmappableFile(UnmappableFile unmappableFile, String slug) { Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug))); @@ -106,8 +119,6 @@ public class GameService { DetectedGame game = gameMapper.toDetectedGame(igdbGame, Path.of(existingGame.getPath())); game = detectedGameRepository.save(game); - detectedGameRepository.deleteById(existingGame.getSlug()); - return game; } } diff --git a/frontend/src/app/api/GamesApi.ts b/frontend/src/app/api/GamesApi.ts index b198553..aa24c14 100644 --- a/frontend/src/app/api/GamesApi.ts +++ b/frontend/src/app/api/GamesApi.ts @@ -7,4 +7,5 @@ export interface GamesApi { getGame(slug: String): Observable; getGameOverviews(): Observable; getAllGameMappings(): Observable>; + refreshGame(slug: String): Observable; } 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 8d2d39b..e9798f7 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 @@ -33,6 +33,9 @@
+ 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 444f1b0..88f6db7 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 @@ -48,6 +48,24 @@ export class GameDetailViewComponent { this.gamesService.downloadGame(this.game.slug); } + public refreshGame(): void { + this.gamesService.refreshGame(this.game.slug).subscribe({ + next: game => { + this.game = game; + if(game.companies !== undefined) { + this.companiesWithLogo = game.companies.filter(c => c.logoId !== undefined && c.logoId.length > 0); + } + }, + error: error => { + if (error.status === 404) { + this.router.navigate(['/library']); + } else { + console.error(error); + } + } + }); + } + public bytesAsHumanReadableString(bytes: number): string { const thresh = 1024; diff --git a/frontend/src/app/services/games.service.ts b/frontend/src/app/services/games.service.ts index 87bd6f4..ea7a5b8 100644 --- a/frontend/src/app/services/games.service.ts +++ b/frontend/src/app/services/games.service.ts @@ -92,6 +92,10 @@ export class GamesService implements GamesApi { window.open(`v1${this.apiPath}/game/${slug}/download`, '_top'); } + refreshGame(slug: String): Observable { + return this.http.get(`${this.apiPath}/game/${slug}/refresh`); + } + getGameOverviews(): Observable { return this.http.get(`${this.apiPath}/game-overviews`); } From 46b8ddcd2aa638422934eb28e06f6aa69d60dd88 Mon Sep 17 00:00:00 2001 From: shawly Date: Thu, 20 Oct 2022 00:34:19 +0200 Subject: [PATCH 3/5] fix(game-detail): prevent overflow of long company logos Fixes grimsi/gameyfin#55 --- .../game-detail-view/game-detail-view.component.html | 6 +++--- .../game-detail-view/game-detail-view.component.scss | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) 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 8d2d39b..692a2cf 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 @@ -3,7 +3,7 @@
- +
Game cover
@@ -20,8 +20,8 @@

Developed by

-
- {{company.name}} +
+ {{company.name}}
diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.scss b/frontend/src/app/components/game-detail-view/game-detail-view.component.scss index e69de29..fda9bd6 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.scss +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.scss @@ -0,0 +1,8 @@ +.mat-card { +// min-height: max-content; + + .company-logos img { + max-height: 52px; + max-width: 260px; + } +} From 0c9eb90f5aba20f77a7c6e1af0d6bb5807e31c23 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Fri, 21 Oct 2022 17:30:57 +0200 Subject: [PATCH 4/5] Switch CI pipeline to SonarCloud --- .github/workflows/build.yml | 27 ++++++++++++++++----------- pom.xml | 5 +++++ sonar-project.properties | 2 -- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd19a95..bccdfcc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,14 +16,23 @@ jobs: steps: - name: Git checkout uses: actions/checkout@v3 - + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK uses: actions/setup-java@v3 with: java-version: '18' distribution: 'temurin' cache: 'maven' - + + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Extract Maven project version id: project run: echo "GAMEYFIN_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_OUTPUT @@ -31,15 +40,11 @@ jobs: - name: Show extracted Maven project version run: echo "${{ steps.project.outputs.GAMEYFIN_VERSION }}" - - name: Maven build - run: mvn --batch-mode --update-snapshots package - - - name: SonarQube scan - uses: kitabisa/sonarqube-action@v1.2.0 - with: - host: https://sonarqube.grimsi.de - login: ${{ secrets.SONARQUBE_TOKEN }} - projectKey: grimsi_gameyfin_AYPM67pzsxiaNzCh9BZd + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=grimsi_gameyfin - name: Upload build artifact uses: actions/upload-artifact@v3 diff --git a/pom.xml b/pom.xml index 4911d82..64af3b9 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,11 @@ backend + + grimsi-github + https://sonarcloud.io + + org.springframework.boot spring-boot-starter-parent diff --git a/sonar-project.properties b/sonar-project.properties index cb0b4b5..efe7579 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,2 @@ -sonar.projectKey=grimsi_gameyfin_AYPM67pzsxiaNzCh9BZd - # Point SONAR to the compiled Java classes sonar.java.binaries=./backend/target From 435ed2360e1aa9c427fce8c3611d915f1c61c746 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Fri, 21 Oct 2022 17:44:57 +0200 Subject: [PATCH 5/5] Generate test coverage report --- backend/pom.xml | 15 +++++++++++++++ pom.xml | 50 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/backend/pom.xml b/backend/pom.xml index 0842744..511520f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -223,6 +223,21 @@ + + + + org.jacoco + jacoco-maven-plugin + + + report + + report-aggregate + + verify + + + diff --git a/pom.xml b/pom.xml index 64af3b9..3bd169b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 de.grimsi @@ -18,30 +19,69 @@ grimsi-github https://sonarcloud.io + 0.8.8 + 2.5.3 org.springframework.boot spring-boot-starter-parent 2.7.4 - + scm:git:https://github.com/grimsi/gameyfin.git scm:git:https://github.com/grimsi/gameyfin.git scm:git:https://github.com/grimsi/gameyfin.git - v1.2.2 + + + coverage + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + + report + + report + + + + + + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + + org.apache.maven.plugins maven-release-plugin - 2.5.3 + ${maven-release-plugin.version} - [ci skip] + [ci skip] v@{project.version} frontend/package.json