Public Access
feat(library): time to beat from IGDB
This commit is contained in:
1 parent
2f2f8a2918
commit
00a7991380
23 files changed
+852
-21
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 = "playtimeMinutes,desc") String sort,
|
||||
@RequestParam(required = false) GameStatus status,
|
||||
@RequestParam(required = false) String q
|
||||
@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));
|
||||
@@ -81,7 +82,7 @@ public class LibraryController {
|
||||
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||
int safePage = Math.max(0, page);
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -24,5 +24,12 @@ public record GameDetailDto(
|
||||
int achievementsUnlocked,
|
||||
int achievementsRemaining,
|
||||
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.GameStatus;
|
||||
|
||||
/**
|
||||
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
|
||||
*/
|
||||
public record GameDto(
|
||||
Long appid,
|
||||
String name,
|
||||
@@ -10,5 +13,6 @@ public record GameDto(
|
||||
int playtimeMinutes,
|
||||
GameStatus status,
|
||||
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 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(
|
||||
long total,
|
||||
Map<GameStatus, Long> byStatus,
|
||||
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 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 " +
|
||||
"where ug.userId = :userId and ug.removedAt is null " +
|
||||
"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,
|
||||
@Param("status") GameStatus status,
|
||||
@Param("namePattern") String namePattern,
|
||||
@Param("maxMinutes") Integer maxMinutes,
|
||||
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
|
||||
* 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.UserGameId;
|
||||
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.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
@@ -43,6 +44,7 @@ public class GameDetailService {
|
||||
private final UserGameAchievementRepository userAchievementRepository;
|
||||
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
private final SteamApiService steamApiService;
|
||||
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
|
||||
@Value("${app.library.achievement-ttl-hours:1}")
|
||||
private long achievementTtlHours;
|
||||
@@ -121,7 +123,10 @@ public class GameDetailService {
|
||||
unlocked,
|
||||
remaining,
|
||||
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.domain.DayMinutes;
|
||||
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.UserGame;
|
||||
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.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
@@ -47,6 +49,7 @@ public class LibraryQueryService {
|
||||
private final SteamGameRepository steamGameRepository;
|
||||
private final PlaytimeDayRepository playtimeDayRepository;
|
||||
private final WishlistGameRepository wishlistGameRepository;
|
||||
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
|
||||
@Value("${app.library.zone:Europe/Berlin}")
|
||||
private ZoneId zone;
|
||||
@@ -55,15 +58,18 @@ public class LibraryQueryService {
|
||||
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(),
|
||||
UserGameRepository.querySort(pageable.getSort()));
|
||||
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();
|
||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
|
||||
.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 neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
|
||||
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);
|
||||
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());
|
||||
}
|
||||
|
||||
private static GameDto toDto(UserGame ug, SteamGame meta) {
|
||||
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time) {
|
||||
return new GameDto(
|
||||
ug.getAppid(),
|
||||
meta != null ? meta.getName() : null,
|
||||
@@ -156,7 +163,8 @@ public class LibraryQueryService {
|
||||
ug.getPlaytimeMinutes(),
|
||||
ug.getStatus(),
|
||||
ug.getRating(),
|
||||
ug.getPriority());
|
||||
ug.getPriority(),
|
||||
time != null ? time.getNormallyMinutes() : null);
|
||||
}
|
||||
|
||||
public Optional<UserSteamSync> getSyncStatus(Long userId) {
|
||||
|
||||
@@ -47,6 +47,7 @@ public class LibrarySyncWorker {
|
||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||
private final PlaytimeDayRepository playtimeDayRepository;
|
||||
private final WishlistGameRepository wishlistGameRepository;
|
||||
private final TimeToBeatService timeToBeatService;
|
||||
|
||||
@Value("${app.library.running-stale-minutes:5}")
|
||||
private long runningStaleMinutes;
|
||||
@@ -113,6 +114,7 @@ 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());
|
||||
finalStatus = Status.OK;
|
||||
} catch (Exception 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,12 @@ app:
|
||||
api-key: ${STEAM_API_KEY}
|
||||
base-url: https://api.steampowered.com
|
||||
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:
|
||||
sync-ttl-hours: 24
|
||||
force-sync-cooldown-seconds: 60
|
||||
@@ -68,3 +74,8 @@ resilience4j:
|
||||
limit-for-period: 2
|
||||
limit-refresh-period: 1s
|
||||
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()
|
||||
.ignoreDependency(
|
||||
JavaClass.Predicates.resideInAPackage("core_service.modules.library.."),
|
||||
JavaClass.Predicates.resideInAPackage("core_service.modules.steam_adapter.."))
|
||||
.because("Feature modules should be isolated; steam_adapter is an explicit shared port.");
|
||||
JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter.."))
|
||||
.because("Feature modules should be isolated; steam_adapter and igdb_adapter are explicit shared ports.");
|
||||
|
||||
@ArchTest
|
||||
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;
|
||||
|
||||
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.UserGame;
|
||||
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;
|
||||
@@ -37,6 +39,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
userGameRepository.deleteAll();
|
||||
@@ -50,6 +55,7 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
|
||||
.andExpect(jsonPath("$.neverPlayed").value(0))
|
||||
.andExpect(jsonPath("$.byStatus.BACKLOG").value(0))
|
||||
.andExpect(jsonPath("$.byStatus.DROPPED").value(0))
|
||||
.andExpect(jsonPath("$.backlogMinutes").value(0))
|
||||
.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());
|
||||
finished.setStatus(GameStatus.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"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -71,7 +80,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
|
||||
.andExpect(jsonPath("$.byStatus.PLAYING").value(0))
|
||||
.andExpect(jsonPath("$.suggestion.appid").value(220))
|
||||
.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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
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.StatusCount;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -37,6 +38,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
@Autowired
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@@ -56,7 +60,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -89,7 +93,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
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")));
|
||||
|
||||
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
|
||||
@@ -97,7 +101,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -141,6 +145,28 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
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
|
||||
void containsPattern_BlankIsNoFilter() {
|
||||
assertNull(UserGameRepository.containsPattern(" "));
|
||||
|
||||
@@ -96,6 +96,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private WishlistGameRepository wishlistGameRepository;
|
||||
|
||||
@Mock
|
||||
private TimeToBeatService timeToBeatService;
|
||||
|
||||
@InjectMocks
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@@ -351,4 +354,13 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
+92
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user