diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e0d3a3c..08cd03b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ on: branches: - main pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened] workflow_dispatch: jobs: @@ -13,12 +13,12 @@ jobs: name: Build, Test & Scan runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[ci skip]')" - steps: + 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: @@ -42,7 +42,7 @@ jobs: - name: Extract Maven project version id: project run: echo "GAMEYFIN_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_OUTPUT - + - name: Build and analyze env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any diff --git a/backend/pom.xml b/backend/pom.xml index 0842744..5247c41 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -21,6 +21,8 @@ 1.21 3.11.4 3.21.7 + 5.0.0 + 0.4.0 3.1.0 @@ -117,6 +119,35 @@ spring-boot-starter-test test + + org.jeasy + easy-random-core + ${easy-random.version} + test + + + io.github.murdos + easy-random-protobuf + ${easy-random-protobuf.version} + test + + + com.squareup.okhttp3 + okhttp + test + + + com.squareup.okhttp3 + mockwebserver + test + + + com.google.jimfs + jimfs + 1.2 + test + + @@ -223,6 +254,22 @@ + + + + org.jacoco + jacoco-maven-plugin + 0.8.8 + + + report + + report-aggregate + + verify + + + diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java index c4bc180..c65d45b 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java @@ -9,7 +9,6 @@ import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; @@ -37,6 +36,12 @@ public class IgdbWrapper { @Value("${gameyfin.igdb.config.preferred-platforms:6}") private String preferredPlatforms; + @Value("${gameyfin.igdb.api.endpoints.base}") + private String igdbApiBaseUrl; + + @Value("${gameyfin.igdb.api.endpoints.auth}") + private String twitchAuthUrl; + private final WebClient.Builder webclientBuilder; private final WebClientConfig webClientConfig; private final GameMapper gameMapper; @@ -54,11 +59,12 @@ public class IgdbWrapper { initIgdbClient(); } - public void authenticate() { + private void authenticate() { log.info("Authenticating on Twitch API..."); URI url = UriComponentsBuilder - .fromHttpUrl("https://id.twitch.tv/oauth2/token?client_id={client_id}&client_secret={client_secret}&grant_type=client_credentials") + .fromHttpUrl(twitchAuthUrl) + .query("client_id={client_id}").query("client_secret={client_secret}").query("grant_type=client_credentials") .buildAndExpand(clientId, clientSecret) .toUri(); @@ -104,7 +110,7 @@ public class IgdbWrapper { Igdb.GameResult.class ); - if(gameResult == null) return Collections.emptyList(); + if (gameResult == null) return Collections.emptyList(); return gameResult.getGamesList().stream().map(gameMapper::toAutocompleteSuggestionDto).toList(); } @@ -122,10 +128,10 @@ public class IgdbWrapper { // Try to remove brackets (and their content) at the end of the search term and search again // Although this process is recursive, we will only end up with a maximum recursion depth of two - Pattern brackets = Pattern.compile ("[()<>{}\\[\\]]"); + Pattern brackets = Pattern.compile("[()<>{}\\[\\]]"); Matcher hasBrackets = brackets.matcher(searchTerm); - if(hasBrackets.find()) { + if (hasBrackets.find()) { String searchTermWithoutBrackets = searchTerm.split(brackets.pattern())[0].trim(); log.warn("Trying again with search term '{}'", searchTermWithoutBrackets); return searchForGameByTitle(searchTermWithoutBrackets); @@ -163,7 +169,7 @@ public class IgdbWrapper { } igdbApiClient = webclientBuilder - .baseUrl("https://api.igdb.com/v4/") + .baseUrl(igdbApiBaseUrl) .defaultHeader("Client-ID", clientId) .defaultHeader("Authorization", "Bearer %s".formatted(accessToken.getAccessToken())) .filter(WebClientConfig.fixProtobufContentTypeInterceptor()) diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/dto/TwitchOAuthTokenDto.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/dto/TwitchOAuthTokenDto.java index b971883..64fb1fa 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/dto/TwitchOAuthTokenDto.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/dto/TwitchOAuthTokenDto.java @@ -2,6 +2,8 @@ package de.grimsi.gameyfin.igdb.dto; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; 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..a8358f2 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; @@ -44,17 +49,33 @@ 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); 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)); } + @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/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/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/backend/src/main/resources/config/gameyfin.yml b/backend/src/main/resources/config/gameyfin.yml index 1d7eef8..e82b5ac 100644 --- a/backend/src/main/resources/config/gameyfin.yml +++ b/backend/src/main/resources/config/gameyfin.yml @@ -4,6 +4,9 @@ gameyfin: file-extensions: iso, zip, rar, 7z, exe igdb: api: + endpoints: + auth: https://id.twitch.tv/oauth2/token + base: https://api.igdb.com/v4/ client-id: client-secret: max-concurrent-requests: 2 diff --git a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java new file mode 100644 index 0000000..458ed02 --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java @@ -0,0 +1,305 @@ +package de.grimsi.gameyfin.igdb; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.Message; +import com.google.protobuf.Timestamp; +import com.igdb.proto.Igdb; +import de.grimsi.gameyfin.config.WebClientConfig; +import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto; +import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto; +import de.grimsi.gameyfin.mapper.GameMapper; +import io.github.resilience4j.bulkhead.Bulkhead; +import io.github.resilience4j.bulkhead.BulkheadConfig; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okio.Buffer; +import org.jeasy.random.EasyRandom; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class IgdbWrapperTest { + + private static final MockWebServer igdbApiMock = new MockWebServer(); + private static final MockWebServer twitchApiMock = new MockWebServer(); + private static final EasyRandom easyRandom = new EasyRandom(); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static IgdbWrapper target; + + @BeforeAll + static void setup() throws IOException, InterruptedException { + WebClientConfig webClientConfigMock = mock(WebClientConfig.class); + GameMapper gameMapperMock = mock(GameMapper.class); + + target = new IgdbWrapper(WebClient.builder(), webClientConfigMock, gameMapperMock); + + igdbApiMock.start(); + twitchApiMock.start(); + + ReflectionTestUtils.setField(target, "clientId", "client_id_value"); + ReflectionTestUtils.setField(target, "clientSecret", "client_secret_value"); + ReflectionTestUtils.setField(target, "igdbApiBaseUrl", "http://localhost:%s".formatted(igdbApiMock.getPort())); + ReflectionTestUtils.setField(target, "twitchAuthUrl", "http://localhost:%s/oauth2/token".formatted(twitchApiMock.getPort())); + ReflectionTestUtils.setField(target, "preferredPlatforms", "preferred_platforms"); + + when(webClientConfigMock.getIgdbConcurrencyLimiter()).thenReturn(Bulkhead.of("test_bulkhead", BulkheadConfig.ofDefaults())); + when(webClientConfigMock.getIgdbRateLimiter()).thenReturn(RateLimiter.of("test_ratelimiter", RateLimiterConfig.ofDefaults())); + + TwitchOAuthTokenDto mockToken = easyRandom.nextObject(TwitchOAuthTokenDto.class); + + twitchApiMock.enqueue(new MockResponse() + .setBody(objectMapper.writeValueAsString(mockToken)) + .addHeader("Content-Type", "application/json")); + + target.init(); + + RecordedRequest r = twitchApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/oauth2/token"); + assertThat(r.getRequestUrl().queryParameter("client_id")).isEqualTo("client_id_value"); + assertThat(r.getRequestUrl().queryParameter("client_secret")).isEqualTo("client_secret_value"); + assertThat(r.getRequestUrl().queryParameter("grant_type")).isEqualTo("client_credentials"); + + twitchApiMock.shutdown(); + } + + @AfterAll + static void tearDown() throws IOException { + igdbApiMock.shutdown(); + } + + @Test + void getGameById() throws InterruptedException { + //Igdb.GameResult gameResult = easyRandom.nextObject(Igdb.GameResult.class); + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build(), + Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build(), + Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build())) + .build(); + + Long gameId = gameResult.getGames(0).getId(); + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.getGameById(gameId); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + assertThat(game.getId()).isEqualTo(gameId); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "fields %s; where id = %d; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameId); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); + } + + @Test + void getGameBySlug() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setSlug("game_slug_1").build(), + Igdb.Game.newBuilder().setSlug("game_slug_2").build(), + Igdb.Game.newBuilder().setSlug("game_slug_3").build())) + .build(); + + String gameSlug = gameResult.getGames(0).getSlug(); + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.getGameBySlug("game_slug_1"); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + assertThat(game.getSlug()).isEqualTo(gameSlug); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "fields %s; where slug = \"%s\"; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameSlug); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); + } + + @Test + void findPossibleMatchingTitles() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setName("title_1").build(), + Igdb.Game.newBuilder().setName("title_2").build(), + Igdb.Game.newBuilder().setName("title_3").build())) + .build(); + + String gameTitle = gameResult.getGames(0).getName(); + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + List suggestions = target.findPossibleMatchingTitles(gameTitle, gameResult.getGamesCount()); + + assertThat(suggestions).hasSize(gameResult.getGamesCount()); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "search \"%s\"; fields slug,name,first_release_date; where platforms = (preferred_platforms); limit %d;".formatted(gameTitle, gameResult.getGamesCount()); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); + } + + @Test + void searchForGameByTitle_exactMatch() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setName("title_1").build(), + Igdb.Game.newBuilder().setName("title_2").build(), + Igdb.Game.newBuilder().setName("title_3").build())) + .build(); + + String searchTerm = gameResult.getGames(0).getName(); + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.searchForGameByTitle(searchTerm); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + assertThat(game.getName()).isEqualTo(searchTerm); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); + } + + @Test + void searchForGameByTitle_EndsWith() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setName("some_prefix title_1").build(), + Igdb.Game.newBuilder().setName("title_2)").build(), + Igdb.Game.newBuilder().setName("title_3").build())) + .build(); + + String searchTerm = "title_1"; + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.searchForGameByTitle(searchTerm); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + assertThat(game.getName()).isEqualTo(gameResult.getGames(0).getName()); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); + } + + @Test + void searchForGameByTitle_Brackets() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setName("title_1").build(), + Igdb.Game.newBuilder().setName("title_2").build(), + Igdb.Game.newBuilder().setName("title_3").build())) + .build(); + + String searchTerm = gameResult.getGames(0).getName() + " (Text in brackets should be ignored)"; + + // First request should result in an empty response + igdbApiMock.enqueue(new MockResponse().setHeader("Content-Type", "application/protobuf")); + + // Second request should contain the same query, but with brackets removed + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.searchForGameByTitle(searchTerm); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + // Result should be game with title equal to search term with brackets removed + assertThat(game.getName()).isEqualTo(gameResult.getGames(0).getName()); + + // First query (should contain brackets) + RecordedRequest r1 = igdbApiMock.takeRequest(); + assertThat(r1.getRequestUrl()).isNotNull(); + assertThat(r1.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String r1_expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING); + + assertThat(r1.getBody().readUtf8()).isEqualTo(r1_expectedQuery); + + // Second query (should not contain brackets) + RecordedRequest r2 = igdbApiMock.takeRequest(); + assertThat(r2.getRequestUrl()).isNotNull(); + assertThat(r2.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String r2_expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(gameResult.getGames(0).getName(), IgdbApiProperties.GAME_QUERY_FIELDS_STRING); + + assertThat(r2.getBody().readUtf8()).isEqualTo(r2_expectedQuery); + } + + private static Buffer toBuffer(Message input) { + Buffer b = new Buffer(); + b.write(input.toByteArray()); + return b; + } +} diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java new file mode 100644 index 0000000..129c388 --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java @@ -0,0 +1,54 @@ +package de.grimsi.gameyfin.mapper; + +import com.igdb.proto.Igdb; +import de.grimsi.gameyfin.entities.Company; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class CompanyMapperTest extends RandomMapperTest { + + @Test + @Disabled + void toCompany() { + Igdb.InvolvedCompany input = generateRandomInput(); + + Company output = CompanyMapper.toCompany(input); + + assertThat(output.getSlug()).isEqualTo(input.getCompany().getSlug()); + assertThat(output.getName()).isEqualTo(input.getCompany().getName()); + assertThat(output.getLogoId()).isEqualTo(input.getCompany().getLogo().getImageId()); + } + + @Test + @Disabled + void toCompanies() { + List input = List.of(generateRandomInput(), generateRandomInput(), generateRandomInput()); + + List output = CompanyMapper.toCompanies(input); + + for (int i = 0; i < output.size(); i++) { + assertThat(output.get(i).getSlug()).isEqualTo(input.get(i).getCompany().getSlug()); + assertThat(output.get(i).getName()).isEqualTo(input.get(i).getCompany().getName()); + assertThat(output.get(i).getLogoId()).isEqualTo(input.get(i).getCompany().getLogo().getImageId()); + } + } + + private static Igdb.InvolvedCompany generateRandomInvolvedCompany() { + Igdb.Company c = Igdb.Company.newBuilder() + .setSlug(UUID.randomUUID().toString()) + .setName(UUID.randomUUID().toString()) + .setLogo(Igdb.CompanyLogo.newBuilder() + .setImageId(UUID.randomUUID().toString()) + .build()) + .build(); + + return Igdb.InvolvedCompany.newBuilder() + .setCompany(c) + .build(); + } +} diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java new file mode 100644 index 0000000..e60cab8 --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java @@ -0,0 +1,37 @@ +package de.grimsi.gameyfin.mapper; + +import com.igdb.proto.Igdb; +import de.grimsi.gameyfin.entities.Genre; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class GenreMapperTest extends RandomMapperTest { + + @Test + @Disabled + void toGenre() { + Igdb.Genre input = generateRandomInput(); + + Genre output = GenreMapper.toGenre(input); + + assertThat(output.getSlug()).isEqualTo(input.getSlug()); + assertThat(output.getName()).isEqualTo(input.getName()); + } + + @Test + @Disabled + void toGenres() { + List input = List.of(generateRandomInput(), generateRandomInput(), generateRandomInput()); + + List output = GenreMapper.toGenres(input); + + for (int i = 0; i < output.size(); i++) { + assertThat(output.get(i).getSlug()).isEqualTo(input.get(i).getSlug()); + assertThat(output.get(i).getName()).isEqualTo(input.get(i).getName()); + } + } +} diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java new file mode 100644 index 0000000..172b853 --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java @@ -0,0 +1,49 @@ +package de.grimsi.gameyfin.mapper; + +import com.google.protobuf.Message; +import org.jeasy.random.EasyRandom; +import org.jeasy.random.EasyRandomParameters; +import org.springframework.core.GenericTypeResolver; + +import java.util.List; + +public class RandomMapperTest { + + private static final int DEFAULT_COUNT = 5; + private final EasyRandom easyRandom = new EasyRandom(); + + private final Class inputClass; + private final Class outputClass; + + @SuppressWarnings("unchecked") + public RandomMapperTest() { + Class[] typeArguments = GenericTypeResolver.resolveTypeArguments(getClass(), RandomMapperTest.class); + assert typeArguments != null; + inputClass = (Class) typeArguments[0]; + outputClass = (Class) typeArguments[1]; + } + + protected Input generateRandomInput() { + return easyRandom.nextObject(inputClass); + } + + protected List generateRandomInputs() { + return easyRandom.objects(inputClass, DEFAULT_COUNT).toList(); + } + + protected List generateRandomInputs(int count) { + return easyRandom.objects(inputClass, count).toList(); + } + + protected Output generateRandomOutput() { + return easyRandom.nextObject(outputClass); + } + + protected List generateRandomOutputs() { + return easyRandom.objects(outputClass, DEFAULT_COUNT).toList(); + } + + protected List generateRandomOutputs(int count) { + return easyRandom.objects(outputClass, count).toList(); + } +} diff --git a/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java b/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java new file mode 100644 index 0000000..306dcf5 --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java @@ -0,0 +1,124 @@ +package de.grimsi.gameyfin.util; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +class FilenameUtilTest { + + private static final FileSystem unixFS = Jimfs.newFileSystem(Configuration.unix()); + private static final FileSystem osxFS = Jimfs.newFileSystem(Configuration.osX()); + private static final FileSystem winFS = Jimfs.newFileSystem(Configuration.windows()); + + private static final List gameFileExtensions = List.of("extension_1", "extension_2", "extension_3"); + + @BeforeAll + static void init() { + new FilenameUtil().setPossibleGameFileExtensions(gameFileExtensions); + } + + @AfterAll + static void closeFileSystems() throws IOException { + unixFS.close(); + osxFS.close(); + winFS.close(); + } + + @ParameterizedTest + @MethodSource("fileSystems") + void getFilenameWithoutExtension_File(FileSystem fileSystem) throws IOException { + String filename = "example_file"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0))); + Files.createFile(p); + + String result = FilenameUtil.getFilenameWithoutExtension(p); + + assertThat(result).isEqualTo(filename); + + Files.deleteIfExists(p); + } + + @ParameterizedTest + @MethodSource("fileSystems") + void getFilenameWithoutExtension_Folder(FileSystem fileSystem) throws IOException { + String filename = "example_folder"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0))); + Files.createDirectory(p); + + String result = FilenameUtil.getFilenameWithoutExtension(p); + + assertThat(result).isEqualTo("%s.%s".formatted(filename, gameFileExtensions.get(0))); + + Files.deleteIfExists(p); + } + + @ParameterizedTest + @MethodSource("fileSystems") + void getFilenameWithExtension(FileSystem fileSystem) throws IOException { + String filename = "example_file"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0))); + Files.createFile(p); + + String result = FilenameUtil.getFilenameWithExtension(p); + + assertThat(result).isEqualTo("%s.%s".formatted(filename, gameFileExtensions.get(0))); + + Files.deleteIfExists(p); + } + + @ParameterizedTest + @MethodSource("fileSystems") + void hasGameArchiveExtension_gameArchive(FileSystem fileSystem) throws IOException { + String filename = "example_file"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0))); + Files.createFile(p); + + assertThat(FilenameUtil.hasGameArchiveExtension(p)).isTrue(); + + Files.deleteIfExists(p); + } + + @ParameterizedTest + @MethodSource("fileSystems") + void hasGameArchiveExtension_notGameArchive(FileSystem fileSystem) throws IOException { + String filename = "example_file"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, "some_other_extension")); + Files.createFile(p); + + assertThat(FilenameUtil.hasGameArchiveExtension(p)).isFalse(); + + Files.deleteIfExists(p); + } + + private static Stream fileSystems() { + return Stream.of( + arguments(named("Unix", unixFS)), + arguments(named("OSX", osxFS)), + arguments(named("Windows", winFS)) + ); + } +} \ No newline at end of file diff --git a/backend/src/test/java/de/grimsi/gameyfin/util/ProtobufUtilTest.java b/backend/src/test/java/de/grimsi/gameyfin/util/ProtobufUtilTest.java new file mode 100644 index 0000000..240b53b --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/util/ProtobufUtilTest.java @@ -0,0 +1,20 @@ +package de.grimsi.gameyfin.util; + +import com.google.protobuf.Timestamp; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProtobufUtilTest { + + @Test + void toInstant() { + Timestamp t = Timestamp.newBuilder().setSeconds(1).build(); + + Instant i = ProtobufUtil.toInstant(t); + + assertThat(i.getEpochSecond()).isEqualTo(1); + } +} \ No newline at end of file diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..c02f7cf --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,5 @@ +gameyfin: + igdb: + api: + client-id: igdb_client_id + client-secret: igdb_client_secret \ No newline at end of file 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", 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..f5b284b 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}}
@@ -33,6 +33,9 @@
+ 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; + } +} 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`); } diff --git a/pom.xml b/pom.xml index c2c5cf9..d8f8f43 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 de.grimsi @@ -19,7 +20,7 @@ org.springframework.boot spring-boot-starter-parent 2.7.4 - + @@ -27,6 +28,11 @@ scm:git:https://github.com/grimsi/gameyfin.git scm:git:https://github.com/grimsi/gameyfin.git + + + grimsi-github + https://sonarcloud.io + grimsi-github diff --git a/sonar-project.properties b/sonar-project.properties index efe7579..94f3f62 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,5 @@ # Point SONAR to the compiled Java classes sonar.java.binaries=./backend/target + +# Point SONAR to the JaCoCo report +sonar.coverage.jacoco.xmlReportPaths=./backend/target/site/jacoco-aggregate/jacoco.xml