feat(library): time to beat from IGDB

This commit is contained in:
lobo committed 2026-09-24 14:40:41 +02:00
1 parent 2f2f8a2918
commit 00a7991380
23 files changed
+853 -22

No files matched your search

@@ -0,0 +1,43 @@
package core_service.modules.igdb_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* The parts of the Twitch token and IGDB v4 answers the adapter reads.
*/
public final class IgdbResponses {
private IgdbResponses() {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Token(
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") Long expiresIn
) {}
/**
* @param game the IGDB game id
* @param uid the id on the other service, here the Steam appid as a string
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExternalGame(
@JsonProperty("game") Long game,
@JsonProperty("uid") String uid
) {}
/**
* Times in seconds; each can be missing.
*
* @param count how many players submitted a time
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TimeToBeat(
@JsonProperty("game_id") Long gameId,
@JsonProperty("hastily") Integer hastily,
@JsonProperty("normally") Integer normally,
@JsonProperty("completely") Integer completely,
@JsonProperty("count") Integer count
) {}
}
@@ -0,0 +1,20 @@
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.web.client.RestClient;
@Configuration
public class IgdbClientConfig {
@Bean
public RestClient igdbRestClient(@Value("${app.igdb.base-url}") String baseUrl) {
return RestClient.builder().baseUrl(baseUrl).build();
}
@Bean
public RestClient twitchRestClient(@Value("${app.igdb.token-base-url}") String tokenBaseUrl) {
return RestClient.builder().baseUrl(tokenBaseUrl).build();
}
}
@@ -0,0 +1,176 @@
package core_service.modules.igdb_adapter.service;
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
import io.github.resilience4j.ratelimiter.RateLimiter;
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.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Time to beat from IGDB (api-docs.igdb.com), found by Steam appid. Needs a Twitch developer app; without its
* client id and secret the adapter is off and never calls out.
*/
@Service
@Slf4j
public class IgdbApiService {
/**
* @param hastilyMinutes to the credits; each value is null when IGDB has no data for it
*/
public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {}
// 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;
private static final long STEAM_SOURCE = 1;
private static final Duration TOKEN_RENEW_BEFORE = Duration.ofHours(1);
private final RestClient igdbRestClient;
private final RestClient twitchRestClient;
private final String clientId;
private final String clientSecret;
// Per IGDB request, not per call: one call makes two requests per batch.
private final RateLimiter rateLimiter;
private String accessToken;
private Instant accessTokenRenewAt = Instant.EPOCH;
public IgdbApiService(@Qualifier("igdbRestClient") RestClient igdbRestClient,
@Qualifier("twitchRestClient") RestClient twitchRestClient,
@Value("${app.igdb.client-id:}") String clientId,
@Value("${app.igdb.client-secret:}") String clientSecret,
RateLimiterRegistry rateLimiterRegistry) {
this.igdbRestClient = igdbRestClient;
this.twitchRestClient = twitchRestClient;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.rateLimiter = rateLimiterRegistry.rateLimiter("igdbApi");
}
public boolean isEnabled() {
return clientId != null && !clientId.isBlank() && clientSecret != null && !clientSecret.isBlank();
}
/**
* @return the times of the apps IGDB knows, apps without data are missing; empty when the adapter is off or a
* request failed, so the caller does not store "unknown" for apps it could not ask about
*/
public Optional<Map<Long, TimeToBeat>> getTimeToBeat(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, TimeToBeat> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
result.putAll(getBatch(appids.subList(from, Math.min(from + BATCH, appids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for time to beat", e);
return Optional.empty();
}
}
private Map<Long, TimeToBeat> getBatch(List<Long> steamAppids) {
String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(","));
IgdbResponses.ExternalGame[] externals = query("/external_games",
"fields game,uid; where external_game_source = " + STEAM_SOURCE + " & uid = (" + uids + "); limit " + LIMIT + ";",
IgdbResponses.ExternalGame[].class);
// Editions and re-releases: one IGDB game can have several Steam apps.
Map<Long, List<Long>> steamByIgdb = new HashMap<>();
for (IgdbResponses.ExternalGame external : externals) {
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) {
steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid()));
}
}
if (steamByIgdb.isEmpty()) {
return Map.of();
}
String gameIds = steamByIgdb.keySet().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);
// A Steam app linked to several IGDB games takes the time most players submitted.
Map<Long, TimeToBeat> result = new HashMap<>();
Arrays.stream(times)
.filter(time -> steamByIgdb.containsKey(time.gameId()))
.sorted(Comparator.comparing((IgdbResponses.TimeToBeat time) -> time.count() != null ? time.count() : 0).reversed())
.forEach(time -> steamByIgdb.get(time.gameId()).forEach(appid -> result.putIfAbsent(appid,
new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely())))));
return result;
}
private <T> T query(String path, String body, Class<T> type) {
String token = token();
RateLimiter.waitForPermission(rateLimiter);
T answer;
try {
answer = igdbRestClient.post()
.uri(path)
.header("Client-ID", clientId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.TEXT_PLAIN)
.accept(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(type);
} catch (HttpClientErrorException.Unauthorized e) {
// Revoked, e.g. after a secret reset. The next call asks for a new token.
dropToken(token);
throw e;
}
if (answer == null) {
throw new IllegalStateException("IGDB " + path + " answered without a body");
}
return answer;
}
private synchronized String token() {
if (accessToken == null || Instant.now().isAfter(accessTokenRenewAt)) {
IgdbResponses.Token token = twitchRestClient.post()
.uri(builder -> builder.path("/oauth2/token")
.queryParam("client_id", clientId)
.queryParam("client_secret", clientSecret)
.queryParam("grant_type", "client_credentials")
.build())
.retrieve()
.body(IgdbResponses.Token.class);
if (token == null || token.accessToken() == null) {
throw new IllegalStateException("Twitch answered without an access token");
}
long expiresIn = token.expiresIn() != null ? token.expiresIn() : 0;
accessToken = token.accessToken();
accessTokenRenewAt = Instant.now().plusSeconds(expiresIn).minus(TOKEN_RENEW_BEFORE);
}
return accessToken;
}
private synchronized void dropToken(String token) {
if (token.equals(accessToken)) {
accessToken = null;
}
}
private static Integer minutes(Integer seconds) {
return seconds != null && seconds > 0 ? Math.round(seconds / 60f) : null;
}
}
@@ -69,7 +69,8 @@ public class LibraryController {
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "playtimeMinutes,desc") String sort, @RequestParam(defaultValue = "playtimeMinutes,desc") String sort,
@RequestParam(required = false) GameStatus status, @RequestParam(required = false) GameStatus status,
@RequestParam(required = false) String q @RequestParam(required = false) String q,
@RequestParam(required = false) Integer maxMinutes
) { ) {
if (principal.steamId() == null) { if (principal.steamId() == null) {
return ResponseEntity.ok(new PageDto<>(java.util.List.of(), 0, DEFAULT_SIZE, 0, 0)); return ResponseEntity.ok(new PageDto<>(java.util.List.of(), 0, DEFAULT_SIZE, 0, 0));
@@ -81,7 +82,7 @@ public class LibraryController {
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page); int safePage = Math.max(0, page);
Sort safeSort = parseSort(sort); Sort safeSort = parseSort(sort);
var dtoPage = queryService.listOwnedGames(userId, status, q, PageRequest.of(safePage, safeSize, safeSort)); var dtoPage = queryService.listOwnedGames(userId, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, PageRequest.of(safePage, safeSize, safeSort));
return ResponseEntity.ok(PageDto.of(dtoPage)); return ResponseEntity.ok(PageDto.of(dtoPage));
} }
@@ -24,5 +24,12 @@ public record GameDetailDto(
int achievementsUnlocked, int achievementsUnlocked,
int achievementsRemaining, int achievementsRemaining,
double completionPercent, double completionPercent,
List<AchievementDto> achievements List<AchievementDto> achievements,
) {} TimeToBeatDto timeToBeat
) {
/**
* IGDB's averages in minutes; each is null when unknown.
*/
public record TimeToBeatDto(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {}
}
@@ -3,6 +3,9 @@ package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
/**
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
*/
public record GameDto( public record GameDto(
Long appid, Long appid,
String name, String name,
@@ -10,5 +13,6 @@ public record GameDto(
int playtimeMinutes, int playtimeMinutes,
GameStatus status, GameStatus status,
Integer rating, Integer rating,
GamePriority priority GamePriority priority,
Integer timeToBeatMinutes
) {} ) {}
@@ -7,10 +7,12 @@ import java.util.Map;
/** /**
* @param byStatus has every status, also those with 0 games * @param byStatus has every status, also those with 0 games
* @param suggestion a random backlog game, null when the backlog is empty * @param suggestion a random backlog game, null when the backlog is empty
* @param backlogMinutes normal time to beat of the backlog games IGDB knows; 0 when none or IGDB is off
*/ */
public record LibrarySummaryDto( public record LibrarySummaryDto(
long total, long total,
Map<GameStatus, Long> byStatus, Map<GameStatus, Long> byStatus,
long neverPlayed, long neverPlayed,
GameDto suggestion GameDto suggestion,
long backlogMinutes
) {} ) {}
@@ -0,0 +1,39 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* IGDB's time to beat of one Steam app; every time is null when IGDB has no data for it.
*/
@Entity
@Table(name = "game_time_to_beat")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class GameTimeToBeat {
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "hastily_minutes")
private Integer hastilyMinutes;
@Column(name = "normally_minutes")
private Integer normallyMinutes;
@Column(name = "completely_minutes")
private Integer completelyMinutes;
@Column(name = "fetched_at", nullable = false)
private Instant fetchedAt;
}
@@ -0,0 +1,33 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameTimeToBeat;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Collection;
import java.util.Set;
public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat, Long> {
@Query("select t.appid from GameTimeToBeat t where t.appid in :appids and t.fetchedAt > :since")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
// An upsert, not save(): two users' syncs can store the same new app at the same time.
@Modifying
@Transactional
@Query(value = "insert into game_time_to_beat (appid, hastily_minutes, normally_minutes, completely_minutes, fetched_at) " +
"values (:appid, :hastily, :normally, :completely, :fetchedAt) " +
"on conflict (appid) do update set hastily_minutes = excluded.hastily_minutes, " +
"normally_minutes = excluded.normally_minutes, completely_minutes = excluded.completely_minutes, " +
"fetched_at = excluded.fetched_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid,
@Param("hastily") Integer hastilyMinutes,
@Param("normally") Integer normallyMinutes,
@Param("completely") Integer completelyMinutes,
@Param("fetchedAt") Instant fetchedAt);
}
@@ -49,16 +49,28 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
/** /**
* @param status null for every status * @param status null for every status
* @param namePattern a {@link #containsPattern} result, null for every name * @param namePattern a {@link #containsPattern} result, null for every name
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
*/ */
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + @Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " + "where ug.userId = :userId and ug.removedAt is null " +
"and (:status is null or ug.status = :status) " + "and (:status is null or ug.status = :status) " +
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\')") "and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes))")
Page<UserGame> findOwnedGames(@Param("userId") Long userId, Page<UserGame> findOwnedGames(@Param("userId") Long userId,
@Param("status") GameStatus status, @Param("status") GameStatus status,
@Param("namePattern") String namePattern, @Param("namePattern") String namePattern,
@Param("maxMinutes") Integer maxMinutes,
Pageable pageable); Pageable pageable);
/**
* Normal time to beat of the user's backlog, only games IGDB has data for.
*/
@Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
long sumBacklogMinutes(@Param("userId") Long userId);
/** /**
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository;
@@ -43,6 +44,7 @@ public class GameDetailService {
private final UserGameAchievementRepository userAchievementRepository; private final UserGameAchievementRepository userAchievementRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final SteamApiService steamApiService; private final SteamApiService steamApiService;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
@Value("${app.library.achievement-ttl-hours:1}") @Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours; private long achievementTtlHours;
@@ -121,7 +123,10 @@ public class GameDetailService {
unlocked, unlocked,
remaining, remaining,
Math.round(pct * 10.0) / 10.0, Math.round(pct * 10.0) / 10.0,
achievements achievements,
gameTimeToBeatRepository.findById(userGame.getAppid())
.map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes()))
.orElse(null)
); );
} }
@@ -8,9 +8,11 @@ import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
@@ -47,6 +49,7 @@ public class LibraryQueryService {
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final PlaytimeDayRepository playtimeDayRepository; private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository; private final WishlistGameRepository wishlistGameRepository;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
@@ -55,15 +58,18 @@ public class LibraryQueryService {
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId); return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
} }
public Page<GameDto> listOwnedGames(Long userId, GameStatus status, String nameQuery, Pageable pageable) { public Page<GameDto> listOwnedGames(Long userId, GameStatus status, String nameQuery, Integer maxMinutes,
Pageable pageable) {
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
UserGameRepository.querySort(pageable.getSort())); UserGameRepository.querySort(pageable.getSort()));
Page<UserGame> page = userGameRepository.findOwnedGames( Page<UserGame> page = userGameRepository.findOwnedGames(
userId, status, UserGameRepository.containsPattern(nameQuery), stable); userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList(); List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream() Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, g -> g)); .collect(Collectors.toMap(SteamGame::getAppid, g -> g));
return page.map(ug -> toDto(ug, meta.get(ug.getAppid()))); Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
.collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t));
return page.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid())));
} }
/** /**
@@ -74,9 +80,10 @@ public class LibraryQueryService {
long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0); long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1) GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1)
.map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null))) .map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null),
gameTimeToBeatRepository.findById(ug.getAppid()).orElse(null)))
.orElse(null); .orElse(null);
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion); return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId));
} }
/** /**
@@ -148,7 +155,7 @@ public class LibraryQueryService {
.toList()); .toList());
} }
private static GameDto toDto(UserGame ug, SteamGame meta) { private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time) {
return new GameDto( return new GameDto(
ug.getAppid(), ug.getAppid(),
meta != null ? meta.getName() : null, meta != null ? meta.getName() : null,
@@ -156,7 +163,8 @@ public class LibraryQueryService {
ug.getPlaytimeMinutes(), ug.getPlaytimeMinutes(),
ug.getStatus(), ug.getStatus(),
ug.getRating(), ug.getRating(),
ug.getPriority()); ug.getPriority(),
time != null ? time.getNormallyMinutes() : null);
} }
public Optional<UserSteamSync> getSyncStatus(Long userId) { public Optional<UserSteamSync> getSyncStatus(Long userId) {
@@ -47,6 +47,7 @@ public class LibrarySyncWorker {
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final PlaytimeDayRepository playtimeDayRepository; private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository; private final WishlistGameRepository wishlistGameRepository;
private final TimeToBeatService timeToBeatService;
@Value("${app.library.running-stale-minutes:5}") @Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes; private long runningStaleMinutes;
@@ -113,6 +114,7 @@ public class LibrarySyncWorker {
log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed); log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed);
} }
userSteamSyncRepository.markHistoryBaseline(userId, steamId); userSteamSyncRepository.markHistoryBaseline(userId, steamId);
timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
finalStatus = Status.OK; finalStatus = Status.OK;
} catch (Exception e) { } catch (Exception e) {
log.error("Sync failed for user {}", userId, e); log.error("Sync failed for user {}", userId, e);
@@ -0,0 +1,55 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
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.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@Service
@RequiredArgsConstructor
@Slf4j
public class TimeToBeatService {
private final IgdbApiService igdbApiService;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
@Value("${app.library.time-to-beat-ttl-days:30}")
private long ttlDays;
/**
* 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.
*/
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
// Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock.
Set<Long> stale = new TreeSet<>(appids);
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;
}
Instant now = Instant.now();
for (Long appid : stale) {
IgdbApiService.TimeToBeat time = answer.get().get(appid);
if (time == null) {
gameTimeToBeatRepository.upsert(appid, null, null, null, now);
} else {
gameTimeToBeatRepository.upsert(appid, time.hastilyMinutes(), time.normallyMinutes(), time.completelyMinutes(), now);
}
}
}
}
+12 -1
View File
@@ -43,6 +43,12 @@ app:
api-key: ${STEAM_API_KEY} api-key: ${STEAM_API_KEY}
base-url: https://api.steampowered.com base-url: https://api.steampowered.com
imageService-url: https://cdn.cloudflare.steamstatic.com/steam/apps/ imageService-url: https://cdn.cloudflare.steamstatic.com/steam/apps/
igdb:
# A Twitch developer app (dev.twitch.tv). Without both values time to beat is off.
client-id: ${IGDB_CLIENT_ID:}
client-secret: ${IGDB_CLIENT_SECRET:}
base-url: https://api.igdb.com/v4
token-base-url: https://id.twitch.tv
library: library:
sync-ttl-hours: 24 sync-ttl-hours: 24
force-sync-cooldown-seconds: 60 force-sync-cooldown-seconds: 60
@@ -67,4 +73,9 @@ resilience4j:
# Steam allows ~100k req/day. Cap at ~2 req/s to leave headroom for bursts. # Steam allows ~100k req/day. Cap at ~2 req/s to leave headroom for bursts.
limit-for-period: 2 limit-for-period: 2
limit-refresh-period: 1s limit-refresh-period: 1s
timeout-duration: 5s timeout-duration: 5s
igdbApi:
# IGDB allows 4 requests/s. Taken per HTTP request.
limit-for-period: 4
limit-refresh-period: 1s
timeout-duration: 10s
@@ -0,0 +1,9 @@
-- Time to beat per Steam app from IGDB, shared by all users. A row with only NULL times means IGDB has no data;
-- it is kept so the app is not asked again before the row is stale.
CREATE TABLE game_time_to_beat (
appid BIGINT PRIMARY KEY,
hastily_minutes INTEGER,
normally_minutes INTEGER,
completely_minutes INTEGER,
fetched_at TIMESTAMPTZ NOT NULL
);
@@ -33,8 +33,8 @@ class ArchitectureTest extends AbstractArchitectureTest {
.should().notDependOnEachOther() .should().notDependOnEachOther()
.ignoreDependency( .ignoreDependency(
JavaClass.Predicates.resideInAPackage("core_service.modules.library.."), JavaClass.Predicates.resideInAPackage("core_service.modules.library.."),
JavaClass.Predicates.resideInAPackage("core_service.modules.steam_adapter..")) JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter.."))
.because("Feature modules should be isolated; steam_adapter is an explicit shared port."); .because("Feature modules should be isolated; steam_adapter and igdb_adapter are explicit shared ports.");
@ArchTest @ArchTest
static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices() static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices()
@@ -0,0 +1,178 @@
package core_service.modules.igdb_adapter.service;
import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.LongStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.not;
import static org.springframework.test.web.client.ExpectedCount.once;
import static org.springframework.test.web.client.ExpectedCount.times;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withUnauthorizedRequest;
class IgdbApiServiceTest extends AbstractUnitTest {
private static final String TOKEN = "{\"access_token\": \"token-1\", \"expires_in\": 5000000, \"token_type\": \"bearer\"}";
private MockRestServiceServer twitch;
private MockRestServiceServer igdb;
private IgdbApiService service;
private IgdbApiService create(String clientId, String clientSecret) {
RestClient.Builder twitchBuilder = RestClient.builder();
RestClient.Builder igdbBuilder = RestClient.builder();
twitch = MockRestServiceServer.bindTo(twitchBuilder).build();
igdb = MockRestServiceServer.bindTo(igdbBuilder).build();
return new IgdbApiService(igdbBuilder.build(), twitchBuilder.build(), clientId, clientSecret, RateLimiterRegistry.ofDefaults());
}
@BeforeEach
void setUp() {
service = create("client-1", "secret-1");
}
private void expectToken() {
twitch.expect(once(), requestTo(containsString("/oauth2/token")))
.andExpect(method(HttpMethod.POST))
.andExpect(queryParam("client_id", "client-1"))
.andExpect(queryParam("client_secret", "secret-1"))
.andExpect(queryParam("grant_type", "client_credentials"))
.andRespond(withSuccess(TOKEN, MediaType.APPLICATION_JSON));
}
@Test
void isEnabled_OnlyWithIdAndSecret() {
assertThat(service.isEnabled()).isTrue();
assertThat(create("", "secret").isEnabled()).isFalse();
assertThat(create("id", null).isEnabled()).isFalse();
}
@Test
void getTimeToBeat_MapsSteamAppsToIgdbTimesInMinutes() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andExpect(header("Client-ID", "client-1"))
.andExpect(header("Authorization", "Bearer token-1"))
.andExpect(content().string(allOf(containsString("external_game_source = 1"), containsString("uid = (\"620\",\"220\")"))))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andExpect(content().string(containsString("game_id = (72,233)")))
.andRespond(withSuccess("[{\"id\": 9, \"game_id\": 72, \"hastily\": 30600, \"normally\": 45000, \"completely\": 81000, \"count\": 12}]",
MediaType.APPLICATION_JSON));
Optional<Map<Long, IgdbApiService.TimeToBeat>> result = service.getTimeToBeat(List.of(620L, 220L));
assertThat(result).isPresent();
assertThat(result.get()).containsOnlyKeys(620L);
assertThat(result.get().get(620L)).isEqualTo(new IgdbApiService.TimeToBeat(510, 750, 1350));
}
@Test
void getTimeToBeat_ReusesTheToken() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
service.getTimeToBeat(List.of(620L));
service.getTimeToBeat(List.of(220L));
twitch.verify();
}
@Test
void getTimeToBeat_AsksIn250erBatches() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andExpect(content().string(allOf(containsString("\"250\""), not(containsString("\"251\"")), containsString("limit 500"))))
.andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/external_games")))
.andExpect(content().string(allOf(containsString("\"251\""), not(containsString("\"250\"")))))
.andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeat(LongStream.rangeClosed(1, 251).boxed().toList())).contains(Map.of());
igdb.verify();
}
@Test
void getTimeToBeat_Error_IsEmptySoNothingIsStoredAsUnknown() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getTimeToBeat(List.of(620L))).isEmpty();
}
@Test
void getTimeToBeat_401_RequestsANewTokenOnTheNextCall() {
twitch.expect(times(2), requestTo(containsString("/oauth2/token")))
.andRespond(withSuccess(TOKEN, MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withUnauthorizedRequest());
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeat(List.of(620L))).isEmpty();
assertThat(service.getTimeToBeat(List.of(620L))).contains(Map.of());
twitch.verify();
}
@Test
void getTimeToBeat_OneIgdbGameWithTwoSteamApps_TimesBothApps() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 72, \"uid\": \"221\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andRespond(withSuccess("[{\"id\": 9, \"game_id\": 72, \"hastily\": 600, \"normally\": 1200, \"completely\": 1800}]",
MediaType.APPLICATION_JSON));
Optional<Map<Long, IgdbApiService.TimeToBeat>> result = service.getTimeToBeat(List.of(620L, 221L));
assertThat(result).isPresent();
assertThat(result.get()).containsOnlyKeys(620L, 221L);
}
@Test
void getTimeToBeat_OneSteamAppWithTwoIgdbGames_TakesTheTimeMostPlayersSubmitted() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 73, \"uid\": \"620\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andExpect(content().string(containsString("count")))
.andRespond(withSuccess("[{\"game_id\": 72, \"normally\": 600, \"count\": 2}, {\"game_id\": 73, \"normally\": 1200, \"count\": 40}]",
MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeat(List.of(620L)).orElseThrow().get(620L).normallyMinutes()).isEqualTo(20);
}
@Test
void getTimeToBeat_Disabled_CallsNothing() {
IgdbApiService disabled = create("", "");
assertThat(disabled.getTimeToBeat(List.of(620L))).isEmpty();
twitch.verify();
igdb.verify();
}
}
@@ -1,8 +1,10 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
@@ -37,6 +39,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
@Autowired @Autowired
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@BeforeEach @BeforeEach
void clean() { void clean() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
@@ -50,6 +55,7 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.neverPlayed").value(0)) .andExpect(jsonPath("$.neverPlayed").value(0))
.andExpect(jsonPath("$.byStatus.BACKLOG").value(0)) .andExpect(jsonPath("$.byStatus.BACKLOG").value(0))
.andExpect(jsonPath("$.byStatus.DROPPED").value(0)) .andExpect(jsonPath("$.byStatus.DROPPED").value(0))
.andExpect(jsonPath("$.backlogMinutes").value(0))
.andExpect(jsonPath("$.suggestion").value(nullValue())); .andExpect(jsonPath("$.suggestion").value(nullValue()));
} }
@@ -61,6 +67,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
UserGame finished = new UserGame(USER_ID, 620L, 600, Instant.now(), Instant.now()); UserGame finished = new UserGame(USER_ID, 620L, 600, Instant.now(), Instant.now());
finished.setStatus(GameStatus.FINISHED); finished.setStatus(GameStatus.FINISHED);
userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), finished)); userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), finished));
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(220L, 500, 700, 900, Instant.now()),
new GameTimeToBeat(620L, 1, 9999, 1, Instant.now())));
mockMvc.perform(get("/api/library/summary")) mockMvc.perform(get("/api/library/summary"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -71,7 +80,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.byStatus.PLAYING").value(0)) .andExpect(jsonPath("$.byStatus.PLAYING").value(0))
.andExpect(jsonPath("$.suggestion.appid").value(220)) .andExpect(jsonPath("$.suggestion.appid").value(220))
.andExpect(jsonPath("$.suggestion.name").value("Half-Life 2")) .andExpect(jsonPath("$.suggestion.name").value("Half-Life 2"))
.andExpect(jsonPath("$.suggestion.status").value("BACKLOG")); .andExpect(jsonPath("$.suggestion.status").value("BACKLOG"))
.andExpect(jsonPath("$.suggestion.timeToBeatMinutes").value(700))
.andExpect(jsonPath("$.backlogMinutes").value(700));
} }
@Test @Test
@@ -1,6 +1,7 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -37,6 +38,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
private SteamGameRepository steamGameRepository; private SteamGameRepository steamGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired @Autowired
private PlatformTransactionManager transactionManager; private PlatformTransactionManager transactionManager;
@@ -56,7 +60,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
} }
private List<Long> appids(GameStatus status, String query) { private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), PAGE) return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), null, PAGE)
.map(UserGame::getAppid).getContent(); .map(UserGame::getAppid).getContent();
} }
@@ -89,7 +93,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void findOwnedGames_CountsAllMatchesAcrossPages() { void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"), var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"), null,
PageRequest.of(0, 1, Sort.by("appid"))); PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent()); assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
@@ -97,7 +101,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
} }
private List<Long> sortedAppids(Sort sort) { private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) return userGameRepository.findOwnedGames(USER_ID, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
.map(UserGame::getAppid).getContent(); .map(UserGame::getAppid).getContent();
} }
@@ -141,6 +145,28 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
userGameRepository.flush(); userGameRepository.flush();
} }
@Test
void findOwnedGames_MaxMinutes_KeepsOnlyGamesWithAKnownShortTime() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(220L, 500, 700, 1000, Instant.now()),
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(380L, null, null, null, Instant.now())));
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
assertEquals(List.of(620L), shortGames);
}
@Test
void sumBacklogMinutes_AddsTheNormalTimeOfBacklogGamesWithData() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(380L, 100, 200, 300, Instant.now()),
new GameTimeToBeat(700L, null, 150, null, Instant.now()),
new GameTimeToBeat(620L, 1, 9999, 1, Instant.now())));
assertEquals(350, userGameRepository.sumBacklogMinutes(USER_ID));
}
@Test @Test
void containsPattern_BlankIsNoFilter() { void containsPattern_BlankIsNoFilter() {
assertNull(UserGameRepository.containsPattern(" ")); assertNull(UserGameRepository.containsPattern(" "));
@@ -96,6 +96,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock @Mock
private WishlistGameRepository wishlistGameRepository; private WishlistGameRepository wishlistGameRepository;
@Mock
private TimeToBeatService timeToBeatService;
@InjectMocks @InjectMocks
private LibrarySyncWorker worker; private LibrarySyncWorker worker;
@@ -351,4 +354,13 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(wishlistGameRepository).deleteByUserId(USER_ID); verify(wishlistGameRepository).deleteByUserId(USER_ID);
} }
@Test
void runSync_RefreshesTheTimeToBeatOfTheOwnedGames() {
steamReturns(game(0, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(timeToBeatService).refresh(List.of(220L));
}
} }
@@ -0,0 +1,92 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Two users' syncs run in parallel and can miss the same app, e.g. every app right after the first deploy.
*/
class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest {
@Autowired
private TimeToBeatService timeToBeatService;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@MockitoBean
private IgdbApiService igdbApiService;
// Before as well: other integration tests leave rows in this shared table, and a fresh row would hide the race.
@BeforeEach
@AfterEach
void clean() {
gameTimeToBeatRepository.deleteAll();
}
@Test
void refresh_TwoParallelSyncsOfTheSameNewApp_BothCommit() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.TimeToBeat(510, 750, 1350))));
CountDownLatch firstWrote = new CountDownLatch(1);
CountDownLatch secondStarted = new CountDownLatch(1);
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> {
timeToBeatService.refresh(List.of(620L));
firstWrote.countDown();
await(secondStarted);
// Keep the transaction open while the second sync writes the same row.
sleep();
}));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
await(firstWrote);
secondStarted.countDown();
new TransactionTemplate(transactionManager).executeWithoutResult(tx -> timeToBeatService.refresh(List.of(620L)));
});
assertDoesNotThrow(() -> CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS));
assertEquals(750, gameTimeToBeatRepository.findById(620L).orElseThrow().getNormallyMinutes());
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
private static void sleep() {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,85 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TimeToBeatServiceTest extends AbstractUnitTest {
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameTimeToBeatRepository gameTimeToBeatRepository;
@InjectMocks
private TimeToBeatService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "ttlDays", 30L);
}
@Test
void refresh_AsksOnlyForMissingOrOldAppsAndStoresUnknownOnesToo() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L));
when(igdbApiService.getTimeToBeat(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.TimeToBeat(510, 750, 1350))));
service.refresh(List.of(220L, 620L, 700L));
verify(gameTimeToBeatRepository).upsert(eq(620L), eq(510), eq(750), eq(1350), any(Instant.class));
verify(gameTimeToBeatRepository).upsert(eq(700L), isNull(), isNull(), isNull(), any(Instant.class));
verify(gameTimeToBeatRepository, never()).upsert(eq(220L), any(), any(), any(), any());
}
@Test
void refresh_IgdbError_StoresNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameTimeToBeatRepository, never()).upsert(any(), any(), any(), any(), any());
}
@Test
void refresh_Disabled_DoesNotEvenReadTheCache() {
when(igdbApiService.isEnabled()).thenReturn(false);
service.refresh(List.of(620L));
verify(gameTimeToBeatRepository, never()).findFreshAppids(anyCollection(), any(Instant.class));
}
@Test
void refresh_AllFresh_AsksIgdbNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(620L));
service.refresh(List.of(620L));
verify(igdbApiService, never()).getTimeToBeat(any());
}
}