diff --git a/src/main/java/core_service/configuration/CacheConfig.java b/src/main/java/core_service/configuration/CacheConfig.java index 219f0f2..be4c21d 100644 --- a/src/main/java/core_service/configuration/CacheConfig.java +++ b/src/main/java/core_service/configuration/CacheConfig.java @@ -17,7 +17,8 @@ public class CacheConfig { @Bean public CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary"); + CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary", + "igdbSearch"); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(1000)); diff --git a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java index 632bc60..cebc111 100644 --- a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java +++ b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java @@ -27,6 +27,41 @@ public final class IgdbResponses { @JsonProperty("uid") String uid ) {} + /** + * @param firstReleaseDate Unix seconds + * @param externalGames the same game on other services, e.g. Steam + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Game( + @JsonProperty("id") Long id, + @JsonProperty("name") String name, + @JsonProperty("first_release_date") Long firstReleaseDate, + @JsonProperty("cover") Image cover, + @JsonProperty("artworks") java.util.List artworks, + @JsonProperty("screenshots") java.util.List screenshots, + @JsonProperty("platforms") java.util.List platforms, + @JsonProperty("external_games") java.util.List externalGames + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Image(@JsonProperty("image_id") String imageId) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Platform( + @JsonProperty("name") String name, + @JsonProperty("abbreviation") String abbreviation + ) {} + + /** + * @param source the service, 1 is Steam + * @param uid the id on that service + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExternalGameSource( + @JsonProperty("external_game_source") Long source, + @JsonProperty("uid") String uid + ) {} + /** * Times in seconds; each can be missing. * diff --git a/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java b/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java index f55a0ac..4715cfc 100644 --- a/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java +++ b/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java @@ -3,18 +3,29 @@ package core_service.modules.igdb_adapter.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import java.time.Duration; + @Configuration public class IgdbClientConfig { @Bean public RestClient igdbRestClient(@Value("${app.igdb.base-url}") String baseUrl) { - return RestClient.builder().baseUrl(baseUrl).build(); + return RestClient.builder().baseUrl(baseUrl).requestFactory(requestFactory()).build(); } @Bean public RestClient twitchRestClient(@Value("${app.igdb.token-base-url}") String tokenBaseUrl) { - return RestClient.builder().baseUrl(tokenBaseUrl).build(); + return RestClient.builder().baseUrl(tokenBaseUrl).requestFactory(requestFactory()).build(); + } + + // The search, the add and the detail page call IGDB on a request thread, so a hanging IGDB must not hold it. + private static SimpleClientHttpRequestFactory requestFactory() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(3)); + requestFactory.setReadTimeout(Duration.ofSeconds(10)); + return requestFactory; } } diff --git a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java index fe85dfe..dc95807 100644 --- a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java +++ b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java @@ -6,6 +6,7 @@ import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; @@ -13,6 +14,7 @@ import org.springframework.web.client.RestClient; import java.time.Duration; import java.time.Instant; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -22,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Time to beat from IGDB (api-docs.igdb.com), found by Steam appid. Needs a Twitch developer app; without its @@ -36,6 +39,22 @@ public class IgdbApiService { */ public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {} + /** + * One IGDB game as the library needs it. + * + * @param year of the first release, null when unknown + * @param imageUrl a landscape artwork or screenshot, else the cover; null when IGDB has none + * @param platforms short platform names, e.g. "PS5", "Switch" + * @param steamAppid the same game on Steam, null when it is not there + */ + public record Game(long igdbId, String name, Integer year, String imageUrl, List platforms, Long steamAppid) {} + + private static final int SEARCH_LIMIT = 10; + private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id," + + "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source," + + "external_games.uid;"; + private static final String IMAGE_BASE = "https://images.igdb.com/igdb/image/upload/"; + // IGDB answers at most 500 rows per query, and one Steam app can have more than one external_games row. private static final int BATCH = 250; private static final int LIMIT = 500; @@ -89,6 +108,113 @@ public class IgdbApiService { } } + /** + * Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed. + */ + @Cacheable(value = "igdbSearch", key = "#query.trim().toLowerCase()", unless = "#result == null or #result.isEmpty()") + public Optional> searchGames(String query) { + if (!isEnabled()) { + return Optional.empty(); + } + try { + IgdbResponses.Game[] games = query("/games", "search \"" + searchText(query) + "\"; " + GAME_FIELDS + + " where version_parent = null; limit " + SEARCH_LIMIT + ";", IgdbResponses.Game[].class); + return Optional.of(Arrays.stream(games).map(IgdbApiService::toGame).flatMap(Optional::stream).toList()); + } catch (Exception e) { + log.error("Error searching IGDB games", e); + return Optional.empty(); + } + } + + /** + * Empty when the adapter is off, IGDB failed or has no such game. + */ + public Optional getGame(long igdbId) { + if (!isEnabled()) { + return Optional.empty(); + } + try { + IgdbResponses.Game[] games = query("/games", GAME_FIELDS + " where id = " + igdbId + ";", + IgdbResponses.Game[].class); + return Arrays.stream(games).findFirst().flatMap(IgdbApiService::toGame); + } catch (Exception e) { + log.error("Error reading IGDB game {}", igdbId, e); + return Optional.empty(); + } + } + + /** + * Like {@link #getTimeToBeat}, but for IGDB game ids. + */ + public Optional> getTimeToBeatByGameIds(Collection igdbIds) { + if (!isEnabled() || igdbIds.isEmpty()) { + return Optional.empty(); + } + try { + List ids = List.copyOf(igdbIds); + Map result = new HashMap<>(); + for (int from = 0; from < ids.size(); from += BATCH) { + String gameIds = ids.subList(from, Math.min(from + BATCH, ids.size())).stream() + .map(String::valueOf).collect(Collectors.joining(",")); + IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats", + "fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";", + IgdbResponses.TimeToBeat[].class); + for (IgdbResponses.TimeToBeat time : times) { + if (time.gameId() != null) { + result.putIfAbsent(time.gameId(), + new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely()))); + } + } + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with IGDB for time to beat", e); + return Optional.empty(); + } + } + + private static Optional toGame(IgdbResponses.Game game) { + if (game.id() == null || game.name() == null || game.name().isBlank()) { + return Optional.empty(); + } + Integer year = game.firstReleaseDate() == null ? null + : Instant.ofEpochSecond(game.firstReleaseDate()).atZone(ZoneOffset.UTC).getYear(); + List platforms = game.platforms() == null ? List.of() : game.platforms().stream() + .map(p -> p.abbreviation() != null && !p.abbreviation().isBlank() ? p.abbreviation() : p.name()) + .filter(name -> name != null && !name.isBlank()) + .distinct() + .toList(); + Long steamAppid = game.externalGames() == null ? null : game.externalGames().stream() + .filter(e -> e.source() != null && e.source() == STEAM_SOURCE && e.uid() != null && e.uid().matches("\\d{1,10}")) + .map(e -> Long.parseLong(e.uid())) + .findFirst() + .orElse(null); + return Optional.of(new Game(game.id(), game.name().trim(), year, imageUrl(game), platforms, steamAppid)); + } + + // The library shows wide pictures; a cover is tall and only the last choice. + private static String imageUrl(IgdbResponses.Game game) { + String wide = Stream.of(game.artworks(), game.screenshots()) + .filter(images -> images != null) + .flatMap(List::stream) + .map(IgdbResponses.Image::imageId) + .filter(id -> id != null && !id.isBlank()) + .findFirst() + .orElse(null); + if (wide != null) { + return IMAGE_BASE + "t_screenshot_med/" + wide + ".jpg"; + } + return game.cover() != null && game.cover().imageId() != null && !game.cover().imageId().isBlank() + ? IMAGE_BASE + "t_cover_big/" + game.cover().imageId() + ".jpg" + : null; + } + + // The text goes into an Apicalypse string. Quotes, backslashes and ";" could end it and add query parts, and no + // game name needs them for a search. + private static String searchText(String text) { + return text.replaceAll("[\"\\\\;\\p{Cntrl}]", " ").trim(); + } + private Map getBatch(List steamAppids) { String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(",")); IgdbResponses.ExternalGame[] externals = query("/external_games", diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index ea3215d..846da6d 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -62,14 +62,12 @@ public class LibraryController { @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { - if (principal.steamId() == null) { - return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false)); - } Long userId = principal.getIdAsLong(); - String steamId = String.valueOf(principal.steamId()); - syncService.syncIfStale(userId, steamId); + if (principal.steamId() != null) { + syncService.syncIfStale(userId, String.valueOf(principal.steamId())); + } long count = queryService.countOwnedGames(userId); - return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true)); + return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null)); } @GetMapping("/games") @@ -82,12 +80,10 @@ public class LibraryController { @RequestParam(required = false) String q, @RequestParam(required = false) Integer maxMinutes ) { - if (principal.steamId() == null) { - return ResponseEntity.ok(new PageDto<>(java.util.List.of(), 0, DEFAULT_SIZE, 0, 0)); - } Long userId = principal.getIdAsLong(); - String steamId = String.valueOf(principal.steamId()); - syncService.syncIfStale(userId, steamId); + if (principal.steamId() != null) { + syncService.syncIfStale(userId, String.valueOf(principal.steamId())); + } int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safePage = Math.max(0, page); @@ -128,11 +124,8 @@ public class LibraryController { @GetMapping("/games/{appid}") public ResponseEntity getGameDetail(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("appid") Long appid) { - if (principal.steamId() == null) { - return ResponseEntity.badRequest().body(Map.of("code", "NO_STEAM_ID", "message", "User has no Steam ID")); - } Long userId = principal.getIdAsLong(); - String steamId = String.valueOf(principal.steamId()); + String steamId = principal.steamId() != null ? String.valueOf(principal.steamId()) : null; return gameDetailService.getGameDetail(userId, steamId, appid) .>map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) diff --git a/src/main/java/core_service/modules/library/api/ManualGameController.java b/src/main/java/core_service/modules/library/api/ManualGameController.java new file mode 100644 index 0000000..fe0d2ec --- /dev/null +++ b/src/main/java/core_service/modules/library/api/ManualGameController.java @@ -0,0 +1,90 @@ +package core_service.modules.library.api; + +import core_service.modules.library.api.dto.AddGameRequest; +import core_service.modules.library.api.dto.AddedGameDto; +import core_service.modules.library.api.dto.GameSearchDto; +import core_service.modules.library.api.dto.ManualGameUpdate; +import core_service.modules.library.service.ManualGameService; +import core_service.shared.security.UserPrincipal; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.util.Map; + +/** + * Games added by hand from an IGDB search, next to the Steam library. + */ +@RestController +@RequestMapping("/api/library") +@RequiredArgsConstructor +public class ManualGameController { + + private static final Map NOT_FOUND = Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"); + private static final Map STEAM_GAME = Map.of("code", "STEAM_GAME", + "message", "Steam games come from the sync and cannot be changed here"); + + private final ManualGameService manualGameService; + + @GetMapping("/game-search") + public ResponseEntity search(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam("q") @Size(min = 2, max = 100) String query) { + return manualGameService.search(principal.getIdAsLong(), query) + .>map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(Map.of("code", "IGDB_FAILED", "message", "IGDB did not answer"))); + } + + @PostMapping("/games") + public ResponseEntity add(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody AddGameRequest request) { + int minutes = request.playtimeMinutes() != null ? request.playtimeMinutes() : 0; + return switch (manualGameService.add(principal.getIdAsLong(), request.igdbId(), request.platform().trim(), + request.status(), minutes)) { + case ManualGameService.AddResult.Added added -> ResponseEntity + .created(URI.create("/api/library/games/" + added.appid())) + .body(new AddedGameDto(added.appid())); + case ManualGameService.AddResult.AlreadyInLibrary ignored -> ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("code", "ALREADY_IN_LIBRARY", "message", "The game is already in the library")); + case ManualGameService.AddResult.UnknownGame ignored -> ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("code", "UNKNOWN_GAME", "message", "IGDB has no such game or did not answer")); + case ManualGameService.AddResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of("code", "IGDB_OFF", "message", "Adding games needs the IGDB app")); + }; + } + + @PutMapping("/games/{appid}/manual") + public ResponseEntity update(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") long appid, + @Valid @RequestBody ManualGameUpdate update) { + return toResponse(manualGameService.update(principal.getIdAsLong(), appid, update.platform().trim(), + update.playtimeMinutes())); + } + + @DeleteMapping("/games/{appid}") + public ResponseEntity remove(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") long appid) { + return toResponse(manualGameService.remove(principal.getIdAsLong(), appid)); + } + + private static ResponseEntity toResponse(ManualGameService.ChangeResult result) { + return switch (result) { + case DONE -> ResponseEntity.noContent().build(); + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND); + case STEAM_GAME -> ResponseEntity.status(HttpStatus.CONFLICT).body(STEAM_GAME); + }; + } +} diff --git a/src/main/java/core_service/modules/library/api/dto/AddGameRequest.java b/src/main/java/core_service/modules/library/api/dto/AddGameRequest.java new file mode 100644 index 0000000..de88ba0 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AddGameRequest.java @@ -0,0 +1,21 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.GameStatus; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * Core reads name and picture from IGDB itself; the client only picks the game. + * + * @param platform where the user owns the game, e.g. "PS5" + * @param playtimeMinutes typed by the user, null for 0 + */ +public record AddGameRequest( + @NotNull @Min(1) Long igdbId, + @NotBlank @Size(max = ManualGameUpdate.MAX_PLATFORM_LENGTH) String platform, + @NotNull GameStatus status, + @Min(0) @Max(ManualGameUpdate.MAX_MINUTES) Integer playtimeMinutes +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/AddedGameDto.java b/src/main/java/core_service/modules/library/api/dto/AddedGameDto.java new file mode 100644 index 0000000..7350fd3 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AddedGameDto.java @@ -0,0 +1,3 @@ +package core_service.modules.library.api.dto; + +public record AddedGameDto(long appid) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index d2d37cb..09286f8 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -1,6 +1,7 @@ package core_service.modules.library.api.dto; import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import java.time.Instant; @@ -25,7 +26,10 @@ public record GameDetailDto( int achievementsRemaining, double completionPercent, List achievements, - TimeToBeatDto timeToBeat + TimeToBeatDto timeToBeat, + GameSource source, + String platform, + String imageUrl ) { /** diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index ec12771..c5d0f4e 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -1,12 +1,15 @@ package core_service.modules.library.api.dto; import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; /** * @param timeToBeatMinutes IGDB's normal time to beat, null when unknown * @param achievementsTotal null when the game has no achievements or they were not fetched yet * @param achievementsUnlocked null when not fetched yet or hidden by Steam + * @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games + * @param imageUrl picture for a game that has no Steam art; null means the Steam CDN has one */ public record GameDto( Long appid, @@ -18,5 +21,8 @@ public record GameDto( GamePriority priority, Integer timeToBeatMinutes, Integer achievementsUnlocked, - Integer achievementsTotal + Integer achievementsTotal, + GameSource source, + String platform, + String imageUrl ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java b/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java new file mode 100644 index 0000000..95dcbf3 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java @@ -0,0 +1,16 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +/** + * @param available false when Core has no IGDB app; then results is empty + */ +public record GameSearchDto(boolean available, List results) { + + /** + * @param appid the library key the game gets, see GameKeys + * @param inLibrary the user has this game already, from Steam or by hand + */ + public record Hit(long appid, long igdbId, String name, Integer year, String imageUrl, List platforms, + boolean inLibrary) {} +} diff --git a/src/main/java/core_service/modules/library/api/dto/ManualGameUpdate.java b/src/main/java/core_service/modules/library/api/dto/ManualGameUpdate.java new file mode 100644 index 0000000..2c056d2 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/ManualGameUpdate.java @@ -0,0 +1,21 @@ +package core_service.modules.library.api.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * The fields only a game added by hand has. Replaces both. + */ +public record ManualGameUpdate( + @NotBlank @Size(max = MAX_PLATFORM_LENGTH) String platform, + @NotNull @Min(0) @Max(MAX_MINUTES) Integer playtimeMinutes +) { + // 100 000 hours: more than anyone plays, and far below the int limit. + public static final int MAX_MINUTES = 6_000_000; + + // The size of the user_game.platform column. + public static final int MAX_PLATFORM_LENGTH = 32; +} diff --git a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java index 7311604..fc46b3d 100644 --- a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java @@ -23,5 +23,10 @@ public record PublicLibraryDto( Double averageCompletion ) { - public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {} + /** + * @param platform see {@link GameDto#platform()} + * @param imageUrl see {@link GameDto#imageUrl()} + */ + public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating, + String platform, String imageUrl) {} } diff --git a/src/main/java/core_service/modules/library/domain/GameKeys.java b/src/main/java/core_service/modules/library/domain/GameKeys.java new file mode 100644 index 0000000..6aa3f1d --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameKeys.java @@ -0,0 +1,23 @@ +package core_service.modules.library.domain; + +/** + * The library key of a game, stored as "appid": the Steam appid for a game on Steam, minus the IGDB game id for a + * game that is not on Steam. Steam appids are always positive, so the two cannot meet. + */ +public final class GameKeys { + + private GameKeys() { + } + + public static boolean isSteam(long key) { + return key > 0; + } + + public static long ofIgdb(long igdbId) { + return -igdbId; + } + + public static long igdbId(long key) { + return -key; + } +} diff --git a/src/main/java/core_service/modules/library/domain/GameSource.java b/src/main/java/core_service/modules/library/domain/GameSource.java new file mode 100644 index 0000000..2c449a0 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameSource.java @@ -0,0 +1,11 @@ +package core_service.modules.library.domain; + +/** + * Where a library entry comes from. The Steam sync owns STEAM rows; it never removes or changes a MANUAL row, + * except that it takes over a MANUAL row once the user owns that game on Steam. A MANUAL row has no Steam + * achievements, even under a Steam appid: the user owns the game elsewhere. + */ +public enum GameSource { + STEAM, + MANUAL +} diff --git a/src/main/java/core_service/modules/library/domain/SteamGame.java b/src/main/java/core_service/modules/library/domain/SteamGame.java index 6df1b23..f538fb8 100644 --- a/src/main/java/core_service/modules/library/domain/SteamGame.java +++ b/src/main/java/core_service/modules/library/domain/SteamGame.java @@ -31,6 +31,9 @@ public class SteamGame { @Column(name = "last_seen_at", nullable = false) private Instant lastSeenAt; + @Column(name = "image_url", length = 512) + private String imageUrl; + public SteamGame(Long appid, String name, String imgIconUrl, Instant lastSeenAt) { this.appid = appid; this.name = name; diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 0e12cc5..1c7b74e 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -62,6 +62,14 @@ public class UserGame { @Column(name = "priority", length = 8) private GamePriority priority; + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 8) + private GameSource source = GameSource.STEAM; + + /** The platform the user owns a MANUAL game on, e.g. "PS5". Null for Steam games. */ + @Column(name = "platform", length = 32) + private String platform; + public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) { this.userId = userId; this.appid = appid; @@ -70,4 +78,13 @@ public class UserGame { this.lastSeenAt = lastSeenAt; this.status = GameStatus.initialFor(playtimeMinutes); } + + public static UserGame manual(Long userId, Long appid, String platform, GameStatus status, int playtimeMinutes, + Instant now) { + UserGame game = new UserGame(userId, appid, playtimeMinutes, now, now); + game.source = GameSource.MANUAL; + game.platform = platform; + game.status = status; + return game; + } } diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index d991d53..519dd55 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -23,8 +23,20 @@ import java.util.Optional; public interface UserGameRepository extends JpaRepository { + /** + * A key no game has: Steam appids are positive and a game off Steam is minus its IGDB id, which starts at 1. + */ + long NO_GAME = 0; + List findByUserIdAndRemovedAtIsNull(Long userId); + @Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.appid in :appids") + List findActiveAppids(@Param("userId") Long userId, @Param("appids") Collection appids); + + @Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null " + + "and ug.source = core_service.modules.library.domain.GameSource.MANUAL") + List findActiveManualAppids(@Param("userId") Long userId); + long countByUserIdAndRemovedAtIsNull(Long userId); @Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null") @@ -42,7 +54,7 @@ public interface UserGameRepository extends JpaRepository List countByStatus(@Param("userId") Long userId); /** - * @param excludeAppid never picked; -1 excludes nothing + * @param excludeAppid never picked; {@link #NO_GAME} excludes nothing */ @Query(value = "select * from user_game where user_id = :userId and removed_at is null and status = 'BACKLOG' " + "and appid <> :excludeAppid order by random() limit 1", nativeQuery = true) @@ -53,7 +65,8 @@ public interface UserGameRepository extends JpaRepository "from UserGame ug " + "left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " + "left join UserGameAchievementMeta um on um.userId = ug.userId and um.appid = ug.appid " + - "where ug.userId = :userId and ug.removedAt is null") + "where ug.userId = :userId and ug.removedAt is null " + + "and ug.source = core_service.modules.library.domain.GameSource.STEAM") List findAchievementProgress(@Param("userId") Long userId); /** @@ -64,8 +77,8 @@ public interface UserGameRepository extends JpaRepository */ default List findPickerGames(Long userId, Integer maxMinutes, Collection excludeAppids, int count) { List excluded = new ArrayList<>(excludeAppids); - // "not in ()" is invalid SQL, and no appid is -1 - excluded.add(-1L); + // "not in ()" is invalid SQL + excluded.add(NO_GAME); return queryPickerGames(userId, maxMinutes, excluded, count); } @@ -134,9 +147,13 @@ public interface UserGameRepository extends JpaRepository return "%" + escaped + "%"; } + /** + * Removes the Steam games the last sync did not see. Games added by hand never come from Steam and stay. + */ @Modifying @Query("update UserGame ug set ug.removedAt = :now " + - "where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null") + "where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null " + + "and ug.source = core_service.modules.library.domain.GameSource.STEAM") int softDeleteMissingSince(@Param("userId") Long userId, @Param("syncStartedAt") Instant syncStartedAt, @Param("now") Instant now); diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java index 01d869e..33f4638 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -2,6 +2,7 @@ package core_service.modules.library.service; import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievement; import core_service.modules.library.domain.UserGameAchievementMeta; @@ -133,6 +134,7 @@ public class AchievementSyncService { private void runLibrarySync(Long userId, String steamId) { List games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream() + .filter(game -> game.getSource() == GameSource.STEAM) .sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed()) .toList(); Map schemas = schemaMetaRepository diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 75bb733..9a7fe78 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -5,6 +5,7 @@ import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievement; @@ -41,6 +42,7 @@ public class GameDetailService { private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final AchievementSyncService achievementSyncService; private final GameTimeToBeatRepository gameTimeToBeatRepository; + private final TimeToBeatService timeToBeatService; @Value("${app.library.achievement-ttl-hours:1}") private long achievementTtlHours; @@ -48,6 +50,8 @@ public class GameDetailService { /** * Not transactional on purpose: the refreshes commit in their own transactions, and the reads after them must * see those rows, not a cached copy from a transaction that started before the refresh. + * + * @param steamId null when the user has none; then the stored achievements are shown without a refresh */ public Optional getGameDetail(Long userId, String steamId, Long appid) { Optional userGameOpt = userGameRepository.findById(new UserGameId(userId, appid)); @@ -56,6 +60,11 @@ public class GameDetailService { } UserGame userGame = userGameOpt.get(); SteamGame meta = steamGameRepository.findById(appid).orElse(null); + if (userGame.getSource() == GameSource.MANUAL) { + // Users without a Steam ID never sync, so the time to beat of their games is asked again here. + timeToBeatService.refreshInBackground(List.of(appid)); + return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of())); + } try { achievementSyncService.refreshSchemaIfStale(appid); @@ -68,11 +77,13 @@ public class GameDetailService { return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of())); } - try { - achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(), - Duration.ofHours(achievementTtlHours)); - } catch (Exception e) { - log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage()); + if (steamId != null) { + try { + achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(), + Duration.ofHours(achievementTtlHours)); + } catch (Exception e) { + log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage()); + } } UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null); @@ -132,7 +143,10 @@ public class GameDetailService { achievements, gameTimeToBeatRepository.findById(userGame.getAppid()) .map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes())) - .orElse(null) + .orElse(null), + userGame.getSource(), + userGame.getPlatform(), + meta != null ? meta.getImageUrl() : null ); } } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 0dcd2d5..0b76773 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -11,6 +11,7 @@ import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; @@ -153,7 +154,7 @@ public class LibraryQueryService { Map byStatus = countByStatus(userId); long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0); - GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1) + GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : UserGameRepository.NO_GAME) .map(ug -> toDtos(List.of(ug)).getFirst()) .orElse(null); return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId)); @@ -219,7 +220,8 @@ public class LibraryQueryService { return games.stream() .map(ug -> new PublicLibraryDto.Game(ug.getAppid(), meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null, - ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating())) + ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), ug.getPlatform(), + meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null)) .toList(); } @@ -232,8 +234,8 @@ public class LibraryQueryService { private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) { - Integer achievementsTotal = schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0 - ? schema.getAchievementsTotal() : null; + Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema() + && schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null; Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() ? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null; return new GameDto( @@ -246,7 +248,10 @@ public class LibraryQueryService { ug.getPriority(), time != null ? time.getNormallyMinutes() : null, achievementsUnlocked, - achievementsTotal); + achievementsTotal, + ug.getSource(), + ug.getPlatform(), + meta != null ? meta.getImageUrl() : null); } public Optional getSyncStatus(Long userId) { diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 8288155..d04a82b 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -1,5 +1,6 @@ package core_service.modules.library.service; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; @@ -24,6 +25,7 @@ import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -116,7 +118,10 @@ public class LibrarySyncWorker { log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed); } userSteamSyncRepository.markHistoryBaseline(userId, steamId); - timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList()); + // Also the games added by hand: here a failed refresh from the add is asked again. + List timeToBeatAppids = new ArrayList<>(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList()); + timeToBeatAppids.addAll(userGameRepository.findActiveManualAppids(userId)); + timeToBeatService.refresh(timeToBeatAppids); eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId)); finalStatus = Status.OK; } catch (Exception e) { @@ -179,6 +184,8 @@ public class LibrarySyncWorker { Long appid = g.appid().longValue(); UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null); int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0; + // Hours typed by hand are no Steam playtime, so the first Steam value of a game added by hand is a baseline. + boolean takesOverManualGame = existing != null && existing.getSource() == GameSource.MANUAL; int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0; int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0; if (existing == null) { @@ -190,9 +197,11 @@ public class LibrarySyncWorker { existing.setPlaytime2Weeks(playtime2Weeks); existing.setLastSeenAt(now); existing.setRemovedAt(null); + existing.setSource(GameSource.STEAM); + existing.setPlatform(null); } // A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted. - if (playedOn != null && playtime > previousPlaytime) { + if (playedOn != null && !takesOverManualGame && playtime > previousPlaytime) { playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime); } } diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java new file mode 100644 index 0000000..d3e8a20 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -0,0 +1,201 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.api.dto.GameSearchDto; +import core_service.modules.library.api.dto.ManualGameUpdate; +import core_service.modules.library.domain.GameKeys; +import core_service.modules.library.domain.GameSource; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Games the user adds by hand from IGDB, e.g. owned on PlayStation or Switch. See GameSource for how they live next + * to the Steam sync. + */ +@Service +@Slf4j +public class ManualGameService { + + public sealed interface AddResult { + record Added(long appid) implements AddResult {} + + record AlreadyInLibrary() implements AddResult {} + + record UnknownGame() implements AddResult {} + + record SearchUnavailable() implements AddResult {} + } + + public enum ChangeResult { DONE, NOT_FOUND, STEAM_GAME } + + private final IgdbApiService igdbApiService; + private final UserGameRepository userGameRepository; + private final SteamGameRepository steamGameRepository; + private final TimeToBeatService timeToBeatService; + private final TransactionTemplate transaction; + + public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository, + SteamGameRepository steamGameRepository, TimeToBeatService timeToBeatService, + PlatformTransactionManager transactionManager) { + this.igdbApiService = igdbApiService; + this.userGameRepository = userGameRepository; + this.steamGameRepository = steamGameRepository; + this.timeToBeatService = timeToBeatService; + this.transaction = new TransactionTemplate(transactionManager); + } + + /** + * @return empty when IGDB failed; an unavailable result when Core has no IGDB app + */ + public Optional search(Long userId, String query) { + if (!igdbApiService.isEnabled()) { + return Optional.of(new GameSearchDto(false, List.of())); + } + return igdbApiService.searchGames(query).map(games -> { + Set owned = new HashSet<>(userGameRepository.findActiveAppids(userId, + games.stream().flatMap(game -> possibleKeys(game).stream()).toList())); + return new GameSearchDto(true, games.stream() + .map(game -> new GameSearchDto.Hit(key(game), game.igdbId(), game.name(), game.year(), + game.imageUrl(), fittingPlatforms(game), possibleKeys(game).stream().anyMatch(owned::contains))) + .toList()); + }); + } + + /** + * Adds the game under its Steam appid when IGDB knows it on Steam: then the sync takes it over once the user owns + * it there, and it keeps one row. A game the user removed before comes back with its old status and notes. + */ + public AddResult add(Long userId, long igdbId, String platform, GameStatus status, int playtimeMinutes) { + if (!igdbApiService.isEnabled()) { + return new AddResult.SearchUnavailable(); + } + // Outside the transaction: no database connection waits for IGDB. + Optional found = igdbApiService.getGame(igdbId); + if (found.isEmpty()) { + return new AddResult.UnknownGame(); + } + IgdbApiService.Game game = found.get(); + long appid = key(game); + AddResult result; + try { + result = addInTransaction(userId, appid, game, platform, status, playtimeMinutes); + } catch (DataIntegrityViolationException e) { + // A parallel add or sync inserted the row after the check. The second try sees it. + log.info("Add of appid={} for user={} raced another insert, trying again", appid, userId); + try { + result = addInTransaction(userId, appid, game, platform, status, playtimeMinutes); + } catch (DataIntegrityViolationException again) { + return new AddResult.AlreadyInLibrary(); + } + } + if (result instanceof AddResult.Added) { + timeToBeatService.refreshInBackground(List.of(appid)); + } + return result; + } + + private AddResult addInTransaction(Long userId, long appid, IgdbApiService.Game game, String platform, + GameStatus status, int playtimeMinutes) { + return transaction.execute(tx -> { + Instant now = Instant.now(); + Optional existing = userGameRepository.findById(new UserGameId(userId, appid)); + if (existing.isPresent() && existing.get().getRemovedAt() == null) { + return new AddResult.AlreadyInLibrary(); + } + long igdbKey = GameKeys.ofIgdb(game.igdbId()); + if (appid != igdbKey && userGameRepository.findById(new UserGameId(userId, igdbKey)) + .filter(added -> added.getRemovedAt() == null).isPresent()) { + return new AddResult.AlreadyInLibrary(); + } + saveCatalogEntry(appid, game, now); + if (existing.isPresent()) { + UserGame revived = existing.get(); + revived.setSource(GameSource.MANUAL); + revived.setPlatform(platform); + revived.setStatus(status); + revived.setPlaytimeMinutes(playtimeMinutes); + revived.setPlaytime2Weeks(0); + revived.setLastSeenAt(now); + revived.setRemovedAt(null); + } else { + userGameRepository.save(UserGame.manual(userId, appid, platform, status, playtimeMinutes, now)); + } + return new AddResult.Added(appid); + }); + } + + public ChangeResult update(Long userId, long appid, String platform, int playtimeMinutes) { + return transaction.execute(tx -> changeManual(userId, appid, game -> { + game.setPlatform(platform); + game.setPlaytimeMinutes(playtimeMinutes); + })); + } + + /** + * Deletes the row with its status, rating and notes. Steam games cannot be removed: the next sync would add + * them again. + */ + public ChangeResult remove(Long userId, long appid) { + return transaction.execute(tx -> changeManual(userId, appid, userGameRepository::delete)); + } + + private ChangeResult changeManual(Long userId, long appid, Consumer change) { + Optional game = userGameRepository.findById(new UserGameId(userId, appid)) + .filter(found -> found.getRemovedAt() == null); + if (game.isEmpty()) { + return ChangeResult.NOT_FOUND; + } + if (game.get().getSource() != GameSource.MANUAL) { + return ChangeResult.STEAM_GAME; + } + change.accept(game.get()); + return ChangeResult.DONE; + } + + // A Steam row belongs to the sync, which has the better name and art; only a game off Steam takes IGDB's. + private void saveCatalogEntry(long appid, IgdbApiService.Game game, Instant now) { + Optional existing = steamGameRepository.findById(appid); + if (existing.isEmpty()) { + SteamGame created = new SteamGame(appid, game.name(), null, now); + if (!GameKeys.isSteam(appid)) { + created.setImageUrl(game.imageUrl()); + } + steamGameRepository.save(created); + } else if (!GameKeys.isSteam(appid)) { + existing.get().setName(game.name()); + existing.get().setImageUrl(game.imageUrl()); + existing.get().setLastSeenAt(now); + } + } + + // A longer name would not fit the column; with no platform left, the user types one. + private static List fittingPlatforms(IgdbApiService.Game game) { + return game.platforms().stream().filter(platform -> platform.length() <= ManualGameUpdate.MAX_PLATFORM_LENGTH).toList(); + } + + // IGDB can learn a game's Steam appid after the user added it under minus its IGDB id. + private static List possibleKeys(IgdbApiService.Game game) { + long igdbKey = GameKeys.ofIgdb(game.igdbId()); + return key(game) == igdbKey ? List.of(igdbKey) : List.of(key(game), igdbKey); + } + + private static long key(IgdbApiService.Game game) { + return game.steamAppid() != null ? game.steamAppid() : GameKeys.ofIgdb(game.igdbId()); + } +} diff --git a/src/main/java/core_service/modules/library/service/TimeToBeatService.java b/src/main/java/core_service/modules/library/service/TimeToBeatService.java index 14fa522..1b147ac 100644 --- a/src/main/java/core_service/modules/library/service/TimeToBeatService.java +++ b/src/main/java/core_service/modules/library/service/TimeToBeatService.java @@ -1,17 +1,19 @@ package core_service.modules.library.service; import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.domain.GameKeys; import core_service.modules.library.repo.GameTimeToBeatRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.time.Duration; import java.time.Instant; import java.util.Collection; +import java.util.HashMap; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.TreeSet; @@ -26,6 +28,18 @@ public class TimeToBeatService { @Value("${app.library.time-to-beat-ttl-days:30}") private long ttlDays; + /** + * Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it. + */ + @Async + public void refreshInBackground(Collection appids) { + try { + refresh(appids); + } catch (Exception e) { + log.warn("Time to beat of {} not refreshed: {}", appids, e.getMessage()); + } + } + /** * Asks IGDB for the apps without a fresh row and stores the answer, also "no data". Does nothing when IGDB is * off, and stores nothing when IGDB fails, so the apps are asked again on the next sync. @@ -37,14 +51,35 @@ public class TimeToBeatService { stale.removeAll(gameTimeToBeatRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays)))); if (stale.isEmpty()) return; - Optional> answer = igdbApiService.getTimeToBeat(stale); - if (answer.isEmpty()) { - log.warn("Time to beat for {} apps not refreshed, IGDB did not answer", stale.size()); - return; + Set steamAppids = new TreeSet<>(); + Set igdbIds = new TreeSet<>(); + for (Long appid : stale) { + if (GameKeys.isSteam(appid)) { + steamAppids.add(appid); + } else { + igdbIds.add(GameKeys.igdbId(appid)); + } + } + Map times = new HashMap<>(); + Set answered = new TreeSet<>(); + if (!steamAppids.isEmpty()) { + igdbApiService.getTimeToBeat(steamAppids).ifPresent(found -> { + times.putAll(found); + answered.addAll(steamAppids); + }); + } + if (!igdbIds.isEmpty()) { + igdbApiService.getTimeToBeatByGameIds(igdbIds).ifPresent(found -> { + found.forEach((igdbId, time) -> times.put(GameKeys.ofIgdb(igdbId), time)); + igdbIds.forEach(igdbId -> answered.add(GameKeys.ofIgdb(igdbId))); + }); + } + if (answered.size() < stale.size()) { + log.warn("Time to beat for {} apps not refreshed, IGDB did not answer", stale.size() - answered.size()); } Instant now = Instant.now(); - for (Long appid : stale) { - IgdbApiService.TimeToBeat time = answer.get().get(appid); + for (Long appid : answered) { + IgdbApiService.TimeToBeat time = times.get(appid); if (time == null) { gameTimeToBeatRepository.upsert(appid, null, null, null, now); } else { diff --git a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java index b86dd7f..b054a96 100644 --- a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java +++ b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java @@ -15,5 +15,8 @@ public record PublicStatsDto( List topOwnedGames, List topPlayedGames ) { - public record TopGame(Long appid, String name, String imgIconUrl, long value) {} + /** + * @param imageUrl picture for a game that has no Steam art, see GameDto + */ + public record TopGame(Long appid, String name, String imgIconUrl, String imageUrl, long value) {} } diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java index 6852b98..cd3d41a 100644 --- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -69,11 +69,11 @@ public class StatsRepository { public List> topOwnedGames(int limit) { return jdbc.queryForList(""" - SELECT ug.appid, sg.name, sg.img_icon_url, COUNT(*) AS value + SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value FROM user_game ug LEFT JOIN steam_game sg ON sg.appid = ug.appid WHERE ug.removed_at IS NULL - GROUP BY ug.appid, sg.name, sg.img_icon_url + GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? """, limit); @@ -81,11 +81,11 @@ public class StatsRepository { public List> topPlayedGames(int limit) { return jdbc.queryForList(""" - SELECT ug.appid, sg.name, sg.img_icon_url, SUM(ug.playtime_minutes) AS value + SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value FROM user_game ug LEFT JOIN steam_game sg ON sg.appid = ug.appid WHERE ug.removed_at IS NULL - GROUP BY ug.appid, sg.name, sg.img_icon_url + GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? """, limit); diff --git a/src/main/java/core_service/modules/stats/service/PublicStatsService.java b/src/main/java/core_service/modules/stats/service/PublicStatsService.java index 9639281..9ceaf28 100644 --- a/src/main/java/core_service/modules/stats/service/PublicStatsService.java +++ b/src/main/java/core_service/modules/stats/service/PublicStatsService.java @@ -53,6 +53,7 @@ public class PublicStatsService { ((Number) r.get("appid")).longValue(), (String) r.get("name"), (String) r.get("img_icon_url"), + (String) r.get("image_url"), ((Number) r.get("value")).longValue() )) .toList(); diff --git a/src/main/resources/db/migration/V8__Manual_Games.sql b/src/main/resources/db/migration/V8__Manual_Games.sql new file mode 100644 index 0000000..0873837 --- /dev/null +++ b/src/main/resources/db/migration/V8__Manual_Games.sql @@ -0,0 +1,8 @@ +-- Games the user adds by hand from IGDB, next to the Steam sync. The key stays appid: the Steam appid when IGDB +-- knows the game on Steam, else minus the IGDB game id. So steam_game now also holds games that are not on Steam. +ALTER TABLE user_game + ADD COLUMN source VARCHAR(8) NOT NULL DEFAULT 'STEAM', + ADD COLUMN platform VARCHAR(32); + +-- Picture for a game Steam has no art for, e.g. an IGDB artwork. +ALTER TABLE steam_game ADD COLUMN image_url VARCHAR(512); diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java index ee541e7..1369a4a 100644 --- a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java @@ -175,4 +175,95 @@ class IgdbApiServiceTest extends AbstractUnitTest { twitch.verify(); igdb.verify(); } + + private static final String ZELDA = "[{\"id\": 119388, \"name\": \"The Legend of Zelda: Tears of the Kingdom\"," + + " \"first_release_date\": 1683849600," + + " \"cover\": {\"id\": 1, \"image_id\": \"co5vmg\"}," + + " \"screenshots\": [{\"id\": 2, \"image_id\": \"sc1\"}]," + + " \"platforms\": [{\"id\": 130, \"name\": \"Nintendo Switch\", \"abbreviation\": \"Switch\"}]}," + + " {\"id\": 119133, \"name\": \"Elden Ring\"," + + " \"artworks\": [{\"id\": 3, \"image_id\": \"ar1\"}], \"screenshots\": [{\"id\": 4, \"image_id\": \"sc2\"}]," + + " \"platforms\": [{\"id\": 167, \"name\": \"PlayStation 5\", \"abbreviation\": \"PS5\"}," + + " {\"id\": 6, \"name\": \"PC (Microsoft Windows)\", \"abbreviation\": \"PC\"}]," + + " \"external_games\": [{\"id\": 5, \"external_game_source\": 5, \"uid\": \"x\"}," + + " {\"id\": 6, \"external_game_source\": 1, \"uid\": \"1245620\"}]}," + + " {\"id\": 7, \"name\": \" \"}]"; + + @Test + void searchGames_MapsNameYearPlatformsSteamAppAndAWidePicture() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andExpect(content().string(allOf(containsString("search \"zelda\";"), containsString("version_parent = null"), + containsString("limit 10")))) + .andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON)); + + List games = service.searchGames(" zelda ").orElseThrow(); + + assertThat(games).containsExactly( + new IgdbApiService.Game(119388, "The Legend of Zelda: Tears of the Kingdom", 2023, + "https://images.igdb.com/igdb/image/upload/t_screenshot_med/sc1.jpg", List.of("Switch"), null), + new IgdbApiService.Game(119133, "Elden Ring", null, + "https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L)); + } + + @Test + void searchGames_OnlyACover_UsesTheCover() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andRespond(withSuccess("[{\"id\": 1, \"name\": \"Tetris\", \"cover\": {\"image_id\": \"co1\"}}]", + MediaType.APPLICATION_JSON)); + + assertThat(service.searchGames("tetris").orElseThrow().getFirst().imageUrl()) + .isEqualTo("https://images.igdb.com/igdb/image/upload/t_cover_big/co1.jpg"); + } + + @Test + void searchGames_QuotesAndSemicolonsCannotChangeTheQuery() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andExpect(content().string(allOf(containsString("search \"a where id = 1 b\";"), + not(containsString("\\"))))) + .andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + + assertThat(service.searchGames("a\"; where id = 1;\\ b")).contains(List.of()); + } + + @Test + void searchGames_OffOrError_IsEmpty() { + assertThat(create("", "").searchGames("zelda")).isEmpty(); + + service = create("client-1", "secret-1"); + expectToken(); + igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError()); + assertThat(service.searchGames("zelda")).isEmpty(); + } + + @Test + void getGame_AsksForTheOneId() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andExpect(content().string(containsString("where id = 119133;"))) + .andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON)); + + assertThat(service.getGame(119133)).map(IgdbApiService.Game::igdbId).contains(119388L); + } + + @Test + void getGame_Unknown_IsEmpty() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + + assertThat(service.getGame(1)).isEmpty(); + } + + @Test + void getTimeToBeatByGameIds_MapsIgdbIdsToMinutes() { + expectToken(); + igdb.expect(requestTo(containsString("/game_time_to_beats"))) + .andExpect(content().string(containsString("game_id = (119388,7)"))) + .andRespond(withSuccess("[{\"id\": 9, \"game_id\": 119388, \"normally\": 216000}]", MediaType.APPLICATION_JSON)); + + assertThat(service.getTimeToBeatByGameIds(List.of(119388L, 7L))) + .contains(Map.of(119388L, new IgdbApiService.TimeToBeat(null, 3600, null))); + } } diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbSearchCacheTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbSearchCacheTest.java new file mode 100644 index 0000000..598494f --- /dev/null +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbSearchCacheTest.java @@ -0,0 +1,42 @@ +package core_service.modules.igdb_adapter.service; + +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The real, cache-proxied IgdbApiService with no IGDB reachable: the cache must let the empty answer through. + */ +@AutoConfigureMockMvc +@TestPropertySource(properties = { + "app.igdb.client-id=test-client", + "app.igdb.client-secret=test-secret", + "app.igdb.base-url=http://127.0.0.1:1", + "app.igdb.token-base-url=http://127.0.0.1:1" +}) +class IgdbSearchCacheTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private IgdbApiService igdbApiService; + + @Test + void searchGames_IgdbDown_IsEmptyNotAnException() { + assertThat(igdbApiService.searchGames("zelda")).isEmpty(); + } + + @Test + void search_IgdbDown_Is502() throws Exception { + mockMvc.perform(get("/api/library/game-search").param("q", "zelda")) + .andExpect(status().isBadGateway()); + } +} diff --git a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java new file mode 100644 index 0000000..8debf89 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java @@ -0,0 +1,348 @@ +package core_service.modules.library.api; + +import core_service.configuration.security.ClientHeaderFilter; +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.domain.GameSource; +import core_service.modules.library.domain.GameTimeToBeat; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.GameTimeToBeatRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The dev profile authenticates every request as user 1. + */ +@AutoConfigureMockMvc +class ManualGameControllerTest extends AbstractIntegrationTest { + + private static final long USER_ID = 1L; + private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023, + "https://images.igdb.com/igdb/image/upload/t_screenshot_med/sc1.jpg", List.of("Switch"), null); + private static final IgdbApiService.Game ELDEN_RING = new IgdbApiService.Game(119133, "Elden Ring", 2022, + "https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + + @MockitoBean + private IgdbApiService igdbApiService; + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + gameTimeToBeatRepository.deleteAll(); + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA)); + when(igdbApiService.getGame(119133)).thenReturn(Optional.of(ELDEN_RING)); + when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of())); + when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of( + 119388L, new IgdbApiService.TimeToBeat(null, 3600, null)))); + } + + private org.springframework.test.web.servlet.ResultActions add(long igdbId, String platform) throws Exception { + return mockMvc.perform(post("/api/library/games") + .header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"igdbId\": " + igdbId + ", \"platform\": \"" + platform + "\", \"status\": \"PLAYING\", " + + "\"playtimeMinutes\": 600}")); + } + + private UserGame stored(long appid) { + return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow(); + } + + @Test + void add_GameNotOnSteam_GetsMinusItsIgdbIdAndItsTimeToBeat() throws Exception { + add(119388, " Switch ") + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/library/games/-119388")) + .andExpect(jsonPath("$.appid").value(-119388)); + + UserGame game = stored(-119388); + assertEquals(GameSource.MANUAL, game.getSource()); + assertEquals("Switch", game.getPlatform()); + assertEquals(GameStatus.PLAYING, game.getStatus()); + assertEquals(600, game.getPlaytimeMinutes()); + SteamGame catalog = steamGameRepository.findById(-119388L).orElseThrow(); + assertEquals("Zelda: Tears of the Kingdom", catalog.getName()); + assertEquals(ZELDA.imageUrl(), catalog.getImageUrl()); + assertEquals(3600, timeToBeatStored(-119388L).getNormallyMinutes()); + } + + // The add asks IGDB for the time to beat in the background. + private GameTimeToBeat timeToBeatStored(long appid) throws InterruptedException { + for (int attempt = 0; attempt < 50; attempt++) { + Optional found = gameTimeToBeatRepository.findById(appid); + if (found.isPresent()) { + return found.get(); + } + Thread.sleep(100); + } + throw new AssertionError("No time to beat stored for appid " + appid); + } + + @Test + void add_GameAddedBeforeIgdbKnewItOnSteam_Is409() throws Exception { + add(119388, "Switch").andExpect(status().isCreated()); + when(igdbApiService.getGame(119388)).thenReturn(Optional.of(new IgdbApiService.Game(119388, ZELDA.name(), + 2023, null, List.of("Switch"), 2_000_000L))); + + add(119388, "Switch").andExpect(status().isConflict()); + + assertTrue(userGameRepository.findById(new UserGameId(USER_ID, 2_000_000L)).isEmpty()); + } + + @Test + void add_GameOnSteam_UsesTheSteamAppidAndKeepsTheSteamCatalogEntry() throws Exception { + steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", "icon", Instant.now())); + + add(119133, "PS5").andExpect(status().isCreated()).andExpect(jsonPath("$.appid").value(1245620)); + + assertEquals(GameSource.MANUAL, stored(1245620).getSource()); + SteamGame catalog = steamGameRepository.findById(1245620L).orElseThrow(); + assertEquals("ELDEN RING", catalog.getName()); + assertNull(catalog.getImageUrl()); + } + + @Test + void add_GameAlreadyInTheLibrary_Is409() throws Exception { + steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now())); + + add(119133, "PS5") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("ALREADY_IN_LIBRARY")); + + assertEquals(GameSource.STEAM, stored(1245620).getSource()); + } + + @Test + void add_GameRemovedBefore_ComesBackWithItsNotes() throws Exception { + steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now())); + UserGame refunded = new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now()); + refunded.setNotes("Refunded on Steam"); + refunded.setRemovedAt(Instant.now()); + userGameRepository.save(refunded); + + add(119133, "PS5").andExpect(status().isCreated()); + + UserGame back = stored(1245620); + assertNull(back.getRemovedAt()); + assertEquals(GameSource.MANUAL, back.getSource()); + assertEquals("Refunded on Steam", back.getNotes()); + assertEquals(600, back.getPlaytimeMinutes()); + } + + @Test + void add_UnknownIgdbGame_Is400() throws Exception { + when(igdbApiService.getGame(anyLong())).thenReturn(Optional.empty()); + + add(5, "PS5").andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("UNKNOWN_GAME")); + } + + @Test + void add_WithoutIgdb_Is503AndAsksNothing() throws Exception { + when(igdbApiService.isEnabled()).thenReturn(false); + + add(119388, "Switch").andExpect(status().isServiceUnavailable()); + + verify(igdbApiService, never()).getGame(anyLong()); + } + + @Test + void add_BlankPlatform_Is400() throws Exception { + add(119388, " ").andExpect(status().isBadRequest()); + } + + @Test + void search_MarksGamesAlreadyInTheLibrary() throws Exception { + steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now())); + when(igdbApiService.searchGames("elden")).thenReturn(Optional.of(List.of(ELDEN_RING, ZELDA))); + + mockMvc.perform(get("/api/library/game-search").param("q", "elden")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.available").value(true)) + .andExpect(jsonPath("$.results[0].appid").value(1245620)) + .andExpect(jsonPath("$.results[0].inLibrary").value(true)) + .andExpect(jsonPath("$.results[0].platforms[0]").value("PS5")) + .andExpect(jsonPath("$.results[1].appid").value(-119388)) + .andExpect(jsonPath("$.results[1].inLibrary").value(false)); + } + + @Test + void search_GameAddedBeforeIgdbKnewItOnSteam_IsInTheLibrary() throws Exception { + userGameRepository.save(UserGame.manual(USER_ID, -119388L, "Switch", GameStatus.BACKLOG, 0, Instant.now())); + when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388, + ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L)))); + + mockMvc.perform(get("/api/library/game-search").param("q", "zelda")) + .andExpect(jsonPath("$.results[0].appid").value(2_000_000)) + .andExpect(jsonPath("$.results[0].inLibrary").value(true)); + } + + @Test + void search_LeavesOutPlatformNamesTooLongToStore() throws Exception { + when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388, + ZELDA.name(), 2023, null, List.of("Switch", "A".repeat(33)), null)))); + + mockMvc.perform(get("/api/library/game-search").param("q", "zelda")) + .andExpect(jsonPath("$.results[0].platforms.length()").value(1)) + .andExpect(jsonPath("$.results[0].platforms[0]").value("Switch")); + } + + @Test + void search_WithoutIgdb_SaysItIsNotAvailable() throws Exception { + when(igdbApiService.isEnabled()).thenReturn(false); + + mockMvc.perform(get("/api/library/game-search").param("q", "zelda")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.available").value(false)) + .andExpect(jsonPath("$.results").isEmpty()); + } + + @Test + void search_IgdbFailed_Is502() throws Exception { + when(igdbApiService.searchGames(any())).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/library/game-search").param("q", "zelda")) + .andExpect(status().isBadGateway()); + } + + @Test + void search_TooShort_Is400() throws Exception { + mockMvc.perform(get("/api/library/game-search").param("q", "z")) + .andExpect(status().isBadRequest()); + } + + @Test + void update_ChangesPlatformAndHoursOfAGameAddedByHand() throws Exception { + add(119388, "Switch"); + + mockMvc.perform(put("/api/library/games/-119388/manual") + .header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"platform\": \"Switch 2\", \"playtimeMinutes\": 1200}")) + .andExpect(status().isNoContent()); + + assertEquals("Switch 2", stored(-119388).getPlatform()); + assertEquals(1200, stored(-119388).getPlaytimeMinutes()); + } + + @Test + void update_SteamGame_Is409() throws Exception { + steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now())); + + mockMvc.perform(put("/api/library/games/220/manual") + .header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"platform\": \"PS5\", \"playtimeMinutes\": 1}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("STEAM_GAME")); + + assertEquals(300, stored(220).getPlaytimeMinutes()); + } + + @Test + void remove_DeletesAGameAddedByHandButNoSteamGame() throws Exception { + add(119388, "Switch"); + steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now())); + + mockMvc.perform(delete("/api/library/games/-119388").header(ClientHeaderFilter.CLIENT_HEADER, "test")) + .andExpect(status().isNoContent()); + mockMvc.perform(delete("/api/library/games/220").header(ClientHeaderFilter.CLIENT_HEADER, "test")) + .andExpect(status().isConflict()); + mockMvc.perform(delete("/api/library/games/-1").header(ClientHeaderFilter.CLIENT_HEADER, "test")) + .andExpect(status().isNotFound()); + + assertTrue(userGameRepository.findById(new UserGameId(USER_ID, -119388L)).isEmpty()); + assertNull(stored(220).getRemovedAt()); + } + + @Test + void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception { + add(119388, "Switch"); + + mockMvc.perform(get("/api/library/games").param("q", "zelda")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].appid").value(-119388)) + .andExpect(jsonPath("$.content[0].source").value("MANUAL")) + .andExpect(jsonPath("$.content[0].platform").value("Switch")) + .andExpect(jsonPath("$.content[0].imageUrl").value(ZELDA.imageUrl())) + .andExpect(jsonPath("$.content[0].timeToBeatMinutes").value(3600)); + mockMvc.perform(get("/api/library/games/-119388")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Zelda: Tears of the Kingdom")) + .andExpect(jsonPath("$.source").value("MANUAL")) + .andExpect(jsonPath("$.hasAchievements").value(false)); + } + + // Signed like an Auth login token, for a user who saved no Steam ID. + private static String tokenWithoutSteamId() { + return io.jsonwebtoken.Jwts.builder() + .subject("no-steam@example.com") + .claim("userId", String.valueOf(USER_ID)) + .expiration(new java.util.Date(System.currentTimeMillis() + 60_000)) + .signWith(TEST_KEYS.getPrivate(), io.jsonwebtoken.Jwts.SIG.EdDSA) + .compact(); + } + + @Test + void userWithoutSteamId_SeesTheGamesAddedByHand() throws Exception { + add(119388, "Switch"); + jakarta.servlet.http.Cookie login = new jakarta.servlet.http.Cookie("tbp_access", tokenWithoutSteamId()); + + mockMvc.perform(get("/api/library/games").cookie(login)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].appid").value(-119388)); + mockMvc.perform(get("/api/library/games-count").cookie(login)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(1)) + .andExpect(jsonPath("$.hasSteamId").value(false)); + mockMvc.perform(get("/api/library/games/-119388").cookie(login)) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 800539b..d9016b5 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -1,6 +1,7 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.StatusCount; @@ -64,6 +65,39 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { .map(UserGame::getAppid).getContent(); } + @Test + void softDeleteMissingSince_KeepsGamesAddedByHand() { + steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now())); + userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 600, + Instant.now().minusSeconds(3600))); + Instant syncStartedAt = Instant.now(); + + int removed = userGameRepository.softDeleteMissingSince(USER_ID, syncStartedAt, Instant.now()); + + assertEquals(4, removed); + UserGame manual = userGameRepository.findById(new UserGameId(USER_ID, -1942L)).orElseThrow(); + assertNull(manual.getRemovedAt()); + assertEquals(GameSource.MANUAL, manual.getSource()); + } + + @Test + void findAchievementProgress_LeavesOutGamesAddedByHand() { + steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now())); + userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now())); + + assertTrue(userGameRepository.findAchievementProgress(USER_ID).stream().noneMatch(p -> p.appid() == 999L)); + } + + @Test + void findRandomBacklogGameAndPicker_ExcludeNothingStillFindTheGameWithKeyMinusOne() { + userGameRepository.deleteAll(); + steamGameRepository.save(new SteamGame(-1L, "IGDB game 1", null, Instant.now())); + userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now())); + + assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid()); + assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, List.of(), 3).size()); + } + @Test void findOwnedGames_NoFilter_ReturnsOwnActiveLibrarySorted() { UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java index fb819b3..835c175 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -113,6 +113,17 @@ class AchievementSyncServiceTest extends AbstractUnitTest { verify(userAchievementMetaRepository, never()).save(any()); } + @Test + void syncLibrary_GameAddedByHand_AsksSteamNothing() { + UserGame manual = UserGame.manual(USER_ID, 620L, "PS5", core_service.modules.library.domain.GameStatus.PLAYING, + 150, Instant.now()); + library(List.of(manual), List.of(schema(620L, 10)), List.of()); + + service.syncLibrary(USER_ID, STEAM_ID); + + verifyNoInteractions(steamApiService); + } + @Test void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() { library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120))); diff --git a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java index e35ae81..00407e3 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; @@ -63,6 +64,9 @@ class GameDetailServiceTest extends AbstractIntegrationTest { @MockitoBean private SteamApiService steamApiService; + @MockitoBean + private TimeToBeatService timeToBeatService; + @BeforeEach void seed() { userGameRepository.deleteAll(); @@ -78,6 +82,26 @@ class GameDetailServiceTest extends AbstractIntegrationTest { return new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L); } + @Test + void getGameDetail_GameAddedByHand_AsksSteamNothingAndShowsNoAchievements() { + userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now())); + schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now().minus(Duration.ofDays(31)))); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); + + org.mockito.Mockito.verifyNoInteractions(steamApiService); + assertEquals(false, detail.hasAchievements()); + } + + @Test + void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAgain() { + userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now())); + + gameDetailService.getGameDetail(USER_ID, null, 620L); + + org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L)); + } + @Test void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() { schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now())); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 34213c6..684ce35 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -1,5 +1,6 @@ package core_service.modules.library.service; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; @@ -32,6 +33,7 @@ import java.util.Map; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; @@ -177,6 +179,35 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { assertEquals(expected, saved.getValue().getStatus()); } + @Test + void runSync_GameAddedByHandIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog() { + UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH); + manual.setRating(5); + manual.setNotes("Played on the couch"); + when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(manual)); + steamReturns(game(90, 30), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + assertEquals(GameSource.STEAM, manual.getSource()); + assertNull(manual.getPlatform()); + assertEquals(90, manual.getPlaytimeMinutes()); + assertEquals(GameStatus.FINISHED, manual.getStatus()); + assertEquals(5, manual.getRating()); + assertEquals("Played on the couch", manual.getNotes()); + } + + @Test + void runSync_GameAddedByHandIsNowOnSteam_TheHandTypedHoursAreNoPlaytimeGrowth() { + UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH); + when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(manual)); + steamReturns(game(90, 30), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); + } + @Test void runSync_ExistingGame_KeepsStatus() { UserGame existing = new UserGame(USER_ID, 220L, 0, Instant.EPOCH, Instant.EPOCH); @@ -370,4 +401,14 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(timeToBeatService).refresh(List.of(220L)); } + + @Test + void runSync_AlsoRefreshesTheTimeToBeatOfTheGamesAddedByHand() { + steamReturns(game(0, 0), STEAM_ID); + when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L)); + + worker.runSync(USER_ID, STEAM_ID); + + verify(timeToBeatService).refresh(List.of(220L, -1025L)); + } } diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java new file mode 100644 index 0000000..09b8476 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -0,0 +1,79 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +class ManualGameServiceTest extends AbstractUnitTest { + + private static final long USER_ID = 1L; + private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023, + null, List.of("Switch"), null); + + @Mock + private IgdbApiService igdbApiService; + + @Mock + private UserGameRepository userGameRepository; + + @Mock + private SteamGameRepository steamGameRepository; + + @Mock + private TimeToBeatService timeToBeatService; + + @Mock + private PlatformTransactionManager transactionManager; + + private ManualGameService service; + + @BeforeEach + void setUp() { + service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService, + transactionManager); + } + + @Test + void add_RowInsertedMeanwhile_IsAlreadyInLibraryNotAnError() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA)); + when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty()); + when(steamGameRepository.findById(-119388L)).thenReturn(Optional.empty()); + when(userGameRepository.save(any())).thenThrow( + new DataIntegrityViolationException("duplicate key value violates unique constraint \"user_game_pkey\"")); + + ManualGameService.AddResult result = service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0); + + assertThat(result).isInstanceOf(ManualGameService.AddResult.AlreadyInLibrary.class); + } + + @Test + void add_CatalogRowInsertedMeanwhile_TriesAgainAndAdds() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA)); + when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty()); + when(steamGameRepository.findById(-119388L)).thenReturn(Optional.empty()); + when(steamGameRepository.save(any())) + .thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"steam_game_pkey\"")) + .thenReturn(null); + + ManualGameService.AddResult result = service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0); + + assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L)); + } +} diff --git a/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java b/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java index bc09065..19840b0 100644 --- a/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java +++ b/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java @@ -53,6 +53,35 @@ class TimeToBeatServiceTest extends AbstractUnitTest { verify(gameTimeToBeatRepository, never()).upsert(eq(220L), any(), any(), any(), any()); } + @Test + void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of())); + when(igdbApiService.getTimeToBeatByGameIds(Set.of(119388L, 7L))).thenReturn(Optional.of(Map.of( + 119388L, new IgdbApiService.TimeToBeat(null, 3600, null)))); + + service.refresh(List.of(620L, -119388L, -7L)); + + verify(gameTimeToBeatRepository).upsert(eq(-119388L), isNull(), eq(3600), isNull(), any(Instant.class)); + verify(gameTimeToBeatRepository).upsert(eq(-7L), isNull(), isNull(), isNull(), any(Instant.class)); + verify(gameTimeToBeatRepository).upsert(eq(620L), isNull(), isNull(), isNull(), any(Instant.class)); + } + + @Test + void refresh_OnlyTheGamesOffSteamFailed_StoresTheSteamOnes() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of( + 620L, new IgdbApiService.TimeToBeat(510, 750, 1350)))); + when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.empty()); + + service.refresh(List.of(620L, -7L)); + + verify(gameTimeToBeatRepository).upsert(eq(620L), eq(510), eq(750), eq(1350), any(Instant.class)); + verify(gameTimeToBeatRepository, never()).upsert(eq(-7L), any(), any(), any(), any()); + } + @Test void refresh_IgdbError_StoresNothing() { when(igdbApiService.isEnabled()).thenReturn(true);