feat(library): add games by name from IGDB
Build, Test & Push Core Service / quality-check (push) Successful in 51s
Build, Test & Push Core Service / docker (push) Successful in 1m24s

Games the user owns outside Steam get the key minus their
IGDB id, or the Steam appid when IGDB knows the game on
Steam. The sync never removes a MANUAL row and takes it
over once the user owns the game on Steam. Users without
a Steam ID can use the library.
This commit is contained in:
lobo committed 2026-09-25 14:37:21 +02:00
1 parent 33e0a59664
commit 4d50863cb8
37 files changed
+1431 -51

No files matched your search

@@ -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));
@@ -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<Image> artworks,
@JsonProperty("screenshots") java.util.List<Image> screenshots,
@JsonProperty("platforms") java.util.List<Platform> platforms,
@JsonProperty("external_games") java.util.List<ExternalGameSource> 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.
*
@@ -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;
}
}
@@ -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<String> 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<List<Game>> 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<Game> 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<Map<Long, TimeToBeat>> getTimeToBeatByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, TimeToBeat> 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<Game> 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<String> 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<Long, TimeToBeat> getBatch(List<Long> steamAppids) {
String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(","));
IgdbResponses.ExternalGame[] externals = query("/external_games",
@@ -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)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
@@ -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<String, String> NOT_FOUND = Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library");
private static final Map<String, String> 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)
.<ResponseEntity<?>>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);
};
}
}
@@ -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
) {}
@@ -0,0 +1,3 @@
package core_service.modules.library.api.dto;
public record AddedGameDto(long appid) {}
@@ -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<AchievementDto> achievements,
TimeToBeatDto timeToBeat
TimeToBeatDto timeToBeat,
GameSource source,
String platform,
String imageUrl
) {
/**
@@ -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
) {}
@@ -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<Hit> 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<String> platforms,
boolean inLibrary) {}
}
@@ -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;
}
@@ -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) {}
}
@@ -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;
}
}
@@ -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
}
@@ -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;
@@ -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;
}
}
@@ -23,8 +23,20 @@ import java.util.Optional;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
/**
* 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<UserGame> 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<Long> findActiveAppids(@Param("userId") Long userId, @Param("appids") Collection<Long> 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<Long> 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<UserGame, UserGameId>
List<StatusCount> 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<UserGame, UserGameId>
"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<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
/**
@@ -64,8 +77,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Collection<Long> excludeAppids, int count) {
List<Long> 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<UserGame, UserGameId>
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);
@@ -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<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
.filter(game -> game.getSource() == GameSource.STEAM)
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
.toList();
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
@@ -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<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) {
Optional<UserGame> 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
);
}
}
@@ -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<GameStatus, Long> 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<UserSteamSync> getSyncStatus(Long userId) {
@@ -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<Long> 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);
}
}
@@ -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<GameSearchDto> search(Long userId, String query) {
if (!igdbApiService.isEnabled()) {
return Optional.of(new GameSearchDto(false, List.of()));
}
return igdbApiService.searchGames(query).map(games -> {
Set<Long> 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<IgdbApiService.Game> 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<UserGame> 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<UserGame> change) {
Optional<UserGame> 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<SteamGame> 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<String> 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<Long> 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());
}
}
@@ -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<Long> 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<Map<Long, IgdbApiService.TimeToBeat>> answer = igdbApiService.getTimeToBeat(stale);
if (answer.isEmpty()) {
log.warn("Time to beat for {} apps not refreshed, IGDB did not answer", stale.size());
return;
Set<Long> steamAppids = new TreeSet<>();
Set<Long> igdbIds = new TreeSet<>();
for (Long appid : stale) {
if (GameKeys.isSteam(appid)) {
steamAppids.add(appid);
} else {
igdbIds.add(GameKeys.igdbId(appid));
}
}
Map<Long, IgdbApiService.TimeToBeat> times = new HashMap<>();
Set<Long> 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 {
@@ -15,5 +15,8 @@ public record PublicStatsDto(
List<TopGame> topOwnedGames,
List<TopGame> 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) {}
}
@@ -69,11 +69,11 @@ public class StatsRepository {
public List<Map<String, Object>> 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<Map<String, Object>> 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);
@@ -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();
@@ -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);
@@ -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<IgdbApiService.Game> 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)));
}
}
@@ -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());
}
}
@@ -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<GameTimeToBeat> 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());
}
}
@@ -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();
@@ -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)));
@@ -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()));
@@ -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));
}
}
@@ -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));
}
}
@@ -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);